Skip to content

Anisotropic diffusion coefficient - #1260

Draft
RemDelaporteMathurin wants to merge 2 commits into
mainfrom
rem/anisotropic-diffusion
Draft

Anisotropic diffusion coefficient#1260
RemDelaporteMathurin wants to merge 2 commits into
mainfrom
rem/anisotropic-diffusion

Conversation

@RemDelaporteMathurin

Copy link
Copy Markdown
Collaborator

A homogenised polycrystal, a rolled or columnar microstructure, and any non-cubic lattice conduct differently along different directions, and the property that describes that is a second-rank tensor. D_0 may now be given as a square matrix:

F.Material(D_0=[[9e-11, 2e-12], [2e-12, 2.8e-11]], E_D=0.1)

E_D stays a scalar -- one activation energy shared by every direction, the prefactor carrying the anisotropy. For per-direction activation energies, which a matrix D_0 deliberately cannot express, Material.D now also accepts a fem.Constant, a ufl expression or a plain matrix as well as a fem.Function, so a tensor built from the problem's temperature stays temperature dependent.

Four places read D as a scalar and had to be written with the matrix product. Each reduces to the previous expression when D is a scalar:

  • SurfaceFlux: -dot(D grad(u), n) rather than -D dot(grad(u), n), which for a tensor cannot even be assembled.
  • Nitsche: the consistency and symmetry terms take the matrix product, and the penalty scales on the normal conductance n.D.n, which is the conductance the boundary actually sees.
  • define_D_global builds tensor-valued DG spaces when the material is anisotropic.
  • as_fenics_constant builds tensor constants.

ChangeVar and the drift terms already used D * grad(...) and are unchanged.

Also fixes a latent bug this feature would otherwise trip over: if self.D_0 and self.D in Material.__init__ raises "truth value of an array is ambiguous" for any array input, as does if material.D in define_D_global. Both now test against None.

Tests: a manufactured solution sin(pi x) sin(pi y) on the unit square, run with a rotated tensor as well as a diagonal one -- with D_xy == 0 the off-diagonal term of the source vanishes and a formulation that mishandled it would still pass. Second-order convergence for identity, diagonal, rotated and strongly rotated tensors, the Arrhenius factor applied to the tensor, the surface flux of a slab against its analytical value and against the same integral assembled by hand, and weak Dirichlet enforcement against strong.

Description

Summary

Related Issues

Motivation and Context

Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 🔨 Code refactoring (no functional changes, no API changes)
  • 📝 Documentation update
  • ✅ Test update (adding missing tests or correcting existing tests)
  • 🔧 Build/CI configuration change

Testing

  • All existing tests pass locally (pytest)
  • I have added new tests that prove my fix is effective or that my feature works

Code Quality Checklist

  • My code follows the code style of this project (Ruff formatted: ruff format .)
  • My code passes linting checks (ruff check .)
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas

Documentation

  • I have updated the documentation accordingly (if applicable)
  • I have added docstrings to new functions/classes following the project conventions

Breaking Changes

Screenshots/Examples

Additional Notes

A homogenised polycrystal, a rolled or columnar microstructure, and any
non-cubic lattice conduct differently along different directions, and the
property that describes that is a second-rank tensor. ``D_0`` may now be given
as a square matrix:

    F.Material(D_0=[[9e-11, 2e-12], [2e-12, 2.8e-11]], E_D=0.1)

``E_D`` stays a scalar -- one activation energy shared by every direction, the
prefactor carrying the anisotropy. For per-direction activation energies, which a
matrix ``D_0`` deliberately cannot express, ``Material.D`` now also accepts a
``fem.Constant``, a ufl expression or a plain matrix as well as a ``fem.Function``,
so a tensor built from the problem's temperature stays temperature dependent.

Four places read D as a scalar and had to be written with the matrix product.
Each reduces to the previous expression when D is a scalar:

* ``SurfaceFlux``: ``-dot(D grad(u), n)`` rather than ``-D dot(grad(u), n)``,
  which for a tensor cannot even be assembled.
* Nitsche: the consistency and symmetry terms take the matrix product, and the
  penalty scales on the normal conductance ``n.D.n``, which is the conductance the
  boundary actually sees.
* ``define_D_global`` builds tensor-valued DG spaces when the material is
  anisotropic.
* ``as_fenics_constant`` builds tensor constants.

