diff --git a/README.md b/README.md
index be63c0d8d..3efca1142 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,7 @@ Workflow notebooks that run simulations.
| [Band Structure (HSE)](other/materials_designer/workflows/band_structure_hse.ipynb) | Band structure with the HSE hybrid functional. |
| [Convergence](other/materials_designer/workflows/convergence.ipynb) | General convergence study. |
| [Surface Energy](other/materials_designer/workflows/surface_energy.ipynb) | Calculate surface formation energy. |
+| [Interfacial Energy](other/materials_designer/workflows/interfacial_energy.ipynb) | Calculate interfacial energy γ at a heterostructure. |
| [Valence Band Offset](other/materials_designer/workflows/valence_band_offset.ipynb) | Determine the valence band offset at an interface. |
### `notebooks_utils` — Python Utility Package
diff --git a/other/materials_designer/workflows/Introduction.ipynb b/other/materials_designer/workflows/Introduction.ipynb
index 8f6a89bd6..592d0a641 100644
--- a/other/materials_designer/workflows/Introduction.ipynb
+++ b/other/materials_designer/workflows/Introduction.ipynb
@@ -62,8 +62,8 @@
"### 6.2. Surface Energy\n",
"#### [6.2.1. Surface energy calculation.](surface_energy.ipynb)\n",
"\n",
- "### 6.3. Interface Energy\n",
- "#### 6.3.1. Interface energy calculation. *(to be added)*\n",
+ "### 6.3. Interfacial Energy\n",
+ "#### [6.3.1. Interfacial energy calculation.](interfacial_energy.ipynb)\n",
"\n",
"### 6.4. Zero-Point Energy\n",
"#### [6.4.1. Zero-point energy calculation.](zero_point_energy.ipynb)\n",
diff --git a/other/materials_designer/workflows/interfacial_energy.ipynb b/other/materials_designer/workflows/interfacial_energy.ipynb
new file mode 100644
index 000000000..18fa5c611
--- /dev/null
+++ b/other/materials_designer/workflows/interfacial_energy.ipynb
@@ -0,0 +1,640 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# Interfacial Energy\n",
+ "\n",
+ "Calculate the interfacial energy γ (eV/Ų) of a heterostructure using a multi-material DFT workflow on the Mat3ra platform.\n",
+ "\n",
+ "The workflow takes **three materials, in order**:\n",
+ "\n",
+ "- **[0] Interface** — the heterostructure (e.g. Gr/Ni(111)).\n",
+ "- **[1] Substrate bulk** — the substrate reference crystal.\n",
+ "- **[2] Film bulk** — the film reference crystal.\n",
+ "\n",
+ "Only the interface is computed here (`pw_scf`). The two bulk reference energies are fetched from previously-finished **Total Energy** jobs, so each bulk must already be converged/relaxed and have such a job on the platform. The substrate/film multiplicities are derived automatically from the structures; `N_INTERFACES` (1 with vacuum, 2 for a periodic stack) is provided by this notebook.\n",
+ "\n",
+ "This notebook loads an interface from `../uploads`, resolves the substrate and film bulk crystals from the interface `metadata.build`, verifies each bulk has a total energy on the platform, and submits the three materials to the multi-material workflow.\n",
+ "\n",
+ "
Usage
\n",
+ "\n",
+ "1. Build an interface (for example via `create_interface_with_min_strain_zsl.ipynb`) and export it to `../uploads`, or set `INTERFACE_NAME` to match an interface on the platform.\n",
+ "2. Run [Total Energy](total_energy.ipynb) for each resolved substrate and film bulk crystal.\n",
+ "3. Set parameters in cells 1.2 and 1.3 below.\n",
+ "4. Click \"Run\" > \"Run All\".\n",
+ "5. Inspect the resolved bulks, their total energies, and the final interfacial energy.\n",
+ "\n",
+ "## Summary\n",
+ "\n",
+ "1. Set up the environment and parameters.\n",
+ "2. Authenticate and initialize API client.\n",
+ "3. Load the interface, resolve substrate/film bulks with their total energies, and assemble the three job materials.\n",
+ "4. Configure the Interfacial Energy workflow.\n",
+ "5. Configure compute.\n",
+ "6. Create, submit, and monitor the multi-material job.\n",
+ "7. Retrieve results.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1",
+ "metadata": {},
+ "source": [
+ "## 1. Set up the environment and parameters\n",
+ "### 1.1. Install packages (JupyterLite)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.notebooks_utils.packages import install_packages\n",
+ "\n",
+ "await install_packages(\"made|api_examples\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3",
+ "metadata": {},
+ "source": [
+ "### 1.2. Set parameters\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from datetime import datetime\n",
+ "from mat3ra.ide.compute import QueueName\n",
+ "\n",
+ "# 2. Auth and organization parameters\n",
+ "ORGANIZATION_NAME = None\n",
+ "\n",
+ "# 3. Material parameters\n",
+ "FOLDER = \"../uploads\"\n",
+ "INTERFACE_NAME = \"Interface\" # Name to search in uploads or on the platform\n",
+ "\n",
+ "# 4. Workflow parameters\n",
+ "WORKFLOW_SEARCH_TERM = \"interfacial_energy.json\"\n",
+ "APPLICATION_NAME = \"espresso\"\n",
+ "MY_WORKFLOW_NAME = \"Interfacial Energy\"\n",
+ "\n",
+ "# 5. Compute parameters\n",
+ "CLUSTER_NAME = None # specify full or partial name i.e. \"cluster-001\" to select\n",
+ "QUEUE_NAME = QueueName.D\n",
+ "PPN = 1\n",
+ "\n",
+ "# 6. Job parameters\n",
+ "timestamp = datetime.now().strftime(\"%Y-%m-%d %H:%M\")\n",
+ "POLL_INTERVAL = 30 # seconds\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5",
+ "metadata": {},
+ "source": [
+ "### 1.3. Set specific interfacial energy parameters"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# K-grid for interface SCF (if not set, KPPRA is used by default)\n",
+ "SCF_KGRID = None # e.g., [4, 4, 1]\n",
+ "\n",
+ "# Number of interfaces in the supercell: 1 if the slab has vacuum (one physical\n",
+ "# interface), 2 for a periodic stack with no vacuum. Substrate and film\n",
+ "# multiplicities are derived automatically from the structures.\n",
+ "N_INTERFACES = 1"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7",
+ "metadata": {},
+ "source": [
+ "## 2. Authenticate and initialize API client\n",
+ "### 2.1. Authenticate\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.notebooks_utils.auth import authenticate\n",
+ "\n",
+ "await authenticate()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9",
+ "metadata": {},
+ "source": [
+ "### 2.2. Initialize API client\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "10",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.api_client import APIClient\n",
+ "\n",
+ "client = APIClient.authenticate()\n",
+ "client\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "11",
+ "metadata": {},
+ "source": [
+ "### 2.3. Select account to work under\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "12",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "client.list_accounts()\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "13",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "selected_account = client.my_account\n",
+ "\n",
+ "if ORGANIZATION_NAME:\n",
+ " selected_account = client.get_account(name=ORGANIZATION_NAME)\n",
+ "\n",
+ "ACCOUNT_ID = selected_account.id\n",
+ "print(f\"✅ Selected account ID: {ACCOUNT_ID}, name: {selected_account.name}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "14",
+ "metadata": {},
+ "source": [
+ "### 2.4. Select project\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "15",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "projects = client.projects.list({\"isDefault\": True, \"owner._id\": ACCOUNT_ID})\n",
+ "project_id = projects[0][\"_id\"]\n",
+ "print(f\"✅ Using project: {projects[0]['name']} ({project_id})\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "16",
+ "metadata": {},
+ "source": [
+ "## 3. Load interface and resolve substrate/film bulks\n",
+ "### 3.1. Load interface from local file or platform\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "17",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import re\n",
+ "from mat3ra.made.material import Material\n",
+ "from mat3ra.notebooks_utils.material import load_material_from_folder\n",
+ "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n",
+ "\n",
+ "interface = load_material_from_folder(FOLDER, INTERFACE_NAME)\n",
+ "\n",
+ "if interface is None:\n",
+ " interface_matches = client.materials.list({\n",
+ " \"name\": {\"$regex\": re.escape(INTERFACE_NAME), \"$options\": \"i\"},\n",
+ " })\n",
+ " if not interface_matches:\n",
+ " raise ValueError(\n",
+ " f\"No interface containing '{INTERFACE_NAME}' was found in '{FOLDER}' or on the platform.\"\n",
+ " )\n",
+ " interface = Material.create(interface_matches[0])\n",
+ " print(f\"♻️ Loaded interface from platform: {interface_matches[0]['_id']}\")\n",
+ "else:\n",
+ " print(f\"✅ Loaded interface from folder: {interface.name}\")\n",
+ "\n",
+ "visualize(interface, repetitions=[1, 1, 1])\n",
+ "visualize(interface, repetitions=[1, 1, 1], rotation=\"-90x\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "18",
+ "metadata": {},
+ "source": [
+ "### 3.2. Prepare interface for Quantum ESPRESSO\n",
+ "Strip atom labels. Labels are useful for analysis notebooks but are not compatible with QE input generation.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "19",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "interface_material = interface.clone()\n",
+ "interface_material.basis.set_labels_from_list([])\n",
+ "print(f\"Prepared interface material: {interface_material.name}\")\n",
+ "visualize(interface_material, repetitions=[1, 1, 1], rotation=\"-90x\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "20",
+ "metadata": {},
+ "source": [
+ "### 3.3. Load workflow from Standata\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "21",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.standata.workflows import WorkflowStandata\n",
+ "from mat3ra.wode.workflows import Workflow\n",
+ "\n",
+ "interfacial_workflow_config = WorkflowStandata.filter_by_application(APPLICATION_NAME).get_by_name_first_match(\n",
+ " WORKFLOW_SEARCH_TERM\n",
+ ")\n",
+ "interfacial_workflow = Workflow.create(interfacial_workflow_config)\n",
+ "print(f\"Loaded workflow: {interfacial_workflow.name}\")\n",
+ "print(f\"Multi-material: {getattr(interfacial_workflow, 'isMultiMaterial', False)}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22",
+ "metadata": {},
+ "source": [
+ "### 3.4. Resolve substrate/film crystals from interface build metadata\n",
+ "Uses `made`'s `get_interface_bulk_crystal` helper for typed access to the interface build configuration, instead of hand-parsing the raw `metadata.build` dict."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "23",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.made.tools.analyze.interface_material import get_interface_bulk_crystal\n",
+ "from mat3ra.made.tools.build_components.metadata import MaterialWithBuildMetadata\n",
+ "\n",
+ "interface_with_metadata = MaterialWithBuildMetadata.create(interface_material.to_dict())\n",
+ "\n",
+ "# Bulk crystals as recorded when the interface was built.\n",
+ "CRYSTAL_SUBSTRATE = Material.create(get_interface_bulk_crystal(interface_with_metadata, part=\"substrate\"))\n",
+ "CRYSTAL_FILM = Material.create(get_interface_bulk_crystal(interface_with_metadata, part=\"film\"))\n",
+ "\n",
+ "print(f\"Substrate crystal: {CRYSTAL_SUBSTRATE.name}\")\n",
+ "print(f\"Film crystal: {CRYSTAL_FILM.name}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "24",
+ "metadata": {},
+ "source": [
+ "### 3.5. Find substrate and film bulk materials on the platform\n",
+ "Each bulk must already exist on the platform with a finished Total Energy job (see [Total Energy](total_energy.ipynb)); the workflow will fetch that energy rather than recomputing it."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "25",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.notebooks_utils.core.entity.material.api import get_bulk_material_by_crystal\n",
+ "from mat3ra.notebooks_utils.core.entity.property.api import find_total_energy_for_material\n",
+ "\n",
+ "substrate_bulk = get_bulk_material_by_crystal(client, CRYSTAL_SUBSTRATE, ACCOUNT_ID)\n",
+ "substrate_te_property = find_total_energy_for_material(client, substrate_bulk.id)\n",
+ "if substrate_te_property is None:\n",
+ " raise RuntimeError(\"Substrate bulk total energy not found. Run total_energy.ipynb for the substrate bulk first.\")\n",
+ "print(f\"✅ Substrate bulk (material 1): {substrate_bulk.name} ({substrate_bulk.id}), \"\n",
+ " f\"total energy: {substrate_te_property['data']['value']} eV\")\n",
+ "\n",
+ "film_bulk = get_bulk_material_by_crystal(client, CRYSTAL_FILM, ACCOUNT_ID)\n",
+ "film_te_property = find_total_energy_for_material(client, film_bulk.id)\n",
+ "if film_te_property is None:\n",
+ " raise RuntimeError(\"Film bulk total energy not found. Run total_energy.ipynb for the film bulk first.\")\n",
+ "print(f\"✅ Film bulk (material 2): {film_bulk.name} ({film_bulk.id}), \"\n",
+ " f\"total energy: {film_te_property['data']['value']} eV\")\n",
+ "\n",
+ "visualize([substrate_bulk, film_bulk], repetitions=[1, 1, 1])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "26",
+ "metadata": {},
+ "source": [
+ "### 3.6. Save the interface and assemble the job materials\n",
+ "Save the (label-stripped) interface, then assemble the three materials the multi-material workflow expects, in order: `[0]` interface, `[1]` substrate bulk, `[2]` film bulk."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "27",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.notebooks_utils.core.entity.material.api import get_or_create_material\n",
+ "\n",
+ "saved_interface_response = get_or_create_material(client, interface_material, ACCOUNT_ID)\n",
+ "saved_interface = Material.create(saved_interface_response)\n",
+ "\n",
+ "# Order matters: the workflow reads material 0 as the interface, 1 as the\n",
+ "# substrate bulk, and 2 as the film bulk.\n",
+ "materials = [saved_interface, substrate_bulk, film_bulk]\n",
+ "print(\"✅ Materials for the multi-material job (index → material):\")\n",
+ "for index, material in enumerate(materials):\n",
+ " print(f\" [{index}] {material.name} ({material.id})\")\n",
+ "\n",
+ "visualize(materials, repetitions=[1, 1, 1], rotation=\"-90x\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "28",
+ "metadata": {},
+ "source": [
+ "## 4. Configure the Interfacial Energy workflow\n",
+ "### 4.1. Select application"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "29",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.ade.application import Application\n",
+ "from mat3ra.standata.applications import ApplicationStandata\n",
+ "\n",
+ "app_config = ApplicationStandata.get_by_name_first_match(APPLICATION_NAME)\n",
+ "app = Application(**app_config)\n",
+ "print(f\"Using application: {app.name}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "30",
+ "metadata": {},
+ "source": [
+ "### 4.2. Configure workflow and preview it"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "31",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.notebooks_utils.workflow import apply_scf_kgrid\n",
+ "from mat3ra.notebooks_utils.ipython.entity.workflow.visualize import visualize_workflow\n",
+ "\n",
+ "if type(N_INTERFACES) is not int or N_INTERFACES not in (1, 2):\n",
+ " raise ValueError(\"N_INTERFACES must be either 1 (vacuum) or 2 (periodic stack)\")\n",
+ "\n",
+ "interfacial_workflow.name = MY_WORKFLOW_NAME\n",
+ "interfacial_workflow = apply_scf_kgrid(interfacial_workflow, scf_kgrid=SCF_KGRID)\n",
+ "\n",
+ "for subworkflow in interfacial_workflow.subworkflows:\n",
+ " if \"set-n-interfaces\" not in [unit.name for unit in subworkflow.units]:\n",
+ " continue\n",
+ " unit = subworkflow.get_unit_by_name(name=\"set-n-interfaces\")\n",
+ " unit.value = str(N_INTERFACES)\n",
+ " subworkflow.set_unit(unit)\n",
+ "\n",
+ "visualize_workflow(interfacial_workflow)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "32",
+ "metadata": {},
+ "source": [
+ "## 5. Create the compute configuration\n",
+ "### 5.1. Select cluster"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "33",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "clusters = client.clusters.list()\n",
+ "print(f\"Available clusters: {[c['hostname'] for c in clusters]}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "34",
+ "metadata": {},
+ "source": [
+ "### 5.2. Create compute configuration"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "35",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.ide.compute import Compute\n",
+ "\n",
+ "if CLUSTER_NAME:\n",
+ " cluster = next((c for c in clusters if CLUSTER_NAME in c[\"hostname\"]), None)\n",
+ "else:\n",
+ " cluster = clusters[0]\n",
+ "\n",
+ "compute = Compute(cluster=cluster, queue=QUEUE_NAME, ppn=PPN)\n",
+ "print(f\"Using cluster: {compute.cluster.hostname}, queue: {QUEUE_NAME}, ppn: {PPN}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "36",
+ "metadata": {},
+ "source": [
+ "## 6. Create the Interfacial Energy job\n",
+ "### 6.1. Create the multi-material job (interface + substrate bulk + film bulk)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "37",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.notebooks_utils.api.job import wait_for_jobs_to_finish_async\n",
+ "from mat3ra.notebooks_utils.job import create_job\n",
+ "from mat3ra.notebooks_utils.ui import display_JSON\n",
+ "from mat3ra.utils.namespace import dict_to_namespace_recursive\n",
+ "\n",
+ "print(\"Materials (job material order):\")\n",
+ "for index, material in enumerate(materials):\n",
+ " print(f\" [{index}] {material.name} ({material.id})\")\n",
+ "print(f\"Project: {project_id}\")\n",
+ "\n",
+ "interfacial_job_name = f\"{MY_WORKFLOW_NAME} {saved_interface.formula} {timestamp}\"\n",
+ "interfacial_job_response = create_job(\n",
+ " api_client=client,\n",
+ " materials=materials,\n",
+ " workflow=interfacial_workflow,\n",
+ " project_id=project_id,\n",
+ " owner_id=ACCOUNT_ID,\n",
+ " prefix=interfacial_job_name,\n",
+ " compute=compute.to_dict(),\n",
+ ")\n",
+ "\n",
+ "interfacial_job = dict_to_namespace_recursive(interfacial_job_response)\n",
+ "interfacial_job_id = interfacial_job._id\n",
+ "print(f\"✅ Interfacial Energy job created successfully: {interfacial_job_id}\")\n",
+ "display_JSON(interfacial_job_response)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "38",
+ "metadata": {},
+ "source": [
+ "### 6.2. Submit the Interfacial Energy job and monitor the status"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "39",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "client.jobs.submit(interfacial_job_id)\n",
+ "print(f\"✅ Job {interfacial_job_id} submitted successfully!\")\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "40",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await wait_for_jobs_to_finish_async(client.jobs, [interfacial_job_id], poll_interval=POLL_INTERVAL)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "41",
+ "metadata": {},
+ "source": [
+ "## 7. Retrieve results\n",
+ "### 7.1. Retrieve and visualize interfacial energy"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "42",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.prode import PropertyName\n",
+ "from mat3ra.notebooks_utils.ipython.entity.property.visualize import visualize_properties\n",
+ "\n",
+ "interfacial_energy_data = client.properties.get_for_job(\n",
+ " interfacial_job_id, property_name=\"interfacial_energy\"\n",
+ ")\n",
+ "interface_te_data = client.properties.get_for_job(\n",
+ " interfacial_job_id, property_name=PropertyName.scalar.total_energy.value\n",
+ ")\n",
+ "\n",
+ "visualize_properties(interfacial_energy_data, title=\"Interfacial Energy\")\n",
+ "\n",
+ "print(f\"Interface (material 0): {saved_interface.name} ({saved_interface.id}), \"\n",
+ " f\"total energy: {interface_te_data[0]['data']['value']} eV\")\n",
+ "print(f\"Substrate bulk (material 1): {substrate_bulk.name} ({substrate_bulk.id}), \"\n",
+ " f\"total energy: {substrate_te_property['data']['value']} eV\")\n",
+ "print(f\"Film bulk (material 2): {film_bulk.name} ({film_bulk.id}), \"\n",
+ " f\"total energy: {film_te_property['data']['value']} eV\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "43",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "pygments_lexer": "ipython3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/other/materials_designer/workflows/surface_energy.ipynb b/other/materials_designer/workflows/surface_energy.ipynb
index 10f20e754..cef5240bf 100644
--- a/other/materials_designer/workflows/surface_energy.ipynb
+++ b/other/materials_designer/workflows/surface_energy.ipynb
@@ -9,9 +9,9 @@
"\n",
"Calculate the surface energy of a slab using a DFT workflow on the Mat3ra platform.\n",
"\n",
- "This notebook resolves the bulk material from slab metadata and checks that both the bulk material and its refined `total_energy` property already exist on the platform.\n",
+ "This notebook resolves the bulk material from slab metadata and checks that it already has a finished Total Energy job on the platform.\n",
"\n",
- "If either the bulk material or bulk total energy is missing, the notebook stops and points to the Total Energy notebook. It does not create or run a bulk pre-calculation automatically.\n",
+ "If either the bulk material or its total energy is missing, the notebook stops and points to the Total Energy notebook. It does not create or run a bulk pre-calculation automatically.\n",
"\n",
"Usage
\n",
"\n",
@@ -19,18 +19,17 @@
"2. Set the slab and workflow parameters in cells 1.2 and 1.3 below.\n",
"3. Click \"Run\" > \"Run All\".\n",
"4. If the bulk material or bulk total energy is missing, run the Total Energy notebook for the exact bulk material first, then rerun this notebook.\n",
- "5. Inspect the resolved bulk, the bulk total energy used by the workflow, and the final surface energy.\n",
+ "5. Inspect the resolved bulk, its total energy, and the final surface energy.\n",
"\n",
"## Summary\n",
"\n",
"1. Set up the environment and parameters.\n",
"2. Authenticate and initialize API client.\n",
- "3. Load slab, resolve the exact bulk candidate, and check that exact bulk exists on the platform.\n",
- "4. Check that refined bulk total energy already exists.\n",
- "5. Configure and save the Surface Energy workflow.\n",
- "6. Configure compute.\n",
- "7. Create, submit, and monitor the Surface Energy job.\n",
- "8. Retrieve results.\n"
+ "3. Load the slab, resolve the exact bulk material with its total energy, and save the slab.\n",
+ "4. Configure the Surface Energy workflow.\n",
+ "5. Configure compute.\n",
+ "6. Create, submit, and monitor the Surface Energy job.\n",
+ "7. Retrieve results."
]
},
{
@@ -59,7 +58,7 @@
"id": "3",
"metadata": {},
"source": [
- "### 1.2. Set parameters and configurations for the workflow and job\n"
+ "### 1.2. Set parameters\n"
]
},
{
@@ -73,7 +72,6 @@
"from mat3ra.ide.compute import QueueName\n",
"\n",
"# 2. Auth and organization parameters\n",
- "# Set organization name to use it as the owner, otherwise your personal account is used\n",
"ORGANIZATION_NAME = None\n",
"\n",
"# 3. Material parameters\n",
@@ -81,9 +79,8 @@
"SLAB_NAME = \"Slab\" # Name of the slab to load from uploads folder or from the platform\n",
"\n",
"# 4. Workflow parameters\n",
- "WORKFLOW_SEARCH_TERM = \"surface_energy.json\" # Search term for Workflows Standata\n",
- "APPLICATION_NAME = \"espresso\" # Specify application name (e.g., \"espresso\", \"vasp\", \"nwchem\")\n",
- "SCF_KGRID = None # e.g., [4, 4, 1]\n",
+ "WORKFLOW_SEARCH_TERM = \"surface_energy.json\"\n",
+ "APPLICATION_NAME = \"espresso\"\n",
"MY_WORKFLOW_NAME = \"Surface Energy\"\n",
"\n",
"# 5. Compute parameters\n",
@@ -100,6 +97,25 @@
"cell_type": "markdown",
"id": "5",
"metadata": {},
+ "source": [
+ "### 1.3. Set specific surface energy parameters"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# K-grid for slab SCF (if not set, KPPRA is used by default)\n",
+ "SCF_KGRID = None # e.g., [4, 4, 1]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7",
+ "metadata": {},
"source": [
"## 2. Authenticate and initialize API client\n",
"### 2.1. Authenticate\n",
@@ -109,7 +125,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "6",
+ "id": "8",
"metadata": {},
"outputs": [],
"source": [
@@ -120,7 +136,7 @@
},
{
"cell_type": "markdown",
- "id": "7",
+ "id": "9",
"metadata": {},
"source": [
"### 2.2. Initialize API client\n"
@@ -129,7 +145,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8",
+ "id": "10",
"metadata": {},
"outputs": [],
"source": [
@@ -141,7 +157,7 @@
},
{
"cell_type": "markdown",
- "id": "9",
+ "id": "11",
"metadata": {},
"source": [
"### 2.3. Select account to work under\n"
@@ -150,7 +166,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "10",
+ "id": "12",
"metadata": {},
"outputs": [],
"source": [
@@ -160,7 +176,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "11",
+ "id": "13",
"metadata": {},
"outputs": [],
"source": [
@@ -175,7 +191,7 @@
},
{
"cell_type": "markdown",
- "id": "12",
+ "id": "14",
"metadata": {},
"source": [
"### 2.4. Select project\n"
@@ -184,7 +200,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "13",
+ "id": "15",
"metadata": {},
"outputs": [],
"source": [
@@ -195,17 +211,17 @@
},
{
"cell_type": "markdown",
- "id": "14",
+ "id": "16",
"metadata": {},
"source": [
- "## 3. Load slab and resolve the exact bulk candidate\n",
- "### 3.1. Load slab from local file or platform\n"
+ "## 3. Load slab, resolve bulk material, and save\n",
+ "### 3.1. Load slab from local file or platform"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "15",
+ "id": "17",
"metadata": {},
"outputs": [],
"source": [
@@ -232,39 +248,12 @@
"visualize(slab)"
]
},
- {
- "cell_type": "markdown",
- "id": "16",
- "metadata": {},
- "source": [
- "### 3.2. Check for the exact bulk material from slab metadata\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "17",
- "metadata": {},
- "outputs": [],
- "source": [
- "from mat3ra.notebooks_utils.core.entity.material.analysis import get_bulk_material\n",
- "from mat3ra.notebooks_utils.ui import display_JSON\n",
- "\n",
- "bulk_query, bulk_material_response, bulk_material = get_bulk_material(\n",
- " client,\n",
- " slab,\n",
- " ACCOUNT_ID,\n",
- ")\n",
- "print(f\"Resolved bulk query: {bulk_query}\")\n",
- "display_JSON({\"bulk_query\": bulk_query}, level=2)"
- ]
- },
{
"cell_type": "markdown",
"id": "18",
"metadata": {},
"source": [
- "### 3.3. Inspect the exact bulk material found on the platform\n"
+ "### 3.2. Load workflow from Standata\n"
]
},
{
@@ -274,8 +263,14 @@
"metadata": {},
"outputs": [],
"source": [
- "display_JSON(bulk_material_response, level=2)\n",
- "visualize(bulk_material)\n"
+ "from mat3ra.standata.workflows import WorkflowStandata\n",
+ "from mat3ra.wode.workflows import Workflow\n",
+ "\n",
+ "surface_workflow_config = WorkflowStandata.filter_by_application(APPLICATION_NAME).get_by_name_first_match(\n",
+ " WORKFLOW_SEARCH_TERM\n",
+ ")\n",
+ "surface_workflow = Workflow.create(surface_workflow_config)\n",
+ "print(f\"Loaded workflow: {surface_workflow.name}\")"
]
},
{
@@ -283,7 +278,8 @@
"id": "20",
"metadata": {},
"source": [
- "### 3.4. Save the slab material for the workflow\n"
+ "### 3.3. Find the bulk material and its total energy on the platform\n",
+ "Resolves the exact bulk from the slab's build metadata and requires a finished Total Energy job — the workflow fetches that energy rather than recomputing it."
]
},
{
@@ -293,12 +289,18 @@
"metadata": {},
"outputs": [],
"source": [
- "from mat3ra.notebooks_utils.core.entity.material.api import get_or_create_material\n",
+ "from mat3ra.notebooks_utils.core.entity.material.api import get_bulk_material\n",
+ "from mat3ra.notebooks_utils.core.entity.property.api import find_total_energy_for_material\n",
"\n",
- "saved_slab_response = get_or_create_material(client, slab, ACCOUNT_ID)\n",
+ "bulk_material = get_bulk_material(client, slab, ACCOUNT_ID)\n",
"\n",
- "print(f\"✅ Saved slab material: {saved_slab_response['_id']}\")\n",
- "saved_slab = Material.create(saved_slab_response)"
+ "bulk_total_energy_property = find_total_energy_for_material(client, bulk_material.id)\n",
+ "if bulk_total_energy_property is None:\n",
+ " raise RuntimeError(\"Bulk total energy not found. Run total_energy.ipynb for the bulk material first.\")\n",
+ "\n",
+ "print(f\"✅ Bulk material: {bulk_material.name} ({bulk_material.id}), \"\n",
+ " f\"total energy: {bulk_total_energy_property['data']['value']} eV\")\n",
+ "visualize(bulk_material)"
]
},
{
@@ -306,8 +308,7 @@
"id": "22",
"metadata": {},
"source": [
- "## 4. Check bulk total energy\n",
- "### 4.1. Check refined bulk total energy for the exact bulk material\n"
+ "### 3.4. Save the slab material for the workflow"
]
},
{
@@ -317,39 +318,12 @@
"metadata": {},
"outputs": [],
"source": [
- "import json\n",
- "\n",
- "\n",
- "def get_refined_bulk_total_energy(api_client: APIClient, bulk_material_dict: dict):\n",
- " query = {\n",
- " \"exabyteId\": bulk_material_dict[\"exabyteId\"],\n",
- " \"data.name\": \"total_energy\",\n",
- " \"group\": {\"$regex\": \"qe:\"},\n",
- " }\n",
- " projection = {\"sort\": {\"precision.value\": -1}, \"limit\": 1}\n",
- " results = api_client.properties.request(\n",
- " \"GET\",\n",
- " \"refined-properties\",\n",
- " params={\n",
- " \"query\": json.dumps(query),\n",
- " \"projection\": json.dumps(projection),\n",
- " },\n",
- " )\n",
- " return results[0] if results else None\n",
+ "from mat3ra.notebooks_utils.core.entity.material.api import get_or_create_material\n",
"\n",
+ "saved_slab_response = get_or_create_material(client, slab, ACCOUNT_ID)\n",
"\n",
- "bulk_total_energy_property = get_refined_bulk_total_energy(client, bulk_material_response)\n",
- "if bulk_total_energy_property is None:\n",
- " raise RuntimeError(\n",
- " \"Bulk total energy does not exist for the resolved exact bulk material. \"\n",
- " \"Run total_energy.ipynb first.\"\n",
- " )\n",
- "\n",
- "print(\n",
- " f\"♻️ Found refined bulk total energy: {bulk_total_energy_property['_id']} -> \"\n",
- " f\"{bulk_total_energy_property['data']['value']}\"\n",
- ")\n",
- "display_JSON(bulk_total_energy_property, level=2)\n"
+ "print(f\"✅ Saved slab material: {saved_slab_response['_id']}\")\n",
+ "saved_slab = Material.create(saved_slab_response)"
]
},
{
@@ -357,8 +331,8 @@
"id": "24",
"metadata": {},
"source": [
- "## 5. Configure the Surface Energy workflow\n",
- "### 5.1. Select application\n"
+ "## 4. Configure the Surface Energy workflow\n",
+ "### 4.1. Select application"
]
},
{
@@ -381,7 +355,7 @@
"id": "26",
"metadata": {},
"source": [
- "### 5.2. Load workflow from Standata and preview it\n"
+ "### 4.2. Configure workflow and preview it"
]
},
{
@@ -391,33 +365,11 @@
"metadata": {},
"outputs": [],
"source": [
- "from mat3ra.standata.workflows import WorkflowStandata\n",
- "from mat3ra.wode.context.providers import PointsGridDataProvider\n",
- "from mat3ra.wode.workflows import Workflow\n",
- "from mat3ra.notebooks_utils.core.entity.workflow.api import get_or_create_workflow\n",
+ "from mat3ra.notebooks_utils.workflow import apply_scf_kgrid\n",
"from mat3ra.notebooks_utils.ipython.entity.workflow.visualize import visualize_workflow\n",
"\n",
- "\n",
- "def apply_workflow_kgrids(workflow: Workflow, scf_kgrid=None) -> Workflow:\n",
- " if scf_kgrid is not None:\n",
- " new_context_scf = PointsGridDataProvider(dimensions=scf_kgrid, isEdited=True).get_context_item_data()\n",
- " for subworkflow in workflow.subworkflows:\n",
- " unit_names = [unit.name for unit in subworkflow.units]\n",
- " if \"pw_scf\" not in unit_names:\n",
- " continue\n",
- " unit_to_modify_scf = subworkflow.get_unit_by_name(name=\"pw_scf\")\n",
- " unit_to_modify_scf.add_context(new_context_scf)\n",
- " subworkflow.set_unit(unit_to_modify_scf)\n",
- " break\n",
- " return workflow\n",
- "\n",
- "\n",
- "surface_workflow_config = WorkflowStandata.filter_by_application(app.name).get_by_name_first_match(\n",
- " WORKFLOW_SEARCH_TERM\n",
- ")\n",
- "surface_workflow = Workflow.create(surface_workflow_config)\n",
"surface_workflow.name = MY_WORKFLOW_NAME\n",
- "surface_workflow = apply_workflow_kgrids(surface_workflow, scf_kgrid=SCF_KGRID)\n",
+ "surface_workflow = apply_scf_kgrid(surface_workflow, scf_kgrid=SCF_KGRID, first_only=True)\n",
"\n",
"visualize_workflow(surface_workflow)"
]
@@ -427,7 +379,8 @@
"id": "28",
"metadata": {},
"source": [
- "### 5.3. Save workflow to collection\n"
+ "## 5. Create the compute configuration\n",
+ "### 5.1. Select cluster"
]
},
{
@@ -436,27 +389,6 @@
"id": "29",
"metadata": {},
"outputs": [],
- "source": [
- "saved_surface_workflow_response = get_or_create_workflow(client, surface_workflow, ACCOUNT_ID)\n",
- "saved_surface_workflow = Workflow.create(saved_surface_workflow_response)\n",
- "print(f\"Surface workflow ID: {saved_surface_workflow.id}\")\n"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "30",
- "metadata": {},
- "source": [
- "## 6. Create the compute configuration\n",
- "### 6.1. Select cluster\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "31",
- "metadata": {},
- "outputs": [],
"source": [
"clusters = client.clusters.list()\n",
"print(f\"Available clusters: {[c['hostname'] for c in clusters]}\")\n"
@@ -464,16 +396,16 @@
},
{
"cell_type": "markdown",
- "id": "32",
+ "id": "30",
"metadata": {},
"source": [
- "### 6.2. Create compute configuration\n"
+ "### 5.2. Create compute configuration"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "33",
+ "id": "31",
"metadata": {},
"outputs": [],
"source": [
@@ -490,27 +422,27 @@
},
{
"cell_type": "markdown",
- "id": "34",
+ "id": "32",
"metadata": {},
"source": [
- "## 7. Create the Surface Energy job\n",
- "### 7.1. Create job with slab and workflow configuration\n"
+ "## 6. Create the Surface Energy job\n",
+ "### 6.1. Create job with slab and workflow configuration"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "35",
+ "id": "33",
"metadata": {},
"outputs": [],
"source": [
- "from mat3ra.notebooks_utils.job import create_job, wait_for_jobs_to_finish_async\n",
+ "from mat3ra.notebooks_utils.api.job import wait_for_jobs_to_finish_async\n",
+ "from mat3ra.notebooks_utils.job import create_job\n",
"from mat3ra.utils.namespace import dict_to_namespace_recursive\n",
"from mat3ra.notebooks_utils.ui import display_JSON\n",
"\n",
"print(f\"Slab material: {saved_slab.id}\")\n",
- "print(f\"Resolved exact bulk material: {bulk_material.id}\")\n",
- "print(f\"Surface workflow: {saved_surface_workflow.id}\")\n",
+ "print(f\"Resolved bulk material: {bulk_material.id}\")\n",
"print(f\"Project: {project_id}\")\n",
"\n",
"surface_job_name = f\"{MY_WORKFLOW_NAME} {saved_slab.formula} {timestamp}\"\n",
@@ -532,16 +464,16 @@
},
{
"cell_type": "markdown",
- "id": "36",
+ "id": "34",
"metadata": {},
"source": [
- "### 7.2. Submit the Surface Energy job and monitor the status\n"
+ "### 6.2. Submit the Surface Energy job and monitor the status"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "37",
+ "id": "35",
"metadata": {},
"outputs": [],
"source": [
@@ -552,7 +484,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "38",
+ "id": "36",
"metadata": {},
"outputs": [],
"source": [
@@ -561,17 +493,17 @@
},
{
"cell_type": "markdown",
- "id": "39",
+ "id": "37",
"metadata": {},
"source": [
- "## 8. Retrieve results\n",
- "### 8.1. Retrieve and visualize Surface Energy results\n"
+ "## 7. Retrieve results\n",
+ "### 7.1. Retrieve and visualize Surface Energy results"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "40",
+ "id": "38",
"metadata": {},
"outputs": [],
"source": [
@@ -583,21 +515,12 @@
" surface_job_id, property_name=PropertyName.scalar.total_energy.value\n",
")\n",
"\n",
- "if surface_energy_data:\n",
- " visualize_properties(surface_energy_data, title=\"Surface Energy\")\n",
- "else:\n",
- " print(\"No 'surface_energy' property was returned for the job.\")\n",
- "\n",
- "if slab_total_energy_data:\n",
- " visualize_properties(slab_total_energy_data, title=\"Slab Total Energy\")\n",
- "else:\n",
- " print(\"No slab total energy property was returned for the job.\")\n",
+ "visualize_properties(surface_energy_data, title=\"Surface Energy\")\n",
"\n",
- "print(f\"Resolved bulk query from slab metadata: {bulk_query}\")\n",
- "print(f\"Exact bulk material used by the workflow: {bulk_material_response['_id']}\")\n",
- "print(f\"Exact bulk exabyteId: {bulk_material_response['exabyteId']}\")\n",
- "print(f\"Refined bulk total energy used by the workflow: {bulk_total_energy_property['data']['value']}\")\n",
- "print(f\"Saved slab material used by the workflow: {saved_slab_response['_id']}\")"
+ "print(f\"Slab: {saved_slab.name} ({saved_slab.id}), \"\n",
+ " f\"total energy: {slab_total_energy_data[0]['data']['value']} eV\")\n",
+ "print(f\"Bulk: {bulk_material.name} ({bulk_material.id}), \"\n",
+ " f\"total energy: {bulk_total_energy_property['data']['value']} eV\")"
]
}
],
diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/analysis.py b/src/py/mat3ra/notebooks_utils/core/entity/material/analysis.py
index d7156261c..78424dd0c 100644
--- a/src/py/mat3ra/notebooks_utils/core/entity/material/analysis.py
+++ b/src/py/mat3ra/notebooks_utils/core/entity/material/analysis.py
@@ -7,6 +7,8 @@
import pymatgen.io.ase
import pymatgen.symmetry.analyzer
from mat3ra.made.material import Material
+from mat3ra.made.tools.analyze.slab import SlabMaterialAnalyzer
+from mat3ra.made.tools.build import MaterialWithBuildMetadata
def is_symmetric(slab: pymatgen.core.structure.Structure) -> bool:
@@ -107,49 +109,21 @@ def get_slab_area(a_vector: np.ndarray, b_vector: np.ndarray) -> float:
return np.linalg.norm(crossprod)
-def get_bulk_material(api_client: Any, slab_material: Material, owner_id: str):
- slab_dict = slab_material.to_dict()
- metadata = slab_dict.get("metadata") or {}
- bulk_crystal = None
-
- if metadata.get("bulkId") is not None:
- bulk_query = {"_id": metadata["bulkId"]}
- else:
- for build_step in reversed(metadata.get("build") or []):
- try:
- bulk_crystal = build_step["configuration"]["stack_components"][0]["crystal"]
- break
- except (KeyError, IndexError, TypeError):
- continue
-
- if bulk_crystal is None:
- raise ValueError(
- "No metadata.build[*].configuration.stack_components[0].crystal entry was found on the slab."
- )
-
- if bulk_crystal.get("_id") is not None:
- bulk_query = {"_id": bulk_crystal["_id"]}
- elif bulk_crystal.get("scaledHash") is not None:
- bulk_query = {"scaledHash": bulk_crystal["scaledHash"]}
- elif bulk_crystal.get("hash") is not None:
- bulk_query = {"hash": bulk_crystal["hash"]}
- else:
- try:
- bulk_query = {"hash": Material.create(bulk_crystal).hash}
- except Exception as exc:
- raise ValueError("Could not resolve a bulk query from the slab metadata.") from exc
-
- matches = api_client.materials.list(bulk_query)
- bulk_material_response = next(
- (item for item in matches if item.get("owner", {}).get("_id") == owner_id),
- None,
- ) or (matches[0] if matches else None)
-
- if bulk_material_response is None:
- raise ValueError(
- "The bulk material resolved from slab metadata is not present on the platform. "
- "Run the Total Energy notebook for that bulk material first, then rerun this notebook."
- )
-
- print(f"Found exact bulk material: {bulk_material_response['_id']}")
- return bulk_query, bulk_material_response, Material.create(bulk_material_response)
+def get_slab_bulk_crystal(slab_material: Material) -> dict:
+ """Gets the bulk crystal a slab was built from, as recorded in its build metadata."""
+ slab_with_metadata = MaterialWithBuildMetadata.create(slab_material.to_dict())
+ crystal = SlabMaterialAnalyzer(material=slab_with_metadata).build_configuration.atomic_layers.crystal
+ if crystal is None:
+ raise ValueError("No bulk crystal for slab in build metadata.")
+ return crystal if isinstance(crystal, dict) else crystal.to_dict()
+
+
+def resolve_bulk_query_from_crystal(bulk_crystal: dict) -> dict:
+ """Builds a materials.list query that resolves a bulk crystal to a platform material."""
+ for key in ("scaledHash", "hash", "_id"):
+ if bulk_crystal.get(key) is not None:
+ return {key: bulk_crystal[key]}
+ try:
+ return {"hash": Material.create(bulk_crystal).hash}
+ except Exception as exc:
+ raise ValueError("Could not resolve a bulk query from crystal metadata.") from exc
diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py
index a82127b1b..3051b42ad 100644
--- a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py
+++ b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py
@@ -1,4 +1,7 @@
from mat3ra.api_client import APIClient
+from mat3ra.made.material import Material
+
+from .analysis import get_slab_bulk_crystal, resolve_bulk_query_from_crystal
def get_or_create_material(api_client: APIClient, material, owner_id: str) -> dict:
@@ -21,3 +24,51 @@ def get_or_create_material(api_client: APIClient, material, owner_id: str) -> di
created = api_client.materials.create(material.to_dict(), owner_id=owner_id)
print(f"✅ Material created: {created['_id']}")
return created
+
+
+def get_bulk_material(api_client: APIClient, slab_material: Material, owner_id: str) -> Material:
+ """
+ Resolves the platform bulk material a slab was built from, owned by the given account.
+
+ Args:
+ api_client (APIClient): API client instance.
+ slab_material (Material): The slab whose bulk material should be resolved.
+ owner_id (str): Account ID the resolved bulk material must belong to.
+
+ Returns:
+ Material: The resolved bulk material.
+ """
+ metadata = slab_material.to_dict().get("metadata") or {}
+ bulk_query = (
+ {"_id": metadata["bulkId"]}
+ if metadata.get("bulkId") is not None
+ else resolve_bulk_query_from_crystal(get_slab_bulk_crystal(slab_material))
+ )
+ return _require_material_for_owner(api_client, bulk_query, owner_id)
+
+
+def get_bulk_material_by_crystal(api_client: APIClient, bulk_crystal: Material, owner_id: str) -> Material:
+ """
+ Resolves the platform bulk material matching a given bulk crystal, owned by the given account.
+
+ Args:
+ api_client (APIClient): API client instance.
+ bulk_crystal (Material): The bulk crystal to resolve on the platform.
+ owner_id (str): Account ID the resolved bulk material must belong to.
+
+ Returns:
+ Material: The resolved bulk material.
+ """
+ bulk_query = resolve_bulk_query_from_crystal(bulk_crystal.to_dict())
+ return _require_material_for_owner(api_client, bulk_query, owner_id)
+
+
+def _require_material_for_owner(api_client: APIClient, query: dict, owner_id: str) -> Material:
+ matches = api_client.materials.list(query)
+ material_response = next((item for item in matches if item.get("owner", {}).get("_id") == owner_id), None)
+ if material_response is None:
+ raise ValueError(
+ "The bulk material resolved from metadata is not present on the platform for this account. "
+ "Run the Total Energy notebook for that bulk material first, then rerun this notebook."
+ )
+ return Material.create(material_response)
diff --git a/src/py/mat3ra/notebooks_utils/core/entity/property/api.py b/src/py/mat3ra/notebooks_utils/core/entity/property/api.py
index 090373fc4..e0189e0ac 100644
--- a/src/py/mat3ra/notebooks_utils/core/entity/property/api.py
+++ b/src/py/mat3ra/notebooks_utils/core/entity/property/api.py
@@ -71,6 +71,37 @@ def update_property_holder_value(client: APIClient, property_holder_id: str, val
return client.properties.update(property_holder_id, {"$set": {"data.value": value}})
+def find_total_energy_for_material(client: APIClient, material_id: str) -> Optional[dict]:
+ """
+ Find the best-precision total_energy property for a material. Mirrors the
+ platform's "Resolve Total Energies for Elemental Materials" subworkflow,
+ which queries properties directly by material and selects by precision --
+ no job lookup involved.
+
+ Properties are keyed by the material's `exabyteId`, not its platform `_id`,
+ so the material is fetched first to resolve that field.
+
+ Args:
+ client (APIClient): API client instance.
+ material_id (str): Material _id to look up the total_energy property for.
+
+ Returns:
+ The best-precision total_energy property, or None if none exists.
+ """
+ material = client.materials.get(material_id)
+ exabyte_id = material.get("exabyteId")
+ if not exabyte_id:
+ return None
+ properties = client.properties.list(
+ query={
+ "exabyteId": exabyte_id,
+ "slug": "total_energy",
+ },
+ projection={"sort": {"precision.value": -1}, "limit": 1},
+ )
+ return properties[0] if properties else None
+
+
def get_property_by_subworkflow_and_unit_indicies(
endpoint: PropertiesEndpoints, property_name: str, job: dict, subworkflow_index: int, unit_index: int
) -> dict:
diff --git a/src/py/mat3ra/notebooks_utils/workflow.py b/src/py/mat3ra/notebooks_utils/workflow.py
index 594332866..3e324813a 100644
--- a/src/py/mat3ra/notebooks_utils/workflow.py
+++ b/src/py/mat3ra/notebooks_utils/workflow.py
@@ -2,6 +2,7 @@
from typing import Dict, List, Optional
from mat3ra.wode import Workflow
+from mat3ra.wode.context.providers import PointsGridDataProvider
FORTRAN_NUMBER_PATTERN = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[de][+-]?\d+)?$", re.IGNORECASE)
@@ -59,3 +60,29 @@ def patch_workflow_qe_input(
template.set_content(content)
subworkflow.set_unit(unit)
return workflow
+
+
+def apply_scf_kgrid(
+ workflow: Workflow, scf_kgrid=None, *, unit_name: str = "pw_scf", first_only: bool = False
+) -> Workflow:
+ """
+ Attaches an edited SCF k-grid context to units named `unit_name`.
+
+ Args:
+ workflow: Workflow with subworkflows.
+ scf_kgrid: K-grid dimensions, e.g. [4, 4, 1]. If None, the workflow is returned unchanged.
+ unit_name: Name of the unit to attach the k-grid context to.
+ first_only: If True, only patch the first matching subworkflow.
+ """
+ if scf_kgrid is None:
+ return workflow
+ context = PointsGridDataProvider(dimensions=scf_kgrid, isEdited=True).get_context_item_data()
+ for subworkflow in workflow.subworkflows:
+ if unit_name not in [unit.name for unit in subworkflow.units]:
+ continue
+ unit = subworkflow.get_unit_by_name(name=unit_name)
+ unit.add_context(context)
+ subworkflow.set_unit(unit)
+ if first_only:
+ break
+ return workflow
diff --git a/tests/py/unit/core/entity/test_material_analysis.py b/tests/py/unit/core/entity/test_material_analysis.py
new file mode 100644
index 000000000..28e433114
--- /dev/null
+++ b/tests/py/unit/core/entity/test_material_analysis.py
@@ -0,0 +1,28 @@
+"""Unit tests for bulk-crystal query resolution."""
+
+import pytest
+from mat3ra.notebooks_utils.core.entity.material.analysis import resolve_bulk_query_from_crystal
+from mat3ra.standata.materials import Materials
+
+SILICON = Materials.get_by_name_first_match("Silicon")
+
+
+@pytest.mark.parametrize(
+ "extra_keys,expected",
+ [
+ (
+ {"scaledHash": "scaled-hash-value", "hash": "hash-value", "_id": "material-id"},
+ {"scaledHash": "scaled-hash-value"},
+ ),
+ ({"hash": "hash-value", "_id": "material-id"}, {"hash": "hash-value"}),
+ ({"_id": "material-id"}, {"_id": "material-id"}),
+ ],
+)
+def test_resolve_bulk_query_prefers_scaled_hash_then_hash_then_id(extra_keys, expected):
+ assert resolve_bulk_query_from_crystal({**SILICON, **extra_keys}) == expected
+
+
+def test_resolve_bulk_query_computes_hash_when_none_present():
+ query = resolve_bulk_query_from_crystal(SILICON)
+ assert set(query) == {"hash"}
+ assert query["hash"]
diff --git a/tests/py/unit/test_workflow_utils.py b/tests/py/unit/test_workflow_utils.py
index 69aa2f5b1..ad4323be7 100644
--- a/tests/py/unit/test_workflow_utils.py
+++ b/tests/py/unit/test_workflow_utils.py
@@ -1,10 +1,12 @@
import pytest
-from mat3ra.notebooks_utils.workflow import patch_workflow_qe_input
+from mat3ra.notebooks_utils.workflow import apply_scf_kgrid, patch_workflow_qe_input
from mat3ra.standata.workflows import WorkflowStandata
from mat3ra.wode.workflows import Workflow
FIXED_CELL_RELAXATION = "fixed_cell_relaxation.json"
RELAX_UNIT_NAMES = ["pw_relax"]
+SURFACE_ENERGY_WORKFLOW = "surface_energy.json"
+SCF_KGRID = [4, 4, 1]
def _relax_workflow():
@@ -40,3 +42,20 @@ def test_patch_workflow_qe_input(parameters, present, absent, error):
assert text in content
for text in absent:
assert text not in content
+
+
+def _surface_workflow():
+ config = WorkflowStandata.filter_by_application("espresso").get_by_name_first_match(SURFACE_ENERGY_WORKFLOW)
+ return Workflow.create(config)
+
+
+def test_apply_scf_kgrid_updates_pw_scf_context():
+ workflow = _surface_workflow()
+ apply_scf_kgrid(workflow, scf_kgrid=SCF_KGRID, first_only=True)
+ unit = next(
+ subworkflow.get_unit_by_name(name="pw_scf")
+ for subworkflow in workflow.subworkflows
+ if subworkflow.get_unit_by_name(name="pw_scf")
+ )
+ kgrid_item = next(item for item in unit.context if item.get("name") == "kgrid")
+ assert kgrid_item["data"]["dimensions"] == SCF_KGRID