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