diff --git a/other/materials_designer/workflows/Introduction.ipynb b/other/materials_designer/workflows/Introduction.ipynb
index d910e07c..6fa6699d 100644
--- a/other/materials_designer/workflows/Introduction.ipynb
+++ b/other/materials_designer/workflows/Introduction.ipynb
@@ -16,8 +16,7 @@
"\n",
"### 1.2. Parameter Convergence\n",
"#### [1.2.1. K-point convergence.](total_energy_convergence.ipynb)\n",
- "#### 1.2.2. Plane-wave cutoff convergence. *(to be added)*\n",
- "#### 1.2.3. Smearing convergence. *(to be added)*\n",
+ "#### [1.2.2. Any parameter convergence (Plane-wave cutoff example).](convergence.ipynb)\n",
"\n",
"## 2. Basics\n",
"\n",
@@ -88,7 +87,7 @@
"## 8. Electronics\n",
"\n",
"### 8.1. Valence Band Offset\n",
- "#### 8.1.1. Valence band offset at an interface. *(to be added)*\n",
+ "#### [8.1.1. Valence band offset at an interface.](valence_band_offset.ipynb)\n",
"\n",
"### 8.2. Dielectric Tensor\n",
"#### 8.2.1. Dielectric tensor calculation. *(to be added)*\n",
@@ -99,6 +98,14 @@
"### 9.1. Python / Shell\n",
"#### 9.1.1. Custom Python and Shell workflows. *(to be added)*\n"
]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1",
+ "metadata": {},
+ "outputs": [],
+ "source": []
}
],
"metadata": {
diff --git a/other/materials_designer/workflows/convergence.ipynb b/other/materials_designer/workflows/convergence.ipynb
new file mode 100644
index 00000000..6b2c6a56
--- /dev/null
+++ b/other/materials_designer/workflows/convergence.ipynb
@@ -0,0 +1,465 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Workflow Parameter Convergence\n",
+ "\n",
+ "Run a convergence study for **any parameter** in a single-unit workflow and return the converged value for reuse in another notebook.\n",
+ "\n",
+ "The parameter is injected into the unit's input template via regex substitution, so any plaintext assignment in the input (e.g. `degauss = 0.005`) can be turned into a convergence variable.\n",
+ "\n",
+ "
Usage
\n",
+ "\n",
+ "1. Set material, workflow, convergence, and compute parameters in cell 1.2. below (or use the default values).\n",
+ "1. Click \"Run\" > \"Run All\" to run all cells.\n",
+ "1. Wait for the job to complete.\n",
+ "1. Scroll down to review the convergence series and the converged parameter value.\n",
+ "\n",
+ "## Summary\n",
+ "\n",
+ "1. Set up the environment and parameters: install packages (JupyterLite only) and configure parameters for the material, workflow, convergence loop, compute resources, and job.\n",
+ "1. Authenticate and initialize API client: authenticate via browser, initialize the client, then select account and project.\n",
+ "1. Create material: materials are read from the `../uploads` folder. If the material is not found by name, Standata is used as a fallback. The material is then saved to the platform.\n",
+ "1. Build the convergence workflow: load the workflow from Standata, inject the convergence parameter via regex into the unit's input template, and add the convergence loop.\n",
+ "1. Configure compute: get the list of clusters and create compute configuration with the selected cluster, queue, and number of processors.\n",
+ "1. Create the job with material and workflow configuration: assemble the job from the saved material, convergence-enabled workflow, project, and compute configuration.\n",
+ "1. Submit the job and monitor the status: submit the job and wait for completion.\n",
+ "1. Retrieve results: reconstruct the convergence series from `job.scopeTrack` and report the converged parameter value."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 1. Set up the environment and parameters\n",
+ "### 1.1. Install packages (JupyterLite)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import sys\n",
+ "\n",
+ "if sys.platform == \"emscripten\":\n",
+ " import micropip\n",
+ "\n",
+ " await micropip.install(\"mat3ra-api-examples\", deps=False)\n",
+ " await micropip.install(\"mat3ra-utils\")\n",
+ " from mat3ra.utils.jupyterlite.packages import install_packages\n",
+ "\n",
+ " await install_packages(\"api_examples\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### 1.2. Set parameters and configurations for the convergence job"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from datetime import datetime\n",
+ "from mat3ra.ide.compute import QueueName\n",
+ "\n",
+ "# 1. Organization / account selection\n",
+ "ORGANIZATION_NAME = None\n",
+ "\n",
+ "# 2. Material parameters\n",
+ "FOLDER = \"../uploads\"\n",
+ "MATERIAL_NAME = \"Silicon\"\n",
+ "\n",
+ "# 3. Workflow parameters\n",
+ "APPLICATION_NAME = \"espresso\"\n",
+ "WORKFLOW_SEARCH_TERM = \"total_energy.json\"\n",
+ "\n",
+ "# 4. Convergence parameter\n",
+ "# Common parameters to converge:\n",
+ "# - ecutwfc: wavefunction cutoff (most important) — start ~20 Ry, increment 5-10 Ry\n",
+ "# - ecutrho: charge density cutoff — start 4×ecutwfc, increment 20-40 Ry\n",
+ "# - degauss: smearing width (metals) — start 0.001 Ry, increment 0.002 Ry\n",
+ "# Advanced (workflow-specific):\n",
+ "# - ecutfock: Fock exchange cutoff (HSE) — start ~80 Ry, increment 20 Ry\n",
+ "# - tr2_ph: phonon self-consistency threshold — start 1e-12, decrease by 10×\n",
+ "# - num_band: number of bands (GW) — start 8, increment 2-4\n",
+ "# - ecut_corr: correlation cutoff (GW) — start 5 Ry, increment 1 Ry\n",
+ "\n",
+ "PARAMETER_NAME = \"ecutwfc\" # adjust to desired parameter in the input template\n",
+ "PARAMETER_INITIAL = 20\n",
+ "PARAMETER_INCREMENT = 5\n",
+ "\n",
+ "# 5. Convergence result\n",
+ "# Common results to monitor:\n",
+ "# - total_energy: standard baseline\n",
+ "# - total_force: for relaxations/phonons\n",
+ "# - fermi_energy: for metals, work function calculations\n",
+ "# - pressure: for equation of state, elastic constants\n",
+ "RESULT_NAME = \"total_energy\" # adjust to desired result in the output template\n",
+ "RESULT_INITIAL = 0\n",
+ "TOLERANCE = 1e-4 # for comparison\n",
+ "\n",
+ "# 6. Compute parameters\n",
+ "CLUSTER_NAME = None\n",
+ "QUEUE_NAME = QueueName.D\n",
+ "PPN = 1\n",
+ "\n",
+ "# 7. Job naming\n",
+ "RUN_LABEL = datetime.now().strftime(\"%Y-%m-%d %H:%M\")\n",
+ "POLL_INTERVAL = 30"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 2. Authenticate and initialize API client\n",
+ "### 2.1. Authenticate\n",
+ "Authenticate in the browser and store the credentials in the current environment."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from utils.auth import authenticate\n",
+ "\n",
+ "await authenticate()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### 2.2. Initialize API Client"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.api_client import APIClient\n",
+ "\n",
+ "client = APIClient.authenticate()\n",
+ "client"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### 2.3. Select account"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "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}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### 2.4. Select project"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "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})\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 3. Load and save the material\n",
+ "### 3.1. Load material from local uploads or Standata"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.made.material import Material\n",
+ "from mat3ra.standata.materials import Materials\n",
+ "from utils.jupyterlite import load_material_from_folder\n",
+ "from utils.visualize import visualize_materials as visualize\n",
+ "\n",
+ "material = load_material_from_folder(FOLDER, MATERIAL_NAME) or Material.create(\n",
+ " Materials.get_by_name_first_match(MATERIAL_NAME)\n",
+ ")\n",
+ "\n",
+ "visualize(material)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### 3.2. Save material to the platform"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from utils.api import get_or_create_material\n",
+ "\n",
+ "saved_material_response = get_or_create_material(client, material, ACCOUNT_ID)\n",
+ "saved_material = Material.create(saved_material_response)\n",
+ "print(f\"Saved material ID: {saved_material.id}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 4. Build the convergence workflow\n",
+ "### 4.1. Load the workflow"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.ade.application import Application\n",
+ "from mat3ra.standata.applications import ApplicationStandata\n",
+ "from mat3ra.standata.workflows import WorkflowStandata\n",
+ "from mat3ra.wode.workflows import Workflow\n",
+ "from utils.visualize import visualize_workflow\n",
+ "\n",
+ "app_config = ApplicationStandata.get_by_name_first_match(APPLICATION_NAME)\n",
+ "app = Application(**app_config)\n",
+ "workflow_config = WorkflowStandata.filter_by_application(app.name).get_by_name_first_match(WORKFLOW_SEARCH_TERM)\n",
+ "workflow = Workflow.create(workflow_config)\n",
+ "workflow.name = f\"Convergence - {RUN_LABEL}\"\n",
+ "\n",
+ "visualize_workflow(workflow)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### 4.2. Inject convergence parameter and add convergence loop\n",
+ "\n",
+ "The regex `PARAM_NAME = ` is auto-generated from `PARAM_NAME`, locating the existing assignment in the input template and replacing it with a runtime scope variable. The convergence loop then varies that variable each iteration."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "convergence_subworkflow = workflow.subworkflows[0]\n",
+ "convergence_subworkflow.add_template_parameter_convergence(\n",
+ " parameter_name=PARAMETER_NAME,\n",
+ " parameter_initial=PARAMETER_INITIAL,\n",
+ " parameter_increment=PARAMETER_INCREMENT,\n",
+ " result_name=RESULT_NAME,\n",
+ " result_initial=RESULT_INITIAL,\n",
+ " tolerance=TOLERANCE,\n",
+ ")\n",
+ "\n",
+ "print(f\"Convergence parameter: {convergence_subworkflow.convergence_parameter}\")\n",
+ "print(f\"Convergence result: {convergence_subworkflow.convergence_result}\")\n",
+ "visualize_workflow(workflow)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 5. Configure compute\n",
+ "### 5.1. Configure compute"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.ide.compute import Compute\n",
+ "\n",
+ "clusters = client.clusters.list()\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}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 6. Create the job with material and workflow configuration\n",
+ "### 6.1. Create the job with the embedded convergence workflow"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from utils.api import create_job\n",
+ "from utils.generic import dict_to_namespace\n",
+ "from utils.visualize import display_JSON\n",
+ "\n",
+ "job_name = f\"{workflow.name} {saved_material.formula}\"\n",
+ "job_response = create_job(\n",
+ " api_client=client,\n",
+ " materials=[saved_material],\n",
+ " workflow=workflow,\n",
+ " project_id=PROJECT_ID,\n",
+ " owner_id=ACCOUNT_ID,\n",
+ " prefix=job_name,\n",
+ " compute=compute.to_dict(),\n",
+ ")\n",
+ "\n",
+ "job = dict_to_namespace(job_response)\n",
+ "job_id = job._id\n",
+ "print(f\"Job created: {job_id}\")\n",
+ "display_JSON(job_response)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 7. Submit the job and monitor the status"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "client.jobs.submit(job_id)\n",
+ "print(f\"Submitted job: {job_id}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from utils.api import wait_for_jobs_to_finish_async\n",
+ "await wait_for_jobs_to_finish_async(client.jobs, [job_id], poll_interval=POLL_INTERVAL)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 8. Retrieve results\n",
+ "### 8.1. Fetch the finished job and reconstruct the convergence series"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from utils.api import get_convergence_series\n",
+ "from utils.plot import plot_series\n",
+ "\n",
+ "series = get_convergence_series(client, job_id)\n",
+ "\n",
+ "print(\"Convergence series:\")\n",
+ "for item in series:\n",
+ " print(item)\n",
+ "\n",
+ "plot_series(\n",
+ " series=series,\n",
+ " x_key=\"parameter\",\n",
+ " y_key=\"y\",\n",
+ " xlabel=f\"{PARAMETER_NAME}\",\n",
+ " ylabel=f\"{RESULT_NAME}\",\n",
+ " title=f\"Convergence: {PARAMETER_NAME}\",\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### 8.2. Report the converged parameter value\n",
+ "Use `converged_value` in another notebook when setting the parameter for the production calculation."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "converged_value = series[-1][\"parameter\"]\n",
+ "print(f\"Convergence parameter: {PARAMETER_NAME}\")\n",
+ "print(f\"Converged value: {converged_value} \")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.8.6"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/other/materials_designer/workflows/total_energy_convergence.ipynb b/other/materials_designer/workflows/total_energy_convergence.ipynb
index 99432e97..0473d1b7 100644
--- a/other/materials_designer/workflows/total_energy_convergence.ipynb
+++ b/other/materials_designer/workflows/total_energy_convergence.ipynb
@@ -316,7 +316,7 @@
")\n",
"\n",
"print(f\"K-grid convergence type: {KGRID_CONVERGENCE_TYPE}\")\n",
- "print(f\"Convergence parameter: {convergence_subworkflow.convergence_param}\")\n",
+ "print(f\"Convergence parameter: {convergence_subworkflow.convergence_parameter}\")\n",
"print(f\"Convergence result: {convergence_subworkflow.convergence_result}\")\n",
"visualize_workflow(workflow)\n"
]
@@ -433,17 +433,14 @@
"metadata": {},
"outputs": [],
"source": [
+ "from utils.api import get_convergence_series\n",
"from utils.plot import plot_series\n",
"\n",
"subworkflow_index = 0\n",
- "finished_job = client.jobs.get(job_id)\n",
- "job_workflow = Workflow.create(finished_job[\"workflow\"])\n",
- "subworkflow = job_workflow.subworkflows[subworkflow_index]\n",
- "scope_track = finished_job.get(\"scopeTrack\")\n",
- "series = subworkflow.convergence_series(scope_track)\n",
- "\n",
- "parameter_name = job_workflow.subworkflows[subworkflow_index].convergence_param\n",
- "parameter_value = series[-1][\"param\"]\n",
+ "series = get_convergence_series(client, job_id, subworkflow_index)\n",
+ "\n",
+ "parameter_name = CONVERGENCE_PARAMETER\n",
+ "parameter_value = series[-1][\"parameter\"]\n",
"if parameter_name == ConvergenceParameterNameEnum.N_k.value:\n",
" converged_kgrid = [parameter_value, parameter_value, parameter_value]\n",
"if parameter_name == ConvergenceParameterNameEnum.N_k_nonuniform.value:\n",
@@ -455,10 +452,9 @@
"for item in series:\n",
" print(item)\n",
"\n",
- "\n",
"plot_series(\n",
" series=series,\n",
- " x_key=\"param\",\n",
+ " x_key=\"parameter\",\n",
" y_key=\"y\",\n",
" xlabel=parameter_name,\n",
" ylabel=\"Total Energy (Ry)\",\n",
diff --git a/other/materials_designer/workflows/valence_band_offset.ipynb b/other/materials_designer/workflows/valence_band_offset.ipynb
new file mode 100644
index 00000000..03dac64e
--- /dev/null
+++ b/other/materials_designer/workflows/valence_band_offset.ipynb
@@ -0,0 +1,712 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# Valence Band Offset (VBO)\n",
+ "\n",
+ "Calculate the valence band offset for a labeled interface using a DFT workflow on the Mat3ra platform.\n",
+ "\n",
+ "The notebook supports two modes controlled by `IS_POLAR` in cell 1.2:\n",
+ "\n",
+ "- `IS_POLAR = False`: use the standard VBO workflow based on extrema extracted from the macroscopically averaged electrostatic potential.\n",
+ "- `IS_POLAR = True`: append a final Python post-process that fits the electrostatic potential in the left and right slab regions, then uses those fitted averages for the polar-interface VBO evaluation.\n",
+ "\n",
+ "When the polar option is enabled, the final results section shows both the scalar VBO value and an additional plot, `Polar VBO Fit`, highlighting the fitted slab regions and linear fits used in the polar correction.\n",
+ "\n",
+ "Usage
\n",
+ "\n",
+ "1. Create and save an interface with labels (for example via `create_interface_with_min_strain_zsl.ipynb`).\n",
+ "1. Set the interface and calculation parameters in cells 1.2 and 1.3 below, including `IS_POLAR` if the interface is polar.\n",
+ "1. Click \"Run\" > \"Run All\" to run all cells.\n",
+ "1. Wait for the job to complete.\n",
+ "1. Scroll down to view the VBO result.\n",
+ "\n",
+ "## Summary\n",
+ "\n",
+ "1. Set up the environment and parameters: install packages (JupyterLite only) and configure parameters for the interface, workflow, compute resources, and job.\n",
+ "1. Authenticate and initialize API client: authenticate via browser, initialize the client, then select account and project.\n",
+ "1. Create materials: load an interface from the `../uploads` folder, split it into interface/left/right parts using interface labels, strip labels required by Quantum ESPRESSO, and save all three materials to the platform tagged with the interface name.\n",
+ "1. Configure workflow: select application, load the VBO workflow from Standata, assign materials to subworkflows by role, set model and computational parameters, and preview the workflow.\n",
+ "1. Configure compute: get list of clusters and create compute configuration with selected cluster, queue, and number of processors.\n",
+ "1. Create the job with materials and workflow configuration: assemble the job from materials, workflow, project, and compute configuration.\n",
+ "1. Submit the job and monitor the status: submit the job and wait for completion.\n",
+ "1. Retrieve results: get and display the valence band offset, average ESP profiles, and the polar fit plot when `IS_POLAR = True`.\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": [
+ "import sys\n",
+ "\n",
+ "if sys.platform == \"emscripten\":\n",
+ " import micropip\n",
+ "\n",
+ " await micropip.install(\"mat3ra-api-examples\", deps=False)\n",
+ " await micropip.install(\"mat3ra-utils\")\n",
+ " from mat3ra.utils.jupyterlite.packages import install_packages\n",
+ "\n",
+ " await install_packages(\"api_examples\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3",
+ "metadata": {},
+ "source": [
+ "### 1.2. Set parameters\n",
+ "Provide the INTERFACE_NAME to match the name of the structure in \"uploads\" folder for search. Left and right parts will be extracted based on substrate/film labels present in the generated interface."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from datetime import datetime\n",
+ "from mat3ra.ide.compute import QueueName\n",
+ "from mat3ra.made.tools.convert.interface_parts_enum import InterfacePartsEnum\n",
+ "\n",
+ "# 2. Auth and organization parameters\n",
+ "ORGANIZATION_NAME = None\n",
+ "\n",
+ "# 3. Material parameters\n",
+ "FOLDER = \"../uploads\"\n",
+ "INTERFACE_NAME = \"Interface\" # To search for in \"uploads\" folder\n",
+ "LEFT_SIDE_PART = InterfacePartsEnum.SUBSTRATE\n",
+ "RIGHT_SIDE_PART = InterfacePartsEnum.FILM\n",
+ "INTERFACE_SYSTEM_NAME = None # Used as tag to group the materials. Defaults to shorthand from the loaded interface name\n",
+ "\n",
+ "IS_POLAR = False # Whether the interface is polar, to adjust the VBO calculation method accordingly.\n",
+ "\n",
+ "# 4. Workflow parameters\n",
+ "APPLICATION_NAME = \"espresso\"\n",
+ "WORKFLOW_SEARCH_TERM = \"valence_band_offset.json\"\n",
+ "MY_WORKFLOW_NAME = \"VBO\"\n",
+ "\n",
+ "# 5. Compute parameters\n",
+ "CLUSTER_NAME = None\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 = 60 # seconds"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5",
+ "metadata": {},
+ "source": [
+ "### 1.3. Set specific VBO parameters\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Method parameters\n",
+ "PSEUDOPOTENTIAL_TYPE = \"us\" # \"us\" (ultrasoft), \"nc\" (norm-conserving), \"paw\"\n",
+ "FUNCTIONAL = \"pbe\" # for gga: \"pbe\", \"pbesol\"; for lda: \"pz\"\n",
+ "MODEL_SUBTYPE = \"gga\"\n",
+ "\n",
+ "# K-grid and k-path\n",
+ "SCF_KGRID = None # e.g. [8, 8, 1]\n",
+ "KPATH = None # e.g. [{\"point\": \"G\", \"steps\": 20}, {\"point\": \"M\", \"steps\": 20}]\n",
+ "\n",
+ "# SCF diagonalization and mixing\n",
+ "DIAGONALIZATION = \"david\" # \"david\" or \"cg\"\n",
+ "MIXING_BETA = 0.3\n",
+ "\n",
+ "# Energy cutoffs\n",
+ "ECUTWFC = 40\n",
+ "ECUTRHO = 200\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7",
+ "metadata": {},
+ "source": [
+ "## 2. Authenticate and initialize API client\n",
+ "### 2.1. Authenticate\n",
+ "Authenticate in the browser and have credentials stored in environment variable \"OIDC_ACCESS_TOKEN\".\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from 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\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. Create materials\n",
+ "### 3.1. Load interface from local folder\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "17",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from utils.jupyterlite import load_material_from_folder\n",
+ "from utils.visualize import visualize_materials as visualize\n",
+ "\n",
+ "interface = load_material_from_folder(FOLDER, INTERFACE_NAME)\n",
+ "\n",
+ "visualize(interface, repetitions=[1, 1, 1])\n",
+ "visualize(interface, repetitions=[1, 1, 1], rotation=\"-90x\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "18",
+ "metadata": {},
+ "source": [
+ "### 3.2. Create materials from interface parts\n",
+ "Slabs are isolated based on labels, then labels removed as they are not compatible with Quantum ESPRESSO. The three materials (interface, left slab, right slab) are named and visualized."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "19",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import re\n",
+ "from mat3ra.made.tools.modify import interface_get_part\n",
+ "\n",
+ "interface_shorthand = re.match(r\"^(.+?)\\s\", interface.name).group(1) if re.match(r\"^(.+?)\\s\",\n",
+ " interface.name) else INTERFACE_NAME\n",
+ "interface_system_name = INTERFACE_SYSTEM_NAME or interface_shorthand\n",
+ "left_material = interface_get_part(interface, part=LEFT_SIDE_PART)\n",
+ "right_material = interface_get_part(interface, part=RIGHT_SIDE_PART)\n",
+ "interface_material = interface.clone()\n",
+ "\n",
+ "left_material.basis.set_labels_from_list([])\n",
+ "right_material.basis.set_labels_from_list([])\n",
+ "interface_material.basis.set_labels_from_list([])\n",
+ "\n",
+ "interface_material.name = f\"{interface_system_name} Interface\"\n",
+ "left_material.name = f\"{interface_system_name} Left\"\n",
+ "right_material.name = f\"{interface_system_name} Right\"\n",
+ "\n",
+ "materials_by_role = {\"interface\": interface_material, \"substrate\": left_material, \"film\": right_material}\n",
+ "for role, material in materials_by_role.items():\n",
+ " print(f\" {role}: {material.name}\")\n",
+ "visualize(list(materials_by_role.values()), repetitions=[1, 1, 1], rotation=\"-90x\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "20",
+ "metadata": {},
+ "source": [
+ "### 3.3. Save materials\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "21",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.made.material import Material\n",
+ "\n",
+ "saved_materials = {}\n",
+ "for role, material in materials_by_role.items():\n",
+ " material_config = material.to_dict()\n",
+ " material_config[\"name\"] = material.name\n",
+ " existing_tags = material_config.get(\"tags\") or []\n",
+ " material_config[\"tags\"] = sorted(set([*existing_tags, interface_system_name]))\n",
+ " saved = Material.create(client.materials.create(material_config, owner_id=ACCOUNT_ID))\n",
+ " saved_materials[role] = saved\n",
+ " print(f\" {role}: {saved.name} ({saved.id}) | tags={material_config['tags']}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22",
+ "metadata": {},
+ "source": [
+ "## 4. Configure workflow\n",
+ "### 4.1. Select application\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "23",
+ "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": "24",
+ "metadata": {},
+ "source": [
+ "### 4.2. Load workflow from Standata and preview it\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "25",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.standata.workflows import WorkflowStandata\n",
+ "from mat3ra.wode.workflows import Workflow\n",
+ "from utils.polar_vbo import add_polar_vbo_postprocess\n",
+ "from utils.visualize import visualize_workflow\n",
+ "\n",
+ "workflow_config = WorkflowStandata.filter_by_application(app.name).get_by_name_first_match(WORKFLOW_SEARCH_TERM)\n",
+ "workflow = Workflow.create(workflow_config)\n",
+ "if IS_POLAR:\n",
+ " workflow = add_polar_vbo_postprocess(workflow)\n",
+ "workflow.name = MY_WORKFLOW_NAME\n",
+ "\n",
+ "visualize_workflow(workflow)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "26",
+ "metadata": {},
+ "source": [
+ "### 4.3. Set model and its parameters (physics)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "27",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.mode.model import Model\n",
+ "from mat3ra.standata.model_tree import ModelTreeStandata\n",
+ "\n",
+ "model_config = ModelTreeStandata.get_model_by_parameters(\n",
+ " type=\"dft\", subtype=MODEL_SUBTYPE, functional=FUNCTIONAL\n",
+ ")\n",
+ "model_config[\"method\"] = {\"type\": \"pseudopotential\", \"subtype\": PSEUDOPOTENTIAL_TYPE}\n",
+ "model = Model.create(model_config)\n",
+ "\n",
+ "for subworkflow in workflow.subworkflows:\n",
+ " if subworkflow.application.name == APPLICATION_NAME:\n",
+ " subworkflow.model = model\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "28",
+ "metadata": {},
+ "source": [
+ "### 4.4. Modify method (computational parameters): k-grid, k-path, cutoffs, diagonalization, and mixing\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "29",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.wode.context.providers import (\n",
+ " PlanewaveCutoffsContextProvider,\n",
+ " PointsGridDataProvider,\n",
+ " PointsPathDataProvider,\n",
+ ")\n",
+ "\n",
+ "\n",
+ "def set_pw_electrons_parameters(unit, diagonalization, mixing_beta):\n",
+ " unit.replace_in_input_content(r\"diagonalization\\s*=\\s*'[^']*'\", f\"diagonalization = '{diagonalization}'\")\n",
+ " unit.replace_in_input_content(r\"mixing_beta\\s*=\\s*[-+0-9.eE]+\", f\"mixing_beta = {mixing_beta}\")\n",
+ " for input in unit.input:\n",
+ " if isinstance(input, dict) and \"content\" in input:\n",
+ " input[\"rendered\"] = input[\"content\"]\n",
+ " return unit\n",
+ "\n",
+ "\n",
+ "for subworkflow in workflow.subworkflows:\n",
+ " if subworkflow.application.name != APPLICATION_NAME:\n",
+ " continue\n",
+ "\n",
+ " unit_names = [unit.name for unit in subworkflow.units]\n",
+ "\n",
+ " if SCF_KGRID is not None and \"pw_scf\" in unit_names:\n",
+ " unit = subworkflow.get_unit_by_name(name=\"pw_scf\")\n",
+ " unit.add_context(PointsGridDataProvider(dimensions=SCF_KGRID, isEdited=True).yield_data())\n",
+ " subworkflow.set_unit(unit)\n",
+ "\n",
+ " if KPATH is not None and \"pw_bands\" in unit_names:\n",
+ " unit = subworkflow.get_unit_by_name(name=\"pw_bands\")\n",
+ " unit.add_context(PointsPathDataProvider(path=KPATH, isEdited=True).yield_data())\n",
+ " subworkflow.set_unit(unit)\n",
+ "\n",
+ " cutoffs_context = PlanewaveCutoffsContextProvider(\n",
+ " wavefunction=ECUTWFC, density=ECUTRHO, isEdited=True\n",
+ " ).yield_data()\n",
+ " for unit_name in [\"pw_scf\", \"pw_bands\"]:\n",
+ " if unit_name not in unit_names:\n",
+ " continue\n",
+ " unit = subworkflow.get_unit_by_name(name=unit_name)\n",
+ " unit.add_context(cutoffs_context)\n",
+ " unit = set_pw_electrons_parameters(unit, DIAGONALIZATION, MIXING_BETA)\n",
+ " subworkflow.set_unit(unit)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "30",
+ "metadata": {},
+ "source": [
+ "### 4.5. Preview final workflow\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "31",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "visualize_workflow(workflow)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "32",
+ "metadata": {},
+ "source": [
+ "## 5. Create the compute configuration\n",
+ "### 5.1. Get list of clusters\n"
+ ]
+ },
+ {
+ "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\n"
+ ]
+ },
+ {
+ "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(\n",
+ " cluster=cluster,\n",
+ " queue=QUEUE_NAME,\n",
+ " ppn=PPN\n",
+ ")\n",
+ "print(f\"Using cluster: {compute.cluster.hostname}, queue: {QUEUE_NAME}, ppn: {PPN}\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "36",
+ "metadata": {},
+ "source": [
+ "## 6. Create the job\n",
+ "### 6.1. Create job\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "37",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from utils.api import create_job\n",
+ "from utils.generic import dict_to_namespace\n",
+ "from utils.visualize import display_JSON\n",
+ "\n",
+ "materials = list(saved_materials.values())\n",
+ "\n",
+ "job_name = f\"{MY_WORKFLOW_NAME} {interface_system_name} {timestamp}\"\n",
+ "workflow.name = job_name\n",
+ "\n",
+ "print(f\"Materials: {[m.id for m in materials]}\")\n",
+ "print(f\"Project: {project_id}\")\n",
+ "\n",
+ "job_response = create_job(\n",
+ " api_client=client,\n",
+ " materials=materials,\n",
+ " workflow=workflow,\n",
+ " project_id=project_id,\n",
+ " owner_id=ACCOUNT_ID,\n",
+ " prefix=job_name,\n",
+ " compute=compute.to_dict(),\n",
+ ")\n",
+ "\n",
+ "job = dict_to_namespace(job_response)\n",
+ "job_id = job._id\n",
+ "print(\"✅ Job created successfully!\")\n",
+ "print(f\"Job ID: {job_id}\")\n",
+ "\n",
+ "display_JSON(job_response)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "38",
+ "metadata": {},
+ "source": [
+ "## 7. Submit the job and monitor the status\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "39",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "client.jobs.submit(job_id)\n",
+ "print(f\"✅ Job {job_id} submitted successfully!\")\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "40",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from utils.api import wait_for_jobs_to_finish_async\n",
+ "\n",
+ "await wait_for_jobs_to_finish_async(client.jobs, [job_id], poll_interval=POLL_INTERVAL)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "41",
+ "metadata": {},
+ "source": [
+ "## 8. Retrieve and visualize results\n",
+ "### 8.1. Valence Band Offset and average ESP profiles\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "42",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from mat3ra.prode import PropertyName\n",
+ "from utils.api import attach_signed_url_to_file_property\n",
+ "from utils.visualize import visualize_properties\n",
+ "\n",
+ "job_data = client.jobs.get(job_id)\n",
+ "workflow = job_data[\"workflow\"]\n",
+ "\n",
+ "vbo_value = client.properties.get_for_job(\n",
+ " job_id,\n",
+ " property_name=PropertyName.scalar.valence_band_offset.value,\n",
+ ")[0]\n",
+ "print(f\"Valence Band Offset (VBO) value: {vbo_value['value']:.3f} eV\")\n",
+ "\n",
+ "avg_esp_unit_ids = {}\n",
+ "polar_file_unit_id = None\n",
+ "for subworkflow in workflow[\"subworkflows\"]:\n",
+ " subworkflow_name = subworkflow[\"name\"]\n",
+ " for unit in subworkflow[\"units\"]:\n",
+ " result_names = [result[\"name\"] for result in unit.get(\"results\", [])]\n",
+ " if \"average_potential_profile\" in result_names:\n",
+ " avg_esp_unit_ids[subworkflow_name] = unit[\"flowchartId\"]\n",
+ " if IS_POLAR and \"file_content\" in result_names:\n",
+ " polar_file_unit_id = unit[\"flowchartId\"]\n",
+ "\n",
+ "ordered_names = [\n",
+ " \"BS + Avg ESP (Interface)\",\n",
+ " \"BS + Avg ESP (interface left)\",\n",
+ " \"BS + Avg ESP (interface right)\",\n",
+ "]\n",
+ "\n",
+ "for subworkflow_name in ordered_names:\n",
+ " unit_id = avg_esp_unit_ids[subworkflow_name]\n",
+ " avg_esp_data = client.properties.get_for_job(\n",
+ " job_id,\n",
+ " property_name=\"average_potential_profile\",\n",
+ " unit_id=unit_id,\n",
+ " )[0]\n",
+ " visualize_properties(avg_esp_data, title=subworkflow_name)\n",
+ "\n",
+ "if IS_POLAR and polar_file_unit_id is not None:\n",
+ " polar_file_results = client.properties.get_for_job(\n",
+ " job_id,\n",
+ " property_name=\"file_content\",\n",
+ " unit_id=polar_file_unit_id,\n",
+ " )\n",
+ " if polar_file_results:\n",
+ " polar_file_data = attach_signed_url_to_file_property(client, job_id, polar_file_results[0])\n",
+ " visualize_properties(polar_file_data, title=\"Polar VBO Fit\")\n",
+ "\n"
+ ]
+ },
+ {
+ "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",
+ "version": "3.11.2"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/utils/api.py b/utils/api.py
index 37efaa5e..6e8e324e 100644
--- a/utils/api.py
+++ b/utils/api.py
@@ -200,6 +200,40 @@ def get_properties_for_job(client: APIClient, job_id: str, property_name: Option
return [{**prop, "fermiEnergy": fermi_energy} for prop in properties]
+def attach_signed_url_to_file_property(client: APIClient, job_id: str, file_property: dict) -> dict:
+ """
+ Enrich a file_content property with a signed URL from the job file listing.
+
+ Args:
+ client (APIClient): API client instance.
+ job_id (str): Job ID.
+ file_property (dict): Property record, typically a file_content result.
+
+ Returns:
+ dict: Property dict with signedUrl added when a matching job file is found.
+ """
+ property_with_url = dict(file_property)
+ object_name = property_with_url.get("objectData", {}).get("NAME")
+ basename = property_with_url.get("basename")
+ job_files = client.jobs.list_files(job_id)
+
+ matching_job_file = next(
+ (
+ file
+ for file in job_files
+ if file.get("key") == object_name
+ or file.get("name") == basename
+ or file.get("key", "").endswith(f"/{basename}")
+ or file.get("key", "") == basename
+ ),
+ None,
+ )
+ if matching_job_file:
+ property_with_url["signedUrl"] = matching_job_file.get("signedUrl") or matching_job_file.get("signedURL")
+
+ return property_with_url
+
+
def create_job(
api_client: APIClient,
materials: List[Union[dict, Material]],
@@ -238,7 +272,7 @@ def create_job(
# Strip _id so the server uses the embedded workflow as-is instead of fetching from DB,
# which would discard any unit-level context (kpath, kgrid, cutoffs, etc.).
job_workflow_dict.pop("_id", None)
- is_multimaterial = job_workflow_dict.get("isMultimaterial", False)
+ is_multimaterial = job_workflow_dict.get("isMultiMaterial", False)
config = {
"_project": {"_id": project_id},
@@ -248,7 +282,11 @@ def create_job(
}
if is_multimaterial:
- config["_materials"] = [{"_id": mid} for mid in {md["_id"] for md in material_dicts}]
+ # Some API environments still validate `_material._id` even for
+ # multi-material workflows, so provide the first material as a
+ # compatibility fallback while preserving the full ordered list.
+ config["_material"] = {"_id": material_dicts[0]["_id"]}
+ config["_materials"] = [{"_id": m["_id"]} for m in material_dicts]
else:
config["_material"] = {"_id": material_dicts[0]["_id"]}
@@ -257,6 +295,24 @@ def create_job(
return api_client.jobs.create(config)
+def get_convergence_series(client: APIClient, job_id: str, subworkflow_index: int = 0) -> List[dict]:
+ """
+ Returns the convergence series from a finished convergence job.
+
+ Args:
+ client: API client instance.
+ job_id: ID of the finished convergence job.
+ subworkflow_index: Index of the convergence subworkflow (default 0).
+
+ Returns:
+ List of dicts with keys "x", "parameter", "y".
+ """
+ finished_job = client.jobs.get(job_id)
+ job_workflow = Workflow.create(finished_job["workflow"])
+ subworkflow = job_workflow.subworkflows[subworkflow_index]
+ return subworkflow.convergence_series(finished_job.get("scopeTrack"))
+
+
def submit_jobs(endpoint: JobEndpoints, job_ids: List[str]) -> None:
"""
Submits jobs by IDs.
diff --git a/utils/polar_vbo.py b/utils/polar_vbo.py
new file mode 100644
index 00000000..f6d43940
--- /dev/null
+++ b/utils/polar_vbo.py
@@ -0,0 +1,261 @@
+from copy import deepcopy
+
+from mat3ra.wode.workflows import Workflow
+
+POLAR_POSTPROCESS_SUBWORKFLOW_ID = "df9ddd647d8540ef6fd947fa"
+POLAR_POSTPROCESS_FLOWCHART_ID = "87b5f54edde75c917c0d9389"
+CALCULATE_VBO_SUBWORKFLOW_NAME = "Calculate VBO"
+POLAR_POSTPROCESS_SUBWORKFLOW_NAME = "Python VBO Polar PostProcess"
+
+POLAR_VBO_SCRIPT = """# ------------------------------------------------------------------ #
+# Linear Fit of ESP for Polar Interface VBO Calculation #
+# ------------------------------------------------------------------ #
+#
+# Reference: Choudhary & Garrity, arXiv:2401.02021 (InterMat) #
+#
+# For polar interfaces, ESP shows linear gradient in bulk regions
+# due to internal electric field. We fit each slab region and use
+# the average value of the fit as the ESP reference.
+#
+# VBO Calculation:
+# 1. Fit interface profile over slab 1 region -> Va_interface
+# 2. Fit interface profile over slab 2 region -> Vb_interface
+# 3. Fit bulk left profile over slab 1 region -> Va_bulk_left
+# 4. Fit bulk right profile over slab 2 region -> Vb_bulk_right
+# 5. VBO = (delta V_interface) - (delta V_bulk)
+# where delta V_interface = Vb_interface - Va_interface
+# delta V_bulk = Vb_bulk_right - Va_bulk_left
+#
+# Input:
+# - profile_left, profile_right: ESP profiles for bulk materials
+# - profile_interface: ESP profile for interface structure
+#
+# Output: VBO (Valence Band Offset)
+# ------------------------------------------------------------------ #
+import json
+from types import SimpleNamespace
+
+import ase.io
+import matplotlib
+import numpy as np
+from mat3ra.made.material import Material
+from mat3ra.made.tools.convert import from_ase
+from scipy.stats import linregress
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+
+pw_scf_output = "./pw_scf.out"
+pw_scf_output_1 = "./pw_scf.out-1"
+pw_scf_output_2 = "./pw_scf.out-2"
+
+atoms = ase.io.read(pw_scf_output, format="espresso-out")
+atoms_1 = ase.io.read(pw_scf_output_1, format="espresso-out")
+atoms_2 = ase.io.read(pw_scf_output_2, format="espresso-out")
+
+material = Material.create(from_ase(atoms))
+material_1 = Material.create(from_ase(atoms_1))
+material_2 = Material.create(from_ase(atoms_2))
+
+material.to_cartesian()
+material_1.to_cartesian()
+material_2.to_cartesian()
+
+coords = material.basis.coordinates.values
+elements = material.basis.elements.values
+z_elements = sorted(zip([c[2] for c in coords], elements))
+n_left = len(material_1.basis.elements.values)
+
+z_max_1 = z_elements[n_left - 1][0]
+z_min_2 = z_elements[n_left][0]
+z_min_1 = z_elements[0][0]
+z_max_2 = z_elements[-1][0]
+
+print(f"Detected Slab 1 (left) boundaries: z = {z_min_1:.3f} to {z_max_1:.3f} A")
+print(f"Detected Slab 2 (right) boundaries: z = {z_min_2:.3f} to {z_max_2:.3f} A")
+
+checkpoint_file = "./.mat3ra/checkpoint"
+with open(checkpoint_file, "r") as f:
+ checkpoint_data = json.load(f)
+ profile_interface = SimpleNamespace(
+ **checkpoint_data["scope"]["local"]["average-electrostatic-potential"]["average_potential_profile"]
+ )
+ profile_left = SimpleNamespace(
+ **checkpoint_data["scope"]["local"]["average-electrostatic-potential-left"]["average_potential_profile"]
+ )
+ profile_right = SimpleNamespace(
+ **checkpoint_data["scope"]["local"]["average-electrostatic-potential-right"]["average_potential_profile"]
+ )
+
+X = np.array(profile_interface.xDataArray)
+Y = np.array(profile_interface.yDataSeries[1])
+X_left = np.array(profile_left.xDataArray)
+Y_left = np.array(profile_left.yDataSeries[1])
+X_right = np.array(profile_right.xDataArray)
+Y_right = np.array(profile_right.yDataSeries[1])
+
+
+def get_region_indices(x_data, x_min, x_max):
+ mask = (x_data >= x_min) & (x_data <= x_max)
+ indices = np.where(mask)[0]
+ if len(indices) == 0:
+ return 0, len(x_data)
+ return indices[0], indices[-1] + 1
+
+
+def fit_and_average(x_data, y_data, start_idx, end_idx):
+ x_region = x_data[start_idx:end_idx]
+ y_region = y_data[start_idx:end_idx]
+
+ if len(x_region) < 2:
+ avg = float(np.mean(y_region)) if len(y_region) > 0 else 0.0
+ return avg, 0.0, avg
+
+ slope, intercept, _, _, _ = linregress(x_region, y_region)
+ x_mid = (x_region[0] + x_region[-1]) / 2.0
+ avg_value = slope * x_mid + intercept
+ return float(avg_value), float(slope), float(intercept)
+
+
+slab1_start, slab1_end = get_region_indices(X, z_min_1, z_max_1)
+slab2_start, slab2_end = get_region_indices(X, z_min_2, z_max_2)
+
+Va_interface, slope_a, intercept_a = fit_and_average(X, Y, slab1_start, slab1_end)
+Vb_interface, slope_b, intercept_b = fit_and_average(X, Y, slab2_start, slab2_end)
+
+slab1_start_left, slab1_end_left = get_region_indices(X_left, z_min_1, z_max_1)
+slab2_start_right, slab2_end_right = get_region_indices(X_right, z_min_2, z_max_2)
+
+Va_bulk_left, _, _ = fit_and_average(X_left, Y_left, slab1_start_left, slab1_end_left)
+Vb_bulk_right, _, _ = fit_and_average(X_right, Y_right, slab2_start_right, slab2_end_right)
+
+VBO = (Vb_interface - Va_interface) - (Vb_bulk_right - Va_bulk_left)
+
+print(f"Interface ESP Slab 1 (Va_interface): {Va_interface:.3f} eV")
+print(f"Interface ESP Slab 2 (Vb_interface): {Vb_interface:.3f} eV")
+print(f"Bulk ESP Left (Va_bulk): {Va_bulk_left:.3f} eV")
+print(f"Bulk ESP Right (Vb_bulk): {Vb_bulk_right:.3f} eV")
+print(f"Interface delta V: {Vb_interface - Va_interface:.3f} eV")
+print(f"Bulk delta V: {Vb_bulk_right - Va_bulk_left:.3f} eV")
+print(f"Valence Band Offset (VBO): {VBO:.3f} eV")
+
+plt.figure(figsize=(10, 6))
+plt.plot(X, Y, label="Macroscopic Average Potential", linewidth=2)
+plt.axvspan(z_min_1, z_max_1, color="red", alpha=0.2, label="Slab 1 Region")
+plt.axvspan(z_min_2, z_max_2, color="blue", alpha=0.2, label="Slab 2 Region")
+
+if slab1_end > slab1_start:
+ x_fit1 = X[slab1_start:slab1_end]
+ y_fit1 = slope_a * x_fit1 + intercept_a
+ plt.plot(x_fit1, y_fit1, color="darkred", linestyle="--", linewidth=2, label="Fit Slab 1")
+
+if slab2_end > slab2_start:
+ x_fit2 = X[slab2_start:slab2_end]
+ y_fit2 = slope_b * x_fit2 + intercept_b
+ plt.plot(x_fit2, y_fit2, color="darkblue", linestyle="--", linewidth=2, label="Fit Slab 2")
+
+plt.axhline(Va_interface, color="red", linestyle=":", linewidth=2, label=f"Avg ESP Slab 1 = {Va_interface:.3f} eV")
+plt.axhline(Vb_interface, color="blue", linestyle=":", linewidth=2, label=f"Avg ESP Slab 2 = {Vb_interface:.3f} eV")
+
+plt.xlabel("z-coordinate (A)", fontsize=12)
+plt.ylabel("Macroscopic Average Potential (eV)", fontsize=12)
+plt.title(f"Polar Interface VBO = {VBO:.3f} eV", fontsize=14, fontweight="bold")
+plt.legend(loc="best", fontsize=10)
+plt.grid(True, alpha=0.3)
+plt.tight_layout()
+plt.savefig("polar_vbo_fit_interface.png", dpi=150, bbox_inches="tight")
+plt.close()
+"""
+
+POLAR_VBO_REQUIREMENTS = """munch==2.5.0
+numpy>=1.19.5
+scipy>=1.5.4
+matplotlib>=3.0.0
+ase>=3.22.1
+mat3ra-made[tools]>=2024.11.12.post0
+"""
+
+
+def _polar_postprocess_subworkflow(source_python_subworkflow):
+ source_unit = next(unit for unit in source_python_subworkflow["units"] if unit["type"] == "execution")
+ source_flavor_inputs = source_unit.get("flavor", {}).get("input", [])
+ script_input_name = source_flavor_inputs[0].get("name", "script.py") if source_flavor_inputs else "script.py"
+ script_template_name = (
+ source_flavor_inputs[0].get("templateName", script_input_name) if source_flavor_inputs else script_input_name
+ )
+ requirements_input_name = (
+ source_flavor_inputs[1].get("name", "requirements.txt") if len(source_flavor_inputs) > 1 else "requirements.txt"
+ )
+ requirements_template_name = (
+ source_flavor_inputs[1].get("templateName", requirements_input_name)
+ if len(source_flavor_inputs) > 1
+ else requirements_input_name
+ )
+
+ polar_unit = deepcopy(source_unit)
+ polar_unit["flowchartId"] = "20ea6fb714ad8529fae09fd3"
+ polar_unit["name"] = "Get VBO for Polar material"
+ polar_unit["head"] = True
+ polar_unit.pop("next", None)
+ polar_unit["context"] = {"subworkflowContext": {}}
+ polar_unit["results"] = [{"name": "file_content", "filetype": "image", "basename": "polar_vbo_fit_interface.png"}]
+ polar_unit["input"] = [
+ {
+ "content": POLAR_VBO_SCRIPT,
+ "_id": "cKsDNiq3STXgGX9hP",
+ "applicationName": "python",
+ "executableName": "python",
+ "name": script_input_name,
+ "templateName": script_template_name,
+ "contextProviders": [],
+ "rendered": POLAR_VBO_SCRIPT,
+ "schemaVersion": "2022.8.16",
+ },
+ {
+ "content": POLAR_VBO_REQUIREMENTS,
+ "_id": "yTM9FqBpxmFf2iigk",
+ "applicationName": "python",
+ "executableName": "python",
+ "name": requirements_input_name,
+ "templateName": requirements_template_name,
+ "contextProviders": [],
+ "rendered": POLAR_VBO_REQUIREMENTS,
+ "schemaVersion": "2022.8.16",
+ },
+ ]
+
+ polar_subworkflow = deepcopy(source_python_subworkflow)
+ polar_subworkflow["_id"] = POLAR_POSTPROCESS_SUBWORKFLOW_ID
+ polar_subworkflow["name"] = POLAR_POSTPROCESS_SUBWORKFLOW_NAME
+ polar_subworkflow["properties"] = []
+ polar_subworkflow["units"] = [polar_unit]
+ return polar_subworkflow
+
+
+def add_polar_vbo_postprocess(workflow: Workflow) -> Workflow:
+ workflow_dict = deepcopy(workflow.to_dict())
+
+ if any(unit["name"] == POLAR_POSTPROCESS_SUBWORKFLOW_NAME for unit in workflow_dict["units"]):
+ return workflow
+
+ calculate_vbo_unit = next(unit for unit in workflow_dict["units"] if unit["name"] == CALCULATE_VBO_SUBWORKFLOW_NAME)
+ calculate_vbo_unit["next"] = POLAR_POSTPROCESS_FLOWCHART_ID
+
+ source_python_subworkflow = next(
+ subworkflow for subworkflow in workflow_dict["subworkflows"] if subworkflow["application"]["name"] == "python"
+ )
+
+ workflow_dict["units"].append(
+ {
+ "name": POLAR_POSTPROCESS_SUBWORKFLOW_NAME,
+ "type": "subworkflow",
+ "_id": POLAR_POSTPROCESS_SUBWORKFLOW_ID,
+ "flowchartId": POLAR_POSTPROCESS_FLOWCHART_ID,
+ "status": "idle",
+ "statusTrack": [],
+ "tags": [],
+ "head": False,
+ }
+ )
+ workflow_dict["subworkflows"].append(_polar_postprocess_subworkflow(source_python_subworkflow))
+ return Workflow.create(workflow_dict)
diff --git a/utils/visualize.py b/utils/visualize.py
index 5d791e94..0867dfde 100644
--- a/utils/visualize.py
+++ b/utils/visualize.py
@@ -390,6 +390,45 @@ def visualize_workflow(workflow, level: int = 2) -> None:
display_JSON(workflow_config, level=level)
+def _build_file_content_extra_config(results):
+ files = []
+ for result in results:
+ if result.get("name") != "file_content":
+ continue
+ signed_url = result.get("signedUrl") or result.get("signedURL") or result.get("url")
+ basename = result.get("basename")
+ key = result.get("objectData", {}).get("NAME") or basename
+ if signed_url and key:
+ files.append({"basename": basename, "key": key, "signedUrl": signed_url})
+
+ if not files:
+ return None
+
+ files_json = json.dumps(files)
+ return f"""{{
+ getFileContent: function(data, onSuccess) {{
+ const files = {files_json};
+ const file = files.find((item) =>
+ (data && data.objectData && data.objectData.NAME && item.key === data.objectData.NAME) ||
+ (data && data.basename && item.basename === data.basename)
+ );
+ if (!file) {{
+ console.warn("No signed URL found for file_content result", data);
+ return;
+ }}
+ const fileMetadata = {{ key: file.key, signedUrl: file.signedUrl }};
+ if (data && data.filetype === "image") {{
+ onSuccess("", fileMetadata);
+ return;
+ }}
+ fetch(file.signedUrl)
+ .then((response) => response.text())
+ .then((content) => onSuccess(content, fileMetadata))
+ .catch((error) => console.error("Failed to load file content", error));
+ }}
+ }}"""
+
+
def visualize_properties(results, width=900, title="Properties", extra_config=None):
"""
Visualize properties using a Prove viewer.
@@ -403,13 +442,18 @@ def visualize_properties(results, width=900, title="Properties", extra_config=No
if isinstance(results, dict):
results = [results]
- DATA_KEYS = {"value", "values", "xDataArray"}
+ DATA_KEYS = {"value", "values", "xDataArray", "objectData"}
results = [r for r in results if DATA_KEYS & r.keys()]
timestamp = time.time()
div_id = f"prove-{timestamp}"
results_json = json.dumps(results)
- extra_config_json = json.dumps(extra_config) if extra_config else "undefined"
+ if isinstance(extra_config, str):
+ extra_config_json = extra_config
+ elif extra_config:
+ extra_config_json = json.dumps(extra_config)
+ else:
+ extra_config_json = _build_file_content_extra_config(results) or "undefined"
html = get_viewer_html(
div_id=div_id,