42Cummer commited on
Commit
45dcc02
·
verified ·
1 Parent(s): d883d10

Upload 9 files

Browse files
Files changed (10) hide show
  1. .gitattributes +1 -0
  2. Dockerfile +35 -0
  3. readme.md +26 -0
  4. requirements.txt +10 -0
  5. src/__init__.py +2 -0
  6. src/api.py +55 -0
  7. src/app.py +118 -0
  8. src/doper.py +97 -0
  9. static/favicon.png +3 -0
  10. static/index.html +213 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ static/favicon.png filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Install system dependencies if needed
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy requirements first for better caching
11
+ COPY requirements.txt .
12
+
13
+ # Install Python dependencies
14
+ RUN pip install --no-cache-dir --upgrade pip && \
15
+ pip install --no-cache-dir -r requirements.txt
16
+
17
+ # Copy application code
18
+ COPY src/ ./src/
19
+
20
+ # Copy static files for UI
21
+ COPY static/ ./static/
22
+
23
+ # Create __init__.py if it doesn't exist (for proper Python package structure)
24
+ RUN touch src/__init__.py
25
+
26
+ # Expose port (Hugging Face Spaces uses 7860 by default, but can use PORT env var)
27
+ EXPOSE 7860
28
+
29
+ # Set environment variables
30
+ ENV PYTHONUNBUFFERED=1
31
+ ENV PORT=7860
32
+
33
+ # Run the application
34
+ # Hugging Face Spaces will set the PORT environment variable
35
+ CMD uvicorn src.app:app --host 0.0.0.0 --port ${PORT:-7860}
readme.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AURELIUS Battery Optimizer
2
+
3
+ ![Python](https://img.shields.io/badge/Python-3.10-blue) ![FastAPI](https://img.shields.io/badge/Framework-FastAPI-green) ![Docker](https://img.shields.io/badge/Deploy-Docker-2496ED)
4
+
5
+ ## 🔬 The Problem
6
+ Traditional materials discovery is slow. Simulating the stability of a doped battery material usually requires setting up complex DFT calculations or managing messy scripts.
7
+
8
+ ## 🚀 The Solution
9
+ **Aurelius** is a microservice architecture that allows researchers to screen thousands of doping strategies in seconds.
10
+
11
+ * **Universal Physics Engine:** Dynamically calculates Vegard's Law lattice strain and electronegativity mismatches for *any* host-dopant system using periodic table data (`mendeleev`).
12
+ * **Real-Time Data Integration:** Connects to the **Materials Project API** to fetch ground-truth thermodynamic properties (Band Gap, Volume) for the host material.
13
+ * **High-Entropy Support:** Capable of simulating complex co-doping recipes (mixtures of 2+ elements) to predict stability in high-entropy configurations.
14
+
15
+ ## 🛠️ Engineering Architecture
16
+ This is not just a script; it is a production-ready system designed for scale.
17
+
18
+ * **Backend:** FastAPI (Python) serving an async REST API.
19
+ * **Streaming:** Implements **NDJSON Streaming** to deliver inference results row-by-row, preventing Gateway Timeouts during large batch processing.
20
+ * **Resilience:** Features a "Graceful Degradation" adapter that switches to theoretical estimation if the external Materials Project API is down or the material is novel.
21
+ * **Deployment:** Fully Dockerized container serving both the API and a lightweight static UI.
22
+
23
+ ## 💻 Usage
24
+ 1. Enter a host formula (e.g., `Li3PS4`).
25
+ 2. Define a batch of doping recipes (JSON).
26
+ 3. The system streams stability predictions back in real-time.
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ pydantic
4
+ mendeleev
5
+ mp-api
6
+ numpy
7
+ scikit-learn
8
+ scipy
9
+ python-dotenv
10
+ typing_extensions
src/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Project Aurelius: Material Architect
2
+ # Generative Design API for Solid State Batteries
src/api.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from typing import Dict, Any
4
+
5
+ # Workaround for Python 3.10: Add NotRequired to typing module
6
+ if sys.version_info < (3, 11):
7
+ try:
8
+ from typing_extensions import NotRequired
9
+ import typing
10
+ if not hasattr(typing, 'NotRequired'):
11
+ typing.NotRequired = NotRequired
12
+ except ImportError:
13
+ pass
14
+
15
+ # Try to import MPRester, handle case where key is missing/invalid gracefully
16
+ try:
17
+ from mp_api.client import MPRester
18
+ except ImportError:
19
+ MPRester = None
20
+
21
+ class MaterialsProjectAdapter:
22
+ def __init__(self, api_key: str = None):
23
+ self.api_key = api_key
24
+ self.has_key = api_key is not None and len(api_key) > 10 and MPRester is not None
25
+
26
+ def get_material_properties(self, formula: str) -> Dict[str, Any]:
27
+ print(f"CONTACTING MATERIALS PROJECT: {formula}...")
28
+
29
+ if self.has_key:
30
+ try:
31
+ with MPRester(self.api_key) as mpr:
32
+ docs = mpr.materials.summary.search(
33
+ formula=[formula],
34
+ fields=["band_gap", "volume", "formation_energy_per_atom"]
35
+ )
36
+ if docs:
37
+ best_match = min(docs, key=lambda x: x.formation_energy_per_atom)
38
+ return {
39
+ "source": "Materials Project (Real Data)",
40
+ "band_gap": float(best_match.band_gap),
41
+ "volume": float(best_match.volume),
42
+ "is_estimated": False
43
+ }
44
+ except Exception as e:
45
+ print(f"❌ API ERROR: {str(e)}. Switching to fallback.")
46
+ # Fallback if no key or API fails
47
+ return self._generate_fallback(formula)
48
+
49
+ def _generate_fallback(self, formula: str) -> Dict[str, Any]:
50
+ return {
51
+ "source": "Theoretical Estimation (Fallback)",
52
+ "band_gap": 2.0,
53
+ "volume": 200.0,
54
+ "is_estimated": True
55
+ }
src/app.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from fastapi import FastAPI
4
+ from fastapi.responses import StreamingResponse, FileResponse
5
+ from fastapi.staticfiles import StaticFiles
6
+ from pydantic import BaseModel, Field
7
+ from typing import Dict, List
8
+ from src.doper import UniversalMaterialsOptimizer
9
+ from dotenv import load_dotenv
10
+
11
+ app = FastAPI(
12
+ title="AURELIUS Battery Optimizer",
13
+ description="Generative Design API for Co-Doped Solid State Batteries with Streaming Support",
14
+ version="4.0"
15
+ )
16
+
17
+ # Load environment variables from .env file
18
+ load_dotenv()
19
+
20
+ # Get API Key from Hugging Face Secrets (Environment Variable)
21
+ MP_API_KEY = os.getenv("MP_API_KEY") or os.getenv("MP_API")
22
+
23
+ # --- SERVE THE UI ---
24
+ # This makes the "static" folder accessible
25
+ app.mount("/static", StaticFiles(directory="static"), name="static")
26
+
27
+ # --- NEW INPUT MODEL ---
28
+ class MultiDopantRequest(BaseModel):
29
+ host_formula: str = Field(..., example="Li3PS4")
30
+ host_site_element: str = Field(..., example="S")
31
+
32
+ # Now we accept a list of RECIPES to test
33
+ # Example: Check "Just Cl" AND "Cl + Br mixed"
34
+ recipes: List[Dict[str, float]] = Field(..., example=[
35
+ {"Cl": 0.2}, # Pure Cl doping
36
+ {"Cl": 0.1, "Br": 0.1}, # Co-doping (High Entropy)
37
+ {"I": 0.05, "F": 0.05} # Exotic mix
38
+ ])
39
+
40
+ @app.get("/")
41
+ async def read_index():
42
+ # When user hits the homepage, send the HTML file
43
+ return FileResponse('static/index.html')
44
+
45
+ # --- THE GENERATOR FUNCTION ---
46
+ # This function doesn't run all at once. It pauses and resumes.
47
+ async def run_simulation_stream(job: MultiDopantRequest):
48
+
49
+ # 1. Initialize Engine (Once)
50
+ optimizer = UniversalMaterialsOptimizer(
51
+ host_formula=job.host_formula,
52
+ host_element_symbol=job.host_site_element,
53
+ api_key=MP_API_KEY
54
+ )
55
+
56
+ # 2. First, stream the metadata header
57
+ # This tells the client "I am ready, here is the context"
58
+ metadata = {
59
+ "type": "meta",
60
+ "host_system": job.host_formula,
61
+ "base_properties": optimizer.props
62
+ }
63
+ yield json.dumps(metadata) + "\n"
64
+
65
+ # 3. Stream results one by one
66
+ for recipe in job.recipes:
67
+ try:
68
+ result = optimizer.analyze_mixture(recipe)
69
+ # Add a type tag so client knows this is a data row
70
+ result["type"] = "data"
71
+
72
+ # Send this chunk immediately!
73
+ yield json.dumps(result) + "\n"
74
+
75
+ except Exception as e:
76
+ error_msg = {"type": "error", "recipe": recipe, "msg": str(e)}
77
+ yield json.dumps(error_msg) + "\n"
78
+
79
+ @app.post("/simulate_stream")
80
+ def simulate_mixture_stream(job: MultiDopantRequest):
81
+ """
82
+ Returns a StreamingResponse.
83
+ The client will receive line-by-line JSON as calculations finish.
84
+ Ideally, use this endpoint for large numbers of recipes.
85
+ For small numbers of recipes, use /simulate_mix instead.
86
+ """
87
+ return StreamingResponse(
88
+ run_simulation_stream(job),
89
+ media_type="application/x-ndjson"
90
+ )
91
+
92
+ @app.post("/simulate_mix")
93
+ def simulate_mixture(job: MultiDopantRequest):
94
+ """
95
+ Non-streaming endpoint for backward compatibility.
96
+ Returns all results at once after all calculations complete.
97
+ Use this endpoint if there are relatively few recipes to test.
98
+ For large numbers of recipes, use /simulate_stream instead.
99
+ """
100
+ optimizer = UniversalMaterialsOptimizer(
101
+ host_formula=job.host_formula,
102
+ host_element_symbol=job.host_site_element,
103
+ api_key=MP_API_KEY
104
+ )
105
+
106
+ results = []
107
+ for recipe in job.recipes:
108
+ try:
109
+ res = optimizer.analyze_mixture(recipe)
110
+ results.append(res)
111
+ except Exception as e:
112
+ results.append({"recipe": recipe, "error": str(e)})
113
+
114
+ return {
115
+ "host_system": job.host_formula,
116
+ "base_properties": optimizer.props,
117
+ "results": results
118
+ }
src/doper.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from src.api import MaterialsProjectAdapter
2
+ from mendeleev import element
3
+
4
+ class UniversalMaterialsOptimizer:
5
+ def __init__(self, host_formula: str, host_element_symbol: str, api_key: str = None):
6
+ self.adapter = MaterialsProjectAdapter(api_key)
7
+ self.props = self.adapter.get_material_properties(host_formula)
8
+ self.base_voltage = self.props['band_gap']
9
+
10
+ host = element(host_element_symbol)
11
+ self.host_radius = host.ionic_radii[0].ionic_radius
12
+ self.host_en = host.en_pauling
13
+ self.host_symbol = host_element_symbol
14
+
15
+ def analyze_dopant(self, dopant_symbol: str, concentration: float):
16
+ dopant = element(dopant_symbol)
17
+
18
+ # 1. Physics Calculations
19
+ delta_en = dopant.en_pauling - self.host_en
20
+ # Simple ionic radius lookup (using first available radius for simplicity)
21
+ dop_rad = dopant.ionic_radii[0].ionic_radius if dopant.ionic_radii else self.host_radius
22
+ radius_diff = dop_rad - self.host_radius
23
+
24
+ voltage_gain = 1.5 * concentration * delta_en
25
+ predicted_voltage = self.base_voltage + voltage_gain
26
+ strain_energy = (radius_diff ** 2) * concentration * 100
27
+
28
+ # 2. Verdict Logic
29
+ status = "Stable"
30
+ if strain_energy > 500: status = "Critical Strain - Phase Separation"
31
+ elif predicted_voltage < 1.0: status = "Voltage Collapse"
32
+
33
+ return {
34
+ "dopant": dopant_symbol,
35
+ "concentration": concentration,
36
+ "predicted_voltage": round(predicted_voltage, 3),
37
+ "lattice_strain": round(strain_energy, 2),
38
+ "stability_status": status,
39
+ "data_confidence": "Low (Estimated)" if self.props['is_estimated'] else "High (Real Data)"
40
+ }
41
+
42
+ def analyze_mixture(self, dopant_recipe: dict):
43
+ """
44
+ Analyzes a mixture of dopants (Co-doping).
45
+ Input: {"Cl": 0.1, "Br": 0.1} -> implies 0.2 total doping
46
+ """
47
+ total_conc = sum(dopant_recipe.values())
48
+
49
+ # 1. Edge Case: Empty Recipe
50
+ if total_conc == 0:
51
+ return {"error": "Recipe is empty. Please add at least one dopant."}
52
+
53
+ # 2. Calculate Weighted Properties (Vegard's Law)
54
+ weighted_radius = 0.0
55
+ weighted_en = 0.0
56
+
57
+ detailed_breakdown = []
58
+
59
+ for symbol, amount in dopant_recipe.items():
60
+ atom = element(symbol)
61
+
62
+ # Get radius (default to host radius if data missing to prevent crash)
63
+ r = atom.ionic_radii[0].ionic_radius if atom.ionic_radii else self.host_radius
64
+ en = atom.en_pauling if atom.en_pauling else self.host_en
65
+
66
+ # Contribution to the average
67
+ fraction = amount / total_conc
68
+ weighted_radius += r * fraction
69
+ weighted_en += en * fraction
70
+
71
+ detailed_breakdown.append(f"{symbol} ({amount*100:.1f}%)")
72
+
73
+ # 3. Compare 'Virtual Dopant' vs Host
74
+ radius_diff = weighted_radius - self.host_radius
75
+ delta_en = weighted_en - self.host_en
76
+
77
+ # 4. Physics Engine
78
+ # Voltage is boosted by the total amount of dopant * average electronegativity gain
79
+ voltage_gain = 1.5 * total_conc * delta_en
80
+ predicted_voltage = self.base_voltage + voltage_gain
81
+
82
+ # Strain is proportional to the mismatch squared * total concentration
83
+ strain_energy = (radius_diff ** 2) * total_conc * 100
84
+
85
+ # 5. Verdict
86
+ status = "Stable"
87
+ if strain_energy > 500: status = "Critical Strain - Phase Separation"
88
+ elif predicted_voltage < 1.0: status = "Voltage Collapse"
89
+
90
+ return {
91
+ "recipe_description": ", ".join(detailed_breakdown),
92
+ "total_doping_level": round(total_conc, 3),
93
+ "predicted_voltage": round(predicted_voltage, 3),
94
+ "lattice_strain": round(strain_energy, 2),
95
+ "stability_status": status,
96
+ "data_confidence": "Low (Estimated)" if self.props['is_estimated'] else "High (Real Data)"
97
+ }
static/favicon.png ADDED

Git LFS Details

  • SHA256: aeb2ad5487d7c8992d2dab7c2fb51352bdbd30bd9a2ba63d22573f83659e2f97
  • Pointer size: 132 Bytes
  • Size of remote file: 2.19 MB
static/index.html ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AURELIUS | BATTERY OPTIMIZER</title>
7
+ <link rel="icon" type="image/png" href="/static/favicon.png">
8
+ <style>
9
+ /* "Cyberpunk Lab" Aesthetic */
10
+ body { font-family: 'Courier New', monospace; background-color: #0d1117; color: #c9d1d9; max-width: 950px; margin: 40px auto; padding: 20px; font-size: 16px; }
11
+ h1 { border-bottom: 1px solid #30363d; padding-bottom: 10px; color: #58a6ff; letter-spacing: 2px;}
12
+
13
+ .control-panel { background: #161b22; padding: 25px; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); }
14
+
15
+ label { display: block; margin-top: 15px; margin-bottom: 5px; color: #8b949e; font-size: 15px; text-transform: uppercase; letter-spacing: 1px; }
16
+ input, textarea, button { width: 100%; box-sizing: border-box; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; font-family: inherit; font-size: 16px; }
17
+
18
+ input { padding: 10px; }
19
+ textarea { padding: 10px; resize: vertical; font-size: 16px; }
20
+ textarea:focus { outline: none; border-color: #58a6ff; }
21
+
22
+ /* Action Buttons */
23
+ .btn-group { display: flex; gap: 10px; margin-bottom: 10px; }
24
+ .btn-small { width: auto; padding: 5px 15px; font-size: 14px; background: #21262d; cursor: pointer; border-radius: 4px; transition: all 0.2s; }
25
+ .btn-small:hover { background: #30363d; border-color: #8b949e; }
26
+
27
+ .btn-primary { margin-top: 20px; padding: 15px; background: #238636; color: white; cursor: pointer; font-weight: bold; border: none; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; }
28
+ .btn-primary:hover { background: #2ea043; }
29
+
30
+ /* Results Table */
31
+ table { width: 100%; border-collapse: collapse; margin-top: 30px; font-size: 16px; }
32
+ th, td { text-align: left; padding: 12px; border-bottom: 1px solid #21262d; }
33
+ th { color: #8b949e; text-transform: uppercase; font-size: 14px; }
34
+
35
+ /* Status Indicators */
36
+ .status-stable { color: #3fb950; font-weight: bold; }
37
+ .status-critical { color: #f85149; font-weight: bold; }
38
+ .status-warn { color: #d29922; }
39
+
40
+ /* Animation */
41
+ .log-entry { opacity: 0; animation: fadeIn 0.4s forwards; }
42
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
43
+ </style>
44
+ </head>
45
+ <body>
46
+
47
+ <h1>AURELIUS // BATTERY OPTIMIZER</h1>
48
+
49
+ <div class="control-panel">
50
+ <div style="display: flex; gap: 20px;">
51
+ <div style="flex: 1;">
52
+ <label>Host Formula</label>
53
+ <input type="text" id="host" value="Li3PS4">
54
+ </div>
55
+ <div style="flex: 1;">
56
+ <label>Substitution Site</label>
57
+ <input type="text" id="site" value="S">
58
+ </div>
59
+ </div>
60
+
61
+ <label>Doping Strategies (JSON Batch)</label>
62
+
63
+ <div class="btn-group">
64
+ <button class="btn-small" onclick="loadExample('simple')">LOAD: Single Dopants</button>
65
+ <button class="btn-small" onclick="loadExample('complex')">LOAD: High-Entropy Mix</button>
66
+ <button class="btn-small" onclick="loadExample('stress')">LOAD: Stress Test (Solubility)</button>
67
+ </div>
68
+
69
+ <textarea id="jsonInput" rows="8"></textarea>
70
+
71
+ <button class="btn-primary" onclick="startSimulation()">>> INITIATE SIMULATION STREAM</button>
72
+ </div>
73
+
74
+ <div id="statusLine" style="color: #8b949e; margin-bottom: 10px; font-size: 15px; min-height: 20px;">SYSTEM READY. AWAITING INPUT.</div>
75
+
76
+ <table id="resultsTable">
77
+ <thead>
78
+ <tr>
79
+ <th style="width: 30%">Composition Strategy</th>
80
+ <th>Pred. Voltage</th>
81
+ <th>Lattice Strain</th>
82
+ <th>Stability Verdict</th>
83
+ </tr>
84
+ </thead>
85
+ <tbody></tbody>
86
+ </table>
87
+
88
+ <script>
89
+ // --- 1. PRE-LOAD DATA FUNCTION ---
90
+ function loadExample(type) {
91
+ const data = {
92
+ simple: `[
93
+ {"Cl": 0.1},
94
+ {"Br": 0.1},
95
+ {"I": 0.05}
96
+ ]`,
97
+ complex: `[
98
+ {"Cl": 0.1, "Br": 0.1},
99
+ {"I": 0.05, "F": 0.05},
100
+ {"Cl": 0.2, "I": 0.02},
101
+ {"Br": 0.15, "Cl": 0.05}
102
+ ]`,
103
+ stress: `[
104
+ {"Cl": 0.9},
105
+ {"I": 0.5},
106
+ {"F": 0.5, "Cl": 0.5}
107
+ ]`
108
+ };
109
+ document.getElementById("jsonInput").value = data[type];
110
+ document.getElementById("jsonInput").style.borderColor = "#30363d"; // Reset color
111
+ }
112
+
113
+ // Initialize with simple data
114
+ loadExample('simple');
115
+
116
+ // --- 2. MAIN SIMULATION LOGIC ---
117
+ async function startSimulation() {
118
+ const tableBody = document.querySelector("#resultsTable tbody");
119
+ const statusLine = document.getElementById("statusLine");
120
+ const inputArea = document.getElementById("jsonInput");
121
+
122
+ // UX: Reset
123
+ tableBody.innerHTML = "";
124
+ statusLine.innerText = "PARSING BATCH INSTRUCTIONS...";
125
+ inputArea.style.borderColor = "#30363d";
126
+
127
+ // A. VALIDATE JSON
128
+ let parsedRecipes;
129
+ try {
130
+ parsedRecipes = JSON.parse(inputArea.value);
131
+ } catch (e) {
132
+ statusLine.innerHTML = `<span style="color: #f85149">⚠️ SYNTAX ERROR: ${e.message}</span>`;
133
+ inputArea.style.borderColor = "#f85149";
134
+ return;
135
+ }
136
+
137
+ if (!Array.isArray(parsedRecipes)) {
138
+ statusLine.innerHTML = `<span style="color: #f85149">⚠️ FORMAT ERROR: Root must be a list [...]</span>`;
139
+ return;
140
+ }
141
+
142
+ // B. CONNECT TO STREAM
143
+ statusLine.innerText = "ESTABLISHING UPLINK TO PHYSICS ENGINE...";
144
+
145
+ try {
146
+ const payload = {
147
+ host_formula: document.getElementById("host").value,
148
+ host_site_element: document.getElementById("site").value,
149
+ recipes: parsedRecipes
150
+ };
151
+
152
+ const response = await fetch("/simulate_stream", {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json" },
155
+ body: JSON.stringify(payload)
156
+ });
157
+
158
+ if (!response.ok) throw new Error(await response.text());
159
+
160
+ // C. READ THE STREAM
161
+ const reader = response.body.getReader();
162
+ const decoder = new TextDecoder();
163
+ statusLine.innerText = "RECEIVING TELEMETRY STREAM...";
164
+
165
+ while (true) {
166
+ const { done, value } = await reader.read();
167
+ if (done) break;
168
+
169
+ const chunk = decoder.decode(value, { stream: true });
170
+ const lines = chunk.split("\n");
171
+
172
+ for (const line of lines) {
173
+ if (!line.trim()) continue;
174
+ try {
175
+ const data = JSON.parse(line);
176
+
177
+ if (data.type === "meta") {
178
+ const source = data.base_properties.source.includes("Real") ? "REAL" : "ESTIMATED";
179
+ statusLine.innerText = `CONNECTED: ${data.host_system} | DATA SOURCE: ${source}`;
180
+ }
181
+ else if (data.type === "data") {
182
+ addRow(data);
183
+ }
184
+ } catch (e) { console.error(e); }
185
+ }
186
+ }
187
+ statusLine.innerText = "BATCH PROCESSING COMPLETE.";
188
+
189
+ } catch (err) {
190
+ statusLine.innerHTML = `<span style="color: #f85149">❌ SYSTEM FAILURE: ${err.message}</span>`;
191
+ }
192
+ }
193
+
194
+ function addRow(data) {
195
+ const tableBody = document.querySelector("#resultsTable tbody");
196
+ const row = document.createElement("tr");
197
+ row.className = "log-entry";
198
+
199
+ let statusClass = "status-warn";
200
+ if (data.stability_status.includes("Stable")) statusClass = "status-stable";
201
+ if (data.stability_status.includes("Critical") || data.stability_status.includes("Collapse")) statusClass = "status-critical";
202
+
203
+ row.innerHTML = `
204
+ <td><strong style="color: #c9d1d9">${data.recipe_description}</strong></td>
205
+ <td>${data.predicted_voltage.toFixed(3)} V</td>
206
+ <td>${data.lattice_strain.toFixed(1)} MJ</td>
207
+ <td class="${statusClass}">${data.stability_status}</td>
208
+ `;
209
+ tableBody.appendChild(row);
210
+ }
211
+ </script>
212
+ </body>
213
+ </html>