ConcPre / streamlit_app.py
Heterogeneity2025's picture
Upload 32 files
7a1376e verified
Raw
History Blame Contribute Delete
25.9 kB
"""Concrete Compressive Strength — Predict / Design (Streamlit).
Two tools:
* Predict strength — enter a mix design (any field may be left blank), get the
predicted compressive strength.
* Design a mix — enter a target strength, get detailed mix designs that
reach it (drawn from real data, optionally refined). All
suggestions are reported at 28-day strength.
Run locally: streamlit run app/streamlit_app.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
import streamlit as st
# Make sibling modules importable no matter how the script is launched
# (`streamlit run`, AppTest, or a Hugging Face Space at repo root).
sys.path.insert(0, str(Path(__file__).resolve().parent))
from inference import (
AGE_COL,
CURING_CARBON_FACTORS,
DESIGN_COLS,
EMBODIED_CARBON_FACTORS,
SCM_COLS,
Predictor,
embodied_carbon,
_default_ckpt_dir,
_default_index,
)
st.set_page_config(
page_title="Concrete Compressive Strength",
page_icon="◧",
layout="wide",
initial_sidebar_state="collapsed",
)
CKPT_DIR = _default_ckpt_dir()
INDEX_PATH = _default_index()
DESIGN_AGE = 28.0 # all design suggestions are reported at 28-day strength
# ---------------------------------------------------------------------------
# Styling — editorial: cream canvas, black frame + top bar, big grotesque type.
# ---------------------------------------------------------------------------
INK = "#1a1a1a" # near-black text / accent (monochrome)
SUBTLE = "#6b6b63" # warm gray secondary text
CREAM = "#ffffff" # page background / light text on dark elements
CARD = "#f6f6f7" # faint card surface (separates groups on white)
HAIRLINE = "rgba(0,0,0,0.10)"
FONT = ('"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", '
'Roboto, Helvetica, Arial, sans-serif')
CSS = f"""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
/* hide default Streamlit chrome */
#MainMenu, footer, header[data-testid="stHeader"] {{ display: none; }}
[data-testid="stSidebar"] {{ display: none; }}
html, body, .stApp, [class*="css"] {{ font-family: {FONT}; }}
.stApp {{ background-color: {CREAM}; color: {INK}; }}
.block-container {{ padding-top: 78px; padding-bottom: 3rem; max-width: 1300px; }}
/* black top bar with header labels (no side/bottom frame) */
.topbar {{ position: fixed; top: 0; left: 0; right: 0; height: 46px; z-index: 10000;
background: #000; display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; pointer-events: none; }}
.topbar span {{ color: {CREAM}; font-size: 0.72rem; font-weight: 700;
letter-spacing: 0.14em; text-transform: uppercase; white-space: nowrap; }}
.topbar .center {{ position: absolute; left: 50%; transform: translateX(-50%); }}
/* hero: cube on the left, big editorial two-line title on the right */
.hero {{ display: flex; align-items: center; gap: 46px; padding: 8px 0 14px; }}
.htext {{ text-align: left; }}
.htext h1 {{ font-size: 3rem; font-weight: 800; color: {INK};
letter-spacing: -0.035em; line-height: 1.04; margin: 0; }}
.htext h2 {{ font-size: 3rem; font-weight: 800; color: {INK};
letter-spacing: -0.035em; line-height: 1.04; margin: 0; }}
.htext p {{ font-size: 1.18rem; font-weight: 400; color: #333333;
margin: 18px 0 0; max-width: 660px; line-height: 1.5; }}
/* 3D rotating concrete block (transparent background, CSS only) */
.scene {{ width: 184px; height: 184px; flex-shrink: 0; perspective: 1000px; }}
.cube {{ width: 140px; height: 140px; position: relative; transform-style: preserve-3d;
margin: 22px auto; animation: spin 16s linear infinite; }}
@keyframes spin {{
from {{ transform: rotateX(-24deg) rotateY(0deg); }}
to {{ transform: rotateX(-24deg) rotateY(360deg); }}
}}
.face {{ position: absolute; width: 140px; height: 140px;
border: 1px solid rgba(0,0,0,0.12);
box-shadow: inset 0 0 30px rgba(0,0,0,0.10); }}
.front {{ transform: translateZ(70px); background: #bdbdbd; }}
.back {{ transform: rotateY(180deg) translateZ(70px); background: #bdbdbd; }}
.right {{ transform: rotateY(90deg) translateZ(70px); background: #ababab; }}
.left {{ transform: rotateY(-90deg) translateZ(70px); background: #ababab; }}
.top {{ transform: rotateX(90deg) translateZ(70px); background: #d4d4d4; }}
.bottom {{ transform: rotateX(-90deg) translateZ(70px); background: #8f8f8f; }}
/* tabs as a segmented control (left-aligned, black selected) */
.stTabs [data-baseweb="tab-list"] {{
background: #ededef; border-radius: 10px; padding: 4px; gap: 4px;
width: fit-content; margin: 18px 0 12px; border: none;
}}
.stTabs [data-baseweb="tab"] {{ height: 40px; padding: 0 28px; background: transparent; border-radius: 7px; }}
.stTabs [data-baseweb="tab"] p {{ font-size: 1.0rem; font-weight: 700; color: {INK}; }}
.stTabs [aria-selected="true"] {{ background: {INK}; }}
.stTabs [aria-selected="true"] p {{ color: {CREAM}; }}
.stTabs [data-baseweb="tab-highlight"] {{ display: none; }}
/* step + section headings */
.step {{ font-size: 1.35rem; font-weight: 700; color: {INK};
letter-spacing: -0.01em; margin: 8px 0 0; }}
.sec {{ font-size: 0.95rem; font-weight: 700; color: {INK};
text-transform: uppercase; letter-spacing: 0.05em; margin: 2px 0 10px; }}
/* group cards */
[data-testid="stVerticalBlockBorderWrapper"] {{
background: {CARD}; border: 1px solid {HAIRLINE}; border-radius: 14px;
padding: 10px 20px 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}}
/* result cards */
[data-testid="stMetric"] {{
background: {CARD}; border: 1px solid {HAIRLINE}; border-radius: 16px; padding: 22px 24px;
}}
[data-testid="stMetricValue"] {{ font-size: 2.5rem; font-weight: 800; color: {INK};
letter-spacing: -0.02em; }}
[data-testid="stMetricLabel"] p {{ color: {SUBTLE}; font-weight: 600; font-size: 0.95rem; }}
/* inputs */
[data-testid="stNumberInput"] input, [data-baseweb="select"] > div {{ border-radius: 9px; }}
/* field labels (Cement, GGBS, …) — darker for contrast on the cards */
[data-testid="stWidgetLabel"] p {{ color: {INK}; font-weight: 500; }}
/* buttons — solid black */
.stButton > button {{
background: {INK}; color: {CREAM}; border: 0; border-radius: 8px;
padding: 0.6rem 1.8rem; font-weight: 700; font-size: 1.0rem;
}}
.stButton > button:hover {{ background: #000; color: {CREAM}; }}
.stDownloadButton > button {{
background: transparent; color: {INK}; border: 1px solid {INK}; border-radius: 8px;
padding: 0.5rem 1.4rem; font-weight: 700;
}}
</style>
"""
FRAME = """
<div class="topbar">
<span class="left">Concrete Mix Toolkit</span>
<span class="center">Compressive Strength</span>
<span class="right">Predict / Design</span>
</div>
"""
HERO = """
<div class="hero">
<div class="scene"><div class="cube">
<div class="face front"></div><div class="face back"></div>
<div class="face right"></div><div class="face left"></div>
<div class="face top"></div><div class="face bottom"></div>
</div></div>
<div class="htext">
<h1>Concrete Compressive Strength</h1>
<h2>Predict &amp; Design</h2>
<p>Estimate the strength of any concrete mix — or design a mix to hit a target strength.</p>
</div>
</div>
"""
@st.cache_resource(show_spinner="Loading…")
def get_predictor() -> Predictor:
return Predictor(CKPT_DIR)
@st.cache_data(show_spinner="Loading mix database…")
def get_index() -> pd.DataFrame:
return pd.read_csv(INDEX_PATH) if INDEX_PATH else pd.DataFrame()
# ---- friendly field definitions: (column, label, unit) ----
BINDER = [
("cement_kg_m3", "Cement", "kg/m³"),
("slag_kg_m3", "GGBS / slag", "kg/m³"),
("fly_ash_kg_m3", "Fly ash", "kg/m³"),
("silica_fume_kg_m3", "Silica fume", "kg/m³"),
("metakaolin_kg_m3", "Metakaolin", "kg/m³"),
("limestone_powder_kg_m3", "Limestone powder", "kg/m³"),
("other_scm_kg_m3", "Other SCM / filler", "kg/m³"),
]
AGG = [
("coarse_aggregate_kg_m3", "Coarse aggregate", "kg/m³"),
("fine_aggregate_kg_m3", "Fine aggregate / sand", "kg/m³"),
]
FIBRE = [
("fibre_content_kg_m3", "Fibre content", "kg/m³"),
("fibre_length_mm", "Fibre length", "mm"),
("fibre_diameter_mm", "Fibre diameter", "mm"),
("fibre_tensile_strength_mpa", "Fibre tensile strength", "MPa"),
("fibre_modulus_gpa", "Fibre modulus", "GPa"),
]
CHEM = [
("cement_CaO_pct", "Cement CaO", "%"), ("cement_SiO2_pct", "Cement SiO₂", "%"),
("cement_Al2O3_pct", "Cement Al₂O₃", "%"), ("cement_Fe2O3_pct", "Cement Fe₂O₃", "%"),
("cement_MgO_pct", "Cement MgO", "%"), ("cement_SO3_pct", "Cement SO₃", "%"),
("cement_alkali_pct", "Cement alkali (Na₂O-eq)", "%"), ("cement_LOI_pct", "Cement LOI", "%"),
("scm_CaO_pct", "SCM CaO", "%"), ("scm_SiO2_pct", "SCM SiO₂", "%"),
("scm_Al2O3_pct", "SCM Al₂O₃", "%"), ("scm_Fe2O3_pct", "SCM Fe₂O₃", "%"),
("scm_MgO_pct", "SCM MgO", "%"), ("scm_LOI_pct", "SCM LOI", "%"),
("cement_grade_mpa", "Cement grade", "MPa"),
("specimen_size_mm", "Specimen size", "mm"),
]
# Official-style labels -> the model's canonical categorical values.
CEMENT_TYPE_OPTIONS = {
"(unspecified)": None,
"CEM I — Ordinary Portland (OPC)": "opc",
"Portland Type I/II": "type_i_ii",
"Type III — Rapid-hardening": "type_iii",
"Sulfate-resisting (HS)": "hs",
"Other": "other",
}
FIBRE_TYPE_OPTIONS = {
"(unspecified)": None, "None": "none", "Steel": "steel",
"PVA": "pva", "PE / polyethylene": "pe", "Other": "other",
}
CURING_OPTIONS = {
"(unspecified)": None, "Standard": "standard", "Steam": "steam",
"Heat-cured": "heat", "Autoclave": "autoclave", "Other": "other",
}
# Friendly SCM labels for the inverse "exclude" picker (keyed by column).
SCM_LABELS = {
"slag_kg_m3": "GGBS / slag",
"fly_ash_kg_m3": "Fly ash",
"silica_fume_kg_m3": "Silica fume",
"metakaolin_kg_m3": "Metakaolin",
"limestone_powder_kg_m3": "Limestone powder",
"other_scm_kg_m3": "Other SCM / filler",
}
# Curing regimes a user can prefer in the inverse search (label -> model value).
CURING_PREF_OPTIONS = {
"Standard": "standard", "Steam": "steam",
"Heat-cured": "heat", "Autoclave": "autoclave", "Other": "other",
}
# Map every raw column to a clear, units-bearing label for the results table.
COLUMN_LABELS = {c: f"{lab} ({u})" for c, lab, u in (BINDER + AGG + FIBRE + CHEM)}
COLUMN_LABELS.update({
"max_coarse_aggregate_size_mm": "Max coarse agg. size (mm)",
"max_fine_aggregate_size_mm": "Max fine agg. size (mm)",
"curing_temperature_c": "Curing temperature (°C)",
"curing_humidity_pct": "Curing humidity (%)",
"pred_gnn": "Predicted strength (MPa)",
"target_mpa": "Target (MPa)",
"water_binder_ratio": "Water / binder",
"scm_fraction": "SCM fraction",
"water_kg_m3": "Water (kg/m³)",
"superplasticizer_kg_m3": "Superplasticizer (kg/m³)",
"age_days": "Curing age (days)",
"embodied_carbon_kgco2e_m3": "Embodied carbon (kg CO₂e/m³)",
})
# Short ingredient names for the embodied-carbon inventory & breakdown.
CARBON_LABELS = {
"cement_kg_m3": "Cement",
"slag_kg_m3": "GGBS / slag",
"fly_ash_kg_m3": "Fly ash",
"silica_fume_kg_m3": "Silica fume",
"metakaolin_kg_m3": "Metakaolin",
"limestone_powder_kg_m3": "Limestone powder",
"other_scm_kg_m3": "Other SCM / filler",
"water_kg_m3": "Water",
"superplasticizer_kg_m3": "Superplasticizer",
"coarse_aggregate_kg_m3": "Coarse aggregate",
"fine_aggregate_kg_m3": "Fine aggregate / sand",
"fibre_content_kg_m3": "Fibre",
}
def _num(pred, col, label, unit, default=0.0, optional=False,
lo=None, hi=None, min_value=0.0, max_value=None):
"""Render a number_input. ``lo``/``hi`` override the 'typical' hint range;
``min_value``/``max_value`` set hard entry limits."""
b = pred.bounds.get(col, {})
p_lo = lo if lo is not None else b.get("p01")
p_hi = hi if hi is not None else b.get("p99")
help_txt = (f"typical {p_lo:.0f}{p_hi:.0f} {unit}"
if (p_lo is not None and p_hi is not None) else None)
return st.number_input(
f"{label} ({unit})",
value=(None if optional else float(default)),
min_value=float(min_value),
max_value=(float(max_value) if max_value is not None else None),
step=1.0, help=help_txt, key=f"in_{col}",
)
def _step(title: str) -> None:
st.markdown(f"<div class='step'>{title}</div>", unsafe_allow_html=True)
def _sec(title: str) -> None:
st.markdown(f"<div class='sec'>{title}</div>", unsafe_allow_html=True)
def _category_select(label, options, mix, key):
choice = st.selectbox(label, list(options.keys()), index=0)
if options[choice] is not None:
mix[key] = options[choice]
def carbon_inventory_editor(key_prefix: str) -> tuple[dict, dict]:
"""Editable embodied-carbon inventory.
Returns ``(material_factors, curing_factors)`` — material kg CO₂e/kg and
curing-process kg CO₂e/m³.
"""
factors, curing = {}, {}
with st.expander("Embodied-carbon inventory (emission factors)"):
st.caption("Cradle-to-gate kg CO₂e per kg of material. Indicative "
"defaults (ICE database / EPD literature) — adjust to your "
"suppliers' EPDs.")
cols = st.columns(3)
for i, (col, default) in enumerate(EMBODIED_CARBON_FACTORS.items()):
with cols[i % 3]:
factors[col] = st.number_input(
CARBON_LABELS.get(col, col),
value=float(default), min_value=0.0, step=0.001,
format="%.4f", key=f"{key_prefix}_cf_{col}",
)
st.caption("Curing-process energy, kg CO₂e per m³ (heated regimes only).")
ccols = st.columns(3)
# Standard/ambient curing adds no process energy, so it isn't editable.
editable = [(r, d) for r, d in CURING_CARBON_FACTORS.items() if r != "standard"]
for i, (regime, default) in enumerate(editable):
with ccols[i % 3]:
curing[regime] = st.number_input(
f"Curing: {regime}",
value=float(default), min_value=0.0, step=1.0,
format="%.1f", key=f"{key_prefix}_cure_{regime}",
)
return factors, curing
def show_carbon_breakdown(mix: dict, factors: dict, curing: dict,
strength: float | None = None) -> float:
"""Render the embodied-carbon and intensity metrics only. Returns total."""
total, _ = embodied_carbon(mix, factors, curing)
cols = st.columns(2)
cols[0].metric("Embodied carbon", f"{total:,.0f} kg CO₂e / m³")
if strength and strength > 0:
cols[1].metric("Carbon intensity", f"{total / strength:.1f} kg CO₂e / m³ / MPa")
return total
def ingredient_lines(row: pd.Series) -> list[tuple[str, float]]:
"""(label, amount) for each non-trivial design ingredient in a suggested mix."""
always = {"cement_kg_m3", "water_kg_m3"}
items = []
for col in DESIGN_COLS:
if col == AGE_COL or col not in row:
continue
val = pd.to_numeric(row[col], errors="coerce")
if pd.isna(val) or (val == 0 and col not in always):
continue
items.append((COLUMN_LABELS.get(col, col), float(val)))
return items
def format_suggestions(out: pd.DataFrame) -> pd.DataFrame:
"""Clean, clearly-named subset of the suggested mixes."""
always = {"cement_kg_m3", "water_kg_m3", "age_days"}
ing = [c for c in DESIGN_COLS if c in out.columns]
keep_ing = [c for c in ing if c in always
or pd.to_numeric(out[c], errors="coerce").fillna(0).abs().sum() > 0]
head = [c for c in ["pred_gnn", "water_binder_ratio"]
if c in out.columns]
disp = out[head + keep_ing].copy()
return disp.rename(columns=COLUMN_LABELS)
# ---------------------------------------------------------------------------
# Tab 1 — forward
# ---------------------------------------------------------------------------
def forward_tab(pred: Predictor) -> None:
_step("Enter your mix design")
st.caption("Leave any field blank if you don't have it. Amounts are per m³ of concrete.")
mix: dict = {}
c1, c2, c3 = st.columns(3, gap="large")
with c1:
with st.container(border=True):
_sec("Binder")
med = pred.bounds.get("cement_kg_m3", {}).get("p50", 350.0)
for col, label, unit in BINDER:
default = med if col == "cement_kg_m3" else 0.0
mix[col] = _num(pred, col, label, unit, default=default)
with c2:
with st.container(border=True):
_sec("Water & admixture")
mix["water_kg_m3"] = _num(
pred, "water_kg_m3", "Water", "kg/m³",
default=pred.bounds.get("water_kg_m3", {}).get("p50", 175.0), lo=120, hi=600,
)
mix["superplasticizer_kg_m3"] = _num(
pred, "superplasticizer_kg_m3", "Superplasticizer", "kg/m³",
default=pred.bounds.get("superplasticizer_kg_m3", {}).get("p50", 0.0),
)
with st.container(border=True):
_sec("Aggregate")
for col, label, unit in AGG:
d = pred.bounds.get(col, {}).get("p50", 0.0)
mix[col] = _num(pred, col, label, unit, default=d)
mix["max_coarse_aggregate_size_mm"] = _num(
pred, "max_coarse_aggregate_size_mm", "Max coarse agg. size", "mm",
optional=True, lo=10, hi=40, min_value=10, max_value=40,
)
mix["max_fine_aggregate_size_mm"] = _num(
pred, "max_fine_aggregate_size_mm", "Max fine agg. size", "mm", optional=True,
)
with c3:
with st.container(border=True):
_sec("Curing & age")
mix[AGE_COL] = st.number_input("Curing age (days)", value=28.0, min_value=1.0, step=1.0)
mix["curing_temperature_c"] = _num(
pred, "curing_temperature_c", "Curing temperature", "°C", optional=True)
mix["curing_humidity_pct"] = _num(
pred, "curing_humidity_pct", "Curing humidity", "%", optional=True)
with st.expander("Fibres (optional)"):
fc = st.columns(len(FIBRE))
for (col, label, unit), c in zip(FIBRE, fc):
with c:
mix[col] = _num(pred, col, label, unit, optional=True)
_category_select("Fibre type", FIBRE_TYPE_OPTIONS, mix, "fibre_type_norm")
with st.expander("Cement / SCM chemistry & type (advanced, optional)"):
gcols = st.columns(4)
for i, (col, label, unit) in enumerate(CHEM):
with gcols[i % 4]:
mix[col] = _num(pred, col, label, unit, optional=True)
s1, s2 = st.columns(2)
with s1:
_category_select("Cement type", CEMENT_TYPE_OPTIONS, mix, "cement_type_norm")
with s2:
_category_select("Curing regime", CURING_OPTIONS, mix, "curing_regime_norm")
carbon_factors, curing_factors = carbon_inventory_editor("fwd")
show_curve = st.checkbox("Show strength gain with curing age", value=True)
st.write("")
go = st.button("Predict strength", type="primary")
if go:
clean = {k: v for k, v in mix.items() if v not in (None, "")}
with st.spinner("Predicting…"):
res = pred.predict_strength(clean)
st.write("")
st.metric("Predicted compressive strength", f"{res['gnn']:.1f} MPa")
rmse = pred.config.get("test_metrics", {}).get("rmse")
if rmse:
st.caption(f"Typical accuracy ≈ ±{rmse:.0f} MPa on held-out test data.")
flags = pred.out_of_range(clean)
if flags:
pretty = ", ".join(COLUMN_LABELS.get(f, f) for f in flags)
st.warning(f"Some inputs are outside the usual data range ({pretty}); "
"the prediction there is an extrapolation.")
st.markdown("##### Embodied carbon")
show_carbon_breakdown(clean, carbon_factors, curing_factors, strength=res["gnn"])
if show_curve:
curve = pred.age_curve(clean)[["age_days", "gnn_mpa"]].rename(
columns={"gnn_mpa": "Predicted (MPa)"}
)
st.markdown("##### Predicted strength gain with curing age")
chart_col, _ = st.columns([3, 2])
with chart_col:
st.line_chart(curve.set_index("age_days"))
# ---------------------------------------------------------------------------
# Tab 2 — inverse
# ---------------------------------------------------------------------------
def inverse_tab(pred: Predictor, index: pd.DataFrame) -> None:
_step("Choose a target strength")
if index.empty:
st.info("The mix database (`inverse_index.csv`) isn't built yet. "
"Run `python app/build_inverse_index.py` first.")
return
smin = float(pred.config.get("strength_min", 10))
smax = float(pred.config.get("strength_max", 200))
c1, c2 = st.columns([2, 1])
with c1:
target = st.slider("Target compressive strength (MPa)", smin, smax,
min(80.0, smax), step=1.0)
with c2:
k = st.slider("Number of mix options", 1, 4, 1)
o1, o2, o3 = st.columns(3)
allow_fibre = o1.checkbox("Allow fibres", value=True)
require_coarse = o2.checkbox(
"Require coarse aggregate", value=True,
help="Exclude binder/sand-only mixes (UHPC, mortars). Switch off only if "
"you're designing a UHPC-style mix.")
refine = o3.checkbox("Refine to target", value=False,
help="Fine-tune each candidate so its predicted strength matches the target.")
e1, e2 = st.columns(2)
with e1:
excluded_labels = st.multiselect(
"SCMs you can't use", list(SCM_LABELS.values()),
help="Mixes containing any selected supplementary cementitious material are excluded.")
with e2:
preferred_labels = st.multiselect(
"Preferred curing methods", list(CURING_PREF_OPTIONS.keys()),
help="Leave empty to allow any curing method. Records with no recorded "
"regime are treated as standard; steam/heat/autoclave data comes "
"only from the UHPC dataset.")
exclude_scms = [c for c, lab in SCM_LABELS.items() if lab in excluded_labels]
curing_regimes = [CURING_PREF_OPTIONS[lab] for lab in preferred_labels]
carbon_factors, curing_factors = carbon_inventory_editor("inv")
st.caption("All suggestions are reported at 28-day strength.")
st.write("")
if st.button("Design mixes", type="primary"):
with st.spinner("Searching mix designs…"):
out = pred.suggest_mixes(
target, index, k=k, allow_fibre=allow_fibre,
require_coarse_aggregate=require_coarse,
exclude_scms=exclude_scms, curing_regimes=curing_regimes,
domain="any", age=DESIGN_AGE, refine=refine,
)
if out.empty:
st.warning("No mixes match those constraints — try widening them.")
return
st.success(f"{len(out)} mix design(s) predicted to reach ≈ {target:.0f} MPa at 28 days")
out = out.reset_index(drop=True)
out["embodied_carbon_kgco2e_m3"] = [
round(embodied_carbon(r.to_dict(), carbon_factors, curing_factors)[0], 1)
for _, r in out.iterrows()
]
# One clearly-itemised card per mix option (no wide table).
for i, row in out.iterrows():
with st.container(border=True):
st.markdown(f"#### Mix option {i + 1}")
m = st.columns(3)
m[0].metric("Predicted strength", f"{row['pred_gnn']:.1f} MPa")
wb = pd.to_numeric(row.get("water_binder_ratio"), errors="coerce")
m[1].metric("Water / binder", f"{wb:.2f}" if pd.notna(wb) else "—")
m[2].metric("Embodied carbon",
f"{row['embodied_carbon_kgco2e_m3']:,.0f} kg CO₂e/m³")
st.markdown("**Ingredients (per m³ of concrete)**")
items = ingredient_lines(row)
lc = st.columns(2)
for j, (label, val) in enumerate(items):
lc[j % 2].markdown(f"- {label}: **{val:g}**")
# Full machine-readable export still available.
export = format_suggestions(out)
if "embodied_carbon_kgco2e_m3" in out.columns:
export["Embodied carbon (kg CO₂e/m³)"] = out["embodied_carbon_kgco2e_m3"].values
st.write("")
st.download_button("Download these mixes (CSV)", export.to_csv(index=False),
file_name=f"mix_designs_{int(target)}MPa.csv", mime="text/csv")
def main() -> None:
st.markdown(CSS, unsafe_allow_html=True)
st.markdown(FRAME, unsafe_allow_html=True)
st.markdown(HERO, unsafe_allow_html=True)
if not (Path(CKPT_DIR) / "hierarchical.pt").exists():
st.error(f"No trained model found in `{CKPT_DIR}`.")
st.stop()
pred = get_predictor()
index = get_index()
tab1, tab2 = st.tabs(["Predict strength", "Design a mix"])
with tab1:
forward_tab(pred)
with tab2:
inverse_tab(pred, index)
if __name__ == "__main__":
main()
else:
main()