Heterogeneity2025 commited on
Commit
3dc9f3b
·
verified ·
1 Parent(s): 5665108

Upload 29 files

Browse files
.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 &amp; 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()