Spaces:
Running
Running
Upload 29 files
Browse files- .gitattributes +2 -0
- .gitignore +2 -0
- Dockerfile +22 -0
- README.md +35 -0
- build_inverse_index.py +114 -0
- checkpoints_full_rich/hierarchical.pt +3 -0
- checkpoints_full_rich/model_config.json +292 -0
- checkpoints_full_rich/tabular_ann.pt +3 -0
- concrete_gnn/__init__.py +183 -0
- concrete_gnn/categoricals.py +78 -0
- concrete_gnn/data.py +372 -0
- concrete_gnn/graph_generator.py +800 -0
- concrete_gnn/ground_truth.py +181 -0
- concrete_gnn/missing_features.py +159 -0
- concrete_gnn/models/__init__.py +28 -0
- concrete_gnn/models/concrete_gnn.py +100 -0
- concrete_gnn/models/hierarchical.py +521 -0
- concrete_gnn/models/itz_model.py +44 -0
- concrete_gnn/models/layers.py +161 -0
- concrete_gnn/models/mortar_gnn.py +89 -0
- concrete_gnn/models/outputs.py +75 -0
- concrete_gnn/real_data.py +648 -0
- concrete_gnn/schema.py +249 -0
- concrete_gnn/train.py +316 -0
- concrete_gnn/visualize.py +329 -0
- inference.py +441 -0
- inverse_index.csv +3 -0
- requirements.txt +13 -0
- streamlit_app.py +459 -0
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.csv filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
ENV PYTHONPATH=/app
|
| 6 |
+
|
| 7 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
build-essential \
|
| 9 |
+
curl \
|
| 10 |
+
git \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
COPY . .
|
| 17 |
+
|
| 18 |
+
EXPOSE 8501
|
| 19 |
+
|
| 20 |
+
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health || exit 1
|
| 21 |
+
|
| 22 |
+
ENTRYPOINT ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Concrete Strength Predict Design
|
| 3 |
+
colorFrom: gray
|
| 4 |
+
colorTo: blue
|
| 5 |
+
sdk: docker
|
| 6 |
+
app_port: 8501
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Concrete Compressive-Strength Predict / Design
|
| 12 |
+
|
| 13 |
+
An interactive tool for concrete mix design.
|
| 14 |
+
|
| 15 |
+
- **Predict strength** - enter a mix design, with optional fields allowed, and get the predicted compressive strength.
|
| 16 |
+
- **Design a mix** - enter a target strength and get several detailed mix designs that reach it.
|
| 17 |
+
|
| 18 |
+
Trained on about 9,700 mixes spanning about 2 to 198 MPa, from normal-strength concrete through ultra-high-performance concrete.
|
| 19 |
+
|
| 20 |
+
## Running locally
|
| 21 |
+
|
| 22 |
+
```bash
|
| 23 |
+
pip install -r requirements.txt
|
| 24 |
+
streamlit run streamlit_app.py
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
## Files
|
| 28 |
+
|
| 29 |
+
| file | purpose |
|
| 30 |
+
|---|---|
|
| 31 |
+
| `streamlit_app.py` | the web UI |
|
| 32 |
+
| `inference.py` | model loading plus predict / design logic |
|
| 33 |
+
| `build_inverse_index.py` | offline script to precompute the design index and `model_config.json` |
|
| 34 |
+
| `checkpoints_full_rich/` | trained model files and `model_config.json` |
|
| 35 |
+
| `inverse_index.csv` | precomputed predictions over the training mixes |
|
build_inverse_index.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Precompute the inverse-design retrieval index + the serving config.
|
| 2 |
+
|
| 3 |
+
Runs the trained GNN over every training row once, so the app's inverse
|
| 4 |
+
("strength -> mixes") can retrieve without any live model calls. Also derives
|
| 5 |
+
the per-feature bounds (for UI defaults, range warnings and refine clipping)
|
| 6 |
+
and copies the held-out metrics out of the checkpoint, writing them next to the
|
| 7 |
+
checkpoints as ``model_config.json``.
|
| 8 |
+
|
| 9 |
+
Run (after training):
|
| 10 |
+
python app/build_inverse_index.py \
|
| 11 |
+
--data Data/combined_rich_model_ready.csv \
|
| 12 |
+
--checkpoint-dir Hybrid/outputs/checkpoints_full_rich
|
| 13 |
+
|
| 14 |
+
Writes:
|
| 15 |
+
app/inverse_index.csv
|
| 16 |
+
<checkpoint-dir>/model_config.json
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import json
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import pandas as pd
|
| 27 |
+
import torch
|
| 28 |
+
|
| 29 |
+
from inference import ( # local module
|
| 30 |
+
AGE_COL,
|
| 31 |
+
AMOUNT_COLS,
|
| 32 |
+
CURING_ONLY_GLOBALS,
|
| 33 |
+
CURING_ONLY_MORTAR_GLOBALS,
|
| 34 |
+
MASKABLE_COLS,
|
| 35 |
+
Predictor,
|
| 36 |
+
_HERE,
|
| 37 |
+
build_input_frame,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
BOUND_COLS = AMOUNT_COLS + [AGE_COL] + MASKABLE_COLS
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _bounds(df: pd.DataFrame) -> dict:
|
| 44 |
+
out = {}
|
| 45 |
+
for c in BOUND_COLS:
|
| 46 |
+
if c not in df.columns:
|
| 47 |
+
continue
|
| 48 |
+
s = pd.to_numeric(df[c], errors="coerce").dropna()
|
| 49 |
+
if s.empty:
|
| 50 |
+
continue
|
| 51 |
+
out[c] = {
|
| 52 |
+
"min": float(s.min()), "max": float(s.max()),
|
| 53 |
+
"p01": float(s.quantile(0.01)), "p50": float(s.median()),
|
| 54 |
+
"p99": float(s.quantile(0.99)),
|
| 55 |
+
}
|
| 56 |
+
return out
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def main() -> None:
|
| 60 |
+
ap = argparse.ArgumentParser()
|
| 61 |
+
ap.add_argument("--data", default="Data/combined_rich_model_ready.csv")
|
| 62 |
+
ap.add_argument("--checkpoint-dir", default="Hybrid/outputs/checkpoints_full_rich")
|
| 63 |
+
ap.add_argument("--out", default=str(_HERE / "inverse_index.csv"))
|
| 64 |
+
ap.add_argument("--batch-size", type=int, default=256)
|
| 65 |
+
args = ap.parse_args()
|
| 66 |
+
|
| 67 |
+
ckpt_dir = Path(args.checkpoint_dir)
|
| 68 |
+
pred = Predictor(ckpt_dir)
|
| 69 |
+
df = pd.read_csv(args.data)
|
| 70 |
+
print(f"scoring {len(df)} rows with the GNN ...")
|
| 71 |
+
|
| 72 |
+
# Predict in chunks to bound memory / show progress.
|
| 73 |
+
preds = np.empty(len(df), dtype=float)
|
| 74 |
+
bs = args.batch_size
|
| 75 |
+
for start in range(0, len(df), bs):
|
| 76 |
+
chunk = df.iloc[start:start + bs].reset_index(drop=True)
|
| 77 |
+
mixes = chunk.to_dict("records")
|
| 78 |
+
preds[start:start + len(chunk)] = pred.predict_df(
|
| 79 |
+
build_input_frame(mixes), which=("gnn",)
|
| 80 |
+
)["gnn"]
|
| 81 |
+
print(f" {min(start + bs, len(df))}/{len(df)}")
|
| 82 |
+
|
| 83 |
+
index = df.copy()
|
| 84 |
+
index["measured"] = pd.to_numeric(index["compressive_strength_mpa"], errors="coerce")
|
| 85 |
+
index["pred_gnn"] = np.round(preds, 2)
|
| 86 |
+
index.to_csv(args.out, index=False)
|
| 87 |
+
print(f"wrote {args.out} ({len(index)} rows)")
|
| 88 |
+
|
| 89 |
+
# ---- model_config.json ----
|
| 90 |
+
ck = torch.load(ckpt_dir / "hierarchical.pt", map_location="cpu", weights_only=False)
|
| 91 |
+
config = {
|
| 92 |
+
"schema": "curing_only",
|
| 93 |
+
"curing_only_globals": list(CURING_ONLY_GLOBALS),
|
| 94 |
+
"curing_only_mortar_globals": list(CURING_ONLY_MORTAR_GLOBALS),
|
| 95 |
+
"strength_head_kind": "mortar_capacity",
|
| 96 |
+
"pool": "mean",
|
| 97 |
+
"num_layers": 2,
|
| 98 |
+
"target_mean": float(ck["target_mean"]),
|
| 99 |
+
"target_std": float(ck["target_std"]),
|
| 100 |
+
"test_metrics": ck.get("test_metrics", {}),
|
| 101 |
+
"train_data": args.data,
|
| 102 |
+
"n_rows": int(len(df)),
|
| 103 |
+
"strength_min": float(index["measured"].min()),
|
| 104 |
+
"strength_max": float(index["measured"].max()),
|
| 105 |
+
"feature_bounds": _bounds(df),
|
| 106 |
+
}
|
| 107 |
+
cfg_path = ckpt_dir / "model_config.json"
|
| 108 |
+
cfg_path.write_text(json.dumps(config, indent=2))
|
| 109 |
+
print(f"wrote {cfg_path}")
|
| 110 |
+
print("test metrics:", config["test_metrics"])
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
if __name__ == "__main__":
|
| 114 |
+
main()
|
checkpoints_full_rich/hierarchical.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ccaa6a54cf7eb6f31d5081b68900cb762fb0bc9b86e18d3dc1a589f181082039
|
| 3 |
+
size 7744095
|
checkpoints_full_rich/model_config.json
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema": "curing_only",
|
| 3 |
+
"curing_only_globals": [
|
| 4 |
+
"relative_humidity",
|
| 5 |
+
"temperature_C",
|
| 6 |
+
"curing_age_days",
|
| 7 |
+
"_placeholder_0",
|
| 8 |
+
"_placeholder_1"
|
| 9 |
+
],
|
| 10 |
+
"curing_only_mortar_globals": [
|
| 11 |
+
"mortar_curing_relative_humidity",
|
| 12 |
+
"mortar_curing_temperature_C",
|
| 13 |
+
"mortar_curing_age_days",
|
| 14 |
+
"_placeholder_0",
|
| 15 |
+
"_placeholder_1"
|
| 16 |
+
],
|
| 17 |
+
"strength_head_kind": "mortar_capacity",
|
| 18 |
+
"pool": "mean",
|
| 19 |
+
"num_layers": 2,
|
| 20 |
+
"target_mean": 76.61848449707031,
|
| 21 |
+
"target_std": 54.902469635009766,
|
| 22 |
+
"test_metrics": {
|
| 23 |
+
"rmse": 10.050239562988281,
|
| 24 |
+
"mae": 6.930269718170166,
|
| 25 |
+
"r2": 0.9658663272857666
|
| 26 |
+
},
|
| 27 |
+
"train_data": "Data\\combined_rich_model_ready.csv",
|
| 28 |
+
"n_rows": 9701,
|
| 29 |
+
"strength_min": 2.331807851791382,
|
| 30 |
+
"strength_max": 298.0,
|
| 31 |
+
"feature_bounds": {
|
| 32 |
+
"cement_kg_m3": {
|
| 33 |
+
"min": 19.0,
|
| 34 |
+
"max": 1530.0,
|
| 35 |
+
"p01": 132.0,
|
| 36 |
+
"p50": 400.0,
|
| 37 |
+
"p99": 1150.0
|
| 38 |
+
},
|
| 39 |
+
"slag_kg_m3": {
|
| 40 |
+
"min": 0.0,
|
| 41 |
+
"max": 768.0,
|
| 42 |
+
"p01": 0.0,
|
| 43 |
+
"p50": 0.0,
|
| 44 |
+
"p99": 564.0
|
| 45 |
+
},
|
| 46 |
+
"fly_ash_kg_m3": {
|
| 47 |
+
"min": 0.0,
|
| 48 |
+
"max": 700.0,
|
| 49 |
+
"p01": 0.0,
|
| 50 |
+
"p50": 0.0,
|
| 51 |
+
"p99": 480.0
|
| 52 |
+
},
|
| 53 |
+
"silica_fume_kg_m3": {
|
| 54 |
+
"min": 0.0,
|
| 55 |
+
"max": 617.64703,
|
| 56 |
+
"p01": 0.0,
|
| 57 |
+
"p50": 0.0,
|
| 58 |
+
"p99": 291.0
|
| 59 |
+
},
|
| 60 |
+
"metakaolin_kg_m3": {
|
| 61 |
+
"min": 0.0,
|
| 62 |
+
"max": 510.0,
|
| 63 |
+
"p01": 0.0,
|
| 64 |
+
"p50": 0.0,
|
| 65 |
+
"p99": 78.0
|
| 66 |
+
},
|
| 67 |
+
"limestone_powder_kg_m3": {
|
| 68 |
+
"min": 0.0,
|
| 69 |
+
"max": 1058.2,
|
| 70 |
+
"p01": 0.0,
|
| 71 |
+
"p50": 0.0,
|
| 72 |
+
"p99": 265.3
|
| 73 |
+
},
|
| 74 |
+
"other_scm_kg_m3": {
|
| 75 |
+
"min": 0.0,
|
| 76 |
+
"max": 1113.0,
|
| 77 |
+
"p01": 0.0,
|
| 78 |
+
"p50": 0.0,
|
| 79 |
+
"p99": 722.0
|
| 80 |
+
},
|
| 81 |
+
"water_kg_m3": {
|
| 82 |
+
"min": 67.83000183105469,
|
| 83 |
+
"max": 476.0,
|
| 84 |
+
"p01": 120.0,
|
| 85 |
+
"p50": 177.23077,
|
| 86 |
+
"p99": 306.0
|
| 87 |
+
},
|
| 88 |
+
"superplasticizer_kg_m3": {
|
| 89 |
+
"min": 0.0,
|
| 90 |
+
"max": 151.7,
|
| 91 |
+
"p01": 0.0,
|
| 92 |
+
"p50": 7.903999805450439,
|
| 93 |
+
"p99": 62.15
|
| 94 |
+
},
|
| 95 |
+
"coarse_aggregate_kg_m3": {
|
| 96 |
+
"min": 0.0,
|
| 97 |
+
"max": 1816.0,
|
| 98 |
+
"p01": 0.0,
|
| 99 |
+
"p50": 877.0,
|
| 100 |
+
"p99": 1280.0
|
| 101 |
+
},
|
| 102 |
+
"fine_aggregate_kg_m3": {
|
| 103 |
+
"min": 0.0,
|
| 104 |
+
"max": 1994.0,
|
| 105 |
+
"p01": 384.6,
|
| 106 |
+
"p50": 805.0,
|
| 107 |
+
"p99": 1600.0
|
| 108 |
+
},
|
| 109 |
+
"fibre_content_kg_m3": {
|
| 110 |
+
"min": 0.0,
|
| 111 |
+
"max": 780.0,
|
| 112 |
+
"p01": 0.0,
|
| 113 |
+
"p50": 0.0,
|
| 114 |
+
"p99": 234.0
|
| 115 |
+
},
|
| 116 |
+
"fibre_length_mm": {
|
| 117 |
+
"min": 0.0,
|
| 118 |
+
"max": 60.0,
|
| 119 |
+
"p01": 0.0,
|
| 120 |
+
"p50": 0.0,
|
| 121 |
+
"p99": 24.75
|
| 122 |
+
},
|
| 123 |
+
"fibre_diameter_mm": {
|
| 124 |
+
"min": 0.0,
|
| 125 |
+
"max": 0.55,
|
| 126 |
+
"p01": 0.0,
|
| 127 |
+
"p50": 0.0,
|
| 128 |
+
"p99": 0.25
|
| 129 |
+
},
|
| 130 |
+
"fibre_tensile_strength_mpa": {
|
| 131 |
+
"min": 0.0,
|
| 132 |
+
"max": 4300.0,
|
| 133 |
+
"p01": 0.0,
|
| 134 |
+
"p50": 0.0,
|
| 135 |
+
"p99": 3000.0
|
| 136 |
+
},
|
| 137 |
+
"fibre_modulus_gpa": {
|
| 138 |
+
"min": 0.0,
|
| 139 |
+
"max": 250.0,
|
| 140 |
+
"p01": 0.0,
|
| 141 |
+
"p50": 0.0,
|
| 142 |
+
"p99": 200.0
|
| 143 |
+
},
|
| 144 |
+
"age_days": {
|
| 145 |
+
"min": 1.0,
|
| 146 |
+
"max": 365.0,
|
| 147 |
+
"p01": 1.0,
|
| 148 |
+
"p50": 28.0,
|
| 149 |
+
"p99": 100.0
|
| 150 |
+
},
|
| 151 |
+
"max_coarse_aggregate_size_mm": {
|
| 152 |
+
"min": 20.0,
|
| 153 |
+
"max": 40.0,
|
| 154 |
+
"p01": 20.0,
|
| 155 |
+
"p50": 20.0,
|
| 156 |
+
"p99": 40.0
|
| 157 |
+
},
|
| 158 |
+
"max_fine_aggregate_size_mm": {
|
| 159 |
+
"min": 0.045,
|
| 160 |
+
"max": 4.75,
|
| 161 |
+
"p01": 0.1,
|
| 162 |
+
"p50": 0.9,
|
| 163 |
+
"p99": 4.75
|
| 164 |
+
},
|
| 165 |
+
"curing_temperature_c": {
|
| 166 |
+
"min": 20.0,
|
| 167 |
+
"max": 210.0,
|
| 168 |
+
"p01": 20.0,
|
| 169 |
+
"p50": 22.0,
|
| 170 |
+
"p99": 210.0
|
| 171 |
+
},
|
| 172 |
+
"cement_CaO_pct": {
|
| 173 |
+
"min": 53.32,
|
| 174 |
+
"max": 76.2,
|
| 175 |
+
"p01": 54.63,
|
| 176 |
+
"p50": 63.59,
|
| 177 |
+
"p99": 69.02
|
| 178 |
+
},
|
| 179 |
+
"cement_SiO2_pct": {
|
| 180 |
+
"min": 11.8,
|
| 181 |
+
"max": 29.67,
|
| 182 |
+
"p01": 15.4,
|
| 183 |
+
"p50": 21.01,
|
| 184 |
+
"p99": 24.86
|
| 185 |
+
},
|
| 186 |
+
"cement_Al2O3_pct": {
|
| 187 |
+
"min": 0.21,
|
| 188 |
+
"max": 8.33,
|
| 189 |
+
"p01": 2.0,
|
| 190 |
+
"p50": 5.07,
|
| 191 |
+
"p99": 8.33
|
| 192 |
+
},
|
| 193 |
+
"cement_Fe2O3_pct": {
|
| 194 |
+
"min": 0.36,
|
| 195 |
+
"max": 5.4,
|
| 196 |
+
"p01": 1.8,
|
| 197 |
+
"p50": 3.318,
|
| 198 |
+
"p99": 5.1
|
| 199 |
+
},
|
| 200 |
+
"cement_MgO_pct": {
|
| 201 |
+
"min": 0.6,
|
| 202 |
+
"max": 5.31,
|
| 203 |
+
"p01": 0.64,
|
| 204 |
+
"p50": 1.91,
|
| 205 |
+
"p99": 5.11
|
| 206 |
+
},
|
| 207 |
+
"cement_SO3_pct": {
|
| 208 |
+
"min": 0.35,
|
| 209 |
+
"max": 4.1,
|
| 210 |
+
"p01": 0.6,
|
| 211 |
+
"p50": 2.6,
|
| 212 |
+
"p99": 3.67
|
| 213 |
+
},
|
| 214 |
+
"cement_alkali_pct": {
|
| 215 |
+
"min": 0.12502,
|
| 216 |
+
"max": 1.658,
|
| 217 |
+
"p01": 0.13,
|
| 218 |
+
"p50": 0.5964,
|
| 219 |
+
"p99": 1.03956
|
| 220 |
+
},
|
| 221 |
+
"cement_LOI_pct": {
|
| 222 |
+
"min": 0.0,
|
| 223 |
+
"max": 11.6,
|
| 224 |
+
"p01": 0.7,
|
| 225 |
+
"p50": 2.2,
|
| 226 |
+
"p99": 11.6
|
| 227 |
+
},
|
| 228 |
+
"scm_CaO_pct": {
|
| 229 |
+
"min": 0.01,
|
| 230 |
+
"max": 47.498333,
|
| 231 |
+
"p01": 0.03,
|
| 232 |
+
"p50": 3.3997133,
|
| 233 |
+
"p99": 43.28320000000008
|
| 234 |
+
},
|
| 235 |
+
"scm_SiO2_pct": {
|
| 236 |
+
"min": 15.435,
|
| 237 |
+
"max": 99.8,
|
| 238 |
+
"p01": 27.0,
|
| 239 |
+
"p50": 79.99951,
|
| 240 |
+
"p99": 99.8
|
| 241 |
+
},
|
| 242 |
+
"scm_Al2O3_pct": {
|
| 243 |
+
"min": 0.0,
|
| 244 |
+
"max": 38.139545,
|
| 245 |
+
"p01": 0.01,
|
| 246 |
+
"p50": 1.635,
|
| 247 |
+
"p99": 37.9693
|
| 248 |
+
},
|
| 249 |
+
"scm_Fe2O3_pct": {
|
| 250 |
+
"min": 0.0,
|
| 251 |
+
"max": 57.0,
|
| 252 |
+
"p01": 0.043465704,
|
| 253 |
+
"p50": 1.21,
|
| 254 |
+
"p99": 57.0
|
| 255 |
+
},
|
| 256 |
+
"scm_MgO_pct": {
|
| 257 |
+
"min": 0.01,
|
| 258 |
+
"max": 9.5,
|
| 259 |
+
"p01": 0.05,
|
| 260 |
+
"p50": 0.8678877,
|
| 261 |
+
"p99": 8.086817
|
| 262 |
+
},
|
| 263 |
+
"scm_LOI_pct": {
|
| 264 |
+
"min": 0.0,
|
| 265 |
+
"max": 7.5909767,
|
| 266 |
+
"p01": 0.02,
|
| 267 |
+
"p50": 2.1069672,
|
| 268 |
+
"p99": 6.07
|
| 269 |
+
},
|
| 270 |
+
"cement_grade_mpa": {
|
| 271 |
+
"min": 42.5,
|
| 272 |
+
"max": 53.0,
|
| 273 |
+
"p01": 42.5,
|
| 274 |
+
"p50": 52.5,
|
| 275 |
+
"p99": 53.0
|
| 276 |
+
},
|
| 277 |
+
"curing_humidity_pct": {
|
| 278 |
+
"min": 50.0,
|
| 279 |
+
"max": 100.0,
|
| 280 |
+
"p01": 50.0,
|
| 281 |
+
"p50": 95.0,
|
| 282 |
+
"p99": 100.0
|
| 283 |
+
},
|
| 284 |
+
"specimen_size_mm": {
|
| 285 |
+
"min": 40.0,
|
| 286 |
+
"max": 150.0,
|
| 287 |
+
"p01": 40.0,
|
| 288 |
+
"p50": 50.0,
|
| 289 |
+
"p99": 150.0
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
}
|
checkpoints_full_rich/tabular_ann.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d801170b240d582d8f9b579fd6f61b633c8aedec30485dbac592ba457b81ed0a
|
| 3 |
+
size 111795
|
concrete_gnn/__init__.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hierarchical multiscale concrete GNN — unified package.
|
| 2 |
+
|
| 3 |
+
This package merges the strongest pieces of the three earlier prototypes:
|
| 4 |
+
|
| 5 |
+
* Codex-Claude's end-to-end-trainable multiscale architecture, with
|
| 6 |
+
structure-dependent ground-truth physics and a contact-density diagnostic;
|
| 7 |
+
* Claude's modular package layout, learnable missing-feature encoder,
|
| 8 |
+
node-/edge-type embeddings, PyG-based ``NNConv`` / ``GINEConv`` operators,
|
| 9 |
+
and auxiliary mortar / ITZ losses;
|
| 10 |
+
* Codex's bounded output transforms, PyG-optional fallback, and the explicit
|
| 11 |
+
placeholder slots in every schema vector.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from .data import (
|
| 15 |
+
ConcreteSample,
|
| 16 |
+
MultiscaleBatch,
|
| 17 |
+
MultiscaleConcreteDataset,
|
| 18 |
+
collate_multiscale,
|
| 19 |
+
make_dataloader,
|
| 20 |
+
split_dataset,
|
| 21 |
+
)
|
| 22 |
+
from .graph_generator import (
|
| 23 |
+
ConcreteGraph,
|
| 24 |
+
MixDesign,
|
| 25 |
+
MortarMicrograph,
|
| 26 |
+
MortarMixDesign,
|
| 27 |
+
generate_concrete_graph,
|
| 28 |
+
generate_mortar_graph,
|
| 29 |
+
)
|
| 30 |
+
from .ground_truth import (
|
| 31 |
+
GroundTruthProps,
|
| 32 |
+
homogenize_concrete_targets,
|
| 33 |
+
itz_target_vector,
|
| 34 |
+
mortar_target_vector,
|
| 35 |
+
topology_statistics,
|
| 36 |
+
true_micro_properties,
|
| 37 |
+
)
|
| 38 |
+
from .missing_features import MissingFeatureEncoder, apply_random_missingness
|
| 39 |
+
from .models import (
|
| 40 |
+
ConcreteMesoGNN,
|
| 41 |
+
HAS_PYG,
|
| 42 |
+
ITZSubModel,
|
| 43 |
+
IntegratedMultiscaleModel,
|
| 44 |
+
MortarSubModel,
|
| 45 |
+
TabularANN,
|
| 46 |
+
global_add_pool,
|
| 47 |
+
global_mean_pool,
|
| 48 |
+
transform_concrete_outputs,
|
| 49 |
+
transform_itz_outputs,
|
| 50 |
+
transform_mortar_outputs,
|
| 51 |
+
)
|
| 52 |
+
from .real_data import (
|
| 53 |
+
COL_STRENGTH,
|
| 54 |
+
NORM_STRENGTH,
|
| 55 |
+
TABULAR_DIM,
|
| 56 |
+
TARGET_NAME,
|
| 57 |
+
Standardizer,
|
| 58 |
+
StrengthHead,
|
| 59 |
+
ConcreteMixDataset,
|
| 60 |
+
collect_strength_predictions,
|
| 61 |
+
evaluate_strength,
|
| 62 |
+
fit_encoder_standardizers,
|
| 63 |
+
load_strength_checkpoint,
|
| 64 |
+
metrics,
|
| 65 |
+
raw_mix_vector,
|
| 66 |
+
read_table,
|
| 67 |
+
split_indices,
|
| 68 |
+
strength_column,
|
| 69 |
+
)
|
| 70 |
+
from .schema import (
|
| 71 |
+
AGGREGATE_FEATURES,
|
| 72 |
+
CONCRETE_TARGETS,
|
| 73 |
+
DEFAULT_SCHEMA,
|
| 74 |
+
EDGE_TYPE_AGGREGATE_AGGREGATE,
|
| 75 |
+
EDGE_TYPE_ITZ,
|
| 76 |
+
EDGE_TYPE_MORTAR_MORTAR,
|
| 77 |
+
GLOBAL_FEATURES,
|
| 78 |
+
ITZ_FEATURES,
|
| 79 |
+
ITZ_TARGETS,
|
| 80 |
+
MORTAR_EDGE_FEATURES,
|
| 81 |
+
MORTAR_FEATURES,
|
| 82 |
+
MORTAR_TARGETS,
|
| 83 |
+
NODE_TYPE_AGGREGATE,
|
| 84 |
+
NODE_TYPE_MORTAR,
|
| 85 |
+
SchemaSpec,
|
| 86 |
+
)
|
| 87 |
+
from .train import (
|
| 88 |
+
MultiTaskLoss,
|
| 89 |
+
TrainConfig,
|
| 90 |
+
compute_sublabel_scales,
|
| 91 |
+
compute_target_scale,
|
| 92 |
+
evaluate,
|
| 93 |
+
evaluate_metrics,
|
| 94 |
+
load_best_checkpoint,
|
| 95 |
+
train,
|
| 96 |
+
train_one_epoch,
|
| 97 |
+
)
|
| 98 |
+
from .visualize import (
|
| 99 |
+
collect_predictions,
|
| 100 |
+
plot_parity,
|
| 101 |
+
plot_r2_bars,
|
| 102 |
+
plot_rve_graph,
|
| 103 |
+
plot_training_curves,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
__all__ = [
|
| 107 |
+
"AGGREGATE_FEATURES",
|
| 108 |
+
"COL_STRENGTH",
|
| 109 |
+
"CONCRETE_TARGETS",
|
| 110 |
+
"ConcreteGraph",
|
| 111 |
+
"ConcreteMesoGNN",
|
| 112 |
+
"ConcreteSample",
|
| 113 |
+
"DEFAULT_SCHEMA",
|
| 114 |
+
"EDGE_TYPE_AGGREGATE_AGGREGATE",
|
| 115 |
+
"EDGE_TYPE_ITZ",
|
| 116 |
+
"EDGE_TYPE_MORTAR_MORTAR",
|
| 117 |
+
"GLOBAL_FEATURES",
|
| 118 |
+
"GroundTruthProps",
|
| 119 |
+
"HAS_PYG",
|
| 120 |
+
"ITZ_FEATURES",
|
| 121 |
+
"ITZ_TARGETS",
|
| 122 |
+
"ITZSubModel",
|
| 123 |
+
"IntegratedMultiscaleModel",
|
| 124 |
+
"MORTAR_EDGE_FEATURES",
|
| 125 |
+
"MORTAR_FEATURES",
|
| 126 |
+
"MORTAR_TARGETS",
|
| 127 |
+
"collect_predictions",
|
| 128 |
+
"plot_parity",
|
| 129 |
+
"plot_r2_bars",
|
| 130 |
+
"plot_rve_graph",
|
| 131 |
+
"plot_training_curves",
|
| 132 |
+
"MissingFeatureEncoder",
|
| 133 |
+
"MixDesign",
|
| 134 |
+
"MortarMicrograph",
|
| 135 |
+
"MortarMixDesign",
|
| 136 |
+
"MortarSubModel",
|
| 137 |
+
"MultiTaskLoss",
|
| 138 |
+
"MultiscaleBatch",
|
| 139 |
+
"MultiscaleConcreteDataset",
|
| 140 |
+
"NODE_TYPE_AGGREGATE",
|
| 141 |
+
"NODE_TYPE_MORTAR",
|
| 142 |
+
"NORM_STRENGTH",
|
| 143 |
+
"SchemaSpec",
|
| 144 |
+
"Standardizer",
|
| 145 |
+
"StrengthHead",
|
| 146 |
+
"TABULAR_DIM",
|
| 147 |
+
"TARGET_NAME",
|
| 148 |
+
"TabularANN",
|
| 149 |
+
"TrainConfig",
|
| 150 |
+
"ConcreteMixDataset",
|
| 151 |
+
"apply_random_missingness",
|
| 152 |
+
"collate_multiscale",
|
| 153 |
+
"compute_sublabel_scales",
|
| 154 |
+
"compute_target_scale",
|
| 155 |
+
"collect_strength_predictions",
|
| 156 |
+
"evaluate",
|
| 157 |
+
"evaluate_strength",
|
| 158 |
+
"evaluate_metrics",
|
| 159 |
+
"fit_encoder_standardizers",
|
| 160 |
+
"load_best_checkpoint",
|
| 161 |
+
"load_strength_checkpoint",
|
| 162 |
+
"generate_concrete_graph",
|
| 163 |
+
"generate_mortar_graph",
|
| 164 |
+
"global_add_pool",
|
| 165 |
+
"global_mean_pool",
|
| 166 |
+
"homogenize_concrete_targets",
|
| 167 |
+
"itz_target_vector",
|
| 168 |
+
"make_dataloader",
|
| 169 |
+
"metrics",
|
| 170 |
+
"mortar_target_vector",
|
| 171 |
+
"raw_mix_vector",
|
| 172 |
+
"read_table",
|
| 173 |
+
"split_dataset",
|
| 174 |
+
"split_indices",
|
| 175 |
+
"strength_column",
|
| 176 |
+
"topology_statistics",
|
| 177 |
+
"train",
|
| 178 |
+
"train_one_epoch",
|
| 179 |
+
"transform_concrete_outputs",
|
| 180 |
+
"transform_itz_outputs",
|
| 181 |
+
"transform_mortar_outputs",
|
| 182 |
+
"true_micro_properties",
|
| 183 |
+
]
|
concrete_gnn/categoricals.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Canonical categorical vocabularies for the UHPC rich pipeline.
|
| 2 |
+
|
| 3 |
+
Single source of truth shared by the data builder (``Data/prepare_uhpc_rich.py``)
|
| 4 |
+
and the loader (``real_data.py``). The builder writes the *canonical* class string
|
| 5 |
+
into the CSV; the loader one-hot-encodes it for the TabularANN and maps the cement
|
| 6 |
+
type to the GNN's ``cement_type_id`` paste slot.
|
| 7 |
+
|
| 8 |
+
Each ``canonical_*`` mapper collapses the many free-text spellings in the raw
|
| 9 |
+
spreadsheet (e.g. "Straight Steel Fibers", "Steel fibre", "Steel Fiber") into a
|
| 10 |
+
small, stable vocabulary. The first vocabulary entry is the natural default for a
|
| 11 |
+
missing value, except cement/curing which default to "other".
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import List, Sequence
|
| 17 |
+
|
| 18 |
+
CEMENT_TYPES: Sequence[str] = ("opc", "type_i_ii", "type_iii", "hs", "other")
|
| 19 |
+
FIBRE_TYPES: Sequence[str] = ("none", "steel", "pe", "pva", "other")
|
| 20 |
+
CURING_REGIMES: Sequence[str] = ("standard", "steam", "heat", "autoclave", "other")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _clean(raw) -> str:
|
| 24 |
+
s = str(raw).strip().lower()
|
| 25 |
+
return "" if s in ("", "nan", "none") else s
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def canonical_cement_type(raw) -> str:
|
| 29 |
+
t = _clean(raw)
|
| 30 |
+
if not t:
|
| 31 |
+
return "other"
|
| 32 |
+
if "sulfate" in t or "type hs" in t or "hs cement" in t:
|
| 33 |
+
return "hs"
|
| 34 |
+
if "type iii" in t or "type-iii" in t or "type 3" in t:
|
| 35 |
+
return "type_iii"
|
| 36 |
+
if "i/ii" in t or "i / ii" in t:
|
| 37 |
+
return "type_i_ii"
|
| 38 |
+
if any(k in t for k in ("portland", "opc", "cem i", "type i", "type 1", "p.o", "p.i")):
|
| 39 |
+
return "opc"
|
| 40 |
+
return "other"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def canonical_fibre_type(raw) -> str:
|
| 44 |
+
t = _clean(raw)
|
| 45 |
+
if not t:
|
| 46 |
+
return "none"
|
| 47 |
+
if "steel" in t or "seel" in t: # "seel" = common typo in the sheet
|
| 48 |
+
return "steel"
|
| 49 |
+
if "pva" in t:
|
| 50 |
+
return "pva"
|
| 51 |
+
if t.startswith("pe") or "polyeth" in t:
|
| 52 |
+
return "pe"
|
| 53 |
+
return "other"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def canonical_curing_regime(raw) -> str:
|
| 57 |
+
t = _clean(raw)
|
| 58 |
+
if not t:
|
| 59 |
+
return "other"
|
| 60 |
+
if "autoclave" in t:
|
| 61 |
+
return "autoclave"
|
| 62 |
+
if "steam" in t:
|
| 63 |
+
return "steam"
|
| 64 |
+
if any(k in t for k in ("heat", "hot", "warm", "thermal")):
|
| 65 |
+
return "heat"
|
| 66 |
+
if "standard" in t or "normal" in t or "ambient" in t:
|
| 67 |
+
return "standard"
|
| 68 |
+
return "other"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def one_hot(value: str, vocab: Sequence[str]) -> List[float]:
|
| 72 |
+
"""One-hot encode ``value`` against ``vocab`` (all-zero if not present)."""
|
| 73 |
+
return [1.0 if value == v else 0.0 for v in vocab]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def type_id(value: str, vocab: Sequence[str]) -> int:
|
| 77 |
+
"""Integer index of ``value`` in ``vocab`` (0 if not present)."""
|
| 78 |
+
return vocab.index(value) if value in vocab else 0
|
concrete_gnn/data.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset, batch container and collate function for the multiscale model.
|
| 2 |
+
|
| 3 |
+
Every training sample bundles three heterogeneous pieces:
|
| 4 |
+
|
| 5 |
+
- a mesoscale concrete graph (aggregate + mortar nodes, three edge types);
|
| 6 |
+
- a mortar micrograph (sand + paste nodes, contact edges);
|
| 7 |
+
- a per-sample ITZ mix vector used to build ITZ-edge static inputs.
|
| 8 |
+
|
| 9 |
+
The custom collate concatenates per-graph tensors, offsets edge indices,
|
| 10 |
+
and keeps the mortar batch aligned with the concrete batch so the
|
| 11 |
+
hierarchical model can wire predictions between them.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import random
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from typing import List, Optional, Sized, cast
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
from torch import Tensor
|
| 22 |
+
from torch.utils.data import DataLoader, Dataset
|
| 23 |
+
|
| 24 |
+
from .graph_generator import (
|
| 25 |
+
ConcreteGraph,
|
| 26 |
+
MixDesign,
|
| 27 |
+
MortarMicrograph,
|
| 28 |
+
MortarMixDesign,
|
| 29 |
+
generate_concrete_graph,
|
| 30 |
+
generate_mortar_graph,
|
| 31 |
+
mortar_volume_fraction,
|
| 32 |
+
)
|
| 33 |
+
from .ground_truth import (
|
| 34 |
+
homogenize_concrete_targets,
|
| 35 |
+
itz_target_vector,
|
| 36 |
+
mortar_target_vector,
|
| 37 |
+
topology_statistics,
|
| 38 |
+
true_micro_properties,
|
| 39 |
+
)
|
| 40 |
+
from .schema import DEFAULT_SCHEMA, SchemaSpec
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# Containers
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class ConcreteSample:
|
| 50 |
+
graph: ConcreteGraph
|
| 51 |
+
micrograph: MortarMicrograph
|
| 52 |
+
mix_itz: Tensor
|
| 53 |
+
concrete_target: Tensor
|
| 54 |
+
mortar_target: Optional[Tensor] = None
|
| 55 |
+
itz_target: Optional[Tensor] = None
|
| 56 |
+
tabular_mix: Optional[Tensor] = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class MultiscaleBatch:
|
| 61 |
+
x: Tensor
|
| 62 |
+
x_mask: Tensor
|
| 63 |
+
edge_index: Tensor
|
| 64 |
+
edge_attr: Tensor
|
| 65 |
+
edge_attr_mask: Tensor
|
| 66 |
+
global_features: Tensor
|
| 67 |
+
global_mask: Tensor
|
| 68 |
+
batch: Tensor
|
| 69 |
+
pos: Tensor
|
| 70 |
+
node_type: Tensor
|
| 71 |
+
edge_type: Tensor
|
| 72 |
+
m_x: Tensor
|
| 73 |
+
m_mask: Tensor
|
| 74 |
+
m_edge_index: Tensor
|
| 75 |
+
m_edge_attr: Tensor
|
| 76 |
+
m_node_type: Tensor
|
| 77 |
+
m_batch: Tensor
|
| 78 |
+
mortar_global: Tensor
|
| 79 |
+
mortar_global_mask: Tensor
|
| 80 |
+
mix_itz: Tensor
|
| 81 |
+
concrete_target: Tensor
|
| 82 |
+
mortar_target: Optional[Tensor]
|
| 83 |
+
itz_target: Optional[Tensor]
|
| 84 |
+
tabular_mix: Optional[Tensor] = None
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def num_graphs(self) -> int:
|
| 88 |
+
return self.concrete_target.size(0)
|
| 89 |
+
|
| 90 |
+
def to(self, device: torch.device) -> "MultiscaleBatch":
|
| 91 |
+
def m(t):
|
| 92 |
+
return t.to(device) if isinstance(t, Tensor) else t
|
| 93 |
+
|
| 94 |
+
return MultiscaleBatch(
|
| 95 |
+
x=m(self.x),
|
| 96 |
+
x_mask=m(self.x_mask),
|
| 97 |
+
edge_index=m(self.edge_index),
|
| 98 |
+
edge_attr=m(self.edge_attr),
|
| 99 |
+
edge_attr_mask=m(self.edge_attr_mask),
|
| 100 |
+
global_features=m(self.global_features),
|
| 101 |
+
global_mask=m(self.global_mask),
|
| 102 |
+
batch=m(self.batch),
|
| 103 |
+
pos=m(self.pos),
|
| 104 |
+
node_type=m(self.node_type),
|
| 105 |
+
edge_type=m(self.edge_type),
|
| 106 |
+
m_x=m(self.m_x),
|
| 107 |
+
m_mask=m(self.m_mask),
|
| 108 |
+
m_edge_index=m(self.m_edge_index),
|
| 109 |
+
m_edge_attr=m(self.m_edge_attr),
|
| 110 |
+
m_node_type=m(self.m_node_type),
|
| 111 |
+
m_batch=m(self.m_batch),
|
| 112 |
+
mortar_global=m(self.mortar_global),
|
| 113 |
+
mortar_global_mask=m(self.mortar_global_mask),
|
| 114 |
+
mix_itz=m(self.mix_itz),
|
| 115 |
+
concrete_target=m(self.concrete_target),
|
| 116 |
+
mortar_target=m(self.mortar_target) if self.mortar_target is not None else None,
|
| 117 |
+
itz_target=m(self.itz_target) if self.itz_target is not None else None,
|
| 118 |
+
tabular_mix=m(self.tabular_mix) if self.tabular_mix is not None else None,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Helpers
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _mix_itz_vector(mix: MixDesign) -> Tensor:
|
| 128 |
+
"""Per-graph mix vector used as static input to the ITZ MLP."""
|
| 129 |
+
|
| 130 |
+
return torch.tensor(
|
| 131 |
+
[
|
| 132 |
+
mix.cement_content_kg_m3,
|
| 133 |
+
float(mix.scm_type_id),
|
| 134 |
+
mix.scm_fraction,
|
| 135 |
+
mix.water_to_binder_ratio,
|
| 136 |
+
mix.admixture_dosage,
|
| 137 |
+
mix.relative_humidity,
|
| 138 |
+
mix.temperature_C,
|
| 139 |
+
mix.curing_age_days,
|
| 140 |
+
],
|
| 141 |
+
dtype=torch.float32,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _mortar_mix_for(mix: MixDesign) -> MortarMixDesign:
|
| 146 |
+
"""Derive a mortar-scale mix from the concrete-scale mix.
|
| 147 |
+
|
| 148 |
+
Cement content is converted from a concrete basis to a mortar basis by
|
| 149 |
+
removing the coarse-aggregate volume (see ``mortar_volume_fraction``); w/b,
|
| 150 |
+
scm_fraction and admixture dosage are intensive ratios and stay invariant.
|
| 151 |
+
Here the synthetic ``aggregate_volume_fraction`` is the true coarse fraction.
|
| 152 |
+
"""
|
| 153 |
+
|
| 154 |
+
mortar_vf = mortar_volume_fraction(mix.aggregate_volume_fraction)
|
| 155 |
+
return MortarMixDesign(
|
| 156 |
+
water_to_binder_ratio=mix.water_to_binder_ratio,
|
| 157 |
+
cement_content_kg_m3=mix.cement_content_kg_m3 / mortar_vf,
|
| 158 |
+
scm_type_id=mix.scm_type_id,
|
| 159 |
+
scm_fraction=mix.scm_fraction,
|
| 160 |
+
admixture_dosage=mix.admixture_dosage,
|
| 161 |
+
curing_relative_humidity=mix.relative_humidity,
|
| 162 |
+
curing_temperature_C=mix.temperature_C,
|
| 163 |
+
curing_age_days=mix.curing_age_days,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ---------------------------------------------------------------------------
|
| 168 |
+
# Dataset
|
| 169 |
+
# ---------------------------------------------------------------------------
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class MultiscaleConcreteDataset(Dataset):
|
| 173 |
+
"""Synthetic dataset whose targets depend on graph structure + micrograph.
|
| 174 |
+
|
| 175 |
+
Each sample carries optional ground-truth mortar / ITZ target vectors so
|
| 176 |
+
the training loop can use auxiliary losses when the ablation calls for it.
|
| 177 |
+
"""
|
| 178 |
+
|
| 179 |
+
def __init__(
|
| 180 |
+
self,
|
| 181 |
+
num_graphs: int = 200,
|
| 182 |
+
seed: int = 17,
|
| 183 |
+
schema: SchemaSpec = DEFAULT_SCHEMA,
|
| 184 |
+
missing_rate: float = 0.12,
|
| 185 |
+
with_sublabels: bool = True,
|
| 186 |
+
):
|
| 187 |
+
self.schema = schema
|
| 188 |
+
self.missing_rate = missing_rate
|
| 189 |
+
self.with_sublabels = with_sublabels
|
| 190 |
+
self.samples: List[ConcreteSample] = []
|
| 191 |
+
|
| 192 |
+
mix_rng = random.Random(seed + 1)
|
| 193 |
+
for k in range(num_graphs):
|
| 194 |
+
mix = MixDesign(
|
| 195 |
+
aggregate_volume_fraction=mix_rng.uniform(0.30, 0.48),
|
| 196 |
+
water_to_binder_ratio=mix_rng.uniform(0.32, 0.55),
|
| 197 |
+
cement_content_kg_m3=mix_rng.uniform(300.0, 430.0),
|
| 198 |
+
scm_type_id=int(mix_rng.choice([0, 1, 2])),
|
| 199 |
+
scm_fraction=mix_rng.uniform(0.0, 0.30),
|
| 200 |
+
admixture_dosage=mix_rng.uniform(0.0, 1.0),
|
| 201 |
+
relative_humidity=mix_rng.uniform(0.65, 0.98),
|
| 202 |
+
temperature_C=mix_rng.uniform(10.0, 35.0),
|
| 203 |
+
curing_age_days=float(mix_rng.choice([7, 14, 28, 56, 90])),
|
| 204 |
+
)
|
| 205 |
+
graph = generate_concrete_graph(
|
| 206 |
+
mix, schema=schema, seed=seed + 100 + k, missing_rate=missing_rate
|
| 207 |
+
)
|
| 208 |
+
mortar_mix = _mortar_mix_for(mix)
|
| 209 |
+
micro = generate_mortar_graph(
|
| 210 |
+
mortar_mix, schema=schema, seed=seed + 200 + k
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
agg_node_frac, itz_edge_frac, aa_edge_frac = topology_statistics(graph)
|
| 214 |
+
props = true_micro_properties(mix, micro.sand_contact_density)
|
| 215 |
+
y = homogenize_concrete_targets(
|
| 216 |
+
mix, props, agg_node_frac, itz_edge_frac, aa_edge_frac
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
mortar_y = mortar_target_vector(props) if with_sublabels else None
|
| 220 |
+
|
| 221 |
+
itz_count = int((graph.edge_type == 0).sum().item())
|
| 222 |
+
itz_y = (
|
| 223 |
+
itz_target_vector(props).unsqueeze(0).expand(itz_count, -1).clone()
|
| 224 |
+
if with_sublabels and itz_count > 0
|
| 225 |
+
else None
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
self.samples.append(
|
| 229 |
+
ConcreteSample(
|
| 230 |
+
graph=graph,
|
| 231 |
+
micrograph=micro,
|
| 232 |
+
mix_itz=_mix_itz_vector(mix),
|
| 233 |
+
concrete_target=y,
|
| 234 |
+
mortar_target=mortar_y,
|
| 235 |
+
itz_target=itz_y,
|
| 236 |
+
)
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
def __len__(self) -> int:
|
| 240 |
+
return len(self.samples)
|
| 241 |
+
|
| 242 |
+
def __getitem__(self, idx: int) -> ConcreteSample:
|
| 243 |
+
return self.samples[idx]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ---------------------------------------------------------------------------
|
| 247 |
+
# Collate
|
| 248 |
+
# ---------------------------------------------------------------------------
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def collate_multiscale(samples: List[ConcreteSample]) -> MultiscaleBatch:
|
| 252 |
+
xs, x_masks, e_idx, e_attr, e_attr_masks = [], [], [], [], []
|
| 253 |
+
globals_, global_masks = [], []
|
| 254 |
+
batches, positions = [], []
|
| 255 |
+
node_types, edge_types = [], []
|
| 256 |
+
m_xs, m_masks, m_eidx, m_eattr, m_node_types, m_batches = [], [], [], [], [], []
|
| 257 |
+
mortar_globals, mortar_global_masks = [], []
|
| 258 |
+
mix_itzs, ys = [], []
|
| 259 |
+
mortar_targets, itz_targets = [], []
|
| 260 |
+
|
| 261 |
+
node_offset = 0
|
| 262 |
+
micro_offset = 0
|
| 263 |
+
have_mortar_target = samples[0].mortar_target is not None
|
| 264 |
+
have_itz_target = samples[0].itz_target is not None
|
| 265 |
+
have_tabular = samples[0].tabular_mix is not None
|
| 266 |
+
tabular_mixes: List[Tensor] = []
|
| 267 |
+
|
| 268 |
+
for gid, sample in enumerate(samples):
|
| 269 |
+
graph: ConcreteGraph = sample.graph
|
| 270 |
+
micro: MortarMicrograph = sample.micrograph
|
| 271 |
+
n_nodes = graph.x.size(0)
|
| 272 |
+
n_micro = micro.x.size(0)
|
| 273 |
+
|
| 274 |
+
xs.append(graph.x)
|
| 275 |
+
x_masks.append(graph.x_mask)
|
| 276 |
+
e_idx.append(graph.edge_index + node_offset)
|
| 277 |
+
e_attr.append(graph.edge_attr)
|
| 278 |
+
e_attr_masks.append(graph.edge_attr_mask)
|
| 279 |
+
globals_.append(graph.global_features)
|
| 280 |
+
global_masks.append(graph.global_mask)
|
| 281 |
+
batches.append(torch.full((n_nodes,), gid, dtype=torch.long))
|
| 282 |
+
positions.append(graph.pos)
|
| 283 |
+
node_types.append(graph.node_type)
|
| 284 |
+
edge_types.append(graph.edge_type)
|
| 285 |
+
|
| 286 |
+
m_xs.append(micro.x)
|
| 287 |
+
m_masks.append(micro.x_mask)
|
| 288 |
+
m_eidx.append(micro.edge_index + micro_offset)
|
| 289 |
+
m_eattr.append(micro.edge_attr)
|
| 290 |
+
m_node_types.append(micro.node_type)
|
| 291 |
+
m_batches.append(torch.full((n_micro,), gid, dtype=torch.long))
|
| 292 |
+
mortar_globals.append(micro.global_features)
|
| 293 |
+
mortar_global_masks.append(micro.global_mask)
|
| 294 |
+
|
| 295 |
+
mix_itzs.append(sample.mix_itz)
|
| 296 |
+
ys.append(sample.concrete_target)
|
| 297 |
+
if have_mortar_target:
|
| 298 |
+
mortar_targets.append(sample.mortar_target)
|
| 299 |
+
if have_itz_target and sample.itz_target is not None and sample.itz_target.numel() > 0:
|
| 300 |
+
itz_targets.append(sample.itz_target)
|
| 301 |
+
if have_tabular and sample.tabular_mix is not None:
|
| 302 |
+
tabular_mixes.append(sample.tabular_mix)
|
| 303 |
+
|
| 304 |
+
node_offset += n_nodes
|
| 305 |
+
micro_offset += n_micro
|
| 306 |
+
|
| 307 |
+
return MultiscaleBatch(
|
| 308 |
+
x=torch.cat(xs, dim=0),
|
| 309 |
+
x_mask=torch.cat(x_masks, dim=0),
|
| 310 |
+
edge_index=torch.cat(e_idx, dim=1),
|
| 311 |
+
edge_attr=torch.cat(e_attr, dim=0),
|
| 312 |
+
edge_attr_mask=torch.cat(e_attr_masks, dim=0),
|
| 313 |
+
global_features=torch.stack(globals_, dim=0),
|
| 314 |
+
global_mask=torch.stack(global_masks, dim=0),
|
| 315 |
+
batch=torch.cat(batches, dim=0),
|
| 316 |
+
pos=torch.cat(positions, dim=0),
|
| 317 |
+
node_type=torch.cat(node_types, dim=0),
|
| 318 |
+
edge_type=torch.cat(edge_types, dim=0),
|
| 319 |
+
m_x=torch.cat(m_xs, dim=0),
|
| 320 |
+
m_mask=torch.cat(m_masks, dim=0),
|
| 321 |
+
m_edge_index=torch.cat(m_eidx, dim=1),
|
| 322 |
+
m_edge_attr=torch.cat(m_eattr, dim=0),
|
| 323 |
+
m_node_type=torch.cat(m_node_types, dim=0),
|
| 324 |
+
m_batch=torch.cat(m_batches, dim=0),
|
| 325 |
+
mortar_global=torch.stack(mortar_globals, dim=0),
|
| 326 |
+
mortar_global_mask=torch.stack(mortar_global_masks, dim=0),
|
| 327 |
+
mix_itz=torch.stack(mix_itzs, dim=0),
|
| 328 |
+
concrete_target=torch.stack(ys, dim=0),
|
| 329 |
+
mortar_target=torch.stack(mortar_targets, dim=0) if have_mortar_target else None,
|
| 330 |
+
itz_target=torch.cat(itz_targets, dim=0)
|
| 331 |
+
if (have_itz_target and itz_targets)
|
| 332 |
+
else None,
|
| 333 |
+
tabular_mix=torch.stack(tabular_mixes, dim=0) if have_tabular else None,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def make_dataloader(
|
| 338 |
+
dataset: Dataset,
|
| 339 |
+
batch_size: int = 8,
|
| 340 |
+
shuffle: bool = True,
|
| 341 |
+
num_workers: int = 0,
|
| 342 |
+
pin_memory: bool = False,
|
| 343 |
+
drop_last: bool = False,
|
| 344 |
+
) -> DataLoader:
|
| 345 |
+
return DataLoader(
|
| 346 |
+
dataset,
|
| 347 |
+
batch_size=batch_size,
|
| 348 |
+
shuffle=shuffle,
|
| 349 |
+
num_workers=num_workers,
|
| 350 |
+
pin_memory=pin_memory,
|
| 351 |
+
drop_last=drop_last,
|
| 352 |
+
collate_fn=collate_multiscale,
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
# ---------------------------------------------------------------------------
|
| 357 |
+
# Splits
|
| 358 |
+
# ---------------------------------------------------------------------------
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def split_dataset(
|
| 362 |
+
dataset: Dataset,
|
| 363 |
+
seed: int = 0,
|
| 364 |
+
train_fraction: float = 0.7,
|
| 365 |
+
val_fraction: float = 0.15,
|
| 366 |
+
) -> tuple:
|
| 367 |
+
n = len(cast(Sized, dataset))
|
| 368 |
+
idx = list(range(n))
|
| 369 |
+
random.Random(seed).shuffle(idx)
|
| 370 |
+
n_train = int(train_fraction * n)
|
| 371 |
+
n_val = int(val_fraction * n)
|
| 372 |
+
return idx[:n_train], idx[n_train : n_train + n_val], idx[n_train + n_val :]
|
concrete_gnn/graph_generator.py
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic graph generators for mesoscale concrete and microscale mortar.
|
| 2 |
+
|
| 3 |
+
Geometry pipeline (concrete RVE):
|
| 4 |
+
1. Random sequential adsorption of circular aggregate particles sampled
|
| 5 |
+
from a discrete grain-size distribution until the requested area
|
| 6 |
+
fraction is approximately reached.
|
| 7 |
+
2. Sample a regular grid of mortar seed points and discard any that fall
|
| 8 |
+
inside an aggregate, giving the mortar segmentation centroids.
|
| 9 |
+
3. Approximate the Voronoi adjacency between every (aggregate, mortar)
|
| 10 |
+
point pair by sampling points in the RVE and counting how often each
|
| 11 |
+
pair appears as the two nearest neighbours. This produces both
|
| 12 |
+
adjacency information and a proxy for shared-boundary length.
|
| 13 |
+
4. Mortar-mortar adjacencies in the Voronoi partition are often
|
| 14 |
+
interrupted by aggregate cells; we therefore *augment* mortar-mortar
|
| 15 |
+
edges using k-nearest-neighbour links between mortar seeds.
|
| 16 |
+
5. Aggregate-aggregate proximity edges are added when two aggregates are
|
| 17 |
+
within a small surface gap.
|
| 18 |
+
|
| 19 |
+
The mortar microscale graph reuses the same Voronoi adjacency primitive but
|
| 20 |
+
with its own grain-size distribution and node features.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import math
|
| 26 |
+
import random
|
| 27 |
+
from dataclasses import dataclass
|
| 28 |
+
from typing import Dict, List, Optional, Sequence, Tuple
|
| 29 |
+
|
| 30 |
+
import numpy as np
|
| 31 |
+
import torch
|
| 32 |
+
from torch import Tensor
|
| 33 |
+
|
| 34 |
+
from .schema import (
|
| 35 |
+
DEFAULT_SCHEMA,
|
| 36 |
+
EDGE_TYPE_AGGREGATE_AGGREGATE,
|
| 37 |
+
EDGE_TYPE_ITZ,
|
| 38 |
+
EDGE_TYPE_MORTAR_MORTAR,
|
| 39 |
+
MORTAR_NODE_TYPE_PASTE,
|
| 40 |
+
MORTAR_NODE_TYPE_SAND,
|
| 41 |
+
NODE_TYPE_AGGREGATE,
|
| 42 |
+
NODE_TYPE_MORTAR,
|
| 43 |
+
SchemaSpec,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# Mix-design containers (concrete + mortar scales kept separate)
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def mortar_volume_fraction(coarse_aggregate_volume_fraction: float) -> float:
|
| 53 |
+
"""Fraction of the concrete volume occupied by the mortar phase.
|
| 54 |
+
|
| 55 |
+
The mortar phase is everything except coarse aggregate, so it occupies
|
| 56 |
+
``1 - phi_coarse`` of the concrete volume. Per-volume *densities* reported
|
| 57 |
+
on a concrete basis (cement, SCM, fibre content in kg/m^3) are converted to
|
| 58 |
+
a mortar basis by dividing by this fraction.
|
| 59 |
+
|
| 60 |
+
``phi_coarse`` must be the *raw* coarse-aggregate volume fraction (0 for
|
| 61 |
+
UHPC, which has no coarse aggregate) -- not the clamped
|
| 62 |
+
``aggregate_volume_fraction`` global feature. ``phi_coarse`` is capped at
|
| 63 |
+
0.70 (mortar floor 0.30) -- the exact complement of the
|
| 64 |
+
``aggregate_volume_fraction`` clamp -- so the two stay consistent and the
|
| 65 |
+
conversion factor is bounded for unusually coarse mixes.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
phi_coarse = min(0.70, max(0.0, coarse_aggregate_volume_fraction))
|
| 69 |
+
return max(0.30, 1.0 - phi_coarse)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass
|
| 74 |
+
class MixDesign:
|
| 75 |
+
"""Concrete-scale mix design and curing metadata."""
|
| 76 |
+
|
| 77 |
+
rve_size_mm: float = 150.0
|
| 78 |
+
aggregate_size_bins_mm: Sequence[float] = (8.0, 12.0, 16.0, 20.0, 24.0, 32.0)
|
| 79 |
+
aggregate_size_fractions: Sequence[float] = (0.10, 0.20, 0.25, 0.20, 0.15, 0.10)
|
| 80 |
+
aggregate_volume_fraction: float = 0.40
|
| 81 |
+
water_to_binder_ratio: float = 0.42
|
| 82 |
+
cement_content_kg_m3: float = 360.0
|
| 83 |
+
scm_type_id: int = 1
|
| 84 |
+
scm_fraction: float = 0.12
|
| 85 |
+
admixture_dosage: float = 0.5
|
| 86 |
+
relative_humidity: float = 0.95
|
| 87 |
+
temperature_C: float = 20.0
|
| 88 |
+
curing_age_days: float = 28.0
|
| 89 |
+
fibre_content_kg_m3: float = 0.0
|
| 90 |
+
fibre_length_mm: float = 0.0
|
| 91 |
+
fibre_diameter_mm: float = 0.0
|
| 92 |
+
fibre_tensile_strength_MPa: float = 0.0
|
| 93 |
+
fibre_modulus_GPa: float = 0.0
|
| 94 |
+
# Fibre material-class id (index into categoricals.FIBRE_TYPES; 0 = none).
|
| 95 |
+
fibre_type_id: float = 0.0
|
| 96 |
+
# Measured nominal max grain sizes (mm); ``None`` means not reported, in
|
| 97 |
+
# which case the default bin distribution is used and the corresponding
|
| 98 |
+
# global feature is masked out. ``temperature_observed`` drives the same
|
| 99 |
+
# masking for curing temperature (which always carries a numeric fallback).
|
| 100 |
+
max_coarse_aggregate_size_mm: Optional[float] = None
|
| 101 |
+
max_fine_aggregate_size_mm: Optional[float] = None
|
| 102 |
+
temperature_observed: bool = True
|
| 103 |
+
|
| 104 |
+
def effective_aggregate_bins_mm(self) -> np.ndarray:
|
| 105 |
+
"""Default bins rescaled so their top equals the measured max size.
|
| 106 |
+
|
| 107 |
+
Rescaling (rather than clipping) preserves the relative gradation shape
|
| 108 |
+
while respecting the reported nominal maximum coarse-aggregate size.
|
| 109 |
+
"""
|
| 110 |
+
|
| 111 |
+
bins = np.asarray(self.aggregate_size_bins_mm, dtype=float)
|
| 112 |
+
if self.max_coarse_aggregate_size_mm and self.max_coarse_aggregate_size_mm > 0:
|
| 113 |
+
top = bins.max()
|
| 114 |
+
if top > 0:
|
| 115 |
+
bins = bins * (float(self.max_coarse_aggregate_size_mm) / top)
|
| 116 |
+
return bins
|
| 117 |
+
|
| 118 |
+
def sample_radius(self, rng: np.random.Generator) -> float:
|
| 119 |
+
bins = self.effective_aggregate_bins_mm()
|
| 120 |
+
weights = np.asarray(self.aggregate_size_fractions, dtype=float)
|
| 121 |
+
weights = weights / weights.sum()
|
| 122 |
+
idx = int(rng.choice(len(bins), p=weights))
|
| 123 |
+
return float(bins[idx] * 0.5 * rng.uniform(0.85, 1.0))
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@dataclass
|
| 127 |
+
class MortarMixDesign:
|
| 128 |
+
"""Mortar-scale mix description, distinct from :class:`MixDesign`.
|
| 129 |
+
|
| 130 |
+
The mortar scale operates on a small RVE (default 6 mm) so each micrograph
|
| 131 |
+
holds on the order of 10-30 nodes - enough for relational message passing
|
| 132 |
+
but cheap enough to train end-to-end alongside the mesoscale GNN.
|
| 133 |
+
"""
|
| 134 |
+
|
| 135 |
+
rve_size_mm: float = 6.0
|
| 136 |
+
sand_size_bins_mm: Sequence[float] = (0.6, 1.0, 1.5, 2.0)
|
| 137 |
+
sand_size_fractions: Sequence[float] = (0.2, 0.3, 0.3, 0.2)
|
| 138 |
+
sand_volume_fraction: float = 0.35
|
| 139 |
+
water_to_binder_ratio: float = 0.45
|
| 140 |
+
cement_content_kg_m3: float = 360.0
|
| 141 |
+
scm_type_id: int = 1
|
| 142 |
+
scm_fraction: float = 0.20
|
| 143 |
+
admixture_dosage: float = 0.5
|
| 144 |
+
cement_chem: Tuple[float, float, float, float] = (64.0, 21.0, 5.0, 3.0)
|
| 145 |
+
# Extra cement oxides (MgO, SO3, Na2O-equivalent alkali) for the formerly
|
| 146 |
+
# placeholder paste slots; OPC-typical defaults when not measured.
|
| 147 |
+
cement_chem_ext: Tuple[float, float, float] = (2.0, 2.5, 0.6)
|
| 148 |
+
scm_chem: Tuple[float, float] = (4.0, 55.0)
|
| 149 |
+
# Extended SCM oxides (Al2O3, Fe2O3, MgO, LOI); generic-pozzolan defaults.
|
| 150 |
+
scm_chem_ext: Tuple[float, float, float, float] = (15.0, 5.0, 2.0, 3.0)
|
| 151 |
+
# Cement-type categorical id (index into categoricals.CEMENT_TYPES). Default
|
| 152 |
+
# 1.0 preserves the previous constant paste ``cement_type_id`` slot value.
|
| 153 |
+
cement_type_id: float = 1.0
|
| 154 |
+
curing_relative_humidity: float = 0.95
|
| 155 |
+
curing_temperature_C: float = 23.0
|
| 156 |
+
curing_age_days: float = 28.0
|
| 157 |
+
fibre_content_kg_m3: float = 0.0
|
| 158 |
+
fibre_length_mm: float = 0.0
|
| 159 |
+
fibre_diameter_mm: float = 0.0
|
| 160 |
+
fibre_tensile_strength_MPa: float = 0.0
|
| 161 |
+
fibre_modulus_GPa: float = 0.0
|
| 162 |
+
# Measured nominal max fine/sand grain size (mm); ``None`` means not
|
| 163 |
+
# reported. ``curing_temperature_observed`` masks the mortar curing-temp
|
| 164 |
+
# global when the source did not report it.
|
| 165 |
+
max_sand_size_mm: Optional[float] = None
|
| 166 |
+
curing_temperature_observed: bool = True
|
| 167 |
+
|
| 168 |
+
def effective_sand_bins_mm(self) -> np.ndarray:
|
| 169 |
+
"""Default sand bins rescaled to the measured max fine grain size."""
|
| 170 |
+
|
| 171 |
+
bins = np.asarray(self.sand_size_bins_mm, dtype=float)
|
| 172 |
+
if self.max_sand_size_mm and self.max_sand_size_mm > 0:
|
| 173 |
+
top = bins.max()
|
| 174 |
+
if top > 0:
|
| 175 |
+
bins = bins * (float(self.max_sand_size_mm) / top)
|
| 176 |
+
return bins
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ---------------------------------------------------------------------------
|
| 180 |
+
# Output containers
|
| 181 |
+
# ---------------------------------------------------------------------------
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@dataclass
|
| 185 |
+
class ConcreteGraph:
|
| 186 |
+
x: Tensor
|
| 187 |
+
x_mask: Tensor
|
| 188 |
+
edge_index: Tensor
|
| 189 |
+
edge_attr: Tensor
|
| 190 |
+
edge_attr_mask: Tensor
|
| 191 |
+
global_features: Tensor
|
| 192 |
+
global_mask: Tensor
|
| 193 |
+
pos: Tensor
|
| 194 |
+
node_type: Tensor
|
| 195 |
+
edge_type: Tensor
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@dataclass
|
| 199 |
+
class MortarMicrograph:
|
| 200 |
+
x: Tensor
|
| 201 |
+
x_mask: Tensor
|
| 202 |
+
edge_index: Tensor
|
| 203 |
+
edge_attr: Tensor
|
| 204 |
+
node_type: Tensor
|
| 205 |
+
global_features: Tensor
|
| 206 |
+
global_mask: Tensor
|
| 207 |
+
sand_contact_density: float
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ---------------------------------------------------------------------------
|
| 211 |
+
# Geometry helpers
|
| 212 |
+
# ---------------------------------------------------------------------------
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _sample_aggregates(
|
| 216 |
+
mix: MixDesign,
|
| 217 |
+
rng: np.random.Generator,
|
| 218 |
+
max_attempts: int = 6000,
|
| 219 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 220 |
+
rve_area = mix.rve_size_mm ** 2
|
| 221 |
+
target_area = mix.aggregate_volume_fraction * rve_area
|
| 222 |
+
|
| 223 |
+
centroids: List[np.ndarray] = []
|
| 224 |
+
radii: List[float] = []
|
| 225 |
+
placed_area = 0.0
|
| 226 |
+
attempts = 0
|
| 227 |
+
while placed_area < target_area and attempts < max_attempts:
|
| 228 |
+
attempts += 1
|
| 229 |
+
r = mix.sample_radius(rng)
|
| 230 |
+
c = rng.uniform(r, mix.rve_size_mm - r, size=2)
|
| 231 |
+
ok = True
|
| 232 |
+
for cj, rj in zip(centroids, radii):
|
| 233 |
+
if np.linalg.norm(c - cj) < (r + rj) * 1.05:
|
| 234 |
+
ok = False
|
| 235 |
+
break
|
| 236 |
+
if not ok:
|
| 237 |
+
continue
|
| 238 |
+
centroids.append(c)
|
| 239 |
+
radii.append(r)
|
| 240 |
+
placed_area += math.pi * r * r
|
| 241 |
+
|
| 242 |
+
if not centroids:
|
| 243 |
+
centroids = [np.array([mix.rve_size_mm / 2.0, mix.rve_size_mm / 2.0])]
|
| 244 |
+
radii = [mix.rve_size_mm * 0.05]
|
| 245 |
+
return np.asarray(centroids), np.asarray(radii)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _mortar_seed_grid(
|
| 249 |
+
mix: MixDesign,
|
| 250 |
+
agg_centroids: np.ndarray,
|
| 251 |
+
agg_radii: np.ndarray,
|
| 252 |
+
n_side: int = 8,
|
| 253 |
+
) -> np.ndarray:
|
| 254 |
+
xs = np.linspace(0, mix.rve_size_mm, n_side + 2)[1:-1]
|
| 255 |
+
grid = np.array([(x, y) for x in xs for y in xs])
|
| 256 |
+
if len(agg_centroids) == 0:
|
| 257 |
+
return grid
|
| 258 |
+
diff = grid[:, None, :] - agg_centroids[None, :, :]
|
| 259 |
+
d2 = np.einsum("nki,nki->nk", diff, diff)
|
| 260 |
+
inside = np.any(d2 < (agg_radii * 1.05) ** 2, axis=1)
|
| 261 |
+
kept = grid[~inside]
|
| 262 |
+
if len(kept) == 0:
|
| 263 |
+
kept = np.array([[mix.rve_size_mm / 2.0, mix.rve_size_mm / 2.0]])
|
| 264 |
+
return kept
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _voronoi_adjacency(
|
| 268 |
+
points: np.ndarray,
|
| 269 |
+
rve_size: float,
|
| 270 |
+
rng: np.random.Generator,
|
| 271 |
+
n_samples: int = 4000,
|
| 272 |
+
) -> Dict[Tuple[int, int], int]:
|
| 273 |
+
"""Approximate Voronoi adjacency by sampling.
|
| 274 |
+
|
| 275 |
+
For each random sample we look up its two nearest seeds; that pair shares
|
| 276 |
+
a Voronoi boundary. The number of samples falling on each pair is a
|
| 277 |
+
monotone proxy for the shared boundary length.
|
| 278 |
+
"""
|
| 279 |
+
|
| 280 |
+
if points.shape[0] < 2:
|
| 281 |
+
return {}
|
| 282 |
+
samples = rng.uniform(0.0, rve_size, size=(n_samples, 2))
|
| 283 |
+
diff = samples[:, None, :] - points[None, :, :]
|
| 284 |
+
d2 = np.einsum("nki,nki->nk", diff, diff)
|
| 285 |
+
nearest_two = np.argpartition(d2, kth=1, axis=1)[:, :2]
|
| 286 |
+
adjacency: Dict[Tuple[int, int], int] = {}
|
| 287 |
+
a = nearest_two[:, 0]
|
| 288 |
+
b = nearest_two[:, 1]
|
| 289 |
+
lo = np.minimum(a, b)
|
| 290 |
+
hi = np.maximum(a, b)
|
| 291 |
+
for i, j in zip(lo, hi):
|
| 292 |
+
if i == j:
|
| 293 |
+
continue
|
| 294 |
+
key = (int(i), int(j))
|
| 295 |
+
adjacency[key] = adjacency.get(key, 0) + 1
|
| 296 |
+
return adjacency
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _voronoi_cell_fraction(
|
| 300 |
+
seeds: np.ndarray,
|
| 301 |
+
rve_size: float,
|
| 302 |
+
rng: np.random.Generator,
|
| 303 |
+
n_samples: int = 4000,
|
| 304 |
+
) -> np.ndarray:
|
| 305 |
+
"""Fraction of the RVE area nearest to each seed (Monte-Carlo Voronoi cells).
|
| 306 |
+
|
| 307 |
+
Returns an array summing to 1.0; used to apportion the non-particle area
|
| 308 |
+
across mortar / paste seed nodes so each carries a heterogeneous area share.
|
| 309 |
+
"""
|
| 310 |
+
|
| 311 |
+
n = len(seeds)
|
| 312 |
+
if n == 0:
|
| 313 |
+
return np.zeros(0, dtype=np.float64)
|
| 314 |
+
if n == 1:
|
| 315 |
+
return np.ones(1, dtype=np.float64)
|
| 316 |
+
samples = rng.uniform(0.0, rve_size, size=(n_samples, 2))
|
| 317 |
+
diff = samples[:, None, :] - seeds[None, :, :]
|
| 318 |
+
d2 = np.einsum("nki,nki->nk", diff, diff)
|
| 319 |
+
nearest = np.argmin(d2, axis=1)
|
| 320 |
+
counts = np.bincount(nearest, minlength=n).astype(np.float64)
|
| 321 |
+
total = counts.sum()
|
| 322 |
+
if total <= 0:
|
| 323 |
+
return np.full(n, 1.0 / n, dtype=np.float64)
|
| 324 |
+
return counts / total
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# ---------------------------------------------------------------------------
|
| 328 |
+
# Feature synthesis (concrete scale)
|
| 329 |
+
# ---------------------------------------------------------------------------
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _aggregate_feature_vector(
|
| 333 |
+
radius: float,
|
| 334 |
+
rng: np.random.Generator,
|
| 335 |
+
schema: SchemaSpec,
|
| 336 |
+
area_fraction: float = 0.0,
|
| 337 |
+
) -> np.ndarray:
|
| 338 |
+
feats = np.zeros(len(schema.aggregate), dtype=np.float32)
|
| 339 |
+
feats[0] = 2.0 * radius
|
| 340 |
+
feats[1] = float(rng.uniform(0.6, 1.0))
|
| 341 |
+
feats[2] = float(rng.normal(120.0, 15.0))
|
| 342 |
+
feats[3] = float(rng.normal(20.0, 4.0))
|
| 343 |
+
feats[4] = float(rng.uniform(0.7, 1.0))
|
| 344 |
+
feats[5] = float(rng.uniform(0.0, 0.05))
|
| 345 |
+
feats[6] = float(rng.uniform(0.2, 2.0))
|
| 346 |
+
feats[7] = float(rng.uniform(0.4, 1.0))
|
| 347 |
+
feats[8] = float(rng.normal(2.65, 0.05))
|
| 348 |
+
feats[9] = float(rng.normal(1600.0, 50.0))
|
| 349 |
+
feats[schema.aggregate.index("area_fraction")] = float(area_fraction)
|
| 350 |
+
return feats
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _mortar_placeholder_vector(
|
| 354 |
+
mix: MixDesign,
|
| 355 |
+
rng: np.random.Generator,
|
| 356 |
+
schema: SchemaSpec,
|
| 357 |
+
area_fraction: float = 0.0,
|
| 358 |
+
) -> np.ndarray:
|
| 359 |
+
"""A coarse prior for mortar features; overwritten by the sub-model."""
|
| 360 |
+
|
| 361 |
+
feats = np.zeros(len(schema.mortar), dtype=np.float32)
|
| 362 |
+
w = mix.water_to_binder_ratio
|
| 363 |
+
base = 85.0 - 65.0 * w
|
| 364 |
+
feats[0] = float(rng.normal(base, 5.0))
|
| 365 |
+
feats[1] = float(rng.normal(0.1 * base, 0.5))
|
| 366 |
+
feats[2] = float(rng.normal(0.16 * base, 0.5))
|
| 367 |
+
feats[3] = float(rng.normal(15.0 + 0.35 * base, 2.0))
|
| 368 |
+
feats[4] = float(rng.uniform(1e-12, 1e-10))
|
| 369 |
+
feats[5] = float(rng.uniform(1e-12, 1e-10))
|
| 370 |
+
feats[6] = float(rng.uniform(0.08, 0.20))
|
| 371 |
+
feats[7] = float(rng.uniform(1.0, 3.0))
|
| 372 |
+
# Fibre conditioning (slots 8-11): broadcast mix-level fibre attributes
|
| 373 |
+
# to every mortar node. Aspect ratio = length / diameter when both > 0.
|
| 374 |
+
feats[8] = float(mix.fibre_content_kg_m3)
|
| 375 |
+
if mix.fibre_diameter_mm > 0.0:
|
| 376 |
+
feats[9] = float(mix.fibre_length_mm / mix.fibre_diameter_mm)
|
| 377 |
+
feats[10] = float(mix.fibre_tensile_strength_MPa)
|
| 378 |
+
feats[11] = float(mix.fibre_modulus_GPa)
|
| 379 |
+
feats[schema.mortar.index("mortar_area_fraction")] = float(area_fraction)
|
| 380 |
+
feats[schema.mortar.index("fibre_type_id")] = float(mix.fibre_type_id)
|
| 381 |
+
return feats
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _itz_placeholder_vector(
|
| 385 |
+
mix: MixDesign, rng: np.random.Generator, schema: SchemaSpec
|
| 386 |
+
) -> np.ndarray:
|
| 387 |
+
feats = np.zeros(len(schema.itz), dtype=np.float32)
|
| 388 |
+
w = mix.water_to_binder_ratio
|
| 389 |
+
feats[0] = float(rng.uniform(5.0, 80.0))
|
| 390 |
+
# Porosity rises with w/b (kept physical) but spans the wider [0.05, 0.5].
|
| 391 |
+
feats[1] = float(np.clip(0.05 + 0.45 * w + rng.uniform(-0.03, 0.03), 0.05, 0.5))
|
| 392 |
+
feats[2] = float(rng.normal(20.0, 4.0))
|
| 393 |
+
feats[3] = float(rng.normal(15.0, 3.0))
|
| 394 |
+
feats[4] = float(rng.uniform(1e-11, 1e-9))
|
| 395 |
+
feats[5] = float(rng.uniform(1e-11, 1e-9))
|
| 396 |
+
return feats
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _global_feature_vector(
|
| 400 |
+
mix: MixDesign, schema: SchemaSpec
|
| 401 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 402 |
+
"""Return ``(values, observed_mask)`` for the concrete global vector.
|
| 403 |
+
|
| 404 |
+
Maskable descriptors (curing temperature, max coarse / fine grain size)
|
| 405 |
+
are flagged 0 in the mask when the mix did not report them, so the
|
| 406 |
+
MissingFeatureEncoder treats them as missing rather than as a real value.
|
| 407 |
+
"""
|
| 408 |
+
|
| 409 |
+
values = {
|
| 410 |
+
"relative_humidity": mix.relative_humidity,
|
| 411 |
+
"temperature_C": mix.temperature_C,
|
| 412 |
+
"curing_age_days": mix.curing_age_days,
|
| 413 |
+
"water_to_binder_ratio": mix.water_to_binder_ratio,
|
| 414 |
+
"cement_content_kg_m3": mix.cement_content_kg_m3,
|
| 415 |
+
"scm_fraction": mix.scm_fraction,
|
| 416 |
+
"aggregate_volume_fraction": mix.aggregate_volume_fraction,
|
| 417 |
+
"fibre_content_kg_m3": mix.fibre_content_kg_m3,
|
| 418 |
+
"max_coarse_aggregate_size_mm": mix.max_coarse_aggregate_size_mm or 0.0,
|
| 419 |
+
"max_fine_aggregate_size_mm": mix.max_fine_aggregate_size_mm or 0.0,
|
| 420 |
+
}
|
| 421 |
+
observed = {
|
| 422 |
+
"temperature_C": mix.temperature_observed,
|
| 423 |
+
"max_coarse_aggregate_size_mm": mix.max_coarse_aggregate_size_mm is not None,
|
| 424 |
+
"max_fine_aggregate_size_mm": mix.max_fine_aggregate_size_mm is not None,
|
| 425 |
+
}
|
| 426 |
+
g = np.zeros(len(schema.glob), dtype=np.float32)
|
| 427 |
+
mask = np.zeros(len(schema.glob), dtype=np.float32)
|
| 428 |
+
for i, name in enumerate(schema.glob):
|
| 429 |
+
if name in values:
|
| 430 |
+
g[i] = values[name]
|
| 431 |
+
mask[i] = 1.0 if observed.get(name, True) else 0.0
|
| 432 |
+
return g, mask
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
# ---------------------------------------------------------------------------
|
| 436 |
+
# Concrete graph generator
|
| 437 |
+
# ---------------------------------------------------------------------------
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def generate_concrete_graph(
|
| 441 |
+
mix: MixDesign,
|
| 442 |
+
schema: SchemaSpec = DEFAULT_SCHEMA,
|
| 443 |
+
seed: Optional[int] = None,
|
| 444 |
+
missing_rate: float = 0.10,
|
| 445 |
+
) -> ConcreteGraph:
|
| 446 |
+
rng = np.random.default_rng(seed)
|
| 447 |
+
agg_xy, agg_r = _sample_aggregates(mix, rng)
|
| 448 |
+
mortar_xy = _mortar_seed_grid(mix, agg_xy, agg_r)
|
| 449 |
+
|
| 450 |
+
n_agg = len(agg_xy)
|
| 451 |
+
n_mortar = len(mortar_xy)
|
| 452 |
+
all_points = np.concatenate([agg_xy, mortar_xy], axis=0)
|
| 453 |
+
adjacency = _voronoi_adjacency(all_points, rve_size=mix.rve_size_mm, rng=rng)
|
| 454 |
+
|
| 455 |
+
# Augment mortar-mortar edges via k-nearest neighbours (Voronoi cells of
|
| 456 |
+
# mortar seeds are frequently interrupted by aggregate cells).
|
| 457 |
+
if n_mortar > 1:
|
| 458 |
+
k = min(4, n_mortar - 1)
|
| 459 |
+
d2 = np.sum((mortar_xy[:, None, :] - mortar_xy[None, :, :]) ** 2, axis=-1)
|
| 460 |
+
np.fill_diagonal(d2, np.inf)
|
| 461 |
+
nbr = np.argpartition(d2, kth=k - 1, axis=1)[:, :k]
|
| 462 |
+
for i in range(n_mortar):
|
| 463 |
+
for j in nbr[i]:
|
| 464 |
+
a = n_agg + int(i)
|
| 465 |
+
b = n_agg + int(j)
|
| 466 |
+
if a == b:
|
| 467 |
+
continue
|
| 468 |
+
key = (min(a, b), max(a, b))
|
| 469 |
+
adjacency.setdefault(key, 1)
|
| 470 |
+
|
| 471 |
+
pad_node = schema.node_pad_dim
|
| 472 |
+
pad_edge = schema.edge_pad_dim
|
| 473 |
+
n_total = n_agg + n_mortar
|
| 474 |
+
|
| 475 |
+
x = np.zeros((n_total, pad_node), dtype=np.float32)
|
| 476 |
+
x_mask = np.zeros((n_total, pad_node), dtype=np.float32)
|
| 477 |
+
|
| 478 |
+
# Per-node area fractions. Aggregates use exact disk area; mortar seeds split
|
| 479 |
+
# the remaining (non-aggregate) area by their Monte-Carlo Voronoi cell share.
|
| 480 |
+
rve_area = float(mix.rve_size_mm ** 2)
|
| 481 |
+
agg_area_frac = (np.pi * np.asarray(agg_r, dtype=np.float64) ** 2) / rve_area
|
| 482 |
+
mortar_total_frac = max(0.0, 1.0 - float(agg_area_frac.sum()))
|
| 483 |
+
mortar_cell = _voronoi_cell_fraction(mortar_xy, mix.rve_size_mm, rng)
|
| 484 |
+
mortar_area_frac = mortar_cell * mortar_total_frac
|
| 485 |
+
|
| 486 |
+
for i in range(n_agg):
|
| 487 |
+
feats = _aggregate_feature_vector(
|
| 488 |
+
agg_r[i], rng, schema, area_fraction=float(agg_area_frac[i])
|
| 489 |
+
)
|
| 490 |
+
agg_dim = len(schema.aggregate)
|
| 491 |
+
x[i, :agg_dim] = feats
|
| 492 |
+
x_mask[i, :agg_dim] = 1.0
|
| 493 |
+
|
| 494 |
+
for j in range(n_mortar):
|
| 495 |
+
feats = _mortar_placeholder_vector(
|
| 496 |
+
mix, rng, schema, area_fraction=float(mortar_area_frac[j])
|
| 497 |
+
)
|
| 498 |
+
mortar_dim = len(schema.mortar)
|
| 499 |
+
x[n_agg + j, :mortar_dim] = feats
|
| 500 |
+
x_mask[n_agg + j, :mortar_dim] = 1.0
|
| 501 |
+
|
| 502 |
+
node_type = np.concatenate(
|
| 503 |
+
[
|
| 504 |
+
np.full(n_agg, NODE_TYPE_AGGREGATE, dtype=np.int64),
|
| 505 |
+
np.full(n_mortar, NODE_TYPE_MORTAR, dtype=np.int64),
|
| 506 |
+
]
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
# Edges
|
| 510 |
+
edge_index: List[List[int]] = []
|
| 511 |
+
edge_attr: List[np.ndarray] = []
|
| 512 |
+
edge_mask: List[np.ndarray] = []
|
| 513 |
+
edge_type: List[int] = []
|
| 514 |
+
|
| 515 |
+
for (a, b), shared in adjacency.items():
|
| 516 |
+
type_a = NODE_TYPE_AGGREGATE if a < n_agg else NODE_TYPE_MORTAR
|
| 517 |
+
type_b = NODE_TYPE_AGGREGATE if b < n_agg else NODE_TYPE_MORTAR
|
| 518 |
+
pa = all_points[a]
|
| 519 |
+
pb = all_points[b]
|
| 520 |
+
dist = float(np.linalg.norm(pa - pb))
|
| 521 |
+
boundary = float(shared) * (mix.rve_size_mm / 60.0)
|
| 522 |
+
|
| 523 |
+
feats = np.zeros(pad_edge, dtype=np.float32)
|
| 524 |
+
mask = np.zeros(pad_edge, dtype=np.float32)
|
| 525 |
+
|
| 526 |
+
if type_a != type_b:
|
| 527 |
+
etype = EDGE_TYPE_ITZ
|
| 528 |
+
itz_vals = _itz_placeholder_vector(mix, rng, schema)
|
| 529 |
+
feats[: len(itz_vals)] = itz_vals
|
| 530 |
+
mask[: len(itz_vals)] = 1.0
|
| 531 |
+
elif type_a == NODE_TYPE_MORTAR:
|
| 532 |
+
etype = EDGE_TYPE_MORTAR_MORTAR
|
| 533 |
+
feats[0] = dist
|
| 534 |
+
feats[1] = boundary
|
| 535 |
+
feats[2] = float(pb[0] - pa[0])
|
| 536 |
+
feats[3] = float(pb[1] - pa[1])
|
| 537 |
+
mask[:4] = 1.0
|
| 538 |
+
else:
|
| 539 |
+
ai = a
|
| 540 |
+
bi = b
|
| 541 |
+
surface_gap = max(0.0, dist - float(agg_r[ai]) - float(agg_r[bi]))
|
| 542 |
+
if surface_gap > 1.5 * (mix.rve_size_mm / 60.0):
|
| 543 |
+
continue
|
| 544 |
+
etype = EDGE_TYPE_AGGREGATE_AGGREGATE
|
| 545 |
+
feats[0] = surface_gap
|
| 546 |
+
feats[1] = dist
|
| 547 |
+
mask[:2] = 1.0
|
| 548 |
+
|
| 549 |
+
for src, dst in ((a, b), (b, a)):
|
| 550 |
+
edge_index.append([src, dst])
|
| 551 |
+
edge_attr.append(feats.copy())
|
| 552 |
+
edge_mask.append(mask.copy())
|
| 553 |
+
edge_type.append(etype)
|
| 554 |
+
|
| 555 |
+
if not edge_index:
|
| 556 |
+
edge_index = [[0, 0]]
|
| 557 |
+
edge_attr = [np.zeros(pad_edge, dtype=np.float32)]
|
| 558 |
+
edge_mask = [np.zeros(pad_edge, dtype=np.float32)]
|
| 559 |
+
edge_type = [EDGE_TYPE_ITZ]
|
| 560 |
+
|
| 561 |
+
edge_index_t = torch.tensor(np.asarray(edge_index).T, dtype=torch.long)
|
| 562 |
+
edge_attr_t = torch.tensor(np.stack(edge_attr), dtype=torch.float32)
|
| 563 |
+
edge_mask_t = torch.tensor(np.stack(edge_mask), dtype=torch.float32)
|
| 564 |
+
edge_type_t = torch.tensor(edge_type, dtype=torch.long)
|
| 565 |
+
pos_t = torch.tensor(all_points, dtype=torch.float32)
|
| 566 |
+
x_t = torch.tensor(x, dtype=torch.float32)
|
| 567 |
+
x_mask_t = torch.tensor(x_mask, dtype=torch.float32)
|
| 568 |
+
node_type_t = torch.tensor(node_type, dtype=torch.long)
|
| 569 |
+
|
| 570 |
+
if missing_rate > 0.0:
|
| 571 |
+
protected = _protected_node_columns(schema)
|
| 572 |
+
drop = (torch.rand_like(x_mask_t) < missing_rate) & (x_mask_t > 0)
|
| 573 |
+
drop[:, protected] = False
|
| 574 |
+
x_mask_t = x_mask_t * (~drop).float()
|
| 575 |
+
x_t = x_t * x_mask_t
|
| 576 |
+
|
| 577 |
+
g, g_observed = _global_feature_vector(mix, schema)
|
| 578 |
+
g_t = torch.tensor(g, dtype=torch.float32)
|
| 579 |
+
g_mask = torch.tensor(g_observed, dtype=torch.float32)
|
| 580 |
+
g_t = g_t * g_mask # zero out unreported descriptors
|
| 581 |
+
if missing_rate > 0.0:
|
| 582 |
+
drop = torch.rand_like(g_t) < missing_rate
|
| 583 |
+
g_mask = g_mask * (~drop).float()
|
| 584 |
+
g_t = g_t * g_mask
|
| 585 |
+
|
| 586 |
+
return ConcreteGraph(
|
| 587 |
+
x=x_t,
|
| 588 |
+
x_mask=x_mask_t,
|
| 589 |
+
edge_index=edge_index_t,
|
| 590 |
+
edge_attr=edge_attr_t,
|
| 591 |
+
edge_attr_mask=edge_mask_t,
|
| 592 |
+
global_features=g_t,
|
| 593 |
+
global_mask=g_mask,
|
| 594 |
+
pos=pos_t,
|
| 595 |
+
node_type=node_type_t,
|
| 596 |
+
edge_type=edge_type_t,
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def _protected_node_columns(schema: SchemaSpec) -> List[int]:
|
| 601 |
+
"""Indices of features that should never be hidden by random missingness."""
|
| 602 |
+
|
| 603 |
+
return [0] # protect the first slot (e.g. aggregate size / strength prior)
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
# ---------------------------------------------------------------------------
|
| 607 |
+
# Mortar micrograph generator
|
| 608 |
+
# ---------------------------------------------------------------------------
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def _sample_sand(
|
| 612 |
+
mix: MortarMixDesign,
|
| 613 |
+
rng: np.random.Generator,
|
| 614 |
+
max_attempts: int = 800,
|
| 615 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 616 |
+
bins = mix.effective_sand_bins_mm()
|
| 617 |
+
weights = np.asarray(mix.sand_size_fractions, dtype=float)
|
| 618 |
+
weights = weights / weights.sum()
|
| 619 |
+
|
| 620 |
+
rve_area = mix.rve_size_mm ** 2
|
| 621 |
+
target_area = mix.sand_volume_fraction * rve_area
|
| 622 |
+
|
| 623 |
+
centroids: List[np.ndarray] = []
|
| 624 |
+
radii: List[float] = []
|
| 625 |
+
placed = 0.0
|
| 626 |
+
attempts = 0
|
| 627 |
+
while placed < target_area and attempts < max_attempts:
|
| 628 |
+
attempts += 1
|
| 629 |
+
idx = int(rng.choice(len(bins), p=weights))
|
| 630 |
+
r = float(bins[idx] * 0.5 * rng.uniform(0.85, 1.0))
|
| 631 |
+
c = rng.uniform(r, mix.rve_size_mm - r, size=2)
|
| 632 |
+
if any(np.linalg.norm(c - cj) < (r + rj) * 1.05 for cj, rj in zip(centroids, radii)):
|
| 633 |
+
continue
|
| 634 |
+
centroids.append(c)
|
| 635 |
+
radii.append(r)
|
| 636 |
+
placed += math.pi * r * r
|
| 637 |
+
|
| 638 |
+
if not centroids:
|
| 639 |
+
centroids = [np.array([mix.rve_size_mm / 2.0, mix.rve_size_mm / 2.0])]
|
| 640 |
+
radii = [0.5]
|
| 641 |
+
return np.asarray(centroids), np.asarray(radii)
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
def _paste_grid(mix: MortarMixDesign, sand_xy: np.ndarray, sand_r: np.ndarray) -> np.ndarray:
|
| 645 |
+
n_side = 5
|
| 646 |
+
xs = np.linspace(0, mix.rve_size_mm, n_side + 2)[1:-1]
|
| 647 |
+
grid = np.array([(x, y) for x in xs for y in xs])
|
| 648 |
+
if len(sand_xy) == 0:
|
| 649 |
+
return grid
|
| 650 |
+
diff = grid[:, None, :] - sand_xy[None, :, :]
|
| 651 |
+
d2 = np.einsum("nki,nki->nk", diff, diff)
|
| 652 |
+
inside = np.any(d2 < (sand_r * 1.05) ** 2, axis=1)
|
| 653 |
+
kept = grid[~inside]
|
| 654 |
+
if len(kept) == 0:
|
| 655 |
+
kept = np.array([[mix.rve_size_mm / 2.0, mix.rve_size_mm / 2.0]])
|
| 656 |
+
return kept
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def _sand_feature_vector(
|
| 660 |
+
radius: float,
|
| 661 |
+
rng: np.random.Generator,
|
| 662 |
+
schema: SchemaSpec,
|
| 663 |
+
area_fraction: float = 0.0,
|
| 664 |
+
) -> np.ndarray:
|
| 665 |
+
v = np.zeros(len(schema.sand), dtype=np.float32)
|
| 666 |
+
v[0] = 2.0 * radius
|
| 667 |
+
v[1] = float(rng.uniform(2.0, 3.5))
|
| 668 |
+
v[2] = float(rng.uniform(2.4, 3.2))
|
| 669 |
+
v[3] = float(rng.uniform(0.5, 2.0))
|
| 670 |
+
v[4] = float(rng.normal(80.0, 10.0))
|
| 671 |
+
v[5] = float(rng.normal(70.0, 8.0))
|
| 672 |
+
v[schema.sand.index("sand_area_fraction")] = float(area_fraction)
|
| 673 |
+
return v
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def _paste_feature_vector(
|
| 677 |
+
mix: MortarMixDesign, schema: SchemaSpec, area_fraction: float = 0.0
|
| 678 |
+
) -> np.ndarray:
|
| 679 |
+
v = np.zeros(len(schema.paste), dtype=np.float32)
|
| 680 |
+
v[0] = float(mix.cement_type_id)
|
| 681 |
+
v[1:5] = np.asarray(mix.cement_chem, dtype=np.float32)
|
| 682 |
+
v[5] = mix.cement_content_kg_m3
|
| 683 |
+
v[6] = float(mix.scm_type_id)
|
| 684 |
+
v[7:9] = np.asarray(mix.scm_chem, dtype=np.float32)
|
| 685 |
+
v[9] = mix.scm_fraction * mix.cement_content_kg_m3
|
| 686 |
+
v[10] = mix.water_to_binder_ratio
|
| 687 |
+
v[11] = mix.admixture_dosage
|
| 688 |
+
v[schema.paste.index("paste_area_fraction")] = float(area_fraction)
|
| 689 |
+
ext = np.asarray(mix.cement_chem_ext, dtype=np.float32)
|
| 690 |
+
v[schema.paste.index("cement_MgO_pct")] = ext[0]
|
| 691 |
+
v[schema.paste.index("cement_SO3_pct")] = ext[1]
|
| 692 |
+
v[schema.paste.index("cement_alkali_pct")] = ext[2]
|
| 693 |
+
sext = np.asarray(mix.scm_chem_ext, dtype=np.float32)
|
| 694 |
+
v[schema.paste.index("scm_Al2O3_pct")] = sext[0]
|
| 695 |
+
v[schema.paste.index("scm_Fe2O3_pct")] = sext[1]
|
| 696 |
+
v[schema.paste.index("scm_MgO_pct")] = sext[2]
|
| 697 |
+
v[schema.paste.index("scm_LOI_pct")] = sext[3]
|
| 698 |
+
return v
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def _mortar_global_vector(
|
| 702 |
+
mix: MortarMixDesign, schema: SchemaSpec
|
| 703 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 704 |
+
"""Return ``(values, observed_mask)`` for the mortar global vector."""
|
| 705 |
+
|
| 706 |
+
values = {
|
| 707 |
+
"mortar_water_to_binder_ratio": mix.water_to_binder_ratio,
|
| 708 |
+
"mortar_cement_content_kg_m3": mix.cement_content_kg_m3,
|
| 709 |
+
"mortar_scm_fraction": mix.scm_fraction,
|
| 710 |
+
"mortar_admixture_dosage": mix.admixture_dosage,
|
| 711 |
+
"mortar_curing_relative_humidity": mix.curing_relative_humidity,
|
| 712 |
+
"mortar_curing_temperature_C": mix.curing_temperature_C,
|
| 713 |
+
"mortar_curing_age_days": mix.curing_age_days,
|
| 714 |
+
"mortar_fibre_content_kg_m3": mix.fibre_content_kg_m3,
|
| 715 |
+
}
|
| 716 |
+
observed = {"mortar_curing_temperature_C": mix.curing_temperature_observed}
|
| 717 |
+
g = np.zeros(len(schema.mortar_global), dtype=np.float32)
|
| 718 |
+
mask = np.zeros(len(schema.mortar_global), dtype=np.float32)
|
| 719 |
+
for i, name in enumerate(schema.mortar_global):
|
| 720 |
+
if name in values:
|
| 721 |
+
g[i] = values[name]
|
| 722 |
+
mask[i] = 1.0 if observed.get(name, True) else 0.0
|
| 723 |
+
return g, mask
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def generate_mortar_graph(
|
| 727 |
+
mix: MortarMixDesign,
|
| 728 |
+
schema: SchemaSpec = DEFAULT_SCHEMA,
|
| 729 |
+
seed: Optional[int] = None,
|
| 730 |
+
) -> MortarMicrograph:
|
| 731 |
+
rng = np.random.default_rng(seed)
|
| 732 |
+
sand_xy, sand_r = _sample_sand(mix, rng)
|
| 733 |
+
paste_xy = _paste_grid(mix, sand_xy, sand_r)
|
| 734 |
+
|
| 735 |
+
n_sand = len(sand_xy)
|
| 736 |
+
n_paste = len(paste_xy)
|
| 737 |
+
pts = np.concatenate([sand_xy, paste_xy], axis=0)
|
| 738 |
+
adjacency = _voronoi_adjacency(pts, rve_size=mix.rve_size_mm, rng=rng, n_samples=3000)
|
| 739 |
+
|
| 740 |
+
pad = schema.mortar_node_pad_dim
|
| 741 |
+
n_total = n_sand + n_paste
|
| 742 |
+
x = np.zeros((n_total, pad), dtype=np.float32)
|
| 743 |
+
mask = np.zeros((n_total, pad), dtype=np.float32)
|
| 744 |
+
node_type = np.zeros(n_total, dtype=np.int64)
|
| 745 |
+
|
| 746 |
+
# Sand uses exact disk area; paste seeds split the remaining area by their
|
| 747 |
+
# Monte-Carlo Voronoi cell share.
|
| 748 |
+
mortar_rve_area = float(mix.rve_size_mm ** 2)
|
| 749 |
+
sand_area_frac = (np.pi * np.asarray(sand_r, dtype=np.float64) ** 2) / mortar_rve_area
|
| 750 |
+
paste_total_frac = max(0.0, 1.0 - float(sand_area_frac.sum()))
|
| 751 |
+
paste_cell = _voronoi_cell_fraction(paste_xy, mix.rve_size_mm, rng, n_samples=3000)
|
| 752 |
+
paste_area_frac = paste_cell * paste_total_frac
|
| 753 |
+
|
| 754 |
+
for i in range(n_sand):
|
| 755 |
+
v = _sand_feature_vector(
|
| 756 |
+
sand_r[i], rng, schema, area_fraction=float(sand_area_frac[i])
|
| 757 |
+
)
|
| 758 |
+
x[i, : len(v)] = v
|
| 759 |
+
mask[i, : len(v)] = 1.0
|
| 760 |
+
node_type[i] = MORTAR_NODE_TYPE_SAND
|
| 761 |
+
|
| 762 |
+
for j in range(n_paste):
|
| 763 |
+
v = _paste_feature_vector(mix, schema, area_fraction=float(paste_area_frac[j]))
|
| 764 |
+
x[n_sand + j, : len(v)] = v
|
| 765 |
+
mask[n_sand + j, : len(v)] = 1.0
|
| 766 |
+
node_type[n_sand + j] = MORTAR_NODE_TYPE_PASTE
|
| 767 |
+
|
| 768 |
+
edge_index: List[List[int]] = []
|
| 769 |
+
edge_attr: List[List[float]] = []
|
| 770 |
+
sand_sand_pairs = 0
|
| 771 |
+
for (a, b), shared in adjacency.items():
|
| 772 |
+
d = float(np.linalg.norm(pts[a] - pts[b]))
|
| 773 |
+
same_phase = 1.0 if node_type[a] == node_type[b] else 0.0
|
| 774 |
+
if node_type[a] == MORTAR_NODE_TYPE_SAND and node_type[b] == MORTAR_NODE_TYPE_SAND:
|
| 775 |
+
sand_sand_pairs += 1
|
| 776 |
+
for src, dst in ((a, b), (b, a)):
|
| 777 |
+
edge_index.append([src, dst])
|
| 778 |
+
edge_attr.append([d, same_phase])
|
| 779 |
+
|
| 780 |
+
if not edge_index:
|
| 781 |
+
edge_index = [[0, 0]]
|
| 782 |
+
edge_attr = [[0.0, 0.0]]
|
| 783 |
+
|
| 784 |
+
contact_density = min(1.0, (sand_sand_pairs / max(1, n_sand)) / 4.0)
|
| 785 |
+
|
| 786 |
+
g, g_observed = _mortar_global_vector(mix, schema)
|
| 787 |
+
g_t = torch.tensor(g, dtype=torch.float32)
|
| 788 |
+
g_mask = torch.tensor(g_observed, dtype=torch.float32)
|
| 789 |
+
g_t = g_t * g_mask # zero out unreported descriptors
|
| 790 |
+
|
| 791 |
+
return MortarMicrograph(
|
| 792 |
+
x=torch.tensor(x, dtype=torch.float32),
|
| 793 |
+
x_mask=torch.tensor(mask, dtype=torch.float32),
|
| 794 |
+
edge_index=torch.tensor(np.asarray(edge_index).T, dtype=torch.long),
|
| 795 |
+
edge_attr=torch.tensor(edge_attr, dtype=torch.float32),
|
| 796 |
+
node_type=torch.tensor(node_type, dtype=torch.long),
|
| 797 |
+
global_features=g_t,
|
| 798 |
+
global_mask=g_mask,
|
| 799 |
+
sand_contact_density=float(contact_density),
|
| 800 |
+
)
|
concrete_gnn/ground_truth.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic physics that produces concrete-level targets dependent on graph topology.
|
| 2 |
+
|
| 3 |
+
The targets generated here intentionally depend on (a) microscale properties
|
| 4 |
+
that are *relational* with respect to the mortar micrograph (sand-sand
|
| 5 |
+
contact density), and (b) the *realised* mesoscale topology (aggregate node
|
| 6 |
+
fraction, ITZ edge fraction, aggregate-aggregate edge fraction). Because
|
| 7 |
+
both quantities vary per RVE even at a fixed mix design, a model that sees
|
| 8 |
+
only global mix features cannot reproduce that per-RVE variance, while a
|
| 9 |
+
hierarchical GNN can. This makes the question "does the architecture
|
| 10 |
+
matter?" empirically testable.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import math
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
from torch import Tensor
|
| 20 |
+
|
| 21 |
+
from .graph_generator import ConcreteGraph, MixDesign, MortarMicrograph
|
| 22 |
+
from .schema import (
|
| 23 |
+
EDGE_TYPE_AGGREGATE_AGGREGATE,
|
| 24 |
+
EDGE_TYPE_ITZ,
|
| 25 |
+
NODE_TYPE_AGGREGATE,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class GroundTruthProps:
|
| 31 |
+
mortar_elastic: float
|
| 32 |
+
mortar_strength: float
|
| 33 |
+
mortar_permeability: float
|
| 34 |
+
mortar_porosity: float
|
| 35 |
+
mortar_creep: float
|
| 36 |
+
mortar_tensile: float
|
| 37 |
+
mortar_flexural: float
|
| 38 |
+
mortar_diffusivity: float
|
| 39 |
+
itz_strength: float
|
| 40 |
+
itz_permeability: float
|
| 41 |
+
itz_thickness: float
|
| 42 |
+
itz_porosity: float
|
| 43 |
+
itz_elastic: float
|
| 44 |
+
itz_diffusivity: float
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def true_micro_properties(mix: MixDesign, sand_contact_density: float) -> GroundTruthProps:
|
| 48 |
+
"""Closed-form true mortar and ITZ properties.
|
| 49 |
+
|
| 50 |
+
``sand_contact_density`` is a relational quantity (sand-sand contacts per
|
| 51 |
+
sand particle) recoverable only from micrograph connectivity (sum-based
|
| 52 |
+
message passing), not from pooled node features.
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
w = mix.water_to_binder_ratio
|
| 56 |
+
cem = mix.cement_content_kg_m3 / 360.0
|
| 57 |
+
age = min(1.25, math.log1p(mix.curing_age_days) / math.log1p(28.0))
|
| 58 |
+
base = 85.0 - 65.0 * w
|
| 59 |
+
|
| 60 |
+
mortar_elastic = (15.0 + 0.35 * base) * (0.8 + 0.4 * sand_contact_density)
|
| 61 |
+
mortar_strength = base * age * (0.95 + 0.1 * cem)
|
| 62 |
+
mortar_permeability = 1.0e-12 * (1.0 + 3.0 * w)
|
| 63 |
+
mortar_tensile = 0.1 * mortar_strength
|
| 64 |
+
mortar_flexural = 1.8 * mortar_tensile
|
| 65 |
+
mortar_diffusivity = 1.0e-11 * (1.0 + 4.0 * w)
|
| 66 |
+
mortar_porosity = 0.08 + 0.25 * w
|
| 67 |
+
mortar_creep = 1.0 + 2.0 * w
|
| 68 |
+
|
| 69 |
+
itz_strength = 0.70 * base * (0.9 + 0.1 * age)
|
| 70 |
+
itz_permeability = 2.0e-12 * (1.0 + 4.0 * w)
|
| 71 |
+
itz_thickness = 20.0 + 30.0 * w
|
| 72 |
+
itz_porosity = 0.12 + 0.28 * w
|
| 73 |
+
itz_elastic = 12.0 + 10.0 * (1.0 - w)
|
| 74 |
+
itz_diffusivity = 2.0e-11 * (1.0 + 5.0 * w)
|
| 75 |
+
|
| 76 |
+
return GroundTruthProps(
|
| 77 |
+
mortar_elastic=mortar_elastic,
|
| 78 |
+
mortar_strength=mortar_strength,
|
| 79 |
+
mortar_permeability=mortar_permeability,
|
| 80 |
+
mortar_porosity=mortar_porosity,
|
| 81 |
+
mortar_creep=mortar_creep,
|
| 82 |
+
mortar_tensile=mortar_tensile,
|
| 83 |
+
mortar_flexural=mortar_flexural,
|
| 84 |
+
mortar_diffusivity=mortar_diffusivity,
|
| 85 |
+
itz_strength=itz_strength,
|
| 86 |
+
itz_permeability=itz_permeability,
|
| 87 |
+
itz_thickness=itz_thickness,
|
| 88 |
+
itz_porosity=itz_porosity,
|
| 89 |
+
itz_elastic=itz_elastic,
|
| 90 |
+
itz_diffusivity=itz_diffusivity,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def homogenize_concrete_targets(
|
| 95 |
+
mix: MixDesign,
|
| 96 |
+
props: GroundTruthProps,
|
| 97 |
+
agg_node_fraction: float,
|
| 98 |
+
itz_edge_fraction: float,
|
| 99 |
+
aa_edge_fraction: float,
|
| 100 |
+
) -> Tensor:
|
| 101 |
+
"""Combine microscale properties with realised topology into concrete targets.
|
| 102 |
+
|
| 103 |
+
Output order matches ``schema.CONCRETE_TARGETS``.
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
w = mix.water_to_binder_ratio
|
| 107 |
+
V = mix.aggregate_volume_fraction
|
| 108 |
+
e_agg = 60.0
|
| 109 |
+
|
| 110 |
+
e_voigt = V * e_agg + (1.0 - V) * props.mortar_elastic
|
| 111 |
+
e_reuss = 1.0 / (V / e_agg + (1.0 - V) / props.mortar_elastic)
|
| 112 |
+
elastic = 0.5 * (e_voigt + e_reuss) * (1.0 - 0.12 * itz_edge_fraction)
|
| 113 |
+
|
| 114 |
+
itz_quality = props.itz_strength / 60.0
|
| 115 |
+
compressive = (
|
| 116 |
+
props.mortar_strength
|
| 117 |
+
* (1.0 - 0.2 * V)
|
| 118 |
+
* (0.8 + 0.3 * itz_quality)
|
| 119 |
+
* (1.0 - 0.15 * aa_edge_fraction)
|
| 120 |
+
* (1.0 + 0.1 * (agg_node_fraction - 0.3))
|
| 121 |
+
)
|
| 122 |
+
compressive = max(5.0, compressive)
|
| 123 |
+
tensile = 0.1 * compressive * (0.85 + 0.3 * itz_quality)
|
| 124 |
+
flexural = 0.15 * compressive + 0.6
|
| 125 |
+
|
| 126 |
+
permeability = props.mortar_permeability * (1.0 - itz_edge_fraction) + (
|
| 127 |
+
props.itz_permeability * itz_edge_fraction * (1.0 + 2.0 * aa_edge_fraction)
|
| 128 |
+
)
|
| 129 |
+
diffusivity = (0.5 + 0.5 * w) * 1.0e-11 + 5.0e-11 * itz_edge_fraction * (
|
| 130 |
+
1.0 + aa_edge_fraction
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
return torch.tensor(
|
| 134 |
+
[compressive, tensile, flexural, elastic, permeability, diffusivity],
|
| 135 |
+
dtype=torch.float32,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def mortar_target_vector(props: GroundTruthProps) -> Tensor:
|
| 140 |
+
"""Mortar sub-model target vector (order matches ``schema.MORTAR_TARGETS``)."""
|
| 141 |
+
|
| 142 |
+
return torch.tensor(
|
| 143 |
+
[
|
| 144 |
+
props.mortar_strength,
|
| 145 |
+
props.mortar_tensile,
|
| 146 |
+
props.mortar_flexural,
|
| 147 |
+
props.mortar_elastic,
|
| 148 |
+
props.mortar_permeability,
|
| 149 |
+
props.mortar_diffusivity,
|
| 150 |
+
props.mortar_porosity,
|
| 151 |
+
props.mortar_creep,
|
| 152 |
+
],
|
| 153 |
+
dtype=torch.float32,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def itz_target_vector(props: GroundTruthProps) -> Tensor:
|
| 158 |
+
"""ITZ sub-model target vector (order matches ``schema.ITZ_TARGETS``)."""
|
| 159 |
+
|
| 160 |
+
return torch.tensor(
|
| 161 |
+
[
|
| 162 |
+
props.itz_thickness,
|
| 163 |
+
props.itz_porosity,
|
| 164 |
+
props.itz_strength,
|
| 165 |
+
props.itz_elastic,
|
| 166 |
+
props.itz_permeability,
|
| 167 |
+
props.itz_diffusivity,
|
| 168 |
+
],
|
| 169 |
+
dtype=torch.float32,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def topology_statistics(graph: ConcreteGraph) -> tuple:
|
| 174 |
+
"""Return (agg_node_fraction, itz_edge_fraction, aa_edge_fraction)."""
|
| 175 |
+
|
| 176 |
+
agg_node_fraction = (graph.node_type == NODE_TYPE_AGGREGATE).float().mean().item()
|
| 177 |
+
itz_edge_fraction = (graph.edge_type == EDGE_TYPE_ITZ).float().mean().item()
|
| 178 |
+
aa_edge_fraction = (
|
| 179 |
+
(graph.edge_type == EDGE_TYPE_AGGREGATE_AGGREGATE).float().mean().item()
|
| 180 |
+
)
|
| 181 |
+
return agg_node_fraction, itz_edge_fraction, aa_edge_fraction
|
concrete_gnn/missing_features.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Missing-feature handling.
|
| 2 |
+
|
| 3 |
+
For every input feature we store a binary mask (1 = observed, 0 = missing).
|
| 4 |
+
Observed values are first standardized per channel (z-score using frozen stats
|
| 5 |
+
fit on the training set), so a small-magnitude but informative feature (e.g.
|
| 6 |
+
water/binder ratio ~0.4, admixture dosage ~0.01) is not swamped by a large one
|
| 7 |
+
(cement content ~500) in the input projection. Missing entries are then
|
| 8 |
+
replaced by a learnable per-channel embedding, observed entries pick up a
|
| 9 |
+
learnable per-channel bias, and the value-plus-mask pair is projected through a
|
| 10 |
+
small Linear + LayerNorm + SiLU stack. The mask is fed in both as a
|
| 11 |
+
multiplicative gate and as a side channel so the network can still learn whether
|
| 12 |
+
a value was measured, imputed, or structurally absent.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Optional, Tuple
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
from torch import nn
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def masked_feature_stats(
|
| 24 |
+
x: torch.Tensor, mask: torch.Tensor, eps: float = 1e-5
|
| 25 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 26 |
+
"""Per-feature (mean, std) over observed entries only (``mask > 0``).
|
| 27 |
+
|
| 28 |
+
Columns with no observed entries (or with near-constant values) return
|
| 29 |
+
``mean=0, std=1`` so standardization is a no-op there.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
mask = (mask > 0).to(x.dtype)
|
| 33 |
+
x = torch.nan_to_num(x, nan=0.0)
|
| 34 |
+
count = mask.sum(dim=0)
|
| 35 |
+
safe = count.clamp(min=1.0)
|
| 36 |
+
mean = (x * mask).sum(dim=0) / safe
|
| 37 |
+
var = (((x - mean) ** 2) * mask).sum(dim=0) / safe
|
| 38 |
+
std = var.clamp(min=0.0).sqrt()
|
| 39 |
+
has = count > 0
|
| 40 |
+
mean = torch.where(has, mean, torch.zeros_like(mean))
|
| 41 |
+
std = torch.where(has & (std > eps), std, torch.ones_like(std))
|
| 42 |
+
return mean, std
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def masked_feature_stats_by_type(
|
| 46 |
+
x: torch.Tensor,
|
| 47 |
+
mask: torch.Tensor,
|
| 48 |
+
type_index: torch.Tensor,
|
| 49 |
+
num_types: int,
|
| 50 |
+
eps: float = 1e-5,
|
| 51 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 52 |
+
"""Per-(type, feature) masked stats, shape ``(num_types, D)`` each.
|
| 53 |
+
|
| 54 |
+
Used where one encoder multiplexes several feature schemas into the same
|
| 55 |
+
columns (e.g. aggregate vs mortar nodes), so each row type is standardized
|
| 56 |
+
against its own statistics. Types with no rows fall back to ``mean=0, std=1``.
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
in_dim = x.size(1)
|
| 60 |
+
means = torch.zeros(num_types, in_dim, dtype=x.dtype)
|
| 61 |
+
stds = torch.ones(num_types, in_dim, dtype=x.dtype)
|
| 62 |
+
for t in range(num_types):
|
| 63 |
+
sel = type_index == t
|
| 64 |
+
if bool(sel.any()):
|
| 65 |
+
means[t], stds[t] = masked_feature_stats(x[sel], mask[sel], eps)
|
| 66 |
+
return means, stds
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class MissingFeatureEncoder(nn.Module):
|
| 70 |
+
"""Encode a possibly-missing feature vector into a dense hidden representation.
|
| 71 |
+
|
| 72 |
+
Parameters
|
| 73 |
+
----------
|
| 74 |
+
in_dim: int
|
| 75 |
+
Number of raw input channels.
|
| 76 |
+
out_dim: int
|
| 77 |
+
Output hidden dimension.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
def __init__(self, in_dim: int, out_dim: int, num_stat_groups: int = 1):
|
| 81 |
+
super().__init__()
|
| 82 |
+
self.in_dim = in_dim
|
| 83 |
+
self.out_dim = out_dim
|
| 84 |
+
self.num_stat_groups = num_stat_groups
|
| 85 |
+
|
| 86 |
+
self.missing_embedding = nn.Parameter(torch.zeros(in_dim))
|
| 87 |
+
nn.init.normal_(self.missing_embedding, std=0.02)
|
| 88 |
+
self.observed_bias = nn.Parameter(torch.zeros(in_dim))
|
| 89 |
+
|
| 90 |
+
# Frozen per-feature input standardization (z-score). Default identity;
|
| 91 |
+
# call ``set_feature_stats`` with training-set stats to activate. Stored
|
| 92 |
+
# as buffers (shape ``(num_stat_groups, in_dim)``) so they travel with
|
| 93 |
+
# .to(device) and persist in checkpoints. ``num_stat_groups`` > 1 lets one
|
| 94 |
+
# encoder hold per-node-type / per-edge-type stats, selected per row via a
|
| 95 |
+
# ``type_index`` in ``forward`` (the columns mean different features for
|
| 96 |
+
# different types, so a single shared z-score would blend them).
|
| 97 |
+
self.register_buffer("feat_mean", torch.zeros(num_stat_groups, in_dim))
|
| 98 |
+
self.register_buffer("feat_std", torch.ones(num_stat_groups, in_dim))
|
| 99 |
+
|
| 100 |
+
self.proj = nn.Sequential(
|
| 101 |
+
nn.Linear(in_dim * 2, out_dim),
|
| 102 |
+
nn.LayerNorm(out_dim),
|
| 103 |
+
nn.SiLU(),
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
@torch.no_grad()
|
| 107 |
+
def set_feature_stats(
|
| 108 |
+
self, mean: torch.Tensor, std: torch.Tensor, eps: float = 1e-5
|
| 109 |
+
) -> None:
|
| 110 |
+
"""Freeze standardization stats (from the train split).
|
| 111 |
+
|
| 112 |
+
``mean`` / ``std`` are ``(in_dim,)`` for a single group or
|
| 113 |
+
``(num_stat_groups, in_dim)`` for per-type stats.
|
| 114 |
+
"""
|
| 115 |
+
|
| 116 |
+
mean = torch.as_tensor(mean, dtype=self.feat_mean.dtype, device=self.feat_mean.device)
|
| 117 |
+
std = torch.as_tensor(std, dtype=self.feat_std.dtype, device=self.feat_std.device)
|
| 118 |
+
if mean.dim() == 1:
|
| 119 |
+
mean = mean.unsqueeze(0)
|
| 120 |
+
if std.dim() == 1:
|
| 121 |
+
std = std.unsqueeze(0)
|
| 122 |
+
std = torch.where(std > eps, std, torch.ones_like(std))
|
| 123 |
+
self.feat_mean.copy_(mean.expand_as(self.feat_mean))
|
| 124 |
+
self.feat_std.copy_(std.expand_as(self.feat_std))
|
| 125 |
+
|
| 126 |
+
def forward(
|
| 127 |
+
self,
|
| 128 |
+
x: torch.Tensor,
|
| 129 |
+
mask: Optional[torch.Tensor] = None,
|
| 130 |
+
type_index: Optional[torch.Tensor] = None,
|
| 131 |
+
) -> torch.Tensor:
|
| 132 |
+
if mask is None:
|
| 133 |
+
mask = torch.ones_like(x)
|
| 134 |
+
x = torch.nan_to_num(x, nan=0.0)
|
| 135 |
+
if type_index is None:
|
| 136 |
+
mean, std = self.feat_mean[0], self.feat_std[0]
|
| 137 |
+
else:
|
| 138 |
+
mean = self.feat_mean.index_select(0, type_index)
|
| 139 |
+
std = self.feat_std.index_select(0, type_index)
|
| 140 |
+
x = (x - mean) / std
|
| 141 |
+
imputed = x * mask + self.missing_embedding * (1.0 - mask)
|
| 142 |
+
imputed = imputed + self.observed_bias * mask
|
| 143 |
+
joined = torch.cat([imputed, mask], dim=-1)
|
| 144 |
+
return self.proj(joined)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def apply_random_missingness(
|
| 148 |
+
x: torch.Tensor,
|
| 149 |
+
rate: float = 0.15,
|
| 150 |
+
generator: Optional[torch.Generator] = None,
|
| 151 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 152 |
+
"""Randomly drop entries of ``x`` and return ``(x_masked, mask)``."""
|
| 153 |
+
|
| 154 |
+
if generator is None:
|
| 155 |
+
mask = (torch.rand_like(x) > rate).float()
|
| 156 |
+
else:
|
| 157 |
+
rnd = torch.rand(x.shape, generator=generator, device=x.device)
|
| 158 |
+
mask = (rnd > rate).float()
|
| 159 |
+
return x * mask, mask
|
concrete_gnn/models/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model package exports."""
|
| 2 |
+
|
| 3 |
+
from .concrete_gnn import ConcreteMesoGNN
|
| 4 |
+
from .hierarchical import IntegratedMultiscaleModel, TabularANN
|
| 5 |
+
from .itz_model import ITZSubModel
|
| 6 |
+
from .layers import HAS_PYG, global_add_pool, global_mean_pool, make_gine_conv, make_nn_conv
|
| 7 |
+
from .mortar_gnn import MortarSubModel
|
| 8 |
+
from .outputs import (
|
| 9 |
+
transform_concrete_outputs,
|
| 10 |
+
transform_itz_outputs,
|
| 11 |
+
transform_mortar_outputs,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
"ConcreteMesoGNN",
|
| 16 |
+
"HAS_PYG",
|
| 17 |
+
"ITZSubModel",
|
| 18 |
+
"IntegratedMultiscaleModel",
|
| 19 |
+
"MortarSubModel",
|
| 20 |
+
"TabularANN",
|
| 21 |
+
"global_add_pool",
|
| 22 |
+
"global_mean_pool",
|
| 23 |
+
"make_gine_conv",
|
| 24 |
+
"make_nn_conv",
|
| 25 |
+
"transform_concrete_outputs",
|
| 26 |
+
"transform_itz_outputs",
|
| 27 |
+
"transform_mortar_outputs",
|
| 28 |
+
]
|
concrete_gnn/models/concrete_gnn.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mesoscale concrete GNN.
|
| 2 |
+
|
| 3 |
+
Edge-conditioned message passing on the concrete RVE graph. Each layer uses
|
| 4 |
+
``NNConv`` (PyG when available, otherwise a native fallback) to let the edge
|
| 5 |
+
attributes generate per-edge weight matrices - the right inductive bias when
|
| 6 |
+
ITZ vs. mortar-mortar vs. aggregate-aggregate interactions are physically
|
| 7 |
+
distinct. Node-type and edge-type embeddings carry the discrete semantics
|
| 8 |
+
that the raw feature one-hots cannot fully express.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from torch import Tensor, nn
|
| 15 |
+
|
| 16 |
+
from ..missing_features import MissingFeatureEncoder
|
| 17 |
+
from ..schema import DEFAULT_SCHEMA, SchemaSpec
|
| 18 |
+
from .layers import global_mean_pool, global_min_pool, make_nn_conv
|
| 19 |
+
from .outputs import transform_concrete_outputs
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ConcreteMesoGNN(nn.Module):
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
schema: SchemaSpec = DEFAULT_SCHEMA,
|
| 26 |
+
hidden_dim: int = 96,
|
| 27 |
+
num_layers: int = 2, # see receptive-field ablation; depth >2 gives no gain
|
| 28 |
+
pool: str = "mean", # graph readout: "mean" or "min" (weakest-link)
|
| 29 |
+
):
|
| 30 |
+
super().__init__()
|
| 31 |
+
self.schema = schema
|
| 32 |
+
self.hidden_dim = hidden_dim
|
| 33 |
+
if pool not in ("mean", "min"):
|
| 34 |
+
raise ValueError(f"pool must be 'mean' or 'min', got {pool!r}")
|
| 35 |
+
self.pool = pool
|
| 36 |
+
|
| 37 |
+
self.node_encoder = MissingFeatureEncoder(
|
| 38 |
+
schema.node_pad_dim, hidden_dim, num_stat_groups=schema.num_node_types
|
| 39 |
+
)
|
| 40 |
+
self.node_type_embed = nn.Embedding(schema.num_node_types, hidden_dim)
|
| 41 |
+
|
| 42 |
+
self.edge_encoder = MissingFeatureEncoder(
|
| 43 |
+
schema.edge_pad_dim, hidden_dim, num_stat_groups=schema.num_edge_types
|
| 44 |
+
)
|
| 45 |
+
self.edge_type_embed = nn.Embedding(schema.num_edge_types, hidden_dim)
|
| 46 |
+
|
| 47 |
+
self.global_encoder = MissingFeatureEncoder(schema.global_dim, hidden_dim)
|
| 48 |
+
|
| 49 |
+
self.layers = nn.ModuleList()
|
| 50 |
+
self.norms = nn.ModuleList()
|
| 51 |
+
for _ in range(num_layers):
|
| 52 |
+
edge_nn = nn.Sequential(
|
| 53 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 54 |
+
nn.SiLU(),
|
| 55 |
+
nn.Linear(hidden_dim, hidden_dim * hidden_dim),
|
| 56 |
+
)
|
| 57 |
+
self.layers.append(
|
| 58 |
+
make_nn_conv(hidden_dim, hidden_dim, edge_nn, aggr="mean")
|
| 59 |
+
)
|
| 60 |
+
self.norms.append(nn.LayerNorm(hidden_dim))
|
| 61 |
+
|
| 62 |
+
self.feature_dim = hidden_dim * 2
|
| 63 |
+
self.head = nn.Sequential(
|
| 64 |
+
nn.Linear(self.feature_dim, hidden_dim),
|
| 65 |
+
nn.SiLU(),
|
| 66 |
+
nn.Linear(hidden_dim, len(schema.concrete_targets)),
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def forward(
|
| 70 |
+
self,
|
| 71 |
+
x: Tensor,
|
| 72 |
+
x_mask: Tensor,
|
| 73 |
+
edge_index: Tensor,
|
| 74 |
+
edge_attr: Tensor,
|
| 75 |
+
edge_attr_mask: Tensor,
|
| 76 |
+
node_type: Tensor,
|
| 77 |
+
edge_type: Tensor,
|
| 78 |
+
global_features: Tensor,
|
| 79 |
+
global_mask: Tensor,
|
| 80 |
+
batch: Tensor,
|
| 81 |
+
num_graphs: int,
|
| 82 |
+
return_features: bool = False,
|
| 83 |
+
):
|
| 84 |
+
h = self.node_encoder(x, x_mask, node_type) + self.node_type_embed(node_type)
|
| 85 |
+
e = self.edge_encoder(edge_attr, edge_attr_mask, edge_type) + self.edge_type_embed(edge_type)
|
| 86 |
+
|
| 87 |
+
for conv, norm in zip(self.layers, self.norms):
|
| 88 |
+
h_new = conv(h, edge_index, e)
|
| 89 |
+
h = norm(h + h_new)
|
| 90 |
+
h = torch.nn.functional.silu(h)
|
| 91 |
+
|
| 92 |
+
pool_fn = global_min_pool if self.pool == "min" else global_mean_pool
|
| 93 |
+
pooled = pool_fn(h, batch, num_graphs=num_graphs)
|
| 94 |
+
g = self.global_encoder(global_features, global_mask)
|
| 95 |
+
features = torch.cat([pooled, g], dim=-1)
|
| 96 |
+
raw = self.head(features)
|
| 97 |
+
concrete_pred = transform_concrete_outputs(raw)
|
| 98 |
+
if return_features:
|
| 99 |
+
return concrete_pred, features
|
| 100 |
+
return concrete_pred
|
concrete_gnn/models/hierarchical.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Integrated multiscale model + tabular ANN baseline.
|
| 2 |
+
|
| 3 |
+
The hierarchical model wires the mortar GNN and the ITZ MLP into the
|
| 4 |
+
mesoscale GNN via *differentiable* feature injection: mortar predictions
|
| 5 |
+
overwrite the mortar-node feature slots, ITZ predictions overwrite the ITZ
|
| 6 |
+
edge feature slots, and gradients flow through both sub-models from the
|
| 7 |
+
concrete-level loss. Sub-model targets, when available, are exposed via
|
| 8 |
+
``mortar_pred`` and ``itz_pred`` so the trainer can attach auxiliary losses.
|
| 9 |
+
|
| 10 |
+
``TabularANN`` is a structure-blind reference baseline: a 4-layer MLP over
|
| 11 |
+
the raw mix-design columns from the CSV. Any test-set gap between the
|
| 12 |
+
hierarchical model and this baseline is attributable to structure.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Dict, Optional, Tuple
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
from torch import Tensor, nn
|
| 21 |
+
|
| 22 |
+
from ..missing_features import MissingFeatureEncoder
|
| 23 |
+
from ..schema import (
|
| 24 |
+
DEFAULT_SCHEMA,
|
| 25 |
+
EDGE_TYPE_ITZ,
|
| 26 |
+
NODE_TYPE_AGGREGATE,
|
| 27 |
+
NODE_TYPE_MORTAR,
|
| 28 |
+
SchemaSpec,
|
| 29 |
+
)
|
| 30 |
+
from .concrete_gnn import ConcreteMesoGNN
|
| 31 |
+
from .itz_model import ITZSubModel
|
| 32 |
+
from .mortar_gnn import MortarSubModel
|
| 33 |
+
from .outputs import transform_concrete_outputs
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _slot_indices(target_names, feature_names):
|
| 37 |
+
return [feature_names.index(n) for n in target_names if n in feature_names]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _mortar_slot_indices(schema: SchemaSpec):
|
| 41 |
+
return _slot_indices(schema.mortar_targets, schema.mortar)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _itz_slot_indices(schema: SchemaSpec):
|
| 45 |
+
return _slot_indices(schema.itz_targets, schema.itz)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def inject_mortar(
|
| 49 |
+
x: Tensor,
|
| 50 |
+
x_mask: Tensor,
|
| 51 |
+
mortar_pred: Tensor,
|
| 52 |
+
node_type: Tensor,
|
| 53 |
+
batch: Tensor,
|
| 54 |
+
slot_indices: list,
|
| 55 |
+
) -> Tuple[Tensor, Tensor]:
|
| 56 |
+
"""Write mortar predictions into mortar-node feature slots, keeping gradients."""
|
| 57 |
+
|
| 58 |
+
mortar_nodes = (node_type == NODE_TYPE_MORTAR).to(x.dtype)
|
| 59 |
+
pred_per_node = mortar_pred[batch]
|
| 60 |
+
injected = torch.zeros_like(x)
|
| 61 |
+
inj_mask = torch.zeros_like(x)
|
| 62 |
+
for k, idx in enumerate(slot_indices):
|
| 63 |
+
injected[:, idx] = pred_per_node[:, k] * mortar_nodes
|
| 64 |
+
inj_mask[:, idx] = mortar_nodes
|
| 65 |
+
x_new = x * (1.0 - inj_mask) + injected
|
| 66 |
+
x_mask_new = torch.maximum(x_mask, inj_mask)
|
| 67 |
+
return x_new, x_mask_new
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def inject_itz(
|
| 71 |
+
edge_attr: Tensor,
|
| 72 |
+
edge_attr_mask: Tensor,
|
| 73 |
+
itz_pred: Tensor,
|
| 74 |
+
itz_edge_mask: Tensor,
|
| 75 |
+
slot_indices: list,
|
| 76 |
+
) -> Tuple[Tensor, Tensor]:
|
| 77 |
+
"""Write ITZ predictions into ITZ-edge feature slots, keeping gradients."""
|
| 78 |
+
|
| 79 |
+
itz_idx = itz_edge_mask.nonzero(as_tuple=True)[0]
|
| 80 |
+
injected = torch.zeros_like(edge_attr)
|
| 81 |
+
inj_mask = torch.zeros_like(edge_attr)
|
| 82 |
+
for k, idx in enumerate(slot_indices):
|
| 83 |
+
injected[itz_idx, idx] = itz_pred[:, k]
|
| 84 |
+
inj_mask[itz_idx, idx] = 1.0
|
| 85 |
+
edge_new = edge_attr * (1.0 - inj_mask) + injected
|
| 86 |
+
edge_mask_new = torch.maximum(edge_attr_mask, inj_mask)
|
| 87 |
+
return edge_new, edge_mask_new
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def build_itz_inputs(
|
| 91 |
+
edge_index: Tensor,
|
| 92 |
+
edge_type: Tensor,
|
| 93 |
+
node_type: Tensor,
|
| 94 |
+
x: Tensor,
|
| 95 |
+
pos: Tensor,
|
| 96 |
+
batch_per_node: Tensor,
|
| 97 |
+
mix_itz_per_graph: Tensor,
|
| 98 |
+
schema: SchemaSpec,
|
| 99 |
+
input_dim: int,
|
| 100 |
+
) -> Tuple[Tensor, Tensor, Tensor, Tensor]:
|
| 101 |
+
"""Assemble ITZ sub-model inputs for every ITZ edge."""
|
| 102 |
+
|
| 103 |
+
itz_edge_mask = edge_type == EDGE_TYPE_ITZ
|
| 104 |
+
src_all = edge_index[0]
|
| 105 |
+
edge_graph = batch_per_node[src_all]
|
| 106 |
+
itz_src = edge_index[0][itz_edge_mask]
|
| 107 |
+
itz_dst = edge_index[1][itz_edge_mask]
|
| 108 |
+
itz_graph = edge_graph[itz_edge_mask]
|
| 109 |
+
n_itz = int(itz_edge_mask.sum().item())
|
| 110 |
+
|
| 111 |
+
features = torch.zeros(n_itz, input_dim, device=x.device, dtype=x.dtype)
|
| 112 |
+
masks = torch.zeros_like(features)
|
| 113 |
+
if n_itz == 0:
|
| 114 |
+
return features, masks, itz_edge_mask, itz_graph
|
| 115 |
+
|
| 116 |
+
mix_dim = mix_itz_per_graph.size(1)
|
| 117 |
+
features[:, :mix_dim] = mix_itz_per_graph[itz_graph]
|
| 118 |
+
|
| 119 |
+
# Geometric distance between the two endpoints (positions are in mm).
|
| 120 |
+
edge_dist = torch.linalg.norm(pos[itz_src] - pos[itz_dst], dim=-1)
|
| 121 |
+
features[:, mix_dim] = edge_dist
|
| 122 |
+
|
| 123 |
+
# Aggregate-side surface texture (read from the aggregate endpoint).
|
| 124 |
+
src_is_agg = node_type[itz_src] == NODE_TYPE_AGGREGATE
|
| 125 |
+
agg_nodes = torch.where(src_is_agg, itz_src, itz_dst)
|
| 126 |
+
surface_texture_col = schema.aggregate.index("surface_texture")
|
| 127 |
+
features[:, mix_dim + 1] = x[agg_nodes, surface_texture_col]
|
| 128 |
+
|
| 129 |
+
masks[:, : mix_dim + 2] = 1.0
|
| 130 |
+
return features, masks, itz_edge_mask, itz_graph
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def aggregate_itz_per_graph(
|
| 134 |
+
itz_pred: Tensor, itz_graph: Tensor, num_graphs: int
|
| 135 |
+
) -> Tensor:
|
| 136 |
+
"""Mean ITZ prediction per concrete graph, shape ``(num_graphs, n_itz_targets)``.
|
| 137 |
+
|
| 138 |
+
Falls back to zero for graphs that contain no ITZ edges (empty RVE).
|
| 139 |
+
"""
|
| 140 |
+
|
| 141 |
+
if itz_pred.numel() == 0:
|
| 142 |
+
return torch.zeros(
|
| 143 |
+
num_graphs, itz_pred.size(-1) if itz_pred.dim() == 2 else 0,
|
| 144 |
+
device=itz_pred.device, dtype=itz_pred.dtype,
|
| 145 |
+
)
|
| 146 |
+
n_targets = itz_pred.size(-1)
|
| 147 |
+
out = torch.zeros(num_graphs, n_targets, device=itz_pred.device, dtype=itz_pred.dtype)
|
| 148 |
+
counts = torch.zeros(num_graphs, device=itz_pred.device, dtype=itz_pred.dtype)
|
| 149 |
+
out.index_add_(0, itz_graph, itz_pred)
|
| 150 |
+
counts.index_add_(0, itz_graph, torch.ones_like(itz_graph, dtype=itz_pred.dtype))
|
| 151 |
+
return out / counts.clamp(min=1.0).unsqueeze(-1)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _scatter_mean_1d(src: Tensor, index: Tensor, num_graphs: int) -> Tensor:
|
| 155 |
+
out = torch.zeros(num_graphs, device=src.device, dtype=src.dtype)
|
| 156 |
+
cnt = torch.zeros(num_graphs, device=src.device, dtype=src.dtype)
|
| 157 |
+
out.index_add_(0, index, src)
|
| 158 |
+
cnt.index_add_(0, index, torch.ones_like(src))
|
| 159 |
+
return out / cnt.clamp(min=1.0)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _scatter_amin_1d(src: Tensor, index: Tensor, num_graphs: int) -> Tensor:
|
| 163 |
+
base = src.new_full((num_graphs,), float("inf"))
|
| 164 |
+
out = base.scatter_reduce(0, index, src, reduce="amin", include_self=True)
|
| 165 |
+
return torch.where(torch.isinf(out), torch.zeros_like(out), out)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _scatter_amin_2d(src: Tensor, index: Tensor, num_graphs: int) -> Tensor:
|
| 169 |
+
base = src.new_full((num_graphs, src.size(-1)), float("inf"))
|
| 170 |
+
out = base.scatter_reduce(
|
| 171 |
+
0, index.unsqueeze(-1).expand_as(src), src, reduce="amin", include_self=True
|
| 172 |
+
)
|
| 173 |
+
return torch.where(torch.isinf(out), torch.zeros_like(out), out)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def build_itz_summary(
|
| 177 |
+
itz_pred: Tensor,
|
| 178 |
+
itz_graph: Tensor,
|
| 179 |
+
edge_index: Tensor,
|
| 180 |
+
edge_type: Tensor,
|
| 181 |
+
pos: Tensor,
|
| 182 |
+
node_type: Tensor,
|
| 183 |
+
x: Tensor,
|
| 184 |
+
batch_per_node: Tensor,
|
| 185 |
+
num_graphs: int,
|
| 186 |
+
schema: SchemaSpec,
|
| 187 |
+
) -> Tensor:
|
| 188 |
+
"""Per-graph ITZ + interface descriptor summary for the eta_ITZ head.
|
| 189 |
+
|
| 190 |
+
Columns: [mean ITZ targets (6), min ITZ targets (6),
|
| 191 |
+
aggregate-mortar contact density, mean ITZ edge distance,
|
| 192 |
+
min ITZ edge distance, mean aggregate surface texture].
|
| 193 |
+
"""
|
| 194 |
+
|
| 195 |
+
n_itz_targets = len(schema.itz_targets)
|
| 196 |
+
itz_mean = aggregate_itz_per_graph(itz_pred, itz_graph, num_graphs)
|
| 197 |
+
if itz_pred.numel() > 0:
|
| 198 |
+
itz_min = _scatter_amin_2d(itz_pred, itz_graph, num_graphs)
|
| 199 |
+
else:
|
| 200 |
+
itz_min = torch.zeros(num_graphs, n_itz_targets, device=x.device, dtype=x.dtype)
|
| 201 |
+
|
| 202 |
+
# ITZ edge geometry (distance between the two endpoints of each ITZ edge).
|
| 203 |
+
itz_edge_mask = edge_type == EDGE_TYPE_ITZ
|
| 204 |
+
isrc = edge_index[0][itz_edge_mask]
|
| 205 |
+
idst = edge_index[1][itz_edge_mask]
|
| 206 |
+
if isrc.numel() > 0:
|
| 207 |
+
dist = torch.linalg.norm(pos[isrc] - pos[idst], dim=-1)
|
| 208 |
+
dist_mean = _scatter_mean_1d(dist, itz_graph, num_graphs)
|
| 209 |
+
dist_min = _scatter_amin_1d(dist, itz_graph, num_graphs)
|
| 210 |
+
n_itz_per_graph = torch.zeros(num_graphs, device=x.device, dtype=x.dtype)
|
| 211 |
+
n_itz_per_graph.index_add_(0, itz_graph, torch.ones_like(dist))
|
| 212 |
+
else:
|
| 213 |
+
dist_mean = torch.zeros(num_graphs, device=x.device, dtype=x.dtype)
|
| 214 |
+
dist_min = torch.zeros(num_graphs, device=x.device, dtype=x.dtype)
|
| 215 |
+
n_itz_per_graph = torch.zeros(num_graphs, device=x.device, dtype=x.dtype)
|
| 216 |
+
|
| 217 |
+
# Aggregate-mortar contact density = ITZ edges per aggregate node.
|
| 218 |
+
agg_mask = node_type == NODE_TYPE_AGGREGATE
|
| 219 |
+
agg_graph = batch_per_node[agg_mask]
|
| 220 |
+
n_agg = torch.zeros(num_graphs, device=x.device, dtype=x.dtype)
|
| 221 |
+
n_agg.index_add_(0, agg_graph, torch.ones(int(agg_mask.sum()), device=x.device, dtype=x.dtype))
|
| 222 |
+
contact_density = n_itz_per_graph / n_agg.clamp(min=1.0)
|
| 223 |
+
|
| 224 |
+
# Mean aggregate surface texture per graph (an explicit interface descriptor).
|
| 225 |
+
st_col = schema.aggregate.index("surface_texture")
|
| 226 |
+
st_mean = _scatter_mean_1d(x[agg_mask, st_col], agg_graph, num_graphs)
|
| 227 |
+
|
| 228 |
+
return torch.cat(
|
| 229 |
+
[
|
| 230 |
+
itz_mean,
|
| 231 |
+
itz_min,
|
| 232 |
+
contact_density.unsqueeze(-1),
|
| 233 |
+
dist_mean.unsqueeze(-1),
|
| 234 |
+
dist_min.unsqueeze(-1),
|
| 235 |
+
st_mean.unsqueeze(-1),
|
| 236 |
+
],
|
| 237 |
+
dim=-1,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
class PhysicsStrengthHead(nn.Module):
|
| 242 |
+
"""Concrete compressive strength as a physics-informed combination.
|
| 243 |
+
|
| 244 |
+
``f_c = mortar_compressive * η_ITZ * η_matrix``
|
| 245 |
+
|
| 246 |
+
Both ``η_ITZ`` and ``η_matrix`` live in ``(0, 1]`` (sigmoids), so the
|
| 247 |
+
predicted concrete strength is bounded above by the predicted mortar
|
| 248 |
+
strength. This forces the mortar sub-model to carry signal — collapse to
|
| 249 |
+
a constant directly bounds concrete predictions and is no longer free.
|
| 250 |
+
|
| 251 |
+
* ``η_ITZ`` is derived from per-graph mean ITZ predictions + mesoscale
|
| 252 |
+
features (so weak ITZ pulls concrete strength down).
|
| 253 |
+
* ``η_matrix`` is derived from mesoscale graph features alone (captures
|
| 254 |
+
aggregate volume fraction, paste reactivity, geometry).
|
| 255 |
+
"""
|
| 256 |
+
|
| 257 |
+
def __init__(self, feature_dim: int, itz_dim: int, hidden_dim: int = 64):
|
| 258 |
+
super().__init__()
|
| 259 |
+
self.itz_eff = nn.Sequential(
|
| 260 |
+
nn.Linear(feature_dim + itz_dim, hidden_dim),
|
| 261 |
+
nn.SiLU(),
|
| 262 |
+
nn.Linear(hidden_dim, 1),
|
| 263 |
+
)
|
| 264 |
+
self.matrix_eff = nn.Sequential(
|
| 265 |
+
nn.Linear(feature_dim, hidden_dim),
|
| 266 |
+
nn.SiLU(),
|
| 267 |
+
nn.Linear(hidden_dim, 1),
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
def forward(
|
| 271 |
+
self,
|
| 272 |
+
mortar_compressive: Tensor,
|
| 273 |
+
itz_mean: Tensor,
|
| 274 |
+
graph_features: Tensor,
|
| 275 |
+
) -> Tensor:
|
| 276 |
+
"""Return per-graph concrete compressive strength, shape ``(G,)``."""
|
| 277 |
+
|
| 278 |
+
eta_itz = torch.sigmoid(
|
| 279 |
+
self.itz_eff(torch.cat([graph_features, itz_mean], dim=-1))
|
| 280 |
+
).squeeze(-1)
|
| 281 |
+
eta_matrix = torch.sigmoid(self.matrix_eff(graph_features)).squeeze(-1)
|
| 282 |
+
return mortar_compressive * eta_itz * eta_matrix
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
class MortarCapacityStrengthHead(nn.Module):
|
| 286 |
+
"""Concrete strength with mortar capacity central, ITZ/matrix as reducers.
|
| 287 |
+
|
| 288 |
+
``f_c = (mortar_compressive * eta_mortar) * eta_itz * eta_matrix``
|
| 289 |
+
|
| 290 |
+
* ``eta_mortar`` in (0, 1] is learned from the *other* mortar properties
|
| 291 |
+
(tensile / flexural / modulus / permeability / diffusivity / porosity /
|
| 292 |
+
creep), so compressive strength stays physically central while the rest
|
| 293 |
+
of the mortar state adjusts the usable capacity.
|
| 294 |
+
* ``eta_itz`` in (0, 1] is learned only from explicit ITZ + interface
|
| 295 |
+
descriptors (mean/min ITZ properties, contact density, edge-distance
|
| 296 |
+
stats, aggregate surface texture) - not the shared graph vector.
|
| 297 |
+
* ``eta_matrix`` in (0, 1] is learned from the mesoscale graph
|
| 298 |
+
representation (packing, topology, global curing context).
|
| 299 |
+
|
| 300 |
+
The point is to avoid feeding every factor the same latent graph vector.
|
| 301 |
+
"""
|
| 302 |
+
|
| 303 |
+
def __init__(
|
| 304 |
+
self,
|
| 305 |
+
feature_dim: int,
|
| 306 |
+
mortar_other_dim: int,
|
| 307 |
+
itz_summary_dim: int,
|
| 308 |
+
hidden_dim: int = 64,
|
| 309 |
+
):
|
| 310 |
+
super().__init__()
|
| 311 |
+
self.mortar_eff = nn.Sequential(
|
| 312 |
+
nn.Linear(mortar_other_dim, hidden_dim), nn.SiLU(), nn.Linear(hidden_dim, 1)
|
| 313 |
+
)
|
| 314 |
+
self.itz_eff = nn.Sequential(
|
| 315 |
+
nn.Linear(itz_summary_dim, hidden_dim), nn.SiLU(), nn.Linear(hidden_dim, 1)
|
| 316 |
+
)
|
| 317 |
+
self.matrix_eff = nn.Sequential(
|
| 318 |
+
nn.Linear(feature_dim, hidden_dim), nn.SiLU(), nn.Linear(hidden_dim, 1)
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
def forward(
|
| 322 |
+
self, mortar_pred: Tensor, itz_summary: Tensor, graph_features: Tensor
|
| 323 |
+
) -> Tensor:
|
| 324 |
+
eta_mortar = torch.sigmoid(self.mortar_eff(mortar_pred[:, 1:])).squeeze(-1)
|
| 325 |
+
capacity = mortar_pred[:, 0] * eta_mortar
|
| 326 |
+
eta_itz = torch.sigmoid(self.itz_eff(itz_summary)).squeeze(-1)
|
| 327 |
+
eta_matrix = torch.sigmoid(self.matrix_eff(graph_features)).squeeze(-1)
|
| 328 |
+
return capacity * eta_itz * eta_matrix
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
class IntegratedMultiscaleModel(nn.Module):
|
| 332 |
+
"""Mortar GNN + ITZ MLP + mesoscale GNN, trained jointly end-to-end."""
|
| 333 |
+
|
| 334 |
+
def __init__(
|
| 335 |
+
self,
|
| 336 |
+
schema: SchemaSpec = DEFAULT_SCHEMA,
|
| 337 |
+
hidden_dim: int = 96,
|
| 338 |
+
num_layers: int = 2, # ablation (1/3/5) showed depth >2 doesn't help this target
|
| 339 |
+
itz_input_dim: int = 24,
|
| 340 |
+
physics_aware_strength: bool = True, # legacy flag; default head is "mortar_capacity"
|
| 341 |
+
pool: str = "mean", # mesoscale graph readout: "mean" or "min"
|
| 342 |
+
strength_head_kind: Optional[str] = None, # "free" | "physics" | "mortar_capacity"
|
| 343 |
+
):
|
| 344 |
+
super().__init__()
|
| 345 |
+
self.schema = schema
|
| 346 |
+
self.itz_input_dim = itz_input_dim
|
| 347 |
+
self.mortar_slot_indices = _mortar_slot_indices(schema)
|
| 348 |
+
self.itz_slot_indices = _itz_slot_indices(schema)
|
| 349 |
+
# Back-compat: derive head kind from the legacy boolean when unset.
|
| 350 |
+
# Default head is the mortar-capacity head (keeps ITZ non-degenerate at
|
| 351 |
+
# no accuracy cost vs the plain physics head).
|
| 352 |
+
if strength_head_kind is None:
|
| 353 |
+
strength_head_kind = "mortar_capacity" if physics_aware_strength else "free"
|
| 354 |
+
self.strength_head_kind = strength_head_kind
|
| 355 |
+
self.physics_aware_strength = strength_head_kind != "free"
|
| 356 |
+
|
| 357 |
+
self.mortar = MortarSubModel(
|
| 358 |
+
schema=schema,
|
| 359 |
+
hidden_dim=64,
|
| 360 |
+
num_layers=2,
|
| 361 |
+
)
|
| 362 |
+
self.itz = ITZSubModel(
|
| 363 |
+
schema=schema,
|
| 364 |
+
hidden_dim=64,
|
| 365 |
+
input_dim=itz_input_dim,
|
| 366 |
+
)
|
| 367 |
+
self.mesoscale = ConcreteMesoGNN(
|
| 368 |
+
schema=schema,
|
| 369 |
+
hidden_dim=hidden_dim,
|
| 370 |
+
num_layers=num_layers,
|
| 371 |
+
pool=pool,
|
| 372 |
+
)
|
| 373 |
+
# ITZ summary dim: mean(n) + min(n) + [contact density, dist mean, dist min, surface texture]
|
| 374 |
+
itz_summary_dim = 2 * len(schema.itz_targets) + 4
|
| 375 |
+
if strength_head_kind == "physics":
|
| 376 |
+
self.strength_head = PhysicsStrengthHead(
|
| 377 |
+
feature_dim=self.mesoscale.feature_dim,
|
| 378 |
+
itz_dim=len(schema.itz_targets),
|
| 379 |
+
)
|
| 380 |
+
elif strength_head_kind == "mortar_capacity":
|
| 381 |
+
self.strength_head = MortarCapacityStrengthHead(
|
| 382 |
+
feature_dim=self.mesoscale.feature_dim,
|
| 383 |
+
mortar_other_dim=len(schema.mortar_targets) - 1,
|
| 384 |
+
itz_summary_dim=itz_summary_dim,
|
| 385 |
+
)
|
| 386 |
+
elif strength_head_kind == "free":
|
| 387 |
+
self.strength_head = None
|
| 388 |
+
else:
|
| 389 |
+
raise ValueError(f"unknown strength_head_kind {strength_head_kind!r}")
|
| 390 |
+
|
| 391 |
+
def forward(self, batch) -> Dict[str, Tensor]:
|
| 392 |
+
mortar_pred = self.mortar(
|
| 393 |
+
x=batch.m_x,
|
| 394 |
+
x_mask=batch.m_mask,
|
| 395 |
+
edge_index=batch.m_edge_index,
|
| 396 |
+
edge_attr=batch.m_edge_attr,
|
| 397 |
+
node_type=batch.m_node_type,
|
| 398 |
+
batch=batch.m_batch,
|
| 399 |
+
num_graphs=batch.num_graphs,
|
| 400 |
+
global_features=batch.mortar_global,
|
| 401 |
+
global_mask=batch.mortar_global_mask,
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
x, x_mask = inject_mortar(
|
| 405 |
+
batch.x,
|
| 406 |
+
batch.x_mask,
|
| 407 |
+
mortar_pred,
|
| 408 |
+
batch.node_type,
|
| 409 |
+
batch.batch,
|
| 410 |
+
slot_indices=self.mortar_slot_indices,
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
itz_feat, itz_mask, itz_edge_mask, itz_graph = build_itz_inputs(
|
| 414 |
+
edge_index=batch.edge_index,
|
| 415 |
+
edge_type=batch.edge_type,
|
| 416 |
+
node_type=batch.node_type,
|
| 417 |
+
x=batch.x,
|
| 418 |
+
pos=batch.pos,
|
| 419 |
+
batch_per_node=batch.batch,
|
| 420 |
+
mix_itz_per_graph=batch.mix_itz,
|
| 421 |
+
schema=self.schema,
|
| 422 |
+
input_dim=self.itz_input_dim,
|
| 423 |
+
)
|
| 424 |
+
if itz_feat.size(0) > 0:
|
| 425 |
+
itz_pred = self.itz(itz_feat, itz_mask)
|
| 426 |
+
edge_attr, edge_attr_mask = inject_itz(
|
| 427 |
+
batch.edge_attr,
|
| 428 |
+
batch.edge_attr_mask,
|
| 429 |
+
itz_pred,
|
| 430 |
+
itz_edge_mask,
|
| 431 |
+
slot_indices=self.itz_slot_indices,
|
| 432 |
+
)
|
| 433 |
+
else:
|
| 434 |
+
itz_pred = torch.zeros(
|
| 435 |
+
0, len(self.schema.itz_targets), device=batch.edge_attr.device
|
| 436 |
+
)
|
| 437 |
+
edge_attr, edge_attr_mask = batch.edge_attr, batch.edge_attr_mask
|
| 438 |
+
|
| 439 |
+
mesoscale_out = self.mesoscale(
|
| 440 |
+
x=x,
|
| 441 |
+
x_mask=x_mask,
|
| 442 |
+
edge_index=batch.edge_index,
|
| 443 |
+
edge_attr=edge_attr,
|
| 444 |
+
edge_attr_mask=edge_attr_mask,
|
| 445 |
+
node_type=batch.node_type,
|
| 446 |
+
edge_type=batch.edge_type,
|
| 447 |
+
global_features=batch.global_features,
|
| 448 |
+
global_mask=batch.global_mask,
|
| 449 |
+
batch=batch.batch,
|
| 450 |
+
num_graphs=batch.num_graphs,
|
| 451 |
+
return_features=self.strength_head is not None,
|
| 452 |
+
)
|
| 453 |
+
if self.strength_head_kind == "physics":
|
| 454 |
+
concrete_pred, graph_features = mesoscale_out
|
| 455 |
+
itz_mean = aggregate_itz_per_graph(itz_pred, itz_graph, batch.num_graphs)
|
| 456 |
+
strength = self.strength_head(
|
| 457 |
+
mortar_compressive=mortar_pred[:, 0],
|
| 458 |
+
itz_mean=itz_mean,
|
| 459 |
+
graph_features=graph_features,
|
| 460 |
+
)
|
| 461 |
+
concrete_pred = concrete_pred.clone()
|
| 462 |
+
concrete_pred[:, 0] = strength
|
| 463 |
+
elif self.strength_head_kind == "mortar_capacity":
|
| 464 |
+
concrete_pred, graph_features = mesoscale_out
|
| 465 |
+
itz_summary = build_itz_summary(
|
| 466 |
+
itz_pred,
|
| 467 |
+
itz_graph,
|
| 468 |
+
batch.edge_index,
|
| 469 |
+
batch.edge_type,
|
| 470 |
+
batch.pos,
|
| 471 |
+
batch.node_type,
|
| 472 |
+
batch.x,
|
| 473 |
+
batch.batch,
|
| 474 |
+
batch.num_graphs,
|
| 475 |
+
self.schema,
|
| 476 |
+
)
|
| 477 |
+
strength = self.strength_head(mortar_pred, itz_summary, graph_features)
|
| 478 |
+
concrete_pred = concrete_pred.clone()
|
| 479 |
+
concrete_pred[:, 0] = strength
|
| 480 |
+
else:
|
| 481 |
+
concrete_pred = mesoscale_out
|
| 482 |
+
return {
|
| 483 |
+
"concrete_pred": concrete_pred,
|
| 484 |
+
"mortar_pred": mortar_pred,
|
| 485 |
+
"itz_pred": itz_pred,
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
class TabularANN(nn.Module):
|
| 490 |
+
"""Tabular ANN baseline over the raw mix-design columns from the CSV.
|
| 491 |
+
|
| 492 |
+
Consumes the full ``batch.tabular_mix`` vector directly (cement, slag, fly
|
| 493 |
+
ash, SCMs, water, superplasticizer, coarse/fine aggregate, fibre fields,
|
| 494 |
+
and age). A BatchNorm at the input handles the wildly different scales of
|
| 495 |
+
these raw columns (kg/m^3 vs. days vs. dosage).
|
| 496 |
+
"""
|
| 497 |
+
|
| 498 |
+
def __init__(
|
| 499 |
+
self,
|
| 500 |
+
in_dim: int,
|
| 501 |
+
schema: SchemaSpec = DEFAULT_SCHEMA,
|
| 502 |
+
hidden_dim: int = 96,
|
| 503 |
+
):
|
| 504 |
+
super().__init__()
|
| 505 |
+
self.schema = schema
|
| 506 |
+
self.in_dim = in_dim
|
| 507 |
+
self.input_bn = nn.BatchNorm1d(in_dim)
|
| 508 |
+
self.net = nn.Sequential(
|
| 509 |
+
nn.Linear(in_dim, hidden_dim),
|
| 510 |
+
nn.SiLU(),
|
| 511 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 512 |
+
nn.SiLU(),
|
| 513 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 514 |
+
nn.SiLU(),
|
| 515 |
+
nn.Linear(hidden_dim, len(schema.concrete_targets)),
|
| 516 |
+
)
|
| 517 |
+
|
| 518 |
+
def forward(self, batch) -> Dict[str, Tensor]:
|
| 519 |
+
x = self.input_bn(batch.tabular_mix)
|
| 520 |
+
raw = self.net(x)
|
| 521 |
+
return {"concrete_pred": transform_concrete_outputs(raw)}
|
concrete_gnn/models/itz_model.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ITZ sub-model.
|
| 2 |
+
|
| 3 |
+
The ITZ is treated as a modified cement paste whose properties depend on
|
| 4 |
+
cement chemistry, SCM chemistry, water-to-binder ratio, admixture dosage,
|
| 5 |
+
aggregate surface descriptors and curing condition. Inputs are tabular per
|
| 6 |
+
aggregate-mortar pair, so a small MLP with a missing-feature encoder and a
|
| 7 |
+
bounded output transform is the natural fit.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
from torch import Tensor, nn
|
| 16 |
+
|
| 17 |
+
from ..missing_features import MissingFeatureEncoder
|
| 18 |
+
from ..schema import DEFAULT_SCHEMA, SchemaSpec
|
| 19 |
+
from .outputs import transform_itz_outputs
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ITZSubModel(nn.Module):
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
schema: SchemaSpec = DEFAULT_SCHEMA,
|
| 26 |
+
hidden_dim: int = 64,
|
| 27 |
+
input_dim: int = 24,
|
| 28 |
+
):
|
| 29 |
+
super().__init__()
|
| 30 |
+
self.schema = schema
|
| 31 |
+
self.input_dim = input_dim
|
| 32 |
+
self.encoder = MissingFeatureEncoder(input_dim, hidden_dim)
|
| 33 |
+
self.body = nn.Sequential(
|
| 34 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 35 |
+
nn.SiLU(),
|
| 36 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 37 |
+
nn.SiLU(),
|
| 38 |
+
)
|
| 39 |
+
self.head = nn.Linear(hidden_dim, len(schema.itz_targets))
|
| 40 |
+
|
| 41 |
+
def forward(self, x: Tensor, mask: Optional[Tensor] = None) -> Tensor:
|
| 42 |
+
h = self.encoder(x, mask)
|
| 43 |
+
h = self.body(h)
|
| 44 |
+
return transform_itz_outputs(self.head(h))
|
concrete_gnn/models/layers.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PyG-optional message-passing layers and pooling.
|
| 2 |
+
|
| 3 |
+
When ``torch_geometric`` is importable we expose its battle-tested
|
| 4 |
+
``GINEConv`` and ``NNConv`` operators (plus ``global_mean_pool`` and
|
| 5 |
+
``global_add_pool``). When PyG is not available we fall back to
|
| 6 |
+
mathematically equivalent native PyTorch implementations so the prototype
|
| 7 |
+
still runs end-to-end - useful when wheels are unavailable on the target
|
| 8 |
+
machine or for unit-testing in lightweight environments.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
from torch import Tensor, nn
|
| 17 |
+
|
| 18 |
+
try: # pragma: no cover - depends on local environment.
|
| 19 |
+
from torch_geometric.nn import (
|
| 20 |
+
GINEConv as _PygGINEConv,
|
| 21 |
+
NNConv as _PygNNConv,
|
| 22 |
+
global_add_pool as _pyg_add_pool,
|
| 23 |
+
global_mean_pool as _pyg_mean_pool,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
HAS_PYG = True
|
| 27 |
+
except Exception: # pragma: no cover - PyG missing
|
| 28 |
+
_PygGINEConv = None
|
| 29 |
+
_PygNNConv = None
|
| 30 |
+
_pyg_add_pool = None
|
| 31 |
+
_pyg_mean_pool = None
|
| 32 |
+
HAS_PYG = False
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Pooling
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def global_mean_pool(x: Tensor, batch: Tensor, num_graphs: Optional[int] = None) -> Tensor:
|
| 41 |
+
"""Mean-aggregate node embeddings into one row per graph."""
|
| 42 |
+
|
| 43 |
+
if HAS_PYG and num_graphs is None:
|
| 44 |
+
return _pyg_mean_pool(x, batch)
|
| 45 |
+
if num_graphs is None:
|
| 46 |
+
num_graphs = int(batch.max().item()) + 1
|
| 47 |
+
pooled = torch.zeros(num_graphs, x.size(-1), device=x.device, dtype=x.dtype)
|
| 48 |
+
pooled.index_add_(0, batch, x)
|
| 49 |
+
counts = torch.zeros(num_graphs, 1, device=x.device, dtype=x.dtype)
|
| 50 |
+
counts.index_add_(0, batch, torch.ones(x.size(0), 1, device=x.device, dtype=x.dtype))
|
| 51 |
+
return pooled / counts.clamp_min(1.0)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def global_add_pool(x: Tensor, batch: Tensor, num_graphs: Optional[int] = None) -> Tensor:
|
| 55 |
+
"""Sum-aggregate node embeddings into one row per graph."""
|
| 56 |
+
|
| 57 |
+
if HAS_PYG and num_graphs is None:
|
| 58 |
+
return _pyg_add_pool(x, batch)
|
| 59 |
+
if num_graphs is None:
|
| 60 |
+
num_graphs = int(batch.max().item()) + 1
|
| 61 |
+
pooled = torch.zeros(num_graphs, x.size(-1), device=x.device, dtype=x.dtype)
|
| 62 |
+
pooled.index_add_(0, batch, x)
|
| 63 |
+
return pooled
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def global_min_pool(x: Tensor, batch: Tensor, num_graphs: Optional[int] = None) -> Tensor:
|
| 67 |
+
"""Min-aggregate node embeddings into one row per graph (weakest-link readout).
|
| 68 |
+
|
| 69 |
+
Per-channel minimum over the nodes of each graph. Empty graphs (no nodes)
|
| 70 |
+
yield a zero row rather than +inf.
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
if num_graphs is None:
|
| 74 |
+
num_graphs = int(batch.max().item()) + 1
|
| 75 |
+
base = x.new_full((num_graphs, x.size(-1)), float("inf"))
|
| 76 |
+
idx = batch.unsqueeze(-1).expand_as(x)
|
| 77 |
+
out = base.scatter_reduce(0, idx, x, reduce="amin", include_self=True)
|
| 78 |
+
return torch.where(torch.isinf(out), torch.zeros_like(out), out)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# Native fallbacks
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class _NativeGINE(nn.Module):
|
| 87 |
+
"""Sum-aggregating edge-aware conv used when PyG is unavailable.
|
| 88 |
+
|
| 89 |
+
Approximates ``GINEConv`` semantics: messages are ReLU(x_j + e_ij), summed
|
| 90 |
+
over neighbours, then passed through the user-supplied MLP.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
def __init__(self, mlp: nn.Module, edge_dim: int, in_dim: int, train_eps: bool = True):
|
| 94 |
+
super().__init__()
|
| 95 |
+
self.mlp = mlp
|
| 96 |
+
self.eps = nn.Parameter(torch.zeros(1)) if train_eps else None
|
| 97 |
+
self.edge_align = nn.Linear(edge_dim, in_dim) if edge_dim != in_dim else nn.Identity()
|
| 98 |
+
|
| 99 |
+
def forward(self, x: Tensor, edge_index: Tensor, edge_attr: Tensor) -> Tensor:
|
| 100 |
+
src, dst = edge_index
|
| 101 |
+
e = self.edge_align(edge_attr)
|
| 102 |
+
messages = torch.relu(x[src] + e)
|
| 103 |
+
aggregated = torch.zeros_like(x)
|
| 104 |
+
aggregated.index_add_(0, dst, messages)
|
| 105 |
+
eps = self.eps if self.eps is not None else torch.zeros(1, device=x.device)
|
| 106 |
+
return self.mlp((1.0 + eps) * x + aggregated)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class _NativeNNConv(nn.Module):
|
| 110 |
+
"""Edge-conditioned conv used when PyG is unavailable.
|
| 111 |
+
|
| 112 |
+
Mirrors ``NNConv`` with mean aggregation: an edge MLP produces a
|
| 113 |
+
(out_dim*in_dim) weight matrix per edge, applied to the source node.
|
| 114 |
+
"""
|
| 115 |
+
|
| 116 |
+
def __init__(self, in_dim: int, out_dim: int, edge_nn: nn.Module, aggr: str = "mean"):
|
| 117 |
+
super().__init__()
|
| 118 |
+
assert aggr in ("mean", "sum"), "aggr must be 'mean' or 'sum'"
|
| 119 |
+
self.in_dim = in_dim
|
| 120 |
+
self.out_dim = out_dim
|
| 121 |
+
self.edge_nn = edge_nn
|
| 122 |
+
self.aggr = aggr
|
| 123 |
+
self.root = nn.Linear(in_dim, out_dim, bias=True)
|
| 124 |
+
|
| 125 |
+
def forward(self, x: Tensor, edge_index: Tensor, edge_attr: Tensor) -> Tensor:
|
| 126 |
+
src, dst = edge_index
|
| 127 |
+
weights = self.edge_nn(edge_attr).view(-1, self.out_dim, self.in_dim)
|
| 128 |
+
msg = torch.bmm(weights, x[src].unsqueeze(-1)).squeeze(-1)
|
| 129 |
+
out = torch.zeros(x.size(0), self.out_dim, device=x.device, dtype=x.dtype)
|
| 130 |
+
out.index_add_(0, dst, msg)
|
| 131 |
+
if self.aggr == "mean":
|
| 132 |
+
deg = torch.zeros(x.size(0), 1, device=x.device, dtype=x.dtype)
|
| 133 |
+
deg.index_add_(0, dst, torch.ones(msg.size(0), 1, device=x.device, dtype=x.dtype))
|
| 134 |
+
out = out / deg.clamp_min(1.0)
|
| 135 |
+
return out + self.root(x)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
# Public factories
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def make_gine_conv(mlp: nn.Module, edge_dim: int, in_dim: int) -> nn.Module:
|
| 144 |
+
"""``GINEConv`` from PyG when available, otherwise a sum-aggregating fallback."""
|
| 145 |
+
|
| 146 |
+
if HAS_PYG:
|
| 147 |
+
return _PygGINEConv(mlp, train_eps=True, edge_dim=edge_dim)
|
| 148 |
+
return _NativeGINE(mlp, edge_dim=edge_dim, in_dim=in_dim, train_eps=True)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def make_nn_conv(
|
| 152 |
+
in_dim: int,
|
| 153 |
+
out_dim: int,
|
| 154 |
+
edge_nn: nn.Module,
|
| 155 |
+
aggr: str = "mean",
|
| 156 |
+
) -> nn.Module:
|
| 157 |
+
"""``NNConv`` from PyG when available, otherwise an edge-conditioned fallback."""
|
| 158 |
+
|
| 159 |
+
if HAS_PYG:
|
| 160 |
+
return _PygNNConv(in_dim, out_dim, edge_nn, aggr=aggr)
|
| 161 |
+
return _NativeNNConv(in_dim, out_dim, edge_nn, aggr=aggr)
|
concrete_gnn/models/mortar_gnn.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Microscale mortar GNN.
|
| 2 |
+
|
| 3 |
+
The mortar sub-model consumes a small sand/paste micrograph and produces a
|
| 4 |
+
mortar property vector that is later injected into mortar nodes of the
|
| 5 |
+
mesoscale concrete graph. Message passing uses ``GINEConv`` (sum
|
| 6 |
+
aggregation), the minimum needed to count sand-sand contacts - a relational
|
| 7 |
+
quantity that drives mortar stiffness in the synthetic ground truth.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
from torch import Tensor, nn
|
| 14 |
+
|
| 15 |
+
from ..missing_features import MissingFeatureEncoder
|
| 16 |
+
from ..schema import DEFAULT_SCHEMA, SchemaSpec
|
| 17 |
+
from .layers import global_mean_pool, make_gine_conv
|
| 18 |
+
from .outputs import transform_mortar_outputs
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class MortarSubModel(nn.Module):
|
| 22 |
+
"""Sand/paste GNN that produces a mortar property vector per micrograph."""
|
| 23 |
+
|
| 24 |
+
def __init__(
|
| 25 |
+
self,
|
| 26 |
+
schema: SchemaSpec = DEFAULT_SCHEMA,
|
| 27 |
+
hidden_dim: int = 64,
|
| 28 |
+
num_layers: int = 2,
|
| 29 |
+
):
|
| 30 |
+
super().__init__()
|
| 31 |
+
self.schema = schema
|
| 32 |
+
|
| 33 |
+
self.node_encoder = MissingFeatureEncoder(
|
| 34 |
+
schema.mortar_node_pad_dim, hidden_dim, num_stat_groups=schema.num_mortar_node_types
|
| 35 |
+
)
|
| 36 |
+
self.type_embed = nn.Embedding(schema.num_mortar_node_types, hidden_dim)
|
| 37 |
+
|
| 38 |
+
self.edge_encoder = nn.Sequential(
|
| 39 |
+
nn.Linear(2, hidden_dim),
|
| 40 |
+
nn.LayerNorm(hidden_dim),
|
| 41 |
+
nn.SiLU(),
|
| 42 |
+
)
|
| 43 |
+
self.layers = nn.ModuleList(
|
| 44 |
+
[
|
| 45 |
+
make_gine_conv(
|
| 46 |
+
nn.Sequential(
|
| 47 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 48 |
+
nn.SiLU(),
|
| 49 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 50 |
+
),
|
| 51 |
+
edge_dim=hidden_dim,
|
| 52 |
+
in_dim=hidden_dim,
|
| 53 |
+
)
|
| 54 |
+
for _ in range(num_layers)
|
| 55 |
+
]
|
| 56 |
+
)
|
| 57 |
+
self.norms = nn.ModuleList(
|
| 58 |
+
[nn.LayerNorm(hidden_dim) for _ in range(num_layers)]
|
| 59 |
+
)
|
| 60 |
+
self.global_encoder = MissingFeatureEncoder(schema.mortar_global_dim, hidden_dim)
|
| 61 |
+
|
| 62 |
+
self.head = nn.Sequential(
|
| 63 |
+
nn.Linear(hidden_dim * 2, hidden_dim),
|
| 64 |
+
nn.SiLU(),
|
| 65 |
+
nn.Linear(hidden_dim, len(schema.mortar_targets)),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
def forward(
|
| 69 |
+
self,
|
| 70 |
+
x: Tensor,
|
| 71 |
+
x_mask: Tensor,
|
| 72 |
+
edge_index: Tensor,
|
| 73 |
+
edge_attr: Tensor,
|
| 74 |
+
node_type: Tensor,
|
| 75 |
+
batch: Tensor,
|
| 76 |
+
num_graphs: int,
|
| 77 |
+
global_features: Tensor,
|
| 78 |
+
global_mask: Tensor,
|
| 79 |
+
) -> Tensor:
|
| 80 |
+
h = self.node_encoder(x, x_mask, node_type) + self.type_embed(node_type)
|
| 81 |
+
e = self.edge_encoder(edge_attr)
|
| 82 |
+
for conv, norm in zip(self.layers, self.norms):
|
| 83 |
+
h_new = conv(h, edge_index, e)
|
| 84 |
+
h = norm(h + h_new)
|
| 85 |
+
h = torch.nn.functional.silu(h)
|
| 86 |
+
pooled = global_mean_pool(h, batch, num_graphs=num_graphs)
|
| 87 |
+
g = self.global_encoder(global_features, global_mask)
|
| 88 |
+
raw = self.head(torch.cat([pooled, g], dim=-1))
|
| 89 |
+
return transform_mortar_outputs(raw)
|
concrete_gnn/models/outputs.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Output transforms that constrain predictions to plausible engineering ranges.
|
| 2 |
+
|
| 3 |
+
Free linear heads are easy to optimise but tend to drift into nonphysical
|
| 4 |
+
territory (negative strengths, impossibly high moduli, etc.). Each transform
|
| 5 |
+
maps an unconstrained model output through a sigmoid scaled to a sensible
|
| 6 |
+
engineering interval, so predictions stay positive and bounded without
|
| 7 |
+
flattening the gradient where the data actually lives.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
from torch import Tensor
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def transform_mortar_outputs(raw: Tensor) -> Tensor:
|
| 17 |
+
"""Map mortar sub-model outputs to plausible engineering ranges.
|
| 18 |
+
|
| 19 |
+
Output order matches ``schema.MORTAR_TARGETS``:
|
| 20 |
+
(compressive, tensile, flexural, elastic_modulus, permeability,
|
| 21 |
+
diffusivity, porosity, creep_coefficient).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
compressive = 15.0 + 285.0 * torch.sigmoid(raw[..., 0:1])
|
| 25 |
+
tensile = 1.0 + 8.0 * torch.sigmoid(raw[..., 1:2])
|
| 26 |
+
flexural = 2.0 + 48.0 * torch.sigmoid(raw[..., 2:3])
|
| 27 |
+
elastic = 10.0 + 40.0 * torch.sigmoid(raw[..., 3:4])
|
| 28 |
+
permeability = 1.0e-13 + 1.0e-11 * torch.sigmoid(raw[..., 4:5])
|
| 29 |
+
diffusivity = 1.0e-12 + 1.0e-10 * torch.sigmoid(raw[..., 5:6])
|
| 30 |
+
porosity = 0.03 + 0.25 * torch.sigmoid(raw[..., 6:7])
|
| 31 |
+
creep = 0.5 + 3.0 * torch.sigmoid(raw[..., 7:8])
|
| 32 |
+
return torch.cat(
|
| 33 |
+
[compressive, tensile, flexural, elastic, permeability, diffusivity, porosity, creep],
|
| 34 |
+
dim=-1,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def transform_itz_outputs(raw: Tensor) -> Tensor:
|
| 39 |
+
"""Map ITZ sub-model outputs to plausible engineering ranges.
|
| 40 |
+
|
| 41 |
+
Output order matches ``schema.ITZ_TARGETS``:
|
| 42 |
+
(thickness_um, porosity, strength, elastic_modulus, permeability,
|
| 43 |
+
diffusivity).
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
thickness = 5.0 + 75.0 * torch.sigmoid(raw[..., 0:1])
|
| 47 |
+
porosity = 0.05 + 0.45 * torch.sigmoid(raw[..., 1:2])
|
| 48 |
+
strength = 5.0 + 65.0 * torch.sigmoid(raw[..., 2:3])
|
| 49 |
+
elastic = 5.0 + 35.0 * torch.sigmoid(raw[..., 3:4])
|
| 50 |
+
permeability = 1.0e-13 + 2.0e-11 * torch.sigmoid(raw[..., 4:5])
|
| 51 |
+
diffusivity = 1.0e-12 + 2.0e-10 * torch.sigmoid(raw[..., 5:6])
|
| 52 |
+
return torch.cat(
|
| 53 |
+
[thickness, porosity, strength, elastic, permeability, diffusivity],
|
| 54 |
+
dim=-1,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def transform_concrete_outputs(raw: Tensor) -> Tensor:
|
| 59 |
+
"""Map mesoscale concrete predictions to plausible engineering ranges.
|
| 60 |
+
|
| 61 |
+
Output order matches ``schema.CONCRETE_TARGETS``:
|
| 62 |
+
(compressive, tensile, flexural, elastic_modulus, permeability,
|
| 63 |
+
diffusivity).
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
compressive = 350.0 * torch.sigmoid(raw[..., 0:1])
|
| 67 |
+
tensile = 0.5 + 29.5 * torch.sigmoid(raw[..., 1:2])
|
| 68 |
+
flexural = 1.0 + 49.0 * torch.sigmoid(raw[..., 2:3])
|
| 69 |
+
elastic = 8.0 + 92.0 * torch.sigmoid(raw[..., 3:4])
|
| 70 |
+
permeability = 1.0e-13 + 3.0e-11 * torch.sigmoid(raw[..., 4:5])
|
| 71 |
+
diffusivity = 1.0e-12 + 3.0e-10 * torch.sigmoid(raw[..., 5:6])
|
| 72 |
+
return torch.cat(
|
| 73 |
+
[compressive, tensile, flexural, elastic, permeability, diffusivity],
|
| 74 |
+
dim=-1,
|
| 75 |
+
)
|
concrete_gnn/real_data.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utilities for training Hybrid models on real concrete mix datasets."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, List, Optional, Sequence, Union
|
| 10 |
+
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import torch
|
| 13 |
+
from torch import Tensor, nn
|
| 14 |
+
from torch.utils.data import DataLoader, Dataset
|
| 15 |
+
|
| 16 |
+
from .categoricals import (
|
| 17 |
+
CEMENT_TYPES,
|
| 18 |
+
CURING_REGIMES,
|
| 19 |
+
FIBRE_TYPES,
|
| 20 |
+
canonical_cement_type,
|
| 21 |
+
canonical_curing_regime,
|
| 22 |
+
canonical_fibre_type,
|
| 23 |
+
one_hot,
|
| 24 |
+
type_id,
|
| 25 |
+
)
|
| 26 |
+
from .data import ConcreteSample
|
| 27 |
+
from .graph_generator import (
|
| 28 |
+
MixDesign,
|
| 29 |
+
MortarMixDesign,
|
| 30 |
+
generate_concrete_graph,
|
| 31 |
+
generate_mortar_graph,
|
| 32 |
+
mortar_volume_fraction,
|
| 33 |
+
)
|
| 34 |
+
from .schema import DEFAULT_SCHEMA, SchemaSpec
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
COL_CEMENT = "Cement (component 1)(kg in a m^3 mixture)"
|
| 38 |
+
COL_SLAG = "Blast Furnace Slag (component 2)(kg in a m^3 mixture)"
|
| 39 |
+
COL_FLY_ASH = "Fly Ash (component 3)(kg in a m^3 mixture)"
|
| 40 |
+
COL_WATER = "Water (component 4)(kg in a m^3 mixture)"
|
| 41 |
+
COL_SUPERPLASTICIZER = "Superplasticizer (component 5)(kg in a m^3 mixture)"
|
| 42 |
+
COL_COARSE = "Coarse Aggregate (component 6)(kg in a m^3 mixture)"
|
| 43 |
+
COL_FINE = "Fine Aggregate (component 7)(kg in a m^3 mixture)"
|
| 44 |
+
COL_AGE = "Age (day)"
|
| 45 |
+
COL_STRENGTH = "Concrete compressive strength(MPa, megapascals) "
|
| 46 |
+
TARGET_NAME = "compressive_strength_MPa"
|
| 47 |
+
|
| 48 |
+
NORM_CEMENT = "cement_kg_m3"
|
| 49 |
+
NORM_SLAG = "slag_kg_m3"
|
| 50 |
+
NORM_FLY_ASH = "fly_ash_kg_m3"
|
| 51 |
+
NORM_SILICA_FUME = "silica_fume_kg_m3"
|
| 52 |
+
NORM_METAKAOLIN = "metakaolin_kg_m3"
|
| 53 |
+
NORM_LIMESTONE = "limestone_powder_kg_m3"
|
| 54 |
+
NORM_OTHER_SCM = "other_scm_kg_m3"
|
| 55 |
+
NORM_WATER = "water_kg_m3"
|
| 56 |
+
NORM_SUPERPLASTICIZER = "superplasticizer_kg_m3"
|
| 57 |
+
NORM_COARSE = "coarse_aggregate_kg_m3"
|
| 58 |
+
NORM_FINE = "fine_aggregate_kg_m3"
|
| 59 |
+
NORM_FIBRE_CONTENT = "fibre_content_kg_m3"
|
| 60 |
+
NORM_FIBRE_LENGTH = "fibre_length_mm"
|
| 61 |
+
NORM_FIBRE_DIAMETER = "fibre_diameter_mm"
|
| 62 |
+
NORM_FIBRE_TENSILE = "fibre_tensile_strength_mpa"
|
| 63 |
+
NORM_FIBRE_MODULUS = "fibre_modulus_gpa"
|
| 64 |
+
NORM_MAX_COARSE = "max_coarse_aggregate_size_mm"
|
| 65 |
+
NORM_MAX_FINE = "max_fine_aggregate_size_mm"
|
| 66 |
+
NORM_CURING_TEMP = "curing_temperature_c"
|
| 67 |
+
NORM_AGE = "age_days"
|
| 68 |
+
NORM_STRENGTH = "compressive_strength_mpa"
|
| 69 |
+
|
| 70 |
+
# Rich UHPC inputs (Data/prepare_uhpc_rich.py): per-mix cement & SCM oxide
|
| 71 |
+
# chemistry plus a few extra numerics. All maskable -> NaN/absent for sources
|
| 72 |
+
# that do not report them, in which case the synthetic OPC prior is used for the
|
| 73 |
+
# GNN paste slots and the tabular mask channel is set to 0.
|
| 74 |
+
NORM_CEMENT_CAO = "cement_CaO_pct"
|
| 75 |
+
NORM_CEMENT_SIO2 = "cement_SiO2_pct"
|
| 76 |
+
NORM_CEMENT_AL2O3 = "cement_Al2O3_pct"
|
| 77 |
+
NORM_CEMENT_FE2O3 = "cement_Fe2O3_pct"
|
| 78 |
+
NORM_CEMENT_MGO = "cement_MgO_pct"
|
| 79 |
+
NORM_CEMENT_SO3 = "cement_SO3_pct"
|
| 80 |
+
NORM_CEMENT_ALKALI = "cement_alkali_pct"
|
| 81 |
+
NORM_CEMENT_LOI = "cement_LOI_pct"
|
| 82 |
+
NORM_SCM_CAO = "scm_CaO_pct"
|
| 83 |
+
NORM_SCM_SIO2 = "scm_SiO2_pct"
|
| 84 |
+
NORM_SCM_AL2O3 = "scm_Al2O3_pct"
|
| 85 |
+
NORM_SCM_FE2O3 = "scm_Fe2O3_pct"
|
| 86 |
+
NORM_SCM_MGO = "scm_MgO_pct"
|
| 87 |
+
NORM_SCM_LOI = "scm_LOI_pct"
|
| 88 |
+
NORM_CEMENT_GRADE = "cement_grade_mpa"
|
| 89 |
+
NORM_CURING_HUMIDITY = "curing_humidity_pct"
|
| 90 |
+
NORM_SPECIMEN_SIZE = "specimen_size_mm"
|
| 91 |
+
|
| 92 |
+
# Canonical free-text categoricals (string columns; one-hot for the TabularANN).
|
| 93 |
+
NORM_CEMENT_TYPE = "cement_type_norm"
|
| 94 |
+
NORM_FIBRE_TYPE = "fibre_type_norm"
|
| 95 |
+
NORM_CURING_REGIME = "curing_regime_norm"
|
| 96 |
+
|
| 97 |
+
# Maskable rich columns fed (value + observed-mask channel) to the TabularANN.
|
| 98 |
+
RICH_TABULAR_COLUMNS = (
|
| 99 |
+
NORM_CEMENT_CAO,
|
| 100 |
+
NORM_CEMENT_SIO2,
|
| 101 |
+
NORM_CEMENT_AL2O3,
|
| 102 |
+
NORM_CEMENT_FE2O3,
|
| 103 |
+
NORM_CEMENT_MGO,
|
| 104 |
+
NORM_CEMENT_SO3,
|
| 105 |
+
NORM_CEMENT_ALKALI,
|
| 106 |
+
NORM_CEMENT_LOI,
|
| 107 |
+
NORM_SCM_CAO,
|
| 108 |
+
NORM_SCM_SIO2,
|
| 109 |
+
NORM_SCM_AL2O3,
|
| 110 |
+
NORM_SCM_FE2O3,
|
| 111 |
+
NORM_SCM_MGO,
|
| 112 |
+
NORM_SCM_LOI,
|
| 113 |
+
NORM_CEMENT_GRADE,
|
| 114 |
+
NORM_CURING_HUMIDITY,
|
| 115 |
+
NORM_SPECIMEN_SIZE,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# One-hot categorical blocks appended to the tabular vector (after the rich block).
|
| 119 |
+
CATEGORICAL_TABULAR = (
|
| 120 |
+
(NORM_CEMENT_TYPE, canonical_cement_type, CEMENT_TYPES, "other"),
|
| 121 |
+
(NORM_FIBRE_TYPE, canonical_fibre_type, FIBRE_TYPES, "none"),
|
| 122 |
+
(NORM_CURING_REGIME, canonical_curing_regime, CURING_REGIMES, "other"),
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# OPC-typical fallbacks used for the GNN paste chemistry when a row does not
|
| 126 |
+
# carry measured oxides (keeps non-UHPC behavior identical to the old defaults).
|
| 127 |
+
_DEFAULT_CEMENT_CHEM = (64.0, 21.0, 5.0, 3.0) # CaO, SiO2, Al2O3, Fe2O3
|
| 128 |
+
_DEFAULT_CEMENT_CHEM_EXT = (2.0, 2.5, 0.6) # MgO, SO3, Na2O-eq alkali
|
| 129 |
+
_DEFAULT_SCM_CHEM = (4.0, 55.0) # CaO, SiO2
|
| 130 |
+
_DEFAULT_SCM_CHEM_EXT = (15.0, 5.0, 2.0, 3.0) # Al2O3, Fe2O3, MgO, LOI
|
| 131 |
+
|
| 132 |
+
TableInput = Union[str, os.PathLike, pd.DataFrame]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def read_table(data: TableInput) -> pd.DataFrame:
|
| 136 |
+
if isinstance(data, pd.DataFrame):
|
| 137 |
+
return data.copy()
|
| 138 |
+
path = Path(data)
|
| 139 |
+
if path.suffix.lower() == ".csv":
|
| 140 |
+
return pd.read_csv(path)
|
| 141 |
+
return pd.read_excel(path)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def row_value(row, name: str, default: float = 0.0) -> float:
|
| 145 |
+
value = row.get(name, default)
|
| 146 |
+
if pd.isna(value):
|
| 147 |
+
return default
|
| 148 |
+
return float(value)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def row_value_optional(row, name: str) -> Optional[float]:
|
| 152 |
+
"""Return the float value, or ``None`` when the column is absent / NaN.
|
| 153 |
+
|
| 154 |
+
Used for maskable descriptors (max grain sizes, curing temperature) where
|
| 155 |
+
"not reported" must stay distinct from a genuine zero.
|
| 156 |
+
"""
|
| 157 |
+
|
| 158 |
+
if name not in row.index:
|
| 159 |
+
return None
|
| 160 |
+
value = row[name]
|
| 161 |
+
if pd.isna(value):
|
| 162 |
+
return None
|
| 163 |
+
return float(value)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def is_normalized_row(row) -> bool:
|
| 167 |
+
return NORM_CEMENT in row.index
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _chem_or_default(row, name: str, default: float) -> float:
|
| 171 |
+
"""Measured oxide value when the column is present & non-NaN, else ``default``."""
|
| 172 |
+
value = row_value_optional(row, name)
|
| 173 |
+
return default if value is None else value
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _category(row, name: str, canon_fn, vocab, default: str) -> str:
|
| 177 |
+
"""Read a categorical column as a canonical class string.
|
| 178 |
+
|
| 179 |
+
The rich CSV already stores canonical labels (in ``vocab``); raw labels from
|
| 180 |
+
other sources are canonicalized on the fly; an absent/blank column -> ``default``.
|
| 181 |
+
"""
|
| 182 |
+
if name not in row.index:
|
| 183 |
+
return default
|
| 184 |
+
value = row[name]
|
| 185 |
+
if value is None or (isinstance(value, float) and pd.isna(value)):
|
| 186 |
+
return default
|
| 187 |
+
text = str(value).strip()
|
| 188 |
+
if text == "" or text.lower() == "nan":
|
| 189 |
+
return default
|
| 190 |
+
return text if text in vocab else canon_fn(text)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@dataclass
|
| 194 |
+
class Standardizer:
|
| 195 |
+
mean: Tensor
|
| 196 |
+
std: Tensor
|
| 197 |
+
|
| 198 |
+
@classmethod
|
| 199 |
+
def from_values(cls, values: Tensor) -> "Standardizer":
|
| 200 |
+
return cls(values.mean(), values.std().clamp_min(1.0e-6))
|
| 201 |
+
|
| 202 |
+
def encode(self, values: Tensor) -> Tensor:
|
| 203 |
+
mean = self.mean.to(device=values.device, dtype=values.dtype)
|
| 204 |
+
std = self.std.to(device=values.device, dtype=values.dtype)
|
| 205 |
+
return (values - mean) / std
|
| 206 |
+
|
| 207 |
+
def decode(self, values: Tensor) -> Tensor:
|
| 208 |
+
mean = self.mean.to(device=values.device, dtype=values.dtype)
|
| 209 |
+
std = self.std.to(device=values.device, dtype=values.dtype)
|
| 210 |
+
return values * std + mean
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def strength_column(df: pd.DataFrame) -> str:
|
| 214 |
+
return NORM_STRENGTH if NORM_STRENGTH in df.columns else COL_STRENGTH
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def row_to_mix(row) -> MixDesign:
|
| 218 |
+
if is_normalized_row(row):
|
| 219 |
+
cement = row_value(row, NORM_CEMENT)
|
| 220 |
+
slag = row_value(row, NORM_SLAG)
|
| 221 |
+
fly_ash = row_value(row, NORM_FLY_ASH)
|
| 222 |
+
silica_fume = row_value(row, NORM_SILICA_FUME)
|
| 223 |
+
metakaolin = row_value(row, NORM_METAKAOLIN)
|
| 224 |
+
limestone = row_value(row, NORM_LIMESTONE)
|
| 225 |
+
other_scm = row_value(row, NORM_OTHER_SCM)
|
| 226 |
+
water = row_value(row, NORM_WATER)
|
| 227 |
+
coarse = row_value(row, NORM_COARSE)
|
| 228 |
+
superplasticizer = row_value(row, NORM_SUPERPLASTICIZER)
|
| 229 |
+
age = row_value(row, NORM_AGE)
|
| 230 |
+
else:
|
| 231 |
+
cement = float(row[COL_CEMENT])
|
| 232 |
+
slag = float(row[COL_SLAG])
|
| 233 |
+
fly_ash = float(row[COL_FLY_ASH])
|
| 234 |
+
silica_fume = 0.0
|
| 235 |
+
metakaolin = 0.0
|
| 236 |
+
limestone = 0.0
|
| 237 |
+
other_scm = 0.0
|
| 238 |
+
water = float(row[COL_WATER])
|
| 239 |
+
coarse = float(row[COL_COARSE])
|
| 240 |
+
superplasticizer = float(row[COL_SUPERPLASTICIZER])
|
| 241 |
+
age = float(row[COL_AGE])
|
| 242 |
+
|
| 243 |
+
extra_scm = metakaolin + limestone + other_scm
|
| 244 |
+
binder = max(cement + slag + fly_ash + silica_fume + extra_scm, 1.0)
|
| 245 |
+
scm = slag + fly_ash + silica_fume + extra_scm
|
| 246 |
+
primary_count = sum(v > 0.0 for v in (slag, fly_ash, silica_fume))
|
| 247 |
+
if primary_count > 1 or (primary_count >= 1 and extra_scm > 0.0):
|
| 248 |
+
scm_type = 3
|
| 249 |
+
elif slag > 0.0:
|
| 250 |
+
scm_type = 1
|
| 251 |
+
elif fly_ash > 0.0:
|
| 252 |
+
scm_type = 2
|
| 253 |
+
elif silica_fume > 0.0 or extra_scm > 0.0:
|
| 254 |
+
scm_type = 4
|
| 255 |
+
else:
|
| 256 |
+
scm_type = 0
|
| 257 |
+
|
| 258 |
+
aggregate_volume_fraction = max(0.0, min(0.70, coarse / 2650.0))
|
| 259 |
+
water_to_binder = water / binder
|
| 260 |
+
|
| 261 |
+
fibre_content = row_value(row, NORM_FIBRE_CONTENT) if is_normalized_row(row) else 0.0
|
| 262 |
+
fibre_length = row_value(row, NORM_FIBRE_LENGTH) if is_normalized_row(row) else 0.0
|
| 263 |
+
fibre_diameter = row_value(row, NORM_FIBRE_DIAMETER) if is_normalized_row(row) else 0.0
|
| 264 |
+
fibre_tensile = row_value(row, NORM_FIBRE_TENSILE) if is_normalized_row(row) else 0.0
|
| 265 |
+
fibre_modulus = row_value(row, NORM_FIBRE_MODULUS) if is_normalized_row(row) else 0.0
|
| 266 |
+
# Fibre material class -> id for the mesoscale mortar-node fibre_type_id slot.
|
| 267 |
+
fibre_type = _category(row, NORM_FIBRE_TYPE, canonical_fibre_type, FIBRE_TYPES, "none")
|
| 268 |
+
|
| 269 |
+
# Maskable descriptors: None when the source did not report them.
|
| 270 |
+
max_coarse = row_value_optional(row, NORM_MAX_COARSE)
|
| 271 |
+
max_fine = row_value_optional(row, NORM_MAX_FINE)
|
| 272 |
+
curing_temp = row_value_optional(row, NORM_CURING_TEMP)
|
| 273 |
+
# Measured curing humidity (%) when reported (UHPC), else the 0.95 default.
|
| 274 |
+
humidity_pct = row_value_optional(row, NORM_CURING_HUMIDITY)
|
| 275 |
+
relative_humidity = humidity_pct / 100.0 if humidity_pct is not None else 0.95
|
| 276 |
+
|
| 277 |
+
return MixDesign(
|
| 278 |
+
aggregate_volume_fraction=aggregate_volume_fraction,
|
| 279 |
+
water_to_binder_ratio=max(0.10, min(1.20, water_to_binder)),
|
| 280 |
+
cement_content_kg_m3=cement,
|
| 281 |
+
scm_type_id=scm_type,
|
| 282 |
+
scm_fraction=max(0.0, min(0.70, scm / binder)),
|
| 283 |
+
admixture_dosage=superplasticizer / binder,
|
| 284 |
+
relative_humidity=relative_humidity,
|
| 285 |
+
temperature_C=curing_temp if curing_temp is not None else 20.0,
|
| 286 |
+
temperature_observed=curing_temp is not None,
|
| 287 |
+
curing_age_days=age,
|
| 288 |
+
max_coarse_aggregate_size_mm=max_coarse,
|
| 289 |
+
max_fine_aggregate_size_mm=max_fine,
|
| 290 |
+
fibre_content_kg_m3=fibre_content,
|
| 291 |
+
fibre_length_mm=fibre_length,
|
| 292 |
+
fibre_diameter_mm=fibre_diameter,
|
| 293 |
+
fibre_tensile_strength_MPa=fibre_tensile,
|
| 294 |
+
fibre_modulus_GPa=fibre_modulus,
|
| 295 |
+
fibre_type_id=float(type_id(fibre_type, FIBRE_TYPES)),
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def mortar_mix_from_row(row, mix: MixDesign) -> MortarMixDesign:
|
| 300 |
+
fine = row_value(row, NORM_FINE) if is_normalized_row(row) else float(row[COL_FINE])
|
| 301 |
+
coarse = row_value(row, NORM_COARSE) if is_normalized_row(row) else float(row[COL_COARSE])
|
| 302 |
+
# Convert concrete-basis densities (kg per m^3 concrete) to a mortar basis
|
| 303 |
+
# (kg per m^3 mortar) by removing the coarse-aggregate volume. The mortar
|
| 304 |
+
# phase is denser in cement and fibre because it excludes coarse aggregate.
|
| 305 |
+
# w/b, scm_fraction and admixture_dosage are intensive ratios -> invariant.
|
| 306 |
+
mortar_vf = mortar_volume_fraction(coarse / 2650.0)
|
| 307 |
+
# Real cement / SCM oxide chemistry where the row carries it (UHPC rich CSV);
|
| 308 |
+
# OPC-typical synthetic priors otherwise (keeps other sources unchanged).
|
| 309 |
+
cement_chem = (
|
| 310 |
+
_chem_or_default(row, NORM_CEMENT_CAO, _DEFAULT_CEMENT_CHEM[0]),
|
| 311 |
+
_chem_or_default(row, NORM_CEMENT_SIO2, _DEFAULT_CEMENT_CHEM[1]),
|
| 312 |
+
_chem_or_default(row, NORM_CEMENT_AL2O3, _DEFAULT_CEMENT_CHEM[2]),
|
| 313 |
+
_chem_or_default(row, NORM_CEMENT_FE2O3, _DEFAULT_CEMENT_CHEM[3]),
|
| 314 |
+
)
|
| 315 |
+
cement_chem_ext = (
|
| 316 |
+
_chem_or_default(row, NORM_CEMENT_MGO, _DEFAULT_CEMENT_CHEM_EXT[0]),
|
| 317 |
+
_chem_or_default(row, NORM_CEMENT_SO3, _DEFAULT_CEMENT_CHEM_EXT[1]),
|
| 318 |
+
_chem_or_default(row, NORM_CEMENT_ALKALI, _DEFAULT_CEMENT_CHEM_EXT[2]),
|
| 319 |
+
)
|
| 320 |
+
scm_chem = (
|
| 321 |
+
_chem_or_default(row, NORM_SCM_CAO, _DEFAULT_SCM_CHEM[0]),
|
| 322 |
+
_chem_or_default(row, NORM_SCM_SIO2, _DEFAULT_SCM_CHEM[1]),
|
| 323 |
+
)
|
| 324 |
+
scm_chem_ext = (
|
| 325 |
+
_chem_or_default(row, NORM_SCM_AL2O3, _DEFAULT_SCM_CHEM_EXT[0]),
|
| 326 |
+
_chem_or_default(row, NORM_SCM_FE2O3, _DEFAULT_SCM_CHEM_EXT[1]),
|
| 327 |
+
_chem_or_default(row, NORM_SCM_MGO, _DEFAULT_SCM_CHEM_EXT[2]),
|
| 328 |
+
_chem_or_default(row, NORM_SCM_LOI, _DEFAULT_SCM_CHEM_EXT[3]),
|
| 329 |
+
)
|
| 330 |
+
cement_type = _category(row, NORM_CEMENT_TYPE, canonical_cement_type, CEMENT_TYPES, "other")
|
| 331 |
+
return MortarMixDesign(
|
| 332 |
+
sand_volume_fraction=max(0.20, min(0.60, fine / 2650.0)),
|
| 333 |
+
water_to_binder_ratio=mix.water_to_binder_ratio,
|
| 334 |
+
cement_content_kg_m3=mix.cement_content_kg_m3 / mortar_vf,
|
| 335 |
+
scm_type_id=mix.scm_type_id,
|
| 336 |
+
scm_fraction=mix.scm_fraction,
|
| 337 |
+
admixture_dosage=mix.admixture_dosage,
|
| 338 |
+
cement_chem=cement_chem,
|
| 339 |
+
cement_chem_ext=cement_chem_ext,
|
| 340 |
+
scm_chem=scm_chem,
|
| 341 |
+
scm_chem_ext=scm_chem_ext,
|
| 342 |
+
cement_type_id=float(type_id(cement_type, CEMENT_TYPES)),
|
| 343 |
+
curing_relative_humidity=mix.relative_humidity,
|
| 344 |
+
curing_temperature_C=mix.temperature_C,
|
| 345 |
+
curing_temperature_observed=mix.temperature_observed,
|
| 346 |
+
max_sand_size_mm=mix.max_fine_aggregate_size_mm,
|
| 347 |
+
curing_age_days=mix.curing_age_days,
|
| 348 |
+
fibre_content_kg_m3=mix.fibre_content_kg_m3 / mortar_vf,
|
| 349 |
+
fibre_length_mm=mix.fibre_length_mm,
|
| 350 |
+
fibre_diameter_mm=mix.fibre_diameter_mm,
|
| 351 |
+
fibre_tensile_strength_MPa=mix.fibre_tensile_strength_MPa,
|
| 352 |
+
fibre_modulus_GPa=mix.fibre_modulus_GPa,
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def mix_itz_vector(mix: MixDesign) -> Tensor:
|
| 357 |
+
return torch.tensor(
|
| 358 |
+
[
|
| 359 |
+
mix.cement_content_kg_m3,
|
| 360 |
+
float(mix.scm_type_id),
|
| 361 |
+
mix.scm_fraction,
|
| 362 |
+
mix.water_to_binder_ratio,
|
| 363 |
+
mix.admixture_dosage,
|
| 364 |
+
mix.relative_humidity,
|
| 365 |
+
mix.temperature_C,
|
| 366 |
+
mix.curing_age_days,
|
| 367 |
+
],
|
| 368 |
+
dtype=torch.float32,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
RAW_MIX_COLUMNS = (
|
| 373 |
+
NORM_CEMENT,
|
| 374 |
+
NORM_SLAG,
|
| 375 |
+
NORM_FLY_ASH,
|
| 376 |
+
NORM_SILICA_FUME,
|
| 377 |
+
NORM_METAKAOLIN,
|
| 378 |
+
NORM_LIMESTONE,
|
| 379 |
+
NORM_OTHER_SCM,
|
| 380 |
+
NORM_WATER,
|
| 381 |
+
NORM_SUPERPLASTICIZER,
|
| 382 |
+
NORM_COARSE,
|
| 383 |
+
NORM_FINE,
|
| 384 |
+
NORM_FIBRE_CONTENT,
|
| 385 |
+
NORM_FIBRE_LENGTH,
|
| 386 |
+
NORM_FIBRE_DIAMETER,
|
| 387 |
+
NORM_FIBRE_TENSILE,
|
| 388 |
+
NORM_FIBRE_MODULUS,
|
| 389 |
+
NORM_AGE,
|
| 390 |
+
)
|
| 391 |
+
# Tabular input = raw mix columns, then the rich maskable columns, then one
|
| 392 |
+
# observed-mask channel per rich column (1 = measured, 0 = missing), then one-hot
|
| 393 |
+
# blocks for the categorical columns. The mask channels let the ANN distinguish
|
| 394 |
+
# "not reported" from a genuine zero.
|
| 395 |
+
_CATEGORICAL_WIDTH = sum(len(vocab) for _, _, vocab, _ in CATEGORICAL_TABULAR)
|
| 396 |
+
TABULAR_DIM = len(RAW_MIX_COLUMNS) + 2 * len(RICH_TABULAR_COLUMNS) + _CATEGORICAL_WIDTH
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def raw_mix_vector(row) -> Tensor:
|
| 400 |
+
"""Build the per-sample raw mix-design vector used by TabularANN.
|
| 401 |
+
|
| 402 |
+
Layout: raw mix columns, then the rich maskable block (value + observed-mask
|
| 403 |
+
channels), then one-hot categorical blocks. Uses the normalized columns when
|
| 404 |
+
available, otherwise maps the original Yeh XLS columns into the leading slots;
|
| 405 |
+
sources lacking the rich/categorical columns contribute zeros and the default
|
| 406 |
+
categorical bucket.
|
| 407 |
+
"""
|
| 408 |
+
|
| 409 |
+
if is_normalized_row(row):
|
| 410 |
+
base = [row_value(row, name) for name in RAW_MIX_COLUMNS]
|
| 411 |
+
else:
|
| 412 |
+
base = [
|
| 413 |
+
float(row[COL_CEMENT]),
|
| 414 |
+
float(row[COL_SLAG]),
|
| 415 |
+
float(row[COL_FLY_ASH]),
|
| 416 |
+
0.0, # silica_fume
|
| 417 |
+
0.0, # metakaolin
|
| 418 |
+
0.0, # limestone
|
| 419 |
+
0.0, # other_scm
|
| 420 |
+
float(row[COL_WATER]),
|
| 421 |
+
float(row[COL_SUPERPLASTICIZER]),
|
| 422 |
+
float(row[COL_COARSE]),
|
| 423 |
+
float(row[COL_FINE]),
|
| 424 |
+
0.0, # fibre_content
|
| 425 |
+
0.0, # fibre_length
|
| 426 |
+
0.0, # fibre_diameter
|
| 427 |
+
0.0, # fibre_tensile
|
| 428 |
+
0.0, # fibre_modulus
|
| 429 |
+
float(row[COL_AGE]),
|
| 430 |
+
]
|
| 431 |
+
|
| 432 |
+
rich_values: List[float] = []
|
| 433 |
+
rich_mask: List[float] = []
|
| 434 |
+
for name in RICH_TABULAR_COLUMNS:
|
| 435 |
+
v = row_value_optional(row, name)
|
| 436 |
+
rich_values.append(0.0 if v is None else v)
|
| 437 |
+
rich_mask.append(0.0 if v is None else 1.0)
|
| 438 |
+
|
| 439 |
+
categorical: List[float] = []
|
| 440 |
+
for name, canon_fn, vocab, default in CATEGORICAL_TABULAR:
|
| 441 |
+
categorical.extend(one_hot(_category(row, name, canon_fn, vocab, default), vocab))
|
| 442 |
+
|
| 443 |
+
return torch.tensor(base + rich_values + rich_mask + categorical, dtype=torch.float32)
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
class ConcreteMixDataset(Dataset):
|
| 447 |
+
def __init__(
|
| 448 |
+
self,
|
| 449 |
+
data: TableInput,
|
| 450 |
+
indices: Optional[Sequence[int]] = None,
|
| 451 |
+
missing_rate: float = 0.0,
|
| 452 |
+
seed: int = 17,
|
| 453 |
+
target_standardizer: Optional[Standardizer] = None,
|
| 454 |
+
schema: SchemaSpec = DEFAULT_SCHEMA,
|
| 455 |
+
):
|
| 456 |
+
df = read_table(data)
|
| 457 |
+
if indices is not None:
|
| 458 |
+
df = df.iloc[list(indices)].reset_index(drop=True)
|
| 459 |
+
self.df = df.reset_index(drop=True)
|
| 460 |
+
self.schema = schema
|
| 461 |
+
y = torch.tensor(
|
| 462 |
+
self.df[strength_column(self.df)].to_numpy(dtype="float32"),
|
| 463 |
+
dtype=torch.float32,
|
| 464 |
+
)
|
| 465 |
+
self.y_scaled = (
|
| 466 |
+
target_standardizer.encode(y) if target_standardizer is not None else y
|
| 467 |
+
)
|
| 468 |
+
self.samples: List[ConcreteSample] = []
|
| 469 |
+
self._build_samples(seed, missing_rate)
|
| 470 |
+
|
| 471 |
+
def _build_samples(self, seed: int, missing_rate: float) -> None:
|
| 472 |
+
for i, row in self.df.iterrows():
|
| 473 |
+
mix = row_to_mix(row)
|
| 474 |
+
mortar_mix = mortar_mix_from_row(row, mix)
|
| 475 |
+
graph = generate_concrete_graph(
|
| 476 |
+
mix,
|
| 477 |
+
schema=self.schema,
|
| 478 |
+
seed=seed + 1000 + i,
|
| 479 |
+
missing_rate=missing_rate,
|
| 480 |
+
)
|
| 481 |
+
micro = generate_mortar_graph(
|
| 482 |
+
mortar_mix, schema=self.schema, seed=seed + 2000 + i
|
| 483 |
+
)
|
| 484 |
+
y = torch.zeros(6, dtype=torch.float32)
|
| 485 |
+
y[0] = self.y_scaled[i]
|
| 486 |
+
self.samples.append(
|
| 487 |
+
ConcreteSample(
|
| 488 |
+
graph=graph,
|
| 489 |
+
micrograph=micro,
|
| 490 |
+
mix_itz=mix_itz_vector(mix),
|
| 491 |
+
concrete_target=y,
|
| 492 |
+
mortar_target=None,
|
| 493 |
+
itz_target=None,
|
| 494 |
+
tabular_mix=raw_mix_vector(row),
|
| 495 |
+
)
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
def __len__(self) -> int:
|
| 499 |
+
return len(self.samples)
|
| 500 |
+
|
| 501 |
+
def __getitem__(self, idx: int) -> ConcreteSample:
|
| 502 |
+
return self.samples[idx]
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
class StrengthHead(nn.Module):
|
| 506 |
+
"""Wrap a Hybrid model and train only compressive strength."""
|
| 507 |
+
|
| 508 |
+
def __init__(self, base: nn.Module, standardizer: Standardizer):
|
| 509 |
+
super().__init__()
|
| 510 |
+
self.base = base
|
| 511 |
+
self.standardizer = standardizer
|
| 512 |
+
|
| 513 |
+
def forward(self, batch) -> Tensor:
|
| 514 |
+
out = self.base(batch)["concrete_pred"][:, 0]
|
| 515 |
+
return self.standardizer.encode(out)
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def split_indices(n: int, seed: int) -> tuple:
|
| 519 |
+
idx = list(range(n))
|
| 520 |
+
random.Random(seed).shuffle(idx)
|
| 521 |
+
n_train = int(0.70 * n)
|
| 522 |
+
n_val = int(0.15 * n)
|
| 523 |
+
return idx[:n_train], idx[n_train : n_train + n_val], idx[n_train + n_val :]
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def metrics(pred_mpa: Tensor, true_mpa: Tensor) -> Dict[str, float]:
|
| 527 |
+
resid = true_mpa - pred_mpa
|
| 528 |
+
ss_res = resid.pow(2).sum()
|
| 529 |
+
ss_tot = (true_mpa - true_mpa.mean()).pow(2).sum().clamp_min(1.0e-12)
|
| 530 |
+
return {
|
| 531 |
+
"rmse": float(resid.pow(2).mean().sqrt()),
|
| 532 |
+
"mae": float(resid.abs().mean()),
|
| 533 |
+
"r2": float(1.0 - ss_res / ss_tot),
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
@torch.no_grad()
|
| 538 |
+
def collect_strength_predictions(
|
| 539 |
+
model: StrengthHead, loader: DataLoader, device: torch.device
|
| 540 |
+
) -> tuple:
|
| 541 |
+
model.eval()
|
| 542 |
+
pred_scaled, true_scaled = [], []
|
| 543 |
+
for batch in loader:
|
| 544 |
+
batch = batch.to(device)
|
| 545 |
+
pred_scaled.append(model(batch).cpu())
|
| 546 |
+
true_scaled.append(batch.concrete_target[:, 0].cpu())
|
| 547 |
+
pred = model.standardizer.decode(torch.cat(pred_scaled))
|
| 548 |
+
true = model.standardizer.decode(torch.cat(true_scaled))
|
| 549 |
+
return pred, true
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def evaluate_strength(
|
| 553 |
+
model: StrengthHead, loader: DataLoader, device: torch.device
|
| 554 |
+
) -> Dict[str, float]:
|
| 555 |
+
pred, true = collect_strength_predictions(model, loader, device)
|
| 556 |
+
return metrics(pred, true)
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def fit_encoder_standardizers(model: nn.Module, samples: Sequence) -> None:
|
| 560 |
+
"""Fit per-feature input standardization for the GNN encoders that carry
|
| 561 |
+
real mix-design signal: the mortar sub-model nodes (paste w/b, cement,
|
| 562 |
+
admixture) and globals, the mesoscale globals, and the ITZ mix vector.
|
| 563 |
+
|
| 564 |
+
This mirrors the input ``BatchNorm`` the ``TabularANN`` baseline already
|
| 565 |
+
uses, so small-magnitude-but-informative features (w/b ratio ~0.4, admixture
|
| 566 |
+
dosage ~0.01) are not swamped by large ones (cement ~500). Stats are masked
|
| 567 |
+
so unobserved / placeholder slots are ignored.
|
| 568 |
+
|
| 569 |
+
Encoders whose columns multiplex several feature schemas (mesoscale nodes:
|
| 570 |
+
aggregate vs mortar; mesoscale edges: ITZ vs mortar-mortar vs agg-agg; mortar
|
| 571 |
+
nodes: sand vs paste) are standardized per row TYPE, so each schema is scaled
|
| 572 |
+
against its own statistics rather than a blended one. Stats are fit from the
|
| 573 |
+
raw graph priors, which ``_mortar_placeholder_vector`` / ``_itz_feature_vector``
|
| 574 |
+
deliberately scale-calibrate to the values the sub-models later inject.
|
| 575 |
+
"""
|
| 576 |
+
|
| 577 |
+
from .missing_features import masked_feature_stats, masked_feature_stats_by_type
|
| 578 |
+
|
| 579 |
+
if not samples or not hasattr(model, "mortar"):
|
| 580 |
+
return
|
| 581 |
+
|
| 582 |
+
schema = model.schema
|
| 583 |
+
|
| 584 |
+
# Mesoscale globals (single schema; curing context under curing-only).
|
| 585 |
+
gf = torch.stack([s.graph.global_features for s in samples])
|
| 586 |
+
gm = torch.stack([s.graph.global_mask for s in samples])
|
| 587 |
+
model.mesoscale.global_encoder.set_feature_stats(*masked_feature_stats(gf, gm))
|
| 588 |
+
|
| 589 |
+
# Mesoscale nodes (aggregate vs mortar) -- per node type.
|
| 590 |
+
nx = torch.cat([s.graph.x for s in samples], dim=0)
|
| 591 |
+
nxm = torch.cat([s.graph.x_mask for s in samples], dim=0)
|
| 592 |
+
nnt = torch.cat([s.graph.node_type for s in samples], dim=0)
|
| 593 |
+
model.mesoscale.node_encoder.set_feature_stats(
|
| 594 |
+
*masked_feature_stats_by_type(nx, nxm, nnt, schema.num_node_types)
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
# Mesoscale edges (ITZ vs mortar-mortar vs agg-agg) -- per edge type.
|
| 598 |
+
ex = torch.cat([s.graph.edge_attr for s in samples], dim=0)
|
| 599 |
+
exm = torch.cat([s.graph.edge_attr_mask for s in samples], dim=0)
|
| 600 |
+
eet = torch.cat([s.graph.edge_type for s in samples], dim=0)
|
| 601 |
+
model.mesoscale.edge_encoder.set_feature_stats(
|
| 602 |
+
*masked_feature_stats_by_type(ex, exm, eet, schema.num_edge_types)
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
# Mortar sub-model nodes (sand vs paste) -- per type; paste carries w/b,
|
| 606 |
+
# cement content and admixture dosage, the features the diagnosis flagged.
|
| 607 |
+
mx = torch.cat([s.micrograph.x for s in samples], dim=0)
|
| 608 |
+
mxm = torch.cat([s.micrograph.x_mask for s in samples], dim=0)
|
| 609 |
+
mnt = torch.cat([s.micrograph.node_type for s in samples], dim=0)
|
| 610 |
+
model.mortar.node_encoder.set_feature_stats(
|
| 611 |
+
*masked_feature_stats_by_type(mx, mxm, mnt, schema.num_mortar_node_types)
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
# Mortar sub-model globals (single schema).
|
| 615 |
+
mg = torch.stack([s.micrograph.global_features for s in samples])
|
| 616 |
+
mgm = torch.stack([s.micrograph.global_mask for s in samples])
|
| 617 |
+
model.mortar.global_encoder.set_feature_stats(*masked_feature_stats(mg, mgm))
|
| 618 |
+
|
| 619 |
+
# ITZ encoder: standardize only the leading mix-vector columns; the trailing
|
| 620 |
+
# geometry / texture / padding columns keep their identity stats.
|
| 621 |
+
mix_itz = torch.stack([s.mix_itz for s in samples])
|
| 622 |
+
mean, std = masked_feature_stats(mix_itz, torch.ones_like(mix_itz))
|
| 623 |
+
enc = model.itz.encoder
|
| 624 |
+
full_mean = enc.feat_mean[0].clone()
|
| 625 |
+
full_std = enc.feat_std[0].clone()
|
| 626 |
+
k = mix_itz.size(1)
|
| 627 |
+
full_mean[:k] = mean
|
| 628 |
+
full_std[:k] = std
|
| 629 |
+
enc.set_feature_stats(full_mean, full_std)
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def load_strength_checkpoint(
|
| 633 |
+
base_model: nn.Module,
|
| 634 |
+
checkpoint_path: Union[str, os.PathLike],
|
| 635 |
+
map_location: Optional[Union[str, torch.device]] = None,
|
| 636 |
+
strict: bool = True,
|
| 637 |
+
) -> dict:
|
| 638 |
+
checkpoint = torch.load(checkpoint_path, map_location=map_location)
|
| 639 |
+
state = checkpoint.get("base_model_state_dict")
|
| 640 |
+
if state is None:
|
| 641 |
+
wrapper_state = checkpoint["model_state_dict"]
|
| 642 |
+
state = {
|
| 643 |
+
key.removeprefix("base."): value
|
| 644 |
+
for key, value in wrapper_state.items()
|
| 645 |
+
if key.startswith("base.")
|
| 646 |
+
}
|
| 647 |
+
base_model.load_state_dict(state, strict=strict)
|
| 648 |
+
return checkpoint
|
concrete_gnn/schema.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feature schema for the hierarchical concrete GNN.
|
| 2 |
+
|
| 3 |
+
A single schema fixes the names, order, and sizes of every feature vector used
|
| 4 |
+
by the graph generator, the missing-feature encoder, the sub-models and the
|
| 5 |
+
mesoscale GNN. Every vector reserves a small number of explicit placeholder
|
| 6 |
+
slots so additional physical descriptors can be added later without changing
|
| 7 |
+
the encoder dimensions or retraining the input projections.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from typing import Tuple
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
NODE_TYPE_AGGREGATE = 0
|
| 17 |
+
NODE_TYPE_MORTAR = 1
|
| 18 |
+
|
| 19 |
+
EDGE_TYPE_ITZ = 0
|
| 20 |
+
EDGE_TYPE_MORTAR_MORTAR = 1
|
| 21 |
+
EDGE_TYPE_AGGREGATE_AGGREGATE = 2
|
| 22 |
+
|
| 23 |
+
MORTAR_NODE_TYPE_SAND = 0
|
| 24 |
+
MORTAR_NODE_TYPE_PASTE = 1
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _with_placeholders(names: Tuple[str, ...], n: int = 4) -> Tuple[str, ...]:
|
| 28 |
+
return tuple(names) + tuple(f"_placeholder_{i}" for i in range(n))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
AGGREGATE_FEATURES: Tuple[str, ...] = _with_placeholders(
|
| 32 |
+
(
|
| 33 |
+
"aggregate_size_mm",
|
| 34 |
+
"shape_descriptor",
|
| 35 |
+
"crushing_strength_MPa",
|
| 36 |
+
"toughness_J",
|
| 37 |
+
"abrasion_resistance",
|
| 38 |
+
"moisture_content",
|
| 39 |
+
"water_absorption_pct",
|
| 40 |
+
"surface_texture",
|
| 41 |
+
"specific_gravity",
|
| 42 |
+
"bulk_density_kg_m3",
|
| 43 |
+
"area_fraction", # node area / RVE area (exact pi r^2 for aggregate disks)
|
| 44 |
+
),
|
| 45 |
+
n=3,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
MORTAR_FEATURES: Tuple[str, ...] = (
|
| 49 |
+
"mortar_compressive_strength_MPa",
|
| 50 |
+
"mortar_tensile_strength_MPa",
|
| 51 |
+
"mortar_flexural_strength_MPa",
|
| 52 |
+
"mortar_elastic_modulus_GPa",
|
| 53 |
+
"mortar_permeability",
|
| 54 |
+
"mortar_diffusivity",
|
| 55 |
+
"mortar_porosity",
|
| 56 |
+
"mortar_creep_coefficient",
|
| 57 |
+
# Fibre conditioning (zero = no fibre). Aspect ratio = length / diameter.
|
| 58 |
+
"fibre_content_kg_m3",
|
| 59 |
+
"fibre_aspect_ratio",
|
| 60 |
+
"fibre_tensile_strength_MPa",
|
| 61 |
+
"fibre_modulus_GPa",
|
| 62 |
+
# Voronoi-cell area fraction of this mortar region (survives sub-model injection).
|
| 63 |
+
"mortar_area_fraction",
|
| 64 |
+
# Fibre material class id (index into categoricals.FIBRE_TYPES). Appended last
|
| 65 |
+
# so it survives mortar sub-model injection (only mortar_targets slots 0-7 are
|
| 66 |
+
# overwritten). Steel/PE/PVA differ in stiffness & bond beyond the raw numbers.
|
| 67 |
+
"fibre_type_id",
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
ITZ_FEATURES: Tuple[str, ...] = _with_placeholders(
|
| 71 |
+
(
|
| 72 |
+
"itz_thickness_um",
|
| 73 |
+
"itz_porosity",
|
| 74 |
+
"itz_strength_MPa",
|
| 75 |
+
"itz_elastic_modulus_GPa",
|
| 76 |
+
"itz_permeability",
|
| 77 |
+
"itz_diffusivity",
|
| 78 |
+
)
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
MORTAR_EDGE_FEATURES: Tuple[str, ...] = _with_placeholders(
|
| 82 |
+
(
|
| 83 |
+
"edge_distance_mm",
|
| 84 |
+
"shared_boundary_length_mm",
|
| 85 |
+
"centroid_dx_mm",
|
| 86 |
+
"centroid_dy_mm",
|
| 87 |
+
),
|
| 88 |
+
n=2,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
AGGREGATE_EDGE_FEATURES: Tuple[str, ...] = _with_placeholders(
|
| 92 |
+
(
|
| 93 |
+
"agg_agg_surface_gap_mm",
|
| 94 |
+
"agg_agg_centroid_distance_mm",
|
| 95 |
+
),
|
| 96 |
+
n=2,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
GLOBAL_FEATURES: Tuple[str, ...] = (
|
| 100 |
+
"relative_humidity",
|
| 101 |
+
"temperature_C", # curing temperature; observed for UHPC, masked elsewhere
|
| 102 |
+
"curing_age_days",
|
| 103 |
+
"water_to_binder_ratio",
|
| 104 |
+
"cement_content_kg_m3",
|
| 105 |
+
"scm_fraction",
|
| 106 |
+
"aggregate_volume_fraction",
|
| 107 |
+
"fibre_content_kg_m3", # total fibre dosage; mirror of paste composition pattern
|
| 108 |
+
# Nominal max grain sizes (mm). Masked when the source does not report them.
|
| 109 |
+
"max_coarse_aggregate_size_mm",
|
| 110 |
+
"max_fine_aggregate_size_mm",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
CONCRETE_TARGETS: Tuple[str, ...] = (
|
| 114 |
+
"compressive_strength_MPa",
|
| 115 |
+
"tensile_strength_MPa",
|
| 116 |
+
"flexural_strength_MPa",
|
| 117 |
+
"elastic_modulus_GPa",
|
| 118 |
+
"permeability",
|
| 119 |
+
"diffusivity",
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
MORTAR_TARGETS: Tuple[str, ...] = (
|
| 123 |
+
"mortar_compressive_strength_MPa",
|
| 124 |
+
"mortar_tensile_strength_MPa",
|
| 125 |
+
"mortar_flexural_strength_MPa",
|
| 126 |
+
"mortar_elastic_modulus_GPa",
|
| 127 |
+
"mortar_permeability",
|
| 128 |
+
"mortar_diffusivity",
|
| 129 |
+
"mortar_porosity",
|
| 130 |
+
"mortar_creep_coefficient",
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
ITZ_TARGETS: Tuple[str, ...] = (
|
| 134 |
+
"itz_thickness_um",
|
| 135 |
+
"itz_porosity",
|
| 136 |
+
"itz_strength_MPa",
|
| 137 |
+
"itz_elastic_modulus_GPa",
|
| 138 |
+
"itz_permeability",
|
| 139 |
+
"itz_diffusivity",
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
SAND_FEATURES: Tuple[str, ...] = _with_placeholders(
|
| 143 |
+
(
|
| 144 |
+
"sand_size_mm",
|
| 145 |
+
"sand_gradation",
|
| 146 |
+
"sand_fineness_modulus",
|
| 147 |
+
"sand_water_absorption_pct",
|
| 148 |
+
"sand_compressive_strength_MPa",
|
| 149 |
+
"sand_elastic_modulus_GPa",
|
| 150 |
+
"sand_area_fraction", # exact pi r^2 / mortar-RVE area
|
| 151 |
+
),
|
| 152 |
+
n=3,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
CEMENT_PASTE_FEATURES: Tuple[str, ...] = (
|
| 156 |
+
"cement_type_id",
|
| 157 |
+
"cement_CaO_pct",
|
| 158 |
+
"cement_SiO2_pct",
|
| 159 |
+
"cement_Al2O3_pct",
|
| 160 |
+
"cement_Fe2O3_pct",
|
| 161 |
+
"cement_content_kg_m3",
|
| 162 |
+
"scm_type_id",
|
| 163 |
+
"scm_CaO_pct",
|
| 164 |
+
"scm_SiO2_pct",
|
| 165 |
+
"scm_content_kg_m3",
|
| 166 |
+
"water_to_binder_ratio",
|
| 167 |
+
"admixture_dosage",
|
| 168 |
+
"paste_area_fraction", # Voronoi-cell area fraction of this paste region
|
| 169 |
+
# Extra cement oxides (formerly the 3 reserved placeholder slots). Populated
|
| 170 |
+
# from the UHPC ``Sheet1`` composition table when available, otherwise a
|
| 171 |
+
# synthetic OPC prior. Alkali is the Na2O equivalent (Na2O + 0.658*K2O).
|
| 172 |
+
"cement_MgO_pct",
|
| 173 |
+
"cement_SO3_pct",
|
| 174 |
+
"cement_alkali_pct",
|
| 175 |
+
# Extended SCM (binder-blend) oxides, content-weighted across the mix's SCM
|
| 176 |
+
# ingredients in ``Sheet1``. CaO/SiO2 above carry the dominant signal; these
|
| 177 |
+
# add the rest of the reactive-phase chemistry.
|
| 178 |
+
"scm_Al2O3_pct",
|
| 179 |
+
"scm_Fe2O3_pct",
|
| 180 |
+
"scm_MgO_pct",
|
| 181 |
+
"scm_LOI_pct",
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
MORTAR_GLOBAL_FEATURES: Tuple[str, ...] = _with_placeholders(
|
| 185 |
+
(
|
| 186 |
+
"mortar_water_to_binder_ratio",
|
| 187 |
+
"mortar_cement_content_kg_m3",
|
| 188 |
+
"mortar_scm_fraction",
|
| 189 |
+
"mortar_admixture_dosage",
|
| 190 |
+
"mortar_curing_relative_humidity",
|
| 191 |
+
"mortar_curing_temperature_C",
|
| 192 |
+
"mortar_curing_age_days",
|
| 193 |
+
"mortar_fibre_content_kg_m3", # same dosage signal at the mortar level
|
| 194 |
+
),
|
| 195 |
+
n=2,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@dataclass(frozen=True)
|
| 200 |
+
class SchemaSpec:
|
| 201 |
+
"""Bundle of schema vectors with derived size helpers."""
|
| 202 |
+
|
| 203 |
+
aggregate: Tuple[str, ...] = field(default=AGGREGATE_FEATURES)
|
| 204 |
+
mortar: Tuple[str, ...] = field(default=MORTAR_FEATURES)
|
| 205 |
+
itz: Tuple[str, ...] = field(default=ITZ_FEATURES)
|
| 206 |
+
mortar_edge: Tuple[str, ...] = field(default=MORTAR_EDGE_FEATURES)
|
| 207 |
+
aggregate_edge: Tuple[str, ...] = field(default=AGGREGATE_EDGE_FEATURES)
|
| 208 |
+
glob: Tuple[str, ...] = field(default=GLOBAL_FEATURES)
|
| 209 |
+
concrete_targets: Tuple[str, ...] = field(default=CONCRETE_TARGETS)
|
| 210 |
+
mortar_targets: Tuple[str, ...] = field(default=MORTAR_TARGETS)
|
| 211 |
+
itz_targets: Tuple[str, ...] = field(default=ITZ_TARGETS)
|
| 212 |
+
sand: Tuple[str, ...] = field(default=SAND_FEATURES)
|
| 213 |
+
paste: Tuple[str, ...] = field(default=CEMENT_PASTE_FEATURES)
|
| 214 |
+
mortar_global: Tuple[str, ...] = field(default=MORTAR_GLOBAL_FEATURES)
|
| 215 |
+
|
| 216 |
+
@property
|
| 217 |
+
def node_pad_dim(self) -> int:
|
| 218 |
+
return max(len(self.aggregate), len(self.mortar))
|
| 219 |
+
|
| 220 |
+
@property
|
| 221 |
+
def edge_pad_dim(self) -> int:
|
| 222 |
+
return max(len(self.itz), len(self.mortar_edge), len(self.aggregate_edge))
|
| 223 |
+
|
| 224 |
+
@property
|
| 225 |
+
def mortar_node_pad_dim(self) -> int:
|
| 226 |
+
return max(len(self.sand), len(self.paste))
|
| 227 |
+
|
| 228 |
+
@property
|
| 229 |
+
def global_dim(self) -> int:
|
| 230 |
+
return len(self.glob)
|
| 231 |
+
|
| 232 |
+
@property
|
| 233 |
+
def mortar_global_dim(self) -> int:
|
| 234 |
+
return len(self.mortar_global)
|
| 235 |
+
|
| 236 |
+
@property
|
| 237 |
+
def num_node_types(self) -> int:
|
| 238 |
+
return 2
|
| 239 |
+
|
| 240 |
+
@property
|
| 241 |
+
def num_edge_types(self) -> int:
|
| 242 |
+
return 3
|
| 243 |
+
|
| 244 |
+
@property
|
| 245 |
+
def num_mortar_node_types(self) -> int:
|
| 246 |
+
return 2
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
DEFAULT_SCHEMA = SchemaSpec()
|
concrete_gnn/train.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training utilities for the hierarchical concrete GNN.
|
| 2 |
+
|
| 3 |
+
The training loop supports:
|
| 4 |
+
|
| 5 |
+
* MSE on standardised targets via :class:`MultiTaskLoss` (per-target scaling),
|
| 6 |
+
* optional auxiliary losses on mortar / ITZ sub-model outputs when ground-truth
|
| 7 |
+
sub-labels are available,
|
| 8 |
+
* gradient clipping (off by default; set ``TrainConfig.grad_clip > 0``),
|
| 9 |
+
* per-target R\\ :sup:`2` reporting on a held-out loader.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import math
|
| 15 |
+
import os
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from typing import Dict, Iterable, List, Optional
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
from torch import Tensor, nn
|
| 21 |
+
|
| 22 |
+
from .data import MultiscaleBatch
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Loss / config
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class MultiTaskLoss:
|
| 32 |
+
"""Per-target standardised MSE.
|
| 33 |
+
|
| 34 |
+
``target_scales`` should be precomputed on the training set so val / test
|
| 35 |
+
losses are reported in the same units as training (i.e. relative
|
| 36 |
+
deviations). If ``None``, the per-batch absolute mean is used as a
|
| 37 |
+
fallback - useful for ad-hoc experiments but not for cross-run comparison.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
target_scales: Optional[Tensor] = None
|
| 41 |
+
weights: Optional[Tensor] = None
|
| 42 |
+
|
| 43 |
+
def __call__(
|
| 44 |
+
self,
|
| 45 |
+
pred: Tensor,
|
| 46 |
+
target: Tensor,
|
| 47 |
+
mask: Optional[Tensor] = None,
|
| 48 |
+
) -> Tensor:
|
| 49 |
+
scales = self.target_scales
|
| 50 |
+
if scales is None:
|
| 51 |
+
scales = target.detach().abs().mean(dim=0).clamp_min(1e-6)
|
| 52 |
+
diff = (pred - target) / scales
|
| 53 |
+
sq = diff.pow(2)
|
| 54 |
+
if mask is not None:
|
| 55 |
+
sq = sq * mask
|
| 56 |
+
denom = mask.sum().clamp_min(1.0)
|
| 57 |
+
sq = sq.sum() / denom
|
| 58 |
+
elif self.weights is not None:
|
| 59 |
+
sq = (sq * self.weights).mean()
|
| 60 |
+
else:
|
| 61 |
+
sq = sq.mean()
|
| 62 |
+
return sq
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class TrainConfig:
|
| 67 |
+
epochs: int = 60
|
| 68 |
+
lr: float = 1.0e-3
|
| 69 |
+
weight_decay: float = 1.0e-4
|
| 70 |
+
grad_clip: float = 0.0
|
| 71 |
+
aux_mortar_weight: float = 0.0
|
| 72 |
+
aux_itz_weight: float = 0.0
|
| 73 |
+
log_every: int = 5
|
| 74 |
+
name: str = "model"
|
| 75 |
+
checkpoint_path: Optional[str] = None # save best-val-loss weights here
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# Helpers
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def compute_target_scale(loader: Iterable[MultiscaleBatch], device: torch.device) -> Tensor:
|
| 84 |
+
"""Per-target std on the loader; used as the loss normaliser."""
|
| 85 |
+
|
| 86 |
+
ys = [batch.concrete_target for batch in loader]
|
| 87 |
+
y = torch.cat(ys, dim=0)
|
| 88 |
+
return y.std(dim=0).clamp_min(1.0e-12).to(device)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def compute_sublabel_scales(
|
| 92 |
+
loader: Iterable[MultiscaleBatch], device: torch.device
|
| 93 |
+
) -> Dict[str, Optional[Tensor]]:
|
| 94 |
+
"""Per-target std for the auxiliary mortar / ITZ labels (None when absent)."""
|
| 95 |
+
|
| 96 |
+
mortar_targets, itz_targets = [], []
|
| 97 |
+
for batch in loader:
|
| 98 |
+
if batch.mortar_target is not None:
|
| 99 |
+
mortar_targets.append(batch.mortar_target)
|
| 100 |
+
if batch.itz_target is not None:
|
| 101 |
+
itz_targets.append(batch.itz_target)
|
| 102 |
+
mortar_scale = (
|
| 103 |
+
torch.cat(mortar_targets, dim=0).std(dim=0).clamp_min(1.0e-12).to(device)
|
| 104 |
+
if mortar_targets
|
| 105 |
+
else None
|
| 106 |
+
)
|
| 107 |
+
itz_scale = (
|
| 108 |
+
torch.cat(itz_targets, dim=0).std(dim=0).clamp_min(1.0e-12).to(device)
|
| 109 |
+
if itz_targets
|
| 110 |
+
else None
|
| 111 |
+
)
|
| 112 |
+
return {"mortar": mortar_scale, "itz": itz_scale}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# Training loop
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def train_one_epoch(
|
| 121 |
+
model: nn.Module,
|
| 122 |
+
loader: Iterable[MultiscaleBatch],
|
| 123 |
+
optimiser: torch.optim.Optimizer,
|
| 124 |
+
loss_fn: MultiTaskLoss,
|
| 125 |
+
config: TrainConfig,
|
| 126 |
+
device: torch.device,
|
| 127 |
+
mortar_loss_fn: Optional[MultiTaskLoss] = None,
|
| 128 |
+
itz_loss_fn: Optional[MultiTaskLoss] = None,
|
| 129 |
+
) -> Dict[str, float]:
|
| 130 |
+
if mortar_loss_fn is None:
|
| 131 |
+
mortar_loss_fn = MultiTaskLoss()
|
| 132 |
+
if itz_loss_fn is None:
|
| 133 |
+
itz_loss_fn = MultiTaskLoss()
|
| 134 |
+
|
| 135 |
+
model.train()
|
| 136 |
+
running = {"loss": 0.0, "concrete": 0.0, "mortar": 0.0, "itz": 0.0, "n": 0}
|
| 137 |
+
|
| 138 |
+
for batch in loader:
|
| 139 |
+
batch = batch.to(device)
|
| 140 |
+
out = model(batch)
|
| 141 |
+
concrete_pred = out["concrete_pred"]
|
| 142 |
+
concrete_loss = loss_fn(concrete_pred, batch.concrete_target)
|
| 143 |
+
|
| 144 |
+
total = concrete_loss
|
| 145 |
+
mortar_loss_val = 0.0
|
| 146 |
+
itz_loss_val = 0.0
|
| 147 |
+
|
| 148 |
+
if (
|
| 149 |
+
config.aux_mortar_weight > 0
|
| 150 |
+
and batch.mortar_target is not None
|
| 151 |
+
and "mortar_pred" in out
|
| 152 |
+
):
|
| 153 |
+
mortar_loss = mortar_loss_fn(out["mortar_pred"], batch.mortar_target)
|
| 154 |
+
total = total + config.aux_mortar_weight * mortar_loss
|
| 155 |
+
mortar_loss_val = float(mortar_loss.detach().cpu())
|
| 156 |
+
|
| 157 |
+
if (
|
| 158 |
+
config.aux_itz_weight > 0
|
| 159 |
+
and batch.itz_target is not None
|
| 160 |
+
and "itz_pred" in out
|
| 161 |
+
and out["itz_pred"].size(0) == batch.itz_target.size(0)
|
| 162 |
+
):
|
| 163 |
+
itz_loss = itz_loss_fn(out["itz_pred"], batch.itz_target)
|
| 164 |
+
total = total + config.aux_itz_weight * itz_loss
|
| 165 |
+
itz_loss_val = float(itz_loss.detach().cpu())
|
| 166 |
+
|
| 167 |
+
optimiser.zero_grad()
|
| 168 |
+
total.backward()
|
| 169 |
+
if config.grad_clip > 0:
|
| 170 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), config.grad_clip)
|
| 171 |
+
optimiser.step()
|
| 172 |
+
|
| 173 |
+
running["loss"] += float(total.detach().cpu())
|
| 174 |
+
running["concrete"] += float(concrete_loss.detach().cpu())
|
| 175 |
+
running["mortar"] += mortar_loss_val
|
| 176 |
+
running["itz"] += itz_loss_val
|
| 177 |
+
running["n"] += 1
|
| 178 |
+
|
| 179 |
+
n = max(running["n"], 1)
|
| 180 |
+
return {
|
| 181 |
+
"loss": running["loss"] / n,
|
| 182 |
+
"concrete": running["concrete"] / n,
|
| 183 |
+
"mortar": running["mortar"] / n,
|
| 184 |
+
"itz": running["itz"] / n,
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@torch.no_grad()
|
| 189 |
+
def evaluate(
|
| 190 |
+
model: nn.Module,
|
| 191 |
+
loader: Iterable[MultiscaleBatch],
|
| 192 |
+
loss_fn: MultiTaskLoss,
|
| 193 |
+
device: torch.device,
|
| 194 |
+
) -> Dict[str, float]:
|
| 195 |
+
model.eval()
|
| 196 |
+
running = {"concrete": 0.0, "n": 0}
|
| 197 |
+
for batch in loader:
|
| 198 |
+
batch = batch.to(device)
|
| 199 |
+
out = model(batch)
|
| 200 |
+
running["concrete"] += float(loss_fn(out["concrete_pred"], batch.concrete_target).cpu())
|
| 201 |
+
running["n"] += 1
|
| 202 |
+
return {"concrete": running["concrete"] / max(running["n"], 1)}
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@torch.no_grad()
|
| 206 |
+
def evaluate_metrics(
|
| 207 |
+
model: nn.Module,
|
| 208 |
+
loader: Iterable[MultiscaleBatch],
|
| 209 |
+
device: torch.device,
|
| 210 |
+
) -> Dict[str, Tensor]:
|
| 211 |
+
"""Return per-target R^2 and MAE on the loader."""
|
| 212 |
+
|
| 213 |
+
model.eval()
|
| 214 |
+
preds: List[Tensor] = []
|
| 215 |
+
trues: List[Tensor] = []
|
| 216 |
+
for batch in loader:
|
| 217 |
+
batch = batch.to(device)
|
| 218 |
+
out = model(batch)
|
| 219 |
+
preds.append(out["concrete_pred"].cpu())
|
| 220 |
+
trues.append(batch.concrete_target.cpu())
|
| 221 |
+
pred = torch.cat(preds, dim=0)
|
| 222 |
+
true = torch.cat(trues, dim=0)
|
| 223 |
+
ss_res = ((true - pred) ** 2).sum(dim=0)
|
| 224 |
+
ss_tot = ((true - true.mean(dim=0)) ** 2).sum(dim=0).clamp_min(1.0e-12)
|
| 225 |
+
r2 = 1.0 - ss_res / ss_tot
|
| 226 |
+
mae = (true - pred).abs().mean(dim=0)
|
| 227 |
+
return {"r2": r2, "mae": mae}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def train(
|
| 231 |
+
model: nn.Module,
|
| 232 |
+
train_loader: Iterable[MultiscaleBatch],
|
| 233 |
+
config: TrainConfig = TrainConfig(),
|
| 234 |
+
val_loader: Optional[Iterable[MultiscaleBatch]] = None,
|
| 235 |
+
device: Optional[torch.device] = None,
|
| 236 |
+
loss_fn: Optional[MultiTaskLoss] = None,
|
| 237 |
+
mortar_loss_fn: Optional[MultiTaskLoss] = None,
|
| 238 |
+
itz_loss_fn: Optional[MultiTaskLoss] = None,
|
| 239 |
+
) -> List[Dict[str, float]]:
|
| 240 |
+
if device is None:
|
| 241 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 242 |
+
model.to(device)
|
| 243 |
+
if loss_fn is None:
|
| 244 |
+
loss_fn = MultiTaskLoss()
|
| 245 |
+
|
| 246 |
+
optimiser = torch.optim.AdamW(
|
| 247 |
+
model.parameters(), lr=config.lr, weight_decay=config.weight_decay
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
history: List[Dict[str, float]] = []
|
| 251 |
+
best_val = math.inf
|
| 252 |
+
best_epoch = -1
|
| 253 |
+
if config.checkpoint_path is not None:
|
| 254 |
+
os.makedirs(os.path.dirname(os.path.abspath(config.checkpoint_path)) or ".", exist_ok=True)
|
| 255 |
+
|
| 256 |
+
for epoch in range(1, config.epochs + 1):
|
| 257 |
+
stats = train_one_epoch(
|
| 258 |
+
model,
|
| 259 |
+
train_loader,
|
| 260 |
+
optimiser,
|
| 261 |
+
loss_fn,
|
| 262 |
+
config,
|
| 263 |
+
device,
|
| 264 |
+
mortar_loss_fn=mortar_loss_fn,
|
| 265 |
+
itz_loss_fn=itz_loss_fn,
|
| 266 |
+
)
|
| 267 |
+
if val_loader is not None:
|
| 268 |
+
val_stats = evaluate(model, val_loader, loss_fn, device)
|
| 269 |
+
stats["val_concrete"] = val_stats["concrete"]
|
| 270 |
+
if config.checkpoint_path is not None and val_stats["concrete"] < best_val:
|
| 271 |
+
best_val = val_stats["concrete"]
|
| 272 |
+
best_epoch = epoch
|
| 273 |
+
torch.save(
|
| 274 |
+
{
|
| 275 |
+
"epoch": epoch,
|
| 276 |
+
"val_concrete": best_val,
|
| 277 |
+
"model_state_dict": model.state_dict(),
|
| 278 |
+
"config_name": config.name,
|
| 279 |
+
},
|
| 280 |
+
config.checkpoint_path,
|
| 281 |
+
)
|
| 282 |
+
history.append(stats)
|
| 283 |
+
if epoch % max(1, config.log_every) == 0 or epoch == 1:
|
| 284 |
+
msg = (
|
| 285 |
+
f"[{config.name}] epoch={epoch:03d} "
|
| 286 |
+
f"loss={stats['loss']:.4f} "
|
| 287 |
+
f"concrete={stats['concrete']:.4f} "
|
| 288 |
+
f"mortar={stats['mortar']:.4f} "
|
| 289 |
+
f"itz={stats['itz']:.4f}"
|
| 290 |
+
)
|
| 291 |
+
if "val_concrete" in stats:
|
| 292 |
+
msg += f" val={stats['val_concrete']:.4f}"
|
| 293 |
+
print(msg)
|
| 294 |
+
|
| 295 |
+
if config.checkpoint_path is not None and best_epoch > 0:
|
| 296 |
+
print(
|
| 297 |
+
f"[{config.name}] best val_concrete={best_val:.4f} at epoch {best_epoch} "
|
| 298 |
+
f"-> saved to {config.checkpoint_path}"
|
| 299 |
+
)
|
| 300 |
+
return history
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def load_best_checkpoint(
|
| 304 |
+
model: nn.Module, path: str, device: Optional[torch.device] = None
|
| 305 |
+
) -> Dict[str, float]:
|
| 306 |
+
"""Load best-val-loss weights into ``model`` in place and return metadata."""
|
| 307 |
+
|
| 308 |
+
if device is None:
|
| 309 |
+
device = next(model.parameters()).device
|
| 310 |
+
state = torch.load(path, map_location=device, weights_only=False)
|
| 311 |
+
model.load_state_dict(state["model_state_dict"])
|
| 312 |
+
return {
|
| 313 |
+
"epoch": state.get("epoch", -1),
|
| 314 |
+
"val_concrete": state.get("val_concrete", float("nan")),
|
| 315 |
+
"config_name": state.get("config_name", ""),
|
| 316 |
+
}
|
concrete_gnn/visualize.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Plotting helpers for model diagnostics.
|
| 2 |
+
|
| 3 |
+
Four figures:
|
| 4 |
+
* :func:`plot_parity` - test-set pred vs true per target.
|
| 5 |
+
* :func:`plot_r2_bars` - per-target R^2 across models.
|
| 6 |
+
* :func:`plot_training_curves` - train + val concrete loss vs epoch.
|
| 7 |
+
* :func:`plot_rve_graph` - one generated 2D concrete RVE.
|
| 8 |
+
|
| 9 |
+
All functions accept a ``save_path``; if provided the figure is written and
|
| 10 |
+
the matplotlib ``Figure`` is also returned for further customisation.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
from typing import Dict, Iterable, List, Optional, Sequence
|
| 17 |
+
|
| 18 |
+
import matplotlib
|
| 19 |
+
|
| 20 |
+
matplotlib.use("Agg") # safe headless backend
|
| 21 |
+
import matplotlib.pyplot as plt
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch
|
| 24 |
+
|
| 25 |
+
from .graph_generator import ConcreteGraph
|
| 26 |
+
from .schema import (
|
| 27 |
+
CONCRETE_TARGETS,
|
| 28 |
+
EDGE_TYPE_AGGREGATE_AGGREGATE,
|
| 29 |
+
EDGE_TYPE_ITZ,
|
| 30 |
+
EDGE_TYPE_MORTAR_MORTAR,
|
| 31 |
+
NODE_TYPE_AGGREGATE,
|
| 32 |
+
NODE_TYPE_MORTAR,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Internal helpers
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _ensure_dir(path: str) -> None:
|
| 42 |
+
parent = os.path.dirname(os.path.abspath(path))
|
| 43 |
+
os.makedirs(parent, exist_ok=True)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _r2(true: np.ndarray, pred: np.ndarray) -> float:
|
| 47 |
+
ss_res = float(np.sum((true - pred) ** 2))
|
| 48 |
+
ss_tot = float(np.sum((true - true.mean()) ** 2))
|
| 49 |
+
if ss_tot < 1e-20:
|
| 50 |
+
return float("nan")
|
| 51 |
+
return 1.0 - ss_res / ss_tot
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
# 1. Parity plots
|
| 56 |
+
# ---------------------------------------------------------------------------
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def plot_parity(
|
| 60 |
+
predictions: Dict[str, np.ndarray],
|
| 61 |
+
truths: np.ndarray,
|
| 62 |
+
model_order: Sequence[str],
|
| 63 |
+
save_path: Optional[str] = None,
|
| 64 |
+
target_names: Sequence[str] = CONCRETE_TARGETS,
|
| 65 |
+
):
|
| 66 |
+
"""One subplot per target, one row per model.
|
| 67 |
+
|
| 68 |
+
Parameters
|
| 69 |
+
----------
|
| 70 |
+
predictions: dict
|
| 71 |
+
Mapping ``model_name -> (N, T)`` test predictions (numpy).
|
| 72 |
+
truths: ndarray
|
| 73 |
+
Test ground-truth array of shape ``(N, T)``.
|
| 74 |
+
model_order: sequence of str
|
| 75 |
+
Which models (and in which order) to draw.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
n_models = len(model_order)
|
| 79 |
+
n_targets = len(target_names)
|
| 80 |
+
fig, axes = plt.subplots(
|
| 81 |
+
n_models,
|
| 82 |
+
n_targets,
|
| 83 |
+
figsize=(2.6 * n_targets, 2.6 * n_models),
|
| 84 |
+
squeeze=False,
|
| 85 |
+
)
|
| 86 |
+
for i, name in enumerate(model_order):
|
| 87 |
+
pred = predictions[name]
|
| 88 |
+
for t in range(n_targets):
|
| 89 |
+
ax = axes[i, t]
|
| 90 |
+
y_true = truths[:, t]
|
| 91 |
+
y_pred = pred[:, t]
|
| 92 |
+
r2 = _r2(y_true, y_pred)
|
| 93 |
+
lo = float(min(y_true.min(), y_pred.min()))
|
| 94 |
+
hi = float(max(y_true.max(), y_pred.max()))
|
| 95 |
+
span = hi - lo if hi > lo else max(1.0, abs(hi))
|
| 96 |
+
lo -= 0.05 * span
|
| 97 |
+
hi += 0.05 * span
|
| 98 |
+
|
| 99 |
+
ax.plot([lo, hi], [lo, hi], "k--", lw=0.8, alpha=0.6)
|
| 100 |
+
ax.scatter(y_true, y_pred, s=22, alpha=0.7, edgecolor="none")
|
| 101 |
+
ax.set_xlim(lo, hi)
|
| 102 |
+
ax.set_ylim(lo, hi)
|
| 103 |
+
ax.set_aspect("equal", adjustable="box")
|
| 104 |
+
ax.tick_params(labelsize=7)
|
| 105 |
+
ax.text(
|
| 106 |
+
0.04,
|
| 107 |
+
0.93,
|
| 108 |
+
f"$R^2$={r2:.2f}",
|
| 109 |
+
transform=ax.transAxes,
|
| 110 |
+
fontsize=8,
|
| 111 |
+
va="top",
|
| 112 |
+
)
|
| 113 |
+
if i == n_models - 1:
|
| 114 |
+
ax.set_xlabel(target_names[t], fontsize=8)
|
| 115 |
+
if t == 0:
|
| 116 |
+
ax.set_ylabel(f"{name}\npred", fontsize=8)
|
| 117 |
+
fig.suptitle("Test-set parity (true on x, predicted on y)", fontsize=11)
|
| 118 |
+
fig.tight_layout(rect=(0, 0, 1, 0.97))
|
| 119 |
+
if save_path is not None:
|
| 120 |
+
_ensure_dir(save_path)
|
| 121 |
+
fig.savefig(save_path, dpi=160)
|
| 122 |
+
return fig
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
# 2. Per-target R^2 bars
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def plot_r2_bars(
|
| 131 |
+
r2_by_model: Dict[str, np.ndarray],
|
| 132 |
+
model_order: Sequence[str],
|
| 133 |
+
save_path: Optional[str] = None,
|
| 134 |
+
target_names: Sequence[str] = CONCRETE_TARGETS,
|
| 135 |
+
):
|
| 136 |
+
n_targets = len(target_names)
|
| 137 |
+
n_models = len(model_order)
|
| 138 |
+
x = np.arange(n_targets)
|
| 139 |
+
width = 0.8 / max(1, n_models)
|
| 140 |
+
|
| 141 |
+
fig, ax = plt.subplots(figsize=(1.2 * n_targets + 2.0, 4.0))
|
| 142 |
+
colours = plt.cm.tab10(np.linspace(0, 1, max(3, n_models)))
|
| 143 |
+
for i, name in enumerate(model_order):
|
| 144 |
+
r2 = np.asarray(r2_by_model[name])
|
| 145 |
+
offset = (i - (n_models - 1) / 2.0) * width
|
| 146 |
+
ax.bar(x + offset, r2, width=width * 0.95, label=name, color=colours[i])
|
| 147 |
+
ax.axhline(0.0, color="k", lw=0.6, alpha=0.5)
|
| 148 |
+
ax.set_xticks(x)
|
| 149 |
+
ax.set_xticklabels(target_names, rotation=30, ha="right", fontsize=8)
|
| 150 |
+
ax.set_ylabel("Test-set $R^2$")
|
| 151 |
+
ax.set_title("Per-target $R^2$ by model")
|
| 152 |
+
ax.legend(fontsize=8, frameon=False)
|
| 153 |
+
ax.set_ylim(min(-0.2, min((r2_by_model[n].min() for n in model_order))) - 0.05, 1.05)
|
| 154 |
+
fig.tight_layout()
|
| 155 |
+
if save_path is not None:
|
| 156 |
+
_ensure_dir(save_path)
|
| 157 |
+
fig.savefig(save_path, dpi=160)
|
| 158 |
+
return fig
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ---------------------------------------------------------------------------
|
| 162 |
+
# 3. Training curves
|
| 163 |
+
# ---------------------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def plot_training_curves(
|
| 167 |
+
histories: Dict[str, List[Dict[str, float]]],
|
| 168 |
+
model_order: Sequence[str],
|
| 169 |
+
save_path: Optional[str] = None,
|
| 170 |
+
):
|
| 171 |
+
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
|
| 172 |
+
train_ax, val_ax = axes
|
| 173 |
+
colours = plt.cm.tab10(np.linspace(0, 1, max(3, len(model_order))))
|
| 174 |
+
|
| 175 |
+
for i, name in enumerate(model_order):
|
| 176 |
+
history = histories[name]
|
| 177 |
+
epochs = np.arange(1, len(history) + 1)
|
| 178 |
+
train_loss = np.asarray([h["concrete"] for h in history])
|
| 179 |
+
train_ax.plot(epochs, train_loss, label=name, color=colours[i], lw=1.4)
|
| 180 |
+
if "val_concrete" in history[0]:
|
| 181 |
+
val_loss = np.asarray([h.get("val_concrete", np.nan) for h in history])
|
| 182 |
+
val_ax.plot(epochs, val_loss, label=name, color=colours[i], lw=1.4)
|
| 183 |
+
if np.isfinite(val_loss).any():
|
| 184 |
+
best_idx = int(np.nanargmin(val_loss))
|
| 185 |
+
val_ax.scatter(
|
| 186 |
+
[epochs[best_idx]],
|
| 187 |
+
[val_loss[best_idx]],
|
| 188 |
+
color=colours[i],
|
| 189 |
+
edgecolor="black",
|
| 190 |
+
zorder=5,
|
| 191 |
+
s=42,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
for ax, title in zip(axes, ("train concrete loss", "val concrete loss")):
|
| 195 |
+
ax.set_yscale("log")
|
| 196 |
+
ax.set_xlabel("epoch")
|
| 197 |
+
ax.set_ylabel("normalised MSE")
|
| 198 |
+
ax.set_title(title)
|
| 199 |
+
ax.grid(True, alpha=0.3)
|
| 200 |
+
ax.legend(fontsize=8, frameon=False)
|
| 201 |
+
fig.suptitle("Training curves (best-val checkpoint marked)", fontsize=11)
|
| 202 |
+
fig.tight_layout(rect=(0, 0, 1, 0.95))
|
| 203 |
+
if save_path is not None:
|
| 204 |
+
_ensure_dir(save_path)
|
| 205 |
+
fig.savefig(save_path, dpi=160)
|
| 206 |
+
return fig
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ---------------------------------------------------------------------------
|
| 210 |
+
# 4. Concrete RVE graph visualization
|
| 211 |
+
# ---------------------------------------------------------------------------
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def plot_rve_graph(
|
| 215 |
+
graph: ConcreteGraph,
|
| 216 |
+
rve_size_mm: float = 150.0,
|
| 217 |
+
save_path: Optional[str] = None,
|
| 218 |
+
):
|
| 219 |
+
"""Draw aggregates as circles, mortar nodes as dots, edges coloured by type."""
|
| 220 |
+
|
| 221 |
+
pos = graph.pos.detach().cpu().numpy()
|
| 222 |
+
node_type = graph.node_type.detach().cpu().numpy()
|
| 223 |
+
edge_index = graph.edge_index.detach().cpu().numpy()
|
| 224 |
+
edge_type = graph.edge_type.detach().cpu().numpy()
|
| 225 |
+
x = graph.x.detach().cpu().numpy()
|
| 226 |
+
|
| 227 |
+
fig, ax = plt.subplots(figsize=(7, 7))
|
| 228 |
+
|
| 229 |
+
# Edges first (so nodes draw on top).
|
| 230 |
+
seen = set()
|
| 231 |
+
edge_styles = {
|
| 232 |
+
EDGE_TYPE_ITZ: dict(color="#d62728", alpha=0.6, lw=0.8, ls="-", label="ITZ"),
|
| 233 |
+
EDGE_TYPE_MORTAR_MORTAR: dict(
|
| 234 |
+
color="#7f7f7f", alpha=0.45, lw=0.6, ls="-", label="mortar-mortar"
|
| 235 |
+
),
|
| 236 |
+
EDGE_TYPE_AGGREGATE_AGGREGATE: dict(
|
| 237 |
+
color="#1f77b4", alpha=0.6, lw=0.7, ls=":", label="agg-agg"
|
| 238 |
+
),
|
| 239 |
+
}
|
| 240 |
+
legend_seen = set()
|
| 241 |
+
for k in range(edge_index.shape[1]):
|
| 242 |
+
a = int(edge_index[0, k])
|
| 243 |
+
b = int(edge_index[1, k])
|
| 244 |
+
if a == b:
|
| 245 |
+
continue
|
| 246 |
+
key = (min(a, b), max(a, b))
|
| 247 |
+
if key in seen:
|
| 248 |
+
continue
|
| 249 |
+
seen.add(key)
|
| 250 |
+
style = edge_styles[int(edge_type[k])]
|
| 251 |
+
label = style["label"] if int(edge_type[k]) not in legend_seen else None
|
| 252 |
+
legend_seen.add(int(edge_type[k]))
|
| 253 |
+
ax.plot(
|
| 254 |
+
[pos[a, 0], pos[b, 0]],
|
| 255 |
+
[pos[a, 1], pos[b, 1]],
|
| 256 |
+
color=style["color"],
|
| 257 |
+
alpha=style["alpha"],
|
| 258 |
+
lw=style["lw"],
|
| 259 |
+
ls=style["ls"],
|
| 260 |
+
label=label,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# Aggregate circles (diameter is the first feature channel).
|
| 264 |
+
for i in range(pos.shape[0]):
|
| 265 |
+
if int(node_type[i]) == NODE_TYPE_AGGREGATE:
|
| 266 |
+
diameter = float(x[i, 0])
|
| 267 |
+
radius = max(1.0, 0.5 * diameter)
|
| 268 |
+
ax.add_patch(
|
| 269 |
+
plt.Circle(
|
| 270 |
+
(pos[i, 0], pos[i, 1]),
|
| 271 |
+
radius=radius,
|
| 272 |
+
facecolor="#bdbdbd",
|
| 273 |
+
edgecolor="black",
|
| 274 |
+
lw=0.6,
|
| 275 |
+
alpha=0.85,
|
| 276 |
+
zorder=2,
|
| 277 |
+
)
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
mortar_pts = pos[node_type == NODE_TYPE_MORTAR]
|
| 281 |
+
ax.scatter(
|
| 282 |
+
mortar_pts[:, 0],
|
| 283 |
+
mortar_pts[:, 1],
|
| 284 |
+
s=18,
|
| 285 |
+
c="#2ca02c",
|
| 286 |
+
edgecolor="black",
|
| 287 |
+
lw=0.3,
|
| 288 |
+
zorder=3,
|
| 289 |
+
label="mortar patch",
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
ax.set_xlim(0, rve_size_mm)
|
| 293 |
+
ax.set_ylim(0, rve_size_mm)
|
| 294 |
+
ax.set_aspect("equal")
|
| 295 |
+
ax.set_xlabel("x [mm]")
|
| 296 |
+
ax.set_ylabel("y [mm]")
|
| 297 |
+
ax.set_title("Generated 2D concrete RVE (aggregates + mortar + edge types)")
|
| 298 |
+
ax.legend(fontsize=8, loc="upper right", frameon=True)
|
| 299 |
+
fig.tight_layout()
|
| 300 |
+
if save_path is not None:
|
| 301 |
+
_ensure_dir(save_path)
|
| 302 |
+
fig.savefig(save_path, dpi=160)
|
| 303 |
+
return fig
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
# ---------------------------------------------------------------------------
|
| 307 |
+
# Prediction collection helper
|
| 308 |
+
# ---------------------------------------------------------------------------
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@torch.no_grad()
|
| 312 |
+
def collect_predictions(
|
| 313 |
+
model: torch.nn.Module,
|
| 314 |
+
loader: Iterable,
|
| 315 |
+
device: torch.device,
|
| 316 |
+
) -> Dict[str, np.ndarray]:
|
| 317 |
+
"""Return ``{"pred": (N, T), "true": (N, T)}`` arrays on CPU."""
|
| 318 |
+
|
| 319 |
+
model.eval()
|
| 320 |
+
preds, trues = [], []
|
| 321 |
+
for batch in loader:
|
| 322 |
+
batch = batch.to(device)
|
| 323 |
+
out = model(batch)
|
| 324 |
+
preds.append(out["concrete_pred"].cpu())
|
| 325 |
+
trues.append(batch.concrete_target.cpu())
|
| 326 |
+
return {
|
| 327 |
+
"pred": torch.cat(preds, dim=0).numpy(),
|
| 328 |
+
"true": torch.cat(trues, dim=0).numpy(),
|
| 329 |
+
}
|
inference.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference layer for the deployed concrete-strength app.
|
| 2 |
+
|
| 3 |
+
Single source of truth for:
|
| 4 |
+
* loading the trained hierarchical GNN + tabular-ANN checkpoints,
|
| 5 |
+
* Function 1 (forward): a (possibly incomplete) mix design -> compressive strength,
|
| 6 |
+
* Function 2 (inverse): a target strength -> several detailed mix designs.
|
| 7 |
+
|
| 8 |
+
Imported by ``streamlit_app.py`` and runnable as a CLI for verification:
|
| 9 |
+
|
| 10 |
+
# forward (fields left out are treated as "not measured")
|
| 11 |
+
python app/inference.py predict --cement 500 --water 175 --age 28 \
|
| 12 |
+
--coarse 950 --fine 750
|
| 13 |
+
|
| 14 |
+
# inverse
|
| 15 |
+
python app/inference.py suggest --target 120 --k 5
|
| 16 |
+
python app/inference.py suggest --target 40 --k 5 --no-fibre
|
| 17 |
+
|
| 18 |
+
The checkpoints were trained with the *curing-only* schema and the
|
| 19 |
+
``mortar_capacity`` strength head; that is NOT stored in the checkpoint, so we
|
| 20 |
+
rebuild the model with exactly those settings here (see CURING_ONLY_* below).
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import json
|
| 27 |
+
import sys
|
| 28 |
+
from dataclasses import dataclass, field
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from typing import Dict, List, Optional, Sequence
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
import pandas as pd
|
| 34 |
+
import torch
|
| 35 |
+
from torch.utils.data import DataLoader
|
| 36 |
+
|
| 37 |
+
# --- make the concrete_gnn package importable whether we run from the repo
|
| 38 |
+
# (package lives in ../Hybrid) or from a self-contained HF Space bundle
|
| 39 |
+
# (package copied next to this file). -------------------------------------
|
| 40 |
+
_HERE = Path(__file__).resolve().parent
|
| 41 |
+
_PKG_ROOT = None
|
| 42 |
+
for _cand in (_HERE, _HERE.parent / "Hybrid", _HERE.parent):
|
| 43 |
+
if (_cand / "concrete_gnn" / "__init__.py").exists():
|
| 44 |
+
sys.path.insert(0, str(_cand))
|
| 45 |
+
_PKG_ROOT = _cand
|
| 46 |
+
break
|
| 47 |
+
if _PKG_ROOT is None:
|
| 48 |
+
raise RuntimeError(
|
| 49 |
+
"Missing local concrete_gnn package. Deploy the contents of "
|
| 50 |
+
"app/space_bundle, not app/ alone; the Space root must contain "
|
| 51 |
+
"concrete_gnn/__init__.py next to streamlit_app.py."
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
from concrete_gnn import ( # noqa: E402
|
| 55 |
+
ConcreteMixDataset,
|
| 56 |
+
IntegratedMultiscaleModel,
|
| 57 |
+
SchemaSpec,
|
| 58 |
+
Standardizer,
|
| 59 |
+
StrengthHead,
|
| 60 |
+
TABULAR_DIM,
|
| 61 |
+
TabularANN,
|
| 62 |
+
collate_multiscale,
|
| 63 |
+
collect_strength_predictions,
|
| 64 |
+
fit_encoder_standardizers,
|
| 65 |
+
strength_column,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Schema used at training time (curing-only globals). MUST match train_real_data.py.
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
CURING_ONLY_GLOBALS = (
|
| 72 |
+
"relative_humidity", "temperature_C", "curing_age_days",
|
| 73 |
+
"_placeholder_0", "_placeholder_1",
|
| 74 |
+
)
|
| 75 |
+
CURING_ONLY_MORTAR_GLOBALS = (
|
| 76 |
+
"mortar_curing_relative_humidity", "mortar_curing_temperature_C",
|
| 77 |
+
"mortar_curing_age_days", "_placeholder_0", "_placeholder_1",
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def make_schema() -> SchemaSpec:
|
| 82 |
+
return SchemaSpec(glob=CURING_ONLY_GLOBALS, mortar_global=CURING_ONLY_MORTAR_GLOBALS)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Column layout (normalized names the ConcreteMixDataset loader expects).
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
TARGET_COL = "compressive_strength_mpa"
|
| 89 |
+
|
| 90 |
+
# Binder + water + aggregate + fibre amounts: a missing value means "none of
|
| 91 |
+
# this component", i.e. a genuine 0.0 (not a masked/unknown channel).
|
| 92 |
+
AMOUNT_COLS = [
|
| 93 |
+
"cement_kg_m3", "slag_kg_m3", "fly_ash_kg_m3", "silica_fume_kg_m3",
|
| 94 |
+
"metakaolin_kg_m3", "limestone_powder_kg_m3", "other_scm_kg_m3",
|
| 95 |
+
"water_kg_m3", "superplasticizer_kg_m3",
|
| 96 |
+
"coarse_aggregate_kg_m3", "fine_aggregate_kg_m3",
|
| 97 |
+
"fibre_content_kg_m3", "fibre_length_mm", "fibre_diameter_mm",
|
| 98 |
+
"fibre_tensile_strength_mpa", "fibre_modulus_gpa",
|
| 99 |
+
]
|
| 100 |
+
# Maskable descriptors: a missing value means "not reported" -> NaN -> masked.
|
| 101 |
+
MASKABLE_COLS = [
|
| 102 |
+
"max_coarse_aggregate_size_mm", "max_fine_aggregate_size_mm",
|
| 103 |
+
"curing_temperature_c",
|
| 104 |
+
"cement_CaO_pct", "cement_SiO2_pct", "cement_Al2O3_pct", "cement_Fe2O3_pct",
|
| 105 |
+
"cement_MgO_pct", "cement_SO3_pct", "cement_alkali_pct", "cement_LOI_pct",
|
| 106 |
+
"scm_CaO_pct", "scm_SiO2_pct", "scm_Al2O3_pct", "scm_Fe2O3_pct",
|
| 107 |
+
"scm_MgO_pct", "scm_LOI_pct",
|
| 108 |
+
"cement_grade_mpa", "curing_humidity_pct", "specimen_size_mm",
|
| 109 |
+
]
|
| 110 |
+
CATEGORICAL_COLS = ["cement_type_norm", "fibre_type_norm", "curing_regime_norm"]
|
| 111 |
+
|
| 112 |
+
# age has its own default (0 d would imply no strength); everything else 0.0.
|
| 113 |
+
AGE_COL = "age_days"
|
| 114 |
+
DEFAULT_AGE = 28.0
|
| 115 |
+
|
| 116 |
+
# Design knobs the inverse "refine" step is allowed to perturb.
|
| 117 |
+
REFINE_KNOBS = [
|
| 118 |
+
"cement_kg_m3", "water_kg_m3", "superplasticizer_kg_m3",
|
| 119 |
+
"slag_kg_m3", "fly_ash_kg_m3", "silica_fume_kg_m3",
|
| 120 |
+
]
|
| 121 |
+
# Columns shown / clustered for inverse diversity (the design vector).
|
| 122 |
+
DESIGN_COLS = AMOUNT_COLS + [AGE_COL]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def build_input_frame(mixes: Sequence[Dict[str, float]]) -> pd.DataFrame:
|
| 126 |
+
"""Turn a list of partial mix dicts into a full normalized DataFrame.
|
| 127 |
+
|
| 128 |
+
Unspecified amount columns -> 0.0 (component absent); unspecified maskable
|
| 129 |
+
descriptors / chemistry -> NaN (not reported -> masked); unspecified
|
| 130 |
+
categoricals -> NaN (default bucket); age defaults to 28 d.
|
| 131 |
+
"""
|
| 132 |
+
rows = []
|
| 133 |
+
for mix in mixes:
|
| 134 |
+
row: Dict[str, object] = {}
|
| 135 |
+
for c in AMOUNT_COLS:
|
| 136 |
+
row[c] = float(mix.get(c, 0.0) or 0.0)
|
| 137 |
+
row[AGE_COL] = float(mix.get(AGE_COL, DEFAULT_AGE) or DEFAULT_AGE)
|
| 138 |
+
for c in MASKABLE_COLS:
|
| 139 |
+
v = mix.get(c, None)
|
| 140 |
+
row[c] = np.nan if v is None or v == "" else float(v)
|
| 141 |
+
for c in CATEGORICAL_COLS:
|
| 142 |
+
v = mix.get(c, None)
|
| 143 |
+
row[c] = np.nan if v is None or v == "" else str(v)
|
| 144 |
+
row[TARGET_COL] = float(mix.get(TARGET_COL, 0.0) or 0.0) # placeholder
|
| 145 |
+
rows.append(row)
|
| 146 |
+
return pd.DataFrame(rows)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def add_derived(df: pd.DataFrame) -> pd.DataFrame:
|
| 150 |
+
"""Append water/binder ratio and SCM fraction for display."""
|
| 151 |
+
df = df.copy()
|
| 152 |
+
binder = (
|
| 153 |
+
df["cement_kg_m3"] + df["slag_kg_m3"] + df["fly_ash_kg_m3"]
|
| 154 |
+
+ df["silica_fume_kg_m3"] + df["metakaolin_kg_m3"]
|
| 155 |
+
+ df["limestone_powder_kg_m3"] + df["other_scm_kg_m3"]
|
| 156 |
+
).clip(lower=1.0)
|
| 157 |
+
df["water_binder_ratio"] = (df["water_kg_m3"] / binder).round(3)
|
| 158 |
+
df["scm_fraction"] = ((binder - df["cement_kg_m3"]) / binder).round(3)
|
| 159 |
+
return df
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ---------------------------------------------------------------------------
|
| 163 |
+
# Predictor
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
def _detect_head_kind(state: dict) -> str:
|
| 166 |
+
keys = list(state.keys())
|
| 167 |
+
if any(k.startswith("strength_head.mortar_eff") for k in keys):
|
| 168 |
+
return "mortar_capacity"
|
| 169 |
+
if any(k.startswith("strength_head.") for k in keys):
|
| 170 |
+
return "physics"
|
| 171 |
+
return "free"
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@dataclass
|
| 175 |
+
class Predictor:
|
| 176 |
+
checkpoint_dir: Path
|
| 177 |
+
device: torch.device = field(default_factory=lambda: torch.device("cpu"))
|
| 178 |
+
seed: int = 17
|
| 179 |
+
config: dict = field(default_factory=dict)
|
| 180 |
+
|
| 181 |
+
def __post_init__(self) -> None:
|
| 182 |
+
self.schema = make_schema()
|
| 183 |
+
cfg_path = Path(self.checkpoint_dir) / "model_config.json"
|
| 184 |
+
if cfg_path.exists():
|
| 185 |
+
self.config = json.loads(cfg_path.read_text())
|
| 186 |
+
self.gnn, self.std = self._load("hierarchical.pt", kind="gnn")
|
| 187 |
+
self.tabular, _ = self._load("tabular_ann.pt", kind="tabular")
|
| 188 |
+
self.bounds: dict = self.config.get("feature_bounds", {})
|
| 189 |
+
|
| 190 |
+
def _load(self, fname: str, kind: str):
|
| 191 |
+
path = Path(self.checkpoint_dir) / fname
|
| 192 |
+
ck = torch.load(path, map_location=self.device, weights_only=False)
|
| 193 |
+
std = Standardizer(
|
| 194 |
+
mean=torch.tensor(float(ck["target_mean"])),
|
| 195 |
+
std=torch.tensor(float(ck["target_std"])),
|
| 196 |
+
)
|
| 197 |
+
state = ck["base_model_state_dict"]
|
| 198 |
+
if kind == "gnn":
|
| 199 |
+
head_kind = self.config.get("strength_head_kind") or _detect_head_kind(state)
|
| 200 |
+
base = IntegratedMultiscaleModel(schema=self.schema, strength_head_kind=head_kind)
|
| 201 |
+
else:
|
| 202 |
+
base = TabularANN(in_dim=TABULAR_DIM, schema=self.schema)
|
| 203 |
+
base.load_state_dict(state)
|
| 204 |
+
model = StrengthHead(base, std).to(self.device).eval()
|
| 205 |
+
return model, std
|
| 206 |
+
|
| 207 |
+
# ---- forward ---- #
|
| 208 |
+
@torch.no_grad()
|
| 209 |
+
def predict_df(self, df: pd.DataFrame, which=("gnn", "tabular")) -> Dict[str, np.ndarray]:
|
| 210 |
+
df = df.copy()
|
| 211 |
+
if TARGET_COL not in df.columns:
|
| 212 |
+
df[TARGET_COL] = 0.0
|
| 213 |
+
ds = ConcreteMixDataset(df, None, 0.0, self.seed, self.std, schema=self.schema)
|
| 214 |
+
dl = DataLoader(ds, batch_size=128, shuffle=False, collate_fn=collate_multiscale)
|
| 215 |
+
models = {"gnn": self.gnn, "tabular": self.tabular}
|
| 216 |
+
out: Dict[str, np.ndarray] = {}
|
| 217 |
+
for name in which:
|
| 218 |
+
pred, _ = collect_strength_predictions(models[name], dl, self.device)
|
| 219 |
+
out[name] = pred.numpy()
|
| 220 |
+
return out
|
| 221 |
+
|
| 222 |
+
def predict_strength(self, mix: Dict[str, float]) -> Dict[str, float]:
|
| 223 |
+
"""Single partial mix -> {'gnn': MPa, 'tabular': MPa}."""
|
| 224 |
+
df = build_input_frame([mix])
|
| 225 |
+
res = self.predict_df(df)
|
| 226 |
+
return {k: float(v[0]) for k, v in res.items()}
|
| 227 |
+
|
| 228 |
+
def age_curve(self, mix: Dict[str, float], ages=None) -> pd.DataFrame:
|
| 229 |
+
"""Predict strength across curing ages holding the rest of the mix fixed."""
|
| 230 |
+
if ages is None:
|
| 231 |
+
ages = [1, 3, 7, 14, 28, 56, 90, 180, 365]
|
| 232 |
+
mixes = [{**mix, AGE_COL: float(a)} for a in ages]
|
| 233 |
+
res = self.predict_df(build_input_frame(mixes))
|
| 234 |
+
return pd.DataFrame({"age_days": ages, "gnn_mpa": res["gnn"], "tabular_mpa": res["tabular"]})
|
| 235 |
+
|
| 236 |
+
def out_of_range(self, mix: Dict[str, float]) -> List[str]:
|
| 237 |
+
"""Names of supplied fields that fall outside the training p1-p99 range."""
|
| 238 |
+
flags = []
|
| 239 |
+
for c, v in mix.items():
|
| 240 |
+
b = self.bounds.get(c)
|
| 241 |
+
if b and v not in (None, "") and isinstance(v, (int, float)):
|
| 242 |
+
if v < b["p01"] or v > b["p99"]:
|
| 243 |
+
flags.append(c)
|
| 244 |
+
return flags
|
| 245 |
+
|
| 246 |
+
# ---- inverse ---- #
|
| 247 |
+
def suggest_mixes(
|
| 248 |
+
self,
|
| 249 |
+
target: float,
|
| 250 |
+
index: pd.DataFrame,
|
| 251 |
+
k: int = 5,
|
| 252 |
+
tol: Optional[float] = None,
|
| 253 |
+
allow_fibre: bool = True,
|
| 254 |
+
allow_scm: bool = True,
|
| 255 |
+
age: Optional[float] = None,
|
| 256 |
+
domain: str = "any", # "any" | "uhpc" | "normal"
|
| 257 |
+
refine: bool = True,
|
| 258 |
+
) -> pd.DataFrame:
|
| 259 |
+
df = index.copy()
|
| 260 |
+
if not allow_fibre:
|
| 261 |
+
df = df[df["fibre_content_kg_m3"].fillna(0) <= 0]
|
| 262 |
+
if not allow_scm:
|
| 263 |
+
scm = (df[["slag_kg_m3", "fly_ash_kg_m3", "silica_fume_kg_m3",
|
| 264 |
+
"metakaolin_kg_m3", "limestone_powder_kg_m3", "other_scm_kg_m3"]]
|
| 265 |
+
.fillna(0).sum(axis=1))
|
| 266 |
+
df = df[scm <= 0]
|
| 267 |
+
if domain == "uhpc":
|
| 268 |
+
df = df[df["source"] == "UHPC"]
|
| 269 |
+
elif domain == "normal":
|
| 270 |
+
df = df[df["source"] != "UHPC"]
|
| 271 |
+
if age is not None:
|
| 272 |
+
df = df[np.isclose(pd.to_numeric(df["age_days"], errors="coerce"), age)]
|
| 273 |
+
if df.empty:
|
| 274 |
+
return df
|
| 275 |
+
|
| 276 |
+
df = df.copy()
|
| 277 |
+
df["err"] = (df["pred_gnn"] - target).abs()
|
| 278 |
+
# Keep the candidate pool within a tolerance band so even the most
|
| 279 |
+
# "diverse" pick stays near the target; widen if too few rows qualify.
|
| 280 |
+
base_tol = tol if tol is not None else max(5.0, 0.10 * target)
|
| 281 |
+
need = max(k * 4, 12)
|
| 282 |
+
pool, mult = df[df["err"] <= base_tol], 1.0
|
| 283 |
+
while len(pool) < need and mult < 8:
|
| 284 |
+
mult *= 2
|
| 285 |
+
pool = df[df["err"] <= base_tol * mult]
|
| 286 |
+
if pool.empty:
|
| 287 |
+
pool = df.nsmallest(need, "err")
|
| 288 |
+
pool = pool.sort_values("err").head(200)
|
| 289 |
+
|
| 290 |
+
seeds = self._diversify(pool, k)
|
| 291 |
+
if refine and len(seeds) > 0:
|
| 292 |
+
seeds = self._refine(seeds, target)
|
| 293 |
+
out = self._assemble(seeds, target)
|
| 294 |
+
# Return the k closest-to-target after refinement.
|
| 295 |
+
order = out["pred_gnn"].sub(target).abs().sort_values().index
|
| 296 |
+
return out.loc[order].head(k).reset_index(drop=True)
|
| 297 |
+
|
| 298 |
+
def _diversify(self, df: pd.DataFrame, k: int) -> pd.DataFrame:
|
| 299 |
+
"""Greedy max-min selection over the normalized design vector."""
|
| 300 |
+
if len(df) <= k:
|
| 301 |
+
return df
|
| 302 |
+
X = df[DESIGN_COLS].fillna(0.0).to_numpy(dtype=float)
|
| 303 |
+
mu, sigma = X.mean(0), X.std(0) + 1e-6
|
| 304 |
+
Z = (X - mu) / sigma
|
| 305 |
+
chosen = [0] # closest-to-target row is first (df is sorted by err)
|
| 306 |
+
while len(chosen) < k:
|
| 307 |
+
d = np.min(
|
| 308 |
+
np.linalg.norm(Z[:, None, :] - Z[chosen][None, :, :], axis=2), axis=1
|
| 309 |
+
)
|
| 310 |
+
d[chosen] = -1.0
|
| 311 |
+
chosen.append(int(np.argmax(d)))
|
| 312 |
+
return df.iloc[chosen]
|
| 313 |
+
|
| 314 |
+
def _refine(self, seeds: pd.DataFrame, target: float, n_per_seed: int = 32) -> pd.DataFrame:
|
| 315 |
+
"""Perturb a few knobs per seed, predict, keep the closest-to-target variant."""
|
| 316 |
+
rng = np.random.default_rng(self.seed)
|
| 317 |
+
candidates: List[Dict[str, float]] = []
|
| 318 |
+
owners: List[int] = []
|
| 319 |
+
seed_rows = seeds.reset_index(drop=True)
|
| 320 |
+
for i, row in seed_rows.iterrows():
|
| 321 |
+
base = {c: (None if pd.isna(row[c]) else row[c]) for c in
|
| 322 |
+
AMOUNT_COLS + [AGE_COL] + MASKABLE_COLS + CATEGORICAL_COLS}
|
| 323 |
+
candidates.append(dict(base)); owners.append(i) # keep the seed itself
|
| 324 |
+
for _ in range(n_per_seed):
|
| 325 |
+
cand = dict(base)
|
| 326 |
+
for knob in REFINE_KNOBS:
|
| 327 |
+
cur = float(base.get(knob) or 0.0)
|
| 328 |
+
if cur <= 0 and knob in ("slag_kg_m3", "fly_ash_kg_m3", "silica_fume_kg_m3"):
|
| 329 |
+
continue # don't invent an SCM that wasn't there
|
| 330 |
+
factor = float(rng.uniform(0.85, 1.15))
|
| 331 |
+
cand[knob] = self._clip(knob, cur * factor)
|
| 332 |
+
candidates.append(cand); owners.append(i)
|
| 333 |
+
preds = self.predict_df(build_input_frame(candidates), which=("gnn",))["gnn"]
|
| 334 |
+
owners = np.asarray(owners)
|
| 335 |
+
best_rows = []
|
| 336 |
+
for i in range(len(seed_rows)):
|
| 337 |
+
sel = np.where(owners == i)[0]
|
| 338 |
+
err = np.abs(preds[sel] - target)
|
| 339 |
+
best = sel[int(np.argmin(err))]
|
| 340 |
+
best_rows.append({**candidates[best], "pred_gnn": float(preds[best]),
|
| 341 |
+
"source": seed_rows.loc[i].get("source", "refined"),
|
| 342 |
+
"measured": seed_rows.loc[i].get("measured", np.nan)})
|
| 343 |
+
return pd.DataFrame(best_rows)
|
| 344 |
+
|
| 345 |
+
def _clip(self, col: str, value: float) -> float:
|
| 346 |
+
b = self.bounds.get(col)
|
| 347 |
+
if b:
|
| 348 |
+
return float(min(max(value, b["min"]), b["max"]))
|
| 349 |
+
return float(max(value, 0.0))
|
| 350 |
+
|
| 351 |
+
def _assemble(self, seeds: pd.DataFrame, target: float) -> pd.DataFrame:
|
| 352 |
+
df = seeds.copy().reset_index(drop=True)
|
| 353 |
+
if "pred_gnn" not in df.columns:
|
| 354 |
+
df["pred_gnn"] = self.predict_df(build_input_frame(df.to_dict("records")),
|
| 355 |
+
which=("gnn",))["gnn"]
|
| 356 |
+
df = add_derived(df)
|
| 357 |
+
for c in DESIGN_COLS: # tidy kg/mm/day values for display
|
| 358 |
+
if c in df.columns:
|
| 359 |
+
df[c] = pd.to_numeric(df[c], errors="coerce").round(1)
|
| 360 |
+
df["pred_gnn"] = df["pred_gnn"].round(1)
|
| 361 |
+
df["target_mpa"] = float(target)
|
| 362 |
+
front = (["pred_gnn", "target_mpa", "measured", "source",
|
| 363 |
+
"water_binder_ratio", "scm_fraction"] + DESIGN_COLS)
|
| 364 |
+
cols = [c for c in front if c in df.columns] + \
|
| 365 |
+
[c for c in df.columns if c not in front]
|
| 366 |
+
return df[cols]
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# ---------------------------------------------------------------------------
|
| 370 |
+
# CLI (verification)
|
| 371 |
+
# ---------------------------------------------------------------------------
|
| 372 |
+
def _default_ckpt_dir() -> Path:
|
| 373 |
+
for c in (_HERE / "checkpoints_full_rich",
|
| 374 |
+
_HERE.parent / "Hybrid" / "outputs" / "checkpoints_full_rich"):
|
| 375 |
+
if (c / "hierarchical.pt").exists():
|
| 376 |
+
return c
|
| 377 |
+
return _HERE / "checkpoints_full_rich"
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def _default_index() -> Optional[Path]:
|
| 381 |
+
for c in (_HERE / "inverse_index.csv",):
|
| 382 |
+
if c.exists():
|
| 383 |
+
return c
|
| 384 |
+
return None
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def main() -> None:
|
| 388 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 389 |
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
| 390 |
+
|
| 391 |
+
p = sub.add_parser("predict", help="forward: mix -> strength")
|
| 392 |
+
p.add_argument("--checkpoint-dir", default=str(_default_ckpt_dir()))
|
| 393 |
+
p.add_argument("--cement", type=float); p.add_argument("--slag", type=float)
|
| 394 |
+
p.add_argument("--fly-ash", type=float); p.add_argument("--silica-fume", type=float)
|
| 395 |
+
p.add_argument("--water", type=float); p.add_argument("--sp", type=float)
|
| 396 |
+
p.add_argument("--coarse", type=float); p.add_argument("--fine", type=float)
|
| 397 |
+
p.add_argument("--age", type=float, default=28.0)
|
| 398 |
+
|
| 399 |
+
s = sub.add_parser("suggest", help="inverse: strength -> mixes")
|
| 400 |
+
s.add_argument("--checkpoint-dir", default=str(_default_ckpt_dir()))
|
| 401 |
+
s.add_argument("--index", default=str(_default_index() or ""))
|
| 402 |
+
s.add_argument("--target", type=float, required=True)
|
| 403 |
+
s.add_argument("--k", type=int, default=5)
|
| 404 |
+
s.add_argument("--tol", type=float, default=None)
|
| 405 |
+
s.add_argument("--no-fibre", action="store_true")
|
| 406 |
+
s.add_argument("--no-scm", action="store_true")
|
| 407 |
+
s.add_argument("--no-refine", action="store_true")
|
| 408 |
+
args = ap.parse_args()
|
| 409 |
+
|
| 410 |
+
pred = Predictor(Path(args.checkpoint_dir))
|
| 411 |
+
if args.cmd == "predict":
|
| 412 |
+
mix = {
|
| 413 |
+
"cement_kg_m3": args.cement, "slag_kg_m3": args.slag,
|
| 414 |
+
"fly_ash_kg_m3": args.fly_ash, "silica_fume_kg_m3": args.silica_fume,
|
| 415 |
+
"water_kg_m3": args.water, "superplasticizer_kg_m3": args.sp,
|
| 416 |
+
"coarse_aggregate_kg_m3": args.coarse, "fine_aggregate_kg_m3": args.fine,
|
| 417 |
+
"age_days": args.age,
|
| 418 |
+
}
|
| 419 |
+
mix = {k: v for k, v in mix.items() if v is not None}
|
| 420 |
+
res = pred.predict_strength(mix)
|
| 421 |
+
print(f"input: {mix}")
|
| 422 |
+
print(f"GNN : {res['gnn']:.1f} MPa")
|
| 423 |
+
print(f"tabular : {res['tabular']:.1f} MPa")
|
| 424 |
+
flags = pred.out_of_range(mix)
|
| 425 |
+
if flags:
|
| 426 |
+
print(f"[warning] outside training range: {flags}")
|
| 427 |
+
else:
|
| 428 |
+
if not args.index:
|
| 429 |
+
ap.error("suggest needs --index (run app/build_inverse_index.py first)")
|
| 430 |
+
index = pd.read_csv(args.index)
|
| 431 |
+
out = pred.suggest_mixes(
|
| 432 |
+
args.target, index, k=args.k, tol=args.tol,
|
| 433 |
+
allow_fibre=not args.no_fibre, allow_scm=not args.no_scm,
|
| 434 |
+
refine=not args.no_refine,
|
| 435 |
+
)
|
| 436 |
+
pd.set_option("display.width", 220, "display.max_columns", 40)
|
| 437 |
+
print(out.to_string(index=False))
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
if __name__ == "__main__":
|
| 441 |
+
main()
|
inverse_index.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d2f2031959a2949acfc1e4ea8cc3081f4c666c89d5c4af3ed019ec0f787b7009
|
| 3 |
+
size 1848726
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CPU PyTorch wheels (keeps the Hugging Face Space image small).
|
| 2 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 3 |
+
|
| 4 |
+
# Pinned to the training environment so the saved (PyG-style) weights load
|
| 5 |
+
# identically. PyG 2.6 on torch 2.x uses torch.scatter_reduce for GINEConv /
|
| 6 |
+
# NNConv, so the compiled torch-scatter / torch-sparse extensions are NOT needed.
|
| 7 |
+
torch==2.5.1
|
| 8 |
+
torch_geometric==2.6.1
|
| 9 |
+
|
| 10 |
+
numpy
|
| 11 |
+
pandas
|
| 12 |
+
matplotlib
|
| 13 |
+
streamlit>=1.49
|
streamlit_app.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Concrete Compressive Strength — Predict / Design (Streamlit).
|
| 2 |
+
|
| 3 |
+
Two tools:
|
| 4 |
+
* Predict strength — enter a mix design (any field may be left blank), get the
|
| 5 |
+
predicted compressive strength.
|
| 6 |
+
* Design a mix — enter a target strength, get detailed mix designs that
|
| 7 |
+
reach it (drawn from real data, optionally refined). All
|
| 8 |
+
suggestions are reported at 28-day strength.
|
| 9 |
+
|
| 10 |
+
Run locally: streamlit run app/streamlit_app.py
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import pandas as pd
|
| 19 |
+
import streamlit as st
|
| 20 |
+
|
| 21 |
+
# Make sibling modules importable no matter how the script is launched
|
| 22 |
+
# (`streamlit run`, AppTest, or a Hugging Face Space at repo root).
|
| 23 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 24 |
+
|
| 25 |
+
from inference import (
|
| 26 |
+
AGE_COL,
|
| 27 |
+
DESIGN_COLS,
|
| 28 |
+
Predictor,
|
| 29 |
+
_default_ckpt_dir,
|
| 30 |
+
_default_index,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
st.set_page_config(
|
| 34 |
+
page_title="Concrete Compressive Strength",
|
| 35 |
+
page_icon="◧",
|
| 36 |
+
layout="wide",
|
| 37 |
+
initial_sidebar_state="collapsed",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
CKPT_DIR = _default_ckpt_dir()
|
| 41 |
+
INDEX_PATH = _default_index()
|
| 42 |
+
DESIGN_AGE = 28.0 # all design suggestions are reported at 28-day strength
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# Styling — editorial: cream canvas, black frame + top bar, big grotesque type.
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
INK = "#1a1a1a" # near-black text / accent (monochrome)
|
| 48 |
+
SUBTLE = "#6b6b63" # warm gray secondary text
|
| 49 |
+
CREAM = "#ffffff" # page background / light text on dark elements
|
| 50 |
+
CARD = "#f6f6f7" # faint card surface (separates groups on white)
|
| 51 |
+
HAIRLINE = "rgba(0,0,0,0.10)"
|
| 52 |
+
FONT = ('"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", '
|
| 53 |
+
'Roboto, Helvetica, Arial, sans-serif')
|
| 54 |
+
|
| 55 |
+
CSS = f"""
|
| 56 |
+
<style>
|
| 57 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
| 58 |
+
|
| 59 |
+
/* hide default Streamlit chrome */
|
| 60 |
+
#MainMenu, footer, header[data-testid="stHeader"] {{ display: none; }}
|
| 61 |
+
[data-testid="stSidebar"] {{ display: none; }}
|
| 62 |
+
|
| 63 |
+
html, body, .stApp, [class*="css"] {{ font-family: {FONT}; }}
|
| 64 |
+
.stApp {{ background-color: {CREAM}; color: {INK}; }}
|
| 65 |
+
.block-container {{ padding-top: 66px; padding-bottom: 3rem; max-width: 1300px; }}
|
| 66 |
+
|
| 67 |
+
/* black top bar with header labels (no side/bottom frame) */
|
| 68 |
+
.topbar {{ position: fixed; top: 0; left: 0; right: 0; height: 46px; z-index: 10000;
|
| 69 |
+
background: #000; display: flex; align-items: center; justify-content: space-between;
|
| 70 |
+
padding: 0 24px; pointer-events: none; }}
|
| 71 |
+
.topbar span {{ color: {CREAM}; font-size: 0.72rem; font-weight: 700;
|
| 72 |
+
letter-spacing: 0.14em; text-transform: uppercase; white-space: nowrap; }}
|
| 73 |
+
.topbar .center {{ position: absolute; left: 50%; transform: translateX(-50%); }}
|
| 74 |
+
|
| 75 |
+
/* hero: cube on the left, big editorial title on the right */
|
| 76 |
+
.hero {{ display: flex; align-items: center; gap: 52px; padding: 6px 0 4px; }}
|
| 77 |
+
.htext {{ text-align: left; }}
|
| 78 |
+
.htext h1 {{ font-size: 3rem; font-weight: 800; color: {INK};
|
| 79 |
+
letter-spacing: -0.03em; line-height: 1.0; margin: 0; }}
|
| 80 |
+
.htext h2 {{ font-size: 1.9rem; font-weight: 500; color: {INK}; opacity: 0.82;
|
| 81 |
+
letter-spacing: -0.02em; line-height: 1.05; margin: 4px 0 0; }}
|
| 82 |
+
.htext p {{ font-size: 1.06rem; font-weight: 400; color: {SUBTLE};
|
| 83 |
+
margin: 12px 0 0; max-width: 680px; }}
|
| 84 |
+
|
| 85 |
+
/* 3D rotating concrete block (transparent background, CSS only) */
|
| 86 |
+
.scene {{ width: 118px; height: 118px; flex-shrink: 0; perspective: 700px; }}
|
| 87 |
+
.cube {{ width: 90px; height: 90px; position: relative; transform-style: preserve-3d;
|
| 88 |
+
margin: 14px auto; animation: spin 16s linear infinite; }}
|
| 89 |
+
@keyframes spin {{
|
| 90 |
+
from {{ transform: rotateX(-24deg) rotateY(0deg); }}
|
| 91 |
+
to {{ transform: rotateX(-24deg) rotateY(360deg); }}
|
| 92 |
+
}}
|
| 93 |
+
.face {{ position: absolute; width: 90px; height: 90px;
|
| 94 |
+
border: 1px solid rgba(0,0,0,0.10);
|
| 95 |
+
box-shadow: inset 0 0 22px rgba(0,0,0,0.12); }}
|
| 96 |
+
.front {{ transform: translateZ(45px); background: linear-gradient(145deg,#cfccc4,#a8a59c); }}
|
| 97 |
+
.back {{ transform: rotateY(180deg) translateZ(45px); background: linear-gradient(145deg,#cfccc4,#a8a59c); }}
|
| 98 |
+
.right {{ transform: rotateY(90deg) translateZ(45px); background: linear-gradient(145deg,#c2bfb6,#9a978e); }}
|
| 99 |
+
.left {{ transform: rotateY(-90deg) translateZ(45px); background: linear-gradient(145deg,#c2bfb6,#9a978e); }}
|
| 100 |
+
.top {{ transform: rotateX(90deg) translateZ(45px); background: linear-gradient(145deg,#e0ddd3,#c4c1b8); }}
|
| 101 |
+
.bottom {{ transform: rotateX(-90deg) translateZ(45px); background: linear-gradient(145deg,#9a978e,#82807a); }}
|
| 102 |
+
|
| 103 |
+
/* tabs as a segmented control (left-aligned, black selected) */
|
| 104 |
+
.stTabs [data-baseweb="tab-list"] {{
|
| 105 |
+
background: #e7e3d7; border-radius: 10px; padding: 4px; gap: 4px;
|
| 106 |
+
width: fit-content; margin: 18px 0 12px; border: none;
|
| 107 |
+
}}
|
| 108 |
+
.stTabs [data-baseweb="tab"] {{ height: 40px; padding: 0 28px; background: transparent; border-radius: 7px; }}
|
| 109 |
+
.stTabs [data-baseweb="tab"] p {{ font-size: 1.0rem; font-weight: 700; color: {INK}; }}
|
| 110 |
+
.stTabs [aria-selected="true"] {{ background: {INK}; }}
|
| 111 |
+
.stTabs [aria-selected="true"] p {{ color: {CREAM}; }}
|
| 112 |
+
.stTabs [data-baseweb="tab-highlight"] {{ display: none; }}
|
| 113 |
+
|
| 114 |
+
/* step + section headings */
|
| 115 |
+
.step {{ font-size: 1.35rem; font-weight: 700; color: {INK};
|
| 116 |
+
letter-spacing: -0.01em; margin: 8px 0 0; }}
|
| 117 |
+
.sec {{ font-size: 0.76rem; font-weight: 700; color: {SUBTLE};
|
| 118 |
+
text-transform: uppercase; letter-spacing: 0.08em; margin: 2px 0 6px; }}
|
| 119 |
+
|
| 120 |
+
/* group cards */
|
| 121 |
+
[data-testid="stVerticalBlockBorderWrapper"] {{
|
| 122 |
+
background: {CARD}; border: 1px solid {HAIRLINE}; border-radius: 14px;
|
| 123 |
+
padding: 10px 20px 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
| 124 |
+
}}
|
| 125 |
+
|
| 126 |
+
/* result cards */
|
| 127 |
+
[data-testid="stMetric"] {{
|
| 128 |
+
background: {CARD}; border: 1px solid {HAIRLINE}; border-radius: 16px; padding: 22px 24px;
|
| 129 |
+
}}
|
| 130 |
+
[data-testid="stMetricValue"] {{ font-size: 2.5rem; font-weight: 800; color: {INK};
|
| 131 |
+
letter-spacing: -0.02em; }}
|
| 132 |
+
[data-testid="stMetricLabel"] p {{ color: {SUBTLE}; font-weight: 600; font-size: 0.95rem; }}
|
| 133 |
+
|
| 134 |
+
/* inputs */
|
| 135 |
+
[data-testid="stNumberInput"] input, [data-baseweb="select"] > div {{ border-radius: 9px; }}
|
| 136 |
+
|
| 137 |
+
/* buttons — solid black */
|
| 138 |
+
.stButton > button {{
|
| 139 |
+
background: {INK}; color: {CREAM}; border: 0; border-radius: 8px;
|
| 140 |
+
padding: 0.6rem 1.8rem; font-weight: 700; font-size: 1.0rem;
|
| 141 |
+
}}
|
| 142 |
+
.stButton > button:hover {{ background: #000; color: {CREAM}; }}
|
| 143 |
+
.stDownloadButton > button {{
|
| 144 |
+
background: transparent; color: {INK}; border: 1px solid {INK}; border-radius: 8px;
|
| 145 |
+
padding: 0.5rem 1.4rem; font-weight: 700;
|
| 146 |
+
}}
|
| 147 |
+
</style>
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
FRAME = """
|
| 151 |
+
<div class="frame"></div>
|
| 152 |
+
<div class="topbar">
|
| 153 |
+
<span class="left">Concrete Mix Toolkit</span>
|
| 154 |
+
<span class="center">Compressive Strength</span>
|
| 155 |
+
<span class="right">Predict / Design</span>
|
| 156 |
+
</div>
|
| 157 |
+
"""
|
| 158 |
+
|
| 159 |
+
HERO = """
|
| 160 |
+
<div class="hero">
|
| 161 |
+
<div class="scene"><div class="cube">
|
| 162 |
+
<div class="face front"></div><div class="face back"></div>
|
| 163 |
+
<div class="face right"></div><div class="face left"></div>
|
| 164 |
+
<div class="face top"></div><div class="face bottom"></div>
|
| 165 |
+
</div></div>
|
| 166 |
+
<div class="htext">
|
| 167 |
+
<h1>Concrete Compressive Strength</h1>
|
| 168 |
+
<h2>Predict & Design</h2>
|
| 169 |
+
<p>Estimate the strength of any concrete mix — or design a mix to hit a target strength.</p>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
"""
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@st.cache_resource(show_spinner="Loading…")
|
| 176 |
+
def get_predictor() -> Predictor:
|
| 177 |
+
return Predictor(CKPT_DIR)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@st.cache_data(show_spinner="Loading mix database…")
|
| 181 |
+
def get_index() -> pd.DataFrame:
|
| 182 |
+
return pd.read_csv(INDEX_PATH) if INDEX_PATH else pd.DataFrame()
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ---- friendly field definitions: (column, label, unit) ----
|
| 186 |
+
BINDER = [
|
| 187 |
+
("cement_kg_m3", "Cement", "kg/m³"),
|
| 188 |
+
("slag_kg_m3", "GGBS / slag", "kg/m³"),
|
| 189 |
+
("fly_ash_kg_m3", "Fly ash", "kg/m³"),
|
| 190 |
+
("silica_fume_kg_m3", "Silica fume", "kg/m³"),
|
| 191 |
+
("metakaolin_kg_m3", "Metakaolin", "kg/m³"),
|
| 192 |
+
("limestone_powder_kg_m3", "Limestone powder", "kg/m³"),
|
| 193 |
+
("other_scm_kg_m3", "Other SCM / filler", "kg/m³"),
|
| 194 |
+
]
|
| 195 |
+
AGG = [
|
| 196 |
+
("coarse_aggregate_kg_m3", "Coarse aggregate", "kg/m³"),
|
| 197 |
+
("fine_aggregate_kg_m3", "Fine aggregate / sand", "kg/m³"),
|
| 198 |
+
]
|
| 199 |
+
FIBRE = [
|
| 200 |
+
("fibre_content_kg_m3", "Fibre content", "kg/m³"),
|
| 201 |
+
("fibre_length_mm", "Fibre length", "mm"),
|
| 202 |
+
("fibre_diameter_mm", "Fibre diameter", "mm"),
|
| 203 |
+
("fibre_tensile_strength_mpa", "Fibre tensile strength", "MPa"),
|
| 204 |
+
("fibre_modulus_gpa", "Fibre modulus", "GPa"),
|
| 205 |
+
]
|
| 206 |
+
CHEM = [
|
| 207 |
+
("cement_CaO_pct", "Cement CaO", "%"), ("cement_SiO2_pct", "Cement SiO₂", "%"),
|
| 208 |
+
("cement_Al2O3_pct", "Cement Al₂O₃", "%"), ("cement_Fe2O3_pct", "Cement Fe₂O₃", "%"),
|
| 209 |
+
("cement_MgO_pct", "Cement MgO", "%"), ("cement_SO3_pct", "Cement SO₃", "%"),
|
| 210 |
+
("cement_alkali_pct", "Cement alkali (Na₂O-eq)", "%"), ("cement_LOI_pct", "Cement LOI", "%"),
|
| 211 |
+
("scm_CaO_pct", "SCM CaO", "%"), ("scm_SiO2_pct", "SCM SiO₂", "%"),
|
| 212 |
+
("scm_Al2O3_pct", "SCM Al₂O₃", "%"), ("scm_Fe2O3_pct", "SCM Fe₂O₃", "%"),
|
| 213 |
+
("scm_MgO_pct", "SCM MgO", "%"), ("scm_LOI_pct", "SCM LOI", "%"),
|
| 214 |
+
("cement_grade_mpa", "Cement grade", "MPa"),
|
| 215 |
+
("specimen_size_mm", "Specimen size", "mm"),
|
| 216 |
+
]
|
| 217 |
+
|
| 218 |
+
# Official-style labels -> the model's canonical categorical values.
|
| 219 |
+
CEMENT_TYPE_OPTIONS = {
|
| 220 |
+
"(unspecified)": None,
|
| 221 |
+
"CEM I — Ordinary Portland (OPC)": "opc",
|
| 222 |
+
"Portland Type I/II": "type_i_ii",
|
| 223 |
+
"Type III — Rapid-hardening": "type_iii",
|
| 224 |
+
"Sulfate-resisting (HS)": "hs",
|
| 225 |
+
"Other": "other",
|
| 226 |
+
}
|
| 227 |
+
FIBRE_TYPE_OPTIONS = {
|
| 228 |
+
"(unspecified)": None, "None": "none", "Steel": "steel",
|
| 229 |
+
"PVA": "pva", "PE / polyethylene": "pe", "Other": "other",
|
| 230 |
+
}
|
| 231 |
+
CURING_OPTIONS = {
|
| 232 |
+
"(unspecified)": None, "Standard": "standard", "Steam": "steam",
|
| 233 |
+
"Heat-cured": "heat", "Autoclave": "autoclave", "Other": "other",
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
# Map every raw column to a clear, units-bearing label for the results table.
|
| 237 |
+
COLUMN_LABELS = {c: f"{lab} ({u})" for c, lab, u in (BINDER + AGG + FIBRE + CHEM)}
|
| 238 |
+
COLUMN_LABELS.update({
|
| 239 |
+
"max_coarse_aggregate_size_mm": "Max coarse agg. size (mm)",
|
| 240 |
+
"max_fine_aggregate_size_mm": "Max fine agg. size (mm)",
|
| 241 |
+
"curing_temperature_c": "Curing temperature (°C)",
|
| 242 |
+
"curing_humidity_pct": "Curing humidity (%)",
|
| 243 |
+
"pred_gnn": "Predicted strength (MPa)",
|
| 244 |
+
"target_mpa": "Target (MPa)",
|
| 245 |
+
"water_binder_ratio": "Water / binder",
|
| 246 |
+
"scm_fraction": "SCM fraction",
|
| 247 |
+
"water_kg_m3": "Water (kg/m³)",
|
| 248 |
+
"superplasticizer_kg_m3": "Superplasticizer (kg/m³)",
|
| 249 |
+
"age_days": "Curing age (days)",
|
| 250 |
+
})
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _num(pred, col, label, unit, default=0.0, optional=False,
|
| 254 |
+
lo=None, hi=None, min_value=0.0, max_value=None):
|
| 255 |
+
"""Render a number_input. ``lo``/``hi`` override the 'typical' hint range;
|
| 256 |
+
``min_value``/``max_value`` set hard entry limits."""
|
| 257 |
+
b = pred.bounds.get(col, {})
|
| 258 |
+
p_lo = lo if lo is not None else b.get("p01")
|
| 259 |
+
p_hi = hi if hi is not None else b.get("p99")
|
| 260 |
+
help_txt = (f"typical {p_lo:.0f}–{p_hi:.0f} {unit}"
|
| 261 |
+
if (p_lo is not None and p_hi is not None) else None)
|
| 262 |
+
return st.number_input(
|
| 263 |
+
f"{label} ({unit})",
|
| 264 |
+
value=(None if optional else float(default)),
|
| 265 |
+
min_value=float(min_value),
|
| 266 |
+
max_value=(float(max_value) if max_value is not None else None),
|
| 267 |
+
step=1.0, help=help_txt, key=f"in_{col}",
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _step(title: str) -> None:
|
| 272 |
+
st.markdown(f"<div class='step'>{title}</div>", unsafe_allow_html=True)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _sec(title: str) -> None:
|
| 276 |
+
st.markdown(f"<div class='sec'>{title}</div>", unsafe_allow_html=True)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _category_select(label, options, mix, key):
|
| 280 |
+
choice = st.selectbox(label, list(options.keys()), index=0)
|
| 281 |
+
if options[choice] is not None:
|
| 282 |
+
mix[key] = options[choice]
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def format_suggestions(out: pd.DataFrame) -> pd.DataFrame:
|
| 286 |
+
"""Clean, clearly-named subset of the suggested mixes."""
|
| 287 |
+
always = {"cement_kg_m3", "water_kg_m3", "age_days"}
|
| 288 |
+
ing = [c for c in DESIGN_COLS if c in out.columns]
|
| 289 |
+
keep_ing = [c for c in ing if c in always
|
| 290 |
+
or pd.to_numeric(out[c], errors="coerce").fillna(0).abs().sum() > 0]
|
| 291 |
+
head = [c for c in ["pred_gnn", "water_binder_ratio", "scm_fraction"]
|
| 292 |
+
if c in out.columns]
|
| 293 |
+
disp = out[head + keep_ing].copy()
|
| 294 |
+
return disp.rename(columns=COLUMN_LABELS)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# ---------------------------------------------------------------------------
|
| 298 |
+
# Tab 1 — forward
|
| 299 |
+
# ---------------------------------------------------------------------------
|
| 300 |
+
def forward_tab(pred: Predictor) -> None:
|
| 301 |
+
_step("Enter your mix design")
|
| 302 |
+
st.caption("Leave any field blank if you don't have it. Amounts are per m³ of concrete.")
|
| 303 |
+
|
| 304 |
+
mix: dict = {}
|
| 305 |
+
c1, c2, c3 = st.columns(3, gap="large")
|
| 306 |
+
with c1:
|
| 307 |
+
with st.container(border=True):
|
| 308 |
+
_sec("Binder")
|
| 309 |
+
med = pred.bounds.get("cement_kg_m3", {}).get("p50", 350.0)
|
| 310 |
+
for col, label, unit in BINDER:
|
| 311 |
+
default = med if col == "cement_kg_m3" else 0.0
|
| 312 |
+
mix[col] = _num(pred, col, label, unit, default=default)
|
| 313 |
+
with c2:
|
| 314 |
+
with st.container(border=True):
|
| 315 |
+
_sec("Water & admixture")
|
| 316 |
+
mix["water_kg_m3"] = _num(
|
| 317 |
+
pred, "water_kg_m3", "Water", "kg/m³",
|
| 318 |
+
default=pred.bounds.get("water_kg_m3", {}).get("p50", 175.0), lo=120, hi=600,
|
| 319 |
+
)
|
| 320 |
+
mix["superplasticizer_kg_m3"] = _num(
|
| 321 |
+
pred, "superplasticizer_kg_m3", "Superplasticizer", "kg/m³",
|
| 322 |
+
default=pred.bounds.get("superplasticizer_kg_m3", {}).get("p50", 0.0),
|
| 323 |
+
)
|
| 324 |
+
with st.container(border=True):
|
| 325 |
+
_sec("Aggregate")
|
| 326 |
+
for col, label, unit in AGG:
|
| 327 |
+
d = pred.bounds.get(col, {}).get("p50", 0.0)
|
| 328 |
+
mix[col] = _num(pred, col, label, unit, default=d)
|
| 329 |
+
mix["max_coarse_aggregate_size_mm"] = _num(
|
| 330 |
+
pred, "max_coarse_aggregate_size_mm", "Max coarse agg. size", "mm",
|
| 331 |
+
optional=True, lo=10, hi=40, min_value=10, max_value=40,
|
| 332 |
+
)
|
| 333 |
+
mix["max_fine_aggregate_size_mm"] = _num(
|
| 334 |
+
pred, "max_fine_aggregate_size_mm", "Max fine agg. size", "mm", optional=True,
|
| 335 |
+
)
|
| 336 |
+
with c3:
|
| 337 |
+
with st.container(border=True):
|
| 338 |
+
_sec("Curing & age")
|
| 339 |
+
mix[AGE_COL] = st.number_input("Curing age (days)", value=28.0, min_value=1.0, step=1.0)
|
| 340 |
+
mix["curing_temperature_c"] = _num(
|
| 341 |
+
pred, "curing_temperature_c", "Curing temperature", "°C", optional=True)
|
| 342 |
+
mix["curing_humidity_pct"] = _num(
|
| 343 |
+
pred, "curing_humidity_pct", "Curing humidity", "%", optional=True)
|
| 344 |
+
|
| 345 |
+
with st.expander("Fibres (optional)"):
|
| 346 |
+
fc = st.columns(len(FIBRE))
|
| 347 |
+
for (col, label, unit), c in zip(FIBRE, fc):
|
| 348 |
+
with c:
|
| 349 |
+
mix[col] = _num(pred, col, label, unit, optional=True)
|
| 350 |
+
_category_select("Fibre type", FIBRE_TYPE_OPTIONS, mix, "fibre_type_norm")
|
| 351 |
+
|
| 352 |
+
with st.expander("Cement / SCM chemistry & type (advanced, optional)"):
|
| 353 |
+
gcols = st.columns(4)
|
| 354 |
+
for i, (col, label, unit) in enumerate(CHEM):
|
| 355 |
+
with gcols[i % 4]:
|
| 356 |
+
mix[col] = _num(pred, col, label, unit, optional=True)
|
| 357 |
+
s1, s2 = st.columns(2)
|
| 358 |
+
with s1:
|
| 359 |
+
_category_select("Cement type", CEMENT_TYPE_OPTIONS, mix, "cement_type_norm")
|
| 360 |
+
with s2:
|
| 361 |
+
_category_select("Curing regime", CURING_OPTIONS, mix, "curing_regime_norm")
|
| 362 |
+
|
| 363 |
+
show_curve = st.checkbox("Show strength gain with curing age", value=True)
|
| 364 |
+
st.write("")
|
| 365 |
+
go = st.button("Predict strength", type="primary")
|
| 366 |
+
|
| 367 |
+
if go:
|
| 368 |
+
clean = {k: v for k, v in mix.items() if v not in (None, "")}
|
| 369 |
+
with st.spinner("Predicting…"):
|
| 370 |
+
res = pred.predict_strength(clean)
|
| 371 |
+
st.write("")
|
| 372 |
+
m1, m2 = st.columns(2)
|
| 373 |
+
m1.metric("Predicted compressive strength", f"{res['gnn']:.1f} MPa")
|
| 374 |
+
m2.metric("Secondary estimate", f"{res['tabular']:.1f} MPa",
|
| 375 |
+
help="An independent model used as a cross-check.")
|
| 376 |
+
rmse = pred.config.get("test_metrics", {}).get("rmse")
|
| 377 |
+
if rmse:
|
| 378 |
+
st.caption(f"Typical accuracy ≈ ±{rmse:.0f} MPa on held-out test data.")
|
| 379 |
+
|
| 380 |
+
flags = pred.out_of_range(clean)
|
| 381 |
+
if flags:
|
| 382 |
+
pretty = ", ".join(COLUMN_LABELS.get(f, f) for f in flags)
|
| 383 |
+
st.warning(f"Some inputs are outside the usual data range ({pretty}); "
|
| 384 |
+
"the prediction there is an extrapolation.")
|
| 385 |
+
|
| 386 |
+
if show_curve:
|
| 387 |
+
curve = pred.age_curve(clean).rename(
|
| 388 |
+
columns={"gnn_mpa": "Predicted (MPa)", "tabular_mpa": "Secondary (MPa)"}
|
| 389 |
+
)
|
| 390 |
+
st.markdown("##### Predicted strength gain with curing age")
|
| 391 |
+
st.line_chart(curve.set_index("age_days"))
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# ---------------------------------------------------------------------------
|
| 395 |
+
# Tab 2 — inverse
|
| 396 |
+
# ---------------------------------------------------------------------------
|
| 397 |
+
def inverse_tab(pred: Predictor, index: pd.DataFrame) -> None:
|
| 398 |
+
_step("Choose a target strength")
|
| 399 |
+
if index.empty:
|
| 400 |
+
st.info("The mix database (`inverse_index.csv`) isn't built yet. "
|
| 401 |
+
"Run `python app/build_inverse_index.py` first.")
|
| 402 |
+
return
|
| 403 |
+
smin = float(pred.config.get("strength_min", 10))
|
| 404 |
+
smax = float(pred.config.get("strength_max", 200))
|
| 405 |
+
|
| 406 |
+
c1, c2 = st.columns([2, 1])
|
| 407 |
+
with c1:
|
| 408 |
+
target = st.slider("Target compressive strength (MPa)", smin, smax,
|
| 409 |
+
min(80.0, smax), step=1.0)
|
| 410 |
+
with c2:
|
| 411 |
+
k = st.slider("Number of mix options", 1, 4, 1)
|
| 412 |
+
|
| 413 |
+
o1, o2, o3 = st.columns(3)
|
| 414 |
+
allow_fibre = o1.checkbox("Allow fibres", value=True)
|
| 415 |
+
allow_scm = o2.checkbox("Allow SCMs", value=True)
|
| 416 |
+
refine = o3.checkbox("Refine to target", value=False,
|
| 417 |
+
help="Fine-tune each candidate so its predicted strength matches the target.")
|
| 418 |
+
st.caption("All suggestions are reported at 28-day strength.")
|
| 419 |
+
|
| 420 |
+
st.write("")
|
| 421 |
+
if st.button("Design mixes", type="primary"):
|
| 422 |
+
with st.spinner("Searching mix designs…"):
|
| 423 |
+
out = pred.suggest_mixes(
|
| 424 |
+
target, index, k=k, allow_fibre=allow_fibre, allow_scm=allow_scm,
|
| 425 |
+
domain="any", age=DESIGN_AGE, refine=refine,
|
| 426 |
+
)
|
| 427 |
+
if out.empty:
|
| 428 |
+
st.warning("No mixes match those constraints — try widening them.")
|
| 429 |
+
return
|
| 430 |
+
st.success(f"{len(out)} mix design(s) predicted to reach ≈ {target:.0f} MPa at 28 days")
|
| 431 |
+
disp = format_suggestions(out)
|
| 432 |
+
st.dataframe(disp, width="stretch", hide_index=True)
|
| 433 |
+
st.download_button("Download these mixes (CSV)", disp.to_csv(index=False),
|
| 434 |
+
file_name=f"mix_designs_{int(target)}MPa.csv", mime="text/csv")
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def main() -> None:
|
| 438 |
+
st.markdown(CSS, unsafe_allow_html=True)
|
| 439 |
+
st.markdown(FRAME, unsafe_allow_html=True)
|
| 440 |
+
st.markdown(HERO, unsafe_allow_html=True)
|
| 441 |
+
|
| 442 |
+
if not (Path(CKPT_DIR) / "hierarchical.pt").exists():
|
| 443 |
+
st.error(f"No trained model found in `{CKPT_DIR}`.")
|
| 444 |
+
st.stop()
|
| 445 |
+
|
| 446 |
+
pred = get_predictor()
|
| 447 |
+
index = get_index()
|
| 448 |
+
|
| 449 |
+
tab1, tab2 = st.tabs(["Predict strength", "Design a mix"])
|
| 450 |
+
with tab1:
|
| 451 |
+
forward_tab(pred)
|
| 452 |
+
with tab2:
|
| 453 |
+
inverse_tab(pred, index)
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
if __name__ == "__main__":
|
| 457 |
+
main()
|
| 458 |
+
else:
|
| 459 |
+
main()
|