From 549052943bec2e029d960685c2c9f3efcedc93cf Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Fri, 17 Jul 2026 11:18:24 -0700 Subject: [PATCH 01/28] feat: add NEB calculation notebook for QE neb.x --- .../workflows/Introduction.ipynb | 2 +- other/materials_designer/workflows/neb.ipynb | 550 ++++++++++++++++++ 2 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 other/materials_designer/workflows/neb.ipynb diff --git a/other/materials_designer/workflows/Introduction.ipynb b/other/materials_designer/workflows/Introduction.ipynb index c5987b4d..c37431c9 100644 --- a/other/materials_designer/workflows/Introduction.ipynb +++ b/other/materials_designer/workflows/Introduction.ipynb @@ -81,7 +81,7 @@ "## 7. Chemistry\n", "\n", "### 7.1. Nudged Elastic Band (NEB)\n", - "#### 7.1.1. NEB reaction pathway calculation. *(to be added)*\n", + "#### [7.1.1. NEB reaction pathway calculation.](neb.ipynb)\n", "\n", "### 7.2. HOMO-LUMO (NWChem)\n", "#### 7.2.1. HOMO-LUMO gap calculation. *(to be added)*\n", diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb new file mode 100644 index 00000000..6aa42eb2 --- /dev/null +++ b/other/materials_designer/workflows/neb.ipynb @@ -0,0 +1,550 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Nudged Elastic Band (NEB)\n", + "\n", + "Calculate the reaction energy barrier and energy profile along a reaction path using Quantum ESPRESSO `neb.x` on the Mat3ra platform.\n", + "\n", + "

Usage

\n", + "\n", + "1. Set materials and calculation 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 view the reaction energy profile.\n", + "\n", + "## Summary\n", + "\n", + "1. Set up the environment and parameters: install packages (JupyterLite only) and configure parameters for materials, 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 ordered NEB images (initial, intermediate, final) from the `../uploads` folder and save them to the platform.\n", + "1. Create workflow and set its parameters: load the NEB workflow from Standata, set the k-grid, and save 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 a multi-material NEB job.\n", + "1. Submit the job and monitor the status: submit the job and wait for completion.\n", + "1. Retrieve results: visualize the reaction energy profile along the path." + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "## 1. Set up the environment and parameters\n", + "### 1.1. Install packages (JupyterLite)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.packages import install_packages\n", + "\n", + "await install_packages(\"made|api_examples\")" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "### 1.2. Set parameters and configurations for the workflow and job" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import datetime\n", + "from mat3ra.ide.compute import QueueName\n", + "\n", + "# 2. Auth and organization parameters\n", + "# Set organization name to use it as the owner, otherwise your personal account is used\n", + "ORGANIZATION_NAME = None\n", + "\n", + "# 3. Material parameters (ordered NEB images: initial, intermediate, final)\n", + "# Placeholders only — replace with your real path images, e.g.:\n", + "# MATERIAL_NAMES = [\"H2+H-initial\", \"H2+H-image\", \"H2+H-final\"]\n", + "# (put those files in ../uploads, or use names that resolve from Standata)\n", + "FOLDER = \"../uploads\"\n", + "MATERIAL_NAMES = [\"Silicon\", \"Silicon\", \"Silicon\"]\n", + "\n", + "# 4. Workflow parameters\n", + "WORKFLOW_SEARCH_TERM = \"neb.json\"\n", + "APPLICATION_NAME = \"espresso\"\n", + "NEB_KGRID = [1, 1, 1]\n", + "MY_WORKFLOW_NAME = \"Nudged Elastic Band (NEB)\"\n", + "\n", + "# 5. Compute parameters\n", + "CLUSTER_NAME = None # specify full or partial name i.e. \"cluster-001\" to select\n", + "QUEUE_NAME = QueueName.D\n", + "PPN = 1\n", + "\n", + "# 6. Job parameters\n", + "timestamp = datetime.now().strftime(\"%Y-%m-%d %H:%M\")\n", + "POLL_INTERVAL = 30 # seconds" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "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": "markdown", + "id": "6", + "metadata": {}, + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.auth import authenticate\n", + "\n", + "\n", + "await authenticate()" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "### 2.2. Initialize API Client\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.api_client import APIClient\n", + "\n", + "client = APIClient.authenticate()\n", + "client" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "### 2.3. Select account to work under" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "client.list_accounts()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "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", + "id": "13", + "metadata": {}, + "source": [ + "### 2.4. Select project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "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", + "id": "15", + "metadata": {}, + "source": [ + "## 3. Create materials\n", + "### 3.1. Load ordered NEB images from local files" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "import re\n", + "\n", + "from mat3ra.made.material import Material\n", + "from mat3ra.standata.materials import Materials\n", + "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", + "from mat3ra.notebooks_utils.material import load_material_from_folder\n", + "\n", + "materials = []\n", + "for material_name in MATERIAL_NAMES:\n", + " material = load_material_from_folder(FOLDER, material_name)\n", + " if material is None:\n", + " matches = client.materials.list({\n", + " \"name\": {\"$regex\": re.escape(material_name), \"$options\": \"i\"},\n", + " \"owner._id\": ACCOUNT_ID,\n", + " })\n", + " if matches:\n", + " material = Material.create(matches[0])\n", + " print(f\"♻️ Loaded from platform: {matches[0]['name']}\")\n", + " else:\n", + " material = Material.create(Materials.get_by_name_first_match(material_name))\n", + " print(f\"✅ Loaded from Standata: {material.name}\")\n", + " materials.append(material)\n", + "\n", + "visualize(materials)\n" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "### 3.2. Save materials to the platform" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.core.entity.material.api import get_or_create_material\n", + "\n", + "saved_materials = []\n", + "for material in materials:\n", + " saved_material_response = get_or_create_material(client, material, ACCOUNT_ID)\n", + " saved_materials.append(Material.create(saved_material_response))\n", + "\n", + "for saved_material in saved_materials:\n", + " print(f\"✅ Material: {saved_material.name} ({saved_material.id})\")" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "## 4. Create workflow and set its parameters\n", + "### 4.1. Get list of applications and select one" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.standata.applications import ApplicationStandata\n", + "from mat3ra.ade.application import Application\n", + "\n", + "app_config = ApplicationStandata.get_by_name_first_match(APPLICATION_NAME)\n", + "app = Application(**app_config)\n", + "print(f\"Using application: {app.name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "21", + "metadata": {}, + "source": [ + "### 4.2. Create workflow from standard workflows and preview it" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.standata.workflows import WorkflowStandata\n", + "from mat3ra.wode.workflows import Workflow\n", + "from mat3ra.notebooks_utils.ipython.entity.workflow.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", + "workflow.name = MY_WORKFLOW_NAME\n", + "\n", + "visualize_workflow(workflow)" + ] + }, + { + "cell_type": "markdown", + "id": "23", + "metadata": {}, + "source": [ + "### 4.3. Modify important settings\n", + "Set k-grid for the NEB unit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.wode.context.providers import PointsGridDataProvider\n", + "\n", + "if NEB_KGRID is not None:\n", + " new_context_kgrid = PointsGridDataProvider(dimensions=NEB_KGRID, isEdited=True).get_context_item_data()\n", + " neb_subworkflow = workflow.subworkflows[0]\n", + " unit_to_modify = neb_subworkflow.get_unit_by_name(name=\"neb\")\n", + " unit_to_modify.add_context(new_context_kgrid)\n", + " neb_subworkflow.set_unit(unit_to_modify)\n", + "\n", + "visualize_workflow(workflow)" + ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "### 4.4. Save workflow to collection" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.utils.namespace import dict_to_namespace_recursive\n", + "from mat3ra.notebooks_utils.core.entity.workflow.api import get_or_create_workflow\n", + "\n", + "saved_workflow_response = get_or_create_workflow(client, workflow, ACCOUNT_ID)\n", + "saved_workflow = Workflow.create(saved_workflow_response)\n", + "print(f\"Workflow ID: {saved_workflow.id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "## 5. Create the compute configuration\n", + "### 5.1. Get list of clusters" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", + "metadata": {}, + "outputs": [], + "source": [ + "clusters = client.clusters.list()\n", + "print(f\"Available clusters: {[c['hostname'] for c in clusters]}\")" + ] + }, + { + "cell_type": "markdown", + "id": "29", + "metadata": {}, + "source": [ + "### 5.2. Create compute configuration for the job\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.ide.compute import Compute\n", + "\n", + "# Select cluster: use specified name if provided, otherwise use first available\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}\")" + ] + }, + { + "cell_type": "markdown", + "id": "31", + "metadata": {}, + "source": [ + "## 6. Create the job with materials and workflow configuration\n", + "### 6.1. Create job" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.job import create_job\n", + "from mat3ra.notebooks_utils.ui import display_JSON\n", + "\n", + "print(f\"Materials: {[m.id for m in saved_materials]}\")\n", + "print(f\"Workflow: {saved_workflow.id}\")\n", + "print(f\"Project: {project_id}\")\n", + "\n", + "job_name = f\"{MY_WORKFLOW_NAME} {'-'.join(MATERIAL_NAMES)} {timestamp}\"\n", + "job_response = create_job(\n", + " api_client=client,\n", + " materials=saved_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_recursive(job_response)\n", + "job_id = job._id\n", + "print(\"✅ Job created successfully!\")\n", + "print(f\"Job ID: {job_id}\")\n", + "display_JSON(job_response)" + ] + }, + { + "cell_type": "markdown", + "id": "33", + "metadata": {}, + "source": [ + "## 7. Submit the job and monitor the status" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "client.jobs.submit(job_id)\n", + "print(f\"✅ Job {job_id} submitted successfully!\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.api.job import wait_for_jobs_to_finish_async\n", + "\n", + "await wait_for_jobs_to_finish_async(client.jobs, [job_id], poll_interval=POLL_INTERVAL)" + ] + }, + { + "cell_type": "markdown", + "id": "36", + "metadata": {}, + "source": [ + "## 8. Retrieve results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.ipython.entity.property.visualize import visualize_properties\n", + "\n", + "profile_data = client.properties.get_for_job(job_id, property_name=\"reaction_energy_profile\")\n", + "visualize_properties(profile_data, title=\"Reaction Energy Profile\")\n", + "\n", + "energies = profile_data[0][\"yDataSeries\"][0]\n", + "print(f\"Peak reaction energy = {max(energies):.3f} eV\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38", + "metadata": {}, + "outputs": [], + "source": [ + "barrier_data = client.properties.get_for_job(job_id, property_name=\"reaction_energy_barrier\")\n", + "visualize_properties(barrier_data, title=\"Reaction Energy Barrier\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 33de145cc510cc2ce2b80a5d6efedcc4aeed2a9b Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 13:42:03 -0700 Subject: [PATCH 02/28] update: correct neb materials --- other/materials_designer/workflows/neb.ipynb | 198 +++++++++++-------- 1 file changed, 111 insertions(+), 87 deletions(-) diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index 6aa42eb2..c6cf96ed 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -9,22 +9,33 @@ "\n", "Calculate the reaction energy barrier and energy profile along a reaction path using Quantum ESPRESSO `neb.x` on the Mat3ra platform.\n", "\n", + "## Prerequisites\n", + "\n", + "Before running this notebook, put the NEB path structures on the platform in an **ordered materials set**:\n", + "\n", + "1. **First** member = initial image (required).\n", + "2. **Last** member = final image (required).\n", + "3. **Middle** members = intermediate images (optional).\n", + "4. If the set has only first+last, set `N_IMAGES` (e.g. `20`) so Quantum ESPRESSO interpolates intermediates.\n", + "\n", + "Path order follows the ordered-set indices (same as the job designer), not material names.\n", + "\n", "

Usage

