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,