Spaces:
Running
Running
Miyu Horiuchi commited on
Commit ·
572e624
1
Parent(s): 4c18dfd
Surface hybrid oxygen source in UI
Browse files- api/main.py +58 -3
- web/src/components/Accuracy.jsx +15 -11
- web/src/components/Catalog.jsx +6 -2
- web/src/components/DetailDrawer.jsx +5 -2
- web/src/components/PredictBar.jsx +5 -2
- web/src/components/Primitives.jsx +26 -0
- web/src/components/TestTab.jsx +7 -4
api/main.py
CHANGED
|
@@ -67,6 +67,57 @@ def _phylum(tax: str | None) -> str:
|
|
| 67 |
return "—"
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
@app.on_event("startup")
|
| 71 |
def _load_resources() -> None:
|
| 72 |
print("Loading recommender models...")
|
|
@@ -79,12 +130,13 @@ def _load_resources() -> None:
|
|
| 79 |
zip(media_meta["medium_id"].astype(str), media_meta["name"], strict=True)
|
| 80 |
)
|
| 81 |
|
| 82 |
-
unc =
|
| 83 |
unc["phylum"] = unc["gtdb_taxonomy"].map(_phylum)
|
| 84 |
unc["truly_uncultured"] = (
|
| 85 |
unc["ncbi_organism_name"].fillna("").str.lower().str.startswith("uncultured")
|
| 86 |
)
|
| 87 |
_state["catalog"] = unc
|
|
|
|
| 88 |
print(f" → {len(unc):,} catalog rows ({int(unc['truly_uncultured'].sum()):,} truly uncultured)")
|
| 89 |
|
| 90 |
|
|
@@ -106,6 +158,7 @@ class CatalogRow(BaseModel):
|
|
| 106 |
pH: float
|
| 107 |
O2: str
|
| 108 |
O2_conf: float
|
|
|
|
| 109 |
salt: float
|
| 110 |
top_medium_id: str
|
| 111 |
top_medium_name: str
|
|
@@ -127,6 +180,7 @@ def health():
|
|
| 127 |
"ok": True,
|
| 128 |
"models_loaded": len(_state.get("models", {})),
|
| 129 |
"catalog_rows": len(_state.get("catalog", [])),
|
|
|
|
| 130 |
}
|
| 131 |
|
| 132 |
|
|
@@ -162,6 +216,7 @@ def catalog():
|
|
| 162 |
"pH": _safe_float(m["pred_optimal_ph"], 2),
|
| 163 |
"O2": _safe_str(m["pred_oxygen_requirement"], "—"),
|
| 164 |
"O2_conf": _safe_float(m.get("pred_oxygen_requirement_confidence"), 3, 0.0),
|
|
|
|
| 165 |
"salt": _safe_float(m["pred_salt_tolerance_pct"], 2),
|
| 166 |
"top_medium_id": _safe_str(m["top1_medium_id"], "—"),
|
| 167 |
"top_medium_name": _safe_str(m["top1_medium_name"], "—"),
|
|
@@ -173,7 +228,7 @@ def catalog():
|
|
| 173 |
"top3_medium_name": _safe_str(m.get("top3_medium_name")),
|
| 174 |
"top3_confidence": _safe_float(m.get("top3_confidence"), 4) if pd.notna(m.get("top3_confidence")) else None,
|
| 175 |
})
|
| 176 |
-
return {"count": len(rows), "rows": rows}
|
| 177 |
|
| 178 |
|
| 179 |
@app.get("/api/ncbi-search")
|
|
@@ -235,7 +290,7 @@ def predict(req: PredictRequest):
|
|
| 235 |
feats, accession, n_contigs = _load_genome_features(target)
|
| 236 |
|
| 237 |
feats_series = pd.Series(feats)
|
| 238 |
-
phenotypes = _predict_phenotypes(feats_series)
|
| 239 |
|
| 240 |
models = _state["models"]
|
| 241 |
feature_cols = _state["feature_cols"]
|
|
|
|
| 67 |
return "—"
|
| 68 |
|
| 69 |
|
| 70 |
+
def _load_catalog_frame() -> tuple[pd.DataFrame, str]:
|
| 71 |
+
"""Load the deploy catalog and overlay hybrid phenotype predictions if available."""
|
| 72 |
+
base = pd.read_parquet(config.ARTIFACTS / "uncultured_predictions.parquet")
|
| 73 |
+
if "pred_oxygen_requirement_source" not in base.columns:
|
| 74 |
+
base["pred_oxygen_requirement_source"] = "tabular"
|
| 75 |
+
|
| 76 |
+
hybrid_path = config.ARTIFACTS / "hybrid_predictions.parquet"
|
| 77 |
+
if not hybrid_path.exists():
|
| 78 |
+
return base, "tabular"
|
| 79 |
+
|
| 80 |
+
hybrid = pd.read_parquet(hybrid_path)
|
| 81 |
+
if "genome_accession" not in hybrid.columns:
|
| 82 |
+
print(f" ! ignoring {hybrid_path}: missing genome_accession")
|
| 83 |
+
return base, "tabular"
|
| 84 |
+
|
| 85 |
+
pred_cols = [c for c in hybrid.columns if c.startswith("pred_")]
|
| 86 |
+
if not pred_cols:
|
| 87 |
+
print(f" ! ignoring {hybrid_path}: no pred_* columns")
|
| 88 |
+
return base, "tabular"
|
| 89 |
+
|
| 90 |
+
hybrid = hybrid[["genome_accession", *pred_cols]].drop_duplicates("genome_accession")
|
| 91 |
+
merged = base.merge(hybrid, on="genome_accession", how="left", suffixes=("", "_hybrid"))
|
| 92 |
+
|
| 93 |
+
for col in pred_cols:
|
| 94 |
+
hcol = f"{col}_hybrid" if col in base.columns else col
|
| 95 |
+
if hcol not in merged.columns:
|
| 96 |
+
continue
|
| 97 |
+
if col in base.columns and hcol != col:
|
| 98 |
+
merged[col] = merged[hcol].combine_first(merged[col])
|
| 99 |
+
merged = merged.drop(columns=[hcol])
|
| 100 |
+
|
| 101 |
+
if "pred_oxygen_requirement_source" not in merged.columns:
|
| 102 |
+
merged["pred_oxygen_requirement_source"] = "tabular"
|
| 103 |
+
merged["pred_oxygen_requirement_source"] = merged["pred_oxygen_requirement_source"].fillna("tabular")
|
| 104 |
+
return merged, "hybrid"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _tag_prediction_sources(phenotypes: dict[str, Any]) -> dict[str, Any]:
|
| 108 |
+
"""Expose model source metadata to the UI without changing prediction values."""
|
| 109 |
+
for key, source in {
|
| 110 |
+
"optimal_temperature_c": "tabular",
|
| 111 |
+
"optimal_ph": "tabular",
|
| 112 |
+
"oxygen_requirement": "tabular",
|
| 113 |
+
"salt_tolerance_pct": "tabular",
|
| 114 |
+
}.items():
|
| 115 |
+
item = phenotypes.get(key)
|
| 116 |
+
if isinstance(item, dict):
|
| 117 |
+
item.setdefault("source", source)
|
| 118 |
+
return phenotypes
|
| 119 |
+
|
| 120 |
+
|
| 121 |
@app.on_event("startup")
|
| 122 |
def _load_resources() -> None:
|
| 123 |
print("Loading recommender models...")
|
|
|
|
| 130 |
zip(media_meta["medium_id"].astype(str), media_meta["name"], strict=True)
|
| 131 |
)
|
| 132 |
|
| 133 |
+
unc, catalog_source = _load_catalog_frame()
|
| 134 |
unc["phylum"] = unc["gtdb_taxonomy"].map(_phylum)
|
| 135 |
unc["truly_uncultured"] = (
|
| 136 |
unc["ncbi_organism_name"].fillna("").str.lower().str.startswith("uncultured")
|
| 137 |
)
|
| 138 |
_state["catalog"] = unc
|
| 139 |
+
_state["catalog_source"] = catalog_source
|
| 140 |
print(f" → {len(unc):,} catalog rows ({int(unc['truly_uncultured'].sum()):,} truly uncultured)")
|
| 141 |
|
| 142 |
|
|
|
|
| 158 |
pH: float
|
| 159 |
O2: str
|
| 160 |
O2_conf: float
|
| 161 |
+
O2_source: str = "tabular"
|
| 162 |
salt: float
|
| 163 |
top_medium_id: str
|
| 164 |
top_medium_name: str
|
|
|
|
| 180 |
"ok": True,
|
| 181 |
"models_loaded": len(_state.get("models", {})),
|
| 182 |
"catalog_rows": len(_state.get("catalog", [])),
|
| 183 |
+
"catalog_source": _state.get("catalog_source", "tabular"),
|
| 184 |
}
|
| 185 |
|
| 186 |
|
|
|
|
| 216 |
"pH": _safe_float(m["pred_optimal_ph"], 2),
|
| 217 |
"O2": _safe_str(m["pred_oxygen_requirement"], "—"),
|
| 218 |
"O2_conf": _safe_float(m.get("pred_oxygen_requirement_confidence"), 3, 0.0),
|
| 219 |
+
"O2_source": _safe_str(m.get("pred_oxygen_requirement_source"), "tabular"),
|
| 220 |
"salt": _safe_float(m["pred_salt_tolerance_pct"], 2),
|
| 221 |
"top_medium_id": _safe_str(m["top1_medium_id"], "—"),
|
| 222 |
"top_medium_name": _safe_str(m["top1_medium_name"], "—"),
|
|
|
|
| 228 |
"top3_medium_name": _safe_str(m.get("top3_medium_name")),
|
| 229 |
"top3_confidence": _safe_float(m.get("top3_confidence"), 4) if pd.notna(m.get("top3_confidence")) else None,
|
| 230 |
})
|
| 231 |
+
return {"count": len(rows), "source": _state.get("catalog_source", "tabular"), "rows": rows}
|
| 232 |
|
| 233 |
|
| 234 |
@app.get("/api/ncbi-search")
|
|
|
|
| 290 |
feats, accession, n_contigs = _load_genome_features(target)
|
| 291 |
|
| 292 |
feats_series = pd.Series(feats)
|
| 293 |
+
phenotypes = _tag_prediction_sources(_predict_phenotypes(feats_series))
|
| 294 |
|
| 295 |
models = _state["models"]
|
| 296 |
feature_cols = _state["feature_cols"]
|
web/src/components/Accuracy.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
import { THEME, tempColor, pHColor, saltColor, O2_COLOR } from '../theme.js';
|
| 3 |
-
import { MediaConfBar, OxygenConfArc, IntervalBar } from './Primitives.jsx';
|
| 4 |
|
| 5 |
const TARGETS = [
|
| 6 |
{ key: 'T', label: 'Temperature optimum', metric: 'MAE', value: '3.28', unit: '°C', color: tempColor(45),
|
|
@@ -9,9 +9,9 @@ const TARGETS = [
|
|
| 9 |
{ key: 'pH', label: 'pH optimum', metric: 'MAE', value: '0.52', unit: '', color: pHColor(7),
|
| 10 |
verdict: 'Marginal — distinguishes acidic / neutral / alkaline, not finer.',
|
| 11 |
detail: 'Trained on 4,652 BacDive strains. Quantile regression for 80% prediction interval.' },
|
| 12 |
-
{ key: 'O2', label: 'Oxygen requirement', metric: 'F1', value: '0.
|
| 13 |
-
verdict: '
|
| 14 |
-
detail: '
|
| 15 |
{ key: 'salt', label: 'Salt tolerance', metric: 'MAE', value: '2.51', unit: '%', color: saltColor(3),
|
| 16 |
verdict: 'Decent — separates freshwater / marine / halotolerant.',
|
| 17 |
detail: 'Trained on 4,793 BacDive strains.' },
|
|
@@ -23,7 +23,7 @@ export default function Accuracy() {
|
|
| 23 |
<div style={{ marginBottom: 24, padding: '14px 16px', background: '#ede4cd', border: `1px solid ${THEME.rule}` }}>
|
| 24 |
<div style={{ font: `500 11px ${THEME.mono}`, color: THEME.accent, letterSpacing: '0.05em', textTransform: 'uppercase', marginBottom: 4 }}>The verdict</div>
|
| 25 |
<div style={{ font: `400 14px ${THEME.serif}`, color: THEME.ink, fontStyle: 'italic' }}>
|
| 26 |
-
|
| 27 |
</div>
|
| 28 |
</div>
|
| 29 |
|
|
@@ -32,7 +32,7 @@ export default function Accuracy() {
|
|
| 32 |
<div key={t.key} style={{ border: `1px solid ${THEME.rule}`, padding: '16px 18px', background: THEME.paper }}>
|
| 33 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
| 34 |
<span style={{ font: `500 12px ${THEME.font}`, color: THEME.ink }}>{t.label}</span>
|
| 35 |
-
<span style={{ font: `400 11px ${THEME.mono}`, color: THEME.inkFaint }}>5-fold GroupKFold by family</span>
|
| 36 |
</div>
|
| 37 |
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8 }}>
|
| 38 |
<span style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase' }}>{t.metric}</span>
|
|
@@ -58,11 +58,12 @@ export default function Accuracy() {
|
|
| 58 |
</div>
|
| 59 |
|
| 60 |
<div style={{ marginTop: 24, font: `400 12px ${THEME.font}`, color: THEME.inkSoft, lineHeight: 1.6 }}>
|
| 61 |
-
|
| 62 |
-
|
|
|
|
| 63 |
GTDB genomes scored against <span style={{ color: THEME.ink, fontWeight: 500 }}>24</span> DSMZ media.
|
| 64 |
Features: 353 handcrafted genome statistics — GC, codon usage, tetranucleotide frequencies, amino-acid composition.
|
| 65 |
-
XGBoost classifiers
|
| 66 |
</div>
|
| 67 |
</div>
|
| 68 |
);
|
|
@@ -83,10 +84,13 @@ function Legend({ kind }) {
|
|
| 83 |
if (kind === 'oxygen') {
|
| 84 |
return (
|
| 85 |
<div style={{ border: `1px solid ${THEME.rule}`, padding: '12px 14px', background: THEME.paper }}>
|
| 86 |
-
<
|
|
|
|
|
|
|
|
|
|
| 87 |
<div style={{ font: `500 12px ${THEME.font}`, color: THEME.ink, marginTop: 8 }}>Oxygen confidence</div>
|
| 88 |
<div style={{ font: `400 11.5px ${THEME.font}`, color: THEME.inkSoft, lineHeight: 1.5 }}>
|
| 89 |
-
|
| 90 |
</div>
|
| 91 |
</div>
|
| 92 |
);
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import { THEME, tempColor, pHColor, saltColor, O2_COLOR } from '../theme.js';
|
| 3 |
+
import { MediaConfBar, OxygenConfArc, IntervalBar, SourceBadge } from './Primitives.jsx';
|
| 4 |
|
| 5 |
const TARGETS = [
|
| 6 |
{ key: 'T', label: 'Temperature optimum', metric: 'MAE', value: '3.28', unit: '°C', color: tempColor(45),
|
|
|
|
| 9 |
{ key: 'pH', label: 'pH optimum', metric: 'MAE', value: '0.52', unit: '', color: pHColor(7),
|
| 10 |
verdict: 'Marginal — distinguishes acidic / neutral / alkaline, not finer.',
|
| 11 |
detail: 'Trained on 4,652 BacDive strains. Quantile regression for 80% prediction interval.' },
|
| 12 |
+
{ key: 'O2', label: 'Oxygen requirement', metric: 'F1', value: '0.94', unit: '', color: O2_COLOR,
|
| 13 |
+
verdict: 'Strong on fold 0 with LoRA; still needs folds 1-4 before publication-grade validation.',
|
| 14 |
+
detail: 'Hybrid oxygen uses LoRA ESM-2 fold 0 when available, with tabular prediction as the deploy fallback.' },
|
| 15 |
{ key: 'salt', label: 'Salt tolerance', metric: 'MAE', value: '2.51', unit: '%', color: saltColor(3),
|
| 16 |
verdict: 'Decent — separates freshwater / marine / halotolerant.',
|
| 17 |
detail: 'Trained on 4,793 BacDive strains.' },
|
|
|
|
| 23 |
<div style={{ marginBottom: 24, padding: '14px 16px', background: '#ede4cd', border: `1px solid ${THEME.rule}` }}>
|
| 24 |
<div style={{ font: `500 11px ${THEME.mono}`, color: THEME.accent, letterSpacing: '0.05em', textTransform: 'uppercase', marginBottom: 4 }}>The verdict</div>
|
| 25 |
<div style={{ font: `400 14px ${THEME.serif}`, color: THEME.ink, fontStyle: 'italic' }}>
|
| 26 |
+
Hybrid v2 keeps tabular models for temperature, pH, salt, and media; oxygen uses LoRA when the checkpoint-backed predictions are available.
|
| 27 |
</div>
|
| 28 |
</div>
|
| 29 |
|
|
|
|
| 32 |
<div key={t.key} style={{ border: `1px solid ${THEME.rule}`, padding: '16px 18px', background: THEME.paper }}>
|
| 33 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
| 34 |
<span style={{ font: `500 12px ${THEME.font}`, color: THEME.ink }}>{t.label}</span>
|
| 35 |
+
<span style={{ font: `400 11px ${THEME.mono}`, color: THEME.inkFaint }}>{t.key === 'O2' ? 'LoRA fold 0' : '5-fold GroupKFold by family'}</span>
|
| 36 |
</div>
|
| 37 |
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8 }}>
|
| 38 |
<span style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase' }}>{t.metric}</span>
|
|
|
|
| 58 |
</div>
|
| 59 |
|
| 60 |
<div style={{ marginTop: 24, font: `400 12px ${THEME.font}`, color: THEME.inkSoft, lineHeight: 1.6 }}>
|
| 61 |
+
Tabular phenotypes were trained from <span style={{ color: THEME.ink, fontWeight: 500 }}>46,029</span> BacDive-derived strain rows;
|
| 62 |
+
LoRA oxygen fold 0 used <span style={{ color: THEME.ink, fontWeight: 500 }}>32,375</span> training rows and <span style={{ color: THEME.ink, fontWeight: 500 }}>8,094</span> validation rows.
|
| 63 |
+
The uncultured catalog is <span style={{ color: THEME.ink, fontWeight: 500 }}>5,000</span> held-out
|
| 64 |
GTDB genomes scored against <span style={{ color: THEME.ink, fontWeight: 500 }}>24</span> DSMZ media.
|
| 65 |
Features: 353 handcrafted genome statistics — GC, codon usage, tetranucleotide frequencies, amino-acid composition.
|
| 66 |
+
XGBoost classifiers handle media and tabular phenotypes; quantile regression XGBoost provides prediction intervals.
|
| 67 |
</div>
|
| 68 |
</div>
|
| 69 |
);
|
|
|
|
| 84 |
if (kind === 'oxygen') {
|
| 85 |
return (
|
| 86 |
<div style={{ border: `1px solid ${THEME.rule}`, padding: '12px 14px', background: THEME.paper }}>
|
| 87 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 88 |
+
<OxygenConfArc value={0.72} size={36} />
|
| 89 |
+
<SourceBadge source="lora" />
|
| 90 |
+
</div>
|
| 91 |
<div style={{ font: `500 12px ${THEME.font}`, color: THEME.ink, marginTop: 8 }}>Oxygen confidence</div>
|
| 92 |
<div style={{ font: `400 11.5px ${THEME.font}`, color: THEME.inkSoft, lineHeight: 1.5 }}>
|
| 93 |
+
LoRA confidence is the max softmax probability across four oxygen classes. Tabular remains the fallback when hybrid artifacts are absent.
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
);
|
web/src/components/Catalog.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import React, { useEffect, useMemo, useState } from 'react';
|
| 2 |
import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
|
| 3 |
-
import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag } from './Primitives.jsx';
|
| 4 |
|
| 5 |
function ModeStrip({ mode, setMode }) {
|
| 6 |
const focused = mode === 'focused';
|
|
@@ -98,7 +98,10 @@ function FeaturedCard({ m, onSelect }) {
|
|
| 98 |
<PhenoMicro label="pH" value={m.pH.toFixed(1)} color={pHColor(m.pH)} />
|
| 99 |
<PhenoMicro label="salt" value={m.salt.toFixed(1)} unit="%" color={saltColor(m.salt)} />
|
| 100 |
<div>
|
| 101 |
-
<div style={{
|
|
|
|
|
|
|
|
|
|
| 102 |
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 103 |
<OxygenConfArc value={m.O2_conf} size={26} />
|
| 104 |
<div style={{ font: `500 12px ${THEME.font}`, color: THEME.ink, lineHeight: 1.2 }}>{m.O2}</div>
|
|
@@ -139,6 +142,7 @@ function TableRow({ m, onSelect, isLast }) {
|
|
| 139 |
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
| 140 |
<OxygenConfArc value={m.O2_conf} size={20} />
|
| 141 |
<span style={{ font: `400 11.5px ${THEME.font}`, color: THEME.ink }}>{m.O2}</span>
|
|
|
|
| 142 |
</div>
|
| 143 |
</td>
|
| 144 |
<td style={{ padding: '10px 12px', font: `500 12px ${THEME.serif}`, color: saltColor(m.salt), fontVariantNumeric: 'tabular-nums' }}>{m.salt.toFixed(1)}%</td>
|
|
|
|
| 1 |
import React, { useEffect, useMemo, useState } from 'react';
|
| 2 |
import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
|
| 3 |
+
import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag, SourceBadge } from './Primitives.jsx';
|
| 4 |
|
| 5 |
function ModeStrip({ mode, setMode }) {
|
| 6 |
const focused = mode === 'focused';
|
|
|
|
| 98 |
<PhenoMicro label="pH" value={m.pH.toFixed(1)} color={pHColor(m.pH)} />
|
| 99 |
<PhenoMicro label="salt" value={m.salt.toFixed(1)} unit="%" color={saltColor(m.salt)} />
|
| 100 |
<div>
|
| 101 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
| 102 |
+
<span style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, letterSpacing: '0.05em', textTransform: 'uppercase' }}>O₂</span>
|
| 103 |
+
<SourceBadge source={m.O2_source} compact />
|
| 104 |
+
</div>
|
| 105 |
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 106 |
<OxygenConfArc value={m.O2_conf} size={26} />
|
| 107 |
<div style={{ font: `500 12px ${THEME.font}`, color: THEME.ink, lineHeight: 1.2 }}>{m.O2}</div>
|
|
|
|
| 142 |
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
| 143 |
<OxygenConfArc value={m.O2_conf} size={20} />
|
| 144 |
<span style={{ font: `400 11.5px ${THEME.font}`, color: THEME.ink }}>{m.O2}</span>
|
| 145 |
+
<SourceBadge source={m.O2_source} compact />
|
| 146 |
</div>
|
| 147 |
</td>
|
| 148 |
<td style={{ padding: '10px 12px', font: `500 12px ${THEME.serif}`, color: saltColor(m.salt), fontVariantNumeric: 'tabular-nums' }}>{m.salt.toFixed(1)}%</td>
|
web/src/components/DetailDrawer.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
|
| 3 |
-
import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag } from './Primitives.jsx';
|
| 4 |
|
| 5 |
export default function DetailDrawer({ microbe, onClose }) {
|
| 6 |
if (!microbe) return null;
|
|
@@ -34,7 +34,10 @@ export default function DetailDrawer({ microbe, onClose }) {
|
|
| 34 |
<PhenoCell label="pH" value={microbe.pH} color={pHColor(microbe.pH)} scaleMin={2} scaleMax={11} />
|
| 35 |
<PhenoCell label="salt" value={microbe.salt} unit="%" color={saltColor(microbe.salt)} scaleMin={0} scaleMax={25} />
|
| 36 |
<div style={{ border: `1px solid ${THEME.rule}`, padding: '10px 12px' }}>
|
| 37 |
-
<div style={{
|
|
|
|
|
|
|
|
|
|
| 38 |
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 39 |
<OxygenConfArc value={microbe.O2_conf} size={36} />
|
| 40 |
<div style={{ font: `500 14px ${THEME.font}`, color: THEME.ink }}>{microbe.O2}</div>
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
|
| 3 |
+
import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag, SourceBadge } from './Primitives.jsx';
|
| 4 |
|
| 5 |
export default function DetailDrawer({ microbe, onClose }) {
|
| 6 |
if (!microbe) return null;
|
|
|
|
| 34 |
<PhenoCell label="pH" value={microbe.pH} color={pHColor(microbe.pH)} scaleMin={2} scaleMax={11} />
|
| 35 |
<PhenoCell label="salt" value={microbe.salt} unit="%" color={saltColor(microbe.salt)} scaleMin={0} scaleMax={25} />
|
| 36 |
<div style={{ border: `1px solid ${THEME.rule}`, padding: '10px 12px' }}>
|
| 37 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
| 38 |
+
<span style={{ font: `400 10px ${THEME.mono}`, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Oxygen</span>
|
| 39 |
+
<SourceBadge source={microbe.O2_source} compact />
|
| 40 |
+
</div>
|
| 41 |
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 42 |
<OxygenConfArc value={microbe.O2_conf} size={36} />
|
| 43 |
<div style={{ font: `500 14px ${THEME.font}`, color: THEME.ink }}>{microbe.O2}</div>
|
web/src/components/PredictBar.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import React, { useState, useRef } from 'react';
|
| 2 |
import { THEME } from '../theme.js';
|
| 3 |
-
import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag } from './Primitives.jsx';
|
| 4 |
import { tempColor, pHColor, saltColor } from '../theme.js';
|
| 5 |
|
| 6 |
const QUICK_TRY = [
|
|
@@ -198,7 +198,10 @@ function PredictResultBanner({ result, onClear }) {
|
|
| 198 |
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
| 199 |
<OxygenConfArc value={O2.confidence || 0} size={32} />
|
| 200 |
<div>
|
| 201 |
-
<div style={{
|
|
|
|
|
|
|
|
|
|
| 202 |
<div style={{ fontFamily: THEME.font, fontSize: 12, color: THEME.ink }}>{O2.prediction}</div>
|
| 203 |
</div>
|
| 204 |
</div>
|
|
|
|
| 1 |
import React, { useState, useRef } from 'react';
|
| 2 |
import { THEME } from '../theme.js';
|
| 3 |
+
import { MediaConfBar, OxygenConfArc, IntervalBar, MonoTag, SourceBadge } from './Primitives.jsx';
|
| 4 |
import { tempColor, pHColor, saltColor } from '../theme.js';
|
| 5 |
|
| 6 |
const QUICK_TRY = [
|
|
|
|
| 198 |
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
| 199 |
<OxygenConfArc value={O2.confidence || 0} size={32} />
|
| 200 |
<div>
|
| 201 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 2 }}>
|
| 202 |
+
<span style={{ fontFamily: THEME.mono, fontSize: 9, color: THEME.inkFaint, textTransform: 'uppercase', letterSpacing: '0.05em' }}>O₂</span>
|
| 203 |
+
<SourceBadge source={O2.source} compact />
|
| 204 |
+
</div>
|
| 205 |
<div style={{ fontFamily: THEME.font, fontSize: 12, color: THEME.ink }}>{O2.prediction}</div>
|
| 206 |
</div>
|
| 207 |
</div>
|
web/src/components/Primitives.jsx
CHANGED
|
@@ -65,3 +65,29 @@ export function MonoTag({ children, color = THEME.accent }) {
|
|
| 65 |
}}>{children}</span>
|
| 66 |
);
|
| 67 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}}>{children}</span>
|
| 66 |
);
|
| 67 |
}
|
| 68 |
+
|
| 69 |
+
export function SourceBadge({ source = 'tabular', compact = false }) {
|
| 70 |
+
const normalized = String(source || 'tabular').toLowerCase();
|
| 71 |
+
const isLora = normalized === 'lora';
|
| 72 |
+
const color = isLora ? O2_COLOR : THEME.inkFaint;
|
| 73 |
+
return (
|
| 74 |
+
<span style={{
|
| 75 |
+
display: 'inline-flex',
|
| 76 |
+
alignItems: 'center',
|
| 77 |
+
width: 'fit-content',
|
| 78 |
+
fontFamily: THEME.mono,
|
| 79 |
+
fontSize: compact ? 9 : 10,
|
| 80 |
+
fontWeight: 500,
|
| 81 |
+
letterSpacing: '0.04em',
|
| 82 |
+
textTransform: 'uppercase',
|
| 83 |
+
padding: compact ? '1px 4px' : '2px 6px',
|
| 84 |
+
border: `1px solid ${color}`,
|
| 85 |
+
color,
|
| 86 |
+
borderRadius: 2,
|
| 87 |
+
whiteSpace: 'nowrap',
|
| 88 |
+
lineHeight: 1.2,
|
| 89 |
+
}}>
|
| 90 |
+
{isLora ? 'LoRA' : 'tabular'}
|
| 91 |
+
</span>
|
| 92 |
+
);
|
| 93 |
+
}
|
web/src/components/TestTab.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
|
| 3 |
-
import { OxygenConfArc, IntervalBar, MediaConfBar, MonoTag } from './Primitives.jsx';
|
| 4 |
|
| 5 |
const PRESETS = [
|
| 6 |
{ id: 'ecoli', name: 'Escherichia coli K-12 MG1655', accession: 'GCF_000005845.2',
|
|
@@ -124,9 +124,12 @@ function Comparison({ preset, result }) {
|
|
| 124 |
<div style={{ border: `1px solid ${THEME.rule}`, padding: '14px 16px', background: THEME.paper }}>
|
| 125 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
|
| 126 |
<span style={{ font: `500 11px ${THEME.mono}`, color: THEME.inkSoft, letterSpacing: '0.05em', textTransform: 'uppercase' }}>Oxygen requirement</span>
|
| 127 |
-
<
|
| 128 |
-
{O2.
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
</div>
|
| 131 |
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, alignItems: 'center' }}>
|
| 132 |
<div>
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { THEME, tempColor, pHColor, saltColor } from '../theme.js';
|
| 3 |
+
import { OxygenConfArc, IntervalBar, MediaConfBar, MonoTag, SourceBadge } from './Primitives.jsx';
|
| 4 |
|
| 5 |
const PRESETS = [
|
| 6 |
{ id: 'ecoli', name: 'Escherichia coli K-12 MG1655', accession: 'GCF_000005845.2',
|
|
|
|
| 124 |
<div style={{ border: `1px solid ${THEME.rule}`, padding: '14px 16px', background: THEME.paper }}>
|
| 125 |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
|
| 126 |
<span style={{ font: `500 11px ${THEME.mono}`, color: THEME.inkSoft, letterSpacing: '0.05em', textTransform: 'uppercase' }}>Oxygen requirement</span>
|
| 127 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 128 |
+
<SourceBadge source={O2.source} compact />
|
| 129 |
+
<span style={{ font: `500 11px ${THEME.mono}`, color: O2.prediction === pub.O2 ? THEME.pos : THEME.warn }}>
|
| 130 |
+
{O2.prediction === pub.O2 ? '✓ match' : '△ mismatch'}
|
| 131 |
+
</span>
|
| 132 |
+
</div>
|
| 133 |
</div>
|
| 134 |
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, alignItems: 'center' }}>
|
| 135 |
<div>
|