diff --git a/.github/workflows/python_package.yaml b/.github/workflows/python_package.yaml index b29b8bf6..dd6f9b04 100644 --- a/.github/workflows/python_package.yaml +++ b/.github/workflows/python_package.yaml @@ -8,6 +8,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: python-version: [3.9, '3.10', '3.11', '3.12'] os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] @@ -30,7 +31,7 @@ jobs: run: | python -m pip install --upgrade pip pip install setuptools wheel - pip install flake8 pytest==6.2.5 pytest-cov coveralls + pip install flake8 pytest pytest-cov coveralls - name: Install package requirements run: pip install -r requirements.txt - name: Install package diff --git a/.gitignore b/.gitignore index af8719d0..aee735e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ test_reports/ .vscode/ docs/build*/ +# generated by the documentation build (the examples are run by sphinx-gallery) +docs/source/auto_examples/ +docs/source/sphinx_gallery/*.csv +src/microstructpy/examples/*/ ############################################################################### # # diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 8a52c7f9..d71c95c1 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,7 +10,7 @@ formats: all build: os: ubuntu-22.04 tools: - python: "3.8" + python: "3.10" apt_packages: - freeglut3-dev @@ -24,5 +24,6 @@ sphinx: python: install: - requirements: docs/requirements.txt + - requirements: requirements.txt - method: pip path: . diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6c44047d..8772d1cf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,181 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to `Semantic Versioning`_. +Unreleased +---------- +Added +''''' +- Periodic microstructures, in 2D and 3D: the ```` field of the + domain (or the ``periodic`` argument of ``cli.run``, ``SeedList.position`` + and ``PolyMesh.from_seeds``) selects the periodic axes. Seeds crossing a + periodic face are placed without overlapping the opposite side, the + Laguerre tessellation is periodic across those faces (cells crossing a + face are cut and their pieces tile the domain), and the triangular, + tetrahedral and raster meshes have matching nodes on opposite faces + with the quality and size settings (``min_angle``, ``max_volume``, the + ``max_volume`` of each phase, ``max_edge_length``) acting as on + non-periodic meshes: in 2D the cells next to the periodic faces are + copied outside the faces while meshing, so that Triangle refines both + faces the same way; in 3D the facets are triangulated with the points + TetGen adds on them in a first pass, identically on opposite faces, and + the mesh is built again with the facets fixed. The pairs of periodic + points/nodes and facets + are stored in the meshes and their text files; the Abaqus output has a + node set per periodic face in matching order; the verification unwraps + grains that are split by the faces. The examples ``periodic_2D.xml``, + ``periodic_3D.xml`` and ``periodic_tiling.py`` demonstrate periodic + microstructures, ``pbx_2D.xml`` and ``pbx_3D.xml`` a periodic + particulate composite (crystalline inclusions in a binder), and + ``pbx_interface_2D.xml`` and ``pbx_interface_3D.xml`` meshes refined at + the grain boundaries. Cells of the same amorphous phase + that touch across a periodic face are merged into one region, like cells + that share a facet, and the merged region is labelled with the smallest + seed number among its cells by every mesher and writer. The element + attributes and the facets of periodic meshes are computed from the + geometry of the polymesh, since TetGen can leave sub-faces of a facet + unmarked when it may not modify the boundary and its region attributes + then leak between cells. gmsh is not supported for periodic meshes. +- ``periodic_margin`` (a setting, and an argument of ``SeedList.position`` + and ``cli.run``): the minimum distance between the surface of a seed and + a periodic face. A seed that ends within the margin of a face, or crosses + it by less, is placed elsewhere, since it would leave a thin piece of its + grain on the opposite face and elements much smaller than the target size + of the mesh there. ``auto`` uses half the target edge length of the mesh, + or an eighth of the size of the smallest seed if that is smaller (the + smallest seed needs about four elements across it, and cannot satisfy a + margin larger than itself). +- ``PolyMesh.from_seeds(edge_opt=True, periodic_margin=...)``: the edge + optimization of periodic meshes treats the thickness of each piece of a + cell at a periodic face as a feature like an edge, and moves the seeds of + the pieces thinner than the margin (and of their neighbors) normal to the + face until the piece reaches the margin or the cell no longer crosses the + face. The CLI passes its ``periodic_margin`` setting on. A trial of the + optimization is now kept when the shortest feature that it changes gets + longer (for the shortest edge of the mesh, the criterion is unchanged), + a target that does not improve in ``n_iter`` trials is left alone and the + next one is taken, and the 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 files match the polygonal mesh. The + periodic examples use ``periodic_margin`` ``auto`` and ``edge_opt``. + With ``min_angle`` (the minimum angle of the mesh to be built, passed by + the CLI from ``mesh_min_angle``), the corners of the cells at the + periodic faces narrower than that angle are features too, since the + mesher cannot reach the minimum angle there and fills them with shells of + very small elements: the seeds on both sides of the facet are moved along + it to open the corner. +- When the nodes on the periodic faces of a 2D mesh do not match after the + first pass, the next pass starts from all the points of the mesh (those on + a periodic face and on its image merged and put on both faces), so that + Triangle only refines it around the merged points, instead of meshing the + cells again with the points on the faces only, which split the narrow + corners of the cells again at every pass, down to very small elements. + The copies of the cells at the corners of the domain, used while meshing, + were open on one side and partly discarded by Triangle. + +Fixed +''''' +- Seed generation is reproducible: the RNG seed chain no longer depends on + the (hash-randomized) iteration order of the phase keywords, and + ``SeedList.from_info`` and ``cli.run`` no longer modify the ``rng_seeds`` + and ``filetypes`` arguments (or their mutable defaults). +- `` cdf `` inputs are no longer distorted when the + x-values in the CSV file are not evenly spaced (``density=False`` is now + passed to ``scipy.stats.rv_histogram``); ``pdf`` is accepted as an alias + of ``histogram``. +- 3D ``mesh_max_volume`` and per-phase ``max_volume`` are now honored by + TetGen; in 2D a per-phase ``max_volume`` larger than the global value is + no longer capped, and an infinite ``mesh_max_volume`` is no longer passed + to Triangle as the (mis-parsed) switch ``ainf``. +- ``Ellipsoid.approximate`` mapped the axes incorrectly for the ordering + c >= a >= b, so those grains were tessellated with the wrong orientation; + the b >= c >= a ordering is now sorted explicitly as well. +- Seeds read back from ``seeds.txt`` can be repositioned; ellipsoid seeds + with a rotation sequence are written in a form that can be read back. +- Cells that intersect a circular or elliptical domain without having a + vertex inside it are no longer dropped, cells cut twice by the boundary + are clipped correctly, and the stored areas of clipped cells are correct + (``PolyMesh.volumes``, ``verification.volume_fractions``). +- ``_segment_cross`` no longer hangs for large coordinate values; + ``sample_pos_within`` raises instead of looping forever when the position + distribution does not cover the domain. +- ``cli.plot_tri`` no longer hangs in 3D when a void grain touches the + boundary of the domain. +- Relative ```` and ```` paths inside repeated tags + (e.g. several ```` blocks) are resolved relative to the input + file; a top-level ```` no longer discards materials; values such + as ``true_cdf.csv`` no longer cause infinite recursion; ``inf`` is parsed + as a float. +- Verification: ``angle_rad`` inputs are no longer replaced by a uniform + distribution, `` random `` and vector-valued + parameters (``side_lengths``, ``axes``) no longer crash, unknown phase + fields are ignored, the caller's phases are not modified. +- ``RasterMesh``: elements are counter-clockwise / right-handed (valid for + Abaqus CPS4/C3D8), facets and their attributes are correct, ``vtk`` and + ``abaqus`` output work (including with voids), 3D plotting works. +- ``TriMesh.write``: valid ``.ele``/``.edge``/``.face`` files, Abaqus + exterior surface unions reference only defined surfaces, full-precision + points in text files, no dangling headers for meshes without attributes. +- ``PolyMesh.from_seeds(edge_opt=True)`` leaves the seed list in the + accepted state (positions and breakdowns consistent) and is quiet unless + ``verbose``; ``PolyMesh.write(format='poly')`` writes the file; + ``PolyMesh.__eq__`` is silent and no longer cubic. +- ``Ellipse(axes=...)``, ``Ellipse(matrix=...)``, ``Ellipsoid(c=.., + ratio_bc=..)``, ``Square.area_expectation(side_lengths=...)``, the + ``*_expectation`` methods with numpy scalars, ``Sphere.plot`` and 3D + ``PolyMesh.plot``/``SeedList.plot_breakdown`` on a fresh figure, + ``Rectangle.within`` for rotated rectangles, ``reflect`` for ellipses + and ellipsoids, single-material ``color_by`` settings, numpy arrays as + per-item plot keywords. + +Changed +''''''' +- pyvoro is installed from the ``pyvoro-rimoli`` package instead of + ``pyvoro-mmalahe``. Both provide the same ``pyvoro`` module, but + 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 resulting polygonal mesh. + pyvoro-rimoli bundles the current Voro++, where this is fixed, and has + wheels for Linux, macOS and Windows. Uninstall pyvoro-mmalahe before + upgrading (``pip uninstall pyvoro-mmalahe``), since the two packages + install the same files. +- The continuous integration installs the current pytest and no longer + installs tox from the requirements: the pinned tox 3.14 forced an old + pluggy that the current pytest-cov cannot load, so no test could run. + The jobs of the test matrix no longer cancel each other on a failure. +- Read the Docs builds the documentation with Python 3.10 instead of 3.8, + which pyvoro-rimoli and the current versions of other dependencies do not + support, and installs ``requirements.txt`` like the documentation check + of the continuous integration. +- The facets of the triangular and tetrahedral meshes created from a + polygonal mesh are sorted (nodes in ascending order within a facet, + facets in lexicographic order), whatever the mesher. 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 the order of their facets. +- ``max_edge_length`` (``mesh_max_edge_length``) acts in 3D on the triangles + of 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 (see the ``pbx_interface_3D.xml`` example). The + geometric tests of a mesh against its polymesh use the non-planarity of + the facets (from the snapping of the points to the periodic faces) as + their tolerance, and each element is assigned to the cell in which its + centroid is deepest. +- The overlap tolerance fit ``rtol='fit'`` uses the coefficients published + in Hart and Rimoli, CMAME 370 (2020) 113242, Eqs. (14) and (15). For very + wide size distributions this allows less overlap than before (2D + asymptote 0.18 instead of 0.36), so some seeds of high-cv inputs may be + rejected during placement. +- ``Ellipsoid.limits`` is exact for rotated ellipsoids (it was sampled). +- A ``Seed`` created with a ``position`` (or a geometry with a center) has + its breakdown at that position; the geometry center is no longer reset + to the origin. +- ``Ellipse``, ``Ellipsoid`` and ``NBox`` geometries compare equal when + their parameters are equal. +- Unused sampling helpers were removed from ``seeding.seedlist``. + + `1.5.9`_ - 2023-10-05 -------------------------- Added diff --git a/README.rst b/README.rst index e26fdb1f..bd5641cd 100644 --- a/README.rst +++ b/README.rst @@ -44,6 +44,9 @@ To install MicroStructPy, download it from PyPI using:: If there is an error with the install, try ``pip install pybind11`` first, then install MicroStructPy. +When upgrading from an earlier version, run ``pip uninstall pyvoro-mmalahe`` +first: MicroStructPy now uses ``pyvoro-rimoli``, which installs the same +``pyvoro`` module. MicroStructPy can also be installed from source:: diff --git a/docs/source/cli/domain.rst b/docs/source/cli/domain.rst index 40de86a9..6e42d9fa 100644 --- a/docs/source/cli/domain.rst +++ b/docs/source/cli/domain.rst @@ -1,3 +1,5 @@ +.. _cli_domain: + ==================================== ```` - Microstructure Domain ==================================== @@ -298,3 +300,65 @@ Below are some example square domain definitions.
5, 0
+ +Periodicity +^^^^^^^^^^^ + +Rectangular domains (rectangle, square, box, cube) can be made periodic with +the ```` field, which lists the periodic axes. Seeds that cross a +periodic face are placed so that they do not overlap seeds on the opposite +side, the tessellation is periodic across those faces (a grain that crosses a +face continues on the opposite side), and the triangular mesh has matching +nodes on opposite faces. The pairs of periodic nodes are written with the +meshes, and the Abaqus output contains a node set per periodic face, in +matching order. + +.. code-block:: XML + + + + + + square + 10 + + True + + + + rectangle + 10, 5 + + x + + + + cube + 10 + + xz + + + +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 very small elements there; +the ``periodic_margin`` setting rejects such positions and, with +``edge_opt``, moves the seeds of the cells that still leave such pieces (see +:ref:`cli_settings`). + +The mesher must be Triangle/TetGen for periodic microstructures (with gmsh the +nodes on opposite faces are not guaranteed to match), and the mesh size of a +raster mesh must divide the domain length along the periodic axes. +The mesh quality and size settings (``mesh_min_angle``, ``mesh_max_volume``, +the ``max_volume`` of each phase and ``mesh_max_edge_length``) apply to +periodic meshes as to non-periodic ones. In 2D, the cells next to the +periodic faces are copied outside the faces while meshing, so that Triangle +refines both faces of a pair the same way; if some nodes on the faces have +no image, the mesh is built again from all of its points, with the points +of a periodic face and of its image merged and put on both faces, so that +Triangle only refines it around those points. In 3D, the +mesh is built once as usual, then the facets are triangulated with the points +TetGen added on them (the facets on opposite periodic faces with the points +of both) and the mesh is built again with the facets fixed; the elements next +to the periodic faces are slightly more numerous and of slightly lower +quality than in a non-periodic mesh. diff --git a/docs/source/cli/settings.rst b/docs/source/cli/settings.rst index 95037f16..52d566c3 100644 --- a/docs/source/cli/settings.rst +++ b/docs/source/cli/settings.rst @@ -1,3 +1,5 @@ +.. _cli_settings: + ========================= ```` - Settings ========================= @@ -277,11 +279,28 @@ This optimization process, however, will increase the time to generate a polygonal mesh. To track the progress of the optimizer, set ``verbose`` to ``True``. +In a periodic domain (see :ref:`cli_domain`), the cells that cross a periodic +face are split into pieces, and a cell that barely crosses a face leaves a +thin piece on the opposite face, which forces very small elements there. +With ``edge_opt`` and a positive ``periodic_margin``, the optimizer also +treats the thickness of each piece at a periodic face as a feature of the +mesh, like an edge, and moves the seeds of every piece thinner than the +margin (and of its neighbors) normal to the face, until the piece is at least +as thick as the margin or the cell no longer crosses the face. +A corner of a cell at a periodic face that is narrower than ``mesh_min_angle`` +is treated the same way: the mesher cannot reach the minimum angle in such a +corner and fills it with shells of very small elements instead, so the +optimizer moves the seeds on both sides of the facet along it to open the +corner. +A change is kept when the shortest feature it modifies gets longer, so the +shorter features of the mesh never get worse. + edge_opt_n_iter --------------- This field specifies how many times the optimizer should attempt to increase -the length of the shortest edge in the polygonal mesh. +the length of the shortest edge in the polygonal mesh (or the thickness of a +piece at a periodic face). The default is `` 100 ``, which limits the optimizer to 100 attempts per edge. This field is ignored if ``edge_opt`` is set to ``False``. @@ -341,14 +360,42 @@ This setting controls the aspect ratio of the elements, with angles between The default is `` 0 ``, which effectively turns off the angle quality control. +periodic_margin +--------------- + +This field sets the minimum distance between the surface of a seed and a +periodic face of the domain (see :ref:`cli_domain` for periodic domains). +A position where a seed ends within this distance inside a face, or crosses +a face by less than this distance, is rejected and another one is tried: +such seeds leave thin pieces of grains on the faces of the domain, and +elements much smaller than the target size of the mesh there. +The value ``auto`` uses the smaller of two lengths: half the target edge +length of the mesh, taken from ``mesh_max_edge_length`` or, if it is not set, +from ``mesh_max_volume``; and an eighth of the size of the smallest seed (its +smallest diameter or side). +The smallest seeds need about four elements across them, so the mesh cannot +be coarser than a quarter of their size, and a seed cannot satisfy a margin +larger than itself: with such a margin, the positions near the faces would +all be rejected and the seeds dropped. +A large margin makes the seeds harder to place near the faces. +The margin also applies to the cells: with ``edge_opt``, the optimizer +thickens or removes the pieces of the cells at the periodic faces that are +thinner than the margin, which the placement of the seeds alone cannot +prevent (a cell extends beyond its seed, and its corners can cross a face +by a small amount). +The default is `` 0 ``, which turns off +the margin. It has no effect on non-periodic domains. + mesh_max_edge_length -------------------- -This field defines the maximum edge length along a grain boundary in a 2D -triangular mesh. +This field defines the maximum edge length along a grain boundary: of the +segments of a 2D triangular mesh, and of the triangles on the facets of a 3D +tetrahedral mesh. A small maximum edge length will increase resolution of the mesh at grain -boundaries. -Currently this feature has no equivalent in 3D. +boundaries, while the size of the elements inside the grains is controlled +by the maximum volume (see the :ref:`ex_pbx_interface_2d` and +:ref:`ex_pbx_interface_3d`). The default value is `` inf ``, which effectively turns off the edge length quality control. diff --git a/docs/source/examples/cli/pbx_2d.rst b/docs/source/examples/cli/pbx_2d.rst new file mode 100644 index 00000000..34ded8bc --- /dev/null +++ b/docs/source/examples/cli/pbx_2d.rst @@ -0,0 +1,88 @@ +.. _ex_pbx_2d: + +============================= +Binder and Inclusions Example +============================= + +XML Input File +============== + +The basename for this file is ``pbx_2D.xml``. +The file can be run using this command:: + + microstructpy --demo=pbx_2D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/pbx_2D.xml + :language: xml + + +Materials +========= + +This microstructure is a particulate composite, such as a plastic-bonded +explosive: crystalline inclusions in a polymer binder. + +The first material is the binder, a ``matrix`` phase that makes up 35% of the +area. +Its seeds are small circles: the cells of a matrix phase are merged into one +region, so the seeds only need to fill the space between the inclusions. + +The second material is the inclusions, a ``crystalline`` phase that makes up +65% of the area, with circular seeds whose diameters follow a lognormal +distribution. +Each seed of a crystalline phase becomes one polygonal grain of the mesh. + +Domain Geometry +=============== + +The materials fill a square domain of side length 3, periodic in both +directions. + +Settings +======== + +The overlap tolerance ``rtol`` is set to 0.5: with the default fitted value +some of the small binder seeds do not fit between the inclusions and are +rejected. +The random number generator seeds make the microstructure repeatable. + +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small triangles. + +The mesh has a minimum angle of 25 degrees and a maximum element area of +0.01. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polygon mesh, +and the triangular mesh. +These three plots are shown in :numref:`f_ex_pbx2d_seeds` - +:numref:`f_ex_pbx2d_tri`. +The binder is one region of the mesh; its boundaries with the inclusions are +facets of the mesh. + +.. _f_ex_pbx2d_seeds: +.. figure:: ../../../../src/microstructpy/examples/pbx_2D/seeds.png + :alt: Seed geometries. + + Binder and inclusions example - seed geometries. + +.. _f_ex_pbx2d_poly: +.. figure:: ../../../../src/microstructpy/examples/pbx_2D/polymesh.png + :alt: Polygonal mesh. + + Binder and inclusions example - polygonal mesh. + +.. _f_ex_pbx2d_tri: +.. figure:: ../../../../src/microstructpy/examples/pbx_2D/trimesh.png + :alt: Triangular mesh. + + Binder and inclusions example - triangular mesh. diff --git a/docs/source/examples/cli/pbx_3d.rst b/docs/source/examples/cli/pbx_3d.rst new file mode 100644 index 00000000..1659eaac --- /dev/null +++ b/docs/source/examples/cli/pbx_3d.rst @@ -0,0 +1,76 @@ +.. _ex_pbx_3d: + +================================ +Binder and Inclusions 3D Example +================================ + +XML Input File +============== + +The basename for this file is ``pbx_3D.xml``. +The file can be run using this command:: + + microstructpy --demo=pbx_3D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/pbx_3D.xml + :language: xml + + +Materials +========= + +This is the 3D version of the :ref:`ex_pbx_2d`: crystalline +inclusions (65% of the volume, spherical seeds with lognormal diameters) in +a binder (a ``matrix`` phase, 35% of the volume, seeded with small spheres). + +Domain Geometry +=============== + +The materials fill a cube of side length 3, periodic in the three +directions. + +Settings +======== + +The overlap tolerance ``rtol`` is set to 0.7 so that the small binder seeds +can be placed between the inclusions, and the random number generator seeds +make the microstructure repeatable. + +The mesh has a minimum dihedral angle of 15 degrees and a maximum element +volume of 0.02. + +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small tetrahedra. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polyhedral +mesh, and the tetrahedral mesh. +These three plots are shown in :numref:`f_ex_pbx3d_seeds` - +:numref:`f_ex_pbx3d_tri`. + +.. _f_ex_pbx3d_seeds: +.. figure:: ../../../../src/microstructpy/examples/pbx_3D/seeds.png + :alt: Seed geometries. + + Binder and inclusions 3D example - seed geometries. + +.. _f_ex_pbx3d_poly: +.. figure:: ../../../../src/microstructpy/examples/pbx_3D/polymesh.png + :alt: Polyhedral mesh. + + Binder and inclusions 3D example - polyhedral mesh. + +.. _f_ex_pbx3d_tri: +.. figure:: ../../../../src/microstructpy/examples/pbx_3D/trimesh.png + :alt: Tetrahedral mesh. + + Binder and inclusions 3D example - tetrahedral mesh. diff --git a/docs/source/examples/cli/pbx_interface_2d.rst b/docs/source/examples/cli/pbx_interface_2d.rst new file mode 100644 index 00000000..5f01d950 --- /dev/null +++ b/docs/source/examples/cli/pbx_interface_2d.rst @@ -0,0 +1,71 @@ +.. _ex_pbx_interface_2d: + +============================ +Interface Refinement Example +============================ + +XML Input File +============== + +The basename for this file is ``pbx_interface_2D.xml``. +The file can be run using this command:: + + microstructpy --demo=pbx_interface_2D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/pbx_interface_2D.xml + :language: xml + + +Materials +========= + +The materials are those of the :ref:`ex_pbx_2d`: crystalline +inclusions (65% of the area, circular seeds with lognormal diameters) in a +binder (a ``matrix`` phase, 35% of the area, seeded with small circles). + +Domain Geometry +=============== + +The materials fill a square domain of side length 3, periodic in both +directions. + +Settings +======== + +The mesh is refined along the grain boundaries, which are the interfaces +between the binder and the inclusions (and between neighboring inclusions), +and coarse inside the grains. + +``mesh_max_edge_length`` sets the maximum length of the element edges along +the grain boundaries, 0.03 here, while ``mesh_max_volume`` sets the maximum +area of the elements, 0.02 here, which is the area of a triangle with edges +about seven times longer. +Triangle grades the element size between the two. + +The mesh has a minimum angle of 25 degrees. + +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small triangles. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polygon mesh, +and the triangular mesh. +The seeds and the polygonal mesh are those of the :ref:`ex_pbx_2d` +(:numref:`f_ex_pbx2d_seeds` and :numref:`f_ex_pbx2d_poly`), since the seeds +and the settings of the optimization are the same; only the triangular mesh +differs, shown in :numref:`f_ex_pbxint2d_tri`. + +.. _f_ex_pbxint2d_tri: +.. figure:: ../../../../src/microstructpy/examples/pbx_interface_2D/trimesh.png + :alt: Triangular mesh. + + Interface refinement example - triangular mesh. diff --git a/docs/source/examples/cli/pbx_interface_3d.rst b/docs/source/examples/cli/pbx_interface_3d.rst new file mode 100644 index 00000000..5c9935ac --- /dev/null +++ b/docs/source/examples/cli/pbx_interface_3d.rst @@ -0,0 +1,72 @@ +.. _ex_pbx_interface_3d: + +=============================== +Interface Refinement 3D Example +=============================== + +XML Input File +============== + +The basename for this file is ``pbx_interface_3D.xml``. +The file can be run using this command:: + + microstructpy --demo=pbx_interface_3D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/pbx_interface_3D.xml + :language: xml + + +Materials +========= + +The materials are those of the :ref:`ex_pbx_3d`: crystalline +inclusions (65% of the volume, spherical seeds with lognormal diameters) in +a binder (a ``matrix`` phase, 35% of the volume, seeded with small spheres). + +Domain Geometry +=============== + +The materials fill a cube of side length 3, periodic in the three +directions. + +Settings +======== + +The mesh is refined on the grain boundaries, which are the interfaces +between the binder and the inclusions (and between neighboring inclusions), +and coarse inside the grains. + +In 3D, ``mesh_max_edge_length`` sets the maximum edge length of the +triangles on the facets of the polyhedral mesh, 0.2 here: the facets are +triangulated to that size before TetGen meshes the cells. +``mesh_max_volume`` sets the maximum volume of the tetrahedra, 0.05 here, +which is the volume of a regular tetrahedron with edges about four times +longer, and TetGen grades the element size between the two. + +The mesh has a minimum dihedral angle of 15 degrees. + +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small tetrahedra. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polyhedral +mesh, and the tetrahedral mesh. +The seeds and the polyhedral mesh are those of the :ref:`ex_pbx_3d` +(:numref:`f_ex_pbx3d_seeds` and :numref:`f_ex_pbx3d_poly`), since the seeds +and the settings of the optimization are the same; only the tetrahedral +mesh differs, shown (its facets) in :numref:`f_ex_pbxint3d_tri`. + +.. _f_ex_pbxint3d_tri: +.. figure:: ../../../../src/microstructpy/examples/pbx_interface_3D/trimesh.png + :alt: Tetrahedral mesh. + + Interface refinement 3D example - tetrahedral mesh. diff --git a/docs/source/examples/cli/periodic_2d.rst b/docs/source/examples/cli/periodic_2d.rst new file mode 100644 index 00000000..01d2baa5 --- /dev/null +++ b/docs/source/examples/cli/periodic_2d.rst @@ -0,0 +1,100 @@ +.. _ex_periodic_2d: + +=================== +Periodic 2D Example +=================== + +XML Input File +============== + +The basename for this file is ``periodic_2D.xml``. +The file can be run using this command:: + + microstructpy --demo=periodic_2D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/periodic_2D.xml + :language: xml + + +Materials +========= + +The first material makes up two thirds of the area, with circular grains +whose diameters are uniformly distributed between 0.2 and 0.4. + +The second material makes up the remaining third, with elliptical grains +of aspect ratio 2 and random orientations. + +Domain Geometry +=============== + +The materials fill a square domain of side length 2, which is periodic in +both directions: the ```` field of the domain is ``True``. +The periodicity can also be restricted to some of the axes, for example +`` x ``. + +Grains that cross a periodic face of the domain continue on the opposite +face, and the meshes have matching nodes on opposite faces. +The seeds are positioned without overlapping the grains on the other side, +the polygonal mesh is periodic across the faces, and the triangular mesh has +the same nodes on opposite faces, so that periodic boundary conditions can +be applied to it directly. +The pairs of periodic nodes are stored in the mesh (in its text file and, +for Abaqus, in node sets named ``Set-N-Periodic-X-Low`` and +``Set-N-Periodic-X-High``). + +Settings +======== + +The random number generator seeds make the microstructure repeatable. + +The mesh has a minimum angle of 25 degrees and a maximum element area of +0.004. The quality and size settings act on a periodic mesh as on a +non-periodic one. + +A grain that barely crosses a periodic face, or ends just inside it, leaves +a thin piece of itself on the opposite face and very small elements there. +``periodic_margin`` rejects the positions where a seed ends within the +margin of a periodic face or crosses it by less than the margin; ``auto`` +sets the margin to half the target edge length of the mesh, or to an eighth +of the smallest grain if that is smaller. +The edge optimization removes the shortest edges of the polygonal mesh, for +the same reason, and with the margin it also thickens or removes the pieces +of the grains at the periodic faces that are thinner than the margin, which +the placement of the seeds alone cannot prevent (a grain extends beyond its +seed, and its corners can cross a face by a small amount), and opens the +corners of the grains at the faces that are narrower than the minimum angle +of the mesh. + +The plots are colored by seed number, so that the pieces of a grain on +opposite faces of the domain have the same color. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polygon mesh, +and the triangular mesh. +These three plots are shown in :numref:`f_ex_per2d_seeds` - +:numref:`f_ex_per2d_tri`. +The grains cut by the faces of the domain continue on the opposite faces. + +.. _f_ex_per2d_seeds: +.. figure:: ../../../../src/microstructpy/examples/periodic_2D/seeds.png + :alt: Seed geometries. + + Periodic 2D example - seed geometries. + +.. _f_ex_per2d_poly: +.. figure:: ../../../../src/microstructpy/examples/periodic_2D/polymesh.png + :alt: Polygonal mesh. + + Periodic 2D example - polygonal mesh. + +.. _f_ex_per2d_tri: +.. figure:: ../../../../src/microstructpy/examples/periodic_2D/trimesh.png + :alt: Triangular mesh. + + Periodic 2D example - triangular mesh. diff --git a/docs/source/examples/cli/periodic_3d.rst b/docs/source/examples/cli/periodic_3d.rst new file mode 100644 index 00000000..a2b3bdf0 --- /dev/null +++ b/docs/source/examples/cli/periodic_3d.rst @@ -0,0 +1,87 @@ +.. _ex_periodic_3d: + +=================== +Periodic 3D Example +=================== + +XML Input File +============== + +The basename for this file is ``periodic_3D.xml``. +The file can be run using this command:: + + microstructpy --demo=periodic_3D.xml + +The full text of the file is: + +.. literalinclude:: ../../../../src/microstructpy/examples/periodic_3D.xml + :language: xml + + +Materials +========= + +The two materials make up half of the volume each. +The first has spherical grains whose diameters are uniformly distributed +between 0.8 and 1.4, the second has spherical grains of diameter 1.2. + +Domain Geometry +=============== + +The materials fill a cube of side length 4, which is periodic in the three +directions: the ```` field of the domain is ``xyz``. +Any subset of the axes can be given, for example ``xz`` to leave the y +direction free. + +The polyhedral mesh is periodic across the faces (a grain cut by a face +continues on the opposite face) and the tetrahedral mesh has the same nodes +on opposite faces, stored as pairs in the mesh. + +Settings +======== + +The random number generator seeds make the microstructure repeatable. + +The mesh has a minimum dihedral angle of 15 degrees and a maximum element +volume of 0.02. +In 3D, the domain is meshed once as usual, then the facets are +triangulated with the points TetGen added on them, identically on opposite +faces, and the mesh is built again with these facets; the quality and size +settings act as on a non-periodic mesh. + +The seeds are placed a margin away from the periodic faces (``periodic_margin`` +set to ``auto``: half the target edge length, or an eighth of the smallest +grain if that is smaller) and the edge optimization moves the seeds that +leave a thin piece of a grain on a face, a corner narrower than the minimum +angle of the mesh, or a very short edge, since any of these forces very +small tetrahedra. + +The plots are colored by seed number and the line widths are reduced to +make the grains visible. + + +Output Files +============ + +The three plots that this file generates are the seeding, the polyhedral +mesh, and the tetrahedral mesh. +These three plots are shown in :numref:`f_ex_per3d_seeds` - +:numref:`f_ex_per3d_tri`. + +.. _f_ex_per3d_seeds: +.. figure:: ../../../../src/microstructpy/examples/periodic_3D/seeds.png + :alt: Seed geometries. + + Periodic 3D example - seed geometries. + +.. _f_ex_per3d_poly: +.. figure:: ../../../../src/microstructpy/examples/periodic_3D/polymesh.png + :alt: Polyhedral mesh. + + Periodic 3D example - polyhedral mesh. + +.. _f_ex_per3d_tri: +.. figure:: ../../../../src/microstructpy/examples/periodic_3D/trimesh.png + :alt: Tetrahedral mesh. + + Periodic 3D example - tetrahedral mesh. diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 537d708d..946f9579 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -75,6 +75,12 @@ CLI Examples cli/basalt cli/two_phase_3d cli/colormap + cli/periodic_2d + cli/periodic_3d + cli/pbx_2d + cli/pbx_3d + cli/pbx_interface_2d + cli/pbx_interface_3d .. only:: html @@ -103,6 +109,36 @@ CLI Examples :width: 32% :target: cli/colormap.html + .. image:: ../../../src/microstructpy/examples/periodic_2D/trimesh.png + :alt: Triangular mesh from periodic 2D example. + :width: 32% + :target: cli/periodic_2d.html + + .. image:: ../../../src/microstructpy/examples/periodic_3D/polymesh.png + :alt: Polyhedral mesh from periodic 3D example. + :width: 32% + :target: cli/periodic_3d.html + + .. image:: ../../../src/microstructpy/examples/pbx_2D/trimesh.png + :alt: Triangular mesh of binder and inclusions. + :width: 32% + :target: cli/pbx_2d.html + + .. image:: ../../../src/microstructpy/examples/pbx_3D/polymesh.png + :alt: Polyhedral mesh of binder and inclusions. + :width: 32% + :target: cli/pbx_3d.html + + .. image:: ../../../src/microstructpy/examples/pbx_interface_2D/trimesh.png + :alt: Triangular mesh refined at the interfaces. + :width: 32% + :target: cli/pbx_interface_2d.html + + .. image:: ../../../src/microstructpy/examples/pbx_interface_3D/trimesh.png + :alt: Tetrahedral mesh refined at the interfaces. + :width: 32% + :target: cli/pbx_interface_3d.html + .. _package_examples: @@ -120,6 +156,7 @@ Python Package Examples package/grain_neighborhoods package/from_image package/mesh_process + package/periodic_tiling .. only:: html @@ -156,3 +193,8 @@ Python Package Examples .. image:: ../../../src/microstructpy/examples/docs_banner/banner.png :alt: Microstructure meshing process.. :target: package/mesh_process.html + + .. image:: ../../../src/microstructpy/examples/periodic_tiling/tiled_2D.png + :alt: Periodic microstructure, tiled 2 x 2. + :height: 210px + :target: package/periodic_tiling.html diff --git a/docs/source/examples/package/periodic_tiling.rst b/docs/source/examples/package/periodic_tiling.rst new file mode 100644 index 00000000..2003b6ac --- /dev/null +++ b/docs/source/examples/package/periodic_tiling.rst @@ -0,0 +1,100 @@ +.. _ex_periodic_tiling: + +======================================== +Periodic Microstructures and Their Tiles +======================================== + +Python Script +============= + +The basename for this file is ``periodic_tiling.py``. +The file can be run using this command:: + + microstructpy --demo=periodic_tiling.py + +The full text of the script is: + +.. literalinclude:: ../../../../src/microstructpy/examples/periodic_tiling.py + :language: python + +Domain +====== + +The domain of the microstructure is a :class:`.Square` of side length 2, +with its bottom left corner at the origin. +The microstructure is periodic in both directions: a grain that crosses a +face of the domain continues on the opposite face, and the meshes have +matching nodes on opposite faces. +The periodicity can be restricted to some axes, for example +``periodic='x'``. + +Phases +====== + +There are two phases: circular grains, which occupy two thirds of the +domain, and elliptical inclusions of aspect ratio 2, which occupy the +remaining third. + +Seeds +===== + +The seeds are created with :func:`~microstructpy.seeding.SeedList.from_info` +to fill 90% of the area, so that all of them can be placed, and positioned +with :func:`~microstructpy.seeding.SeedList.position` with +``periodic=True``: a seed that crosses a face of the domain is also checked +for overlaps on the opposite face. +The ``periodic_margin`` keeps the seeds from ending within half a target +edge length of a face, or crossing one by less, since such seeds leave thin +pieces of grains on the opposite face and very small triangles there; it is +capped at an eighth of the smallest grain, which needs about four elements +across it (the ``auto`` value of the CLI setting does the same). + +Polygon and Triangle Meshing +============================ + +The polygonal mesh is created with +:func:`~microstructpy.meshing.PolyMesh.from_seeds`, again with +``periodic=True``, and the triangular mesh with +:func:`~microstructpy.meshing.TriMesh.from_polymesh`, which reads the +periodicity from the polygonal mesh. +The edge optimization of the polygonal mesh (``edge_opt``) lengthens its +shortest edges and, with the same margin and the minimum angle of the +triangular mesh, thickens or removes the pieces of the grains at the faces +that are thinner than the margin and opens the corners of the grains at the +faces that are narrower than the minimum angle. + +Plotting +======== + +The polygonal mesh and the triangular mesh are drawn four times, in a +2 x 2 tiling of the domain. +The grains cut by the faces of the domain are colored by seed number and +continue across the faces, and the nodes of the triangular mesh on the face +``x = 0`` (red) have their images on the face ``x = 2`` (blue), listed in +``periodic_nodes[0]`` of the mesh. +The tiling is shown in :numref:`f_ex_tiling_2d`. + +.. _f_ex_tiling_2d: +.. figure:: ../../../../src/microstructpy/examples/periodic_tiling/tiled_2D.png + :alt: Periodic 2D microstructure, tiled 2 x 2. + + Periodic polygonal and triangular meshes, tiled 2 x 2. + +Periodic Microstructure in 3D +============================= + +The domain is a :class:`.Cube` of side length 4, filled with two phases of +spherical grains, periodic in the three directions. +The script prints the number of pairs of nodes on opposite faces of the +tetrahedral mesh, which are exact images of each other along each axis. + +The faces of the polyhedral mesh that are visible from the viewpoint are +drawn for the domain and for a copy of the domain translated along ``x``, +in :numref:`f_ex_tiling_3d`: the grains continue across the periodic +face. + +.. _f_ex_tiling_3d: +.. figure:: ../../../../src/microstructpy/examples/periodic_tiling/tiled_3D.png + :alt: Periodic 3D microstructure, tiled twice along x. + + Periodic polyhedral mesh, tiled twice along x. diff --git a/docs/source/file_formats.rst b/docs/source/file_formats.rst index 716bbd1d..4c55e56d 100644 --- a/docs/source/file_formats.rst +++ b/docs/source/file_formats.rst @@ -219,6 +219,9 @@ element. The facet attribute is the facet number from the polygonal mesh, so all of the triangular mesh facets with the same attribute make up a polygonal mesh facet. +The facets of a mesh created from a polygonal mesh are listed with their +nodes in ascending order and in lexicographic order, so that the file is +the same from one run to the next. .. note:: diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index 93446cfa..8eca756b 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -32,6 +32,15 @@ For Windows, it may be in a path similar to If the install fails and the last several error messages reference ``pybind11``, run ``pip install pybind11`` first then install MicroStructPy. +.. note:: + MicroStructPy computes the tessellations with the ``pyvoro`` module of the + ``pyvoro-rimoli`` package. Earlier versions used ``pyvoro-mmalahe``, which + installs the same module with an older Voro++. When upgrading, remove it + first:: + + pip uninstall pyvoro-mmalahe + pip install --upgrade microstructpy + Running Demonstrations ---------------------- diff --git a/docs/source/package_guide.rst b/docs/source/package_guide.rst index 40e60407..fc8b03ad 100644 --- a/docs/source/package_guide.rst +++ b/docs/source/package_guide.rst @@ -293,7 +293,7 @@ phase-specific. .. _`MeshPy`: https://mathema.tician.de/software/meshpy/ .. _`Power Diagram`: https://en.wikipedia.org/wiki/Power_diagram .. _`pygmsh`: https://pygmsh.readthedocs.io -.. _`pyvoro`: https://github.com/mmalahe/pyvoro +.. _`pyvoro`: https://github.com/rimoli/pyvoro .. _`TetGen`: http://wias-berlin.de/software/tetgen/ .. _`Triangle`: https://www.cs.cmu.edu/~quake/triangle.html .. _`Voro++`: http://math.lbl.gov/voro++/ diff --git a/requirements.txt b/requirements.txt index 8298e352..61c48b75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,10 +5,9 @@ pygmsh==7.1.17 MeshPy==2022.1.3 numpy>=1.24.4,<2.0 pyquaternion==0.9.5 -pyvoro-mmalahe==1.3.4 +pyvoro-rimoli==1.4.0 scipy>=1.10.1 setuptools>=70.0.0 xmltodict==0.12.0 -tox==3.14.0 lsq-ellipse==2.0.1 zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/setup.py b/setup.py index 2aa562c0..12e16d27 100644 --- a/setup.py +++ b/setup.py @@ -85,7 +85,7 @@ def find_version(*fname): 'numpy>=1.22.2', 'pygmsh>=7.0.2', 'pyquaternion', - 'pyvoro-mmalahe>=1.3.4', # install issue with pyvoro + 'pyvoro-rimoli>=1.4.0', # pyvoro with the current Voro++ 'scipy', 'xmltodict' ], diff --git a/src/microstructpy/_misc.py b/src/microstructpy/_misc.py index 61f425f7..a3d1944a 100644 --- a/src/microstructpy/_misc.py +++ b/src/microstructpy/_misc.py @@ -4,6 +4,7 @@ """ import ast +import re import numpy as np @@ -40,6 +41,12 @@ def from_str(string): This function takes a string and converts it into a number or a list. + Booleans are recognized regardless of case (``true``, ``FALSE``, ...), + on their own or inside a tuple/list such as ``(true, false)``. + Strings that merely contain these words (``true_cdf.csv``) are returned + unchanged. Values that Python does not accept as literals but ``float`` + does, such as ``inf``, ``-inf`` and ``nan``, are converted to floats. + Args: string (str): The string. @@ -49,25 +56,32 @@ def from_str(string): """ s = string.strip() try: - val = ast.literal_eval(s) + return ast.literal_eval(s) except (ValueError, SyntaxError): - if 'true' in s.lower(): - tmp_s = s.lower().replace('true', 'True') - tmp_val = from_str(tmp_s) - if tmp_val != tmp_s: - val = tmp_val - else: - val = s - elif 'false' in s.lower(): - tmp_s = s.lower().replace('false', 'False') - tmp_val = from_str(tmp_s) - if tmp_val != tmp_s: - val = tmp_val - else: - val = s - else: - val = s - return val + pass + + # Booleans, case-insensitive + if s.lower() in ('true', 'false'): + return s.lower() == 'true' + + # Booleans inside a literal, e.g. '(true, False)' + norm_s = _bool_re.sub(lambda m: m.group(0).capitalize(), s) + if norm_s != s: + try: + return ast.literal_eval(norm_s) + except (ValueError, SyntaxError): + pass + + # Floats that are not Python literals: inf, -inf, nan + try: + return float(s) + except ValueError: + pass + + return s + + +_bool_re = re.compile(r'\b(true|false)\b', flags=re.IGNORECASE) # --------------------------------------------------------------------------- # @@ -164,3 +178,313 @@ def ax_objects(ax): for att in ['collections', 'images', 'lines', 'patches', 'texts']: n += len(getattr(ax, att)) return n + + +# --------------------------------------------------------------------------- # +# # +# Periodicity # +# # +# --------------------------------------------------------------------------- # +def periodic_axes(periodic, n_dim): + """Per-axis periodicity flags. + + The periodicity of a microstructure can be given as a boolean (all axes + or none), a list of booleans (one per axis), or a string with the names + of the periodic axes, such as ``'x'``, ``'xy'`` or ``'xz'``. + + Args: + periodic (bool, list, or str): The periodicity specification. + n_dim (int): Number of dimensions of the domain. + + Returns: + list: ``n_dim`` booleans, True for the periodic axes. + + Raises: + ValueError: If the specification cannot be interpreted. + + """ + if periodic is None: + return [False for _ in range(n_dim)] + + if isinstance(periodic, (bool, np.bool_)): + return [bool(periodic) for _ in range(n_dim)] + + axis_names = 'xyz'[:n_dim] + if isinstance(periodic, str): + text = periodic.strip().lower() + if text in ('true', 'all', 'yes'): + return [True for _ in range(n_dim)] + if text in ('false', 'none', 'no', ''): + return [False for _ in range(n_dim)] + flags = [False for _ in range(n_dim)] + for word in text.replace(',', ' ').split(): + for char in word: + if char not in axis_names: + e_str = 'Cannot interpret periodic axes ' + repr(periodic) + e_str += '. Use a boolean, a list of ' + str(n_dim) + e_str += ' booleans, or axis names such as ' + e_str += repr(axis_names) + '.' + raise ValueError(e_str) + flags[axis_names.index(char)] = True + return flags + + flags = [bool(f) for f in periodic] + if len(flags) != n_dim: + e_str = 'Expected ' + str(n_dim) + ' periodicity flags, got ' + e_str += str(len(flags)) + ': ' + repr(periodic) + '.' + raise ValueError(e_str) + return flags + + +class UnionFind(object): + """Disjoint sets of hashable items, with path halving. + + Args: + items (iterable): The items, each initially in its own set. + + """ + def __init__(self, items): + self.parent = {item: item for item in items} + + def find(self, item): + """Root of the set of an item.""" + parent = self.parent + while parent[item] != item: + parent[item] = parent[parent[item]] + item = parent[item] + return item + + def union(self, item_a, item_b): + """Join the sets of two items under the smaller of their roots, so + that the root of a set of integers is its smallest member.""" + r_a, r_b = self.find(item_a), self.find(item_b) + if r_a != r_b: + self.parent[max(r_a, r_b)] = min(r_a, r_b) + + def attach(self, item_a, item_b): + """Put the root of the set of the first item under the root of the + set of the second one.""" + self.parent[self.find(item_a)] = self.find(item_b) + + +def wall_axis_side(wall): + """Axis and side of a wall id of a rectangular domain. + + The walls are numbered -1, -2 for the lower and upper faces along x, + -3, -4 along y and -5, -6 along z (the convention of Voro++). + + Args: + wall (int): The wall id (negative). + + Returns: + tuple: The axis (0, 1 or 2) and the side (0 for the lower face, 1 + for the upper face). + + """ + return divmod(-wall - 1, 2) + + +def periodic_domain_limits(domain): + """(lower, upper) bounds of a rectangular, axis-aligned domain. + + Periodic microstructures are only supported in such domains. + + Args: + domain (from :mod:`microstructpy.geometry`): The domain. + + Returns: + list: One (lower, upper) tuple per axis. + + Raises: + ValueError: If the domain is not a rectangle, square, box, or cube, + or if it is rotated. + + """ + name = type(domain).__name__.lower() + if name not in ('rectangle', 'square', 'box', 'cube'): + e_str = 'Periodic microstructures require a rectangular domain ' + e_str += '(Rectangle, Square, Box, or Cube), not ' + name + '.' + raise ValueError(e_str) + if not np.allclose(np.array(domain.matrix), np.eye(domain.n_dim)): + e_str = 'Periodic microstructures require an axis-aligned domain.' + raise ValueError(e_str) + return [(float(lb), float(ub)) for lb, ub in domain.limits] + + +def periodic_bounds(points, per_axes): + """(lower, upper) bounds of the domain of a periodic mesh. + + The points of a mesh that fills a rectangular domain span the domain, + so its bounds are the extents of the points. + + Args: + points (list or numpy.ndarray): The points of the mesh. + per_axes (list): Periodicity flag of each axis. + + Returns: + list: One (lower, upper) tuple per axis. + + """ + pts = np.array(points, dtype='float') + return [(float(lb), float(ub)) for lb, ub in + zip(pts.min(axis=0), pts.max(axis=0))] + + +def pair_periodic_points(points, per_axes, dom_lims, rel_tol=1e-8): + """Pair the points on opposite periodic faces of a domain. + + For each periodic axis, every point on the lower face is matched with + its image on the upper face, and the coordinates of the pair are + snapped so that the image is exactly the point translated by the + domain length. + + Args: + points (list or numpy.ndarray): The points. + per_axes (list): Periodicity flag of each axis. + dom_lims (list): (lower, upper) bounds of the domain, per axis. + rel_tol (float): Matching tolerance, relative to the largest + domain length. + + Returns: + tuple: The snapped points (numpy.ndarray) and a dictionary that + maps each periodic axis to a list of (lower, upper) point numbers. + + Raises: + ValueError: If a point on a periodic face has no image on the + opposite face. + + """ + pts = np.array(points, dtype='float') + n_dim = pts.shape[1] + lengths = [ub - lb for lb, ub in dom_lims] + tol = rel_tol * max(lengths) + + pairs = {} + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = dom_lims[axis] + shift = np.zeros(n_dim) + shift[axis] = ub - lb + others = [i for i in range(n_dim) if i != axis] + + low = np.nonzero(np.abs(pts[:, axis] - lb) <= tol)[0] + high = np.nonzero(np.abs(pts[:, axis] - ub) <= tol)[0] + if len(low) != len(high): + e_str = 'The periodic faces along axis ' + str(axis) + e_str += ' have different numbers of points (' + e_str += str(len(low)) + ' and ' + str(len(high)) + ').' + raise ValueError(e_str) + + axis_pairs = [] + if len(low) > 0: + rel = pts[low][:, None, :][:, :, others] + rel = rel - pts[high][None, :, :][:, :, others] + dists = np.sqrt(np.sum(rel * rel, axis=-1)) + for i_low, kp_low in enumerate(low): + i_high = int(np.argmin(dists[i_low])) + if dists[i_low, i_high] > tol: + e_str = 'Point ' + str(kp_low) + ' on the lower ' + e_str += 'periodic face of axis ' + str(axis) + e_str += ' has no image on the upper face.' + raise ValueError(e_str) + dists[:, i_high] = np.inf # one-to-one + kp_high = int(high[i_high]) + pts[kp_low, axis] = lb + pts[kp_high] = pts[kp_low] + shift + axis_pairs.append((int(kp_low), kp_high)) + pairs[axis] = axis_pairs + return pts, pairs + + +def pair_periodic_mesh(points, facets, per_axes, dom_lims): + """Pair the points and the facets of a mesh on opposite periodic faces. + + See :func:`pair_periodic_points` and :func:`pair_periodic_facets`. + + Args: + points (list or numpy.ndarray): The points. + facets (list or None): The facets, or None if the mesh has none. + per_axes (list): Periodicity flag of each axis. + dom_lims (list): (lower, upper) bounds of the domain, per axis. + + Returns: + tuple: The snapped points (numpy.ndarray), the point pairs and the + facet pairs (dictionaries: axis -> list of (lower, upper) numbers; + the facet pairs are empty lists if the mesh has no facets). + + """ + pts, point_pairs = pair_periodic_points(points, per_axes, dom_lims) + if facets is None: + facet_pairs = {axis: [] for axis in point_pairs} + else: + facet_pairs = pair_periodic_facets(facets, point_pairs) + return pts, point_pairs, facet_pairs + + +def pair_periodic_facets(facets, point_pairs): + """Pair the facets lying on opposite periodic faces. + + Args: + facets (list): Facets (lists of point numbers). + point_pairs (dict): Output of :func:`pair_periodic_points`. + + Returns: + dict: Maps each periodic axis to a list of (lower, upper) facet + numbers. + + Raises: + ValueError: If a facet on a periodic face has no image. + + """ + pairs = {} + for axis, axis_pairs in point_pairs.items(): + kp_map = dict(axis_pairs) + low_set = set(kp_map) + high_set = set(kp_map.values()) + high_facets = {} + for f_num, facet in enumerate(facets): + if len(facet) > 0 and all([kp in high_set for kp in facet]): + high_facets[frozenset(facet)] = f_num + f_pairs = [] + for f_num, facet in enumerate(facets): + if len(facet) == 0 or not all([kp in low_set for kp in facet]): + continue + key = frozenset([kp_map[kp] for kp in facet]) + if key not in high_facets: + e_str = 'Facet ' + str(f_num) + ' on the lower periodic' + e_str += ' face of axis ' + str(axis) + ' has no image' + e_str += ' on the upper face.' + raise ValueError(e_str) + f_pairs.append((f_num, high_facets[key])) + pairs[axis] = f_pairs + return pairs + + +def unwrap_points(points, center, per_axes, dom_lims): + """Translate points by domain lengths to the image nearest a center. + + Used to reassemble a grain that a periodic domain splits into pieces: + along each periodic axis, every point is moved by a multiple of the + domain length so that it lies within half a length of the center. + + Args: + points (list or numpy.ndarray): The points. + center (list or numpy.ndarray): The reference point (e.g. the seed + position). + per_axes (list): Periodicity flag of each axis. + dom_lims (list): (lower, upper) bounds of the domain, per axis. + + Returns: + numpy.ndarray: The unwrapped points. + + """ + pts = np.array(points, dtype='float') + cen = np.array(center, dtype='float') + for axis, flag in enumerate(per_axes): + if not flag: + continue + length = dom_lims[axis][1] - dom_lims[axis][0] + n_shift = np.round((cen[axis] - pts[:, axis]) / length) + pts[:, axis] += n_shift * length + return pts diff --git a/src/microstructpy/cli.py b/src/microstructpy/cli.py index 23ca2928..88d8fc07 100644 --- a/src/microstructpy/cli.py +++ b/src/microstructpy/cli.py @@ -11,12 +11,13 @@ from __future__ import print_function import argparse -import ast import collections +import copy import glob import os import shutil import subprocess +import sys import numpy as np import scipy.stats @@ -67,7 +68,14 @@ def main(): args = parser.parse_args() # run user-generated files - user_files = [f for fnames in args.user_files for f in glob.glob(fnames)] + user_files = [] + for pattern in args.user_files: + matches = glob.glob(pattern) + if not matches: + e_str = 'Error: no input file matches ' + repr(pattern) + '.' + print(e_str, file=sys.stderr) + sys.exit(1) + user_files.extend(matches) for filename in set(user_files): run_file(filename) @@ -160,11 +168,15 @@ def read_input(filename): domain_data = in_data['domain'] domain_shape = domain_data['shape'] domain_kwargs = {k: v for k, v in domain_data.items() if k != 'shape'} + # periodicity is a property of the run, not of the geometry + periodic = domain_kwargs.pop('periodic', None) domain = geometry.factory(domain_shape, **domain_kwargs) in_data['domain'] = domain # Default settings kwargs = in_data.get('settings', {}) + if periodic is not None: + kwargs['periodic'] = periodic run_dir = kwargs.get('directory', '.') if not os.path.isabs(run_dir): rel_path = os.path.join(file_path, run_dir) @@ -197,7 +209,7 @@ def input2dict(filename, root_tag='input'): def _include_expand(inp, filename, key): - if isinstance(inp, str): + if inp is None or isinstance(inp, str): return inp if isinstance(inp, list): return [_include_expand(inp_i, filename, key) for inp_i in inp] @@ -210,25 +222,105 @@ def _include_expand(inp, filename, key): if not isinstance(includes, list): includes = [includes] for inc_filename in includes: - inc_fname = os.path.expanduser(inc_filename) + inc_fname = os.path.expanduser(inc_filename.strip()) if os.path.isabs(inc_fname): fname = inc_fname else: fname = os.path.join(file_path, inc_fname) inc_dict = input2dict(fname, key) - exp_dict.update(inc_dict[key]) + for inc_key, inc_val in inc_dict[key].items(): + _include_merge(exp_dict, inc_key, inc_val) else: - exp_dict[inp_key] = _include_expand(inp_val, filename, inp_key) + exp_val = _include_expand(inp_val, filename, inp_key) + _include_merge(exp_dict, inp_key, exp_val) return exp_dict +def _include_merge(exp_dict, key, val): + """Add a value to the dictionary of an expanded input file. + + When the same tag comes from an ```` and from the including + file (or from two includes), structured values (dictionaries and lists) + are concatenated into a list - the same as xmltodict does for repeated + tags - so that, for example, no ```` is discarded. + Scalar values are overridden by the later occurrence. + """ + if key not in exp_dict: + exp_dict[key] = val + return + + old_val = exp_dict[key] + repeated = (key == 'material' or isinstance(old_val, list) or + isinstance(val, list)) + if repeated and isinstance(old_val, (dict, list)) and \ + isinstance(val, (dict, list)): + old_list = old_val if isinstance(old_val, list) else [old_val] + new_list = val if isinstance(val, list) else [val] + exp_dict[key] = old_list + new_list + else: + exp_dict[key] = val + + +_tri_exts = {'abaqus': '.inp', 'txt': '.txt', 'str': '.txt', 'tet/tri': '', + 'vtk': '.vtk'} + + +def _periodic_margin(periodic_margin, n_dim, max_volume, max_edge_length, + seeds=None): + """Margin between the seeds and the periodic faces, from the settings. + + ``'auto'`` is the smaller of half the target edge length of the mesh + (the maximum edge length if it is set, otherwise the edge of the + equilateral triangle (2D) or regular tetrahedron (3D) with the maximum + volume) and an eighth of the size of the smallest seed (its smallest + diameter or side): the smallest seed needs about four elements across + it, so the mesh cannot be coarser than a quarter of it, and a margin + larger than the seed could not be satisfied by the seed anyway. + """ + if not isinstance(periodic_margin, str): + return float(periodic_margin) + key = periodic_margin.strip().lower() + if key in ('none', 'false', 'no', ''): + return 0.0 + if key not in ('auto', 'fit', 'true'): + e_str = 'Cannot interpret periodic_margin ' + repr(periodic_margin) + e_str += ". Use a length or 'auto'." + raise ValueError(e_str) + h_val = float(max_edge_length) + if np.isfinite(max_volume): + if n_dim == 2: + h_val = min(h_val, np.sqrt(4 * max_volume / np.sqrt(3))) + else: + h_val = min(h_val, (6 * np.sqrt(2) * max_volume) ** (1.0 / 3)) + if seeds: + h_val = min(h_val, 0.25 * min([_seed_size(s) for s in seeds])) + if not np.isfinite(h_val): + return 0.0 + return 0.5 * h_val + + +def _seed_size(seed): + """Smallest dimension of a seed: the smallest diameter of an ellipse + or ellipsoid, the shortest side of a box, the diameter of a circle or + sphere; infinity if the geometry has none of these.""" + geom = seed.geometry + if hasattr(geom, 'axes'): + return 2 * min(geom.axes) + if hasattr(geom, 'side_lengths'): + return min(geom.side_lengths) + if hasattr(geom, 'r'): + return 2 * geom.r + return getattr(geom, 'size', float('inf')) + + def run(phases, domain, verbose=False, restart=True, directory='.', - filetypes={}, rng_seeds={}, plot_axes=True, rtol='fit', edge_opt=False, - edge_opt_n_iter=100, mesher='Triangle/TetGen', + filetypes=None, rng_seeds=None, plot_axes=True, rtol='fit', + edge_opt=False, edge_opt_n_iter=100, mesher='Triangle/TetGen', mesh_max_volume=float('inf'), mesh_min_angle=0, mesh_max_edge_length=float('inf'), mesh_size=float('inf'), verify=False, color_by='material', colormap='viridis', - seeds_kwargs={}, poly_kwargs={}, tri_kwargs={}): + seeds_kwargs=None, poly_kwargs=None, tri_kwargs=None, + periodic=False, periodic_margin=0.0): r"""Run MicroStructPy This is the primary run function for the package. It performs these steps: @@ -298,10 +390,14 @@ def run(phases, domain, verbose=False, restart=True, directory='.', edge length in the PolyMesh. The seeds associated with the shortest edge are displaced randomly to find improvement and this process iterates until `n_iter` attempts have been made - for a given edge. Defaults to False. + for a given edge. In periodic domains with a `periodic_margin`, + the pieces of the cells at the periodic faces that are thinner + than the margin, and the corners of the cells at the faces + narrower than `mesh_min_angle`, are optimized too. Defaults to + False. edge_opt_n_iter (int): *(optional)* Maximum number of iterations per - edge during optimization. Ignored if `edge_opt` set to False. - Defaults to 100. + edge (or thin piece) during optimization. Ignored if `edge_opt` + set to False. Defaults to 100. mesher (str): {'raster' | 'Triangle/TetGen' | 'Triangle' | 'TetGen' | 'gmsh'} specify the mesh generator. Default is 'Triangle/TetGen'. @@ -315,7 +411,8 @@ def run(phases, domain, verbose=False, restart=True, directory='.', Defaults to 0, which turns off the angle quality constraint. Value should be in the range 0-60. mesh_max_edge_length (float): *(optional)* The maximum edge length of - elements along grain boundaries. Currently only supported in 2D. + elements along grain boundaries: of the segments in 2D and of + the triangles on the facets in 3D. mesh_size (float): The target size of the mesh elements. This option is used with gmsh. Default is infinity, whihch turns off this control. @@ -338,6 +435,29 @@ def run(phases, domain, verbose=False, restart=True, directory='.', :meth:`.PolyMesh.plot` in 3D. tri_kwargs (dict): Additional keyword arguments that will be passed to :meth:`.TriMesh.plot`. + periodic (bool, list, or str): *(optional)* Periodicity of the + microstructure: True for all axes, a list of booleans (one per + axis), or the names of the periodic axes such as ``'x'`` or + ``'xy'``. Seeds are placed, the domain is tessellated and the + mesh is generated so that opposite faces of the (rectangular) + domain match; the pairs of periodic nodes are stored in the + meshes. In the XML input, ```` is a field of + ````. Defaults to False. + periodic_margin (float or str): *(optional)* Minimum distance + between the surface of a seed and a periodic face: a seed that + ends within this distance inside a face, or crosses a face by + less than this distance, is placed elsewhere, since it would + give elements much smaller than the target size of the mesh. + ``'auto'`` uses the smaller of half the target edge length of + the mesh (``mesh_max_edge_length`` or, from + ``mesh_max_volume``, the edge of the equilateral triangle or + regular tetrahedron of that volume) and an eighth of the size + of the smallest seed (its smallest diameter or side), since + the smallest seed needs about four elements across it and + cannot satisfy a margin larger than itself. With `edge_opt`, + the pieces of the cells at the periodic faces thinner than the + margin are thickened or removed by moving their seeds. + Defaults to 0 (no margin). .. _`Specifying Colors`: https://matplotlib.org/users/colors.html .. _`Choosing Colormaps in Matplotlib`: https://matplotlib.org/tutorials/colors/colormaps.html @@ -353,6 +473,14 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # Settings # -------- + # Work on copies of the dictionaries: the caller's arguments are never + # modified, so repeated calls with the same inputs give the same results. + filetypes = copy.deepcopy(filetypes) if filetypes is not None else {} + rng_seeds = copy.deepcopy(rng_seeds) if rng_seeds is not None else {} + seeds_kwargs = dict(seeds_kwargs) if seeds_kwargs is not None else {} + poly_kwargs = dict(poly_kwargs) if poly_kwargs is not None else {} + tri_kwargs = dict(tri_kwargs) if tri_kwargs is not None else {} + # filetypes if restart: for kw in ('seeds', 'poly', 'tri'): @@ -364,6 +492,20 @@ def run(phases, domain, verbose=False, restart=True, directory='.', else: filetypes[kw] = [filetypes[kw], 'txt'] + # Check the triangular mesh output types before doing any work + tri_types = filetypes.get('tri', []) + if not isinstance(tri_types, list): + tri_types = [tri_types] + for tri_type in tri_types: + if tri_type not in _tri_exts: + e_str = 'Unsupported output type ' + repr(tri_type) + '. ' + e_str += 'Supported types are: ' + e_str += ', '.join([repr(t) for t in sorted(_tri_exts)]) + '.' + raise ValueError(e_str) + + # mesher + raster = mesher.strip().lower() == 'raster' + if verbose: print('Running MicroStructPy in verbose mode.') @@ -395,6 +537,10 @@ def run(phases, domain, verbose=False, restart=True, directory='.', seeds = _unpositioned_seeds(phases, domain, rng_seeds) + # the margin between the seeds (and their cells) and the periodic faces + margin = _periodic_margin(periodic_margin, domain.n_dim, mesh_max_volume, + mesh_max_edge_length, seeds) + if seeds_created: if verbose: print('There are ' + str(len(seeds)) + ' seeds.') print('Positioning seeds in domain.') @@ -402,16 +548,21 @@ def run(phases, domain, verbose=False, restart=True, directory='.', kw = 'position' rng_seed = rng_seeds.get(kw, 0) pos_dists = {i: p[kw] for i, p in enumerate(phases) if kw in p} - seeds.position(domain, pos_dists, rng_seed, rtol=rtol, verbose=verbose) + seeds.position(domain, pos_dists, rng_seed, rtol=rtol, verbose=verbose, + periodic=periodic, periodic_margin=margin) # Write seeds seeds_types = filetypes.get('seeds', []) - if type(seeds_types) != list: + if not isinstance(seeds_types, list): seeds_types = [seeds_types] - for seeds_type in seeds_types: - fname = seed_filename.rstrip('.txt') + '.' + seeds_type - if seeds_created or not os.path.exists(fname): - seeds.write(fname, format=seeds_type) + + def write_seeds(force=False): + for seeds_type in seeds_types: + fname = os.path.splitext(seed_filename)[0] + '.' + seeds_type + if force or seeds_created or not os.path.exists(fname): + seeds.write(fname, format=seeds_type) + + write_seeds() # ----------------------------------------------------------------------- # # Plot Seeds # @@ -422,18 +573,21 @@ def run(phases, domain, verbose=False, restart=True, directory='.', elif type(plot_types) is not list: plot_types = [plot_types] - plot_files = [] - for ext in plot_types: - fname = os.path.join(directory, 'seeds.' + str(ext)) - if seeds_created or not os.path.exists(fname): - plot_files.append(fname) + def plot_seed_files(force=False): + plot_files = [] + for ext in plot_types: + fname = os.path.join(directory, 'seeds.' + str(ext)) + if force or seeds_created or not os.path.exists(fname): + plot_files.append(fname) - if plot_files and verbose: - print('Plotting seeds.') + if plot_files and verbose: + print('Plotting seeds.') - if plot_files: - plot_seeds(seeds, phases, domain, plot_files, plot_axes, color_by, - colormap, **seeds_kwargs) + if plot_files: + plot_seeds(seeds, phases, domain, plot_files, plot_axes, color_by, + colormap, **seeds_kwargs) + + plot_seed_files() # ----------------------------------------------------------------------- # # Create Polygon Mesh # @@ -456,15 +610,24 @@ def run(phases, domain, verbose=False, restart=True, directory='.', print('Creating polygon mesh.') pmesh = PolyMesh.from_seeds(seeds, domain, edge_opt, edge_opt_n_iter, - verbose) + verbose, periodic=periodic, + periodic_margin=margin, + min_angle=mesh_min_angle) + if edge_opt: + # the optimization moved seeds: their files and plots are + # updated to the seeds that produce the polygon mesh + if verbose: + print('Updating the seeds moved by the edge optimization.') + write_seeds(force=True) + plot_seed_files(force=True) # Write polymesh poly_types = filetypes.get('poly', []) - if type(poly_types) != list: + if not isinstance(poly_types, list): poly_types = [poly_types] for poly_type in poly_types: - fname = poly_filename.replace('.txt', '.' + poly_type) + fname = os.path.splitext(poly_filename)[0] + '.' + poly_type if poly_created or not os.path.exists(fname): pmesh.write(fname, poly_type) @@ -493,14 +656,11 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # ----------------------------------------------------------------------- # # Create Triangular Mesh # # ----------------------------------------------------------------------- # - raster = mesher == 'raster' if raster: tri_basename = 'rastermesh.txt' else: tri_basename = 'trimesh.txt' tri_filename = os.path.join(directory, tri_basename) - exts = {'abaqus': '.inp', 'txt': '.txt', 'str': '.txt', 'tet/tri': '', - 'vtk': '.vtk'} if restart and os.path.exists(tri_filename) and not poly_created: # Read triangle mesh @@ -530,12 +690,8 @@ def run(phases, domain, verbose=False, restart=True, directory='.', mesh_max_edge_length, mesh_size) # Write triangular mesh - tri_types = filetypes.get('tri', []) - if type(tri_types) != list: - tri_types = [tri_types] - for tri_type in tri_types: - fname = tri_filename.replace('.txt', exts[tri_type]) + fname = os.path.splitext(tri_filename)[0] + _tri_exts[tri_type] if tri_created or not os.path.exists(fname): tmesh.write(fname, tri_type, seeds, pmesh) @@ -645,7 +801,9 @@ def run(phases, domain, verbose=False, restart=True, directory='.', # Created Unpositioned List of Seeds # # # # --------------------------------------------------------------------------- # -def _unpositioned_seeds(phases, domain, rng_seeds={}): +def _unpositioned_seeds(phases, domain, rng_seeds=None): + if rng_seeds is None: + rng_seeds = {} if domain.n_dim == 2: dom_vol = domain.area else: @@ -684,7 +842,6 @@ def plot_seeds(seeds, phases, domain, plot_files=[], plot_axes=True, :meth:`.SeedList.plot`. """ - print('plot files seeds', plot_files) if not plot_files: plot_files = ['seeds.png'] @@ -703,7 +860,8 @@ def plot_seeds(seeds, phases, domain, plot_files=[], plot_axes=True, plt.clf() plt.close('all') fig = plt.figure() - ax = fig.add_subplot(projection={2: None, 3: Axes3D.name}[n_dim], label='seeds') + projection = {2: None, 3: Axes3D.name}[n_dim] + ax = fig.add_subplot(projection=projection, label='seeds') if not plot_axes: if n_dim == 2: @@ -738,7 +896,8 @@ def plot_seeds(seeds, phases, domain, plot_files=[], plot_axes=True, for fname in plot_files: if n_dim == 3: _misc.axisEqual3D(ax) - plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, wspace=0, hspace=0) + plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, + wspace=0, hspace=0) plt.savefig(fname) else: plt.savefig(fname, bbox_inches='tight', pad_inches=0) @@ -751,10 +910,10 @@ def _seed_colors(seeds, phases, color_by='material', colormap='viridis'): return [_phase_color(s.phase, phases) for s in seeds] elif color_by == 'seed number': n = len(seeds) - return [_cm_color(i / (n - 1), colormap) for i in range(n)] + return [_cm_color(_cm_frac(i, n), colormap) for i in range(n)] elif color_by == 'material number': n = len(phases) - return [_cm_color(s.phase / (n - 1), colormap) for s in seeds] + return [_cm_color(_cm_frac(s.phase, n), colormap) for s in seeds] def _phase_color(i, phases): @@ -766,7 +925,12 @@ def _phase_color_by(i, phases, color_by='material', colormap='viridis'): return phases[i].get('color', 'C' + str(i % 10)) elif color_by == 'material number': n = len(phases) - return _cm_color(i / (n - 1), colormap) + return _cm_color(_cm_frac(i, n), colormap) + + +def _cm_frac(i, n): + """Position of item i of n in the colormap, in [0, 1]""" + return i / max(n - 1, 1) def _cm_color(f, colormap='viridis'): @@ -824,7 +988,8 @@ def plot_poly(pmesh, phases, plot_files=['polymesh.png'], plot_axes=True, plt.clf() plt.close('all') fig = plt.figure() - ax = fig.add_subplot(projection={2: None, 3: Axes3D.name}[n_dim], label='poly') + projection = {2: None, 3: Axes3D.name}[n_dim] + ax = fig.add_subplot(projection=projection, label='poly') if not plot_axes: if n_dim == 2: @@ -842,7 +1007,10 @@ def plot_poly(pmesh, phases, plot_files=['polymesh.png'], plot_axes=True, else: pmesh.plot(facecolors=fcs) + # The edge color is applied per facet below, so remove both the + # plural and the singular matplotlib keywords from the pass-through. edge_color = edge_kwargs.pop('edgecolors', (0, 0, 0, 1)) + edge_color = edge_kwargs.pop('edgecolor', edge_color) facet_colors = [] for neigh_pair in pmesh.facet_neighbors: if facet_check(neigh_pair, pmesh, phases): @@ -864,7 +1032,8 @@ def plot_poly(pmesh, phases, plot_files=['polymesh.png'], plot_axes=True, for fname in plot_files: if n_dim == 3: _misc.axisEqual3D(ax) - plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, wspace=0, hspace=0) + plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, + wspace=0, hspace=0) plt.savefig(fname) else: plt.tight_layout() @@ -878,11 +1047,11 @@ def _poly_colors(pmesh, phases, color_by, colormap, n_dim): r_colors = [_phase_color(n, phases) for n in pmesh.phase_numbers] elif color_by == 'seed number': n = max(pmesh.seed_numbers) + 1 - r_colors = [_cm_color(s / (n - 1), colormap) for s in + r_colors = [_cm_color(_cm_frac(s, n), colormap) for s in pmesh.seed_numbers] elif color_by == 'material number': n = len(phases) - r_colors = [_cm_color(p / (n - 1), colormap) for p in + r_colors = [_cm_color(_cm_frac(p, n), colormap) for p in pmesh.phase_numbers] n_seeds = max(pmesh.seed_numbers) + 1 s_colors = ['none' for i in range(n_seeds)] @@ -898,10 +1067,10 @@ def _poly_colors(pmesh, phases, color_by, colormap, n_dim): phase_num = s2p[s] color = _phase_color(phase_num, phases) elif color_by == 'seed number': - color = _cm_color(s / (n - 1), colormap) + color = _cm_color(_cm_frac(s, n), colormap) elif color_by == 'material number': n_phases = len(phases) - color = _cm_color(s2p[s] / (n_phases - 1), colormap) + color = _cm_color(_cm_frac(s2p[s], n_phases), colormap) else: color = 'none' colors.append(color) @@ -960,7 +1129,8 @@ def plot_tri(tmesh, phases, seeds, pmesh, plot_files=[], plot_axes=True, plt.clf() plt.close('all') fig = plt.figure() - ax = fig.add_subplot(projection={2: None, 3: Axes3D.name}[n_dim], label='tri') + projection = {2: None, 3: Axes3D.name}[n_dim] + ax = fig.add_subplot(projection=projection, label='tri') if not plot_axes: if n_dim == 2: @@ -970,40 +1140,23 @@ def plot_tri(tmesh, phases, seeds, pmesh, plot_files=[], plot_axes=True, else: ax._axis3don = False - # Determine which facets are visible - vis_regions = set() + # Determine which regions are visible invis_regions = set(range(-6, 0)) - f_front = set([i for i, fn in enumerate(pmesh.facet_neighbors) - if min(fn) < 0]) - while f_front and n_dim > 2: - new_front = set() - for f in f_front: - neighs = set(pmesh.facet_neighbors[f]) - for n in neighs - invis_regions: - p = pmesh.phase_numbers[n] - p_type = phases[p].get('material_type', 'solid') - if p_type in _misc.kw_void: - new_front |= set(pmesh.regions[n]) - else: - vis_regions.add(n) - new_front -= f_front - f_front = new_front - if n_dim < 3: - vis_regions = set(range(len(pmesh.regions))) + vis_regions = _visible_regions(pmesh, phases) # Determine facet colors based on visibility seed_colors = _seed_colors(seeds, phases, color_by, colormap) facet_colors = [] facet_phases = [] - for i, fn in enumerate(pmesh.facet_neighbors): - if _f_plottable(fn, vis_regions, invis_regions): - r = list(set(fn) - invis_regions)[0] + for fn in pmesh.facet_neighbors: + r = _visible_neighbor(fn, vis_regions, invis_regions) + if r is None: + color = 'none' + phase = -1 + else: s = pmesh.seed_numbers[r] color = seed_colors[s] phase = seeds[s].phase - else: - color = 'none' - phase = -1 facet_colors.append(color) facet_phases.append(phase) @@ -1042,7 +1195,8 @@ def plot_tri(tmesh, phases, seeds, pmesh, plot_files=[], plot_axes=True, for fname in plot_files: if n_dim == 3: _misc.axisEqual3D(ax) - plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, wspace=0, hspace=0) + plt.subplots_adjust(left=0, bottom=.05, right=1, top=1, + wspace=0, hspace=0) plt.savefig(fname) else: plt.tight_layout() @@ -1051,6 +1205,65 @@ def plot_tri(tmesh, phases, seeds, pmesh, plot_files=[], plot_axes=True, plt.close('all') +def _visible_regions(pmesh, phases): + """Determine the regions visible from outside the domain + + In 3D, the exterior facets are walked inward: a non-void region behind + a facet is visible, while the facets of a void region are added to the + front, since the regions behind a void can be seen through it. + Each facet is visited at most once, so the walk always terminates, + even when a void region touches the domain boundary. + In 2D, all regions are visible. + + Args: + pmesh (PolyMesh): Polygonal/polyhedral mesh. + phases (list): List of phase dictionaries. + + Returns: + set: Numbers of the visible regions. + + """ + n_dim = len(pmesh.points[0]) + if n_dim < 3: + return set(range(len(pmesh.regions))) + + vis_regions = set() + checked_regions = set() + front = set([i for i, fn in enumerate(pmesh.facet_neighbors) + if min(fn) < 0]) + visited = set(front) + while front: + new_front = set() + for f in front: + for n in pmesh.facet_neighbors[f]: + if n < 0 or n in checked_regions: + continue + checked_regions.add(n) + p = pmesh.phase_numbers[n] + p_type = phases[p].get('material_type', 'solid') + if p_type in _misc.kw_void: + new_front |= set(pmesh.regions[n]) - visited + else: + vis_regions.add(n) + visited |= new_front + front = new_front + return vis_regions + + +def _visible_neighbor(n_pair, vis, invis): + """Visible (non-void, non-wall) region on either side of a facet + + Returns None if the facet is not plottable or neither neighbor + is a visible region. + """ + if not _f_plottable(n_pair, vis, invis): + return None + for n in n_pair: + if n in vis: + return n + return None + + def _f_plottable(n_pair, vis, invis): if set(n_pair) <= vis or set(n_pair) <= invis: return False @@ -1077,11 +1290,11 @@ def dict_convert(dictionary, filepath='.'): First, if the value of ``dist_type`` is ``cdf``, then the remaining key should be ``filename`` and its value should be the path to a CSV file, where each row contains the (x, CDF) points along the CDF curve. - Second, if the value of ``dist_type`` is ``histogram``, then the remaining - key should also be ``filename`` and its value should be the path to a CSV - file. - For the histogram, the first row of this CDF should be the *n* bin heights - and the second row should be the *n+1* bin locations. + Second, if the value of ``dist_type`` is ``histogram`` (or its alias + ``pdf``), then the remaining key should also be ``filename`` and its value + should be the path to a CSV file. + For the histogram, the first row of this file should be the *n* bin + heights and the second row should be the *n+1* bin locations. Additionally, if a key in the dictionary contains ``filename`` or ``directory`` and the value associated with that key is a relative path, @@ -1105,7 +1318,7 @@ def dict_convert(dictionary, filepath='.'): # Convert lists if isinstance(dictionary, list): - return [dict_convert(d) for d in dictionary] + return [dict_convert(d, filepath) for d in dictionary] # Convert strings if isinstance(dictionary, str): @@ -1150,23 +1363,46 @@ def _dist_convert(dist_dict): del params['dist_type'] if dist_type == 'cdf': - cdf_filename = params['filename'] - with open(cdf_filename, 'r') as file: - cdf = [[float(s) for s in line.split(',')] for line in file] + cdf = _read_csv(params['filename']) bin_bnds = [x for x, _ in cdf] bin_cnts = [cdf[i + 1][1] - cdf[i][1] for i in range(len(cdf) - 1)] - return scipy.stats.rv_histogram(tuple([bin_cnts, bin_bnds])) + return _rv_histogram(bin_cnts, bin_bnds, density=False) - elif dist_type == 'histogram': - hist_filename = params['filename'] - with open(hist_filename, 'r') as file: - hist = [[float(s) for s in line.split(',')] for line in file] - return scipy.stats.rv_histogram(tuple(hist)) + elif dist_type in ('histogram', 'pdf'): + bin_hgts, bin_bnds = _read_csv(params['filename']) + return _rv_histogram(bin_hgts, bin_bnds, density=True) else: return scipy.stats.__dict__[dist_type](**params) +def _read_csv(filename): + """Read the numbers in a CSV file, skipping blank lines""" + with open(filename, 'r') as file: + lines = [line for line in file if line.strip()] + return [[float(s) for s in line.split(',')] for line in lines] + + +def _rv_histogram(bin_vals, bin_bnds, density): + """Histogram distribution from bin values and boundaries + + The CDF increments of a ``cdf`` file are probability masses, so they + are passed with ``density=False``; otherwise SciPy would re-weight the + bins by their widths whenever the bin boundaries are not evenly spaced. + The bin heights of a ``pdf``/``histogram`` file are densities. + """ + hist = (list(bin_vals), list(bin_bnds)) + try: + return scipy.stats.rv_histogram(hist, density=density) + except TypeError: + # SciPy < 1.11 has no density keyword and treats the values as + # densities, so masses are converted to densities beforehand. + if not density: + widths = np.diff(np.array(bin_bnds, dtype='float')) + hist = (list(np.array(bin_vals, dtype='float') / widths), hist[1]) + return scipy.stats.rv_histogram(hist) + + if __name__ == '__main__': main() diff --git a/src/microstructpy/examples/pbx_2D.xml b/src/microstructpy/examples/pbx_2D.xml new file mode 100644 index 00000000..5a9a930f --- /dev/null +++ b/src/microstructpy/examples/pbx_2D.xml @@ -0,0 +1,67 @@ + + + + Binder + matrix + 35 + circle + + uniform + 0.08 + 0.08 + + lightgray + + + + Inclusions + crystalline + 65 + circle + + lognorm + 0.35 + 0.35 + + darkorange + + + + square + 3 + (0, 0) + True + + + + pbx_2D + True + + 1 + 1 + + + + 0.5 + + + auto + True + 25 + + 25 + 0.01 + + + 0.3 + + + 0.3 + + + 0.15 + + + diff --git a/src/microstructpy/examples/pbx_3D.xml b/src/microstructpy/examples/pbx_3D.xml new file mode 100644 index 00000000..489f50c6 --- /dev/null +++ b/src/microstructpy/examples/pbx_3D.xml @@ -0,0 +1,67 @@ + + + + Binder + matrix + 35 + sphere + + uniform + 0.45 + 0.25 + + lightgray + + + + Inclusions + crystalline + 65 + sphere + + lognorm + 0.8 + 0.3 + + darkorange + + + + cube + 3 + (0, 0, 0) + True + + + + pbx_3D + True + + 1 + 1 + + + + 0.7 + + + auto + True + 10 + + 15 + 0.02 + + + 0.2 + + + 0.2 + + + 0.1 + + + diff --git a/src/microstructpy/examples/pbx_interface_2D.xml b/src/microstructpy/examples/pbx_interface_2D.xml new file mode 100644 index 00000000..d1c9a1ac --- /dev/null +++ b/src/microstructpy/examples/pbx_interface_2D.xml @@ -0,0 +1,71 @@ + + + + Binder + matrix + 35 + circle + + uniform + 0.08 + 0.08 + + lightgray + + + + Inclusions + crystalline + 65 + circle + + lognorm + 0.35 + 0.35 + + darkorange + + + + square + 3 + (0, 0) + True + + + + pbx_interface_2D + True + + 1 + 1 + + + + 0.5 + + + auto + True + 25 + + + 25 + 0.03 + 0.02 + + + 0.3 + + + 0.3 + + + 0.15 + + + diff --git a/src/microstructpy/examples/pbx_interface_3D.xml b/src/microstructpy/examples/pbx_interface_3D.xml new file mode 100644 index 00000000..a5962531 --- /dev/null +++ b/src/microstructpy/examples/pbx_interface_3D.xml @@ -0,0 +1,71 @@ + + + + Binder + matrix + 35 + sphere + + uniform + 0.45 + 0.25 + + lightgray + + + + Inclusions + crystalline + 65 + sphere + + lognorm + 0.8 + 0.3 + + darkorange + + + + cube + 3 + (0, 0, 0) + True + + + + pbx_interface_3D + True + + 1 + 1 + + + + 0.7 + + + auto + True + 10 + + + 15 + 0.2 + 0.05 + + + 0.2 + + + 0.2 + + + 0.1 + + + diff --git a/src/microstructpy/examples/periodic_2D.xml b/src/microstructpy/examples/periodic_2D.xml new file mode 100644 index 00000000..68f79f8b --- /dev/null +++ b/src/microstructpy/examples/periodic_2D.xml @@ -0,0 +1,68 @@ + + + + Grains + 2 + circle + + uniform + 0.2 + 0.2 + + + + + Inclusions + 1 + ellipse + + uniform + 0.25 + 0.15 + + 2 + + uniform + 0 + 180 + + + + + square + 2 + (0, 0) + + True + + + + periodic_2D + True + + 1 + 1 + + + + auto + + + True + 25 + + 25 + 0.004 + + seed number + tab20 + + 0.2 + + + diff --git a/src/microstructpy/examples/periodic_3D.xml b/src/microstructpy/examples/periodic_3D.xml new file mode 100644 index 00000000..5f414df3 --- /dev/null +++ b/src/microstructpy/examples/periodic_3D.xml @@ -0,0 +1,59 @@ + + + + Phase 1 + 1 + sphere + + uniform + 0.8 + 0.6 + + + + + Phase 2 + 1 + sphere + 1.2 + + + + cube + 4 + (0, 0, 0) + + xyz + + + + periodic_3D + True + + 2 + 2 + + + + auto + True + 10 + + 15 + 0.02 + + seed number + tab20 + + 0.2 + + + 0.2 + + + 0.1 + + + diff --git a/src/microstructpy/examples/periodic_tiling.py b/src/microstructpy/examples/periodic_tiling.py new file mode 100644 index 00000000..0bc417a1 --- /dev/null +++ b/src/microstructpy/examples/periodic_tiling.py @@ -0,0 +1,192 @@ +from __future__ import division + +import os + +import numpy as np +import scipy.stats +from matplotlib import collections +from matplotlib import pyplot as plt +from mpl_toolkits.mplot3d.art3d import Poly3DCollection + +import microstructpy as msp + +# ------------------------------------------------------------------------ # +# # +# 2D: a periodic microstructure and its 2 x 2 tiling # +# # +# ------------------------------------------------------------------------ # + +# Create domain +domain_2d = msp.geometry.Square(side_length=2, corner=(0, 0)) + +# Create phases: a matrix of circular grains and elliptical inclusions +phases_2d = [ + {'shape': 'circle', 'size': scipy.stats.uniform(loc=0.2, scale=0.2), + 'material_type': 'crystalline', 'fraction': 2}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(loc=0.25, scale=0.15), + 'aspect_ratio': 2, 'angle_deg': scipy.stats.uniform(loc=0, scale=180), + 'material_type': 'crystalline', 'fraction': 1}, +] + +# Create seeds and position them, periodic in x and y (the seeds fill 90% +# of the area, so that all of them can be placed). The margin keeps the +# seeds from ending within half a target edge length of a periodic face, +# or crossing one by less, which would leave thin pieces of grains on the +# opposite face and very small triangles there. The smallest grain needs +# about four elements across it, so the margin is at most an eighth of its +# smallest diameter (the CLI setting periodic_margin = auto does the same). +max_volume = 0.004 +h_target = np.sqrt(4 * max_volume / np.sqrt(3)) +seeds_2d = msp.seeding.SeedList.from_info(phases_2d, 0.9 * domain_2d.area, + rng_seeds={'size': 1}) +d_min = min([2 * min(getattr(s.geometry, 'axes', (s.geometry.size / 2,))) + for s in seeds_2d]) +margin = min(0.5 * h_target, d_min / 8) +seeds_2d.position(domain_2d, rng_seed=1, periodic=True, + periodic_margin=margin) + +# Create the polygonal and triangular meshes. The edge optimization moves +# the seeds slightly to remove the shortest edges of the polygonal mesh, +# which would otherwise force very small triangles in the mesh; with the +# margin, it also thickens or removes the pieces of the grains at the +# periodic faces that are thinner than the margin, and opens the corners +# of the grains at the faces that are narrower than the minimum angle of +# the triangles, which the mesher would fill with very small triangles. +min_angle = 25 +pmesh_2d = msp.meshing.PolyMesh.from_seeds(seeds_2d, domain_2d, + periodic=True, edge_opt=True, + n_iter=25, periodic_margin=margin, + min_angle=min_angle) +tmesh_2d = msp.meshing.TriMesh.from_polymesh(pmesh_2d, phases_2d, + min_angle=min_angle, + max_volume=max_volume) + +# Plot the tiled polygonal mesh, with each grain in one color, and the +# tiled triangular mesh, with the matching nodes on the periodic faces +pts = np.array(pmesh_2d.points) +cmap = plt.get_cmap('tab20') +colors = [cmap(s % 20) for s in pmesh_2d.seed_numbers] +tile_x, tile_y = domain_2d.side_length, domain_2d.side_length +offsets = [(i * tile_x, j * tile_y) for i in (0, 1) for j in (0, 1)] + + +def cell_polygon(region): + # the points of a (convex) cell, in order around its center + kps = sorted(set([kp for f in region for kp in pmesh_2d.facets[f]])) + center = pts[kps].mean(axis=0) + angles = np.arctan2(pts[kps, 1] - center[1], pts[kps, 0] - center[0]) + return pts[np.array(kps)[np.argsort(angles)]] + + +# the cells, and the grain boundaries (an elliptical grain is made of +# several cells, whose common facets are not grain boundaries) +polys = [cell_polygon(region) for region in pmesh_2d.regions] +seed_nums = np.array(pmesh_2d.seed_numbers) +boundaries = [] +for facet, (n_1, n_2) in zip(pmesh_2d.facets, pmesh_2d.facet_neighbors): + if min(n_1, n_2) < 0 or seed_nums[n_1] != seed_nums[n_2]: + boundaries.append(pts[facet]) +fig, axes = plt.subplots(1, 2, figsize=(12, 6)) +for offset in offsets: + axes[0].add_collection(collections.PolyCollection( + [poly + offset for poly in polys], facecolors=colors, + edgecolors=colors, linewidths=0.5)) + axes[0].add_collection(collections.LineCollection( + [line + offset for line in boundaries], colors='k', linewidths=0.6)) +axes[0].set_title('Polygonal mesh, tiled 2 x 2') + +t_pts = np.array(tmesh_2d.points) +t_elems = np.array(tmesh_2d.elements) +for offset in offsets: + axes[1].triplot(t_pts[:, 0] + offset[0], t_pts[:, 1] + offset[1], + t_elems, color='0.3', linewidth=0.25) +lows = [lo for lo, hi in tmesh_2d.periodic_nodes[0]] +highs = [hi for lo, hi in tmesh_2d.periodic_nodes[0]] +axes[1].plot(t_pts[lows, 0], t_pts[lows, 1], 'r.', markersize=4, + label='nodes on x = 0') +axes[1].plot(t_pts[highs, 0], t_pts[highs, 1], 'b.', markersize=4, + label='their images on x = 2') +axes[1].legend(loc='upper right') +axes[1].set_title('Triangular mesh, tiled 2 x 2') + +for ax in axes: + ax.axvline(tile_x, color='w', linewidth=1.2) + ax.axhline(tile_y, color='w', linewidth=1.2) + ax.set_aspect('equal') + ax.set_xlim(0, 2 * tile_x) + ax.set_ylim(0, 2 * tile_y) + +file_dir = os.path.dirname(os.path.realpath(__file__)) +out_dir = os.path.join(file_dir, 'periodic_tiling') +if not os.path.exists(out_dir): + os.makedirs(out_dir) +plt.savefig(os.path.join(out_dir, 'tiled_2D.png'), bbox_inches='tight', + pad_inches=0.1) +plt.close(fig) + +# ------------------------------------------------------------------------ # +# # +# 3D: a periodic microstructure and its 2 x 1 x 1 tiling # +# # +# ------------------------------------------------------------------------ # + +# Create domain +side = 4 +domain_3d = msp.geometry.Cube(side_length=side, corner=(0, 0, 0)) + +# Create phases +phases_3d = [ + {'shape': 'sphere', 'size': scipy.stats.uniform(loc=0.8, scale=0.6), + 'fraction': 1}, + {'shape': 'sphere', 'size': 1.2, 'fraction': 1}, +] + +# Create seeds, position them and mesh the domain, periodic in x, y and z +# (the seeds fill 90% of the volume, so that all of them can be placed) +seeds_3d = msp.seeding.SeedList.from_info(phases_3d, 0.9 * domain_3d.volume, + rng_seeds={'size': 2}) +seeds_3d.position(domain_3d, rng_seed=2, periodic=True) +pmesh_3d = msp.meshing.PolyMesh.from_seeds(seeds_3d, domain_3d, + periodic=True) +tmesh_3d = msp.meshing.TriMesh.from_polymesh(pmesh_3d, phases_3d, + min_angle=15, max_volume=0.02) + +# The nodes on opposite faces of the domain are images of each other +for axis, pairs in sorted(tmesh_3d.periodic_nodes.items()): + print('axis ' + 'xyz'[axis] + ': ' + str(len(pairs)) + ' pairs of nodes') + +# Plot the faces of the polyhedral mesh that are visible from the viewpoint +# (y = 0, x = side and z = side), for the domain and for a copy translated +# along x: the grains continue across the periodic face +pts = np.array(pmesh_3d.points) +colors = [cmap(s % 20) for s in pmesh_3d.seed_numbers] +visible = {-3: 0.85, -2: 0.7, -6: 1.0} # wall number: shading + + +def wall_polygons(offset, walls): + polys, facecolors = [], [] + for facet, neighs in zip(pmesh_3d.facets, pmesh_3d.facet_neighbors): + wall = min(neighs) + if wall in walls: + region = max(neighs) + polys.append(pts[facet] + offset) + facecolors.append(np.array(colors[region]) * walls[wall]) + return polys, facecolors + + +fig = plt.figure(figsize=(12, 6)) +ax = fig.add_subplot(projection='3d') +ax.set_position([0, 0, 1, 1]) +polys, facecolors = wall_polygons(np.zeros(3), {-3: 0.85, -6: 1.0}) +polys_2, facecolors_2 = wall_polygons(np.array([side, 0, 0]), visible) +ax.add_collection3d(Poly3DCollection(polys + polys_2, + facecolors=facecolors + facecolors_2, + edgecolors='k', linewidths=0.3)) +ax.set_xlim(0, 2 * side) +ax.set_ylim(0, side) +ax.set_zlim(0, side) +ax.set_box_aspect((2, 1, 1)) +ax.view_init(elev=22, azim=-55) +ax.set_title('Polyhedral mesh, tiled twice along x') +plt.savefig(os.path.join(out_dir, 'tiled_3D.png'), bbox_inches='tight', + pad_inches=0.1) diff --git a/src/microstructpy/geometry/ellipse.py b/src/microstructpy/geometry/ellipse.py index 05013765..93300bd1 100644 --- a/src/microstructpy/geometry/ellipse.py +++ b/src/microstructpy/geometry/ellipse.py @@ -53,7 +53,7 @@ def __init__(self, **kwargs): if kw in kwargs and kwargs[kw] <= 0: raise ValueError(kw + ' should be positive.') if 'axes' in kwargs: - for i, ax in kwargs['axes']: + for i, ax in enumerate(kwargs['axes']): if ax <= 0: raise ValueError('axes[{}] should be positive'.format(i)) @@ -62,7 +62,7 @@ def __init__(self, **kwargs): m = np.array(kwargs[kw]) if m.shape != (2, 2): raise ValueError(kw + ' should be 2x2.') - if not np.all(np.isclose(m * m.T, np.eye(2))): + if not np.all(np.isclose(m.dot(m.T), np.eye(2))): raise ValueError(kw + ' should be orthonormal.') # position @@ -252,6 +252,24 @@ def __repr__(self): repr_str += ')' return repr_str + # ----------------------------------------------------------------------- # + # Equality # + # ----------------------------------------------------------------------- # + def __eq__(self, other): + if not isinstance(other, Ellipse): + return False + c1 = np.array(self.center, dtype='float') + c2 = np.array(other.center, dtype='float') + if c1.shape != c2.shape or not np.allclose(c1, c2): + return False + if not np.allclose([self.a, self.b], [other.a, other.b]): + return False + d_ang = (self.angle - other.angle + 180) % 360 - 180 + return np.isclose(d_ang, 0) + + def __ne__(self, other): + return not self.__eq__(other) + # ----------------------------------------------------------------------- # # Size and Orientation Getters # # ----------------------------------------------------------------------- # @@ -368,10 +386,10 @@ def area_expectation(cls, **kwargs): """ # NOQA: E501 if 'size' in kwargs: s_dist = kwargs['size'] - - if type(s_dist) in (float, int): + try: + return 0.25 * np.pi * s_dist.moment(2) + except AttributeError: return 0.25 * np.pi * s_dist * s_dist - return 0.25 * np.pi * s_dist.moment(2) if 'area' in kwargs: a_dist = kwargs['area'] @@ -381,16 +399,11 @@ def area_expectation(cls, **kwargs): a_exp = a_dist return a_exp + if 'axes' in kwargs: + return np.pi * _prod_exp(*kwargs['axes']) + if ('a' in kwargs) and ('b' in kwargs): - exp = np.pi - for kw in ('a', 'b'): - dist = kwargs[kw] - if type(dist) in (float, int): - mu = dist - else: - mu = dist.moment(1) - exp *= mu - return exp + return np.pi * _prod_exp(kwargs['a'], kwargs['b']) if ('b' in kwargs) and ('aspect_ratio' in kwargs): exp = np.pi @@ -407,13 +420,14 @@ def area_expectation(cls, **kwargs): if ('a' in kwargs) and ('aspect_ratio' in kwargs): n = 1000 + rng = np.random.RandomState(0) try: - a = kwargs['a'].rvs(size=n) + a = kwargs['a'].rvs(size=n, random_state=rng) except AttributeError: a = np.full(n, kwargs['a']) try: - k = kwargs['aspect_ratio'].rvs(size=n) + k = kwargs['aspect_ratio'].rvs(size=n, random_state=rng) except AttributeError: k = np.full(n, kwargs['aspect_ratio']) return np.pi * np.mean((a * a) / k) @@ -689,11 +703,24 @@ def reflect(self, points): new_dist = 2 - dist[mask] scl = new_dist / dist[mask] - new_scl_pos = scl_pos[mask] * scl - new_rel_pos = new_scl_pos.dot(self.orientation.T) + new_scl_pos = scl_pos[mask] * scl.reshape(-1, 1) + new_rot_pos = new_scl_pos * np.array(self.axes).reshape(1, -1) + new_rel_pos = new_rot_pos.dot(self.orientation.T) new_pos = new_rel_pos + np.array(self.center) if single_pt: return new_pos[0] else: return new_pos + + +def _prod_exp(*args): + """Product of the expected values of constants and distributions.""" + prod = 1 + for arg in args: + try: + arg_mu = arg.moment(1) + except AttributeError: + arg_mu = arg + prod *= arg_mu + return prod diff --git a/src/microstructpy/geometry/ellipsoid.py b/src/microstructpy/geometry/ellipsoid.py index d767e0d3..6fd08c0c 100644 --- a/src/microstructpy/geometry/ellipsoid.py +++ b/src/microstructpy/geometry/ellipsoid.py @@ -137,7 +137,7 @@ def __init__(self, **kwargs): if (ratio_ab is not None) and (self.a is not None): self.b = self.a / ratio_ab elif (ratio_bc is not None) and (self.c is not None): - self.b = ratio_bc * self.a + self.b = ratio_bc * self.c if self.c is None: if (ratio_ac is not None) and (self.a is not None): @@ -338,13 +338,10 @@ def __str__(self): str_str += 'b: ' + str(self.b) + '\n' str_str += 'c: ' + str(self.c) if len(self.rot_seq) > 0: - str_str += '\nrot_seq: (' - for i, (ax, ang) in enumerate(self.rot_seq): - str_str += '(' + str(ax) + ', ' + str(ang) + ')' - if i < len(self.rot_seq) - 1: - str_str += ', ' - else: - str_str += ')' + # axis names must be quoted so that the string can be parsed + # back (see Seed.from_str) + rot_seq = tuple([(ax, float(ang)) for ax, ang in self.rot_seq]) + str_str += '\nrot_seq: ' + repr(rot_seq) return str_str def __repr__(self): @@ -356,6 +353,23 @@ def __repr__(self): repr_str += ')' return repr_str + # ----------------------------------------------------------------------- # + # Equality # + # ----------------------------------------------------------------------- # + def __eq__(self, other): + if not isinstance(other, Ellipsoid): + return False + c1 = np.array(self.center, dtype='float') + c2 = np.array(other.center, dtype='float') + if c1.shape != c2.shape or not np.allclose(c1, c2): + return False + if not np.allclose(self.axes, other.axes): + return False + return np.allclose(self.matrix, other.matrix) + + def __ne__(self, other): + return not self.__eq__(other) + # ----------------------------------------------------------------------- # # Size and Orientation Getters # # ----------------------------------------------------------------------- # @@ -531,9 +545,10 @@ def volume_expectation(cls, **kwargs): # Check for size distribution if 'size' in kwargs: s_dist = kwargs['size'] - if type(s_dist) in (float, int): + try: + return 0.5 * np.pi * s_dist.moment(3) / 3 + except AttributeError: return 0.5 * np.pi * s_dist * s_dist * s_dist / 3 - return 0.5 * np.pi * s_dist.moment(3) / 3 if 'volume' in kwargs: v_dist = kwargs['volume'] @@ -559,14 +574,15 @@ def volume_expectation(cls, **kwargs): # Use Monte Carlo to determine expected volume n_trials = 1000 - kws = set(kwargs.keys()) - set(_misc.ori_kws) + kws = sorted(set(kwargs.keys()) - set(_misc.ori_kws)) + rng = np.random.RandomState(0) total_vol = 0 for i in range(n_trials): params = {} for kw in kws: try: - params[kw] = kwargs[kw].rvs() + params[kw] = kwargs[kw].rvs(random_state=rng) except AttributeError: params[kw] = kwargs[kw] total_vol += Ellipsoid(**params).volume @@ -635,12 +651,17 @@ def approximate(self, x1=None): a = self.c b = self.a c = self.b - inds = [0, 2, 1] - else: + inds = [1, 2, 0] + elif (self.c >= self.b) and (self.b >= self.a): a = self.c b = self.b c = self.a inds = [2, 1, 0] + else: # (self.b >= self.c) and (self.c >= self.a) + a = self.b + b = self.c + c = self.a + inds = [2, 0, 1] # Prolate Ellipsoid if np.isclose(b, c): @@ -761,7 +782,7 @@ def plot(self, **kwargs): mod_kwargs = {} for key, val in kwargs.items(): - if key == 'facecolors' and type(val) != list: + if key == 'facecolors' and not isinstance(val, list): mod_kwargs['color'] = val else: mod_kwargs[key] = val @@ -773,26 +794,13 @@ def plot(self, **kwargs): @property def limits(self): """list: List of (lower, upper) bounds for the bounding box""" - if np.all(np.isclose(self.matrix, np.eye(3))): - ax = np.array(self.axes) - cen = np.array(self.center) - return [(x - r, x + r) for x, r in zip(cen, ax)] - - n = 4 - u = np.linspace(0, 2 * np.pi, 1 + 4 * n) - cv = np.linspace(-1, 1, 1 + 2 * n) - uu, cvv = np.meshgrid(u, cv) - svv = np.sin(np.arccos(cvv)) - - xp = self.a * np.cos(uu) * svv - yp = self.b * np.sin(uu) * svv - zp = self.c * cvv - - pts = np.array([xp.flatten(), yp.flatten(), zp.flatten()]) - r_pts = self.matrix.dot(pts) - lbs = r_pts.min(axis=-1) + np.array(self.center) - ubs = r_pts.max(axis=-1) + np.array(self.center) - return list(zip(lbs, ubs)) + # The i-th coordinate of a surface point is sum_j R_ij a_j u_j with + # |u| = 1, so the half-extent along axis i is the 2-norm of the + # vector (R_ij a_j)_j. + scl_mat = np.array(self.matrix) * np.array(self.axes).reshape(1, -1) + half = np.sqrt(np.sum(scl_mat * scl_mat, axis=1)) + cen = np.array(self.center) + return [(x - r, x + r) for x, r in zip(cen, half)] @property def sample_limits(self): @@ -867,8 +875,9 @@ def reflect(self, points): new_dist = 2 - dist[mask] scl = new_dist / dist[mask] - new_scl_pos = scl_pos[mask] * scl - new_rel_pos = new_scl_pos.dot(self.orientation.T) + new_scl_pos = scl_pos[mask] * scl.reshape(-1, 1) + new_rot_pos = new_scl_pos * np.array(self.axes).reshape(1, -1) + new_rel_pos = new_rot_pos.dot(self.orientation.T) new_pos = new_rel_pos + np.array(self.center) if single_pt: diff --git a/src/microstructpy/geometry/n_box.py b/src/microstructpy/geometry/n_box.py index 80598a81..ef72fcf4 100644 --- a/src/microstructpy/geometry/n_box.py +++ b/src/microstructpy/geometry/n_box.py @@ -88,21 +88,37 @@ def __init__(self, **kwargs): # String and Representation Functions # # ----------------------------------------------------------------------- # def __str__(self): - cen = np.array(self.center) - sides = np.array(self.side_lengths) - cen_str = np.array2string(cen, separator=', ') - sides_str = np.array2string(sides, separator=', ') + # full precision, in a form that ast.literal_eval can parse back + cen_str = repr(tuple([float(x) for x in self.center])) + sides_str = repr(tuple([float(x) for x in self.side_lengths])) str_str = 'Center: ' + cen_str + '\n' str_str += 'Side Lengths: ' + sides_str + '\n' str_str += 'Matrix: (' for row in self.matrix: str_str += '(' - str_str += ', '.join([str(val) for val in row]) + str_str += ', '.join([repr(float(val)) for val in row]) str_str += '),' str_str = str_str[:-1] + ')' return str_str + # ----------------------------------------------------------------------- # + # Equality # + # ----------------------------------------------------------------------- # + def __eq__(self, other): + if not isinstance(other, NBox): + return False + c1 = np.array(self.center, dtype='float') + c2 = np.array(other.center, dtype='float') + if c1.shape != c2.shape or not np.allclose(c1, c2): + return False + if not np.allclose(self.side_lengths, other.side_lengths): + return False + return np.allclose(self.matrix, other.matrix) + + def __ne__(self, other): + return not self.__eq__(other) + def __repr__(self): repr_str = 'NBox(' repr_str += 'center=' + repr(tuple(self.center)) + ', ' @@ -187,6 +203,8 @@ def within(self, points): pts = pts.reshape(1, -1) rel_pos = pts - np.array(self.center) + # rotate into the local (box-aligned) frame + rel_pos = rel_pos.dot(np.array(self.matrix)) min_dist = 0.5 * np.array(self.side_lengths) mask = np.all(np.abs(rel_pos) <= min_dist, axis=-1) diff --git a/src/microstructpy/geometry/n_sphere.py b/src/microstructpy/geometry/n_sphere.py index f68b737b..ec588602 100644 --- a/src/microstructpy/geometry/n_sphere.py +++ b/src/microstructpy/geometry/n_sphere.py @@ -78,7 +78,7 @@ def best_fit(cls, points): https://dtcenter.org/met/users/docs/write_ups/circle_fit.pdf """ # NOQA: E501 # convert points to numpy array - pts = np.array(points) + pts = np.array(points, dtype='float') n_pts, n_dim = pts.shape if n_pts <= n_dim: mid = pts.mean(axis=0) @@ -103,7 +103,7 @@ def best_fit(cls, points): # Solve linear system for the center try: cen_b = np.linalg.solve(mat, vec) - except np.linalg.linalg.LinAlgError: + except np.linalg.LinAlgError: cen_b = pts.mean(axis=0) cen = cen_b + bcenter diff --git a/src/microstructpy/geometry/rectangle.py b/src/microstructpy/geometry/rectangle.py index eb31f8e9..b64f09a2 100644 --- a/src/microstructpy/geometry/rectangle.py +++ b/src/microstructpy/geometry/rectangle.py @@ -43,8 +43,10 @@ class Rectangle(NBox): """ def __init__(self, **kwargs): - if 'length' in kwargs and 'width' in kwargs: - kwargs['side_lengths'] = [kwargs['length'], kwargs['width']] + if 'length' in kwargs or 'width' in kwargs: + # consistent with area_expectation, a missing side defaults to 1 + kwargs['side_lengths'] = [kwargs.get('length', 1), + kwargs.get('width', 1)] if 'angle' in kwargs: cp = np.cos(np.radians(kwargs['angle'])) @@ -484,7 +486,7 @@ def area_expectation(cls, **kwargs): area_exp = len_dist * len_dist return area_exp - Rectangle.area_expectation(**kwargs) + return Rectangle.area_expectation(**kwargs) # ----------------------------------------------------------------------- # # Circle Approximation # diff --git a/src/microstructpy/geometry/sphere.py b/src/microstructpy/geometry/sphere.py index a2dd3c5d..c20c6a84 100644 --- a/src/microstructpy/geometry/sphere.py +++ b/src/microstructpy/geometry/sphere.py @@ -115,10 +115,11 @@ def volume_expectation(cls, **kwargs): elif 'r' in kwargs: r_dist = kwargs['r'] - if type(r_dist) in (float, int): - return 4 * np.pi * r_dist * r_dist * r_dist / 3 - elif r_dist is not None: - return 4 * np.pi * r_dist.moment(3) / 3 + if r_dist is not None: + try: + return 4 * np.pi * r_dist.moment(3) / 3 + except AttributeError: + return 4 * np.pi * r_dist * r_dist * r_dist / 3 # Check for diameter distribution d_dist = None @@ -127,10 +128,11 @@ def volume_expectation(cls, **kwargs): d_dist = kwargs[d_kw] break - if type(d_dist) in (float, int): - return 0.5 * np.pi * d_dist * d_dist * d_dist / 3 - elif d_dist is not None: - return 0.5 * np.pi * d_dist.moment(3) / 3 + if d_dist is not None: + try: + return 0.5 * np.pi * d_dist.moment(3) / 3 + except AttributeError: + return 0.5 * np.pi * d_dist * d_dist * d_dist / 3 if 'volume' in kwargs: v_dist = kwargs['volume'] @@ -163,7 +165,7 @@ def plot(self, **kwargs): if plt.gcf().axes: ax = plt.gca() else: - ax = plt.add_subplot(projection=Axes3D.name) + ax = plt.gcf().add_subplot(projection=Axes3D.name) u = np.linspace(0, 2 * np.pi, 11) cv = np.linspace(-1, 1, 12) @@ -179,7 +181,7 @@ def plot(self, **kwargs): mod_kwargs = {} for key, val in kwargs.items(): - if key == 'facecolors' and type(val) != list: + if key == 'facecolors' and not isinstance(val, list): mod_kwargs['color'] = val else: mod_kwargs[key] = val diff --git a/src/microstructpy/meshing/polymesh.py b/src/microstructpy/meshing/polymesh.py index ea7d062f..b1a72dd3 100644 --- a/src/microstructpy/meshing/polymesh.py +++ b/src/microstructpy/meshing/polymesh.py @@ -13,6 +13,7 @@ from __future__ import division from __future__ import print_function +import copy import os import subprocess import sys @@ -26,6 +27,9 @@ from matplotlib import pyplot as plt from mpl_toolkits.mplot3d import Axes3D from mpl_toolkits.mplot3d.art3d import Poly3DCollection +from scipy.spatial import ConvexHull +from scipy.spatial import QhullError +from scipy.spatial import cKDTree from scipy.spatial import distance from microstructpy import _misc @@ -94,12 +98,20 @@ class PolyMesh(object): # Constructors # # ----------------------------------------------------------------------- # def __init__(self, points, facets, regions, seed_numbers=None, - phase_numbers=None, facet_neighbors=None, volumes=None): + phase_numbers=None, facet_neighbors=None, volumes=None, + periodic_axes=None, periodic_points=None, + periodic_facets=None): self.points = points self.facets = facets self.regions = regions + # Periodicity: flags per axis, and the pairs of (low face, high face) + # points and facets that are periodic images of each other, per axis + self.periodic_axes = periodic_axes + self.periodic_points = periodic_points + self.periodic_facets = periodic_facets + if facet_neighbors is None: # Find facet neighbors facet_neighs = [[-1, -1] for _ in facets] @@ -199,12 +211,12 @@ def __repr__(self): def __str__(self): nv = len(self.points) - nd = len(self.points[0]) - pt_fmt = '\t' - pt_fmt += ', '.join(['{pt[' + str(i) + ']: e}' for i in range(nd)]) + # points are written with full precision (repr of a float is the + # shortest string that round-trips exactly) str_str = 'Mesh Points: ' + str(nv) + '\n' - str_str += ''.join([pt_fmt.format(pt=p) + '\n' for p in self.points]) + str_str += ''.join(['\t' + ', '.join([repr(float(x)) for x in p]) + + '\n' for p in self.points]) str_str += 'Mesh Facets: ' + str(len(self.facets)) + '\n' str_str += ''.join(['\t' + str(tuple(f))[1:-1] + '\n' @@ -226,6 +238,18 @@ def __str__(self): str_str += 'Volumes: ' + str(len(self.volumes)) + '\n' str_str += '\n'.join(['\t' + str(v) for v in self.volumes]) + + if self.periodic_axes is not None and any(self.periodic_axes): + flags = [int(bool(f)) for f in self.periodic_axes] + str_str += '\nPeriodic Axes: ' + str(len(flags)) + '\n' + str_str += '\t' + ', '.join([str(f) for f in flags]) + for name, pairs in (('Periodic Points', self.periodic_points), + ('Periodic Facets', self.periodic_facets)): + rows = [(ax, lo, hi) for ax in sorted(pairs or {}) + for lo, hi in pairs[ax]] + str_str += '\n' + name + ': ' + str(len(rows)) + str_str += ''.join(['\n\t' + ', '.join([str(n) for n in row]) + for row in rows]) return str_str # ----------------------------------------------------------------------- # @@ -269,14 +293,17 @@ def write(self, filename, format='txt'): poly += ''.join([' '.join([str(n) for n in (nv + i, k1, k2)]) + '\n' for i, (k1, k2) in enumerate(self.facets)]) + with open(filename, 'w') as f: + f.write(poly) + elif format == 'ply': nv = len(self.points) nd = len(self.points[0]) nf = len(self.facets) nr = len(self.regions) assert nd <= 3 - - # Force 3D points + + # Force 3D points pts = np.zeros((nv, 3)) pts[:, :nd] = self.points axes = ['x', 'y', 'z'] @@ -443,8 +470,17 @@ def from_file(cls, filename): seed_numbers = [] phase_numbers = [] volumes = [] + per_axes = None + per_pts = [] + per_fts = [] for line in file.readlines(): - if 'Mesh Points'.lower() in line.lower(): + if 'Periodic Axes'.lower() in line.lower(): + stage = 'periodic axes' + elif 'Periodic Points'.lower() in line.lower(): + stage = 'periodic points' + elif 'Periodic Facets'.lower() in line.lower(): + stage = 'periodic facets' + elif 'Mesh Points'.lower() in line.lower(): n_pts = int(line.split(':')[1]) stage = 'points' elif 'Mesh Facets'.lower() in line.lower(): @@ -480,6 +516,12 @@ def from_file(cls, filename): phase_numbers.append(_misc.from_str(line)) elif stage == 'volumes': volumes.append(_misc.from_str(line)) + elif stage == 'periodic axes': + per_axes = [bool(int(f)) for f in line.split(',')] + elif stage == 'periodic points': + per_pts.append([int(n) for n in line.split(',')]) + elif stage == 'periodic facets': + per_fts.append([int(n) for n in line.split(',')]) else: pass @@ -496,15 +538,28 @@ def from_file(cls, filename): else: assert len(f_neighbors) == n_nns + per_points = None + per_facets = None + if per_axes is not None: + per_points = {ax: [] for ax, f in enumerate(per_axes) if f} + per_facets = {ax: [] for ax, f in enumerate(per_axes) if f} + for ax, lo, hi in per_pts: + per_points[ax].append((lo, hi)) + for ax, lo, hi in per_fts: + per_facets[ax].append((lo, hi)) + return cls(pts, facets, regions, seed_numbers, phase_numbers, - volumes=volumes, facet_neighbors=f_neighbors) + volumes=volumes, facet_neighbors=f_neighbors, + periodic_axes=per_axes, periodic_points=per_points, + periodic_facets=per_facets) # ----------------------------------------------------------------------- # # Construct from Seed List # # ----------------------------------------------------------------------- # @classmethod def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, - verbose=False): + verbose=False, periodic=False, periodic_margin=0.0, + min_angle=0.0): """Create from :class:`.SeedList` and a domain. This function creates a polygon/polyhedron mesh from a seed list and @@ -520,6 +575,15 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, in the polygonal/polyhedral mesh. Short edges cause numerical issues in finite element analysis - setting `edge_opt` to True can improve mesh quality with minimal changes to the microstructure. + In periodic meshes, the cells that cross a periodic face are split + into pieces, and a piece that is thin (the cell barely crosses the + face) forces very small elements: with a positive `periodic_margin`, + the optimization also thickens or removes the pieces thinner than + the margin. A corner of a cell at a periodic face that is narrower + than the minimum angle of the mesh (`min_angle`) forces very small + elements too, since the mesher cannot reach that angle in the + corner and refines it in shells instead: such corners are opened + by the optimization like thin pieces. Args: seedlist (SeedList): A list of seeds in the microstructure. @@ -529,12 +593,45 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, edge length in the PolyMesh. The seeds associated with the shortest edge are displaced randomly to find improvement and this process iterates until `n_iter` attempts have been made - for a given edge. Defaults to False. + for a given edge. A trial is kept when the shortest feature + that it changes (an edge, or the thickness of a piece at a + periodic face) gets longer: the features that it creates + are all longer than the shortest one that it removes. The + accepted displacements are applied to `seedlist`. Defaults + to False. n_iter (int): *(optional)* Maximum number of iterations per edge - during optimization. Ignored if `edge_opt` set to False. - Defaults to 100. + (or per thin piece) during optimization. Ignored if + `edge_opt` set to False. Defaults to 100. verbose (bool): *(optional)* Print status of edge optimization to screen. Defaults to False. + periodic (bool, list, or str): *(optional)* Periodicity of the + microstructure: True for all axes, a list of booleans (one + per axis), or the names of the periodic axes such as + ``'x'`` or ``'xy'``. The tessellation is then periodic across + those faces of the (rectangular) domain: cells that cross a + periodic face are split into pieces that tile the domain, + and the points and facets on opposite faces are paired + (see ``periodic_points`` and ``periodic_facets``). + Defaults to False. + periodic_margin (float): *(optional)* With `edge_opt`, the + minimum thickness of the pieces of the cells at the + periodic faces (their extent normal to the face). The seeds + of a thinner piece and of its neighbors are moved, normal + to the face, until the piece is at least this thick or the + cell no longer crosses the face. Ignored if `edge_opt` is + False or the mesh is not periodic. Defaults to 0 (only the + shortest edge is optimized). + min_angle (float): *(optional)* The minimum angle (2D) or + dihedral angle (3D) of the mesh that will be built from + this one, in degrees (the `min_angle` of + :meth:`.TriMesh.from_polymesh`). With `edge_opt` in + periodic meshes, a corner of a cell at a periodic face that + is narrower than this angle (between a facet and the face) + is a feature like a thin piece, with the size of the small + elements that the mesher would put in it (a quarter of the + thickness of the wedge at the end of its shorter side), and + the seeds on both sides of the facet are moved along it to + open the corner. Defaults to 0 (no such corners). Returns: PolyMesh: A polygon/polyhedron mesh. @@ -542,6 +639,11 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, .. _`Voro++`: http://math.lbl.gov/voro++/ """ + per_axes = _misc.periodic_axes(periodic, domain.n_dim) + is_periodic = any(per_axes) + if is_periodic: + dom_lims = _misc.periodic_domain_limits(domain) + # Collect all breakdowns bkdwn2seed = np.array([], dtype='int') bkdwns = np.array([]) @@ -549,6 +651,10 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, if len(seed.breakdown) == 0: seed.update_breakdown() bkdwn = np.array(seed.breakdown).reshape(-1, domain.n_dim + 1) + if is_periodic: + # centers outside the domain along a periodic axis are + # wrapped into it (Voro++ needs the particles in the box) + bkdwn = _wrap_points(bkdwn, dom_lims, per_axes) in_mask = domain.within(bkdwn[:, :-1]) breakdown = bkdwn[in_mask] @@ -603,7 +709,8 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, call_str += 'pyvoro.compute_' if n_dim == 2: call_str += '2d_' - call_str += 'voronoi(pts, lims, sz, rads)\n' + call_str += 'voronoi(pts, lims, sz, rads, periodic=' + call_str += str([bool(f) for f in per_axes]) + ')\n' file = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) @@ -640,15 +747,34 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, # compute voronoi diagram voro_fun = {2: pyvoro.compute_2d_voronoi, 3: pyvoro.compute_voronoi}[n_dim] - voro = voro_fun(cens, lims, sz, rads) + voro = voro_fun(cens, lims, sz, rads, + periodic=[bool(f) for f in per_axes]) + + if is_periodic: + # Cells of a periodic tessellation wrap across the periodic + # faces: split them at those faces and translate the outside + # pieces into the domain + pieces_fun = {2: _periodic_pieces_2d, 3: _periodic_pieces_3d} + voro, bkdwn2seed = pieces_fun[n_dim](voro, bkdwn2seed, lims, + per_axes) # Get only the cells within the domain cell_mask = np.full(len(bkdwn2seed), True, dtype='bool') rect_doms = ['square', 'cube', 'rectangle', 'box', 'nbox'] if type(domain).__name__.lower() not in rect_doms: - for cell_num, cell in enumerate(voro): - cell_pts = np.array(cell['vertices']) - cell_mask[cell_num] = np.any(domain.within(cell_pts)) + if n_dim == 2: + # Clip the cells to the domain. Cells that do not intersect + # the domain are removed. + for cell_num, cell in enumerate(voro): + clipped_cell = _clip_cell(cell, domain) + if clipped_cell is None: + cell_mask[cell_num] = False + else: + voro[cell_num] = clipped_cell + else: + for cell_num, cell in enumerate(voro): + cell_pts = np.array(cell['vertices']) + cell_mask[cell_num] = np.any(domain.within(cell_pts)) bkdwn2seed = bkdwn2seed[cell_mask] new_cell_nums = np.full(len(cell_mask), -1, dtype='int') @@ -669,8 +795,11 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, if cell_mask[old_cell_num]: reduced_voro.append(cell) - # Clip cells to domain - voro = [_clip_cell(c, domain) for c in reduced_voro] + # Clip cells to domain (2D cells have already been clipped) + if n_dim == 2: + voro = reduced_voro + else: + voro = [_clip_cell(c, domain) for c in reduced_voro] # create global key point and facet lists pts_global = [] @@ -742,81 +871,54 @@ def from_seeds(cls, seedlist, domain, edge_opt=False, n_iter=100, vols = [cell['volume'] for cell in voro] # Create initial mesh + if is_periodic: + # merge clusters of nearly coincident points, consistently on + # both periodic faces + eps = _MERGE_TOL * max([ub - lb for lb, ub in dom_lims]) + collapsed = _collapse_close_points(pts_global, facet_list, + facet_neighbor_list, + region_list, eps) + pts_global, facet_list, facet_neighbor_list, region_list = \ + collapsed + pmesh = cls(pts_global, facet_list, region_list, bkdwn2seed, phase_nums, facet_neighbor_list, vols) + if is_periodic: + pmesh._set_periodic_pairs(per_axes, dom_lims) - # short edge optimization + # short edge (and thin periodic piece) optimization if edge_opt: - seed2bkdwn = {i: [] for i in range(len(seedlist))} - for i, n in enumerate(pmesh.seed_numbers): - seed2bkdwn[n].append(i) - - # Find the shorted edge - edge_lens = _edge_lengths(pmesh) - min_edge = _shortest_edge(edge_lens) - min_len = edge_lens[min_edge]['length'] - - # Format verbose print string - n_kps = len(pmesh.points) - n_kp_space = int(np.log10(n_kps)) + 1 - n_iter_space = int(np.log10(n_iter)) - v_fmt = 'min length: {0:.3e} | ' - v_fmt += 'edge: {1[0]:' + str(n_kp_space) + 'd}, ' - v_fmt += '{1[1]:' + str(n_kp_space) + 'd} | ' - v_fmt += 'n iter: {2:' + str(n_iter_space) + 'd} / ' - v_fmt += str(n_iter) - - i_n_attempts = 0 - while i_n_attempts < n_iter: - print(v_fmt.format(min_len, min_edge, i_n_attempts)) - # Create Displacement - max_step_size = float('inf') - step_fracs = 2 * np.random.rand(3) - 1 # [-1, 1] - new_cens = np.copy(cens) - - e_neighs = edge_lens[min_edge]['regions'] - edge_pts = np.array(pmesh.points)[list(min_edge)] - for region_num in e_neighs: - if region_num >= 0: - step_size = 0.1 * rads[region_num] - max_step_size = min(max_step_size, step_size) - for f, region_num in zip(step_fracs, e_neighs): - if region_num >= 0: - e_norm_vec = _point_line_vec(cens[region_num], - edge_pts) - step = f * max_step_size * e_norm_vec - new_cens[region_num] += step - - # Update Seeds - new_bkdwns = [list(c) + [r] for c, r in zip(new_cens, rads)] - for i, seed in enumerate(seedlist): - seed.breakdown = [new_bkdwns[j] for j in seed2bkdwn[i]] - - # Create New Polygonal Mesh - try: - new_pmesh = cls.from_seeds(seedlist, domain, - edge_opt=False) - except AssertionError: - i_n_attempts += 1 - continue + pmesh = _optimize_features(cls, pmesh, seedlist, domain, n_iter, + verbose, periodic, periodic_margin, + min_angle) + return pmesh - new_edge_lens = _edge_lengths(new_pmesh) - new_min_edge = _shortest_edge(new_edge_lens) - new_min_len = new_edge_lens[new_min_edge]['length'] + # ----------------------------------------------------------------------- # + # Periodicity # + # ----------------------------------------------------------------------- # + def _set_periodic_pairs(self, per_axes, dom_lims): + """Pair the points and facets on opposite periodic faces. + + For each periodic axis, every point on the lower face is matched + with its image on the upper face; the coordinates of the pair are + snapped so that the image is exactly the point translated by the + domain length. Facets lying on the faces are paired likewise. + The results are stored in ``periodic_axes``, ``periodic_points`` + (dict: axis -> list of (lower, upper) point numbers) and + ``periodic_facets`` (dict: axis -> list of (lower, upper) facet + numbers). + + Raises: + ValueError: If a point or facet on a periodic face has no + image on the opposite face. - if new_min_len > min_len: - if new_min_edge != min_edge: - i_n_attempts = 0 - else: - i_n_attempts += 1 - edge_lens = new_edge_lens - pmesh = new_pmesh - min_len = new_min_len - min_edge = new_min_edge - cens = new_cens - else: - i_n_attempts += 1 - return pmesh + """ + pts, per_points, per_facets = _misc.pair_periodic_mesh( + self.points, self.facets, per_axes, dom_lims) + self.points = pts.tolist() + self.periodic_axes = [bool(f) for f in per_axes] + self.periodic_points = per_points + self.periodic_facets = per_facets # ----------------------------------------------------------------------- # # Plot Mesh # @@ -852,7 +954,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): """ n_dim = len(self.points[0]) - if n_dim == 2 or plt.gca().axes: + if n_dim == 2 or plt.gcf().axes: ax = plt.gca() else: ax = plt.gcf().add_subplot(projection=Axes3D.name) @@ -872,7 +974,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): plt_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): plt_value = [] for s, p in zip(self.seed_numbers, self.phase_numbers): if index_by == 'material': @@ -904,7 +1006,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): p_kwargs = [{'label': m} for m in material] s2p = {s: p for s, p in zip(self.seed_numbers, self.phase_numbers)} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): if index_by == 'material': for p, v in enumerate(value): p_kwargs[p][key] = v @@ -970,7 +1072,7 @@ def plot_facets(self, index_by='seed', hide_interior=True, **kwargs): """ f_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): f_values = [] for fn in range(len(self.facets)): neighs = self.facet_neighbors[fn] @@ -1059,7 +1161,6 @@ def plot_facets(self, index_by='seed', hide_interior=True, **kwargs): def __eq__(self, other_mesh): # check type if type(other_mesh) is not PolyMesh: - print('not same type') return False # check that the lengths are all the same @@ -1070,7 +1171,6 @@ def __eq__(self, other_mesh): same &= len(self.seed_numbers) == len(other_mesh.seed_numbers) same &= len(self.phase_numbers) == len(other_mesh.phase_numbers) if not same: - print('not same length') return False # check that the vertices have the same coordinates @@ -1080,59 +1180,703 @@ def __eq__(self, other_mesh): same &= np.all(same_ints.sum(axis=0) == 1) same &= np.all(same_ints.sum(axis=1) == 1) if not same: - print('not same verts') return False - kp_conv = np.argwhere(same_pt) - kp_other = kp_conv[:, 1] - print('transform') - print(np.array(kp_other)) + # kp_other[i] is the point in other_mesh that matches point i + kp_other = np.argmax(same_pt, axis=1) # check that the facets are the same - facets_in_other_kps = [[kp_other[kp] for kp in f] for f in self.facets] - o_fnum = [] - for i, s_facet in enumerate(facets_in_other_kps): - for j, o_facet in enumerate(other_mesh.facets): - if j in o_fnum: - continue - else: - if set(s_facet) == set(o_facet): - o_fnum.append(j) - break - - if len(o_fnum) != i + 1: - print('not same facets') - return False + o_fnum = _match_index_sets( + [[kp_other[kp] for kp in f] for f in self.facets], + other_mesh.facets) + if o_fnum is None: + return False # check that the regions are the same - regions_in_other_fnums = [[o_fnum[f] for f in r] for r in self.regions] - o_rnum = [] - for i, s_region in enumerate(regions_in_other_fnums): - for j, o_region in enumerate(other_mesh.regions): - if j in o_rnum: - continue - else: - if set(s_region) == set(o_region): - o_rnum.append(j) - break - - if len(o_rnum) != i + 1: - print('not same regions') - return False + o_rnum = _match_index_sets( + [[o_fnum[f] for f in r] for r in self.regions], + other_mesh.regions) + if o_rnum is None: + return False # check that the seed numbers are the same s_seed_nums = np.array(self.seed_numbers) o_seed_nums = np.array(other_mesh.seed_numbers) same &= np.all(s_seed_nums == o_seed_nums[o_rnum]) - print('checking seed numbers', same) # check that the phase numbers are the same s_phase_nums = np.array(self.phase_numbers) o_phase_nums = np.array(other_mesh.phase_numbers) same &= np.all(s_phase_nums == o_phase_nums[o_rnum]) - print('checking phase numbers', same) - return same + return bool(same) + + +def _wrap_points(bkdwn, dom_lims, per_axes): + """Wrap the centers of a breakdown into the domain along periodic axes. + + Args: + bkdwn (numpy.ndarray): N x (d + 1) array of (center, radius) rows. + dom_lims (list): (lower, upper) bounds of the domain, per axis. + per_axes (list): Periodicity flag of each axis. + + Returns: + numpy.ndarray: The wrapped breakdown. + + """ + bkdwn = np.array(bkdwn, dtype='float') + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = dom_lims[axis] + bkdwn[:, axis] = lb + np.mod(bkdwn[:, axis] - lb, ub - lb) + return bkdwn + + +# Vertices closer than this fraction of the largest domain length to a +# periodic face are snapped onto it before the cells are cut there. +_SNAP_TOL = 1e-5 + + +def _snap_to_planes(pts, axis, values, snap_tol): + """Snap the coordinates along an axis that are within a tolerance of + the given values onto those values (returns a copy).""" + pts = np.array(pts, dtype='float') + for value in values: + mask = np.abs(pts[:, axis] - value) <= snap_tol + pts[mask, axis] = value + return pts + + +# Points closer than this fraction of the largest domain length are merged +# in a periodic mesh (Voro++ can produce clusters of nearly coincident +# vertices, which the periodic faces must share consistently). +_MERGE_TOL = 1e-6 + + +def _collapse_close_points(pts, facets, facet_neighbors, regions, eps): + """Merge the points of a mesh that are closer than ``eps``. + + Clusters of close points are replaced by their mean. Facets left with + fewer than ``n_dim`` distinct points are removed, along with their + entries in the regions. + + Args: + pts (list): The points. + facets (list): Facets (lists of point numbers). + facet_neighbors (list): Neighbors of each facet. + regions (list): Regions (lists of facet numbers). + eps (float): Merging distance. + + Returns: + tuple: The new points, facets, facet neighbors and regions. + + """ + pts = np.array(pts, dtype='float') + n_pts, n_dim = pts.shape + roots, means = _cluster_points(pts, eps) + if len(means) == n_pts: + return pts.tolist(), facets, facet_neighbors, regions + + new_pts = [] + root_ids = {} + for root in sorted(means): + root_ids[root] = len(new_pts) + new_pts.append(means[root]) + kp_new = [root_ids[roots[i]] for i in range(n_pts)] + + new_facets = [] + new_neighs = [] + f_new = {} + for f_num, facet in enumerate(facets): + loop = [] + for kp in facet: + kp_n = kp_new[kp] + if not loop or loop[-1] != kp_n: + loop.append(kp_n) + if len(loop) > 1 and loop[0] == loop[-1]: + loop.pop() + if len(set(loop)) >= n_dim: + f_new[f_num] = len(new_facets) + new_facets.append(loop) + new_neighs.append(facet_neighbors[f_num]) + new_regions = [[f_new[f] for f in region if f in f_new] + for region in regions] + return np.array(new_pts).tolist(), new_facets, new_neighs, new_regions + + +def _cluster_points(pts, tol): + """Clusters of points closer than ``tol`` to each other (transitively). + + Returns: + tuple: The root of the cluster of each point (its smallest point + number), as an array, and a dictionary that maps each root to the + mean of the points of its cluster. + + """ + n_pts = len(pts) + sets = _misc.UnionFind(range(n_pts)) + for i, j in cKDTree(pts).query_pairs(tol): + sets.union(i, j) + roots = np.array([sets.find(i) for i in range(n_pts)]) + means = {} + for root in np.unique(roots): + means[root] = pts[np.nonzero(roots == root)[0]].mean(axis=0) + return roots, means + + +def _cell_loop(cell): + """Vertex loop of a 2D pyvoro cell and the adjacent cell of each edge. + + Returns: + tuple: The vertices in loop order (N x 2 array) and a list with the + adjacent cell of the edge that starts at each vertex. + + """ + faces = cell['faces'] + loop = kp_loop([f['vertices'] for f in faces]) + edge_adj = {frozenset(f['vertices']): f['adjacent_cell'] for f in faces} + pts = np.array(cell['vertices'], dtype='float')[loop] + n_kp = len(loop) + adj = [edge_adj[frozenset((loop[k], loop[(k + 1) % n_kp]))] + for k in range(n_kp)] + return pts, adj + + +def _clip_loop(pts, adj, axis, value, keep_below, wall, tol): + """Clip a convex polygon by an axis-aligned line (Sutherland-Hodgman). + + Args: + pts (numpy.ndarray): Vertices of the polygon, in loop order. + adj (list): Adjacent cell of the edge starting at each vertex. + axis (int): Axis of the clipping line. + value (float): Position of the clipping line along the axis. + keep_below (bool): Keep the side below the line (True) or above it. + wall (int): Adjacent cell id given to the edges created on the + line (a negative wall id). + tol (float): Points within this distance of the line are on it. + + Returns: + tuple: The clipped vertices and their edge adjacencies (empty if + the polygon lies entirely on the other side). + + """ + n_kp = len(pts) + on_line = np.abs(pts[:, axis] - value) <= tol + if keep_below: + inside = (pts[:, axis] <= value + tol) | on_line + else: + inside = (pts[:, axis] >= value - tol) | on_line + + new_pts = [] + new_adj = [] + for k in range(n_kp): + k1 = (k + 1) % n_kp + p, q = pts[k], pts[k1] + if inside[k]: + new_pts.append(p) + new_adj.append(adj[k]) + if inside[k] != inside[k1]: + if inside[k] and on_line[k]: + # p itself is the crossing point; the next edge is the cut + new_adj[-1] = wall + continue + if inside[k1] and on_line[k1]: + continue # q itself is the crossing point + t = (value - p[axis]) / (q[axis] - p[axis]) + x = p + t * (q - p) + x[axis] = value + new_pts.append(x) + # leaving the kept side: the next edge lies on the line; + # entering it: the edge from the crossing to q is the original + new_adj.append(wall if inside[k] else adj[k]) + + if len(new_pts) < 3: + return np.zeros((0, pts.shape[1])), [] + + # edges that lie on the line are walls + new_pts = np.array(new_pts) + for k in range(len(new_pts)): + k1 = (k + 1) % len(new_pts) + if (abs(new_pts[k, axis] - value) <= tol and + abs(new_pts[k1, axis] - value) <= tol): + new_adj[k] = wall + return new_pts, new_adj + + +def _unify_cell_vertices(voro, lims, per_axes, merge_tol, snap_tol): + """Give the cells identical coordinates for their shared vertices. + + Voro++ computes each cell on its own, so two cells that share a vertex + hold copies of it that differ by its precision, and in periodic mode + the copies may lie in different images of the domain. The copies that + coincide, modulo the length of the domain along the periodic axes, are + replaced by their mean, snapped onto the periodic faces when within + the snapping tolerance, so that the cells are cut consistently at the + faces and their pieces match exactly. + + Args: + voro (list): The cells from pyvoro. + lims (list): (lower, upper) bounds of the domain, per axis. + per_axes (list): Periodicity flag of each axis. + merge_tol (float): Distance below which copies are one vertex. + snap_tol (float): Distance below which a vertex is on a face. + + Returns: + list: The cells, with the unified vertices. + + """ + lb = np.array([lim[0] for lim in lims], dtype='float') + lengths = np.array([ub - lo for lo, ub in lims], dtype='float') + counts = [len(cell['vertices']) for cell in voro] + all_pts = np.vstack([np.array(cell['vertices'], dtype='float') + for cell in voro]) + + # the images of the vertices in the domain, along the periodic axes + shifts = np.zeros_like(all_pts) + for axis, flag in enumerate(per_axes): + if flag: + n_img = np.floor((all_pts[:, axis] - lb[axis]) / lengths[axis]) + shifts[:, axis] = n_img * lengths[axis] + wrapped = all_pts - shifts + + # coincident copies are one vertex + roots, means = _cluster_points(wrapped, merge_tol) + unified = np.array([means[root] for root in roots]) + + # vertices next to a periodic face are on it + for axis, flag in enumerate(per_axes): + if not flag: + continue + for value in (lb[axis], lb[axis] + lengths[axis]): + on_face = np.abs(unified[:, axis] - value) <= snap_tol + unified[on_face, axis] = value + unified += shifts + + new_voro = [] + start = 0 + for cell, count in zip(voro, counts): + new_cell = dict(cell) + new_cell['vertices'] = unified[start:start + count].tolist() + new_voro.append(new_cell) + start += count + return new_voro + + +def _cut_cell_at_faces(parts, per_axes, lims, tol, snap_tol, clip, n_min): + """Cut the parts of a cell at the periodic faces, axis by axis. + + The part below the lower face of an axis is translated to the upper + side of the domain, the part above the upper face to the lower side, + and the part in between stays. The vertices next to a cut line/plane + are snapped onto it first, so that the two cells sharing an edge/face + are cut consistently and no sliver pieces are created; flat pieces on + a cut are dropped. + + Args: + parts (list): (vertices, boundary) tuples: the vertex loop and the + adjacent cell of each edge in 2D, the vertices and the faces + (vertex lists with their adjacent cells) in 3D. + per_axes (list): Periodicity flag of each axis. + lims (list): (lower, upper) bounds of the domain, per axis. + tol (float): Geometric tolerance. + snap_tol (float): Snapping distance to the faces. + clip (callable): The clipping function (:func:`_clip_loop` or + :func:`_clip_polyhedron`), called as + ``clip(vertices, boundary, axis, value, keep_below, wall, tol)``. + n_min (int): Minimum number of vertices (and of faces, in 3D) of a + piece. + + Returns: + list: The pieces, as (vertices, boundary) tuples. + + """ + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = lims[axis] + length = ub - lb + wall_lo = -(2 * axis + 1) + wall_hi = -(2 * axis + 2) + new_parts = [] + for pts, bnd in parts: + pts = _snap_to_planes(pts, axis, (lb, ub), snap_tol) + _check_cell_width(pts, axis, length, tol) + # part below the lower face, translated to the upper side + below = clip(pts, bnd, axis, lb, True, wall_hi, tol) + rest = clip(pts, bnd, axis, lb, False, wall_lo, tol) + if len(rest[0]) == 0: + inner, above = rest, rest + else: + inner = clip(rest[0], rest[1], axis, ub, True, wall_hi, tol) + above = clip(rest[0], rest[1], axis, ub, False, wall_lo, tol) + for (p_pts, p_bnd), shift in ((below, length), (inner, 0), + (above, -length)): + if len(p_pts) < n_min or (n_min > 3 and len(p_bnd) < n_min): + continue + p_pts = np.array(p_pts) + if p_pts[:, axis].max() - p_pts[:, axis].min() <= tol: + continue # flat piece, lies on the cut + p_pts[:, axis] += shift + new_parts.append((p_pts, p_bnd)) + parts = new_parts + return parts + + +def _periodic_pieces_2d(voro, bkdwn2seed, lims, per_axes): + """Split the cells of a periodic 2D tessellation at the periodic faces. + + The cells computed by Voro++ in periodic mode wrap across the periodic + faces of the domain. Each cell is cut at those faces and the pieces + outside the domain are translated into it, so that the pieces tile the + domain. The cut edges become domain boundary facets (Voro++ wall ids + -1/-2 for the x faces, -3/-4 for the y faces) and the adjacent cell of + every other edge is resolved to the piece that shares it. + + Args: + voro (list): The cells from pyvoro. + bkdwn2seed (numpy.ndarray): Seed number of each cell. + lims (list): (lower, upper) bounds of the domain, per axis. + per_axes (list): Periodicity flag of each axis. + + Returns: + tuple: The pieces, in the pyvoro cell format, and the seed number + of each piece. + + Raises: + ValueError: If a cell is wider than the domain (too few seeds for + a periodic tessellation) or an edge cannot be matched. + + """ + lengths = [ub - lb for lb, ub in lims] + tol = 1e-10 * max(lengths) + merge_tol = _MERGE_TOL * max(lengths) + snap_tol = _SNAP_TOL * max(lengths) + voro = _unify_cell_vertices(voro, lims, per_axes, merge_tol, snap_tol) + + # Cut the cells at the periodic faces + pieces = [] # (cell number, vertices, edge adjacencies) + for cell_num, cell in enumerate(voro): + parts = _cut_cell_at_faces([_cell_loop(cell)], per_axes, lims, tol, + snap_tol, _clip_loop, 3) + for pts, adj in parts: + pieces.append((cell_num, pts, adj)) + + # Resolve the adjacent cells of the edges to pieces + cell_pieces = {} + for piece_num, (cell_num, _, _) in enumerate(pieces): + cell_pieces.setdefault(cell_num, []).append(piece_num) + + new_voro = [] + for piece_num, (cell_num, pts, adj) in enumerate(pieces): + n_kp = len(pts) + faces = [] + for k in range(n_kp): + k1 = (k + 1) % n_kp + adj_cell = adj[k] + if adj_cell >= 0: + candidates = [p for p in cell_pieces.get(adj_cell, []) + if p != piece_num] + adj_cell = _matching_piece(pts[[k, k1]], candidates, pieces, + merge_tol) + if adj_cell is None: + adj_cell = _wall_of_points(pts[[k, k1]], lims, tol) + faces.append({'adjacent_cell': int(adj_cell), + 'vertices': [k, k1]}) + new_voro.append({'vertices': pts.tolist(), + 'faces': faces, + 'adjacency': [[(k - 1) % n_kp, (k + 1) % n_kp] + for k in range(n_kp)], + 'original': voro[cell_num]['original'], + 'volume': _loop_area(pts, list(range(n_kp)))}) + new_bkdwn2seed = np.array([bkdwn2seed[cell_num] + for cell_num, _, _ in pieces], dtype='int') + return new_voro, new_bkdwn2seed + + +def _check_cell_width(pts, axis, length, tol): + """Raise if a cell is wider than the domain along an axis: it cannot + be cut into pieces that tile the domain (too few seeds).""" + extent = pts[:, axis].max() - pts[:, axis].min() + if extent > length + tol: + e_str = 'A cell of the periodic tessellation is wider ' + e_str += 'than the domain along axis ' + str(axis) + e_str += '. More seeds are needed for a periodic ' + e_str += 'microstructure.' + raise ValueError(e_str) + + +# --------------------------------------------------------------------------- # +# # +# Periodic Tessellation - 3D # +# # +# --------------------------------------------------------------------------- # +def _clip_polyhedron(verts, faces, axis, value, keep_below, wall, tol): + """Clip a convex polyhedron by an axis-aligned plane. + + Args: + verts (numpy.ndarray): N x 3 vertices. + faces (list): (vertex loop, adjacent cell) pairs. + axis (int): Axis of the clipping plane. + value (float): Position of the plane along the axis. + keep_below (bool): Keep the side below the plane (True) or above. + wall (int): Adjacent cell id of the face created on the plane. + tol (float): Vertices within this distance of the plane are on it. + + Returns: + tuple: The clipped vertices and faces (empty if nothing is kept). + + """ + on_plane = np.abs(verts[:, axis] - value) <= tol + if keep_below: + inside = (verts[:, axis] <= value + tol) | on_plane + else: + inside = (verts[:, axis] >= value - tol) | on_plane + if np.all(inside): + return verts, faces + if not np.any(inside): + return np.zeros((0, 3)), [] + + new_verts = [] + kp_map = {} + for kp, is_in in enumerate(inside): + if is_in: + kp_map[kp] = len(new_verts) + new_verts.append(verts[kp]) + + # intersection points are computed once per edge, so that the two + # faces sharing the edge use the same point + edge_cut = {} + + def cut_point(kp_a, kp_b): + key = (min(kp_a, kp_b), max(kp_a, kp_b)) + if key not in edge_cut: + p, q = verts[key[0]], verts[key[1]] + t = (value - p[axis]) / (q[axis] - p[axis]) + x = p + t * (q - p) + x[axis] = value + edge_cut[key] = len(new_verts) + new_verts.append(x) + return edge_cut[key] + + new_faces = [] + for loop, adj in faces: + n_kp = len(loop) + new_loop = [] + for k in range(n_kp): + kp_a, kp_b = loop[k], loop[(k + 1) % n_kp] + if inside[kp_a]: + new_loop.append(kp_map[kp_a]) + if inside[kp_a] != inside[kp_b]: + # A vertex on the plane is itself the crossing point: no + # new (coincident) vertex is created for it. + if inside[kp_a] and not on_plane[kp_a]: + new_loop.append(cut_point(kp_a, kp_b)) + elif inside[kp_b] and not on_plane[kp_b]: + new_loop.append(cut_point(kp_a, kp_b)) + # drop repeated consecutive vertices (edges lying on the plane) + loop_out = [] + for kp in new_loop: + if not loop_out or loop_out[-1] != kp: + loop_out.append(kp) + if len(loop_out) > 1 and loop_out[0] == loop_out[-1]: + loop_out.pop() + if len(loop_out) >= 3: + new_faces.append((loop_out, adj)) + + new_verts = np.array(new_verts) + + # The cap face is the cross-section of the (convex) polyhedron by the + # plane: the convex hull of the cut points and the vertices on the + # plane. This does not depend on the orientation of the faces. + cap_ids = set(edge_cut.values()) + cap_ids |= set([kp_map[kp] for kp in range(len(verts)) + if inside[kp] and on_plane[kp]]) + cap_loop = _plane_hull_loop(new_verts, sorted(cap_ids), axis, tol) + if len(cap_loop) >= 3: + new_faces.append((cap_loop, wall)) + # faces lying on the plane are walls + for i, (loop, adj) in enumerate(new_faces): + if np.all(np.abs(new_verts[loop, axis] - value) <= tol): + new_faces[i] = (loop, wall) + return new_verts, new_faces + + +def _plane_hull_loop(verts, ids, axis, tol): + """Loop of the points (given by id) that bound the convex hull of a set + of coplanar points, in a plane normal to ``axis``. + + Points that lie on an edge of the hull (collinear) are included, so + that the loop shares every vertex with the faces around it. + + Returns: + list: The point ids in loop order (empty if fewer than 3 points + span the hull). + + """ + if len(ids) < 3: + return [] + others = [i for i in range(verts.shape[1]) if i != axis] + pts_2d = verts[ids][:, others] + try: + hull = ConvexHull(pts_2d) + except QhullError: + return [] # collinear points: the cross-section is degenerate + hull_ids = [int(i) for i in hull.vertices] + loop = [ids[i] for i in hull_ids] + + # insert the points lying on hull edges + on_hull = set(hull_ids) + rest = [i for i in range(len(ids)) if i not in on_hull] + if rest: + new_loop = [] + n_hull = len(hull_ids) + for k in range(n_hull): + i_a, i_b = hull_ids[k], hull_ids[(k + 1) % n_hull] + p_a, p_b = pts_2d[i_a], pts_2d[i_b] + d_ab = p_b - p_a + length = np.linalg.norm(d_ab) + new_loop.append(ids[i_a]) + on_edge = [] + for i in rest: + rel = pts_2d[i] - p_a + t = np.dot(rel, d_ab) / (length * length) + if -1e-12 < t < 1 + 1e-12: + dist = abs(rel[0] * d_ab[1] - rel[1] * d_ab[0]) / length + if dist <= tol: + on_edge.append((t, ids[i])) + new_loop.extend([kp for _, kp in sorted(on_edge)]) + loop = new_loop + return loop + + +def _polyhedron_volume(verts, faces): + """Volume of a convex polyhedron given by its faces (fan from the + centroid of the vertices).""" + cen = verts.mean(axis=0) + volume = 0.0 + for loop, _ in faces: + p0 = verts[loop[0]] + for k in range(1, len(loop) - 1): + p1, p2 = verts[loop[k]], verts[loop[k + 1]] + volume += abs(np.dot(np.cross(p1 - p0, p2 - p0), cen - p0)) + return volume / 6.0 + + +def _periodic_pieces_3d(voro, bkdwn2seed, lims, per_axes): + """Split the cells of a periodic 3D tessellation at the periodic faces. + + The 3D counterpart of :func:`_periodic_pieces_2d`: each wrapped cell is + clipped by the planes of the periodic faces, the outside pieces are + translated into the domain, cut faces become domain boundary facets + (Voro++ wall ids -1 ... -6) and the adjacent cells of the other faces + are resolved to the pieces that share them. + + Args: + voro (list): The cells from pyvoro. + bkdwn2seed (numpy.ndarray): Seed number of each cell. + lims (list): (lower, upper) bounds of the domain, per axis. + per_axes (list): Periodicity flag of each axis. + + Returns: + tuple: The pieces, in the pyvoro cell format, and the seed number + of each piece. + + """ + lengths = [ub - lb for lb, ub in lims] + tol = 1e-10 * max(lengths) + merge_tol = _MERGE_TOL * max(lengths) + snap_tol = _SNAP_TOL * max(lengths) + voro = _unify_cell_vertices(voro, lims, per_axes, merge_tol, snap_tol) + + pieces = [] # (cell number, vertices, faces) + for cell_num, cell in enumerate(voro): + verts = np.array(cell['vertices'], dtype='float') + faces = [(list(f['vertices']), f['adjacent_cell']) + for f in cell['faces']] + parts = _cut_cell_at_faces([(verts, faces)], per_axes, lims, tol, + snap_tol, _clip_polyhedron, 4) + for p_verts, p_faces in parts: + pieces.append((cell_num, p_verts, p_faces)) + + # Resolve the adjacent cells of the faces to pieces + cell_pieces = {} + for piece_num, (cell_num, _, _) in enumerate(pieces): + cell_pieces.setdefault(cell_num, []).append(piece_num) + + new_voro = [] + for piece_num, (cell_num, verts, faces) in enumerate(pieces): + out_faces = [] + for loop, adj_cell in faces: + if adj_cell >= 0: + candidates = [p for p in cell_pieces.get(adj_cell, []) + if p != piece_num] + adj_cell = _matching_piece(verts[loop], candidates, pieces, + merge_tol) + if adj_cell is None: + adj_cell = _wall_of_points(verts[loop], lims, tol) + out_faces.append({'adjacent_cell': int(adj_cell), + 'vertices': list(loop)}) + adjacency = [[] for _ in range(len(verts))] + for face in out_faces: + loop = face['vertices'] + for k in range(len(loop)): + kp_a, kp_b = loop[k], loop[(k + 1) % len(loop)] + if kp_b not in adjacency[kp_a]: + adjacency[kp_a].append(kp_b) + if kp_a not in adjacency[kp_b]: + adjacency[kp_b].append(kp_a) + new_voro.append({'vertices': verts.tolist(), + 'faces': out_faces, + 'adjacency': adjacency, + 'original': voro[cell_num]['original'], + 'volume': _polyhedron_volume(verts, faces)}) + new_bkdwn2seed = np.array([bkdwn2seed[cell_num] + for cell_num, _, _ in pieces], dtype='int') + return new_voro, new_bkdwn2seed + + +def _matching_piece(face_pts, candidates, pieces, tol): + """Piece among the candidates that has vertices at all the points (the + two ends of an edge in 2D, the vertices of a face in 3D).""" + for piece_num in candidates: + pts = pieces[piece_num][1] + dists = np.linalg.norm(face_pts[:, None, :] - pts[None, :, :], + axis=2) + if np.all(dists.min(axis=1) <= tol): + return piece_num + return None + + +def _wall_of_points(face_pts, lims, tol): + """Wall id of the face of the domain on which all the points lie (the + ends of an edge in 2D, the vertices of a face in 3D).""" + for axis, (lb, ub) in enumerate(lims): + if np.all(np.abs(face_pts[:, axis] - lb) <= tol): + return -(2 * axis + 1) + if np.all(np.abs(face_pts[:, axis] - ub) <= tol): + return -(2 * axis + 2) + e_str = 'Cannot resolve the neighbor of a facet of the periodic ' + e_str += 'tessellation at ' + str(np.round(face_pts.mean(axis=0), 6)) + e_str += '.' + raise ValueError(e_str) + + +def _match_index_sets(items, other_items): + """Match each item to an unused item of ``other_items`` with the same + set of indices. Returns the list of matched positions, or None if any + item has no match.""" + unused = {} + for j, other_item in enumerate(other_items): + unused.setdefault(frozenset(other_item), []).append(j) + + matches = [] + for item in items: + candidates = unused.get(frozenset(item), []) + if not candidates: + return None + matches.append(candidates.pop(0)) + return matches def kp_loop(kp_pairs): @@ -1150,112 +1894,266 @@ def kp_loop(kp_pairs): def _clip_cell(cell_data, domain): + """Clip a Voronoi cell to the domain. + + Rectangular domains do not require clipping and the cell is returned + unchanged. In 2D, the (convex) cell is clipped to the (convex) domain + and ``None`` is returned if the cell does not intersect the domain. + Non-rectangular 3D domains are not supported: a warning is raised and + the cell is returned unchanged. + + Args: + cell_data (dict): A cell from pyvoro, with the 'vertices', 'faces', + 'adjacency', 'original', and 'volume' keys. + domain (from :mod:`microstructpy.geometry`): The domain. + + Returns: + dict or None: The clipped cell, or None if it is outside the domain. + + """ domain_name = type(domain).__name__.lower() if domain_name in ['rectangle', 'square', 'box', 'cube']: return cell_data if domain.n_dim == 2: - pts = np.array(cell_data['vertices']) - if np.all(domain.within(pts)): - return cell_data - - # split the edges that contain the boundary - new_adj = np.copy(cell_data['adjacency']) - new_faces = [] - new_pts = np.copy(cell_data['vertices']) - new_kps = [] - - for face in cell_data['faces']: - adj_cell = face['adjacent_cell'] - verts = face['vertices'] - face_pts = pts[verts] - pts_within = domain.within(face_pts) - if np.all(pts_within) or np.all(~pts_within): - new_faces.append(face) - continue - crossing_pt = _segment_cross(face_pts, domain) - - # Add point to list of vertices and face to list of faces - crossing_kp = len(new_pts) - new_pts = np.vstack((new_pts, crossing_pt.reshape(1, -1))) - new_kps.append(crossing_kp) - - for kp_i, kp in enumerate(verts): - kp_other = verts[1 - kp_i] - new_adj[kp] = [kp_other, crossing_kp] - - new_verts = [kp, crossing_kp] - new_faces.append({'adjacent_cell': adj_cell, - 'vertices': new_verts}) - - # add divider face - new_faces.append({'adjacent_cell': -1, 'vertices': new_kps}) - - # Create cell within the domain - new_within = domain.within(new_pts) - new_within[new_kps] = True - - within_pts = new_pts[new_within] - kp_conv = np.full(len(new_pts), -1, dtype='int') - kp_conv[new_within] = np.arange(np.sum(new_within)) - - within_adj = [[] for pt in within_pts] - within_faces = [] - for face in new_faces: - adj_cell = face['adjacent_cell'] - old_verts = face['vertices'] - new_verts = [kp_conv[v] for v in old_verts] - within_face = {'adjacent_cell': adj_cell, 'vertices': new_verts} - if all([v >= 0 for v in new_verts]): - within_faces.append(within_face) - within_adj[new_verts[0]].append(new_verts[1]) - within_adj[new_verts[1]].append(new_verts[0]) - - # Compute cell area - within_loop = kp_loop([f['vertices'] for f in within_faces]) - within_area = _loop_area(within_pts, within_loop) - - new_cell_data = {'adjacency': within_adj, - 'faces': within_faces, - 'original': cell_data['original'], - 'vertices': within_pts, - 'volume': within_area} - - return new_cell_data + return _clip_cell_2d(cell_data, domain) w_str = 'Cannot clip cells to fit to a ' + domain_name + '.' - w_str = ' Currently 3D geometries are not supported, other than boxes.' + w_str += ' Currently 3D geometries are not supported, other than boxes.' warnings.warn(w_str, RuntimeWarning) return cell_data -def _segment_cross(pts, domain): - end_pts = np.copy(pts) - ds = np.inf - while ds > 1e-12: - within = domain.within(end_pts) +def _clip_cell_2d(cell_data, domain, n_samples=64, n_bnd_pts=64): + """Clip a convex 2D cell to a convex domain. + + The vertex loop of the cell is walked and the points inside the domain + are kept: the vertices within the domain and the points where the edges + cross the domain boundary (an edge with both ends outside the domain can + cross it twice). Consecutive kept points that lie on the same edge of the + cell are joined by that edge, every other gap is an arc of the domain + boundary and is closed by a boundary face (``'adjacent_cell': -1``). + If the domain lies entirely within the cell, the cell becomes a polygon + that approximates the domain boundary. + + Args: + cell_data (dict): A cell from pyvoro. + domain (from :mod:`microstructpy.geometry`): The 2D domain. + n_samples (int): Number of points sampled along an edge with both + ends outside the domain, to detect a double crossing. + n_bnd_pts (int): Number of points on the domain boundary, when the + domain is entirely within the cell. + + Returns: + dict or None: The clipped cell, or None if it is outside the domain. + + """ + pts = np.array(cell_data['vertices'], dtype='float') + faces = cell_data['faces'] + within = domain.within(pts) + if np.all(within): + return cell_data + + # order the vertices of the cell in a loop + loop = kp_loop([f['vertices'] for f in faces]) + n_kp = len(loop) + edge_faces = {frozenset(f['vertices']): f for f in faces} + + # points closer than this are considered coincident + tol = 1e-12 * max(np.max(np.abs(pts)), np.finfo(float).tiny) + + # walk the loop and collect the points inside the domain, and for each + # point the face that joins it to the next point (None: domain boundary) + kept_pts = [] + kept_faces = [] + + def add_point(pt, face): + if kept_pts and np.linalg.norm(pt - kept_pts[-1]) <= tol: + kept_faces[-1] = face + else: + kept_pts.append(pt) + kept_faces.append(face) + + for i in range(n_kp): + kp_a = loop[i] + kp_b = loop[(i + 1) % n_kp] + face = edge_faces[frozenset((kp_a, kp_b))] + pt_a = pts[kp_a] + pt_b = pts[kp_b] + if within[kp_a] and within[kp_b]: + add_point(pt_a, face) + elif within[kp_a]: + add_point(pt_a, face) + add_point(_segment_cross([pt_a, pt_b], domain), None) + elif within[kp_b]: + add_point(_segment_cross([pt_a, pt_b], domain), face) + else: + crossings = _segment_double_cross(pt_a, pt_b, domain, n_samples, + tol) + if crossings is not None: + add_point(crossings[0], face) + add_point(crossings[1], None) + + if len(kept_pts) > 1: + if np.linalg.norm(kept_pts[-1] - kept_pts[0]) <= tol: + kept_pts.pop() + kept_faces.pop() + + if len(kept_pts) == 0: + # the cell is either outside the domain, or contains the domain + if not _point_in_convex_loop(domain.center, pts[loop]): + return None + kept_pts = list(_domain_boundary(domain, n_bnd_pts)) + kept_faces = [None for _ in kept_pts] + + elif len(kept_pts) == 2: + # the domain crosses a single edge of the cell, so the clipped cell + # is bounded by that edge and an arc; the midpoint of the arc is added + # so that the cell has a non-zero area + n_faces = sum([f is not None for f in kept_faces]) + if n_faces != 1: + return None + if kept_faces[0] is None: + kept_pts.reverse() + kept_faces.reverse() + pt_a, pt_b = kept_pts + mid_pt = 0.5 * (pt_a + pt_b) + d_pt = pt_b - pt_a + n_vec = np.array([-d_pt[1], d_pt[0]]) + if np.dot(n_vec, pts.mean(axis=0) - mid_pt) < 0: + n_vec *= -1 + n_vec /= np.linalg.norm(n_vec) + far_pt = mid_pt + _domain_extent(domain) * n_vec + kept_pts.append(_segment_cross([mid_pt, far_pt], domain)) + kept_faces.append(None) + + n_new = len(kept_pts) + if n_new < 3: + return None + + new_pts = np.array(kept_pts) + new_faces = [] + for k, face in enumerate(kept_faces): + verts = [k, (k + 1) % n_new] + if face is None: + new_faces.append({'adjacent_cell': -1, 'vertices': verts}) + else: + new_faces.append({'adjacent_cell': face['adjacent_cell'], + 'vertices': verts}) + new_adj = [[(k - 1) % n_new, (k + 1) % n_new] for k in range(n_new)] + + new_cell_data = {'adjacency': new_adj, + 'faces': new_faces, + 'original': cell_data['original'], + 'vertices': new_pts.tolist(), + 'volume': _loop_area(new_pts, list(range(n_new)))} + return new_cell_data + + +def _segment_cross(pts, domain, n_iter=60): + """Find the point where a segment crosses the domain boundary. + + One end of the segment must be inside the domain and the other outside. + The crossing is found by bisection with a fixed number of iterations, + so the result is accurate to machine precision for any magnitude of the + coordinates. The result does not depend on the order of the end points. + + Args: + pts (list or numpy.ndarray): The two end points of the segment. + domain (from :mod:`microstructpy.geometry`): The domain. + n_iter (int): Number of bisection iterations. + + Returns: + numpy.ndarray: The crossing point. + + """ + end_pts = np.array(pts, dtype='float') + within = domain.within(end_pts) + for _ in range(n_iter): pt = end_pts.mean(axis=0) - pt_within = domain.within(pt) - if within[0] == pt_within: + if domain.within(pt) == within[0]: end_pts[0] = pt else: end_pts[1] = pt - dx = end_pts[1] - end_pts[0] - ds = np.linalg.norm(dx) - return pt + return end_pts.mean(axis=0) + + +def _segment_double_cross(pt_a, pt_b, domain, n_samples=64, tol=0): + """Find where a segment with both ends outside the domain crosses it. + + The segment is sampled to detect whether it passes through the (convex) + domain. The end points are put in a canonical order before sampling, so + that the two cells sharing an edge compute identical crossing points. + + Returns: + tuple or None: The crossing points nearest to ``pt_a`` and ``pt_b``, + or None if the segment does not cross the domain. + + """ + end_pts = np.array([pt_a, pt_b], dtype='float') + order = np.lexsort((end_pts[:, 1], end_pts[:, 0])) + pt_0 = end_pts[order[0]] + pt_1 = end_pts[order[1]] + + t = np.arange(1, n_samples + 1) / (n_samples + 1) + samples = pt_0 + t.reshape(-1, 1) * (pt_1 - pt_0) + inside = domain.within(samples) + if not np.any(inside): + return None + + pt_in = samples[np.argmax(inside)] + cross_0 = _segment_cross([pt_in, pt_0], domain) + cross_1 = _segment_cross([pt_in, pt_1], domain) + if np.linalg.norm(cross_1 - cross_0) <= tol: + return None # the segment only touches the domain + + crossings = [None, None] + crossings[order[0]] = cross_0 + crossings[order[1]] = cross_1 + return tuple(crossings) + + +def _domain_extent(domain): + """A distance that leaves the domain from any point within it.""" + return 2 * max([ub - lb for lb, ub in domain.limits]) + + +def _domain_boundary(domain, n_pts=64): + """Points on the boundary of a convex 2D domain, in loop order. + + The points are found along rays from the center of the domain. + """ + cen = np.array(domain.center, dtype='float') + ext = _domain_extent(domain) + t = np.linspace(0, 2 * np.pi, n_pts, endpoint=False) + dirs = np.column_stack((np.cos(t), np.sin(t))) + return np.array([_segment_cross([cen, cen + ext * u], domain) + for u in dirs]) + + +def _point_in_convex_loop(pt, loop_pts): + """Test whether a point is within a convex polygon.""" + pt = np.array(pt, dtype='float') + loop_pts = np.array(loop_pts, dtype='float') + d_edge = np.roll(loop_pts, -1, axis=0) - loop_pts + d_pt = pt - loop_pts + cross = d_edge[:, 0] * d_pt[:, 1] - d_edge[:, 1] * d_pt[:, 0] + return bool(np.all(cross >= 0) or np.all(cross <= 0)) def _loop_area(pts, loop): + """Area of the polygon with vertices ``pts[loop[0]]``, ``pts[loop[1]]``, + ... (shoelace formula).""" double_area = 0 n = len(loop) for i in range(n): ip1 = (i + 1) % n - xi = pts[i][0] - yi = pts[i][1] - xip1 = pts[ip1][0] - yip1 = pts[ip1][1] + xi = pts[loop[i]][0] + yi = pts[loop[i]][1] + xip1 = pts[loop[ip1]][0] + yip1 = pts[loop[ip1]][1] det = xi * yip1 - xip1 * yi double_area += det @@ -1309,17 +2207,6 @@ def _edge_lengths(pmesh): return edge_lens -def _shortest_edge(edge_lens): - min_len = float('inf') - min_pair = (-1, -1) - for pair in edge_lens: - length = edge_lens[pair]['length'] - if length < min_len: - min_len = length - min_pair = pair - return min_pair - - def _point_line_vec(pt, line_pts): ptA, ptB = line_pts n_vec = (ptB - ptA) / np.linalg.norm(ptB - ptA) @@ -1330,3 +2217,454 @@ def _point_line_vec(pt, line_pts): u_vec = dist_vec / np.linalg.norm(dist_vec) return u_vec + + +def _displace_seed(seed, step, dom_lims=None, per_axes=None): + """Translate a seed rigidly by ``step``. + + The position setter of the seed translates the geometry and the + breakdown along with the position. Along the periodic axes (when + ``dom_lims`` and ``per_axes`` are given), the position is wrapped back + into the domain: a seed translated by the period is the same seed. + """ + if isinstance(seed.breakdown, tuple): + seed.breakdown = [list(b) for b in seed.breakdown] + pos = np.array(seed.position, dtype='float') + pos += np.array(step, dtype='float') + if dom_lims is not None: + for axis, flag in enumerate(per_axes): + if flag: + lb, ub = dom_lims[axis] + pos[axis] = lb + (pos[axis] - lb) % (ub - lb) + seed.position = pos.tolist() + + +# --------------------------------------------------------------------------- # +# Edge / thin piece optimization # +# --------------------------------------------------------------------------- # +_MAX_OPT_TRIALS_PER_ITER = 50 # safety cap: total trials <= this * n_iter + + +def _optimize_features(cls, pmesh, seedlist, domain, n_iter, verbose, + periodic, periodic_margin, min_angle=0.0): + """Lengthen the shortest features of a mesh by moving its seeds. + + The features are the edges of the mesh and, in periodic meshes, the + thicknesses of the pieces of the cells at the periodic faces and the + corners of the cells at the faces narrower than ``min_angle``. The + target is the shortest feature or, with a positive margin, the + thinnest piece or corner under the margin. The seeds around the target are + displaced on a copy of the seed list and the trial is kept when the + shortest feature that it changes gets longer: every feature that it + creates is longer than the shortest one that it removes (for the + shortest edge of the mesh, this is the usual criterion that the + shortest edge gets longer). A target that does not improve in + ``n_iter`` consecutive trials is left alone and the next one is taken; + the optimization ends when no target is left. The accepted + displacements are applied to ``seedlist``. + + Returns: + PolyMesh: The optimized mesh. + + """ + n_dim = domain.n_dim + per_axes = _misc.periodic_axes(periodic, n_dim) + dom_lims = None + if any(per_axes): + dom_lims = _misc.periodic_domain_limits(domain) + scale = max([ub - lb for lb, ub in domain.limits]) + tol = 1e-9 * scale + + features = _mesh_features(pmesh, per_axes, dom_lims, scale, min_angle) + n_kp_space = int(np.log10(max(len(pmesh.points), 1))) + 1 + n_iter_space = int(np.log10(max(n_iter, 1))) + 1 + + stuck = set() + last_key = None + n_attempts = 0 + n_trials = 0 + max_trials = _MAX_OPT_TRIALS_PER_ITER * n_iter + while n_trials < max_trials: + target = _select_target(features, periodic_margin, stuck) + if target is None: + break + if target['key'] != last_key: + last_key = target['key'] + n_attempts = 0 + if verbose: + print(_target_string(target, n_attempts, n_iter, n_kp_space, + n_iter_space)) + + steps = _trial_steps(target, seedlist, n_attempts, periodic_margin, + dom_lims, per_axes, n_dim) + n_trials += 1 + accepted = False + if steps: + trial_seeds = copy.deepcopy(seedlist) + for seed_num, step in steps.items(): + _displace_seed(trial_seeds[seed_num], step, dom_lims, + per_axes) + try: + new_pmesh = cls.from_seeds(trial_seeds, domain, + edge_opt=False, periodic=periodic) + except (AssertionError, ValueError): + new_pmesh = None + if new_pmesh is not None: + new_features = _mesh_features(new_pmesh, per_axes, dom_lims, + scale, min_angle) + accepted = _accept_trial(new_features, features, tol) + + if accepted: + # apply the same displacements to the caller's seeds, so that + # they reproduce the new mesh + for seed_num, step in steps.items(): + _displace_seed(seedlist[seed_num], step, dom_lims, per_axes) + pmesh = new_pmesh + features = new_features + n_attempts = 0 + else: + n_attempts += 1 + if n_attempts >= n_iter or not steps: + stuck.add(target['key']) + return pmesh + + +def _region_points(pmesh, region): + """Sorted point numbers of a region (a list of facet numbers).""" + return sorted({kp for f in region for kp in pmesh.facets[f]}) + + +def _mesh_features(pmesh, per_axes, dom_lims, scale, min_angle=0.0): + """Edges of the mesh, pieces of the cells at the periodic faces and + corners of the cells at the faces narrower than ``min_angle``. + + Each feature is a dict with its ``kind`` ('edge', 'piece' or 'wedge'), + ``size`` (length, thickness normal to the face, or the size of the + elements that the corner forces), a ``key`` that identifies it + geometrically across re-tessellations and the ``seeds`` around it. + """ + pts = np.array(pmesh.points, dtype='float') + seed_nums = [int(s) for s in pmesh.seed_numbers] + features = [] + edge_lens = _edge_lengths(pmesh) + for (kp1, kp2), info in edge_lens.items(): + regions = [r for r in info['regions'] if r >= 0] + mid = 0.5 * (pts[kp1] + pts[kp2]) + features.append({ + 'kind': 'edge', + 'size': info['length'], + 'key': ('edge',) + tuple(np.round(mid / scale, 6)), + 'seeds': sorted({seed_nums[r] for r in regions}), + 'kps': (kp1, kp2), + 'pts': pts[[kp1, kp2]], + }) + if dom_lims is None: + return features + + for r, region in enumerate(pmesh.regions): + walls = set() + neighs = set() + for f in region: + for n in pmesh.facet_neighbors[f]: + if n < 0: + walls.add(n) + elif n != r: + neighs.add(seed_nums[n]) + if not walls: + continue + kps = _region_points(pmesh, region) + cen = pts[kps].mean(axis=0) + for wall in sorted(walls): + axis, side = _misc.wall_axis_side(wall) + if not per_axes[axis]: + continue + # the region touches the wall: its extent normal to the wall + # is its thickness + features.append({ + 'kind': 'piece', + 'size': np.ptp(pts[kps, axis]), + 'key': ('piece', seed_nums[r], axis, side) + + tuple(np.round(cen / scale, 6)), + 'seeds': sorted(neighs | {seed_nums[r]}), + 'seed': seed_nums[r], + 'axis': axis, + 'side': side, + }) + features += _wedge_features(pmesh, pts, seed_nums, per_axes, scale, + min_angle) + return features + + +def _wedge_features(pmesh, pts, seed_nums, per_axes, scale, min_angle): + """Corners of the cells at the periodic faces narrower than + ``min_angle`` (degrees): the angle between a facet of the cell and the + face, at a vertex on the face in 2D and along an edge on the face in + 3D. The mesher cannot reach the minimum angle in such a corner and + refines it in shells of elements down to about a quarter of the + thickness of the wedge at the end of its shorter side, which is the + ``size`` of the feature. The facet turns when the seeds on its two + sides move along it: ``u_vec`` is that direction, away from the + face.""" + q_min = np.radians(min_angle) + if q_min <= 0: + return [] + n_dim = pts.shape[1] + features = [] + for r, region in enumerate(pmesh.regions): + walls = [] + inner = [] + for f in region: + neighs = pmesh.facet_neighbors[f] + if min(neighs) < 0: + axis, side = _misc.wall_axis_side(min(neighs)) + if per_axes[axis]: + walls.append((f, axis, side)) + else: + inner.append(f) + if not walls: + continue + kps = _region_points(pmesh, region) + cen = pts[kps].mean(axis=0) + for f_wall, axis, side in walls: + wall_set = set(pmesh.facets[f_wall]) + for f in inner: + shared = [kp for kp in pmesh.facets[f] if kp in wall_set] + if len(shared) != n_dim - 1: + continue + wedge = _wedge_geometry(pts, pmesh.facets[f_wall], + pmesh.facets[f], shared, cen) + if wedge is None: + continue + angle, length, u_vec, where = wedge + if not angle < q_min: + continue + n_r = [n for n in pmesh.facet_neighbors[f] if n != r][0] + if seed_nums[n_r] == seed_nums[r]: + continue + features.append({ + 'kind': 'wedge', + 'size': 0.25 * length * np.sin(angle), + 'key': ('wedge', seed_nums[r], axis, side) + + tuple(np.round(where / scale, 6)), + 'seeds': sorted({seed_nums[r], seed_nums[n_r]}), + 'seed': seed_nums[r], + 'neighbor': seed_nums[n_r], + 'axis': axis, + 'side': side, + 'angle': angle, + 'min_angle': q_min, + 'u_vec': u_vec, + }) + return features + + +def _wedge_geometry(pts, wall_facet, facet, shared, cen): + """Angle of the corner between a wall facet and a facet of a convex + cell at their shared vertex (2D) or edge (3D), the shorter extent of + the two facets from it, the unit vector along the facet away from the + wall, and the location of the corner; None if degenerate.""" + if len(shared) == 1: + kp = shared[0] + a_vec = pts[[k for k in wall_facet if k != kp][0]] - pts[kp] + b_vec = pts[[k for k in facet if k != kp][0]] - pts[kp] + len_a = np.linalg.norm(a_vec) + len_b = np.linalg.norm(b_vec) + if len_a == 0 or len_b == 0: + return None + cos_ang = np.dot(a_vec, b_vec) / (len_a * len_b) + angle = np.arccos(np.clip(cos_ang, -1, 1)) + return angle, min(len_a, len_b), b_vec / len_b, pts[kp] + + e_pt = pts[shared[0]] + e_vec = pts[shared[1]] - e_pt + e_len = np.linalg.norm(e_vec) + if e_len == 0: + return None + e_vec /= e_len + normals = [] + extents = [] + for loop in (wall_facet, facet): + loop_pts = pts[loop] + n_vec = np.zeros(3) + for i in range(len(loop_pts)): + n_vec += np.cross(loop_pts[i], loop_pts[(i + 1) % len(loop_pts)]) + n_len = np.linalg.norm(n_vec) + if n_len == 0: + return None + n_vec /= n_len + if np.dot(n_vec, loop_pts.mean(axis=0) - cen) < 0: + n_vec = -n_vec # outward from the cell + normals.append(n_vec) + rel = loop_pts - e_pt + rel -= np.outer(rel @ e_vec, e_vec) + extents.append(np.max(np.linalg.norm(rel, axis=1))) + # the interior dihedral angle, from the outward normals + cos_ang = np.dot(normals[0], normals[1]) + angle = np.pi - np.arccos(np.clip(cos_ang, -1, 1)) + u_vec = pts[facet].mean(axis=0) - e_pt + u_vec -= np.dot(u_vec, e_vec) * e_vec + u_len = np.linalg.norm(u_vec) + if u_len == 0 or min(extents) == 0: + return None + where = 0.5 * (pts[shared[0]] + pts[shared[1]]) + return angle, min(extents), u_vec / u_len, where + + +def _select_target(features, margin, stuck): + """The shortest feature, or the thinnest piece or corner under the + margin, that is not stuck; None when there is no such target.""" + sizes = np.array([f['size'] for f in features]) + order = np.argsort(sizes, kind='stable') + cands = [order[0]] + cands += [i for i in order if features[i]['kind'] in ('piece', 'wedge') + and sizes[i] < margin] + for i in cands: + if features[i]['key'] not in stuck: + return features[i] + return None + + +def _trial_steps(target, seedlist, n_attempts, margin, dom_lims, per_axes, + n_dim): + """Displacements of the seeds around the target for one trial. + + Edge: each seed is moved normal to the edge by a random fraction of + 0.1 times its equivalent radius. Piece: the first trial pushes the + cell of the piece so that the piece reaches the margin, the second + retracts it so that the cell ends a margin inside the face (the cell + boundary moves by about half the displacement of the seed), and the + others move the seed of the piece normal to the face, and the seeds + of its neighbors along their lines to the seed of the piece (normal to + their facets with it), by random fractions of the margin or of 0.1 + times their equivalent radii, whichever is larger. Wedge: 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 2 degrees, and + the others move them along the facet by random fractions as above. + """ + steps = {} + if target['kind'] == 'wedge': + u_vec = target['u_vec'] + seed_w, seed_n = target['seed'], target['neighbor'] + if n_attempts == 0: + pos_w = np.array(seedlist[seed_w].position, dtype='float') + pos_n = np.array(seedlist[seed_n].position, dtype='float') + if dom_lims is not None: + pos_n = _nearest_image(pos_n.reshape(1, -1), pos_w, + dom_lims, per_axes)[0] + phi = target['min_angle'] + np.radians(2) - target['angle'] + delta = 0.5 * phi * np.linalg.norm(pos_n - pos_w) + # the corner opens when the facet turns away from the wall: + # the line between the seeds turns towards the facet + return {seed_w: delta * u_vec, seed_n: -delta * u_vec} + if target['kind'] == 'piece': + axis = target['axis'] + grow = np.zeros(n_dim) + grow[axis] = 1.0 if target['side'] == 0 else -1.0 + thickness = target['size'] + if n_attempts == 0 and margin > thickness: + steps[target['seed']] = 2.2 * (margin - thickness) * grow + return steps + if n_attempts == 1: + steps[target['seed']] = -2 * (thickness + margin) * grow + return steps + pos_piece = np.array(seedlist[target['seed']].position, + dtype='float') + + for seed_num in target['seeds']: + seed = seedlist[seed_num] + pos = np.array(seed.position, dtype='float') + if n_dim == 2: + r_eq = np.sqrt(seed.volume / np.pi) + else: + r_eq = np.cbrt(3 * seed.volume / (4 * np.pi)) + step_max = 0.1 * r_eq + if target['kind'] == 'wedge': + step_max = max(step_max, margin) + u_vec = target['u_vec'] + elif target['kind'] == 'piece': + step_max = max(step_max, margin) + if seed_num == target['seed']: + u_vec = grow + else: + ref = _nearest_image(pos_piece.reshape(1, -1), pos, + dom_lims, per_axes)[0] + u_vec = ref - pos + if np.linalg.norm(u_vec) == 0: + continue + u_vec /= np.linalg.norm(u_vec) + else: + edge_pts = target['pts'] + if dom_lims is not None: + edge_pts = _nearest_image(edge_pts, pos, dom_lims, per_axes) + with np.errstate(divide='ignore', invalid='ignore'): + u_vec = _point_line_vec(pos, edge_pts) + if not np.all(np.isfinite(u_vec)): + continue + step_frac = 2 * np.random.rand() - 1 # [-1, 1] + steps[seed_num] = step_frac * step_max * u_vec + return steps + + +def _nearest_image(pts, ref, dom_lims, per_axes): + """Translate ``pts`` (as a whole) by periods so that their center is + closest to ``ref``.""" + pts = np.array(pts, dtype='float') + cen = pts.mean(axis=0) + shift = np.zeros(len(ref)) + for axis, flag in enumerate(per_axes): + if flag: + length = dom_lims[axis][1] - dom_lims[axis][0] + shift[axis] = length * np.round((ref[axis] - cen[axis]) / length) + return pts + shift + + +def _accept_trial(new_features, old_features, tol): + """Whether a trial improves the features that it changes: the shortest + feature that it creates is longer than the shortest one that it + removes (the features are matched by their keys, and a removed and a + created feature of the same size cancel out).""" + old_keys = set([f['key'] for f in old_features]) + new_keys = set([f['key'] for f in new_features]) + removed = sorted([f['size'] for f in old_features + if f['key'] not in new_keys]) + added = sorted([f['size'] for f in new_features + if f['key'] not in old_keys]) + i = j = 0 + kept_removed = [] + kept_added = [] + while i < len(removed) and j < len(added): + if abs(removed[i] - added[j]) <= tol: + i += 1 + j += 1 + elif removed[i] < added[j]: + kept_removed.append(removed[i]) + i += 1 + else: + kept_added.append(added[j]) + j += 1 + kept_removed += removed[i:] + kept_added += added[j:] + if not kept_removed: + return False + if not kept_added: + return True + return kept_added[0] > kept_removed[0] + tol + + +def _target_string(target, n_attempts, n_iter, n_kp_space, n_iter_space): + if target['kind'] == 'edge': + kp_fmt = '{0:' + str(n_kp_space) + 'd}' + s = 'min length: {0:.3e} | edge: '.format(target['size']) + s += ', '.join([kp_fmt.format(kp) for kp in target['kps']]) + elif target['kind'] == 'piece': + face = 'xyz'[target['axis']] + '-+'[target['side']] + s = 'thickness: {0:.3e} | piece: seed {1:d}, face {2}' + s = s.format(target['size'], target['seed'], face) + else: + face = 'xyz'[target['axis']] + '-+'[target['side']] + s = 'corner: {0:.3e} | wedge: seed {1:d}, face {2}, {3:.1f} deg' + s = s.format(target['size'], target['seed'], face, + np.degrees(target['angle'])) + s += ' | n iter: {0:' + str(n_iter_space) + 'd} / {1:d}' + return s.format(n_attempts, n_iter) diff --git a/src/microstructpy/meshing/trimesh.py b/src/microstructpy/meshing/trimesh.py index c63e7bc5..33903f42 100644 --- a/src/microstructpy/meshing/trimesh.py +++ b/src/microstructpy/meshing/trimesh.py @@ -13,6 +13,8 @@ from __future__ import division from __future__ import print_function +import itertools + import meshpy.tet import meshpy.triangle import numpy as np @@ -22,6 +24,7 @@ from matplotlib import pyplot as plt from mpl_toolkits.mplot3d import Axes3D from mpl_toolkits.mplot3d.art3d import Poly3DCollection +from scipy.spatial import cKDTree from microstructpy import _misc @@ -67,13 +70,20 @@ class TriMesh(object): # Constructors # # ----------------------------------------------------------------------- # def __init__(self, points, elements, element_attributes=None, facets=None, - facet_attributes=None): + facet_attributes=None, periodic_axes=None, + periodic_nodes=None, periodic_facets=None): self.points = points self.elements = elements self.element_attributes = element_attributes self.facets = facets self.facet_attributes = facet_attributes + # Periodicity: flags per axis, and the pairs of (low face, high face) + # nodes and facets that are periodic images of each other, per axis + self.periodic_axes = periodic_axes + self.periodic_nodes = periodic_nodes + self.periodic_facets = periodic_facets + @classmethod def from_file(cls, filename): """Read TriMesh from file. @@ -100,8 +110,17 @@ def from_file(cls, filename): n_eas = 0 n_facets = 0 n_fas = 0 + per_axes = None + per_nodes = [] + per_fts = [] for line in file.readlines(): - if 'Mesh Points'.lower() in line.lower(): + if 'Periodic Axes'.lower() in line.lower(): + stage = 'periodic axes' + elif 'Periodic Nodes'.lower() in line.lower(): + stage = 'periodic nodes' + elif 'Periodic Facets'.lower() in line.lower(): + stage = 'periodic facets' + elif 'Mesh Points'.lower() in line.lower(): n_pts = int(line.split(':')[1]) stage = 'points' elif 'Mesh Elements'.lower() in line.lower(): @@ -129,6 +148,12 @@ def from_file(cls, filename): elif stage == 'facet attributes': if n_fas > 0: facet_atts.append(_misc.from_str(line)) + elif stage == 'periodic axes': + per_axes = [bool(int(f)) for f in line.split(',')] + elif stage == 'periodic nodes': + per_nodes.append([int(n) for n in line.split(',')]) + elif stage == 'periodic facets': + per_fts.append([int(n) for n in line.split(',')]) else: pass @@ -139,7 +164,19 @@ def from_file(cls, filename): assert len(facets) == n_facets assert len(facet_atts) == n_fas - return cls(pts, elems, elem_atts, facets, facet_atts) + per_node_pairs = None + per_facet_pairs = None + if per_axes is not None: + per_node_pairs = {ax: [] for ax, f in enumerate(per_axes) if f} + per_facet_pairs = {ax: [] for ax, f in enumerate(per_axes) if f} + for ax, lo, hi in per_nodes: + per_node_pairs[ax].append((lo, hi)) + for ax, lo, hi in per_fts: + per_facet_pairs[ax].append((lo, hi)) + + return cls(pts, elems, elem_atts, facets, facet_atts, + periodic_axes=per_axes, periodic_nodes=per_node_pairs, + periodic_facets=per_facet_pairs) @classmethod def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', @@ -191,60 +228,121 @@ def from_polymesh(cls, polymesh, phases=None, mesher='Triangle/Tetgen', is not set for each phase. This option is used with Triangle or TetGen. Defaults to infinity, which turns off this control. max_edge_length (float): The maximum edge length of elements - along grain boundaries. This option is used with Triangle - and gmsh. Defaults to infinity, which turns off this control. + along grain boundaries: of the segments in 2D and of the + triangles on the facets in 3D. This option is used with + Triangle/TetGen and gmsh. Defaults to infinity, which turns + off this control. mesh_size (float): The target size of the mesh elements. This option is used with gmsh. Default is infinity, whihch turns off this control. + Note: + The facets of the mesh are listed with their nodes in ascending + order and in lexicographic order, whatever the mesher: the order + in which Triangle and TetGen list the edges/faces of a mesh + varies from one run to the next, and the sorted facets make the + mesh, and its files, reproducible. + """ - key = mesher.lower().strip() + # A periodic polygon mesh gives a periodic triangular mesh: the + # nodes on opposite periodic faces are images of each other + per_axes = getattr(polymesh, 'periodic_axes', None) + periodic = per_axes is not None and any(per_axes) + + key = str(mesher).lower().strip() if key in ('triangle/tetgen', 'triangle', 'tetgen'): tri_args = _call_meshpy(polymesh, phases, min_angle, max_volume, - max_edge_length) + max_edge_length, periodic=periodic) elif key == 'gmsh': + if periodic: + e_str = 'Periodic meshes are not supported with gmsh; use ' + e_str += 'the Triangle/TetGen mesher.' + raise NotImplementedError(e_str) tri_args = _call_gmsh(polymesh, phases, mesh_size, max_edge_length) + else: + e_str = 'Unknown mesher ' + repr(mesher) + '. Options are ' + e_str += "'Triangle/TetGen', 'Triangle', 'TetGen', and 'gmsh'." + raise ValueError(e_str) + + tri_pts, tri_elems, tri_e_atts, tri_f, tri_fa = tri_args + tri_f, tri_fa = _sorted_facets(tri_f, tri_fa) + mesh = cls(tri_pts, tri_elems, tri_e_atts, tri_f, tri_fa) + if periodic: + dom_lims = _misc.periodic_bounds(polymesh.points, per_axes) + mesh._set_periodic_pairs(per_axes, dom_lims) + return mesh + + # ----------------------------------------------------------------------- # + # Periodicity # + # ----------------------------------------------------------------------- # + def _set_periodic_pairs(self, per_axes, dom_lims): + """Pair the nodes and facets on opposite periodic faces. + + The nodes on the lower face of each periodic axis are matched with + their images on the upper face and snapped to exact translates; + the facets on the faces are paired likewise. The results are stored + in ``periodic_axes``, ``periodic_nodes`` (dict: axis -> list of + (lower, upper) node numbers) and ``periodic_facets`` (dict: axis -> + list of (lower, upper) facet numbers). - return cls(*tri_args) + Raises: + ValueError: If a node or facet on a periodic face has no image. + + """ + pts, per_nodes, per_facets = _misc.pair_periodic_mesh( + self.points, self.facets, per_axes, dom_lims) + self.points = pts + self.periodic_axes = [bool(f) for f in per_axes] + self.periodic_nodes = per_nodes + self.periodic_facets = per_facets # ----------------------------------------------------------------------- # # String and Representation Functions # # ----------------------------------------------------------------------- # def __str__(self): nv = len(self.points) - nd = len(self.points[0]) - pt_fmt = '\t' - pt_fmt += ', '.join(['{pt[' + str(i) + ']: e}' for i in range(nd)]) + # Points are written with the shortest representation that + # round-trips exactly (repr of a float), so that a mesh read back + # from the file is identical to the one written. str_str = 'Mesh Points: ' + str(nv) + '\n' - str_str += ''.join([pt_fmt.format(pt=p) + '\n' for p in self.points]) + str_str += ''.join(['\t' + ', '.join([repr(float(x)) for x in p]) + + '\n' for p in self.points]) str_str += 'Mesh Elements: ' + str(len(self.elements)) + '\n' - str_str += '\n'.join(['\t' + str(tuple(e))[1:-1] for e in - self.elements]) + str_str += '\n'.join(['\t' + ', '.join([str(int(kp)) for kp in e]) + for e in self.elements]) - try: + # Optional attributes and facets: only write the sections that exist, + # so that the file never contains a dangling header. + if self.element_attributes is not None: str_str += '\nElement Attributes: ' str_str += str(len(self.element_attributes)) + '\n' str_str += '\n'.join(['\t' + str(a) for a in self.element_attributes]) - except TypeError: - pass - try: + if self.facets is not None: str_str += '\nFacets: ' + str(len(self.facets)) + '\n' - str_str += '\n'.join(['\t' + str(tuple(f))[1:-1] for f in - self.facets]) - except TypeError: - pass + str_str += '\n'.join(['\t' + ', '.join([str(int(kp)) for kp in + f]) for f in self.facets]) - try: + if self.facet_attributes is not None: str_str += '\nFacet Attributes: ' str_str += str(len(self.facet_attributes)) + '\n' str_str += '\n'.join(['\t' + str(a) for a in self.facet_attributes]) - except TypeError: - pass + + if self.periodic_axes is not None and any(self.periodic_axes): + flags = [int(bool(f)) for f in self.periodic_axes] + str_str += '\nPeriodic Axes: ' + str(len(flags)) + '\n' + str_str += '\t' + ', '.join([str(f) for f in flags]) + for name, pairs in (('Periodic Nodes', self.periodic_nodes), + ('Periodic Facets', self.periodic_facets)): + rows = [(ax, lo, hi) for ax in sorted(pairs or {}) + for lo, hi in pairs[ax]] + str_str += '\n' + name + ': ' + str(len(rows)) + str_str += ''.join(['\n\t' + ', '.join([str(n) for n in row]) + for row in rows]) return str_str @@ -284,6 +382,18 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): """ # NOQA: E501 fmt = format.lower() + if fmt in ('abaqus', 'tet/tri', 'vtk'): + # These formats infer the element type from the number of nodes + # per element, so make sure the elements are simplices. + n_dim = len(self.points[0]) + n_kp = len(self.elements[0]) + if n_kp != n_dim + 1: + e_str = 'TriMesh elements must be triangles/tetrahedra with ' + e_str += str(n_dim + 1) + ' nodes each to write the ' + e_str += repr(format) + ' format, but the elements have ' + e_str += str(n_kp) + ' nodes.' + raise ValueError(e_str) + if fmt == 'abaqus': # write top matter abaqus = '*Heading\n' @@ -308,9 +418,15 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): for kp in elm]) + '\n' for i, elm in enumerate(self.elements)]) + # Node sets - periodic faces (in paired order) + abaqus += _abaqus_periodic_nsets(self) + # Element sets - seed number elset_n_per = 16 - elem_atts = np.array(self.element_attributes) + if self.element_attributes is None: + elem_atts = np.array([]) + else: + elem_atts = np.array(self.element_attributes) for att in np.unique(elem_atts): elset_name = 'Set-E-Seed-' + str(att) elset_str = '*Elset, elset=' + elset_name + '\n' @@ -350,42 +466,37 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): abaqus += elset_str # Surfaces - Exterior and Interior - facets = np.array(self.facets) - facet_atts = np.array(self.facet_attributes) - - face_ids = {2: [2, 3, 1], 3: [3, 4, 2, 1]}[n_dim] - - for att in np.unique(facet_atts): - facet_name = 'Surface-' + str(att) - surf_str = '*Surface, name=' + facet_name + ', type=element\n' - - att_facets = facets[facet_atts == att] - for facet in att_facets: - mask = np.isin(self.elements, facet) - n_match = mask.astype('int').sum(axis=1) - i_elem = np.argmax(n_match) - elem_id = i_elem + 1 - - i_missing = np.argmin(mask[i_elem]) - face_id = face_ids[i_missing] - - surf_str += str(elem_id) + ', S' + str(face_id) + '\n' - - abaqus += surf_str - - # Surfaces - Exterior - poly_neighbors = np.array(polymesh.facet_neighbors) - poly_mask = np.any(poly_neighbors < 0, axis=1) - neigh_nums = np.min(poly_neighbors, axis=1) - u_neighs = np.unique(neigh_nums[poly_mask]) - for neigh_num in u_neighs: - mask = neigh_nums == neigh_num - facet_name = 'Ext-Surface-' + str(-neigh_num) - surf_str = '*Surface, name=' + facet_name + ', combine=union\n' - for i, flag in enumerate(mask): - if flag: - surf_str += 'Surface-' + str(i) + '\n' - abaqus += surf_str + defined_surfs = set() + has_facets = self.facets is not None and len(self.facets) > 0 + if has_facets and self.facet_attributes is not None: + facets = np.array(self.facets) + facet_atts = np.array(self.facet_attributes) + + face_ids = {2: [2, 3, 1], 3: [3, 4, 2, 1]}[n_dim] + + for att in np.unique(facet_atts): + facet_name = 'Surface-' + str(att) + surf_str = '*Surface, name=' + facet_name + surf_str += ', type=element\n' + + att_facets = facets[facet_atts == att] + for facet in att_facets: + mask = np.isin(self.elements, facet) + n_match = mask.astype('int').sum(axis=1) + i_elem = np.argmax(n_match) + elem_id = i_elem + 1 + + i_missing = np.argmin(mask[i_elem]) + face_id = face_ids[i_missing] + + surf_str += str(elem_id) + ', S' + str(face_id) + '\n' + + abaqus += surf_str + defined_surfs.add(int(att)) + + # Surfaces - Exterior (unions of the surfaces on each domain face) + if polymesh is not None: + abaqus += _abaqus_exterior_unions(polymesh, defined_surfs) # End Part abaqus += '*End Part\n\n' @@ -413,29 +524,24 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): file.write(str(self) + '\n') elif fmt == 'tet/tri': - # create boundary markers - bnd_mkrs = np.full(len(self.points), 0, dtype='int') - - facet_arr = np.array(self.facets) - f_bnd_mkrs = np.full(len(self.facets), 0, dtype='int') + n_pts, n_dim = np.array(self.points).shape elem_arr = np.array(self.elements) - for elem in self.elements: - for i in range(len(elem)): - e_facet = np.delete(elem, i) - f_mask = np.full(elem_arr.shape[0], True) - for kp in e_facet: - f_mask &= np.any(elem_arr == kp, axis=-1) + n_ele, n_kp = elem_arr.shape - if np.sum(f_mask) == 1: - bnd_mkrs[e_facet] = 1 + # Boundary markers: the faces of the elements that belong to a + # single element are on the boundary of the mesh. + face_counts = {} + for elem in elem_arr: + for i in range(n_kp): + key = tuple(sorted(np.delete(elem, i).tolist())) + face_counts[key] = face_counts.get(key, 0) + 1 - f_mask = np.full(facet_arr.shape[0], True) - for kp in e_facet: - f_mask &= np.any(facet_arr == kp, axis=-1) - f_bnd_mkrs[f_mask] = 1 + bnd_mkrs = np.full(n_pts, 0, dtype='int') + for key, count in face_counts.items(): + if count == 1: + bnd_mkrs[list(key)] = 1 # write vertices - n_pts, n_dim = np.array(self.points).shape nodes = ' '.join([str(n) for n in (n_pts, n_dim, 0, 1)]) + '\n' nodes += ''.join([str(i) + ''.join([' ' + str(x) for x in pt]) + ' ' + str(bnd_mkrs[i]) + '\n' for i, pt in @@ -445,12 +551,12 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): file.write(nodes) # write elements - n_ele, n_kp = np.array(self.elements).shape is_att = self.element_attributes is not None n_att = int(is_att) eles = ' '.join([str(n) for n in (n_ele, n_kp, n_att)]) + '\n' for i, simplex in enumerate(self.elements): - e_str = ' '.join([str(kp) for kp in simplex]) + e_str = str(i) + ''.join([' ' + str(int(kp)) for kp in + simplex]) if is_att: e_str += ' ' + str(self.element_attributes[i]) e_str += '\n' @@ -460,14 +566,18 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): file.write(eles) # Write edges/faces - if self.facets is not None: + # Format: '<# of edges/faces> <# of boundary markers (0 or 1)>' + # followed by ' [] ' lines. + if self.facets is not None and len(self.facets) > 0: ext = {2: '.edge', 3: '.face'}[n_dim] - n_facet, n_kp = np.array(self.facets).shape - edge = ' '.join([str(n) for n in (n_facet, n_kp, 1)]) - edge += ''.join([str(i) + ''.join([' ' + str(k) for k in f]) + - ' ' + str(mkr) + '\n' for f, mkr in - zip(self.facets, f_bnd_mkrs)]) + n_facet = len(self.facets) + edge = str(n_facet) + ' 1\n' + for i, facet in enumerate(self.facets): + key = tuple(sorted([int(kp) for kp in facet])) + mkr = int(face_counts.get(key, 0) == 1) + edge += str(i) + ''.join([' ' + str(k) for k in facet]) + edge += ' ' + str(mkr) + '\n' with open(filename + ext, 'w') as file: file.write(edge) @@ -503,24 +613,26 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): vtk += ''.join(n_elem * [cell_type + '\n']) # write element attributes - try: - int(self.element_attributes[0]) - att_type = 'int' - except TypeError: - att_type = 'float' - - vtk += '\nCELL_DATA ' + str(n_elem) + '\n' - vtk += 'SCALARS element_attributes ' + att_type + ' 1 \n' - vtk += 'LOOKUP_TABLE element_attributes\n' - vtk += ''.join([str(a) + '\n' for a in self.element_attributes]) - - # Write phase numbers - if seeds is not None: - vtk += '\nSCALARS phase_numbers int 1 \n' - vtk += 'LOOKUP_TABLE phase_numbers\n' - vtk += ''.join([str(seeds[a].phase) + '\n' for a in + if self.element_attributes is not None: + try: + int(self.element_attributes[0]) + att_type = 'int' + except TypeError: + att_type = 'float' + + vtk += '\nCELL_DATA ' + str(n_elem) + '\n' + vtk += 'SCALARS element_attributes ' + att_type + ' 1 \n' + vtk += 'LOOKUP_TABLE element_attributes\n' + vtk += ''.join([str(a) + '\n' for a in self.element_attributes]) + # Write phase numbers + if seeds is not None: + vtk += '\nSCALARS phase_numbers int 1 \n' + vtk += 'LOOKUP_TABLE phase_numbers\n' + vtk += ''.join([str(seeds[a].phase) + '\n' for a in + self.element_attributes]) + with open(filename, 'w') as file: file.write(vtk) @@ -589,7 +701,7 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): plt_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): plt_value = [] for f_num, f_att in enumerate(self.facet_attributes): if index_by == 'element': @@ -614,12 +726,12 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): if material and index_by == 'attribute': p_kwargs = [{'label': m} for m in material] for key, value in kwargs.items(): - if type(value) not in (list, np.array): + if not isinstance(value, (list, np.ndarray)): for kws in p_kwargs: kws[key] = value for i, m in enumerate(material): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): p_kwargs[i][key] = value[i] else: p_kwargs[i][key] = value @@ -652,7 +764,7 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): # --------------------------------------------------------------------------- # # # -# RasterMesh Class # +# RasterMesh Class # # # # --------------------------------------------------------------------------- # class RasterMesh(TriMesh): @@ -734,266 +846,124 @@ def from_polymesh(cls, polymesh, mesh_size, phases=None): options for each phase. Default is ``{'material_type': 'solid', 'max_volume': float('inf')}``. - """ + if phases is None: + phases = _default_phases(polymesh) + # 1. Create node and element grids p_pts = np.array(polymesh.points) mins = p_pts.min(axis=0) maxs = p_pts.max(axis=0) - lens = (maxs - mins)*(1 + 1e-9) + lens = (maxs - mins) * (1 + 1e-9) sides = [lb + np.arange(0, dlen, mesh_size) for lb, dlen in zip(mins, lens)] - mgrid = np.meshgrid(*sides) + + # A periodic polymesh gives a periodic raster mesh: the grid must + # then reach the opposite faces exactly + per_axes = getattr(polymesh, 'periodic_axes', None) + periodic = per_axes is not None and any(per_axes) + if periodic: + for axis, flag in enumerate(per_axes): + if not flag: + continue + length = maxs[axis] - mins[axis] + n_pix = int(round(length / mesh_size)) + misfit = abs(n_pix * mesh_size - length) + if n_pix < 1 or misfit > 1e-8 * length: + e_str = 'The mesh size of a periodic raster mesh must ' + e_str += 'divide the domain length along axis ' + e_str += str(axis) + ' (' + str(length) + ').' + raise ValueError(e_str) + sides[axis] = np.linspace(mins[axis], maxs[axis], n_pix + 1) + + n_dim = len(mins) + if n_dim not in _RASTER_CORNERS: + e_str = 'Cannot create a raster mesh in ' + str(n_dim) + 'D.' + raise NotImplementedError(e_str) + + # 'ij' indexing: node_nums[i, j(, k)] is the node at + # (sides[0][i], sides[1][j](, sides[2][k])) + mgrid = np.meshgrid(*sides, indexing='ij') nodes = np.array([g.flatten() for g in mgrid]).T node_nums = np.arange(mgrid[0].size).reshape(mgrid[0].shape) - - n_dim = len(mins) - if n_dim == 2: - m, n = node_nums.shape - kp1 = node_nums[:(m-1), :(n-1)].flatten() - kp2 = node_nums[1:m, :(n-1)].flatten() - kp3 = node_nums[1:m, 1:n].flatten() - kp4 = node_nums[:(m-1), 1:n].flatten() - elems = np.array([kp1, kp2, kp3, kp4]).T - elif n_dim == 3: - m, n, p = node_nums.shape - kp1 = node_nums[:(m-1), :(n-1), :(p-1)].flatten() - kp2 = node_nums[1:m, :(n-1), :(p-1)].flatten() - kp3 = node_nums[1:m, 1:n, :(p-1)].flatten() - kp4 = node_nums[:(m-1), 1:n, :(p-1)].flatten() - kp5 = node_nums[:(m-1), :(n-1), 1:p].flatten() - kp6 = node_nums[1:m, :(n-1), 1:p].flatten() - kp7 = node_nums[1:m, 1:n, 1:p].flatten() - kp8 = node_nums[:(m-1), 1:n, 1:p].flatten() - elems = np.array([kp1, kp2, kp3, kp4, kp5, kp6, kp7, kp8]).T - else: - raise NotImplementedError + # Elements are counter-clockwise (2D) / right-handed with nodes 1-4 + # on the bottom face and 5-8 on the top face (3D). + pix_shape = tuple([n - 1 for n in node_nums.shape]) + kp_cols = [] + for offset in _RASTER_CORNERS[n_dim]: + slices = [slice(o, o + n) for o, n in zip(offset, pix_shape)] + kp_cols.append(node_nums[tuple(slices)].flatten()) + elems = np.array(kp_cols).T + n_elems = elems.shape[0] + elem_grid = np.arange(n_elems).reshape(pix_shape) # 2. Compute element centers cens = nodes[elems[:, 0]] + 0.5 * mesh_size - # 3. For each region: - i_remain = np.arange(cens.shape[0]) - elem_regs = np.full(cens.shape[0], -1) - elem_atts = np.full(cens.shape[0], -1) - for r_num, region in enumerate(polymesh.regions): - # A. Create a bounding box - r_kps = np.unique([k for f in region for k in polymesh.facets[f]]) - r_pts = p_pts[r_kps] - r_mins = r_pts.min(axis=0) - r_maxs = r_pts.max(axis=0) - - # B. Isolate element centers with box - r_i_remain = np.copy(i_remain) - for i, lb in enumerate(r_mins): - ub = r_maxs[i] - x = cens[r_i_remain, i] - in_range = (x >= lb) & (x <= ub) - r_i_remain = r_i_remain[in_range] - - # C. For each facet, remove centers on the wrong side - # note: regions are convex, so mean pt is on correct side of facets - r_cen = r_pts.mean(axis=0) - for f in region: - f_kps = polymesh.facets[f] - f_pts = p_pts[f_kps] - u_in, f_cen = _facet_in_normal(f_pts, r_cen) - - rel_pos = cens[r_i_remain] - f_cen - dp = rel_pos.dot(u_in) - inside = dp >= 0 - r_i_remain = r_i_remain[inside] - - # D. Assign remaining centers to region + # 3. For each region: assign the pixels/voxels with centers inside + cell_geom = _CellGeometry(polymesh, p_pts) + i_remain = np.arange(n_elems) + elem_regs = np.full(n_elems, -1) + seed_nums = np.full(n_elems, -1) + for r_num in range(len(polymesh.regions)): + # A. Isolate element centers with the bounding box of the cell + r_mins, r_maxs = cell_geom.limits(r_num) + r_cens = cens[i_remain] + in_box = np.all((r_cens >= r_mins) & (r_cens <= r_maxs), axis=1) + r_i_remain = i_remain[in_box] + + # B. Remove centers on the wrong side of the facets + # note: regions are convex, so mean pt is on correct side + _, normals, centers = cell_geom.facets(r_num) + rel_pos = cens[r_i_remain][:, np.newaxis, :] - centers + dp = np.einsum('efd,fd->ef', rel_pos, normals) + r_i_remain = r_i_remain[np.all(dp >= 0, axis=1)] + + # C. Assign remaining centers to region elem_regs[r_i_remain] = r_num - elem_atts[r_i_remain] = polymesh.seed_numbers[r_num] + seed_nums[r_i_remain] = polymesh.seed_numbers[r_num] i_remain = np.setdiff1d(i_remain, r_i_remain) # 4. Combine regions of the same seed number - if phases is not None: - conv_dict = _amorphous_seed_numbers(polymesh, phases) - elem_atts = np.array([conv_dict.get(s, s) for s in elem_atts]) - - # 5. Define remaining facets, inherit their attributes - facets = [] - facet_atts = [] - for f_num, f_neighs in enumerate(polymesh.facet_neighbors): - n1, n2 = f_neighs - if n1 >= 0: - e1 = elems[elem_regs == n1] - e2 = elems[elem_regs == n2] - - # Shift +x - e1_s = e1[:, 1] - e2_s = e2[:, 0] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - if n_dim == 2: - facet = elem[[1, 2]] - else: - facet = elem[[1, 2, 6, 5]] - facets.append(facet) - facet_atts.append(f_num) - - # Shift -x - e1_s = e1[:, 0] - e2_s = e2[:, 1] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - if n_dim == 2: - facet = elem[[3, 0]] - else: - facet = elem[[0, 4, 7, 3]] - facets.append(facet) - facet_atts.append(f_num) - - # Shift +y - e1_s = e1[:, 3] - e2_s = e2[:, 0] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - if n_dim == 2: - facet = elem[[2, 3]] - else: - facet = elem[[2, 3, 7, 6]] - facets.append(facet) - facet_atts.append(f_num) - - # Shift -y - e1_s = e1[:, 0] - e2_s = e2[:, 3] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - if n_dim == 2: - facet = elem[[0, 1]] - else: - facet = elem[[0, 1, 5, 4]] - facets.append(facet) - facet_atts.append(f_num) - - if n_dim < 3: - continue - - # Shift +z - e1_s = e1[:, 4] - e2_s = e1[:, 0] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - facet = elem[[4, 5, 6, 7]] - facets.append(facet) - facet_atts.append(f_num) - - # Shift -z - e1_s = e1[:, 0] - e2_s = e1[:, 4] - mask = np.isin(e1_s, e2_s) - for elem in e1[mask]: - facet = elem[[0, 1, 2, 3]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -1: - # -x face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 0], 0] - mask = np.isclose(x2, mins[0]) - for elem in e2[mask]: - if n_dim == 2: - facet = elem[[3, 0]] - else: - facet = elem[[0, 4, 7, 3]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -2: - # +x face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 1], 0] - mask = np.isclose(x2, maxs[0]) - for elem in e2[mask]: - if n_dim == 2: - facet = elem[[1, 2]] - else: - facet = elem[[1, 2, 6, 5]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -3: - # -y face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 0], 1] - mask = np.isclose(x2, mins[1]) - for elem in e2[mask]: - if n_dim == 2: - facet = elem[[0, 1]] - else: - facet = elem[[0, 1, 5, 4]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -4: - # +y face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 2], 1] - mask = np.isclose(x2, maxs[1]) - for elem in e2[mask]: - if n_dim == 2: - facet = elem[[2, 3]] - else: - facet = elem[[2, 3, 7, 6]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -5: - # -z face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 0], 2] - mask = np.isclose(x2, mins[2]) - for elem in e2[mask]: - facet = elem[[0, 1, 2, 3]] - facets.append(facet) - facet_atts.append(f_num) - - elif n1 == -6: - # +z face - e2 = elems[elem_regs == n2] - x2 = nodes[e2[:, 4], 2] - mask = x2 == maxs[2] - for elem in e2[mask]: - facet = elem[[4, 5, 6, 7]] - facets.append(facet) - facet_atts.append(f_num) - - # 6. Remove voids and excess cells - if phases is not None: - att_rm = [-1] - for i, phase in enumerate(phases): - if phase.get('material_type', 'solid') in _misc.kw_void: - r_mask = np.array(polymesh.phase_numbers) == i - seeds = np.unique(np.array(polymesh.seed_numbers)[r_mask]) - att_rm.extend(list(seeds)) - - # Remove elements - rm_mask = np.isin(elem_atts, att_rm) - elems = elems[~rm_mask] - elem_atts = elem_atts[~rm_mask] - - # Re-number nodes - nodes_mask = np.isin(np.arange(nodes.shape[0]), elems) - n_remain = np.sum(nodes_mask) - node_n_conv = np.arange(nodes.shape[0]) - node_n_conv[nodes_mask] = np.arange(n_remain) - - nodes = nodes[nodes_mask] - elems = node_n_conv[elems] - if len(facets) > 0: - f_keep = np.all(nodes_mask[facets], axis=1) - facets = node_n_conv[np.array(facets)[f_keep, :]] - facet_atts = np.array(facet_atts)[f_keep] - - return cls(nodes, elems, elem_atts, facets, facet_atts) + conv_dict = _amorphous_seed_numbers(polymesh, phases) + elem_atts = np.array([conv_dict.get(s, s) for s in seed_nums]) + + # 5. Elements to keep: inside a cell of the polymesh and not void + void_seeds = [] + for seed_num, phase_num in zip(polymesh.seed_numbers, + polymesh.phase_numbers): + mat_type = phases[phase_num].get('material_type', 'solid') + if mat_type in _misc.kw_void: + void_seeds.append(seed_num) + keep = (elem_regs >= 0) & ~np.isin(seed_nums, void_seeds) + + # 6. Facets: faces between pixels of different cells, and faces on + # the boundary of the domain, with the polymesh facet number as + # the attribute + facets, facet_atts = _raster_facets(polymesh, phases, cell_geom, + elems, elem_grid, elem_regs, + keep, cens, mesh_size) + + # 7. Remove voids and excess cells, re-number nodes + elems = elems[keep] + elem_atts = elem_atts[keep] + + nodes_mask = np.full(nodes.shape[0], False) + nodes_mask[elems] = True + node_n_conv = np.full(nodes.shape[0], -1) + node_n_conv[nodes_mask] = np.arange(np.sum(nodes_mask)) + + nodes = nodes[nodes_mask] + elems = node_n_conv[elems] + facets = node_n_conv[facets] + + mesh = cls(nodes, elems, elem_atts, facets, facet_atts) + if periodic: + dom_lims = [(float(lb), float(ub)) for lb, ub in zip(mins, maxs)] + mesh._set_periodic_pairs(per_axes, dom_lims) + return mesh # ----------------------------------------------------------------------- # # String and Representation Functions # @@ -1057,13 +1027,19 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): elem_type = {2: 'CPS4', 3: 'C3D8'}[n_dim] abaqus += '*Element, type=' + elem_type + '\n' - abaqus += ''.join([str(i + 1) + ''.join([', ' + str(kp + 1) for kp - in elem]) + '\n' for - i, elem in enumerate(self.elements)]) + abaqus += ''.join([str(i + 1) + ''.join([', ' + str(int(kp) + 1) + for kp in elem]) + '\n' + for i, elem in enumerate(self.elements)]) + + # Node sets - periodic faces (in paired order) + abaqus += _abaqus_periodic_nsets(self) # Element sets - seed number elset_n_per = 16 - elem_atts = np.array(self.element_attributes) + if self.element_attributes is None: + elem_atts = np.array([]) + else: + elem_atts = np.array(self.element_attributes) for att in np.unique(elem_atts): elset_name = 'Set-E-Seed-' + str(att) elset_str = '*Elset, elset=' + elset_name + '\n' @@ -1103,42 +1079,40 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): abaqus += elset_str # Surfaces - Exterior and Interior - facets = np.array(self.facets) - facet_atts = np.array(self.facet_attributes) - - face_ids = {2: [2, 3, 1], 3: [3, 4, 2, 1]}[n_dim] - - for att in np.unique(facet_atts): - facet_name = 'Surface-' + str(att) - surf_str = '*Surface, name=' + facet_name + ', type=element\n' - - att_facets = facets[facet_atts == att] - for facet in att_facets: - mask = np.isin(self.elements, facet) - n_match = mask.astype('int').sum(axis=1) - i_elem = np.argmax(n_match) - elem_id = i_elem + 1 - - i_missing = np.argmin(mask[i_elem]) - face_id = face_ids[i_missing] - - surf_str += str(elem_id) + ', S' + str(face_id) + '\n' - - abaqus += surf_str - - # Surfaces - Exterior - poly_neighbors = np.array(polymesh.facet_neighbors) - poly_mask = np.any(poly_neighbors < 0, axis=1) - neigh_nums = np.min(poly_neighbors, axis=1) - u_neighs = np.unique(neigh_nums[poly_mask]) - for neigh_num in u_neighs: - mask = neigh_nums == neigh_num - facet_name = 'Ext-Surface-' + str(-neigh_num) - surf_str = '*Surface, name=' + facet_name + ', combine=union\n' - for i, flag in enumerate(mask): - if flag: - surf_str += 'Surface-' + str(i) + '\n' - abaqus += surf_str + # Each facet is the face of a pixel/voxel. The Abaqus face id + # (S1, S2, ...) is found from the local node numbers of the face. + defined_surfs = set() + has_facets = self.facets is not None and len(self.facets) > 0 + if has_facets and self.facet_attributes is not None: + elem_faces = {} + face_ids = _ABAQUS_FACE_IDS[n_dim] + for i, elem in enumerate(self.elements): + for face, local_kps in _RASTER_FACES[n_dim].items(): + key = tuple(sorted([int(elem[k]) for k in local_kps])) + elem_faces.setdefault(key, (i + 1, face_ids[face])) + + facets = np.array(self.facets) + facet_atts = np.array(self.facet_attributes) + for att in np.unique(facet_atts): + facet_name = 'Surface-' + str(att) + surf_str = '*Surface, name=' + facet_name + surf_str += ', type=element\n' + + for facet in facets[facet_atts == att]: + key = tuple(sorted([int(kp) for kp in facet])) + if key not in elem_faces: + e_str = 'Facet ' + str(list(key)) + e_str += ' is not a face of any element.' + raise ValueError(e_str) + elem_id, face_id = elem_faces[key] + surf_str += str(elem_id) + ', S' + str(face_id) + '\n' + + abaqus += surf_str + defined_surfs.add(int(att)) + + # Surfaces - Exterior (unions of the surfaces on each domain face) + if polymesh is not None: + abaqus += _abaqus_exterior_unions(polymesh, defined_surfs) # End Part abaqus += '*End Part\n\n' @@ -1167,16 +1141,21 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): elif fmt == 'vtk': n_kp = len(self.elements[0]) mesh_type = {4: 'Pixel', 8: 'Voxel'}[n_kp] - pt_fmt = '{: f} {: f} {: f}\n' + + # Element attributes on the full grid, -1 where there is no + # element (voids, outside the domain) + has_atts = self.element_attributes is not None + arr = self.as_array(element_attributes=has_atts) # Dimensions pts = np.array(self.points) - coords = [np.unique(ax) for ax in pts.T] + mins = pts.min(axis=0) + sz = self.mesh_size + coords = [mins[i] + sz * np.arange(n + 1) for i, n in + enumerate(arr.shape)] if len(coords) < 3: - coords.append([0]) # force z=0 for 2D meshes + coords.append(np.array([0.0])) # force z=0 for 2D meshes dims = [len(c) for c in coords] - n_dim = len(dims) - # write heading vtk = '# vtk DataFile Version 2.0\n' @@ -1188,119 +1167,34 @@ def write(self, filename, format='txt', seeds=None, polymesh=None): # write points for ind, ax in enumerate(['X', 'Y', 'Z']): vtk += '{}_COORDINATES {} float\n'.format(ax, dims[ind]) - line = '' - for x in coords[ind]: - x_str = '{:f}'.format(x) - if len(line) == 0: - line = x_str - elif len(line) + len(' ') + len(x_str) < 80: - line += ' ' + x_str - else: - vtk += line + '\n' - line = x_str - vtk += line + '\n' - - # write element attributes - vtk += 'CELL_DATA {}\n'.format(len(self.element_attributes)) - vtk += 'SCALARS element_attributes float\n' - vtk += 'LOOKUP_TABLE default\n' - line = '' - phase_nums = '' - phase_line = '' - pts = np.array(self.points) - elems = np.sort(self.elements) - if len(coords[-1]) == 1: # 2D - for y_ind in range(len(coords[1][:-1])): - y_mask_ind = pts[:, 1] == coords[1][y_ind] - y_mask_ip1 = pts[:, 1] == coords[1][y_ind] - y_mask = y_mask_ind | y_mask_ip1 - - for x_ind in range(len(coords[0][:-1])): - # mask self.points - x_mask_ind = pts[:, 0] == coords[0][x_ind] - x_mask_ip1 = pts[:, 0] == coords[0][x_ind + 1] - x_mask = x_mask_ind | x_mask_ip1 - - mask = x_mask & y_mask - el = np.where(mask) - e_ind = np.where(np.all(elems == el, axis=1))[0][0] - - # element attribute - att = self.element_attributes[e_ind] - att_str = '{:f}'.format(att) - if len(line) == 0: - line += att_str - elif len(line) + len(' ') + len(att_str) < 80: - line += ' ' + att_str - else: - vtk += line + '\n' - line = att_str - - # phase number - if seeds is not None: - phase = seeds[att].phase - p_str = str(int(phase)) - if len(phase_line) == 0: - phase_line = p_str - elif len(line) + len(' ') + len(p_str) < 80: - phase_line += ' ' + p_str - else: - phase_nums += phase_line + '\n' - phase_line = p_str - vtk += line + '\n' - if seeds is not None: - vtk += 'SCALARS phase_numbers int\n' - vtk += 'LOOKUP_TABLE default\n' - vtk += phase_nums + phase_line + '\n' + vtk += _vtk_lines(['{:f}'.format(x) for x in coords[ind]]) + # write element attributes, in the order VTK expects the cells + # (x index varying fastest, then y, then z) + vals = arr.flatten(order='F') + if np.issubdtype(arr.dtype, np.integer): + att_type = 'int' + att_strs = [str(int(v)) for v in vals] else: - for z_ind in range(len(coords[2][:-1])): - z_mask_ind = pts[:, 2] == coords[2][z_ind] - z_mask_ip1 = pts[:, 2] == coords[2][z_ind + 1] - z_mask = z_mask_ind | z_mask_ip1 - - for y_ind in range(len(coords[1][:-1])): - y_mask_ind = pts[:, 1] == coords[1][y_ind] - y_mask_ip1 = pts[:, 1] == coords[1][y_ind + 1] - y_mask = y_mask_ind | y_mask_ip1 - - for x_ind in range(len(coords[0][:-1])): - # mask self.points - x_mask_ind = pts[:, 0] == coords[0][x_ind] - x_mask_ip1 = pts[:, 0] == coords[0][x_ind + 1] - x_mask = x_mask_ind | x_mask_ip1 - - mask = x_mask & y_mask & z_mask - el = np.where(mask) - e_ind = np.where(np.all(elems == el, axis=1))[0][0] - - # element attribute - att = self.element_attributes[e_ind] - att_str = '{:f}'.format(att) - if len(line) == 0: - line += att_str - elif len(line) + len(' ') + len(att_str) < 80: - line += ' ' + att_str - else: - vtk += line + '\n' - line = att_str - - # phase number - if seeds is not None: - phase = seeds[att].phase - p_str = str(int(phase)) - if len(phase_line) == 0: - phase_line = p_str - elif len(line) + len(' ') + len(p_str) < 80: - phase_line += ' ' + p_str - else: - phase_nums += phase_line + '\n' - phase_line = p_str - vtk += line + '\n' - if seeds is not None: - vtk += 'SCALARS phase_numbers int\n' - vtk += 'LOOKUP_TABLE default\n' - vtk += phase_nums + phase_line + '\n' + att_type = 'float' + att_strs = ['{:f}'.format(v) for v in vals] + + vtk += 'CELL_DATA {}\n'.format(len(vals)) + vtk += 'SCALARS element_attributes {} 1\n'.format(att_type) + vtk += 'LOOKUP_TABLE default\n' + vtk += _vtk_lines(att_strs) + + # write phase numbers + if seeds is not None and has_atts: + phase_strs = [] + for v in vals: + if v < 0: + phase_strs.append('-1') + else: + phase_strs.append(str(int(seeds[int(v)].phase))) + vtk += 'SCALARS phase_numbers int 1\n' + vtk += 'LOOKUP_TABLE default\n' + vtk += _vtk_lines(phase_strs) with open(filename, 'w') as file: file.write(vtk) @@ -1344,18 +1238,19 @@ def as_array(self, element_attributes=True): # 2. Create array full of -1 values inds_maxs = elem_tups.max(axis=0) - arr = np.full(inds_maxs + 1, -1) - - # 3. For each element: populate array with element attributes if element_attributes: - vals = self.element_attributes + vals = np.asarray(self.element_attributes) else: vals = np.arange(elem_tups.shape[0]) - for t, v in zip(elem_tups, vals): - arr[tuple(t)] = v + if vals.dtype.kind in 'biu': + arr = np.full(inds_maxs + 1, -1) + else: + arr = np.full(inds_maxs + 1, -1, dtype=vals.dtype) - return arr + # 3. Populate array with element attributes (or indices) + arr[tuple(elem_tups.T)] = vals + return arr # ----------------------------------------------------------------------- # # Plot Function # @@ -1393,10 +1288,10 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): """ n_dim = len(self.points[0]) - if n_dim == 2: + if n_dim == 2 or plt.gcf().axes: ax = plt.gca() else: - ax = plt.gcf().gca(projection=Axes3D.name) + ax = plt.gcf().add_subplot(projection=Axes3D.name) n_obj = _misc.ax_objects(ax) if n_obj > 0: xlim = ax.get_xlim() @@ -1412,15 +1307,15 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): else: zlim = [float('inf'), -float('inf')] - inds = self.as_array(element_attributes=index_by=='attribute') + inds = self.as_array(element_attributes=index_by == 'attribute') plt_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): plt_value = np.empty(inds.shape, dtype=object) for i, val_i in enumerate(value): plt_value[inds == i] = val_i if 'color' in key: - unset_mask = plt_value == None + unset_mask = np.equal(plt_value, None) plt_value[unset_mask] = 'k' inds[unset_mask] = -1 @@ -1428,28 +1323,25 @@ def plot(self, index_by='element', material=[], loc=0, **kwargs): plt_value = value plt_kwargs[key] = plt_value - # Scale axes + # Corners of the voxels pts = np.array(self.points) mins = pts.min(axis=0) sz = self.mesh_size - pt_tups = np.round((pts - mins) / sz).astype(int) - maxs = pt_tups.max(axis=0) - grids = np.indices(maxs + 1, dtype=float) - for pt, pt_tup in zip(pts, pt_tups): - for i, x in enumerate(pt): - grids[i][tuple(pt_tup)] = x + axes = [mins[i] + sz * np.arange(n + 1) for i, n in + enumerate(inds.shape)] + grids = np.meshgrid(*axes, indexing='ij') ax.voxels(*grids, inds >= 0, **plt_kwargs) # Add legend if material and index_by == 'attribute': p_kwargs = [{'label': m} for m in material] for key, value in kwargs.items(): - if type(value) not in (list, np.array): + if not isinstance(value, (list, np.ndarray)): for kws in p_kwargs: kws[key] = value for i, m in enumerate(material): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): p_kwargs[i][key] = value[i] else: p_kwargs[i][key] = value @@ -1509,14 +1401,11 @@ def _pt_ab(i, pt): def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), - max_edge_length=float('inf')): + max_edge_length=float('inf'), periodic=False): # condition the phases input if phases is None: - default_dict = {'material_type': 'solid', - 'max_volume': float('inf')} - n_phases = int(np.max(polymesh.phase_numbers)) + 1 - phases = [default_dict for _ in range(n_phases)] + phases = _default_phases(polymesh) # create point and facet lists kps = {} @@ -1552,6 +1441,19 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), n_float = np.linalg.norm(rel_pos) / max_edge_length n_int = max(1, np.ceil(n_float)) n_subs[i] = n_int + + # Facets on opposite periodic faces are subdivided identically, so + # that their nodes are images of each other + if periodic: + f_index = {f_num - 1: i for i, f_num in enumerate(facet_nums)} + for axis_pairs in (polymesh.periodic_facets or {}).values(): + for f_lo, f_hi in axis_pairs: + if f_lo in f_index and f_hi in f_index: + n_max = max(n_subs[f_index[f_lo]], + n_subs[f_index[f_hi]]) + n_subs[f_index[f_lo]] = n_max + n_subs[f_index[f_hi]] = n_max + sub_out = meshpy.triangle.subdivide_facets(n_subs, pts, facets, facet_nums) pts, facets, facet_nums = sub_out @@ -1561,6 +1463,11 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), regions = [] holes = [] + # Merged cells are labelled with the smallest seed number among them + # (the same convention as the other writers and meshers), which is not + # the seed number of the first cell of the group in a periodic mesh. + labels = _merged_seed_numbers(polymesh, phases) + ungrouped = np.full(len(polymesh.regions), True, dtype='?') while np.any(ungrouped): cell_ind = np.argmax(ungrouped) @@ -1571,7 +1478,7 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), cell_cen = pts_arr[list(cell_kps)].mean(axis=0) # seed number and phase type - seed_num = int(polymesh.seed_numbers[cell_ind]) + seed_num = int(labels[cell_ind]) phase_num = polymesh.phase_numbers[cell_ind] phase = phases[phase_num] phase_type = phase.get('material_type', 'crystalline') @@ -1607,53 +1514,98 @@ def _call_meshpy(polymesh, phases=None, min_angle=0, max_volume=float('inf'), else: regions.append(cell_cen.tolist() + [seed_num, phase_vol]) - # build inputs - if n_dim == 2: - info = meshpy.triangle.MeshInfo() - else: - info = meshpy.tet.MeshInfo() - - info.set_points(pts) - info.set_facets(facets, facet_nums) - info.set_holes(holes) - - info.regions.resize(len(regions)) - for i, r in enumerate(regions): - info.regions[i] = tuple(r) - # run MeshPy + # The maximum element volume is set per region above, using the global + # value as the default for the phases that do not set their own. Only + # these regional constraints are passed to Triangle/TetGen: a fixed + # (global) constraint would cap the per-phase values and, in 2D, an + # infinite one is formatted as 'ainf', which Triangle reads as the + # switches -a -i -n -f. + # A periodic mesh is built like a non-periodic one, then made periodic + # (see _build_periodic_2d and _build_periodic_3d). if n_dim == 2: - tri_mesh = meshpy.triangle.build(info, - attributes=True, - volume_constraints=True, - max_volume=max_volume, - min_angle=min_angle, - generate_faces=True) + if periodic: + tri_pts, tri_elems, tri_e_atts = _build_periodic_2d( + polymesh, phases, labels, kps, pts, facets, facet_nums, + holes, regions, min_angle, max_volume) + else: + tri_mesh = _build_2d(pts, facets, facet_nums, holes, regions, + min_angle, True) else: opts = meshpy.tet.Options('pq') opts.mindihedral = min_angle - opts.maxvolume = float('inf') - opts.fixedvolume = 1 + opts.varvolume = 1 + opts.fixedvolume = 0 opts.regionattrib = 1 opts.facesout = 1 - tri_mesh = meshpy.tet.build(info, options=opts) + if periodic: + tri_mesh = _build_periodic_3d(polymesh, phases, kps, pts, + facet_nums, holes, regions, opts, + max_volume, max_edge_length) + else: + if np.isfinite(max_edge_length): + # the facets are triangulated to the maximum edge length + # (TetGen has no such control) and TetGen refines the + # interior of the cells to the maximum volume + pts, facets, facet_nums = _triangulate_facets_3d( + polymesh, phases, kps, pts, facet_nums, max_volume, + max_edge_length, {}, {}) + info = _tet_info(pts, facets, facet_nums, holes, regions) + tri_mesh = meshpy.tet.build(info, options=opts) # return mesh - tri_pts = np.array(tri_mesh.points) - tri_elems = np.array(tri_mesh.elements) - tri_e_atts = np.array(tri_mesh.element_attributes, dtype='int') - - tri_faces = np.array(tri_mesh.faces) - tri_f_atts = np.array(tri_mesh.face_markers) - f_mask = tri_f_atts > 0 - tri_f = tri_faces[f_mask] - tri_fa = tri_f_atts[f_mask] - 1 + if periodic: + # The element attributes and the facets are taken from the + # geometry of the polymesh (TetGen can leave sub-faces unmarked + # when it may not modify the facets, and its region attributes + # then leak between cells) + if n_dim == 3: + tri_pts = np.array(tri_mesh.points) + tri_elems = np.array(tri_mesh.elements) + tri_e_atts, tri_f, tri_fa = _attributes_from_polymesh( + tri_pts, tri_elems, polymesh, labels) + else: + tri_pts = np.array(tri_mesh.points) + tri_elems = np.array(tri_mesh.elements) + tri_e_atts = np.array(tri_mesh.element_attributes, dtype='int') + tri_faces = np.array(tri_mesh.faces) + tri_f_atts = np.array(tri_mesh.face_markers) + f_mask = tri_f_atts > 0 + tri_f = tri_faces[f_mask] + tri_fa = tri_f_atts[f_mask] - 1 tri_args = (tri_pts, tri_elems, tri_e_atts, tri_f, tri_fa) return tri_args +def _sorted_facets(facets, facet_atts): + """Facets with their nodes in ascending order, in lexicographic order. + + 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; the sorted + facets make a mesh, and its files, reproducible. MicroStructPy does not + use the orientation of the facets. + + Args: + facets (list or numpy.ndarray): The facets (node numbers). + facet_atts (list or numpy.ndarray): The attribute of each facet. + + Returns: + tuple: The sorted facets and their attributes, as arrays. + + """ + facets = np.array(facets, dtype='int') + facet_atts = np.array(facet_atts) + if facets.size == 0: + return facets, facet_atts + facets = np.sort(facets, axis=1) + order = np.lexsort(facets.T[::-1]) + return facets[order], facet_atts[order] + + def _call_gmsh(pmesh, phases, res, edge_res): + if phases is None: + phases = _default_phases(pmesh) if res == float('inf'): res = None # If edge length not specified, default to mesh size input @@ -1721,7 +1673,7 @@ def _call_gmsh(pmesh, phases, res, edge_res): # ---------------------------------------------------------------------- # # CREATE GEOMETRY - # ---------------------------------------------------------------------- + # ---------------------------------------------------------------------- # with pg.geo.Geometry() as geom: # Add points pt_arr = np.array(pmesh.points) @@ -1794,7 +1746,8 @@ def _call_gmsh(pmesh, phases, res, edge_res): for i in facets_info: info = facets_info[i] facet_seeds = info['seeds'] - to_add = len(facet_seeds) < 2 or facet_seeds[0] != facet_seeds[1] + to_add = len(facet_seeds) < 2 + to_add |= facet_seeds[0] != facet_seeds[1] if not to_add: surfs.append('') continue @@ -1839,7 +1792,8 @@ def _call_gmsh(pmesh, phases, res, edge_res): pt = geom.add_point(_pt3d(cen), res) geom.in_volume(pt, volumes[-1]) else: - raise ValueError('Points cannot have dimension ' + str(n_dim) + '.') + e_str = 'Points cannot have dimension ' + str(n_dim) + '.' + raise ValueError(e_str) mesh = geom.generate_mesh() @@ -1881,6 +1835,18 @@ def _call_gmsh(pmesh, phases, res, edge_res): facets = facets[facet_set] facet_atts = facet_atts[facet_set] + # Remove the points that are not in any element (e.g. inside voids) + # and re-number the remaining ones + used = np.unique(tets) + kp_conv = np.full(len(pts), -1, dtype='int') + kp_conv[used] = np.arange(len(used)) + pts = pts[used] + tets = kp_conv[tets] + if len(facets) > 0: + f_keep = np.all(kp_conv[facets] >= 0, axis=1) + facets = kp_conv[facets[f_keep]] + facet_atts = facet_atts[f_keep] + tri_args = (pts, tets, tet_atts, facets, facet_atts) return tri_args @@ -1913,8 +1879,11 @@ def _sort_element(elem_pts): def _sort_facets(pairs): + """Chain the edges of a closed loop so that each one starts where the + previous one ends. Raises ValueError if the edges do not form a single + loop (e.g. the boundary of a region that is not simply connected). + """ remaining_inds = [i for i in range(1, len(pairs))] - sorted_inds = [0] s_pairs = [pairs[0]] while remaining_inds: last_kp = s_pairs[-1][-1] @@ -1922,7 +1891,13 @@ def _sort_facets(pairs): pair = pairs[i] if last_kp in pair: break - sorted_inds.append(i) + else: + e_str = 'The facets do not form a single closed loop: none of ' + e_str += 'the ' + str(len(remaining_inds)) + ' remaining facets ' + e_str += 'contains point ' + str(last_kp) + '. The boundary of ' + e_str += 'a region with holes, or of a region made of ' + e_str += 'disconnected cells, cannot be sorted.' + raise ValueError(e_str) del remaining_inds[ind] if last_kp == pair[0]: s_pairs.append(pair) @@ -1931,30 +1906,70 @@ def _sort_facets(pairs): return s_pairs -def _amorphous_seed_numbers(pmesh, phases): +def _merged_seed_numbers(pmesh, phases): + """Label (seed number) of each region after merging amorphous cells. + + Cells of the same amorphous phase that share a facet are merged into a + single region of the mesh, labelled with the smallest seed number among + them. In a periodic mesh, the pieces of one seed and the cells that touch + across a periodic face belong to the same region. + + Returns: + numpy.ndarray: The label of each region of the polymesh. + """ + seed_nums = np.array(pmesh.seed_numbers) phase_nums = np.array(pmesh.phase_numbers) is_amorph = np.array([p.get('material_type', 'solid') in _misc.kw_amorph for p in phases]) amorph_mask = is_amorph[phase_nums] - neighs = np.array(pmesh.facet_neighbors) - neighs = neighs[np.min(neighs, axis=1) >= 0] - neighs_mask = phase_nums[neighs[:, 0]] == phase_nums[neighs[:, 1]] - neighs_mask &= amorph_mask[neighs[:, 0]] - amorph_neighs = neighs[neighs_mask] - - new_seed_numbers = np.array(pmesh.seed_numbers) - changes_made = True - while changes_made: - changes_made = False - for pair in amorph_neighs: - seeds = new_seed_numbers[pair] - if seeds[0] != seeds[1]: - changes_made = True - new_seed_numbers[pair] = np.min(seeds) - conv_dict = {s1: s2 for s1, s2 in zip(pmesh.seed_numbers, new_seed_numbers) - if s1 != s2} - return conv_dict + sets = _misc.UnionFind(range(len(seed_nums))) + + pairs = [tuple(neighs) for neighs in pmesh.facet_neighbors] + per_facets = getattr(pmesh, 'periodic_facets', None) or {} + for axis_pairs in per_facets.values(): + for f_lo, f_hi in axis_pairs: + pairs.append((max(pmesh.facet_neighbors[f_lo]), + max(pmesh.facet_neighbors[f_hi]))) + for r_a, r_b in pairs: + if r_a < 0 or r_b < 0: + continue + if amorph_mask[r_a] and phase_nums[r_a] == phase_nums[r_b]: + sets.union(r_a, r_b) + + first_region = {} + for r, s in enumerate(seed_nums): + if s in first_region: + sets.union(first_region[s], r) + else: + first_region[s] = r + + roots = np.array([sets.find(r) for r in range(len(seed_nums))]) + labels = seed_nums.copy() + for root in np.unique(roots): + members = roots == root + labels[members] = seed_nums[members].min() + return labels + + +def _amorphous_seed_numbers(pmesh, phases): + """Seed numbers that change when amorphous cells are merged. + + Returns: + dict: Maps the seed number of each merged cell to the label of its + merged region (see :func:`_merged_seed_numbers`). + """ + labels = _merged_seed_numbers(pmesh, phases) + return {int(s): int(lbl) for s, lbl in zip(pmesh.seed_numbers, labels) + if s != lbl} + + +def _default_phases(polymesh): + """Default phases: one solid phase per phase number of the polymesh.""" + n_phases = int(np.max(polymesh.phase_numbers)) + 1 + return [{'material_type': 'solid', 'max_volume': float('inf')} + for _ in range(n_phases)] + def _pt3d(pt): pt3d = np.zeros(3) @@ -1963,24 +1978,1529 @@ def _pt3d(pt): def _facet_in_normal(pts, cen_pt): + """Inward unit normal and center of a facet of a convex cell. + + Args: + pts (numpy.ndarray): Vertices of the facet. + cen_pt (numpy.ndarray): A point inside the cell. + + Returns: + tuple: The unit normal pointing into the cell and the center of + the facet. + + """ + pts = np.asarray(pts, dtype='float') + f_cen = pts.mean(axis=0) n_dim = len(cen_pt) if n_dim == 2: - ptA = pts[0] - ptB = pts[1] - vt = ptB - ptA + vt = pts[1] - pts[0] vn = np.array([-vt[1], vt[0]]) else: - ptA = pts[0] - ptB = pts[1] - ptC = pts[2] - v1 = ptB - ptA - v2 = ptC - ptA - vn = np.cross(v1, v2) - - sgn = vn.dot(cen_pt - ptA) - vn *= sgn # flip so center is inward + # Newell's method, which is robust to collinear vertices + rel_pts = pts - f_cen + vn = np.zeros(3) + for i in range(len(rel_pts)): + vn += np.cross(rel_pts[i - 1], rel_pts[i]) + + if vn.dot(cen_pt - f_cen) < 0: + vn = -vn # flip so center is inward un = vn / np.linalg.norm(vn) - return un, pts.mean(axis=0) + return un, f_cen + + +_FACE_MIN_ANGLE = 20.0 # quality of the triangles on the periodic faces (3D) +_MAX_EDGE_SUBDIVISIONS = 400 +_MAX_PERIODIC_PASSES = 4 + + +def _facet_sizes(polymesh, phases, facet_nums, max_volume, max_edge_length): + """Target edge length of the elements on each facet. + + The facets on the periodic faces are triangulated before meshing: to + the maximum edge length, and to the edge length of the regular + tetrahedron with the maximum volume of the phase of the cell on the + facet. + + Returns: + dict: Maps the polymesh facet number to the edge length. + + """ + n_dim = len(polymesh.points[0]) + h_facets = {} + for f_num in facet_nums: + h_val = max_edge_length + for reg in polymesh.facet_neighbors[f_num - 1]: + if reg < 0: + continue + phase = phases[polymesh.phase_numbers[reg]] + vol = phase.get('max_volume', max_volume) + if np.isfinite(vol): + if n_dim == 2: + h_val = min(h_val, np.sqrt(4 * vol / np.sqrt(3))) + else: + h_val = min(h_val, (6 * np.sqrt(2) * vol) ** (1.0 / 3)) + h_facets[f_num - 1] = h_val + return h_facets + + +def _edge_key(kp_a, kp_b): + return (min(kp_a, kp_b), max(kp_a, kp_b)) + + +def _points_on_segment(new_pts, pt_a, pt_b, with_ids=False): + """Parameters (0 < t < 1) of the points that lie on a segment, sorted; + with ``with_ids``, the indices of the points in the same order too.""" + if len(new_pts) == 0: + return ([], []) if with_ids else [] + rel = np.array(new_pts) - pt_a + seg = pt_b - pt_a + len2 = np.dot(seg, seg) + t_vals = rel.dot(seg) / len2 + dists = np.linalg.norm(rel - np.outer(t_vals, seg), axis=1) + on_seg = (t_vals > 1e-9) & (t_vals < 1 - 1e-9) + on_seg &= dists <= 1e-9 * np.sqrt(len2) + ids = np.nonzero(on_seg)[0] + order = np.argsort(t_vals[ids]) + ids = ids[order] + if with_ids: + return t_vals[ids].tolist(), ids.tolist() + return t_vals[ids].tolist() + + +def _merge_params(t_vals, sides=None, n_max=_MAX_EDGE_SUBDIVISIONS, + min_gap=1e-6): + """Subdivision of a segment from the parameters of points on it. + + The points come from the refinement of the facets that share the + segment and of its periodic images, so each face of a periodic pair + contributes a set of points. Points closer than ``min_gap`` (relative + to the segment) are merged, and a point is dropped when a point of + another side (``sides``, one value per parameter) is kept closer than + 0.4 times the gap to the next point: the subdivision is as fine as the + finest side, not the union of the sides. + + Returns: + list: The sorted parameters (0 < t < 1) of the subdivision points, + at most n_max - 1 of them. + + """ + if sides is None: + sides = [None] * len(t_vals) + order = np.argsort(t_vals) + ts = [t_vals[i] for i in order] + ss = [sides[i] for i in order] + kept_t = [0.0] + kept_s = [None] + for i, (t_val, side) in enumerate(zip(ts, ss)): + if not (0 < t_val < 1 - min_gap) or t_val - kept_t[-1] <= min_gap: + continue + gap_next = (ts[i + 1] if i + 1 < len(ts) else 1.0) - t_val + if (side is not None and kept_s[-1] is not None and + kept_s[-1] != side and t_val - kept_t[-1] < 0.4 * gap_next): + continue + kept_t.append(t_val) + kept_s.append(side) + merged = kept_t[1:] + if len(merged) >= n_max: + merged = [i / n_max for i in range(1, n_max)] + return merged + + +def _triangle_polygon(loop_pts, h_val, allow_boundary_steiner, extra_pts=(), + quality=True): + """Triangulate a planar convex polygon in 3D with Triangle. + + With ``quality``, the triangles have a minimum angle of 20 degrees + and, if ``h_val`` is finite, at most the area of the equilateral + triangle with that edge length; Steiner points are added on the edges + of the polygon only if allowed. Without it, the triangulation is the + constrained Delaunay triangulation of the points. The extra points, + inside the polygon, are vertices of the triangulation. + + Returns: + tuple: The points (the polygon points, then the extra points, in + order, then the new ones) as an array, and the triangles (lists of + point indices). + + """ + loop_pts = np.asarray(loop_pts, dtype='float') + n_pts = len(loop_pts) + extra_pts = np.asarray(extra_pts, dtype='float').reshape(-1, 3) + n_in = n_pts + len(extra_pts) + + # orthonormal basis of the plane of the polygon + normal = np.zeros(3) + for i in range(n_pts): + normal += np.cross(loop_pts[i - 1], loop_pts[i]) + normal /= np.linalg.norm(normal) + edges = np.roll(loop_pts, -1, axis=0) - loop_pts + u_vec = edges[np.argmax(np.linalg.norm(edges, axis=1))] + u_vec = u_vec - np.dot(u_vec, normal) * normal + u_vec /= np.linalg.norm(u_vec) + v_vec = np.cross(normal, u_vec) + origin = loop_pts[0] + in_pts = np.vstack([loop_pts, extra_pts]) + rel = in_pts - origin + pts_2d = np.column_stack([rel.dot(u_vec), rel.dot(v_vec)]) + + info = meshpy.triangle.MeshInfo() + info.set_points(pts_2d.tolist()) + info.set_facets([(i, (i + 1) % n_pts) for i in range(n_pts)]) + max_area = None + min_angle = None + if quality: + min_angle = _FACE_MIN_ANGLE + if np.isfinite(h_val): + max_area = 0.25 * np.sqrt(3) * h_val * h_val + tri = meshpy.triangle.build(info, max_volume=max_area, + min_angle=min_angle, quality_meshing=quality, + allow_boundary_steiner=allow_boundary_steiner) + + # Triangle keeps the input points first, in order + out_2d = np.array(tri.points) + if len(out_2d) < n_in or not np.allclose(out_2d[:n_in], pts_2d): + raise RuntimeError('Triangle did not keep the input points of a ' + 'facet.') + out_pts = origin + np.outer(out_2d[:, 0], u_vec) + out_pts += np.outer(out_2d[:, 1], v_vec) + out_pts[:n_pts] = loop_pts # the extra points are projected on the plane + for axis in range(3): + if np.ptp(loop_pts[:, axis]) <= 1e-12: + out_pts[:, axis] = loop_pts[0, axis] + tris = [list(elem) for elem in np.array(tri.elements)] + return out_pts, tris + + +def _triangulate_facets_3d(polymesh, phases, kps, pts, facet_nums, max_volume, + max_edge_length, edge_t, face_pts): + """Triangulate the facets of a periodic 3D polymesh. + + Each facet is triangulated with Triangle (with a minimum angle of 20 + degrees and at most the area given by the mesh size, see + :func:`_facet_sizes`). The edges of the facets are subdivided where + Triangle refines them and at the extra parameters in ``edge_t``, and + the triangulations contain the extra points in ``face_pts``. An edge + on a periodic face is subdivided identically to its periodic images, + and the facets on the upper periodic faces get the images of the + points and triangles of the facets on the lower faces, so that the + nodes on opposite faces match. + + Args: + polymesh (PolyMesh): The periodic polymesh. + phases (list): The phases. + kps (dict): Maps polymesh point numbers to the point numbers of the + mesher input. + pts (list): Points of the mesher input. + facet_nums (list): Polymesh facet number + 1 of each facet of the + mesher input. + max_volume (float): The default maximum volume of the elements. + max_edge_length (float): The maximum edge length. + edge_t (dict): Maps an edge (pair of polymesh point numbers, in + increasing order) to parameters of extra points on it. + face_pts (dict): Maps a facet number to extra points in the facet + (for a facet on an upper periodic face, they are stored with + the facet on the lower face). + + Returns: + tuple: The new points, facets (triangles) and facet numbers. + + """ + pts = [list(p) for p in pts] + p_arr = np.array(polymesh.points) + n_dim = p_arr.shape[1] + lengths = p_arr.max(axis=0) - p_arr.min(axis=0) + scale = lengths.max() + per_pts = polymesh.periodic_points or {} + per_facets = polymesh.periodic_facets or {} + lo_hi = {axis: dict(pairs) for axis, pairs in per_pts.items()} + hi_lo = {axis: {b: a for a, b in pairs} for axis, pairs in + per_pts.items()} + h_facets = _facet_sizes(polymesh, phases, facet_nums, max_volume, + max_edge_length) + + upper = {} + for axis, f_pairs in per_facets.items(): + for f_lo, f_hi in f_pairs: + upper[f_lo] = (axis, f_hi) + is_upper = set([f_hi for _, f_hi in upper.values()]) + + # 1. Edges of the facets; an edge and its periodic images are + # subdivided identically + edge_keys = set() + for f_num in facet_nums: + loop = polymesh.facets[f_num - 1] + for i in range(len(loop)): + edge_keys.add(_edge_key(loop[i - 1], loop[i])) + + classes = _misc.UnionFind(edge_keys) + find = classes.find + for kp_map in lo_hi.values(): + for key in edge_keys: + if key[0] in kp_map and key[1] in kp_map: + image = _edge_key(kp_map[key[0]], kp_map[key[1]]) + if image in edge_keys: + classes.attach(key, image) + + def to_root(key, t_vals): + # the parameters along key, in the orientation of its class root + root = find(key) + seg = p_arr[key[1]] - p_arr[key[0]] + seg_root = p_arr[root[1]] - p_arr[root[0]] + if np.dot(seg, seg_root) >= 0: + return list(t_vals) + return [1 - t for t in t_vals] + + # 2. Parameters of the points on the edges: the edges are subdivided + # to the maximum edge length, and at the extra parameters + periodic_facets = set(upper) | is_upper + splits = {} + for f_num in facet_nums: + f = f_num - 1 + if not np.isfinite(max_edge_length): + continue + loop = polymesh.facets[f] + for i in range(len(loop)): + key = _edge_key(loop[i - 1], loop[i]) + edge_len = np.linalg.norm(p_arr[key[1]] - p_arr[key[0]]) + n_sub = int(np.ceil(edge_len / max_edge_length)) + t_vals = [k / n_sub for k in range(1, n_sub)] + splits.setdefault(find(key), []).extend( + [(t, None) for t in to_root(key, t_vals)]) + for key, vals in edge_t.items(): + if key in edge_keys: + ts = to_root(key, [t for t, _ in vals]) + splits.setdefault(find(key), []).extend( + zip(ts, [s for _, s in vals])) + + # 3. Subdivide the edges; the images of an edge get translated copies of + # its points, recorded in image_map (lower point -> upper point) + edge_pts = {} + image_map = {axis: {kps[a]: kps[b] for a, b in pairs} for axis, pairs in + per_pts.items()} + for key in sorted(edge_keys): + if key in edge_pts: + continue + pt_a, pt_b = p_arr[key[0]], p_arr[key[1]] + edge_len = np.linalg.norm(pt_b - pt_a) + min_gap = max(1e-6, 1e-6 * scale / edge_len) + vals = splits.get(find(key), []) + t_vals = _merge_params([t for t, _ in vals], [s for _, s in vals], + min_gap=min_gap) + t_vals = sorted(to_root(key, t_vals)) + ids = [] + for t_val in t_vals: + ids.append(len(pts)) + pts.append((pt_a + t_val * (pt_b - pt_a)).tolist()) + edge_pts[key] = ids + + queue = [key] + while queue: + kp_a, kp_b = queue.pop() + ids = edge_pts[(kp_a, kp_b)] + for axis in lo_hi: + for kp_map, sign in ((lo_hi[axis], 1), (hi_lo[axis], -1)): + if kp_a not in kp_map or kp_b not in kp_map: + continue + im_a, im_b = kp_map[kp_a], kp_map[kp_b] + im_key = _edge_key(im_a, im_b) + if im_key not in edge_keys: + continue + if im_key not in edge_pts: + shift = np.zeros(n_dim) + shift[axis] = sign * lengths[axis] + im_ids = [] + for pid in ids: + im_ids.append(len(pts)) + pts.append((np.array(pts[pid]) + shift).tolist()) + if im_a != im_key[0]: + im_ids = im_ids[::-1] + edge_pts[im_key] = im_ids + queue.append(im_key) + im_ids = edge_pts[im_key] + if im_a != im_key[0]: + im_ids = im_ids[::-1] + for pid, im_pid in zip(ids, im_ids): + if sign > 0: + image_map[axis][pid] = im_pid + else: + image_map[axis][im_pid] = pid + + # 4. Facet loops with the new points + loops = {} + for f_num in facet_nums: + loop = polymesh.facets[f_num - 1] + new_loop = [] + for i in range(len(loop)): + kp_a, kp_b = loop[i], loop[(i + 1) % len(loop)] + new_loop.append(kps[kp_a]) + key = _edge_key(kp_a, kp_b) + ids = edge_pts[key] + new_loop.extend(ids if kp_a == key[0] else ids[::-1]) + loops[f_num - 1] = new_loop + + # 5. Triangulate the facets with their edges fixed; the facets on the + # upper periodic faces are the images of those on the lower faces. The + # facets on the periodic faces are refined to the mesh size when one is + # given (TetGen cannot refine them afterwards), and all the facets to + # the maximum edge length when it is given; the others are the + # constrained Delaunay triangulations of their points. + new_facets = [] + new_nums = [] + for f_num in facet_nums: + f = f_num - 1 + if f in is_upper: + continue + loop_ids = loops[f] + extra = face_pts.get(f, []) + quality = f in periodic_facets or np.isfinite(max_edge_length) + h_val = h_facets[f] + if quality and np.isfinite(h_val): + # the area bound is met by equilateral triangles of that edge + # length; a smaller area keeps the edges of the other triangles + # at about the maximum edge length, and the elements on the + # faces, which TetGen may not split, below the maximum volume + h_val = 0.75 * h_val + out_pts, tris = _triangle_polygon([pts[k] for k in loop_ids], + h_val, False, extra, quality) + ids = list(loop_ids) + new_ids = [] + for pt in out_pts[len(loop_ids):]: + new_ids.append(len(pts)) + ids.append(len(pts)) + pts.append(pt.tolist()) + tris = [[ids[k] for k in tri] for tri in tris] + new_facets.extend(tris) + new_nums.extend([f_num] * len(tris)) + if f not in upper: + continue + + p_axis, f_hi = upper[f] + kp_map = image_map[p_axis] + shift = np.zeros(n_dim) + shift[p_axis] = lengths[p_axis] + for pid in new_ids: + kp_map[pid] = len(pts) + pts.append((np.array(pts[pid]) + shift).tolist()) + new_facets.extend([[kp_map[k] for k in tri] for tri in tris]) + new_nums.extend([f_hi + 1] * len(tris)) + return pts, new_facets, new_nums + + +def _merge_face_points(raw, loop_pts, tol_dup): + """Points inside a facet from the refinement of both periodic faces. + + A point of one side is dropped when a point of another side is kept + within 0.4 times its distance to the nearest point of its own side (or + vertex of the facet), so that the facet is as refined as the finest + side, not the union of the sides. Points closer than ``tol_dup`` are + merged. + + Args: + raw (list): Pairs of a point and its side. + loop_pts (numpy.ndarray): The vertices of the facet. + tol_dup (float): Distance below which points are the same. + + Returns: + list: The points kept. + + """ + by_side = {} + for pt, side in raw: + by_side.setdefault(side, []).append(pt) + kept = [] + kept_side = [] + for side in sorted(by_side, key=lambda s: -len(by_side[s])): + pts_s = np.array(by_side[side]) + tree = cKDTree(np.vstack([pts_s, loop_pts])) + d_own = tree.query(pts_s, k=2)[0][:, 1] + for pt, s_own in zip(pts_s, d_own): + if kept: + dists = np.linalg.norm(np.array(kept) - pt, axis=1) + j = int(np.argmin(dists)) + if dists[j] <= tol_dup: + continue + if kept_side[j] != side and dists[j] < 0.4 * s_own: + continue + kept.append(pt.tolist()) + kept_side.append(side) + return kept + + +def _collect_facet_points_3d(new_pts, polymesh, edge_t, face_pts): + """Record the points that TetGen added on the facets of a polymesh. + + A point on an edge of a facet is added to the parameters of that edge + (``edge_t``), a point inside a facet to the extra points of the facet + (``face_pts``); points inside the cells are ignored. A point on an + upper periodic face is moved to the lower face and recorded with the + facet there, so that the next triangulation of the facets has the + point, and its images, on both faces. Each point is recorded with its + side (the periodic faces it was on), and the points of the two faces + of a pair are merged so that the facets are as refined as the finest + side (see :func:`_merge_params` and :func:`_merge_face_points`). + + Returns: + int: The number of points recorded. + + """ + p_arr = np.array(polymesh.points) + mins = p_arr.min(axis=0) + lengths = p_arr.max(axis=0) - mins + maxs = mins + lengths + scale = lengths.max() + tol = max(1e-9, 4 * _facet_nonplanarity(polymesh)) * scale + tol_face = 1e-9 * scale + tol_dup = 1e-6 * scale + per_axes = polymesh.periodic_axes + to_lower = {} + for axis, f_pairs in (polymesh.periodic_facets or {}).items(): + for f_lo, f_hi in f_pairs: + to_lower[f_hi] = (axis, f_lo) + + new_pts = np.asarray(new_pts, dtype='float').reshape(-1, 3) + if len(new_pts) == 0: + return 0 + + # 1. Facets whose plane contains each point, among the facets of the + # cells that contain it + cell_geom = _CellGeometry(polymesh, p_arr) + point_facets = {} + for r_num, cand, dp in cell_geom.containing(new_pts, tol): + f_nums = cell_geom.facets(r_num)[0] + inside = np.all(dp >= -tol, axis=1) + for i, row in zip(cand[inside], dp[inside]): + for k in np.nonzero(np.abs(row) <= tol)[0]: + point_facets.setdefault(i, set()).add(int(f_nums[k])) + + # 2. Record the points on edges and inside facets, with their sides + n_found = 0 + raw_face = {} + for i, f_set in point_facets.items(): + pt = np.array(new_pts[i]) + side = tuple([int(bool(per_axes[k]) and + abs(pt[k] - maxs[k]) <= tol_face) + for k in range(3)]) + f_list = sorted(f_set) + if len(f_list) > 1: + loop = polymesh.facets[f_list[0]] + for k in range(len(loop)): + key = _edge_key(loop[k - 1], loop[k]) + t_vals = _points_on_segment([pt], p_arr[key[0]], + p_arr[key[1]]) + if t_vals: + known = edge_t.setdefault(key, []) + edge_len = np.linalg.norm(p_arr[key[1]] - p_arr[key[0]]) + if all([abs(t_vals[0] - t) * edge_len > tol_dup + for t, _ in known]): + known.append((t_vals[0], side)) + n_found += 1 + break + else: + f = f_list[0] + if f in to_lower: + axis, f = to_lower[f] + pt[axis] -= lengths[axis] + raw_face.setdefault(f, []).append((pt, side)) + for f, raw in raw_face.items(): + merged = _merge_face_points(raw, p_arr[polymesh.facets[f]], tol_dup) + face_pts.setdefault(f, []).extend(merged) + n_found += len(merged) + return n_found + + +def _tet_info(pts, facets, facet_nums, holes, regions): + """Build the TetGen input.""" + info = meshpy.tet.MeshInfo() + info.set_points(pts) + info.set_facets(facets, facet_nums) + info.set_holes(holes) + info.regions.resize(len(regions)) + for i, region in enumerate(regions): + info.regions[i] = tuple(region) + return info + + +def _build_periodic_3d(polymesh, phases, kps, pts, facet_nums, holes, regions, + opts, max_volume, max_edge_length): + """Build a periodic tetrahedral mesh with TetGen, in two passes. + + In the first pass, the facets are triangulated (identically on + opposite periodic faces) and TetGen meshes the domain as usual, adding + points on the facets where its quality and size settings require it. + The facets are then triangulated again with these points, the facets + on opposite periodic faces getting the points of both, and TetGen + meshes the domain without changing the facets (-Y): the mesh then has + matching nodes on opposite faces, facets refined as in the first pass, + and TetGen still refines the interior of the cells. + + Returns: + The mesh built by MeshPy. + + """ + pts_1, facets_1, nums_1 = _triangulate_facets_3d( + polymesh, phases, kps, pts, facet_nums, max_volume, max_edge_length, + {}, {}) + info = _tet_info(pts_1, facets_1, nums_1, holes, regions) + tri_mesh = meshpy.tet.build(info, options=opts) + tri_pts = np.array(tri_mesh.points) + n_in = len(pts_1) + if len(tri_pts) < n_in or not np.allclose(tri_pts[:n_in], pts_1): + raise RuntimeError('TetGen did not keep the input points.') + + edge_t = {} + face_pts = {} + _collect_facet_points_3d(tri_pts[n_in:], polymesh, edge_t, face_pts) + pts_2, facets_2, nums_2 = _triangulate_facets_3d( + polymesh, phases, kps, pts, facet_nums, max_volume, max_edge_length, + edge_t, face_pts) + info = _tet_info(pts_2, facets_2, nums_2, holes, regions) + opts.nobisect = 1 + return meshpy.tet.build(info, options=opts) + + +def _build_2d(pts, facets, facet_nums, holes, regions, min_angle, + allow_boundary_steiner): + """Build a 2D mesh with Triangle.""" + info = meshpy.triangle.MeshInfo() + info.set_points(pts) + info.set_facets(facets, facet_nums) + info.set_holes(holes) + info.regions.resize(len(regions)) + for i, region in enumerate(regions): + info.regions[i] = tuple(region) + return meshpy.triangle.build(info, attributes=True, + volume_constraints=True, max_volume=None, + min_angle=min_angle, generate_faces=True, + allow_boundary_steiner=allow_boundary_steiner) + + +def _split_periodic_boundary_2d(tri_pts, pts, facets, facet_nums, polymesh, + n_input=None): + """Add the points that Triangle put on the facets to the facets. + + Triangle refines the segments of a mesh where its quality and size + settings require it, but not the same way on opposite periodic faces. + The points it added on a facet of a periodic face and on the image of + the facet on the opposite face are inserted in both facets, as images + of each other, so that the next mesh has matching nodes on opposite + faces. The points of the two facets are merged so that the facets are + as refined as the finest of the two (see :func:`_merge_params`). The + points added on the other facets (the other walls, the facets between + cells, and the facets of the copies of the cells outside the domain) + are inserted as they are, and the points added inside the cells are + appended as points of the input, so that the next mesh contains the + previous one. + + Args: + tri_pts (numpy.ndarray): Points of the mesh built by Triangle. + pts (list): Points of the mesher input. + facets (list): Facets (segments) of the mesher input. + facet_nums (list): Polymesh facet number + 1 of each facet. + polymesh (PolyMesh): The periodic polymesh. + n_input (int): *(optional)* Number of points of the input of the + mesh that Triangle built; the points after them are the ones + it added. Defaults to the number of ``pts``. + + Returns: + tuple: The new points, facets and facet numbers, and the number of + points that Triangle added on the periodic faces. + + """ + pts = [list(p) for p in pts] + if n_input is None: + n_input = len(pts) + new_pts = np.array(tri_pts)[n_input:] + p_arr = np.array(polymesh.points) + mins = p_arr.min(axis=0) + lengths = p_arr.max(axis=0) - mins + scale = lengths.max() + tol = 1e-9 * scale + per_axes = polymesh.periodic_axes + + # the facets (those of the copied cells too), with the parameters of + # the new points on them + seg_t = {} + ends = {} + periodic_segs = set() + on_facets = set() + for i, f_num in enumerate(facet_nums): + pt_a, pt_b = np.array(pts[facets[i][0]]), np.array(pts[facets[i][1]]) + seg_t[i], ids = _points_on_segment(new_pts, pt_a, pt_b, True) + on_facets.update(ids) + ends[i] = (pt_a, pt_b) + if f_num <= 0: + continue # a facet of a copied cell, outside the domain + wall = min(polymesh.facet_neighbors[f_num - 1]) + if wall < 0 and per_axes[_misc.wall_axis_side(wall)[0]]: + periodic_segs.add(i) + n_new = sum([len(seg_t[i]) for i in periodic_segs]) + + # the points that are not on a facet + free = [k for k in range(len(new_pts)) if k not in on_facets] + free_pts = new_pts[free].tolist() + + # a facet on a lower periodic face and its image on the upper face, + # matched by their midpoints + seg_ids = sorted(periodic_segs) + mids = np.array([0.5 * (ends[i][0] + ends[i][1]) for i in seg_ids]) + tree = cKDTree(mids) if seg_ids else None + pair_of = {} + for i in seg_ids: + pt_a, pt_b = ends[i] + for axis, flag in enumerate(per_axes): + if not flag or not np.allclose([pt_a[axis], pt_b[axis]], + mins[axis], atol=tol): + continue + shift = np.zeros(len(mins)) + shift[axis] = lengths[axis] + dist, k = tree.query(0.5 * (pt_a + pt_b) + shift) + j = seg_ids[k] + if dist <= tol and j != i: + same = np.allclose(ends[j][0], pt_a + shift, atol=tol) + pair_of[i] = (j, shift, same) + is_upper = set([j for j, _, _ in pair_of.values()]) + + def chain(kp_a, ids, kp_b): + kp_list = [kp_a] + ids + [kp_b] + return [[kp_list[k], kp_list[k + 1]] for k in range(len(kp_list) - 1)] + + new_facets = [] + new_nums = [] + for i, (facet, f_num) in enumerate(zip(facets, facet_nums)): + if i not in seg_t: + new_facets.append(list(facet)) + new_nums.append(f_num) + continue + if i in is_upper: + continue + pt_a, pt_b = ends[i] + t_vals = list(seg_t[i]) + sides = [0] * len(t_vals) + if i in pair_of: + j, shift, same = pair_of[i] + t_vals += [t if same else 1 - t for t in seg_t[j]] + sides += [1] * len(seg_t[j]) + ids = [] + for t_val in _merge_params(t_vals, sides): + ids.append(len(pts)) + pts.append((pt_a + t_val * (pt_b - pt_a)).tolist()) + new_facets.extend(chain(facet[0], ids, facet[1])) + new_nums.extend([f_num] * (len(ids) + 1)) + if i in pair_of: + j, shift, same = pair_of[i] + im_ids = [] + for pid in ids: + im_ids.append(len(pts)) + pts.append((np.array(pts[pid]) + shift).tolist()) + if not same: + im_ids = im_ids[::-1] + new_facets.extend(chain(facets[j][0], im_ids, facets[j][1])) + new_nums.extend([facet_nums[j]] * (len(im_ids) + 1)) + return pts + free_pts, new_facets, new_nums, n_new + + +def _unmatched_periodic_nodes(pts, polymesh): + """Nodes on a periodic face without an image on the opposite face. + + Returns: + list: Tuples of the node number, the axis and the value of the + coordinate of the opposite face. + + """ + pts = np.asarray(pts, dtype='float') + p_arr = np.array(polymesh.points) + mins = p_arr.min(axis=0) + lengths = p_arr.max(axis=0) - mins + tol = 1e-9 * lengths.max() + unmatched = [] + for axis, flag in enumerate(polymesh.periodic_axes): + if not flag: + continue + others = [i for i in range(len(mins)) if i != axis] + for value, opposite in ((mins[axis], mins[axis] + lengths[axis]), + (mins[axis] + lengths[axis], mins[axis])): + on_face = np.nonzero(np.abs(pts[:, axis] - value) <= tol)[0] + on_opp = np.nonzero(np.abs(pts[:, axis] - opposite) <= tol)[0] + if len(on_opp) == 0: + unmatched.extend([(kp, axis, opposite) for kp in on_face]) + continue + tree = cKDTree(pts[on_opp][:, others]) + dists, _ = tree.query(pts[on_face][:, others]) + for kp, dist in zip(on_face, dists): + if dist > tol: + unmatched.append((kp, axis, opposite)) + return unmatched + + +def _mirror_boundary_points_2d(tri_pts, tri_elems, tri_e_atts, polymesh): + """Give every node on a periodic face an image on the opposite face. + + A node without an image is mirrored by splitting the boundary edge of + the opposite face, and the triangle behind it, at the image. This is + only needed for the few points that Triangle keeps adding on the + periodic faces when their refinement does not converge. + + Returns: + tuple: The points, elements and element attributes, and the number + of nodes mirrored. + + """ + pts = [list(p) for p in tri_pts] + elems = [list(e) for e in tri_elems] + atts = list(tri_e_atts) + p_arr = np.array(polymesh.points) + tol = 1e-9 * (p_arr.max(axis=0) - p_arr.min(axis=0)).max() + n_mirrored = 0 + for kp, axis, opposite in _unmatched_periodic_nodes(tri_pts, polymesh): + other = 1 - axis + image = np.array(pts[kp]) + image[axis] = opposite + # a node added by the mirroring of another node may be its image + arr = np.array(pts) + on_opp = np.abs(arr[:, axis] - opposite) <= tol + if np.any(np.abs(arr[on_opp, other] - image[other]) <= tol): + continue + new_kp = len(pts) + split = False + for e_num, elem in enumerate(elems): + e_pts = np.array([pts[k] for k in elem]) + on_line = np.abs(e_pts[:, axis] - opposite) <= tol + if np.sum(on_line) != 2: + continue + k_a, k_b = [elem[k] for k in np.nonzero(on_line)[0]] + v_a, v_b = pts[k_a][other], pts[k_b][other] + if not (min(v_a, v_b) + tol < image[other] < + max(v_a, v_b) - tol): + continue + pts.append(image.tolist()) + # keep the orientation of the split triangle + order = list(elem) + i_a, i_b = order.index(k_a), order.index(k_b) + tri_1 = list(order) + tri_1[i_b] = new_kp + tri_2 = list(order) + tri_2[i_a] = new_kp + elems[e_num] = tri_1 + elems.append(tri_2) + atts.append(atts[e_num]) + split = True + break + if not split: + e_str = 'A node on a periodic face has no image and cannot be ' + e_str += 'mirrored.' + raise RuntimeError(e_str) + n_mirrored += 1 + return (np.array(pts), np.array(elems), np.array(atts, dtype='int'), + n_mirrored) + + +def _ghost_layer(polymesh, phases, labels, kps, pts, facets, facet_nums, + regions, holes, max_volume): + """Add periodic images of the cells outside the periodic faces. + + The cells that touch a periodic face are copied outside that face, + translated by the length of the domain along the axis (and, for the + cells that touch several periodic faces, along each combination of + the axes). The mesher then sees the same geometry on both sides of a + periodic face, and around both faces of a pair, and refines them the + same way. The elements outside the domain are removed after meshing. + + Args: + polymesh (PolyMesh): The periodic polymesh. + phases (list): The phases. + labels (numpy.ndarray): The label of each region of the polymesh. + kps (dict): Maps polymesh point numbers to mesher point numbers. + pts (list): Points of the mesher input. + facets (list): Facets of the mesher input. + facet_nums (list): Polymesh facet number + 1 of each facet. + regions (list): Region points of the mesher input. + holes (list): Hole points of the mesher input. + max_volume (float): The default maximum volume of the elements. + + Returns: + tuple: The points, facets, facet numbers (0 for the facets of the + copies), region points and holes, extended with the copies. + + """ + pts = [list(p) for p in pts] + facets = [list(f) for f in facets] + facet_nums = list(facet_nums) + regions = [list(r) for r in regions] + holes = [list(h) for h in holes] + p_arr = np.array(polymesh.points) + n_dim = p_arr.shape[1] + lengths = p_arr.max(axis=0) - p_arr.min(axis=0) + per_axes = polymesh.periodic_axes + + # the periodic faces touched by each cell, and the translations of its + # copies: +1 moves the cell by the domain length, from the lower face + touched = {} + for f_num, neighs in enumerate(polymesh.facet_neighbors): + wall = min(neighs) + if wall < 0: + axis, side = _misc.wall_axis_side(wall) + if per_axes[axis]: + sign = 1 if side == 0 else -1 + touched.setdefault(max(neighs), {})[axis] = sign + copies = {} + for reg, signs in touched.items(): + axes = sorted(signs) + copies[reg] = [] + for n_sel in range(1, len(axes) + 1): + for combo in itertools.combinations(axes, n_sel): + copies[reg].append(tuple([signs[a] if a in combo else 0 + for a in range(n_dim)])) + + # points by location, so that a location reached from a point and from + # its periodic image, with different translations, is one point + scale = lengths.max() + by_location = {} + for i, pt in enumerate(pts): + by_location.setdefault(tuple(np.round(np.array(pt) / scale, 9)), i) + ghost_pts = {} + + def image_id(kp, trans): + # the mesher point of a polymesh point moved by a translation + key = (kp, trans) + if key in ghost_pts: + return ghost_pts[key] + shift = np.array([s * lengths[a] for a, s in enumerate(trans)]) + new_pt = p_arr[kp] + shift + loc = tuple(np.round(new_pt / scale, 9)) + if loc not in by_location: + by_location[loc] = len(pts) + pts.append(new_pt.tolist()) + ghost_pts[key] = by_location[loc] + return ghost_pts[key] + + existing = set([tuple(sorted(f)) for f in facets]) + done = set() + for reg, trans_list in copies.items(): + phase = phases[polymesh.phase_numbers[reg]] + mat_type = phase.get('material_type', 'solid') + reg_kps = set([kp for f in polymesh.regions[reg] + for kp in polymesh.facets[f]]) + center = p_arr[sorted(reg_kps)].mean(axis=0) + for trans in trans_list: + shift = np.array([s * lengths[a] for a, s in enumerate(trans)]) + cen = (center + shift).tolist() + if mat_type in _misc.kw_void: + holes.append(cen) + else: + regions.append(cen + [int(labels[reg]), + phase.get('max_volume', max_volume)]) + for f in polymesh.regions[reg]: + if (f, trans) in done: + continue + done.add((f, trans)) + neighs = polymesh.facet_neighbors[f] + other = neighs[0] if neighs[1] == reg else neighs[1] + # a facet removed between merged cells is removed between + # their copies too, and the copy of a facet on a periodic + # face, moved along the axis of that face only, is the + # facet on the opposite face (possibly subdivided), which + # the input already has; moved along other axes too, it + # lies outside the domain and closes the copy + if (other >= 0 and trans in copies.get(other, []) and + not facet_check(neighs, polymesh, phases)): + continue + if other < 0: + axis = _misc.wall_axis_side(other)[0] + if (per_axes[axis] and trans[axis] != 0 and + not any([t for a, t in enumerate(trans) + if a != axis])): + continue + ids = [image_id(kp, trans) for kp in polymesh.facets[f]] + key = tuple(sorted(ids)) + if key in existing: + continue + existing.add(key) + facets.append(ids) + facet_nums.append(0) + return pts, facets, facet_nums, regions, holes + + +def _build_periodic_2d(polymesh, phases, labels, kps, pts, facets, + facet_nums, holes, regions, min_angle, max_volume): + """Build a periodic triangular mesh with Triangle, in two passes. + + The cells next to the periodic faces are copied outside the faces + (see :func:`_ghost_layer`) and the mesh is built like a non-periodic + one, so that Triangle refines both faces of a pair the same way, up + to the order of its operations. If some nodes on the periodic faces + have no image on the opposite face, all the points of the mesh become + the input of the next pass: the points on the facets are put in the + facets (those on a periodic face and on its image merged and put on + both, as images of each other) and the others are points of the + input, so that Triangle only refines the mesh around the points + brought from the opposite faces, in the same surroundings (the copies) + that produced them. Passing only the points on the faces back, and + meshing the cells again from scratch, made Triangle split the narrow + corners of the cells again at every pass, down to very small + elements. The elements outside the domain are removed, and the few + nodes that may remain without an image are mirrored by splitting the + elements behind them. + + Returns: + tuple: The points, elements and element attributes. + + """ + pts, facets, facet_nums, regions, holes = _ghost_layer( + polymesh, phases, labels, kps, pts, facets, facet_nums, regions, + holes, max_volume) + tri_mesh = _build_2d(pts, facets, facet_nums, holes, regions, min_angle, + True) + p_arr = np.array(polymesh.points) + mins = p_arr.min(axis=0) + maxs = p_arr.max(axis=0) + tol = 1e-9 * (maxs - mins).max() + for _ in range(_MAX_PERIODIC_PASSES): + # the elements inside the domain + all_pts = np.array(tri_mesh.points) + tri_elems = np.array(tri_mesh.elements) + tri_e_atts = np.array(tri_mesh.element_attributes, dtype='int') + cens = all_pts[tri_elems].mean(axis=1) + inside = np.all((cens >= mins - tol) & (cens <= maxs + tol), axis=1) + tri_elems = tri_elems[inside] + tri_e_atts = tri_e_atts[inside] + used = np.unique(tri_elems) + renum = np.full(len(all_pts), -1) + renum[used] = np.arange(len(used)) + tri_pts = all_pts[used] + tri_elems = renum[tri_elems] + if not _unmatched_periodic_nodes(tri_pts, polymesh): + break + + # all the points of the mesh become the input of the next pass + n_input = len(pts) + if (len(all_pts) < n_input or + not np.allclose(all_pts[:n_input], pts, atol=tol)): + raise RuntimeError('Triangle did not keep the input points.') + pts, facets, facet_nums, n_new = _split_periodic_boundary_2d( + all_pts, pts, facets, facet_nums, polymesh, n_input) + if n_new == 0: + break + tri_mesh = _build_2d(pts, facets, facet_nums, holes, regions, + min_angle, True) + + tri_pts, tri_elems, tri_e_atts, _ = _mirror_boundary_points_2d( + tri_pts, tri_elems, tri_e_atts, polymesh) + return tri_pts, tri_elems, tri_e_atts + + +def _facet_nonplanarity(polymesh): + """Largest distance of a vertex to the plane of its facet. + + The facets of a periodic polymesh are planar only within the tolerance + of the snapping of the points to the periodic faces. The geometric + tests of the meshes against the polymesh use this distance as their + tolerance. + + Returns: + float: The distance, relative to the size of the domain. + + """ + pts = np.array(polymesh.points) + scale = np.max(pts.max(axis=0) - pts.min(axis=0)) + max_dev = 0.0 + for facet in polymesh.facets: + if len(facet) < 4: + continue + loop = pts[facet] + normal = np.zeros(3) + for i in range(len(loop)): + normal += np.cross(loop[i - 1], loop[i]) + norm = np.linalg.norm(normal) + if norm > 0: + dev = np.abs((loop - loop[0]).dot(normal / norm)).max() + max_dev = max(max_dev, dev) + return max_dev / scale + + +def _attributes_from_polymesh(tri_pts, tri_elems, polymesh, labels): + """Element attributes and facets of a mesh, from the polymesh geometry. + + Each element belongs to the (convex) cell of the polymesh that contains + its centroid and its attribute is the label of that cell. The facets of + the mesh are the faces between elements of cells with different labels + and the faces on the boundary of the mesh; their attributes are the + numbers of the polymesh facets they lie on. + + TetGen can leave some sub-faces of a facet unmarked when it may not + modify the boundary (option -Y, used for periodic meshes). The region + attributes it assigns then leak between the cells on either side of + the facet and the facet is incomplete in its output. The geometry of + the polymesh does not have this problem. + + Args: + tri_pts (numpy.ndarray): The points of the mesh. + tri_elems (numpy.ndarray): The elements of the mesh. + polymesh (PolyMesh): The polygon/polyhedron mesh. + labels (numpy.ndarray): The label of each region of the polymesh. + + Returns: + tuple: The element attributes, the facets and the facet attributes. + + Raises: + RuntimeError: If the mesh does not conform to the polymesh, i.e. an + element centroid lies outside every cell or a face between two + cells does not lie on a facet of the polymesh. + """ + n_dim = tri_pts.shape[1] + p_pts = np.array(polymesh.points) + scale = np.max(p_pts.max(axis=0) - p_pts.min(axis=0)) + tol = max(1e-9, 4 * _facet_nonplanarity(polymesh)) * scale + cell_geom = _CellGeometry(polymesh, p_pts) + labels = np.array(labels) + + # 1. Cell containing the centroid of each element: the cell in which + # the centroid is deepest + cens = tri_pts[tri_elems].mean(axis=1) + elem_regs = np.full(len(tri_elems), -1) + depths = np.full(len(tri_elems), -np.inf) + for r_num, r_i, dp in cell_geom.containing(cens, tol): + depth = dp.min(axis=1) + deeper = depth > depths[r_i] + elem_regs[r_i[deeper]] = r_num + depths[r_i[deeper]] = depth[deeper] + n_outside = int(np.sum(depths < -tol)) + if n_outside > 0: + e_str = 'The mesh does not conform to the polymesh: the centroids ' + e_str += 'of ' + str(n_outside) + ' elements are outside every ' + e_str += 'cell.' + raise RuntimeError(e_str) + elem_atts = labels[elem_regs] + + # 2. Faces of the elements, with the cells on either side + n_elems = len(tri_elems) + faces = np.concatenate([np.delete(tri_elems, k, axis=1) + for k in range(n_dim + 1)]) + faces.sort(axis=1) + owners = np.tile(np.arange(n_elems), n_dim + 1) + u_faces, inv, counts = np.unique(faces, axis=0, return_inverse=True, + return_counts=True) + if np.any(counts > 2): + e_str = 'The mesh is not a manifold: a face is shared by more ' + e_str += 'than two elements.' + raise RuntimeError(e_str) + order = np.argsort(inv.reshape(-1), kind='stable') + starts = np.cumsum(counts) - counts + two = counts == 2 + r1 = elem_regs[owners[order[starts]]] + r2 = np.full(len(u_faces), -1) + r2[two] = elem_regs[owners[order[starts[two] + 1]]] + + # 3. Facets: faces between cells with different labels and faces on the + # boundary, numbered by the polymesh facet they lie on. Cells with the + # same label are not separated by facets of the mesh, so an element can + # span several of them: the facet is found from the cells that contain + # the center of the face, which lies on that facet. + is_facet = np.full(len(u_faces), True) + is_facet[two] = labels[r1[two]] != labels[r2[two]] + f_ids = np.nonzero(is_facet)[0] + f_cens = tri_pts[u_faces[f_ids]].mean(axis=1) + claims = [[] for _ in f_ids] + for r_num, c_i, dp in cell_geom.containing(f_cens, tol): + for j in c_i[np.all(dp >= -tol, axis=1)]: + claims[j].append(r_num) + + pair_facets = {} + for f_num, neighs in enumerate(polymesh.facet_neighbors): + if min(neighs) >= 0: + pair_facets[(min(neighs), max(neighs))] = f_num + e_str = 'The mesh does not conform to the polymesh: a face of the mesh ' + e_str += 'is not on a facet of the polymesh.' + facets = [] + facet_atts = [] + for j, i in enumerate(f_ids): + face = u_faces[i] + f_pts = tri_pts[face] + best_dist = float('inf') + best_f = None + for c_1 in [c for c in claims[j] if labels[c] == labels[r1[i]]]: + f_nums, normals, centers = cell_geom.facets(c_1) + rel_pos = f_pts[:, np.newaxis, :] - centers + dists = np.abs(np.einsum('pfd,fd->pf', rel_pos, + normals)).max(axis=0) + if two[i]: + cands = [pair_facets.get((min(c_1, c_2), max(c_1, c_2))) + for c_2 in claims[j] if labels[c_2] == labels[r2[i]]] + else: + # on the boundary of the domain, or of a void cell + cands = [f for f in f_nums if + min(polymesh.facet_neighbors[f]) < 0 or + labels[polymesh.facet_neighbors[f][0]] != + labels[polymesh.facet_neighbors[f][1]]] + for f_num in cands: + if f_num is None: + continue + k = np.nonzero(f_nums == f_num)[0][0] + if dists[k] < best_dist: + best_dist = dists[k] + best_f = int(f_num) + if best_f is None or best_dist > max(1e-8 * scale, tol): + raise RuntimeError(e_str) + facets.append(face) + facet_atts.append(best_f) + facets = np.array(facets, dtype='int').reshape(-1, n_dim) + return elem_atts, facets, np.array(facet_atts, dtype='int') + + +def _abaqus_periodic_nsets(mesh): + """Abaqus node sets of the periodic faces of a mesh. + + For each periodic axis, two unsorted node sets are written, + ``Set-N-Periodic--Low`` and ``Set-N-Periodic--High``, whose + n-th entries are periodic images of each other (so that the pairs can + be tied by equations). + + Args: + mesh (TriMesh): The mesh. + + Returns: + str: The ``*Nset`` blocks, or an empty string for a non-periodic + mesh. + + """ + per_nodes = getattr(mesh, 'periodic_nodes', None) + if not per_nodes: + return '' + abaqus = '' + n_per = 16 + for axis in sorted(per_nodes): + pairs = per_nodes[axis] + if not pairs: + continue + axis_name = 'XYZ'[axis] + for side, kps in (('Low', [lo for lo, _ in pairs]), + ('High', [hi for _, hi in pairs])): + name = 'Set-N-Periodic-' + axis_name + '-' + side + abaqus += '*Nset, nset=' + name + ', unsorted\n' + for i in range(0, len(kps), n_per): + chunk = kps[i:i + n_per] + abaqus += ', '.join([str(int(kp) + 1) for kp in chunk]) + abaqus += '\n' + return abaqus + + +def _abaqus_exterior_unions(polymesh, defined_surfs): + """Abaqus surfaces that combine the facet surfaces on each domain face. + + Args: + polymesh (PolyMesh): The polygon mesh, whose facet neighbors + identify the facets on each face of the domain. + defined_surfs (set): Facet numbers for which a 'Surface-' + surface has been written. Facets without elements in the mesh + (e.g. on the boundary of voids) have no surface and are not + included in the unions. + + Returns: + str: The '*Surface, combine=union' blocks. + + """ + abaqus = '' + poly_neighbors = np.array(polymesh.facet_neighbors) + poly_mask = np.any(poly_neighbors < 0, axis=1) + neigh_nums = np.min(poly_neighbors, axis=1) + u_neighs = np.unique(neigh_nums[poly_mask]) + for neigh_num in u_neighs: + f_nums = np.nonzero(neigh_nums == neigh_num)[0] + members = [int(i) for i in f_nums if int(i) in defined_surfs] + if not members: + continue + facet_name = 'Ext-Surface-' + str(-neigh_num) + abaqus += '*Surface, name=' + facet_name + ', combine=union\n' + abaqus += ''.join(['Surface-' + str(i) + '\n' for i in members]) + return abaqus + + +def _vtk_lines(values): + """Join strings into lines of fewer than 80 characters.""" + lines = [] + line = '' + for v_str in values: + if not line: + line = v_str + elif len(line) + 1 + len(v_str) < 80: + line += ' ' + v_str + else: + lines.append(line) + line = v_str + lines.append(line) + return '\n'.join(lines) + '\n' + + +# --------------------------------------------------------------------------- # +# # +# Raster Mesh Helpers # +# # +# --------------------------------------------------------------------------- # +# Offsets of the corner nodes of a pixel/voxel from its minimum corner, in +# element node order: counter-clockwise in 2D and, in 3D, nodes 1-4 on the +# bottom (-z) face counter-clockwise followed by nodes 5-8 on the top face, +# so that the element is right-handed (Abaqus CPS4 / C3D8 ordering). +_RASTER_CORNERS = { + 2: [(0, 0), (1, 0), (1, 1), (0, 1)], + 3: [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), + (0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)], +} + +# Local node numbers of the faces of a pixel/voxel, keyed by (axis, side), +# where side 0 is the face at the minimum of the axis and side 1 the face +# at its maximum. The node order follows the Abaqus face definitions. +_RASTER_FACES = { + 2: {(0, 0): [3, 0], (0, 1): [1, 2], (1, 0): [0, 1], (1, 1): [2, 3]}, + 3: {(0, 0): [3, 7, 4, 0], (0, 1): [1, 5, 6, 2], + (1, 0): [0, 4, 5, 1], (1, 1): [2, 6, 7, 3], + (2, 0): [0, 1, 2, 3], (2, 1): [4, 7, 6, 5]}, +} + +# Abaqus face ids (S1, S2, ...) of the faces above. +# CPS4: S1 = 1-2, S2 = 2-3, S3 = 3-4, S4 = 4-1 +# C3D8: S1 = 1-2-3-4, S2 = 5-8-7-6, S3 = 1-5-6-2, S4 = 2-6-7-3, +# S5 = 3-7-8-4, S6 = 4-8-5-1 +_ABAQUS_FACE_IDS = { + 2: {(0, 0): 4, (0, 1): 2, (1, 0): 1, (1, 1): 3}, + 3: {(0, 0): 6, (0, 1): 4, (1, 0): 3, (1, 1): 5, (2, 0): 1, (2, 1): 2}, +} + + +class _CellGeometry(object): + """Geometry of the (convex) cells of a polymesh, computed on demand. + + For each cell, the bounding box and the inward unit normals and centers + of its facets are cached the first time they are needed. + + Args: + polymesh (PolyMesh): The polygon/polyhedron mesh. + p_pts (numpy.ndarray): The points of the polymesh, as an array. + + """ + def __init__(self, polymesh, p_pts): + self.polymesh = polymesh + self.p_pts = p_pts + self._cache = {} + + def _compute(self, cell): + region = self.polymesh.regions[cell] + facets = self.polymesh.facets + r_kps = np.unique([k for f in region for k in facets[f]]) + r_pts = self.p_pts[r_kps] + r_cen = r_pts.mean(axis=0) + + normals = [] + centers = [] + for f in region: + u_in, f_cen = _facet_in_normal(self.p_pts[facets[f]], r_cen) + normals.append(u_in) + centers.append(f_cen) + limits = (r_pts.min(axis=0), r_pts.max(axis=0)) + self._cache[cell] = (np.array(region), np.array(normals), + np.array(centers), limits) + + def facets(self, cell): + """Facet numbers, inward unit normals, and facet centers of a cell. + """ + if cell not in self._cache: + self._compute(cell) + return self._cache[cell][:3] + + def limits(self, cell): + """Bounding box (mins, maxs) of a cell.""" + if cell not in self._cache: + self._compute(cell) + return self._cache[cell][3] + + def containing(self, points, tol): + """Cells whose bounding box contains some of the points. + + Args: + points (numpy.ndarray): The points. + tol (float): Tolerance of the bounding box test. + + Yields: + tuple: The cell number, the indices of the points in its + bounding box, and the signed distances of those points to the + planes of the facets of the cell (one row per point, one + column per facet, positive inside the cell). + + """ + for cell in range(len(self.polymesh.regions)): + r_mins, r_maxs = self.limits(cell) + in_box = np.all((points >= r_mins - tol) & + (points <= r_maxs + tol), axis=1) + cand = np.nonzero(in_box)[0] + if len(cand) == 0: + continue + _, normals, centers = self.facets(cell) + rel_pos = points[cand][:, np.newaxis, :] - centers + yield cell, cand, np.einsum('efd,fd->ef', rel_pos, normals) + + def exit_facet(self, cell, origin, direction): + """Facet through which the ray origin + t * direction leaves a cell. + + Returns: + tuple: The facet number and the value of t at the crossing, or + (None, None) if the ray does not leave the cell. + + """ + f_nums, normals, centers = self.facets(cell) + denom = normals.dot(direction) + exiting = denom < 0 + if not np.any(exiting): + return None, None + + t_vals = np.full(len(f_nums), float('inf')) + rel_pos = centers[exiting] - origin + t_vals[exiting] = np.einsum('ij,ij->i', rel_pos, normals[exiting]) + t_vals[exiting] /= denom[exiting] + i_min = np.argmin(t_vals) + return int(f_nums[i_min]), float(t_vals[i_min]) + + +def _raster_facet_number(r1, r2, c1, c2, polymesh, phases, cell_geom, + pair_facets): + """Polymesh facet approximated by the face between two pixels/voxels. + + The first pixel is centered at ``c1``, inside cell ``r1``, and the second + at ``c2``, inside cell ``r2`` (``r2 < 0`` if it is not in any cell). + If the cells are neighbors, the facet between them is returned. + Otherwise, the facets crossed by the segment from ``c1`` to ``c2`` are + found by walking through the cells of the polymesh, and the one that + the mesh keeps (see :func:`facet_check`) closest to the face between + the pixels is returned. Returns None if no facet is found. + """ + if r2 >= 0: + key = (min(r1, r2), max(r1, r2)) + if key in pair_facets: + return pair_facets[key] + + direction = np.asarray(c2, dtype='float') - np.asarray(c1, dtype='float') + cell = r1 + t_prev = 0 + crossed = [] + for _ in range(len(polymesh.regions)): + f_num, t = cell_geom.exit_facet(cell, c1, direction) + if f_num is None or t < t_prev - 1e-9: + break + if r2 >= 0 and t > 1 + 1e-6: + break + crossed.append((t, f_num)) + t_prev = t + + neighs = polymesh.facet_neighbors[f_num] + if cell not in neighs: + break + nxt = neighs[1] if neighs[0] == cell else neighs[0] + if nxt < 0 or nxt == r2: + break + cell = nxt + + if not crossed: + return None + ranked = [(abs(t - 0.5), f) for t, f in crossed if + facet_check(polymesh.facet_neighbors[f], polymesh, phases)] + if not ranked: + ranked = [(abs(t - 0.5), f) for t, f in crossed] + return min(ranked)[1] + + +def _raster_facets(polymesh, phases, cell_geom, elems, elem_grid, elem_regs, + keep, cens, mesh_size): + """Facets of a raster mesh, with polymesh facet numbers as attributes. + + A facet is created on the face between two kept pixels of different + cells when the polymesh facet between them is kept in the mesh (see + :func:`facet_check`), on the face between a kept pixel and a removed + one (void, or outside the domain), and on the faces of the kept pixels + on the boundary of the grid. + + Args: + polymesh (PolyMesh): The polygon/polyhedron mesh. + phases (list): Phase dictionaries. + cell_geom (_CellGeometry): Geometry of the cells of the polymesh. + elems (numpy.ndarray): Nodes of each element, in face order. + elem_grid (numpy.ndarray): Element numbers on the pixel grid. + elem_regs (numpy.ndarray): Polymesh cell of each element (-1 if + the center is not in any cell). + keep (numpy.ndarray): Mask of the elements kept in the mesh. + cens (numpy.ndarray): Centers of the elements. + mesh_size (float): Side length of the pixels/voxels. + + Returns: + tuple: Arrays of facets and facet attributes. + + """ + n_dim = elem_grid.ndim + faces = _RASTER_FACES[n_dim] + + # Polymesh facets between pairs of cells and on the domain boundary + pair_facets = {} + bnd_facets = {} + for f_num, neighs in enumerate(polymesh.facet_neighbors): + n1, n2 = neighs + if min(n1, n2) < 0: + bnd_facets[(max(n1, n2), min(n1, n2))] = f_num + else: + pair_facets[(min(n1, n2), max(n1, n2))] = f_num + args = (polymesh, phases, cell_geom, pair_facets) + + facets = [] + facet_atts = [] + for axis in range(n_dim): + direction = np.zeros(n_dim) + direction[axis] = 1 + + # Faces between neighboring pixels along this axis + sl_lo = [slice(None)] * n_dim + sl_hi = [slice(None)] * n_dim + sl_lo[axis] = slice(0, -1) + sl_hi[axis] = slice(1, None) + e_lo = elem_grid[tuple(sl_lo)].ravel() + e_hi = elem_grid[tuple(sl_hi)].ravel() + r_lo = elem_regs[e_lo] + r_hi = elem_regs[e_hi] + mask = (keep[e_lo] | keep[e_hi]) & (r_lo != r_hi) + for e1, e2 in zip(e_lo[mask], e_hi[mask]): + r1 = elem_regs[e1] + r2 = elem_regs[e2] + if keep[e1] and keep[e2]: + if not facet_check([r1, r2], polymesh, phases): + continue + if keep[e1]: + f_num = _raster_facet_number(r1, r2, cens[e1], cens[e2], + *args) + facet = elems[e1][faces[(axis, 1)]] + else: + f_num = _raster_facet_number(r2, r1, cens[e2], cens[e1], + *args) + facet = elems[e2][faces[(axis, 0)]] + if f_num is not None: + facets.append(facet) + facet_atts.append(f_num) + + # Faces on the boundary of the grid: the neighbor id of the domain + # boundary facets is -1 (-x), -2 (+x), -3 (-y), ..., -6 (+z) + for side in (0, 1): + sl_bnd = [slice(None)] * n_dim + sl_bnd[axis] = -side + e_bnd = elem_grid[tuple(sl_bnd)].ravel() + face_id = -(2 * axis + 1 + side) + sgn = 2 * side - 1 + for e1 in e_bnd[keep[e_bnd]]: + r1 = elem_regs[e1] + f_num = bnd_facets.get((r1, face_id)) + if f_num is None: + c2 = cens[e1] + sgn * mesh_size * direction + f_num = _raster_facet_number(r1, -1, cens[e1], c2, *args) + if f_num is not None: + facets.append(elems[e1][faces[(axis, side)]]) + facet_atts.append(f_num) + + n_fkp = len(faces[(0, 0)]) + facets = np.array(facets, dtype='int').reshape(-1, n_fkp) + facet_atts = np.array(facet_atts, dtype='int') + return facets, facet_atts def _plot_2d(ax, mesh, index_by, **kwargs): @@ -1990,7 +3510,7 @@ def _plot_2d(ax, mesh, index_by, **kwargs): plt_kwargs = {} for key, value in kwargs.items(): - if type(value) in (list, np.array): + if isinstance(value, (list, np.ndarray)): plt_value = [] for e_num, e_att in enumerate(mesh.element_attributes): if index_by == 'element': diff --git a/src/microstructpy/seeding/seed.py b/src/microstructpy/seeding/seed.py index 4cc89326..f9d728bc 100644 --- a/src/microstructpy/seeding/seed.py +++ b/src/microstructpy/seeding/seed.py @@ -66,15 +66,39 @@ def __init__(self, seed_geometry, phase=0, breakdown=None, position=None): self.geometry = seed_geometry self.phase = phase - if position is None and self.geometry is not None: - self.position = [0 for _ in range(self.geometry.n_dim)] + if self.geometry is None: + geom_cen = None else: - self.position = position + geom_cen = [float(x) for x in self.geometry.center] + + # A generated breakdown lies at the geometry center; a breakdown + # given by the caller is assumed to already be at ``position``. + generated = breakdown is None + if generated: + if self.geometry is None: + breakdown = [] + else: + breakdown = self.geometry.approximate() + self.breakdown = _breakdown_array(breakdown) - if breakdown is None: - self.breakdown = seed_geometry.approximate() + if position is None: + position = [] if geom_cen is None else geom_cen + if generated and geom_cen is not None: + self._position = geom_cen else: - self.breakdown = breakdown + self._position = [float(x) for x in position] + self.position = position + + # ----------------------------------------------------------------------- # + # Update Breakdown # + # ----------------------------------------------------------------------- # + def update_breakdown(self): + """Recompute the breakdown from the geometry. + + The circles/spheres are recomputed with the geometry's + ``approximate`` method, at the current position of the seed. + """ + self.breakdown = _breakdown_array(self.geometry.approximate()) # ----------------------------------------------------------------------- # # Factory Method # @@ -137,15 +161,11 @@ def factory(cls, seed_type, phase=0, breakdown=None, position=None, geom = None else: geom = geometry.factory(seed_type, **kwargs) - - if breakdown is None: - if seed_type in ('circle', 'sphere'): - breakdown = np.append(geom.center, geom.r).reshape(1, -1) - else: - breakdown = geom.approximate() - - if position is None: - position = [0 for _ in range(geom.n_dim)] + if not hasattr(geom, 'approximate'): + e_str = 'Seeds of type ' + seed_type + ' are not supported,' + e_str += ' since the geometry cannot be approximated by' + e_str += ' circles/spheres.' + raise ValueError(e_str) return cls(geom, phase, breakdown, position) @@ -190,8 +210,9 @@ def from_str(cls, seed_str): if 'breakdown' in str_dict: breakdown = str_dict['breakdown'] - if not isinstance(breakdown[0], tuple): + if not isinstance(breakdown[0], (tuple, list)): breakdown = (breakdown,) + breakdown = _breakdown_array(breakdown) del str_dict['breakdown'] else: breakdown = None @@ -212,11 +233,14 @@ def __str__(self): str_str = 'Geometry: ' + geom_name + '\n' str_str += str(self.geometry) + '\n' str_str += 'Phase: ' + str(self.phase) + '\n' - bkdwn_str = ', '.join([str(tuple(b)) for b in self.breakdown]) + # plain floats, so that the string can be parsed back exactly + bkdwn_str = ', '.join([repr(tuple([float(x) for x in b])) for b in + self.breakdown]) if len(self.breakdown) == 1: bkdwn_str += ',' # breakdowns will be a tuple of length 1 str_str += 'Breakdown: (' + bkdwn_str + ')\n' - str_str += 'Position: (' + ', '.join([str(x) for x in self.position]) + str_str += 'Position: (' + str_str += ', '.join([repr(float(x)) for x in self.position]) str_str += ')' return str_str @@ -224,9 +248,13 @@ def __repr__(self): repr_str = 'Seed(' repr_str += repr(self.geometry) + ', ' repr_str += 'phase=' + repr(self.phase) + ', ' - bkdwn_str = ', '.join([repr(tuple(b)) for b in self.breakdown]) + bkdwn_str = ', '.join([repr(tuple([float(x) for x in b])) for b in + self.breakdown]) + if len(self.breakdown) == 1: + bkdwn_str += ',' repr_str += 'breakdown=(' + bkdwn_str + '), ' - repr_str += 'position=(' + ', '.join([repr(x) for x in self.position]) + repr_str += 'position=(' + repr_str += ', '.join([repr(float(x)) for x in self.position]) repr_str += ')' repr_str += ')' return repr_str @@ -261,17 +289,24 @@ def __eq__(self, seed): if seed.phase != self.phase: return False - if not np.all(np.isclose(seed.breakdown, self.breakdown)): + b1 = np.array(self.breakdown, dtype='float') + b2 = np.array(seed.breakdown, dtype='float') + if b1.shape != b2.shape or not np.allclose(b1, b2): return False if seed.geometry != self.geometry: return False - if not np.all(np.isclose(seed.position, self.position)): + p1 = np.array(self.position, dtype='float') + p2 = np.array(seed.position, dtype='float') + if p1.shape != p2.shape or not np.allclose(p1, p2): return False return True + def __ne__(self, seed): + return not self.__eq__(seed) + # ----------------------------------------------------------------------- # # Position Getter/Setter # # ----------------------------------------------------------------------- # @@ -291,26 +326,20 @@ def position(self): @position.setter def position(self, pos): - try: - old_pos = np.array(self.position) - except AttributeError: + pos = [float(x) for x in pos] + old_pos = getattr(self, '_position', None) + if old_pos is None or len(old_pos) != len(pos): old_pos = np.zeros(len(pos)) - try: - displace = np.array(pos) - old_pos - for i, bkdwn in enumerate(self.breakdown): - coords = bkdwn[:-1] - rad = bkdwn[-1] - new_coords = [x + d for x, d in zip(coords, displace)] - new_bkdwn = new_coords + [rad] - self.breakdown[i] = new_bkdwn - except AttributeError: - pass - - try: + breakdown = getattr(self, 'breakdown', None) + if breakdown is not None and len(breakdown) > 0 and len(pos) > 0: + displace = np.array(pos) - np.array(old_pos, dtype='float') + new_breakdown = _breakdown_array(breakdown) + new_breakdown[:, :-1] += displace.reshape(1, -1) + self.breakdown = new_breakdown + + if self.geometry is not None: self.geometry.center = pos - except AttributeError: - pass self._position = pos @@ -379,3 +408,11 @@ def plot_breakdown(self, **kwargs): else: [geometry.Sphere(r=r, center=(x, y, z)).plot(**kwargs) for x, y, z, r in self.breakdown] + + +def _breakdown_array(breakdown): + """Breakdown as an N x (d + 1) array of floats (N x 0 if empty).""" + arr = np.array(breakdown, dtype='float') + if arr.size == 0: + return np.zeros((0, 0)) + return arr.reshape(-1, arr.shape[-1]) diff --git a/src/microstructpy/seeding/seedlist.py b/src/microstructpy/seeding/seedlist.py index 0aade885..a2f1bae0 100644 --- a/src/microstructpy/seeding/seedlist.py +++ b/src/microstructpy/seeding/seedlist.py @@ -11,6 +11,7 @@ from __future__ import division from __future__ import print_function +import itertools import warnings import aabbtree @@ -52,8 +53,8 @@ class SeedList(object): # ----------------------------------------------------------------------- # # Constructors # # ----------------------------------------------------------------------- # - def __init__(self, seeds=[]): - self.seeds = seeds + def __init__(self, seeds=None): + self.seeds = [] if seeds is None else seeds @classmethod def from_file(cls, filename): @@ -145,6 +146,10 @@ def from_info(cls, phases, volume, rng_seeds={}): phase['shape'] = default_shapes[n_dim] # compute volume of each phase + # work on a copy: the seeds are updated below and the caller's + # dictionary must not change + rng_seeds = dict(rng_seeds) + vol_rng = rng_seeds.get('fraction', 0) np.random.seed(vol_rng) @@ -187,7 +192,9 @@ def from_info(cls, phases, volume, rng_seeds={}): seed_shape = phase['shape'] seed_args = {'phase': phase_num} kw_n = 0 - for kw in set(phase) - set(_misc.gen_kws): + # sorted: the RNG seed chain depends on the keyword order, and + # set iteration order changes from one process to the next + for kw in sorted(set(phase) - set(_misc.gen_kws)): # set the RNG seed rng_seed = rng_seeds.get(kw, 0) np.random.seed(rng_seed) @@ -295,7 +302,7 @@ def __add__(self, seedlist): .. versionadded:: 1.1 """ - if type(self) == type(seedlist): + if isinstance(seedlist, SeedList): return SeedList(self.seeds + seedlist.seeds) else: return SeedList(self.seeds + seedlist) @@ -441,7 +448,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): for seed_num, seed in enumerate(self): phase_num = seed.phase for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): if index_by == 'seed' and len(val) > seed_num: seed_args[seed_num][key] = val[seed_num] elif index_by == 'material' and len(val) > phase_num: @@ -575,7 +582,8 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): rects = [Rectangle(xy=xyi, width=wi, height=hi, angle=ai) for xyi, wi, hi, ai in zip(rect_data['xy'], rect_data['w'], rect_data['h'], rect_data['angle'])] - rc = collections.PatchCollection(rects, match_original=False, **rect_kwargs) + rc = collections.PatchCollection(rects, match_original=False, + **rect_kwargs) ax.add_collection(rc) # Plot Polygons @@ -593,7 +601,7 @@ def plot(self, index_by='seed', material=[], loc=0, **kwargs): p_kwargs[p].update(seed_kwargs) else: for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): for i, elem in enumerate(val): p_kwargs[i][key] = elem else: @@ -661,7 +669,7 @@ def plot_breakdown(self, index_by='seed', material=[], loc=0, **kwargs): for seed_num, seed in enumerate(self): phase_num = seed.phase for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): if index_by == 'seed' and len(val) > seed_num: seed_args[seed_num][key] = val[seed_num] elif index_by == 'material' and len(val) > phase_num: @@ -670,7 +678,7 @@ def plot_breakdown(self, index_by='seed', material=[], loc=0, **kwargs): seed_args[seed_num][key] = val n = self[0].geometry.n_dim - if n == 2 or plt.gca().get_axes(): + if n == 2 or plt.gcf().axes: ax = plt.gca() else: ax = plt.gcf().add_subplot(projection=Axes3D.name) @@ -730,7 +738,7 @@ def plot_breakdown(self, index_by='seed', material=[], loc=0, **kwargs): p_kwargs[p].update(seed_kwargs) else: for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): for i, elem in enumerate(val): p_kwargs[i][key] = elem else: @@ -770,7 +778,8 @@ def plot_breakdown(self, index_by='seed', material=[], loc=0, **kwargs): # Position Function # # ----------------------------------------------------------------------- # def position(self, domain, pos_dists={}, rng_seed=0, hold=[], - max_attempts=10000, rtol='fit', verbose=False): + max_attempts=10000, rtol='fit', verbose=False, + periodic=False, periodic_margin=0.0): """Position seeds in a domain This method positions the seeds within a domain. The "domain" should be @@ -830,11 +839,34 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], verbose (bool): *(optional)* This option will print a running counter of how many seeds have been positioned. Defaults to False. + periodic (bool, list, or str): *(optional)* Periodicity of the + microstructure: True for all axes, a list of booleans (one + per axis), or the names of the periodic axes such as + ``'x'`` or ``'xy'``. A seed that crosses a periodic face of + the domain is checked for overlap on both sides, through its + periodic images. Requires a rectangular domain. + Defaults to False. + periodic_margin (float): *(optional)* Minimum distance between + the surface of a seed and a periodic face: a position where + a seed ends within this distance inside a face, or crosses + a face by less than this distance, is rejected and another + one is tried. Such seeds give thin pieces of cells on the + faces, and elements much smaller than the target size of the + mesh; a margin of about half the target edge length avoids + most of them. Defaults to 0 (no margin). """ # NOQA: E501 if len(hold) == 0: hold = [False for seed in self] + # Periodicity: seeds that cross a periodic face take part in the + # overlap test through their images across the domain + per_axes = _misc.periodic_axes(periodic, domain.n_dim) + if any(per_axes): + dom_lims = _misc.periodic_domain_limits(domain) + else: + dom_lims = None + # set the spatial distributions u_dist = [scipy.stats.uniform(lb, ub - lb) for lb, ub in domain.sample_limits] @@ -842,16 +874,19 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], distribs = [] n_phases = max([s.phase for s in self]) + 1 for i in range(n_phases): - distribs.append(pos_dists.get(i, u_dist)) + dist = pos_dists.get(i, u_dist) + if isinstance(dist, (list, tuple)): + # 'random' along an axis means uniform across the domain + dist = [u if (isinstance(d, str) and d.lower() == 'random') + else d for d, u in zip(dist, u_dist)] + distribs.append(dist) # Add hold seeds n_seeds = len(self) tree = aabbtree.AABBTree() for i in range(n_seeds): if hold[i]: - # add to tree - aabb = aabbtree.AABB(self[i].geometry.limits) - tree.add(aabb, i) + _add_to_tree(tree, self[i], i, dom_lims, per_axes) positioned = np.array(hold) vols = np.array([s.volume for s in self]) @@ -860,15 +895,8 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], i_position = i_sort[~posd_sort] # allowable overlap, relative to radius - cv = scipy.stats.variation(vols) - if domain.n_dim == 2 and rtol == 'fit': - numer = 0.362954 * cv * cv - 0.419069 * cv + .184959 - denom = cv * cv - 1.05989 * cv + 0.365096 - rtol = numer / denom - elif rtol == 'fit': - numer = 0.471115 * cv * cv - 0.602324 * cv + 0.297562 - denom = cv * cv - 1.08469 * cv + 0.428216 - rtol = numer / denom + if isinstance(rtol, str) and rtol == 'fit': + rtol = calc_rtol(self) # position the remaining seeds i_reject = [] @@ -900,26 +928,34 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], bkdwn = np.array(seed.breakdown) cens = bkdwn[:, :-1] rads = bkdwn[:, -1].reshape(-1, 1) + limits = seed.geometry.limits - aabb = aabbtree.AABB(seed.geometry.limits) - olap_inds = tree.overlap_values(aabb, method='BFS') - olap_seeds = self[olap_inds] - clears = True - for olap_seed in olap_seeds: - o_bkdwn = np.array(olap_seed.breakdown) - o_cens = o_bkdwn[:, :-1] - o_rads = o_bkdwn[:, -1].reshape(1, -1) + # A seed that ends within the margin of a periodic face, or + # crosses it by less than the margin, is placed elsewhere + if periodic_margin > 0 and not _clears_faces( + limits, dom_lims, per_axes, periodic_margin): + continue - if len(rads) > 1: - dists = distance.cdist(cens, o_cens) - else: - rel_pos = o_cens - cens - rp2 = rel_pos * rel_pos - dists = np.sqrt(np.sum(rp2, axis=1)) - tol = rtol * np.minimum(rads, o_rads) - total_dists = dists + tol - rads - o_rads - if np.any(total_dists < 0): - clears = False + # The seed and its periodic images are tested against the + # placed seeds and their images (the tree holds both) + clears = True + images = _periodic_images(limits, dom_lims, per_axes, + include_zero=True) + for t_seed in images: + aabb = _translated_aabb(limits, t_seed) + s_cens = cens + np.array(t_seed) + for j, t_other in tree.overlap_values(aabb, method='BFS'): + o_bkdwn = np.array(self[j].breakdown) + o_cens = o_bkdwn[:, :-1] + np.array(t_other) + o_rads = o_bkdwn[:, -1].reshape(1, -1) + + dists = distance.cdist(s_cens, o_cens) + tol = rtol * np.minimum(rads, o_rads) + total_dists = dists + tol - rads - o_rads + if np.any(total_dists < 0): + clears = False + break + if not clears: break searching = not clears @@ -930,9 +966,8 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], positioned[i] = True self[i] = seed - # add to tree - aabb = aabbtree.AABB(seed.geometry.limits) - tree.add(aabb, i) + # add to tree, with periodic images + _add_to_tree(tree, seed, i, dom_lims, per_axes) keep_mask = np.array(n_seeds * [True]) keep_mask[i_reject] = False @@ -950,110 +985,95 @@ def position(self, domain, pos_dists={}, rng_seed=0, hold=[], self.seeds = self[keep_mask].seeds -def _get_n_dim(phases): - n_dim = None - for phase in phases: - if 'shape' in phase: - n_dim = geometry.factory(phase['shape']).n_dim - if n_dim is None: - e_str = 'Number of dimensions could not be determined from phase ' - e_str += 'shapes. Consider setting the shape of a phase, or' - e_str += ' specifying the number of dimensions.' - raise ValueError(e_str) - return n_dim - - -def _set_sample_rng_seeds(phases, rng_seeds, maxint): - rng_keys = list({k for p in phases for k in p} - set(_misc.gen_kws)) - rng_keys.extend(['fraction', 'phase']) - - n_keys = len(rng_keys) - int_step = maxint / n_keys - sample_seeds = {} - for i, k in enumerate(rng_keys): - rng_seed = int(rng_seeds.get(k, 0) + i * int_step) - sample_seeds[k] = rng_seed % maxint - return sample_seeds - - -def _calc_pop_fracs(n_dim, phases, sample_rng_seeds, max_int): - # compute volume of each phase - vol_rng = sample_rng_seeds['fraction'] - n_phases = len(phases) - rel_vols = np.ones(n_phases) - for i, phase in enumerate(phases): - vol = phase.get('fraction', 1) - try: - v_sample = -1 - while v_sample < 0: - v_sample = vol.rvs(random_state=vol_rng) - vol_rng = (vol_rng + 1) % max_int - rel_vols[i] = v_sample - except AttributeError: - rel_vols[i] = vol - vol_fracs = rel_vols / sum(rel_vols) - - # Compute the average grain volume of each phase - if n_dim == 2: - avg_vols = [geometry.factory(p['shape']).area_expectation(**p) - for p in phases] - else: - avg_vols = [geometry.factory(p['shape']).volume_expectation(**p) - for p in phases] - weights = vol_fracs / np.array(avg_vols) - pop_fracs = weights / sum(weights) - return pop_fracs +def _clears_faces(limits, dom_lims, per_axes, margin): + """Whether a seed keeps its surface away from the periodic faces. + The surface of the seed must either stay at least ``margin`` inside + the domain or cross the periodic face by at least ``margin``. A seed + that ends just inside a face, or barely crosses it, gives a thin piece + of a cell on one of the two faces of the pair and elements much + smaller than the target size of the mesh there. -def _sample_phase_args(phase, sample_rng_seeds, n_dim, maxint): - seed_kwargs = {} - for kw in set(phase) - set(_misc.gen_kws): - rng_seed = sample_rng_seeds[kw] + Args: + limits (list): Bounding box of the seed, as (min, max) per axis. + dom_lims (list): Limits of the domain, as (min, max) per axis. + per_axes (list): Periodicity flags, one per axis. + margin (float): The margin. - # Sample, with special cases for orientation - if kw not in _misc.ori_kws: - try: - val = phase[kw].rvs(random_state=rng_seed) - except AttributeError: - val = phase[kw] - seed_kwargs[kw] = val - elif (phase[kw] == 'random') and (n_dim == 2): - np.random.seed(rng_seed) - ang_dist = scipy.stats.uniform(loc=0, scale=360) - seed_kwargs['angle_deg'] = ang_dist.rvs(random_state=rng_seed) - elif phase[kw] == 'random': - quat_dist = scipy.stats.norm() - elems = quat_dist.rvs(4, random_state=rng_seed) - mag = np.linalg.norm(elems) - elems /= mag - val = Quaternion(elems).rotation_matrix - seed_kwargs[kw] = val - elif kw in ['rot_seq', 'rot_seq_deg', 'rot_seq_rad']: - seq = [] - val = phase[kw] - if not isinstance(val, list): - val = [val] - for rot_i, rotation in enumerate(val): - rot_dict = {str(kw): rotation[kw] for kw in rotation} - ax = rot_dict.get('axis', 'x') - ang_dist = rot_dict.get('angle', 0) - rot_rng = (rng_seed + rot_i) % maxint - try: - ang = ang_dist.rvs(random_state=rot_rng) - except AttributeError: - ang = ang_dist - seq.append((ax, ang)) - seed_kwargs[kw] = seq - else: - try: - val = phase[kw].rvs(random_state=rng_seed) - except AttributeError: - val = phase[kw] - seed_kwargs[kw] = val + Returns: + bool: True if the seed clears the periodic faces. - # Update the RNG seed - sample_rng_seeds[kw] = (rng_seed + 1) % maxint - return seed_kwargs + """ + for axis, flag in enumerate(per_axes): + if not flag: + continue + lb, ub = dom_lims[axis] + lo, hi = limits[axis] + # signed distances of the ends of the seed to the faces: positive + # inside the domain, negative when the seed crosses the face + for gap in (lo - lb, ub - hi): + if abs(gap) < margin: + return False + return True + + +def _periodic_images(limits, dom_lims, per_axes, include_zero=False): + """Translations of the periodic images of a shape. + + A shape whose bounding box ``limits`` crosses a periodic face of the + domain has an image translated by the domain length across that axis; + crossing several faces (edges, corners) gives every combination. + + Args: + limits (list): (lower, upper) bounds of the shape, per axis. + dom_lims (list or None): (lower, upper) bounds of the domain, or + None for a non-periodic domain. + per_axes (list): Periodicity flag of each axis. + include_zero (bool): Whether to include the zero translation (the + shape itself) as the first entry. + + Returns: + list: Translation tuples. + + """ + n_dim = len(limits) + zero = tuple([0.0 for _ in range(n_dim)]) + images = [zero] if include_zero else [] + if dom_lims is None: + return images + + options = [] + for i in range(n_dim): + opts = [0.0] + if per_axes[i]: + lb, ub = dom_lims[i] + length = ub - lb + if limits[i][0] < lb: + opts.append(length) + if limits[i][1] > ub: + opts.append(-length) + options.append(opts) + for t in itertools.product(*options): + if any([x != 0 for x in t]): + images.append(tuple([float(x) for x in t])) + return images + + +def _translated_aabb(limits, translation): + """Axis-aligned bounding box of a shape translated by a vector.""" + return aabbtree.AABB([(lb + t, ub + t) for (lb, ub), t in + zip(limits, translation)]) + + +def _add_to_tree(tree, seed, index, dom_lims, per_axes): + """Add a seed and its periodic images to an AABB tree. + + The values stored in the tree are (seed index, translation) pairs. + """ + limits = seed.geometry.limits + images = _periodic_images(limits, dom_lims, per_axes, include_zero=True) + for t in images: + tree.add(_translated_aabb(limits, t), (index, t)) def _plt_args(seeds, index_by, kwargs): @@ -1061,7 +1081,7 @@ def _plt_args(seeds, index_by, kwargs): for seed_num, seed in enumerate(seeds): phase_num = seed.phase for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): if index_by == 'seed' and len(val) > seed_num: seed_args[seed_num][key] = val[seed_num] elif index_by == 'material' and len(val) > phase_num: @@ -1161,7 +1181,8 @@ def _plot_2d(ax, seeds, seed_args): # Plot Rectangles rects = [Rectangle(**rect_inps) for rect_inps in rect_data] - rc = collections.PatchCollection(rects, match_original=False, **rect_kwargs) + rc = collections.PatchCollection(rects, match_original=False, + **rect_kwargs) ax.add_collection(rc) ax.autoscale_view() @@ -1204,7 +1225,7 @@ def _add_legend(ax, material, seeds, seed_args, kwargs, index_by, loc): p_kwargs[seed.phase].update(seed_kwargs) else: for key, val in kwargs.items(): - if type(val) in (list, np.array): + if isinstance(val, (list, np.ndarray)): for i, elem in enumerate(val): p_kwargs[i][key] = elem else: @@ -1222,20 +1243,32 @@ def _add_legend(ax, material, seeds, seed_args, kwargs, index_by, loc): def calc_rtol(seeds): - """Calculate relative overlap tolerance.""" - cv = scipy.stats.variation([s.volume for s in seeds]) + """Calculate relative overlap tolerance. + + The tolerance is the error-minimizing rational polynomial fit to the + coefficient of variation in seed area/volume, Eqs. (14) and (15) of + Hart and Rimoli, *Comput. Methods Appl. Mech. Engrg.* 370 (2020) + 113242. + + Args: + seeds (SeedList or list): The seeds, used for their volumes and + number of dimensions. + + Returns: + float: The relative overlap tolerance, between 0 and 1. + """ + vols = [s.volume for s in seeds] + cv = scipy.stats.variation(vols) if len(vols) > 1 else 0.0 n_dim = seeds[0].geometry.n_dim if n_dim == 2: - numer = 0.362954 * cv * cv - 0.419069 * cv + .184959 - denom = cv * cv - 1.05989 * cv + 0.365096 - rtol = numer / denom + numer = 0.182 * cv * cv - 0.0135 * cv + 0.198 + denom = cv * cv - 0.613 * cv + 0.390 elif n_dim == 3: - numer = 0.471115 * cv * cv - 0.602324 * cv + 0.297562 - denom = cv * cv - 1.08469 * cv + 0.428216 - rtol = numer / denom + numer = 0.457 * cv * cv - 0.575 * cv + 0.253 + denom = cv * cv - 1.07 * cv + 0.419 else: raise ValueError('Cannot calculate rtol for {}-D.'.format(n_dim)) - return rtol + return numer / denom def sample_pos(distribution, n=1): @@ -1285,12 +1318,35 @@ def sample_pos(distribution, n=1): return pos -def sample_pos_within(distribution, n, domain): +def sample_pos_within(distribution, n, domain, max_rounds=1000): + """Sample a position distribution, rejecting points outside the domain. + + Args: + distribution (list or scipy.stats distribution): The position + distribution, see :func:`sample_pos`. + n (int): Number of samples. + domain (from :mod:`microstructpy.geometry`): The domain. + max_rounds (int): *(optional)* Maximum number of rejection-sampling + rounds of ``n`` samples each before giving up. + + Returns: + numpy.ndarray: An n x d array of positions within the domain. + + Raises: + ValueError: If no sample fell within the domain after ``max_rounds`` + rounds, which indicates that the distribution does not cover + the domain. + """ pos = [] + n_rounds = 0 while len(pos) < n: - samples = sample_pos(distribution, n) + if n_rounds >= max_rounds: + e_str = 'Could not sample positions within the domain after ' + e_str += str(max_rounds) + ' rounds. Check that the position ' + e_str += 'distribution overlaps the domain.' + raise ValueError(e_str) + samples = np.array(sample_pos(distribution, n)).reshape(n, -1) mask = domain.within(samples) pos.extend(samples[mask]) - if n == 1: - return pos + n_rounds += 1 return np.array(pos[:n]) diff --git a/src/microstructpy/verification.py b/src/microstructpy/verification.py index c31c1b09..fe186d09 100644 --- a/src/microstructpy/verification.py +++ b/src/microstructpy/verification.py @@ -281,6 +281,14 @@ def seeds_of_best_fit(seeds, phases, pmesh, tmesh): poly_facet_is_ext = np.min(poly_facet_neigh_seeds, axis=-1) < 0 n_dim = seeds[0].geometry.n_dim + + # In a periodic domain, a grain can be split into pieces on opposite + # faces: its points are unwrapped around the seed before fitting + per_axes = getattr(pmesh, 'periodic_axes', None) + periodic = per_axes is not None and any(per_axes) + if periodic: + dom_lims = _misc.periodic_bounds(pmesh.points, per_axes) + fit_seeds = [] for i, seed in enumerate(seeds): p = seed.phase @@ -310,6 +318,10 @@ def seeds_of_best_fit(seeds, phases, pmesh, tmesh): kps = np.unique([kp for f in seed_facets for kp in f]) seed_pts = poly_pts[kps.astype('int')] + if periodic: + seed_pts = _misc.unwrap_points(seed_pts, seed.position, + per_axes, dom_lims) + try: fit_geom = seed.geometry.best_fit(seed_pts) except ValueError: @@ -410,10 +422,7 @@ def plot_distributions(seeds, phases, dirname='.', ext='png', poly_mesh=None, plt.gca().add_artist(color_legend) plt.grid(True) - xlbl = ' '.join([s.capitalize() for s in kw.split('_')]) - xlbl = xlbl.replace('Rad', '(radians)').replace('Deg', '(degrees)') - xlbl = xlbl.replace('Orientation', 'Orientation (degrees)') - plt.xlabel(xlbl) + plt.xlabel(_axis_label(kw)) plt.ylabel('Probability Density Function') plt.ylim([0, 1.1 * ymax]) @@ -456,10 +465,7 @@ def plot_distributions(seeds, phases, dirname='.', ext='png', poly_mesh=None, plt.gca().add_artist(color_legend) plt.grid(True) - xlbl = ' '.join([s.capitalize() for s in kw.split('_')]) - xlbl = xlbl.replace('Rad', '(radians)').replace('Deg', '(degrees)') - xlbl = xlbl.replace('Orientation', 'Orientation (degrees)') - plt.xlabel(xlbl) + plt.xlabel(_axis_label(kw)) plt.ylabel('Cumulative Distribution Function') plt.ylim([0, 1]) @@ -478,23 +484,34 @@ def plot_distributions(seeds, phases, dirname='.', ext='png', poly_mesh=None, plt.close() +def _axis_label(kw): + """Axis label for a phase keyword, e.g. 'angle_rad' -> 'Angle (radians)' + """ + units = {'rad': '(radians)', 'deg': '(degrees)'} + words = [units.get(s, s.capitalize()) for s in kw.split('_')] + label = ' '.join(words) + if kw == 'orientation': + label += ' (degrees)' + return label + + def _plot_inp_pdf(kw, i, phase): ymax = 0 inp_dist = phase[kw] color = phase.get('color', 'C' + str(i % 10)) - if kw in ori_deg_kws and phase[kw] == 'random': + if kw in ori_deg_kws and _is_random(inp_dist): x_plt = [0, 360] y_plt = [1 / 360, 1 / 360] plt.plot(x_plt, y_plt, color=color, ls=':') ymax = 1 / 360 - elif kw in ori_rad_kws and phase[kw] == 'random': + elif kw in ori_rad_kws and _is_random(inp_dist): x_plt = [0, 2 * np.pi] y_plt = [0.5 / np.pi, 0.5 / np.pi] plt.plot(x_plt, y_plt, color=color, ls=':') ymax = y_plt[0] - elif phase[kw] == 'random': + elif _is_random(inp_dist): e_str = 'Cannot create PDF for random setting' e_str += ' of keyword <' + str(kw) + '>' raise NotImplementedError(e_str) @@ -505,7 +522,7 @@ def _plot_inp_pdf(kw, i, phase): inp_deg = np.rad2deg(np.arctan2(st, ct)) plt.plot([inp_deg, inp_deg], [0, 1e12], color=color, ls=':') - elif isinstance(inp_dist, list): + elif _is_vector(inp_dist): for j, dist in enumerate(inp_dist): try: lb = dist.ppf(1e-3) @@ -540,16 +557,16 @@ def _plot_inp_cdf(kw, i, phase): inp_dist = phase[kw] color = phase.get('color', 'C' + str(i % 10)) - if kw in ori_deg_kws and phase[kw] == 'random': + if kw in ori_deg_kws and _is_random(inp_dist): x_plt = [0, 360] y_plt = [0, 1] plt.plot(x_plt, y_plt, color=color, ls=':') - elif kw in ori_rad_kws and phase[kw] == 'random': + elif kw in ori_rad_kws and _is_random(inp_dist): x_plt = [0, 2 * np.pi] y_plt = [0, 1] plt.plot(x_plt, y_plt, color=color, ls=':') - elif phase[kw] == 'random': + elif _is_random(inp_dist): e_str = 'Cannot create CDF for random setting' e_str += ' of keyword <' + str(kw) + '>' raise NotImplementedError(e_str) @@ -560,7 +577,7 @@ def _plot_inp_cdf(kw, i, phase): inp_deg = np.rad2deg(np.arctan2(st, ct)) plt.plot([inp_deg, inp_deg], [0, 1], color=color, ls=':') - elif isinstance(inp_dist, list): + elif _is_vector(inp_dist): for j, dist in enumerate(inp_dist): try: x_plt = dist.ppf(quants) @@ -610,8 +627,9 @@ def _plot_out_pdf(kw, i, phase, comp_phase): line_colors.append(Line2D([0], [0], color=color)) line_labels.append(name) - elif isinstance(inp_dist, list): - for j, vals in enumerate(comp_vals): + elif _is_vector(inp_dist): + # vector-valued parameter: one histogram per component + for j, vals in enumerate(comp_vals.T): ys, xbs, _ = plt.hist(vals, density=True, histtype='step', color=color) ymax = max(ymax, np.max(ys)) @@ -656,8 +674,9 @@ def _plot_out_cdf(kw, i, phase, comp_phase): line_colors.append(Line2D([0], [0], color=color)) line_labels.append(name) - elif isinstance(inp_dist, list): - for j, vals in enumerate(comp_vals): + elif _is_vector(inp_dist): + # vector-valued parameter: one curve per component + for j, vals in enumerate(comp_vals.T): x_plt = np.quantile(vals, quants) y_plt = quants @@ -779,9 +798,10 @@ def write_mle_phases(inp_phases, out_phases, filename='mles.txt'): row_dict = {'i': i, 'name': name, 'kw': kw} rows_dict.append(row_dict) continue - if isinstance(inp_dist, list): + if _is_vector(inp_dist): for j in range(len(inp_dist)): - row_dict = {'i': i, 'name': name, 'kw': kw + '[' + j + ']'} + kw_j = kw + '[' + str(j) + ']' + row_dict = {'i': i, 'name': name, 'kw': kw_j} inp_dict = _dist_dict(inp_dist[j]) out_dict = _dist_dict(out_dist[j]) for key in inp_dict: @@ -845,10 +865,10 @@ def _mle_hdr(all_kws): h2 = 'Parameter' elif kw.endswith('_inp'): h1 = 'Input' - h2 = kw.rstrip('_inp') + h2 = kw[:-len('_inp')] elif kw.endswith('_out'): h1 = 'Output' - h2 = kw.rstrip('_out') + h2 = kw[:-len('_out')] else: raise ValueError('Cannot creating heading for keyword ' + str(kw)) hdr1.append(h1) @@ -878,55 +898,117 @@ def error_stats(fit_seeds, seeds, phases, poly_mesh=None, verif_mask=None): Returns: list: List with the same size and dictionary keywords as phases, but with error statistics dictionaries in each entry. + Vector-valued parameters (e.g. ``side_lengths``) get a list with + one dictionary per component. + + .. note:: + + In 2D, a ``random`` orientation (``angle``, ``angle_deg``, + ``angle_rad`` or ``orientation``) is compared against a uniform + distribution over the full circle. Orientation matrices are compared + through their rotation angle, in degrees. In 3D, the statistics of + the ``orientation`` are not computed. """ if verif_mask is None: verif_mask = np.full(len(seeds), True) - # Organize the geometry values - init_phases = _phase_values(seeds, phases, verif_mask=verif_mask) - outp_phases = _phase_values(fit_seeds, phases, poly_mesh, verif_mask) + n_dim = len(seeds[0].position) - err_phases = [] - for i in range(len(phases)): - i_phase = init_phases[i] - o_phase = outp_phases[i] - phase = phases[i] + # Work on a copy of the phases: random orientations are replaced by + # their distributions without altering the caller's input. + phases = copy.deepcopy(phases) + for phase in phases: for kw in phase: - if kw in ('angle', 'angle_deg') and phase[kw] == 'random': + if not _is_random(phase[kw]): + continue + if kw in ('angle', 'angle_deg'): phase[kw] = scipy.stats.uniform(loc=0, scale=360) - if kw == 'angle_rad': + elif kw == 'angle_rad': phase[kw] = scipy.stats.uniform(loc=0, scale=2 * np.pi) - err_io = {kw: _kw_errs(i_phase[kw], o_phase[kw]) for kw in i_phase} - err_po = {kw: _kw_stats(phase[kw], o_phase[kw]) for kw in o_phase} + # Organize the geometry values + init_phases = _phase_values(seeds, phases, verif_mask=verif_mask) + outp_phases = _phase_values(fit_seeds, phases, poly_mesh, verif_mask) + err_phases = [] + for i_phase, o_phase, phase in zip(init_phases, outp_phases, phases): err_phase = {} for kw in i_phase: - if kw == 'orientation': - err_phase[kw] = {} - continue - val = err_io[kw].copy() - val.update(err_po[kw]) - err_phase[kw] = val + i_vals = i_phase[kw] + o_vals = o_phase.get(kw, []) + inp_dist = phase[kw] + + if kw in ('orientation', 'matrix'): + if n_dim != 2: + err_phase[kw] = {} + continue + + # 2D: compare the rotation angles, in degrees + if _is_random(inp_dist): + inp_dist = scipy.stats.uniform(loc=0, scale=360) + i_vals = _matrix_angles(i_vals, wrap=True) + o_vals = _matrix_angles(o_vals, wrap=True) + else: + inp_dist = _matrix_angles([inp_dist])[0] + i_vals = _matrix_angles(i_vals) + o_vals = _matrix_angles(o_vals) + + errs = _kw_errs(i_vals, o_vals) + stats = _kw_stats(inp_dist, o_vals) + err_phase[kw] = _merge_stats(errs, stats) err_phases.append(err_phase) return err_phases -def _kw_errs(y_exp, y_act): - if np.array(y_exp).ndim > 1: - return [_kw_errs(*tup) for tup in zip(y_exp, y_act)] +def _matrix_angles(matrices, wrap=False): + """Rotation angles, in degrees, of 2D rotation matrices + + None entries are preserved. If ``wrap`` is True, the angles are in + [0, 360), otherwise in (-180, 180]. + """ + angles = [] + for m in matrices: + if m is None: + angles.append(None) + continue + ang = np.rad2deg(np.arctan2(m[1][0], m[0][0])) + if wrap: + ang = np.mod(ang, 360) + angles.append(ang) + return angles + +def _merge_stats(errs, stats): + """Merge error and statistics dictionaries (or per-component lists)""" + if isinstance(errs, list) or isinstance(stats, list): + if not isinstance(errs, list): + errs = [errs for _ in stats] + if not isinstance(stats, list): + stats = [stats for _ in errs] + return [_merge_stats(e, s) for e, s in zip(errs, stats)] + + merged = dict(errs) + merged.update(stats) + return merged + + +def _kw_errs(y_exp, y_act): errs = {} - mask = np.array([y_a is not None for y_a in y_act]) - if not np.any(mask): + pairs = [(y_e, y_a) for y_e, y_a in zip(y_exp, y_act) + if y_e is not None and y_a is not None] + if not pairs: return errs - y_expect = np.array(y_exp)[mask] - y_actual = np.array([y_a for y_a in y_act if y_a is not None]) + y_expect = np.array([y_e for y_e, _ in pairs], dtype='float') + y_actual = np.array([y_a for _, y_a in pairs], dtype='float') + + if y_expect.ndim > 1: + # vector-valued parameter: errors per component + return [_kw_errs(*tup) for tup in zip(y_expect.T, y_actual.T)] r = y_actual - y_expect @@ -960,16 +1042,28 @@ def _r2(y_act, y_exp): r_ybar = y_act - y_bar mse_baseline = np.mean(r_ybar * r_ybar) + if mse_baseline == 0: + # constant actual values: R^2 is 1 for a perfect match and + # undefined otherwise + return 1.0 if mse == 0 else float('nan') + coeff_det = 1 - (mse / mse_baseline) return coeff_det def _kw_stats(dist_exp, y_act): - if isinstance(dist_exp, list): - return [_kw_stats(*tup) for tup in zip(dist_exp, y_act)] + y_actual = [y_a for y_a in y_act if y_a is not None] + + if _is_vector(dist_exp): + # vector-valued parameter: statistics per component + if y_actual: + comps = np.array(y_actual, dtype='float').T + else: + comps = [[] for _ in dist_exp] + return [_kw_stats(*tup) for tup in zip(dist_exp, comps)] stats = {} - y_actual = np.array([y_a for y_a in y_act if y_a is not None]) + y_actual = np.array(y_actual, dtype='float') if len(y_actual) == 0: return stats @@ -1027,13 +1121,14 @@ def write_error_stats(errs, phases, filename='error_stats.txt'): name = phase.get('name', 'Material ' + str(i + 1)) kws = set(err_dict.keys()) - set(_misc.gen_kws) - for kw in kws: + for kw in sorted(kws): err_metrics = err_dict[kw] - inp_dist = phase[kw] - if isinstance(inp_dist, list): - for j in range(len(inp_dist)): - row_dict = {'i': i, 'name': name, 'kw': kw + '[' + j + ']'} - row_dict.update(err_metrics[j]) + if isinstance(err_metrics, list): + # vector-valued parameter: one row per component + for j, metrics in enumerate(err_metrics): + kw_j = kw + '[' + str(j) + ']' + row_dict = {'i': i, 'name': name, 'kw': kw_j} + row_dict.update(metrics) rows_dict.append(row_dict) else: row_dict = {'i': i, 'name': name, 'kw': kw} @@ -1110,7 +1205,8 @@ def _safe_mean(x): def _safe_rvs(x, size=1): - if isinstance(x, list): + if _is_vector(x): + # vector-valued parameter: samples are (size, n_components) return np.array([_safe_rvs(xi, size) for xi in x]).T try: @@ -1120,9 +1216,34 @@ def _safe_rvs(x, size=1): return samples +def _is_random(val): + """True if the value is the string 'random'""" + return isinstance(val, str) and val.strip().lower() == 'random' + + +def _is_vector(val): + """True if the value has one entry per component (list, tuple, array)""" + return isinstance(val, (list, tuple, np.ndarray)) + + +def _numeric_values(vals): + """True if the values contain at least one numeric (non-None) entry""" + known = [v for v in vals if v is not None] + if not known: + return False + try: + np.asarray(known, dtype='float') + except (TypeError, ValueError): + return False + return True + + def _phase_values(seeds, phases, poly_mesh=None, verif_mask=None): """Takes the properties of the seeds and organizes them like the phases + Phase keywords that are not (numeric) geometry attributes, such as + ``max_volume`` or free-text fields, are ignored. + """ if verif_mask is None: verif_mask = np.full(len(seeds), True) @@ -1152,7 +1273,8 @@ def _phase_values(seeds, phases, poly_mesh=None, verif_mask=None): else: vals = [_getattr(s.geometry, kw) for s in phase_seeds] - comp_phase[kw] = vals + if _numeric_values(vals): + comp_phase[kw] = vals except AttributeError: pass @@ -1169,8 +1291,10 @@ def _getattr(inst, kw): def _mle_dist(values, dist): - if isinstance(dist, list): - return [_mle_dist(*tup) for tup in zip(values, dist)] + if _is_vector(dist): + # vector-valued parameter: one MLE per component + comps = np.array(values, dtype='float').T + return [_mle_dist(*tup) for tup in zip(comps, dist)] if not (hasattr(dist, 'dist') or isinstance(dist, hist_class)): return np.mean(values) diff --git a/tests/cli/test_cli_fixes.py b/tests/cli/test_cli_fixes.py new file mode 100644 index 00000000..370e9ceb --- /dev/null +++ b/tests/cli/test_cli_fixes.py @@ -0,0 +1,465 @@ +"""Tests for the CLI and input parsing fixes (NOTES.md 5.4, table D)""" + +import contextlib +import copy +import os +import shutil +import signal +import sys + +import numpy as np +import pytest + +from microstructpy import _misc +from microstructpy import cli +from microstructpy import geometry +from microstructpy import seeding +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh +from microstructpy.seeding import Seed + +PKG_EXAMPLES = os.path.join(os.path.dirname(cli.__file__), 'examples') + +HIST_CSV = '0.5, 1\n1, 2, 2.5\n' +CDF_CSV = '1, 0\n2, 0.5\n2.5, 1\n' + +MATERIAL_XML = """ + + {name} + circle + {size} + +""" + +CDF_MATERIAL_XML = """ + + {name} + circle + + cdf + {filename} + + +""" + +DOMAIN_XML = """ + + square + 1 + +""" + + +def _tiny_case(): + """A 2D case with a handful of circles, runs in a fraction of a second""" + phases = [{'shape': 'circle', 'size': 0.4}] + domain = geometry.factory('square', side_length=1) + return phases, domain + + +def _box_polymesh(seed_numbers, phase_numbers): + """A 2x1x1 box split into two unit cubes, both touching the boundary + + Point index = 4 x + 2 y + z, for x in {0, 1, 2} and y, z in {0, 1}. + """ + points = [[x, y, z] for x in range(3) for y in range(2) + for z in range(2)] + facets = [[0, 2, 3, 1], # 0: x = 0 + [0, 4, 5, 1], # 1: y = 0, cube 0 + [2, 6, 7, 3], # 2: y = 1, cube 0 + [0, 4, 6, 2], # 3: z = 0, cube 0 + [1, 5, 7, 3], # 4: z = 1, cube 0 + [4, 6, 7, 5], # 5: x = 1, shared + [4, 8, 9, 5], # 6: y = 0, cube 1 + [6, 10, 11, 7], # 7: y = 1, cube 1 + [4, 8, 10, 6], # 8: z = 0, cube 1 + [5, 9, 11, 7], # 9: z = 1, cube 1 + [8, 10, 11, 9]] # 10: x = 2 + regions = [[0, 1, 2, 3, 4, 5], [5, 6, 7, 8, 9, 10]] + facet_neighbors = [[0, -1], [0, -3], [0, -4], [0, -5], [0, -6], [0, 1], + [1, -3], [1, -4], [1, -5], [1, -6], [1, -2]] + volumes = [1.0, 1.0] + return PolyMesh(points, facets, regions, seed_numbers, phase_numbers, + facet_neighbors, volumes) + + +def _cube_trimesh(): + """Tetrahedral mesh of the first unit cube of :func:`_box_polymesh` + + The element attributes are the seed number (0) and the facet attributes + are the numbers of the polymesh facets the triangles belong to. + """ + def ind(x, y, z): + return 4 * x + 2 * y + z + + points = [[x, y, z] for x in range(2) for y in range(2) + for z in range(2)] + o, e = ind(0, 0, 0), ind(1, 1, 1) + path = [ind(1, 0, 0), ind(1, 1, 0), ind(0, 1, 0), ind(0, 1, 1), + ind(0, 0, 1), ind(1, 0, 1)] + elements = [[o, path[i], path[(i + 1) % 6], e] for i in range(6)] + + quads = {0: [0, 2, 3, 1], 1: [0, 4, 5, 1], 2: [2, 6, 7, 3], + 3: [0, 4, 6, 2], 4: [1, 5, 7, 3], 5: [4, 6, 7, 5]} + facets = [] + facet_attributes = [] + for f_num, quad in quads.items(): + facets.append(quad[:3]) + facets.append([quad[0], quad[2], quad[3]]) + facet_attributes.extend([f_num, f_num]) + return TriMesh(points, elements, [0] * len(elements), facets, + facet_attributes) + + +@contextlib.contextmanager +def _time_limit(seconds): + """Fail if the block runs longer than the given time (POSIX only) + + An alarm signal interrupts an endless loop in pure Python code, which + is how the visibility walk used to fail. + """ + if not hasattr(signal, 'SIGALRM'): + yield + return + + def handler(signum, frame): + raise RuntimeError('the function did not terminate') + + old_handler = signal.signal(signal.SIGALRM, handler) + signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, old_handler) + + +# --------------------------------------------------------------------------- # +# D1: cdf bins are masses, not densities # +# --------------------------------------------------------------------------- # +def test_cdf_matches_equivalent_histogram(tmp_path): + hist_file = tmp_path / 'hist.csv' + hist_file.write_text(HIST_CSV) + cdf_file = tmp_path / 'cdf.csv' + cdf_file.write_text(CDF_CSV) + + d_hist = cli._dist_convert({'dist_type': 'histogram', + 'filename': str(hist_file)}) + d_cdf = cli._dist_convert({'dist_type': ' cdf ', + 'filename': str(cdf_file)}) + for dist in (d_hist, d_cdf): + assert np.isclose(dist.cdf(2), 0.5) + assert np.isclose(dist.mean(), 1.875) + + +def test_cdf_file_is_reproduced(tmp_path): + basename = 'aphanitic_cdf.csv' + src = os.path.join(PKG_EXAMPLES, basename) + if not os.path.exists(src): + src = os.path.join(PKG_EXAMPLES, basename) + if not os.path.exists(src): + pytest.skip(basename + ' not available') + dst = tmp_path / basename + shutil.copy(src, str(dst)) + + xs, cdf_exp = np.loadtxt(str(dst), delimiter=',').T + dist = cli._dist_convert({'dist_type': 'cdf', 'filename': str(dst)}) + cdf_act = dist.cdf(xs) + + # The first value can be non-zero in the file while the histogram + # starts at 0 there; the remaining mismatch is the normalization. + assert np.max(np.abs(cdf_act - cdf_exp)) < 2e-2 + assert np.abs(dist.mean() - np.trapz(1 - cdf_exp, xs) - xs[0]) < 1e-2 + + +def test_pdf_is_alias_of_histogram(tmp_path): + hist_file = tmp_path / 'hist.csv' + hist_file.write_text(HIST_CSV) + + dist = cli._dist_convert({'dist_type': ' PDF ', + 'filename': str(hist_file)}) + assert np.isclose(dist.cdf(2), 0.5) + assert np.isclose(dist.mean(), 1.875) + + +# --------------------------------------------------------------------------- # +# D2: plot_tri visibility walk with a void region on the boundary # +# --------------------------------------------------------------------------- # +def test_visible_regions_void_on_boundary(): + pmesh = _box_polymesh([0, 1], [0, 1]) + phases = [{'material_type': 'solid'}, {'material_type': 'void'}] + + with _time_limit(10): + vis = cli._visible_regions(pmesh, phases) + assert vis == {0} + + # the shared facet shows the solid region, the void's exterior nothing + invis = set(range(-6, 0)) + assert cli._visible_neighbor([0, 1], vis, invis) == 0 + assert cli._visible_neighbor([1, 0], vis, invis) == 0 + assert cli._visible_neighbor([1, -2], vis, invis) is None + assert cli._visible_neighbor([0, -1], vis, invis) == 0 + + +def test_visible_regions_all_solid_and_2d(): + pmesh = _box_polymesh([0, 1], [0, 0]) + assert cli._visible_regions(pmesh, [{}]) == {0, 1} + + points = [[0, 0], [1, 0], [1, 1], [0, 1]] + facets = [[0, 1], [1, 2], [2, 3], [3, 0]] + pmesh_2d = PolyMesh(points, facets, [[0, 1, 2, 3]], [0], [0], + [[0, -3], [0, -2], [0, -4], [0, -1]], [1.0]) + assert cli._visible_regions(pmesh_2d, [{}]) == {0} + + +def test_plot_tri_void_on_boundary(tmp_path): + pmesh = _box_polymesh([0, 1], [0, 1]) + tmesh = _cube_trimesh() + seeds = seeding.SeedList([Seed.factory('sphere', phase=0, r=0.5), + Seed.factory('sphere', phase=1, r=0.5)]) + phases = [{'name': 'Solid', 'material_type': 'solid', 'color': 'C0'}, + {'name': 'Hole', 'material_type': 'void', 'color': 'C1'}] + + plot_file = tmp_path / 'trimesh.png' + with _time_limit(30): + cli.plot_tri(tmesh, phases, seeds, pmesh, [str(plot_file)]) + assert plot_file.exists() + + +# --------------------------------------------------------------------------- # +# D3: relative filenames inside repeated tags # +# --------------------------------------------------------------------------- # +def test_relative_filename_in_repeated_materials(tmp_path, monkeypatch): + (tmp_path / 'sizes_cdf.csv').write_text(CDF_CSV) + mats = ''.join([CDF_MATERIAL_XML.format(name=n, filename='sizes_cdf.csv') + for n in ('A', 'B')]) + xml_file = tmp_path / 'input.xml' + xml_file.write_text('' + mats + DOMAIN_XML + '') + + elsewhere = tmp_path / 'elsewhere' + elsewhere.mkdir() + monkeypatch.chdir(str(elsewhere)) + + in_data = cli.read_input(str(xml_file)) + phases = in_data['material'] + assert len(phases) == 2 + for phase in phases: + assert np.isclose(phase['size'].cdf(2), 0.5) + + +# --------------------------------------------------------------------------- # +# D8, D9: from_str booleans, infinities # +# --------------------------------------------------------------------------- # +def test_from_str_words_containing_booleans(): + assert _misc.from_str('true_cdf.csv') == 'true_cdf.csv' + assert _misc.from_str('Falsework') == 'Falsework' + assert _misc.from_str('/data/true_north/cdf.csv') == \ + '/data/true_north/cdf.csv' + + +def test_from_str_booleans(): + assert _misc.from_str('True') is True + assert _misc.from_str('true') is True + assert _misc.from_str('FALSE') is False + assert _misc.from_str(' false ') is False + assert _misc.from_str('(true, false)') == (True, False) + assert _misc.from_str('[True, FALSE]') == [True, False] + + +def test_from_str_special_floats(): + assert _misc.from_str('inf') == float('inf') + assert _misc.from_str(' inf ') == float('inf') + assert _misc.from_str('-inf') == -float('inf') + assert np.isnan(_misc.from_str('nan')) + assert _misc.from_str('1e-3') == 1e-3 + assert isinstance(_misc.from_str('2'), int) + + +def test_dict_convert_inf_setting(): + settings = cli.dict_convert({'settings': {'mesh_max_volume': ' inf '}}) + val = settings['settings']['mesh_max_volume'] + assert isinstance(val, float) + assert val == float('inf') + + +# --------------------------------------------------------------------------- # +# D10: run() must not modify the caller's dictionaries # +# --------------------------------------------------------------------------- # +def test_run_does_not_mutate_arguments(tmp_path): + phases, domain = _tiny_case() + rng_seeds = {'position': 1, 'size': 2} + filetypes = {'seeds': ['txt'], 'seeds_plot': [], 'poly_plot': [], + 'tri_plot': []} + rng_exp = copy.deepcopy(rng_seeds) + ft_exp = copy.deepcopy(filetypes) + + for dirname, restart in (('a', True), ('b', False)): + cli.run(phases, domain, restart=restart, + directory=str(tmp_path / dirname), filetypes=filetypes, + rng_seeds=rng_seeds, verify=False) + assert rng_seeds == rng_exp + assert filetypes == ft_exp + + seeds_a = (tmp_path / 'a' / 'seeds.txt').read_text() + seeds_b = (tmp_path / 'b' / 'seeds.txt').read_text() + assert seeds_a == seeds_b + assert not (tmp_path / 'a' / 'seeds.png').exists() + + +def test_run_default_dicts_are_fresh(tmp_path): + phases, domain = _tiny_case() + for dirname in ('a', 'b'): + cli.run(phases, domain, restart=False, + directory=str(tmp_path / dirname), + filetypes={'seeds': 'txt', 'seeds_plot': [], + 'poly_plot': [], 'tri_plot': []}) + seeds_a = (tmp_path / 'a' / 'seeds.txt').read_text() + seeds_b = (tmp_path / 'b' / 'seeds.txt').read_text() + assert seeds_a == seeds_b + + +# --------------------------------------------------------------------------- # +# D11: plus own # +# --------------------------------------------------------------------------- # +def test_include_and_own_materials(tmp_path): + inc_mats = ''.join([MATERIAL_XML.format(name=n, size=0.1) + for n in ('A', 'B')]) + (tmp_path / 'materials.xml').write_text('' + inc_mats + '') + + own_mat = MATERIAL_XML.format(name='C', size=0.3) + xml_file = tmp_path / 'input.xml' + xml_file.write_text('\n materials.xml \n' + + own_mat + DOMAIN_XML + '') + + in_data = cli.read_input(str(xml_file)) + names = [p['name'] for p in in_data['material']] + assert names == ['A', 'B', 'C'] + assert [p['size'] for p in in_data['material']] == [0.1, 0.1, 0.3] + + +def test_two_includes_with_materials(tmp_path): + for fname, name in (('a.xml', 'A'), ('b.xml', 'B')): + mat = MATERIAL_XML.format(name=name, size=0.1) + (tmp_path / fname).write_text('' + mat + '') + xml_file = tmp_path / 'input.xml' + xml_file.write_text(' a.xml ' + + ' b.xml ' + DOMAIN_XML + + '') + + in_data = cli.read_input(str(xml_file)) + assert [p['name'] for p in in_data['material']] == ['A', 'B'] + + +def test_include_scalar_override(tmp_path): + (tmp_path / 'base.xml').write_text(' circle ' + ' 0.1 ') + xml_file = tmp_path / 'input.xml' + xml_file.write_text(' base.xml ' + ' 0.3 ' + DOMAIN_XML + + '') + in_data = cli.read_input(str(xml_file)) + assert in_data['material']['shape'] == 'circle' + assert in_data['material']['size'] == 0.3 + + +def test_empty_tag_is_allowed(tmp_path): + xml_file = tmp_path / 'input.xml' + xml_file.write_text(' ' + ' False ') + file_dict = cli.input2dict(str(xml_file)) + in_data = cli.dict_convert(file_dict['input'], str(tmp_path)) + assert in_data['settings']['seeds_kwargs'] == {} + assert in_data['settings']['verbose'] is False + + +# --------------------------------------------------------------------------- # +# D12: coloring by number with a single material or seed # +# --------------------------------------------------------------------------- # +def test_color_by_number_single_item(): + color = cli._phase_color_by(0, [{'color': 'r'}], + color_by='material number') + assert len(color) == 4 + + seeds = seeding.SeedList([Seed.factory('circle', phase=0, r=1)]) + for color_by in ('seed number', 'material number'): + colors = cli._seed_colors(seeds, [{}], color_by=color_by) + assert len(colors) == 1 + assert len(colors[0]) == 4 + + # 3D and 2D polygon meshes with a single seed and phase + pmesh = _box_polymesh([0, 0], [0, 0]) + for color_by in ('seed number', 'material number'): + colors = cli._poly_colors(pmesh, [{}], color_by, 'viridis', 3) + assert len(colors) == 1 + + points = [[0, 0], [1, 0], [1, 1], [0, 1]] + facets = [[0, 1], [1, 2], [2, 3], [3, 0]] + pmesh_2d = PolyMesh(points, facets, [[0, 1, 2, 3]], [0], [0], + [[0, -3], [0, -2], [0, -4], [0, -1]], [1.0]) + for color_by in ('seed number', 'material number'): + colors = cli._poly_colors(pmesh_2d, [{}], color_by, 'viridis', 2) + assert len(colors) == 1 + + +# --------------------------------------------------------------------------- # +# Minor: main() exit code, types, mesher case, edgecolor # +# --------------------------------------------------------------------------- # +def test_main_exits_on_missing_input_file(tmp_path, monkeypatch, capsys): + pattern = str(tmp_path / 'missing*.xml') + monkeypatch.setattr(sys, 'argv', ['microstructpy', pattern]) + with pytest.raises(SystemExit) as exc_info: + cli.main() + assert exc_info.value.code != 0 + assert 'missing' in capsys.readouterr().err + + +def test_unsupported_tri_type_error(tmp_path): + phases, domain = _tiny_case() + with pytest.raises(ValueError, match='nonsense'): + cli.run(phases, domain, restart=False, directory=str(tmp_path), + filetypes={'tri': 'nonsense'}) + + +def test_mesher_name_case_insensitive(tmp_path, monkeypatch): + calls = [] + + class FakeRaster(object): + @classmethod + def from_polymesh(cls, *args, **kwargs): + calls.append('raster') + return cls() + + class FakeTri(object): + @classmethod + def from_polymesh(cls, *args, **kwargs): + calls.append('tri') + return cls() + + monkeypatch.setattr(cli, 'RasterMesh', FakeRaster) + monkeypatch.setattr(cli, 'TriMesh', FakeTri) + + phases, domain = _tiny_case() + filetypes = {'seeds_plot': [], 'poly_plot': [], 'tri_plot': []} + cli.run(phases, domain, restart=False, directory=str(tmp_path), + filetypes=filetypes, mesher=' Raster ') + assert calls == ['raster'] + + +def test_plot_poly_pops_singular_edgecolor(tmp_path, monkeypatch): + points = [[0, 0], [1, 0], [1, 1], [0, 1]] + facets = [[0, 1], [1, 2], [2, 3], [3, 0]] + pmesh = PolyMesh(points, facets, [[0, 1, 2, 3]], [0], [0], + [[0, -3], [0, -2], [0, -4], [0, -1]], [1.0]) + received = {} + + def fake_plot_facets(self, **kwargs): + received.update(kwargs) + + monkeypatch.setattr(PolyMesh, 'plot_facets', fake_plot_facets) + monkeypatch.setattr(PolyMesh, 'plot', lambda self, **kwargs: None) + + plot_file = str(tmp_path / 'polymesh.png') + cli.plot_poly(pmesh, [{}], [plot_file], edgecolor='none') + assert 'edgecolor' not in received + assert 'edgecolors' not in received + assert received['color'] == ['none'] * 4 diff --git a/tests/cli/test_periodic_cli.py b/tests/cli/test_periodic_cli.py new file mode 100644 index 00000000..334f01ba --- /dev/null +++ b/tests/cli/test_periodic_cli.py @@ -0,0 +1,164 @@ +"""End-to-end test of a periodic microstructure through the CLI.""" +import os + +import numpy as np +import pytest + +import microstructpy as msp +from microstructpy import cli +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList + +PERIODIC_XML = """ + + + Matrix + circle + + uniform + 0.25 + 0.15 + + 2 + + + Inclusions + ellipse + 0.4 + 2 + + uniform + 0 + 180 + + 1 + + + + square + 3 + 0, 0 + {periodic} + + + + {directory} + False + 20 + 0.1 + True + {extra} + + +""" + + +def _run(tmp_path, periodic, extra=''): + out_dir = tmp_path / 'out' + xml = tmp_path / 'input.xml' + xml.write_text(PERIODIC_XML.format(periodic=periodic, + directory=str(out_dir), extra=extra)) + cli.run_file(str(xml)) + return out_dir + + +def test_periodic_input_read(): + in_data = cli.dict_convert({'domain': {'shape': 'square', + 'periodic': ' xy '}}) + assert in_data['domain']['periodic'].strip() == 'xy' + + +def test_periodic_run(tmp_path): + out_dir = _run(tmp_path, 'xy') + for name in ('seeds.txt', 'polymesh.txt', 'trimesh.txt', 'seeds.png', + 'polymesh.png', 'trimesh.png'): + assert os.path.exists(str(out_dir / name)) + + pmesh = PolyMesh.from_file(str(out_dir / 'polymesh.txt')) + tmesh = TriMesh.from_file(str(out_dir / 'trimesh.txt')) + assert pmesh.periodic_axes == [True, True] + assert tmesh.periodic_axes == [True, True] + assert np.isclose(sum(pmesh.volumes), 9.0) + + pts = np.array(tmesh.points) + for axis in (0, 1): + shift = np.zeros(2) + shift[axis] = 3 + pairs = tmesh.periodic_nodes[axis] + assert len(pairs) == np.sum(np.isclose(pts[:, axis], 0)) + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + + # verification ran (grains split by the faces are unwrapped) + assert os.path.exists(str(out_dir / 'verification' / 'mles.txt')) + + +def test_periodic_run_single_axis(tmp_path): + out_dir = _run(tmp_path, 'y') + tmesh = TriMesh.from_file(str(out_dir / 'trimesh.txt')) + assert tmesh.periodic_axes == [False, True] + assert list(tmesh.periodic_nodes) == [1] + + +def test_periodic_margin_edge_opt(tmp_path): + # the margin is passed to the edge optimization: no piece thinner than + # the margin (half of mesh_max_edge_length) is left at the faces, + # unless the optimizer could not fix it, and the mesh is periodic + extra = ' True \n 3 ' + extra += '\n auto ' + out_dir = _run(tmp_path, 'xy', extra) + pmesh = PolyMesh.from_file(str(out_dir / 'polymesh.txt')) + tmesh = TriMesh.from_file(str(out_dir / 'trimesh.txt')) + assert pmesh.periodic_axes == [True, True] + assert np.isclose(sum(pmesh.volumes), 9.0) + pts = np.array(tmesh.points) + for axis in (0, 1): + for lo, hi in tmesh.periodic_nodes[axis]: + shift = np.zeros(2) + shift[axis] = 3 + assert np.array_equal(pts[hi], pts[lo] + shift) + # the seeds written are the optimized ones: they reproduce the mesh + seeds = SeedList.from_file(str(out_dir / 'seeds.txt')) + domain = msp.geometry.Square(side_length=3, corner=(0, 0)) + pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) + + +def test_periodic_margin_setting(): + inf = float('inf') + h_2d = np.sqrt(4 * 0.004 / np.sqrt(3)) + assert np.isclose(cli._periodic_margin('auto', 2, 0.004, inf), 0.5 * h_2d) + assert np.isclose(cli._periodic_margin('auto', 2, 0.004, 0.05), 0.025) + h_3d = (6 * np.sqrt(2) * 0.02) ** (1.0 / 3) + assert np.isclose(cli._periodic_margin('auto', 3, 0.02, inf), 0.5 * h_3d) + assert cli._periodic_margin('auto', 3, inf, inf) == 0 + assert cli._periodic_margin(0.03, 2, 0.004, inf) == 0.03 + assert cli._periodic_margin('none', 2, 0.004, inf) == 0 + with pytest.raises(ValueError): + cli._periodic_margin('big', 2, 1, 1) + + # the smallest seed limits the margin to an eighth of its size (its + # smallest diameter or side), so that it keeps four elements across + # it and can satisfy the margin + big = Seed.factory('circle', r=0.5) + small = Seed.factory('circle', r=0.05) + seeds = SeedList([big, small]) + assert np.isclose(cli._periodic_margin('auto', 2, 0.004, inf, [big]), + 0.5 * h_2d) + assert np.isclose(cli._periodic_margin('auto', 2, 0.004, inf, seeds), + 0.1 / 8) + assert np.isclose(cli._periodic_margin('auto', 2, inf, inf, seeds), + 0.1 / 8) + ellipse = Seed.factory('ellipse', a=0.5, b=0.02) + assert np.isclose(cli._periodic_margin('auto', 2, inf, inf, [ellipse]), + 0.04 / 8) + box = Seed.factory('rectangle', side_lengths=[0.3, 0.08]) + assert np.isclose(cli._periodic_margin('auto', 2, inf, inf, [box]), + 0.08 / 8) + sphere = Seed.factory('sphere', r=0.2) + assert np.isclose(cli._periodic_margin('auto', 3, 0.02, inf, [sphere]), + 0.4 / 8) + assert cli._periodic_margin(0.03, 2, 0.004, inf, seeds) == 0.03 diff --git a/tests/geometry/test_geometry_fixes.py b/tests/geometry/test_geometry_fixes.py new file mode 100644 index 00000000..fbdaa03d --- /dev/null +++ b/tests/geometry/test_geometry_fixes.py @@ -0,0 +1,227 @@ +"""Regression tests for the geometry module bug fixes.""" +import itertools + +import matplotlib +import numpy as np +import pytest +import scipy.stats +from matplotlib import pyplot as plt +from pyquaternion import Quaternion + +from microstructpy.geometry import Box +from microstructpy.geometry import Circle +from microstructpy.geometry import Ellipse +from microstructpy.geometry import Ellipsoid +from microstructpy.geometry import Rectangle +from microstructpy.geometry import Sphere +from microstructpy.geometry import Square + +matplotlib.use('agg') + + +def _rot2d(deg): + t = np.radians(deg) + return np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]]) + + +# --------------------------------------------------------------------------- # +# Ellipse # +# --------------------------------------------------------------------------- # +def test_ellipse_axes_keyword(): + e = Ellipse(axes=[2, 1]) + assert e.a == 2 and e.b == 1 + with pytest.raises(ValueError): + Ellipse(axes=[2, -1]) + + +def test_ellipse_matrix_keyword_accepts_rotations(): + for deg in (0, 30, 90, -135): + e = Ellipse(a=2, b=1, matrix=_rot2d(deg)) + assert np.isclose((e.angle - deg + 180) % 360 - 180, 0) + e = Ellipse(a=2, b=1, orientation=_rot2d(deg)) + assert np.isclose((e.angle - deg + 180) % 360 - 180, 0) + with pytest.raises(ValueError): + Ellipse(matrix=[[1, 1], [0, 1]]) + + +def test_ellipse_reflect_broadcasting(): + e = Ellipse(a=2, b=1) + pts = e.reflect([[1, 0], [0, 0.5], [2, 2]]) + assert pts.shape == (3, 2) + # a point on the boundary is its own reflection + assert np.allclose(e.reflect([2, 0]), [2, 0]) + + +def test_ellipse_area_expectation_numpy_scalars(): + exp = 0.25 * np.pi * 4 + assert np.isclose(Ellipse.area_expectation(size=np.float64(2), + aspect_ratio=2), exp) + assert np.isclose(Ellipse.area_expectation(axes=[2, np.float32(1)]), + 2 * np.pi) + assert np.isclose(Ellipse.area_expectation(a=np.float64(2), b=1), + 2 * np.pi) + + +def test_ellipse_area_expectation_deterministic(): + kw = {'a': scipy.stats.uniform(1, 1), + 'aspect_ratio': scipy.stats.uniform(1, 2)} + assert Ellipse.area_expectation(**kw) == Ellipse.area_expectation(**kw) + + +def test_ellipse_equality(): + e1 = Ellipse(a=2, b=1, angle_deg=30, center=(1, 2)) + e2 = Ellipse(a=2, b=1, angle_deg=390, center=(1, 2)) + e3 = Ellipse(a=2, b=1.1, angle_deg=30, center=(1, 2)) + assert e1 == e2 + assert e1 != e3 + assert e1 != Circle(r=1) + + +# --------------------------------------------------------------------------- # +# Ellipsoid # +# --------------------------------------------------------------------------- # +def test_ellipsoid_ratio_bc_with_c(): + e = Ellipsoid(c=1, ratio_bc=2) + assert np.allclose(e.axes, (1, 2, 1)) + e = Ellipsoid(size=2, c=0.5, ratio_bc=2) + assert np.isclose(e.ratio_bc, 2) + assert np.isclose(e.size, 2) + + +def test_ellipsoid_str_round_trip_rot_seq(): + import ast + e = Ellipsoid(a=3, b=2, c=1, matrix=Quaternion.random().rotation_matrix) + line = [ln for ln in str(e).split('\n') if ln.startswith('rot_seq')][0] + rot_seq = ast.literal_eval(line.split(':', 1)[1].strip()) + e2 = Ellipsoid(a=3, b=2, c=1, rot_seq=list(rot_seq)) + assert np.allclose(e2.matrix, e.matrix) + + +@pytest.mark.parametrize('axes', list(itertools.permutations([5, 3, 1]))) +def test_ellipsoid_approximate_all_axis_orderings(axes): + e = Ellipsoid(a=axes[0], b=axes[1], c=axes[2]) + sph = e.approximate() + cen, r = sph[:, :3], sph[:, 3] + # union of spheres spans exactly the ellipsoid extents + assert np.allclose(np.max(np.abs(cen) + r[:, None], axis=0), axes) + # every sphere lies inside the ellipsoid + rng = np.random.RandomState(0) + u = rng.normal(size=(200, 3)) + u /= np.linalg.norm(u, axis=1).reshape(-1, 1) + worst = 0 + for c, rad in zip(cen, r): + p = c + rad * u + worst = max(worst, np.max(np.sum((p / np.array(axes)) ** 2, axis=1))) + assert worst < 1.01 + + +def test_ellipsoid_limits_exact_for_rotations(): + rng = np.random.RandomState(1) + for _ in range(20): + axes = rng.uniform(0.2, 5, 3) + R = Quaternion.random().rotation_matrix + cen = rng.uniform(-2, 2, 3) + e = Ellipsoid(axes=axes, matrix=R, center=cen) + lims = np.array(e.limits) + # brute force over a fine sampling of the surface + u = rng.normal(size=(20000, 3)) + u /= np.linalg.norm(u, axis=1).reshape(-1, 1) + pts = (u * axes).dot(R.T) + cen + lo, hi = pts.min(axis=0), pts.max(axis=0) + assert np.all(lims[:, 0] <= lo + 1e-12) + assert np.all(lims[:, 1] >= hi - 1e-12) + assert np.allclose(lims[:, 0], lo, atol=0.02 * axes.max()) + assert np.allclose(lims[:, 1], hi, atol=0.02 * axes.max()) + e = Ellipsoid(a=3, b=2, c=1, center=(1, 1, 1)) + assert np.allclose(e.limits, [(-2, 4), (-1, 3), (0, 2)]) + + +def test_ellipsoid_volume_expectation_numpy_scalars(): + v = Ellipsoid.volume_expectation(size=np.float64(2), ratio_ab=2) + assert np.isclose(v, 4 * np.pi / 3) + + +def test_ellipsoid_volume_expectation_deterministic(): + kw = {'a': scipy.stats.uniform(1, 1), + 'ratio_ab': scipy.stats.uniform(1, 2), + 'ratio_ac': scipy.stats.uniform(1, 2)} + v1 = Ellipsoid.volume_expectation(**kw) + v2 = Ellipsoid.volume_expectation(**kw) + assert v1 == v2 + + +def test_ellipsoid_reflect_broadcasting(): + e = Ellipsoid(a=2, b=1, c=1) + pts = e.reflect(np.zeros((5, 3)) + [1, 0, 0]) + assert pts.shape == (5, 3) + assert np.allclose(pts, [3, 0, 0]) + + +def test_ellipsoid_equality(): + R = Quaternion.random().rotation_matrix + e1 = Ellipsoid(a=3, b=2, c=1, matrix=R, center=(1, 2, 3)) + e2 = Ellipsoid(axes=(3, 2, 1), matrix=R, center=(1, 2, 3)) + assert e1 == e2 + assert e1 != Ellipsoid(a=3, b=2, c=1.5, matrix=R, center=(1, 2, 3)) + + +# --------------------------------------------------------------------------- # +# Spheres, boxes, rectangles # +# --------------------------------------------------------------------------- # +def test_sphere_volume_expectation_numpy_scalars(): + assert np.isclose(Sphere.volume_expectation(r=np.float64(1)), + 4 * np.pi / 3) + assert np.isclose(Sphere.volume_expectation(size=np.float64(2)), + 4 * np.pi / 3) + + +def test_sphere_plot_on_fresh_figure(): + plt.figure() + Sphere(r=1).plot() + plt.close('all') + + +def test_circle_best_fit_singular(): + # collinear points: the linear system is singular, must not raise + pts = [[0, 0], [1, 0], [2, 0], [3, 0]] + c = Circle.best_fit(pts) + assert np.isfinite(c.r) + + +def test_rectangle_within_respects_rotation(): + r = Rectangle(length=4, width=1, angle=90) + assert r.within([0, 1.5]) + assert not r.within([1.5, 0]) + assert np.all(r.within([[0, 1.9], [0.4, -1.9]])) + r = Rectangle(length=4, width=1) + assert r.within([1.5, 0]) and not r.within([0, 1.5]) + + +def test_rectangle_length_only_matches_expectation(): + r = Rectangle(length=2) + assert np.allclose(r.side_lengths, [2, 1]) + assert np.isclose(r.area, Rectangle.area_expectation(length=2)) + + +def test_square_area_expectation_returns_value(): + assert np.isclose(Square.area_expectation(side_lengths=[2, 2]), 4) + assert np.isclose(Square.area_expectation(side_length=2), 4) + + +def test_box_str_round_trip_precision(): + import ast + b = Box(center=(0.1, 0.2, 0.3), side_lengths=(1 / 3, 2 / 3, 1.1)) + vals = {} + for line in str(b).split('\n'): + k, v = line.split(':', 1) + vals[k.strip().lower().replace(' ', '_')] = ast.literal_eval(v.strip()) + assert vals['center'] == (0.1, 0.2, 0.3) + assert vals['side_lengths'] == (1 / 3, 2 / 3, 1.1) + assert Box(**vals) == b + + +def test_nbox_equality(): + r1 = Rectangle(center=(1, 1), length=2, width=1, angle=30) + r2 = Rectangle(center=(1, 1), side_lengths=(2, 1), angle=30) + assert r1 == r2 + assert r1 != Rectangle(center=(1, 1), length=2, width=1, angle=31) diff --git a/tests/meshing/periodic_helpers.py b/tests/meshing/periodic_helpers.py new file mode 100644 index 00000000..b9297819 --- /dev/null +++ b/tests/meshing/periodic_helpers.py @@ -0,0 +1,153 @@ +"""Helpers shared by the tests of periodic microstructures.""" +import copy +import itertools + +import numpy as np + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing.polymesh import _edge_lengths +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList + + +def min_edge(pmesh): + """Length of the shortest edge of a polygonal/polyhedral mesh.""" + return min([e['length'] for e in _edge_lengths(pmesh).values()]) + + +def wedge_seeds_2d(angle_deg): + """Circles in a square of side 3 to be tessellated periodic in x. + + The seeds A and B are 0.5 apart along a line tilted by ``angle_deg`` + from the x axis, so their facet (normal to that line) meets the face + x = 3 at that angle, at about (3, 0.1): the cell of B (seed 1) has a + wedge there, and the cell of A (seed 0) its image on the face x = 0. + The other seeds form a jittered grid away from them. + """ + rng = np.random.RandomState(0) + ang = np.radians(angle_deg) + positions = [[2.5, 1.0], + [2.5 + 0.5 * np.cos(ang), 1.0 + 0.5 * np.sin(ang)]] + for x in (0.75, 1.75): + for y in (0.75, 1.75, 2.75): + if (x, y) == (0.75, 0.75): + continue # its image would cut the corner of the wedge + positions.append([x + 0.03 * (2 * rng.rand() - 1), + y + 0.03 * (2 * rng.rand() - 1)]) + positions.append([2.75, 2.75]) + return SeedList([Seed.factory('circle', r=0.2, position=p) + for p in positions]) + + +def check_periodic_pairs(points, facets, point_pairs, facet_pairs, per_axes, + domain): + """Check the pairs of points and facets on the periodic faces. + + Every point on a periodic face is paired, exactly once, with a point + on the opposite face that is its exact translate by the domain + length, and every facet whose points all lie on a face is paired with + the facet made of their images. + + Args: + points (list or numpy.ndarray): The points of the mesh. + facets (list): The facets of the mesh. + point_pairs (dict): Axis -> list of (lower, upper) point numbers. + facet_pairs (dict): Axis -> list of (lower, upper) facet numbers. + per_axes (list): The periodicity flags. + domain (from :mod:`microstructpy.geometry`): The domain. + + Returns: + dict: Axis -> (set of the points on the lower face, set of the + points on the upper face, number of facets on the lower face). + + """ + pts = np.array(points) + lims = np.array(domain.limits) + n_dim = len(lims) + faces = {} + for axis, flag in enumerate(per_axes): + if not flag: + assert axis not in point_pairs + continue + lb, ub = lims[axis] + shift = np.zeros(n_dim) + shift[axis] = ub - lb + pairs = point_pairs[axis] + low = set(np.nonzero(np.isclose(pts[:, axis], lb))[0]) + high = set(np.nonzero(np.isclose(pts[:, axis], ub))[0]) + assert len(pairs) == len(low) == len(high) > 0 + assert set([lo for lo, _ in pairs]) == low + assert set([hi for _, hi in pairs]) == high + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + kp_map = dict(pairs) + f_pairs = dict(facet_pairs[axis]) + n_low = 0 + for f_num, facet in enumerate(facets): + if all([kp in low for kp in facet]): + n_low += 1 + assert f_num in f_pairs + image = facets[f_pairs[f_num]] + assert set(image) == set([kp_map[kp] for kp in facet]) + faces[axis] = (low, high, n_low) + return faces + + +def seed_volumes(pmesh, n_seeds): + """Volume (area) of the cells of each seed, pieces added up.""" + vols = np.zeros(n_seeds) + for seed_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): + vols[seed_num] += vol + return vols + + +def tiled_reference_volumes(seeds, domain, per_axes, margin=None): + """Volumes of the cells of the seeds in a periodic tessellation, + computed as a non-periodic tessellation of the seeds tiled across the + periodic axes (3 copies per periodic axis). + + With a ``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 cells of the original seeds only reach + seeds that close. In 3D this makes the reference tessellation several + times smaller; with all the copies, it needs gigabytes of memory. + """ + lims = np.array(domain.limits) + lengths = lims[:, 1] - lims[:, 0] + options = [[-length, 0.0, length] if flag else [0.0] + for length, flag in zip(lengths, per_axes)] + if margin is None: + reach = lengths + else: + extent = max([np.max(np.abs(np.array(s.breakdown)[:, :-1] + - s.position)) for s in seeds]) + reach = np.full(len(lengths), margin + extent) + tiled = SeedList() + copies = [] # (translation number, seed number) of each copy + for i_t, t in enumerate(itertools.product(*options)): + for seed_num, seed in enumerate(seeds): + pos = np.array(seed.position) + np.array(t) + if margin is not None and np.any((pos < lims[:, 0] - margin) + | (pos > lims[:, 1] + margin)): + continue + copy_seed = copy.deepcopy(seed) + copy_seed.position = list(pos) + tiled.append(copy_seed) + copies.append((i_t, seed_num)) + big_lims = [(lb - r, ub + r) if flag else (lb, ub) + for (lb, ub), r, flag in zip(lims, reach, per_axes)] + if len(lims) == 2: + big_domain = msp.geometry.Rectangle(limits=big_lims) + else: + big_domain = msp.geometry.Box(limits=big_lims) + pmesh = PolyMesh.from_seeds(tiled, big_domain) + # the cells of the original copies (the zero translation) + i_zero = [i for i, t in enumerate(itertools.product(*options)) + if not any(t)][0] + vols = np.zeros(len(seeds)) + for tiled_num, vol in zip(pmesh.seed_numbers, pmesh.volumes): + i_t, seed_num = copies[tiled_num] + if i_t == i_zero: + vols[seed_num] += vol + return vols diff --git a/tests/meshing/test_edge_opt_pieces.py b/tests/meshing/test_edge_opt_pieces.py new file mode 100644 index 00000000..3527e765 --- /dev/null +++ b/tests/meshing/test_edge_opt_pieces.py @@ -0,0 +1,409 @@ +"""Tests of the edge optimization with thin pieces at the periodic faces.""" +import copy + +import numpy as np +from periodic_helpers import min_edge +from periodic_helpers import wedge_seeds_2d + +from microstructpy import geometry +from microstructpy.meshing import PolyMesh +from microstructpy.meshing.polymesh import _accept_trial +from microstructpy.meshing.polymesh import _displace_seed +from microstructpy.meshing.polymesh import _edge_lengths +from microstructpy.meshing.polymesh import _mesh_features +from microstructpy.meshing.polymesh import _nearest_image +from microstructpy.meshing.polymesh import _select_target +from microstructpy.meshing.polymesh import _wedge_geometry +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _grid_seeds_2d(x_shift, rng_seed=0): + """Circles on a 3 x 3 grid (spacing 1, at 0.75, 1.75, 2.75) in a + periodic square of side 3: the cells cross the faces by 0.25. The seed + at (0.75, 1.75) is moved to ``x_shift``: its cell then crosses the + face x = 0 by about (0.25 - x_shift) / 2 and leaves a piece that thick + on the face x = 3. The other rows are jittered to avoid degenerate + vertices.""" + rng = np.random.RandomState(rng_seed) + seeds = SeedList() + for i in range(3): + for j in range(3): + x, y = 0.75 + i, 0.75 + j + if (i, j) == (0, 1): + x = x_shift + else: + y += 0.03 * (2 * rng.rand() - 1) + seeds.append(Seed.factory('circle', r=0.2, position=[x, y])) + return seeds + + +def _grid_seeds_3d(x_shift, rng_seed=0): + """Spheres on a 2 x 2 x 2 grid (at 0.75 and 1.75) in a periodic cube + of side 2, with the seed at (0.75, 0.75, 0.75) moved to ``x_shift``.""" + rng = np.random.RandomState(rng_seed) + seeds = SeedList() + for i in range(2): + for j in range(2): + for k in range(2): + x, y, z = 0.75 + i, 0.75 + j, 0.75 + k + if (i, j, k) == (0, 0, 0): + x = x_shift + else: + y += 0.03 * (2 * rng.rand() - 1) + z += 0.03 * (2 * rng.rand() - 1) + seeds.append(Seed.factory('sphere', r=0.2, + position=[x, y, z])) + return seeds + + +def _pieces(pmesh, domain): + n_dim = domain.n_dim + scale = max([ub - lb for lb, ub in domain.limits]) + feats = _mesh_features(pmesh, [True] * n_dim, domain.limits, scale) + return [f for f in feats if f['kind'] == 'piece'] + + +def _feature(kind, size, key, seeds=()): + return {'kind': kind, 'size': size, 'key': key, 'seeds': list(seeds)} + + +# --------------------------------------------------------------------------- # +# Features # +# --------------------------------------------------------------------------- # +def test_piece_features_2d(): + domain = geometry.Square(side_length=3, corner=(0, 0)) + seeds = _grid_seeds_2d(0.05) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + pieces = _pieces(pmesh, domain) + + # the shifted seed (number 1) has a piece about 0.1 thick on the face + # x = 3 (the part of its cell beyond x = 0) and its main part on the + # face x = 0, about 0.9 thick; the other pieces are about 0.25 thick + thin = [p for p in pieces if p['seed'] == 1 and p['axis'] == 0 + and p['side'] == 1] + assert len(thin) == 1 + assert np.isclose(thin[0]['size'], 0.1, atol=0.02) + assert 1 in thin[0]['seeds'] + assert len(thin[0]['seeds']) > 1 # the neighbors across its facets + main = [p for p in pieces if p['seed'] == 1 and p['axis'] == 0 + and p['side'] == 0] + assert len(main) == 1 + assert np.isclose(main[0]['size'], 0.9, atol=0.02) + others = [p['size'] for p in pieces if p['seed'] != 1] + assert np.all(np.array(others) > 0.15) + + # every piece touches its face, and the keys are distinct + pts = np.array(pmesh.points) + lims = np.array(domain.limits) + for p in pieces: + assert p['size'] > 0 + keys = [p['key'] for p in pieces] + assert len(set(keys)) == len(keys) + for f, neighs in enumerate(pmesh.facet_neighbors): + if min(neighs) < 0: + axis, side = divmod(-min(neighs) - 1, 2) + assert np.allclose(pts[pmesh.facets[f], axis], lims[axis, side]) + + # edge features cover every edge of the mesh + scale = 3.0 + feats = _mesh_features(pmesh, [True, True], domain.limits, scale) + n_edges = len([f for f in feats if f['kind'] == 'edge']) + assert n_edges == len(_edge_lengths(pmesh)) + + +def test_no_piece_features_without_periodicity(): + domain = geometry.Square(side_length=3, corner=(0, 0)) + seeds = _grid_seeds_2d(0.05) + pmesh = PolyMesh.from_seeds(seeds, domain) + feats = _mesh_features(pmesh, [False, False], None, 3.0) + assert all([f['kind'] == 'edge' for f in feats]) + # pieces on the periodic axes only + pmesh_x = PolyMesh.from_seeds(seeds, domain, periodic='x') + feats = _mesh_features(pmesh_x, [True, False], domain.limits, 3.0) + assert all([f['axis'] == 0 for f in feats if f['kind'] == 'piece']) + + +# --------------------------------------------------------------------------- # +# Acceptance and target selection # +# --------------------------------------------------------------------------- # +def test_accept_trial(): + old = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b'), + _feature('piece', 0.01, 'c'), _feature('edge', 0.3, 'd')] + # the thin piece is replaced by longer features: accepted + new = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b'), + _feature('piece', 0.05, 'e'), _feature('edge', 0.25, 'f')] + assert _accept_trial(new, old, 1e-9) + # a feature shorter than the removed ones appears: rejected + new = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b'), + _feature('piece', 0.05, 'e'), _feature('edge', 0.005, 'f')] + assert not _accept_trial(new, old, 1e-9) + # nothing changes: rejected + assert not _accept_trial(copy.deepcopy(old), old, 1e-9) + # a feature is only removed: accepted; only added: rejected + assert _accept_trial(old[:3], old, 1e-9) + assert not _accept_trial(old + [_feature('edge', 0.9, 'g')], old, 1e-9) + # a removed and an added feature of the same size cancel out, so the + # comparison is between the piece (0.01) and the new edge (0.015) + new = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b2'), + _feature('edge', 0.015, 'e'), _feature('edge', 0.3, 'd')] + assert _accept_trial(new, old, 1e-9) + new[2]['size'] = 0.009 + assert not _accept_trial(new, old, 1e-9) + + +def test_select_target(): + feats = [_feature('edge', 0.5, 'a'), _feature('edge', 0.02, 'b'), + _feature('piece', 0.03, 'c'), _feature('piece', 0.08, 'd'), + _feature('piece', 0.2, 'e')] + # without a margin: the shortest feature only + assert _select_target(feats, 0.0, set())['key'] == 'b' + assert _select_target(feats, 0.0, {'b'}) is None + # with a margin: the shortest feature, then the pieces under the + # margin from the thinnest + assert _select_target(feats, 0.1, set())['key'] == 'b' + assert _select_target(feats, 0.1, {'b'})['key'] == 'c' + assert _select_target(feats, 0.1, {'b', 'c'})['key'] == 'd' + assert _select_target(feats, 0.1, {'b', 'c', 'd'}) is None + # a piece that is the shortest feature is the target even without a + # margin + feats[2]['size'] = 0.01 + assert _select_target(feats, 0.0, set())['key'] == 'c' + + +def test_displace_seed_wraps(): + seed = Seed.factory('circle', r=0.2, position=[2.9, 1.0]) + seed.update_breakdown() + dom_lims = [(0.0, 3.0), (0.0, 3.0)] + _displace_seed(seed, [0.3, -1.2], dom_lims, [True, False]) + assert np.allclose(seed.position, [0.2, -0.2]) + assert np.allclose(seed.geometry.center, seed.position) + assert np.allclose(seed.breakdown[0][:-1], seed.position) + # without periodicity, no wrapping + _displace_seed(seed, [3.0, 0.0]) + assert np.allclose(seed.position, [3.2, -0.2]) + + +def test_nearest_image(): + dom_lims = [(0.0, 3.0), (0.0, 3.0)] + pts = np.array([[2.9, 1.0], [2.8, 1.5]]) + ref = np.array([0.1, 1.2]) + image = _nearest_image(pts, ref, dom_lims, [True, True]) + assert np.allclose(image, [[-0.1, 1.0], [-0.2, 1.5]]) + image = _nearest_image(pts, ref, dom_lims, [False, True]) + assert np.allclose(image, pts) + + +# --------------------------------------------------------------------------- # +# Optimization # +# --------------------------------------------------------------------------- # +def test_edge_opt_fixes_thin_piece_2d(): + np.random.seed(0) + domain = geometry.Square(side_length=3, corner=(0, 0)) + margin = 0.1 + seeds = _grid_seeds_2d(0.2) + seeds_orig = copy.deepcopy(seeds) + pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic=True) + thin_0 = [p['size'] for p in _pieces(pmesh_0, domain) + if p['size'] < margin] + assert len(thin_0) == 1 + assert np.isclose(thin_0[0], 0.025, atol=0.01) + min_edge_0 = min_edge(pmesh_0) + + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True, edge_opt=True, + n_iter=2, periodic_margin=margin) + + # no piece thinner than the margin is left, and the shortest edge of + # the mesh did not get shorter + thin = [p['size'] for p in _pieces(pmesh, domain) if p['size'] < margin] + assert thin == [] + assert min_edge(pmesh) >= min_edge_0 - 1e-9 + + # the mesh is periodic and the seeds reproduce it + assert pmesh.periodic_axes == [True, True] + pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) + assert np.isclose(sum(pmesh.volumes), domain.area) + + # the shifted seed moved (into the domain, normal to the face) and + # every seed is in the domain + assert not np.allclose(seeds[1].position, seeds_orig[1].position) + assert np.isclose(seeds[1].position[1], seeds_orig[1].position[1]) + for seed in seeds: + assert np.all(np.array(seed.position) >= 0) + assert np.all(np.array(seed.position) <= 3) + assert np.allclose(seed.geometry.center, seed.position) + + +def test_edge_opt_fixes_thin_piece_3d(): + np.random.seed(0) + domain = geometry.Cube(side_length=2, corner=(0, 0, 0)) + margin = 0.1 + seeds = _grid_seeds_3d(0.2) + pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic=True) + # the piece of the shifted seed, and a corner of its neighbor along x + # that pokes through the face x = 2 (the bisector with the image of + # the shifted seed is tilted by the jitter) + thin_0 = [p['size'] for p in _pieces(pmesh_0, domain) + if p['size'] < margin] + assert len(thin_0) == 2 + assert min(thin_0) < 0.01 + min_edge_0 = min_edge(pmesh_0) + + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True, edge_opt=True, + n_iter=5, periodic_margin=margin) + thin = [p['size'] for p in _pieces(pmesh, domain) if p['size'] < margin] + assert thin == [] + assert min_edge(pmesh) >= min_edge_0 - 1e-9 + assert np.isclose(sum(pmesh.volumes), domain.volume) + pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) + + +# --------------------------------------------------------------------------- # +# Wedges (corners at the periodic faces narrower than the mesh angle) # +# --------------------------------------------------------------------------- # + + +def _wedge_seeds_3d(angle_deg=15.0): + """The 2D configuration extruded along z in a cube of side 3, periodic + in x: the facet of A and B contains the z direction and meets the + face x = 3 along a line, with a dihedral angle of ``angle_deg``.""" + rng = np.random.RandomState(0) + ang = np.radians(angle_deg) + positions = [[2.5, 1.0, 1.5], + [2.5 + 0.5 * np.cos(ang), 1.0 + 0.5 * np.sin(ang), 1.5]] + for x in (0.75, 1.75): + for y in (0.75, 1.75, 2.75): + for z in (0.75, 2.25): + positions.append([x + 0.03 * (2 * rng.rand() - 1), + y + 0.03 * (2 * rng.rand() - 1), + z + 0.03 * (2 * rng.rand() - 1)]) + positions.append([2.75, 2.75, 0.75]) + positions.append([2.75, 2.75, 2.25]) + return SeedList([Seed.factory('sphere', r=0.2, position=p) + for p in positions]) + + +def _wedges(pmesh, domain, per_axes, min_angle): + scale = max([ub - lb for lb, ub in domain.limits]) + feats = _mesh_features(pmesh, per_axes, domain.limits, scale, min_angle) + return [f for f in feats if f['kind'] == 'wedge'] + + +def test_wedge_geometry(): + # 2D: a vertex on the wall, the wall edge along -x and a facet at 20 + # degrees from it, 0.5 long + ang = np.radians(20) + pts = np.array([[0.0, 0.0], [-1.0, 0.0], + [-0.5 * np.cos(ang), 0.5 * np.sin(ang)]]) + angle, length, u_vec, where = _wedge_geometry(pts, [0, 1], [0, 2], [0], + np.array([-0.5, 0.05])) + assert np.isclose(angle, ang) + assert np.isclose(length, 0.5) + assert np.allclose(u_vec, [-np.cos(ang), np.sin(ang)]) + assert np.allclose(where, [0, 0]) + + # 3D: the wall facet in the plane y = 0, the cell above it, and a + # facet leaving their common edge (along x) at 20 degrees + d = np.array([0.0, np.sin(ang), np.cos(ang)]) + pts = np.array([[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1], + [1, 0, 0] + 0.5 * d, [0, 0, 0] + 0.5 * d], dtype=float) + cen = np.array([0.5, 0.1, 0.6]) + angle, length, u_vec, where = _wedge_geometry(pts, [0, 1, 2, 3], + [0, 1, 4, 5], [0, 1], cen) + assert np.isclose(angle, ang) + assert np.isclose(length, 0.5) + assert np.allclose(u_vec, d) + assert np.allclose(where, [0.5, 0, 0]) + # the same with the facets listed in the other order round the edge + angle_2, _, _, _ = _wedge_geometry(pts, [3, 2, 1, 0], [5, 4, 1, 0], + [1, 0], cen) + assert np.isclose(angle_2, ang) + + +def test_wedge_features_2d(): + domain = geometry.Square(side_length=3, corner=(0, 0)) + seeds = wedge_seeds_2d(15.0) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + # no wedges narrower than 10 degrees; two narrower than 25: the corner + # of the cell of B (seed 1) on the face x = 3, and its image on the + # face x = 0, where the facet continues into the piece of A (seed 0) + assert _wedges(pmesh, domain, [True, False], 10.0) == [] + wedges = _wedges(pmesh, domain, [True, False], 25.0) + assert len(wedges) == 2 + by_seed = {w['seed']: w for w in wedges} + assert set(by_seed) == {0, 1} + w = by_seed[1] + assert w['neighbor'] == 0 + assert (w['axis'], w['side']) == (0, 1) + assert np.isclose(np.degrees(w['angle']), 15.0, atol=1.0) + assert 0 < w['size'] < 0.25 * 3 * np.sin(w['angle']) + # the direction is along the facet, away from the face + assert w['u_vec'][0] < 0 + assert np.isclose(np.linalg.norm(w['u_vec']), 1) + w_0 = by_seed[0] + assert w_0['neighbor'] == 1 + assert (w_0['axis'], w_0['side']) == (0, 0) + assert np.isclose(np.degrees(w_0['angle']), 15.0, atol=1.0) + assert w_0['u_vec'][0] > 0 + # no wedges at all without a minimum angle + assert _wedges(pmesh, domain, [True, False], 0.0) == [] + # no wedges on non-periodic faces (y is not periodic) + assert all([f['axis'] == 0 for f in wedges]) + + +def test_wedge_features_3d(): + domain = geometry.Cube(side_length=3, corner=(0, 0, 0)) + seeds = _wedge_seeds_3d(15.0) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + wedges = _wedges(pmesh, domain, [True, False, False], 25.0) + mine = [w for w in wedges if w['seed'] == 1] + assert len(mine) >= 1 + for w in mine: + assert w['neighbor'] == 0 + assert (w['axis'], w['side']) == (0, 1) + assert np.isclose(np.degrees(w['angle']), 15.0, atol=1.0) + assert w['u_vec'][0] < 0 + assert np.isclose(w['u_vec'][2], 0, atol=0.05) + assert _wedges(pmesh, domain, [True, False, False], 10.0) == [] + + +def test_edge_opt_opens_wedge_2d(): + np.random.seed(0) + domain = geometry.Square(side_length=3, corner=(0, 0)) + seeds = wedge_seeds_2d(15.0) + pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic='x') + assert len(_wedges(pmesh_0, domain, [True, False], 25.0)) == 2 + min_edge_0 = min_edge(pmesh_0) + + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x', edge_opt=True, + n_iter=5, periodic_margin=0.05, + min_angle=25.0) + assert _wedges(pmesh, domain, [True, False], 25.0) == [] + assert min_edge(pmesh) >= min_edge_0 - 1e-9 + assert np.isclose(sum(pmesh.volumes), domain.area) + pmesh_re = PolyMesh.from_seeds(seeds, domain, periodic='x') + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) + + +def test_edge_opt_opens_wedge_3d(): + np.random.seed(0) + domain = geometry.Cube(side_length=3, corner=(0, 0, 0)) + seeds = _wedge_seeds_3d(15.0) + pmesh_0 = PolyMesh.from_seeds(seeds, domain, periodic='x') + n_0 = len(_wedges(pmesh_0, domain, [True, False, False], 25.0)) + assert n_0 >= 1 + min_edge_0 = min_edge(pmesh_0) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x', edge_opt=True, + n_iter=5, periodic_margin=0.05, + min_angle=25.0) + assert len(_wedges(pmesh, domain, [True, False, False], 25.0)) < n_0 + assert min_edge(pmesh) >= min_edge_0 - 1e-9 + assert np.isclose(sum(pmesh.volumes), domain.volume) diff --git a/tests/meshing/test_periodic_geometry.py b/tests/meshing/test_periodic_geometry.py new file mode 100644 index 00000000..35c24fd2 --- /dev/null +++ b/tests/meshing/test_periodic_geometry.py @@ -0,0 +1,287 @@ +"""Geometric checks of periodic meshes. + +The cells of a periodic polygonal/polyhedral mesh must partition the +domain: each cell is a closed convex polytope inside the domain, the cell +volumes add up to the domain volume and every point of the domain lies in +exactly one cell (no gaps, no overlaps). The elements of the triangular/ +tetrahedral mesh must partition the cells in the same way. + +The points of a periodic polymesh are snapped onto the periodic faces +within ``_SNAP_TOL`` (relative to the size of the domain), which makes some +facets non-planar by up to that distance: the geometric tests use it as +their tolerance. +""" +from collections import Counter + +import numpy as np +import pytest +import scipy.stats + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh +from microstructpy.meshing.polymesh import _SNAP_TOL +from microstructpy.meshing.trimesh import _amorphous_seed_numbers +from microstructpy.seeding import SeedList + +PHASES = { + 2: [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15), + 'material_type': 'crystalline'}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180), + 'material_type': 'amorphous'}], + 3: [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2), + 'material_type': 'crystalline'}, + {'shape': 'sphere', 'size': 0.4, 'material_type': 'amorphous'}], +} + +CASES = [ + ('square-xy', msp.geometry.Square(side_length=2, corner=(0, 0)), 1, True), + ('rect-x', msp.geometry.Rectangle(length=3, width=2, corner=(0, 0)), 2, + 'x'), + ('cube-xyz-1', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 1, + True), + ('cube-xyz-2', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 2, + True), + ('cube-xyz-4', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 4, + True), + ('cube-xz', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 3, + 'xz'), + ('cube-xz-10', msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)), 10, + 'xz'), + ('box-y', msp.geometry.Box(limits=[(0, 2), (0, 1), (0, 1.3)]), 7, 'y'), + ('box-none', msp.geometry.Box(limits=[(0, 2), (0, 1), (0, 1.3)]), 9, + False), +] + + +@pytest.fixture(scope='module', params=CASES, ids=[c[0] for c in CASES]) +def case(request): + name, domain, rng_seed, periodic = request.param + n_dim = len(domain.limits) + phases = PHASES[n_dim] + seeds = SeedList.from_info(phases, 0.5 * domain.n_vol) + seeds.position(domain, rtol=0.0, rng_seed=rng_seed, periodic=periodic) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=periodic) + return domain, phases, seeds, pmesh + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _facet_normal(loop): + """Outward-agnostic normal of a facet, scaled by its measure.""" + if loop.shape[1] == 2: + t = loop[1] - loop[0] + return np.array([t[1], -t[0]]) + n = np.zeros(3) + for i in range(len(loop)): + n += np.cross(loop[i], loop[(i + 1) % len(loop)]) + return 0.5 * n + + +def _cell_planes(pts, pmesh, reg): + """Outward unit normals, offsets and measures of the facets of a cell.""" + verts = np.unique(np.concatenate([pmesh.facets[f] + for f in pmesh.regions[reg]])) + center = pts[verts].mean(axis=0) + planes = [] + for f in pmesh.regions[reg]: + loop = pts[pmesh.facets[f]] + n = _facet_normal(loop) + measure = np.linalg.norm(n) + assert measure > 0 + n = n / measure + p0 = loop.mean(axis=0) + if np.dot(n, p0 - center) < 0: + n = -n + planes.append((n, np.dot(n, p0), measure, p0)) + return planes, verts + + +def _check_closed(pmesh, reg): + """Each vertex (2D) or edge (3D) is shared by exactly two facets.""" + counts = Counter() + for f in pmesh.regions[reg]: + facet = pmesh.facets[f] + if len(facet) == 2: + counts.update(facet) + else: + for i in range(len(facet)): + counts[tuple(sorted((facet[i], + facet[(i + 1) % len(facet)])))] += 1 + assert all([c == 2 for c in counts.values()]) + if len(pmesh.facets[0]) > 2: + verts = set() + for f in pmesh.regions[reg]: + verts.update(pmesh.facets[f]) + assert len(verts) - len(counts) + len(pmesh.regions[reg]) == 2 + + +def _inside(planes, points, tol=1e-9): + normals = np.array([p[0] for p in planes]) + offsets = np.array([p[1] for p in planes]) + return np.all(points @ normals.T - offsets <= tol, axis=1) + + +# --------------------------------------------------------------------------- # +# Polygonal / polyhedral meshes # +# --------------------------------------------------------------------------- # +def test_cells_partition_the_domain(case): + domain, phases, seeds, pmesh = case + pts = np.array(pmesh.points) + lims = np.array(domain.limits) + n_dim = len(lims) + scale = np.max(lims[:, 1] - lims[:, 0]) + geom_tol = 2 * _SNAP_TOL * scale + assert np.all(pts >= lims[:, 0] - 1e-9) + assert np.all(pts <= lims[:, 1] + 1e-9) + # no duplicate points + assert len(np.unique(np.round(pts / scale, 9), axis=0)) == len(pts) + + # facets are planar with a non-zero measure and distinct points + for facet in pmesh.facets: + assert len(set(facet)) == len(facet) >= n_dim + loop = pts[facet] + n = _facet_normal(loop) + assert np.linalg.norm(n) > 1e-12 + n /= np.linalg.norm(n) + assert np.abs((loop - loop[0]) @ n).max() < geom_tol + + # facet neighbors and regions agree; wall facets are on their wall + for f, neighs in enumerate(pmesh.facet_neighbors): + for r in neighs: + if r >= 0: + assert f in pmesh.regions[r] + else: + axis, side = divmod(-r - 1, 2) + assert np.allclose(pts[pmesh.facets[f], axis], + lims[axis][side], atol=1e-9) + for r, region in enumerate(pmesh.regions): + for f in region: + assert r in pmesh.facet_neighbors[f] + + # every cell is closed and convex; volumes add up to the domain volume + vols = np.zeros(len(pmesh.regions)) + all_planes = [] + for r in range(len(pmesh.regions)): + _check_closed(pmesh, r) + planes, verts = _cell_planes(pts, pmesh, r) + all_planes.append(planes) + vols[r] = sum([np.dot(n, p0) * m for n, _, m, p0 in planes]) / n_dim + assert vols[r] > 0 + assert np.all(_inside(planes, pts[verts], tol=geom_tol)) + assert np.isclose(vols.sum(), domain.n_vol, rtol=1e-6) + assert np.allclose(pmesh.volumes, vols, rtol=1e-6, atol=1e-9) + + # random points of the domain lie in exactly one cell + rng = np.random.default_rng(0) + lengths = lims[:, 1] - lims[:, 0] + sample = lims[:, 0] + rng.random((20000, n_dim)) * lengths + # (no gaps: every point is in a cell widened by the tolerance; no + # overlaps: at most one cell contains it when the cells are shrunk) + n_loose = np.zeros(len(sample), dtype=int) + n_strict = np.zeros(len(sample), dtype=int) + for planes in all_planes: + n_loose += _inside(planes, sample, tol=geom_tol) + n_strict += _inside(planes, sample, tol=-geom_tol) + assert np.all(n_loose >= 1) + assert np.all(n_strict <= 1) + + +# --------------------------------------------------------------------------- # +# Triangular / tetrahedral meshes # +# --------------------------------------------------------------------------- # +def test_elements_partition_the_cells(case): + domain, phases, seeds, pmesh = case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + lims = np.array(domain.limits) + n_dim = len(lims) + geom_tol = 2 * _SNAP_TOL * np.max(lims[:, 1] - lims[:, 0]) + + # positively oriented elements that add up to the domain volume + rel = pts[elems[:, 1:]] - pts[elems[:, :1]] + svol = np.linalg.det(rel) / (2.0 if n_dim == 2 else 6.0) + assert np.all(svol > 0) + assert np.isclose(svol.sum(), domain.n_vol, rtol=1e-9) + + # every element face is shared by two elements or lies on the boundary + face_elems = {} + for e_num, e in enumerate(elems): + for i in range(n_dim + 1): + face_elems.setdefault(tuple(sorted(np.delete(e, i))), + []).append(e_num) + assert max([len(e) for e in face_elems.values()]) == 2 + for face, e_nums in face_elems.items(): + if len(e_nums) == 1: + fp = pts[list(face)] + assert any([np.allclose(fp[:, ax], lims[ax][k], atol=1e-9) + for ax in range(n_dim) for k in range(2)]) + + # element attributes: cells of the same amorphous phase are merged and + # labelled with one seed number; the element volumes of each attribute + # add up to the volume of its cells and every element lies in one of them + attrs = np.array(mesh.element_attributes) + conv = _amorphous_seed_numbers(pmesh, phases) + att_of_reg = np.array([conv.get(s, s) for s in pmesh.seed_numbers]) + assert set(attrs.tolist()) == set(att_of_reg.tolist()) + + # the facets of the mesh: every face between elements with different + # attributes is a facet, and the facets cover each facet of the polymesh + # that separates different attributes (or is on the boundary) exactly + mesh_facets = {tuple(sorted(f)): a for f, a in + zip(mesh.facets, mesh.facet_attributes)} + for face, e_nums in face_elems.items(): + if len(e_nums) == 2 and attrs[e_nums[0]] != attrs[e_nums[1]]: + assert face in mesh_facets + if len(e_nums) == 1: + assert face in mesh_facets + ppts = np.array(pmesh.points) + covered = np.zeros(len(pmesh.facets)) + for face, f_num in mesh_facets.items(): + fp = pts[list(face)] + covered[f_num] += np.linalg.norm(_facet_normal(fp)) + loop = ppts[pmesh.facets[f_num]] + n = _facet_normal(loop) + n /= np.linalg.norm(n) + assert np.abs((fp - loop[0]) @ n).max() < geom_tol + for f_num, (r_a, r_b) in enumerate(pmesh.facet_neighbors): + measure = np.linalg.norm(_facet_normal(ppts[pmesh.facets[f_num]])) + if min(r_a, r_b) < 0 or att_of_reg[r_a] != att_of_reg[r_b]: + assert np.isclose(covered[f_num], measure, atol=1e-9) + else: + assert covered[f_num] == 0 + cell_vols = np.array(pmesh.volumes) + cents = pts[elems].mean(axis=1) + for att in np.unique(att_of_reg): + regs = np.nonzero(att_of_reg == att)[0] + mask = attrs == att + assert np.isclose(svol[mask].sum(), cell_vols[regs].sum(), rtol=1e-6) + inside = np.zeros(np.sum(mask), dtype=bool) + for r in regs: + planes, _ = _cell_planes(ppts, pmesh, r) + inside |= _inside(planes, cents[mask], tol=geom_tol) + assert np.all(inside) + + # the labels: crystalline cells keep their seed number, amorphous cells + # that share a facet (also across a periodic face) share a label, which + # is the seed number of one of them + seed_nums = np.array(pmesh.seed_numbers) + phase_nums = np.array(pmesh.phase_numbers) + amorph = np.array([phases[p]['material_type'] == 'amorphous' + for p in phase_nums]) + assert np.array_equal(att_of_reg[~amorph], seed_nums[~amorph]) + assert set(att_of_reg[amorph]) <= set(seed_nums[amorph]) + pairs = [tuple(n) for n in pmesh.facet_neighbors] + for axis_pairs in (pmesh.periodic_facets or {}).values(): + pairs += [(max(pmesh.facet_neighbors[lo]), + max(pmesh.facet_neighbors[hi])) for lo, hi in axis_pairs] + n_merged = 0 + for r_a, r_b in pairs: + if min(r_a, r_b) >= 0 and amorph[r_a] and amorph[r_b]: + assert att_of_reg[r_a] == att_of_reg[r_b] + n_merged += 1 + assert n_merged > 0 diff --git a/tests/meshing/test_periodic_polymesh.py b/tests/meshing/test_periodic_polymesh.py new file mode 100644 index 00000000..64729894 --- /dev/null +++ b/tests/meshing/test_periodic_polymesh.py @@ -0,0 +1,248 @@ +"""Tests for periodic polygonal and polyhedral meshes.""" +import numpy as np +import pytest +import scipy.stats +from periodic_helpers import check_periodic_pairs +from periodic_helpers import seed_volumes +from periodic_helpers import tiled_reference_volumes + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing.polymesh import kp_loop +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _phases(): + return [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15)}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180)}] + + +def _periodic_seeds(domain, per_axes, rng_seed=0, fill=0.55): + seeds = SeedList.from_info(_phases(), fill * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=rng_seed, periodic=per_axes) + return seeds + + +def _check_periodic_structure(pmesh, domain, per_axes): + """Points and facets on the periodic faces are paired and are exact + translates of each other, and the facets on the faces are wall + facets.""" + assert pmesh.periodic_axes == list(per_axes) + faces = check_periodic_pairs(pmesh.points, pmesh.facets, + pmesh.periodic_points, + pmesh.periodic_facets, per_axes, domain) + for axis, (low, high, _) in faces.items(): + for f_num, neighs in enumerate(pmesh.facet_neighbors): + facet = pmesh.facets[f_num] + if all([kp in low for kp in facet]): + assert min(neighs) == -(2 * axis + 1) + if all([kp in high for kp in facet]): + assert min(neighs) == -(2 * axis + 2) + + +def _region_loops(pmesh): + pts = np.array(pmesh.points) + return [pts[kp_loop([pmesh.facets[f] for f in r])] + for r in pmesh.regions] + + +# --------------------------------------------------------------------------- # +# Analytic cases # +# --------------------------------------------------------------------------- # +def test_two_seeds_periodic_in_x(): + domain = msp.geometry.Square(side_length=1, corner=(0, 0)) + seeds = SeedList([Seed.factory('circle', r=0.2, position=(0.1, 0.5)), + Seed.factory('circle', r=0.2, position=(0.6, 0.5))]) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + + # cell 0 is cut by the periodic face: [0, 0.35] and [0.85, 1] + assert len(pmesh.regions) == 3 + assert sorted(pmesh.seed_numbers) == [0, 0, 1] + areas = seed_volumes(pmesh, 2) + assert np.allclose(areas, [0.5, 0.5]) + pieces = sorted([v for v, s in zip(pmesh.volumes, pmesh.seed_numbers) + if s == 0]) + assert np.allclose(pieces, [0.15, 0.35]) + _check_periodic_structure(pmesh, domain, [True, False]) + assert len(pmesh.periodic_points[0]) == 2 + assert len(pmesh.periodic_facets[0]) == 1 + + +def test_single_seed_tiles_the_domain(): + domain = msp.geometry.Square(side_length=1, corner=(0, 0)) + seeds = SeedList([Seed.factory('circle', r=0.2, position=(0.3, 0.7))]) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert len(pmesh.regions) == 4 + assert np.isclose(sum(pmesh.volumes), 1.0) + assert np.allclose(sorted(pmesh.volumes), [0.04, 0.16, 0.16, 0.64]) + _check_periodic_structure(pmesh, domain, [True, True]) + + +def test_non_periodic_unchanged(): + domain = msp.geometry.Square(side_length=1, corner=(0, 0)) + seeds = SeedList([Seed.factory('circle', r=0.2, position=(0.1, 0.5)), + Seed.factory('circle', r=0.2, position=(0.6, 0.5))]) + pmesh = PolyMesh.from_seeds(seeds, domain) + assert pmesh.periodic_axes is None + assert pmesh.periodic_points is None + assert len(pmesh.regions) == 2 + assert np.allclose(sorted(pmesh.volumes), [0.35, 0.65]) + + +# --------------------------------------------------------------------------- # +# Random microstructures against a tiled reference # +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize('per_axes', [[True, True], [True, False], + [False, True]]) +def test_periodic_matches_tiled_reference(per_axes): + domain = msp.geometry.Rectangle(limits=[(-1, 2), (0.5, 2.5)]) + seeds = _periodic_seeds(domain, per_axes, rng_seed=1) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=per_axes) + + assert np.isclose(sum(pmesh.volumes), domain.area) + assert np.all(np.array(pmesh.volumes) > 0) + assert set(pmesh.seed_numbers) == set(range(len(seeds))) + areas = seed_volumes(pmesh, len(seeds)) + ref = tiled_reference_volumes(seeds, domain, per_axes) + assert np.allclose(areas, ref, rtol=1e-9, atol=1e-12) + _check_periodic_structure(pmesh, domain, per_axes) + + # every piece is a convex polygon inside the domain + lims = np.array(domain.limits) + for loop in _region_loops(pmesh): + assert np.all(loop >= lims[:, 0] - 1e-9) + assert np.all(loop <= lims[:, 1] + 1e-9) + d_edge = np.roll(loop, -1, axis=0) - loop + d_next = np.roll(d_edge, -1, axis=0) + cross = d_edge[:, 0] * d_next[:, 1] - d_edge[:, 1] * d_next[:, 0] + assert np.all(cross >= -1e-12) or np.all(cross <= 1e-12) + + +def test_periodic_seeds_crossing_faces_are_split(): + domain = msp.geometry.Square(side_length=2) + seeds = _periodic_seeds(domain, [True, True], rng_seed=2) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + lims = np.array(domain.limits) + n_pieces = np.bincount(pmesh.seed_numbers, minlength=len(seeds)) + # seeds crossing a face have more than one piece + crossing = 0 + for seed_num, seed in enumerate(seeds): + s_lims = np.array(seed.geometry.limits) + if np.any(s_lims[:, 0] < lims[:, 0]) or \ + np.any(s_lims[:, 1] > lims[:, 1]): + crossing += 1 + assert n_pieces[seed_num] >= 2 + assert crossing > 0 + + +# --------------------------------------------------------------------------- # +# Files and errors # +# --------------------------------------------------------------------------- # +def test_periodic_file_round_trip(tmp_path): + domain = msp.geometry.Square(side_length=2) + seeds = _periodic_seeds(domain, [True, False], rng_seed=3, fill=0.5) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + fname = str(tmp_path / 'polymesh.txt') + pmesh.write(fname) + loaded = PolyMesh.from_file(fname) + assert loaded == pmesh + assert np.array_equal(np.array(loaded.points), np.array(pmesh.points)) + assert loaded.periodic_axes == pmesh.periodic_axes + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_points.items()} == pmesh.periodic_points + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_facets.items()} == pmesh.periodic_facets + + # a non-periodic mesh has no periodic sections + pmesh_np = PolyMesh.from_seeds(seeds, domain) + assert 'Periodic' not in str(pmesh_np) + + +def test_periodic_errors(): + seeds = SeedList([Seed.factory('circle', r=0.2, position=(0.5, 0.5))]) + with pytest.raises(ValueError): + PolyMesh.from_seeds(seeds, msp.geometry.Circle(r=1), periodic=True) + # only boxes can be periodic in 3D + seeds_3d = SeedList([Seed.factory('sphere', r=0.2, + position=(0.5, 0.5, 0.5))]) + with pytest.raises(ValueError): + PolyMesh.from_seeds(seeds_3d, msp.geometry.Sphere(r=1), + periodic=True) + + +# --------------------------------------------------------------------------- # +# 3D # +# --------------------------------------------------------------------------- # + + +def test_two_spheres_periodic_in_x(): + domain = msp.geometry.Cube(side_length=1, corner=(0, 0, 0)) + seeds = SeedList([Seed.factory('sphere', r=0.2, position=(0.2, .5, .5)), + Seed.factory('sphere', r=0.2, position=(0.7, .5, .5))]) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + assert sorted(pmesh.seed_numbers) == [0, 0, 1] + assert np.allclose(seed_volumes(pmesh, 2), [0.5, 0.5]) + pieces = sorted([v for v, s in zip(pmesh.volumes, pmesh.seed_numbers) + if s == 0]) + assert np.allclose(pieces, [0.05, 0.45]) + _check_periodic_structure(pmesh, domain, [True, False, False]) + + +def test_single_sphere_tiles_the_cube(): + domain = msp.geometry.Cube(side_length=1, corner=(0, 0, 0)) + seeds = SeedList([Seed.factory('sphere', r=0.2, position=(.3, .6, .8))]) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + assert len(pmesh.regions) == 8 + assert len(pmesh.points) == 27 + assert len(pmesh.facets) == 36 + assert np.isclose(sum(pmesh.volumes), 1.0) + _check_periodic_structure(pmesh, domain, [True, True, True]) + for axis in range(3): + assert len(pmesh.periodic_points[axis]) == 9 + assert len(pmesh.periodic_facets[axis]) == 4 + + +@pytest.mark.parametrize('per_axes', [[True, True, True], + [True, False, True]]) +def test_periodic_3d_matches_tiled_reference(per_axes): + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2)}, + {'shape': 'ellipsoid', 'size': scipy.stats.uniform(0.35, 0.15), + 'ratio_ab': 2, 'ratio_ac': 1.5, 'orientation': 'random'}] + domain = msp.geometry.Box(limits=[(0, 2), (-1, 1), (0.5, 2.5)]) + seeds = SeedList.from_info(phases, 0.4 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=per_axes) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=per_axes) + + assert np.isclose(sum(pmesh.volumes), domain.volume) + assert np.all(np.array(pmesh.volumes) > 0) + assert set(pmesh.seed_numbers) == set(range(len(seeds))) + vols = seed_volumes(pmesh, len(seeds)) + # The copies within 1 of the domain are enough for these seeds: the + # volumes are the same as with all the copies, to rounding. With all + # of them, the reference tessellation (27 copies of about 3000 + # breakdown spheres) needs about 10 GB of memory. + ref = tiled_reference_volumes(seeds, domain, per_axes, margin=1) + # vertices within 1e-5 of the faces are snapped onto them + assert np.allclose(vols, ref, rtol=1e-6, atol=1e-6) + _check_periodic_structure(pmesh, domain, per_axes) + + +def test_periodic_3d_file_round_trip(tmp_path): + domain = msp.geometry.Cube(side_length=2) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.4, 0.2)}] + seeds = SeedList.from_info(phases, 0.4 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=2, periodic='xy') + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='xy') + fname = str(tmp_path / 'polymesh.txt') + pmesh.write(fname) + loaded = PolyMesh.from_file(fname) + assert loaded == pmesh + assert loaded.periodic_axes == [True, True, False] + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_points.items()} == pmesh.periodic_points diff --git a/tests/meshing/test_periodic_quality.py b/tests/meshing/test_periodic_quality.py new file mode 100644 index 00000000..cac08d69 --- /dev/null +++ b/tests/meshing/test_periodic_quality.py @@ -0,0 +1,196 @@ +"""Quality and size control of periodic meshes. + +The quality and size settings of the mesh (min_angle, max_volume, the +max_volume of each phase, max_edge_length) must act on periodic meshes as +on non-periodic ones, and the nodes on opposite faces must still match. +""" +import numpy as np +import pytest +import scipy.stats + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh +from microstructpy.seeding import SeedList + +PHASES_2D = [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15), + 'material_type': 'crystalline', 'max_volume': 1e-3}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180), + 'material_type': 'amorphous'}] +PHASES_3D = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2), + 'material_type': 'crystalline', 'max_volume': 5e-4}, + {'shape': 'sphere', 'size': 0.4, 'material_type': 'amorphous'}] + + +@pytest.fixture(scope='module') +def case_2d(): + domain = msp.geometry.Square(side_length=2, corner=(0, 0)) + seeds = SeedList.from_info(PHASES_2D, 0.55 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=3, periodic=True) + return domain, PolyMesh.from_seeds(seeds, domain, periodic=True) + + +@pytest.fixture(scope='module') +def case_3d(): + domain = msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)) + seeds = SeedList.from_info(PHASES_3D, 0.5 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=True) + return domain, PolyMesh.from_seeds(seeds, domain, periodic=True) + + +def _min_angles_2d(pts, elems): + p = pts[elems] + angs = [] + for k in range(3): + a = p[:, k] - p[:, (k + 1) % 3] + b = p[:, k] - p[:, (k + 2) % 3] + cos = np.einsum('ij,ij->i', a, b) + cos /= np.linalg.norm(a, axis=1) * np.linalg.norm(b, axis=1) + angs.append(np.degrees(np.arccos(np.clip(cos, -1, 1)))) + return np.min(angs, axis=0) + + +def _min_dihedrals(pts, tets): + p = pts[tets] + faces = [(1, 2, 3), (0, 3, 2), (0, 1, 3), (0, 2, 1)] + normals = [] + for f in faces: + n = np.cross(p[:, f[1]] - p[:, f[0]], p[:, f[2]] - p[:, f[0]]) + normals.append(n / np.linalg.norm(n, axis=1)[:, None]) + dih = [] + for i in range(4): + for j in range(i + 1, 4): + cos = np.einsum('ij,ij->i', normals[i], normals[j]) + dih.append(180 - np.degrees(np.arccos(np.clip(cos, -1, 1)))) + return np.min(dih, axis=0) + + +def _corner_angles_2d(pmesh): + """Interior angles of the cells of a 2D polymesh.""" + pts = np.array(pmesh.points) + angs = [] + for region in pmesh.regions: + kps = sorted(set([kp for f in region for kp in pmesh.facets[f]])) + cen = pts[kps].mean(axis=0) + order = np.argsort(np.arctan2(pts[kps][:, 1] - cen[1], + pts[kps][:, 0] - cen[0])) + loop = pts[np.array(kps)[order]] + for i in range(len(loop)): + a = loop[i - 1] - loop[i] + b = loop[(i + 1) % len(loop)] - loop[i] + cos = np.dot(a, b) / np.linalg.norm(a) / np.linalg.norm(b) + angs.append(np.degrees(np.arccos(np.clip(cos, -1, 1)))) + return np.array(angs) + + +def _check_pairs(mesh, domain): + pts = np.array(mesh.points) + lims = np.array(domain.limits) + for axis, (lb, ub) in enumerate(lims): + pairs = mesh.periodic_nodes[axis] + n_low = np.sum(np.isclose(pts[:, axis], lb)) + n_high = np.sum(np.isclose(pts[:, axis], ub)) + assert len(pairs) == n_low == n_high > 0 + shift = np.zeros(len(lims)) + shift[axis] = ub - lb + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + + +def _attribute_phases(mesh, pmesh): + phase_of_seed = {} + for seed_num, phase_num in zip(pmesh.seed_numbers, pmesh.phase_numbers): + phase_of_seed[seed_num] = phase_num + return np.array([phase_of_seed[a] for a in mesh.element_attributes]) + + +# --------------------------------------------------------------------------- # +# 2D # +# --------------------------------------------------------------------------- # +def test_2d_min_angle(case_2d): + domain, pmesh = case_2d + mesh = TriMesh.from_polymesh(pmesh, PHASES_2D, min_angle=25) + pts, elems = np.array(mesh.points), np.array(mesh.elements) + angs = _min_angles_2d(pts, elems) + # Triangle does not improve the angles of the cells themselves + n_small_corners = np.sum(_corner_angles_2d(pmesh) < 25) + assert np.sum(angs < 25 - 1e-6) <= n_small_corners + assert angs.min() >= 10 + _check_pairs(mesh, domain) + + +def test_2d_max_volume(case_2d): + domain, pmesh = case_2d + mesh = TriMesh.from_polymesh(pmesh, PHASES_2D, min_angle=25, + max_volume=4e-3) + pts, elems = np.array(mesh.points), np.array(mesh.elements) + areas = np.linalg.det(pts[elems[:, 1:]] - pts[elems[:, :1]]) / 2.0 + assert np.all(areas > 0) + assert areas.max() <= 4e-3 * (1 + 1e-9) + # the maximum volume of the first phase + phases = _attribute_phases(mesh, pmesh) + assert areas[phases == 0].max() <= 1e-3 * (1 + 1e-9) + assert areas[phases == 1].max() > 1e-3 + assert np.isclose(areas.sum(), domain.area) + _check_pairs(mesh, domain) + + +def test_2d_max_edge_length(case_2d): + domain, pmesh = case_2d + mesh = TriMesh.from_polymesh(pmesh, PHASES_2D, min_angle=25, + max_edge_length=0.06) + pts = np.array(mesh.points) + facets = np.array(mesh.facets) + lengths = np.linalg.norm(pts[facets[:, 0]] - pts[facets[:, 1]], axis=1) + assert lengths.max() <= 0.06 * (1 + 1e-9) + _check_pairs(mesh, domain) + + +# --------------------------------------------------------------------------- # +# 3D # +# --------------------------------------------------------------------------- # +def test_3d_min_dihedral(case_3d): + domain, pmesh = case_3d + mesh = TriMesh.from_polymesh(pmesh, PHASES_3D, min_angle=15) + pts, elems = np.array(mesh.points), np.array(mesh.elements) + dih = _min_dihedrals(pts, elems) + assert np.percentile(dih, 5) >= 15 + assert np.mean(dih < 10) <= 0.02 + _check_pairs(mesh, domain) + + +def test_3d_max_volume(case_3d): + domain, pmesh = case_3d + mesh = TriMesh.from_polymesh(pmesh, PHASES_3D, min_angle=15, + max_volume=2e-3) + pts, elems = np.array(mesh.points), np.array(mesh.elements) + vols = np.linalg.det(pts[elems[:, 1:]] - pts[elems[:, :1]]) / 6.0 + assert np.all(vols > 0) + assert vols.max() <= 2e-3 * (1 + 1e-9) + phases = _attribute_phases(mesh, pmesh) + assert vols[phases == 0].max() <= 5e-4 * (1 + 1e-9) + assert vols[phases == 1].max() > 5e-4 + assert np.isclose(vols.sum(), domain.volume) + _check_pairs(mesh, domain) + + +def test_3d_max_edge_length(case_3d): + domain, pmesh = case_3d + mesh = TriMesh.from_polymesh(pmesh, PHASES_3D, min_angle=15, + max_edge_length=0.1) + pts = np.array(mesh.points) + lims = np.array(domain.limits) + facets = np.array(mesh.facets) + on_wall = np.zeros(len(facets), dtype=bool) + for axis in range(3): + for value in lims[axis]: + on_wall |= np.all(np.isclose(pts[facets][:, :, axis], value), + axis=1) + tris = facets[on_wall] + edges = np.concatenate([np.linalg.norm(pts[tris[:, i]] - + pts[tris[:, (i + 1) % 3]], axis=1) + for i in range(3)]) + assert edges.max() <= 0.1 * 1.1 + _check_pairs(mesh, domain) diff --git a/tests/meshing/test_periodic_trimesh.py b/tests/meshing/test_periodic_trimesh.py new file mode 100644 index 00000000..c553be2e --- /dev/null +++ b/tests/meshing/test_periodic_trimesh.py @@ -0,0 +1,316 @@ +"""Tests for periodic triangular, tetrahedral and raster meshes.""" +import numpy as np +import pytest +import scipy.stats +from periodic_helpers import check_periodic_pairs +from periodic_helpers import wedge_seeds_2d + +import microstructpy as msp +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import RasterMesh +from microstructpy.meshing import TriMesh +from microstructpy.meshing import trimesh as trimesh_module +from microstructpy.seeding import SeedList + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _phases(): + return [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15), + 'material_type': 'crystalline'}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180), + 'material_type': 'amorphous'}] + + +@pytest.fixture(scope='module') +def periodic_case(): + domain = msp.geometry.Square(side_length=2, corner=(0, 0)) + phases = _phases() + seeds = SeedList.from_info(phases, 0.55 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=True) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + return domain, phases, seeds, pmesh + + +def _element_areas(mesh): + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + p0, p1, p2 = pts[elems[:, 0]], pts[elems[:, 1]], pts[elems[:, 2]] + return 0.5 * ((p1[:, 0] - p0[:, 0]) * (p2[:, 1] - p0[:, 1]) - + (p2[:, 0] - p0[:, 0]) * (p1[:, 1] - p0[:, 1])) + + +def _check_periodic_mesh(mesh, domain, per_axes): + """Every node on a periodic face is paired with its exact image, and + the facets on the faces (edges in 2D, triangles in 3D) are paired.""" + assert mesh.periodic_axes == list(per_axes) + faces = check_periodic_pairs(mesh.points, mesh.facets, + mesh.periodic_nodes, mesh.periodic_facets, + per_axes, domain) + for axis, (low, high, n_low) in faces.items(): + if domain.n_dim == 2: + # the edges on a face connect its nodes in a chain + assert n_low == len(low) - 1 + else: + assert n_low > 0 + + +# --------------------------------------------------------------------------- # +# Triangular meshes # +# --------------------------------------------------------------------------- # +def test_periodic_trimesh_nodes_match(periodic_case): + domain, phases, seeds, pmesh = periodic_case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + _check_periodic_mesh(mesh, domain, [True, True]) + areas = _element_areas(mesh) + assert np.all(areas > 0) + assert np.isclose(areas.sum(), domain.area) + # element attributes are seed numbers of the mesh + assert set(mesh.element_attributes) <= set(pmesh.seed_numbers) + + +def test_periodic_trimesh_edge_subdivision(periodic_case): + domain, phases, seeds, pmesh = periodic_case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20, + max_edge_length=0.08) + _check_periodic_mesh(mesh, domain, [True, True]) + pts = np.array(mesh.points) + facets = np.array(mesh.facets) + lengths = np.linalg.norm(pts[facets[:, 0]] - pts[facets[:, 1]], axis=1) + assert lengths.max() <= 0.08 + 1e-9 + # the boundary was subdivided (more node pairs than polymesh points + # on the face) + poly_pts = np.array(pmesh.points) + n_poly = np.sum(np.isclose(poly_pts[:, 0], 0)) + assert len(mesh.periodic_nodes[0]) > n_poly + + +def test_periodic_trimesh_single_axis(): + domain = msp.geometry.Rectangle(length=3, width=2, corner=(0, 0)) + phases = _phases() + seeds = SeedList.from_info(phases, 0.55 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=2, periodic='x') + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='x') + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + _check_periodic_mesh(mesh, domain, [True, False]) + assert np.isclose(_element_areas(mesh).sum(), domain.area) + + +def test_non_periodic_trimesh_unchanged(periodic_case): + domain, phases, seeds, pmesh = periodic_case + pmesh_np = PolyMesh.from_seeds(seeds, domain) + mesh = TriMesh.from_polymesh(pmesh_np, phases, min_angle=20) + assert mesh.periodic_axes is None + assert mesh.periodic_nodes is None + assert 'Periodic' not in str(mesh) + + +def test_periodic_trimesh_file_round_trip(periodic_case, tmp_path): + domain, phases, seeds, pmesh = periodic_case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + fname = str(tmp_path / 'trimesh.txt') + mesh.write(fname) + loaded = TriMesh.from_file(fname) + assert np.array_equal(np.array(loaded.points), np.array(mesh.points)) + assert np.array_equal(np.array(loaded.elements), + np.array(mesh.elements)) + assert loaded.periodic_axes == mesh.periodic_axes + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_nodes.items()} == mesh.periodic_nodes + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_facets.items()} == mesh.periodic_facets + + +def test_periodic_trimesh_abaqus_node_sets(periodic_case, tmp_path): + domain, phases, seeds, pmesh = periodic_case + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + fname = str(tmp_path / 'mesh.inp') + mesh.write(fname, 'abaqus', seeds, pmesh) + with open(fname, 'r') as file: + lines = file.read().splitlines() + + def nset(name): + i = lines.index('*Nset, nset=' + name + ', unsorted') + 1 + nodes = [] + while i < len(lines) and not lines[i].startswith('*'): + nodes.extend([int(n) for n in lines[i].split(',')]) + i += 1 + return nodes + + pts = np.array(mesh.points) + for axis, name in enumerate('XY'): + low = nset('Set-N-Periodic-' + name + '-Low') + high = nset('Set-N-Periodic-' + name + '-High') + pairs = mesh.periodic_nodes[axis] + assert low == [lo + 1 for lo, _ in pairs] + assert high == [hi + 1 for _, hi in pairs] + shift = np.zeros(2) + shift[axis] = 2 + for lo, hi in zip(low, high): + assert np.array_equal(pts[hi - 1], pts[lo - 1] + shift) + + +def _wedge_polymesh(angle_deg): + """A square of side 3, periodic in x, with a facet meeting the face + x = 3 at ``angle_deg`` (see the tests of the edge optimization).""" + domain = msp.geometry.Square(side_length=3, corner=(0, 0)) + return PolyMesh.from_seeds(wedge_seeds_2d(angle_deg), domain, + periodic='x') + + +def _min_edge(mesh): + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + lengths = [np.linalg.norm(pts[elems[:, i]] - pts[elems[:, (i + 1) % 3]], + axis=1) for i in range(3)] + return np.min(lengths) + + +def test_periodic_trimesh_no_cascade_at_wedges(): + # a corner narrower than the minimum angle makes Triangle refine it in + # shells of small elements; the passes that match the periodic faces + # must not deepen the shells (they did, one level per pass) + phases = [{'shape': 'circle', 'size': 0.4}] + for angle_deg, min_angle in ((15.0, 20), (15.0, 25)): + pmesh = _wedge_polymesh(angle_deg) + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=min_angle, + max_volume=0.05) + pmesh.periodic_axes = [False, False] + pmesh.periodic_points = {} + pmesh.periodic_facets = {} + plain = TriMesh.from_polymesh(pmesh, phases, min_angle=min_angle, + max_volume=0.05) + # at most one level of shells beyond the non-periodic mesh (the + # shells of both faces are put together), with some slack + assert _min_edge(mesh) >= 0.3 * _min_edge(plain) + + +def test_ghost_layer_copies_are_closed(periodic_case): + # the copies of the cells outside the periodic faces are closed + # polygons, including those of the cells at the corners of the domain, + # which are moved along both axes: every point of a copy is shared by + # at least two facets, so that Triangle does not eat into the copies + domain, phases, seeds, pmesh = periodic_case + pts = [list(p) for p in pmesh.points] + kps = {i: i for i in range(len(pts))} + facets = [list(f) for f in pmesh.facets] + facet_nums = [f + 1 for f in range(len(facets))] + labels = np.arange(len(pmesh.regions)) + out = trimesh_module._ghost_layer(pmesh, phases, labels, kps, pts, + facets, facet_nums, [], [], np.inf) + g_pts, g_facets, g_nums = out[0], out[1], out[2] + assert len(g_pts) > len(pts) + assert any([n == 0 for n in g_nums]) + degree = np.zeros(len(g_pts), dtype=int) + for facet in g_facets: + for kp in facet: + degree[kp] += 1 + assert np.all(degree[len(pts):] >= 2) + # the copies cover the corners of the domain: points outside along + # both axes exist + arr = np.array(g_pts) + lims = np.array(domain.limits) + outside = (arr < lims[:, 0] - 1e-9) | (arr > lims[:, 1] + 1e-9) + assert np.any(np.all(outside, axis=1)) + + +def test_periodic_gmsh_not_supported(periodic_case): + domain, phases, seeds, pmesh = periodic_case + with pytest.raises(NotImplementedError): + TriMesh.from_polymesh(pmesh, phases, mesher='gmsh', mesh_size=0.1) + + +# --------------------------------------------------------------------------- # +# Raster meshes # +# --------------------------------------------------------------------------- # +def test_periodic_raster_mesh(periodic_case): + domain, phases, seeds, pmesh = periodic_case + mesh = RasterMesh.from_polymesh(pmesh, 0.05, phases) + pts = np.array(mesh.points) + assert mesh.periodic_axes == [True, True] + for axis in (0, 1): + pairs = mesh.periodic_nodes[axis] + assert len(pairs) == 41 + shift = np.zeros(2) + shift[axis] = 2 + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) + with pytest.raises(ValueError): + RasterMesh.from_polymesh(pmesh, 0.07, phases) + + +# --------------------------------------------------------------------------- # +# 3D # +# --------------------------------------------------------------------------- # +@pytest.fixture(scope='module') +def periodic_case_3d(): + domain = msp.geometry.Cube(side_length=1.5, corner=(0, 0, 0)) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.35, 0.2), + 'material_type': 'crystalline'}, + {'shape': 'sphere', 'size': 0.4, 'material_type': 'amorphous'}] + seeds = SeedList.from_info(phases, 0.5 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=True) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + return domain, phases, seeds, pmesh + + +def _element_volumes(mesh): + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + rel = pts[elems[:, 1:]] - pts[elems[:, :1]] + return np.linalg.det(rel) / 6.0 + + +def test_periodic_tetmesh_nodes_match(periodic_case_3d): + domain, phases, seeds, pmesh = periodic_case_3d + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + _check_periodic_mesh(mesh, domain, [True, True, True]) + vols = _element_volumes(mesh) + assert np.all(np.abs(vols) > 0) + assert np.isclose(np.abs(vols).sum(), domain.volume) + assert set(mesh.element_attributes) <= set(pmesh.seed_numbers) + + +def test_periodic_tetmesh_single_axis(): + domain = msp.geometry.Box(limits=[(0, 1.5), (0, 1), (0, 1)]) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2)}] + seeds = SeedList.from_info(phases, 0.5 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=3, periodic='z') + pmesh = PolyMesh.from_seeds(seeds, domain, periodic='z') + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + _check_periodic_mesh(mesh, domain, [False, False, True]) + assert np.isclose(np.abs(_element_volumes(mesh)).sum(), domain.volume) + + +def test_periodic_tetmesh_file_and_abaqus(periodic_case_3d, tmp_path): + domain, phases, seeds, pmesh = periodic_case_3d + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + fname = str(tmp_path / 'trimesh.txt') + mesh.write(fname) + loaded = TriMesh.from_file(fname) + assert np.array_equal(np.array(loaded.points), np.array(mesh.points)) + assert {k: [tuple(p) for p in v] for k, v in + loaded.periodic_nodes.items()} == mesh.periodic_nodes + mesh.write(str(tmp_path / 'mesh.inp'), 'abaqus', seeds, pmesh) + with open(str(tmp_path / 'mesh.inp'), 'r') as file: + text = file.read() + for name in 'XYZ': + assert '*Nset, nset=Set-N-Periodic-' + name + '-Low' in text + assert '*Nset, nset=Set-N-Periodic-' + name + '-High' in text + + +def test_periodic_raster_mesh_3d(periodic_case_3d): + domain, phases, seeds, pmesh = periodic_case_3d + mesh = RasterMesh.from_polymesh(pmesh, 0.15, phases) + assert mesh.periodic_axes == [True, True, True] + pts = np.array(mesh.points) + for axis in range(3): + pairs = mesh.periodic_nodes[axis] + assert len(pairs) == 11 * 11 + shift = np.zeros(3) + shift[axis] = 1.5 + for lo, hi in pairs: + assert np.array_equal(pts[hi], pts[lo] + shift) diff --git a/tests/meshing/test_polymesh_fixes.py b/tests/meshing/test_polymesh_fixes.py new file mode 100644 index 00000000..02d95722 --- /dev/null +++ b/tests/meshing/test_polymesh_fixes.py @@ -0,0 +1,356 @@ +"""Tests for the PolyMesh bug fixes. + +Each test covers one of the fixes to :mod:`microstructpy.meshing.polymesh`: +the area of a vertex loop, the clipping of cells to non-rectangular 2D +domains, the short-edge optimization, the precision of the text file, +the poly file writer, the segment/boundary crossing at large coordinates, +3D plotting on a fresh figure, per-region numpy arrays in plot keyword +arguments, and the silence of the mesh comparison. +""" +from __future__ import division + +import copy +import os +import time + +import numpy as np +from matplotlib import path as mpath +from matplotlib import pyplot as plt +from periodic_helpers import min_edge + +from microstructpy import geometry +from microstructpy.meshing.polymesh import PolyMesh +from microstructpy.meshing.polymesh import _loop_area +from microstructpy.meshing.polymesh import _segment_cross +from microstructpy.meshing.polymesh import kp_loop +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _circle_seeds(positions, r=0.3): + """Circle seeds at the given positions (breakdowns translated).""" + seeds = SeedList([]) + for pos in positions: + seed = Seed.factory('circle', r=r) + seed.position = list(pos) + seeds.append(seed) + return seeds + + +def _random_seeds(domain, n_seeds, r=0.1, rng_seed=0): + seeds = SeedList([Seed.factory('circle', r=r) for _ in range(n_seeds)]) + seeds.position(domain, rng_seed=rng_seed) + return seeds + + +def _region_paths(pmesh): + pts = np.array(pmesh.points) + loops = [kp_loop([pmesh.facets[f] for f in r]) for r in pmesh.regions] + return [mpath.Path(pts[loop]) for loop in loops] + + +def _random_interior_points(domain, n_pts, frac=0.9, rng_seed=1): + """Random points within ``frac`` of the domain boundary.""" + rng = np.random.RandomState(rng_seed) + pts = [] + while len(pts) < n_pts: + pt = rng.uniform(-1, 1, 2) + if np.linalg.norm(pt) >= frac: + continue + if isinstance(domain, geometry.Ellipse): + pt = domain.matrix.dot(pt * np.array([domain.a, domain.b])) + else: + pt = pt * domain.r + pts.append(np.array(domain.center) + pt) + return pts + + +def _check_partition(pmesh, domain, n_seeds): + """Check that the mesh partitions the domain.""" + pts = np.array(pmesh.points) + vols = np.array(pmesh.volumes) + assert len(pmesh.regions) == n_seeds + assert set(pmesh.seed_numbers) == set(range(n_seeds)) + assert np.all(np.isfinite(pts)) + assert np.all(vols > 0) + assert abs(vols.sum() - domain.area) < 0.02 * domain.area + + # the stored volumes match the polygons + recomputed = PolyMesh(pmesh.points, pmesh.facets, pmesh.regions).volumes + assert np.allclose(vols, recomputed) + + # each interior point is in exactly one region + paths = _region_paths(pmesh) + for pt in _random_interior_points(domain, 200): + n_in = sum([path.contains_point(pt) for path in paths]) + assert n_in == 1 + + +# --------------------------------------------------------------------------- # +# 1. _loop_area # +# --------------------------------------------------------------------------- # +def test_loop_area_unordered_points(): + # unit square, vertices stored out of loop order + pts = [(0, 0), (1, 1), (1, 0), (0, 1)] + loop = [0, 2, 1, 3] + assert np.isclose(_loop_area(pts, loop), 1.0) + assert np.isclose(_loop_area(pts, loop[::-1]), 1.0) + + +# --------------------------------------------------------------------------- # +# 2. Clipping cells to circular and elliptical domains # +# --------------------------------------------------------------------------- # +def test_clip_cells_without_interior_vertices(): + # the top and bottom cells have no vertex within the circle + domain = geometry.Circle(r=1) + seeds = _circle_seeds([(0, 0.4), (0, -0.4), (0, 0)]) + pmesh = PolyMesh.from_seeds(seeds, domain) + + assert len(pmesh.regions) == 3 + assert sorted(pmesh.seed_numbers) == [0, 1, 2] + assert np.all(np.isfinite(np.array(pmesh.points))) + assert all([len(r) >= 3 for r in pmesh.regions]) + assert np.all(np.array(pmesh.volumes) > 0) + + # the boundary arcs are approximated by chords (one per gap in the + # cell), which under-estimates the area - significantly with only + # three cells + total_area = sum(pmesh.volumes) + assert 0.7 * np.pi < total_area < np.pi + + +def test_clip_domain_within_cell(): + # a single seed: the Voronoi cell is the bounding box of the circle + domain = geometry.Circle(r=2) + pmesh = PolyMesh.from_seeds(_circle_seeds([(0, 0)]), domain) + + assert len(pmesh.regions) == 1 + assert abs(pmesh.volumes[0] - 4 * np.pi) < 0.01 * 4 * np.pi + assert len(pmesh.regions[0]) >= 3 + assert np.allclose(np.linalg.norm(np.array(pmesh.points), axis=1), 2) + + +def test_clip_single_edge_crossing(): + # the cell of the second seed is a circular segment: the circle crosses + # a single edge of the cell twice + domain = geometry.Circle(r=1) + seeds = _circle_seeds([(0, 0), (0, -0.99)], r=0.3) + pmesh = PolyMesh.from_seeds(seeds, domain) + + assert len(pmesh.regions) == 2 + assert sorted(pmesh.seed_numbers) == [0, 1] + assert all([len(r) >= 3 for r in pmesh.regions]) + assert np.all(np.array(pmesh.volumes) > 0) + + +def test_clip_partition_circle(): + domain = geometry.Circle(r=1) + n_seeds = 60 + seeds = _random_seeds(domain, n_seeds) + pmesh = PolyMesh.from_seeds(seeds, domain) + _check_partition(pmesh, domain, n_seeds) + + +def test_clip_partition_ellipse(): + domain = geometry.Ellipse(a=1.5, b=1) + n_seeds = 100 + seeds = _random_seeds(domain, n_seeds) + pmesh = PolyMesh.from_seeds(seeds, domain) + _check_partition(pmesh, domain, n_seeds) + + +def test_clip_partition_rotated_ellipse(): + domain = geometry.Ellipse(a=1.5, b=1, angle=30, center=(0.3, -0.2)) + n_seeds = 100 + seeds = _random_seeds(domain, n_seeds) + pmesh = PolyMesh.from_seeds(seeds, domain) + _check_partition(pmesh, domain, n_seeds) + + +def test_rectangular_domain_unchanged(): + domain = geometry.Square(side_length=2) + seeds = _random_seeds(domain, 30) + pmesh = PolyMesh.from_seeds(seeds, domain) + + assert len(pmesh.regions) == 30 + assert abs(sum(pmesh.volumes) - domain.area) < 1e-10 + + +# --------------------------------------------------------------------------- # +# 3. Short edge optimization # +# --------------------------------------------------------------------------- # +def test_edge_opt(capsys): + np.random.seed(0) + domain = geometry.Square(side_length=2) + seeds = _random_seeds(domain, 12, r=0.15, rng_seed=3) + seeds_orig = copy.deepcopy(seeds) + + pmesh_0 = PolyMesh.from_seeds(seeds, domain) + min_len_0 = min_edge(pmesh_0) + + capsys.readouterr() + pmesh = PolyMesh.from_seeds(seeds, domain, edge_opt=True, n_iter=10, + verbose=False) + captured = capsys.readouterr() + assert captured.out == '' + + # the minimum edge length does not decrease + assert min_edge(pmesh) >= min_len_0 + + # the seeds are in the accepted state: re-tessellating them + # reproduces the returned mesh + pmesh_re = PolyMesh.from_seeds(seeds, domain) + assert len(pmesh_re.regions) == len(pmesh.regions) + assert np.allclose(np.sort(pmesh_re.volumes), np.sort(pmesh.volumes), + rtol=0, atol=1e-9) + + # the seeds were displaced rigidly + for seed in seeds: + assert np.allclose(seed.breakdown[0][:-1], seed.position) + assert np.allclose(seed.geometry.center, seed.position) + + # the seeds were displaced (the mesh changed) + n_moved = sum([not np.allclose(s.position, s0.position) + for s, s0 in zip(seeds, seeds_orig)]) + assert n_moved > 0 + assert n_moved <= 2 * 10 * 2 # at most 2 seeds per accepted trial + + +# --------------------------------------------------------------------------- # +# 4. Full precision text files # +# --------------------------------------------------------------------------- # +def test_write_read_full_precision(tmp_path): + domain = geometry.Square(side_length=1.7) + seeds = _random_seeds(domain, 15, r=0.1) + pmesh = PolyMesh.from_seeds(seeds, domain) + + filename = str(tmp_path / 'polymesh.txt') + pmesh.write(filename) + pmesh_rw = PolyMesh.from_file(filename) + + assert np.array_equal(np.array(pmesh.points, dtype='float'), + np.array(pmesh_rw.points, dtype='float')) + assert np.array_equal(np.array(pmesh.volumes, dtype='float'), + np.array(pmesh_rw.volumes, dtype='float')) + assert pmesh == pmesh_rw + + +# --------------------------------------------------------------------------- # +# 5. Poly file writer # +# --------------------------------------------------------------------------- # +def test_write_poly(tmp_path): + pts = [(0, 0), (1, 0), (1, 1), (0, 1), (1.5, 0)] + facets = [(0, 1), (1, 2), (2, 3), (3, 0), (1, 4), (4, 2)] + regions = [(0, 1, 2, 3), (4, 5, 1)] + pmesh = PolyMesh(pts, facets, regions) + + filename = str(tmp_path / 'polymesh.poly') + pmesh.write(filename, format='poly') + + assert os.path.exists(filename) + with open(filename, 'r') as file: + lines = [ln for ln in file.read().split('\n') if ln.strip()] + data_lines = [ln for ln in lines if not ln.startswith('#')] + assert int(data_lines[0].split()[0]) == len(pts) + + +# --------------------------------------------------------------------------- # +# 6. Segment crossing at large coordinates # +# --------------------------------------------------------------------------- # +def test_segment_cross_large_coordinates(): + center = np.array([1e5, 1e5]) + domain = geometry.Circle(center=center, r=1) + pts = [center, center + np.array([2, 0])] + + t_start = time.time() + crossing = _segment_cross(pts, domain) + elapsed = time.time() - t_start + + expected = center + np.array([1, 0]) + assert np.linalg.norm(crossing - expected) < 1e-9 * np.linalg.norm( + expected) + assert elapsed < 1 + + # the result does not depend on the order of the end points + assert np.array_equal(_segment_cross(pts[::-1], domain), crossing) + + +# --------------------------------------------------------------------------- # +# 7. 3D plot on a fresh figure # +# --------------------------------------------------------------------------- # +def test_plot_3d_fresh_figure(): + domain = geometry.Cube(side_length=1) + positions = [(-0.2, -0.2, -0.2), (0.2, 0.2, 0.2), (0.2, -0.2, 0.1)] + seeds = SeedList([Seed.factory('sphere', r=0.25, position=p) + for p in positions]) + pmesh = PolyMesh.from_seeds(seeds, domain) + plt.close('all') + plt.figure() + try: + pmesh.plot() + assert plt.gca().name == '3d' + finally: + plt.close('all') + + +# --------------------------------------------------------------------------- # +# 8. Per-region numpy arrays in plot keyword arguments # +# --------------------------------------------------------------------------- # +def test_plot_facecolors_array(): + domain = geometry.Square(side_length=2) + seeds = _random_seeds(domain, 8, r=0.15) + phases = [0, 0, 0, 1, 1, 1, 1, 1] + for seed, phase in zip(seeds, phases): + seed.phase = phase + pmesh = PolyMesh.from_seeds(seeds, domain) + n_regions = len(pmesh.regions) + + # one colour per region, indexed by seed + colors = np.array([plt.cm.viridis(i / n_regions) + for i in range(n_regions)]) + plt.close('all') + plt.figure() + try: + pmesh.plot(facecolors=colors) + collection = plt.gca().collections[-1] + assert len(collection.get_facecolors()) == n_regions + assert np.allclose(collection.get_facecolors(), + colors[pmesh.seed_numbers]) + finally: + plt.close('all') + + # one colour per material phase + colors = np.array([[1, 0, 0, 1], [0, 0, 1, 1]], dtype='float') + plt.figure() + try: + pmesh.plot(index_by='material', facecolors=colors) + collection = plt.gca().collections[-1] + assert len(collection.get_facecolors()) == n_regions + assert np.allclose(collection.get_facecolors(), + colors[pmesh.phase_numbers]) + finally: + plt.close('all') + + +# --------------------------------------------------------------------------- # +# 9. Mesh comparison is silent # +# --------------------------------------------------------------------------- # +def test_eq_silent(capsys): + pts = [(0, 0), (1, 0), (1, 1), (0, 1), (1.5, 0)] + facets = [(0, 1), (1, 2), (2, 3), (3, 0), (1, 4), (4, 2)] + regions = [(0, 1, 2, 3), (4, 5, 1)] + pmesh = PolyMesh(pts, facets, regions, [0, 1], [2, 2]) + pmesh_2 = PolyMesh(pts, facets, [(0, 3, 2, 1), (4, 1, 5)], [0, 1], + [2, 2]) + pmesh_3 = PolyMesh(pts, facets, regions, [0, 1], [2, 3]) + + capsys.readouterr() + assert pmesh == pmesh_2 + assert pmesh != pmesh_3 + assert pmesh != pts + captured = capsys.readouterr() + assert captured.out == '' + assert captured.err == '' diff --git a/tests/meshing/test_trimesh_fixes.py b/tests/meshing/test_trimesh_fixes.py new file mode 100644 index 00000000..dbe0623d --- /dev/null +++ b/tests/meshing/test_trimesh_fixes.py @@ -0,0 +1,734 @@ +"""Tests for the bug fixes in microstructpy.meshing.trimesh. + +Each test corresponds to a defect that was fixed in the TriMesh and +RasterMesh classes: mesh size controls that were ignored, raster meshes +with inverted elements and wrong facets, corrupt or incomplete output +files, and robustness of the constructors and writers. +""" +from __future__ import division + +import copy +import re + +import meshpy.triangle +import numpy as np +import pytest +import scipy.stats +from matplotlib import colors as mcolors +from matplotlib import pyplot as plt + +import microstructpy as msp +from microstructpy.meshing import trimesh as trimesh_module +from microstructpy.meshing.trimesh import RasterMesh +from microstructpy.meshing.trimesh import TriMesh + +# Abaqus element face definitions (local node numbers, 0-based) +# CPS4: S1 = 1-2, S2 = 2-3, S3 = 3-4, S4 = 4-1 +# C3D8: S1 = 1-2-3-4, S2 = 5-8-7-6, S3 = 1-5-6-2, S4 = 2-6-7-3, +# S5 = 3-7-8-4, S6 = 4-8-5-1 +ABAQUS_FACES = { + 4: {1: (0, 1), 2: (1, 2), 3: (2, 3), 4: (3, 0)}, + 8: {1: (0, 1, 2, 3), 2: (4, 7, 6, 5), 3: (0, 4, 5, 1), + 4: (1, 5, 6, 2), 5: (2, 6, 7, 3), 6: (3, 7, 4, 0)}, +} + +MESH_SIZE = 0.1 + + +# --------------------------------------------------------------------------- # +# Fixtures # +# --------------------------------------------------------------------------- # +@pytest.fixture(scope='module') +def case_2d(): + """2D microstructure with crystalline, amorphous, and void phases.""" + np.random.seed(1) + phases = [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.1), + 'material_type': 'crystalline', 'fraction': 0.5}, + {'shape': 'ellipse', 'size': 0.25, 'aspect_ratio': 2, + 'angle_deg': scipy.stats.uniform(0, 180), + 'material_type': 'amorphous', 'fraction': 0.35}, + {'shape': 'circle', 'size': 0.2, 'material_type': 'void', + 'fraction': 0.15}] + domain = msp.geometry.Square(side_length=2) + seeds = msp.seeding.SeedList.from_info(phases, domain.area) + seeds.position(domain) + pmesh = msp.meshing.PolyMesh.from_seeds(seeds, domain) + return pmesh, phases, seeds + + +@pytest.fixture(scope='module') +def case_3d(): + """3D microstructure with crystalline, amorphous, and void phases.""" + np.random.seed(1) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.3, 0.2), + 'material_type': 'crystalline', 'fraction': 0.5}, + {'shape': 'sphere', 'size': 0.4, 'material_type': 'amorphous', + 'fraction': 0.35}, + {'shape': 'sphere', 'size': 0.35, 'material_type': 'void', + 'fraction': 0.15}] + domain = msp.geometry.Cube(side_length=1.5) + seeds = msp.seeding.SeedList.from_info(phases, domain.volume) + seeds.position(domain) + pmesh = msp.meshing.PolyMesh.from_seeds(seeds, domain) + return pmesh, phases, seeds + + +@pytest.fixture(scope='module') +def tri_2d(case_2d): + pmesh, phases, _ = case_2d + return TriMesh.from_polymesh(pmesh, phases, min_angle=20) + + +@pytest.fixture(scope='module') +def tri_3d(case_3d): + pmesh, phases, _ = case_3d + return TriMesh.from_polymesh(pmesh, phases, min_angle=10) + + +@pytest.fixture(scope='module') +def raster_2d(case_2d): + pmesh, phases, _ = case_2d + return RasterMesh.from_polymesh(pmesh, MESH_SIZE, phases) + + +@pytest.fixture(scope='module') +def raster_3d(case_3d): + pmesh, phases, _ = case_3d + return RasterMesh.from_polymesh(pmesh, MESH_SIZE, phases) + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def simplex_volumes(mesh): + """Areas of the triangles or volumes of the tetrahedra of a mesh.""" + pts = np.array(mesh.points) + elems = np.array(mesh.elements) + rel = pts[elems[:, 1:]] - pts[elems[:, :1]] + if pts.shape[1] == 2: + return 0.5 * np.abs(np.linalg.det(rel)) + return np.abs(np.linalg.det(rel)) / 6 + + +def element_phases(mesh, seeds): + """Phase number of each element, from the seed numbers.""" + seed_phases = np.array([seed.phase for seed in seeds]) + return seed_phases[np.array(mesh.element_attributes)] + + +def facet_elements(mesh): + """Set of elements that contain all the nodes of each facet.""" + node_elems = {} + for e_num, elem in enumerate(mesh.elements): + for kp in elem: + node_elems.setdefault(int(kp), set()).add(e_num) + return [set.intersection(*[node_elems[int(kp)] for kp in facet]) + for facet in mesh.facets] + + +def domain_face(pts, facet): + """Voro++ id of the domain face all the points of a facet lie on. + + Returns -1/-2 for the -x/+x faces, -3/-4 for y, -5/-6 for z, and None + if the facet is not on the boundary of the bounding box of the points. + """ + mins = pts.min(axis=0) + maxs = pts.max(axis=0) + f_pts = pts[list(facet)] + for axis in range(pts.shape[1]): + if np.allclose(f_pts[:, axis], mins[axis]): + return -(2 * axis + 1) + if np.allclose(f_pts[:, axis], maxs[axis]): + return -(2 * axis + 2) + return None + + +def parse_abaqus(filename): + """Nodes, elements, element surfaces, and surface unions of a deck.""" + nodes = {} + elems = {} + surfaces = {} + unions = {} + block = None + name = None + with open(filename, 'r') as file: + for line in file: + line = line.strip() + if not line or line.startswith('**'): + continue + if line.startswith('*'): + key = line.split(',')[0].lower() + if key == '*node': + block = 'node' + elif key == '*element': + block = 'element' + elif key == '*surface': + name = re.search(r'name=([^,]+)', line).group(1) + if 'combine=union' in line: + block = 'union' + unions[name] = [] + else: + block = 'surface' + surfaces[name] = [] + else: + block = None + continue + + vals = [v.strip() for v in line.split(',')] + if block == 'node': + nodes[int(vals[0])] = [float(v) for v in vals[1:]] + elif block == 'element': + elems[int(vals[0])] = [int(v) for v in vals[1:]] + elif block == 'surface': + surfaces[name].append((int(vals[0]), int(vals[1][1:]))) + elif block == 'union': + unions[name].append(vals[0]) + return nodes, elems, surfaces, unions + + +def check_raster_facets(mesh, pmesh): + """Checks on the facets of a raster mesh built from a polymesh.""" + pts = np.array(mesh.points) + facets = np.array(mesh.facets) + facet_atts = np.array(mesh.facet_attributes) + elem_atts = np.array(mesh.element_attributes) + n_kp = 2 * (pts.shape[1] - 1) + + assert facets.shape == (len(facet_atts), n_kp) + assert len(facets) > 0 + + # Attributes are polymesh facet numbers + assert np.all(facet_atts >= 0) + assert np.all(facet_atts < len(pmesh.facets)) + + # No duplicate facets + keys = [tuple(sorted(f)) for f in facets] + assert len(set(keys)) == len(keys) + + poly_neighbors = np.array(pmesh.facet_neighbors) + n_interior = 0 + n_boundary = 0 + for facet, att, elems in zip(facets, facet_atts, facet_elements(mesh)): + neighs = poly_neighbors[att] + on_face = domain_face(pts, facet) + if len(elems) == 2: + # interface: a shared face between pixels of different seeds + # (or merged amorphous regions), approximating an interior facet + e1, e2 = elems + assert elem_atts[e1] != elem_atts[e2] + assert np.min(neighs) >= 0 + n_interior += 1 + else: + # boundary of the mesh: the domain boundary or a void + assert len(elems) == 1 + if np.min(neighs) < 0: + assert on_face == np.min(neighs) + n_boundary += 1 + else: + assert on_face is None + assert n_interior > 0 + assert n_boundary > 0 + + # Every face of the mesh boundary is a facet + face_counts = {} + for elem in mesh.elements: + for local_kps in ABAQUS_FACES[len(elem)].values(): + key = tuple(sorted([int(elem[k]) for k in local_kps])) + face_counts[key] = face_counts.get(key, 0) + 1 + boundary_faces = {k for k, n in face_counts.items() if n == 1} + assert boundary_faces <= set(keys) + + +def check_raster_abaqus(mesh, pmesh, filename): + """Checks on the Abaqus deck of a raster mesh.""" + mesh.write(filename, 'abaqus', polymesh=pmesh) + nodes, elems, surfaces, unions = parse_abaqus(filename) + + assert len(nodes) == len(mesh.points) + assert len(elems) == len(mesh.elements) + + facet_sets = {} + for facet, att in zip(mesh.facets, mesh.facet_attributes): + key = frozenset([int(kp) + 1 for kp in facet]) + facet_sets.setdefault(int(att), set()).add(key) + + n_entries = 0 + for name, entries in surfaces.items(): + att = int(name.split('-')[1]) + for elem_id, face_id in entries: + elem = elems[elem_id] + face = ABAQUS_FACES[len(elem)][face_id] + face_nodes = frozenset([elem[k] for k in face]) + assert face_nodes in facet_sets[att] + n_entries += 1 + assert n_entries == len(mesh.facets) + + assert len(unions) > 0 + for members in unions.values(): + assert len(members) > 0 + for member in members: + assert member in surfaces + + +def parse_vtk_rectilinear(filename): + """Dimensions and scalar cell data arrays of a rectilinear grid.""" + with open(filename, 'r') as file: + text = file.read() + dims = [int(n) for n in re.search(r'DIMENSIONS (\d+) (\d+) (\d+)', + text).groups()] + n_cells = int(re.search(r'CELL_DATA (\d+)', text).group(1)) + scalars = {} + for block in text.split('SCALARS ')[1:]: + name = block.split()[0] + values = block.split('LOOKUP_TABLE default')[1].split() + scalars[name] = [float(v) for v in values] + return dims, n_cells, scalars + + +# --------------------------------------------------------------------------- # +# 1. TetGen maximum volume # +# --------------------------------------------------------------------------- # +def test_tetgen_global_max_volume(case_3d): + pmesh, phases, _ = case_3d + free = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + # a bound below the largest unconstrained element must refine the mesh + max_volume = 0.5 * simplex_volumes(free).max() + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10, + max_volume=max_volume) + + vols = simplex_volumes(mesh) + assert np.all(vols <= max_volume + 1e-9) + assert len(mesh.elements) > len(free.elements) + + +def test_tetgen_per_phase_max_volume(case_3d): + pmesh, phases, seeds = case_3d + max_volume = 5e-4 + + # Only the phase with a maximum volume is refined + phases = copy.deepcopy(phases) + phases[0]['max_volume'] = max_volume + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10) + vols = simplex_volumes(mesh) + elem_phases = element_phases(mesh, seeds) + assert np.all(vols[elem_phases == 0] <= max_volume + 1e-9) + assert np.max(vols[elem_phases != 0]) > max_volume + + # A per-phase maximum larger than the global default is not capped + phases[0]['max_volume'] = 1e-2 + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=10, + max_volume=max_volume) + vols = simplex_volumes(mesh) + elem_phases = element_phases(mesh, seeds) + assert np.all(vols[elem_phases != 0] <= max_volume + 1e-9) + assert np.max(vols[elem_phases == 0]) > max_volume + assert np.all(vols[elem_phases == 0] <= 1e-2 + 1e-9) + + +# --------------------------------------------------------------------------- # +# 2. Triangle maximum area # +# --------------------------------------------------------------------------- # +def test_triangle_per_phase_max_volume_exceeds_global(case_2d): + pmesh, phases, seeds = case_2d + phases = copy.deepcopy(phases) + phases[0]['max_volume'] = 1e-2 + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20, + max_volume=1e-3) + + areas = simplex_volumes(mesh) + elem_phases = element_phases(mesh, seeds) + assert np.max(areas[elem_phases == 0]) > 1e-3 + assert np.all(areas[elem_phases == 0] <= 1e-2 + 1e-9) + assert np.all(areas[elem_phases != 0] <= 1e-3 + 1e-9) + + +def test_triangle_infinite_max_volume_not_passed(case_2d, monkeypatch): + pmesh, phases, _ = case_2d + captured = {} + orig_build = meshpy.triangle.build + + def build(info, **kwargs): + captured['info'] = info + captured['kwargs'] = kwargs + return orig_build(info, **kwargs) + + monkeypatch.setattr(meshpy.triangle, 'build', build) + mesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + + # An infinite area would be formatted as the switch 'ainf' + assert captured['kwargs']['max_volume'] is None + assert captured['kwargs']['volume_constraints'] + + # Without an area constraint the mesh is the min_angle-only mesh + ref_mesh = orig_build(captured['info'], attributes=True, + volume_constraints=False, max_volume=None, + min_angle=20, generate_faces=True) + n_ref = len(ref_mesh.elements) + assert abs(len(mesh.elements) - n_ref) <= 0.05 * n_ref + + +# --------------------------------------------------------------------------- # +# 3. Raster meshes # +# --------------------------------------------------------------------------- # +def test_raster_2d_elements_counter_clockwise(raster_2d): + pts = np.array(raster_2d.points) + elems = np.array(raster_2d.elements) + assert elems.shape[1] == 4 + + x = pts[elems, 0] + y = pts[elems, 1] + x_next = np.roll(x, -1, axis=1) + y_next = np.roll(y, -1, axis=1) + signed_areas = 0.5 * np.sum(x * y_next - x_next * y, axis=1) + assert np.all(signed_areas > 0) + assert np.allclose(signed_areas, MESH_SIZE ** 2) + + +def test_raster_3d_elements_right_handed(raster_3d): + pts = np.array(raster_3d.points) + elems = np.array(raster_3d.elements) + assert elems.shape[1] == 8 + + # nodes 1-4 on the bottom face, 5-8 on the top face + z = pts[elems, 2] + assert np.allclose(z[:, :4], z[:, :1]) + assert np.allclose(z[:, 4:], z[:, :1] + MESH_SIZE) + + # scalar triple product of the edges at node 1 + p1 = pts[elems[:, 0]] + v12 = pts[elems[:, 1]] - p1 + v14 = pts[elems[:, 3]] - p1 + v15 = pts[elems[:, 4]] - p1 + triple = np.einsum('ij,ij->i', np.cross(v12, v14), v15) + assert np.all(triple > 0) + assert np.allclose(triple, MESH_SIZE ** 3) + + +def test_raster_2d_facets(raster_2d, case_2d): + check_raster_facets(raster_2d, case_2d[0]) + + +def test_raster_3d_facets(raster_3d, case_3d): + check_raster_facets(raster_3d, case_3d[0]) + + +def test_raster_default_phases(case_2d): + pmesh = case_2d[0] + mesh = RasterMesh.from_polymesh(pmesh, MESH_SIZE) + assert len(mesh.elements) == 400 + assert np.all(np.array(mesh.element_attributes) >= 0) + assert len(mesh.facets) > 0 + + +def test_raster_vtk_2d(raster_2d, case_2d, tmp_path): + filename = str(tmp_path / 'raster_2d.vtk') + raster_2d.write(filename, 'vtk', seeds=case_2d[2]) + + dims, n_cells, scalars = parse_vtk_rectilinear(filename) + assert dims == [21, 21, 1] + assert n_cells == 400 + assert len(scalars['element_attributes']) == n_cells + assert len(scalars['phase_numbers']) == n_cells + + # Void pixels are marked with -1, the others have their attributes + atts = np.array(scalars['element_attributes']) + assert np.sum(atts >= 0) == len(raster_2d.elements) + assert set(atts[atts >= 0]) == set(raster_2d.element_attributes) + + +def test_raster_vtk_3d_with_void(raster_3d, case_3d, tmp_path): + filename = str(tmp_path / 'raster_3d.vtk') + raster_3d.write(filename, 'vtk', seeds=case_3d[2]) + + dims, n_cells, scalars = parse_vtk_rectilinear(filename) + assert dims == [16, 16, 16] + assert n_cells == 15 ** 3 + assert len(scalars['element_attributes']) == n_cells + assert len(scalars['phase_numbers']) == n_cells + + atts = np.array(scalars['element_attributes']) + assert np.sum(atts < 0) > 0 # voids + assert np.sum(atts >= 0) == len(raster_3d.elements) + + +def test_raster_abaqus_2d(raster_2d, case_2d, tmp_path): + check_raster_abaqus(raster_2d, case_2d[0], str(tmp_path / 'r2d.inp')) + + +def test_raster_abaqus_3d(raster_3d, case_3d, tmp_path): + check_raster_abaqus(raster_3d, case_3d[0], str(tmp_path / 'r3d.inp')) + + +def test_raster_plot_3d_fresh_figure(raster_3d): + n_att = int(np.max(raster_3d.element_attributes)) + 1 + facecolors = np.array(['C' + str(i % 10) for i in range(n_att)]) + plt.close('all') + fig = plt.figure() + try: + raster_3d.plot(index_by='attribute', facecolors=facecolors) + assert len(fig.axes) == 1 + assert fig.axes[0].name == '3d' + finally: + plt.close('all') + + +# --------------------------------------------------------------------------- # +# 4. Triangle/TetGen file format # +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize('n_dim', [2, 3]) +def test_tet_tri_files(n_dim, tri_2d, tri_3d, tmp_path): + mesh = {2: tri_2d, 3: tri_3d}[n_dim] + basename = str(tmp_path / 'mesh') + mesh.write(basename, 'tet/tri') + + # edge/face file + ext = {2: '.edge', 3: '.face'}[n_dim] + with open(basename + ext, 'r') as file: + lines = file.read().strip().split('\n') + header = lines[0].split() + assert len(header) == 2 + n_facets, n_markers = [int(n) for n in header] + assert n_facets == len(mesh.facets) + assert n_markers == 1 + assert len(lines) == n_facets + 1 + for i, (line, facet) in enumerate(zip(lines[1:], mesh.facets)): + vals = [int(v) for v in line.split()] + assert len(vals) == n_dim + 2 + assert vals[0] == i + assert vals[1:-1] == [int(kp) for kp in facet] + assert vals[-1] in (0, 1) + + # element file + with open(basename + '.ele', 'r') as file: + lines = file.read().strip().split('\n') + n_elems, n_kp, n_atts = [int(n) for n in lines[0].split()] + assert (n_elems, n_kp, n_atts) == (len(mesh.elements), n_dim + 1, 1) + assert len(lines) == n_elems + 1 + for i, (line, elem) in enumerate(zip(lines[1:], mesh.elements)): + vals = line.split() + assert int(vals[0]) == i + assert [int(v) for v in vals[1:1 + n_kp]] == [int(k) for k in elem] + + # node file + with open(basename + '.node', 'r') as file: + lines = file.read().strip().split('\n') + assert [int(n) for n in lines[0].split()] == [len(mesh.points), n_dim, + 0, 1] + assert len(lines) == len(mesh.points) + 1 + + +# --------------------------------------------------------------------------- # +# 5. Abaqus exterior surfaces # +# --------------------------------------------------------------------------- # +def test_abaqus_exterior_unions_defined(tri_2d, case_2d, tmp_path): + pmesh, _, seeds = case_2d + filename = str(tmp_path / 'tri_2d.inp') + tri_2d.write(filename, 'abaqus', seeds=seeds, polymesh=pmesh) + + _, elems, surfaces, unions = parse_abaqus(filename) + assert len(elems) == len(tri_2d.elements) + assert len(unions) == 4 # one per side of the square + for members in unions.values(): + assert len(members) > 0 + for member in members: + assert member in surfaces + + # Facets of the polymesh on the boundary of voids have no surface + poly_neighbors = np.array(pmesh.facet_neighbors) + n_boundary = np.sum(np.any(poly_neighbors < 0, axis=1)) + n_union = sum([len(members) for members in unions.values()]) + assert n_union < n_boundary + + +# --------------------------------------------------------------------------- # +# 6. Text format precision # +# --------------------------------------------------------------------------- # +def test_txt_round_trip_exact(tri_2d, tmp_path): + filename = str(tmp_path / 'tri_2d.txt') + tri_2d.write(filename, 'txt') + mesh = TriMesh.from_file(filename) + + assert np.array_equal(np.array(mesh.points), np.array(tri_2d.points)) + assert np.array_equal(np.array(mesh.elements), + np.array(tri_2d.elements)) + assert np.array_equal(np.array(mesh.element_attributes), + np.array(tri_2d.element_attributes)) + assert np.array_equal(np.array(mesh.facets), np.array(tri_2d.facets)) + assert np.array_equal(np.array(mesh.facet_attributes), + np.array(tri_2d.facet_attributes)) + + +# --------------------------------------------------------------------------- # +# 7. Robustness # +# --------------------------------------------------------------------------- # +def test_str_without_optional_attributes(tri_2d, tmp_path): + mesh = TriMesh(tri_2d.points, tri_2d.elements) + mesh_str = str(mesh) + assert 'Element Attributes' not in mesh_str + assert 'Facet' not in mesh_str + assert mesh_str.endswith(', '.join([str(k) for k in mesh.elements[-1]])) + + filename = str(tmp_path / 'no_atts.txt') + mesh.write(filename) + read_mesh = TriMesh.from_file(filename) + assert np.array_equal(np.array(read_mesh.points), + np.array(mesh.points)) + assert np.array_equal(np.array(read_mesh.elements), + np.array(mesh.elements)) + + # Facets without facet attributes + mesh = TriMesh(tri_2d.points, tri_2d.elements, facets=tri_2d.facets) + mesh_str = str(mesh) + assert 'Facets: ' + str(len(tri_2d.facets)) in mesh_str + assert 'Facet Attributes' not in mesh_str + mesh.write(filename) + read_mesh = TriMesh.from_file(filename) + assert np.array_equal(np.array(read_mesh.facets), + np.array(mesh.facets)) + + +def test_abaqus_without_polymesh(tri_2d, tmp_path): + filename = str(tmp_path / 'no_polymesh.inp') + tri_2d.write(filename, 'abaqus') + + _, elems, surfaces, unions = parse_abaqus(filename) + assert len(elems) == len(tri_2d.elements) + assert len(surfaces) == len(np.unique(tri_2d.facet_attributes)) + assert len(unions) == 0 + + +def test_unknown_mesher_raises(case_2d): + pmesh, phases, _ = case_2d + with pytest.raises(ValueError) as excinfo: + TriMesh.from_polymesh(pmesh, phases, mesher='bogus') + assert 'Triangle/TetGen' in str(excinfo.value) + assert 'gmsh' in str(excinfo.value) + + # the comparison is case-insensitive + mesh = TriMesh.from_polymesh(pmesh, phases, mesher=' TRIANGLE ', + min_angle=20) + assert len(mesh.elements) > 0 + + +def test_gmsh_default_phases(case_2d): + pmesh = case_2d[0] + mesh = TriMesh.from_polymesh(pmesh, mesher='gmsh') + assert len(mesh.elements) > 0 + assert len(mesh.element_attributes) == len(mesh.elements) + assert set(mesh.element_attributes) <= set(pmesh.seed_numbers) + + +def test_gmsh_no_unused_points(case_2d): + pmesh, phases, _ = case_2d + mesh = TriMesh.from_polymesh(pmesh, phases, mesher='gmsh') + + n_pts = len(mesh.points) + used = np.unique(np.array(mesh.elements)) + assert np.array_equal(used, np.arange(n_pts)) + assert np.all(np.array(mesh.facets) < n_pts) + assert len(mesh.facets) > 0 + + +def test_sort_facets_disjoint_loops_raises(): + loop = [[0, 1], [2, 0], [1, 2]] + assert trimesh_module._sort_facets(loop) == [[0, 1], [1, 2], [2, 0]] + + two_loops = [[0, 1], [1, 2], [2, 0], [3, 4], [4, 5], [5, 3]] + with pytest.raises(ValueError) as excinfo: + trimesh_module._sort_facets(two_loops) + assert 'loop' in str(excinfo.value) + + +# --------------------------------------------------------------------------- # +# 8. Plot keyword arguments as numpy arrays # +# --------------------------------------------------------------------------- # +def test_plot_numpy_facecolors_per_attribute(tri_2d): + atts = np.array(tri_2d.element_attributes) + n_att = int(np.max(atts)) + 1 + facecolors = np.array(['C' + str(i % 10) for i in range(n_att)]) + + plt.close('all') + plt.figure() + try: + ax = plt.gca() + tri_2d.plot(index_by='attribute', facecolors=facecolors) + pc = ax.collections[-1] + fc = pc.get_facecolor() + assert fc.shape == (len(tri_2d.elements), 4) + expected = mcolors.to_rgba_array(facecolors[atts]) + assert np.allclose(fc, expected) + finally: + plt.close('all') + + +# --------------------------------------------------------------------------- # +# 10. Element type checks in the writers # +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize('fmt', ['abaqus', 'vtk', 'tet/tri']) +def test_write_rejects_non_simplex_elements(fmt, raster_2d, tmp_path): + mesh = TriMesh(raster_2d.points, raster_2d.elements, + raster_2d.element_attributes, raster_2d.facets, + raster_2d.facet_attributes) + with pytest.raises(ValueError) as excinfo: + mesh.write(str(tmp_path / 'quads'), fmt) + assert '4 nodes' in str(excinfo.value) + + # the text format does not depend on the element type + mesh.write(str(tmp_path / 'quads.txt'), 'txt') + read_mesh = TriMesh.from_file(str(tmp_path / 'quads.txt')) + assert len(read_mesh.elements) == len(mesh.elements) + + +# --------------------------------------------------------------------------- # +# Sorted facets # +# --------------------------------------------------------------------------- # +def test_sorted_facets_helper(): + facets = [[5, 2], [1, 9], [2, 5], [3, 1], [9, 1]] + atts = [10, 11, 12, 13, 14] + out_f, out_a = trimesh_module._sorted_facets(facets, atts) + assert out_f.tolist() == [[1, 3], [1, 9], [1, 9], [2, 5], [2, 5]] + # the attributes travel with their facets + assert out_a.tolist() == [13, 11, 14, 10, 12] + # triangles, and an empty list + out_f, out_a = trimesh_module._sorted_facets([[7, 3, 5], [2, 9, 1]], + [1, 2]) + assert out_f.tolist() == [[1, 2, 9], [3, 5, 7]] + assert out_a.tolist() == [2, 1] + out_f, out_a = trimesh_module._sorted_facets([], []) + assert len(out_f) == 0 and len(out_a) == 0 + + +def _assert_facets_sorted(mesh): + facets = np.array(mesh.facets) + assert np.all(np.diff(facets, axis=1) > 0) + keys = [tuple(f) for f in facets] + assert keys == sorted(keys) + + +def test_facets_sorted_2d_and_3d(): + # 2D, Triangle, and a periodic mesh (whose facets come from the + # polymesh geometry) + domain = msp.geometry.Square(side_length=2, corner=(0, 0)) + seeds = msp.seeding.SeedList.from_info( + [{'shape': 'circle', 'size': 0.4}], 0.9 * domain.area) + seeds.position(domain, rng_seed=0, rtol=0.0) + pmesh = msp.meshing.PolyMesh.from_seeds(seeds, domain) + mesh = TriMesh.from_polymesh(pmesh, min_angle=20, max_volume=0.05) + assert len(mesh.facets) > 0 + _assert_facets_sorted(mesh) + assert set(np.unique(mesh.facet_attributes)) <= set( + range(len(pmesh.facets))) + seeds.position(domain, rng_seed=0, rtol=0.0, periodic=True) + pmesh_per = msp.meshing.PolyMesh.from_seeds(seeds, domain, periodic=True) + mesh_per = TriMesh.from_polymesh(pmesh_per, min_angle=20) + _assert_facets_sorted(mesh_per) + + # 3D, TetGen + domain = msp.geometry.Cube(side_length=2, corner=(0, 0, 0)) + seeds = msp.seeding.SeedList.from_info( + [{'shape': 'sphere', 'size': 0.8}], 0.9 * domain.volume) + seeds.position(domain, rng_seed=0, rtol=0.0) + pmesh = msp.meshing.PolyMesh.from_seeds(seeds, domain) + mesh = TriMesh.from_polymesh(pmesh, min_angle=10) + assert len(mesh.facets) > 0 + _assert_facets_sorted(mesh) diff --git a/tests/seeding/test_periodic_seeding.py b/tests/seeding/test_periodic_seeding.py new file mode 100644 index 00000000..3b2c9907 --- /dev/null +++ b/tests/seeding/test_periodic_seeding.py @@ -0,0 +1,220 @@ +"""Tests for the periodic placement of seeds.""" +import itertools + +import numpy as np +import pytest +import scipy.stats +from scipy.spatial import distance + +import microstructpy as msp +from microstructpy import _misc +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList +from microstructpy.seeding.seedlist import _periodic_images + + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # +def _translations(lengths, per_axes): + """All translations by 0 or +/- the domain length along periodic axes.""" + options = [[0.0, length, -length] if flag else [0.0] + for length, flag in zip(lengths, per_axes)] + return [np.array(t) for t in itertools.product(*options)] + + +def _worst_overlap(seeds, lengths, per_axes, rtol=0.0): + """Largest violation of the overlap condition over all pairs of seeds, + including the periodic images of the second seed (Eq. 3 of the paper). + Positive values are overlaps beyond the tolerance.""" + bkdwns = [np.array(s.breakdown) for s in seeds] + n_dim = bkdwns[0].shape[1] - 1 + worst = -np.inf + for i, j in itertools.combinations(range(len(seeds)), 2): + c_i, r_i = bkdwns[i][:, :n_dim], bkdwns[i][:, n_dim].reshape(-1, 1) + c_j, r_j = bkdwns[j][:, :n_dim], bkdwns[j][:, n_dim].reshape(1, -1) + for t in _translations(lengths, per_axes): + dists = distance.cdist(c_i, c_j + t) + viol = r_i + r_j - rtol * np.minimum(r_i, r_j) - dists + worst = max(worst, viol.max()) + return worst + + +def _cross_face_overlap(seeds, lengths, per_axes): + """Largest overlap of a seed with a periodic image (non-zero + translation) of another seed.""" + bkdwns = [np.array(s.breakdown) for s in seeds] + n_dim = bkdwns[0].shape[1] - 1 + worst = -np.inf + for i, j in itertools.permutations(range(len(seeds)), 2): + c_i, r_i = bkdwns[i][:, :n_dim], bkdwns[i][:, n_dim].reshape(-1, 1) + c_j, r_j = bkdwns[j][:, :n_dim], bkdwns[j][:, n_dim].reshape(1, -1) + for t in _translations(lengths, per_axes): + if not np.any(t): + continue + dists = distance.cdist(c_i, c_j + t) + worst = max(worst, (r_i + r_j - dists).max()) + return worst + + +# --------------------------------------------------------------------------- # +# Periodicity specification # +# --------------------------------------------------------------------------- # +def test_periodic_axes_parsing(): + assert _misc.periodic_axes(True, 2) == [True, True] + assert _misc.periodic_axes(False, 3) == [False, False, False] + assert _misc.periodic_axes(None, 3) == [False, False, False] + assert _misc.periodic_axes('xy', 3) == [True, True, False] + assert _misc.periodic_axes('z', 3) == [False, False, True] + assert _misc.periodic_axes('x, z', 3) == [True, False, True] + assert _misc.periodic_axes('True', 2) == [True, True] + assert _misc.periodic_axes([True, False], 2) == [True, False] + assert _misc.periodic_axes(np.array([0, 1, 1]), 3) == [False, True, True] + with pytest.raises(ValueError): + _misc.periodic_axes('xw', 3) + with pytest.raises(ValueError): + _misc.periodic_axes('z', 2) + with pytest.raises(ValueError): + _misc.periodic_axes([True], 2) + + +def test_periodic_images_translations(): + dom_lims = [(0.0, 2.0), (0.0, 3.0)] + # crosses the -x face and the +y face: 3 images (edge, edge, corner) + limits = [(-0.1, 0.5), (2.5, 3.2)] + images = _periodic_images(limits, dom_lims, [True, True]) + assert sorted(images) == sorted([(2.0, 0.0), (0.0, -3.0), (2.0, -3.0)]) + # only x periodic: one image + assert _periodic_images(limits, dom_lims, [True, False]) == [(2.0, 0.0)] + # inside the domain: no images + assert _periodic_images([(0.5, 1.0), (0.5, 1.0)], dom_lims, + [True, True]) == [] + # non-periodic domain + assert _periodic_images(limits, None, [False, False], + include_zero=True) == [(0.0, 0.0)] + + +# --------------------------------------------------------------------------- # +# Positioning # +# --------------------------------------------------------------------------- # +def _phases_2d(): + return [{'shape': 'circle', 'size': scipy.stats.uniform(0.15, 0.15)}, + {'shape': 'ellipse', 'size': scipy.stats.uniform(0.2, 0.15), + 'aspect_ratio': scipy.stats.uniform(1.5, 1.5), + 'angle_deg': scipy.stats.uniform(0, 180)}] + + +def test_periodic_position_2d_no_overlaps(): + domain = msp.geometry.Square(side_length=2.5, corner=(1, -1)) + lengths = domain.side_lengths + + seeds = SeedList.from_info(_phases_2d(), 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=1, periodic=True) + # seed centers stay inside the domain + assert np.all(domain.within([s.position for s in seeds])) + # no overlaps, including through the periodic faces + assert _worst_overlap(seeds, lengths, [True, True]) <= 1e-9 + # some seeds do cross the faces (the test is not vacuous) + lims = np.array([s.geometry.limits for s in seeds]) + dom = np.array(domain.limits) + crossing = np.any((lims[:, :, 0] < dom[:, 0]) | + (lims[:, :, 1] > dom[:, 1]), axis=1) + assert crossing.sum() > 0 + + # without periodicity, the same packing overlaps through the faces + seeds_np = SeedList.from_info(_phases_2d(), 0.6 * domain.area) + seeds_np.position(domain, rtol=0.0, rng_seed=1) + assert _cross_face_overlap(seeds_np, lengths, [True, True]) > 1e-6 + + +def test_periodic_position_2d_single_axis(): + domain = msp.geometry.Rectangle(length=3, width=2) + lengths = domain.side_lengths + seeds = SeedList.from_info(_phases_2d(), 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=2, periodic='x') + # no overlaps through the x faces (translations along x only) + assert _worst_overlap(seeds, lengths, [True, False]) <= 1e-9 + + +def test_periodic_position_with_tolerance(): + domain = msp.geometry.Square(side_length=2) + seeds = SeedList.from_info(_phases_2d(), domain.area) + seeds.position(domain, rtol=0.3, rng_seed=3, periodic=[True, True]) + assert _worst_overlap(seeds, domain.side_lengths, [True, True], + rtol=0.3) <= 1e-9 + + +def test_periodic_position_held_seed_images(): + domain = msp.geometry.Square(side_length=2) + # a held seed in the corner, crossing the -x and -y faces + held = Seed.factory('circle', r=0.3, position=(0.05, 0.05)) + others = SeedList.from_info([{'shape': 'circle', 'size': 0.3}], + 0.5 * domain.area) + others.position(domain, rtol=0.0, rng_seed=0, periodic=True) + seeds = SeedList([held]) + others + hold = [True] + [False for _ in others] + seeds.position(domain, rtol=0.0, rng_seed=4, hold=hold, + periodic=True) + assert np.allclose(seeds[0].position, [0.05, 0.05]) + assert _worst_overlap(seeds, domain.side_lengths, [True, True]) <= 1e-9 + + +def test_periodic_position_3d(): + domain = msp.geometry.Cube(side_length=2) + phases = [{'shape': 'sphere', 'size': scipy.stats.uniform(0.4, 0.3)}] + seeds = SeedList.from_info(phases, 0.45 * domain.volume) + seeds.position(domain, rtol=0.0, rng_seed=5, periodic=True) + assert _worst_overlap(seeds, domain.side_lengths, + [True, True, True]) <= 1e-9 + seeds.position(domain, rtol=0.0, rng_seed=6, periodic='xz') + assert _worst_overlap(seeds, domain.side_lengths, + [True, False, True]) <= 1e-9 + + +def test_periodic_requires_rectangular_domain(): + seeds = SeedList.from_info([{'shape': 'circle', 'size': 0.3}], 2.0) + with pytest.raises(ValueError): + seeds.position(msp.geometry.Circle(r=1), periodic=True) + with pytest.raises(ValueError): + seeds.position(msp.geometry.Rectangle(length=2, width=2, angle=30), + periodic=True) + # a non-periodic call on a circular domain still works + seeds.position(msp.geometry.Circle(r=1), periodic=False) + + +def test_periodic_margin(): + domain = msp.geometry.Square(side_length=2, corner=(0, 0)) + phases = [{'shape': 'circle', 'size': scipy.stats.uniform(0.2, 0.2)}] + margin = 0.1 + seeds = SeedList.from_info(phases, 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=5, periodic=True, + periodic_margin=margin) + # every seed either stays a margin away from a periodic face, on the + # inside, or crosses it by at least the margin + for seed in seeds: + for axis, (lb, ub) in enumerate(domain.limits): + lo, hi = seed.geometry.limits[axis] + for gap in (lo - lb, ub - hi): + assert abs(gap) >= margin - 1e-12 + # without the margin, some seeds do not + seeds = SeedList.from_info(phases, 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=5, periodic=True) + gaps = [] + for seed in seeds: + for axis, (lb, ub) in enumerate(domain.limits): + lo, hi = seed.geometry.limits[axis] + gaps.extend([abs(lo - lb), abs(ub - hi)]) + assert min(gaps) < margin + # the margin only applies to the periodic axes + seeds = SeedList.from_info(phases, 0.6 * domain.area) + seeds.position(domain, rtol=0.0, rng_seed=5, periodic='x', + periodic_margin=margin) + gaps_y = [] + for seed in seeds: + lo, hi = seed.geometry.limits[1] + lb, ub = domain.limits[1] + gaps_y.extend([abs(lo - lb), abs(ub - hi)]) + lo, hi = seed.geometry.limits[0] + lb, ub = domain.limits[0] + assert min(abs(lo - lb), abs(ub - hi)) >= margin - 1e-12 + assert min(gaps_y) < margin diff --git a/tests/seeding/test_seeding_fixes.py b/tests/seeding/test_seeding_fixes.py new file mode 100644 index 00000000..f99d872b --- /dev/null +++ b/tests/seeding/test_seeding_fixes.py @@ -0,0 +1,243 @@ +"""Regression tests for the seeding module bug fixes.""" +import copy + +import matplotlib +import numpy as np +import pytest +import scipy.stats +from matplotlib import pyplot as plt + +import microstructpy as msp +from microstructpy.seeding import Seed +from microstructpy.seeding import SeedList +from microstructpy.seeding.seedlist import calc_rtol +from microstructpy.seeding.seedlist import sample_pos_within + +matplotlib.use('agg') + + +def _phases_2d(): + return [{'shape': 'ellipse', 'size': scipy.stats.lognorm(s=0.3, scale=0.5), + 'aspect_ratio': scipy.stats.uniform(1, 2), + 'angle_deg': scipy.stats.uniform(0, 180), 'fraction': 0.6}, + {'shape': 'circle', 'size': scipy.stats.uniform(0.2, 0.3), + 'fraction': 0.4}] + + +def _signature(seeds): + return [(s.phase, round(s.volume, 10), tuple(np.round(s.position, 8))) + for s in seeds] + + +# --------------------------------------------------------------------------- # +# Seed generation # +# --------------------------------------------------------------------------- # +def test_from_info_keyword_order_independent(): + """The RNG chain must not depend on the (hash-dependent) set order.""" + phases = _phases_2d() + seeds_1 = SeedList.from_info(copy.deepcopy(phases), 30.0) + # same phases with the dictionary keys inserted in another order + reordered = [dict(reversed(list(p.items()))) for p in phases] + seeds_2 = SeedList.from_info(reordered, 30.0) + assert _signature(seeds_1) == _signature(seeds_2) + + +def test_from_info_repeated_calls_identical(): + phases = _phases_2d() + seeds_1 = SeedList.from_info(copy.deepcopy(phases), 30.0) + seeds_2 = SeedList.from_info(copy.deepcopy(phases), 30.0) + assert _signature(seeds_1) == _signature(seeds_2) + + +def test_from_info_does_not_mutate_rng_seeds(): + rng_seeds = {'size': 3, 'fraction': 1} + SeedList.from_info(_phases_2d(), 20.0, rng_seeds) + assert rng_seeds == {'size': 3, 'fraction': 1} + assert SeedList.from_info.__func__.__defaults__[0] == {} + + +# --------------------------------------------------------------------------- # +# Overlap tolerance # +# --------------------------------------------------------------------------- # +def test_calc_rtol_matches_paper(): + # Eq. (14): sigma = 0.5 -> cv = 0.53 -> alpha = 0.70 (paper, Sec. 3.1) + cv = np.sqrt(np.exp(0.25) - 1) + numer = 0.182 * cv * cv - 0.0135 * cv + 0.198 + denom = cv * cv - 0.613 * cv + 0.390 + assert np.isclose(numer / denom, 0.70, atol=0.005) + + # build seeds with exactly that coefficient of variation + rng = np.random.RandomState(0) + areas = np.exp(-9 + 0.5 * rng.normal(size=4000)) + seeds = [Seed.factory('circle', area=a) for a in areas] + cv_s = scipy.stats.variation(areas) + expected = ((0.182 * cv_s ** 2 - 0.0135 * cv_s + 0.198) / + (cv_s ** 2 - 0.613 * cv_s + 0.390)) + assert np.isclose(calc_rtol(seeds), expected) + + seeds_3d = [Seed.factory('sphere', volume=a) for a in areas] + expected_3d = ((0.457 * cv_s ** 2 - 0.575 * cv_s + 0.253) / + (cv_s ** 2 - 1.07 * cv_s + 0.419)) + assert np.isclose(calc_rtol(seeds_3d), expected_3d) + + # constant sizes: cv = 0 (a single seed as well) + same = [Seed.factory('circle', r=1) for _ in range(3)] + assert np.isclose(calc_rtol(same), 0.198 / 0.390) + assert np.isclose(calc_rtol(same[:1]), 0.198 / 0.390) + + +def test_position_uses_calc_rtol(monkeypatch): + seen = {} + real = msp.seeding.seedlist.calc_rtol + + def spy(seeds): + seen['rtol'] = real(seeds) + return seen['rtol'] + + monkeypatch.setattr(msp.seeding.seedlist, 'calc_rtol', spy) + domain = msp.geometry.Square(side_length=4) + seeds = SeedList.from_info(_phases_2d(), domain.area) + seeds.position(domain) + assert np.isclose(seen['rtol'], real(seeds)) + + +# --------------------------------------------------------------------------- # +# Seeds and files # +# --------------------------------------------------------------------------- # +def test_seed_factory_position_and_center_consistent(): + s = Seed.factory('circle', r=0.5, position=(1, 2)) + assert np.allclose(s.breakdown, [[1, 2, 0.5]]) + assert np.allclose(s.geometry.center, [1, 2]) + + s = Seed.factory('circle', r=0.5, center=(3, 4)) + assert np.allclose(s.position, [3, 4]) + assert np.allclose(s.breakdown, [[3, 4, 0.5]]) + + s.position = (5, 5) + assert np.allclose(s.breakdown, [[5, 5, 0.5]]) + assert np.allclose(s.geometry.center, [5, 5]) + + e = Seed.factory('ellipse', a=2, b=1, angle_deg=30, position=(1, -1)) + assert np.allclose(np.mean(np.array(e.breakdown)[:, :2], axis=0), + [1, -1]) + + +def test_seed_update_breakdown_exists(): + s = Seed.factory('ellipse', a=2, b=1) + s.breakdown = [] + s.update_breakdown() + assert len(s.breakdown) > 1 + + +def test_seed_factory_unsupported_shape(): + with pytest.raises(ValueError): + Seed.factory('box', side_lengths=(1, 1, 1)) + + +def test_seed_equality_different_breakdowns(): + s1 = Seed.factory('ellipse', a=2, b=1) + s2 = Seed.factory('ellipse', a=3, b=1) + assert s1 != s2 + assert s1 == Seed.factory('ellipse', a=2, b=1) + + +def test_seedlist_mutable_default(): + x = SeedList() + x.append(Seed.factory('circle', r=1)) + assert len(SeedList()) == 0 + + +def test_seed_file_round_trip_and_reposition(tmp_path): + phases = [{'shape': 'circle', 'size': 0.3}, + {'shape': 'ellipse', 'size': 0.4, 'aspect_ratio': 2, + 'angle_deg': scipy.stats.uniform(0, 180)}, + {'shape': 'rectangle', 'length': 0.3, 'width': 0.2, + 'angle_deg': scipy.stats.uniform(0, 90)}, + {'shape': 'square', 'side_length': 0.25}] + domain = msp.geometry.Square(side_length=3) + seeds = SeedList.from_info(phases, 0.5 * domain.area) + seeds.position(domain) + fname = str(tmp_path / 'seeds.txt') + seeds.write(fname) + loaded = SeedList.from_file(fname) + assert loaded == seeds + for s1, s2 in zip(seeds, loaded): + assert np.allclose(s1.breakdown, s2.breakdown) + assert s1.geometry == s2.geometry + + # seeds loaded from a file can be repositioned + loaded[0].position = [1.0, 2.0] + assert np.allclose(np.array(loaded[0].breakdown)[0, :2], [1.0, 2.0]) + loaded.position(domain, rng_seed=1) + + +def test_ellipsoid_seed_file_round_trip(tmp_path): + phases = [{'shape': 'ellipsoid', 'size': 0.5, 'ratio_ab': 2, + 'ratio_ac': 1.5, 'orientation': 'random'}, + {'shape': 'sphere', 'size': 0.4}] + seeds = SeedList.from_info(phases, 1.0) + for i, s in enumerate(seeds): + s.position = [0.1 * i, -0.2 * i, 0.3 * i] + fname = str(tmp_path / 'seeds.txt') + seeds.write(fname) + loaded = SeedList.from_file(fname) + assert loaded == seeds + for s1, s2 in zip(seeds, loaded): + if isinstance(s1.geometry, msp.geometry.Ellipsoid): + assert np.allclose(s1.geometry.matrix, s2.geometry.matrix) + assert np.allclose(s1.geometry.limits, s2.geometry.limits) + + +# --------------------------------------------------------------------------- # +# Positioning # +# --------------------------------------------------------------------------- # +def test_position_random_axis_distribution(): + phases = [{'shape': 'circle', 'size': 0.3}, + {'shape': 'circle', 'size': 0.3}] + domain = msp.geometry.Square(side_length=5) + seeds = SeedList.from_info(phases, 0.3 * domain.area) + pos_dists = {1: ['random', scipy.stats.norm(0, 0.3)]} + seeds.position(domain, pos_dists=pos_dists, rng_seed=0) + pos = np.array([s.position for s in seeds if s.phase == 1]) + assert pos[:, 0].std() > 1.0 + assert pos[:, 1].std() < 0.8 + assert np.abs(pos[:, 1]).mean() < 0.7 + + +def test_sample_pos_within_raises_when_unreachable(): + domain = msp.geometry.Square(side_length=2) + with pytest.raises(ValueError): + sample_pos_within([5.0, 5.0], 3, domain, max_rounds=10) + mvn = scipy.stats.multivariate_normal([50, 50], np.eye(2)) + with pytest.raises(ValueError): + sample_pos_within(mvn, 3, domain, max_rounds=10) + pts = sample_pos_within([scipy.stats.uniform(-1, 2), + scipy.stats.uniform(-1, 2)], 5, domain) + assert pts.shape == (5, 2) + assert np.all(domain.within(pts)) + + +# --------------------------------------------------------------------------- # +# Plotting # +# --------------------------------------------------------------------------- # +def test_plot_breakdown_3d_fresh_figure(): + seeds = SeedList([Seed.factory('sphere', r=0.5, position=(0, 0, 0)), + Seed.factory('ellipsoid', a=1, b=0.5, c=0.5, + position=(2, 0, 0))]) + plt.figure() + seeds.plot_breakdown() + plt.close('all') + plt.figure() + seeds.plot() + plt.close('all') + + +def test_plot_accepts_numpy_array_colors(): + seeds = SeedList([Seed.factory('circle', r=0.5, position=(i, 0)) + for i in range(3)]) + plt.figure() + seeds.plot(facecolors=np.array(['r', 'g', 'b'])) + colls = [c for c in plt.gca().collections + if type(c).__name__ == 'EllipseCollection'] + assert len(colls[0].get_facecolors()) == 3 + plt.close('all') diff --git a/tests/test_periodic_verification.py b/tests/test_periodic_verification.py new file mode 100644 index 00000000..3c630118 --- /dev/null +++ b/tests/test_periodic_verification.py @@ -0,0 +1,49 @@ +"""Verification of periodic microstructures: split grains are unwrapped.""" +import numpy as np +import scipy.stats + +import microstructpy as msp +from microstructpy import _misc +from microstructpy import verification +from microstructpy.meshing import PolyMesh +from microstructpy.meshing import TriMesh +from microstructpy.seeding import SeedList + + +def test_unwrap_points(): + dom_lims = [(0.0, 2.0), (0.0, 3.0)] + pts = [[1.9, 0.1], [0.1, 2.9], [1.0, 1.0]] + out = _misc.unwrap_points(pts, [0.1, 0.2], [True, True], dom_lims) + assert np.allclose(out, [[-0.1, 0.1], [0.1, -0.1], [1.0, 1.0]]) + out = _misc.unwrap_points(pts, [0.1, 0.2], [True, False], dom_lims) + assert np.allclose(out, [[-0.1, 0.1], [0.1, 2.9], [1.0, 1.0]]) + + +def test_split_grains_fit_after_unwrapping(): + phases = [{'shape': 'circle', 'size': scipy.stats.uniform(0.2, 0.2), + 'material_type': 'crystalline'}] + domain = msp.geometry.Square(side_length=3, corner=(0, 0)) + seeds = SeedList.from_info(phases, domain.area) + seeds.position(domain, rng_seed=1, periodic=True) + pmesh = PolyMesh.from_seeds(seeds, domain, periodic=True) + tmesh = TriMesh.from_polymesh(pmesh, phases, min_angle=20) + + fit = verification.seeds_of_best_fit(seeds, phases, pmesh, tmesh) + n_pieces = np.bincount(pmesh.seed_numbers, minlength=len(seeds)) + split = n_pieces > 1 + assert split.sum() > 0 + assert all([s.geometry is not None for s in fit]) + + r_in = np.array([s.geometry.r for s in seeds]) + r_fit = np.array([s.geometry.r for s in fit]) + rel_err = np.abs(r_fit - r_in) / r_in + # the fits of the split grains are as good as the others, and their + # centers stay near the seeds (not at the average of two images) + assert rel_err[split].mean() < rel_err[~split].mean() + 0.05 + # without unwrapping, the center of a split grain would lie between + # its pieces, about half a domain length away from the seed + for seed, fit_seed, is_split in zip(seeds, fit, split): + if not is_split: + continue + d_cen = np.array(fit_seed.geometry.center) - np.array(seed.position) + assert np.linalg.norm(d_cen) < seed.geometry.r diff --git a/tests/test_verification_fixes.py b/tests/test_verification_fixes.py new file mode 100644 index 00000000..5fef93c4 --- /dev/null +++ b/tests/test_verification_fixes.py @@ -0,0 +1,243 @@ +"""Tests for the verification fixes (NOTES.md 5.4, table D, items D4-D7)""" + +import numpy as np +import scipy.stats + +from microstructpy import seeding +from microstructpy import verification +from microstructpy.seeding import Seed + + +def _ellipse_seeds(angles, rng=None, angle_kw='angle_rad'): + """Ellipse seeds with the given angles, optionally perturbed""" + seeds = [] + for angle in angles: + size = 1.0 + if rng is not None: + size += 0.02 * rng.randn() + angle += 0.02 * rng.randn() + kwargs = {'size': size, 'aspect_ratio': 2, angle_kw: angle} + seeds.append(Seed.factory('ellipse', phase=0, **kwargs)) + return seeding.SeedList(seeds) + + +def _rectangle_seeds(n, rng=None): + seeds = [] + for _ in range(n): + lengths = np.array([0.5, 0.25]) + if rng is not None: + lengths *= 1 + 0.05 * rng.randn(2) + seeds.append(Seed.factory('rectangle', phase=0, + side_lengths=tuple(lengths))) + return seeding.SeedList(seeds) + + +# --------------------------------------------------------------------------- # +# D4: angle_rad distributions are kept, phases are not modified # +# --------------------------------------------------------------------------- # +def test_error_stats_keeps_angle_rad_distribution(): + dist = scipy.stats.uniform(loc=-0.5, scale=1.0) + rng = np.random.RandomState(0) + angles = dist.rvs(size=60, random_state=rng) + seeds = _ellipse_seeds(angles) + fit_seeds = _ellipse_seeds(angles, rng) + phases = [{'shape': 'ellipse', 'size': 1, 'aspect_ratio': 2, + 'angle_rad': dist}] + + errs = verification.error_stats(fit_seeds, seeds, phases) + + assert phases[0]['angle_rad'] is dist + assert errs[0]['angle_rad']['ks_statistic'] < 0.5 + assert errs[0]['angle_rad']['mae'] < 0.1 + + +def test_error_stats_random_angle_rad(): + rng = np.random.RandomState(1) + angles = 2 * np.pi * rng.rand(60) + seeds = _ellipse_seeds(angles) + phases = [{'shape': 'ellipse', 'size': 1, 'aspect_ratio': 2, + 'angle_rad': 'random'}] + + errs = verification.error_stats(seeds, seeds, phases) + assert phases[0]['angle_rad'] == 'random' + assert errs[0]['angle_rad']['ks_statistic'] < 0.5 + + +# --------------------------------------------------------------------------- # +# D5: random # +# --------------------------------------------------------------------------- # +def test_error_stats_random_orientation(tmp_path): + rng = np.random.RandomState(2) + angles = 360 * rng.rand(60) + seeds = _ellipse_seeds(angles, angle_kw='angle_deg') + fit_seeds = _ellipse_seeds(angles, rng, angle_kw='angle_deg') + phases = [{'shape': 'ellipse', 'size': 1, 'aspect_ratio': 2, + 'orientation': 'random'}] + + errs = verification.error_stats(fit_seeds, seeds, phases) + assert phases[0]['orientation'] == 'random' + stats = errs[0]['orientation'] + assert stats['ks_statistic'] < 0.5 + assert stats['mae'] < 5 + + fname = tmp_path / 'err_stats.txt' + verification.write_error_stats(errs, phases, str(fname)) + assert 'orientation' in fname.read_text() + + +def test_error_stats_matrix_orientation(): + angles = np.full(20, 30.0) + seeds = _ellipse_seeds(angles, angle_kw='angle_deg') + fit_seeds = _ellipse_seeds(angles, np.random.RandomState(3), + angle_kw='angle_deg') + ct, st = np.cos(np.radians(30)), np.sin(np.radians(30)) + phases = [{'shape': 'ellipse', 'size': 1, 'aspect_ratio': 2, + 'orientation': np.array([[ct, -st], [st, ct]])}] + + errs = verification.error_stats(fit_seeds, seeds, phases) + assert errs[0]['orientation']['mae'] < 5 + + +def test_error_stats_orientation_skipped_in_3d(): + seeds = seeding.SeedList([Seed.factory('ellipsoid', phase=0, a=1, + b=0.5, c=0.5) for _ in range(5)]) + phases = [{'shape': 'ellipsoid', 'a': 1, 'b': 0.5, 'c': 0.5, + 'orientation': 'random'}] + errs = verification.error_stats(seeds, seeds, phases) + assert errs[0]['orientation'] == {} + + +# --------------------------------------------------------------------------- # +# D6: vector-valued parameters are handled per component # +# --------------------------------------------------------------------------- # +def test_mle_dist_per_component(): + values = np.array([[0.5, 0.25], [0.52, 0.24], [0.48, 0.26], [0.5, 0.25]]) + dists = [scipy.stats.norm(0.5, 0.1), scipy.stats.norm(0.25, 0.1)] + + mles = verification._mle_dist(values, dists) + assert len(mles) == 2 + assert abs(mles[0].mean() - 0.5) < 0.03 + assert abs(mles[1].mean() - 0.25) < 0.03 + + # tuples of constants give the component means + means = verification._mle_dist(values, (0.5, 0.25)) + assert np.allclose(means, [0.5, 0.25]) + + +def test_safe_rvs_tuple(): + samples = verification._safe_rvs((0.4, 0.2), 5) + assert samples.shape == (5, 2) + assert np.allclose(samples[:, 0], 0.4) + assert np.allclose(samples[:, 1], 0.2) + + dists = [scipy.stats.uniform(0, 1), 3] + samples = verification._safe_rvs(dists, 7) + assert samples.shape == (7, 2) + assert np.allclose(samples[:, 1], 3) + + +def test_write_mle_phases_vector(tmp_path): + inp_phases = [{'name': 'Bricks', 'side_lengths': (0.5, 0.25)}] + out_phases = [{'name': 'Bricks', 'side_lengths': [0.51, 0.24]}] + fname = tmp_path / 'mles.txt' + verification.write_mle_phases(inp_phases, out_phases, str(fname)) + + text = fname.read_text() + assert 'side_lengths[0]' in text + assert 'side_lengths[1]' in text + assert '0.51' in text + + +def test_verification_rectangle_side_lengths(tmp_path): + rng = np.random.RandomState(4) + seeds = _rectangle_seeds(30) + fit_seeds = _rectangle_seeds(30, rng) + phases = [{'shape': 'rectangle', 'side_lengths': (0.5, 0.25)}] + + errs = verification.error_stats(fit_seeds, seeds, phases) + assert len(errs[0]['side_lengths']) == 2 + for j, length in enumerate((0.5, 0.25)): + assert errs[0]['side_lengths'][j]['mae'] < 0.1 * length + assert 'ks_statistic' in errs[0]['side_lengths'][j] + + err_file = tmp_path / 'err_stats.txt' + verification.write_error_stats(errs, phases, str(err_file)) + text = err_file.read_text() + assert 'side_lengths[0]' in text + assert 'side_lengths[1]' in text + + mles = verification.mle_phases(fit_seeds, phases) + lengths = np.array([s.geometry.side_lengths for s in fit_seeds]) + assert np.allclose(mles[0]['side_lengths'], lengths.mean(axis=0)) + verification.write_mle_phases(phases, mles, str(tmp_path / 'mles.txt')) + + verification.plot_distributions(fit_seeds, phases, str(tmp_path), 'png') + assert (tmp_path / 'side_lengths_pdf.png').exists() + assert (tmp_path / 'side_lengths_cdf.png').exists() + + +def test_verification_distributed_axes(tmp_path): + dists = [scipy.stats.uniform(0.8, 0.4), scipy.stats.uniform(0.4, 0.2)] + rng = np.random.RandomState(5) + seeds = seeding.SeedList([ + Seed.factory('ellipse', phase=0, axes=[d.rvs(random_state=rng) + for d in dists]) + for _ in range(40)]) + phases = [{'shape': 'ellipse', 'axes': dists}] + + errs = verification.error_stats(seeds, seeds, phases) + assert len(errs[0]['axes']) == 2 + assert errs[0]['axes'][1]['ks_statistic'] < 0.5 + + mles = verification.mle_phases(seeds, phases) + assert abs(mles[0]['axes'][0].mean() - 1.0) < 0.1 + assert abs(mles[0]['axes'][1].mean() - 0.5) < 0.1 + + verification.plot_distributions(seeds, phases, str(tmp_path), 'png') + assert (tmp_path / 'axes_pdf.png').exists() + + +# --------------------------------------------------------------------------- # +# D7: extra phase fields are ignored # +# --------------------------------------------------------------------------- # +def test_extra_phase_fields_ignored(tmp_path): + dist = scipy.stats.uniform(loc=0.5, scale=0.5) + rng = np.random.RandomState(6) + sizes = dist.rvs(size=30, random_state=rng) + seeds = seeding.SeedList([Seed.factory('circle', phase=0, size=s) + for s in sizes]) + phases = [{'shape': 'circle', 'size': dist, 'notes': 'x', + 'max_volume': 0.1, 'color': 'C2'}] + + verification.plot_distributions(seeds, phases, str(tmp_path), 'png') + names = [p.name for p in tmp_path.iterdir()] + assert 'size_pdf.png' in names + assert 'size_cdf.png' in names + assert not any(n.startswith(('notes', 'max_volume')) for n in names) + + mles = verification.mle_phases(seeds, phases) + assert set(mles[0].keys()) == {'size'} + verification.write_mle_phases(phases, mles, str(tmp_path / 'mles.txt')) + + errs = verification.error_stats(seeds, seeds, phases) + assert set(errs[0].keys()) == {'size'} + assert errs[0]['size']['ks_statistic'] < 0.5 + + +# --------------------------------------------------------------------------- # +# Minor: axis labels and table headings # +# --------------------------------------------------------------------------- # +def test_axis_labels(): + assert verification._axis_label('radius') == 'Radius' + assert verification._axis_label('angle_rad') == 'Angle (radians)' + assert verification._axis_label('angle_deg') == 'Angle (degrees)' + assert verification._axis_label('orientation') == \ + 'Orientation (degrees)' + assert verification._axis_label('side_lengths') == 'Side Lengths' + + +def test_mle_headings_strip_suffix(): + hdr1, hdr2 = verification._mle_hdr(['i', 'name', 'kw', 'nu_inp', + 'nu_out', 's_inp', 's_out']) + assert hdr1 == ['', '', '', 'Input', 'Output', 'Input', 'Output'] + assert hdr2 == ['#', 'Name', 'Parameter', 'nu', 'nu', 's', 's']