Spaces:
Running
Running
| """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 & Design</h2> | |
| <p>Estimate the strength of any concrete mix — or design a mix to hit a target strength.</p> | |
| </div> | |
| </div> | |
| """ | |
| def get_predictor() -> Predictor: | |
| return Predictor(CKPT_DIR) | |
| 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() | |