Annon48532 commited on
Commit
2efce37
·
verified ·
1 Parent(s): 635ecd9

Upload Albitron-TPH2O_V1.0_app_200limit.py

Browse files
Files changed (1) hide show
  1. Albitron-TPH2O_V1.0_app_200limit.py +646 -0
Albitron-TPH2O_V1.0_app_200limit.py ADDED
@@ -0,0 +1,646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ from __future__ import annotations
3
+
4
+ import html
5
+ import os
6
+ import re
7
+ import tempfile
8
+ from datetime import datetime
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import gradio as gr
14
+ import pandas as pd
15
+
16
+ from albitron_runtime import AlbitronRuntime, table_from_ui
17
+
18
+ BASE = Path(__file__).resolve().parent
19
+ PUBLIC_NAME = "Albitron-TPH2O"
20
+ PUBLIC_VERSION = "V1.0"
21
+ BATCH_ROW_LIMIT = 200
22
+ MODEL_PATH = Path(
23
+ os.environ.get(
24
+ "ALBITRON_MODEL_PATH",
25
+ BASE / "models" / "Albitron-TPH2O_V1.0_model.joblib",
26
+ )
27
+ )
28
+ SUPPORT_PATH = Path(
29
+ os.environ.get(
30
+ "ALBITRON_SUPPORT_PATH",
31
+ BASE / "models" / "Albitron-TPH2O_V1.0_support.joblib",
32
+ )
33
+ )
34
+
35
+ COMPONENTS = [
36
+ "SiO2", "TiO2", "Al2O3", "FeOt", "MgO", "CaO", "Na2O", "K2O", "Analytical total"
37
+ ]
38
+ EMPTY_INPUT = pd.DataFrame(
39
+ {
40
+ "Component": COMPONENTS,
41
+ "Liquid wt.%": [None] * 9,
42
+ "Plagioclase wt.%": [None] * 9,
43
+ }
44
+ )
45
+ EXAMPLE_INPUT = pd.DataFrame(
46
+ {
47
+ "Component": COMPONENTS,
48
+ "Liquid wt.%": [64.2, 0.6, 16.8, 3.7, 2.9, 4.9, 4.8, 1.8, 95.6],
49
+ "Plagioclase wt.%": [51.752117, 0.0, 30.730948, 0.700706, 0.100101, 12.812903, 3.803831, 0.100101, 99.3],
50
+ }
51
+ )
52
+
53
+ BATCH_TEMPLATE = BASE / "examples" / "Albitron-TPH2O_V1.0_batch_template.csv"
54
+ BATCH_EXAMPLE = BASE / "examples" / "example_input.csv"
55
+
56
+ CSS = r"""
57
+ :root {
58
+ --al-ink: #18212b;
59
+ --al-text: #344054;
60
+ --al-muted: #667085;
61
+ --al-line: #e3e8ef;
62
+ --al-soft: #f7f9fc;
63
+ --al-panel: #ffffff;
64
+ --al-blue: #326e9d;
65
+ --al-blue-soft: #eef6fc;
66
+ --al-blue-line: #b9d3e7;
67
+ --al-red: #b64d4d;
68
+ --al-gold: #b28722;
69
+ --al-water: #3978b8;
70
+ --al-green: #24724c;
71
+ --al-amber: #886719;
72
+ --al-low: #993d3d;
73
+ }
74
+
75
+ .gradio-container {
76
+ max-width: 1120px !important;
77
+ margin: 0 auto !important;
78
+ font-family: Arial, "Helvetica Neue", Helvetica, sans-serif !important;
79
+ color: var(--al-text);
80
+ }
81
+
82
+ #al-hero {
83
+ padding: 30px 4px 18px 4px;
84
+ border-bottom: 1px solid var(--al-line);
85
+ margin-bottom: 14px;
86
+ }
87
+ #al-brand-row {
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: space-between;
91
+ gap: 16px;
92
+ flex-wrap: wrap;
93
+ }
94
+ .al-wordmark {
95
+ font-family: Arial, "Helvetica Neue", Helvetica, sans-serif !important;
96
+ font-size: 2.02rem;
97
+ line-height: 1.08;
98
+ font-weight: 700;
99
+ letter-spacing: .012em;
100
+ color: var(--al-ink);
101
+ font-variant-ligatures: none;
102
+ text-rendering: geometricPrecision;
103
+ }
104
+ .al-release {
105
+ display: inline-flex;
106
+ align-items: center;
107
+ gap: 8px;
108
+ padding: 6px 10px;
109
+ border: 1px solid var(--al-blue-line);
110
+ border-radius: 999px;
111
+ font-size: .78rem;
112
+ font-weight: 700;
113
+ color: var(--al-blue);
114
+ background: #f7fbff;
115
+ white-space: nowrap;
116
+ }
117
+ .al-subtitle {
118
+ margin-top: 10px;
119
+ color: var(--al-muted);
120
+ font-size: 1rem;
121
+ line-height: 1.5;
122
+ }
123
+ .al-meta {
124
+ margin-top: 8px;
125
+ color: #7a8491;
126
+ font-size: .83rem;
127
+ }
128
+
129
+ .quick-nav {
130
+ display: grid;
131
+ grid-template-columns: 1fr 1fr;
132
+ gap: 10px;
133
+ margin: 14px 0 16px 0;
134
+ }
135
+ .quick-nav a {
136
+ text-decoration: none !important;
137
+ color: var(--al-text) !important;
138
+ background: white;
139
+ border: 1px solid var(--al-line);
140
+ border-radius: 12px;
141
+ padding: 11px 14px;
142
+ display: block;
143
+ transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
144
+ }
145
+ .quick-nav a:hover {
146
+ border-color: #b8c9d8;
147
+ box-shadow: 0 4px 12px rgba(16, 24, 40, .06);
148
+ transform: translateY(-1px);
149
+ }
150
+ .quick-nav .nav-title {font-weight: 700; color: var(--al-ink);}
151
+ .quick-nav .nav-sub {font-size: .82rem; color: var(--al-muted); margin-top: 3px;}
152
+
153
+ .al-status {
154
+ border: 1px solid var(--al-line);
155
+ background: white;
156
+ border-radius: 10px;
157
+ padding: 9px 12px;
158
+ margin: 4px 0 12px 0;
159
+ font-size: .9rem;
160
+ }
161
+ .asset-ok {color: var(--al-green); font-weight: 700;}
162
+ .asset-missing {color: var(--al-low); font-weight: 700;}
163
+
164
+ .input-note {
165
+ background: #f8fafc;
166
+ border: 1px solid var(--al-line);
167
+ border-radius: 12px;
168
+ padding: 13px 15px;
169
+ margin-bottom: 18px;
170
+ line-height: 1.58;
171
+ color: #475467;
172
+ }
173
+
174
+ .section-head {margin: 24px 0 10px 0; padding: 0 2px;}
175
+ .section-kicker {
176
+ font-size: .75rem;
177
+ font-weight: 700;
178
+ color: var(--al-blue);
179
+ letter-spacing: .075em;
180
+ text-transform: uppercase;
181
+ }
182
+ .section-title {
183
+ font-size: 1.38rem;
184
+ color: var(--al-ink);
185
+ font-weight: 700;
186
+ margin-top: 3px;
187
+ }
188
+ .section-desc {color: var(--al-muted); font-size: .91rem; margin-top: 4px; line-height: 1.5;}
189
+
190
+ .tool-panel {
191
+ border: 1px solid var(--al-line) !important;
192
+ border-radius: 14px !important;
193
+ background: var(--al-panel) !important;
194
+ padding: 14px !important;
195
+ box-shadow: 0 1px 3px rgba(16,24,40,.035);
196
+ }
197
+
198
+ .step-strip {
199
+ display: flex;
200
+ flex-wrap: wrap;
201
+ gap: 7px;
202
+ margin: 4px 0 12px 0;
203
+ }
204
+ .step-chip {
205
+ border: 1px solid #d9e1e8;
206
+ background: #fafcfe;
207
+ border-radius: 999px;
208
+ padding: 5px 9px;
209
+ color: #526273;
210
+ font-size: .78rem;
211
+ }
212
+ .step-chip b {color: var(--al-blue);}
213
+
214
+ .batch-banner {
215
+ background: linear-gradient(180deg, #f6fbff 0%, #eff6fb 100%);
216
+ border: 1px solid var(--al-blue-line);
217
+ border-left: 5px solid var(--al-blue);
218
+ border-radius: 13px;
219
+ padding: 15px 17px;
220
+ margin: 30px 0 10px 0;
221
+ }
222
+ .batch-banner .title {font-size: 1.48rem; font-weight: 700; color: #243b53;}
223
+ .batch-banner .desc {margin-top: 5px; color: #526475; font-size: .92rem; line-height: 1.52;}
224
+ .template-callout {
225
+ background: #fffdf6;
226
+ border: 1px solid #e8d9a8;
227
+ border-radius: 11px;
228
+ padding: 11px 13px;
229
+ margin: 4px 0 11px 0;
230
+ color: #63551f;
231
+ font-size: .9rem;
232
+ line-height: 1.48;
233
+ }
234
+
235
+ .result-grid {
236
+ display: grid;
237
+ grid-template-columns: repeat(3, minmax(0, 1fr));
238
+ gap: 12px;
239
+ margin: 13px 0 10px 0;
240
+ }
241
+ .result-card {
242
+ background: white;
243
+ border: 1px solid #e6e8eb;
244
+ border-radius: 13px;
245
+ padding: 16px 17px;
246
+ box-shadow: 0 2px 6px rgba(16,24,40,.045);
247
+ }
248
+ .result-card.t {border-top: 5px solid var(--al-red);}
249
+ .result-card.p {border-top: 5px solid var(--al-gold);}
250
+ .result-card.h {border-top: 5px solid var(--al-water);}
251
+ .result-title {font-size: .82rem; color: #667085; font-weight: 700; letter-spacing: .025em;}
252
+ .result-value {font-size: 1.73rem; font-weight: 700; margin: 6px 0; color: #202b33;}
253
+ .support {font-size: .91rem; font-weight: 700;}
254
+ .support.high {color: var(--al-green);}
255
+ .support.mid {color: var(--al-amber);}
256
+ .support.low {color: var(--al-low);}
257
+ .diag {
258
+ font-size: .91rem;
259
+ line-height: 1.62;
260
+ color: #475467;
261
+ background: #fbfcfd;
262
+ border: 1px solid var(--al-line);
263
+ border-radius: 10px;
264
+ padding: 11px 13px;
265
+ }
266
+ .batch-status {
267
+ font-size: .9rem;
268
+ line-height: 1.55;
269
+ color: #475467;
270
+ background: #f8fafc;
271
+ border: 1px solid var(--al-line);
272
+ border-radius: 9px;
273
+ padding: 9px 11px;
274
+ }
275
+ .micro-note {font-size: .81rem; color: var(--al-muted); line-height: 1.45;}
276
+ .footer {
277
+ margin-top: 30px;
278
+ padding: 15px 2px 22px 2px;
279
+ border-top: 1px solid var(--al-line);
280
+ color: #7a8491;
281
+ font-size: .82rem;
282
+ line-height: 1.55;
283
+ }
284
+
285
+ @media (max-width: 760px) {
286
+ .result-grid, .quick-nav {grid-template-columns: 1fr;}
287
+ .al-wordmark {font-size: 1.7rem;}
288
+ }
289
+ """
290
+
291
+
292
+ @lru_cache(maxsize=1)
293
+ def runtime() -> AlbitronRuntime:
294
+ return AlbitronRuntime(MODEL_PATH, SUPPORT_PATH)
295
+
296
+
297
+ def asset_status_html() -> str:
298
+ model_ok = MODEL_PATH.exists()
299
+ support_ok = SUPPORT_PATH.exists()
300
+ if model_ok and support_ok:
301
+ return (
302
+ '<div class="al-status"><span class="asset-ok">运行资产已加载 / Runtime assets ready.</span> '
303
+ 'Albitron-TPH2O V1.0 可进行单点和批量预测。</div>'
304
+ )
305
+ missing = []
306
+ if not model_ok:
307
+ missing.append(MODEL_PATH.name)
308
+ if not support_ok:
309
+ missing.append(SUPPORT_PATH.name)
310
+ return (
311
+ '<div class="al-status"><span class="asset-missing">运行资产缺失 / Runtime assets missing: '
312
+ + html.escape(", ".join(missing))
313
+ + '.</span> 请先运行 <code>python prepare_local_models.py</code> 或将文件放入 models/。</div>'
314
+ )
315
+
316
+
317
+ def bilingual_support(label: str) -> str:
318
+ mapping = {
319
+ "High support": "高支持 / High support",
320
+ "Intermediate support": "中等支持 / Intermediate support",
321
+ "Low support": "低支持 / Low support",
322
+ }
323
+ return mapping.get(str(label), str(label))
324
+
325
+
326
+ def support_class(label: str) -> str:
327
+ label = str(label)
328
+ if "High" in label or "高支持" in label:
329
+ return "high"
330
+ if "Intermediate" in label or "中等支持" in label:
331
+ return "mid"
332
+ return "low"
333
+
334
+
335
+ def render_results(row: pd.Series, warnings: list[str]) -> tuple[str, str]:
336
+ t_sup = bilingual_support(str(row["T_support"]))
337
+ p_sup = bilingual_support(str(row["P_support"]))
338
+ h_sup = bilingual_support(str(row["H2O_support"]))
339
+ cards = f"""
340
+ <div class="result-grid">
341
+ <div class="result-card t">
342
+ <div class="result-title">温度 / Temperature</div>
343
+ <div class="result-value">{row['T_C']:.1f} °C</div>
344
+ <div class="support {support_class(t_sup)}">{html.escape(t_sup)}</div>
345
+ </div>
346
+ <div class="result-card p">
347
+ <div class="result-title">压力 / Pressure</div>
348
+ <div class="result-value">{row['P_MPa']:.1f} MPa</div>
349
+ <div class="support {support_class(p_sup)}">{html.escape(p_sup)}</div>
350
+ </div>
351
+ <div class="result-card h">
352
+ <div class="result-title">溶解水 / Dissolved H₂O</div>
353
+ <div class="result-value">{row['H2O_wt']:.2f} wt.%</div>
354
+ <div class="support {support_class(h_sup)}">{html.escape(h_sup)}</div>
355
+ </div>
356
+ </div>
357
+ """
358
+ warning_html = ""
359
+ if warnings:
360
+ warning_html = "<br><b>输入提示 / Input note:</b> " + " ".join(html.escape(w) for w in warnings)
361
+ diagnostics = f"""
362
+ <div class="diag">
363
+ <b>诊断信息 / Diagnostics</b><br>
364
+ ΔAn = {row['delta_An']:.2f} mol.% &nbsp;·&nbsp;
365
+ 档案距离百分位 / Archive-distance percentile = {row['archive_distance_percentile']:.1f}<br>
366
+ 模型分歧百分位 / Disagreement percentile:
367
+ T {row['T_disagreement_percentile']:.1f} &nbsp;·&nbsp;
368
+ P {row['P_disagreement_percentile']:.1f} &nbsp;·&nbsp;
369
+ H₂O {row['H2O_disagreement_percentile']:.1f}<br>
370
+ 支持等级是经验性资格标记,不是正确概率或置信区间。ΔAn 不改变 T、P、H₂O 或支持等级。<br>
371
+ Support is an empirical qualification label, not a probability or confidence interval. ΔAn does not alter T, P, H₂O, or support classification.
372
+ {warning_html}
373
+ </div>
374
+ """
375
+ return cards, diagnostics
376
+
377
+
378
+ def predict_single(ui_table: pd.DataFrame):
379
+ try:
380
+ raw = table_from_ui(ui_table)
381
+ result = runtime().predict(raw)
382
+ cards, diag = render_results(result.table.iloc[0], result.warnings)
383
+ return cards, diag, ""
384
+ except Exception as exc:
385
+ return "", "", f"预测失败 / Prediction failed: {exc}"
386
+
387
+
388
+ def clear_single():
389
+ return EMPTY_INPUT.copy(), "", "", ""
390
+
391
+
392
+ def read_batch(path: str) -> pd.DataFrame:
393
+ p = Path(path)
394
+ if p.suffix.lower() in {".xlsx", ".xls"}:
395
+ return pd.read_excel(p)
396
+ return pd.read_csv(p)
397
+
398
+
399
+ def safe_output_stem(value: Optional[str]) -> str:
400
+ text = str(value or "").strip()
401
+ text = re.sub(r"\.csv$", "", text, flags=re.IGNORECASE)
402
+ text = re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("._-")
403
+ return text[:80] or "Albitron-TPH2O_batch_prediction"
404
+
405
+
406
+ def predict_batch(file_path: Optional[str], output_name: Optional[str]):
407
+ if not file_path:
408
+ raise gr.Error("请先上传 CSV 或 XLSX 文件。 / Upload a CSV or XLSX file first.")
409
+ try:
410
+ df = read_batch(file_path)
411
+ if len(df) == 0:
412
+ raise ValueError("The uploaded table contains no data rows.")
413
+ if len(df) > BATCH_ROW_LIMIT:
414
+ raise ValueError(
415
+ f"This file contains {len(df)} rows, exceeding the online batch limit of {BATCH_ROW_LIMIT} pairs per run. "
416
+ f"Please split the dataset into files with no more than {BATCH_ROW_LIMIT} rows each."
417
+ )
418
+ result = runtime().predict(df)
419
+ out = result.table.copy()
420
+
421
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
422
+ stem = safe_output_stem(output_name)
423
+ export_dir = Path(tempfile.mkdtemp(prefix="albitron_tph2o_"))
424
+ export = export_dir / f"{stem}_{stamp}.csv"
425
+ out.to_csv(export, index=False, encoding="utf-8-sig")
426
+
427
+ notes = ""
428
+ if result.warnings:
429
+ notes = "**输入警告 / Input warnings:** " + " | ".join(result.warnings[:20])
430
+ status = (
431
+ '<div class="batch-status"><b>批量预测完成 / Batch prediction complete.</b> '
432
+ f'共处理 {len(out)} 行 / {len(out)} rows processed.<br>'
433
+ f'输出文件 / Output file: <code>{html.escape(export.name)}</code><br>'
434
+ '下载表使用稳定的英文列名,便于后续脚本与复现。 / '
435
+ 'The downloaded table keeps stable English column names for reproducibility.</div>'
436
+ )
437
+ return out, str(export), notes, status
438
+ except Exception as exc:
439
+ raise gr.Error(f"批量预测失败 / Batch prediction failed: {exc}")
440
+
441
+
442
+ def clear_batch_results():
443
+ return (
444
+ pd.DataFrame(),
445
+ None,
446
+ "",
447
+ '<div class="batch-status">批量结果已清除;当前上传文件仍保留,可直接再次运行。 / Batch results cleared; the current upload is retained and can be run again.</div>',
448
+ )
449
+
450
+
451
+ def reset_batch():
452
+ return (
453
+ None,
454
+ "Albitron-TPH2O_batch_prediction",
455
+ pd.DataFrame(),
456
+ None,
457
+ "",
458
+ '<div class="batch-status">批量区域已重置,可上传新的数据表。 / Batch workspace reset; upload a new table to continue.</div>',
459
+ )
460
+
461
+
462
+ def build_demo() -> gr.Blocks:
463
+ with gr.Blocks(title="Albitron-TPH2O V1.0") as demo:
464
+ gr.HTML(
465
+ f"""
466
+ <div id="al-hero">
467
+ <div id="al-brand-row">
468
+ <div class="al-wordmark">Albitron-TPH2O</div>
469
+ <div class="al-release">V1.0 · Natural-sample mode</div>
470
+ </div>
471
+ <div class="al-subtitle">
472
+ 斜长石–液相联合 T–P–H₂O 预测与目标特异支持评估<br>
473
+ Joint T–P–H₂O prediction and target-specific support from paired plagioclase–liquid chemistry
474
+ </div>
475
+ <div class="al-meta">作者 / Author: Bowei Yang &nbsp;·&nbsp; © 2026 Bowei Yang</div>
476
+ </div>
477
+ <div class="quick-nav">
478
+ <a href="#single-workspace"><div class="nav-title">单点预测 / Single pair</div><div class="nav-sub">输入一对分析,直接查看 T–P–H₂O 与支持等级。</div></a>
479
+ <a href="#batch-workspace"><div class="nav-title">批量预测 / Batch prediction</div><div class="nav-sub">下载模板、上传数据、连续生成独立预测表。</div></a>
480
+ </div>
481
+ """
482
+ )
483
+ gr.HTML(asset_status_html())
484
+ gr.HTML(
485
+ """
486
+ <div class="input-note">
487
+ <b>输入说明 / Input note.</b>
488
+ 请使用经过质量控制、矩阵校正的 EPMA 氧化物 wt.% 数据,并保留原始分析总量;不要在外部先归一化到 100%。
489
+ Albitron-TPH2O 会在内部为 CLR 表示执行组成闭合。<br>
490
+ Use quality-controlled, matrix-corrected EPMA oxide wt.% and retain the reported analytical totals.
491
+ Do not externally renormalize analyses to 100%; Albitron-TPH2O performs compositional closure internally.
492
+ <b>FeOᵀ</b> 表示以 FeO 计的全铁 / total Fe expressed as FeO.
493
+ </div>
494
+ """
495
+ )
496
+
497
+ gr.HTML(
498
+ """
499
+ <div class="section-head" id="single-workspace">
500
+ <div class="section-kicker">Single pair</div>
501
+ <div class="section-title">单点预测 / Single-pair prediction</div>
502
+ <div class="section-desc">适用于一组明确配对的斜长石与液相分析。 / For one explicitly paired plagioclase and liquid analysis.</div>
503
+ </div>
504
+ <div class="step-strip">
505
+ <span class="step-chip"><b>1</b> 填写组成 / Enter chemistry</span>
506
+ <span class="step-chip"><b>2</b> 运行预测 / Predict</span>
507
+ <span class="step-chip"><b>3</b> 查看支持 / Read support</span>
508
+ </div>
509
+ """
510
+ )
511
+ with gr.Group(elem_classes=["tool-panel"]):
512
+ input_table = gr.Dataframe(
513
+ value=EMPTY_INPUT,
514
+ headers=["组分 / Component", "液相 wt.% / Liquid wt.%", "斜长石 wt.% / Plagioclase wt.%"],
515
+ datatype=["str", "number", "number"],
516
+ row_count=9,
517
+ column_count=3,
518
+ interactive=True,
519
+ static_columns=[0],
520
+ show_row_numbers=False,
521
+ wrap=False,
522
+ label=None,
523
+ column_widths=["34%", "33%", "33%"],
524
+ )
525
+ with gr.Row():
526
+ example_btn = gr.Button("载入示例 / Load example", variant="secondary")
527
+ clear_btn = gr.Button("清除 / Clear", variant="secondary")
528
+ predict_btn = gr.Button("开始预测 / Predict", variant="primary")
529
+
530
+ result_html = gr.HTML()
531
+ diagnostic_html = gr.HTML()
532
+ error_box = gr.Markdown()
533
+
534
+ example_btn.click(lambda: EXAMPLE_INPUT.copy(), outputs=input_table)
535
+ clear_btn.click(clear_single, outputs=[input_table, result_html, diagnostic_html, error_box])
536
+ predict_btn.click(predict_single, inputs=input_table, outputs=[result_html, diagnostic_html, error_box])
537
+
538
+ gr.HTML(
539
+ """
540
+ <div class="batch-banner" id="batch-workspace">
541
+ <div class="title">批量预测 / Batch Prediction</div>
542
+ <div class="desc">
543
+ 一次处理多组已明确配对的斜长石–液相分析。<b>在线版单次最多 200 对分析</b>;更大的数据集请拆分为多个文件。每次运行生成独立、带时间戳的 CSV;清除结果不会清除当前上传文件,因此可在同一页面反复运行并输出不同预测表。<br>
544
+ Process many explicitly paired analyses at once. <b>The online interface accepts up to 200 analysis pairs per run.</b> Split larger datasets into multiple files. Each run creates an independent timestamped CSV; clearing results keeps the current upload so the same workspace can be reused repeatedly.
545
+ </div>
546
+ </div>
547
+ <div class="step-strip">
548
+ <span class="step-chip"><b>1</b> 下载模板 / Get template</span>
549
+ <span class="step-chip"><b>2</b> 上传数据 / Upload</span>
550
+ <span class="step-chip"><b>3</b> 运行并下载 / Run & download</span>
551
+ <span class="step-chip"><b>4</b> 清除后复用 / Clear & reuse</span>
552
+ </div>
553
+ """
554
+ )
555
+ with gr.Group(elem_classes=["tool-panel"]):
556
+ gr.HTML(
557
+ """
558
+ <div class="template-callout">
559
+ <b>建议先下载标准模板 / Start with the standard template.</b>
560
+ 模板包含固定机器列名;Sample 列可选,并会保留到输出结果中。<b>每个文件最多 200 行数据。</b><br>
561
+ The template contains the stable machine-readable schema; the optional Sample column is preserved in the output. <b>Each uploaded file may contain up to 200 data rows.</b>
562
+ </div>
563
+ """
564
+ )
565
+ with gr.Row():
566
+ template_btn = gr.DownloadButton(
567
+ "⬇ 下载批量导入模板 / Download Batch Template",
568
+ value=str(BATCH_TEMPLATE),
569
+ variant="primary",
570
+ )
571
+ example_csv_btn = gr.DownloadButton(
572
+ "下载示例 CSV / Download Example CSV",
573
+ value=str(BATCH_EXAMPLE),
574
+ variant="secondary",
575
+ )
576
+
577
+ with gr.Row(equal_height=False):
578
+ with gr.Column(scale=1, min_width=300):
579
+ batch_file = gr.File(
580
+ label="上传批量数据(最多 200 行) / Upload batch CSV or XLSX (max 200 rows)",
581
+ file_types=[".csv", ".xlsx", ".xls"],
582
+ type="filepath",
583
+ )
584
+ output_name = gr.Textbox(
585
+ value="Albitron-TPH2O_batch_prediction",
586
+ label="输出表名称 / Output table name",
587
+ info="系统自动追加时间戳,因此重复运行不会覆盖之前的输出。 / A timestamp is appended automatically.",
588
+ )
589
+ batch_btn = gr.Button("运行批量预测 / Run Batch Prediction", variant="primary")
590
+ with gr.Row():
591
+ batch_clear_btn = gr.Button("清除结果 / Clear Results", variant="secondary")
592
+ batch_reset_btn = gr.Button("全部重置 / Reset All", variant="secondary")
593
+ gr.HTML('<div class="micro-note">“清除结果”保留上传文件;“全部重置”清空上传与结果。<br>Clear Results retains the upload; Reset All clears both input and output.</div>')
594
+
595
+ with gr.Column(scale=2, min_width=420):
596
+ batch_status = gr.HTML('<div class="batch-status">等待批量输入 / Ready for batch input.</div>')
597
+ batch_table = gr.Dataframe(
598
+ label="批量结果预览 / Batch Results Preview",
599
+ interactive=False,
600
+ wrap=False,
601
+ )
602
+ batch_download = gr.File(label="下载本次预测表 / Download This Prediction Table")
603
+ batch_notes = gr.Markdown()
604
+
605
+ batch_btn.click(
606
+ predict_batch,
607
+ inputs=[batch_file, output_name],
608
+ outputs=[batch_table, batch_download, batch_notes, batch_status],
609
+ )
610
+ batch_clear_btn.click(
611
+ clear_batch_results,
612
+ outputs=[batch_table, batch_download, batch_notes, batch_status],
613
+ )
614
+ batch_reset_btn.click(
615
+ reset_batch,
616
+ outputs=[batch_file, output_name, batch_table, batch_download, batch_notes, batch_status],
617
+ )
618
+
619
+ with gr.Accordion("方法与边界 / Method and limits", open=False):
620
+ gr.Markdown(
621
+ """
622
+ **运行模式 / Mode:** 仅使用自然样品部署模式;预测端不提供实验 study identity。 / Natural-sample deployment only; experimental study identity is not supplied to the prediction head.
623
+
624
+ **输出 / Outputs:** 使用同一对主量元素化学组成预测 T、P 与溶解 H₂O,并基于档案代表性和模型分歧给出目标特异的支持等级。 / T, P and dissolved H₂O are predicted from the same paired major-element chemistry, with target-specific support from archive representation and branch disagreement.
625
+
626
+ **解释边界 / Interpretation:** 支持等级不是正确概率或置信区间。压力的 study-held-out transfer 弱于 T 和 H₂O,因此不应过度解释细小压力差异。 / Support is not a probability of correctness or a confidence interval. Pressure has weaker study-held-out transfer than T and H₂O, so fine pressure differences should be interpreted cautiously.
627
+
628
+ **批量模式 / Batch mode:** 每一行必须是用户明确提供的一对 crystal–liquid analysis。在线界面单次最多处理 **200 对分析**;超过 200 行的数据请拆分为多个文件。Albitron-TPH2O 不自动推断配对,也不自动构造 Cartesian products。 / Each row must be an explicitly supplied crystal–liquid pair. The online interface processes at most **200 analysis pairs per run**; split larger datasets into multiple files. Albitron-TPH2O does not infer pairings or construct Cartesian products automatically.
629
+ """
630
+ )
631
+
632
+ gr.HTML(
633
+ f"""
634
+ <div class="footer">
635
+ <b>Albitron-TPH2O {PUBLIC_VERSION}</b> · Natural-sample prediction interface<br>
636
+ 作者 / Author: Bowei Yang &nbsp;·&nbsp; © 2026 Bowei Yang. All rights reserved.
637
+ </div>
638
+ """
639
+ )
640
+ return demo
641
+
642
+
643
+ demo = build_demo()
644
+
645
+ if __name__ == "__main__":
646
+ demo.launch(css=CSS)