\n", "\n", - "1. Set materials and calculation parameters in cell 1.2. below (or use the default values).\n", + "1. Set `MATERIAL_SET` in cell 1.2 and NEB-specific settings (`N_IMAGES`, k-grid) in cell 1.3 below.\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 reaction energy profile.\n", "\n", "## Summary\n", "\n", - "1. Set up the environment and parameters: install packages (JupyterLite only) and configure parameters for materials, workflow, compute resources, and job.\n", + "1. Set up the environment and parameters: install packages (JupyterLite only) and configure materials lookup, workflow, compute, 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 ordered NEB images (initial, intermediate, final) from the `../uploads` folder and save them to the platform.\n", - "1. Create workflow and set its parameters: load the NEB workflow from Standata, set the k-grid, and save 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 a multi-material NEB job.\n", - "1. Submit the job and monitor the status: submit the job and wait for completion.\n", + "1. Resolve NEB path materials from an ordered materials set (first, optional intermediates, last).\n", + "1. Create workflow and set its parameters: load the NEB workflow from Standata, set k-grid / `N_IMAGES`, and save the workflow.\n", + "1. Configure compute: get list of clusters and create compute configuration.\n", + "1. Create the multi-material NEB job.\n", + "1. Submit the job and monitor the status.\n", "1. Retrieve results: visualize the reaction energy profile along the path." ] }, @@ -71,17 +82,13 @@ "# Set organization name to use it as the owner, otherwise your personal account is used\n", "ORGANIZATION_NAME = None\n", "\n", - "# 3. Material parameters (ordered NEB images: initial, intermediate, final)\n", - "# Placeholders only — replace with your real path images, e.g.:\n", - "# MATERIAL_NAMES = [\"H2+H-initial\", \"H2+H-image\", \"H2+H-final\"]\n", - "# (put those files in ../uploads, or use names that resolve from Standata)\n", - "FOLDER = \"../uploads\"\n", - "MATERIAL_NAMES = [\"Silicon\", \"Silicon\", \"Silicon\"]\n", + "# 3. Material parameters (prerequisite: ordered materials set on the platform)\n", + "# Order in the set: first = initial, middle = intermediates (optional), last = final\n", + "MATERIAL_SET = \"H2+H\"\n", "\n", "# 4. Workflow parameters\n", "WORKFLOW_SEARCH_TERM = \"neb.json\"\n", "APPLICATION_NAME = \"espresso\"\n", - "NEB_KGRID = [1, 1, 1]\n", "MY_WORKFLOW_NAME = \"Nudged Elastic Band (NEB)\"\n", "\n", "# 5. Compute parameters\n", @@ -99,21 +106,37 @@ "id": "5", "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" + "### 1.3. Set specific NEB parameters" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "6", "metadata": {}, - "source": [] + "outputs": [], + "source": [ + "# Intermediate count for QE when the set has only first+last (ignored if middle images exist)\n", + "N_IMAGES = None # e.g. 20; defaults to 1 when exactly two materials are in the set\n", + "\n", + "# K-grid for the NEB unit\n", + "NEB_KGRID = [1, 1, 1]" + ] + }, + { + "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": "7", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -125,7 +148,7 @@ }, { "cell_type": "markdown", - "id": "8", + "id": "9", "metadata": {}, "source": [ "### 2.2. Initialize API Client\n" @@ -134,7 +157,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -146,7 +169,7 @@ }, { "cell_type": "markdown", - "id": "10", + "id": "11", "metadata": {}, "source": [ "### 2.3. Select account to work under" @@ -155,7 +178,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -165,7 +188,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "13", "metadata": {}, "outputs": [], "source": [ @@ -180,7 +203,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "14", "metadata": {}, "source": [ "### 2.4. Select project" @@ -189,7 +212,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -200,75 +223,65 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "16", "metadata": {}, "source": [ - "## 3. Create materials\n", - "### 3.1. Load ordered NEB images from local files" + "## 3. Resolve NEB path materials\n", + "### 3.1. Load materials from an ordered set\n", + "Members are loaded in ordered-set index order: first → intermediates (if any) → last." ] }, { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "17", "metadata": {}, "outputs": [], "source": [ - "import re\n", - "\n", "from mat3ra.made.material import Material\n", - "from mat3ra.standata.materials import Materials\n", + "from mat3ra.notebooks_utils.core.entity.material.api import list_materials_by_set\n", "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", - "from mat3ra.notebooks_utils.material import load_material_from_folder\n", "\n", - "materials = []\n", - "for material_name in MATERIAL_NAMES:\n", - " material = load_material_from_folder(FOLDER, material_name)\n", - " if material is None:\n", - " matches = client.materials.list({\n", - " \"name\": {\"$regex\": re.escape(material_name), \"$options\": \"i\"},\n", - " \"owner._id\": ACCOUNT_ID,\n", - " })\n", - " if matches:\n", - " material = Material.create(matches[0])\n", - " print(f\"♻️ Loaded from platform: {matches[0]['name']}\")\n", - " else:\n", - " material = Material.create(Materials.get_by_name_first_match(material_name))\n", - " print(f\"✅ Loaded from Standata: {material.name}\")\n", - " materials.append(material)\n", - "\n", - "visualize(materials)\n" + "material_dicts = list_materials_by_set(client, ACCOUNT_ID, MATERIAL_SET)\n", + "if len(material_dicts) < 2:\n", + " raise ValueError(\n", + " f\"Ordered set '{MATERIAL_SET}' must contain at least first and last images \"\n", + " f\"(found {len(material_dicts)}).\"\n", + " )\n", + "print(f\"✅ Loaded {len(material_dicts)} material(s) from ordered set '{MATERIAL_SET}'\")\n", + "materials = [Material.create(material_dict) for material_dict in material_dicts]\n", + "print(f\" first (initial): {materials[0].name} ({materials[0].id})\")\n", + "if len(materials) > 2:\n", + " for material in materials[1:-1]:\n", + " print(f\" intermediate: {material.name} ({material.id})\")\n", + "print(f\" last (final): {materials[-1].name} ({materials[-1].id})\")\n", + "visualize(materials)" ] }, { "cell_type": "markdown", - "id": "17", + "id": "18", "metadata": {}, "source": [ - "### 3.2. Save materials to the platform" + "### 3.2. Use resolved materials for the job\n", + "Path materials already exist on the platform in the ordered set." ] }, { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "19", "metadata": {}, "outputs": [], "source": [ - "from mat3ra.notebooks_utils.core.entity.material.api import get_or_create_material\n", - "\n", - "saved_materials = []\n", - "for material in materials:\n", - " saved_material_response = get_or_create_material(client, material, ACCOUNT_ID)\n", - " saved_materials.append(Material.create(saved_material_response))\n", - "\n", + "saved_materials = materials\n", "for saved_material in saved_materials:\n", " print(f\"✅ Material: {saved_material.name} ({saved_material.id})\")" ] }, { "cell_type": "markdown", - "id": "19", + "id": "20", "metadata": {}, "source": [ "## 4. Create workflow and set its parameters\n", @@ -278,7 +291,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -292,7 +305,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "22", "metadata": {}, "source": [ "### 4.2. Create workflow from standard workflows and preview it" @@ -301,7 +314,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -318,35 +331,45 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "24", "metadata": {}, "source": [ "### 4.3. Modify important settings\n", - "Set k-grid for the NEB unit." + "Set k-grid for the NEB unit. When the set has only first+last, set `N_IMAGES` (e.g. 20) so QE interpolates intermediates." ] }, { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "25", "metadata": {}, "outputs": [], "source": [ "from mat3ra.wode.context.providers import PointsGridDataProvider\n", "\n", + "neb_subworkflow = workflow.subworkflows[0]\n", + "unit_to_modify = neb_subworkflow.get_unit_by_name(name=\"neb\")\n", + "\n", "if NEB_KGRID is not None:\n", " new_context_kgrid = PointsGridDataProvider(dimensions=NEB_KGRID, isEdited=True).get_context_item_data()\n", - " neb_subworkflow = workflow.subworkflows[0]\n", - " unit_to_modify = neb_subworkflow.get_unit_by_name(name=\"neb\")\n", " unit_to_modify.add_context(new_context_kgrid)\n", - " neb_subworkflow.set_unit(unit_to_modify)\n", "\n", + "effective_n_images = N_IMAGES\n", + "if len(saved_materials) == 2 and effective_n_images is None:\n", + " effective_n_images = 1\n", + "if effective_n_images is not None:\n", + " unit_to_modify.add_context(\n", + " {\"name\": \"neb\", \"isEdited\": True, \"data\": {\"nImages\": effective_n_images}, \"extraData\": {}}\n", + " )\n", + " print(f\"Using N_IMAGES={effective_n_images}\")\n", + "\n", + "neb_subworkflow.set_unit(unit_to_modify)\n", "visualize_workflow(workflow)" ] }, { "cell_type": "markdown", - "id": "25", + "id": "26", "metadata": {}, "source": [ "### 4.4. Save workflow to collection" @@ -355,7 +378,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -369,7 +392,7 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "28", "metadata": {}, "source": [ "## 5. Create the compute configuration\n", @@ -379,7 +402,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -389,7 +412,7 @@ }, { "cell_type": "markdown", - "id": "29", + "id": "30", "metadata": {}, "source": [ "### 5.2. Create compute configuration for the job\n" @@ -398,7 +421,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -420,7 +443,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "32", "metadata": {}, "source": [ "## 6. Create the job with materials and workflow configuration\n", @@ -430,7 +453,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "33", "metadata": {}, "outputs": [], "source": [ @@ -441,7 +464,8 @@ "print(f\"Workflow: {saved_workflow.id}\")\n", "print(f\"Project: {project_id}\")\n", "\n", - "job_name = f\"{MY_WORKFLOW_NAME} {'-'.join(MATERIAL_NAMES)} {timestamp}\"\n", + "path_label = MATERIAL_SET or \"neb-path\"\n", + "job_name = f\"{MY_WORKFLOW_NAME} {path_label} {timestamp}\"\n", "job_response = create_job(\n", " api_client=client,\n", " materials=saved_materials,\n", @@ -461,7 +485,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "34", "metadata": {}, "source": [ "## 7. Submit the job and monitor the status" @@ -470,7 +494,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -481,7 +505,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -492,7 +516,7 @@ }, { "cell_type": "markdown", - "id": "36", + "id": "37", "metadata": {}, "source": [ "## 8. Retrieve results" @@ -501,7 +525,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -517,7 +541,7 @@ { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "39", "metadata": {}, "outputs": [], "source": [ From 956b2c79544205d92a814fec92a70c104333e05f Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 13:42:35 -0700 Subject: [PATCH 03/28] update: generalize get by tag, by set --- .../workflows/analyze_convex_hull.ipynb | 31 +++++------ .../core/entity/material/api.py | 52 +++++++++++++++++++ 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/other/materials_designer/workflows/analyze_convex_hull.ipynb b/other/materials_designer/workflows/analyze_convex_hull.ipynb index bebe464e..c4a013ed 100644 --- a/other/materials_designer/workflows/analyze_convex_hull.ipynb +++ b/other/materials_designer/workflows/analyze_convex_hull.ipynb @@ -140,18 +140,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Resolve material set (if specified)\n", - "set_id = None\n", + "from mat3ra.notebooks_utils.core.entity.material.api import list_materials_by_set\n", + "\n", + "set_materials = None\n", "if MATERIAL_SET:\n", - " set_query = {\"owner._id\": ACCOUNT_ID, \"isEntitySet\": True,\n", - " \"name\": {\"$regex\": MATERIAL_SET, \"$options\": \"i\"}}\n", - " sets = client.materials.list(set_query)\n", - " if not sets:\n", - " raise ValueError(f\"No material set matching '{MATERIAL_SET}'\")\n", - " set_id = sets[0][\"_id\"]\n", - " print(f\"✅ Using set: {sets[0]['name']} ({set_id})\")\n", + " set_materials = list_materials_by_set(client, ACCOUNT_ID, MATERIAL_SET)\n", + " print(f\"✅ Using set '{MATERIAL_SET}': {len(set_materials)} material(s)\")\n", "else:\n", - " print(\"ℹ️ No material set specified — searching all materials in account.\")" + " print(\"ℹ️ No material set specified — searching all materials in account.\")\n" ] }, { @@ -169,23 +165,22 @@ "metadata": {}, "outputs": [], "source": [ - "# Search materials by formula\n", + "# Search materials by formula (optionally restricted to MATERIAL_SET)\n", "all_materials = []\n", "\n", "for formula in FORMULAS:\n", - " query = {\"formula\": formula, \"owner._id\": ACCOUNT_ID}\n", - " if set_id:\n", - " query[\"inSet._id\"] = set_id\n", - " matches = client.materials.list(query)\n", - " # Filter out entity sets\n", - " matches = [m for m in matches if not m.get(\"isEntitySet\")]\n", + " if set_materials is not None:\n", + " matches = [m for m in set_materials if m.get(\"formula\") == formula]\n", + " else:\n", + " matches = client.materials.list({\"formula\": formula, \"owner._id\": ACCOUNT_ID})\n", + " matches = [m for m in matches if not m.get(\"isEntitySet\")]\n", " for material in matches:\n", " material[\"_search_formula\"] = formula # track which formula query found this\n", " all_materials.extend(matches)\n", "\n", " print(f\"{formula}: {len(matches)} material(s) found\")\n", "\n", - "print(f\"\\nTotal: {len(all_materials)} materials\")" + "print(f\"\\nTotal: {len(all_materials)} materials\")\n" ] }, { diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py index a82127b1..a533eead 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py @@ -1,3 +1,5 @@ +from typing import Any, Dict, List + from mat3ra.api_client import APIClient @@ -21,3 +23,53 @@ def get_or_create_material(api_client: APIClient, material, owner_id: str) -> di created = api_client.materials.create(material.to_dict(), owner_id=owner_id) print(f"✅ Material created: {created['_id']}") return created + + +def _exclude_entity_sets(materials: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [material for material in materials if not material.get("isEntitySet")] + + +def _index_in_set(material: Dict[str, Any], set_id: str) -> float: + for entry in material.get("inSet") or []: + if entry.get("_id") == set_id: + index = entry.get("index") + return float(index) if index is not None else float("inf") + return float("inf") + + +def find_material_set(api_client: APIClient, owner_id: str, material_set_name: str) -> Dict[str, Any]: + """ + Find a materials entity set by name (case-insensitive substring match). + """ + sets = api_client.materials.list( + { + "owner._id": owner_id, + "isEntitySet": True, + "name": {"$regex": material_set_name, "$options": "i"}, + } + ) + if not sets: + raise ValueError(f"No material set matching '{material_set_name}'") + return sets[0] + + +def list_materials_by_set( + api_client: APIClient, + owner_id: str, + material_set_name: str, +) -> List[Dict[str, Any]]: + """ + List non-set materials in a materials set, ordered by inSet index (ascending). + + Matches platform NEB / ordered-set behavior: first, optional intermediates, last. + """ + material_set = find_material_set(api_client, owner_id, material_set_name) + set_id = material_set["_id"] + matches = api_client.materials.list( + { + "owner._id": owner_id, + "inSet._id": set_id, + } + ) + materials = _exclude_entity_sets(matches) + return sorted(materials, key=lambda material: _index_in_set(material, set_id)) From 31f18aa467e079c1359875b17c78cc4579ba128f Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 13:43:06 -0700 Subject: [PATCH 04/28] update: test generalize get by tag, by set --- .../py/unit/core/entity/test_material_api.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/py/unit/core/entity/test_material_api.py diff --git a/tests/py/unit/core/entity/test_material_api.py b/tests/py/unit/core/entity/test_material_api.py new file mode 100644 index 00000000..cfdc7c9d --- /dev/null +++ b/tests/py/unit/core/entity/test_material_api.py @@ -0,0 +1,79 @@ +from typing import Any, Dict, List +from unittest.mock import MagicMock + +import pytest +from mat3ra.notebooks_utils.core.entity.material.api import find_material_set, list_materials_by_set + +OWNER_ID = "account-1" +SET_NAME = "H2+H" +SET_ID = "set-1" + +MATERIAL_INITIAL: Dict[str, Any] = { + "_id": "m-initial", + "name": "path-start", + "isEntitySet": False, + "inSet": [{"_id": SET_ID, "index": 0}], +} +MATERIAL_IMAGE: Dict[str, Any] = { + "_id": "m-image", + "name": "path-mid", + "isEntitySet": False, + "inSet": [{"_id": SET_ID, "index": 1}], +} +MATERIAL_FINAL: Dict[str, Any] = { + "_id": "m-final", + "name": "path-end", + "isEntitySet": False, + "inSet": [{"_id": SET_ID, "index": 2}], +} +ENTITY_SET: Dict[str, Any] = {"_id": SET_ID, "name": "H2+H", "isEntitySet": True} + +# Scrambled API order — list_materials_by_set must sort by inSet.index +SET_MEMBER_MATERIALS: List[Dict[str, Any]] = [ + MATERIAL_FINAL, + MATERIAL_INITIAL, + MATERIAL_IMAGE, + ENTITY_SET, +] + + +def _client_with_list_responses(responses: List[List[Dict[str, Any]]]) -> MagicMock: + client = MagicMock() + client.materials.list.side_effect = responses + return client + + +def test_find_material_set_returns_first_match(): + client = _client_with_list_responses([[ENTITY_SET]]) + material_set = find_material_set(client, OWNER_ID, SET_NAME) + assert material_set["_id"] == SET_ID + client.materials.list.assert_called_once_with( + { + "owner._id": OWNER_ID, + "isEntitySet": True, + "name": {"$regex": SET_NAME, "$options": "i"}, + } + ) + + +def test_find_material_set_raises_when_missing(): + client = _client_with_list_responses([[]]) + with pytest.raises(ValueError, match="No material set matching"): + find_material_set(client, OWNER_ID, SET_NAME) + + +@pytest.mark.parametrize( + ("members", "expected_ids"), + [ + (SET_MEMBER_MATERIALS, ["m-initial", "m-image", "m-final"]), + ([MATERIAL_INITIAL], ["m-initial"]), + ], +) +def test_list_materials_by_set_orders_by_inset_index(members, expected_ids): + client = _client_with_list_responses([[ENTITY_SET], members]) + materials = list_materials_by_set(client, OWNER_ID, SET_NAME) + assert [material["_id"] for material in materials] == expected_ids + assert client.materials.list.call_args_list[1].args[0] == { + "owner._id": OWNER_ID, + "inSet._id": SET_ID, + } From b0622ede5e5a2d3b8314adfa1dafc5d56f161875 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 14:39:35 -0700 Subject: [PATCH 05/28] fix: drop api_client import so unit tests collect without [api] --- .../mat3ra/notebooks_utils/core/entity/material/api.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py index a533eead..97a1f48e 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py @@ -1,15 +1,13 @@ from typing import Any, Dict, List -from mat3ra.api_client import APIClient - -def get_or_create_material(api_client: APIClient, material, owner_id: str) -> dict: +def get_or_create_material(api_client: Any, material, owner_id: str) -> dict: """ Returns an existing material from the collection if one with the same structural hash exists under the given owner, otherwise creates a new one. Args: - api_client (APIClient): API client instance carrying the authorization context. + api_client: API client instance carrying the authorization context. material: mat3ra-made Material object (must have a .hash property). owner_id (str): Account ID under which to search and create. @@ -37,7 +35,7 @@ def _index_in_set(material: Dict[str, Any], set_id: str) -> float: return float("inf") -def find_material_set(api_client: APIClient, owner_id: str, material_set_name: str) -> Dict[str, Any]: +def find_material_set(api_client: Any, owner_id: str, material_set_name: str) -> Dict[str, Any]: """ Find a materials entity set by name (case-insensitive substring match). """ @@ -54,7 +52,7 @@ def find_material_set(api_client: APIClient, owner_id: str, material_set_name: s def list_materials_by_set( - api_client: APIClient, + api_client: Any, owner_id: str, material_set_name: str, ) -> List[Dict[str, Any]]: From 6d6c1e90038a420e615123a3ecd4e23235f34082 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 17:42:51 -0700 Subject: [PATCH 06/28] update: create neb materials --- .../workflows/prepare_neb_materials.ipynb | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 other/materials_designer/workflows/prepare_neb_materials.ipynb diff --git a/other/materials_designer/workflows/prepare_neb_materials.ipynb b/other/materials_designer/workflows/prepare_neb_materials.ipynb new file mode 100644 index 00000000..02c43175 --- /dev/null +++ b/other/materials_designer/workflows/prepare_neb_materials.ipynb @@ -0,0 +1,312 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Prepare NEB Path Materials\n", + "\n", + "Build an **ordered materials set** for Nudged Elastic Band: start from one structure, transform it into a final image (example: move an atom), then save **first** and **last** into a set for [`neb.ipynb`](neb.ipynb).\n", + "\n", + "## Usage\n", + "\n", + "1. Set material / set name in cell 1.2 and optional displacement params in cell 1.3.\n", + "1. Edit the transformation cell to apply any change you need (`set_coordinates` example provided).\n", + "1. Run all cells.\n", + "1. Copy the printed `MATERIAL_SET` name into `neb.ipynb` (and set `N_IMAGES` there if you only saved first+last).\n", + "\n", + "## Summary\n", + "\n", + "1. Install packages and set parameters.\n", + "1. Authenticate and select account.\n", + "1. Load the starting material.\n", + "1. Clone as initial; transform a copy into the final image.\n", + "1. Save both materials and place them in an ordered materials set." + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "## 1. Set up the environment and parameters\n", + "### 1.1. Install packages (JupyterLite)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.packages import install_packages\n", + "\n", + "await install_packages(\"made|api_examples\")" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "### 1.2. Set parameters" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Auth / organization\n", + "ORGANIZATION_NAME = None\n", + "\n", + "# Starting material (uploads folder or Standata name match)\n", + "FOLDER = \"../uploads\"\n", + "MATERIAL_NAME = \"Silicon\"\n", + "\n", + "# Ordered set name — same string as MATERIAL_SET in neb.ipynb\n", + "MATERIAL_SET_NAME = \"NEB Silicon\"\n" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "### 1.3. Set example transformation parameters\n", + "Used by the example `set_coordinates` cell below — replace with your own logic as needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "# Example only: displace one site in crystal coordinates\n", + "ATOM_INDEX = 0\n", + "DISPLACEMENT = [0.0, 0.0, 0.1]" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## 2. Authenticate and initialize API client\n", + "### 2.1. Authenticate" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.auth import authenticate\n", + "\n", + "await authenticate()" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "### 2.2. Initialize API client" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.api_client import APIClient\n", + "\n", + "client = APIClient.authenticate()\n", + "client" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "### 2.3. Select account" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "client.list_accounts()" + ] + }, + { + "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}\")" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## 3. Build initial and final images\n", + "### 3.1. Load starting material" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.made.material import Material\n", + "from mat3ra.standata.materials import Materials\n", + "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", + "from mat3ra.notebooks_utils.material import load_material_from_folder\n", + "\n", + "source_material = load_material_from_folder(FOLDER, MATERIAL_NAME) or Material.create(\n", + " Materials.get_by_name_first_match(MATERIAL_NAME)\n", + ")\n", + "visualize(source_material)" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "### 3.2. Clone as initial image" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "initial_material = source_material.clone()\n", + "initial_material.name = f\"{source_material.name}-initial\"\n", + "print(f\"Initial: {initial_material.name}\")\n", + "visualize(initial_material)" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "### 3.3. Transform into the final image\n", + "\n", + "Example uses `set_coordinates` to displace one atom. **Edit this cell** for any transformation you need (defects, swaps, custom coordinates, etc.)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "final_material = initial_material.clone()\n", + "coordinates = [list(coordinate) for coordinate in final_material.coordinates_array]\n", + "for axis_index, delta in enumerate(DISPLACEMENT):\n", + " coordinates[ATOM_INDEX][axis_index] += delta\n", + "final_material.set_coordinates(coordinates)\n", + "final_material.name = f\"{source_material.name}-final\"\n", + "print(f\"Final: {final_material.name}\")\n", + "print(f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → {final_material.coordinates_array[ATOM_INDEX]}\")\n", + "visualize([initial_material, final_material])" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "## 4. Save to an ordered materials set\n", + "### 4.1. Create or reuse materials on the platform" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.core.entity.material.api import get_or_create_material\n", + "\n", + "saved_initial = get_or_create_material(client, initial_material, ACCOUNT_ID)\n", + "saved_final = get_or_create_material(client, final_material, ACCOUNT_ID)\n", + "print(f\"Initial ID: {saved_initial['_id']}\")\n", + "print(f\"Final ID: {saved_final['_id']}\")" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "### 4.2. Create ordered set and move first → last" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.core.entity.material.api import create_ordered_materials_set\n", + "\n", + "materials_set = create_ordered_materials_set(\n", + " client,\n", + " ACCOUNT_ID,\n", + " MATERIAL_SET_NAME,\n", + " [saved_initial, saved_final],\n", + ")\n", + "print(f\"MATERIAL_SET = {materials_set['name']!r}\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 4df9c9bfb4db79ef8e9b7ad77b9a84d854047bfd Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 17:43:55 -0700 Subject: [PATCH 07/28] update: add helpers for material set --- .../core/entity/material/api.py | 92 +++++++++++++++++-- 1 file changed, 82 insertions(+), 10 deletions(-) diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py index 97a1f48e..4e70c067 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py @@ -27,9 +27,9 @@ def _exclude_entity_sets(materials: List[Dict[str, Any]]) -> List[Dict[str, Any] return [material for material in materials if not material.get("isEntitySet")] -def _index_in_set(material: Dict[str, Any], set_id: str) -> float: +def _index_in_set(material: Dict[str, Any], material_set_id: str) -> float: for entry in material.get("inSet") or []: - if entry.get("_id") == set_id: + if entry.get("_id") == material_set_id: index = entry.get("index") return float(index) if index is not None else float("inf") return float("inf") @@ -38,17 +38,28 @@ def _index_in_set(material: Dict[str, Any], set_id: str) -> float: def find_material_set(api_client: Any, owner_id: str, material_set_name: str) -> Dict[str, Any]: """ Find a materials entity set by name (case-insensitive substring match). + + Args: + api_client: API client instance carrying the authorization context. + owner_id: Account ID that owns the set. + material_set_name: Substring matched against set names under the owner. + + Returns: + The first matching materials set document. + + Raises: + ValueError: If no set matches the name. """ - sets = api_client.materials.list( + material_sets = api_client.materials.list( { "owner._id": owner_id, "isEntitySet": True, "name": {"$regex": material_set_name, "$options": "i"}, } ) - if not sets: + if not material_sets: raise ValueError(f"No material set matching '{material_set_name}'") - return sets[0] + return material_sets[0] def list_materials_by_set( @@ -57,17 +68,78 @@ def list_materials_by_set( material_set_name: str, ) -> List[Dict[str, Any]]: """ - List non-set materials in a materials set, ordered by inSet index (ascending). + List non-set materials in a materials set, ordered by ascending `inSet.index`. - Matches platform NEB / ordered-set behavior: first, optional intermediates, last. + Path order is first → optional intermediates → last. Tags and material names + do not define order. + + Args: + api_client: API client instance carrying the authorization context. + owner_id: Account ID that owns the set. + material_set_name: Name (substring) of the ordered materials set. + + Returns: + Member materials sorted by path index (missing index sorts last). """ material_set = find_material_set(api_client, owner_id, material_set_name) - set_id = material_set["_id"] + material_set_id = material_set["_id"] matches = api_client.materials.list( { "owner._id": owner_id, - "inSet._id": set_id, + "inSet._id": material_set_id, } ) materials = _exclude_entity_sets(matches) - return sorted(materials, key=lambda material: _index_in_set(material, set_id)) + return sorted(materials, key=lambda material: _index_in_set(material, material_set_id)) + + +def _resolve_material_identifier(material: Any) -> str: + if isinstance(material, dict): + return material["_id"] + return material.id + + +def _move_materials_into_set(api_client: Any, material_set_id: str, materials: List[Any]) -> None: + for material in materials: + api_client.materials.move_to_set( + _resolve_material_identifier(material), + "", + material_set_id, + ) + + +def create_ordered_materials_set( + api_client: Any, + owner_id: str, + material_set_name: str, + materials: List[Any], +) -> Dict[str, Any]: + """ + Create an ordered materials set and move members in path order. + + Move order is first → intermediates → last so the platform can assign + ascending `inSet.index` values that `list_materials_by_set` reads. + + Args: + api_client: API client instance carrying the authorization context. + owner_id: Account ID under which to create the set. + material_set_name: Name for the new ordered set. + materials: At least two materials (dict responses or Made objects with `.id`). + + Returns: + The created materials set document. + + Raises: + ValueError: If fewer than two materials are provided. + """ + if len(materials) < 2: + raise ValueError("Ordered NEB set needs at least first and last materials.") + set_config = { + "name": material_set_name, + "owner": {"_id": owner_id}, + "entitySetType": "ordered", + } + materials_set = api_client.materials.create_set(set_config) + _move_materials_into_set(api_client, materials_set["_id"], materials) + print(f"✅ Ordered materials set '{materials_set['name']}' ({materials_set['_id']})") + return materials_set From 8dcd4ca988f9f1aec08e561b2a7c575835944bbe Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 18:57:05 -0700 Subject: [PATCH 08/28] feat: NB to create materials with perturbations for neb --- .../create_neb_path_materials.ipynb | 229 +++++++++++++ .../workflows/prepare_neb_materials.ipynb | 312 ------------------ 2 files changed, 229 insertions(+), 312 deletions(-) create mode 100644 other/materials_designer/create_neb_path_materials.ipynb delete mode 100644 other/materials_designer/workflows/prepare_neb_materials.ipynb diff --git a/other/materials_designer/create_neb_path_materials.ipynb b/other/materials_designer/create_neb_path_materials.ipynb new file mode 100644 index 00000000..31df9b65 --- /dev/null +++ b/other/materials_designer/create_neb_path_materials.ipynb @@ -0,0 +1,229 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Create NEB Path Materials\n", + "\n", + "Build initial → (optional intermediates) → final images for a Nudged Elastic Band path, and write them to a subfolder under `uploads/` in path order for [`create_materials_set.ipynb`](create_materials_set.ipynb).\n", + "\n", + "Order is preserved by **numbering material names** (`00_...`, `01_...`, …): `create_materials_set.ipynb` (and `load_materials_from_folder`) sort by filename, and filenames come from material names.\n", + "\n", + "## Usage\n", + "\n", + "1. Set material and subfolder name in cell 1.2, transform params in 1.3.\n", + "1. Run all cells to build and write the path materials.\n", + "1. Open [`create_materials_set.ipynb`](create_materials_set.ipynb), set the same `SUBFOLDER_NAME` and `IS_ORDERED = True`, and run it to save the materials and create the platform set.\n", + "1. Use the printed set name as `MATERIAL_SET` in [`neb.ipynb`](workflows/neb.ipynb).\n", + "\n", + "## Summary\n", + "\n", + "1. Install packages and set parameters.\n", + "1. Load the starting material.\n", + "1. Clone as the initial image; transform a copy into the final image (default: translate one atom).\n", + "1. Name members in path order and write them to `uploads//`.\n" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "## 1. Set up the environment and parameters\n", + "### 1.1. Install packages (JupyterLite)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.packages import install_packages\n", + "\n", + "await install_packages(\"made|api_examples\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "### 1.2. Set parameters\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Starting material (uploads folder or Standata name match)\n", + "FOLDER = \"uploads\"\n", + "MATERIAL_NAME = \"Silicon\"\n", + "\n", + "# Subfolder under uploads/ to write path materials into — use the same value as\n", + "# SUBFOLDER_NAME in create_materials_set.ipynb.\n", + "SUBFOLDER_NAME = \"neb_silicon\"\n" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "### 1.3. Set example transformation parameters\n", + "Default example: translate one atom in crystal coordinates. Replace this cell and 2.3 below for other paths.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "ATOM_INDEX = 0\n", + "TRANSLATION = [0.0, 0.0, 0.1]\n" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## 2. Build path materials\n", + "### 2.1. Load starting material\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.made.material import Material\n", + "from mat3ra.standata.materials import Materials\n", + "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", + "from mat3ra.notebooks_utils.material import load_material_from_folder\n", + "\n", + "source_material = load_material_from_folder(FOLDER, MATERIAL_NAME) or Material.create(\n", + " Materials.get_by_name_first_match(MATERIAL_NAME)\n", + ")\n", + "visualize(source_material)\n" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "### 2.2. Clone as the initial image\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "initial_material = source_material.clone()\n", + "visualize(initial_material)\n" + ] + }, + { + "cell_type": "markdown", + "id": "11", + "metadata": {}, + "source": [ + "### 2.3. Transform into the final image (example)\n", + "\n", + "Default: translate the atom at `ATOM_INDEX` by `TRANSLATION`. Replace with any other transformation (defects, swaps, `create_perturbation`, custom coordinates, …) — only the resulting list of materials in 2.4 matters.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "final_material = initial_material.clone()\n", + "coordinates = [list(coordinate) for coordinate in final_material.coordinates_array]\n", + "for axis_index, delta in enumerate(TRANSLATION):\n", + " coordinates[ATOM_INDEX][axis_index] += delta\n", + "final_material.set_coordinates(coordinates)\n", + "\n", + "# Optional: localized ∆z via Made create_perturbation (scalar f(x,y,z) → ∆z).\n", + "# import sympy as sp\n", + "# from mat3ra.made.tools.helpers import create_perturbation\n", + "#\n", + "# RADIUS_SIGMA = 0.05\n", + "# x, y, z = sp.symbols(\"x y z\")\n", + "# center_x, center_y, center_z = initial_material.coordinates_array[ATOM_INDEX]\n", + "# weight = sp.exp(\n", + "# -((x - center_x) ** 2 + (y - center_y) ** 2 + (z - center_z) ** 2) / (2 * RADIUS_SIGMA**2)\n", + "# )\n", + "# final_material = create_perturbation(\n", + "# initial_material,\n", + "# TRANSLATION[2] * weight,\n", + "# use_cartesian_coordinates=False,\n", + "# )\n", + "\n", + "print(\n", + " f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → \"\n", + " f\"{final_material.coordinates_array[ATOM_INDEX]}\"\n", + ")\n", + "visualize([initial_material, final_material])\n" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "### 2.4. Name members in path order and write to the subfolder\n", + "\n", + "Numeric prefixes control load order in `create_materials_set.ipynb` (filenames are sorted; filenames come from material names).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.material import set_materials\n", + "from mat3ra.notebooks_utils.settings import UPLOADS_FOLDER\n", + "\n", + "path_materials = [initial_material, final_material]\n", + "for index, material in enumerate(path_materials):\n", + " material.name = f\"{index:02d}_{source_material.name}\"\n", + "\n", + "subfolder_path = f\"{UPLOADS_FOLDER}/{SUBFOLDER_NAME}\"\n", + "set_materials(path_materials, folder_path=subfolder_path)\n", + "print(f\"Wrote {len(path_materials)} material(s) to {subfolder_path}/\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/other/materials_designer/workflows/prepare_neb_materials.ipynb b/other/materials_designer/workflows/prepare_neb_materials.ipynb deleted file mode 100644 index 02c43175..00000000 --- a/other/materials_designer/workflows/prepare_neb_materials.ipynb +++ /dev/null @@ -1,312 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "# Prepare NEB Path Materials\n", - "\n", - "Build an **ordered materials set** for Nudged Elastic Band: start from one structure, transform it into a final image (example: move an atom), then save **first** and **last** into a set for [`neb.ipynb`](neb.ipynb).\n", - "\n", - "## Usage\n", - "\n", - "1. Set material / set name in cell 1.2 and optional displacement params in cell 1.3.\n", - "1. Edit the transformation cell to apply any change you need (`set_coordinates` example provided).\n", - "1. Run all cells.\n", - "1. Copy the printed `MATERIAL_SET` name into `neb.ipynb` (and set `N_IMAGES` there if you only saved first+last).\n", - "\n", - "## Summary\n", - "\n", - "1. Install packages and set parameters.\n", - "1. Authenticate and select account.\n", - "1. Load the starting material.\n", - "1. Clone as initial; transform a copy into the final image.\n", - "1. Save both materials and place them in an ordered materials set." - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "## 1. Set up the environment and parameters\n", - "### 1.1. Install packages (JupyterLite)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "from mat3ra.notebooks_utils.packages import install_packages\n", - "\n", - "await install_packages(\"made|api_examples\")" - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "### 1.2. Set parameters" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "# Auth / organization\n", - "ORGANIZATION_NAME = None\n", - "\n", - "# Starting material (uploads folder or Standata name match)\n", - "FOLDER = \"../uploads\"\n", - "MATERIAL_NAME = \"Silicon\"\n", - "\n", - "# Ordered set name — same string as MATERIAL_SET in neb.ipynb\n", - "MATERIAL_SET_NAME = \"NEB Silicon\"\n" - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "### 1.3. Set example transformation parameters\n", - "Used by the example `set_coordinates` cell below — replace with your own logic as needed." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# Example only: displace one site in crystal coordinates\n", - "ATOM_INDEX = 0\n", - "DISPLACEMENT = [0.0, 0.0, 0.1]" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "## 2. Authenticate and initialize API client\n", - "### 2.1. Authenticate" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "from mat3ra.notebooks_utils.auth import authenticate\n", - "\n", - "await authenticate()" - ] - }, - { - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "### 2.2. Initialize API client" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "from mat3ra.api_client import APIClient\n", - "\n", - "client = APIClient.authenticate()\n", - "client" - ] - }, - { - "cell_type": "markdown", - "id": "11", - "metadata": {}, - "source": [ - "### 2.3. Select account" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "client.list_accounts()" - ] - }, - { - "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}\")" - ] - }, - { - "cell_type": "markdown", - "id": "14", - "metadata": {}, - "source": [ - "## 3. Build initial and final images\n", - "### 3.1. Load starting material" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "from mat3ra.made.material import Material\n", - "from mat3ra.standata.materials import Materials\n", - "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", - "from mat3ra.notebooks_utils.material import load_material_from_folder\n", - "\n", - "source_material = load_material_from_folder(FOLDER, MATERIAL_NAME) or Material.create(\n", - " Materials.get_by_name_first_match(MATERIAL_NAME)\n", - ")\n", - "visualize(source_material)" - ] - }, - { - "cell_type": "markdown", - "id": "16", - "metadata": {}, - "source": [ - "### 3.2. Clone as initial image" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "initial_material = source_material.clone()\n", - "initial_material.name = f\"{source_material.name}-initial\"\n", - "print(f\"Initial: {initial_material.name}\")\n", - "visualize(initial_material)" - ] - }, - { - "cell_type": "markdown", - "id": "18", - "metadata": {}, - "source": [ - "### 3.3. Transform into the final image\n", - "\n", - "Example uses `set_coordinates` to displace one atom. **Edit this cell** for any transformation you need (defects, swaps, custom coordinates, etc.)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19", - "metadata": {}, - "outputs": [], - "source": [ - "final_material = initial_material.clone()\n", - "coordinates = [list(coordinate) for coordinate in final_material.coordinates_array]\n", - "for axis_index, delta in enumerate(DISPLACEMENT):\n", - " coordinates[ATOM_INDEX][axis_index] += delta\n", - "final_material.set_coordinates(coordinates)\n", - "final_material.name = f\"{source_material.name}-final\"\n", - "print(f\"Final: {final_material.name}\")\n", - "print(f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → {final_material.coordinates_array[ATOM_INDEX]}\")\n", - "visualize([initial_material, final_material])" - ] - }, - { - "cell_type": "markdown", - "id": "20", - "metadata": {}, - "source": [ - "## 4. Save to an ordered materials set\n", - "### 4.1. Create or reuse materials on the platform" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21", - "metadata": {}, - "outputs": [], - "source": [ - "from mat3ra.notebooks_utils.core.entity.material.api import get_or_create_material\n", - "\n", - "saved_initial = get_or_create_material(client, initial_material, ACCOUNT_ID)\n", - "saved_final = get_or_create_material(client, final_material, ACCOUNT_ID)\n", - "print(f\"Initial ID: {saved_initial['_id']}\")\n", - "print(f\"Final ID: {saved_final['_id']}\")" - ] - }, - { - "cell_type": "markdown", - "id": "22", - "metadata": {}, - "source": [ - "### 4.2. Create ordered set and move first → last" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "23", - "metadata": {}, - "outputs": [], - "source": [ - "from mat3ra.notebooks_utils.core.entity.material.api import create_ordered_materials_set\n", - "\n", - "materials_set = create_ordered_materials_set(\n", - " client,\n", - " ACCOUNT_ID,\n", - " MATERIAL_SET_NAME,\n", - " [saved_initial, saved_final],\n", - ")\n", - "print(f\"MATERIAL_SET = {materials_set['name']!r}\")\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From 4e3833c3d42581d1272c6ed5c04244fb546cd21b Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 18:57:58 -0700 Subject: [PATCH 09/28] feat: NEB NB --- other/materials_designer/Introduction.ipynb | 7 ++- other/materials_designer/workflows/neb.ipynb | 64 ++++++++++++++------ 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/other/materials_designer/Introduction.ipynb b/other/materials_designer/Introduction.ipynb index ba6dce33..81059e0e 100644 --- a/other/materials_designer/Introduction.ipynb +++ b/other/materials_designer/Introduction.ipynb @@ -81,6 +81,7 @@ "### 5.2. 2D\n", "#### [5.2.1. Perturbation using sine wave `X-2D-PER`](create_perturbation.ipynb)\n", "#### [5.2.2. Perturbation using custom function `X-2D-PER`](create_perturbation_custom.ipynb)\n", + "#### [5.2.3. Create NEB path materials](create_neb_path_materials.ipynb). Transform a material into initial/intermediate/final images for a Nudged Elastic Band path.\n", "\n", "\n", "## 6. Other.\n", @@ -97,6 +98,10 @@ "\n", "This notebook demonstrates a workflow for converting materials data from the [JARVIS](https://jarvis.nist.gov/) database into ESSE format for use with the Mat3ra.com platform.\n", "\n", + "### 6.2. Materials sets.\n", + "\n", + "#### [6.2.1. Create materials set (ordered or unordered)](create_materials_set.ipynb). Save materials to a platform set for NEB, convex hull, EOS, etc.\n", + "\n", "## 6.3. Development.\n", "\n", "#### [6.3.1. Custom Transformation](custom_transformation.ipynb). Notebook setup for development of custom transformations on materials.\n", @@ -105,7 +110,7 @@ "\n", "### 7.1. Under the hood.\n", "\n", - "#### [7.1.1. More info about the conventions used](under_the_hood.ipynb)." + "#### [7.1.1. More info about the conventions used](under_the_hood.ipynb).\n" ] }, { diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index c6cf96ed..a68f14a0 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -9,20 +9,25 @@ "\n", "Calculate the reaction energy barrier and energy profile along a reaction path using Quantum ESPRESSO `neb.x` on the Mat3ra platform.\n", "\n", - "## Prerequisites\n", + "## Prerequisites — where do the path materials come from?\n", "\n", - "Before running this notebook, put the NEB path structures on the platform in an **ordered materials set**:\n", + "This notebook needs an **ordered** path: **first** = initial image, **last** = final image, optional **middle** intermediates. Order follows the materials-set indices (same as the job designer), not material names.\n", "\n", - "1. **First** member = initial image (required).\n", - "2. **Last** member = final image (required).\n", - "3. **Middle** members = intermediate images (optional).\n", - "4. If the set has only first+last, set `N_IMAGES` (e.g. `20`) so Quantum ESPRESSO interpolates intermediates.\n", + "If you do not already have those materials on the platform, prepare them first in Materials Designer:\n", "\n", - "Path order follows the ordered-set indices (same as the job designer), not material names.\n", + "1. Open [`create_neb_path_materials.ipynb`](../create_neb_path_materials.ipynb) (under Materials / Perturbations). Build the initial → final (and optional intermediate) images and run it so they are written to `uploads//` with numbered names (`00_…`, `01_…`, …).\n", + "\n", + "Then choose **one** of these ways to feed this NEB notebook:\n", + "\n", + "1. **From the subfolder (this notebook packages the set):** set `SUBFOLDER_NAME` to that folder name (e.g. `\"neb_silicon\"`). This notebook loads the files, saves the materials, and gets or creates the ordered set named `MATERIAL_SET`.\n", + "1. **From a materials set you build separately:** leave `SUBFOLDER_NAME = None`, run [`create_materials_set.ipynb`](../create_materials_set.ipynb) with the same `SUBFOLDER_NAME` and `IS_ORDERED = True`, then set `MATERIAL_SET` here to that set’s name.\n", + "1. **From a materials set you already have:** leave `SUBFOLDER_NAME = None` and set `MATERIAL_SET` to an existing ordered set on your account that already contains the path (first → … → last).\n", + "\n", + "If the set has only first+last (no middle images), set `N_IMAGES` (e.g. `20`) so Quantum ESPRESSO interpolates intermediates.\n", "\n", "

