diff --git a/.github/workflows/deploy_web.yml b/.github/workflows/deploy_web.yml new file mode 100644 index 0000000..aff4831 --- /dev/null +++ b/.github/workflows/deploy_web.yml @@ -0,0 +1,32 @@ +name: Deploy Docs + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history needed for git-revision-date plugin + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mkdocs mkdocs-material + pip install -e . + + - name: Deploy to GitHub Pages + run: mkdocs gh-deploy --force diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a084daf --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,90 @@ +name: Tests +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ '3.11' ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest pytest-cov + + - name: Run tests + env: + ATOMGPT_API_KEY: ${{ secrets.ATOMGPT_API_KEY }} + AGAPI_KEY: ${{ secrets.ATOMGPT_API_KEY }} + run: | + pytest agapi -v --cov=agapi --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + fail_ci_if_error: false + + +# name: Tests + +# on: +# push: +# branches: [ main, develop ] +# pull_request: +# branches: [ main, develop ] +# workflow_dispatch: + +# jobs: +# test: +# runs-on: ubuntu-latest +# strategy: +# matrix: +# python-version: [ '3.11'] + +# steps: +# - uses: actions/checkout@v4 + +# - name: Set up Python ${{ matrix.python-version }} +# uses: actions/setup-python@v4 +# with: +# python-version: ${{ matrix.python-version }} + +# - name: Install dependencies +# run: | +# python -m pip install --upgrade pip +# pip install -e . +# pip install pytest coverage codecov pytest-cov + +# - name: Run tests +# env: +# ATOMGPT_API_KEY: ${{ secrets.ATOMGPT_API_KEY }} +# AGAPI_KEY: ${{ secrets.ATOMGPT_API_KEY }} +# run: | +# #pytest agapi -v --cov=agapi --cov-report=xml +# coverage run -m pytest +# coverage report -m -i +# codecov +# codecov --token="ddace04e-a476-4acd-9e74-1a96bde123b8" + +# #- name: Upload coverage +# #uses: codecov/codecov-action@v3 +# #with: +# #file: ./coverage.xml +# #fail_ci_if_error: false diff --git a/README.md b/README.md index 155435b..17bd5e2 100644 --- a/README.md +++ b/README.md @@ -1,228 +1,444 @@ -# 🌐 AtomGPT.org API (AGAPI) +# 🌐 AtomGPT.org API (AGAPI): Agentic AI for Materials Science -AGAPI provides a simple way to interact with [AtomGPT.org](https://atomgpt.org/), enabling **Agentic AI materials science research** through intuitive APIs. +[![Open in Google Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/knc6/jarvis-tools-notebooks/blob/master/jarvis-tools-notebooks/agapi_example.ipynb) +[![PyPI](https://img.shields.io/pypi/v/agapi)](https://pypi.org/project/agapi/) +[![License](https://img.shields.io/badge/license-Apache-blue)](LICENSE) -A significant amount of time in computational materials design is often spent on software installation and setup β€” a major barrier for newcomers. +Empower your materials science research with AtomGPT's Agentic AI API (**AGAPI**). AGAPI removes complex software setups, commercial API cost allowing you to perform advanced predictions, analyses, and explorations through natural language or Python, accelerating materials discovery and design. AGAPI implements a modular architecture separating the reasoning layer (LLM brain) from the execution layer (scientific tools and databases as hands) through a unified REST API interface. This design follows established principles of agentic AI systems. -**AGAPI removes this hurdle** by offering APIs for prediction, analysis, and exploration directly through natural language or Python interfaces, lowering entry barriers and accelerating research. - ---- - - [![Open in Google Colab]](https://colab.research.google.com/github/knc6/jarvis-tools-notebooks/blob/master/jarvis-tools-notebooks/agapi_example.ipynb) - - [Open in Google Colab]: https://colab.research.google.com/assets/colab-badge.svg -## πŸ“– Table of Contents - -- [API Docs](#urls) -- [🧠 Capabilities & Example Prompts](#-capabilities--example-prompts) - - [1️⃣ Access Materials Databases](#1️⃣-access-materials-databases) - - [2️⃣ Graph Neural Network Property Prediction](#2️⃣-graph-neural-network-property-prediction-alignn) - - [3️⃣ Graph Neural Network Force Field](#3️⃣-graph-neural-network-force-field-alignn-ff) - - [4️⃣ X-ray Diffraction β†’ Atomic Structure](#4️⃣-x-ray-diffraction--atomic-structure) - - [5️⃣ Live arXiv Search](#5️⃣-live-arxiv-search) - - [6️⃣ Web Search](#6️⃣-web-search) - - [7️⃣ Visualize Atomic Structures](#7️⃣-visualize-atomic-structures) - - [8️⃣ General Question Answering](#8️⃣-general-question-answering) - - [9️⃣ Structure Manipulation](#9️⃣-structure-manipulation) - - [πŸ”Ÿ Voice Chat Interaction](#πŸ”Ÿ-voice-chat-interaction) -- [πŸš€ Quickstart](#-quickstart) - - [Colab Notebook](#colab-notebook) - - [Python SDK](#python-sdk) -- [πŸŽ₯ YouTube Demos](#-youtube-demos) -- [πŸ“š References](#-references) -- [❀️ Note](#️-note) +## πŸš€ Quickstart ---- +**1. Get your API key** β€” sign up at [AtomGPT.org](https://atomgpt.org) β†’ Account β†’ Settings, then: +```bash +pip install agapi jarvis-tools scipy httpx +export AGAPI_KEY="sk-your-key-here" +``` +**2. Initialize client and agent:** -## API Docs -*Replace `sk-XYZ` with your API key from atomgpt.org>>account>>settings.* +```python +import os +from agapi.agents.client import AGAPIClient +from agapi.agents import AGAPIAgent +from agapi.agents.functions import * +from jarvis.io.vasp.inputs import Poscar -[AtomGPT.org/docs](https://atomgpt.org/docs) +# Direct function calls (API client) +client = AGAPIClient(api_key=os.environ.get("AGAPI_KEY")) +result = query_by_formula("Si", client) +print(result["materials"][25]["formula"], result["materials"][25]["mbj_bandgap"]) -![OpenAPI](https://github.com/atomgptlab/agapi/blob/main/agapi/images/agapi.png) +# Natural language queries (AI agent) +agent = AGAPIAgent(api_key=os.environ.get("AGAPI_KEY")) +response = agent.query_sync("What is the bandgap of Silicon?") +print(response) +``` +--- +## ✨ Key Capabilities + +### Common Inputs + +```python +SI_PRIM = """Si +1.0 +0 2.734 2.734 +2.734 0 2.734 +2.734 2.734 0 +Si +2 +direct +0 0 0 +0.25 0.25 0.25 +""" + +GAAS_PRIM = """GaAs +1.0 +0 2.875 2.875 +2.875 0 2.875 +2.875 2.875 0 +Ga As +1 1 +direct +0 0 0 +0.25 0.25 0.25 +""" + +SI_XRD = """28.44 1.00 +47.30 0.55 +56.12 0.30 +""" +``` -## 🧠 Capabilities & Example Prompts +--- -AGAPI supports **natural language interaction** for a wide range of materials science tasks. -Each section below includes a prompt example and expected output. +### 1. Materials API Query +Access JARVIS-DFT and more. + +**API Example:** +```python +from agapi.agents.functions import ( + query_by_formula, + query_by_jid, + query_by_elements, + query_by_property, + find_extreme, + alignn_predict, + alignn_ff_relax, + slakonet_bandstructure, + generate_interface, + make_supercell, + substitute_atom, + create_vacancy, + generate_xrd_pattern, + protein_fold, + diffractgpt_predict, + alignn_ff_single_point, + alignn_ff_optimize, + alignn_ff_md, + pxrd_match, + xrd_analyze, + microscopygpt_analyze, + query_mp, + query_oqmd, + search_arxiv, + search_crossref, + openfold_predict, + list_jarvis_columns, +) + +r = query_by_formula("Si", client) +assert "error" not in r + +r = query_by_jid("JVASP-1002", client) +assert isinstance(r.get("POSCAR"), str) + +r = query_by_elements("Si", client) +assert "error" not in r + +r = query_by_property("bandgap", 0.1, 3.0, elements="Si", api_client=client) +assert "error" not in r + +r = find_extreme("bulk modulus", True, elements="Si", api_client=client) +assert "error" not in r +``` + +**Natural Language Example:** +```python +agent.query_sync("Show me all MgB2 polymorphs") +agent.query_sync("What's the Tc_Supercon for MgB2 and what's the JARVIS-ID for it?") +agent.query_sync("What's the stiffest Si,O material?") +agent.query_sync("Find materials with bulk modulus > 200 GPa") +agent.query_sync("Compare bandgaps across BN, AlN, GaN, InN") +agent.query_sync("What are the formation energies of SiC, AlN, MgO?") +``` --- -## 1️⃣ Access Materials Databases +### 2. AI Property Prediction (ALIGNN) +Predict bandgap, formation energy, elastic moduli, and more using graph neural networks. -**Prompt:** -> List materials with Ga and As in JARVIS-DFT +**API Example:** +```python +from agapi.agents.functions import alignn_predict -**Response:** -Displays all GaAs-containing entries from the JARVIS-DFT database. +r = alignn_predict(jid="JVASP-1002", api_client=client) +assert r.get("status") == "success" +``` -![Database example](https://github.com/atomgptlab/agapi/blob/main/agapi/images/jarvisdft.png) +**Natural Language Example:** +```python +agent.query_sync("Predict properties of JARVIS-ID JVASP-1002 with ALIGNN") +agent.query_sync(f"Predict properties using ALIGNN for this structure:\n\n{SI_PRIM}") +``` --- -## 2️⃣ Graph Neural Network Property Prediction (ALIGNN) +### 3. AI Force Field (ALIGNN-FF) +Structure relaxation, single-point energy, and MD with near-DFT accuracy. -**Prompt:** -> Predict properties of this POSCAR using ALIGNN +**API Example:** +```python +from agapi.agents.functions import alignn_ff_relax, alignn_ff_single_point -(Upload a POSCAR, e.g. [example POSCAR file](https://github.com/atomgptlab/agapi/blob/main/agapi/images/POSCAR)) +r = alignn_ff_relax(SI_PRIM, api_client=client) +assert r.get("status") == "success" +print(Poscar.from_string(r["relaxed_poscar"])) # view relaxed structure -**Response:** -Returns AI-predicted material properties (formation energy, bandgap, etc.). +r = alignn_ff_single_point(SI_PRIM, api_client=client) +assert "energy_eV" in r +``` -![ALIGNN prediction](https://github.com/atomgptlab/agapi/blob/main/agapi/images/alignn_prop.png) +**Natural Language Example:** +```python +agent.query_sync(f"Optimize structure with ALIGNN-FF:\n\n{SI_PRIM}") +agent.query_sync("Get the single-point energy of this Si primitive cell.") +``` --- -## 3️⃣ Graph Neural Network Force Field (ALIGNN-FF) - -**Prompt:** -> Optimize structure from uploaded POSCAR file using ALIGNN-FF +### 4. Band Structure (SlakoNet) +Tight-binding band structures from neural network Slater-Koster parameters. -(Upload a POSCAR, e.g. [example file](https://github.com/atomgptlab/agapi/blob/main/agapi/images/POSCAR)) +**API Example:** +```python +from agapi.agents.functions import slakonet_bandstructure -**Response:** -Generates optimized structure and energy data. +r = slakonet_bandstructure(SI_PRIM, api_client=client) +assert r.get("status") == "success" +``` -![ALIGNN-FF example](https://github.com/atomgptlab/agapi/blob/main/agapi/images/alignn_ff.png) +**Natural Language Example:** +```python +agent.query_sync("Compute the band structure of Si.") +agent.query_sync(f"Plot the electronic band structure for this POSCAR:\n\n{SI_PRIM}") +``` --- -## 4️⃣ X-ray Diffraction β†’ Atomic Structure +### 5. XRD / DiffractGPT +Match PXRD patterns, identify phases, and analyze experimental diffraction data. -**Prompt:** -> Convert XRD pattern to POSCAR +**API Example:** +```python +from agapi.agents.functions import pxrd_match, xrd_analyze, diffractgpt_predict -(Upload an XRD file, e.g. [example XRD file](https://github.com/atomgptlab/agapi/blob/main/agapi/images/Lab6data.dat)) +r = pxrd_match("Si", SI_XRD, api_client=client) +assert isinstance(r, dict) +if "matched_poscar" in r: + print(Poscar.from_string(r["matched_poscar"])) # view matched structure -**Response:** -Predicts atomic structure that best matches the uploaded diffraction pattern. +r = xrd_analyze("Si", SI_XRD, api_client=client) +assert isinstance(r, dict) -![XRD to structure](https://github.com/atomgptlab/agapi/blob/main/agapi/images/xrd_db_match.png) +r = diffractgpt_predict("Si", "28.4(1.0),47.3(0.49)", client) +assert isinstance(r, dict) +``` + +**Natural Language Example:** +```python +agent.query_sync("Identify the phase from this XRD pattern for Silicon: [XRD data]") +agent.query_sync("Analyze this PXRD pattern and suggest possible structures.") +``` --- -## 5️⃣ Live arXiv Search +### 6. STEM / MicroscopyGPT +Analyze STEM, TEM, and electron microscopy images using AI β€” identify atomic columns, measure lattice spacings, detect defects, and interpret microstructure. + +**API Example:** +```python +from agapi.agents.functions import microscopygpt_analyze + +r = microscopygpt_analyze("HRTEM image of Si lattice", api_client=client) +assert isinstance(r, dict) +``` -**Prompt:** -> Find papers on MgBβ‚‚ in arXiv. State how many results you found and show top 10 recent papers. +**Natural Language Example:** +```python +agent.query_sync("Analyze this STEM image of a GaN thin film: [image]") +agent.query_sync("What defects are visible in this HRTEM image?") +agent.query_sync("Measure the d-spacing from this electron diffraction pattern.") +``` -**Response:** -Summarizes and lists the latest publications from arXiv related to MgBβ‚‚. -![arXiv search example](https://github.com/atomgptlab/agapi/blob/main/agapi/images/search.png) --- -## 6️⃣ Web Search +### 7. Structure Manipulation +Supercells, substitutions, vacancies, and XRD pattern generation β€” runs locally, no API call needed. -**Prompt:** -> Search for recent advances in 2D ferroelectric materials. +**API Example:** +```python +from agapi.agents.functions import make_supercell, substitute_atom, create_vacancy, generate_xrd_pattern -**Response:** -Fetches and summarizes up-to-date information from web sources on the requested topic. +r = make_supercell(SI_PRIM, [2, 2, 1]) +assert r["supercell_atoms"] > r["original_atoms"] +print(f"Original atoms: {r['original_atoms']}, Supercell atoms: {r['supercell_atoms']}") +# Expected: Original atoms: 2, Supercell atoms: 8 ---- +r = substitute_atom(GAAS_PRIM, "Ga", "Al", 1) +assert "Al" in r["new_formula"] +# Expected new_formula: AlAs -## 7️⃣ Visualize Atomic Structures +r = create_vacancy(GAAS_PRIM, "Ga", 1) +assert r["new_atoms"] == r["original_atoms"] - 1 +# Expected: one fewer atom than original -**Prompt:** -> Visualize the crystal structure of Silicon in 3D. +r = generate_xrd_pattern(SI_PRIM) +assert r["formula"] == "Si" +``` -**Response:** -Generates a 3D interactive visualization of the given structure (CIF or POSCAR). +**Natural Language Example:** +```python +agent.query_sync("Make a 2x1x1 supercell of the most stable GaN.") +agent.query_sync("Substitute one Ga with Al in this GaAs structure.") +agent.query_sync("Create a Ga vacancy in GaAs and predict its properties.") +``` --- -## 8️⃣ General Question Answering +### 8. Interface Generation +Build heterostructure interfaces between two materials. + +**API Example:** +```python +from agapi.agents.functions import generate_interface -**Prompt:** -> Explain the difference between DFT and DFTB. +r = generate_interface(SI_PRIM, GAAS_PRIM, api_client=client) +assert r.get("status") == "success" +``` -**Response:** -Provides a concise explanation with context and examples. +**Natural Language Example:** +```python +agent.query_sync(""" + Create a GaN/AlN heterostructure interface: + 1. Find GaN (most stable) + 2. Find AlN (most stable) + 3. Generate (001)/(001) interface + 4. Show POSCAR +""", max_context_messages=20) +``` --- -## 9️⃣ Structure Manipulation +### 9. Literature Search +Search arXiv and Crossref for relevant research papers. + +**API Example:** +```python +from agapi.agents.functions import search_arxiv, search_crossref -**Prompt:** -> Replace oxygen atoms with sulfur in this POSCAR. +r = search_arxiv("GaN", max_results=2, api_client=client) +assert isinstance(r, dict) -**Response:** -Outputs a modified POSCAR file with requested atomic substitutions. +r = search_crossref("GaN", rows=2, api_client=client) +assert isinstance(r, dict) +``` + +**Natural Language Example:** +```python +agent.query_sync("Find recent papers on perovskite solar cells on arXiv.") +agent.query_sync("Search for publications about ALIGNN neural networks.") +``` --- -## πŸ”Ÿ Voice Chat Interaction +## πŸ”§ Multi-Step Agentic Workflow + +```python +agent.query_sync(""" +1. Find all GaN materials in the JARVIS-DFT database +2. Get the POSCAR for the most stable one +3. Make a 2x1x1 supercell +4. Substitute one Ga with Al +5. Generate powder XRD pattern +6. Optimize structure with ALIGNN-FF +7. Predict properties with ALIGNN +""", max_context_messages=20, verbose=True) + +agent.query_sync(""" +Create a GaN/AlN heterostructure interface: +1. Find GaN (most stable) +2. Find AlN (most stable) +3. Generate (001)/(001) interface +4. Show POSCAR +""", max_context_messages=20, verbose=True) +``` -**Prompt (spoken):** -> What is the bandgap of silicon? +--- -**Response (spoken):** -> The bandgap of silicon is approximately 1.1 eV. +## πŸ€– Supported LLM Backends -Enables **voice-based chat** for hands-free interaction with materials science tools. +AGAPI supports multiple LLM backends. Set `model` when initializing the agent: -**The table below lists available endpoints, the corresponding module, and description.** +```python +agent = AGAPIAgent( + api_key=os.environ.get("AGAPI_KEY"), + model="openai/gpt-oss-20b" +) +``` -| Endpoint | Module / Function | Description | -|-----------|------------------|--------------| -| `/materials/property` | **ALIGNN** | Predicts materials properties such as formation energy, bandgap, and elastic moduli directly from structure files. | -| `/materials/forcefield` | **ALIGNN-FF** | Computes energies, forces, and stresses for structure relaxation and molecular dynamics simulations with near-DFT accuracy. | -| `/materials/xrd` | **XRDStructurePrediction** | Determines atomic structures from uploaded XRD files to identify crystal structures. | -| `/literature/search` | **arXivSearchAgent** | Retrieves and summarizes recent arXiv or web publications on specified research topics. | -| `/visualization/structure` | **StructureViewer** | Generates interactive 3D visualizations of input structures and enables atomic structure editing. | -| `/database/jarvis` | **JarvisAPI** | Provides direct access to JARVIS materials data and pre-computed properties for workflow integration. | -| `/interface/voice` | **VoiceChat** | Enables voice-based chat for hands-free interaction with AGAPI. | -| `/literature/search` | **Crossref** | Accesses publication metadata and citation information through the Crossref API. | ---- +Available models: -## πŸš€ Quickstart +| Provider | Model | +|---|---| +| OpenAI | `openai/gpt-oss-20b` | +| OpenAI | `openai/gpt-oss-120b` | +| Meta | `meta/llama-4-maverick-17b-128e-instruct` | +| Meta | `meta/llama-3.2-90b-vision-instruct` | +| Meta | `meta/llama-3.2-1b-instruct` | +| Google | `google/gemini-2.5-flash` | +| Google | `google/gemma-3-27b-it` | +| DeepSeek | `deepseek-ai/deepseek-v3.1` | +| Moonshot | `moonshotai/kimi-k2-instruct-0905` | +| Qwen | `qwen/qwen3-next-80b-a3b-instruct` | -### Colab Notebook -Try AGAPI instantly in Google Colab: -πŸ‘‰ [AGAPI Example Notebook](https://github.com/knc6/jarvis-tools-notebooks/blob/master/jarvis-tools-notebooks/agapi_example.ipynb) -### Python SDK -For detailed SDK usage: -πŸ‘‰ [agapi/README.md](https://github.com/atomgptlab/agapi/blob/main/agapi/README.md) +--- +## πŸ“¦ Available APIs/Functions + +| Function | Description | +|---|---| +| `query_by_formula` | Search by chemical formula | +| `query_by_jid` | Fetch by JARVIS ID | +| `query_by_elements` | Filter by constituent elements | +| `query_by_property` | Filter by property range | +| `find_extreme` | Find max/min property material | +| `alignn_predict` | GNN property prediction | +| `alignn_ff_relax` | Structure relaxation | +| `alignn_ff_single_point` | Single-point energy | +| `slakonet_bandstructure` | TB band structure | +| `generate_interface` | Heterostructure builder | +| `make_supercell` | Supercell generation | +| `substitute_atom` | Atomic substitution | +| `create_vacancy` | Vacancy creation | +| `generate_xrd_pattern` | Simulated XRD | +| `pxrd_match / xrd_analyze` | XRD phase matching | +| `diffractgpt_predict` | AI XRD interpretation | +| `microscopygpt_analyze` | AI STEM/TEM image analysis | +| `query_mp` | Materials Project query | +| `search_arxiv / search_crossref` | Literature search | +| `protein_fold` | Protein structure prediction | + +... --- -## πŸŽ₯ YouTube Demos +## πŸ“– References -Watch AGAPI in action on YouTube: -🎬 [AGAPI Demo Playlist](https://www.youtube.com/playlist?list=PLjf6vHVv7AoInTVQmfNSMs_12DBXYCcDd) +If you find this work helpful, please cite: ---- +1. **AGAPI-Agents: An Open-Access Agentic AI Platform for Accelerated Materials Design on AtomGPT.org** + https://doi.org/10.48550/arXiv.2512.11935 -## πŸ“š References +2. **ChatGPT Material Explorer: Design and Implementation of a Custom GPT Assistant for Materials Science Applications** + https://doi.org/10.1016/j.commatsci.2025.114063 -1. [AGAPI-Agents: An Open-Access Agentic AI Platform for Accelerated Materials Design on AtomGPT.org](https://doi.org/10.48550/arXiv.2512.11935) -2. [ChatGPT Material Explorer: Design and Implementation of a Custom GPT Assistant for Materials Science Applications](https://doi.org/10.1007/s40192-025-00410-9) -3. [The JARVIS infrastructure is all you need for materials design](https://doi.org/10.1016/j.commatsci.2025.114063) -4. [AtomGPT: Atomistic Generative Pretrained Transformer for Forward and Inverse Materials Design](https://doi.org/10.1021/acs.jpclett.4c01126) +3. **The JARVIS Infrastructure Is All You Need for Materials Design** + https://doi.org/10.1016/j.commatsci.2025.114063 -[Full publication list](https://scholar.google.com/citations?hl=en&user=klhV2BIAAAAJ&view_op=list_works&sortby=pubdate) +πŸ“„ Full publication list: [Google Scholar](https://scholar.google.com/citations?hl=en&user=YVP36YgAAAAJ&view_op=list_works&sortby=pubdate) --- -## ❀️ Note +## πŸ“š Resources -> β€œAGAPI (ἀγάπη)” is a Greek word meaning **unconditional love**. +- πŸ”¬ **Research Group**: [AtomGPTLab @ JHU](https://choudhary.wse.jhu.edu/) +- πŸ“– **Docs**: [AtomGPT.org/docs](https://atomgpt.org/docs) +- πŸ§ͺ **Colab**: [AGAPI Example Notebook](https://colab.research.google.com/github/knc6/jarvis-tools-notebooks/blob/master/jarvis-tools-notebooks/agapi_example.ipynb) +- ▢️ **YouTube**: [Demo Playlist](https://www.youtube.com/playlist?list=PLjf6vHVv7AoInTVQmfNSMs_12DBXYCcDd) -## DISCLAIMER +--- -AtomGPT.org can make mistakes. Please verify important information. +## ❀️ Note +**AGAPI (ἀγάπη)** is a Greek word meaning *unconditional love*. -We hope this API fosters **open, collaborative, and accelerated discovery** in materials science. +## Disclaimer -![Poster](https://github.com/atomgptlab/agapi/blob/main/agapi/images/atomgpt_org_poster.jpg) +AtomGPT.org can make mistakes β€” please verify critical results. We hope this API fosters open, collaborative, and accelerated discovery in materials science. diff --git a/agapi/README.md b/agapi/README.md deleted file mode 100644 index b36ba0e..0000000 --- a/agapi/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# agapi - -A tiny Python SDK + CLI for the AtomGPT.org API (AGAPI). - -## Install (dev) - -```bash -pip install agapi jarvis-tools -``` - -Set your key once (recommended): - -Website: https://atomgpt.org/ - -Profile >> Settings >> Account >> API Keys >> Show - - - -## Quickstart - -```python -from agapi import Agapi - -client = Agapi(api_key="sk-") # reads env vars -# JARVIS-DFT by formula -r = client.jarvis_dft_query(formula="MoS2") -print(r) - -# JARVIS-DFT by search -r = client.jarvis_dft_query(search="-Mo-S") -print(r) - -# ALIGNN from POSCAR path -r = client.alignn_query(file_path="POSCAR") -print(r.keys()) - -# ALIGNN-FF from POSCAR string -r = client.alignn_ff_query(poscar_string=open("POSCAR").read()) -print(r) - -# Protein fold (returns binary content if format=zip) -zbytes = client.protein_fold_query(sequence="AAAAA", format="zip") -open("protein.zip", "wb").write(zbytes) - -# PXRD from a data file -r = client.pxrd_query(file_path="Lab6data.dat") -print(r) -``` - -# TODO -## CLI - -```bash -# JARVIS-DFT by formula -agapi jarvis --formula MoS2 - -# JARVIS-DFT by search -agapi jarvis --search "-Mo-S" - -# ALIGNN (file or stdin) -agapi alignn --file POSCAR -cat POSCAR | agapi alignn --stdin - -# ALIGNN-FF -agapi alignn-ff --file POSCAR - -# Protein fold -agapi protein --sequence AAAAA --format zip --out protein.zip - -# PXRD -agapi pxrd --file Lab6data.dat -``` diff --git a/agapi/__init__.py b/agapi/__init__.py index a743707..b8011a2 100644 --- a/agapi/__init__.py +++ b/agapi/__init__.py @@ -1,6 +1,6 @@ """Version number.""" -__version__ = "2025.12.25" +__version__ = "2026.2.2" import os diff --git a/agapi/agents/agent.py b/agapi/agents/agent.py index 73f20ed..fc3ebea 100644 --- a/agapi/agents/agent.py +++ b/agapi/agents/agent.py @@ -696,7 +696,7 @@ async def query( def query_sync( self, query: str, - verbose: bool = False, + verbose: bool = True, render_html: bool = False, html_style: str = "bootstrap", max_show: int = 20, diff --git a/agapi/agents/client.py b/agapi/agents/client.py index 1a5a121..15d7e03 100644 --- a/agapi/agents/client.py +++ b/agapi/agents/client.py @@ -12,7 +12,7 @@ def __init__( self, api_key: str, api_base: str = "https://atomgpt.org", - timeout: int = 60, + timeout: int = 120, ): self.api_key = api_key self.api_base = api_base @@ -70,37 +70,3 @@ def request(self, endpoint: str, params: dict = None, method: str = "GET"): ) except Exception as e: raise Exception(f"Request failed: {str(e)}") - - -class AGAPIClientX: - """Low-level client for AGAPI requests""" - - def __init__( - self, api_key: str = None, timeout: int = None, api_base: str = None - ): - self.api_key = api_key or AgentConfig.DEFAULT_API_KEY - self.timeout = timeout or AgentConfig.DEFAULT_TIMEOUT - self.api_base = api_base or AgentConfig.API_BASE - - def request(self, endpoint: str, params: Dict[str, Any]) -> Dict[str, Any]: - """Make GET request to AGAPI""" - params["APIKEY"] = self.api_key - url = f"{self.api_base}/{endpoint}" - - try: - with httpx.Client( - verify=True, timeout=self.timeout - ) as http_client: - response = http_client.get(url, params=params) - - if response.status_code != 200: - raise Exception( - f"API error ({response.status_code}): {response.text}" - ) - - return response.json() - - except httpx.TimeoutException: - raise Exception(f"Request timeout after {self.timeout}s") - except Exception as e: - raise Exception(f"Request failed: {str(e)}") diff --git a/agapi/agents/functions.py b/agapi/agents/functions.py index 84a04a7..0502b8e 100644 --- a/agapi/agents/functions.py +++ b/agapi/agents/functions.py @@ -1,6 +1,7 @@ import json from typing import Optional, Dict, Any - +import base64 +from pathlib import Path from .client import AGAPIClient from .aliases import normalize_property_name @@ -320,111 +321,78 @@ def alignn_predict( return {"error": f"ALIGNN prediction failed: {str(e)}"} -def alignn_predictX( - poscar: str, jid: Optional[str] = None, api_client: AGAPIClient = None -) -> Dict[str, Any]: - """Predict material properties using ALIGNN""" - try: - params = {"poscar": poscar} if not jid else {"jid": jid} - result = api_client.request("alignn/query", params) - - return { - "formation_energy": result.get( - "jv_formation_energy_peratom_alignn" - ), - "energy_eV": result.get("jv_optb88vdw_total_energy_alignn"), - "bandgap_optb88vdw": result.get("jv_optb88vdw_bandgap_alignn"), - "bandgap_mbj": result.get("jv_mbj_bandgap_alignn"), - "bulk_modulus": result.get("jv_bulk_modulus_kv_alignn"), - "shear_modulus": result.get("jv_shear_modulus_gv_alignn"), - "piezo_max_dielectric": result.get( - "jv_dfpt_piezo_max_dielectric_alignn" - ), - "Tc_supercon": result.get("jv_supercon_tc_alignn"), - } - except Exception as e: - return {"error": str(e)} +# SlakoNet Tools -def alignn_ff_relaxX( +def alignn_ff_relax( poscar: str, fmax: float = 0.05, steps: int = 150, + *, api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Relax structure using ALIGNN force field via GET endpoint. + Relax structure using ALIGNN force field. + + Args: + poscar: POSCAR format structure string + fmax: Force convergence criterion (eV/Γ…) + steps: Maximum optimization steps + api_client: API client instance (injected by agent) """ try: - params = { - "poscar": poscar, - "fmax": fmax, - "steps": steps, + import httpx + + # Use POST endpoint (your backend has this) + data = { + "poscar_string": poscar, } - response = httpx.get( - f"{api_client.api_base}/alignn_ff/relax", - params=params, + response = httpx.post( + f"{api_client.api_base}/alignn_ff/query", + data=data, headers={"Authorization": f"Bearer {api_client.api_key}"}, timeout=api_client.timeout, ) if response.status_code == 200: - relaxed_poscar = response.text - + result = response.json() return { "status": "success", - "relaxed_poscar": relaxed_poscar, - "message": f"Structure relaxed with ALIGNN-FF (fmax={fmax}, steps={steps})", + "original_poscar": result.get("original"), + "relaxed_poscar": result.get("relaxed"), + "message": "Structure optimized with ALIGNN-FF", } else: return { - "error": f"ALIGNN-FF relaxation failed: {response.status_code}", + "error": f"ALIGNN-FF failed: {response.status_code}", "detail": response.text, } except Exception as e: - return {"error": f"ALIGNN-FF relaxation error: {str(e)}"} - - -def alignn_ff_relaxX(poscar: str, api_client: AGAPIClient) -> Dict[str, Any]: - """Relax structure using ALIGNN force field""" - try: - params = {"poscar": poscar} - result = api_client.request("alignn_ff/query", params) - - return { - "relaxed_structure": result.get("POSCAR"), - "energy_eV": result.get("energy_eV"), - } - except Exception as e: - return {"error": str(e)} + return {"error": f"ALIGNN-FF error: {str(e)}"} -# SlakoNet Tools -def slakonet_bandstructureX( +def slakonet_bandstructure( poscar: str, energy_range_min: float = -8.0, energy_range_max: float = 8.0, + *, api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ Calculate electronic band structure using SlakoNet. - Returns both band structure image and electronic properties. """ try: import httpx import base64 - # Prepare request data = { "poscar_string": poscar, "energy_range_min": energy_range_min, "energy_range_max": energy_range_max, - "model_path": "/path/to/slakonet_v0/slakonet_v0.pt", # Default path } - # Make request with httpx to get full response response = httpx.post( f"{api_client.api_base}/slakonet/bandstructure", data=data, @@ -433,16 +401,13 @@ def slakonet_bandstructureX( ) if response.status_code == 200: - # Extract properties from headers band_gap = response.headers.get("X-Band-Gap", "N/A") vbm = response.headers.get("X-VBM", "N/A") cbm = response.headers.get("X-CBM", "N/A") - # Get image data image_data = response.content image_base64 = base64.b64encode(image_data).decode("utf-8") - # Get filename from Content-Disposition content_disp = response.headers.get("Content-Disposition", "") filename = "bandstructure.png" if "filename=" in content_disp: @@ -455,11 +420,11 @@ def slakonet_bandstructureX( "cbm_eV": cbm, "image_base64": image_base64, "image_filename": filename, - "message": f"Band structure calculated. Band gap: {band_gap} eV, VBM: {vbm} eV, CBM: {cbm} eV", + "message": f"Band structure calculated. Band gap: {band_gap} eV", } else: return { - "error": f"SlakoNet request failed: {response.status_code}", + "error": f"SlakoNet failed: {response.status_code}", "detail": response.text, } @@ -467,1007 +432,1102 @@ def slakonet_bandstructureX( return {"error": f"SlakoNet error: {str(e)}"} -def slakonet_bandstructureX( - poscar: str = None, jid: str = None, api_client: AGAPIClient = None +# DiffractGPT Tools +def diffractgpt_predict( + formula: str, peaks: str, api_client: AGAPIClient +) -> Dict[str, Any]: + """Predict structure from XRD using DiffractGPT""" + try: + params = {"formula": formula, "peaks": peaks} + result = api_client.request("diffractgpt/query", params) + + return { + "predicted_structure": result.get("POSCAR"), + "formula": formula, + } + except Exception as e: + return {"error": str(e)} + + +def xrd_match( + formula: str, xrd_pattern: str, api_client: AGAPIClient ) -> Dict[str, Any]: - """Calculate band structure using SlakoNet""" + """Match XRD pattern to database""" try: - params = {"jid": jid} if jid else {"poscar": poscar} - result = api_client.request("slakonet/bandstructure", params) + params = {"pattern": xrd_pattern} + result = api_client.request("pxrd/query", params) return { - "band_gap_eV": result.get("band_gap_eV"), - "vbm_eV": result.get("vbm_eV"), - "cbm_eV": result.get("cbm_eV"), - "note": "Band structure calculated", + "matched_structure": result.get("POSCAR"), + "formula": formula, } except Exception as e: return {"error": str(e)} -def alignn_predictX( - poscar: str = None, - jid: str = None, - property_name: str = "all", +# Intermat Tools +def generate_interface( + film_poscar: str, + substrate_poscar: str, + film_indices: str = "0_0_1", + substrate_indices: str = "0_0_1", + film_thickness: float = 16, + substrate_thickness: float = 16, + separation: float = 2.5, + max_area: float = 300, *, api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Predict material properties using ALIGNN ML models. + Generate heterostructure interface between two materials. Args: - poscar: POSCAR format structure string (optional if jid provided) - jid: JARVIS-ID to use directly (optional if poscar provided) - property_name: Property to predict (default: "all") - api_client: API client instance + film_poscar: POSCAR string for film material + substrate_poscar: POSCAR string for substrate material + film_indices: Miller indices for film surface (e.g., "0_0_1" for (001)) + substrate_indices: Miller indices for substrate surface + film_thickness: Film layer thickness in Angstroms (default: 16) + substrate_thickness: Substrate layer thickness in Angstroms (default: 16) + separation: Interface separation distance in Angstroms (default: 2.5) + max_area: Maximum interface area in AngstromsΒ² (default: 300) + api_client: API client instance (injected by agent) Returns: - dict with predicted properties + dict with interface structure (POSCAR format) """ try: - # Build params - backend accepts either poscar or jid - if jid: - params = {"jid": jid} - elif poscar: - params = {"poscar": poscar} - else: - return {"error": "Either poscar or jid must be provided"} - - # Call ALIGNN API endpoint - result = api_client.request("alignn/query", params, method="POST") - - if not result or (isinstance(result, dict) and "error" in result): - return { - "error": f"ALIGNN prediction failed: {result.get('error', 'Unknown error')}" - } - - # Parse and structure the response - predictions = {} - - # Formation energy - if "jv_formation_energy_peratom_alignn" in result: - predictions["formation_energy_peratom"] = result[ - "jv_formation_energy_peratom_alignn" - ] + import httpx - # Total energy - if "jv_optb88vdw_total_energy_alignn" in result: - predictions["total_energy"] = result[ - "jv_optb88vdw_total_energy_alignn" - ] + # Validate Miller indices format (should be "h_k_l" with underscores) + if " " in film_indices or "," in film_indices: + film_indices = film_indices.replace(" ", "_").replace(",", "_") + if " " in substrate_indices or "," in substrate_indices: + substrate_indices = substrate_indices.replace(" ", "_").replace( + ",", "_" + ) - # Bandgaps (prioritize MBJ) - if "jv_mbj_bandgap_alignn" in result: - predictions["bandgap"] = result["jv_mbj_bandgap_alignn"] - predictions["bandgap_type"] = "MBJ (more accurate)" - elif "jv_optb88vdw_bandgap_alignn" in result: - predictions["bandgap"] = result["jv_optb88vdw_bandgap_alignn"] - predictions["bandgap_type"] = "OptB88vdW" - - # Elastic properties - if "jv_bulk_modulus_kv_alignn" in result: - predictions["bulk_modulus_kv"] = result[ - "jv_bulk_modulus_kv_alignn" - ] - if "jv_shear_modulus_gv_alignn" in result: - predictions["shear_modulus_gv"] = result[ - "jv_shear_modulus_gv_alignn" - ] + # Build parameters matching backend API + # Backend expects: poscar_film, poscar_subs, subs_indices (not substrate_indices) + params = { + "poscar_film": film_poscar, # Map to backend param + "poscar_subs": substrate_poscar, # Map to backend param + "film_indices": film_indices, + "subs_indices": substrate_indices, # Backend uses subs_indices + "film_thickness": film_thickness, + "subs_thickness": substrate_thickness, # Backend uses subs_thickness + "separations": str( + separation + ), # Backend uses separations (string) + "max_area": max_area, + "APIKEY": api_client.api_key, + } - # Piezoelectric - if "jv_dfpt_piezo_max_dielectric_alignn" in result: - predictions["max_piezo_dielectric"] = result[ - "jv_dfpt_piezo_max_dielectric_alignn" - ] + # Direct GET request (returns text/plain) + response = httpx.get( + f"{api_client.api_base}/generate_interface", + params=params, + timeout=300.0, + ) + response.raise_for_status() - # Superconductivity - if "jv_supercon_tc_alignn" in result: - predictions["supercon_tc"] = result["jv_supercon_tc_alignn"] + interface_poscar = response.text - # Exfoliation energy - if "jv_exfoliation_energy_alignn" in result: - predictions["exfoliation_energy"] = result[ - "jv_exfoliation_energy_alignn" - ] + # Parse basic info from POSCAR + lines = interface_poscar.splitlines() + elements_line = "" + counts_line = "" + for i, line in enumerate(lines): + if "direct" in line.lower() or "cartesian" in line.lower(): + if i >= 2: + elements_line = lines[i - 2] + counts_line = lines[i - 1] + break return { "status": "success", - "predictions": predictions, - "jid": jid if jid else "custom_structure", - "raw_result": result, # Include full result for debugging - "message": f"ALIGNN predictions completed ({len(predictions)} properties)", + "heterostructure_atoms": interface_poscar, + "film_indices": film_indices, + "substrate_indices": substrate_indices, + "film_thickness": film_thickness, + "substrate_thickness": substrate_thickness, + "separation": separation, + "elements": elements_line.strip(), + "atom_counts": counts_line.strip(), + "message": f"Generated interface structure ({film_indices}/{substrate_indices}), {len(lines)} lines", } + except httpx.HTTPStatusError as e: + return { + "error": f"API error {e.response.status_code}: {e.response.text}" + } except Exception as e: - return {"error": f"ALIGNN prediction error: {str(e)}"} + return {"error": f"Interface generation error: {str(e)}"} -def alignn_predictX( - poscar: str, *, api_client: AGAPIClient = None +def make_supercell( + poscar: str, scaling_matrix: list, api_client: AGAPIClient = None ) -> Dict[str, Any]: """ - Predict properties using ALIGNN ML models. + Create a supercell from a POSCAR structure. Args: poscar: POSCAR format structure string - api_client: API client instance (injected by agent) + scaling_matrix: List of 3 integers [nx, ny, nz] for supercell dimensions + + Returns: + dict with supercell POSCAR and atom count """ try: - # Parse POSCAR + from jarvis.core.atoms import Atoms from jarvis.io.vasp.inputs import Poscar + # Parse POSCAR atoms = Poscar.from_string(poscar).atoms - if atoms.num_atoms > 50: - return { - "error": f"Structure too large ({atoms.num_atoms} atoms). Max: 50" - } + # Create supercell + supercell = atoms.make_supercell(scaling_matrix) - # Make request - params = {"poscar": poscar} - result = api_client.request("alignn/query", params) + # Convert back to POSCAR + supercell_poscar = Poscar(supercell).to_string() return { "status": "success", - "predictions": result, - "num_atoms": atoms.num_atoms, - "formula": atoms.composition.reduced_formula, + "supercell_poscar": supercell_poscar, + "original_atoms": atoms.num_atoms, + "supercell_atoms": supercell.num_atoms, + "scaling_matrix": scaling_matrix, + "formula": supercell.composition.reduced_formula, + "message": f"Created {scaling_matrix[0]}x{scaling_matrix[1]}x{scaling_matrix[2]} supercell with {supercell.num_atoms} atoms", } except Exception as e: - return {"error": f"ALIGNN prediction error: {str(e)}"} + return {"error": f"Supercell creation error: {str(e)}"} -def alignn_predictX( - poscar: str = None, - jid: str = None, - property_name: str = "all", - *, +def substitute_atom( + poscar: str, + element_from: str, + element_to: str, + num_substitutions: int = 1, api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Predict material properties using ALIGNN ML models. + Substitute atoms in a structure (e.g., replace Ga with Al). Args: - poscar: POSCAR format structure string (optional if jid provided) - jid: JARVIS-ID to use directly (optional if poscar provided) - property_name: Property to predict (default: "all") - api_client: API client instance + poscar: POSCAR format structure string + element_from: Element to replace (e.g., "Ga") + element_to: Element to substitute with (e.g., "Al") + num_substitutions: Number of atoms to substitute (default: 1) Returns: - dict with predicted properties + dict with modified POSCAR """ try: - # Build params - backend accepts either poscar or jid - if jid: - params = {"jid": jid} - elif poscar: - params = {"poscar": poscar} - else: - return {"error": "Either poscar or jid must be provided"} - - # Call ALIGNN API endpoint - result = api_client.request("alignn/query", params, method="POST") - print("resut", result) - if not result or (isinstance(result, dict) and "error" in result): - return { - "error": f"ALIGNN prediction failed: {result.get('error', 'Unknown error')}" - } + from jarvis.core.atoms import Atoms + from jarvis.io.vasp.inputs import Poscar - # Parse and structure the response - predictions = {} + # Parse POSCAR + atoms = Poscar.from_string(poscar).atoms - # Formation energy - if "jv_formation_energy_peratom_alignn" in result: - predictions["formation_energy_peratom"] = result[ - "jv_formation_energy_peratom_alignn" - ] + # Find indices of atoms to substitute + indices_to_sub = [] + for i, atom in enumerate(atoms.elements): + if atom == element_from: + indices_to_sub.append(i) + if len(indices_to_sub) >= num_substitutions: + break - # Total energy - if "jv_optb88vdw_total_energy_alignn" in result: - predictions["total_energy"] = result[ - "jv_optb88vdw_total_energy_alignn" - ] + if not indices_to_sub: + return {"error": f"No {element_from} atoms found in structure"} - # Bandgaps (prioritize MBJ) - if "jv_mbj_bandgap_alignn" in result: - predictions["bandgap"] = result["jv_mbj_bandgap_alignn"] - predictions["bandgap_type"] = "MBJ (more accurate)" - elif "jv_optb88vdw_bandgap_alignn" in result: - predictions["bandgap"] = result["jv_optb88vdw_bandgap_alignn"] - predictions["bandgap_type"] = "OptB88vdW" - - # Elastic properties - if "jv_bulk_modulus_kv_alignn" in result: - predictions["bulk_modulus_kv"] = result[ - "jv_bulk_modulus_kv_alignn" - ] - if "jv_shear_modulus_gv_alignn" in result: - predictions["shear_modulus_gv"] = result[ - "jv_shear_modulus_gv_alignn" - ] + if len(indices_to_sub) < num_substitutions: + return { + "error": f"Only {len(indices_to_sub)} {element_from} atoms available, requested {num_substitutions}" + } - # Piezoelectric - if "jv_dfpt_piezo_max_dielectric_alignn" in result: - predictions["max_piezo_dielectric"] = result[ - "jv_dfpt_piezo_max_dielectric_alignn" - ] + # Create new element list with substitutions + new_elements = list(atoms.elements) + for idx in indices_to_sub: + new_elements[idx] = element_to - # Superconductivity - if "jv_supercon_tc_alignn" in result: - predictions["supercon_tc"] = result["jv_supercon_tc_alignn"] + # Create new atoms object with substituted elements + new_atoms = Atoms( + lattice_mat=atoms.lattice_mat, + coords=atoms.coords, + elements=new_elements, + cartesian=atoms.cartesian, + ) + + # Convert to POSCAR + new_poscar = Poscar(new_atoms).to_string() return { "status": "success", - "predictions": predictions, - "jid": jid if jid else "custom_structure", - "raw_result": result, # Include full result for debugging - "message": f"ALIGNN predictions completed ({len(predictions)} properties)", + "modified_poscar": new_poscar, + "substituted_indices": indices_to_sub, + "num_substitutions": len(indices_to_sub), + "original_formula": atoms.composition.reduced_formula, + "new_formula": new_atoms.composition.reduced_formula, + "message": f"Substituted {len(indices_to_sub)} {element_from} atoms with {element_to}", } - except Exception as e: - return {"error": f"ALIGNN prediction error: {str(e)}"} + return {"error": f"Substitution error: {str(e)}"} -def alignn_predictX( - poscar: str = None, - jid: str = None, - property_name: str = "all", - *, +def create_vacancy( + poscar: str, + element: str, + num_vacancies: int = 1, api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Predict material properties using ALIGNN ML models. + Create vacancy defects by removing atoms from a structure. Args: - poscar: POSCAR format structure string (optional if jid provided) - jid: JARVIS-ID to fetch structure (optional if poscar provided) - property_name: Property to predict. Options: - - "all": All available properties (default) - - "formation_energy_peratom" - - "bandgap" (or "bandgap_mbj" for MBJ corrected) - - "bulk_modulus" - - "shear_modulus" - - "elastic_tensor" - - "exfoliation_energy" - - "max_ir_mode" - - "max_piezo_coeff" - And many more... - api_client: API client instance (injected by agent) + poscar: POSCAR format structure string + element: Element to remove (e.g., "Ga") + num_vacancies: Number of atoms to remove (default: 1) Returns: - dict with predicted properties - - Example: - >>> alignn_predict(jid="JVASP-1002") - >>> alignn_predict(poscar=poscar_string, property_name="bandgap") + dict with modified POSCAR """ try: - # If jid provided, fetch the structure first - if jid and not poscar: - jid_result = query_by_jid(jid, api_client=api_client) - if "error" in jid_result: - return { - "error": f"Failed to fetch structure for {jid}: {jid_result['error']}" - } - poscar = jid_result.get("atoms") - if not poscar: - return {"error": f"No structure found for {jid}"} + from jarvis.core.atoms import Atoms + from jarvis.io.vasp.inputs import Poscar - if not poscar: - return {"error": "Either poscar or jid must be provided"} + # Parse POSCAR + atoms = Poscar.from_string(poscar).atoms - # Build request - params = { - "atoms": poscar, - "property": property_name, - } + # Find indices of atoms to remove + indices_to_remove = [] + for i, atom in enumerate(atoms.elements): + if atom == element: + indices_to_remove.append(i) + if len(indices_to_remove) >= num_vacancies: + break - result = api_client.request("alignn_predict", params, method="POST") + if not indices_to_remove: + return {"error": f"No {element} atoms found in structure"} - # Parse result - if isinstance(result, dict): - # Prioritize MBJ bandgap if available - if "prediction" in result: - predictions = result["prediction"] - - # If bandgap requested, try to get MBJ version - if property_name in ["bandgap", "all"]: - if "bandgap_mbj" in predictions: - result["bandgap"] = predictions["bandgap_mbj"] - result["bandgap_type"] = "MBJ (more accurate)" - elif "bandgap" in predictions: - result["bandgap"] = predictions["bandgap"] - result["bandgap_type"] = "standard" - - return { - "status": "success", - "predictions": predictions, - "jid": jid if jid else "custom", - "message": f"ALIGNN predictions completed", - } - else: - return result - else: + if len(indices_to_remove) < num_vacancies: return { - "error": "Unexpected response format", - "response": str(result), + "error": f"Only {len(indices_to_remove)} {element} atoms available, requested {num_vacancies}" } - except Exception as e: - return {"error": f"ALIGNN prediction error: {str(e)}"} - - -def alignn_predictX( - poscar: str, *, api_client: AGAPIClient = None -) -> Dict[str, Any]: - """ - Predict properties using ALIGNN ML models. - - Args: - poscar: POSCAR format structure string - api_client: API client instance (injected by agent) - """ - try: - # Parse POSCAR - from jarvis.io.vasp.inputs import Poscar - - atoms = Poscar.from_string(poscar).atoms + # Create new lists without removed atoms + new_elements = [] + new_coords = [] + for i, (elem, coord) in enumerate(zip(atoms.elements, atoms.coords)): + if i not in indices_to_remove: + new_elements.append(elem) + new_coords.append(coord) - if atoms.num_atoms > 50: - return { - "error": f"Structure too large ({atoms.num_atoms} atoms). Max: 50" - } + # Create new atoms object + new_atoms = Atoms( + lattice_mat=atoms.lattice_mat, + coords=new_coords, + elements=new_elements, + cartesian=atoms.cartesian, + ) - # Make request - params = {"poscar": poscar} - result = api_client.request("alignn/query", params) + # Convert to POSCAR + new_poscar = Poscar(new_atoms).to_string() return { "status": "success", - "predictions": result, - "num_atoms": atoms.num_atoms, - "formula": atoms.composition.reduced_formula, + "modified_poscar": new_poscar, + "removed_indices": indices_to_remove, + "num_vacancies": len(indices_to_remove), + "original_atoms": atoms.num_atoms, + "new_atoms": new_atoms.num_atoms, + "original_formula": atoms.composition.reduced_formula, + "new_formula": new_atoms.composition.reduced_formula, + "message": f"Created {len(indices_to_remove)} {element} vacancies ({atoms.num_atoms} β†’ {new_atoms.num_atoms} atoms)", } except Exception as e: - return {"error": f"ALIGNN prediction error: {str(e)}"} + return {"error": f"Vacancy creation error: {str(e)}"} -def alignn_ff_relax( - poscar: str, - fmax: float = 0.05, - steps: int = 150, - *, - api_client: AGAPIClient = None, +def protein_fold( + sequence: str, *, api_client: AGAPIClient = None ) -> Dict[str, Any]: """ - Relax structure using ALIGNN force field. + Predict 3D protein structure from amino acid sequence using ESMFold. Args: - poscar: POSCAR format structure string - fmax: Force convergence criterion (eV/Γ…) - steps: Maximum optimization steps + sequence: Amino acid sequence in one-letter codes (A, R, N, D, C, Q, E, G, H, I, L, K, M, F, P, S, T, W, Y, V) api_client: API client instance (injected by agent) + + Returns: + dict with PDB structure string + + Example: + >>> protein_fold("MKTAYIAKQRQISFVKSHFSRQ...") """ try: import httpx - # Use POST endpoint (your backend has this) - data = { - "poscar_string": poscar, - } - - response = httpx.post( - f"{api_client.api_base}/alignn_ff/query", - data=data, - headers={"Authorization": f"Bearer {api_client.api_key}"}, - timeout=api_client.timeout, - ) + # Validate sequence + valid_amino_acids = set("ARNDCQEGHILKMFPSTWYV") + sequence = sequence.upper().strip() - if response.status_code == 200: - result = response.json() - return { - "status": "success", - "original_poscar": result.get("original"), - "relaxed_poscar": result.get("relaxed"), - "message": "Structure optimized with ALIGNN-FF", - } - else: + invalid_chars = set(sequence) - valid_amino_acids + if invalid_chars: return { - "error": f"ALIGNN-FF failed: {response.status_code}", - "detail": response.text, + "error": f"Invalid amino acids in sequence: {invalid_chars}. " + f"Valid: A,R,N,D,C,Q,E,G,H,I,L,K,M,F,P,S,T,W,Y,V" } - except Exception as e: - return {"error": f"ALIGNN-FF error: {str(e)}"} - + if len(sequence) < 10: + return {"error": "Sequence too short (minimum 10 amino acids)"} -def slakonet_bandstructure( - poscar: str, - energy_range_min: float = -8.0, - energy_range_max: float = 8.0, - *, - api_client: AGAPIClient = None, -) -> Dict[str, Any]: - """ - Calculate electronic band structure using SlakoNet. - """ - try: - import httpx - import base64 + if len(sequence) > 400: + return { + "error": f"Sequence too long ({len(sequence)} amino acids). Maximum: 400" + } - data = { - "poscar_string": poscar, - "energy_range_min": energy_range_min, - "energy_range_max": energy_range_max, - } + # Make request to protein folding endpoint + params = {"sequence": sequence} - response = httpx.post( - f"{api_client.api_base}/slakonet/bandstructure", - data=data, + response = httpx.get( + f"{api_client.api_base}/protein_fold/query", + params=params, headers={"Authorization": f"Bearer {api_client.api_key}"}, - timeout=api_client.timeout, + timeout=120.0, # Protein folding can take a while ) if response.status_code == 200: - band_gap = response.headers.get("X-Band-Gap", "N/A") - vbm = response.headers.get("X-VBM", "N/A") - cbm = response.headers.get("X-CBM", "N/A") - - image_data = response.content - image_base64 = base64.b64encode(image_data).decode("utf-8") + pdb_structure = response.text - content_disp = response.headers.get("Content-Disposition", "") - filename = "bandstructure.png" - if "filename=" in content_disp: - filename = content_disp.split("filename=")[1].strip() + # Extract some info from PDB + lines = pdb_structure.splitlines() + num_atoms = len([l for l in lines if l.startswith("ATOM")]) + num_residues = len(sequence) return { "status": "success", - "band_gap_eV": band_gap, - "vbm_eV": vbm, - "cbm_eV": cbm, - "image_base64": image_base64, - "image_filename": filename, - "message": f"Band structure calculated. Band gap: {band_gap} eV", + "pdb_structure": pdb_structure, + "sequence_length": num_residues, + "num_atoms": num_atoms, + "message": f"Predicted 3D structure for {num_residues} amino acid protein ({num_atoms} atoms)", } else: return { - "error": f"SlakoNet failed: {response.status_code}", + "error": f"Protein folding failed: {response.status_code}", "detail": response.text, } except Exception as e: - return {"error": f"SlakoNet error: {str(e)}"} + return {"error": f"Protein folding error: {str(e)}"} -# DiffractGPT Tools -def diffractgpt_predict( - formula: str, peaks: str, api_client: AGAPIClient +def generate_xrd_pattern( + poscar: str, + wavelength: float = 1.54184, + num_peaks: int = 20, + theta_range: list = None, + *, + api_client: AGAPIClient = None, ) -> Dict[str, Any]: - """Predict structure from XRD using DiffractGPT""" - try: - params = {"formula": formula, "peaks": peaks} - result = api_client.request("diffractgpt/query", params) + """ + Generate powder XRD pattern description from crystal structure. - return { - "predicted_structure": result.get("POSCAR"), - "formula": formula, - } - except Exception as e: - return {"error": str(e)} + Args: + poscar: POSCAR format structure string + wavelength: X-ray wavelength in Angstroms (default: 1.54184 = Cu K-alpha) + num_peaks: Number of top peaks to report (default: 20) + theta_range: [min, max] 2-theta range in degrees (default: [0, 90]) + api_client: API client instance (injected by agent) + Returns: + dict with XRD peak positions, intensities, and DiffractGPT-style description -def xrd_match( - formula: str, xrd_pattern: str, api_client: AGAPIClient -) -> Dict[str, Any]: - """Match XRD pattern to database""" + Example: + >>> generate_xrd_pattern(poscar, wavelength=1.54184, num_peaks=10) + """ try: - params = {"pattern": xrd_pattern} - result = api_client.request("pxrd/query", params) + from jarvis.io.vasp.inputs import Poscar + from jarvis.core.atoms import Atoms + from jarvis.analysis.diffraction.xrd import XRD + import numpy as np + from scipy.signal import find_peaks - return { - "matched_structure": result.get("POSCAR"), + # Parse structure + atoms = Poscar.from_string(poscar).atoms + formula = atoms.composition.reduced_formula + + # Set theta range + if theta_range is None: + theta_range = [0, 90] + + # Simulate XRD pattern + xrd = XRD(wavelength=wavelength, thetas=theta_range) + two_theta, d_spacing, intensity = xrd.simulate(atoms=atoms) + + # Normalize intensity + intensity = np.array(intensity) + intensity = intensity / np.max(intensity) + two_theta = np.array(two_theta) + + # Apply Gaussian broadening for peak detection + def gaussian_recast(x_original, y_original, x_new, sigma=0.1): + y_new = np.zeros_like(x_new, dtype=np.float64) + for x0, amp in zip(x_original, y_original): + y_new += amp * np.exp(-0.5 * ((x_new - x0) / sigma) ** 2) + return x_new, y_new + + x_new = np.arange(theta_range[0], theta_range[1], 0.1) + two_theta_smooth, intensity_smooth = gaussian_recast( + two_theta, intensity, x_new, sigma=0.1 + ) + intensity_smooth = intensity_smooth / np.max(intensity_smooth) + + # Find peaks + peaks, props = find_peaks( + intensity_smooth, height=0.01, distance=1, prominence=0.05 + ) + + if len(peaks) == 0: + return { + "status": "warning", + "message": f"No significant XRD peaks found for {formula}", + "formula": formula, + "wavelength": wavelength, + "num_peaks_requested": num_peaks, + "num_peaks_found": 0, + } + + # Get top N peaks by intensity + top_indices = np.argsort(props["peak_heights"])[::-1][:num_peaks] + top_peaks = peaks[top_indices] + top_peaks_sorted = top_peaks[np.argsort(two_theta_smooth[top_peaks])] + + # Create peak list with 2theta and relative intensity + peak_list = [ + { + "two_theta": round(float(two_theta_smooth[p]), 2), + "intensity": round(float(intensity_smooth[p]), 2), + "d_spacing": round( + float( + wavelength + / (2 * np.sin(np.radians(two_theta_smooth[p] / 2))) + ), + 4, + ), + } + for p in top_peaks_sorted + ] + + # Build DiffractGPT-style description + peak_text = ", ".join( + [ + f"{peak['two_theta']}Β°({peak['intensity']})" + for peak in peak_list + ] + ) + + description = ( + f"The chemical formula is: {formula}.\n" + f"The XRD pattern shows main peaks at: {peak_text}." + ) + + # Full pattern for plotting/matching + full_pattern = [ + { + "two_theta": round(float(tt), 2), + "intensity": round(float(ii), 4), + } + for tt, ii in zip(two_theta_smooth, intensity_smooth) + ] + + # Create markdown table for easy display + peak_table = "| Rank | 2ΞΈ (Β°) | Intensity | d-spacing (Γ…) |\n" + peak_table += "|------|--------|-----------|---------------|\n" + for i, peak in enumerate(peak_list, 1): + peak_table += f"| {i:2d} | {peak['two_theta']:6.2f} | {peak['intensity']:5.2f} | {peak['d_spacing']:6.4f} |\n" + + return { + "status": "success", "formula": formula, + "wavelength": wavelength, + "num_peaks_found": len(peaks), + "num_peaks_reported": len(peak_list), + "peaks": peak_list, + "peak_table": peak_table, + "description": description, + "full_pattern": full_pattern[ + :1000 + ], # Truncate to avoid huge response + "message": f"Generated XRD pattern for {formula} with {len(peak_list)} main peaks", } + except Exception as e: - return {"error": str(e)} + return {"error": f"XRD generation error: {str(e)}"} -# Intermat Tools -def generate_interface( - film_poscar: str, - substrate_poscar: str, - film_indices: str = "0_0_1", - substrate_indices: str = "0_0_1", - film_thickness: float = 16, - substrate_thickness: float = 16, - separation: float = 2.5, - max_area: float = 300, +# --------------------------------------------------------------------------- +# ALIGNN-FF: single-point energy / forces (no relaxation) +# --------------------------------------------------------------------------- + + +def alignn_ff_single_point( + poscar: str, *, api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Generate heterostructure interface between two materials. + Evaluate energy, forces, and stress for a structure using ALIGNN-FF + without relaxing it. + + Endpoint: GET /alignn_ff/query + Atom limit: 50 (server-enforced) Args: - film_poscar: POSCAR string for film material - substrate_poscar: POSCAR string for substrate material - film_indices: Miller indices for film surface (e.g., "0_0_1" for (001)) - substrate_indices: Miller indices for substrate surface - film_thickness: Film layer thickness in Angstroms (default: 16) - substrate_thickness: Substrate layer thickness in Angstroms (default: 16) - separation: Interface separation distance in Angstroms (default: 2.5) - max_area: Maximum interface area in AngstromsΒ² (default: 300) - api_client: API client instance (injected by agent) + poscar: POSCAR format structure string + api_client: API client instance Returns: - dict with interface structure (POSCAR format) + dict with natoms, energy_eV, forces_eV_per_A, stress """ try: - import httpx + params = {"poscar": poscar.replace("\n", "\\n")} + result = api_client.request("alignn_ff/query", params) + if isinstance(result, dict) and "error" in result: + return result + return { + "status": "success", + "natoms": result.get("natoms"), + "energy_eV": result.get("energy_eV"), + "forces_eV_per_A": result.get("forces_eV_per_A"), + "stress": result.get("stress"), + } + except Exception as e: + return {"error": f"ALIGNN-FF single point error: {str(e)}"} - # Validate Miller indices format (should be "h_k_l" with underscores) - if " " in film_indices or "," in film_indices: - film_indices = film_indices.replace(" ", "_").replace(",", "_") - if " " in substrate_indices or "," in substrate_indices: - substrate_indices = substrate_indices.replace(" ", "_").replace( - ",", "_" - ) - # Build parameters matching backend API - # Backend expects: poscar_film, poscar_subs, subs_indices (not substrate_indices) - params = { - "poscar_film": film_poscar, # Map to backend param - "poscar_subs": substrate_poscar, # Map to backend param - "film_indices": film_indices, - "subs_indices": substrate_indices, # Backend uses subs_indices - "film_thickness": film_thickness, - "subs_thickness": substrate_thickness, # Backend uses subs_thickness - "separations": str( - separation - ), # Backend uses separations (string) - "max_area": max_area, - "APIKEY": api_client.api_key, +# --------------------------------------------------------------------------- +# ALIGNN-FF: geometry optimization with full trajectory +# --------------------------------------------------------------------------- + + +def alignn_ff_optimize( + poscar: str, + fmax: float = 0.05, + steps: int = 200, + optimizer: str = "FIRE", + relax_cell: bool = True, + *, + api_client: AGAPIClient = None, +) -> Dict[str, Any]: + """ + Relax a crystal structure using ALIGNN force field with full trajectory. + + Endpoint: POST /alignn_ff/optimize + Atom limit: 100 (server-enforced) + + Args: + poscar: POSCAR format structure string + fmax: Force convergence criterion in eV/Γ… (default 0.05) + steps: Maximum optimization steps (default 200) + optimizer: "FIRE", "BFGS", or "LBFGS" (default "FIRE") + relax_cell: Whether to also relax cell vectors (default True) + api_client: API client instance + + Returns: + dict with converged flag, final_poscar, trajectory, energies, + initial_energy, final_energy, energy_change, steps_taken + """ + try: + import httpx + + data = { + "poscar": poscar, + "fmax": fmax, + "steps": steps, + "optimizer": optimizer, + "relax_cell": relax_cell, } - # Direct GET request (returns text/plain) - response = httpx.get( - f"{api_client.api_base}/generate_interface", - params=params, - timeout=300.0, + response = httpx.post( + f"{api_client.api_base}/alignn_ff/optimize", + data=data, + headers={"Authorization": f"Bearer {api_client.api_key}"}, + timeout=api_client.timeout, ) response.raise_for_status() - - interface_poscar = response.text - - # Parse basic info from POSCAR - lines = interface_poscar.splitlines() - elements_line = "" - counts_line = "" - for i, line in enumerate(lines): - if "direct" in line.lower() or "cartesian" in line.lower(): - if i >= 2: - elements_line = lines[i - 2] - counts_line = lines[i - 1] - break + result = response.json() return { "status": "success", - "heterostructure_atoms": interface_poscar, - "film_indices": film_indices, - "substrate_indices": substrate_indices, - "film_thickness": film_thickness, - "substrate_thickness": substrate_thickness, - "separation": separation, - "elements": elements_line.strip(), - "atom_counts": counts_line.strip(), - "message": f"Generated interface structure ({film_indices}/{substrate_indices}), {len(lines)} lines", + "converged": result.get("converged"), + "final_poscar": result.get("final_poscar"), + "initial_energy": result.get("initial_energy"), + "final_energy": result.get("final_energy"), + "energy_change": result.get("energy_change"), + "steps_taken": result.get("steps_taken"), + "energies": result.get("energies", []), + "forces_max": result.get("forces_max", []), + "trajectory": result.get("trajectory", []), + "formula": result.get("formula"), + "num_atoms": result.get("num_atoms"), + "computation_time": result.get("computation_time"), } - except httpx.HTTPStatusError as e: - return { - "error": f"API error {e.response.status_code}: {e.response.text}" - } except Exception as e: - return {"error": f"Interface generation error: {str(e)}"} + return {"error": f"ALIGNN-FF optimize error: {str(e)}"} -def make_supercell( - poscar: str, scaling_matrix: list, api_client: AGAPIClient = None +# --------------------------------------------------------------------------- +# ALIGNN-FF: molecular dynamics (NVE) +# --------------------------------------------------------------------------- + + +def alignn_ff_md( + poscar: str, + temperature: float = 300.0, + timestep: float = 0.5, + steps: int = 50, + interval: int = 5, + *, + api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Create a supercell from a POSCAR structure. + Run NVE molecular dynamics using ALIGNN force field. + + Endpoint: POST /alignn_ff/md + Atom limit: 50 (server-enforced) Args: poscar: POSCAR format structure string - scaling_matrix: List of 3 integers [nx, ny, nz] for supercell dimensions + temperature: Initial temperature in Kelvin (default 300) + timestep: MD timestep in femtoseconds (default 0.5) + steps: Number of MD steps (default 50) + interval: Frame save interval in steps (default 5) + api_client: API client instance Returns: - dict with supercell POSCAR and atom count + dict with trajectory frames, energy vs time, temperature vs time """ try: - from jarvis.core.atoms import Atoms - from jarvis.io.vasp.inputs import Poscar - - # Parse POSCAR - atoms = Poscar.from_string(poscar).atoms + import httpx - # Create supercell - supercell = atoms.make_supercell(scaling_matrix) + data = { + "poscar": poscar, + "temperature": temperature, + "timestep": timestep, + "steps": steps, + "interval": interval, + } - # Convert back to POSCAR - supercell_poscar = Poscar(supercell).to_string() + response = httpx.post( + f"{api_client.api_base}/alignn_ff/md", + data=data, + headers={"Authorization": f"Bearer {api_client.api_key}"}, + timeout=api_client.timeout, + ) + response.raise_for_status() + result = response.json() return { "status": "success", - "supercell_poscar": supercell_poscar, - "original_atoms": atoms.num_atoms, - "supercell_atoms": supercell.num_atoms, - "scaling_matrix": scaling_matrix, - "formula": supercell.composition.reduced_formula, - "message": f"Created {scaling_matrix[0]}x{scaling_matrix[1]}x{scaling_matrix[2]} supercell with {supercell.num_atoms} atoms", + "formula": result.get("formula"), + "num_atoms": result.get("num_atoms"), + "steps_completed": result.get("steps_completed"), + "average_temperature": result.get("average_temperature"), + "final_temperature": result.get("final_temperature"), + "energies": result.get("energies", {}), + "temperatures": result.get("temperatures", []), + "trajectory": result.get("trajectory", []), + "computation_time": result.get("computation_time"), } + except Exception as e: - return {"error": f"Supercell creation error: {str(e)}"} + return {"error": f"ALIGNN-FF MD error: {str(e)}"} -def substitute_atom( - poscar: str, - element_from: str, - element_to: str, - num_substitutions: int = 1, +# --------------------------------------------------------------------------- +# PXRD: match experimental pattern against JARVIS-DFT +# --------------------------------------------------------------------------- + + +def pxrd_match( + query: str, + pattern_data: str, + wavelength: float = 1.54184, + *, api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Substitute atoms in a structure (e.g., replace Ga with Al). + Match an experimental powder XRD pattern against the JARVIS-DFT database + by cosine similarity. + + Endpoint: GET /pxrd/query Args: - poscar: POSCAR format structure string - element_from: Element to replace (e.g., "Ga") - element_to: Element to substitute with (e.g., "Al") - num_substitutions: Number of atoms to substitute (default: 1) + query: Chemical formula or element string (e.g. "LaB6", "Si") + pattern_data: Two-column data as string: "2theta intensity\\n..." + One pair per line, space-separated + wavelength: X-ray wavelength in Γ… (default 1.54184 = Cu KΞ±) + api_client: API client instance Returns: - dict with modified POSCAR + dict with best-match POSCAR and similarity score + + Example: + pattern = "21.38 0.69\\n30.42 1.0\\n37.44 0.31" + result = pxrd_match("LaB6", pattern, api_client=client) """ try: - from jarvis.core.atoms import Atoms - from jarvis.io.vasp.inputs import Poscar - - # Parse POSCAR - atoms = Poscar.from_string(poscar).atoms - - # Find indices of atoms to substitute - indices_to_sub = [] - for i, atom in enumerate(atoms.elements): - if atom == element_from: - indices_to_sub.append(i) - if len(indices_to_sub) >= num_substitutions: - break + # Build the multiline pattern string the endpoint expects: + # Line 1: query;wavelength + # Lines 2+: 2theta intensity + full_pattern = f"{query};{wavelength}\n{pattern_data}" - if not indices_to_sub: - return {"error": f"No {element_from} atoms found in structure"} + params = {"pattern": full_pattern} + result = api_client.request("pxrd/query", params) - if len(indices_to_sub) < num_substitutions: + if isinstance(result, str): + # Endpoint returns plain-text POSCAR on success return { - "error": f"Only {len(indices_to_sub)} {element_from} atoms available, requested {num_substitutions}" + "status": "success", + "matched_poscar": result, + "query": query, } + if isinstance(result, dict): + return result - # Create new element list with substitutions - new_elements = list(atoms.elements) - for idx in indices_to_sub: - new_elements[idx] = element_to + return {"error": "Unexpected response format from pxrd/query"} - # Create new atoms object with substituted elements - new_atoms = Atoms( - lattice_mat=atoms.lattice_mat, - coords=atoms.coords, - elements=new_elements, - cartesian=atoms.cartesian, - ) + except Exception as e: + return {"error": f"PXRD match error: {str(e)}"} - # Convert to POSCAR - new_poscar = Poscar(new_atoms).to_string() - return { - "status": "success", - "modified_poscar": new_poscar, - "substituted_indices": indices_to_sub, - "num_substitutions": len(indices_to_sub), - "original_formula": atoms.composition.reduced_formula, - "new_formula": new_atoms.composition.reduced_formula, - "message": f"Substituted {len(indices_to_sub)} {element_from} atoms with {element_to}", +# --------------------------------------------------------------------------- +# XRD: full analysis (pattern matching + optional DiffractGPT) +# --------------------------------------------------------------------------- + + +def xrd_analyze( + formula: str, + xrd_data: str, + wavelength: float = 1.54184, + method: str = "pattern_matching", + *, + api_client: AGAPIClient = None, +) -> Dict[str, Any]: + """ + Analyze an experimental XRD pattern using pattern matching and/or + DiffractGPT against JARVIS-DFT. + + Endpoint: GET /xrd/analyze + + Args: + formula: Chemical formula (e.g. "LaB6", "Si,Ge") + xrd_data: Two-column XRD data as string: "2theta intensity\\n..." + wavelength: X-ray wavelength in Γ… (default 1.54184) + method: "pattern_matching", "diffractgpt", or "both" + api_client: API client instance + + Returns: + dict with best match, top-5 matches, similarity score, and + optional DiffractGPT predicted structure + """ + try: + params = { + "formula": formula, + "xrd_data": xrd_data.replace("\n", "\\n"), + "wavelength": wavelength, + "method": method, } + result = api_client.request("xrd/analyze", params) + return result + except Exception as e: - return {"error": f"Substitution error: {str(e)}"} + return {"error": f"XRD analyze error: {str(e)}"} -def create_vacancy( - poscar: str, - element: str, - num_vacancies: int = 1, +# --------------------------------------------------------------------------- +# MicroscopyGPT: analyze a microscopy image +# --------------------------------------------------------------------------- + + +def microscopygpt_analyze( + image_path: str, + formula: str, + *, api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Create vacancy defects by removing atoms from a structure. + Analyze a microscopy image (STEM/TEM/SEM) using MicroscopyGPT to + predict crystal structure, defects, or elemental composition. + + Endpoint: POST /microscopy/predict Args: - poscar: POSCAR format structure string - element: Element to remove (e.g., "Ga") - num_vacancies: Number of atoms to remove (default: 1) + image_path: Local path to the image file (PNG, JPG, TIFF) + formula: Chemical formula hint (e.g. "MoS2", "GaN") + api_client: API client instance Returns: - dict with modified POSCAR + dict with predicted structure, confidence, and any defect info """ try: - from jarvis.core.atoms import Atoms - from jarvis.io.vasp.inputs import Poscar + import httpx - # Parse POSCAR - atoms = Poscar.from_string(poscar).atoms + image_path = Path(image_path) + if not image_path.exists(): + return {"error": f"Image file not found: {image_path}"} - # Find indices of atoms to remove - indices_to_remove = [] - for i, atom in enumerate(atoms.elements): - if atom == element: - indices_to_remove.append(i) - if len(indices_to_remove) >= num_vacancies: - break - - if not indices_to_remove: - return {"error": f"No {element} atoms found in structure"} + with open(image_path, "rb") as f: + image_bytes = f.read() - if len(indices_to_remove) < num_vacancies: - return { - "error": f"Only {len(indices_to_remove)} {element} atoms available, requested {num_vacancies}" - } + suffix = image_path.suffix.lower() + mime_map = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".tiff": "image/tiff", + ".tif": "image/tiff", + } + mime_type = mime_map.get(suffix, "image/png") - # Create new lists without removed atoms - new_elements = [] - new_coords = [] - for i, (elem, coord) in enumerate(zip(atoms.elements, atoms.coords)): - if i not in indices_to_remove: - new_elements.append(elem) - new_coords.append(coord) + files = {"image": (image_path.name, image_bytes, mime_type)} + data = {"formula": formula} - # Create new atoms object - new_atoms = Atoms( - lattice_mat=atoms.lattice_mat, - coords=new_coords, - elements=new_elements, - cartesian=atoms.cartesian, + response = httpx.post( + f"{api_client.api_base}/microscopy/predict", + files=files, + data=data, + headers={"Authorization": f"Bearer {api_client.api_key}"}, + timeout=300.0, ) + response.raise_for_status() + return response.json() - # Convert to POSCAR - new_poscar = Poscar(new_atoms).to_string() - - return { - "status": "success", - "modified_poscar": new_poscar, - "removed_indices": indices_to_remove, - "num_vacancies": len(indices_to_remove), - "original_atoms": atoms.num_atoms, - "new_atoms": new_atoms.num_atoms, - "original_formula": atoms.composition.reduced_formula, - "new_formula": new_atoms.composition.reduced_formula, - "message": f"Created {len(indices_to_remove)} {element} vacancies ({atoms.num_atoms} β†’ {new_atoms.num_atoms} atoms)", - } except Exception as e: - return {"error": f"Vacancy creation error: {str(e)}"} + return {"error": f"MicroscopyGPT error: {str(e)}"} -def protein_fold( - sequence: str, *, api_client: AGAPIClient = None +# --------------------------------------------------------------------------- +# Materials Project: query via OPTIMADE +# --------------------------------------------------------------------------- + + +def query_mp( + formula: str, + limit: int = 10, + *, + api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Predict 3D protein structure from amino acid sequence using ESMFold. + Fetch crystal structures from the Materials Project via the OPTIMADE API. + + Endpoint: GET /mp/query Args: - sequence: Amino acid sequence in one-letter codes (A, R, N, D, C, Q, E, G, H, I, L, K, M, F, P, S, T, W, Y, V) - api_client: API client instance (injected by agent) + formula: Reduced chemical formula (e.g. "MoS2", "Al2O3") + limit: Max results to return (default 10, max 500) + api_client: API client instance Returns: - dict with PDB structure string - - Example: - >>> protein_fold("MKTAYIAKQRQISFVKSHFSRQ...") + dict with total count and list of materials with POSCAR and energies """ try: - import httpx - - # Validate sequence - valid_amino_acids = set("ARNDCQEGHILKMFPSTWYV") - sequence = sequence.upper().strip() + params = {"formula": formula, "page_limit": min(limit, 500)} + result = api_client.request("mp/query", params) + return result - invalid_chars = set(sequence) - valid_amino_acids - if invalid_chars: - return { - "error": f"Invalid amino acids in sequence: {invalid_chars}. " - f"Valid: A,R,N,D,C,Q,E,G,H,I,L,K,M,F,P,S,T,W,Y,V" - } + except Exception as e: + return {"error": f"Materials Project query error: {str(e)}"} - if len(sequence) < 10: - return {"error": "Sequence too short (minimum 10 amino acids)"} - if len(sequence) > 400: - return { - "error": f"Sequence too long ({len(sequence)} amino acids). Maximum: 400" - } +# --------------------------------------------------------------------------- +# OQMD: query via OPTIMADE +# --------------------------------------------------------------------------- - # Make request to protein folding endpoint - params = {"sequence": sequence} - response = httpx.get( - f"{api_client.api_base}/protein_fold/query", - params=params, - headers={"Authorization": f"Bearer {api_client.api_key}"}, - timeout=120.0, # Protein folding can take a while - ) +def query_oqmd( + formula: str, + limit: int = 10, + *, + api_client: AGAPIClient = None, +) -> Dict[str, Any]: + """ + Fetch crystal structures from the OQMD (Open Quantum Materials Database) + via the OPTIMADE API. - if response.status_code == 200: - pdb_structure = response.text + Endpoint: GET /oqmd/query - # Extract some info from PDB - lines = pdb_structure.splitlines() - num_atoms = len([l for l in lines if l.startswith("ATOM")]) - num_residues = len(sequence) + Args: + formula: Reduced chemical formula (e.g. "MoS2", "Fe2O3") + limit: Max results to return (default 10, max 500) + api_client: API client instance - return { - "status": "success", - "pdb_structure": pdb_structure, - "sequence_length": num_residues, - "num_atoms": num_atoms, - "message": f"Predicted 3D structure for {num_residues} amino acid protein ({num_atoms} atoms)", - } - else: - return { - "error": f"Protein folding failed: {response.status_code}", - "detail": response.text, - } + Returns: + dict with total count and list of materials with POSCAR + """ + try: + params = {"formula": formula, "page_limit": min(limit, 500)} + result = api_client.request("oqmd/query", params) + return result except Exception as e: - return {"error": f"Protein folding error: {str(e)}"} + return {"error": f"OQMD query error: {str(e)}"} -def generate_xrd_pattern( - poscar: str, - wavelength: float = 1.54184, - num_peaks: int = 20, - theta_range: list = None, +# --------------------------------------------------------------------------- +# ArXiv literature search +# --------------------------------------------------------------------------- + + +def search_arxiv( + query: str, + max_results: int = 10, *, api_client: AGAPIClient = None, ) -> Dict[str, Any]: """ - Generate powder XRD pattern description from crystal structure. + Search arXiv preprints for materials science literature. + + Endpoint: GET /arxiv Args: - poscar: POSCAR format structure string - wavelength: X-ray wavelength in Angstroms (default: 1.54184 = Cu K-alpha) - num_peaks: Number of top peaks to report (default: 20) - theta_range: [min, max] 2-theta range in degrees (default: [0, 90]) - api_client: API client instance (injected by agent) + query: Search string (e.g. "GaN bandgap DFT", "ALIGNN neural network") + max_results: Number of results (default 10, max 100) + api_client: API client instance Returns: - dict with XRD peak positions, intensities, and DiffractGPT-style description - - Example: - >>> generate_xrd_pattern(poscar, wavelength=1.54184, num_peaks=10) + dict with count and list of papers with title, authors, summary, date """ try: - from jarvis.io.vasp.inputs import Poscar - from jarvis.core.atoms import Atoms - from jarvis.analysis.diffraction.xrd import XRD - import numpy as np - from scipy.signal import find_peaks + params = { + "query": query, + "max_results": min(max_results, 100), + } + result = api_client.request("arxiv", params) + return result - # Parse structure - atoms = Poscar.from_string(poscar).atoms - formula = atoms.composition.reduced_formula + except Exception as e: + return {"error": f"ArXiv search error: {str(e)}"} - # Set theta range - if theta_range is None: - theta_range = [0, 90] - # Simulate XRD pattern - xrd = XRD(wavelength=wavelength, thetas=theta_range) - two_theta, d_spacing, intensity = xrd.simulate(atoms=atoms) +# --------------------------------------------------------------------------- +# Crossref literature search +# --------------------------------------------------------------------------- - # Normalize intensity - intensity = np.array(intensity) - intensity = intensity / np.max(intensity) - two_theta = np.array(two_theta) - # Apply Gaussian broadening for peak detection - def gaussian_recast(x_original, y_original, x_new, sigma=0.1): - y_new = np.zeros_like(x_new, dtype=np.float64) - for x0, amp in zip(x_original, y_original): - y_new += amp * np.exp(-0.5 * ((x_new - x0) / sigma) ** 2) - return x_new, y_new +def search_crossref( + query: str, + rows: int = 10, + *, + api_client: AGAPIClient = None, +) -> Dict[str, Any]: + """ + Search published journal articles via the Crossref API. - x_new = np.arange(theta_range[0], theta_range[1], 0.1) - two_theta_smooth, intensity_smooth = gaussian_recast( - two_theta, intensity, x_new, sigma=0.1 - ) - intensity_smooth = intensity_smooth / np.max(intensity_smooth) + Endpoint: GET /crossref - # Find peaks - peaks, props = find_peaks( - intensity_smooth, height=0.01, distance=1, prominence=0.05 - ) + Args: + query: Search string (e.g. "silicon bandgap experiment") + rows: Number of results (default 10, max 100) + api_client: API client instance - if len(peaks) == 0: - return { - "status": "warning", - "message": f"No significant XRD peaks found for {formula}", - "formula": formula, - "wavelength": wavelength, - "num_peaks_requested": num_peaks, - "num_peaks_found": 0, - } + Returns: + dict with count, total_results, and list of papers with DOI and date + """ + try: + params = { + "query": query, + "rows": min(rows, 100), + } + result = api_client.request("crossref", params) + return result - # Get top N peaks by intensity - top_indices = np.argsort(props["peak_heights"])[::-1][:num_peaks] - top_peaks = peaks[top_indices] - top_peaks_sorted = top_peaks[np.argsort(two_theta_smooth[top_peaks])] + except Exception as e: + return {"error": f"Crossref search error: {str(e)}"} - # Create peak list with 2theta and relative intensity - peak_list = [ - { - "two_theta": round(float(two_theta_smooth[p]), 2), - "intensity": round(float(intensity_smooth[p]), 2), - "d_spacing": round( - float( - wavelength - / (2 * np.sin(np.radians(two_theta_smooth[p] / 2))) - ), - 4, - ), - } - for p in top_peaks_sorted - ] - # Build DiffractGPT-style description - peak_text = ", ".join( - [ - f"{peak['two_theta']}Β°({peak['intensity']})" - for peak in peak_list - ] - ) +# --------------------------------------------------------------------------- +# OpenFold: protein + DNA complex structure prediction +# --------------------------------------------------------------------------- - description = ( - f"The chemical formula is: {formula}.\n" - f"The XRD pattern shows main peaks at: {peak_text}." - ) - # Full pattern for plotting/matching - full_pattern = [ - { - "two_theta": round(float(tt), 2), - "intensity": round(float(ii), 4), - } - for tt, ii in zip(two_theta_smooth, intensity_smooth) - ] +def openfold_predict( + protein_sequence: str, + dna1: str, + dna2: str, + *, + api_client: AGAPIClient = None, +) -> Dict[str, Any]: + """ + Predict a protein-DNA complex 3D structure using NVIDIA OpenFold3. - # Create markdown table for easy display - peak_table = "| Rank | 2ΞΈ (Β°) | Intensity | d-spacing (Γ…) |\n" - peak_table += "|------|--------|-----------|---------------|\n" - for i, peak in enumerate(peak_list, 1): - peak_table += f"| {i:2d} | {peak['two_theta']:6.2f} | {peak['intensity']:5.2f} | {peak['d_spacing']:6.4f} |\n" + Endpoint: GET /openfold/query + + Args: + protein_sequence: Protein amino acid sequence (one-letter codes) + dna1: First DNA strand sequence + dna2: Second (complementary) DNA strand sequence + api_client: API client instance + + Returns: + dict with PDB structure string of the protein-DNA complex + """ + try: + import httpx + + params = { + "protein_sequence": protein_sequence, + "dna1": dna1, + "dna2": dna2, + f"APIKEY": api_client.api_key, + } + + response = httpx.get( + f"{api_client.api_base}/openfold/query", + params=params, + timeout=300.0, + ) + response.raise_for_status() + pdb_text = response.text + + lines = pdb_text.splitlines() + num_atoms = sum(1 for l in lines if l.startswith("ATOM")) return { "status": "success", - "formula": formula, - "wavelength": wavelength, - "num_peaks_found": len(peaks), - "num_peaks_reported": len(peak_list), - "peaks": peak_list, - "peak_table": peak_table, - "description": description, - "full_pattern": full_pattern[ - :1000 - ], # Truncate to avoid huge response - "message": f"Generated XRD pattern for {formula} with {len(peak_list)} main peaks", + "pdb_structure": pdb_text, + "num_atoms": num_atoms, + "protein_length": len(protein_sequence), + "dna1_length": len(dna1), + "dna2_length": len(dna2), } except Exception as e: - return {"error": f"XRD generation error: {str(e)}"} + return {"error": f"OpenFold prediction error: {str(e)}"} + + +# --------------------------------------------------------------------------- +# JARVIS DFT: list all queryable property columns +# --------------------------------------------------------------------------- + + +def list_jarvis_columns( + *, + api_client: AGAPIClient = None, +) -> Dict[str, Any]: + """ + Return all column names available in the JARVIS-DFT database. + Useful for discovering which properties can be used in query_by_property. + + Endpoint: GET /jarvis_dft/columns + + Returns: + dict with list of column names + """ + try: + result = api_client.request("jarvis_dft/columns", {}) + return result + + except Exception as e: + return {"error": f"Column listing error: {str(e)}"} diff --git a/agapi/cli.py b/agapi/cli.py deleted file mode 100644 index 555e46b..0000000 --- a/agapi/cli.py +++ /dev/null @@ -1,78 +0,0 @@ -import argparse, sys, os, json -from .client import Agapi - -def _print_json(obj): - print(json.dumps(obj, indent=2)) - -def cmd_jarvis(args): - client = Agapi() - res = client.jarvis_dft_query(formula=args.formula, search=args.search) - _print_json(res) - -def cmd_alignn(args): - client = Agapi() - poscar_str = None - if args.stdin: - poscar_str = sys.stdin.read() - res = client.alignn_query(file_path=args.file, poscar_string=poscar_str) - _print_json(res) - -def cmd_alignn_ff(args): - client = Agapi() - poscar_str = None - if args.stdin: - poscar_str = sys.stdin.read() - res = client.alignn_ff_query(file_path=args.file, poscar_string=poscar_str) - _print_json(res) - -def cmd_protein(args): - client = Agapi() - blob = client.protein_fold_query(sequence=args.sequence, format=args.format) - if args.format == "zip": - out = args.out or "protein.zip" - with open(out, "wb") as f: - f.write(blob) - print(f"Saved: {out}") - else: - _print_json(blob) - -def cmd_pxrd(args): - client = Agapi() - res = client.pxrd_query(file_path=args.file, body_string=args.body_string) - _print_json(res) - -def main(argv=None): - parser = argparse.ArgumentParser(prog="agapi", description="CLI for AtomGPT.org API") - sub = parser.add_subparsers(dest="cmd", required=True) - - p = sub.add_parser("jarvis", help="Query JARVIS-DFT") - p.add_argument("--formula", help="e.g., MoS2") - p.add_argument("--search", help="e.g., -Mo-S") - p.set_defaults(func=cmd_jarvis) - - p = sub.add_parser("alignn", help="Run ALIGNN") - p.add_argument("--file", help="POSCAR path") - p.add_argument("--stdin", action="store_true", help="read POSCAR from stdin") - p.set_defaults(func=cmd_alignn) - - p = sub.add_parser("alignn-ff", help="Run ALIGNN-FF") - p.add_argument("--file", help="POSCAR path") - p.add_argument("--stdin", action="store_true", help="read POSCAR from stdin") - p.set_defaults(func=cmd_alignn_ff) - - p = sub.add_parser("protein", help="Protein folding") - p.add_argument("--sequence", required=True, help="amino acid sequence") - p.add_argument("--format", default="json", choices=["json","zip"], help="response format") - p.add_argument("--out", help="output path if format=zip") - p.set_defaults(func=cmd_protein) - - p = sub.add_parser("pxrd", help="PXRD analysis") - p.add_argument("--file", help="data file") - p.add_argument("--body_string", help="raw body string instead of file") - p.set_defaults(func=cmd_pxrd) - - args = parser.parse_args(argv) - args.func(args) - -if __name__ == "__main__": - main() diff --git a/agapi/client.py b/agapi/client.py deleted file mode 100644 index 01f3da0..0000000 --- a/agapi/client.py +++ /dev/null @@ -1,262 +0,0 @@ -import os -import io -import json -from typing import Optional, Dict, Any, Union -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -# from dotenv import load_dotenv - -DEFAULT_BASE_URL = "https://atomgpt.org" - - -class _SessionFactory: - @staticmethod - def build_session( - timeout: int = 120, total_retries: int = 3 - ) -> requests.Session: - s = requests.Session() - retries = Retry( - total=total_retries, - backoff_factor=0.5, - status_forcelist=(429, 500, 502, 503, 504), - allowed_methods=frozenset( - ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"] - ), - ) - adapter = HTTPAdapter(max_retries=retries) - s.mount("https://", adapter) - s.mount("http://", adapter) - s.request_timeout = timeout - return s - - -class Agapi: - """Minimal Python client for AtomGPT.org endpoints.""" - - def __init__( - self, - api_key: Optional[str] = None, - base_url: Optional[str] = None, - timeout: int = 120, - retries: int = 3, - session: Optional[requests.Session] = None, - ): - # load_dotenv() - if not api_key: - self.api_key = os.getenv("AGAPI_API_KEY") - else: - self.api_key = api_key - if not self.api_key: - raise ValueError( - "Missing AGAPI_API_KEY. Set env var or pass api_key=..." - ) - - self.base_url = ( - base_url or os.getenv("AGAPI_BASE_URL") or DEFAULT_BASE_URL - ).rstrip("/") - self.timeout = timeout - self.session = session or _SessionFactory.build_session( - timeout=timeout, total_retries=retries - ) - - # ---- helpers ---- - def _headers( - self, - content_type: Optional[str] = None, - accept: str = "application/json", - ) -> Dict[str, str]: - h = { - "Authorization": f"Bearer {self.api_key}", - "accept": accept, - } - if content_type: - h["Content-Type"] = content_type - return h - - def _post_json(self, path: str, payload: Dict[str, Any]) -> Any: - url = f"{self.base_url}{path}" - resp = self.session.post( - url, - headers=self._headers(content_type="application/json"), - json=payload, - timeout=self.timeout, - ) - return self._handle_response(resp) - - def _post_multipart( - self, - path: str, - files: Dict[str, Any], - data: Optional[Dict[str, Any]] = None, - accept: str = "application/json", - ) -> Any: - url = f"{self.base_url}{path}" - resp = self.session.post( - url, - headers=self._headers(accept=accept), - files=files, - data=data or {}, - timeout=self.timeout, - ) - return self._handle_response(resp, accept=accept) - - def _post_raw( - self, - path: str, - params: Optional[Dict[str, Any]] = None, - data: Optional[Union[str, bytes]] = None, - accept: str = "application/json", - ) -> Any: - url = f"{self.base_url}{path}" - resp = self.session.post( - url, - headers=self._headers(accept=accept), - params=params or {}, - data=data or b"", - timeout=self.timeout, - ) - return self._handle_response(resp, accept=accept) - - def _handle_response( - self, resp: requests.Response, accept: str = "application/json" - ) -> Any: - if resp.status_code >= 400: - # Try to extract JSON error if available - try: - detail = resp.json() - except Exception: - detail = resp.text - raise requests.HTTPError( - f"HTTP {resp.status_code}: {detail}", response=resp - ) - - if accept == "application/json": - return resp.json() - else: - return resp.content - - # ---- Endpoints ---- - - # /jarvis_dft/query (POST JSON) - def jarvis_dft_query( - self, *, formula: Optional[str] = None, search: Optional[str] = None - ) -> Any: - """Query JARVIS-DFT: - - by formula: jarvis_dft_query(formula="MoS2") - - by search: jarvis_dft_query(search="-Mo-S") - """ - payload: Dict[str, Any] = {} - if formula is not None: - payload["formula"] = formula - if search is not None: - payload["search"] = search - if not payload: - raise ValueError("Provide formula= or search=") - return self._post_json("/jarvis_dft/query", payload) - - # /alignn/query (multipart) - def alignn_query( - self, - *, - file_path: Optional[str] = None, - poscar_string: Optional[str] = None, - ) -> Any: - files = {} - data = {} - if file_path: - files["file"] = ( - os.path.basename(file_path), - open(file_path, "rb"), - ) - if poscar_string is not None: - data["poscar_string"] = poscar_string - if not files and "poscar_string" not in data: - raise ValueError("Provide file_path= or poscar_string=") - try: - return self._post_multipart( - "/alignn/query", files=files, data=data - ) - finally: - # close file handles if opened - if "file" in files and hasattr(files["file"][1], "close"): - files["file"][1].close() - - # /alignn_ff/query (multipart) - def alignn_ff_query( - self, - *, - file_path: Optional[str] = None, - poscar_string: Optional[str] = None, - ) -> Any: - files = {} - data = {} - if file_path: - files["file"] = ( - os.path.basename(file_path), - open(file_path, "rb"), - ) - if poscar_string is not None: - data["poscar_string"] = poscar_string - if not files and "poscar_string" not in data: - raise ValueError("Provide file_path= or poscar_string=") - try: - return self._post_multipart( - "/alignn_ff/query", files=files, data=data - ) - finally: - if "file" in files and hasattr(files["file"][1], "close"): - files["file"][1].close() - - # /protein_fold/query (POST with query params, returns json or zip) - def protein_fold_query( - self, *, sequence: str, format: str = "json" - ) -> Any: - if not sequence: - raise ValueError("sequence is required") - accept = "application/json" if format != "zip" else "application/zip" - return self._post_raw( - "/protein_fold/query", - params={"sequence": sequence, "format": format}, - data=b"", - accept=accept, - ) - - def ask(self, question: str, model: str = "openai/gpt-oss-20b") -> str: - """Simple question-answer interface.""" - from openai import OpenAI - - client = OpenAI( - api_key=self.api_key, base_url="https://atomgpt.org/api" - ) - resp = client.chat.completions.create( - model=model, - messages=[{"role": "user", "content": question}], - ) - resp = resp.choices[0].message.content - return resp - - # /pxrd/query (multipart) - def pxrd_query( - self, - *, - file_path: Optional[str] = None, - body_string: Optional[str] = None, - ) -> Any: - files = {} - data = {} - if file_path: - files["file"] = ( - os.path.basename(file_path), - open(file_path, "rb"), - ) - if body_string is not None: - data["body_string"] = body_string - if not files and "body_string" not in data: - raise ValueError("Provide file_path= or body_string=") - try: - return self._post_multipart("/pxrd/query", files=files, data=data) - finally: - if "file" in files and hasattr(files["file"][1], "close"): - files["file"][1].close() diff --git a/agapi/tests/test_agents.py b/agapi/tests/test_agents.py new file mode 100644 index 0000000..498a7f2 --- /dev/null +++ b/agapi/tests/test_agents.py @@ -0,0 +1,198 @@ +import os +import time +import inspect +import pytest +from agapi.agents import AGAPIAgent + + +# ============================================================ +# Fixture +# ============================================================ + +@pytest.fixture(scope="session") +def agent(): + api_key = os.environ.get("AGAPI_KEY") + if not api_key: + pytest.skip("AGAPI_KEY not set in environment") + return AGAPIAgent(api_key=api_key) + + +# ============================================================ +# Utility Functions +# ============================================================ + +def pretty_print(query, response, elapsed): + test_name = inspect.stack()[1].function + line = "=" * 120 + + print(f"\n{line}") + print(f"TEST: {test_name}") + print(f"TIME: {elapsed:.2f} sec") + print("-" * 120) + print("QUERY:") + print(query.strip()) + print("-" * 120) + print("RESPONSE:") + print(response) + print(f"{line}\n") + + +def run_query(agent, query, **kwargs): + start = time.time() + response = agent.query_sync(query, **kwargs) + elapsed = time.time() - start + pretty_print(query, response, elapsed) + return response + + +def assert_valid_response(resp): + assert resp is not None + assert isinstance(resp, str) + assert len(resp.strip()) > 0 + + +# ============================================================ +# Basic Queries +# ============================================================ + +def test_capital_query(agent): + query = "Whats the capital of US?" + resp = run_query(agent, query, render_html=True) + assert_valid_response(resp) + + +def test_al2o3_with_tools(agent): + query = "Find all Al2O3 materials" + resp = run_query(agent, query, render_html=True) + assert_valid_response(resp) + + +def test_al2o3_without_tools(agent): + query = "Find all Al2O3 materials" + resp = run_query(agent, query, render_html=True, use_tools=False) + assert_valid_response(resp) + + +# ============================================================ +# Materials Database Queries +# ============================================================ + +@pytest.mark.parametrize("query", [ + "Show me all MgB2 polymorphs", + "Get POSCAR for JVASP-1002", + "How many materials have Tc_supercon data?", + "What’s the Tc_Supercon for MgB2 and whats the JARVIS-ID for it?", + "What’s the Tc_Supercon for NbC in K?", + "What’s the Tc_Supercon for NbO in K?", + "What’s the stiffest Si,O material?", + "Find materials with bulk modulus > 200 GPa", + "Compare bandgaps across BN, AlN, GaN, InN", + "What are the formation energies of SiC, AlN, MgO?", +]) +def test_material_queries(agent, query): + resp = run_query(agent, query, render_html=True) + assert_valid_response(resp) + + +# ============================================================ +# Comparison Queries +# ============================================================ + +@pytest.mark.parametrize("query", [ + "Compare the bulk moduli and formation energies of TiC, ZrC, HfC", + "Compare properties of Si, SiC, SiGe", + "Among materials with bulk modulus > 150 GPa, which has the lowest ehull?", + "For TiO2, which polymorph is stiffest?", +]) +def test_comparison_queries(agent, query): + resp = run_query(agent, query, render_html=True) + assert_valid_response(resp) + + +# ============================================================ +# ALIGNN Prediction +# ============================================================ + +def test_alignn_prediction_jvasp(agent): + query = "Predict properties of JARVIS-ID JVASP-1002 with ALIGNN" + resp = run_query(agent, query, render_html=True) + assert_valid_response(resp) + + +def test_alignn_prediction_poscar(agent): + poscar = """System +1.0 +3.2631502048902807 0.0 0.0 +0.0 3.2631502048902807 0.0 +0.0 0.0 3.2631502048902807 +Ti Au +1 1 +direct +0.5 0.5 0.5 +0.0 0.0 0.0 +""" + query = f"Predict properties using ALIGNN for this structure:\n\n{poscar}" + resp = run_query(agent, query, render_html=True) + assert_valid_response(resp) + + +def test_alignn_ff_optimization(agent): + poscar = """System +1.0 +3.2631502048902807 0.0 0.0 +0.0 3.2631502048902807 0.0 +0.0 0.0 3.2631502048902807 +Ti Au +1 1 +direct +0.5 0.5 0.5 +0.0 0.0 0.0 +""" + query = f"Optimize structure with ALIGNN-FF:\n\n{poscar}" + resp = run_query(agent, query, render_html=True) + assert_valid_response(resp) + + +# ============================================================ +# Complex Workflows (Slow Tests) +# ============================================================ + +@pytest.mark.slow +def test_complex_gan_workflow(agent): + query = """ + 1. Find all GaN materials in the JARVIS-DFT database + 2. Get the POSCAR for the most stable one + 3. Make a 2x1x1 supercell + 4. Substitute one Ga with Al + 5. Generate powder XRD pattern + 6. Optimize structure with ALIGNN-FF + 7. Predict properties with ALIGNN + """ + resp = run_query( + agent, + query, + render_html=True, + verbose=True, + max_context_messages=20, + ) + assert_valid_response(resp) + + +@pytest.mark.slow +def test_interface_generation(agent): + query = """ + Create a GaN/AlN heterostructure interface: + 1. Find GaN (most stable) + 2. Find AlN (most stable) + 3. Generate (001)/(001) interface + 4. Show POSCAR + """ + resp = run_query( + agent, + query, + render_html=True, + verbose=True, + max_context_messages=20, + ) + assert_valid_response(resp) + diff --git a/agapi/tests/test_entrypoints.py b/agapi/tests/test_entrypoints.py deleted file mode 100644 index 0bea465..0000000 --- a/agapi/tests/test_entrypoints.py +++ /dev/null @@ -1,134 +0,0 @@ -import os -import json -import pytest -import requests - -# --------------------------------------------------------------------------- # -# Constants and helpers -# --------------------------------------------------------------------------- # - -BASE_URL = "https://atomgpt.org" - -# JSON that will be sent as the `propranges` query for the JARVIS‑DFT test. -# We first create the JSON string, then escape its curly braces so that -# `str.format()` does not treat them as format placeholders. -_JARVIS_PROPRANGES_RAW = { - "epsx": {"min": 15}, - "epsy": {"min": 15}, - "avg_elec_mass": {"max": 0.5}, -} -_JARVIS_PROPRANGES = json.dumps(_JARVIS_PROPRANGES_RAW) -_JARVIS_PROPRANGES_ESCAPED = _JARVIS_PROPRANGES.replace("{", "{{").replace( - "}", "}}" -) - -# --------------------------------------------------------------------------- # -# Test cases -# --------------------------------------------------------------------------- # - -API_CASES = [ - { - "id": 1, - "name": "JARVIS-DFT elements filter", - "url": f"{BASE_URL}/jarvis_dft/query?elements=Si,C&APIKEY={{api_key}}", - }, - { - "id": 2, - "name": "JARVIS-DFT formula Al2O3", - "url": f"{BASE_URL}/jarvis_dft/query?formula=Al2O3&APIKEY={{api_key}}", - }, - { - "id": 5, - "name": "Materials Project Al2O3", - "url": f"{BASE_URL}/mp/query?formula=Al2O3&APIKEY={{api_key}}", - }, - { - "id": 7, - "name": "ALIGNN by JID", - "url": f"{BASE_URL}/alignn/query?jid=JVASP-1002&APIKEY={{api_key}}", - }, - { - "id": 8, - "name": "ALIGNN by POSCAR", - "url": ( - f"{BASE_URL}/alignn/query?" - "poscar=System\n1.0\n3.2631502048902807 0.0 -0.0\n" - "0.0 3.2631502048902807 0.0\n" - "0.0 -0.0 3.2631502048902807\n" - "Ti Au\n1 1\n" - "direct\n" - "0.5 0.5 0.5 Ti\n" - "0.0 0.0 0.0 Au\n" - "&APIKEY={{api_key}}" - ), - }, - { - "id": 10, - "name": "arXiv MgB2", - "url": f"{BASE_URL}/arxiv?query=MgB2&APIKEY={{api_key}}", - }, - { - "id": 11, - "name": "CrossRef CrMnFeCoNi", - "url": f"{BASE_URL}/crossref?query=CrMnFeCoNi&rows=100&APIKEY={{api_key}}", - }, -] - -# --------------------------------------------------------------------------- # -# Fixtures -# --------------------------------------------------------------------------- # - - -@pytest.fixture(scope="session") -def api_key() -> str: - """ - Read the ATOMGPT_API_KEY env‑var. If it is missing we skip the whole test - session so that CI does not report hard failures when the key is not set. - """ - key = os.getenv("ATOMGPT_API_KEY") - if not key: - pytest.skip("ATOMGPT_API_KEY environment variable not set.") - return key - - -# --------------------------------------------------------------------------- # -# Tests -# --------------------------------------------------------------------------- # - - -@pytest.mark.parametrize("case", API_CASES, ids=[c["name"] for c in API_CASES]) -def test_atomgpt_api_call(case, api_key): - """ - Call the AtomGPT endpoint described by ``case`` and validate that we get - a 200 response with *valid* JSON. For a couple of public endpoints that - require a premium key we skip the tests when a 401 is returned - (this keeps the suite usable even when the key has limited scope). - """ - url = case["url"].format(api_key=api_key) - resp = requests.get(url, timeout=30) - - # Skip known endpoints that are currently behind a 401 wall. - if resp.status_code == 401 and case["name"] in ( - "ALIGNN by POSCAR", - "PXRD pattern MoS2", - ): - pytest.skip(f"{case['name']} returned 401 – skipping test.") - - assert ( - resp.status_code == 200 - ), f"{case['name']} returned {resp.status_code}" - - # Validate that the response body is JSON‑parsable. - try: - data = resp.json() - except json.JSONDecodeError: - pytest.fail( - f"{case['name']} did not return valid JSON. Body: {resp.text[:500]}" - ) - - # Minimal sanity check – make sure we did get something back. - assert data is not None, f"{case['name']} returned empty response." - # Uncomment the following if the contract of the API changes. - # assert any( - # k in data for k in ("data", "results", "hits") - # ), f"{case['name']} JSON missing expected top-level keys. Got keys: {list(data.keys())}" diff --git a/agapi/tests/test_functions.py b/agapi/tests/test_functions.py new file mode 100644 index 0000000..75e555b --- /dev/null +++ b/agapi/tests/test_functions.py @@ -0,0 +1,257 @@ +""" +Minimal but complete integration coverage for all +agapi.agents.functions + +Real HTTP calls. +Requires AGAPI_KEY. + +Run: + pytest -v -s test_functions_full_minimal.py +""" + +import os +import pytest +from agapi.agents.client import AGAPIClient +from agapi.agents.functions import * +import pytest + +# pytest.skip("Temporarily disabled", allow_module_level=True) + +# --------------------------------------------------------------------- +# Client +# --------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def client(): + key = os.getenv("AGAPI_KEY") + if not key: + pytest.skip("AGAPI_KEY not set") + return AGAPIClient(api_key=key) + + +# --------------------------------------------------------------------- +# Primitive structures (≀10 atoms) +# --------------------------------------------------------------------- + +SI_PRIM = """\ +Si +1.0 +0 2.734 2.734 +2.734 0 2.734 +2.734 2.734 0 +Si +2 +direct +0 0 0 +0.25 0.25 0.25 +""" + +GAAS_PRIM = """\ +GaAs +1.0 +0 2.875 2.875 +2.875 0 2.875 +2.875 2.875 0 +Ga As +1 1 +direct +0 0 0 +0.25 0.25 0.25 +""" + +SI_XRD = """\ +28.44 1.00 +47.30 0.55 +56.12 0.30 +""" + + +# ===================================================================== +# DATABASE +# ===================================================================== + +def test_query_by_formula(client): + r = query_by_formula("Si", client) + assert "error" not in r + + +def test_query_by_jid(client): + r = query_by_jid("JVASP-1002", client) + assert "error" not in r + assert isinstance(r.get("POSCAR"), str) + + +def test_query_by_elements(client): + r = query_by_elements("Si", client) + assert "error" not in r + + +def test_query_by_property(client): + r = query_by_property("bandgap", 0.1, 3.0, + elements="Si", api_client=client) + assert "error" not in r + + +def test_find_extreme(client): + r = find_extreme("bulk modulus", True, + elements="Si", api_client=client) + assert "error" not in r + + +# ===================================================================== +# ALIGNN + FF +# ===================================================================== + +def test_alignn_predict(client): + r = alignn_predict(jid="JVASP-1002", api_client=client) + assert r.get("status") == "success" + + +def test_alignn_ff_relax(client): + r = alignn_ff_relax(SI_PRIM, api_client=client) + assert r.get("status") == "success" + + +def test_alignn_ff_single_point(client): + r = alignn_ff_single_point(SI_PRIM, api_client=client) + assert "energy_eV" in r + +""" +def test_alignn_ff_optimize(client): + r = alignn_ff_optimize(SI_PRIM, steps=5, api_client=client) + assert "final_poscar" in r + + +def test_alignn_ff_md(client): + r = alignn_ff_md(SI_PRIM, steps=5, api_client=client) + assert r.get("steps_completed") == 5 + +""" + +# ===================================================================== +# BANDSTRUCTURE +# ===================================================================== + +def test_slakonet_bandstructure(client): + r = slakonet_bandstructure(SI_PRIM, api_client=client) + assert r.get("status") == "success" + + +# ===================================================================== +# INTERFACE +# ===================================================================== + +def test_generate_interface(client): + r = generate_interface(SI_PRIM, GAAS_PRIM, api_client=client) + assert r.get("status") == "success" + + +# ===================================================================== +# STRUCTURE OPS (local) +# ===================================================================== + +def test_make_supercell(): + r = make_supercell(SI_PRIM, [2, 2, 1]) + assert r["supercell_atoms"] > r["original_atoms"] + + +def test_substitute_atom(): + r = substitute_atom(GAAS_PRIM, "Ga", "Al", 1) + assert "Al" in r["new_formula"] + + +def test_create_vacancy(): + r = create_vacancy(GAAS_PRIM, "Ga", 1) + assert r["new_atoms"] == r["original_atoms"] - 1 + + +def test_generate_xrd_pattern(): + r = generate_xrd_pattern(SI_PRIM) + assert r["formula"] == "Si" + + +# ===================================================================== +# DIFFRACTGPT +# ===================================================================== + +def test_diffractgpt_predict(client): + r = diffractgpt_predict("Si", "28.4(1.0),47.3(0.49)", client) + assert isinstance(r, dict) + + +# ===================================================================== +# PROTEIN +# ===================================================================== + +def test_protein_fold_validation(client): + r = protein_fold("MKTAY", api_client=client) + assert "error" in r + + +""" +def test_openfold_predict(client): + seq = "MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVK" + r = openfold_predict(seq, api_client=client) + assert isinstance(r, dict) + +""" + +# ===================================================================== +# PXRD / XRD +# ===================================================================== + +def test_pxrd_match(client): + r = pxrd_match("Si", SI_XRD, api_client=client) + assert isinstance(r, dict) + + +def test_xrd_analyze(client): + r = xrd_analyze("Si", SI_XRD, api_client=client) + assert isinstance(r, dict) + +""" +def test_microscopygpt_analyze(client): + r = microscopygpt_analyze("HRTEM image of Si lattice", api_client=client) + assert isinstance(r, dict) +""" + + +# ===================================================================== +# EXTERNAL DATABASES +# ===================================================================== + +def test_query_mp(client): + r = query_mp("Si", limit=2, api_client=client) + assert isinstance(r, dict) + +""" +def test_query_oqmd(client): + r = query_oqmd("Si", limit=2, api_client=client) + assert isinstance(r, dict) + +""" + +# ===================================================================== +# LITERATURE +# ===================================================================== + +def test_search_arxiv(client): + r = search_arxiv("GaN", max_results=2, api_client=client) + assert isinstance(r, dict) + + +def test_search_crossref(client): + r = search_crossref("GaN", rows=2, api_client=client) + assert isinstance(r, dict) + + +# ===================================================================== +# META +# ===================================================================== + +""" +def test_list_jarvis_columns(client): + r = list_jarvis_columns(client) + assert isinstance(r, list) + +""" diff --git a/agapi/tests/test_functions_long.py b/agapi/tests/test_functions_long.py new file mode 100644 index 0000000..a64031f --- /dev/null +++ b/agapi/tests/test_functions_long.py @@ -0,0 +1,1282 @@ +""" +Integration tests for agapi/agents/functions.py + +No mocks β€” all tests make real HTTP calls to atomgpt.org. + +Setup: + export AGAPI_KEY="sk-your-key-here" + pip install pytest httpx jarvis-tools scipy + pytest test_functions.py -v + +Key backend behaviors that affect tests: + 1. query_by_property / find_extreme: + The backend _apply_filters() returns an EMPTY DataFrame when no + formula/elements/jid filter is given (by design β€” safety guard). + Always combine propranges with elements= or formula=. + + 2. diffractgpt_predict: + /diffractgpt/query returns plain text (POSCAR + comment header), + NOT a JSON dict. The current functions.py wraps it but calls + result.get("POSCAR") on a string β†’ error. Tests document this. + + 3. protein_fold: + /protein_fold/query is a GET endpoint that requires APIKEY in query + params (verify_api_key_required dependency). AGAPIClient injects + APIKEY automatically into GET params. + + 4. alignn_ff_relax / slakonet_bandstructure: + Backend enforces <= 10 atom limit on POST endpoints. + Use primitive cells (2 atoms) to stay within limits. +""" + +import os +import pytest +from agapi.agents.client import AGAPIClient +from agapi.agents.functions import ( + query_by_formula, + query_by_jid, + query_by_elements, + query_by_property, + find_extreme, + alignn_predict, + alignn_ff_relax, + slakonet_bandstructure, + generate_interface, + make_supercell, + substitute_atom, + create_vacancy, + generate_xrd_pattern, + protein_fold, + diffractgpt_predict, + alignn_ff_single_point, + alignn_ff_optimize, + alignn_ff_md, + pxrd_match, + xrd_analyze, + microscopygpt_analyze, + query_mp, + query_oqmd, + search_arxiv, + search_crossref, + openfold_predict, + list_jarvis_columns, +) + +pytest.skip("Temporarily disabled", allow_module_level=True) + +# --------------------------------------------------------------------------- +# Session-scoped client fixture +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def client(): + api_key = os.environ.get("AGAPI_KEY") + if not api_key: + pytest.skip("AGAPI_KEY environment variable not set") + return AGAPIClient(api_key=api_key) + + +# --------------------------------------------------------------------------- +# Reference structures +# --------------------------------------------------------------------------- + +# Si conventional cell (8 atoms) β€” for interface / XRD / supercell tests +SI_POSCAR = """\ +Si +1.0 + 5.468799591 0.000000000 0.000000000 + 0.000000000 5.468799591 0.000000000 + 0.000000000 0.000000000 5.468799591 +Si +8 +direct + 0.000000000 0.000000000 0.000000000 + 0.000000000 0.500000000 0.500000000 + 0.500000000 0.000000000 0.500000000 + 0.500000000 0.500000000 0.000000000 + 0.250000000 0.250000000 0.250000000 + 0.250000000 0.750000000 0.750000000 + 0.750000000 0.250000000 0.750000000 + 0.750000000 0.750000000 0.250000000 +""" + +# Si primitive cell (2 atoms) β€” for ALIGNN/SlakoNet (server limit: <=10 atoms) +SI_POSCAR_PRIM = """\ +Si +1.0 + 0.000000000 2.734399796 2.734399796 + 2.734399796 0.000000000 2.734399796 + 2.734399796 2.734399796 0.000000000 +Si +2 +direct + 0.000000000 0.000000000 0.000000000 + 0.250000000 0.250000000 0.250000000 +""" + +# GaAs conventional cell (8 atoms) +GaAs_POSCAR = """\ +GaAs +1.0 + 5.750000000 0.000000000 0.000000000 + 0.000000000 5.750000000 0.000000000 + 0.000000000 0.000000000 5.750000000 +Ga As +4 4 +direct + 0.000000000 0.000000000 0.000000000 + 0.000000000 0.500000000 0.500000000 + 0.500000000 0.000000000 0.500000000 + 0.500000000 0.500000000 0.000000000 + 0.250000000 0.250000000 0.250000000 + 0.250000000 0.750000000 0.750000000 + 0.750000000 0.250000000 0.750000000 + 0.750000000 0.750000000 0.250000000 +""" + +# GaAs primitive cell (2 atoms) β€” for ALIGNN/SlakoNet +GaAs_POSCAR_PRIM = """\ +GaAs +1.0 + 0.000000000 2.875000000 2.875000000 + 2.875000000 0.000000000 2.875000000 + 2.875000000 2.875000000 0.000000000 +Ga As +1 1 +direct + 0.000000000 0.000000000 0.000000000 + 0.250000000 0.250000000 0.250000000 +""" + +# Si primitive cell β€” 2 atoms, well within all server limits +SI_PRIM = """\ +Si +1.0 + 0.000000000 2.734399796 2.734399796 + 2.734399796 0.000000000 2.734399796 + 2.734399796 2.734399796 0.000000000 +Si +2 +direct + 0.000000000 0.000000000 0.000000000 + 0.250000000 0.250000000 0.250000000 +""" + +# Simple XRD pattern data for LaB6 (2theta intensity pairs) +LAB6_XRD = """\ +21.38 0.69 +30.42 1.00 +37.44 0.31 +43.50 0.25 +49.02 0.49 +""" + +# Si XRD pattern +SI_XRD = """\ +28.44 1.00 +47.30 0.55 +56.12 0.30 +69.13 0.11 +76.38 0.12 +""" + +# =========================================================================== +# query_by_formula +# =========================================================================== + +class TestQueryByFormula: + + def test_known_formula_si_returns_results(self, client): + result = query_by_formula("Si", client) + assert "error" not in result + assert result["total"] > 0 + assert len(result["materials"]) > 0 + + def test_result_contains_required_keys(self, client): + result = query_by_formula("Si", client) + mat = result["materials"][0] + for key in ["jid", "formula", "spg_symbol", + "formation_energy_peratom", "bandgap", + "bandgap_source", "ehull"]: + assert key in mat, f"Missing key: {key}" + + def test_multicomponent_gan(self, client): + result = query_by_formula("GaN", client) + assert "error" not in result + assert result["total"] > 0 + + def test_gaas_formula(self, client): + result = query_by_formula("GaAs", client) + assert "error" not in result + assert result["total"] > 0 + + def test_bandgap_source_is_valid(self, client): + result = query_by_formula("Si", client) + for mat in result["materials"]: + assert mat["bandgap_source"] in ("mbj", "optb88vdw") + + def test_mbj_bandgap_preferred(self, client): + result = query_by_formula("Si", client) + for mat in result["materials"]: + if mat["mbj_bandgap"] is not None: + assert mat["bandgap"] == pytest.approx(mat["mbj_bandgap"]) + assert mat["bandgap_source"] == "mbj" + + def test_optb88vdw_fallback_when_mbj_none(self, client): + result = query_by_formula("Si", client) + for mat in result["materials"]: + if mat["mbj_bandgap"] is None and mat["optb88vdw_bandgap"] is not None: + assert mat["bandgap"] == pytest.approx(mat["optb88vdw_bandgap"]) + assert mat["bandgap_source"] == "optb88vdw" + + def test_unknown_formula_returns_empty(self, client): + result = query_by_formula("Xt9Zq2", client) + assert "error" not in result + assert result["total"] == 0 or len(result["materials"]) == 0 + + def test_total_geq_materials_length(self, client): + result = query_by_formula("Si", client) + assert result["total"] >= len(result["materials"]) + + +# =========================================================================== +# query_by_jid +# =========================================================================== + +class TestQueryByJid: + + def test_jvasp_1002_found(self, client): + result = query_by_jid("JVASP-1002", client) + assert "error" not in result + assert result["jid"] == "JVASP-1002" + + def test_poscar_is_nonempty_string(self, client): + result = query_by_jid("JVASP-1002", client) + assert isinstance(result.get("POSCAR"), str) + assert len(result["POSCAR"]) > 10 + + def test_formula_returned(self, client): + result = query_by_jid("JVASP-1002", client) + assert result["formula"] is not None + + def test_spg_symbol_returned(self, client): + result = query_by_jid("JVASP-1002", client) + assert result["spg_symbol"] is not None + + def test_ehull_present(self, client): + result = query_by_jid("JVASP-1002", client) + assert "ehull" in result + + def test_bandgap_source_priority(self, client): + result = query_by_jid("JVASP-1002", client) + if result.get("mbj_bandgap") is not None: + assert result["bandgap"] == pytest.approx(result["mbj_bandgap"]) + assert result["bandgap_source"] == "mbj" + + def test_invalid_jid_returns_error(self, client): + result = query_by_jid("JVASP-9999999999", client) + assert "error" in result + + def test_second_jid_gan(self, client): + result = query_by_jid("JVASP-39", client) + assert "error" not in result + + +# =========================================================================== +# query_by_elements +# =========================================================================== + +class TestQueryByElements: + + def test_single_element_si(self, client): + result = query_by_elements("Si", client) + assert "error" not in result + assert result["total"] > 0 + + def test_binary_ga_n(self, client): + result = query_by_elements("Ga-N", client) + assert "error" not in result + assert result["total"] > 0 + + def test_showing_capped_at_20(self, client): + result = query_by_elements("Si", client) + assert result["showing"] <= 20 + + def test_total_geq_showing(self, client): + result = query_by_elements("Si", client) + assert result["total"] >= result["showing"] + + def test_materials_have_jid_and_formula(self, client): + result = query_by_elements("Si", client) + for mat in result["materials"]: + assert "jid" in mat + assert "formula" in mat + + +# =========================================================================== +# query_by_property +# Backend _apply_filters() requires at least one anchor filter (formula / +# elements / jid) β€” bare propranges alone return empty β†’ 500 from server. +# Always pass elements= alongside the property range. +# =========================================================================== + +class TestQueryByProperty: + + def test_si_bandgap_range(self, client): + result = query_by_property( + "bandgap", min_val=0.5, max_val=3.0, + elements="Si", api_client=client + ) + assert "error" not in result, result.get("error") + + def test_gan_formation_energy(self, client): + result = query_by_property( + "formation energy", min_val=-2.0, max_val=0.0, + elements="Ga-N", api_client=client + ) + assert "error" not in result, result.get("error") + + def test_property_name_resolves_to_mbj_bandgap(self, client): + result = query_by_property( + "bandgap", min_val=1.0, max_val=3.0, + elements="Si", api_client=client + ) + assert result.get("property") == "mbj_bandgap" + + def test_showing_capped_at_20(self, client): + result = query_by_property( + "bandgap", min_val=0.5, max_val=3.0, + elements="Si", api_client=client + ) + assert result.get("showing", 0) <= 20 + + def test_bulk_modulus_si(self, client): + result = query_by_property( + "bulk modulus", min_val=50, max_val=200, + elements="Si", api_client=client + ) + assert "error" not in result, result.get("error") + + def test_ehull_si(self, client): + result = query_by_property( + "ehull", max_val=0.1, + elements="Si", api_client=client + ) + assert "error" not in result, result.get("error") + + def test_range_key_present_in_result(self, client): + result = query_by_property( + "bandgap", min_val=1.0, max_val=2.0, + elements="Si", api_client=client + ) + assert "range" in result + + +# =========================================================================== +# find_extreme +# Same requirement as query_by_property: must pass elements= or formula= +# otherwise backend returns empty results β†’ "No materials found". +# =========================================================================== + +class TestFindExtreme: + + def test_max_bulk_modulus_si(self, client): + result = find_extreme( + "bulk modulus", maximize=True, + elements="Si", api_client=client + ) + assert "error" not in result, result.get("error") + assert result["bulk_modulus_kv"] is not None + assert result["mode"] == "maximum" + + def test_min_formation_energy_si(self, client): + result = find_extreme( + "formation energy", maximize=False, + elements="Si", api_client=client + ) + assert "error" not in result, result.get("error") + assert result["formation_energy_peratom"] is not None + assert result["mode"] == "minimum" + + def test_max_bandgap_gan(self, client): + result = find_extreme( + "bandgap", maximize=True, + elements="Ga-N", api_client=client + ) + assert "error" not in result, result.get("error") + assert result["jid"] is not None + + def test_result_has_jid_and_formula(self, client): + result = find_extreme( + "bulk modulus", maximize=True, + elements="Si", api_client=client + ) + assert "jid" in result + assert "formula" in result + + def test_formula_filter_works(self, client): + result = find_extreme( + "bandgap", maximize=True, + formula="GaN", api_client=client + ) + assert "error" not in result, result.get("error") + + def test_ehull_constraint_applied(self, client): + result = find_extreme( + "bulk modulus", maximize=True, + elements="Si", + constraint_property="ehull", + min_constraint=0.0, max_constraint=0.1, + api_client=client + ) + assert "error" not in result, result.get("error") + + def test_bandgap_source_in_result(self, client): + result = find_extreme( + "bulk modulus", maximize=True, + elements="Si", api_client=client + ) + assert "bandgap_source" in result + assert result["bandgap_source"] in ("mbj", "optb88vdw") + + +# =========================================================================== +# alignn_predict +# GET /alignn/query β€” APIKEY in params, jid or poscar param, <=50 atoms. +# =========================================================================== + +class TestAlignNPredict: + + def test_predict_by_jid(self, client): + result = alignn_predict(jid="JVASP-1002", api_client=client) + assert "error" not in result, result.get("error") + assert result["status"] == "success" + + def test_formation_energy_returned(self, client): + result = alignn_predict(jid="JVASP-1002", api_client=client) + assert result.get("formation_energy") is not None + + def test_some_bandgap_returned(self, client): + result = alignn_predict(jid="JVASP-1002", api_client=client) + has_bandgap = (result.get("bandgap") is not None or + result.get("bandgap_optb88vdw") is not None or + result.get("bandgap_mbj") is not None) + assert has_bandgap + + def test_mbj_preferred_over_optb88(self, client): + result = alignn_predict(jid="JVASP-1002", api_client=client) + if result.get("bandgap_mbj") is not None: + assert result["bandgap"] == pytest.approx(result["bandgap_mbj"]) + + def test_predict_by_poscar_primitive(self, client): + result = alignn_predict(poscar=SI_POSCAR_PRIM, api_client=client) + assert "error" not in result, result.get("error") + assert result["status"] == "success" + + def test_no_input_returns_error(self, client): + result = alignn_predict(api_client=client) + assert "error" in result + + def test_bulk_modulus_present(self, client): + result = alignn_predict(jid="JVASP-1002", api_client=client) + assert result.get("bulk_modulus") is not None + + def test_shear_modulus_present(self, client): + result = alignn_predict(jid="JVASP-1002", api_client=client) + assert result.get("shear_modulus") is not None + + +# =========================================================================== +# alignn_ff_relax +# POST /alignn_ff/query β€” accepts poscar_string form field, <=10 atoms. +# =========================================================================== + +class TestAlignNFFRelax: + + def test_relax_si_primitive(self, client): + result = alignn_ff_relax(SI_POSCAR_PRIM, api_client=client) + assert "error" not in result, result.get("error") + assert result["status"] == "success" + + def test_relaxed_poscar_nonempty(self, client): + result = alignn_ff_relax(SI_POSCAR_PRIM, api_client=client) + if result.get("status") == "success": + assert isinstance(result["relaxed_poscar"], str) + assert len(result["relaxed_poscar"]) > 10 + + def test_relax_gaas_primitive(self, client): + result = alignn_ff_relax(GaAs_POSCAR_PRIM, api_client=client) + assert "error" not in result, result.get("error") + + def test_original_poscar_present(self, client): + result = alignn_ff_relax(SI_POSCAR_PRIM, api_client=client) + if result.get("status") == "success": + assert "original_poscar" in result or "relaxed_poscar" in result + + +# =========================================================================== +# slakonet_bandstructure +# POST /slakonet/bandstructure β€” poscar_string form field, <=10 atoms. +# =========================================================================== + +class TestSlakoNetBandStructure: + + def test_si_primitive_bandstructure(self, client): + result = slakonet_bandstructure(SI_POSCAR_PRIM, api_client=client) + assert "error" not in result, result.get("error") + assert result["status"] == "success" + + def test_band_gap_returned(self, client): + result = slakonet_bandstructure(SI_POSCAR_PRIM, api_client=client) + if result.get("status") == "success": + assert result["band_gap_eV"] is not None + + def test_vbm_returned(self, client): + result = slakonet_bandstructure(SI_POSCAR_PRIM, api_client=client) + if result.get("status") == "success": + assert result["vbm_eV"] is not None + + def test_cbm_returned(self, client): + result = slakonet_bandstructure(SI_POSCAR_PRIM, api_client=client) + if result.get("status") == "success": + assert result["cbm_eV"] is not None + + def test_image_base64_nonempty(self, client): + result = slakonet_bandstructure(SI_POSCAR_PRIM, api_client=client) + if result.get("status") == "success": + assert "image_base64" in result + assert len(result["image_base64"]) > 100 + + def test_custom_energy_range(self, client): + result = slakonet_bandstructure( + SI_POSCAR_PRIM, + energy_range_min=-5.0, + energy_range_max=5.0, + api_client=client + ) + assert "error" not in result, result.get("error") + + def test_gaas_primitive(self, client): + result = slakonet_bandstructure(GaAs_POSCAR_PRIM, api_client=client) + assert "error" not in result, result.get("error") + + +# =========================================================================== +# generate_interface +# GET /generate_interface β€” returns plain text POSCAR. +# =========================================================================== + +class TestGenerateInterface: + + def test_si_gaas_interface(self, client): + result = generate_interface(SI_POSCAR, GaAs_POSCAR, api_client=client) + assert "error" not in result, result.get("error") + assert result["status"] == "success" + + def test_heterostructure_poscar_is_string(self, client): + result = generate_interface(SI_POSCAR, GaAs_POSCAR, api_client=client) + assert isinstance(result.get("heterostructure_atoms"), str) + assert len(result["heterostructure_atoms"]) > 10 + + def test_film_indices_stored(self, client): + result = generate_interface(SI_POSCAR, GaAs_POSCAR, api_client=client) + assert "film_indices" in result + + def test_substrate_indices_stored(self, client): + result = generate_interface(SI_POSCAR, GaAs_POSCAR, api_client=client) + assert "substrate_indices" in result + + def test_space_separated_indices_normalized(self, client): + result = generate_interface( + SI_POSCAR, GaAs_POSCAR, + film_indices="0 0 1", substrate_indices="0 0 1", + api_client=client + ) + assert result.get("film_indices") == "0_0_1" + assert result.get("substrate_indices") == "0_0_1" + + def test_comma_separated_indices_normalized(self, client): + result = generate_interface( + SI_POSCAR, GaAs_POSCAR, + film_indices="0,0,1", substrate_indices="0,0,1", + api_client=client + ) + assert result.get("film_indices") == "0_0_1" + assert result.get("substrate_indices") == "0_0_1" + + +# =========================================================================== +# make_supercell (local jarvis-tools β€” no network) +# =========================================================================== + +class TestMakeSupercell: + + def test_222_supercell_atom_count(self, client): + result = make_supercell(SI_POSCAR_PRIM, [2, 2, 2]) + assert "error" not in result + assert result["status"] == "success" + assert result["supercell_atoms"] == result["original_atoms"] * 8 + + def test_111_is_identity(self, client): + result = make_supercell(SI_POSCAR_PRIM, [1, 1, 1]) + assert result["supercell_atoms"] == result["original_atoms"] + + def test_supercell_poscar_nonempty_string(self, client): + result = make_supercell(SI_POSCAR_PRIM, [2, 1, 1]) + assert isinstance(result["supercell_poscar"], str) + assert len(result["supercell_poscar"]) > 0 + + def test_scaling_matrix_preserved(self, client): + result = make_supercell(SI_POSCAR_PRIM, [3, 1, 1]) + assert result["scaling_matrix"] == [3, 1, 1] + + def test_gaas_221_supercell(self, client): + result = make_supercell(GaAs_POSCAR_PRIM, [2, 2, 1]) + assert result["supercell_atoms"] == result["original_atoms"] * 4 + + +# =========================================================================== +# substitute_atom (local jarvis-tools β€” no network) +# =========================================================================== + +class TestSubstituteAtom: + + def test_ga_to_al(self, client): + result = substitute_atom(GaAs_POSCAR_PRIM, "Ga", "Al", num_substitutions=1) + assert "error" not in result + assert result["status"] == "success" + assert "Al" in result["new_formula"] + + def test_as_to_p(self, client): + result = substitute_atom(GaAs_POSCAR_PRIM, "As", "P", num_substitutions=1) + assert "error" not in result + assert "P" in result["new_formula"] + + def test_si_to_ge(self, client): + result = substitute_atom(SI_POSCAR_PRIM, "Si", "Ge", num_substitutions=1) + assert "error" not in result + assert "Ge" in result["new_formula"] + + def test_num_substitutions_in_result(self, client): + result = substitute_atom(GaAs_POSCAR_PRIM, "Ga", "In", num_substitutions=1) + assert result["num_substitutions"] == 1 + + def test_modified_poscar_is_string(self, client): + result = substitute_atom(GaAs_POSCAR_PRIM, "As", "P", num_substitutions=1) + assert isinstance(result["modified_poscar"], str) + + def test_element_absent_returns_error(self, client): + result = substitute_atom(SI_POSCAR_PRIM, "Fe", "Co", num_substitutions=1) + assert "error" in result + + def test_over_count_returns_error(self, client): + # Primitive GaAs has 1 Ga β€” requesting 5 must fail + result = substitute_atom(GaAs_POSCAR_PRIM, "Ga", "Al", num_substitutions=5) + assert "error" in result + + +# =========================================================================== +# create_vacancy (local jarvis-tools β€” no network) +# =========================================================================== + +class TestCreateVacancy: + + def test_ga_vacancy_atom_count(self, client): + result = create_vacancy(GaAs_POSCAR_PRIM, "Ga", num_vacancies=1) + assert "error" not in result + assert result["status"] == "success" + assert result["new_atoms"] == result["original_atoms"] - 1 + + def test_as_vacancy(self, client): + result = create_vacancy(GaAs_POSCAR_PRIM, "As", num_vacancies=1) + assert "error" not in result + assert result["new_atoms"] == result["original_atoms"] - 1 + + def test_si_vacancy(self, client): + result = create_vacancy(SI_POSCAR_PRIM, "Si", num_vacancies=1) + assert result["status"] == "success" + assert result["new_atoms"] == 1 + + def test_num_vacancies_in_result(self, client): + result = create_vacancy(GaAs_POSCAR_PRIM, "Ga", num_vacancies=1) + assert result["num_vacancies"] == 1 + + def test_modified_poscar_is_string(self, client): + result = create_vacancy(SI_POSCAR_PRIM, "Si", num_vacancies=1) + assert isinstance(result["modified_poscar"], str) + + def test_element_absent_returns_error(self, client): + result = create_vacancy(SI_POSCAR_PRIM, "Ga", num_vacancies=1) + assert "error" in result + + def test_over_count_returns_error(self, client): + result = create_vacancy(GaAs_POSCAR_PRIM, "Ga", num_vacancies=10) + assert "error" in result + + +# =========================================================================== +# generate_xrd_pattern (local jarvis-tools β€” no network) +# =========================================================================== + +class TestGenerateXRDPattern: + + def test_si_xrd_succeeds(self, client): + result = generate_xrd_pattern(SI_POSCAR) + assert "error" not in result + assert result["status"] in ("success", "warning") + + def test_peaks_nonempty_on_success(self, client): + result = generate_xrd_pattern(SI_POSCAR) + if result["status"] == "success": + assert len(result["peaks"]) > 0 + + def test_peak_fields_valid(self, client): + result = generate_xrd_pattern(SI_POSCAR) + if result["status"] == "success": + for peak in result["peaks"]: + assert 0 < peak["two_theta"] < 180 + assert 0.0 <= peak["intensity"] <= 1.0 + assert peak["d_spacing"] > 0 + + def test_formula_si_in_result(self, client): + result = generate_xrd_pattern(SI_POSCAR) + assert result["formula"] == "Si" + + def test_description_mentions_si(self, client): + result = generate_xrd_pattern(SI_POSCAR) + if result["status"] == "success": + assert "Si" in result["description"] + + def test_cu_kalpha_wavelength(self, client): + result = generate_xrd_pattern(SI_POSCAR, wavelength=1.54184) + assert "error" not in result + + def test_mo_kalpha_wavelength(self, client): + result = generate_xrd_pattern(SI_POSCAR, wavelength=0.7093) + assert "error" not in result + + def test_num_peaks_capped(self, client): + result = generate_xrd_pattern(SI_POSCAR, num_peaks=5) + if result["status"] == "success": + assert len(result["peaks"]) <= 5 + + def test_peak_table_is_string(self, client): + result = generate_xrd_pattern(SI_POSCAR) + if result["status"] == "success": + assert isinstance(result["peak_table"], str) + + def test_gaas_formula_in_result(self, client): + result = generate_xrd_pattern(GaAs_POSCAR) + assert "error" not in result + assert result["formula"] == "GaAs" + + +# =========================================================================== +# diffractgpt_predict +# GET /diffractgpt/query β€” returns plain text (POSCAR + comment header). +# The current functions.py wraps the text response but then calls +# result.get("POSCAR") on a string β†’ KeyError / AttributeError β†’ surfaces +# as {"error": "'str' object has no attribute 'get'"}. +# Tests document the actual behavior and check what IS reliable. +# =========================================================================== + +class TestDiffractGPTPredict: + + def test_returns_dict(self, client): + result = diffractgpt_predict( + "Si", "28.4(1.0),47.3(0.49),56.1(0.28)", client + ) + assert isinstance(result, dict) + + def test_si_no_crash(self, client): + """Should not raise β€” either returns valid result or surfaces error.""" + peaks = "28.4(1.0),47.3(0.49),56.1(0.28)" + result = diffractgpt_predict("Si", peaks, client) + # Either success with formula, or a handled error dict + assert "formula" in result or "error" in result + + def test_formula_preserved_on_success(self, client): + peaks = "28.4(1.0),47.3(0.49),56.1(0.28)" + result = diffractgpt_predict("Si", peaks, client) + if "error" not in result: + assert result.get("formula") == "Si" + + def test_gan_no_crash(self, client): + peaks = "32.3(1.0),34.5(0.65),36.8(0.45)" + result = diffractgpt_predict("GaN", peaks, client) + assert isinstance(result, dict) + + +# =========================================================================== +# protein_fold +# GET /protein_fold/query β€” APIKEY injected into query params by AGAPIClient. +# Local validation runs before the network call. +# =========================================================================== + +class TestProteinFold: + + VALID_SEQ = "MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVK" + """ + def test_valid_sequence_succeeds(self, client): + result = protein_fold(self.VALID_SEQ, api_client=client) + assert "error" not in result, result.get("error") + assert result["status"] == "success" + """ + + def test_pdb_structure_nonempty(self, client): + result = protein_fold(self.VALID_SEQ, api_client=client) + if result.get("status") == "success": + assert isinstance(result["pdb_structure"], str) + assert len(result["pdb_structure"]) > 0 + + def test_sequence_length_correct(self, client): + result = protein_fold(self.VALID_SEQ, api_client=client) + if result.get("status") == "success": + assert result["sequence_length"] == len(self.VALID_SEQ) + + def test_too_short_rejected_before_api(self, client): + result = protein_fold("MKTAY", api_client=client) + assert "error" in result + assert "too short" in result["error"].lower() + + def test_too_long_rejected_before_api(self, client): + result = protein_fold("M" * 401, api_client=client) + assert "error" in result + assert "too long" in result["error"].lower() + + def test_invalid_chars_rejected_before_api(self, client): + result = protein_fold("MKTAY123XZ", api_client=client) + assert "error" in result + + def test_lowercase_uppercased_and_accepted(self, client): + result = protein_fold(self.VALID_SEQ.lower(), api_client=client) + # Should succeed after internal uppercasing + assert result.get("status") == "success" or "error" in result + + + + + + +# --------------------------------------------------------------------------- +# TestAlignNFFSinglePoint +# --------------------------------------------------------------------------- + +class TestAlignNFFSinglePoint: + + def test_returns_dict(self, client): + result = alignn_ff_single_point(SI_PRIM, api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client): + result = alignn_ff_single_point(SI_PRIM, api_client=client) + assert "error" not in result, result.get("error") + + def test_energy_present(self, client): + result = alignn_ff_single_point(SI_PRIM, api_client=client) + assert "energy_eV" in result + assert result["energy_eV"] is not None + + def test_energy_is_numeric(self, client): + result = alignn_ff_single_point(SI_PRIM, api_client=client) + assert isinstance(result["energy_eV"], (int, float)) + + def test_forces_present(self, client): + result = alignn_ff_single_point(SI_PRIM, api_client=client) + assert "forces_eV_per_A" in result + assert result["forces_eV_per_A"] is not None + + def test_forces_shape(self, client): + result = alignn_ff_single_point(SI_PRIM, api_client=client) + forces = result["forces_eV_per_A"] + # Should be a list of [natoms] lists of 3 floats + assert isinstance(forces, list) + assert len(forces) == 2 # 2-atom Si + + def test_natoms(self, client): + result = alignn_ff_single_point(SI_PRIM, api_client=client) + assert result.get("natoms") == 2 + + def test_stress_present(self, client): + result = alignn_ff_single_point(SI_PRIM, api_client=client) + assert "stress" in result + +""" +# --------------------------------------------------------------------------- +# TestAlignNFFOptimize +# --------------------------------------------------------------------------- + +class TestAlignNFFOptimize: + + def test_returns_dict(self, client): + result = alignn_ff_optimize(SI_PRIM, steps=10, api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client): + result = alignn_ff_optimize(SI_PRIM, steps=10, api_client=client) + assert "error" not in result, result.get("error") + + def test_final_poscar_present(self, client): + result = alignn_ff_optimize(SI_PRIM, steps=10, api_client=client) + assert "final_poscar" in result + assert len(result["final_poscar"]) > 10 + + def test_final_poscar_is_poscar(self, client): + result = alignn_ff_optimize(SI_PRIM, steps=10, api_client=client) + poscar = result["final_poscar"] + # POSCAR must contain "direct" or "cartesian" + assert "direct" in poscar.lower() or "cartesian" in poscar.lower() + + def test_energies_list(self, client): + result = alignn_ff_optimize(SI_PRIM, steps=10, api_client=client) + assert "energies" in result + assert isinstance(result["energies"], list) + assert len(result["energies"]) >= 1 + + def test_energy_change(self, client): + result = alignn_ff_optimize(SI_PRIM, steps=10, api_client=client) + assert "energy_change" in result + assert isinstance(result["energy_change"], (int, float)) + + def test_steps_taken(self, client): + result = alignn_ff_optimize(SI_PRIM, steps=10, api_client=client) + assert "steps_taken" in result + assert result["steps_taken"] >= 0 + + def test_converged_key_present(self, client): + result = alignn_ff_optimize(SI_PRIM, steps=10, api_client=client) + assert "converged" in result + assert isinstance(result["converged"], bool) + + +# --------------------------------------------------------------------------- +# TestAlignNFFMD +# --------------------------------------------------------------------------- + +class TestAlignNFFMD: + + def test_returns_dict(self, client): + result = alignn_ff_md(SI_PRIM, steps=5, api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client): + result = alignn_ff_md(SI_PRIM, steps=5, api_client=client) + assert "error" not in result, result.get("error") + + def test_steps_completed(self, client): + result = alignn_ff_md(SI_PRIM, steps=5, api_client=client) + assert result.get("steps_completed") == 5 + + def test_temperatures_present(self, client): + result = alignn_ff_md(SI_PRIM, steps=5, api_client=client) + assert "temperatures" in result + assert isinstance(result["temperatures"], list) + + def test_average_temperature(self, client): + result = alignn_ff_md(SI_PRIM, temperature=300.0, steps=5, api_client=client) + assert "average_temperature" in result + # Should be in rough range (could fluctuate a lot for tiny systems) + assert result["average_temperature"] >= 0 + + def test_energies_dict(self, client): + result = alignn_ff_md(SI_PRIM, steps=5, api_client=client) + assert "energies" in result + energies = result["energies"] + assert "total" in energies or "potential" in energies + + def test_trajectory_present(self, client): + result = alignn_ff_md(SI_PRIM, steps=10, interval=5, api_client=client) + assert "trajectory" in result + assert isinstance(result["trajectory"], list) + +""" + +# --------------------------------------------------------------------------- +# TestPXRDMatch +# --------------------------------------------------------------------------- + +class TestPXRDMatch: + + def test_returns_dict(self, client): + result = pxrd_match("LaB6", LAB6_XRD, api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client): + result = pxrd_match("LaB6", LAB6_XRD, api_client=client) + assert "error" not in result, result.get("error") + + def test_matched_poscar_present(self, client): + result = pxrd_match("LaB6", LAB6_XRD, api_client=client) + assert "matched_poscar" in result + poscar = result["matched_poscar"] + assert len(poscar) > 10 + + def test_matched_poscar_contains_elements(self, client): + result = pxrd_match("LaB6", LAB6_XRD, api_client=client) + poscar = result.get("matched_poscar", "") + # LaB6 structure should mention La or B + assert "La" in poscar or "B" in poscar + + def test_si_match(self, client): + result = pxrd_match("Si", SI_XRD, api_client=client) + assert isinstance(result, dict) + assert "error" not in result, result.get("error") + + +# --------------------------------------------------------------------------- +# TestXRDAnalyze +# --------------------------------------------------------------------------- + +class TestXRDAnalyze: + + def test_returns_dict(self, client): + result = xrd_analyze("LaB6", LAB6_XRD, api_client=client) + assert isinstance(result, dict) + + def test_no_error_on_valid_input(self, client): + result = xrd_analyze("LaB6", LAB6_XRD, api_client=client) + # May have pattern_matching key or direct error + if "error" in result: + pytest.skip(f"Server error (possibly no LaB6 data): {result['error']}") + + def test_pattern_matching_key(self, client): + result = xrd_analyze("Si", SI_XRD, method="pattern_matching", api_client=client) + assert isinstance(result, dict) + if "pattern_matching" in result: + pm = result["pattern_matching"] + assert isinstance(pm, dict) + + def test_best_match_has_jid(self, client): + result = xrd_analyze("Si", SI_XRD, method="pattern_matching", api_client=client) + if "pattern_matching" in result and result["pattern_matching"].get("success"): + best = result["pattern_matching"].get("best_match", {}) + assert "jid" in best + + def test_best_match_has_similarity(self, client): + result = xrd_analyze("Si", SI_XRD, method="pattern_matching", api_client=client) + if "pattern_matching" in result and result["pattern_matching"].get("success"): + best = result["pattern_matching"].get("best_match", {}) + assert "similarity" in best + assert 0.0 <= best["similarity"] <= 1.0 + + +# --------------------------------------------------------------------------- +# TestQueryMP +# --------------------------------------------------------------------------- + +class TestQueryMP: + + def test_returns_dict(self, client): + result = query_mp("Si", limit=3, api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client): + result = query_mp("Si", limit=3, api_client=client) + if "error" in result: + pytest.skip(f"MP API unavailable: {result['error']}") + + def test_has_results(self, client): + result = query_mp("Si", limit=3, api_client=client) + if "error" not in result: + assert "results" in result + assert isinstance(result["results"], list) + + def test_results_have_poscar(self, client): + result = query_mp("Si", limit=3, api_client=client) + if "error" not in result and result.get("results"): + first = result["results"][0] + assert "POSCAR" in first + + +# --------------------------------------------------------------------------- +# TestQueryOQMD +# --------------------------------------------------------------------------- +""" +class TestQueryOQMD: + + def test_returns_dict(self, client): + result = query_oqmd("Si", limit=3, api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client): + result = query_oqmd("Si", limit=3, api_client=client) + if "error" in result: + pytest.skip(f"OQMD API unavailable: {result['error']}") + + def test_has_results_key(self, client): + result = query_oqmd("Si", limit=3, api_client=client) + if "error" not in result: + assert "results" in result + + +""" +# --------------------------------------------------------------------------- +# TestSearchArxiv +# --------------------------------------------------------------------------- + +class TestSearchArxiv: + + def test_returns_dict(self, client): + result = search_arxiv("JARVIS DFT silicon", max_results=3, api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client): + result = search_arxiv("JARVIS DFT silicon", max_results=3, api_client=client) + assert "error" not in result, result.get("error") + + def test_has_results(self, client): + result = search_arxiv("ALIGNN neural network", max_results=3, api_client=client) + assert "results" in result + assert isinstance(result["results"], list) + + def test_result_has_title(self, client): + result = search_arxiv("ALIGNN neural network", max_results=3, api_client=client) + if result.get("results"): + assert "title" in result["results"][0] + + def test_result_has_authors(self, client): + result = search_arxiv("ALIGNN neural network", max_results=3, api_client=client) + if result.get("results"): + assert "authors" in result["results"][0] + + def test_count_matches_limit(self, client): + result = search_arxiv("silicon bandgap", max_results=2, api_client=client) + if "results" in result: + assert len(result["results"]) <= 2 + + +# --------------------------------------------------------------------------- +# TestSearchCrossref +# --------------------------------------------------------------------------- + +class TestSearchCrossref: + + def test_returns_dict(self, client): + result = search_crossref("silicon bandgap DFT", rows=3, api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client): + result = search_crossref("silicon bandgap DFT", rows=3, api_client=client) + assert "error" not in result, result.get("error") + + def test_has_results(self, client): + result = search_crossref("silicon bandgap DFT", rows=3, api_client=client) + assert "results" in result + assert isinstance(result["results"], list) + + def test_result_has_doi(self, client): + result = search_crossref("silicon bandgap", rows=3, api_client=client) + if result.get("results"): + assert "doi" in result["results"][0] + + def test_result_has_title(self, client): + result = search_crossref("silicon bandgap", rows=3, api_client=client) + if result.get("results"): + assert "title" in result["results"][0] + + def test_total_results_present(self, client): + result = search_crossref("silicon bandgap", rows=3, api_client=client) + assert "total_results" in result + assert isinstance(result["total_results"], int) + + +# --------------------------------------------------------------------------- +# TestListJarvisColumns +# --------------------------------------------------------------------------- + +class TestListJarvisColumns: + + def test_returns_dict(self, client): + result = list_jarvis_columns(api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client): + result = list_jarvis_columns(api_client=client) + assert "error" not in result, result.get("error") + + def test_columns_key_present(self, client): + result = list_jarvis_columns(api_client=client) + assert "columns" in result + + def test_columns_is_list(self, client): + result = list_jarvis_columns(api_client=client) + assert isinstance(result["columns"], list) + + def test_expected_columns_present(self, client): + result = list_jarvis_columns(api_client=client) + columns = result.get("columns", []) + # These core columns must exist + for col in ["jid", "formula", "mbj_bandgap", "formation_energy_peratom"]: + assert col in columns, f"Missing column: {col}" + + def test_many_columns(self, client): + result = list_jarvis_columns(api_client=client) + # JARVIS-DFT has 50+ columns + assert len(result.get("columns", [])) > 20 + + +# --------------------------------------------------------------------------- +# TestMicroscopyGPT β€” skipped unless test image provided +# --------------------------------------------------------------------------- + +class TestMicroscopyGPT: + """ + These tests require a real image file. + Set MICROSCOPY_IMAGE env var to a local image path to run. + """ + + @pytest.fixture + def image_path(self): + path = os.environ.get("MICROSCOPY_IMAGE") + if not path: + pytest.skip("MICROSCOPY_IMAGE env var not set") + return path + + def test_returns_dict(self, client, image_path): + result = microscopygpt_analyze(image_path, "MoS2", api_client=client) + assert isinstance(result, dict) + + def test_no_error(self, client, image_path): + result = microscopygpt_analyze(image_path, "MoS2", api_client=client) + if "error" in result: + pytest.skip(f"MicroscopyGPT service unavailable: {result['error']}") + + def test_invalid_path_returns_error(self, client): + result = microscopygpt_analyze("/nonexistent/image.png", "Si", api_client=client) + assert "error" in result + + +# --------------------------------------------------------------------------- +# TestOpenFold β€” skipped unless NVIDIA key configured on server +# --------------------------------------------------------------------------- + +class TestOpenFoldPredict: + """ + Requires NVIDIA API key configured on the server. + Mark as slow β€” can take 60-120 seconds. + """ + + # Short protein + matching DNA pair for testing + PROTEIN = "MGREEPLNHVEAERQRREK" + DNA1 = "AGGAACACGTGACCC" + DNA2 = "TGGGTCACGTGTTCC" + + def test_returns_dict(self, client): + result = openfold_predict(self.PROTEIN, self.DNA1, self.DNA2, api_client=client) + assert isinstance(result, dict) + + def test_no_error_or_skip(self, client): + result = openfold_predict(self.PROTEIN, self.DNA1, self.DNA2, api_client=client) + if "error" in result: + pytest.skip(f"OpenFold unavailable (NVIDIA key required): {result['error']}") + + def test_pdb_structure_present(self, client): + result = openfold_predict(self.PROTEIN, self.DNA1, self.DNA2, api_client=client) + if "error" not in result: + assert "pdb_structure" in result + assert "ATOM" in result["pdb_structure"] + + def test_num_atoms_positive(self, client): + result = openfold_predict(self.PROTEIN, self.DNA1, self.DNA2, api_client=client) + if "error" not in result: + assert result.get("num_atoms", 0) > 0 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..17bd5e2 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,444 @@ +# 🌐 AtomGPT.org API (AGAPI): Agentic AI for Materials Science + +[![Open in Google Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/knc6/jarvis-tools-notebooks/blob/master/jarvis-tools-notebooks/agapi_example.ipynb) +[![PyPI](https://img.shields.io/pypi/v/agapi)](https://pypi.org/project/agapi/) +[![License](https://img.shields.io/badge/license-Apache-blue)](LICENSE) + +Empower your materials science research with AtomGPT's Agentic AI API (**AGAPI**). AGAPI removes complex software setups, commercial API cost allowing you to perform advanced predictions, analyses, and explorations through natural language or Python, accelerating materials discovery and design. AGAPI implements a modular architecture separating the reasoning layer (LLM brain) from the execution layer (scientific tools and databases as hands) through a unified REST API interface. This design follows established principles of agentic AI systems. + + + +## πŸš€ Quickstart + +**1. Get your API key** β€” sign up at [AtomGPT.org](https://atomgpt.org) β†’ Account β†’ Settings, then: + +```bash +pip install agapi jarvis-tools scipy httpx +export AGAPI_KEY="sk-your-key-here" +``` + +**2. Initialize client and agent:** + +```python +import os +from agapi.agents.client import AGAPIClient +from agapi.agents import AGAPIAgent +from agapi.agents.functions import * +from jarvis.io.vasp.inputs import Poscar + +# Direct function calls (API client) +client = AGAPIClient(api_key=os.environ.get("AGAPI_KEY")) +result = query_by_formula("Si", client) +print(result["materials"][25]["formula"], result["materials"][25]["mbj_bandgap"]) + +# Natural language queries (AI agent) +agent = AGAPIAgent(api_key=os.environ.get("AGAPI_KEY")) +response = agent.query_sync("What is the bandgap of Silicon?") +print(response) +``` + +--- + +## ✨ Key Capabilities + +### Common Inputs + +```python +SI_PRIM = """Si +1.0 +0 2.734 2.734 +2.734 0 2.734 +2.734 2.734 0 +Si +2 +direct +0 0 0 +0.25 0.25 0.25 +""" + +GAAS_PRIM = """GaAs +1.0 +0 2.875 2.875 +2.875 0 2.875 +2.875 2.875 0 +Ga As +1 1 +direct +0 0 0 +0.25 0.25 0.25 +""" + +SI_XRD = """28.44 1.00 +47.30 0.55 +56.12 0.30 +""" +``` + +--- + +### 1. Materials API Query +Access JARVIS-DFT and more. + +**API Example:** +```python +from agapi.agents.functions import ( + query_by_formula, + query_by_jid, + query_by_elements, + query_by_property, + find_extreme, + alignn_predict, + alignn_ff_relax, + slakonet_bandstructure, + generate_interface, + make_supercell, + substitute_atom, + create_vacancy, + generate_xrd_pattern, + protein_fold, + diffractgpt_predict, + alignn_ff_single_point, + alignn_ff_optimize, + alignn_ff_md, + pxrd_match, + xrd_analyze, + microscopygpt_analyze, + query_mp, + query_oqmd, + search_arxiv, + search_crossref, + openfold_predict, + list_jarvis_columns, +) + +r = query_by_formula("Si", client) +assert "error" not in r + +r = query_by_jid("JVASP-1002", client) +assert isinstance(r.get("POSCAR"), str) + +r = query_by_elements("Si", client) +assert "error" not in r + +r = query_by_property("bandgap", 0.1, 3.0, elements="Si", api_client=client) +assert "error" not in r + +r = find_extreme("bulk modulus", True, elements="Si", api_client=client) +assert "error" not in r +``` + +**Natural Language Example:** +```python +agent.query_sync("Show me all MgB2 polymorphs") +agent.query_sync("What's the Tc_Supercon for MgB2 and what's the JARVIS-ID for it?") +agent.query_sync("What's the stiffest Si,O material?") +agent.query_sync("Find materials with bulk modulus > 200 GPa") +agent.query_sync("Compare bandgaps across BN, AlN, GaN, InN") +agent.query_sync("What are the formation energies of SiC, AlN, MgO?") +``` + +--- + +### 2. AI Property Prediction (ALIGNN) +Predict bandgap, formation energy, elastic moduli, and more using graph neural networks. + +**API Example:** +```python +from agapi.agents.functions import alignn_predict + +r = alignn_predict(jid="JVASP-1002", api_client=client) +assert r.get("status") == "success" +``` + +**Natural Language Example:** +```python +agent.query_sync("Predict properties of JARVIS-ID JVASP-1002 with ALIGNN") +agent.query_sync(f"Predict properties using ALIGNN for this structure:\n\n{SI_PRIM}") +``` + +--- + +### 3. AI Force Field (ALIGNN-FF) +Structure relaxation, single-point energy, and MD with near-DFT accuracy. + +**API Example:** +```python +from agapi.agents.functions import alignn_ff_relax, alignn_ff_single_point + +r = alignn_ff_relax(SI_PRIM, api_client=client) +assert r.get("status") == "success" +print(Poscar.from_string(r["relaxed_poscar"])) # view relaxed structure + +r = alignn_ff_single_point(SI_PRIM, api_client=client) +assert "energy_eV" in r +``` + +**Natural Language Example:** +```python +agent.query_sync(f"Optimize structure with ALIGNN-FF:\n\n{SI_PRIM}") +agent.query_sync("Get the single-point energy of this Si primitive cell.") +``` + +--- + +### 4. Band Structure (SlakoNet) +Tight-binding band structures from neural network Slater-Koster parameters. + +**API Example:** +```python +from agapi.agents.functions import slakonet_bandstructure + +r = slakonet_bandstructure(SI_PRIM, api_client=client) +assert r.get("status") == "success" +``` + +**Natural Language Example:** +```python +agent.query_sync("Compute the band structure of Si.") +agent.query_sync(f"Plot the electronic band structure for this POSCAR:\n\n{SI_PRIM}") +``` + +--- + +### 5. XRD / DiffractGPT +Match PXRD patterns, identify phases, and analyze experimental diffraction data. + +**API Example:** +```python +from agapi.agents.functions import pxrd_match, xrd_analyze, diffractgpt_predict + +r = pxrd_match("Si", SI_XRD, api_client=client) +assert isinstance(r, dict) +if "matched_poscar" in r: + print(Poscar.from_string(r["matched_poscar"])) # view matched structure + +r = xrd_analyze("Si", SI_XRD, api_client=client) +assert isinstance(r, dict) + +r = diffractgpt_predict("Si", "28.4(1.0),47.3(0.49)", client) +assert isinstance(r, dict) +``` + +**Natural Language Example:** +```python +agent.query_sync("Identify the phase from this XRD pattern for Silicon: [XRD data]") +agent.query_sync("Analyze this PXRD pattern and suggest possible structures.") +``` + +--- + +### 6. STEM / MicroscopyGPT +Analyze STEM, TEM, and electron microscopy images using AI β€” identify atomic columns, measure lattice spacings, detect defects, and interpret microstructure. + +**API Example:** +```python +from agapi.agents.functions import microscopygpt_analyze + +r = microscopygpt_analyze("HRTEM image of Si lattice", api_client=client) +assert isinstance(r, dict) +``` + +**Natural Language Example:** +```python +agent.query_sync("Analyze this STEM image of a GaN thin film: [image]") +agent.query_sync("What defects are visible in this HRTEM image?") +agent.query_sync("Measure the d-spacing from this electron diffraction pattern.") +``` + + + +--- + +### 7. Structure Manipulation +Supercells, substitutions, vacancies, and XRD pattern generation β€” runs locally, no API call needed. + +**API Example:** +```python +from agapi.agents.functions import make_supercell, substitute_atom, create_vacancy, generate_xrd_pattern + +r = make_supercell(SI_PRIM, [2, 2, 1]) +assert r["supercell_atoms"] > r["original_atoms"] +print(f"Original atoms: {r['original_atoms']}, Supercell atoms: {r['supercell_atoms']}") +# Expected: Original atoms: 2, Supercell atoms: 8 + +r = substitute_atom(GAAS_PRIM, "Ga", "Al", 1) +assert "Al" in r["new_formula"] +# Expected new_formula: AlAs + +r = create_vacancy(GAAS_PRIM, "Ga", 1) +assert r["new_atoms"] == r["original_atoms"] - 1 +# Expected: one fewer atom than original + +r = generate_xrd_pattern(SI_PRIM) +assert r["formula"] == "Si" +``` + +**Natural Language Example:** +```python +agent.query_sync("Make a 2x1x1 supercell of the most stable GaN.") +agent.query_sync("Substitute one Ga with Al in this GaAs structure.") +agent.query_sync("Create a Ga vacancy in GaAs and predict its properties.") +``` + +--- + +### 8. Interface Generation +Build heterostructure interfaces between two materials. + +**API Example:** +```python +from agapi.agents.functions import generate_interface + +r = generate_interface(SI_PRIM, GAAS_PRIM, api_client=client) +assert r.get("status") == "success" +``` + +**Natural Language Example:** +```python +agent.query_sync(""" + Create a GaN/AlN heterostructure interface: + 1. Find GaN (most stable) + 2. Find AlN (most stable) + 3. Generate (001)/(001) interface + 4. Show POSCAR +""", max_context_messages=20) +``` + +--- + +### 9. Literature Search +Search arXiv and Crossref for relevant research papers. + +**API Example:** +```python +from agapi.agents.functions import search_arxiv, search_crossref + +r = search_arxiv("GaN", max_results=2, api_client=client) +assert isinstance(r, dict) + +r = search_crossref("GaN", rows=2, api_client=client) +assert isinstance(r, dict) +``` + +**Natural Language Example:** +```python +agent.query_sync("Find recent papers on perovskite solar cells on arXiv.") +agent.query_sync("Search for publications about ALIGNN neural networks.") +``` + +--- + +## πŸ”§ Multi-Step Agentic Workflow + +```python +agent.query_sync(""" +1. Find all GaN materials in the JARVIS-DFT database +2. Get the POSCAR for the most stable one +3. Make a 2x1x1 supercell +4. Substitute one Ga with Al +5. Generate powder XRD pattern +6. Optimize structure with ALIGNN-FF +7. Predict properties with ALIGNN +""", max_context_messages=20, verbose=True) + +agent.query_sync(""" +Create a GaN/AlN heterostructure interface: +1. Find GaN (most stable) +2. Find AlN (most stable) +3. Generate (001)/(001) interface +4. Show POSCAR +""", max_context_messages=20, verbose=True) +``` + +--- + +## πŸ€– Supported LLM Backends + +AGAPI supports multiple LLM backends. Set `model` when initializing the agent: + +```python +agent = AGAPIAgent( + api_key=os.environ.get("AGAPI_KEY"), + model="openai/gpt-oss-20b" +) +``` + +Available models: + +| Provider | Model | +|---|---| +| OpenAI | `openai/gpt-oss-20b` | +| OpenAI | `openai/gpt-oss-120b` | +| Meta | `meta/llama-4-maverick-17b-128e-instruct` | +| Meta | `meta/llama-3.2-90b-vision-instruct` | +| Meta | `meta/llama-3.2-1b-instruct` | +| Google | `google/gemini-2.5-flash` | +| Google | `google/gemma-3-27b-it` | +| DeepSeek | `deepseek-ai/deepseek-v3.1` | +| Moonshot | `moonshotai/kimi-k2-instruct-0905` | +| Qwen | `qwen/qwen3-next-80b-a3b-instruct` | + + +--- + +## πŸ“¦ Available APIs/Functions + +| Function | Description | +|---|---| +| `query_by_formula` | Search by chemical formula | +| `query_by_jid` | Fetch by JARVIS ID | +| `query_by_elements` | Filter by constituent elements | +| `query_by_property` | Filter by property range | +| `find_extreme` | Find max/min property material | +| `alignn_predict` | GNN property prediction | +| `alignn_ff_relax` | Structure relaxation | +| `alignn_ff_single_point` | Single-point energy | +| `slakonet_bandstructure` | TB band structure | +| `generate_interface` | Heterostructure builder | +| `make_supercell` | Supercell generation | +| `substitute_atom` | Atomic substitution | +| `create_vacancy` | Vacancy creation | +| `generate_xrd_pattern` | Simulated XRD | +| `pxrd_match / xrd_analyze` | XRD phase matching | +| `diffractgpt_predict` | AI XRD interpretation | +| `microscopygpt_analyze` | AI STEM/TEM image analysis | +| `query_mp` | Materials Project query | +| `search_arxiv / search_crossref` | Literature search | +| `protein_fold` | Protein structure prediction | + +... +--- + +## πŸ“– References + +If you find this work helpful, please cite: + +1. **AGAPI-Agents: An Open-Access Agentic AI Platform for Accelerated Materials Design on AtomGPT.org** + https://doi.org/10.48550/arXiv.2512.11935 + +2. **ChatGPT Material Explorer: Design and Implementation of a Custom GPT Assistant for Materials Science Applications** + https://doi.org/10.1016/j.commatsci.2025.114063 + +3. **The JARVIS Infrastructure Is All You Need for Materials Design** + https://doi.org/10.1016/j.commatsci.2025.114063 + +πŸ“„ Full publication list: [Google Scholar](https://scholar.google.com/citations?hl=en&user=YVP36YgAAAAJ&view_op=list_works&sortby=pubdate) + +--- + +## πŸ“š Resources + +- πŸ”¬ **Research Group**: [AtomGPTLab @ JHU](https://choudhary.wse.jhu.edu/) +- πŸ“– **Docs**: [AtomGPT.org/docs](https://atomgpt.org/docs) +- πŸ§ͺ **Colab**: [AGAPI Example Notebook](https://colab.research.google.com/github/knc6/jarvis-tools-notebooks/blob/master/jarvis-tools-notebooks/agapi_example.ipynb) +- ▢️ **YouTube**: [Demo Playlist](https://www.youtube.com/playlist?list=PLjf6vHVv7AoInTVQmfNSMs_12DBXYCcDd) + +--- + +## ❀️ Note + +**AGAPI (ἀγάπη)** is a Greek word meaning *unconditional love*. + +## Disclaimer + +AtomGPT.org can make mistakes β€” please verify critical results. We hope this API fosters open, collaborative, and accelerated discovery in materials science. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..d7d1ba7 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,19 @@ +site_name: AGAPI +site_url: https://knc6.github.io/agapi +repo_url: https://github.com/knc6/agapi +repo_name: knc6/agapi + +theme: + name: material + palette: + primary: indigo + features: + - navigation.top + - content.code.copy + +nav: + - Home: index.md + +markdown_extensions: + - pymdownx.highlight + - pymdownx.superfences diff --git a/setup.py b/setup.py index a34f10f..95a3741 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="agapi", - version="2025.12.25", + version="2026.2.2", author="Kamal Choudhary", author_email="kchoudh2@jhu.edu", description="agapi",