``ChangeVar`` and the drift terms already used ``D * grad(...)`` and are unchanged.

Also fixes a latent bug this feature would otherwise trip over: ``if self.D_0 and
self.D`` in ``Material.__init__`` raises "truth value of an array is ambiguous"
for any array input, as does ``if material.D`` in ``define_D_global``. Both now
test against None.

Tests: a manufactured solution ``sin(pi x) sin(pi y)`` on the unit square, run
with a *rotated* tensor as well as a diagonal one -- with ``D_xy == 0`` the
off-diagonal term of the source vanishes and a formulation that mishandled it
would still pass. Second-order convergence for identity, diagonal, rotated and
strongly rotated tensors, the Arrhenius factor applied to the tensor, the surface
flux of a slab against its analytical value and against the same integral
assembled by hand, and weak Dirichlet enforcement against strong.

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

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.73684% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.86%. Comparing base (fdfffe8) to head (20dcbc1).

Files with missing lines Patch % Lines
src/festim/material.py 92.85% 2 Missing ⚠️
src/festim/helpers.py 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1260      +/-   ##
==========================================
- Coverage   95.89%   95.86%   -0.03%     
==========================================
  Files          57       57              
  Lines        5017     5058      +41     
==========================================
+ Hits         4811     4849      +38     
- Misses        206      209       +3     
Flag Coverage Δ
dolfinx-nightly 95.61% <94.73%> (-0.03%) ⬇️
dolfinx-v0.10.0 92.92% <94.73%> (-0.01%) ⬇️
dolfinx-v0.11.0 95.74% <94.73%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/festim/helpers.py
) -> fem.Constant:
"""Converts a value to a dolfinx.Constant.

Array-like values become tensor-valued constants, which is how an anisotropic

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
Array-like values become tensor-valued constants, which is how an anisotropic
Array-like values become tensor-valued `fem.Constant` objects, which is how an anisotropic

Comment thread src/festim/helpers.py
Comment on lines +37 to 50
if isinstance(value, bool):
raise TypeError(f"Value must not be a bool, not {type(value)}")
if isinstance(value, float | int):
return fem.Constant(mesh, dolfinx.default_scalar_type(float(value)))
elif isinstance(value, fem.Constant):
return value
elif isinstance(value, np.ndarray | list | tuple):
array = np.asarray(value, dtype=dolfinx.default_scalar_type)
return fem.Constant(mesh, array)
else:
raise TypeError(
f"Value must be a float, an int or a dolfinx.Constant, not {type(value)}"
"Value must be a float, an int, an array-like or a dolfinx.Constant, "
f"not {type(value)}"
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The first isinstance check appears redundant with the else statement at the end of the conditional block

The TypeError statement itself is also confusing (...not be a bool, not type()...). If kept, it should be changed

Comment thread src/festim/material.py
Comment on lines +152 to +153
"D must be a fem.Function, a fem.Constant, a ufl expression or an "
f"array-like (for an anisotropic tensor), not {type(value)}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
"D must be a fem.Function, a fem.Constant, a ufl expression or an "
f"array-like (for an anisotropic tensor), not {type(value)}"
"D must be a fem.Function, fem.Constant, ufl expression or a "
f"square array-like (for an anisotropic tensor), not {type(value)}"

Comment thread src/festim/material.py
Comment on lines +175 to +181
def is_anisotropic(self, species=None) -> bool:
"""Whether this material's diffusivity is a tensor rather than a scalar.

Anything reading ``D`` as a scalar -- a surface flux, a Nitsche penalty --
has to ask, because for a tensor the same expression has to be written
with the matrix product instead.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just because a diffusion coefficient is passed as a tensor doesn't necessarily mean that it's anisotropic, e.g. $D = \left[\matrix{D_0 &amp; 0 \\ 0 &amp; D_0}\right]$. Perhaps update the method name?


A homogenised polycrystal, a rolled or columnar microstructure, or any hcp
lattice conducts differently along different directions, and the material
property that describes that is a second-rank tensor. ``D_0`` may therefore be

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In practice allowing the energy to vary with direction could be helpful, for example the energy to migrate across a defect could be different from the ideal lattice.

One could get away with factoring this into D_0 but only at constant temperature, I believe the workaround to this is to allow the user to pass an array of ufl expressions

Comment thread test/test_material.py
F.Material()


class TestAnisotropicDiffusion:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is there a reason these are in their own class?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants