Upload folder using huggingface_hub
Browse files- Dockerfile +30 -0
- README.md +23 -5
- app/__init__.py +4 -0
- app/main.py +207 -0
- app/model_inference.py +272 -0
- example_data/mp-1101653-34.dif +0 -0
- example_data/mp-1204129-20.dif +0 -0
- example_data/mp-706869-94.dif +0 -0
- frontend/.eslintrc.cjs +21 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +33 -0
- frontend/src/App.jsx +62 -0
- frontend/src/components/Controls.jsx +227 -0
- frontend/src/components/ExampleDataPanel.jsx +99 -0
- frontend/src/components/Header.jsx +80 -0
- frontend/src/components/LogitDrawer.jsx +135 -0
- frontend/src/components/ResultsHero.jsx +228 -0
- frontend/src/components/XRDGraph.jsx +204 -0
- frontend/src/components/ui/button.jsx +47 -0
- frontend/src/components/ui/card.jsx +60 -0
- frontend/src/components/ui/slider.jsx +22 -0
- frontend/src/components/ui/switch.jsx +23 -0
- frontend/src/context/XRDContext.jsx +637 -0
- frontend/src/index.css +23 -0
- frontend/src/main.jsx +18 -0
- frontend/src/utils/xrd-processing.js +225 -0
- frontend/src/utils/xrd-processing.test.js +197 -0
- frontend/vite.config.js +31 -0
- requirements.txt +9 -0
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
# Install Node.js for frontend build
|
| 4 |
+
RUN apt-get update && \
|
| 5 |
+
apt-get install -y --no-install-recommends curl && \
|
| 6 |
+
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
| 7 |
+
apt-get install -y --no-install-recommends nodejs && \
|
| 8 |
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Install Python dependencies
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# Build frontend
|
| 17 |
+
COPY frontend/package.json frontend/package-lock.json ./frontend/
|
| 18 |
+
RUN cd frontend && npm ci
|
| 19 |
+
|
| 20 |
+
COPY frontend/ ./frontend/
|
| 21 |
+
RUN cd frontend && npm run build
|
| 22 |
+
|
| 23 |
+
# Copy backend and example data
|
| 24 |
+
COPY app/ ./app/
|
| 25 |
+
COPY example_data/ ./example_data/
|
| 26 |
+
|
| 27 |
+
# HF Spaces expects port 7860
|
| 28 |
+
EXPOSE 7860
|
| 29 |
+
|
| 30 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,28 @@
|
|
| 1 |
---
|
| 2 |
-
title: OpenAlphaDiffract
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: OpenAlphaDiffract
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
license: bsd-3-clause
|
| 9 |
+
models:
|
| 10 |
+
- linked-liszt/OpenAlphaDiffract
|
| 11 |
+
tags:
|
| 12 |
+
- materials-science
|
| 13 |
+
- crystallography
|
| 14 |
+
- x-ray-diffraction
|
| 15 |
+
- pxrd
|
| 16 |
pinned: false
|
| 17 |
---
|
| 18 |
|
| 19 |
+
# OpenAlphaDiffract
|
| 20 |
+
|
| 21 |
+
Automated crystallographic analysis of powder X-ray diffraction (PXRD) data.
|
| 22 |
+
|
| 23 |
+
Upload a `.dif` file or use one of the built-in examples to predict crystal system,
|
| 24 |
+
space group, and lattice parameters from a PXRD pattern.
|
| 25 |
+
|
| 26 |
+
**Paper:** [arXiv:2603.23367](https://arxiv.org/abs/2603.23367)
|
| 27 |
+
**Model:** [linked-liszt/OpenAlphaDiffract](https://huggingface.co/linked-liszt/OpenAlphaDiffract)
|
| 28 |
+
**Code:** [GitHub](https://github.com/AdvancedPhotonSource/OpenAlphaDiffract)
|
app/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAlphaDiffract — XRD Analysis API"""
|
| 2 |
+
from .main import app
|
| 3 |
+
|
| 4 |
+
__all__ = ["app"]
|
app/main.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI main application for XRD Analysis Tool.
|
| 3 |
+
Serves both the API endpoints and the static React frontend.
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import FastAPI, HTTPException
|
| 6 |
+
from fastapi.staticfiles import StaticFiles
|
| 7 |
+
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
| 8 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Dict, List
|
| 11 |
+
import re
|
| 12 |
+
|
| 13 |
+
from .model_inference import XRDModelInference
|
| 14 |
+
|
| 15 |
+
# Initialize FastAPI app
|
| 16 |
+
app = FastAPI(
|
| 17 |
+
title="OpenAlphaDiffract",
|
| 18 |
+
description="Automated crystallographic analysis of powder X-ray diffraction data",
|
| 19 |
+
version="1.0.0",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# CORS — allow all origins (same-origin on HF Spaces, open for embeds)
|
| 23 |
+
app.add_middleware(
|
| 24 |
+
CORSMiddleware,
|
| 25 |
+
allow_origins=["*"],
|
| 26 |
+
allow_credentials=True,
|
| 27 |
+
allow_methods=["*"],
|
| 28 |
+
allow_headers=["*"],
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Initialize model inference
|
| 32 |
+
model_inference = XRDModelInference()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.on_event("startup")
|
| 36 |
+
async def startup_event():
|
| 37 |
+
"""Load model on startup"""
|
| 38 |
+
model_inference.load_model()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@app.get("/api/health")
|
| 42 |
+
async def health_check():
|
| 43 |
+
"""Health check endpoint"""
|
| 44 |
+
return {"status": "healthy", "model_loaded": model_inference.is_loaded()}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@app.post("/api/predict")
|
| 48 |
+
async def predict(data: dict):
|
| 49 |
+
"""
|
| 50 |
+
Predict XRD analysis from preprocessed data.
|
| 51 |
+
|
| 52 |
+
Expects JSON payload: {"x": [2theta values], "y": [intensity values], "metadata": {...}}
|
| 53 |
+
"""
|
| 54 |
+
import time
|
| 55 |
+
|
| 56 |
+
request_start = time.time()
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
metadata = data.get("metadata", {})
|
| 60 |
+
request_id = metadata.get("timestamp", "unknown")
|
| 61 |
+
filename = metadata.get("filename", "unknown")
|
| 62 |
+
analysis_count = metadata.get("analysisCount", "unknown")
|
| 63 |
+
|
| 64 |
+
x = data.get("x", [])
|
| 65 |
+
y = data.get("y", [])
|
| 66 |
+
|
| 67 |
+
if not x or not y:
|
| 68 |
+
return JSONResponse(status_code=400, content={"error": "Missing x or y data"})
|
| 69 |
+
|
| 70 |
+
if len(x) != len(y):
|
| 71 |
+
return JSONResponse(
|
| 72 |
+
status_code=400,
|
| 73 |
+
content={"error": "x and y arrays must have the same length"},
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
results = model_inference.predict(x, y)
|
| 77 |
+
|
| 78 |
+
request_time = (time.time() - request_start) * 1000
|
| 79 |
+
|
| 80 |
+
if isinstance(results, dict):
|
| 81 |
+
results["request_metadata"] = {
|
| 82 |
+
"request_id": request_id,
|
| 83 |
+
"filename": filename,
|
| 84 |
+
"analysis_count": analysis_count,
|
| 85 |
+
"processing_time_ms": request_time,
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return JSONResponse(
|
| 89 |
+
content=results,
|
| 90 |
+
headers={
|
| 91 |
+
"Cache-Control": "no-cache, no-store, must-revalidate, private",
|
| 92 |
+
"Pragma": "no-cache",
|
| 93 |
+
"Expires": "0",
|
| 94 |
+
"X-Request-ID": str(request_id),
|
| 95 |
+
},
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
return JSONResponse(
|
| 100 |
+
status_code=500,
|
| 101 |
+
content={"error": f"Prediction failed: {str(e)}"},
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
# Example data endpoints
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
EXAMPLE_DATA_DIR = Path(__file__).parent.parent / "example_data"
|
| 109 |
+
|
| 110 |
+
CRYSTAL_SYSTEM_NAMES = {
|
| 111 |
+
"1": "Triclinic",
|
| 112 |
+
"2": "Monoclinic",
|
| 113 |
+
"3": "Orthorhombic",
|
| 114 |
+
"4": "Tetragonal",
|
| 115 |
+
"5": "Trigonal",
|
| 116 |
+
"6": "Hexagonal",
|
| 117 |
+
"7": "Cubic",
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _parse_example_metadata(filepath: Path) -> dict:
|
| 122 |
+
"""Extract metadata from the header lines of a .dif file."""
|
| 123 |
+
meta = {
|
| 124 |
+
"filename": filepath.name,
|
| 125 |
+
"material_id": None,
|
| 126 |
+
"crystal_system": None,
|
| 127 |
+
"crystal_system_name": None,
|
| 128 |
+
"space_group": None,
|
| 129 |
+
"wavelength": None,
|
| 130 |
+
}
|
| 131 |
+
with open(filepath, "r") as f:
|
| 132 |
+
for line in f:
|
| 133 |
+
line = line.strip()
|
| 134 |
+
if (
|
| 135 |
+
line
|
| 136 |
+
and not line.startswith("#")
|
| 137 |
+
and not line.startswith("CELL")
|
| 138 |
+
and not line.startswith("SPACE")
|
| 139 |
+
and not line.lower().startswith("wavelength")
|
| 140 |
+
):
|
| 141 |
+
break
|
| 142 |
+
|
| 143 |
+
if m := re.search(r"Material ID:\s*(\S+)", line):
|
| 144 |
+
meta["material_id"] = m.group(1)
|
| 145 |
+
if m := re.search(r"Crystal System:\s*(\d+)", line):
|
| 146 |
+
num = m.group(1)
|
| 147 |
+
meta["crystal_system"] = num
|
| 148 |
+
meta["crystal_system_name"] = CRYSTAL_SYSTEM_NAMES.get(
|
| 149 |
+
num, f"Unknown ({num})"
|
| 150 |
+
)
|
| 151 |
+
if m := re.search(r"SPACE GROUP:\s*(\d+)", line):
|
| 152 |
+
meta["space_group"] = m.group(1)
|
| 153 |
+
if m := re.search(r"wavelength:\s*([\d.]+)", line, re.IGNORECASE):
|
| 154 |
+
meta["wavelength"] = m.group(1)
|
| 155 |
+
return meta
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@app.get("/api/examples")
|
| 159 |
+
async def list_examples():
|
| 160 |
+
"""List available example data files with metadata."""
|
| 161 |
+
if not EXAMPLE_DATA_DIR.exists():
|
| 162 |
+
return []
|
| 163 |
+
|
| 164 |
+
examples = []
|
| 165 |
+
for fp in sorted(EXAMPLE_DATA_DIR.glob("*.dif")):
|
| 166 |
+
examples.append(_parse_example_metadata(fp))
|
| 167 |
+
return examples
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@app.get("/api/examples/{filename}")
|
| 171 |
+
async def get_example(filename: str):
|
| 172 |
+
"""Return the raw text content of an example data file."""
|
| 173 |
+
if "/" in filename or "\\" in filename or ".." in filename:
|
| 174 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 175 |
+
|
| 176 |
+
filepath = EXAMPLE_DATA_DIR / filename
|
| 177 |
+
if not filepath.exists() or not filepath.is_file():
|
| 178 |
+
raise HTTPException(status_code=404, detail="Example file not found")
|
| 179 |
+
|
| 180 |
+
return PlainTextResponse(filepath.read_text())
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
# Static files and SPA support
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
|
| 187 |
+
|
| 188 |
+
if frontend_dist.exists():
|
| 189 |
+
app.mount(
|
| 190 |
+
"/assets",
|
| 191 |
+
StaticFiles(directory=str(frontend_dist / "assets")),
|
| 192 |
+
name="assets",
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
@app.get("/{path:path}")
|
| 196 |
+
async def serve_spa(path: str):
|
| 197 |
+
"""Serve React SPA"""
|
| 198 |
+
file_path = frontend_dist / path
|
| 199 |
+
if file_path.is_file():
|
| 200 |
+
return FileResponse(file_path)
|
| 201 |
+
return FileResponse(frontend_dist / "index.html")
|
| 202 |
+
|
| 203 |
+
else:
|
| 204 |
+
|
| 205 |
+
@app.get("/")
|
| 206 |
+
async def root():
|
| 207 |
+
return {"message": "Frontend not built. Run 'npm run build' in frontend/"}
|
app/model_inference.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model inference logic for XRD pattern analysis.
|
| 3 |
+
Loads the pretrained model from HuggingFace Hub and runs predictions.
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import spglib
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class XRDModelInference:
|
| 15 |
+
"""Handles loading and inference for the XRD analysis model"""
|
| 16 |
+
|
| 17 |
+
# Build a lookup table mapping space group number (1-230) to the
|
| 18 |
+
# corresponding Hall number. spglib.get_spacegroup_type() is indexed
|
| 19 |
+
# by Hall number (1-530), NOT by space group number. We pick the
|
| 20 |
+
# first (standard-setting) Hall number for each space group.
|
| 21 |
+
_sg_to_hall: Dict[int, int] = {}
|
| 22 |
+
for _hall in range(1, 531):
|
| 23 |
+
_sg_type = spglib.get_spacegroup_type(_hall)
|
| 24 |
+
_sg_num = _sg_type.number if hasattr(_sg_type, "number") else _sg_type["number"]
|
| 25 |
+
if _sg_num not in _sg_to_hall:
|
| 26 |
+
_sg_to_hall[_sg_num] = _hall
|
| 27 |
+
|
| 28 |
+
HF_REPO_ID = "linked-liszt/OpenAlphaDiffract"
|
| 29 |
+
|
| 30 |
+
def __init__(self):
|
| 31 |
+
self.model = None
|
| 32 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 33 |
+
|
| 34 |
+
def is_loaded(self) -> bool:
|
| 35 |
+
"""Check if model is loaded"""
|
| 36 |
+
return self.model is not None
|
| 37 |
+
|
| 38 |
+
def load_model(self):
|
| 39 |
+
"""Download and load the pretrained model from HuggingFace Hub."""
|
| 40 |
+
try:
|
| 41 |
+
from huggingface_hub import snapshot_download
|
| 42 |
+
|
| 43 |
+
print(f"Downloading model from {self.HF_REPO_ID}...")
|
| 44 |
+
model_dir = snapshot_download(self.HF_REPO_ID)
|
| 45 |
+
print(f"Model downloaded to {model_dir}")
|
| 46 |
+
|
| 47 |
+
# Import the pure-PyTorch model class from the downloaded repo
|
| 48 |
+
sys.path.insert(0, model_dir)
|
| 49 |
+
from model import AlphaDiffract
|
| 50 |
+
|
| 51 |
+
self.model = AlphaDiffract.from_pretrained(
|
| 52 |
+
model_dir, device=str(self.device)
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
print(f"Model loaded successfully on {self.device}")
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
print(f"Error loading model: {e}")
|
| 59 |
+
import traceback
|
| 60 |
+
traceback.print_exc()
|
| 61 |
+
self.model = None
|
| 62 |
+
|
| 63 |
+
def preprocess_data(self, x: List[float], y: List[float]) -> torch.Tensor:
|
| 64 |
+
"""
|
| 65 |
+
Preprocess XRD data for model input.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
x: 2theta values
|
| 69 |
+
y: Intensity values
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Preprocessed tensor ready for model input
|
| 73 |
+
"""
|
| 74 |
+
y_array = np.array(y, dtype=np.float32)
|
| 75 |
+
|
| 76 |
+
# Floor at zero (remove any negative values)
|
| 77 |
+
y_array = np.maximum(y_array, 0.0)
|
| 78 |
+
|
| 79 |
+
# Rescale intensity values to [0, 100] range (matching training preprocessing)
|
| 80 |
+
y_min = np.min(y_array)
|
| 81 |
+
y_max = np.max(y_array)
|
| 82 |
+
|
| 83 |
+
if y_max - y_min < 1e-10:
|
| 84 |
+
y_scaled = np.zeros_like(y_array, dtype=np.float32)
|
| 85 |
+
else:
|
| 86 |
+
y_normalized = (y_array - y_min) / (y_max - y_min)
|
| 87 |
+
y_scaled = y_normalized * 100.0
|
| 88 |
+
|
| 89 |
+
tensor = torch.from_numpy(y_scaled).unsqueeze(0)
|
| 90 |
+
return tensor.to(self.device)
|
| 91 |
+
|
| 92 |
+
def predict(self, x: List[float], y: List[float]) -> Dict:
|
| 93 |
+
"""
|
| 94 |
+
Run inference on XRD data.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
x: 2theta values
|
| 98 |
+
y: Intensity values
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
Dictionary with predictions and confidence scores
|
| 102 |
+
"""
|
| 103 |
+
if self.model is None:
|
| 104 |
+
return {
|
| 105 |
+
"status": "error",
|
| 106 |
+
"error": "Model not loaded.",
|
| 107 |
+
"http_status": 503,
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
input_tensor = self.preprocess_data(x, y)
|
| 112 |
+
|
| 113 |
+
with torch.no_grad():
|
| 114 |
+
output = self.model(input_tensor)
|
| 115 |
+
|
| 116 |
+
processed = self._process_model_output(output)
|
| 117 |
+
overall_confidence = self._compute_overall_confidence(processed)
|
| 118 |
+
|
| 119 |
+
predictions = {
|
| 120 |
+
"status": "success",
|
| 121 |
+
"predictions": processed,
|
| 122 |
+
"model_info": {
|
| 123 |
+
"type": "AlphaDiffract",
|
| 124 |
+
"device": str(self.device),
|
| 125 |
+
},
|
| 126 |
+
}
|
| 127 |
+
if overall_confidence is not None:
|
| 128 |
+
predictions["confidence"] = overall_confidence
|
| 129 |
+
|
| 130 |
+
return predictions
|
| 131 |
+
|
| 132 |
+
except Exception as e:
|
| 133 |
+
return {
|
| 134 |
+
"status": "error",
|
| 135 |
+
"error": str(e),
|
| 136 |
+
"http_status": 500,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
def _process_model_output(self, output) -> Dict:
|
| 140 |
+
"""Process raw model output into readable predictions"""
|
| 141 |
+
if isinstance(output, dict):
|
| 142 |
+
predictions = []
|
| 143 |
+
|
| 144 |
+
# Crystal System prediction (7 classes)
|
| 145 |
+
if "cs_logits" in output:
|
| 146 |
+
cs_logits = output["cs_logits"].cpu()
|
| 147 |
+
cs_probs = torch.softmax(cs_logits, dim=-1)
|
| 148 |
+
cs_prob, cs_idx = torch.max(cs_probs, dim=-1)
|
| 149 |
+
|
| 150 |
+
cs_names = [
|
| 151 |
+
"Triclinic", "Monoclinic", "Orthorhombic", "Tetragonal",
|
| 152 |
+
"Trigonal", "Hexagonal", "Cubic",
|
| 153 |
+
]
|
| 154 |
+
|
| 155 |
+
cs_all_probs = [
|
| 156 |
+
{
|
| 157 |
+
"class_name": cs_names[i],
|
| 158 |
+
"probability": float(cs_probs[0, i].item()),
|
| 159 |
+
}
|
| 160 |
+
for i in range(len(cs_names))
|
| 161 |
+
]
|
| 162 |
+
cs_all_probs.sort(key=lambda x: x["probability"], reverse=True)
|
| 163 |
+
|
| 164 |
+
predictions.append({
|
| 165 |
+
"phase": "Crystal System",
|
| 166 |
+
"predicted_class": cs_names[cs_idx.item()],
|
| 167 |
+
"confidence": float(cs_prob.item()),
|
| 168 |
+
"all_probabilities": cs_all_probs,
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
# Space Group prediction (230 classes)
|
| 172 |
+
if "sg_logits" in output:
|
| 173 |
+
sg_logits = output["sg_logits"].cpu()
|
| 174 |
+
sg_probs = torch.softmax(sg_logits, dim=-1)
|
| 175 |
+
sg_prob, sg_idx = torch.max(sg_probs, dim=-1)
|
| 176 |
+
|
| 177 |
+
sg_number = sg_idx.item() + 1
|
| 178 |
+
|
| 179 |
+
top_k = min(10, sg_probs.shape[-1])
|
| 180 |
+
top_probs, top_indices = torch.topk(sg_probs[0], top_k)
|
| 181 |
+
|
| 182 |
+
sg_top_probs = [
|
| 183 |
+
{
|
| 184 |
+
"space_group_number": int(idx.item()) + 1,
|
| 185 |
+
"space_group_symbol": self._get_space_group_symbol(int(idx.item()) + 1),
|
| 186 |
+
"probability": float(prob.item()),
|
| 187 |
+
}
|
| 188 |
+
for prob, idx in zip(top_probs, top_indices)
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
predictions.append({
|
| 192 |
+
"phase": "Space Group",
|
| 193 |
+
"predicted_class": f"#{sg_number}",
|
| 194 |
+
"space_group_symbol": self._get_space_group_symbol(sg_number),
|
| 195 |
+
"confidence": float(sg_prob.item()),
|
| 196 |
+
"top_probabilities": sg_top_probs,
|
| 197 |
+
})
|
| 198 |
+
|
| 199 |
+
# Lattice Parameters
|
| 200 |
+
if "lp" in output:
|
| 201 |
+
lp_raw = output["lp"].cpu()
|
| 202 |
+
if lp_raw.shape[0] == 1:
|
| 203 |
+
lp = lp_raw[0].numpy()
|
| 204 |
+
else:
|
| 205 |
+
lp = lp_raw.squeeze().numpy()
|
| 206 |
+
|
| 207 |
+
lp_labels = ["a", "b", "c", "\u03b1", "\u03b2", "\u03b3"]
|
| 208 |
+
|
| 209 |
+
predictions.append({
|
| 210 |
+
"phase": "Lattice Parameters",
|
| 211 |
+
"lattice_params": {
|
| 212 |
+
label: float(val) for label, val in zip(lp_labels, lp)
|
| 213 |
+
},
|
| 214 |
+
"is_lattice": True,
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
"phase_predictions": predictions,
|
| 219 |
+
"intensity_profile": [],
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
elif isinstance(output, torch.Tensor):
|
| 223 |
+
probs = output.cpu().numpy()
|
| 224 |
+
confidence = None
|
| 225 |
+
if output.ndim >= 1 and output.shape[-1] > 1:
|
| 226 |
+
prob_tensor = torch.softmax(output, dim=-1)
|
| 227 |
+
confidence = float(prob_tensor.max().item())
|
| 228 |
+
|
| 229 |
+
predictions = [{"phase": "Predicted Phase", "details": f"Output shape: {probs.shape}"}]
|
| 230 |
+
if confidence is not None:
|
| 231 |
+
predictions[0]["confidence"] = confidence
|
| 232 |
+
|
| 233 |
+
return {
|
| 234 |
+
"phase_predictions": predictions,
|
| 235 |
+
"intensity_profile": probs.tolist() if len(probs.shape) == 1 else [],
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
return {"phase_predictions": [], "intensity_profile": []}
|
| 239 |
+
|
| 240 |
+
def _get_space_group_symbol(self, sg_number: int) -> str:
|
| 241 |
+
"""Get space group symbol from number using spglib."""
|
| 242 |
+
if sg_number < 1 or sg_number > 230:
|
| 243 |
+
return f"SG{sg_number}"
|
| 244 |
+
try:
|
| 245 |
+
hall_number = self._sg_to_hall.get(sg_number)
|
| 246 |
+
if hall_number is None:
|
| 247 |
+
return f"SG{sg_number}"
|
| 248 |
+
sg_type = spglib.get_spacegroup_type(hall_number)
|
| 249 |
+
if sg_type is not None:
|
| 250 |
+
symbol = (
|
| 251 |
+
sg_type.international_short
|
| 252 |
+
if hasattr(sg_type, "international_short")
|
| 253 |
+
else sg_type["international_short"]
|
| 254 |
+
)
|
| 255 |
+
return symbol
|
| 256 |
+
return f"SG{sg_number}"
|
| 257 |
+
except Exception:
|
| 258 |
+
return f"SG{sg_number}"
|
| 259 |
+
|
| 260 |
+
def _compute_overall_confidence(self, processed: Dict) -> Optional[float]:
|
| 261 |
+
"""Compute overall confidence from available per-phase confidences."""
|
| 262 |
+
phase_predictions = (
|
| 263 |
+
processed.get("phase_predictions", []) if isinstance(processed, dict) else []
|
| 264 |
+
)
|
| 265 |
+
confidences = [
|
| 266 |
+
float(p["confidence"])
|
| 267 |
+
for p in phase_predictions
|
| 268 |
+
if isinstance(p, dict) and "confidence" in p and p["confidence"] is not None
|
| 269 |
+
]
|
| 270 |
+
if not confidences:
|
| 271 |
+
return None
|
| 272 |
+
return float(np.mean(confidences))
|
example_data/mp-1101653-34.dif
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
example_data/mp-1204129-20.dif
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
example_data/mp-706869-94.dif
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/.eslintrc.cjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
root: true,
|
| 3 |
+
env: { browser: true, es2020: true },
|
| 4 |
+
extends: [
|
| 5 |
+
'eslint:recommended',
|
| 6 |
+
'plugin:react/recommended',
|
| 7 |
+
'plugin:react/jsx-runtime',
|
| 8 |
+
'plugin:react-hooks/recommended',
|
| 9 |
+
],
|
| 10 |
+
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
| 11 |
+
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
| 12 |
+
settings: { react: { version: '18.2' } },
|
| 13 |
+
plugins: ['react-refresh'],
|
| 14 |
+
rules: {
|
| 15 |
+
'react-refresh/only-export-components': [
|
| 16 |
+
'warn',
|
| 17 |
+
{ allowConstantExport: true },
|
| 18 |
+
],
|
| 19 |
+
'react/prop-types': 'off',
|
| 20 |
+
},
|
| 21 |
+
}
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>XRD Analysis Tool</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "xrd-analysis-frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"react": "^19.2.3",
|
| 14 |
+
"react-dom": "^19.2.3",
|
| 15 |
+
"@mantine/core": "^8.3.10",
|
| 16 |
+
"@mantine/hooks": "^8.3.10",
|
| 17 |
+
"@tabler/icons-react": "^3.36.0",
|
| 18 |
+
"react-plotly.js": "^2.6.0",
|
| 19 |
+
"plotly.js": "^3.3.1",
|
| 20 |
+
"ml-savitzky-golay": "^5.0.0",
|
| 21 |
+
"mathjs": "^15.1.0"
|
| 22 |
+
},
|
| 23 |
+
"devDependencies": {
|
| 24 |
+
"@types/react": "^19.2.7",
|
| 25 |
+
"@types/react-dom": "^19.2.3",
|
| 26 |
+
"@vitejs/plugin-react": "^5.1.2",
|
| 27 |
+
"vite": "^7.3.0",
|
| 28 |
+
"eslint": "^9.39.2",
|
| 29 |
+
"eslint-plugin-react": "^7.33.2",
|
| 30 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 31 |
+
"eslint-plugin-react-refresh": "^0.4.26"
|
| 32 |
+
}
|
| 33 |
+
}
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import { Box, Paper, Stack } from '@mantine/core'
|
| 3 |
+
import { XRDProvider } from './context/XRDContext'
|
| 4 |
+
import Header from './components/Header'
|
| 5 |
+
import ResultsHero from './components/ResultsHero'
|
| 6 |
+
import XRDGraph from './components/XRDGraph'
|
| 7 |
+
import Controls from './components/Controls'
|
| 8 |
+
import LogitDrawer from './components/LogitDrawer'
|
| 9 |
+
|
| 10 |
+
function App() {
|
| 11 |
+
return (
|
| 12 |
+
<XRDProvider>
|
| 13 |
+
<Box style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
| 14 |
+
{/* Header - Full Width */}
|
| 15 |
+
<Header />
|
| 16 |
+
|
| 17 |
+
{/* Main Content Area - T-Shape Layout */}
|
| 18 |
+
<Box style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
| 19 |
+
{/* Sidebar - Controls Only */}
|
| 20 |
+
<Box
|
| 21 |
+
style={{
|
| 22 |
+
width: '350px',
|
| 23 |
+
flexShrink: 0,
|
| 24 |
+
borderRight: '1px solid #e9ecef',
|
| 25 |
+
overflowY: 'auto',
|
| 26 |
+
backgroundColor: 'white',
|
| 27 |
+
}}
|
| 28 |
+
>
|
| 29 |
+
<Box p="lg" style={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
|
| 30 |
+
<Controls />
|
| 31 |
+
</Box>
|
| 32 |
+
</Box>
|
| 33 |
+
|
| 34 |
+
{/* Main Panel - Results + Visualization */}
|
| 35 |
+
<Box
|
| 36 |
+
style={{
|
| 37 |
+
flex: 1,
|
| 38 |
+
overflowY: 'auto',
|
| 39 |
+
background: 'linear-gradient(180deg, #f8f9fa 0%, #f1f3f5 100%)',
|
| 40 |
+
padding: '2rem',
|
| 41 |
+
}}
|
| 42 |
+
>
|
| 43 |
+
<Stack gap="xl">
|
| 44 |
+
{/* Results Hero Section */}
|
| 45 |
+
<ResultsHero />
|
| 46 |
+
|
| 47 |
+
{/* Visualization Section */}
|
| 48 |
+
<Paper shadow="lg" p="lg" withBorder radius="md" style={{ backgroundColor: 'white' }}>
|
| 49 |
+
<XRDGraph />
|
| 50 |
+
</Paper>
|
| 51 |
+
</Stack>
|
| 52 |
+
</Box>
|
| 53 |
+
</Box>
|
| 54 |
+
|
| 55 |
+
{/* Logit Inspector Drawer */}
|
| 56 |
+
<LogitDrawer />
|
| 57 |
+
</Box>
|
| 58 |
+
</XRDProvider>
|
| 59 |
+
)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export default App
|
frontend/src/components/Controls.jsx
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useRef, useState } from 'react'
|
| 2 |
+
import {
|
| 3 |
+
Title,
|
| 4 |
+
Text,
|
| 5 |
+
Button,
|
| 6 |
+
Anchor,
|
| 7 |
+
Slider,
|
| 8 |
+
Switch,
|
| 9 |
+
Divider,
|
| 10 |
+
Stack,
|
| 11 |
+
Box,
|
| 12 |
+
Loader,
|
| 13 |
+
Select,
|
| 14 |
+
NumberInput,
|
| 15 |
+
Badge,
|
| 16 |
+
Alert,
|
| 17 |
+
Group,
|
| 18 |
+
} from '@mantine/core'
|
| 19 |
+
import { IconCloudUpload, IconChartBar, IconAlertCircle } from '@tabler/icons-react'
|
| 20 |
+
import { useXRD } from '../context/XRDContext'
|
| 21 |
+
import ExampleDataPanel from './ExampleDataPanel'
|
| 22 |
+
|
| 23 |
+
const Controls = () => {
|
| 24 |
+
const {
|
| 25 |
+
rawData,
|
| 26 |
+
isLoading,
|
| 27 |
+
detectedWavelength,
|
| 28 |
+
userWavelength,
|
| 29 |
+
setUserWavelength,
|
| 30 |
+
wavelengthSource,
|
| 31 |
+
dataWarnings,
|
| 32 |
+
baselineCorrection,
|
| 33 |
+
setBaselineCorrection,
|
| 34 |
+
interpolationEnabled,
|
| 35 |
+
setInterpolationEnabled,
|
| 36 |
+
scalingEnabled,
|
| 37 |
+
setScalingEnabled,
|
| 38 |
+
interpolationStrategy,
|
| 39 |
+
setInterpolationStrategy,
|
| 40 |
+
handleFileUpload,
|
| 41 |
+
runInference,
|
| 42 |
+
MODEL_WAVELENGTH,
|
| 43 |
+
MODEL_MIN_2THETA,
|
| 44 |
+
MODEL_MAX_2THETA,
|
| 45 |
+
} = useXRD()
|
| 46 |
+
|
| 47 |
+
const fileInputRef = useRef(null)
|
| 48 |
+
const [showExamples, setShowExamples] = useState(true)
|
| 49 |
+
|
| 50 |
+
const handleFileChange = async (event) => {
|
| 51 |
+
const file = event.target.files?.[0]
|
| 52 |
+
if (file) {
|
| 53 |
+
const success = await handleFileUpload(file)
|
| 54 |
+
if (success) setShowExamples(false)
|
| 55 |
+
// Reset file input so the same file can be uploaded again
|
| 56 |
+
if (fileInputRef.current) {
|
| 57 |
+
fileInputRef.current.value = ''
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const handleUploadClick = () => {
|
| 63 |
+
fileInputRef.current?.click()
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<Stack gap="md">
|
| 68 |
+
{/* File Upload */}
|
| 69 |
+
<Box>
|
| 70 |
+
<input
|
| 71 |
+
ref={fileInputRef}
|
| 72 |
+
type="file"
|
| 73 |
+
accept=".xy,.csv,.txt,.cif,.dif"
|
| 74 |
+
onChange={handleFileChange}
|
| 75 |
+
style={{ display: 'none' }}
|
| 76 |
+
/>
|
| 77 |
+
<Button
|
| 78 |
+
fullWidth
|
| 79 |
+
leftSection={<IconCloudUpload size={20} />}
|
| 80 |
+
onClick={handleUploadClick}
|
| 81 |
+
size="md"
|
| 82 |
+
>
|
| 83 |
+
Upload XRD Data
|
| 84 |
+
</Button>
|
| 85 |
+
<Group justify="space-between" mt="xs">
|
| 86 |
+
<Text size="sm" c="dimmed">
|
| 87 |
+
Formats: .xy, .cif, .dif
|
| 88 |
+
</Text>
|
| 89 |
+
<Anchor
|
| 90 |
+
size="sm"
|
| 91 |
+
component="button"
|
| 92 |
+
type="button"
|
| 93 |
+
onClick={() => setShowExamples((v) => !v)}
|
| 94 |
+
>
|
| 95 |
+
{showExamples ? 'Hide samples' : 'Try samples'}
|
| 96 |
+
</Anchor>
|
| 97 |
+
</Group>
|
| 98 |
+
</Box>
|
| 99 |
+
|
| 100 |
+
{showExamples && <ExampleDataPanel onSelect={() => setShowExamples(false)} />}
|
| 101 |
+
|
| 102 |
+
{rawData && (
|
| 103 |
+
<>
|
| 104 |
+
<Divider />
|
| 105 |
+
|
| 106 |
+
{/* Data Info */}
|
| 107 |
+
<Box p="md" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
| 108 |
+
<Stack gap="xs">
|
| 109 |
+
<Text size="sm" c="dimmed">
|
| 110 |
+
Data Points: {rawData.x.length}
|
| 111 |
+
</Text>
|
| 112 |
+
<Text size="sm" c="dimmed">
|
| 113 |
+
Range: {Math.min(...rawData.x).toFixed(2)}° - {Math.max(...rawData.x).toFixed(2)}°
|
| 114 |
+
</Text>
|
| 115 |
+
</Stack>
|
| 116 |
+
</Box>
|
| 117 |
+
|
| 118 |
+
{/* Warnings */}
|
| 119 |
+
{dataWarnings.length > 0 && (
|
| 120 |
+
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light">
|
| 121 |
+
<Stack gap="xs">
|
| 122 |
+
{dataWarnings.map((warning, idx) => (
|
| 123 |
+
<Text key={idx} size="xs">{warning}</Text>
|
| 124 |
+
))}
|
| 125 |
+
</Stack>
|
| 126 |
+
</Alert>
|
| 127 |
+
)}
|
| 128 |
+
|
| 129 |
+
<Divider />
|
| 130 |
+
|
| 131 |
+
{/* Wavelength Configuration */}
|
| 132 |
+
<Title order={4}>Wavelength</Title>
|
| 133 |
+
|
| 134 |
+
{detectedWavelength && (
|
| 135 |
+
<Badge color="blue" variant="light" size="sm" mb="xs">
|
| 136 |
+
Detected: {detectedWavelength.toFixed(4)} Å
|
| 137 |
+
</Badge>
|
| 138 |
+
)}
|
| 139 |
+
|
| 140 |
+
<NumberInput
|
| 141 |
+
label="Wavelength (Å)"
|
| 142 |
+
description="Cu Kα=1.5406, Mo Kα=0.7107, Synch=0.6199"
|
| 143 |
+
value={userWavelength}
|
| 144 |
+
onChange={(value) => setUserWavelength(value || MODEL_WAVELENGTH)}
|
| 145 |
+
min={0.1}
|
| 146 |
+
max={3.0}
|
| 147 |
+
step={0.0001}
|
| 148 |
+
precision={4}
|
| 149 |
+
size="sm"
|
| 150 |
+
decimalScale={4}
|
| 151 |
+
/>
|
| 152 |
+
|
| 153 |
+
<Group gap="xs" mt="xs">
|
| 154 |
+
<Button size="xs" variant="light" onClick={() => setUserWavelength(1.5406)}>Cu Kα</Button>
|
| 155 |
+
<Button size="xs" variant="light" onClick={() => setUserWavelength(0.7107)}>Mo Kα</Button>
|
| 156 |
+
<Button size="xs" variant="light" onClick={() => setUserWavelength(0.6199)}>Synch</Button>
|
| 157 |
+
</Group>
|
| 158 |
+
|
| 159 |
+
<Box mt="md" p="md" style={{ backgroundColor: '#e7f5ff', borderRadius: '8px', border: '1px solid #91a7ff' }}>
|
| 160 |
+
<Text size="sm" fw={600} c="#1864ab" mb={4}>
|
| 161 |
+
Training Data Specs:
|
| 162 |
+
</Text>
|
| 163 |
+
<Text size="sm" c="#364fc7" style={{ lineHeight: 1.6 }}>
|
| 164 |
+
• λ: {MODEL_WAVELENGTH} Å (20 keV)<br/>
|
| 165 |
+
• 2θ: {MODEL_MIN_2THETA}°-{MODEL_MAX_2THETA}° (8192 pts)<br/>
|
| 166 |
+
• Intensity: 0-100 scaled
|
| 167 |
+
</Text>
|
| 168 |
+
</Box>
|
| 169 |
+
|
| 170 |
+
<Divider />
|
| 171 |
+
|
| 172 |
+
{/* Preprocessing Controls */}
|
| 173 |
+
<Title order={4}>Preprocessing</Title>
|
| 174 |
+
|
| 175 |
+
<Switch
|
| 176 |
+
label="Baseline Correction"
|
| 177 |
+
checked={baselineCorrection}
|
| 178 |
+
onChange={(event) => setBaselineCorrection(event.currentTarget.checked)}
|
| 179 |
+
/>
|
| 180 |
+
|
| 181 |
+
<Switch
|
| 182 |
+
label="Signal Scaling (0-100)"
|
| 183 |
+
description="Normalize to model input range"
|
| 184 |
+
checked={scalingEnabled}
|
| 185 |
+
onChange={(event) => setScalingEnabled(event.currentTarget.checked)}
|
| 186 |
+
/>
|
| 187 |
+
|
| 188 |
+
<Select
|
| 189 |
+
label="Interpolation Strategy"
|
| 190 |
+
description="Resampling method for 8192 points"
|
| 191 |
+
value={interpolationStrategy}
|
| 192 |
+
onChange={(value) => {
|
| 193 |
+
if (value) setInterpolationStrategy(value)
|
| 194 |
+
}}
|
| 195 |
+
data={[
|
| 196 |
+
{ value: 'linear', label: 'Linear' },
|
| 197 |
+
{ value: 'cubic', label: 'Cubic Spline' },
|
| 198 |
+
]}
|
| 199 |
+
size="sm"
|
| 200 |
+
allowDeselect={false}
|
| 201 |
+
/>
|
| 202 |
+
|
| 203 |
+
<Divider />
|
| 204 |
+
</>
|
| 205 |
+
)}
|
| 206 |
+
|
| 207 |
+
{/* Analysis Button - Always visible when data loaded */}
|
| 208 |
+
{rawData && (
|
| 209 |
+
<Button
|
| 210 |
+
fullWidth
|
| 211 |
+
color="violet"
|
| 212 |
+
leftSection={isLoading ? <Loader size={18} color="white" /> : <IconChartBar size={20} />}
|
| 213 |
+
onClick={runInference}
|
| 214 |
+
disabled={isLoading}
|
| 215 |
+
size="lg"
|
| 216 |
+
style={{ marginTop: 'auto' }}
|
| 217 |
+
>
|
| 218 |
+
{isLoading ? 'Analyzing...' : 'Run Analysis'}
|
| 219 |
+
</Button>
|
| 220 |
+
)}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
</Stack>
|
| 224 |
+
)
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
export default Controls
|
frontend/src/components/ExampleDataPanel.jsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react'
|
| 2 |
+
import {
|
| 3 |
+
Text,
|
| 4 |
+
Stack,
|
| 5 |
+
Box,
|
| 6 |
+
UnstyledButton,
|
| 7 |
+
Loader,
|
| 8 |
+
Group,
|
| 9 |
+
} from '@mantine/core'
|
| 10 |
+
import { IconDatabase, IconFlask } from '@tabler/icons-react'
|
| 11 |
+
import { useXRD } from '../context/XRDContext'
|
| 12 |
+
|
| 13 |
+
const ExampleDataPanel = ({ onSelect }) => {
|
| 14 |
+
const { loadExampleFile } = useXRD()
|
| 15 |
+
const [examples, setExamples] = useState([])
|
| 16 |
+
const [loading, setLoading] = useState(true)
|
| 17 |
+
const [loadingFile, setLoadingFile] = useState(null)
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const fetchExamples = async () => {
|
| 21 |
+
try {
|
| 22 |
+
const response = await fetch('/api/examples')
|
| 23 |
+
if (response.ok) {
|
| 24 |
+
const data = await response.json()
|
| 25 |
+
setExamples(data)
|
| 26 |
+
}
|
| 27 |
+
} catch (err) {
|
| 28 |
+
console.error('Failed to fetch examples:', err)
|
| 29 |
+
} finally {
|
| 30 |
+
setLoading(false)
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
fetchExamples()
|
| 34 |
+
}, [])
|
| 35 |
+
|
| 36 |
+
const handleSelect = async (filename) => {
|
| 37 |
+
setLoadingFile(filename)
|
| 38 |
+
const success = await loadExampleFile(filename)
|
| 39 |
+
setLoadingFile(null)
|
| 40 |
+
if (success && onSelect) onSelect()
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (loading) {
|
| 44 |
+
return (
|
| 45 |
+
<Box p="md" style={{ textAlign: 'center' }}>
|
| 46 |
+
<Loader size="sm" />
|
| 47 |
+
</Box>
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if (examples.length === 0) return null
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<Box>
|
| 55 |
+
<Group gap="xs" mb="xs">
|
| 56 |
+
<IconDatabase size={16} color="#868e96" />
|
| 57 |
+
<Text size="sm" fw={600} c="dimmed">
|
| 58 |
+
Example Data
|
| 59 |
+
</Text>
|
| 60 |
+
</Group>
|
| 61 |
+
|
| 62 |
+
<Stack gap={8}>
|
| 63 |
+
{examples.map((ex) => (
|
| 64 |
+
<UnstyledButton
|
| 65 |
+
key={ex.filename}
|
| 66 |
+
onClick={() => handleSelect(ex.filename)}
|
| 67 |
+
disabled={loadingFile !== null}
|
| 68 |
+
style={{
|
| 69 |
+
padding: '10px 12px',
|
| 70 |
+
borderRadius: '8px',
|
| 71 |
+
border: '1px solid #dee2e6',
|
| 72 |
+
backgroundColor: loadingFile === ex.filename ? '#f1f3f5' : '#fff',
|
| 73 |
+
cursor: loadingFile !== null ? 'wait' : 'pointer',
|
| 74 |
+
transition: 'background-color 150ms ease',
|
| 75 |
+
}}
|
| 76 |
+
onMouseEnter={(e) => {
|
| 77 |
+
if (!loadingFile) e.currentTarget.style.backgroundColor = '#f8f9fa'
|
| 78 |
+
}}
|
| 79 |
+
onMouseLeave={(e) => {
|
| 80 |
+
if (loadingFile !== ex.filename) e.currentTarget.style.backgroundColor = '#fff'
|
| 81 |
+
}}
|
| 82 |
+
>
|
| 83 |
+
<Group justify="space-between" align="center" wrap="nowrap">
|
| 84 |
+
<Group gap={6}>
|
| 85 |
+
<IconFlask size={14} color="#7950f2" />
|
| 86 |
+
<Text size="sm" fw={500} truncate>
|
| 87 |
+
{ex.material_id || ex.filename}
|
| 88 |
+
</Text>
|
| 89 |
+
</Group>
|
| 90 |
+
{loadingFile === ex.filename && <Loader size={16} />}
|
| 91 |
+
</Group>
|
| 92 |
+
</UnstyledButton>
|
| 93 |
+
))}
|
| 94 |
+
</Stack>
|
| 95 |
+
</Box>
|
| 96 |
+
)
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export default ExampleDataPanel
|
frontend/src/components/Header.jsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import { Group, Title, Badge, Button, Text, Box } from '@mantine/core'
|
| 3 |
+
import { IconRefresh } from '@tabler/icons-react'
|
| 4 |
+
import { useXRD } from '../context/XRDContext'
|
| 5 |
+
|
| 6 |
+
const Header = () => {
|
| 7 |
+
const { filename, analysisStatus, handleReset } = useXRD()
|
| 8 |
+
|
| 9 |
+
const getStatusBadge = () => {
|
| 10 |
+
switch (analysisStatus) {
|
| 11 |
+
case 'PROCESSING':
|
| 12 |
+
return (
|
| 13 |
+
<Badge color="blue" variant="light" size="lg">
|
| 14 |
+
Analyzing...
|
| 15 |
+
</Badge>
|
| 16 |
+
)
|
| 17 |
+
case 'COMPLETE':
|
| 18 |
+
return (
|
| 19 |
+
<Badge color="green" variant="light" size="lg">
|
| 20 |
+
Analysis Complete
|
| 21 |
+
</Badge>
|
| 22 |
+
)
|
| 23 |
+
default:
|
| 24 |
+
return (
|
| 25 |
+
<Badge color="gray" variant="light" size="lg">
|
| 26 |
+
Ready
|
| 27 |
+
</Badge>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<Box
|
| 34 |
+
style={{
|
| 35 |
+
borderBottom: '1px solid #e9ecef',
|
| 36 |
+
backgroundColor: 'white',
|
| 37 |
+
padding: '1rem 2rem',
|
| 38 |
+
}}
|
| 39 |
+
>
|
| 40 |
+
<Group justify="space-between" align="center">
|
| 41 |
+
{/* Left: Logo/App Name */}
|
| 42 |
+
<Title
|
| 43 |
+
order={2}
|
| 44 |
+
style={{
|
| 45 |
+
fontWeight: 700,
|
| 46 |
+
letterSpacing: '-0.02em',
|
| 47 |
+
background: 'linear-gradient(135deg, #9775fa 0%, #1e88e5 100%)',
|
| 48 |
+
WebkitBackgroundClip: 'text',
|
| 49 |
+
WebkitTextFillColor: 'transparent',
|
| 50 |
+
backgroundClip: 'text'
|
| 51 |
+
}}
|
| 52 |
+
>
|
| 53 |
+
Open AlphaDiffract Demo
|
| 54 |
+
</Title>
|
| 55 |
+
|
| 56 |
+
{/* Center: Filename and Status */}
|
| 57 |
+
<Group gap="md">
|
| 58 |
+
<Text size="sm" c="dimmed" style={{ fontFamily: 'monospace' }}>
|
| 59 |
+
{filename || 'No File Loaded'}
|
| 60 |
+
</Text>
|
| 61 |
+
{getStatusBadge()}
|
| 62 |
+
</Group>
|
| 63 |
+
|
| 64 |
+
{/* Right: Action Toolbar */}
|
| 65 |
+
<Group gap="sm">
|
| 66 |
+
<Button
|
| 67 |
+
variant="subtle"
|
| 68 |
+
leftSection={<IconRefresh size={16} />}
|
| 69 |
+
size="sm"
|
| 70 |
+
onClick={handleReset}
|
| 71 |
+
>
|
| 72 |
+
Reset
|
| 73 |
+
</Button>
|
| 74 |
+
</Group>
|
| 75 |
+
</Group>
|
| 76 |
+
</Box>
|
| 77 |
+
)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export default Header
|
frontend/src/components/LogitDrawer.jsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import { Drawer, Title, Text, Stack, Box, Progress, Table, ScrollArea } from '@mantine/core'
|
| 3 |
+
import { useXRD } from '../context/XRDContext'
|
| 4 |
+
|
| 5 |
+
const LogitDrawer = () => {
|
| 6 |
+
const { isLogitDrawerOpen, setIsLogitDrawerOpen, modelResults } = useXRD()
|
| 7 |
+
|
| 8 |
+
if (!modelResults?.predictions?.phase_predictions) {
|
| 9 |
+
return null
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
// Get classification predictions (not lattice parameters)
|
| 13 |
+
const classificationPredictions = modelResults.predictions.phase_predictions
|
| 14 |
+
.filter(p => !p.is_lattice && p.confidence)
|
| 15 |
+
|
| 16 |
+
const getColorForProbability = (prob) => {
|
| 17 |
+
if (prob >= 0.8) return 'green'
|
| 18 |
+
if (prob >= 0.5) return 'yellow'
|
| 19 |
+
return 'orange'
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<Drawer
|
| 24 |
+
opened={isLogitDrawerOpen}
|
| 25 |
+
onClose={() => setIsLogitDrawerOpen(false)}
|
| 26 |
+
position="right"
|
| 27 |
+
size="xl"
|
| 28 |
+
title={
|
| 29 |
+
<Title order={3}>Class Logit Distributions</Title>
|
| 30 |
+
}
|
| 31 |
+
padding="xl"
|
| 32 |
+
>
|
| 33 |
+
<Stack gap="xl">
|
| 34 |
+
<Text size="sm" c="dimmed">
|
| 35 |
+
Detailed logit scores for each classification task. Note: Logits have been normalized with softmax.
|
| 36 |
+
</Text>
|
| 37 |
+
|
| 38 |
+
{classificationPredictions.map((pred, idx) => (
|
| 39 |
+
<Box key={idx}>
|
| 40 |
+
<Title order={4} mb="md">{pred.phase}</Title>
|
| 41 |
+
<Text size="sm" mb="md" c="dimmed">
|
| 42 |
+
Top Prediction: <Text span fw={700} c="violet">{pred.predicted_class}</Text> ({(pred.confidence * 100).toFixed(2)}%)
|
| 43 |
+
</Text>
|
| 44 |
+
|
| 45 |
+
{/* Display all probabilities for Crystal System */}
|
| 46 |
+
{pred.all_probabilities && (
|
| 47 |
+
<ScrollArea>
|
| 48 |
+
<Table highlightOnHover>
|
| 49 |
+
<Table.Thead>
|
| 50 |
+
<Table.Tr>
|
| 51 |
+
<Table.Th>Rank</Table.Th>
|
| 52 |
+
<Table.Th>Class</Table.Th>
|
| 53 |
+
<Table.Th>Logits</Table.Th>
|
| 54 |
+
<Table.Th>Distribution</Table.Th>
|
| 55 |
+
</Table.Tr>
|
| 56 |
+
</Table.Thead>
|
| 57 |
+
<Table.Tbody>
|
| 58 |
+
{pred.all_probabilities.map((item, i) => (
|
| 59 |
+
<Table.Tr key={i} style={{
|
| 60 |
+
backgroundColor: i === 0 ? '#f3f0ff' : 'transparent',
|
| 61 |
+
fontWeight: i === 0 ? 600 : 400
|
| 62 |
+
}}>
|
| 63 |
+
<Table.Td>{i + 1}</Table.Td>
|
| 64 |
+
<Table.Td style={{ fontFamily: 'monospace' }}>{item.class_name}</Table.Td>
|
| 65 |
+
<Table.Td style={{ fontFamily: 'monospace' }}>
|
| 66 |
+
{(item.probability * 100).toFixed(2)}%
|
| 67 |
+
</Table.Td>
|
| 68 |
+
<Table.Td style={{ width: '40%' }}>
|
| 69 |
+
<Progress
|
| 70 |
+
value={item.probability * 100}
|
| 71 |
+
color={getColorForProbability(item.probability)}
|
| 72 |
+
size="lg"
|
| 73 |
+
/>
|
| 74 |
+
</Table.Td>
|
| 75 |
+
</Table.Tr>
|
| 76 |
+
))}
|
| 77 |
+
</Table.Tbody>
|
| 78 |
+
</Table>
|
| 79 |
+
</ScrollArea>
|
| 80 |
+
)}
|
| 81 |
+
|
| 82 |
+
{/* Display top 10 probabilities for Space Group */}
|
| 83 |
+
{pred.top_probabilities && (
|
| 84 |
+
<ScrollArea>
|
| 85 |
+
<Table highlightOnHover>
|
| 86 |
+
<Table.Thead>
|
| 87 |
+
<Table.Tr>
|
| 88 |
+
<Table.Th>Rank</Table.Th>
|
| 89 |
+
<Table.Th>Space Group</Table.Th>
|
| 90 |
+
<Table.Th>Symbol</Table.Th>
|
| 91 |
+
<Table.Th>Logits</Table.Th>
|
| 92 |
+
<Table.Th>Distribution</Table.Th>
|
| 93 |
+
</Table.Tr>
|
| 94 |
+
</Table.Thead>
|
| 95 |
+
<Table.Tbody>
|
| 96 |
+
{pred.top_probabilities.map((item, i) => (
|
| 97 |
+
<Table.Tr key={i} style={{
|
| 98 |
+
backgroundColor: i === 0 ? '#f3f0ff' : 'transparent',
|
| 99 |
+
fontWeight: i === 0 ? 600 : 400
|
| 100 |
+
}}>
|
| 101 |
+
<Table.Td>{i + 1}</Table.Td>
|
| 102 |
+
<Table.Td style={{ fontFamily: 'monospace' }}>#{item.space_group_number}</Table.Td>
|
| 103 |
+
<Table.Td style={{ fontFamily: 'monospace' }}>{item.space_group_symbol}</Table.Td>
|
| 104 |
+
<Table.Td style={{ fontFamily: 'monospace' }}>
|
| 105 |
+
{(item.probability * 100).toFixed(2)}%
|
| 106 |
+
</Table.Td>
|
| 107 |
+
<Table.Td style={{ width: '35%' }}>
|
| 108 |
+
<Progress
|
| 109 |
+
value={item.probability * 100}
|
| 110 |
+
color={getColorForProbability(item.probability)}
|
| 111 |
+
size="lg"
|
| 112 |
+
/>
|
| 113 |
+
</Table.Td>
|
| 114 |
+
</Table.Tr>
|
| 115 |
+
))}
|
| 116 |
+
</Table.Tbody>
|
| 117 |
+
</Table>
|
| 118 |
+
</ScrollArea>
|
| 119 |
+
)}
|
| 120 |
+
</Box>
|
| 121 |
+
))}
|
| 122 |
+
|
| 123 |
+
<Box mt="md" p="md" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
| 124 |
+
<Text size="xs" c="dimmed">
|
| 125 |
+
<strong>Note:</strong> The model outputs raw logit scores for each possible class.
|
| 126 |
+
These scores are normalized using softmax to show relative confidence. For Crystal System, all 7
|
| 127 |
+
classes are shown. For Space Group, the top 10 out of 230 possible groups are displayed.
|
| 128 |
+
</Text>
|
| 129 |
+
</Box>
|
| 130 |
+
</Stack>
|
| 131 |
+
</Drawer>
|
| 132 |
+
)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
export default LogitDrawer
|
frontend/src/components/ResultsHero.jsx
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import { Grid, Card, Text, Title, Group, Box, Button, Stack, Progress } from '@mantine/core'
|
| 3 |
+
import { IconSearch, IconCube, IconGridPattern } from '@tabler/icons-react'
|
| 4 |
+
import { useXRD } from '../context/XRDContext'
|
| 5 |
+
|
| 6 |
+
const ResultsHero = () => {
|
| 7 |
+
const { modelResults, setIsLogitDrawerOpen } = useXRD()
|
| 8 |
+
|
| 9 |
+
// Animation styles
|
| 10 |
+
const cardAnimation = {
|
| 11 |
+
animation: 'slideIn 0.5s ease-out forwards',
|
| 12 |
+
opacity: 0,
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const keyframes = `
|
| 16 |
+
@keyframes slideIn {
|
| 17 |
+
from {
|
| 18 |
+
opacity: 0;
|
| 19 |
+
transform: translateY(20px);
|
| 20 |
+
}
|
| 21 |
+
to {
|
| 22 |
+
opacity: 1;
|
| 23 |
+
transform: translateY(0);
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
`
|
| 27 |
+
|
| 28 |
+
if (!modelResults || !modelResults.predictions?.phase_predictions) {
|
| 29 |
+
return null
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const predictions = modelResults.predictions.phase_predictions
|
| 33 |
+
|
| 34 |
+
// Extract predictions by type
|
| 35 |
+
const crystalSystem = predictions.find(p => p.phase === 'Crystal System')
|
| 36 |
+
const spaceGroup = predictions.find(p => p.phase === 'Space Group')
|
| 37 |
+
const latticeParams = predictions.find(p => p.is_lattice)
|
| 38 |
+
|
| 39 |
+
const getConfidenceColor = (confidence) => {
|
| 40 |
+
if (confidence >= 0.8) return 'green'
|
| 41 |
+
if (confidence >= 0.5) return 'yellow'
|
| 42 |
+
return 'orange'
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<>
|
| 47 |
+
<style>{keyframes}</style>
|
| 48 |
+
<Grid gutter="md" mb="lg">
|
| 49 |
+
{/* Card 1: Crystal System */}
|
| 50 |
+
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
| 51 |
+
<Card
|
| 52 |
+
shadow="sm"
|
| 53 |
+
padding="lg"
|
| 54 |
+
radius="md"
|
| 55 |
+
withBorder
|
| 56 |
+
h="100%"
|
| 57 |
+
style={{
|
| 58 |
+
...cardAnimation,
|
| 59 |
+
animationDelay: '0.1s',
|
| 60 |
+
borderLeft: '4px solid #9775fa',
|
| 61 |
+
background: 'linear-gradient(135deg, #ffffff 0%, #f8f4ff 100%)'
|
| 62 |
+
}}
|
| 63 |
+
>
|
| 64 |
+
<Stack gap="xs" align="center">
|
| 65 |
+
<IconCube size={32} color="#9775fa" />
|
| 66 |
+
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
| 67 |
+
Crystal System
|
| 68 |
+
</Text>
|
| 69 |
+
<Title order={3} ta="center" style={{ fontWeight: 700 }}>
|
| 70 |
+
{crystalSystem?.predicted_class || 'N/A'}
|
| 71 |
+
</Title>
|
| 72 |
+
</Stack>
|
| 73 |
+
</Card>
|
| 74 |
+
</Grid.Col>
|
| 75 |
+
|
| 76 |
+
{/* Card 2: Space Group */}
|
| 77 |
+
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
| 78 |
+
<Card
|
| 79 |
+
shadow="sm"
|
| 80 |
+
padding="lg"
|
| 81 |
+
radius="md"
|
| 82 |
+
withBorder
|
| 83 |
+
h="100%"
|
| 84 |
+
style={{
|
| 85 |
+
...cardAnimation,
|
| 86 |
+
animationDelay: '0.2s',
|
| 87 |
+
borderLeft: '4px solid #1e88e5',
|
| 88 |
+
background: 'linear-gradient(135deg, #ffffff 0%, #f0f7ff 100%)'
|
| 89 |
+
}}
|
| 90 |
+
>
|
| 91 |
+
<Stack gap="xs" align="center">
|
| 92 |
+
<IconGridPattern size={32} color="#1e88e5" />
|
| 93 |
+
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
| 94 |
+
Space Group
|
| 95 |
+
</Text>
|
| 96 |
+
<Title order={3} ta="center" style={{ fontWeight: 700 }}>
|
| 97 |
+
{spaceGroup?.predicted_class || 'N/A'}
|
| 98 |
+
</Title>
|
| 99 |
+
{spaceGroup?.space_group_symbol && (
|
| 100 |
+
<Text size="sm" c="dimmed" style={{ fontFamily: 'monospace' }}>
|
| 101 |
+
{spaceGroup.space_group_symbol}
|
| 102 |
+
</Text>
|
| 103 |
+
)}
|
| 104 |
+
</Stack>
|
| 105 |
+
</Card>
|
| 106 |
+
</Grid.Col>
|
| 107 |
+
|
| 108 |
+
{/* Card 3: Lattice Parameters */}
|
| 109 |
+
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
| 110 |
+
<Card
|
| 111 |
+
shadow="sm"
|
| 112 |
+
padding="lg"
|
| 113 |
+
radius="md"
|
| 114 |
+
withBorder
|
| 115 |
+
h="100%"
|
| 116 |
+
style={{
|
| 117 |
+
...cardAnimation,
|
| 118 |
+
animationDelay: '0.3s',
|
| 119 |
+
borderLeft: '4px solid #00acc1',
|
| 120 |
+
background: 'linear-gradient(135deg, #ffffff 0%, #f0fdff 100%)'
|
| 121 |
+
}}
|
| 122 |
+
>
|
| 123 |
+
<Stack gap="xs">
|
| 124 |
+
<Text size="xs" c="dimmed" tt="uppercase" fw={600} ta="center">
|
| 125 |
+
Lattice Parameters
|
| 126 |
+
</Text>
|
| 127 |
+
{latticeParams?.lattice_params ? (
|
| 128 |
+
<Box style={{ fontFamily: 'monospace', fontSize: '1.1rem' }}>
|
| 129 |
+
<Group gap="lg" justify="center">
|
| 130 |
+
<Stack gap={4}>
|
| 131 |
+
<Text size="md">a = {latticeParams.lattice_params.a?.toFixed(3)} Å</Text>
|
| 132 |
+
<Text size="md">b = {latticeParams.lattice_params.b?.toFixed(3)} Å</Text>
|
| 133 |
+
<Text size="md">c = {latticeParams.lattice_params.c?.toFixed(3)} Å</Text>
|
| 134 |
+
</Stack>
|
| 135 |
+
<Stack gap={4}>
|
| 136 |
+
<Text size="md">α = {latticeParams.lattice_params['α']?.toFixed(2)}°</Text>
|
| 137 |
+
<Text size="md">β = {latticeParams.lattice_params['β']?.toFixed(2)}°</Text>
|
| 138 |
+
<Text size="md">γ = {latticeParams.lattice_params['γ']?.toFixed(2)}°</Text>
|
| 139 |
+
</Stack>
|
| 140 |
+
</Group>
|
| 141 |
+
</Box>
|
| 142 |
+
) : (
|
| 143 |
+
<Text size="sm" c="dimmed" ta="center">N/A</Text>
|
| 144 |
+
)}
|
| 145 |
+
</Stack>
|
| 146 |
+
</Card>
|
| 147 |
+
</Grid.Col>
|
| 148 |
+
|
| 149 |
+
{/* Card 4: Confidence & Inspection */}
|
| 150 |
+
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
| 151 |
+
<Card
|
| 152 |
+
shadow="sm"
|
| 153 |
+
padding="lg"
|
| 154 |
+
radius="md"
|
| 155 |
+
withBorder
|
| 156 |
+
h="100%"
|
| 157 |
+
style={{
|
| 158 |
+
...cardAnimation,
|
| 159 |
+
animationDelay: '0.4s',
|
| 160 |
+
borderLeft: '4px solid #fb8c00',
|
| 161 |
+
background: 'linear-gradient(135deg, #ffffff 0%, #fff8f0 100%)'
|
| 162 |
+
}}
|
| 163 |
+
>
|
| 164 |
+
<Stack gap="sm" align="center" justify="center" h="100%">
|
| 165 |
+
<Text size="xs" c="dimmed" tt="uppercase" fw={600} ta="center">
|
| 166 |
+
Confidence
|
| 167 |
+
</Text>
|
| 168 |
+
|
| 169 |
+
{/* CS and SG Confidences - Compact */}
|
| 170 |
+
<Stack gap="xs" w="100%">
|
| 171 |
+
{/* Crystal System Confidence */}
|
| 172 |
+
{crystalSystem?.confidence && (
|
| 173 |
+
<Box>
|
| 174 |
+
<Group justify="space-between" mb={4}>
|
| 175 |
+
<Text size="xs" fw={600}>CS</Text>
|
| 176 |
+
<Text size="sm" fw={700} c={getConfidenceColor(crystalSystem.confidence)}>
|
| 177 |
+
{(crystalSystem.confidence * 100).toFixed(0)}%
|
| 178 |
+
</Text>
|
| 179 |
+
</Group>
|
| 180 |
+
<Progress
|
| 181 |
+
value={crystalSystem.confidence * 100}
|
| 182 |
+
color={getConfidenceColor(crystalSystem.confidence)}
|
| 183 |
+
size="md"
|
| 184 |
+
radius="xl"
|
| 185 |
+
/>
|
| 186 |
+
</Box>
|
| 187 |
+
)}
|
| 188 |
+
|
| 189 |
+
{/* Space Group Confidence */}
|
| 190 |
+
{spaceGroup?.confidence && (
|
| 191 |
+
<Box>
|
| 192 |
+
<Group justify="space-between" mb={4}>
|
| 193 |
+
<Text size="xs" fw={600}>SG</Text>
|
| 194 |
+
<Text size="sm" fw={700} c={getConfidenceColor(spaceGroup.confidence)}>
|
| 195 |
+
{(spaceGroup.confidence * 100).toFixed(0)}%
|
| 196 |
+
</Text>
|
| 197 |
+
</Group>
|
| 198 |
+
<Progress
|
| 199 |
+
value={spaceGroup.confidence * 100}
|
| 200 |
+
color={getConfidenceColor(spaceGroup.confidence)}
|
| 201 |
+
size="md"
|
| 202 |
+
radius="xl"
|
| 203 |
+
/>
|
| 204 |
+
</Box>
|
| 205 |
+
)}
|
| 206 |
+
</Stack>
|
| 207 |
+
|
| 208 |
+
<Button
|
| 209 |
+
variant="subtle"
|
| 210 |
+
leftSection={<IconSearch size={16} />}
|
| 211 |
+
size="sm"
|
| 212 |
+
onClick={() => setIsLogitDrawerOpen(true)}
|
| 213 |
+
mt="xs"
|
| 214 |
+
style={{
|
| 215 |
+
border: '1px solid #1e88e5'
|
| 216 |
+
}}
|
| 217 |
+
>
|
| 218 |
+
Inspect Logits
|
| 219 |
+
</Button>
|
| 220 |
+
</Stack>
|
| 221 |
+
</Card>
|
| 222 |
+
</Grid.Col>
|
| 223 |
+
</Grid>
|
| 224 |
+
</>
|
| 225 |
+
)
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
export default ResultsHero
|
frontend/src/components/XRDGraph.jsx
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import Plot from 'react-plotly.js'
|
| 3 |
+
import { Box, Text, Stack, Title, Group, Badge } from '@mantine/core'
|
| 4 |
+
import { useXRD } from '../context/XRDContext'
|
| 5 |
+
|
| 6 |
+
const XRDGraph = () => {
|
| 7 |
+
const { rawData, processedData, MODEL_MIN_2THETA, MODEL_MAX_2THETA } = useXRD()
|
| 8 |
+
|
| 9 |
+
if (!rawData) {
|
| 10 |
+
return (
|
| 11 |
+
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 350 }}>
|
| 12 |
+
<Text size="xl" c="dimmed">
|
| 13 |
+
No data loaded
|
| 14 |
+
</Text>
|
| 15 |
+
</Box>
|
| 16 |
+
)
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// Raw data trace
|
| 20 |
+
const rawTrace = {
|
| 21 |
+
x: rawData.x,
|
| 22 |
+
y: rawData.y,
|
| 23 |
+
type: 'scattergl',
|
| 24 |
+
mode: 'lines',
|
| 25 |
+
name: 'Raw Data',
|
| 26 |
+
line: {
|
| 27 |
+
color: 'rgba(128, 128, 128, 1)',
|
| 28 |
+
width: 1.5,
|
| 29 |
+
},
|
| 30 |
+
hovertemplate: '2θ: %{x:.2f}°<br>Intensity: %{y:.2f}<extra></extra>',
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Processed data trace
|
| 34 |
+
const processedTrace = processedData ? {
|
| 35 |
+
x: processedData.x,
|
| 36 |
+
y: processedData.y,
|
| 37 |
+
type: 'scattergl',
|
| 38 |
+
mode: 'lines',
|
| 39 |
+
name: 'Normalized',
|
| 40 |
+
line: {
|
| 41 |
+
color: 'rgba(30, 136, 229, 1)',
|
| 42 |
+
width: 2,
|
| 43 |
+
},
|
| 44 |
+
hovertemplate: '2θ: %{x:.2f}°<br>Intensity: %{y:.4f}<extra></extra>',
|
| 45 |
+
} : null
|
| 46 |
+
|
| 47 |
+
// Layout for raw data graph
|
| 48 |
+
const rawLayout = {
|
| 49 |
+
xaxis: {
|
| 50 |
+
title: {
|
| 51 |
+
text: '2θ (degrees)',
|
| 52 |
+
font: {
|
| 53 |
+
size: 14,
|
| 54 |
+
color: '#333',
|
| 55 |
+
},
|
| 56 |
+
standoff: 10,
|
| 57 |
+
},
|
| 58 |
+
gridcolor: 'rgba(128, 128, 128, 0.2)',
|
| 59 |
+
showline: true,
|
| 60 |
+
linewidth: 1,
|
| 61 |
+
linecolor: 'rgba(128, 128, 128, 0.3)',
|
| 62 |
+
mirror: true,
|
| 63 |
+
range: [Math.min(...rawData.x) - 0.5, Math.max(...rawData.x) + 0.5],
|
| 64 |
+
},
|
| 65 |
+
yaxis: {
|
| 66 |
+
title: {
|
| 67 |
+
text: 'Intensity (a.u.)',
|
| 68 |
+
font: {
|
| 69 |
+
size: 14,
|
| 70 |
+
color: '#333',
|
| 71 |
+
},
|
| 72 |
+
standoff: 10,
|
| 73 |
+
},
|
| 74 |
+
gridcolor: 'rgba(128, 128, 128, 0.2)',
|
| 75 |
+
showline: true,
|
| 76 |
+
linewidth: 1,
|
| 77 |
+
linecolor: 'rgba(128, 128, 128, 0.3)',
|
| 78 |
+
mirror: true,
|
| 79 |
+
},
|
| 80 |
+
hovermode: 'closest',
|
| 81 |
+
showlegend: false,
|
| 82 |
+
margin: {
|
| 83 |
+
l: 60,
|
| 84 |
+
r: 40,
|
| 85 |
+
t: 20,
|
| 86 |
+
b: 60,
|
| 87 |
+
},
|
| 88 |
+
paper_bgcolor: 'transparent',
|
| 89 |
+
plot_bgcolor: 'transparent',
|
| 90 |
+
height: 280,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Layout for processed data graph
|
| 94 |
+
const processedLayout = {
|
| 95 |
+
xaxis: {
|
| 96 |
+
title: {
|
| 97 |
+
text: '2θ (degrees)',
|
| 98 |
+
font: {
|
| 99 |
+
size: 14,
|
| 100 |
+
color: '#333',
|
| 101 |
+
},
|
| 102 |
+
standoff: 10,
|
| 103 |
+
},
|
| 104 |
+
gridcolor: 'rgba(128, 128, 128, 0.2)',
|
| 105 |
+
range: [MODEL_MIN_2THETA - 0.5, MODEL_MAX_2THETA + 0.5],
|
| 106 |
+
showline: true,
|
| 107 |
+
linewidth: 1,
|
| 108 |
+
linecolor: 'rgba(128, 128, 128, 0.3)',
|
| 109 |
+
mirror: true,
|
| 110 |
+
},
|
| 111 |
+
yaxis: {
|
| 112 |
+
title: {
|
| 113 |
+
text: 'Normalized Intensity',
|
| 114 |
+
font: {
|
| 115 |
+
size: 14,
|
| 116 |
+
color: '#333',
|
| 117 |
+
},
|
| 118 |
+
standoff: 10,
|
| 119 |
+
},
|
| 120 |
+
gridcolor: 'rgba(128, 128, 128, 0.2)',
|
| 121 |
+
range: [-5, 105],
|
| 122 |
+
showline: true,
|
| 123 |
+
linewidth: 1,
|
| 124 |
+
linecolor: 'rgba(128, 128, 128, 0.3)',
|
| 125 |
+
mirror: true,
|
| 126 |
+
},
|
| 127 |
+
hovermode: 'closest',
|
| 128 |
+
showlegend: false,
|
| 129 |
+
margin: {
|
| 130 |
+
l: 60,
|
| 131 |
+
r: 40,
|
| 132 |
+
t: 20,
|
| 133 |
+
b: 60,
|
| 134 |
+
},
|
| 135 |
+
paper_bgcolor: 'transparent',
|
| 136 |
+
plot_bgcolor: 'transparent',
|
| 137 |
+
height: 280,
|
| 138 |
+
shapes: [
|
| 139 |
+
{
|
| 140 |
+
type: 'rect',
|
| 141 |
+
xref: 'x',
|
| 142 |
+
yref: 'paper',
|
| 143 |
+
x0: MODEL_MIN_2THETA,
|
| 144 |
+
y0: 0,
|
| 145 |
+
x1: MODEL_MAX_2THETA,
|
| 146 |
+
y1: 1,
|
| 147 |
+
fillcolor: 'rgba(147, 51, 234, 0.05)',
|
| 148 |
+
line: {
|
| 149 |
+
width: 0,
|
| 150 |
+
},
|
| 151 |
+
},
|
| 152 |
+
],
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// Config for Plotly
|
| 156 |
+
const config = {
|
| 157 |
+
responsive: true,
|
| 158 |
+
displayModeBar: true,
|
| 159 |
+
displaylogo: false,
|
| 160 |
+
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
return (
|
| 164 |
+
<Stack gap="md">
|
| 165 |
+
{/* Raw Data Graph */}
|
| 166 |
+
<Box>
|
| 167 |
+
<Group justify="space-between" mb="xs">
|
| 168 |
+
<Title order={5}>Original Data</Title>
|
| 169 |
+
<Badge color="gray" variant="light">
|
| 170 |
+
{rawData.x.length} points
|
| 171 |
+
</Badge>
|
| 172 |
+
</Group>
|
| 173 |
+
<Plot
|
| 174 |
+
data={[rawTrace]}
|
| 175 |
+
layout={rawLayout}
|
| 176 |
+
config={config}
|
| 177 |
+
style={{ width: '100%' }}
|
| 178 |
+
useResizeHandler={true}
|
| 179 |
+
/>
|
| 180 |
+
</Box>
|
| 181 |
+
|
| 182 |
+
{/* Processed Data Graph */}
|
| 183 |
+
{processedData && (
|
| 184 |
+
<Box>
|
| 185 |
+
<Group justify="space-between" mb="xs">
|
| 186 |
+
<Title order={5}>Normalized for Model Input</Title>
|
| 187 |
+
<Badge color="violet" variant="light">
|
| 188 |
+
{processedData.x.length} points • {MODEL_MIN_2THETA}-{MODEL_MAX_2THETA}°
|
| 189 |
+
</Badge>
|
| 190 |
+
</Group>
|
| 191 |
+
<Plot
|
| 192 |
+
data={[processedTrace]}
|
| 193 |
+
layout={processedLayout}
|
| 194 |
+
config={config}
|
| 195 |
+
style={{ width: '100%' }}
|
| 196 |
+
useResizeHandler={true}
|
| 197 |
+
/>
|
| 198 |
+
</Box>
|
| 199 |
+
)}
|
| 200 |
+
</Stack>
|
| 201 |
+
)
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
export default XRDGraph
|
frontend/src/components/ui/button.jsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva } from "class-variance-authority"
|
| 3 |
+
import { cn } from "../../lib/utils"
|
| 4 |
+
|
| 5 |
+
const buttonVariants = cva(
|
| 6 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 11 |
+
destructive:
|
| 12 |
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
| 13 |
+
outline:
|
| 14 |
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
| 15 |
+
secondary:
|
| 16 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 17 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
| 18 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 19 |
+
},
|
| 20 |
+
size: {
|
| 21 |
+
default: "h-10 px-4 py-2",
|
| 22 |
+
sm: "h-9 rounded-md px-3",
|
| 23 |
+
lg: "h-11 rounded-md px-8",
|
| 24 |
+
icon: "h-10 w-10",
|
| 25 |
+
},
|
| 26 |
+
},
|
| 27 |
+
defaultVariants: {
|
| 28 |
+
variant: "default",
|
| 29 |
+
size: "default",
|
| 30 |
+
},
|
| 31 |
+
}
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
const Button = React.forwardRef(
|
| 35 |
+
({ className, variant, size, ...props }, ref) => {
|
| 36 |
+
return (
|
| 37 |
+
<button
|
| 38 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 39 |
+
ref={ref}
|
| 40 |
+
{...props}
|
| 41 |
+
/>
|
| 42 |
+
)
|
| 43 |
+
}
|
| 44 |
+
)
|
| 45 |
+
Button.displayName = "Button"
|
| 46 |
+
|
| 47 |
+
export { Button, buttonVariants }
|
frontend/src/components/ui/card.jsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cn } from "../../lib/utils"
|
| 3 |
+
|
| 4 |
+
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
| 5 |
+
<div
|
| 6 |
+
ref={ref}
|
| 7 |
+
className={cn(
|
| 8 |
+
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
| 9 |
+
className
|
| 10 |
+
)}
|
| 11 |
+
{...props}
|
| 12 |
+
/>
|
| 13 |
+
))
|
| 14 |
+
Card.displayName = "Card"
|
| 15 |
+
|
| 16 |
+
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
| 17 |
+
<div
|
| 18 |
+
ref={ref}
|
| 19 |
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
| 20 |
+
{...props}
|
| 21 |
+
/>
|
| 22 |
+
))
|
| 23 |
+
CardHeader.displayName = "CardHeader"
|
| 24 |
+
|
| 25 |
+
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
| 26 |
+
<h3
|
| 27 |
+
ref={ref}
|
| 28 |
+
className={cn(
|
| 29 |
+
"text-2xl font-semibold leading-none tracking-tight",
|
| 30 |
+
className
|
| 31 |
+
)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
))
|
| 35 |
+
CardTitle.displayName = "CardTitle"
|
| 36 |
+
|
| 37 |
+
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
| 38 |
+
<p
|
| 39 |
+
ref={ref}
|
| 40 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 41 |
+
{...props}
|
| 42 |
+
/>
|
| 43 |
+
))
|
| 44 |
+
CardDescription.displayName = "CardDescription"
|
| 45 |
+
|
| 46 |
+
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
| 47 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
| 48 |
+
))
|
| 49 |
+
CardContent.displayName = "CardContent"
|
| 50 |
+
|
| 51 |
+
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
| 52 |
+
<div
|
| 53 |
+
ref={ref}
|
| 54 |
+
className={cn("flex items-center p-6 pt-0", className)}
|
| 55 |
+
{...props}
|
| 56 |
+
/>
|
| 57 |
+
))
|
| 58 |
+
CardFooter.displayName = "CardFooter"
|
| 59 |
+
|
| 60 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
frontend/src/components/ui/slider.jsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import * as SliderPrimitive from "@radix-ui/react-slider"
|
| 3 |
+
import { cn } from "../../lib/utils"
|
| 4 |
+
|
| 5 |
+
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
| 6 |
+
<SliderPrimitive.Root
|
| 7 |
+
ref={ref}
|
| 8 |
+
className={cn(
|
| 9 |
+
"relative flex w-full touch-none select-none items-center",
|
| 10 |
+
className
|
| 11 |
+
)}
|
| 12 |
+
{...props}
|
| 13 |
+
>
|
| 14 |
+
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
| 15 |
+
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
| 16 |
+
</SliderPrimitive.Track>
|
| 17 |
+
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
| 18 |
+
</SliderPrimitive.Root>
|
| 19 |
+
))
|
| 20 |
+
Slider.displayName = SliderPrimitive.Root.displayName
|
| 21 |
+
|
| 22 |
+
export { Slider }
|
frontend/src/components/ui/switch.jsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
| 3 |
+
import { cn } from "../../lib/utils"
|
| 4 |
+
|
| 5 |
+
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
| 6 |
+
<SwitchPrimitives.Root
|
| 7 |
+
className={cn(
|
| 8 |
+
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
| 9 |
+
className
|
| 10 |
+
)}
|
| 11 |
+
{...props}
|
| 12 |
+
ref={ref}
|
| 13 |
+
>
|
| 14 |
+
<SwitchPrimitives.Thumb
|
| 15 |
+
className={cn(
|
| 16 |
+
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
| 17 |
+
)}
|
| 18 |
+
/>
|
| 19 |
+
</SwitchPrimitives.Root>
|
| 20 |
+
))
|
| 21 |
+
Switch.displayName = SwitchPrimitives.Root.displayName
|
| 22 |
+
|
| 23 |
+
export { Switch }
|
frontend/src/context/XRDContext.jsx
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react'
|
| 2 |
+
|
| 3 |
+
const XRDContext = createContext()
|
| 4 |
+
|
| 5 |
+
export const useXRD = () => {
|
| 6 |
+
const context = useContext(XRDContext)
|
| 7 |
+
if (!context) {
|
| 8 |
+
throw new Error('useXRD must be used within XRDProvider')
|
| 9 |
+
}
|
| 10 |
+
return context
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const XRDProvider = ({ children }) => {
|
| 14 |
+
// Model training specifications (from simulator.yaml)
|
| 15 |
+
const MODEL_INPUT_SIZE = 8192
|
| 16 |
+
const MODEL_WAVELENGTH = 0.6199 // Ångströms (synchrotron)
|
| 17 |
+
const MODEL_MIN_2THETA = 5.0 // degrees
|
| 18 |
+
const MODEL_MAX_2THETA = 20.0 // degrees
|
| 19 |
+
|
| 20 |
+
// Raw data from file upload
|
| 21 |
+
const [rawData, setRawData] = useState(null)
|
| 22 |
+
const [filename, setFilename] = useState(null)
|
| 23 |
+
|
| 24 |
+
// Wavelength management
|
| 25 |
+
const [detectedWavelength, setDetectedWavelength] = useState(null)
|
| 26 |
+
const [userWavelength, setUserWavelength] = useState(MODEL_WAVELENGTH)
|
| 27 |
+
const [wavelengthSource, setWavelengthSource] = useState('default') // 'detected', 'user', 'default'
|
| 28 |
+
|
| 29 |
+
// Processing parameters
|
| 30 |
+
const [baselineCorrection, setBaselineCorrection] = useState(false)
|
| 31 |
+
const [interpolationEnabled, setInterpolationEnabled] = useState(true)
|
| 32 |
+
const [scalingEnabled, setScalingEnabled] = useState(true)
|
| 33 |
+
const [interpolationStrategy, setInterpolationStrategy] = useState('linear') // 'linear' or 'cubic'
|
| 34 |
+
|
| 35 |
+
// Warnings and metadata
|
| 36 |
+
const [dataWarnings, setDataWarnings] = useState([])
|
| 37 |
+
|
| 38 |
+
// Model results from API
|
| 39 |
+
const [modelResults, setModelResults] = useState(null)
|
| 40 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 41 |
+
const [analysisStatus, setAnalysisStatus] = useState('IDLE') // IDLE, PROCESSING, COMPLETE
|
| 42 |
+
|
| 43 |
+
// UI state
|
| 44 |
+
const [isLogitDrawerOpen, setIsLogitDrawerOpen] = useState(false)
|
| 45 |
+
|
| 46 |
+
// Request tracking - ensure every click creates a new request
|
| 47 |
+
const [analysisCount, setAnalysisCount] = useState(0)
|
| 48 |
+
|
| 49 |
+
// Convert wavelength using Bragg's law: λ = 2d·sin(θ)
|
| 50 |
+
// For same d-spacing: sin(θ₂) = (λ₂/λ₁)·sin(θ₁)
|
| 51 |
+
const convertWavelength = (theta_deg, sourceWavelength, targetWavelength) => {
|
| 52 |
+
if (Math.abs(sourceWavelength - targetWavelength) < 0.0001) {
|
| 53 |
+
return theta_deg // No conversion needed
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const theta_rad = (theta_deg * Math.PI) / 180
|
| 57 |
+
const sin_theta2 = (targetWavelength / sourceWavelength) * Math.sin(theta_rad)
|
| 58 |
+
|
| 59 |
+
// Check if conversion is physically possible
|
| 60 |
+
if (Math.abs(sin_theta2) > 1) {
|
| 61 |
+
return null // Peak not observable at target wavelength
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const theta2_rad = Math.asin(sin_theta2)
|
| 65 |
+
return (theta2_rad * 180) / Math.PI
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Interpolate data to fixed size for model input
|
| 69 |
+
const interpolateData = (x, y, targetSize, xMin, xMax, strategy = 'linear') => {
|
| 70 |
+
if (x.length === targetSize && xMin === undefined) {
|
| 71 |
+
return { x, y }
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const minX = xMin !== undefined ? xMin : Math.min(...x)
|
| 75 |
+
const maxX = xMax !== undefined ? xMax : Math.max(...x)
|
| 76 |
+
const step = (maxX - minX) / (targetSize - 1)
|
| 77 |
+
|
| 78 |
+
const newX = Array.from({ length: targetSize }, (_, i) => minX + i * step)
|
| 79 |
+
const newY = new Array(targetSize)
|
| 80 |
+
|
| 81 |
+
// Get data range bounds
|
| 82 |
+
const dataMinX = Math.min(...x)
|
| 83 |
+
const dataMaxX = Math.max(...x)
|
| 84 |
+
|
| 85 |
+
if (strategy === 'linear') {
|
| 86 |
+
// Linear interpolation
|
| 87 |
+
for (let i = 0; i < targetSize; i++) {
|
| 88 |
+
const targetX = newX[i]
|
| 89 |
+
|
| 90 |
+
// Check if out of range - set to 0 instead of extrapolating
|
| 91 |
+
if (targetX < dataMinX || targetX > dataMaxX) {
|
| 92 |
+
newY[i] = 0
|
| 93 |
+
continue
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Find surrounding points
|
| 97 |
+
let idx = x.findIndex(val => val >= targetX)
|
| 98 |
+
if (idx === -1) idx = x.length - 1
|
| 99 |
+
if (idx === 0) idx = 1
|
| 100 |
+
|
| 101 |
+
const x0 = x[idx - 1]
|
| 102 |
+
const x1 = x[idx]
|
| 103 |
+
const y0 = y[idx - 1]
|
| 104 |
+
const y1 = y[idx]
|
| 105 |
+
|
| 106 |
+
// Linear interpolation
|
| 107 |
+
newY[i] = y0 + ((targetX - x0) * (y1 - y0)) / (x1 - x0)
|
| 108 |
+
}
|
| 109 |
+
} else if (strategy === 'cubic') {
|
| 110 |
+
// Cubic spline interpolation (simplified Catmull-Rom)
|
| 111 |
+
for (let i = 0; i < targetSize; i++) {
|
| 112 |
+
const targetX = newX[i]
|
| 113 |
+
|
| 114 |
+
// Check if out of range - set to 0 instead of extrapolating
|
| 115 |
+
if (targetX < dataMinX || targetX > dataMaxX) {
|
| 116 |
+
newY[i] = 0
|
| 117 |
+
continue
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// Find surrounding points
|
| 121 |
+
let idx = x.findIndex(val => val >= targetX)
|
| 122 |
+
if (idx === -1) idx = x.length - 1
|
| 123 |
+
if (idx === 0) idx = 1
|
| 124 |
+
|
| 125 |
+
// Get 4 points for cubic interpolation
|
| 126 |
+
const i0 = Math.max(0, idx - 2)
|
| 127 |
+
const i1 = Math.max(0, idx - 1)
|
| 128 |
+
const i2 = Math.min(x.length - 1, idx)
|
| 129 |
+
const i3 = Math.min(x.length - 1, idx + 1)
|
| 130 |
+
|
| 131 |
+
// Use linear interpolation if we don't have enough points
|
| 132 |
+
if (i2 === i1) {
|
| 133 |
+
newY[i] = y[i1]
|
| 134 |
+
} else {
|
| 135 |
+
const t = (targetX - x[i1]) / (x[i2] - x[i1])
|
| 136 |
+
const t2 = t * t
|
| 137 |
+
const t3 = t2 * t
|
| 138 |
+
|
| 139 |
+
// Catmull-Rom spline coefficients
|
| 140 |
+
const v0 = y[i0]
|
| 141 |
+
const v1 = y[i1]
|
| 142 |
+
const v2 = y[i2]
|
| 143 |
+
const v3 = y[i3]
|
| 144 |
+
|
| 145 |
+
newY[i] = 0.5 * (
|
| 146 |
+
2 * v1 +
|
| 147 |
+
(-v0 + v2) * t +
|
| 148 |
+
(2 * v0 - 5 * v1 + 4 * v2 - v3) * t2 +
|
| 149 |
+
(-v0 + 3 * v1 - 3 * v2 + v3) * t3
|
| 150 |
+
)
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
return { x: newX, y: newY }
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// Process data with optional interpolation
|
| 159 |
+
const processedData = useMemo(() => {
|
| 160 |
+
if (!rawData) return null
|
| 161 |
+
|
| 162 |
+
try {
|
| 163 |
+
const warnings = []
|
| 164 |
+
let processedY = [...rawData.y]
|
| 165 |
+
let processedX = [...rawData.x]
|
| 166 |
+
|
| 167 |
+
// Step 1: Wavelength conversion (if needed)
|
| 168 |
+
const sourceWavelength = userWavelength
|
| 169 |
+
if (sourceWavelength && Math.abs(sourceWavelength - MODEL_WAVELENGTH) > 0.0001) {
|
| 170 |
+
const convertedData = []
|
| 171 |
+
for (let i = 0; i < processedX.length; i++) {
|
| 172 |
+
const convertedTheta = convertWavelength(processedX[i], sourceWavelength, MODEL_WAVELENGTH)
|
| 173 |
+
if (convertedTheta !== null) {
|
| 174 |
+
convertedData.push({ x: convertedTheta, y: processedY[i] })
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
if (convertedData.length < processedX.length) {
|
| 179 |
+
warnings.push(`${processedX.length - convertedData.length} points outside physical range after wavelength conversion`)
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
processedX = convertedData.map(d => d.x)
|
| 183 |
+
processedY = convertedData.map(d => d.y)
|
| 184 |
+
|
| 185 |
+
warnings.push(`Converted from ${sourceWavelength.toFixed(4)} Å to ${MODEL_WAVELENGTH} Å`)
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// Step 2: Apply baseline correction if enabled
|
| 189 |
+
if (baselineCorrection) {
|
| 190 |
+
const baseline = Math.min(...processedY)
|
| 191 |
+
processedY = processedY.map(val => val - baseline)
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// Step 3: Crop to model's 2θ range (5-20°)
|
| 195 |
+
const inRangeData = []
|
| 196 |
+
for (let i = 0; i < processedX.length; i++) {
|
| 197 |
+
if (processedX[i] >= MODEL_MIN_2THETA && processedX[i] <= MODEL_MAX_2THETA) {
|
| 198 |
+
inRangeData.push({ x: processedX[i], y: processedY[i] })
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
if (inRangeData.length === 0) {
|
| 203 |
+
warnings.push(`⚠️ No data points in model range (${MODEL_MIN_2THETA}-${MODEL_MAX_2THETA}°)`)
|
| 204 |
+
// Use original data but warn
|
| 205 |
+
inRangeData.push(...processedX.map((x, i) => ({ x, y: processedY[i] })))
|
| 206 |
+
} else if (inRangeData.length < processedX.length) {
|
| 207 |
+
const coverage = (inRangeData.length / processedX.length * 100).toFixed(1)
|
| 208 |
+
warnings.push(`${coverage}% of data in model range (${MODEL_MIN_2THETA}-${MODEL_MAX_2THETA}°)`)
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
let croppedX = inRangeData.map(d => d.x)
|
| 212 |
+
let croppedY = inRangeData.map(d => d.y)
|
| 213 |
+
|
| 214 |
+
// Step 4: Apply 0-100 scaling if enabled (matching training data)
|
| 215 |
+
// NOTE: Scaling happens AFTER cropping so the max peak in the visible range = 100
|
| 216 |
+
if (scalingEnabled) {
|
| 217 |
+
const minY = Math.min(...croppedY)
|
| 218 |
+
const maxY = Math.max(...croppedY)
|
| 219 |
+
if (maxY - minY > 0) {
|
| 220 |
+
croppedY = croppedY.map(val => ((val - minY) / (maxY - minY)) * 100)
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// Step 5: Interpolate to model input size with fixed range
|
| 225 |
+
const interpolated = interpolateData(
|
| 226 |
+
croppedX,
|
| 227 |
+
croppedY,
|
| 228 |
+
MODEL_INPUT_SIZE,
|
| 229 |
+
MODEL_MIN_2THETA,
|
| 230 |
+
MODEL_MAX_2THETA,
|
| 231 |
+
interpolationStrategy
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
// Update warnings
|
| 235 |
+
setDataWarnings(warnings)
|
| 236 |
+
|
| 237 |
+
return {
|
| 238 |
+
x: interpolated.x,
|
| 239 |
+
y: interpolated.y
|
| 240 |
+
}
|
| 241 |
+
} catch (error) {
|
| 242 |
+
console.error('Error processing data:', error)
|
| 243 |
+
setDataWarnings([`Error: ${error.message}`])
|
| 244 |
+
return rawData
|
| 245 |
+
}
|
| 246 |
+
}, [rawData, baselineCorrection, userWavelength, interpolationStrategy, scalingEnabled])
|
| 247 |
+
|
| 248 |
+
// Extract metadata from CIF/DIF files
|
| 249 |
+
const extractMetadata = (text) => {
|
| 250 |
+
const metadata = {
|
| 251 |
+
wavelength: null,
|
| 252 |
+
cellParams: null,
|
| 253 |
+
spaceGroup: null,
|
| 254 |
+
crystalSystem: null
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
const lines = text.split('\n')
|
| 258 |
+
|
| 259 |
+
// Common wavelength patterns in headers
|
| 260 |
+
const wavelengthPatterns = [
|
| 261 |
+
/wavelength[:\s=]+([0-9.]+)/i,
|
| 262 |
+
/lambda[:\s=]+([0-9.]+)/i,
|
| 263 |
+
/wave[:\s=]+([0-9.]+)/i,
|
| 264 |
+
/_pd_wavelength[:\s]+([0-9.]+)/i, // CIF format
|
| 265 |
+
/_diffrn_radiation_wavelength[:\s]+([0-9.]+)/i, // CIF format
|
| 266 |
+
/radiation.*?([0-9.]+)\s*[AÅ]/i,
|
| 267 |
+
]
|
| 268 |
+
|
| 269 |
+
for (const line of lines) {
|
| 270 |
+
// Extract wavelength
|
| 271 |
+
if (!metadata.wavelength) {
|
| 272 |
+
for (const pattern of wavelengthPatterns) {
|
| 273 |
+
const match = line.match(pattern)
|
| 274 |
+
if (match && match[1]) {
|
| 275 |
+
const wavelength = parseFloat(match[1])
|
| 276 |
+
if (wavelength > 0.1 && wavelength < 3.0) { // Reasonable X-ray range
|
| 277 |
+
metadata.wavelength = wavelength
|
| 278 |
+
break
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// Check for common radiation types
|
| 284 |
+
if (/Cu\s*K[αa]/i.test(line)) metadata.wavelength = 1.5406 // Cu Kα
|
| 285 |
+
else if (/Mo\s*K[αa]/i.test(line)) metadata.wavelength = 0.7107 // Mo Kα
|
| 286 |
+
else if (/Co\s*K[αa]/i.test(line)) metadata.wavelength = 1.7889 // Co Kα
|
| 287 |
+
else if (/Cr\s*K[αa]/i.test(line)) metadata.wavelength = 2.2897 // Cr Kα
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
// Extract cell parameters (DIF format)
|
| 291 |
+
if (/CELL PARAMETERS:/i.test(line)) {
|
| 292 |
+
const match = line.match(/CELL PARAMETERS:\s*([\d.\s]+)/)
|
| 293 |
+
if (match) {
|
| 294 |
+
metadata.cellParams = match[1].trim()
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// Extract space group
|
| 299 |
+
if (/SPACE GROUP:/i.test(line) || /_symmetry_Int_Tables_number/i.test(line)) {
|
| 300 |
+
const match = line.match(/(?:SPACE GROUP:|_symmetry_Int_Tables_number)[:\s]+(\d+)/)
|
| 301 |
+
if (match) {
|
| 302 |
+
metadata.spaceGroup = match[1]
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
// Extract crystal system
|
| 307 |
+
if (/Crystal System:/i.test(line)) {
|
| 308 |
+
const match = line.match(/Crystal System:\s*(\d+)/)
|
| 309 |
+
if (match) {
|
| 310 |
+
metadata.crystalSystem = match[1]
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
return metadata
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Parse CIF format data
|
| 319 |
+
const parseCIF = (text) => {
|
| 320 |
+
const lines = text.split('\n')
|
| 321 |
+
const x = []
|
| 322 |
+
const y = []
|
| 323 |
+
let inDataLoop = false
|
| 324 |
+
let dataColumns = []
|
| 325 |
+
let thetaIndex = -1
|
| 326 |
+
let intensityIndex = -1
|
| 327 |
+
|
| 328 |
+
for (let i = 0; i < lines.length; i++) {
|
| 329 |
+
const line = lines[i].trim()
|
| 330 |
+
|
| 331 |
+
// Detect start of data loop
|
| 332 |
+
if (line === 'loop_') {
|
| 333 |
+
inDataLoop = true
|
| 334 |
+
dataColumns = []
|
| 335 |
+
continue
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
// Collect column names in loop
|
| 339 |
+
if (inDataLoop && line.startsWith('_')) {
|
| 340 |
+
dataColumns.push(line)
|
| 341 |
+
|
| 342 |
+
// Identify 2theta column
|
| 343 |
+
if (/_pd_meas_angle_2theta/i.test(line) || /_pd_calc_angle_2theta/i.test(line)) {
|
| 344 |
+
thetaIndex = dataColumns.length - 1
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
// Identify intensity column
|
| 348 |
+
if (/_pd_proc_intensity/i.test(line) || /_pd_calc_intensity/i.test(line) || /_pd_meas_counts/i.test(line)) {
|
| 349 |
+
intensityIndex = dataColumns.length - 1
|
| 350 |
+
}
|
| 351 |
+
continue
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// Parse data lines
|
| 355 |
+
if (inDataLoop && !line.startsWith('_') && !line.startsWith('loop_') && line.length > 0 && !line.startsWith('#')) {
|
| 356 |
+
// Check if we've found the data section
|
| 357 |
+
if (thetaIndex >= 0 && intensityIndex >= 0) {
|
| 358 |
+
const parts = line.split(/\s+/)
|
| 359 |
+
|
| 360 |
+
if (parts.length >= Math.max(thetaIndex, intensityIndex) + 1) {
|
| 361 |
+
const xVal = parseFloat(parts[thetaIndex])
|
| 362 |
+
const yVal = parseFloat(parts[intensityIndex])
|
| 363 |
+
|
| 364 |
+
if (!isNaN(xVal) && !isNaN(yVal)) {
|
| 365 |
+
x.push(xVal)
|
| 366 |
+
y.push(yVal)
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
} else {
|
| 370 |
+
// End of loop, no data found
|
| 371 |
+
inDataLoop = false
|
| 372 |
+
dataColumns = []
|
| 373 |
+
thetaIndex = -1
|
| 374 |
+
intensityIndex = -1
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
// Reset if we hit another loop_ or data block
|
| 379 |
+
if (inDataLoop && (line.startsWith('data_') || (line === 'loop_' && dataColumns.length > 0))) {
|
| 380 |
+
inDataLoop = false
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
return { x, y }
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
// Parse DIF or XY format (space-separated 2theta intensity)
|
| 388 |
+
const parseDIF = (text) => {
|
| 389 |
+
const lines = text.split('\n')
|
| 390 |
+
const x = []
|
| 391 |
+
const y = []
|
| 392 |
+
|
| 393 |
+
for (const line of lines) {
|
| 394 |
+
const trimmed = line.trim()
|
| 395 |
+
|
| 396 |
+
// Skip comment lines, headers, and metadata
|
| 397 |
+
if (!trimmed ||
|
| 398 |
+
trimmed.startsWith('#') ||
|
| 399 |
+
trimmed.startsWith('_') ||
|
| 400 |
+
trimmed.startsWith('CELL') ||
|
| 401 |
+
trimmed.startsWith('SPACE') ||
|
| 402 |
+
/^[a-zA-Z]/.test(trimmed)) { // Skip lines starting with letters (metadata)
|
| 403 |
+
continue
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
// Split by whitespace
|
| 407 |
+
const parts = trimmed.split(/\s+/)
|
| 408 |
+
if (parts.length >= 2) {
|
| 409 |
+
const xVal = parseFloat(parts[0])
|
| 410 |
+
const yVal = parseFloat(parts[1])
|
| 411 |
+
|
| 412 |
+
if (!isNaN(xVal) && !isNaN(yVal)) {
|
| 413 |
+
x.push(xVal)
|
| 414 |
+
y.push(yVal)
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
return { x, y }
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
// Parse uploaded file
|
| 423 |
+
const parseFile = (file) => {
|
| 424 |
+
return new Promise((resolve, reject) => {
|
| 425 |
+
const reader = new FileReader()
|
| 426 |
+
|
| 427 |
+
reader.onload = (e) => {
|
| 428 |
+
try {
|
| 429 |
+
const text = e.target.result
|
| 430 |
+
|
| 431 |
+
// Extract metadata (including wavelength)
|
| 432 |
+
const metadata = extractMetadata(text)
|
| 433 |
+
if (metadata.wavelength) {
|
| 434 |
+
setDetectedWavelength(metadata.wavelength)
|
| 435 |
+
setUserWavelength(metadata.wavelength)
|
| 436 |
+
setWavelengthSource('detected')
|
| 437 |
+
} else {
|
| 438 |
+
setDetectedWavelength(null)
|
| 439 |
+
setWavelengthSource('default')
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// Determine file format and parse accordingly
|
| 443 |
+
const fileName = file.name.toLowerCase()
|
| 444 |
+
let data = { x: [], y: [] }
|
| 445 |
+
|
| 446 |
+
if (fileName.endsWith('.cif')) {
|
| 447 |
+
// CIF format - look for loop_ structures
|
| 448 |
+
data = parseCIF(text)
|
| 449 |
+
|
| 450 |
+
// Fallback to simple parsing if CIF parsing didn't find data
|
| 451 |
+
if (data.x.length === 0) {
|
| 452 |
+
console.log('CIF loop parsing failed, falling back to simple parser')
|
| 453 |
+
data = parseDIF(text)
|
| 454 |
+
}
|
| 455 |
+
} else {
|
| 456 |
+
// DIF, XY, CSV, TXT - simple space/comma separated
|
| 457 |
+
data = parseDIF(text)
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
if (data.x.length === 0 || data.y.length === 0) {
|
| 461 |
+
reject(new Error('No valid data points found in file'))
|
| 462 |
+
return
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
console.log(`Parsed ${data.x.length} data points from ${fileName}`)
|
| 466 |
+
resolve(data)
|
| 467 |
+
} catch (error) {
|
| 468 |
+
reject(error)
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
reader.onerror = () => reject(new Error('Failed to read file'))
|
| 473 |
+
reader.readAsText(file)
|
| 474 |
+
})
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
// Upload and parse file
|
| 478 |
+
const handleFileUpload = async (file) => {
|
| 479 |
+
try {
|
| 480 |
+
const data = await parseFile(file)
|
| 481 |
+
|
| 482 |
+
setRawData(data)
|
| 483 |
+
setFilename(file.name)
|
| 484 |
+
setModelResults(null) // Clear previous results
|
| 485 |
+
setAnalysisStatus('IDLE')
|
| 486 |
+
setIsLogitDrawerOpen(false) // Close logit drawer if open
|
| 487 |
+
|
| 488 |
+
return true
|
| 489 |
+
} catch (error) {
|
| 490 |
+
console.error('Error uploading file:', error)
|
| 491 |
+
alert(`Error loading file: ${error.message}`)
|
| 492 |
+
return false
|
| 493 |
+
}
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
// Send processed data to API for inference
|
| 497 |
+
const runInference = useCallback(async () => {
|
| 498 |
+
if (!processedData) {
|
| 499 |
+
alert('No data to analyze')
|
| 500 |
+
return
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
// Increment analysis counter - tracks button clicks
|
| 504 |
+
const currentCount = analysisCount + 1
|
| 505 |
+
setAnalysisCount(currentCount)
|
| 506 |
+
|
| 507 |
+
setIsLoading(true)
|
| 508 |
+
setAnalysisStatus('PROCESSING')
|
| 509 |
+
|
| 510 |
+
try {
|
| 511 |
+
const requestTimestamp = Date.now()
|
| 512 |
+
|
| 513 |
+
const response = await fetch('/api/predict', {
|
| 514 |
+
method: 'POST',
|
| 515 |
+
headers: {
|
| 516 |
+
'Content-Type': 'application/json',
|
| 517 |
+
// Anti-caching headers
|
| 518 |
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
| 519 |
+
'Pragma': 'no-cache',
|
| 520 |
+
'Expires': '0',
|
| 521 |
+
// Request tracking
|
| 522 |
+
'X-Request-ID': String(requestTimestamp),
|
| 523 |
+
'X-Filename': filename || 'unknown',
|
| 524 |
+
},
|
| 525 |
+
// Explicitly disable caching for this request
|
| 526 |
+
cache: 'no-store',
|
| 527 |
+
body: JSON.stringify({
|
| 528 |
+
x: processedData.x,
|
| 529 |
+
y: processedData.y,
|
| 530 |
+
// Include metadata to help track requests
|
| 531 |
+
metadata: {
|
| 532 |
+
timestamp: requestTimestamp,
|
| 533 |
+
filename: filename,
|
| 534 |
+
analysisCount: currentCount,
|
| 535 |
+
}
|
| 536 |
+
}),
|
| 537 |
+
})
|
| 538 |
+
|
| 539 |
+
if (!response.ok) {
|
| 540 |
+
throw new Error(`API error: ${response.status}`)
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
const results = await response.json()
|
| 544 |
+
setModelResults(results)
|
| 545 |
+
setAnalysisStatus('COMPLETE')
|
| 546 |
+
} catch (error) {
|
| 547 |
+
console.error('Error running inference:', error)
|
| 548 |
+
alert(`Inference failed: ${error.message}`)
|
| 549 |
+
setAnalysisStatus('IDLE')
|
| 550 |
+
} finally {
|
| 551 |
+
setIsLoading(false)
|
| 552 |
+
}
|
| 553 |
+
}, [processedData, analysisCount, filename])
|
| 554 |
+
|
| 555 |
+
// Load an example data file from the API
|
| 556 |
+
const loadExampleFile = useCallback(async (filename) => {
|
| 557 |
+
try {
|
| 558 |
+
const response = await fetch(`/api/examples/${encodeURIComponent(filename)}`)
|
| 559 |
+
if (!response.ok) {
|
| 560 |
+
throw new Error(`Failed to fetch example: ${response.status}`)
|
| 561 |
+
}
|
| 562 |
+
const text = await response.text()
|
| 563 |
+
|
| 564 |
+
// Extract metadata (including wavelength) — same as normal file upload
|
| 565 |
+
const metadata = extractMetadata(text)
|
| 566 |
+
if (metadata.wavelength) {
|
| 567 |
+
setDetectedWavelength(metadata.wavelength)
|
| 568 |
+
setUserWavelength(metadata.wavelength)
|
| 569 |
+
setWavelengthSource('detected')
|
| 570 |
+
} else {
|
| 571 |
+
setDetectedWavelength(null)
|
| 572 |
+
setWavelengthSource('default')
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
// Parse using the DIF parser (all examples are .dif)
|
| 576 |
+
const data = parseDIF(text)
|
| 577 |
+
if (data.x.length === 0 || data.y.length === 0) {
|
| 578 |
+
throw new Error('No valid data points found in example file')
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
setRawData(data)
|
| 582 |
+
setFilename(filename)
|
| 583 |
+
setModelResults(null)
|
| 584 |
+
setAnalysisStatus('IDLE')
|
| 585 |
+
setIsLogitDrawerOpen(false)
|
| 586 |
+
|
| 587 |
+
return true
|
| 588 |
+
} catch (error) {
|
| 589 |
+
console.error('Error loading example file:', error)
|
| 590 |
+
alert(`Error loading example: ${error.message}`)
|
| 591 |
+
return false
|
| 592 |
+
}
|
| 593 |
+
}, [])
|
| 594 |
+
|
| 595 |
+
// Reset application state
|
| 596 |
+
const handleReset = () => {
|
| 597 |
+
setRawData(null)
|
| 598 |
+
setFilename(null)
|
| 599 |
+
setModelResults(null)
|
| 600 |
+
setAnalysisStatus('IDLE')
|
| 601 |
+
setIsLogitDrawerOpen(false)
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
const value = {
|
| 605 |
+
rawData,
|
| 606 |
+
processedData,
|
| 607 |
+
modelResults,
|
| 608 |
+
isLoading,
|
| 609 |
+
filename,
|
| 610 |
+
analysisStatus,
|
| 611 |
+
detectedWavelength,
|
| 612 |
+
userWavelength,
|
| 613 |
+
setUserWavelength,
|
| 614 |
+
wavelengthSource,
|
| 615 |
+
dataWarnings,
|
| 616 |
+
baselineCorrection,
|
| 617 |
+
setBaselineCorrection,
|
| 618 |
+
interpolationEnabled,
|
| 619 |
+
setInterpolationEnabled,
|
| 620 |
+
scalingEnabled,
|
| 621 |
+
setScalingEnabled,
|
| 622 |
+
interpolationStrategy,
|
| 623 |
+
setInterpolationStrategy,
|
| 624 |
+
isLogitDrawerOpen,
|
| 625 |
+
setIsLogitDrawerOpen,
|
| 626 |
+
handleFileUpload,
|
| 627 |
+
loadExampleFile,
|
| 628 |
+
runInference,
|
| 629 |
+
handleReset,
|
| 630 |
+
MODEL_WAVELENGTH,
|
| 631 |
+
MODEL_MIN_2THETA,
|
| 632 |
+
MODEL_MAX_2THETA,
|
| 633 |
+
MODEL_INPUT_SIZE,
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
return <XRDContext.Provider value={value}>{children}</XRDContext.Provider>
|
| 637 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Basic reset and default styles */
|
| 2 |
+
* {
|
| 3 |
+
box-sizing: border-box;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
body {
|
| 7 |
+
margin: 0;
|
| 8 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
| 9 |
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
| 10 |
+
sans-serif;
|
| 11 |
+
-webkit-font-smoothing: antialiased;
|
| 12 |
+
-moz-osx-font-smoothing: grayscale;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
#root {
|
| 16 |
+
height: 100vh;
|
| 17 |
+
overflow: hidden;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
code {
|
| 21 |
+
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
| 22 |
+
monospace;
|
| 23 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import ReactDOM from 'react-dom/client'
|
| 3 |
+
import App from './App.jsx'
|
| 4 |
+
import { MantineProvider } from '@mantine/core'
|
| 5 |
+
import '@mantine/core/styles.css'
|
| 6 |
+
|
| 7 |
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
| 8 |
+
<React.StrictMode>
|
| 9 |
+
<MantineProvider
|
| 10 |
+
theme={{
|
| 11 |
+
primaryColor: 'blue',
|
| 12 |
+
fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif',
|
| 13 |
+
}}
|
| 14 |
+
>
|
| 15 |
+
<App />
|
| 16 |
+
</MantineProvider>
|
| 17 |
+
</React.StrictMode>,
|
| 18 |
+
)
|
frontend/src/utils/xrd-processing.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Pure utility functions extracted from XRDContext for testability.
|
| 3 |
+
* These functions contain no React state or side effects.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Convert wavelength using Bragg's law: lambda = 2d*sin(theta)
|
| 8 |
+
* For same d-spacing: sin(theta2) = (lambda2/lambda1) * sin(theta1)
|
| 9 |
+
*
|
| 10 |
+
* @param {number} theta_deg - 2theta angle in degrees
|
| 11 |
+
* @param {number} sourceWavelength - Source wavelength in Angstroms
|
| 12 |
+
* @param {number} targetWavelength - Target wavelength in Angstroms
|
| 13 |
+
* @returns {number|null} Converted 2theta angle, or null if physically impossible
|
| 14 |
+
*/
|
| 15 |
+
export function convertWavelength(theta_deg, sourceWavelength, targetWavelength) {
|
| 16 |
+
if (Math.abs(sourceWavelength - targetWavelength) < 0.0001) {
|
| 17 |
+
return theta_deg
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const theta_rad = (theta_deg * Math.PI) / 180
|
| 21 |
+
const sin_theta2 = (targetWavelength / sourceWavelength) * Math.sin(theta_rad)
|
| 22 |
+
|
| 23 |
+
if (Math.abs(sin_theta2) > 1) {
|
| 24 |
+
return null
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const theta2_rad = Math.asin(sin_theta2)
|
| 28 |
+
return (theta2_rad * 180) / Math.PI
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Interpolate data to fixed size for model input.
|
| 33 |
+
*
|
| 34 |
+
* @param {number[]} x - Input x values (2theta)
|
| 35 |
+
* @param {number[]} y - Input y values (intensity)
|
| 36 |
+
* @param {number} targetSize - Desired output length
|
| 37 |
+
* @param {number} [xMin] - Minimum x value for output grid
|
| 38 |
+
* @param {number} [xMax] - Maximum x value for output grid
|
| 39 |
+
* @param {string} [strategy='linear'] - Interpolation strategy: 'linear' or 'cubic'
|
| 40 |
+
* @returns {{x: number[], y: number[]}} Interpolated data
|
| 41 |
+
*/
|
| 42 |
+
export function interpolateData(x, y, targetSize, xMin, xMax, strategy = 'linear') {
|
| 43 |
+
if (x.length === targetSize && xMin === undefined) {
|
| 44 |
+
return { x, y }
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const minX = xMin !== undefined ? xMin : Math.min(...x)
|
| 48 |
+
const maxX = xMax !== undefined ? xMax : Math.max(...x)
|
| 49 |
+
const step = (maxX - minX) / (targetSize - 1)
|
| 50 |
+
|
| 51 |
+
const newX = Array.from({ length: targetSize }, (_, i) => minX + i * step)
|
| 52 |
+
const newY = new Array(targetSize)
|
| 53 |
+
|
| 54 |
+
const dataMinX = Math.min(...x)
|
| 55 |
+
const dataMaxX = Math.max(...x)
|
| 56 |
+
|
| 57 |
+
if (strategy === 'linear') {
|
| 58 |
+
for (let i = 0; i < targetSize; i++) {
|
| 59 |
+
const targetX = newX[i]
|
| 60 |
+
|
| 61 |
+
if (targetX < dataMinX || targetX > dataMaxX) {
|
| 62 |
+
newY[i] = 0
|
| 63 |
+
continue
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
let idx = x.findIndex(val => val >= targetX)
|
| 67 |
+
if (idx === -1) idx = x.length - 1
|
| 68 |
+
if (idx === 0) idx = 1
|
| 69 |
+
|
| 70 |
+
const x0 = x[idx - 1]
|
| 71 |
+
const x1 = x[idx]
|
| 72 |
+
const y0 = y[idx - 1]
|
| 73 |
+
const y1 = y[idx]
|
| 74 |
+
|
| 75 |
+
newY[i] = y0 + ((targetX - x0) * (y1 - y0)) / (x1 - x0)
|
| 76 |
+
}
|
| 77 |
+
} else if (strategy === 'cubic') {
|
| 78 |
+
for (let i = 0; i < targetSize; i++) {
|
| 79 |
+
const targetX = newX[i]
|
| 80 |
+
|
| 81 |
+
if (targetX < dataMinX || targetX > dataMaxX) {
|
| 82 |
+
newY[i] = 0
|
| 83 |
+
continue
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
let idx = x.findIndex(val => val >= targetX)
|
| 87 |
+
if (idx === -1) idx = x.length - 1
|
| 88 |
+
if (idx === 0) idx = 1
|
| 89 |
+
|
| 90 |
+
const i0 = Math.max(0, idx - 2)
|
| 91 |
+
const i1 = Math.max(0, idx - 1)
|
| 92 |
+
const i2 = Math.min(x.length - 1, idx)
|
| 93 |
+
const i3 = Math.min(x.length - 1, idx + 1)
|
| 94 |
+
|
| 95 |
+
if (i2 === i1) {
|
| 96 |
+
newY[i] = y[i1]
|
| 97 |
+
} else {
|
| 98 |
+
const t = (targetX - x[i1]) / (x[i2] - x[i1])
|
| 99 |
+
const t2 = t * t
|
| 100 |
+
const t3 = t2 * t
|
| 101 |
+
|
| 102 |
+
const v0 = y[i0]
|
| 103 |
+
const v1 = y[i1]
|
| 104 |
+
const v2 = y[i2]
|
| 105 |
+
const v3 = y[i3]
|
| 106 |
+
|
| 107 |
+
newY[i] = 0.5 * (
|
| 108 |
+
2 * v1 +
|
| 109 |
+
(-v0 + v2) * t +
|
| 110 |
+
(2 * v0 - 5 * v1 + 4 * v2 - v3) * t2 +
|
| 111 |
+
(-v0 + 3 * v1 - 3 * v2 + v3) * t3
|
| 112 |
+
)
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
return { x: newX, y: newY }
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* Parse DIF or XY format (space-separated 2theta intensity).
|
| 122 |
+
*
|
| 123 |
+
* @param {string} text - Raw file content
|
| 124 |
+
* @returns {{x: number[], y: number[]}} Parsed data points
|
| 125 |
+
*/
|
| 126 |
+
export function parseDIF(text) {
|
| 127 |
+
const lines = text.split('\n')
|
| 128 |
+
const x = []
|
| 129 |
+
const y = []
|
| 130 |
+
|
| 131 |
+
for (const line of lines) {
|
| 132 |
+
const trimmed = line.trim()
|
| 133 |
+
|
| 134 |
+
if (!trimmed ||
|
| 135 |
+
trimmed.startsWith('#') ||
|
| 136 |
+
trimmed.startsWith('_') ||
|
| 137 |
+
trimmed.startsWith('CELL') ||
|
| 138 |
+
trimmed.startsWith('SPACE') ||
|
| 139 |
+
/^[a-zA-Z]/.test(trimmed)) {
|
| 140 |
+
continue
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const parts = trimmed.split(/\s+/)
|
| 144 |
+
if (parts.length >= 2) {
|
| 145 |
+
const xVal = parseFloat(parts[0])
|
| 146 |
+
const yVal = parseFloat(parts[1])
|
| 147 |
+
|
| 148 |
+
if (!isNaN(xVal) && !isNaN(yVal)) {
|
| 149 |
+
x.push(xVal)
|
| 150 |
+
y.push(yVal)
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
return { x, y }
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Extract metadata from CIF/DIF file text.
|
| 160 |
+
*
|
| 161 |
+
* @param {string} text - Raw file content
|
| 162 |
+
* @returns {{wavelength: number|null, cellParams: string|null, spaceGroup: string|null, crystalSystem: string|null}}
|
| 163 |
+
*/
|
| 164 |
+
export function extractMetadata(text) {
|
| 165 |
+
const metadata = {
|
| 166 |
+
wavelength: null,
|
| 167 |
+
cellParams: null,
|
| 168 |
+
spaceGroup: null,
|
| 169 |
+
crystalSystem: null
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
const lines = text.split('\n')
|
| 173 |
+
|
| 174 |
+
const wavelengthPatterns = [
|
| 175 |
+
/wavelength[:\s=]+([0-9.]+)/i,
|
| 176 |
+
/lambda[:\s=]+([0-9.]+)/i,
|
| 177 |
+
/wave[:\s=]+([0-9.]+)/i,
|
| 178 |
+
/_pd_wavelength[:\s]+([0-9.]+)/i,
|
| 179 |
+
/_diffrn_radiation_wavelength[:\s]+([0-9.]+)/i,
|
| 180 |
+
/radiation.*?([0-9.]+)\s*[AÅ]/i,
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
for (const line of lines) {
|
| 184 |
+
if (!metadata.wavelength) {
|
| 185 |
+
for (const pattern of wavelengthPatterns) {
|
| 186 |
+
const match = line.match(pattern)
|
| 187 |
+
if (match && match[1]) {
|
| 188 |
+
const wavelength = parseFloat(match[1])
|
| 189 |
+
if (wavelength > 0.1 && wavelength < 3.0) {
|
| 190 |
+
metadata.wavelength = wavelength
|
| 191 |
+
break
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
if (/Cu\s*K[αa]/i.test(line)) metadata.wavelength = 1.5406
|
| 197 |
+
else if (/Mo\s*K[αa]/i.test(line)) metadata.wavelength = 0.7107
|
| 198 |
+
else if (/Co\s*K[αa]/i.test(line)) metadata.wavelength = 1.7889
|
| 199 |
+
else if (/Cr\s*K[αa]/i.test(line)) metadata.wavelength = 2.2897
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
if (/CELL PARAMETERS:/i.test(line)) {
|
| 203 |
+
const match = line.match(/CELL PARAMETERS:\s*([\d.\s]+)/)
|
| 204 |
+
if (match) {
|
| 205 |
+
metadata.cellParams = match[1].trim()
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
if (/SPACE GROUP:/i.test(line) || /_symmetry_Int_Tables_number/i.test(line)) {
|
| 210 |
+
const match = line.match(/(?:SPACE GROUP:|_symmetry_Int_Tables_number)[:\s]+(\d+)/)
|
| 211 |
+
if (match) {
|
| 212 |
+
metadata.spaceGroup = match[1]
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
if (/Crystal System:/i.test(line)) {
|
| 217 |
+
const match = line.match(/Crystal System:\s*(\d+)/)
|
| 218 |
+
if (match) {
|
| 219 |
+
metadata.crystalSystem = match[1]
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
return metadata
|
| 225 |
+
}
|
frontend/src/utils/xrd-processing.test.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest'
|
| 2 |
+
import {
|
| 3 |
+
convertWavelength,
|
| 4 |
+
interpolateData,
|
| 5 |
+
parseDIF,
|
| 6 |
+
extractMetadata,
|
| 7 |
+
} from './xrd-processing'
|
| 8 |
+
|
| 9 |
+
// ---------------------------------------------------------------------------
|
| 10 |
+
// convertWavelength
|
| 11 |
+
// ---------------------------------------------------------------------------
|
| 12 |
+
describe('convertWavelength', () => {
|
| 13 |
+
it('returns same angle when wavelengths match', () => {
|
| 14 |
+
const result = convertWavelength(10.0, 0.6199, 0.6199)
|
| 15 |
+
expect(result).toBeCloseTo(10.0, 5)
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
it('returns null for physically impossible conversion', () => {
|
| 19 |
+
// Large angle with large wavelength ratio can exceed sin > 1
|
| 20 |
+
const result = convertWavelength(80.0, 0.5, 2.0)
|
| 21 |
+
expect(result).toBeNull()
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
it('converts Cu Ka to synchrotron wavelength', () => {
|
| 25 |
+
const cuKa = 1.5406
|
| 26 |
+
const synchrotron = 0.6199
|
| 27 |
+
const theta = 20.0
|
| 28 |
+
|
| 29 |
+
const result = convertWavelength(theta, cuKa, synchrotron)
|
| 30 |
+
expect(result).not.toBeNull()
|
| 31 |
+
// Shorter wavelength -> smaller 2theta
|
| 32 |
+
expect(result).toBeLessThan(theta)
|
| 33 |
+
expect(result).toBeGreaterThan(0)
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
it('converts synchrotron to Cu Ka wavelength', () => {
|
| 37 |
+
const cuKa = 1.5406
|
| 38 |
+
const synchrotron = 0.6199
|
| 39 |
+
const theta = 10.0
|
| 40 |
+
|
| 41 |
+
const result = convertWavelength(theta, synchrotron, cuKa)
|
| 42 |
+
expect(result).not.toBeNull()
|
| 43 |
+
// Longer wavelength -> larger 2theta
|
| 44 |
+
expect(result).toBeGreaterThan(theta)
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
it('handles zero angle', () => {
|
| 48 |
+
const result = convertWavelength(0, 1.0, 2.0)
|
| 49 |
+
expect(result).toBeCloseTo(0, 5)
|
| 50 |
+
})
|
| 51 |
+
})
|
| 52 |
+
|
| 53 |
+
// ---------------------------------------------------------------------------
|
| 54 |
+
// interpolateData
|
| 55 |
+
// ---------------------------------------------------------------------------
|
| 56 |
+
describe('interpolateData', () => {
|
| 57 |
+
it('returns same data when target size matches', () => {
|
| 58 |
+
const x = [1, 2, 3]
|
| 59 |
+
const y = [10, 20, 30]
|
| 60 |
+
const result = interpolateData(x, y, 3)
|
| 61 |
+
expect(result.x).toEqual(x)
|
| 62 |
+
expect(result.y).toEqual(y)
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
it('interpolates to larger size with linear strategy', () => {
|
| 66 |
+
const x = [0, 10]
|
| 67 |
+
const y = [0, 100]
|
| 68 |
+
const result = interpolateData(x, y, 11, 0, 10, 'linear')
|
| 69 |
+
expect(result.x).toHaveLength(11)
|
| 70 |
+
expect(result.y).toHaveLength(11)
|
| 71 |
+
// Midpoint should be ~50
|
| 72 |
+
expect(result.y[5]).toBeCloseTo(50, 0)
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
it('sets out-of-range values to zero', () => {
|
| 76 |
+
const x = [5, 10, 15]
|
| 77 |
+
const y = [100, 200, 300]
|
| 78 |
+
const result = interpolateData(x, y, 20, 0, 20, 'linear')
|
| 79 |
+
// Points before x=5 should be 0
|
| 80 |
+
expect(result.y[0]).toBe(0)
|
| 81 |
+
// Points after x=15 should be 0
|
| 82 |
+
expect(result.y[19]).toBe(0)
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
it('supports cubic interpolation', () => {
|
| 86 |
+
const x = [0, 2, 4, 6, 8, 10]
|
| 87 |
+
const y = [0, 4, 16, 36, 64, 100]
|
| 88 |
+
const result = interpolateData(x, y, 11, 0, 10, 'cubic')
|
| 89 |
+
expect(result.x).toHaveLength(11)
|
| 90 |
+
expect(result.y).toHaveLength(11)
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
it('handles single point input', () => {
|
| 94 |
+
const x = [5]
|
| 95 |
+
const y = [100]
|
| 96 |
+
const result = interpolateData(x, y, 10, 0, 10, 'linear')
|
| 97 |
+
expect(result.x).toHaveLength(10)
|
| 98 |
+
})
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
// ---------------------------------------------------------------------------
|
| 102 |
+
// parseDIF
|
| 103 |
+
// ---------------------------------------------------------------------------
|
| 104 |
+
describe('parseDIF', () => {
|
| 105 |
+
it('parses space-separated data', () => {
|
| 106 |
+
const text = '5.0 100.0\n10.0 200.0\n15.0 300.0\n'
|
| 107 |
+
const result = parseDIF(text)
|
| 108 |
+
expect(result.x).toEqual([5.0, 10.0, 15.0])
|
| 109 |
+
expect(result.y).toEqual([100.0, 200.0, 300.0])
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
it('skips comment lines', () => {
|
| 113 |
+
const text = '# This is a comment\n5.0 100.0\n10.0 200.0\n'
|
| 114 |
+
const result = parseDIF(text)
|
| 115 |
+
expect(result.x).toHaveLength(2)
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
it('skips metadata lines', () => {
|
| 119 |
+
const text = 'CELL PARAMETERS: 5.0 5.0 5.0 90 90 90\nSPACE GROUP: Fm-3m\n5.0 100.0\n'
|
| 120 |
+
const result = parseDIF(text)
|
| 121 |
+
expect(result.x).toEqual([5.0])
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
it('skips lines starting with underscore', () => {
|
| 125 |
+
const text = '_cell_length_a 5.431\n5.0 100.0\n'
|
| 126 |
+
const result = parseDIF(text)
|
| 127 |
+
expect(result.x).toEqual([5.0])
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
it('handles empty input', () => {
|
| 131 |
+
const result = parseDIF('')
|
| 132 |
+
expect(result.x).toEqual([])
|
| 133 |
+
expect(result.y).toEqual([])
|
| 134 |
+
})
|
| 135 |
+
|
| 136 |
+
it('handles tab-separated data', () => {
|
| 137 |
+
const text = '5.0\t100.0\n10.0\t200.0\n'
|
| 138 |
+
const result = parseDIF(text)
|
| 139 |
+
expect(result.x).toEqual([5.0, 10.0])
|
| 140 |
+
expect(result.y).toEqual([100.0, 200.0])
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
it('skips lines starting with letters', () => {
|
| 144 |
+
const text = 'Header line\n5.0 100.0\nAnother header\n10.0 200.0\n'
|
| 145 |
+
const result = parseDIF(text)
|
| 146 |
+
expect(result.x).toEqual([5.0, 10.0])
|
| 147 |
+
})
|
| 148 |
+
})
|
| 149 |
+
|
| 150 |
+
// ---------------------------------------------------------------------------
|
| 151 |
+
// extractMetadata
|
| 152 |
+
// ---------------------------------------------------------------------------
|
| 153 |
+
describe('extractMetadata', () => {
|
| 154 |
+
it('detects wavelength from numeric pattern', () => {
|
| 155 |
+
const text = '_diffrn_radiation_wavelength 0.6199\n'
|
| 156 |
+
const result = extractMetadata(text)
|
| 157 |
+
expect(result.wavelength).toBeCloseTo(0.6199, 4)
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
it('detects Cu Ka radiation', () => {
|
| 161 |
+
const text = 'Radiation: Cu Ka\n'
|
| 162 |
+
const result = extractMetadata(text)
|
| 163 |
+
expect(result.wavelength).toBeCloseTo(1.5406, 4)
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
it('detects Mo Ka radiation', () => {
|
| 167 |
+
const text = 'Radiation: Mo Ka\n'
|
| 168 |
+
const result = extractMetadata(text)
|
| 169 |
+
expect(result.wavelength).toBeCloseTo(0.7107, 4)
|
| 170 |
+
})
|
| 171 |
+
|
| 172 |
+
it('extracts space group number', () => {
|
| 173 |
+
const text = '_symmetry_Int_Tables_number 225\n'
|
| 174 |
+
const result = extractMetadata(text)
|
| 175 |
+
expect(result.spaceGroup).toBe('225')
|
| 176 |
+
})
|
| 177 |
+
|
| 178 |
+
it('extracts cell parameters', () => {
|
| 179 |
+
const text = 'CELL PARAMETERS: 5.431 5.431 5.431 90.0 90.0 90.0\n'
|
| 180 |
+
const result = extractMetadata(text)
|
| 181 |
+
expect(result.cellParams).not.toBeNull()
|
| 182 |
+
expect(result.cellParams).toContain('5.431')
|
| 183 |
+
})
|
| 184 |
+
|
| 185 |
+
it('returns null for missing data', () => {
|
| 186 |
+
const result = extractMetadata('Some random text without metadata\n')
|
| 187 |
+
expect(result.wavelength).toBeNull()
|
| 188 |
+
expect(result.spaceGroup).toBeNull()
|
| 189 |
+
expect(result.cellParams).toBeNull()
|
| 190 |
+
})
|
| 191 |
+
|
| 192 |
+
it('rejects wavelengths outside X-ray range', () => {
|
| 193 |
+
const text = 'wavelength: 0.001\n'
|
| 194 |
+
const result = extractMetadata(text)
|
| 195 |
+
expect(result.wavelength).toBeNull()
|
| 196 |
+
})
|
| 197 |
+
})
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
import { fileURLToPath } from 'url'
|
| 4 |
+
import { dirname, resolve } from 'path'
|
| 5 |
+
|
| 6 |
+
const __filename = fileURLToPath(import.meta.url)
|
| 7 |
+
const __dirname = dirname(__filename)
|
| 8 |
+
|
| 9 |
+
// https://vitejs.dev/config/
|
| 10 |
+
export default defineConfig({
|
| 11 |
+
plugins: [react()],
|
| 12 |
+
resolve: {
|
| 13 |
+
alias: {
|
| 14 |
+
'@': resolve(__dirname, './src'),
|
| 15 |
+
},
|
| 16 |
+
},
|
| 17 |
+
server: {
|
| 18 |
+
port: 5173,
|
| 19 |
+
proxy: {
|
| 20 |
+
'/api': {
|
| 21 |
+
target: 'http://localhost:7286',
|
| 22 |
+
changeOrigin: true,
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
},
|
| 26 |
+
build: {
|
| 27 |
+
outDir: 'dist',
|
| 28 |
+
assetsDir: 'assets',
|
| 29 |
+
sourcemap: false,
|
| 30 |
+
}
|
| 31 |
+
})
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.24.0
|
| 3 |
+
python-multipart>=0.0.6
|
| 4 |
+
numpy>=1.24.3
|
| 5 |
+
torch>=2.1.0
|
| 6 |
+
safetensors>=0.4.0
|
| 7 |
+
huggingface-hub>=0.20.0
|
| 8 |
+
pydantic>=2.5.0
|
| 9 |
+
spglib>=2.4.0
|