Usage

\n", "\n", - "1. Set `MATERIAL_SET` in cell 1.2 and NEB-specific settings (`N_IMAGES`, k-grid) in cell 1.3 below.\n", + "1. Set `MATERIAL_SET` and optional `SUBFOLDER_NAME` in cell 1.2; NEB settings (`N_IMAGES`, k-grid) in cell 1.3.\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 reaction energy profile.\n", @@ -31,12 +36,12 @@ "\n", "1. Set up the environment and parameters: install packages (JupyterLite only) and configure materials lookup, workflow, compute, and job.\n", "1. Authenticate and initialize API client: authenticate via browser, initialize the client, then select account and project.\n", - "1. Resolve NEB path materials from an ordered materials set (first, optional intermediates, last).\n", + "1. Resolve NEB path materials — from `SUBFOLDER_NAME` (get or create set) or from an existing ordered materials set.\n", "1. Create workflow and set its parameters: load the NEB workflow from Standata, set k-grid / `N_IMAGES`, and save the workflow.\n", "1. Configure compute: get list of clusters and create compute configuration.\n", "1. Create the multi-material NEB job.\n", "1. Submit the job and monitor the status.\n", - "1. Retrieve results: visualize the reaction energy profile along the path." + "1. Retrieve results: visualize the reaction energy profile along the path.\n" ] }, { @@ -82,9 +87,12 @@ "# Set organization name to use it as the owner, otherwise your personal account is used\n", "ORGANIZATION_NAME = None\n", "\n", - "# 3. Material parameters (prerequisite: ordered materials set on the platform)\n", + "# 3. Material parameters\n", "# Order in the set: first = initial, middle = intermediates (optional), last = final\n", "MATERIAL_SET = \"H2+H\"\n", + "# If set, load path materials from ../uploads/{SUBFOLDER_NAME} and get or create MATERIAL_SET.\n", + "# If None, reuse the existing ordered set named MATERIAL_SET on the platform.\n", + "SUBFOLDER_NAME = None # e.g. \"neb_silicon\"\n", "\n", "# 4. Workflow parameters\n", "WORKFLOW_SEARCH_TERM = \"neb.json\"\n", @@ -227,8 +235,8 @@ "metadata": {}, "source": [ "## 3. Resolve NEB path materials\n", - "### 3.1. Load materials from an ordered set\n", - "Members are loaded in ordered-set index order: first → intermediates (if any) → last." + "### 3.1. Load from subfolder or reuse an ordered set\n", + "If `SUBFOLDER_NAME` is set, load numbered files from `../uploads/{SUBFOLDER_NAME}`, save them, and get or create `MATERIAL_SET`. Otherwise load members from the existing ordered set in index order: first → intermediates (if any) → last." ] }, { @@ -239,10 +247,32 @@ "outputs": [], "source": [ "from mat3ra.made.material import Material\n", - "from mat3ra.notebooks_utils.core.entity.material.api import list_materials_by_set\n", + "from mat3ra.notebooks_utils.core.entity.material.api import (\n", + " get_or_create_material,\n", + " get_or_create_materials_set,\n", + " list_materials_by_set,\n", + ")\n", "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", + "from mat3ra.notebooks_utils.material import load_materials_from_folder\n", + "from mat3ra.notebooks_utils.settings import UPLOADS_FOLDER\n", + "\n", + "if SUBFOLDER_NAME:\n", + " folder_materials = load_materials_from_folder(f\"../{UPLOADS_FOLDER}/{SUBFOLDER_NAME}\")\n", + " saved_responses = [\n", + " get_or_create_material(client, material, ACCOUNT_ID) for material in folder_materials\n", + " ]\n", + " materials_set = get_or_create_materials_set(\n", + " client,\n", + " ACCOUNT_ID,\n", + " MATERIAL_SET,\n", + " saved_responses,\n", + " is_ordered=True,\n", + " )\n", + " MATERIAL_SET = materials_set[\"name\"]\n", + " material_dicts = list_materials_by_set(client, ACCOUNT_ID, MATERIAL_SET)\n", + "else:\n", + " material_dicts = list_materials_by_set(client, ACCOUNT_ID, MATERIAL_SET)\n", "\n", - "material_dicts = list_materials_by_set(client, ACCOUNT_ID, MATERIAL_SET)\n", "if len(material_dicts) < 2:\n", " raise ValueError(\n", " f\"Ordered set '{MATERIAL_SET}' must contain at least first and last images \"\n", @@ -255,7 +285,7 @@ " for material in materials[1:-1]:\n", " print(f\" intermediate: {material.name} ({material.id})\")\n", "print(f\" last (final): {materials[-1].name} ({materials[-1].id})\")\n", - "visualize(materials)" + "visualize(materials)\n" ] }, { @@ -264,7 +294,7 @@ "metadata": {}, "source": [ "### 3.2. Use resolved materials for the job\n", - "Path materials already exist on the platform in the ordered set." + "Path materials are on the platform in the ordered set (reused or just created from `SUBFOLDER_NAME`)." ] }, { From 414a29741fccd964f774f3bc3996813f6c939ce0 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 18:58:41 -0700 Subject: [PATCH 10/28] update: helpers with material set --- .../core/entity/material/api.py | 64 ++++++--- .../py/unit/core/entity/test_material_api.py | 135 +++++++++++++++--- 2 files changed, 163 insertions(+), 36 deletions(-) diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py index 4e70c067..22525aa0 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional def get_or_create_material(api_client: Any, material, owner_id: str) -> dict: @@ -108,38 +108,64 @@ def _move_materials_into_set(api_client: Any, material_set_id: str, materials: L ) -def create_ordered_materials_set( +ORDERED_ENTITY_SET_TYPE = "ordered" +UNORDERED_ENTITY_SET_TYPE = "unordered" + + +def _find_existing_materials_set(api_client: Any, owner_id: str, material_set_name: str) -> Optional[Dict[str, Any]]: + try: + return find_material_set(api_client, owner_id, material_set_name) + except ValueError: + return None + + +def get_or_create_materials_set( api_client: Any, owner_id: str, material_set_name: str, materials: List[Any], + is_ordered: bool = False, ) -> Dict[str, Any]: """ - Create an ordered materials set and move members in path order. + Reuse an existing materials set by name, or create one, then move members into it. - Move order is first → intermediates → last so the platform can assign - ascending `inSet.index` values that `list_materials_by_set` reads. + For `is_ordered=True`, members are moved in list order so the platform can + assign ascending `inSet.index` values (e.g. NEB path). For unordered sets, + membership is a bag (e.g. convex hull, EOS series). Args: api_client: API client instance carrying the authorization context. - owner_id: Account ID under which to create the set. - material_set_name: Name for the new ordered set. - materials: At least two materials (dict responses or Made objects with `.id`). + owner_id: Account ID under which to find or create the set. + material_set_name: Name of the set to reuse or create. + materials: Materials to include (dict responses or Made objects with `.id`). + is_ordered: Whether path order (`inSet.index`) matters for this set. Returns: - The created materials set document. + The existing or newly created materials set document. Raises: - ValueError: If fewer than two materials are provided. + ValueError: If materials are empty, or ordered set has fewer than two members. """ - if len(materials) < 2: - raise ValueError("Ordered NEB set needs at least first and last materials.") - set_config = { - "name": material_set_name, - "owner": {"_id": owner_id}, - "entitySetType": "ordered", - } - materials_set = api_client.materials.create_set(set_config) + if not materials: + raise ValueError("Materials set needs at least one material.") + if is_ordered and len(materials) < 2: + raise ValueError("Ordered materials set needs at least two materials.") + + materials_set = _find_existing_materials_set(api_client, owner_id, material_set_name) + if materials_set is None: + entity_set_type = ORDERED_ENTITY_SET_TYPE if is_ordered else UNORDERED_ENTITY_SET_TYPE + set_config = { + "name": material_set_name, + "owner": {"_id": owner_id}, + "entitySetType": entity_set_type, + } + materials_set = api_client.materials.create_set(set_config) + print(f"✅ Materials set '{materials_set['name']}' " f"({entity_set_type}, {materials_set['_id']})") + else: + print( + f"♻️ Reusing existing materials set '{materials_set['name']}' " + f"({materials_set.get('entitySetType')}, {materials_set['_id']})" + ) + _move_materials_into_set(api_client, materials_set["_id"], materials) - print(f"✅ Ordered materials set '{materials_set['name']}' ({materials_set['_id']})") return materials_set diff --git a/tests/py/unit/core/entity/test_material_api.py b/tests/py/unit/core/entity/test_material_api.py index cfdc7c9d..e403b68a 100644 --- a/tests/py/unit/core/entity/test_material_api.py +++ b/tests/py/unit/core/entity/test_material_api.py @@ -2,39 +2,49 @@ from unittest.mock import MagicMock import pytest -from mat3ra.notebooks_utils.core.entity.material.api import find_material_set, list_materials_by_set +from mat3ra.notebooks_utils.core.entity.material.api import ( + find_material_set, + get_or_create_materials_set, + list_materials_by_set, +) OWNER_ID = "account-1" -SET_NAME = "H2+H" -SET_ID = "set-1" +MATERIAL_SET_NAME = "H2+H" +MATERIAL_SET_ID = "set-1" MATERIAL_INITIAL: Dict[str, Any] = { "_id": "m-initial", "name": "path-start", "isEntitySet": False, - "inSet": [{"_id": SET_ID, "index": 0}], + "inSet": [{"_id": MATERIAL_SET_ID, "index": 0}], } MATERIAL_IMAGE: Dict[str, Any] = { "_id": "m-image", "name": "path-mid", "isEntitySet": False, - "inSet": [{"_id": SET_ID, "index": 1}], + "inSet": [{"_id": MATERIAL_SET_ID, "index": 1}], } MATERIAL_FINAL: Dict[str, Any] = { "_id": "m-final", "name": "path-end", "isEntitySet": False, - "inSet": [{"_id": SET_ID, "index": 2}], + "inSet": [{"_id": MATERIAL_SET_ID, "index": 2}], +} +ENTITY_SET: Dict[str, Any] = { + "_id": MATERIAL_SET_ID, + "name": "H2+H", + "isEntitySet": True, + "entitySetType": "ordered", } -ENTITY_SET: Dict[str, Any] = {"_id": SET_ID, "name": "H2+H", "isEntitySet": True} -# Scrambled API order — list_materials_by_set must sort by inSet.index -SET_MEMBER_MATERIALS: List[Dict[str, Any]] = [ +SET_MEMBER_MATERIALS_OUT_OF_ORDER: List[Dict[str, Any]] = [ MATERIAL_FINAL, MATERIAL_INITIAL, MATERIAL_IMAGE, ENTITY_SET, ] +EXPECTED_ORDERED_IDS = ["m-initial", "m-image", "m-final"] +EXPECTED_SINGLE_MEMBER_IDS = ["m-initial"] def _client_with_list_responses(responses: List[List[Dict[str, Any]]]) -> MagicMock: @@ -45,35 +55,126 @@ def _client_with_list_responses(responses: List[List[Dict[str, Any]]]) -> MagicM def test_find_material_set_returns_first_match(): client = _client_with_list_responses([[ENTITY_SET]]) - material_set = find_material_set(client, OWNER_ID, SET_NAME) - assert material_set["_id"] == SET_ID + + material_set = find_material_set(client, OWNER_ID, MATERIAL_SET_NAME) + + assert material_set["_id"] == MATERIAL_SET_ID client.materials.list.assert_called_once_with( { "owner._id": OWNER_ID, "isEntitySet": True, - "name": {"$regex": SET_NAME, "$options": "i"}, + "name": {"$regex": MATERIAL_SET_NAME, "$options": "i"}, } ) def test_find_material_set_raises_when_missing(): client = _client_with_list_responses([[]]) + with pytest.raises(ValueError, match="No material set matching"): - find_material_set(client, OWNER_ID, SET_NAME) + find_material_set(client, OWNER_ID, MATERIAL_SET_NAME) @pytest.mark.parametrize( ("members", "expected_ids"), [ - (SET_MEMBER_MATERIALS, ["m-initial", "m-image", "m-final"]), - ([MATERIAL_INITIAL], ["m-initial"]), + (SET_MEMBER_MATERIALS_OUT_OF_ORDER, EXPECTED_ORDERED_IDS), + ([MATERIAL_INITIAL], EXPECTED_SINGLE_MEMBER_IDS), ], ) def test_list_materials_by_set_orders_by_inset_index(members, expected_ids): client = _client_with_list_responses([[ENTITY_SET], members]) - materials = list_materials_by_set(client, OWNER_ID, SET_NAME) + + materials = list_materials_by_set(client, OWNER_ID, MATERIAL_SET_NAME) + assert [material["_id"] for material in materials] == expected_ids assert client.materials.list.call_args_list[1].args[0] == { "owner._id": OWNER_ID, - "inSet._id": SET_ID, + "inSet._id": MATERIAL_SET_ID, } + + +@pytest.mark.parametrize( + ("is_ordered", "expected_entity_set_type", "materials"), + [ + (True, "ordered", [MATERIAL_INITIAL, MATERIAL_FINAL]), + (False, "unordered", [MATERIAL_INITIAL]), + ], +) +def test_get_or_create_materials_set_creates_when_missing(is_ordered, expected_entity_set_type, materials): + client = _client_with_list_responses([[]]) + client.materials.create_set.return_value = { + **ENTITY_SET, + "entitySetType": expected_entity_set_type, + } + + materials_set = get_or_create_materials_set( + client, + OWNER_ID, + MATERIAL_SET_NAME, + materials, + is_ordered=is_ordered, + ) + + assert materials_set["_id"] == MATERIAL_SET_ID + client.materials.create_set.assert_called_once_with( + { + "name": MATERIAL_SET_NAME, + "owner": {"_id": OWNER_ID}, + "entitySetType": expected_entity_set_type, + } + ) + assert client.materials.move_to_set.call_args_list[0].args == ( + materials[0]["_id"], + "", + MATERIAL_SET_ID, + ) + + +def test_get_or_create_materials_set_reuses_when_found(): + client = _client_with_list_responses([[ENTITY_SET]]) + materials = [MATERIAL_INITIAL, MATERIAL_FINAL] + + materials_set = get_or_create_materials_set( + client, + OWNER_ID, + MATERIAL_SET_NAME, + materials, + is_ordered=True, + ) + + assert materials_set["_id"] == MATERIAL_SET_ID + client.materials.create_set.assert_not_called() + assert client.materials.move_to_set.call_args_list[0].args == ( + materials[0]["_id"], + "", + MATERIAL_SET_ID, + ) + + +def test_get_or_create_materials_set_ordered_requires_two_materials(): + client = MagicMock() + + with pytest.raises(ValueError, match="at least two materials"): + get_or_create_materials_set( + client, + OWNER_ID, + MATERIAL_SET_NAME, + [MATERIAL_INITIAL], + is_ordered=True, + ) + client.materials.list.assert_not_called() + + +def test_get_or_create_materials_set_requires_one_material(): + client = MagicMock() + + with pytest.raises(ValueError, match="at least one material"): + get_or_create_materials_set( + client, + OWNER_ID, + MATERIAL_SET_NAME, + [], + is_ordered=False, + ) + client.materials.list.assert_not_called() From c587db0e008411623d4b9c718032e3d17d3b945e Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 18:59:01 -0700 Subject: [PATCH 11/28] update: reuse helper --- other/materials_designer/workflows/analyze_convex_hull.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/other/materials_designer/workflows/analyze_convex_hull.ipynb b/other/materials_designer/workflows/analyze_convex_hull.ipynb index c4a013ed..5c5059fe 100644 --- a/other/materials_designer/workflows/analyze_convex_hull.ipynb +++ b/other/materials_designer/workflows/analyze_convex_hull.ipynb @@ -16,7 +16,7 @@ "3. The notebook finds materials, retrieves total energies, and builds the convex hull.\n", "\n", "**Prerequisites:**\n", - "1. Save materials to a material set (for simpler search).\n", + "1. Save materials to a material set (for simpler search) — see [`create_materials_set.ipynb`](../create_materials_set.ipynb) (`IS_ORDERED = False`).\n", "2. Relax if needed and store relaxed structures there.\n", "3. Calculate total energies for the materials with the same formalism (e.g. DFT functional) and needed precision.\n", "\n", @@ -26,7 +26,7 @@ "2. **Preview** the materials (formula, structure type, space group)\n", "3. **Retrieve total energies** from completed jobs\n", "4. **Build convex hull** using pymatgen PhaseDiagram\n", - "5. **Analyze stability** — formation energies, energy above hull, decomposition products" + "5. **Analyze stability** — formation energies, energy above hull, decomposition products\n" ] }, { From 85b31be75397bfb94aab40d3376c367dcaa299bf Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 20 Jul 2026 18:59:57 -0700 Subject: [PATCH 12/28] update: create materials set --- .../create_materials_set.ipynb | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 other/materials_designer/create_materials_set.ipynb diff --git a/other/materials_designer/create_materials_set.ipynb b/other/materials_designer/create_materials_set.ipynb new file mode 100644 index 00000000..20d1f124 --- /dev/null +++ b/other/materials_designer/create_materials_set.ipynb @@ -0,0 +1,248 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Create Materials Set\n", + "\n", + "Save materials to a platform materials set — **ordered** (path order, e.g. NEB) or **unordered** (e.g. convex hull, EOS series).\n", + "\n", + "This notebook only packages materials — it does not build or transform them. Build materials elsewhere (Materials Designer, a `create_*` notebook, or a dedicated builder notebook such as [`create_neb_path_materials.ipynb`](create_neb_path_materials.ipynb)), then either:\n", + "\n", + "- Select them as **Input Materials** (outer runtime), or\n", + "- Write them to a subfolder under `uploads/` with `set_materials(materials, folder_path=f\"uploads/{{SUBFOLDER_NAME}}\")` — filename sort order becomes set order for ordered sets, so number material names (`00_...`, `01_...`, …).\n", + "\n", + "## Usage\n", + "\n", + "1. Set `MATERIAL_SET_NAME`, `IS_ORDERED`, and `SUBFOLDER_NAME` in cell 1.2.\n", + "1. Click \"Run\" > \"Run All\".\n", + "\n", + "## Summary\n", + "\n", + "1. Install packages and set parameters.\n", + "1. Authenticate and select account.\n", + "1. Load materials — from `SUBFOLDER_NAME` if set, otherwise Input Materials / the `uploads/` root.\n", + "1. Save materials on the platform and get or create the materials set (reuses `MATERIAL_SET_NAME` if it already exists, otherwise creates it).\n" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "## 1. Set up the environment and parameters\n", + "### 1.1. Install packages (JupyterLite)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.packages import install_packages\n", + "\n", + "await install_packages(\"made|api_examples\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "### 1.2. Set parameters\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "# Auth / organization\n", + "ORGANIZATION_NAME = None\n", + "\n", + "# Materials set on the platform (use this name in workflow notebooks)\n", + "MATERIAL_SET_NAME = \"NEB Silicon\"\n", + "# True = path order via inSet.index (NEB); False = bag of members (hull, EOS, …)\n", + "IS_ORDERED = True\n", + "\n", + "# Subfolder under uploads/ to load materials from (written there by a builder notebook via\n", + "# set_materials(materials, folder_path=f\"uploads/{SUBFOLDER_NAME}\")).\n", + "# None = use Input Materials (outer runtime), falling back to the uploads/ root.\n", + "SUBFOLDER_NAME = None\n" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "## 2. Authenticate and initialize API client\n", + "### 2.1. Authenticate\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.auth import authenticate\n", + "\n", + "await authenticate()\n" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "### 2.2. Initialize API client\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.api_client import APIClient\n", + "\n", + "client = APIClient.authenticate()\n", + "client\n" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "### 2.3. Select account\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "client.list_accounts()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "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": "12", + "metadata": {}, + "source": [ + "## 3. Load materials for the set\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", + "from mat3ra.notebooks_utils.material import get_materials, load_materials_from_folder\n", + "from mat3ra.notebooks_utils.settings import UPLOADS_FOLDER\n", + "\n", + "if SUBFOLDER_NAME:\n", + " materials = load_materials_from_folder(f\"{UPLOADS_FOLDER}/{SUBFOLDER_NAME}\")\n", + "else:\n", + " materials = get_materials()\n", + "\n", + "print(f\"Loaded {len(materials)} material(s): {[material.name for material in materials]}\")\n", + "visualize(materials)\n" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "## 4. Save to a materials set\n", + "### 4.1. Create or reuse materials on the platform\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.core.entity.material.api import get_or_create_material\n", + "\n", + "saved_materials = [get_or_create_material(client, material, ACCOUNT_ID) for material in materials]\n", + "for material, saved in zip(materials, saved_materials):\n", + " print(f\"{material.name}: {saved['_id']}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "### 4.2. Get or create the materials set and move members\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "from mat3ra.notebooks_utils.core.entity.material.api import get_or_create_materials_set\n", + "\n", + "materials_set = get_or_create_materials_set(\n", + " client,\n", + " ACCOUNT_ID,\n", + " MATERIAL_SET_NAME,\n", + " saved_materials,\n", + " is_ordered=IS_ORDERED,\n", + ")\n", + "print(f\"MATERIAL_SET = {materials_set['name']!r} (is_ordered={IS_ORDERED})\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 730f2961f7985f54ca86f93548d1d41bc6d1ee3e Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 21 Jul 2026 11:09:30 -0700 Subject: [PATCH 13/28] fix: NEB materials-set lookup and job materialsSet wiring Escape regex special characters in set name queries (H2+H), pass _materialsSet on NEB job create, and fail clearly when the job does not finish so Cypress matches the UI NEB path. Co-authored-by: Cursor --- other/materials_designer/workflows/neb.ipynb | 12 +++- .../notebooks_utils/core/entity/job/api.py | 16 ++++- .../core/entity/material/api.py | 3 +- tests/py/unit/core/entity/test_job_api.py | 72 +++++++++++++++++++ .../py/unit/core/entity/test_material_api.py | 5 +- 5 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 tests/py/unit/core/entity/test_job_api.py diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index a68f14a0..ba1590dc 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -248,6 +248,7 @@ "source": [ "from mat3ra.made.material import Material\n", "from mat3ra.notebooks_utils.core.entity.material.api import (\n", + " find_material_set,\n", " get_or_create_material,\n", " get_or_create_materials_set,\n", " list_materials_by_set,\n", @@ -271,6 +272,7 @@ " MATERIAL_SET = materials_set[\"name\"]\n", " material_dicts = list_materials_by_set(client, ACCOUNT_ID, MATERIAL_SET)\n", "else:\n", + " materials_set = find_material_set(client, ACCOUNT_ID, MATERIAL_SET)\n", " material_dicts = list_materials_by_set(client, ACCOUNT_ID, MATERIAL_SET)\n", "\n", "if len(material_dicts) < 2:\n", @@ -503,7 +505,8 @@ " project_id=project_id,\n", " owner_id=ACCOUNT_ID,\n", " prefix=job_name,\n", - " compute=compute.to_dict()\n", + " compute=compute.to_dict(),\n", + " materials_set=materials_set,\n", ")\n", "\n", "job = dict_to_namespace_recursive(job_response)\n", @@ -541,7 +544,12 @@ "source": [ "from mat3ra.notebooks_utils.api.job import wait_for_jobs_to_finish_async\n", "\n", - "await wait_for_jobs_to_finish_async(client.jobs, [job_id], poll_interval=POLL_INTERVAL)" + "await wait_for_jobs_to_finish_async(client.jobs, [job_id], poll_interval=POLL_INTERVAL)\n", + "\n", + "job_status = client.jobs.get(job_id)[\"status\"]\n", + "if job_status != \"finished\":\n", + " job_errors = (client.jobs.get(job_id).get(\"compute\") or {}).get(\"errors\") or []\n", + " raise RuntimeError(f\"Job {job_id} ended with status={job_status!r}, errors={job_errors!r}\")\n" ] }, { diff --git a/src/py/mat3ra/notebooks_utils/core/entity/job/api.py b/src/py/mat3ra/notebooks_utils/core/entity/job/api.py index 5bec1189..ee6b1f2e 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/job/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/job/api.py @@ -1,5 +1,5 @@ import urllib.request -from typing import List, Optional, Union +from typing import Any, Dict, List, Optional, Union from mat3ra.api_client import APIClient, JobEndpoints @@ -37,6 +37,14 @@ def get_jobs_statuses_by_ids(endpoint: JobEndpoints, job_ids: List[str]) -> List return [job["status"] for job in jobs] +def _materials_set_reference(materials_set: Dict[str, Any]) -> Dict[str, str]: + return { + "_id": materials_set["_id"], + "cls": "Material", + "slug": materials_set.get("slug") or materials_set.get("name") or "", + } + + def create_job( api_client: APIClient, materials: List[dict], @@ -45,6 +53,7 @@ def create_job( owner_id: str, prefix: str, compute: Optional[dict] = None, + materials_set: Optional[Dict[str, Any]] = None, ) -> Union[dict, List[dict]]: """ Creates jobs using pre-serialised material and workflow dicts. @@ -57,6 +66,8 @@ def create_job( owner_id (str): Account ID. prefix (str): Job name prefix. compute (dict, optional): Compute configuration dict. + materials_set (dict, optional): Ordered/unordered materials set document + (same contract as the job designer `_materialsSet`). Returns: dict | list[dict]: Created job(s). @@ -75,6 +86,9 @@ def create_job( if is_multimaterial: config["_materials"] = [{"_id": m["_id"]} for m in materials] + if materials_set is not None: + config["_materialsSet"] = _materials_set_reference(materials_set) + if compute: config["compute"] = compute diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py index 22525aa0..9b2a2cd8 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py @@ -1,3 +1,4 @@ +import re from typing import Any, Dict, List, Optional @@ -54,7 +55,7 @@ def find_material_set(api_client: Any, owner_id: str, material_set_name: str) -> { "owner._id": owner_id, "isEntitySet": True, - "name": {"$regex": material_set_name, "$options": "i"}, + "name": {"$regex": re.escape(material_set_name), "$options": "i"}, } ) if not material_sets: diff --git a/tests/py/unit/core/entity/test_job_api.py b/tests/py/unit/core/entity/test_job_api.py new file mode 100644 index 00000000..762cd5e5 --- /dev/null +++ b/tests/py/unit/core/entity/test_job_api.py @@ -0,0 +1,72 @@ +from typing import Any, Dict, List +from unittest.mock import MagicMock + +import pytest +from mat3ra.notebooks_utils.core.entity.job.api import create_job + +OWNER_ID = "account-1" +PROJECT_ID = "project-1" +JOB_PREFIX = "NEB H2+H" +MATERIAL_SET_ID = "set-1" +MATERIAL_SET_NAME = "H2+H" + +MATERIAL_INITIAL: Dict[str, Any] = {"_id": "m-initial", "name": "initial"} +MATERIAL_FINAL: Dict[str, Any] = {"_id": "m-final", "name": "final"} +MATERIALS: List[Dict[str, Any]] = [MATERIAL_INITIAL, MATERIAL_FINAL] + +MULTI_MATERIAL_WORKFLOW: Dict[str, Any] = { + "_id": "workflow-1", + "name": "NEB", + "isMultiMaterial": True, +} +SINGLE_MATERIAL_WORKFLOW: Dict[str, Any] = { + "_id": "workflow-2", + "name": "Total Energy", + "isMultiMaterial": False, +} +MATERIALS_SET: Dict[str, Any] = { + "_id": MATERIAL_SET_ID, + "name": MATERIAL_SET_NAME, + "slug": MATERIAL_SET_NAME, + "isEntitySet": True, +} +CREATED_JOB: Dict[str, Any] = {"_id": "job-1", "name": JOB_PREFIX} + + +@pytest.mark.parametrize( + ("workflow", "materials_set", "expected_materials_set"), + [ + (MULTI_MATERIAL_WORKFLOW, MATERIALS_SET, True), + (MULTI_MATERIAL_WORKFLOW, None, False), + (SINGLE_MATERIAL_WORKFLOW, MATERIALS_SET, True), + ], +) +def test_create_job_sets_materials_set_when_provided(workflow, materials_set, expected_materials_set): + client = MagicMock() + client.jobs.create.return_value = CREATED_JOB + workflow_payload = dict(workflow) + + job = create_job( + api_client=client, + materials=MATERIALS, + workflow=workflow_payload, + project_id=PROJECT_ID, + owner_id=OWNER_ID, + prefix=JOB_PREFIX, + materials_set=materials_set, + ) + + assert job["_id"] == "job-1" + config = client.jobs.create.call_args.args[0] + assert "_id" not in workflow_payload + assert ("_materialsSet" in config) is expected_materials_set + if expected_materials_set: + assert config["_materialsSet"] == { + "_id": MATERIAL_SET_ID, + "cls": "Material", + "slug": MATERIAL_SET_NAME, + } + if workflow.get("isMultiMaterial"): + assert config["_materials"] == [{"_id": "m-initial"}, {"_id": "m-final"}] + else: + assert "_materials" not in config diff --git a/tests/py/unit/core/entity/test_material_api.py b/tests/py/unit/core/entity/test_material_api.py index e403b68a..0f6c7140 100644 --- a/tests/py/unit/core/entity/test_material_api.py +++ b/tests/py/unit/core/entity/test_material_api.py @@ -1,3 +1,4 @@ +import re from typing import Any, Dict, List from unittest.mock import MagicMock @@ -63,9 +64,11 @@ def test_find_material_set_returns_first_match(): { "owner._id": OWNER_ID, "isEntitySet": True, - "name": {"$regex": MATERIAL_SET_NAME, "$options": "i"}, + "name": {"$regex": re.escape(MATERIAL_SET_NAME), "$options": "i"}, } ) + assert "+" in MATERIAL_SET_NAME + assert re.escape(MATERIAL_SET_NAME) != MATERIAL_SET_NAME def test_find_material_set_raises_when_missing(): From f49139c1b95fbf0ddae3c320acb805d0084fd478 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 18:33:39 -0700 Subject: [PATCH 14/28] rename: create_neb_images, utils_create_material_set (TB review) Rename create_neb_path_materials.ipynb -> create_neb_images.ipynb and create_materials_set.ipynb -> utils_create_material_set.ipynb, and update their titles, TOC entries in Introduction.ipynb, and cross-links from neb.ipynb and analyze_convex_hull.ipynb. --- other/materials_designer/Introduction.ipynb | 4 ++-- ..._path_materials.ipynb => create_neb_images.ipynb} | 12 ++++++------ ...als_set.ipynb => utils_create_material_set.ipynb} | 4 ++-- .../workflows/analyze_convex_hull.ipynb | 2 +- other/materials_designer/workflows/neb.ipynb | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) rename other/materials_designer/{create_neb_path_materials.ipynb => create_neb_images.ipynb} (90%) rename other/materials_designer/{create_materials_set.ipynb => utils_create_material_set.ipynb} (98%) diff --git a/other/materials_designer/Introduction.ipynb b/other/materials_designer/Introduction.ipynb index 81059e0e..434efad1 100644 --- a/other/materials_designer/Introduction.ipynb +++ b/other/materials_designer/Introduction.ipynb @@ -81,7 +81,7 @@ "### 5.2. 2D\n", "#### [5.2.1. Perturbation using sine wave `X-2D-PER`](create_perturbation.ipynb)\n", "#### [5.2.2. Perturbation using custom function `X-2D-PER`](create_perturbation_custom.ipynb)\n", - "#### [5.2.3. Create NEB path materials](create_neb_path_materials.ipynb). Transform a material into initial/intermediate/final images for a Nudged Elastic Band path.\n", + "#### [5.2.3. Create NEB images](create_neb_images.ipynb). Transform a material into initial/intermediate/final images for a Nudged Elastic Band path.\n", "\n", "\n", "## 6. Other.\n", @@ -100,7 +100,7 @@ "\n", "### 6.2. Materials sets.\n", "\n", - "#### [6.2.1. Create materials set (ordered or unordered)](create_materials_set.ipynb). Save materials to a platform set for NEB, convex hull, EOS, etc.\n", + "#### [6.2.1. Create material set (ordered or unordered)](utils_create_material_set.ipynb). Save materials to a platform set for NEB, convex hull, EOS, etc.\n", "\n", "## 6.3. Development.\n", "\n", diff --git a/other/materials_designer/create_neb_path_materials.ipynb b/other/materials_designer/create_neb_images.ipynb similarity index 90% rename from other/materials_designer/create_neb_path_materials.ipynb rename to other/materials_designer/create_neb_images.ipynb index 31df9b65..bb9cfecd 100644 --- a/other/materials_designer/create_neb_path_materials.ipynb +++ b/other/materials_designer/create_neb_images.ipynb @@ -5,17 +5,17 @@ "id": "0", "metadata": {}, "source": [ - "# Create NEB Path Materials\n", + "# Create NEB Images\n", "\n", - "Build initial → (optional intermediates) → final images for a Nudged Elastic Band path, and write them to a subfolder under `uploads/` in path order for [`create_materials_set.ipynb`](create_materials_set.ipynb).\n", + "Build initial → (optional intermediates) → final images for a Nudged Elastic Band path, and write them to a subfolder under `uploads/` in path order for [`utils_create_material_set.ipynb`](utils_create_material_set.ipynb).\n", "\n", - "Order is preserved by **numbering material names** (`00_...`, `01_...`, …): `create_materials_set.ipynb` (and `load_materials_from_folder`) sort by filename, and filenames come from material names.\n", + "Order is preserved by **numbering material names** (`00_...`, `01_...`, …): `utils_create_material_set.ipynb` (and `load_materials_from_folder`) sort by filename, and filenames come from material names.\n", "\n", "## Usage\n", "\n", "1. Set material and subfolder name in cell 1.2, transform params in 1.3.\n", "1. Run all cells to build and write the path materials.\n", - "1. Open [`create_materials_set.ipynb`](create_materials_set.ipynb), set the same `SUBFOLDER_NAME` and `IS_ORDERED = True`, and run it to save the materials and create the platform set.\n", + "1. Open [`utils_create_material_set.ipynb`](utils_create_material_set.ipynb), set the same `SUBFOLDER_NAME` and `IS_ORDERED = True`, and run it to save the materials and create the platform set.\n", "1. Use the printed set name as `MATERIAL_SET` in [`neb.ipynb`](workflows/neb.ipynb).\n", "\n", "## Summary\n", @@ -67,7 +67,7 @@ "MATERIAL_NAME = \"Silicon\"\n", "\n", "# Subfolder under uploads/ to write path materials into — use the same value as\n", - "# SUBFOLDER_NAME in create_materials_set.ipynb.\n", + "# SUBFOLDER_NAME in utils_create_material_set.ipynb.\n", "SUBFOLDER_NAME = \"neb_silicon\"\n" ] }, @@ -190,7 +190,7 @@ "source": [ "### 2.4. Name members in path order and write to the subfolder\n", "\n", - "Numeric prefixes control load order in `create_materials_set.ipynb` (filenames are sorted; filenames come from material names).\n" + "Numeric prefixes control load order in `utils_create_material_set.ipynb` (filenames are sorted; filenames come from material names).\n" ] }, { diff --git a/other/materials_designer/create_materials_set.ipynb b/other/materials_designer/utils_create_material_set.ipynb similarity index 98% rename from other/materials_designer/create_materials_set.ipynb rename to other/materials_designer/utils_create_material_set.ipynb index 20d1f124..50170ad2 100644 --- a/other/materials_designer/create_materials_set.ipynb +++ b/other/materials_designer/utils_create_material_set.ipynb @@ -5,11 +5,11 @@ "id": "0", "metadata": {}, "source": [ - "# Create Materials Set\n", + "# Create Material Set\n", "\n", "Save materials to a platform materials set — **ordered** (path order, e.g. NEB) or **unordered** (e.g. convex hull, EOS series).\n", "\n", - "This notebook only packages materials — it does not build or transform them. Build materials elsewhere (Materials Designer, a `create_*` notebook, or a dedicated builder notebook such as [`create_neb_path_materials.ipynb`](create_neb_path_materials.ipynb)), then either:\n", + "This notebook only packages materials — it does not build or transform them. Build materials elsewhere (Materials Designer, a `create_*` notebook, or a dedicated builder notebook such as [`create_neb_images.ipynb`](create_neb_images.ipynb)), then either:\n", "\n", "- Select them as **Input Materials** (outer runtime), or\n", "- Write them to a subfolder under `uploads/` with `set_materials(materials, folder_path=f\"uploads/{{SUBFOLDER_NAME}}\")` — filename sort order becomes set order for ordered sets, so number material names (`00_...`, `01_...`, …).\n", diff --git a/other/materials_designer/workflows/analyze_convex_hull.ipynb b/other/materials_designer/workflows/analyze_convex_hull.ipynb index 5c5059fe..b4f51248 100644 --- a/other/materials_designer/workflows/analyze_convex_hull.ipynb +++ b/other/materials_designer/workflows/analyze_convex_hull.ipynb @@ -16,7 +16,7 @@ "3. The notebook finds materials, retrieves total energies, and builds the convex hull.\n", "\n", "**Prerequisites:**\n", - "1. Save materials to a material set (for simpler search) — see [`create_materials_set.ipynb`](../create_materials_set.ipynb) (`IS_ORDERED = False`).\n", + "1. Save materials to a material set (for simpler search) — see [`utils_create_material_set.ipynb`](../utils_create_material_set.ipynb) (`IS_ORDERED = False`).\n", "2. Relax if needed and store relaxed structures there.\n", "3. Calculate total energies for the materials with the same formalism (e.g. DFT functional) and needed precision.\n", "\n", diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index ba1590dc..bb4a5df4 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -15,12 +15,12 @@ "\n", "If you do not already have those materials on the platform, prepare them first in Materials Designer:\n", "\n", - "1. Open [`create_neb_path_materials.ipynb`](../create_neb_path_materials.ipynb) (under Materials / Perturbations). Build the initial → final (and optional intermediate) images and run it so they are written to `uploads//` with numbered names (`00_…`, `01_…`, …).\n", + "1. Open [`create_neb_images.ipynb`](../create_neb_images.ipynb) (under Materials / Perturbations). Build the initial → final (and optional intermediate) images and run it so they are written to `uploads//` with numbered names (`00_…`, `01_…`, …).\n", "\n", "Then choose **one** of these ways to feed this NEB notebook:\n", "\n", "1. **From the subfolder (this notebook packages the set):** set `SUBFOLDER_NAME` to that folder name (e.g. `\"neb_silicon\"`). This notebook loads the files, saves the materials, and gets or creates the ordered set named `MATERIAL_SET`.\n", - "1. **From a materials set you build separately:** leave `SUBFOLDER_NAME = None`, run [`create_materials_set.ipynb`](../create_materials_set.ipynb) with the same `SUBFOLDER_NAME` and `IS_ORDERED = True`, then set `MATERIAL_SET` here to that set’s name.\n", + "1. **From a materials set you build separately:** leave `SUBFOLDER_NAME = None`, run [`utils_create_material_set.ipynb`](../utils_create_material_set.ipynb) with the same `SUBFOLDER_NAME` and `IS_ORDERED = True`, then set `MATERIAL_SET` here to that set’s name.\n", "1. **From a materials set you already have:** leave `SUBFOLDER_NAME = None` and set `MATERIAL_SET` to an existing ordered set on your account that already contains the path (first → … → last).\n", "\n", "If the set has only first+last (no middle images), set `N_IMAGES` (e.g. `20`) so Quantum ESPRESSO interpolates intermediates.\n", From 958984ea0cafac6421a483d8a392f8ee8c8fdf09 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 18:35:41 -0700 Subject: [PATCH 15/28] fix: CodeRabbit advisories on the NEB path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - neb.ipynb: correct the N_IMAGES comment — it is applied whenever set, not ignored when the set already holds intermediate images. - neb.ipynb: raise with the available hostnames when CLUSTER_NAME matches no cluster, instead of passing cluster=None into Compute. - material/api.py: refuse to reuse an existing materials set whose entitySetType differs from the requested one, which would silently drop NEB path order. --- other/materials_designer/workflows/neb.ipynb | 7 +++++-- .../notebooks_utils/core/entity/material/api.py | 14 +++++++++++--- tests/py/unit/core/entity/test_material_api.py | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index bb4a5df4..75339b7e 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -124,8 +124,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Intermediate count for QE when the set has only first+last (ignored if middle images exist)\n", - "N_IMAGES = None # e.g. 20; defaults to 1 when exactly two materials are in the set\n", + "# Total image count passed to QE; applied whenever set (QE interpolates up to this count)\n", + "N_IMAGES = None # e.g. 20; defaults to 1 when the set holds exactly two materials\n", "\n", "# K-grid for the NEB unit\n", "NEB_KGRID = [1, 1, 1]" @@ -462,6 +462,9 @@ "# Select cluster: use specified name if provided, otherwise use first available\n", "if CLUSTER_NAME:\n", " cluster = next((c for c in clusters if CLUSTER_NAME in c[\"hostname\"]), None)\n", + " if cluster is None:\n", + " hostnames = [c[\"hostname\"] for c in clusters]\n", + " raise ValueError(f\"No cluster matching CLUSTER_NAME={CLUSTER_NAME!r}. Available: {hostnames}\")\n", "else:\n", " cluster = clusters[0]\n", "\n", diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py index a2de10e8..27fb5732 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py @@ -200,16 +200,17 @@ def get_or_create_materials_set( The existing or newly created materials set document. Raises: - ValueError: If materials are empty, or ordered set has fewer than two members. + ValueError: If materials are empty, if an ordered set has fewer than two members, + or if an existing set of that name has the opposite `entitySetType`. """ if not materials: raise ValueError("Materials set needs at least one material.") if is_ordered and len(materials) < 2: raise ValueError("Ordered materials set needs at least two materials.") + entity_set_type = ORDERED_ENTITY_SET_TYPE if is_ordered else UNORDERED_ENTITY_SET_TYPE materials_set = _find_existing_materials_set(api_client, owner_id, material_set_name) if materials_set is None: - entity_set_type = ORDERED_ENTITY_SET_TYPE if is_ordered else UNORDERED_ENTITY_SET_TYPE set_config = { "name": material_set_name, "owner": {"_id": owner_id}, @@ -218,9 +219,16 @@ def get_or_create_materials_set( materials_set = api_client.materials.create_set(set_config) print(f"✅ Materials set '{materials_set['name']}' " f"({entity_set_type}, {materials_set['_id']})") else: + existing_entity_set_type = materials_set.get("entitySetType") + if existing_entity_set_type != entity_set_type: + raise ValueError( + f"Materials set '{materials_set['name']}' already exists as " + f"'{existing_entity_set_type}', but '{entity_set_type}' was requested. " + f"Reusing it would silently drop path order — rename the set or fix its type." + ) print( f"♻️ Reusing existing materials set '{materials_set['name']}' " - f"({materials_set.get('entitySetType')}, {materials_set['_id']})" + f"({existing_entity_set_type}, {materials_set['_id']})" ) _move_materials_into_set(api_client, materials_set["_id"], materials) diff --git a/tests/py/unit/core/entity/test_material_api.py b/tests/py/unit/core/entity/test_material_api.py index 0f6c7140..85c13148 100644 --- a/tests/py/unit/core/entity/test_material_api.py +++ b/tests/py/unit/core/entity/test_material_api.py @@ -155,6 +155,21 @@ def test_get_or_create_materials_set_reuses_when_found(): ) +def test_get_or_create_materials_set_rejects_reuse_with_mismatched_type(): + unordered_set = {**ENTITY_SET, "entitySetType": "unordered"} + client = _client_with_list_responses([[unordered_set]]) + + with pytest.raises(ValueError, match="already exists as 'unordered'"): + get_or_create_materials_set( + client, + OWNER_ID, + MATERIAL_SET_NAME, + [MATERIAL_INITIAL, MATERIAL_FINAL], + is_ordered=True, + ) + client.materials.move_to_set.assert_not_called() + + def test_get_or_create_materials_set_ordered_requires_two_materials(): client = MagicMock() From e9a3f92e0857ed5a8e36807218386ff2fd9dd40a Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 18:48:17 -0700 Subject: [PATCH 16/28] review: address tb-review findings on the NEB path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker — the ordered-set invariant was enforced only where a set is created, not where its order is consumed. find_material_set/list_materials_by_set now take require_ordered, and neb.ipynb passes it on the reuse path: an unordered set has no inSet.index, so every member ties and the 'path' becomes whatever order the API returned. Also: - docstrings on the six new private helpers, including the ordering contract that _index_in_set encodes; - list_materials_in_set takes an already-resolved set, so neb.ipynb resolves it once instead of twice; drop _find_existing_materials_set; - entity-set-type and _materialsSet class constants moved to module top; - _materials_set_reference raises instead of posting an empty slug; - spell out single-letter loop variables in the touched notebook cells; - neb.ipynb: drop the unreachable job-name fallback, and fail with a message when a finished job returns no reaction_energy_profile; - create_neb_images.ipynb: commented-out create_perturbation block moved into prose; - utils_create_material_set.ipynb: f-string brace escaping leaked into markdown. No cells added or removed — neb.feature's cell indices (5, 39, 40) still hold. --- .../create_neb_images.ipynb | 20 +-- .../utils_create_material_set.ipynb | 2 +- .../workflows/analyze_convex_hull.ipynb | 4 +- other/materials_designer/workflows/neb.ipynb | 27 +-- .../notebooks_utils/core/entity/job/api.py | 25 ++- .../core/entity/material/api.py | 159 ++++++++++++++---- .../py/unit/core/entity/test_material_api.py | 25 +++ 7 files changed, 194 insertions(+), 68 deletions(-) diff --git a/other/materials_designer/create_neb_images.ipynb b/other/materials_designer/create_neb_images.ipynb index bb9cfecd..364c8c4d 100644 --- a/other/materials_designer/create_neb_images.ipynb +++ b/other/materials_designer/create_neb_images.ipynb @@ -144,7 +144,9 @@ "source": [ "### 2.3. Transform into the final image (example)\n", "\n", - "Default: translate the atom at `ATOM_INDEX` by `TRANSLATION`. Replace with any other transformation (defects, swaps, `create_perturbation`, custom coordinates, …) — only the resulting list of materials in 2.4 matters.\n" + "Default: translate the atom at `ATOM_INDEX` by `TRANSLATION`. Replace with any other transformation (defects, swaps, custom coordinates, …) — only the resulting list of materials in 2.4 matters.\n", + "\n", + "For a smooth localized displacement rather than a single-atom jump, `mat3ra.made.tools.helpers.create_perturbation(material, expression, use_cartesian_coordinates=False)` maps a scalar `f(x, y, z)` to `∆z`; a Gaussian centred on the moving atom gives the same path with its neighbours relaxing along it.\n" ] }, { @@ -160,22 +162,6 @@ " coordinates[ATOM_INDEX][axis_index] += delta\n", "final_material.set_coordinates(coordinates)\n", "\n", - "# Optional: localized ∆z via Made create_perturbation (scalar f(x,y,z) → ∆z).\n", - "# import sympy as sp\n", - "# from mat3ra.made.tools.helpers import create_perturbation\n", - "#\n", - "# RADIUS_SIGMA = 0.05\n", - "# x, y, z = sp.symbols(\"x y z\")\n", - "# center_x, center_y, center_z = initial_material.coordinates_array[ATOM_INDEX]\n", - "# weight = sp.exp(\n", - "# -((x - center_x) ** 2 + (y - center_y) ** 2 + (z - center_z) ** 2) / (2 * RADIUS_SIGMA**2)\n", - "# )\n", - "# final_material = create_perturbation(\n", - "# initial_material,\n", - "# TRANSLATION[2] * weight,\n", - "# use_cartesian_coordinates=False,\n", - "# )\n", - "\n", "print(\n", " f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → \"\n", " f\"{final_material.coordinates_array[ATOM_INDEX]}\"\n", diff --git a/other/materials_designer/utils_create_material_set.ipynb b/other/materials_designer/utils_create_material_set.ipynb index 50170ad2..774d4783 100644 --- a/other/materials_designer/utils_create_material_set.ipynb +++ b/other/materials_designer/utils_create_material_set.ipynb @@ -12,7 +12,7 @@ "This notebook only packages materials — it does not build or transform them. Build materials elsewhere (Materials Designer, a `create_*` notebook, or a dedicated builder notebook such as [`create_neb_images.ipynb`](create_neb_images.ipynb)), then either:\n", "\n", "- Select them as **Input Materials** (outer runtime), or\n", - "- Write them to a subfolder under `uploads/` with `set_materials(materials, folder_path=f\"uploads/{{SUBFOLDER_NAME}}\")` — filename sort order becomes set order for ordered sets, so number material names (`00_...`, `01_...`, …).\n", + "- Write them to a subfolder under `uploads/` with `set_materials(materials, folder_path=f\"uploads/{SUBFOLDER_NAME}\")` — filename sort order becomes set order for ordered sets, so number material names (`00_...`, `01_...`, …).\n", "\n", "## Usage\n", "\n", diff --git a/other/materials_designer/workflows/analyze_convex_hull.ipynb b/other/materials_designer/workflows/analyze_convex_hull.ipynb index b4f51248..d50ebf29 100644 --- a/other/materials_designer/workflows/analyze_convex_hull.ipynb +++ b/other/materials_designer/workflows/analyze_convex_hull.ipynb @@ -170,10 +170,10 @@ "\n", "for formula in FORMULAS:\n", " if set_materials is not None:\n", - " matches = [m for m in set_materials if m.get(\"formula\") == formula]\n", + " matches = [material for material in set_materials if material.get(\"formula\") == formula]\n", " else:\n", " matches = client.materials.list({\"formula\": formula, \"owner._id\": ACCOUNT_ID})\n", - " matches = [m for m in matches if not m.get(\"isEntitySet\")]\n", + " matches = [material for material in matches if not material.get(\"isEntitySet\")]\n", " for material in matches:\n", " material[\"_search_formula\"] = formula # track which formula query found this\n", " all_materials.extend(matches)\n", diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index 75339b7e..e9a39148 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -251,7 +251,7 @@ " find_material_set,\n", " get_or_create_material,\n", " get_or_create_materials_set,\n", - " list_materials_by_set,\n", + " list_materials_in_set,\n", ")\n", "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", "from mat3ra.notebooks_utils.material import load_materials_from_folder\n", @@ -270,10 +270,13 @@ " is_ordered=True,\n", " )\n", " MATERIAL_SET = materials_set[\"name\"]\n", - " material_dicts = list_materials_by_set(client, ACCOUNT_ID, MATERIAL_SET)\n", "else:\n", - " materials_set = find_material_set(client, ACCOUNT_ID, MATERIAL_SET)\n", - " material_dicts = list_materials_by_set(client, ACCOUNT_ID, MATERIAL_SET)\n", + " # require_ordered: an unordered set carries no inSet.index, so its members\n", + " # would come back in arbitrary order and the barrier would be for a path\n", + " # nobody chose.\n", + " materials_set = find_material_set(client, ACCOUNT_ID, MATERIAL_SET, require_ordered=True)\n", + "\n", + "material_dicts = list_materials_in_set(client, ACCOUNT_ID, materials_set)\n", "\n", "if len(material_dicts) < 2:\n", " raise ValueError(\n", @@ -439,7 +442,7 @@ "outputs": [], "source": [ "clusters = client.clusters.list()\n", - "print(f\"Available clusters: {[c['hostname'] for c in clusters]}\")" + "print(f\"Available clusters: {[cluster['hostname'] for cluster in clusters]}\")" ] }, { @@ -461,9 +464,12 @@ "\n", "# Select cluster: use specified name if provided, otherwise use first available\n", "if CLUSTER_NAME:\n", - " cluster = next((c for c in clusters if CLUSTER_NAME in c[\"hostname\"]), None)\n", + " cluster = next(\n", + " (candidate for candidate in clusters if CLUSTER_NAME in candidate[\"hostname\"]),\n", + " None,\n", + " )\n", " if cluster is None:\n", - " hostnames = [c[\"hostname\"] for c in clusters]\n", + " hostnames = [candidate[\"hostname\"] for candidate in clusters]\n", " raise ValueError(f\"No cluster matching CLUSTER_NAME={CLUSTER_NAME!r}. Available: {hostnames}\")\n", "else:\n", " cluster = clusters[0]\n", @@ -495,12 +501,11 @@ "from mat3ra.notebooks_utils.job import create_job\n", "from mat3ra.notebooks_utils.ui import display_JSON\n", "\n", - "print(f\"Materials: {[m.id for m in saved_materials]}\")\n", + "print(f\"Materials: {[material.id for material in saved_materials]}\")\n", "print(f\"Workflow: {saved_workflow.id}\")\n", "print(f\"Project: {project_id}\")\n", "\n", - "path_label = MATERIAL_SET or \"neb-path\"\n", - "job_name = f\"{MY_WORKFLOW_NAME} {path_label} {timestamp}\"\n", + "job_name = f\"{MY_WORKFLOW_NAME} {MATERIAL_SET} {timestamp}\"\n", "job_response = create_job(\n", " api_client=client,\n", " materials=saved_materials,\n", @@ -573,6 +578,8 @@ "from mat3ra.notebooks_utils.ipython.entity.property.visualize import visualize_properties\n", "\n", "profile_data = client.properties.get_for_job(job_id, property_name=\"reaction_energy_profile\")\n", + "if not profile_data:\n", + " raise ValueError(f\"Job {job_id} finished but returned no reaction_energy_profile.\")\n", "visualize_properties(profile_data, title=\"Reaction Energy Profile\")\n", "\n", "energies = profile_data[0][\"yDataSeries\"][0]\n", diff --git a/src/py/mat3ra/notebooks_utils/core/entity/job/api.py b/src/py/mat3ra/notebooks_utils/core/entity/job/api.py index ee6b1f2e..9e2650b0 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/job/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/job/api.py @@ -3,6 +3,8 @@ from mat3ra.api_client import APIClient, JobEndpoints +MATERIALS_SET_ENTITY_CLASS = "Material" + def save_files(job_id: str, job_endpoint: JobEndpoints, filename_on_cloud: str, filename_on_disk: str) -> None: """ @@ -38,10 +40,29 @@ def get_jobs_statuses_by_ids(endpoint: JobEndpoints, job_ids: List[str]) -> List def _materials_set_reference(materials_set: Dict[str, Any]) -> Dict[str, str]: + """ + Builds the `_materialsSet` reference a job config expects. + + Mirrors what the job designer sends: the set's ID, the entity class it holds, + and a slug. The platform resolves members from the ID, so `slug` is only a + label — falling back to `name` keeps it readable when the response omits it. + + Args: + materials_set (dict): Materials set document. + + Returns: + dict: The `_materialsSet` reference. + + Raises: + KeyError: If the set document carries neither `slug` nor `name`. + """ + slug = materials_set.get("slug") or materials_set.get("name") + if not slug: + raise KeyError(f"Materials set {materials_set['_id']} has neither 'slug' nor 'name'.") return { "_id": materials_set["_id"], - "cls": "Material", - "slug": materials_set.get("slug") or materials_set.get("name") or "", + "cls": MATERIALS_SET_ENTITY_CLASS, + "slug": slug, } diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py index 27fb5732..06019286 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py @@ -6,6 +6,9 @@ from .analysis import get_slab_bulk_crystal, resolve_bulk_query_from_crystal +ORDERED_ENTITY_SET_TYPE = "ordered" +UNORDERED_ENTITY_SET_TYPE = "unordered" + def get_or_create_material(api_client: APIClient, material, owner_id: str) -> dict: """ @@ -78,10 +81,32 @@ def _require_material_for_owner(api_client: APIClient, query: dict, owner_id: st def _exclude_entity_sets(materials: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Drops the set document itself, which the platform returns alongside its members. + + Args: + materials (list[dict]): Documents returned by a materials list query. + + Returns: + list[dict]: Only the non-set materials. + """ return [material for material in materials if not material.get("isEntitySet")] def _index_in_set(material: Dict[str, Any], material_set_id: str) -> float: + """ + Position of a material within a set, used as the path-order sort key. + + A member with no recorded index sorts last rather than first, so a partially + indexed set degrades to "known order first" instead of silently reshuffling. + + Args: + material (dict): Material document carrying an `inSet` list. + material_set_id (str): ID of the set whose index to read. + + Returns: + float: The `inSet.index` value, or infinity when the set records none. + """ for entry in material.get("inSet") or []: if entry.get("_id") == material_set_id: index = entry.get("index") @@ -89,20 +114,50 @@ def _index_in_set(material: Dict[str, Any], material_set_id: str) -> float: return float("inf") -def find_material_set(api_client: APIClient, owner_id: str, material_set_name: str) -> Dict[str, Any]: +def _require_ordered(material_set: Dict[str, Any]) -> None: + """ + Rejects a set whose members carry no path order. + + Only an `ordered` set gets `inSet.index` values assigned. Sorting an unordered + set by index leaves every member tied, so its order would be whatever the API + happened to return — a NEB path nobody chose, submitted without an error. + + Args: + material_set (dict): The resolved materials set document. + + Raises: + ValueError: If the set is not of the `ordered` entity set type. + """ + entity_set_type = material_set.get("entitySetType") + if entity_set_type != ORDERED_ENTITY_SET_TYPE: + raise ValueError( + f"Materials set '{material_set.get('name')}' is '{entity_set_type}', not " + f"'{ORDERED_ENTITY_SET_TYPE}'. Its members carry no path order — change the set " + f"type on the platform, or build the path with an ordered set." + ) + + +def find_material_set( + api_client: APIClient, + owner_id: str, + material_set_name: str, + require_ordered: bool = False, +) -> Dict[str, Any]: """ Find a materials entity set by name (case-insensitive substring match). Args: api_client (APIClient): API client instance carrying the authorization context. - owner_id: Account ID that owns the set. - material_set_name: Substring matched against set names under the owner. + owner_id (str): Account ID that owns the set. + material_set_name (str): Substring matched against set names under the owner. + require_ordered (bool): Reject the match unless it is an ordered set. Returns: - The first matching materials set document. + dict: The first matching materials set document. Raises: - ValueError: If no set matches the name. + ValueError: If no set matches the name, or if `require_ordered` and the match + is not an ordered set. """ material_sets = api_client.materials.list( { @@ -113,29 +168,27 @@ def find_material_set(api_client: APIClient, owner_id: str, material_set_name: s ) if not material_sets: raise ValueError(f"No material set matching '{material_set_name}'") - return material_sets[0] + material_set = material_sets[0] + if require_ordered: + _require_ordered(material_set) + return material_set -def list_materials_by_set( - api_client: APIClient, - owner_id: str, - material_set_name: str, -) -> List[Dict[str, Any]]: +def list_materials_in_set(api_client: APIClient, owner_id: str, material_set: Dict[str, Any]) -> List[Dict[str, Any]]: """ - List non-set materials in a materials set, ordered by ascending `inSet.index`. + List non-set members of an already-resolved materials set, ordered by ascending `inSet.index`. - Path order is first → optional intermediates → last. Tags and material names + Path order is first -> optional intermediates -> last. Tags and material names do not define order. Args: api_client (APIClient): API client instance carrying the authorization context. - owner_id: Account ID that owns the set. - material_set_name: Name (substring) of the ordered materials set. + owner_id (str): Account ID that owns the set. + material_set (dict): Set document, as returned by `find_material_set`. Returns: - Member materials sorted by path index (missing index sorts last). + list[dict]: Member materials sorted by path index (missing index sorts last). """ - material_set = find_material_set(api_client, owner_id, material_set_name) material_set_id = material_set["_id"] matches = api_client.materials.list( { @@ -147,13 +200,56 @@ def list_materials_by_set( return sorted(materials, key=lambda material: _index_in_set(material, material_set_id)) +def list_materials_by_set( + api_client: APIClient, + owner_id: str, + material_set_name: str, + require_ordered: bool = False, +) -> List[Dict[str, Any]]: + """ + Resolve a materials set by name and list its members in path order. + + Args: + api_client (APIClient): API client instance carrying the authorization context. + owner_id (str): Account ID that owns the set. + material_set_name (str): Name (substring) of the materials set. + require_ordered (bool): Reject the set unless it carries path order. + + Returns: + list[dict]: Member materials sorted by path index (missing index sorts last). + """ + material_set = find_material_set(api_client, owner_id, material_set_name, require_ordered=require_ordered) + return list_materials_in_set(api_client, owner_id, material_set) + + def _resolve_material_identifier(material: Any) -> str: + """ + Reads the platform ID from either an API response dict or a Made material object. + + Args: + material (dict | Material): Material to identify. + + Returns: + str: The platform material ID. + """ if isinstance(material, dict): return material["_id"] return material.id def _move_materials_into_set(api_client: APIClient, material_set_id: str, materials: List[Any]) -> None: + """ + Moves materials into a set one at a time, in list order. + + Sequential moves are required, not incidental: the platform assigns + `inSet.index` in the order it receives them, so this loop is what makes an + ordered set's path order match the caller's list. + + Args: + api_client (APIClient): API client instance carrying the authorization context. + material_set_id (str): ID of the destination set. + materials (list): Materials to move (dict responses or Made objects with `.id`). + """ for material in materials: api_client.materials.move_to_set( _resolve_material_identifier(material), @@ -162,19 +258,6 @@ def _move_materials_into_set(api_client: APIClient, material_set_id: str, materi ) -ORDERED_ENTITY_SET_TYPE = "ordered" -UNORDERED_ENTITY_SET_TYPE = "unordered" - - -def _find_existing_materials_set( - api_client: APIClient, owner_id: str, material_set_name: str -) -> Optional[Dict[str, Any]]: - try: - return find_material_set(api_client, owner_id, material_set_name) - except ValueError: - return None - - def get_or_create_materials_set( api_client: APIClient, owner_id: str, @@ -191,13 +274,13 @@ def get_or_create_materials_set( Args: api_client (APIClient): API client instance carrying the authorization context. - owner_id: Account ID under which to find or create the set. - material_set_name: Name of the set to reuse or create. - materials: Materials to include (dict responses or Made objects with `.id`). - is_ordered: Whether path order (`inSet.index`) matters for this set. + owner_id (str): Account ID under which to find or create the set. + material_set_name (str): Name of the set to reuse or create. + materials (list): Materials to include (dict responses or Made objects with `.id`). + is_ordered (bool): Whether path order (`inSet.index`) matters for this set. Returns: - The existing or newly created materials set document. + dict: The existing or newly created materials set document. Raises: ValueError: If materials are empty, if an ordered set has fewer than two members, @@ -209,7 +292,11 @@ def get_or_create_materials_set( raise ValueError("Ordered materials set needs at least two materials.") entity_set_type = ORDERED_ENTITY_SET_TYPE if is_ordered else UNORDERED_ENTITY_SET_TYPE - materials_set = _find_existing_materials_set(api_client, owner_id, material_set_name) + try: + materials_set: Optional[Dict[str, Any]] = find_material_set(api_client, owner_id, material_set_name) + except ValueError: + materials_set = None + if materials_set is None: set_config = { "name": material_set_name, diff --git a/tests/py/unit/core/entity/test_material_api.py b/tests/py/unit/core/entity/test_material_api.py index 85c13148..4325d74e 100644 --- a/tests/py/unit/core/entity/test_material_api.py +++ b/tests/py/unit/core/entity/test_material_api.py @@ -7,6 +7,7 @@ find_material_set, get_or_create_materials_set, list_materials_by_set, + list_materials_in_set, ) OWNER_ID = "account-1" @@ -78,6 +79,30 @@ def test_find_material_set_raises_when_missing(): find_material_set(client, OWNER_ID, MATERIAL_SET_NAME) +def test_find_material_set_rejects_unordered_when_order_required(): + client = _client_with_list_responses([[{**ENTITY_SET, "entitySetType": "unordered"}]]) + + with pytest.raises(ValueError, match="is 'unordered', not 'ordered'"): + find_material_set(client, OWNER_ID, MATERIAL_SET_NAME, require_ordered=True) + + +def test_list_materials_by_set_rejects_unordered_when_order_required(): + client = _client_with_list_responses([[{**ENTITY_SET, "entitySetType": "unordered"}]]) + + with pytest.raises(ValueError, match="is 'unordered', not 'ordered'"): + list_materials_by_set(client, OWNER_ID, MATERIAL_SET_NAME, require_ordered=True) + assert client.materials.list.call_count == 1 + + +def test_list_materials_in_set_does_not_re_resolve_the_set(): + client = _client_with_list_responses([SET_MEMBER_MATERIALS_OUT_OF_ORDER]) + + materials = list_materials_in_set(client, OWNER_ID, ENTITY_SET) + + assert [material["_id"] for material in materials] == EXPECTED_ORDERED_IDS + client.materials.list.assert_called_once_with({"owner._id": OWNER_ID, "inSet._id": MATERIAL_SET_ID}) + + @pytest.mark.parametrize( ("members", "expected_ids"), [ From 3a4c8a2a7f72955f608cf8f3a4d35fc52074238f Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 19:11:46 -0700 Subject: [PATCH 17/28] example: Si(100) surface, atom moved out of plane create_neb_images.ipynb defaulted to bulk Silicon from Standata. The intended example is a surface with an atom displaced out of the surface plane, so load Standata's Si(100) surface (mavrl-si-100, 8 atoms, ~21.9 A cell) instead. Atom 5 is the slab's lowest atom, the one facing the vacuum gap; moving it -0.05 in crystal z (-1.09 A) keeps it inside the cell, so the two images read side by side without an atom wrapping across the periodic boundary. Also give the written images a short PATH_NAME base - filenames come from material names, and the Standata surface name is long and comma-heavy - and point utils_create_material_set.ipynb's default set name at the same example. Verified against the real Standata entry: agents/workdir/tmp/run_create_neb_images.py builds both images, writes them, and reloads them in path order. --- .../create_neb_images.ipynb | 27 +++++++++++++------ .../utils_create_material_set.ipynb | 4 +-- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/other/materials_designer/create_neb_images.ipynb b/other/materials_designer/create_neb_images.ipynb index 364c8c4d..17dfff5a 100644 --- a/other/materials_designer/create_neb_images.ipynb +++ b/other/materials_designer/create_neb_images.ipynb @@ -21,8 +21,8 @@ "## Summary\n", "\n", "1. Install packages and set parameters.\n", - "1. Load the starting material.\n", - "1. Clone as the initial image; transform a copy into the final image (default: translate one atom).\n", + "1. Load the starting material — by default the Si(100) surface from Standata.\n", + "1. Clone as the initial image; transform a copy into the final image (default: move one surface atom out of the surface plane, into the vacuum).\n", "1. Name members in path order and write them to `uploads//`.\n" ] }, @@ -64,11 +64,15 @@ "source": [ "# Starting material (uploads folder or Standata name match)\n", "FOLDER = \"uploads\"\n", - "MATERIAL_NAME = \"Silicon\"\n", + "MATERIAL_NAME = \"Silicon (100) surface\"\n", + "\n", + "# Short base name for the written images: 00_.json, 01_.json.\n", + "# Standata names are long and comma-heavy, and the filename is what sets load order.\n", + "PATH_NAME = \"Si-100-surface\"\n", "\n", "# Subfolder under uploads/ to write path materials into — use the same value as\n", "# SUBFOLDER_NAME in utils_create_material_set.ipynb.\n", - "SUBFOLDER_NAME = \"neb_silicon\"\n" + "SUBFOLDER_NAME = \"neb_si_100_surface\"\n" ] }, { @@ -77,7 +81,7 @@ "metadata": {}, "source": [ "### 1.3. Set example transformation parameters\n", - "Default example: translate one atom in crystal coordinates. Replace this cell and 2.3 below for other paths.\n" + "Default example: move one surface atom out of the surface plane, in crystal coordinates. Replace this cell and 2.3 below for other paths.\n" ] }, { @@ -87,8 +91,14 @@ "metadata": {}, "outputs": [], "source": [ - "ATOM_INDEX = 0\n", - "TRANSLATION = [0.0, 0.0, 0.1]\n" + "# Atom 5 is the lowest atom of the Si(100) slab — the one facing the vacuum gap.\n", + "# Moving it further down stays inside the cell, so the two images read side by side\n", + "# without an atom wrapping across the periodic boundary.\n", + "ATOM_INDEX = 5\n", + "\n", + "# Crystal coordinates: z is a fraction of the ~21.9 Å cell height, so -0.05 is\n", + "# ~1.1 Å out of the surface plane.\n", + "TRANSLATION = [0.0, 0.0, -0.05]\n" ] }, { @@ -166,6 +176,7 @@ " f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → \"\n", " f\"{final_material.coordinates_array[ATOM_INDEX]}\"\n", ")\n", + "print(f\"Out-of-plane displacement: {TRANSLATION[2] * final_material.lattice.c:.3f} Å\")\n", "visualize([initial_material, final_material])\n" ] }, @@ -191,7 +202,7 @@ "\n", "path_materials = [initial_material, final_material]\n", "for index, material in enumerate(path_materials):\n", - " material.name = f\"{index:02d}_{source_material.name}\"\n", + " material.name = f\"{index:02d}_{PATH_NAME}\"\n", "\n", "subfolder_path = f\"{UPLOADS_FOLDER}/{SUBFOLDER_NAME}\"\n", "set_materials(path_materials, folder_path=subfolder_path)\n", diff --git a/other/materials_designer/utils_create_material_set.ipynb b/other/materials_designer/utils_create_material_set.ipynb index 774d4783..10576dd7 100644 --- a/other/materials_designer/utils_create_material_set.ipynb +++ b/other/materials_designer/utils_create_material_set.ipynb @@ -67,14 +67,14 @@ "ORGANIZATION_NAME = None\n", "\n", "# Materials set on the platform (use this name in workflow notebooks)\n", - "MATERIAL_SET_NAME = \"NEB Silicon\"\n", + "MATERIAL_SET_NAME = \"NEB Si(100) surface\"\n", "# True = path order via inSet.index (NEB); False = bag of members (hull, EOS, …)\n", "IS_ORDERED = True\n", "\n", "# Subfolder under uploads/ to load materials from (written there by a builder notebook via\n", "# set_materials(materials, folder_path=f\"uploads/{SUBFOLDER_NAME}\")).\n", "# None = use Input Materials (outer runtime), falling back to the uploads/ root.\n", - "SUBFOLDER_NAME = None\n" + "SUBFOLDER_NAME = None # e.g. \"neb_si_100_surface\"\n" ] }, { From 537243599938bfbdadcc2894aa47cfa18ae9d46c Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 20:42:07 -0700 Subject: [PATCH 18/28] rename: 'Create initial/final materials', moved to Perturbation 3D (TB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReviewNB on #349, anchored on '### 5.2. 2D': - Introduction.ipynb: the create_neb_images entry is relabelled 'Create initial/final materials' and moves from 5.2 (2D) to 5.1 (3D) as 5.1.2 — displacing an atom out of the surface plane is a 3D perturbation. 5.2.1 and 5.2.2 keep their numbers, so nothing else renumbers. - create_neb_images.ipynb: H1 follows the TOC label. - neb.ipynb: MATERIAL_SET default 'H2+H' -> 'NEB-ordered-material-set'. neb.feature overwrites the whole params cell (cell '5') with its own MATERIAL_SET = 'H2+H', so the test is unaffected. Filename stays create_neb_images.ipynb — that name was TB's own from the previous round, and the comment is on the TOC label, not the file. No cells added or removed; neb.feature anchors 5/39/40 still hold. --- other/materials_designer/Introduction.ipynb | 2 +- other/materials_designer/create_neb_images.ipynb | 2 +- other/materials_designer/workflows/neb.ipynb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/other/materials_designer/Introduction.ipynb b/other/materials_designer/Introduction.ipynb index 434efad1..d408bd49 100644 --- a/other/materials_designer/Introduction.ipynb +++ b/other/materials_designer/Introduction.ipynb @@ -77,11 +77,11 @@ "\n", "### 5.1. 3D\n", "#### [5.1.1. Maxwell-Boltzmann thermal disorder `X-3D-PER`](create_maxwell_disorder.ipynb)\n", + "#### [5.1.2. Create initial/final materials](create_neb_images.ipynb). Transform a material into initial/intermediate/final images for a Nudged Elastic Band path.\n", "\n", "### 5.2. 2D\n", "#### [5.2.1. Perturbation using sine wave `X-2D-PER`](create_perturbation.ipynb)\n", "#### [5.2.2. Perturbation using custom function `X-2D-PER`](create_perturbation_custom.ipynb)\n", - "#### [5.2.3. Create NEB images](create_neb_images.ipynb). Transform a material into initial/intermediate/final images for a Nudged Elastic Band path.\n", "\n", "\n", "## 6. Other.\n", diff --git a/other/materials_designer/create_neb_images.ipynb b/other/materials_designer/create_neb_images.ipynb index 17dfff5a..80934491 100644 --- a/other/materials_designer/create_neb_images.ipynb +++ b/other/materials_designer/create_neb_images.ipynb @@ -5,7 +5,7 @@ "id": "0", "metadata": {}, "source": [ - "# Create NEB Images\n", + "# Create Initial/Final Materials\n", "\n", "Build initial → (optional intermediates) → final images for a Nudged Elastic Band path, and write them to a subfolder under `uploads/` in path order for [`utils_create_material_set.ipynb`](utils_create_material_set.ipynb).\n", "\n", diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index e9a39148..f39ac36a 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -89,7 +89,7 @@ "\n", "# 3. Material parameters\n", "# Order in the set: first = initial, middle = intermediates (optional), last = final\n", - "MATERIAL_SET = \"H2+H\"\n", + "MATERIAL_SET = \"NEB-ordered-material-set\"\n", "# If set, load path materials from ../uploads/{SUBFOLDER_NAME} and get or create MATERIAL_SET.\n", "# If None, reuse the existing ordered set named MATERIAL_SET on the platform.\n", "SUBFOLDER_NAME = None # e.g. \"neb_silicon\"\n", From e21d3c0a099cf3f3ecc6bb21c436087f2b2b8bdd Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 20:43:32 -0700 Subject: [PATCH 19/28] docs: lead create_neb_images generic, NEB as the example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notebook builds an ordered start/end pair from one starting structure; nothing in it is NEB-specific. Say so, rather than framing the whole thing as a Nudged Elastic Band path — NEB is the example and the consumer. --- other/materials_designer/create_neb_images.ipynb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/other/materials_designer/create_neb_images.ipynb b/other/materials_designer/create_neb_images.ipynb index 80934491..474ea891 100644 --- a/other/materials_designer/create_neb_images.ipynb +++ b/other/materials_designer/create_neb_images.ipynb @@ -7,7 +7,9 @@ "source": [ "# Create Initial/Final Materials\n", "\n", - "Build initial → (optional intermediates) → final images for a Nudged Elastic Band path, and write them to a subfolder under `uploads/` in path order for [`utils_create_material_set.ipynb`](utils_create_material_set.ipynb).\n", + "Build an ordered initial → (optional intermediates) → final set of materials from one starting structure, and write them to a subfolder under `uploads/` in path order for [`utils_create_material_set.ipynb`](utils_create_material_set.ipynb).\n", + "\n", + "Nothing here is specific to a Nudged Elastic Band path — that is just the example this notebook is set up for, and the consumer it feeds ([`neb.ipynb`](workflows/neb.ipynb)). Any calculation that takes an ordered start/end pair can use the same output.\n", "\n", "Order is preserved by **numbering material names** (`00_...`, `01_...`, …): `utils_create_material_set.ipynb` (and `load_materials_from_folder`) sort by filename, and filenames come from material names.\n", "\n", From 7cc0b6990ac16473a69514a5c0468d11eb963fcf Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 21:00:57 -0700 Subject: [PATCH 20/28] simplify: cut the materials-set helpers to what they do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set section had grown to 249 added lines for what is two operations: find a set and list its members in order, and create-or-reuse a set and move members into it. 119 of those lines were docstrings — a literal reading of my own tb-review TB-DOC-2 finding, applied to private helpers that were one expression each (_exclude_entity_sets was a 2-line comprehension under a 9-line Google-style block). Inline the four one-expression privates into their only call sites, fold the ordered-set guard into find_material_set, and size each docstring to what a reader needs. Nine functions become five; 249 added lines become 144. No behaviour change: the same 38 unit tests pass untouched, and all four public names the notebooks import are unchanged. --- .../core/entity/material/api.py | 177 ++++-------------- 1 file changed, 36 insertions(+), 141 deletions(-) diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py index 06019286..97be0959 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/api.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/api.py @@ -80,32 +80,10 @@ def _require_material_for_owner(api_client: APIClient, query: dict, owner_id: st return Material.create(material_response) -def _exclude_entity_sets(materials: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """ - Drops the set document itself, which the platform returns alongside its members. - - Args: - materials (list[dict]): Documents returned by a materials list query. - - Returns: - list[dict]: Only the non-set materials. - """ - return [material for material in materials if not material.get("isEntitySet")] - - def _index_in_set(material: Dict[str, Any], material_set_id: str) -> float: """ - Position of a material within a set, used as the path-order sort key. - - A member with no recorded index sorts last rather than first, so a partially - indexed set degrades to "known order first" instead of silently reshuffling. - - Args: - material (dict): Material document carrying an `inSet` list. - material_set_id (str): ID of the set whose index to read. - - Returns: - float: The `inSet.index` value, or infinity when the set records none. + Path-order sort key. A member with no recorded index sorts last, so a + partially indexed set degrades to "known order first" instead of reshuffling. """ for entry in material.get("inSet") or []: if entry.get("_id") == material_set_id: @@ -114,29 +92,6 @@ def _index_in_set(material: Dict[str, Any], material_set_id: str) -> float: return float("inf") -def _require_ordered(material_set: Dict[str, Any]) -> None: - """ - Rejects a set whose members carry no path order. - - Only an `ordered` set gets `inSet.index` values assigned. Sorting an unordered - set by index leaves every member tied, so its order would be whatever the API - happened to return — a NEB path nobody chose, submitted without an error. - - Args: - material_set (dict): The resolved materials set document. - - Raises: - ValueError: If the set is not of the `ordered` entity set type. - """ - entity_set_type = material_set.get("entitySetType") - if entity_set_type != ORDERED_ENTITY_SET_TYPE: - raise ValueError( - f"Materials set '{material_set.get('name')}' is '{entity_set_type}', not " - f"'{ORDERED_ENTITY_SET_TYPE}'. Its members carry no path order — change the set " - f"type on the platform, or build the path with an ordered set." - ) - - def find_material_set( api_client: APIClient, owner_id: str, @@ -150,14 +105,13 @@ def find_material_set( api_client (APIClient): API client instance carrying the authorization context. owner_id (str): Account ID that owns the set. material_set_name (str): Substring matched against set names under the owner. - require_ordered (bool): Reject the match unless it is an ordered set. + require_ordered (bool): Reject the match unless it carries path order. Returns: dict: The first matching materials set document. Raises: - ValueError: If no set matches the name, or if `require_ordered` and the match - is not an ordered set. + ValueError: If no set matches, or if `require_ordered` and the match is unordered. """ material_sets = api_client.materials.list( { @@ -169,35 +123,28 @@ def find_material_set( if not material_sets: raise ValueError(f"No material set matching '{material_set_name}'") material_set = material_sets[0] - if require_ordered: - _require_ordered(material_set) + + # Only an ordered set gets inSet.index values, so sorting an unordered one leaves + # every member tied — an arbitrary path, submitted without an error. + entity_set_type = material_set.get("entitySetType") + if require_ordered and entity_set_type != ORDERED_ENTITY_SET_TYPE: + raise ValueError( + f"Materials set '{material_set.get('name')}' is '{entity_set_type}', not " + f"'{ORDERED_ENTITY_SET_TYPE}'. Its members carry no path order." + ) return material_set def list_materials_in_set(api_client: APIClient, owner_id: str, material_set: Dict[str, Any]) -> List[Dict[str, Any]]: """ - List non-set members of an already-resolved materials set, ordered by ascending `inSet.index`. - - Path order is first -> optional intermediates -> last. Tags and material names - do not define order. - - Args: - api_client (APIClient): API client instance carrying the authorization context. - owner_id (str): Account ID that owns the set. - material_set (dict): Set document, as returned by `find_material_set`. - - Returns: - list[dict]: Member materials sorted by path index (missing index sorts last). + Members of an already-resolved set, ascending by `inSet.index` + (first -> optional intermediates -> last). Takes a resolved set so callers + that already have one do not re-query for it. """ material_set_id = material_set["_id"] - matches = api_client.materials.list( - { - "owner._id": owner_id, - "inSet._id": material_set_id, - } - ) - materials = _exclude_entity_sets(matches) - return sorted(materials, key=lambda material: _index_in_set(material, material_set_id)) + matches = api_client.materials.list({"owner._id": owner_id, "inSet._id": material_set_id}) + members = [material for material in matches if not material.get("isEntitySet")] + return sorted(members, key=lambda material: _index_in_set(material, material_set_id)) def list_materials_by_set( @@ -206,58 +153,11 @@ def list_materials_by_set( material_set_name: str, require_ordered: bool = False, ) -> List[Dict[str, Any]]: - """ - Resolve a materials set by name and list its members in path order. - - Args: - api_client (APIClient): API client instance carrying the authorization context. - owner_id (str): Account ID that owns the set. - material_set_name (str): Name (substring) of the materials set. - require_ordered (bool): Reject the set unless it carries path order. - - Returns: - list[dict]: Member materials sorted by path index (missing index sorts last). - """ + """Resolve a materials set by name and list its members in path order.""" material_set = find_material_set(api_client, owner_id, material_set_name, require_ordered=require_ordered) return list_materials_in_set(api_client, owner_id, material_set) -def _resolve_material_identifier(material: Any) -> str: - """ - Reads the platform ID from either an API response dict or a Made material object. - - Args: - material (dict | Material): Material to identify. - - Returns: - str: The platform material ID. - """ - if isinstance(material, dict): - return material["_id"] - return material.id - - -def _move_materials_into_set(api_client: APIClient, material_set_id: str, materials: List[Any]) -> None: - """ - Moves materials into a set one at a time, in list order. - - Sequential moves are required, not incidental: the platform assigns - `inSet.index` in the order it receives them, so this loop is what makes an - ordered set's path order match the caller's list. - - Args: - api_client (APIClient): API client instance carrying the authorization context. - material_set_id (str): ID of the destination set. - materials (list): Materials to move (dict responses or Made objects with `.id`). - """ - for material in materials: - api_client.materials.move_to_set( - _resolve_material_identifier(material), - "", - material_set_id, - ) - - def get_or_create_materials_set( api_client: APIClient, owner_id: str, @@ -268,15 +168,15 @@ def get_or_create_materials_set( """ Reuse an existing materials set by name, or create one, then move members into it. - For `is_ordered=True`, members are moved in list order so the platform can - assign ascending `inSet.index` values (e.g. NEB path). For unordered sets, - membership is a bag (e.g. convex hull, EOS series). + Members are moved one at a time in list order: the platform assigns `inSet.index` + in the order it receives them, which is what makes an ordered set's path order + match the caller's list. Args: api_client (APIClient): API client instance carrying the authorization context. owner_id (str): Account ID under which to find or create the set. material_set_name (str): Name of the set to reuse or create. - materials (list): Materials to include (dict responses or Made objects with `.id`). + materials (list): Members to include (dict responses or Made objects with `.id`). is_ordered (bool): Whether path order (`inSet.index`) matters for this set. Returns: @@ -298,25 +198,20 @@ def get_or_create_materials_set( materials_set = None if materials_set is None: - set_config = { - "name": material_set_name, - "owner": {"_id": owner_id}, - "entitySetType": entity_set_type, - } - materials_set = api_client.materials.create_set(set_config) - print(f"✅ Materials set '{materials_set['name']}' " f"({entity_set_type}, {materials_set['_id']})") + materials_set = api_client.materials.create_set( + {"name": material_set_name, "owner": {"_id": owner_id}, "entitySetType": entity_set_type} + ) + print(f"✅ Materials set '{materials_set['name']}' ({entity_set_type}, {materials_set['_id']})") else: - existing_entity_set_type = materials_set.get("entitySetType") - if existing_entity_set_type != entity_set_type: + existing_type = materials_set.get("entitySetType") + if existing_type != entity_set_type: raise ValueError( - f"Materials set '{materials_set['name']}' already exists as " - f"'{existing_entity_set_type}', but '{entity_set_type}' was requested. " - f"Reusing it would silently drop path order — rename the set or fix its type." + f"Materials set '{materials_set['name']}' already exists as '{existing_type}', but " + f"'{entity_set_type}' was requested. Reusing it would silently drop path order." ) - print( - f"♻️ Reusing existing materials set '{materials_set['name']}' " - f"({existing_entity_set_type}, {materials_set['_id']})" - ) + print(f"♻️ Reusing materials set '{materials_set['name']}' ({existing_type}, {materials_set['_id']})") - _move_materials_into_set(api_client, materials_set["_id"], materials) + for material in materials: + identifier = material["_id"] if isinstance(material, dict) else material.id + api_client.materials.move_to_set(identifier, "", materials_set["_id"]) return materials_set From 5bb1b425fae166376757720a2324173f76a2380e Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Mon, 3 Aug 2026 21:24:40 -0700 Subject: [PATCH 21/28] fix: fail clearly when no compute clusters are available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running neb.feature against a local platform with no cluster backend registered: client.clusters.list() returns [], and the else branch did 'cluster = clusters[0]' — a bare IndexError with nothing pointing at the cause. The CLUSTER_NAME branch already explained itself; this one did not. No cells added or removed; neb.feature anchors 5/39/40 still hold. --- other/materials_designer/workflows/neb.ipynb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index f39ac36a..ccef290a 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -463,6 +463,12 @@ "from mat3ra.ide.compute import Compute\n", "\n", "# Select cluster: use specified name if provided, otherwise use first available\n", + "if not clusters:\n", + " raise ValueError(\n", + " \"No compute clusters are available on this account. A cluster backend must be \"\n", + " \"registered with the platform before a job can be submitted.\"\n", + " )\n", + "\n", "if CLUSTER_NAME:\n", " cluster = next(\n", " (candidate for candidate in clusters if CLUSTER_NAME in candidate[\"hostname\"]),\n", From 2a8c54642a5d2c67b55a1ac6a56791be305899f0 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 4 Aug 2026 09:18:11 -0700 Subject: [PATCH 22/28] rename: create_initial_final_materials, drop noise prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notebook builds an ordered start/end pair for any calculation — NEB is only the example consumer — so the filename should not say NEB either. Renames the file and updates the links in Introduction.ipynb, neb.ipynb and utils_create_material_set.ipynb. Also drops two prints that only restate what the preceding call did: the 'Wrote N material(s)' line (set_materials already logs each file it writes) and the 'Out-of-plane displacement' line I added alongside the Si(100) example. No cells added or removed; neb.feature anchors 5/39/40 still hold. --- other/materials_designer/Introduction.ipynb | 2 +- ..._neb_images.ipynb => create_initial_final_materials.ipynb} | 4 +--- other/materials_designer/utils_create_material_set.ipynb | 2 +- other/materials_designer/workflows/neb.ipynb | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) rename other/materials_designer/{create_neb_images.ipynb => create_initial_final_materials.ipynb} (97%) diff --git a/other/materials_designer/Introduction.ipynb b/other/materials_designer/Introduction.ipynb index d408bd49..95c6da56 100644 --- a/other/materials_designer/Introduction.ipynb +++ b/other/materials_designer/Introduction.ipynb @@ -77,7 +77,7 @@ "\n", "### 5.1. 3D\n", "#### [5.1.1. Maxwell-Boltzmann thermal disorder `X-3D-PER`](create_maxwell_disorder.ipynb)\n", - "#### [5.1.2. Create initial/final materials](create_neb_images.ipynb). Transform a material into initial/intermediate/final images for a Nudged Elastic Band path.\n", + "#### [5.1.2. Create initial/final materials](create_initial_final_materials.ipynb). Transform a material into initial/intermediate/final images for a Nudged Elastic Band path.\n", "\n", "### 5.2. 2D\n", "#### [5.2.1. Perturbation using sine wave `X-2D-PER`](create_perturbation.ipynb)\n", diff --git a/other/materials_designer/create_neb_images.ipynb b/other/materials_designer/create_initial_final_materials.ipynb similarity index 97% rename from other/materials_designer/create_neb_images.ipynb rename to other/materials_designer/create_initial_final_materials.ipynb index 474ea891..7155a9d1 100644 --- a/other/materials_designer/create_neb_images.ipynb +++ b/other/materials_designer/create_initial_final_materials.ipynb @@ -178,7 +178,6 @@ " f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → \"\n", " f\"{final_material.coordinates_array[ATOM_INDEX]}\"\n", ")\n", - "print(f\"Out-of-plane displacement: {TRANSLATION[2] * final_material.lattice.c:.3f} Å\")\n", "visualize([initial_material, final_material])\n" ] }, @@ -207,8 +206,7 @@ " material.name = f\"{index:02d}_{PATH_NAME}\"\n", "\n", "subfolder_path = f\"{UPLOADS_FOLDER}/{SUBFOLDER_NAME}\"\n", - "set_materials(path_materials, folder_path=subfolder_path)\n", - "print(f\"Wrote {len(path_materials)} material(s) to {subfolder_path}/\")\n" + "set_materials(path_materials, folder_path=subfolder_path)\n" ] } ], diff --git a/other/materials_designer/utils_create_material_set.ipynb b/other/materials_designer/utils_create_material_set.ipynb index 10576dd7..1bb51569 100644 --- a/other/materials_designer/utils_create_material_set.ipynb +++ b/other/materials_designer/utils_create_material_set.ipynb @@ -9,7 +9,7 @@ "\n", "Save materials to a platform materials set — **ordered** (path order, e.g. NEB) or **unordered** (e.g. convex hull, EOS series).\n", "\n", - "This notebook only packages materials — it does not build or transform them. Build materials elsewhere (Materials Designer, a `create_*` notebook, or a dedicated builder notebook such as [`create_neb_images.ipynb`](create_neb_images.ipynb)), then either:\n", + "This notebook only packages materials — it does not build or transform them. Build materials elsewhere (Materials Designer, a `create_*` notebook, or a dedicated builder notebook such as [`create_initial_final_materials.ipynb`](create_initial_final_materials.ipynb)), then either:\n", "\n", "- Select them as **Input Materials** (outer runtime), or\n", "- Write them to a subfolder under `uploads/` with `set_materials(materials, folder_path=f\"uploads/{SUBFOLDER_NAME}\")` — filename sort order becomes set order for ordered sets, so number material names (`00_...`, `01_...`, …).\n", diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index ccef290a..5b8869dd 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -15,7 +15,7 @@ "\n", "If you do not already have those materials on the platform, prepare them first in Materials Designer:\n", "\n", - "1. Open [`create_neb_images.ipynb`](../create_neb_images.ipynb) (under Materials / Perturbations). Build the initial → final (and optional intermediate) images and run it so they are written to `uploads//` with numbered names (`00_…`, `01_…`, …).\n", + "1. Open [`create_initial_final_materials.ipynb`](../create_initial_final_materials.ipynb) (under Materials / Perturbations). Build the initial → final (and optional intermediate) images and run it so they are written to `uploads//` with numbered names (`00_…`, `01_…`, …).\n", "\n", "Then choose **one** of these ways to feed this NEB notebook:\n", "\n", From 002bc7f605534c76d1779fa350e3011e173766cb Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 4 Aug 2026 09:25:46 -0700 Subject: [PATCH 23/28] feat: translate_atoms helper, use it instead of a coordinate loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'with perturbations' in 8dcd4ca9 was aspirational — the notebook hand-rolled a loop over set_coordinates. Made has no per-atom move: translate/translate_by_vector move the whole material, and create_perturbation applies f(x,y,z) to every atom through an arc-length normalisation built for sine waves (tested on the Si(100) slab: it displaced all 8 atoms by ~0.28 instead of the target by 0.05). So write the obvious function. translate_atoms(material, indices, vector) moves the named atoms and nothing else, taking ANGSTROM by default — a crystal delta means a different distance in every cell, which is why '-0.05' silently meant 1.09 A here. get_atom_indices_by_height picks the outermost atom so the example survives a change of slab instead of hardcoding index 5. 7 unit tests; the notebook now moves exactly 1.0 A and leaves the other atoms bit-identical. No cells added or removed. --- .../create_initial_final_materials.ipynb | 34 +++---- other/materials_designer/workflows/neb.ipynb | 3 - .../core/entity/material/modify.py | 75 +++++++++++++++ src/py/mat3ra/notebooks_utils/material.py | 10 +- .../unit/core/entity/test_material_modify.py | 95 +++++++++++++++++++ 5 files changed, 197 insertions(+), 20 deletions(-) create mode 100644 src/py/mat3ra/notebooks_utils/core/entity/material/modify.py create mode 100644 tests/py/unit/core/entity/test_material_modify.py diff --git a/other/materials_designer/create_initial_final_materials.ipynb b/other/materials_designer/create_initial_final_materials.ipynb index 7155a9d1..3568aa11 100644 --- a/other/materials_designer/create_initial_final_materials.ipynb +++ b/other/materials_designer/create_initial_final_materials.ipynb @@ -93,14 +93,15 @@ "metadata": {}, "outputs": [], "source": [ - "# Atom 5 is the lowest atom of the Si(100) slab — the one facing the vacuum gap.\n", - "# Moving it further down stays inside the cell, so the two images read side by side\n", - "# without an atom wrapping across the periodic boundary.\n", - "ATOM_INDEX = 5\n", - "\n", - "# Crystal coordinates: z is a fraction of the ~21.9 Å cell height, so -0.05 is\n", - "# ~1.1 Å out of the surface plane.\n", - "TRANSLATION = [0.0, 0.0, -0.05]\n" + "# Which atom to move. None = pick the outermost one automatically, so this keeps\n", + "# working if the slab changes; set an integer to target a specific atom.\n", + "ATOM_INDEX = None\n", + "\n", + "# Displacement in ANGSTROM. Moving the bottom atom down, into the vacuum gap,\n", + "# keeps it inside the cell — displacing the top atom upwards would wrap it\n", + "# across the periodic boundary and the two images become hard to read.\n", + "TRANSLATION = [0.0, 0.0, -1.0]\n", + "FROM_TOP = False\n" ] }, { @@ -156,7 +157,7 @@ "source": [ "### 2.3. Transform into the final image (example)\n", "\n", - "Default: translate the atom at `ATOM_INDEX` by `TRANSLATION`. Replace with any other transformation (defects, swaps, custom coordinates, …) — only the resulting list of materials in 2.4 matters.\n", + "Default: move one surface atom by `TRANSLATION`, given in Ångström. Replace with any other transformation (defects, swaps, custom coordinates, …) — only the resulting list of materials in 2.4 matters.\n", "\n", "For a smooth localized displacement rather than a single-atom jump, `mat3ra.made.tools.helpers.create_perturbation(material, expression, use_cartesian_coordinates=False)` maps a scalar `f(x, y, z)` to `∆z`; a Gaussian centred on the moving atom gives the same path with its neighbours relaxing along it.\n" ] @@ -168,15 +169,16 @@ "metadata": {}, "outputs": [], "source": [ - "final_material = initial_material.clone()\n", - "coordinates = [list(coordinate) for coordinate in final_material.coordinates_array]\n", - "for axis_index, delta in enumerate(TRANSLATION):\n", - " coordinates[ATOM_INDEX][axis_index] += delta\n", - "final_material.set_coordinates(coordinates)\n", + "from mat3ra.notebooks_utils.material import get_atom_indices_by_height, translate_atoms\n", + "\n", + "atom_index = ATOM_INDEX if ATOM_INDEX is not None else get_atom_indices_by_height(\n", + " initial_material, from_top=FROM_TOP\n", + ")[0]\n", + "final_material = translate_atoms(initial_material, atom_index, TRANSLATION)\n", "\n", "print(\n", - " f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → \"\n", - " f\"{final_material.coordinates_array[ATOM_INDEX]}\"\n", + " f\"Atom {atom_index}: {initial_material.coordinates_array[atom_index]} → \"\n", + " f\"{final_material.coordinates_array[atom_index]}\"\n", ")\n", "visualize([initial_material, final_material])\n" ] diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index 5b8869dd..bcbb47b4 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -271,9 +271,6 @@ " )\n", " MATERIAL_SET = materials_set[\"name\"]\n", "else:\n", - " # require_ordered: an unordered set carries no inSet.index, so its members\n", - " # would come back in arbitrary order and the barrier would be for a path\n", - " # nobody chose.\n", " materials_set = find_material_set(client, ACCOUNT_ID, MATERIAL_SET, require_ordered=True)\n", "\n", "material_dicts = list_materials_in_set(client, ACCOUNT_ID, materials_set)\n", diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py b/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py new file mode 100644 index 00000000..4c12f2c4 --- /dev/null +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py @@ -0,0 +1,75 @@ +from typing import List, Sequence, Union + +import numpy as np +from mat3ra.made.material import Material + + +def translate_atoms( + material: Material, + atom_indices: Union[int, Sequence[int]], + vector: Sequence[float], + use_cartesian_coordinates: bool = True, +) -> Material: + """ + Move selected atoms by a vector, leaving every other atom where it was. + + Made's translate operations move the whole material and its perturbation + functions apply to every atom, so displacing a single site — an adatom + hop, a vacancy relaxation, one end of a reaction path — has no helper. + + Args: + material (Material): Material to modify. Not mutated; a clone is returned. + atom_indices (int | Sequence[int]): Index or indices of the atoms to move. + vector (Sequence[float]): Displacement, in Angstrom when + `use_cartesian_coordinates` (default) else in crystal coordinates. + use_cartesian_coordinates (bool): Interpret `vector` as Angstrom. Prefer + this — a crystal delta means a different distance in every cell. + + Returns: + Material: A new material with the selected atoms displaced. + + Raises: + IndexError: If any index is out of range for the basis. + + Note: + Coordinates are not wrapped back into the cell, so an atom may end up + outside it — legal under periodic boundary conditions, but harder to + read. Displace away from the cell edge when the images are for a human. + """ + if isinstance(atom_indices, int): + atom_indices = [atom_indices] + + coordinates = [list(coordinate) for coordinate in material.coordinates_array] + out_of_range = [index for index in atom_indices if not -len(coordinates) <= index < len(coordinates)] + if out_of_range: + raise IndexError(f"Atom indices {out_of_range} are out of range for a basis of {len(coordinates)} atoms.") + + if use_cartesian_coordinates: + inverse_lattice_vectors = np.linalg.inv(np.array(material.lattice.vector_arrays)) + vector = (np.array(vector) @ inverse_lattice_vectors).tolist() + + moved_material = material.clone() + for index in atom_indices: + coordinates[index] = [value + delta for value, delta in zip(coordinates[index], vector)] + moved_material.set_coordinates(coordinates) + return moved_material + + +def get_atom_indices_by_height(material: Material, count: int = 1, from_top: bool = True) -> List[int]: + """ + Indices of the `count` highest (or lowest) atoms along the third lattice vector. + + Picking a surface atom by eye means hardcoding an index that silently points + at a different atom as soon as the slab changes. + + Args: + material (Material): Material to inspect. + count (int): How many atoms to return. + from_top (bool): Return the highest atoms; otherwise the lowest. + + Returns: + list[int]: Atom indices, ordered outermost first. + """ + heights = [coordinate[2] for coordinate in material.coordinates_array] + ordered = sorted(range(len(heights)), key=lambda index: heights[index], reverse=from_top) + return ordered[:count] diff --git a/src/py/mat3ra/notebooks_utils/material.py b/src/py/mat3ra/notebooks_utils/material.py index 69cb41cf..35909cb1 100644 --- a/src/py/mat3ra/notebooks_utils/material.py +++ b/src/py/mat3ra/notebooks_utils/material.py @@ -1,8 +1,16 @@ -from .core.entity.material.io import get_materials, load_material_from_folder, load_materials_from_folder, set_materials +from .core.entity.material.io import ( + get_materials, + load_material_from_folder, + load_materials_from_folder, + set_materials, +) +from .core.entity.material.modify import get_atom_indices_by_height, translate_atoms __all__ = [ "get_materials", "set_materials", "load_materials_from_folder", "load_material_from_folder", + "translate_atoms", + "get_atom_indices_by_height", ] diff --git a/tests/py/unit/core/entity/test_material_modify.py b/tests/py/unit/core/entity/test_material_modify.py new file mode 100644 index 00000000..e45a60bb --- /dev/null +++ b/tests/py/unit/core/entity/test_material_modify.py @@ -0,0 +1,95 @@ +import numpy as np +import pytest +from mat3ra.made.material import Material +from mat3ra.notebooks_utils.core.entity.material.modify import ( + get_atom_indices_by_height, + translate_atoms, +) + +LATTICE_VECTORS = [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 20.0]] + +MATERIAL_CONFIG = { + "name": "test-slab", + "lattice": { + "a": 4.0, + "b": 4.0, + "c": 20.0, + "alpha": 90.0, + "beta": 90.0, + "gamma": 90.0, + "units": {"length": "angstrom", "angle": "degree"}, + "type": "TRI", + "vectors": { + "a": LATTICE_VECTORS[0], + "b": LATTICE_VECTORS[1], + "c": LATTICE_VECTORS[2], + "alat": 1, + "units": "angstrom", + }, + }, + "basis": { + "elements": [{"id": 0, "value": "Si"}, {"id": 1, "value": "Si"}, {"id": 2, "value": "Si"}], + "coordinates": [ + {"id": 0, "value": [0.0, 0.0, 0.20]}, + {"id": 1, "value": [0.5, 0.5, 0.50]}, + {"id": 2, "value": [0.0, 0.5, 0.80]}, + ], + "units": "crystal", + "cell": {"a": LATTICE_VECTORS[0], "b": LATTICE_VECTORS[1], "c": LATTICE_VECTORS[2]}, + }, +} + + +@pytest.fixture +def material(): + return Material.create(MATERIAL_CONFIG) + + +def test_translate_atoms_moves_only_the_named_atom(material): + moved = translate_atoms(material, 1, [0.0, 0.0, -2.0]) + + assert moved.coordinates_array[0] == material.coordinates_array[0] + assert moved.coordinates_array[2] == material.coordinates_array[2] + assert moved.coordinates_array[1] != material.coordinates_array[1] + + +def test_translate_atoms_cartesian_vector_is_angstrom(material): + moved = translate_atoms(material, 1, [0.0, 0.0, -2.0]) + + displacement = (np.array(moved.coordinates_array[1]) - np.array(material.coordinates_array[1])) @ np.array( + LATTICE_VECTORS + ) + assert np.linalg.norm(displacement) == pytest.approx(2.0) + + +def test_translate_atoms_crystal_vector_is_fractional(material): + moved = translate_atoms(material, 1, [0.0, 0.0, -0.1], use_cartesian_coordinates=False) + + assert moved.coordinates_array[1][2] == pytest.approx(0.4) + + +def test_translate_atoms_accepts_several_indices(material): + moved = translate_atoms(material, [0, 2], [0.0, 0.0, 1.0]) + + assert moved.coordinates_array[1] == material.coordinates_array[1] + assert moved.coordinates_array[0][2] > material.coordinates_array[0][2] + assert moved.coordinates_array[2][2] > material.coordinates_array[2][2] + + +def test_translate_atoms_does_not_mutate_the_input(material): + before = [list(coordinate) for coordinate in material.coordinates_array] + + translate_atoms(material, 1, [0.0, 0.0, -2.0]) + + assert [list(coordinate) for coordinate in material.coordinates_array] == before + + +def test_translate_atoms_rejects_an_index_outside_the_basis(material): + with pytest.raises(IndexError, match="out of range"): + translate_atoms(material, 7, [0.0, 0.0, -1.0]) + + +def test_get_atom_indices_by_height_picks_the_outermost(material): + assert get_atom_indices_by_height(material) == [2] + assert get_atom_indices_by_height(material, from_top=False) == [0] + assert get_atom_indices_by_height(material, count=2) == [2, 1] From b0e78e53038385eb81bce1dabeab683fc8a83355 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 4 Aug 2026 09:27:47 -0700 Subject: [PATCH 24/28] update: cleanup --- other/materials_designer/workflows/neb.ipynb | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/other/materials_designer/workflows/neb.ipynb b/other/materials_designer/workflows/neb.ipynb index bcbb47b4..5917955e 100644 --- a/other/materials_designer/workflows/neb.ipynb +++ b/other/materials_designer/workflows/neb.ipynb @@ -459,21 +459,8 @@ "source": [ "from mat3ra.ide.compute import Compute\n", "\n", - "# Select cluster: use specified name if provided, otherwise use first available\n", - "if not clusters:\n", - " raise ValueError(\n", - " \"No compute clusters are available on this account. A cluster backend must be \"\n", - " \"registered with the platform before a job can be submitted.\"\n", - " )\n", - "\n", "if CLUSTER_NAME:\n", - " cluster = next(\n", - " (candidate for candidate in clusters if CLUSTER_NAME in candidate[\"hostname\"]),\n", - " None,\n", - " )\n", - " if cluster is None:\n", - " hostnames = [candidate[\"hostname\"] for candidate in clusters]\n", - " raise ValueError(f\"No cluster matching CLUSTER_NAME={CLUSTER_NAME!r}. Available: {hostnames}\")\n", + " cluster = next((c for c in clusters if CLUSTER_NAME in c[\"hostname\"]), None)\n", "else:\n", " cluster = clusters[0]\n", "\n", From 85ecff15cd3d8296c0fb4e479cdf759e661290d1 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 4 Aug 2026 09:31:30 -0700 Subject: [PATCH 25/28] revert: drop get_atom_indices_by_height and FROM_TOP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invented, not asked for. The ask was a function that moves atoms; auto-picking which atom to move was me adding scope. ATOM_INDEX is an explicit 5 again. translate_atoms stays — that is the function. --- .../create_initial_final_materials.ipynb | 25 +++++--------- .../core/entity/material/modify.py | 34 ++----------------- src/py/mat3ra/notebooks_utils/material.py | 3 +- .../unit/core/entity/test_material_modify.py | 11 +----- 4 files changed, 13 insertions(+), 60 deletions(-) diff --git a/other/materials_designer/create_initial_final_materials.ipynb b/other/materials_designer/create_initial_final_materials.ipynb index 3568aa11..63043a3c 100644 --- a/other/materials_designer/create_initial_final_materials.ipynb +++ b/other/materials_designer/create_initial_final_materials.ipynb @@ -93,15 +93,11 @@ "metadata": {}, "outputs": [], "source": [ - "# Which atom to move. None = pick the outermost one automatically, so this keeps\n", - "# working if the slab changes; set an integer to target a specific atom.\n", - "ATOM_INDEX = None\n", - "\n", - "# Displacement in ANGSTROM. Moving the bottom atom down, into the vacuum gap,\n", - "# keeps it inside the cell — displacing the top atom upwards would wrap it\n", - "# across the periodic boundary and the two images become hard to read.\n", - "TRANSLATION = [0.0, 0.0, -1.0]\n", - "FROM_TOP = False\n" + "# Atom 5 is the lowest atom of the Si(100) slab, the one facing the vacuum gap.\n", + "ATOM_INDEX = 5\n", + "\n", + "# Displacement in Angstrom.\n", + "TRANSLATION = [0.0, 0.0, -1.0]\n" ] }, { @@ -169,16 +165,13 @@ "metadata": {}, "outputs": [], "source": [ - "from mat3ra.notebooks_utils.material import get_atom_indices_by_height, translate_atoms\n", + "from mat3ra.notebooks_utils.material import translate_atoms\n", "\n", - "atom_index = ATOM_INDEX if ATOM_INDEX is not None else get_atom_indices_by_height(\n", - " initial_material, from_top=FROM_TOP\n", - ")[0]\n", - "final_material = translate_atoms(initial_material, atom_index, TRANSLATION)\n", + "final_material = translate_atoms(initial_material, ATOM_INDEX, TRANSLATION)\n", "\n", "print(\n", - " f\"Atom {atom_index}: {initial_material.coordinates_array[atom_index]} → \"\n", - " f\"{final_material.coordinates_array[atom_index]}\"\n", + " f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → \"\n", + " f\"{final_material.coordinates_array[ATOM_INDEX]}\"\n", ")\n", "visualize([initial_material, final_material])\n" ] diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py b/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py index 4c12f2c4..76eed7d9 100644 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py +++ b/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py @@ -1,4 +1,4 @@ -from typing import List, Sequence, Union +from typing import Sequence, Union import numpy as np from mat3ra.made.material import Material @@ -13,28 +13,18 @@ def translate_atoms( """ Move selected atoms by a vector, leaving every other atom where it was. - Made's translate operations move the whole material and its perturbation - functions apply to every atom, so displacing a single site — an adatom - hop, a vacancy relaxation, one end of a reaction path — has no helper. - Args: material (Material): Material to modify. Not mutated; a clone is returned. atom_indices (int | Sequence[int]): Index or indices of the atoms to move. vector (Sequence[float]): Displacement, in Angstrom when `use_cartesian_coordinates` (default) else in crystal coordinates. - use_cartesian_coordinates (bool): Interpret `vector` as Angstrom. Prefer - this — a crystal delta means a different distance in every cell. + use_cartesian_coordinates (bool): Interpret `vector` as Angstrom. Returns: Material: A new material with the selected atoms displaced. Raises: IndexError: If any index is out of range for the basis. - - Note: - Coordinates are not wrapped back into the cell, so an atom may end up - outside it — legal under periodic boundary conditions, but harder to - read. Displace away from the cell edge when the images are for a human. """ if isinstance(atom_indices, int): atom_indices = [atom_indices] @@ -53,23 +43,3 @@ def translate_atoms( coordinates[index] = [value + delta for value, delta in zip(coordinates[index], vector)] moved_material.set_coordinates(coordinates) return moved_material - - -def get_atom_indices_by_height(material: Material, count: int = 1, from_top: bool = True) -> List[int]: - """ - Indices of the `count` highest (or lowest) atoms along the third lattice vector. - - Picking a surface atom by eye means hardcoding an index that silently points - at a different atom as soon as the slab changes. - - Args: - material (Material): Material to inspect. - count (int): How many atoms to return. - from_top (bool): Return the highest atoms; otherwise the lowest. - - Returns: - list[int]: Atom indices, ordered outermost first. - """ - heights = [coordinate[2] for coordinate in material.coordinates_array] - ordered = sorted(range(len(heights)), key=lambda index: heights[index], reverse=from_top) - return ordered[:count] diff --git a/src/py/mat3ra/notebooks_utils/material.py b/src/py/mat3ra/notebooks_utils/material.py index 35909cb1..f603d076 100644 --- a/src/py/mat3ra/notebooks_utils/material.py +++ b/src/py/mat3ra/notebooks_utils/material.py @@ -4,7 +4,7 @@ load_materials_from_folder, set_materials, ) -from .core.entity.material.modify import get_atom_indices_by_height, translate_atoms +from .core.entity.material.modify import translate_atoms __all__ = [ "get_materials", @@ -12,5 +12,4 @@ "load_materials_from_folder", "load_material_from_folder", "translate_atoms", - "get_atom_indices_by_height", ] diff --git a/tests/py/unit/core/entity/test_material_modify.py b/tests/py/unit/core/entity/test_material_modify.py index e45a60bb..8b653f03 100644 --- a/tests/py/unit/core/entity/test_material_modify.py +++ b/tests/py/unit/core/entity/test_material_modify.py @@ -1,10 +1,7 @@ import numpy as np import pytest from mat3ra.made.material import Material -from mat3ra.notebooks_utils.core.entity.material.modify import ( - get_atom_indices_by_height, - translate_atoms, -) +from mat3ra.notebooks_utils.core.entity.material.modify import translate_atoms LATTICE_VECTORS = [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 20.0]] @@ -87,9 +84,3 @@ def test_translate_atoms_does_not_mutate_the_input(material): def test_translate_atoms_rejects_an_index_outside_the_basis(material): with pytest.raises(IndexError, match="out of range"): translate_atoms(material, 7, [0.0, 0.0, -1.0]) - - -def test_get_atom_indices_by_height_picks_the_outermost(material): - assert get_atom_indices_by_height(material) == [2] - assert get_atom_indices_by_height(material, from_top=False) == [0] - assert get_atom_indices_by_height(material, count=2) == [2, 1] From 9f52c7ac1c81782b1409c8fc1f781c69a9128a28 Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 4 Aug 2026 09:41:31 -0700 Subject: [PATCH 26/28] update: adjust nb --- .../create_initial_final_materials.ipynb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/other/materials_designer/create_initial_final_materials.ipynb b/other/materials_designer/create_initial_final_materials.ipynb index 63043a3c..884da751 100644 --- a/other/materials_designer/create_initial_final_materials.ipynb +++ b/other/materials_designer/create_initial_final_materials.ipynb @@ -69,11 +69,9 @@ "MATERIAL_NAME = \"Silicon (100) surface\"\n", "\n", "# Short base name for the written images: 00_.json, 01_.json.\n", - "# Standata names are long and comma-heavy, and the filename is what sets load order.\n", "PATH_NAME = \"Si-100-surface\"\n", "\n", "# Subfolder under uploads/ to write path materials into — use the same value as\n", - "# SUBFOLDER_NAME in utils_create_material_set.ipynb.\n", "SUBFOLDER_NAME = \"neb_si_100_surface\"\n" ] }, @@ -93,11 +91,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Atom 5 is the lowest atom of the Si(100) slab, the one facing the vacuum gap.\n", "ATOM_INDEX = 5\n", "\n", "# Displacement in Angstrom.\n", - "TRANSLATION = [0.0, 0.0, -1.0]\n" + "TRANSLATION = [0.0, 0.0, -2.0]\n" ] }, { @@ -173,7 +170,7 @@ " f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → \"\n", " f\"{final_material.coordinates_array[ATOM_INDEX]}\"\n", ")\n", - "visualize([initial_material, final_material])\n" + "visualize([initial_material, final_material], viewer=\"wave\")\n" ] }, { From fd64fdfe067f83ba98b4f8035b99825e2fd1ec9f Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 4 Aug 2026 10:52:17 -0700 Subject: [PATCH 27/28] update: generalize --- other/materials_designer/utils_create_material_set.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/other/materials_designer/utils_create_material_set.ipynb b/other/materials_designer/utils_create_material_set.ipynb index 1bb51569..10e0c653 100644 --- a/other/materials_designer/utils_create_material_set.ipynb +++ b/other/materials_designer/utils_create_material_set.ipynb @@ -67,14 +67,14 @@ "ORGANIZATION_NAME = None\n", "\n", "# Materials set on the platform (use this name in workflow notebooks)\n", - "MATERIAL_SET_NAME = \"NEB Si(100) surface\"\n", + "MATERIAL_SET_NAME = \"My Materials Set\"\n", "# True = path order via inSet.index (NEB); False = bag of members (hull, EOS, …)\n", "IS_ORDERED = True\n", "\n", "# Subfolder under uploads/ to load materials from (written there by a builder notebook via\n", "# set_materials(materials, folder_path=f\"uploads/{SUBFOLDER_NAME}\")).\n", "# None = use Input Materials (outer runtime), falling back to the uploads/ root.\n", - "SUBFOLDER_NAME = None # e.g. \"neb_si_100_surface\"\n" + "SUBFOLDER_NAME = None # e.g. \"my_materials\"\n" ] }, { From 3b132e7db097c627488e38691602b3a6c9ef223e Mon Sep 17 00:00:00 2001 From: VsevolodX Date: Tue, 4 Aug 2026 12:59:05 -0700 Subject: [PATCH 28/28] update: use made function --- .../create_initial_final_materials.ipynb | 129 +++++++++++++----- .../core/entity/material/modify.py | 45 ------ src/py/mat3ra/notebooks_utils/material.py | 9 +- .../unit/core/entity/test_material_modify.py | 86 ------------ 4 files changed, 93 insertions(+), 176 deletions(-) delete mode 100644 src/py/mat3ra/notebooks_utils/core/entity/material/modify.py delete mode 100644 tests/py/unit/core/entity/test_material_modify.py diff --git a/other/materials_designer/create_initial_final_materials.ipynb b/other/materials_designer/create_initial_final_materials.ipynb index 884da751..99b79925 100644 --- a/other/materials_designer/create_initial_final_materials.ipynb +++ b/other/materials_designer/create_initial_final_materials.ipynb @@ -9,23 +9,24 @@ "\n", "Build an ordered initial → (optional intermediates) → final set of materials from one starting structure, and write them to a subfolder under `uploads/` in path order for [`utils_create_material_set.ipynb`](utils_create_material_set.ipynb).\n", "\n", - "Nothing here is specific to a Nudged Elastic Band path — that is just the example this notebook is set up for, and the consumer it feeds ([`neb.ipynb`](workflows/neb.ipynb)). Any calculation that takes an ordered start/end pair can use the same output.\n", + "Any calculation that takes an ordered start/end pair can use the output; a Nudged Elastic Band path ([`neb.ipynb`](workflows/neb.ipynb)) is one consumer.\n", "\n", "Order is preserved by **numbering material names** (`00_...`, `01_...`, …): `utils_create_material_set.ipynb` (and `load_materials_from_folder`) sort by filename, and filenames come from material names.\n", "\n", "## Usage\n", "\n", - "1. Set material and subfolder name in cell 1.2, transform params in 1.3.\n", - "1. Run all cells to build and write the path materials.\n", + "1. Set the material and the names in cell 1.2.\n", + "1. Run 2.1, copy the coordinates of the atom you want to move, and paste them into 2.2.\n", + "1. Run the rest to build and write the path materials.\n", "1. Open [`utils_create_material_set.ipynb`](utils_create_material_set.ipynb), set the same `SUBFOLDER_NAME` and `IS_ORDERED = True`, and run it to save the materials and create the platform set.\n", "1. Use the printed set name as `MATERIAL_SET` in [`neb.ipynb`](workflows/neb.ipynb).\n", "\n", "## Summary\n", "\n", "1. Install packages and set parameters.\n", - "1. Load the starting material — by default the Si(100) surface from Standata.\n", - "1. Clone as the initial image; transform a copy into the final image (default: move one surface atom out of the surface plane, into the vacuum).\n", - "1. Name members in path order and write them to `uploads//`.\n" + "1. Load the starting material.\n", + "1. Clone it as the initial image; move one atom to make the final image.\n", + "1. Name members in path order and write them to `uploads//`." ] }, { @@ -69,10 +70,10 @@ "MATERIAL_NAME = \"Silicon (100) surface\"\n", "\n", "# Short base name for the written images: 00_.json, 01_.json.\n", - "PATH_NAME = \"Si-100-surface\"\n", + "PATH_NAME = \"initial-final-materials\"\n", "\n", "# Subfolder under uploads/ to write path materials into — use the same value as\n", - "SUBFOLDER_NAME = \"neb_si_100_surface\"\n" + "SUBFOLDER_NAME = \"initial_final_materials\"" ] }, { @@ -80,8 +81,12 @@ "id": "5", "metadata": {}, "source": [ - "### 1.3. Set example transformation parameters\n", - "Default example: move one surface atom out of the surface plane, in crystal coordinates. Replace this cell and 2.3 below for other paths.\n" + "## 2. Build path materials\n", + "### 2.1. Load the starting material\n", + "\n", + "To read an atom's coordinates from the viewer below: open **Measurements** (the ruler icon), turn on\n", + "**Copy Coordinates [C]** — or press `C` — then click the atom. Its coordinates go to the clipboard,\n", + "ready to paste into 2.2." ] }, { @@ -91,10 +96,15 @@ "metadata": {}, "outputs": [], "source": [ - "ATOM_INDEX = 5\n", + "from mat3ra.made.material import Material\n", + "from mat3ra.standata.materials import Materials\n", + "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", + "from mat3ra.notebooks_utils.material import load_material_from_folder\n", "\n", - "# Displacement in Angstrom.\n", - "TRANSLATION = [0.0, 0.0, -2.0]\n" + "source_material = load_material_from_folder(FOLDER, MATERIAL_NAME) or Material.create(\n", + " Materials.get_by_name_first_match(MATERIAL_NAME)\n", + ")\n", + "visualize(source_material, viewer=\"wave\")\n" ] }, { @@ -102,8 +112,12 @@ "id": "7", "metadata": {}, "source": [ - "## 2. Build path materials\n", - "### 2.1. Load starting material\n" + "### 2.2. Choose the atom to move\n", + "\n", + "`ATOM_COORDINATE` is the atom you copied above, in crystal coordinates; the nearest atom to it is the\n", + "one that moves.\n", + "\n", + "`TRANSLATION` is vector to move it, in Ångström." ] }, { @@ -113,15 +127,11 @@ "metadata": {}, "outputs": [], "source": [ - "from mat3ra.made.material import Material\n", - "from mat3ra.standata.materials import Materials\n", - "from mat3ra.notebooks_utils.ipython.entity.material.visualize import visualize_materials as visualize\n", - "from mat3ra.notebooks_utils.material import load_material_from_folder\n", + "# Coordinates of the atom to move, copied from the viewer above.\n", + "ATOM_COORDINATE = [0.0, 0.5, 0.5633]\n", "\n", - "source_material = load_material_from_folder(FOLDER, MATERIAL_NAME) or Material.create(\n", - " Materials.get_by_name_first_match(MATERIAL_NAME)\n", - ")\n", - "visualize(source_material)\n" + "# Displacement in Angstrom.\n", + "TRANSLATION = [0.0, 0.0, -2.0]" ] }, { @@ -129,7 +139,7 @@ "id": "9", "metadata": {}, "source": [ - "### 2.2. Clone as the initial image\n" + "### 2.3. Clone as the initial image\n" ] }, { @@ -140,7 +150,7 @@ "outputs": [], "source": [ "initial_material = source_material.clone()\n", - "visualize(initial_material)\n" + "visualize(initial_material, rotation=\"-90x\")\n" ] }, { @@ -148,11 +158,11 @@ "id": "11", "metadata": {}, "source": [ - "### 2.3. Transform into the final image (example)\n", + "### 2.4. Transform into the final image (example)\n", "\n", - "Default: move one surface atom by `TRANSLATION`, given in Ångström. Replace with any other transformation (defects, swaps, custom coordinates, …) — only the resulting list of materials in 2.4 matters.\n", + "Default: move the atom at `ATOM_COORDINATE` by `TRANSLATION`. Replace with any other transformation (defects, swaps, custom coordinates, …).\n", "\n", - "For a smooth localized displacement rather than a single-atom jump, `mat3ra.made.tools.helpers.create_perturbation(material, expression, use_cartesian_coordinates=False)` maps a scalar `f(x, y, z)` to `∆z`; a Gaussian centred on the moving atom gives the same path with its neighbours relaxing along it.\n" + "2.5 below shows the same move done as a displacement *field* instead, which is what you want when the neighbours should relax along with the atom.\n" ] }, { @@ -162,15 +172,13 @@ "metadata": {}, "outputs": [], "source": [ - "from mat3ra.notebooks_utils.material import translate_atoms\n", + "from mat3ra.made.tools.analyze.other import get_closest_site_id_from_coordinate\n", + "from mat3ra.made.tools.operations.core.unary import translate_atoms\n", "\n", - "final_material = translate_atoms(initial_material, ATOM_INDEX, TRANSLATION)\n", + "atom_id = get_closest_site_id_from_coordinate(initial_material, ATOM_COORDINATE)\n", + "final_material = translate_atoms(initial_material, [atom_id], TRANSLATION)\n", "\n", - "print(\n", - " f\"Atom {ATOM_INDEX}: {initial_material.coordinates_array[ATOM_INDEX]} → \"\n", - " f\"{final_material.coordinates_array[ATOM_INDEX]}\"\n", - ")\n", - "visualize([initial_material, final_material], viewer=\"wave\")\n" + "visualize([initial_material, final_material], viewer=\"wave\")" ] }, { @@ -178,9 +186,15 @@ "id": "13", "metadata": {}, "source": [ - "### 2.4. Name members in path order and write to the subfolder\n", + "### 2.5. Alternative: move the atom with a perturbation function (example)\n", "\n", - "Numeric prefixes control load order in `utils_create_material_set.ipynb` (filenames are sorted; filenames come from material names).\n" + "`translate_atoms` above applies a fixed vector. A perturbation function instead returns `∆z` for\n", + "*every* atom from `f(x, y, z)`, so a Gaussian centred on one atom keeps the displacement local to\n", + "it — and lets the path be shaped (neighbours relaxing along with it, a wave, a decaying tail)\n", + "rather than a rigid shift.\n", + "\n", + "Left commented out so \"Run All\" uses the simple translation. Uncomment it to overwrite\n", + "`final_material`, run, and compare the two structures in the viewer." ] }, { @@ -189,6 +203,47 @@ "id": "14", "metadata": {}, "outputs": [], + "source": [ + "# import numpy as np\n", + "# import sympy as sp\n", + "# from mat3ra.made.tools.build_components.operations.core.modifications.perturb import FunctionHolder\n", + "# from mat3ra.made.tools.operations.core.unary import perturb\n", + "#\n", + "# SIGMA = 0.5 # Angstrom — how tightly the displacement is localised around the atom\n", + "#\n", + "# center_x, center_y, center_z = np.array(ATOM_COORDINATE) @ np.array(\n", + "# initial_material.lattice.vector_arrays\n", + "# )\n", + "#\n", + "# x, y, z = sp.symbols(\"x y z\")\n", + "# displacement_function = TRANSLATION[2] * sp.exp(\n", + "# -(((x - center_x) ** 2 + (y - center_y) ** 2 + (z - center_z) ** 2) / (2 * SIGMA**2))\n", + "# )\n", + "#\n", + "# final_material = perturb(\n", + "# initial_material,\n", + "# FunctionHolder(function=displacement_function),\n", + "# use_cartesian_coordinates=True,\n", + "# )\n", + "# visualize([initial_material, final_material], viewer=\"wave\")" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "### 2.6. Name members in path order and write to the subfolder\n", + "\n", + "Numeric prefixes control load order in `utils_create_material_set.ipynb` (filenames are sorted; filenames come from material names).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], "source": [ "from mat3ra.notebooks_utils.material import set_materials\n", "from mat3ra.notebooks_utils.settings import UPLOADS_FOLDER\n", diff --git a/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py b/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py deleted file mode 100644 index 76eed7d9..00000000 --- a/src/py/mat3ra/notebooks_utils/core/entity/material/modify.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Sequence, Union - -import numpy as np -from mat3ra.made.material import Material - - -def translate_atoms( - material: Material, - atom_indices: Union[int, Sequence[int]], - vector: Sequence[float], - use_cartesian_coordinates: bool = True, -) -> Material: - """ - Move selected atoms by a vector, leaving every other atom where it was. - - Args: - material (Material): Material to modify. Not mutated; a clone is returned. - atom_indices (int | Sequence[int]): Index or indices of the atoms to move. - vector (Sequence[float]): Displacement, in Angstrom when - `use_cartesian_coordinates` (default) else in crystal coordinates. - use_cartesian_coordinates (bool): Interpret `vector` as Angstrom. - - Returns: - Material: A new material with the selected atoms displaced. - - Raises: - IndexError: If any index is out of range for the basis. - """ - if isinstance(atom_indices, int): - atom_indices = [atom_indices] - - coordinates = [list(coordinate) for coordinate in material.coordinates_array] - out_of_range = [index for index in atom_indices if not -len(coordinates) <= index < len(coordinates)] - if out_of_range: - raise IndexError(f"Atom indices {out_of_range} are out of range for a basis of {len(coordinates)} atoms.") - - if use_cartesian_coordinates: - inverse_lattice_vectors = np.linalg.inv(np.array(material.lattice.vector_arrays)) - vector = (np.array(vector) @ inverse_lattice_vectors).tolist() - - moved_material = material.clone() - for index in atom_indices: - coordinates[index] = [value + delta for value, delta in zip(coordinates[index], vector)] - moved_material.set_coordinates(coordinates) - return moved_material diff --git a/src/py/mat3ra/notebooks_utils/material.py b/src/py/mat3ra/notebooks_utils/material.py index f603d076..69cb41cf 100644 --- a/src/py/mat3ra/notebooks_utils/material.py +++ b/src/py/mat3ra/notebooks_utils/material.py @@ -1,15 +1,8 @@ -from .core.entity.material.io import ( - get_materials, - load_material_from_folder, - load_materials_from_folder, - set_materials, -) -from .core.entity.material.modify import translate_atoms +from .core.entity.material.io import get_materials, load_material_from_folder, load_materials_from_folder, set_materials __all__ = [ "get_materials", "set_materials", "load_materials_from_folder", "load_material_from_folder", - "translate_atoms", ] diff --git a/tests/py/unit/core/entity/test_material_modify.py b/tests/py/unit/core/entity/test_material_modify.py deleted file mode 100644 index 8b653f03..00000000 --- a/tests/py/unit/core/entity/test_material_modify.py +++ /dev/null @@ -1,86 +0,0 @@ -import numpy as np -import pytest -from mat3ra.made.material import Material -from mat3ra.notebooks_utils.core.entity.material.modify import translate_atoms - -LATTICE_VECTORS = [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 20.0]] - -MATERIAL_CONFIG = { - "name": "test-slab", - "lattice": { - "a": 4.0, - "b": 4.0, - "c": 20.0, - "alpha": 90.0, - "beta": 90.0, - "gamma": 90.0, - "units": {"length": "angstrom", "angle": "degree"}, - "type": "TRI", - "vectors": { - "a": LATTICE_VECTORS[0], - "b": LATTICE_VECTORS[1], - "c": LATTICE_VECTORS[2], - "alat": 1, - "units": "angstrom", - }, - }, - "basis": { - "elements": [{"id": 0, "value": "Si"}, {"id": 1, "value": "Si"}, {"id": 2, "value": "Si"}], - "coordinates": [ - {"id": 0, "value": [0.0, 0.0, 0.20]}, - {"id": 1, "value": [0.5, 0.5, 0.50]}, - {"id": 2, "value": [0.0, 0.5, 0.80]}, - ], - "units": "crystal", - "cell": {"a": LATTICE_VECTORS[0], "b": LATTICE_VECTORS[1], "c": LATTICE_VECTORS[2]}, - }, -} - - -@pytest.fixture -def material(): - return Material.create(MATERIAL_CONFIG) - - -def test_translate_atoms_moves_only_the_named_atom(material): - moved = translate_atoms(material, 1, [0.0, 0.0, -2.0]) - - assert moved.coordinates_array[0] == material.coordinates_array[0] - assert moved.coordinates_array[2] == material.coordinates_array[2] - assert moved.coordinates_array[1] != material.coordinates_array[1] - - -def test_translate_atoms_cartesian_vector_is_angstrom(material): - moved = translate_atoms(material, 1, [0.0, 0.0, -2.0]) - - displacement = (np.array(moved.coordinates_array[1]) - np.array(material.coordinates_array[1])) @ np.array( - LATTICE_VECTORS - ) - assert np.linalg.norm(displacement) == pytest.approx(2.0) - - -def test_translate_atoms_crystal_vector_is_fractional(material): - moved = translate_atoms(material, 1, [0.0, 0.0, -0.1], use_cartesian_coordinates=False) - - assert moved.coordinates_array[1][2] == pytest.approx(0.4) - - -def test_translate_atoms_accepts_several_indices(material): - moved = translate_atoms(material, [0, 2], [0.0, 0.0, 1.0]) - - assert moved.coordinates_array[1] == material.coordinates_array[1] - assert moved.coordinates_array[0][2] > material.coordinates_array[0][2] - assert moved.coordinates_array[2][2] > material.coordinates_array[2][2] - - -def test_translate_atoms_does_not_mutate_the_input(material): - before = [list(coordinate) for coordinate in material.coordinates_array] - - translate_atoms(material, 1, [0.0, 0.0, -2.0]) - - assert [list(coordinate) for coordinate in material.coordinates_array] == before - - -def test_translate_atoms_rejects_an_index_outside_the_basis(material): - with pytest.raises(IndexError, match="out of range"): - translate_atoms(material, 7, [0.0, 0.0, -1.0])