linked-liszt commited on
Commit
6d08d46
·
verified ·
1 Parent(s): ea1f942

Upload folder using huggingface_hub

Browse files
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 UI
3
- emoji: 🌍
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: docker
 
 
 
 
 
 
 
 
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
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