Repository: Quantum-Accelerators/quacc
Pull request #2693 (2025-03-07 01:50:20)

Extend elastic properties to MLPs, and include elastic property analysis
## Summary of Changes

This PR extends elasticity support and addresses a few quality-of-life issues for running elastic workflows:
* Add elastic property support to the MLP recipes
* Add an ElasticSchema to include context on the deformations (needed for final elastic property fitting)
* Add a pre-relax option to the elastic property workflow
* Calculate the ElasticityDoc alongside the others results

### Requirements

- [x] My PR is focused on a [single feature addition or bugfix](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/best-practices-for-pull-requests#write-small-prs).
- [x] My PR has relevant, comprehensive [unit tests](https://quantum-accelerators.github.io/quacc/dev/contributing.html#unit-tests).
- [x] My PR is on a [custom branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-and-deleting-branches-within-your-repository) (i.e. is _not_ named `main`).

Note: If you are an external contributor, you will see a comment from [@buildbot-princeton](https://github.com/buildbot-princeton). This is solely for the maintainers.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/Quantum-Accelerators/quacc/pull/2693?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators) Report
Attention: Patch coverage is `95.12195%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 97.40%. Comparing base [(`01c7d83`)](https://app.codecov.io/gh/Quantum-Accelerators/quacc/commit/01c7d8334419885c1358debc2e5771e5ea3e50ca?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators) to head [(`6e47e76`)](https://app.codecov.io/gh/Quantum-Accelerators/quacc/commit/6e47e762412fd0be6e180c52f77ba75480be363f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators).

| [Files with missing lines](https://app.codecov.io/gh/Quantum-Accelerators/quacc/pull/2693?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators) | Patch % | Lines |
|---|---|---|
| [src/quacc/recipes/emt/elastic.py](https://app.codecov.io/gh/Quantum-Accelerators/quacc/pull/2693?src=pr&el=tree&filepath=src%2Fquacc%2Frecipes%2Femt%2Felastic.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators#diff-c3JjL3F1YWNjL3JlY2lwZXMvZW10L2VsYXN0aWMucHk=) | 83.33% | [1 Missing :warning: ](https://app.codecov.io/gh/Quantum-Accelerators/quacc/pull/2693?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators) |
| [src/quacc/recipes/mlp/elastic.py](https://app.codecov.io/gh/Quantum-Accelerators/quacc/pull/2693?src=pr&el=tree&filepath=src%2Fquacc%2Frecipes%2Fmlp%2Felastic.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators#diff-c3JjL3F1YWNjL3JlY2lwZXMvbWxwL2VsYXN0aWMucHk=) | 93.75% | [1 Missing :warning: ](https://app.codecov.io/gh/Quantum-Accelerators/quacc/pull/2693?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2693      +/-   ##
==========================================
- Coverage   97.48%   97.40%   -0.09%     
==========================================
  Files          90       91       +1     
  Lines        3860     3892      +32     
==========================================
+ Hits         3763     3791      +28     
- Misses         97      101       +4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Quantum-Accelerators/quacc/pull/2693?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Quantum-Accelerators).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
zulissimeta: There are two stages here:

To calculate an elastic tensor, you should provide the stress of the relaxed configuration. This is especially important if users do not set the convergence settings tight enough (eg fmax=0.01 or less). So, for the undeformed structure, you should either
1. Relax -> static
2. Relax
3. static (if you want to calculate elastic properties of a non-relaxed structure?)

For the deformed structures, you should either
1. Relax -> static
2. Relax

I think this is mostly a documentation problem for what "run_static" should mean - I'm interpreting it here as "run static after relaxation for deformed structures".


> @zulissimeta: Thanks! I had a few minutes to give this another look over. We're nearly there.
> 
> First, could you see my two unresolved comments below:
> 
> * https://github.com/Quantum-Accelerators/quacc/pull/2693/files#r1987581514
> * https://github.com/Quantum-Accelerators/quacc/pull/2693/files#r1987582170
> 
> For the first one, we are having `bulk_to_deformations_subflow` always take a `static_job` even though running a static calculation is purely optional. This is not ideal. For instance, let's say a set of recipes does not even have a static job made --- now they will have to make one just to have it be ignored by this subflow. We want to avoid such a scenario, which is why it was originally set to `Job | None = None`. The original method seems more suitable in my opinion since one cannot run a static calculation if no static job is provided. But I am open to suggestions.
> 
> The other review issue to resolve is with regards to some `if/else` code blocks that can be cleaned up (see review comments below).


zulissimeta: > For the first one, we are having `bulk_to_deformations_subflow` always take a `static_job` even though running a static calculation is purely optional. This is not ideal. For instance, let's say a set of recipes does not even have a static job made --- now they will have to make one just to have it be ignored by this subflow. We want to avoid such a scenario, which is why it was originally set to `Job | None = None`. The original method seems more suitable in my opinion since one cannot run a static calculation if no static job is provided. But I am open to suggestions.

I think I got both of these too!
Andrew-S-Rosen: @zulissimeta: Also, it is worth noting that you have modified the various flows/subflows such that their names are no longer logical. For instance, they were originally `bulk_to_deformations_flow` and `bulk_to_deformations_subflow`. They should really be renamed something like `elastic_flow` and `elastic_subflow` if their purpose is not to generate deformed structures (as was originally the case) but rather to generate elastic data.
zulissimeta: > @zulissimeta: Also, it is worth noting that you have modified the various flows/subflows such that their names are no longer representative. For instance, they were originally `bulk_to_deformations_flow` and `bulk_to_deformations_subflow`. They should really be renamed something like `elastic_flow` and `elastic_subflow` if their purpose is not to generate deformed structures (as was originally the case) but rather to generate elastic data.

Done!
Andrew-S-Rosen: @zulissimeta: Thanks! Looks good. I will go ahead and merge.

A minor point:

```
    Workflow consisting of:
    1. Deformed structures generation
    2. Deformed structures relaxations
        - name: "relax_job"
        - job: [quacc.recipes.emt.core.relax_job][]
    3. Deformed structures statics (optional)
        - name: "static_job"
        - job: [quacc.recipes.emt.core.static_job][]
    4. Elastic tensor calculation
```

The above docstring is missing the (optional) relaxation of the undeformed structure and (required) static calculation of the undeformed structure, coming before Step 1 in the list above. Part of the reason to enumerate these is that it tells the user what the underlying function is (e.g. `quacc.recipes.emt.core.relax_job`) and the name (e.g. `"relax_job`") needed when one wants to update the defaults via `job_params`. We can take care of that in a future PR. 

Hopefully this will all be a bit easier, with less code duplication, once I can take care of #2701. I will start chipping away at that in earnest next week.

Patch:
diff --git a/docs/user/recipes/recipes_list.md b/docs/user/recipes/recipes_list.md
index bdef248d..f70044a9 100644
--- a/docs/user/recipes/recipes_list.md
+++ b/docs/user/recipes/recipes_list.md
@@ -25,15 +25,15 @@ The list of available quacc recipes is shown below. The "Req'd Extras" column sp
 
 <center>
 
-| Name                     | Decorator        | Documentation                                           | Req'd Extras     |
-| ------------------------ | ---------------- | ------------------------------------------------------- | ---------------- |
-| EMT Static               | `#!Python @job`  | [quacc.recipes.emt.core.static_job][]                   |                  |
-| EMT Relax                | `#!Python @job`  | [quacc.recipes.emt.core.relax_job][]                    |                  |
-| EMT MD                   | `#!Python @job`  | [quacc.recipes.emt.md.md_job][]                         |                  |
-| EMT Bulk to Defects      | `#!Python @flow` | [quacc.recipes.emt.defects.bulk_to_defects_flow][]      | `quacc[defects]` |
-| EMT Bulk to Slabs        | `#!Python @flow` | [quacc.recipes.emt.slabs.bulk_to_slabs_flow][]          |                  |
-| EMT Phonons              | `#!Python @flow` | [quacc.recipes.emt.phonons.phonon_flow][]               | `quacc[phonons]` |
-| EMT Bulk to Deformations | `#!Python @flow` | [quacc.recipes.emt.elastic.bulk_to_deformations_flow][] |                  |
+| Name                     | Decorator        | Documentation                                             | Req'd Extras     |
+| ------------------------ | ---------------- | --------------------------------------------------------- | ---------------- |
+| EMT Static               | `#!Python @job`  | [quacc.recipes.emt.core.static_job][]                     |                  |
+| EMT Relax                | `#!Python @job`  | [quacc.recipes.emt.core.relax_job][]                      |                  |
+| EMT MD                   | `#!Python @job`  | [quacc.recipes.emt.md.md_job][]                           |                  |
+| EMT Bulk to Defects      | `#!Python @flow` | [quacc.recipes.emt.defects.bulk_to_defects_flow][]        | `quacc[defects]` |
+| EMT Bulk to Slabs        | `#!Python @flow` | [quacc.recipes.emt.slabs.bulk_to_slabs_flow][]            |                  |
+| EMT Phonons              | `#!Python @flow` | [quacc.recipes.emt.phonons.phonon_flow][]                 | `quacc[phonons]` |
+| EMT Bulk to Deformations | `#!Python @flow` | [quacc.recipes.emt.elastic.bulk_to_elastic_tensor_flow][] |                  |
 
 </center>
 
@@ -91,11 +91,12 @@ The list of available quacc recipes is shown below. The "Req'd Extras" column sp
 
 <center>
 
-| Name        | Decorator        | Documentation                             | Req'd Extras         |
-| ----------- | ---------------- | ----------------------------------------- | -------------------- |
-| MLP Static  | `#!Python @job`  | [quacc.recipes.mlp.core.static_job][]     | `quacc[mlp]`         |
-| MLP Relax   | `#!Python @job`  | [quacc.recipes.mlp.core.relax_job][]      | `quacc[mlp]`         |
-| MLP Phonons | `#!Python @flow` | [quacc.recipes.mlp.phonons.phonon_flow][] | `quacc[mlp,phonons]` |
+| Name           | Decorator        | Documentation                                                | Req'd Extras         |
+| -------------- | ---------------- | ------------------------------------------------------------ | -------------------- |
+| MLP Static     | `#!Python @job`  | [quacc.recipes.mlp.core.static_job][]                        | `quacc[mlp]`         |
+| MLP Relax      | `#!Python @job`  | [quacc.recipes.mlp.core.relax_job][]                         | `quacc[mlp]`         |
+| MLP Phonons    | `#!Python @flow` | [quacc.recipes.mlp.phonons.phonon_flow][]                    | `quacc[mlp,phonons]` |
+| MLP Elasticity | `#!Python @flow` | [quacc.recipes.mlp.elastic.bulk_to_elastic_tensor_flow][] | `quacc[mlp]` |
 
 </center>
 
diff --git a/src/quacc/atoms/deformation.py b/src/quacc/atoms/deformation.py
index 1bb53feb..985bb36e 100644
--- a/src/quacc/atoms/deformation.py
+++ b/src/quacc/atoms/deformation.py
@@ -18,7 +18,7 @@ def make_deformations_from_bulk(
     norm_strains: Sequence[float] = (-0.01, -0.005, 0.005, 0.01),
     shear_strains: Sequence[float] = (-0.06, -0.03, 0.03, 0.06),
     symmetry: bool = False,
-) -> list[Atoms]:
+) -> DeformedStructureSet:
     """
     Function to generate deformed structures from a bulk atoms object.
 
@@ -35,16 +35,15 @@ def make_deformations_from_bulk(
 
     Returns
     -------
-    list[Atoms]
-        All generated deformed structures
+    DeformedStructureSet
+        A pymatgen DeformedStructureSet with information on the deformed
+        structures and their strains, useful for fitting later.
     """
-    struct = AseAtomsAdaptor.get_structure(atoms)
+    struct = AseAtomsAdaptor.get_structure(atoms)  # type: ignore
 
-    deformed_set = DeformedStructureSet(
+    return DeformedStructureSet(
         struct,
         norm_strains=norm_strains,
         shear_strains=shear_strains,
         symmetry=symmetry,
     )
-
-    return [structure.to_ase_atoms() for structure in deformed_set]
diff --git a/src/quacc/recipes/common/elastic.py b/src/quacc/recipes/common/elastic.py
index 33d5535c..388b4a6a 100644
--- a/src/quacc/recipes/common/elastic.py
+++ b/src/quacc/recipes/common/elastic.py
@@ -4,24 +4,83 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
-from quacc import subflow
+from ase import units
+from ase.stress import voigt_6_to_full_3x3_stress
+from emmet.core.elasticity import ElasticityDoc
+from emmet.core.mpid import MPID
+from pymatgen.analysis.elasticity.stress import Stress
+from pymatgen.io.ase import AseAtomsAdaptor
+
+from quacc import job, subflow
 from quacc.atoms.deformation import make_deformations_from_bulk
 
 if TYPE_CHECKING:
     from typing import Any
 
-    from ase.atoms import Atoms
+    from pymatgen.analysis.elasticity.strain import DeformedStructureSet
 
     from quacc import Job
+    from quacc.types import ElasticSchema, RunSchema
+
+
+@job
+def deformations_to_elastic_tensor(
+    undeformed_result: RunSchema,
+    deformed_structure_set: DeformedStructureSet,
+    results: list[RunSchema],
+) -> ElasticityDoc:
+    """
+    Function to fit a DeformedStructureSet and result documents to an elastic tensor
+
+    Parameters
+    ----------
+    undeformed_result
+        Single point or relaxation of the undeformed result (for the relaxed stress, if it's not quite 0)
+    deformed_structure_set
+        The pymatgen DeformedStructureSet with information on the strains of each structure
+    results
+        A list of results (one per deformed structure) corresponding to single points or relaxations
+        on the deformed structures.
+
+    Returns
+    -------
+    ElasticityDoc
+        An emmet (i.e. MaterialsProject) ElasticityDoc with information about the final elastic tensor fit
+    """
+    structure = AseAtomsAdaptor.get_structure(undeformed_result["atoms"])  # type: ignore
+    return ElasticityDoc.from_deformations_and_stresses(
+        structure,
+        MPID("quacc-00"),
+        deformations=deformed_structure_set.deformations,
+        equilibrium_stress=Stress(
+            (
+                voigt_6_to_full_3x3_stress(undeformed_result["results"]["stress"])
+                if len(undeformed_result["results"]["stress"]) == 6
+                else undeformed_result["results"]["stress"]
+            )
+            / units.GPa
+        ),
+        stresses=[
+            Stress(
+                (
+                    voigt_6_to_full_3x3_stress(relax_result["results"]["stress"])
+                    if len(relax_result["results"]["stress"]) == 6
+                    else relax_result["results"]["stress"]
+                )
+                / units.GPa
+            )
+            for relax_result in results
+        ],
+    )
 
 
 @subflow
-def bulk_to_deformations_subflow(
-    atoms: Atoms,
+def bulk_to_elastic_tensor_subflow(
+    undeformed_result: RunSchema,
     relax_job: Job,
     static_job: Job | None = None,
     deform_kwargs: dict[str, Any] | None = None,
-) -> list[dict]:
+) -> ElasticSchema:
     """
     Workflow consisting of:
 
@@ -31,14 +90,16 @@ def bulk_to_deformations_subflow(
 
     3. Deformed structures statics (optional)
 
+    4. Elastic tensor calculation
+
     Parameters
     ----------
-    atoms
-        Atoms object
+    undeformed_result
+        Result of a static or optimization calculation
     relax_job
         The relaxation function.
     static_job
-        The static function.
+        The static function
     deform_kwargs
         Additional keyword arguments to pass to
         [quacc.atoms.deformation.make_deformations_from_bulk][]
@@ -50,15 +111,28 @@ def bulk_to_deformations_subflow(
     """
     deform_kwargs = deform_kwargs or {}
 
-    deformations = make_deformations_from_bulk(atoms, **deform_kwargs)
+    deformed_structure_set = make_deformations_from_bulk(
+        undeformed_result["atoms"], **deform_kwargs
+    )
 
     results = []
-    for deformed in deformations:
-        result = relax_job(deformed)
+    for deformed in deformed_structure_set:
+        result = relax_job(deformed.to_ase_atoms())
 
         if static_job is not None:
             result = static_job(result["atoms"])
 
         results.append(result)
 
-    return results
+    elasticity_doc = deformations_to_elastic_tensor(
+        undeformed_result=undeformed_result,
+        deformed_structure_set=deformed_structure_set,
+        results=results,
+    )
+
+    return {
+        "deformed_structure_set": deformed_structure_set,
+        "deformed_results": results,
+        "undeformed_result": undeformed_result,
+        "elasticity_doc": elasticity_doc,
+    }
diff --git a/src/quacc/recipes/emt/elastic.py b/src/quacc/recipes/emt/elastic.py
index 3f2e9dbb..220d732b 100644
--- a/src/quacc/recipes/emt/elastic.py
+++ b/src/quacc/recipes/emt/elastic.py
@@ -5,7 +5,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from quacc import flow
-from quacc.recipes.common.elastic import bulk_to_deformations_subflow
+from quacc.recipes.common.elastic import bulk_to_elastic_tensor_subflow
 from quacc.recipes.emt.core import relax_job, static_job
 from quacc.wflow_tools.customizers import customize_funcs
 
@@ -15,17 +15,18 @@ if TYPE_CHECKING:
 
     from ase.atoms import Atoms
 
-    from quacc.types import OptSchema, RunSchema
+    from quacc.types import ElasticSchema
 
 
 @flow
-def bulk_to_deformations_flow(
+def bulk_to_elastic_tensor_flow(
     atoms: Atoms,
-    run_static: bool = True,
+    pre_relax: bool = True,
+    run_static: bool = False,
     deform_kwargs: dict[str, Any] | None = None,
     job_params: dict[str, dict[str, Any]] | None = None,
     job_decorators: dict[str, Callable | None] | None = None,
-) -> list[RunSchema | OptSchema]:
+) -> ElasticSchema:
     """
     Workflow consisting of:
 
@@ -39,12 +40,17 @@ def bulk_to_deformations_flow(
         - name: "static_job"
         - job: [quacc.recipes.emt.core.static_job][]
 
+    4. Elastic tensor calculation
+
     Parameters
     ----------
     atoms
         Atoms object
+    pre_relax
+        Whether to run a relaxation on the bulk structure before deformation (true) or run a static
+        calculation (false)
     run_static
-        Whether to run static calculations.
+        Whether to run static calculations after any relaxations on the undeformed or deformed structures
     deform_kwargs
         Additional keyword arguments to pass to [quacc.atoms.deformation.make_deformations_from_bulk][]
     job_params
@@ -56,9 +62,7 @@ def bulk_to_deformations_flow(
 
     Returns
     -------
-    list[RunSchema | OptSchema]
-        [RunSchema][quacc.schemas.ase.Summarize.run] or
-        [OptSchema][quacc.schemas.ase.Summarize.opt] for each deformation.
+    ElasticSchema
         See the return type-hint for the data structure.
     """
     relax_job_, static_job_ = customize_funcs(
@@ -66,11 +70,18 @@ def bulk_to_deformations_flow(
         [relax_job, static_job],
         param_swaps=job_params,
         decorators=job_decorators,
-    )
+    )  # type: ignore
+
+    if pre_relax:
+        undeformed_result = relax_job_(atoms, relax_cell=True)
+        if run_static:
+            undeformed_result = static_job_(undeformed_result["atoms"])
+    else:
+        undeformed_result = static_job_(atoms)
 
-    return bulk_to_deformations_subflow(
-        atoms,
-        relax_job_,
+    return bulk_to_elastic_tensor_subflow(
+        undeformed_result=undeformed_result,
+        relax_job=relax_job_,
         static_job=static_job_ if run_static else None,
         deform_kwargs=deform_kwargs,
     )
diff --git a/src/quacc/recipes/mlp/elastic.py b/src/quacc/recipes/mlp/elastic.py
new file mode 100644
index 00000000..1363939b
--- /dev/null
+++ b/src/quacc/recipes/mlp/elastic.py
@@ -0,0 +1,88 @@
+"""Elastic constants recipes for MLPs."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from quacc import flow
+from quacc.recipes.common.elastic import bulk_to_elastic_tensor_subflow
+from quacc.recipes.mlp.core import relax_job, static_job
+from quacc.wflow_tools.customizers import customize_funcs
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+    from typing import Any
+
+    from ase.atoms import Atoms
+
+    from quacc.types import ElasticSchema
+
+
+@flow
+def bulk_to_elastic_tensor_flow(
+    atoms: Atoms,
+    pre_relax: bool = True,
+    run_static: bool = False,
+    deform_kwargs: dict[str, Any] | None = None,
+    job_params: dict[str, dict[str, Any]] | None = None,
+    job_decorators: dict[str, Callable | None] | None = None,
+) -> ElasticSchema:
+    """
+    Workflow consisting of:
+
+    1. Deformed structures generation
+
+    2. Deformed structures relaxations
+        - name: "relax_job"
+        - job: [quacc.recipes.emt.core.relax_job][]
+
+    3. Deformed structures statics (optional)
+        - name: "static_job"
+        - job: [quacc.recipes.emt.core.static_job][]
+
+    4. Elastic tensor calculation
+
+
+    Parameters
+    ----------
+    atoms
+        Atoms object
+    pre_relax
+        Whether to run a relaxation on the bulk structure before deformation (true) or run a static
+        calculation (false)
+    run_static
+        Whether to run static calculations after any relaxations on the undeformed or deformed structures
+    deform_kwargs
+        Additional keyword arguments to pass to [quacc.atoms.deformation.make_deformations_from_bulk][]
+    job_params
+        Custom parameters to pass to each Job in the Flow. This is a dictionary where
+        the keys are the names of the jobs and the values are dictionaries of parameters.
+    job_decorators
+        Custom decorators to apply to each Job in the Flow. This is a dictionary where
+        the keys are the names of the jobs and the values are decorators.
+
+    Returns
+    -------
+    ElasticSchema
+        See the return type-hint for the data structure.
+    """
+    relax_job_, static_job_ = customize_funcs(
+        ["relax_job", "static_job"],
+        [relax_job, static_job],
+        param_swaps=job_params,
+        decorators=job_decorators,
+    )  # type: ignore
+
+    if pre_relax:
+        undeformed_result = relax_job_(atoms, relax_cell=True)
+        if run_static:
+            undeformed_result = static_job_(undeformed_result["atoms"])
+    else:
+        undeformed_result = static_job_(atoms)
+
+    return bulk_to_elastic_tensor_subflow(
+        undeformed_result=undeformed_result,
+        relax_job=relax_job_,
+        static_job=static_job_ if run_static else None,
+        deform_kwargs=deform_kwargs,
+    )
diff --git a/src/quacc/types.py b/src/quacc/types.py
index e75dc01c..f566697a 100644
--- a/src/quacc/types.py
+++ b/src/quacc/types.py
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
     from ase.atoms import Atoms
     from ase.md.md import MolecularDynamics
     from ase.optimize.optimize import Dynamics
+    from emmet.core.elasticity import ElasticityDoc
     from emmet.core.math import ListMatrix3D, Matrix3D, Vector3D
     from emmet.core.symmetry import CrystalSystem
     from emmet.core.vasp.calc_types import CalcType
@@ -32,6 +33,7 @@ if TYPE_CHECKING:
     from emmet.core.vasp.task_valid import TaskState
     from numpy.random import Generator
     from numpy.typing import ArrayLike, NDArray
+    from pymatgen.analysis.elasticity.strain import DeformedStructureSet
     from pymatgen.core.composition import Composition
     from pymatgen.core.lattice import Lattice
     from pymatgen.core.periodic_table import Element
@@ -528,6 +530,14 @@ if TYPE_CHECKING:
         parameters_thermo: ParametersThermo
         results: ThermoResults
 
+    class ElasticSchema(TypedDict):
+        """Elastic properties and fitting schema"""
+
+        deformed_structure_set: DeformedStructureSet
+        deformed_results: list[RunSchema | OptSchema]
+        undeformed_static: RunSchema | OptSchema
+        elasticity_doc: ElasticityDoc
+
     class VibThermoSchema(VibSchema, ThermoSchema):
         """Combined Vibrations and Thermo schema"""
 


Test patch:
diff --git a/tests/core/atoms/test_elastic.py b/tests/core/atoms/test_elastic.py
index 8a04e786..d522ca64 100644
--- a/tests/core/atoms/test_elastic.py
+++ b/tests/core/atoms/test_elastic.py
@@ -16,8 +16,13 @@ def test_make_deformations_from_bulk():
     atoms.info["test"] = "hi"
     deformations = make_deformations_from_bulk(atoms)
     assert len(deformations) == 24
-    assert deformations[0].get_volume() != pytest.approx(atoms.get_volume())
+    assert deformations[0].to_ase_atoms().get_volume() != pytest.approx(
+        atoms.get_volume()
+    )
     for deformation in deformations:
-        assert_equal(deformation.get_atomic_numbers(), [30, 30, 30, 30, 52, 52, 52, 52])
-        assert_equal(deformation.get_chemical_formula(), "Te4Zn4")
-        assert deformation.info["test"] == "hi"
+        assert_equal(
+            deformation.to_ase_atoms().get_atomic_numbers(),
+            [30, 30, 30, 30, 52, 52, 52, 52],
+        )
+        assert_equal(deformation.to_ase_atoms().get_chemical_formula(), "Te4Zn4")
+        assert deformation.to_ase_atoms().info["test"] == "hi"
diff --git a/tests/core/recipes/emt_recipes/test_emt_elastic.py b/tests/core/recipes/emt_recipes/test_emt_elastic.py
index 43d7a76a..d88a23f8 100644
--- a/tests/core/recipes/emt_recipes/test_emt_elastic.py
+++ b/tests/core/recipes/emt_recipes/test_emt_elastic.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 import pytest
 from ase.build import bulk
 
-from quacc.recipes.emt.elastic import bulk_to_deformations_flow
+from quacc.recipes.emt.elastic import bulk_to_elastic_tensor_flow
 
 
 def test_elastic_jobs(tmp_path, monkeypatch):
@@ -11,22 +11,30 @@ def test_elastic_jobs(tmp_path, monkeypatch):
 
     atoms = bulk("Cu")
 
-    outputs = bulk_to_deformations_flow(atoms, run_static=False)
-    assert outputs[0]["atoms"].get_volume() != pytest.approx(atoms.get_volume())
-    for output in outputs:
+    outputs = bulk_to_elastic_tensor_flow(atoms, run_static=False)
+    assert outputs["deformed_results"][0]["atoms"].get_volume() != pytest.approx(
+        atoms.get_volume()
+    )
+    assert outputs["elasticity_doc"].bulk_modulus.voigt == pytest.approx(134.579)
+    for output in outputs["deformed_results"]:
         assert output["parameters"]["asap_cutoff"] is False
         assert output["name"] == "EMT Relax"
         assert output["nelements"] == 1
         assert output["nsites"] == 1
-        assert len(outputs) == 24
+    assert len(outputs["deformed_results"]) == 24
 
-    outputs = bulk_to_deformations_flow(
+    outputs = bulk_to_elastic_tensor_flow(
         atoms, run_static=True, job_params={"static_job": {"asap_cutoff": True}}
     )
-    assert outputs[0]["atoms"].get_volume() != pytest.approx(atoms.get_volume())
-    for output in outputs:
+    assert outputs["deformed_results"][0]["atoms"].get_volume() != pytest.approx(
+        atoms.get_volume()
+    )
+    assert outputs["deformed_results"][0]["atoms"].get_volume() != pytest.approx(
+        atoms.get_volume()
+    )
+    for output in outputs["deformed_results"]:
         assert output["parameters"]["asap_cutoff"] is True
         assert output["name"] == "EMT Static"
         assert output["nelements"] == 1
         assert output["nsites"] == 1
-        assert len(outputs) == 24
+    assert len(outputs["deformed_results"]) == 24
diff --git a/tests/core/recipes/mlp_recipes/test_core_recipes.py b/tests/core/recipes/mlp_recipes/test_core_recipes.py
index 155c8d2f..669c68cb 100644
--- a/tests/core/recipes/mlp_recipes/test_core_recipes.py
+++ b/tests/core/recipes/mlp_recipes/test_core_recipes.py
@@ -34,6 +34,12 @@ if find_spec("fairchem"):
         methods.append("fairchem")
 
 
+def _set_dtype(size, type_="float"):
+    globals()[f"{type_}_th"] = getattr(torch, f"{type_}{size}")
+    globals()[f"{type_}_np"] = getattr(np, f"{type_}{size}")
+    torch.set_default_dtype(getattr(torch, f"float{size}"))
+
+
 @pytest.mark.skipif(has_chgnet is None, reason="chgnet not installed")
 def test_bad_method():
     atoms = bulk("Cu")
@@ -41,12 +47,6 @@ def test_bad_method():
         static_job(atoms, method="bad_method")
 
 
-def _set_dtype(size, type_="float"):
-    globals()[f"{type_}_th"] = getattr(torch, f"{type_}{size}")
-    globals()[f"{type_}_np"] = getattr(np, f"{type_}{size}")
-    torch.set_default_dtype(getattr(torch, f"float{size}"))
-
-
 @pytest.mark.parametrize("method", methods)
 def test_static_job(tmp_path, monkeypatch, method):
     monkeypatch.chdir(tmp_path)
diff --git a/tests/core/recipes/mlp_recipes/test_elastic_recipes.py b/tests/core/recipes/mlp_recipes/test_elastic_recipes.py
new file mode 100644
index 00000000..f45f0deb
--- /dev/null
+++ b/tests/core/recipes/mlp_recipes/test_elastic_recipes.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from ase.build import bulk
+
+from quacc.recipes.mlp.elastic import bulk_to_elastic_tensor_flow
+
+torch = pytest.importorskip("torch")
+
+from importlib.util import find_spec
+
+methods = []
+if has_mace := find_spec("mace"):
+    methods.append("mace-mp-0")
+
+if has_matgl := find_spec("matgl"):
+    methods.append("m3gnet")
+
+if has_chgnet := find_spec("chgnet"):
+    methods.append("chgnet")
+
+if has_sevennet := find_spec("sevenn"):
+    methods.append("sevennet")
+
+if has_orb := find_spec("orb_models"):
+    methods.append("orb")
+
+if find_spec("fairchem"):
+    from huggingface_hub.utils._auth import get_token
+
+    if get_token():
+        methods.append("fairchem")
+
+
+def _set_dtype(size, type_="float"):
+    globals()[f"{type_}_th"] = getattr(torch, f"{type_}{size}")
+    globals()[f"{type_}_np"] = getattr(np, f"{type_}{size}")
+    torch.set_default_dtype(getattr(torch, f"float{size}"))
+
+
+@pytest.mark.parametrize("method", methods)
+def test_elastic_jobs(tmp_path, monkeypatch, method):
+    monkeypatch.chdir(tmp_path)
+
+    if method == "mace-mp-0":
+        _set_dtype(64)
+    else:
+        _set_dtype(32)
+
+    if method == "fairchem":
+        calc_kwargs = {
+            "model_name": "EquiformerV2-31M-OMAT24-mp-salex",
+            "local_cache": "./fairchem_checkpoint_cache/",
+        }
+    else:
+        calc_kwargs = {}
+
+    ref_elastic_modulus = {
+        "chgnet": 199,
+        "m3gnet": 109.369,
+        "mace-mp-0": 130.727,
+        "sevennet": 142.296,
+        "orb": 187.107,
+        "fairchem": 105,
+    }
+
+    atoms = bulk("Cu")
+
+    outputs = bulk_to_elastic_tensor_flow(
+        atoms,
+        run_static=False,
+        pre_relax=True,
+        job_params={
+            "all": {"method": method, **calc_kwargs},
+            "relax_job": {"opt_params": {"fmax": 0.01}},
+        },
+    )
+    assert outputs["deformed_results"][0]["atoms"].get_volume() != pytest.approx(
+        atoms.get_volume()
+    )
+    assert outputs["undeformed_result"]["results"]["stress"] == pytest.approx(
+        0, abs=1e-2
+    )
+    assert outputs["elasticity_doc"].bulk_modulus.voigt == pytest.approx(
+        ref_elastic_modulus[method], abs=2
+    )
+    for output in outputs["deformed_results"]:
+        assert output["nelements"] == 1
+        assert output["nsites"] == 1
+    assert len(outputs["deformed_results"]) == 24
+
+    outputs = bulk_to_elastic_tensor_flow(
+        atoms,
+        run_static=True,
+        pre_relax=True,
+        job_params={
+            "all": {"method": method, **calc_kwargs},
+            "relax_job": {"opt_params": {"fmax": 0.01}},
+        },
+    )
+    assert outputs["deformed_results"][0]["atoms"].get_volume() != pytest.approx(
+        atoms.get_volume()
+    )
+
+    for output in outputs["deformed_results"]:
+        assert output["nelements"] == 1
+        assert output["nsites"] == 1
+    assert len(outputs["deformed_results"]) == 24
+
+    outputs = bulk_to_elastic_tensor_flow(
+        atoms,
+        run_static=True,
+        pre_relax=False,
+        job_params={
+            "all": {"method": method, **calc_kwargs},
+            "relax_job": {"opt_params": {"fmax": 0.01}},
+        },
+    )
+    assert outputs["deformed_results"][0]["atoms"].get_volume() != pytest.approx(
+        atoms.get_volume()
+    )
+
+    for output in outputs["deformed_results"]:
+        assert output["nelements"] == 1
+        assert output["nsites"] == 1
+    assert len(outputs["deformed_results"]) == 24
+
+    outputs = bulk_to_elastic_tensor_flow(
+        atoms,
+        run_static=False,
+        pre_relax=False,
+        job_params={
+            "all": {"method": method, **calc_kwargs},
+            "relax_job": {"opt_params": {"fmax": 0.01}},
+        },
+    )
+    assert outputs["deformed_results"][0]["atoms"].get_volume() != pytest.approx(
+        atoms.get_volume()
+    )
+
+    for output in outputs["deformed_results"]:
+        assert output["nelements"] == 1
+        assert output["nsites"] == 1
+    assert len(outputs["deformed_results"]) == 24