Efradeca commited on
Commit
3e77c56
·
verified ·
1 Parent(s): de3eefe

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: pytorch
4
+ tags: [neural-operator, pde-solver, transolver, linear-attention, elasticity]
5
+ metrics: [relative-l2]
6
+ ---
7
+
8
+ # LinearNO — Elastic Stress Surrogate (Geo-FNO Elasticity)
9
+
10
+ Predicts the per-node **von Mises stress** of a hyper-elastic unit cell with a central void on
11
+ the Geo-FNO **Elasticity** benchmark. Attention block: **LinearNO** (asymmetric linear attention,
12
+ `shared_qk`, `project_out=False`), a drop-in
13
+ replacement for Transolver's Physics-Attention.
14
+
15
+ - **Test relative L2:** 0.005968 (this checkpoint).
16
+ - **Params:** 582275 (≤ the Transolver baseline, 713,665).
17
+ - **Interactive demo:** https://huggingface.co/spaces/Efradeca/elastic-stress-surrogate
18
+
19
+ ## Results (Geo-FNO Elasticity test relative L2, mean ± std over 3 seeds)
20
+
21
+ | Model | test rel-L2 | Params | Notes |
22
+ |---|---|---|---|
23
+ | Transolver baseline (reproduced) | 0.00678 ± 0.0012 | 713,665 | reproduces the published 0.0064 |
24
+ | LinearNO | 0.00664 ± 0.0008 | 713,089 | ≤ baseline params; does **not** reach the paper's 0.0050 |
25
+ | **LinearNO + equilibrium reg (this model)** | 0.00668 ± 0.0006 | 582,275 | accuracy preserved + **~360× lower ∇·σ residual** |
26
+
27
+ The equilibrium regularizer reduces the discrete equilibrium residual ‖∇·σ‖² by ~360× (6.1e6 → 1.7e4)
28
+ with no accuracy cost, and this physical consistency is **maintained out-of-distribution** (OOD residual
29
+ 1.7e4 vs in-dist 1.6e4). It does **not** improve OOD *accuracy* (degradation comparable to LinearNO) —
30
+ reported honestly, not massaged.
31
+
32
+ ## Usage
33
+ ```python
34
+ import torch, json
35
+ from safetensors.torch import load_file
36
+ from model import load_checkpoint # bundled loader (or use stress_operator.models.build_model)
37
+ ```
38
+
39
+ ## Honesty caveats (read these)
40
+ 1. **Scope:** a *narrow* result on one benchmark — **not** a foundation model. The material is
41
+ hyper-elastic (rubber-like), **not steel**; the method transfers, a steel result needs steel data.
42
+ 2. **LinearNO** has no official public repo; it was reimplemented from the paper's equations
43
+ (arXiv:2511.06294). Only the attention block differs from Transolver (ICML 2024, arXiv:2402.02366, MIT).
44
+ 3. **Reproduction variance:** small dataset (1000 train) → run-to-run variance; numbers are
45
+ eager (deterministic), mean ± std over seeds {0,1,2}.
46
+ 4. The interactive demo uses a **synthetic** mesh (no FEM ground truth for arbitrary void shapes).
47
+
48
+ ## Citation
49
+ Transolver (Wu et al., ICML 2024, arXiv:2402.02366) · LinearNO (Hu et al., AAAI 2026,
50
+ arXiv:2511.06294) · Geo-FNO (Li et al., 2022, arXiv:2207.05209).
config.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": {
3
+ "attention": "linearno",
4
+ "linearno_variant": "shared_qk",
5
+ "linearno_project_out": false,
6
+ "space_dim": 2,
7
+ "fun_dim": 0,
8
+ "out_dim": 3,
9
+ "n_layers": 8,
10
+ "n_hidden": 128,
11
+ "n_heads": 8,
12
+ "dim_head": 16,
13
+ "slice_num": 64,
14
+ "mlp_ratio": 1,
15
+ "dropout": 0.0,
16
+ "unified_pos": false,
17
+ "ref": 8
18
+ },
19
+ "normalizer": {
20
+ "mean": 187.73873901367188,
21
+ "std": 127.08287811279297
22
+ },
23
+ "scale_S": 187.73873901367188,
24
+ "metrics": {
25
+ "test_rel_l2": 0.005968,
26
+ "best_test_rel_l2": 0.005968,
27
+ "test_residual": 16361.362044798194,
28
+ "test_residual_scaled": 0.46420697510242465,
29
+ "scale_S": 187.73873901367188,
30
+ "train_rel_l2": 0.004208,
31
+ "n_params": 582275,
32
+ "epochs": 500,
33
+ "approach": "A",
34
+ "lambda": 0.01
35
+ },
36
+ "task": "geo-fno-elasticity-stress",
37
+ "license": "mit"
38
+ }
model.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CPU inference helpers for the demo (loads a trained checkpoint; handles 1- and 3-channel heads).
2
+
3
+ Kept dependency-light so it also works inside a Hugging Face Space. The model architecture is
4
+ imported from the installed ``stress_operator`` package; the HF export bundles a self-contained
5
+ copy (see scripts/05_export_to_hf.sh).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+
12
+ import numpy as np
13
+ import torch
14
+
15
+ # Make `stress_operator` importable in BOTH layouts: the local repo (package under ../src) and a
16
+ # bundled Hugging Face Space (package is a sibling of this file).
17
+ _HERE = os.path.dirname(os.path.abspath(__file__))
18
+ for _p in (_HERE, os.path.join(_HERE, "..", "src")):
19
+ if _p not in sys.path:
20
+ sys.path.insert(0, _p)
21
+
22
+ from stress_operator.models.transolver import build_model # noqa: E402
23
+
24
+
25
+ def load_checkpoint(ckpt_path: str, device: str = "cpu"):
26
+ """Load a trained model. Supports two formats:
27
+
28
+ - ``*.safetensors`` (HF deployment): weights from safetensors + ``config.json`` (sibling)
29
+ holding the model config, normalizer mean/std, and scale_S.
30
+ - ``*.pt`` (local training checkpoint): a dict with state_dict / normalizer / config.
31
+ """
32
+ if ckpt_path.endswith(".safetensors"):
33
+ import json
34
+
35
+ from safetensors.torch import load_file
36
+
37
+ cfg_path = os.path.join(os.path.dirname(ckpt_path) or ".", "config.json")
38
+ with open(cfg_path) as f:
39
+ cfg = json.load(f)
40
+ model_cfg = cfg["model"]
41
+ state_dict = load_file(ckpt_path, device=device)
42
+ mean = torch.tensor(float(cfg["normalizer"]["mean"]), device=device).reshape(1, 1, 1)
43
+ std = torch.tensor(float(cfg["normalizer"]["std"]), device=device).reshape(1, 1, 1)
44
+ scale_S = cfg.get("scale_S", None)
45
+ metrics = cfg.get("metrics", {})
46
+ else:
47
+ ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
48
+ model_cfg = ckpt["config"]["model"]
49
+ state_dict = ckpt["state_dict"]
50
+ mean = ckpt["normalizer"]["mean"].to(device)
51
+ std = ckpt["normalizer"]["std"].to(device)
52
+ scale_S = ckpt.get("scale_S", None)
53
+ metrics = ckpt.get("metrics", {})
54
+
55
+ model = build_model(model_cfg).to(device)
56
+ model.load_state_dict(state_dict)
57
+ model.eval()
58
+ info = {
59
+ "out_dim": model_cfg.get("out_dim", 1),
60
+ "scale_S": scale_S,
61
+ "attention": model_cfg.get("attention", "physics"),
62
+ "metrics": metrics,
63
+ }
64
+ return model, (mean, std), info
65
+
66
+
67
+ @torch.no_grad()
68
+ def predict_stress(model, coords: np.ndarray, norm, info, device: str = "cpu") -> np.ndarray:
69
+ """coords: (N, 2) -> per-node von Mises stress (N,) in physical units."""
70
+ mean, std = norm
71
+ x = torch.as_tensor(coords, dtype=torch.float32, device=device).unsqueeze(0) # (1,N,2)
72
+ out = model(x, None)[0] # (N, out_dim)
73
+ if info["out_dim"] == 3:
74
+ from stress_operator.losses.equilibrium import von_mises
75
+
76
+ S = info["scale_S"] or 1.0
77
+ stress = von_mises(out) * S # 3-channel tensor -> von Mises, undo target scale
78
+ else:
79
+ stress = out[:, 0] * std + mean # de-normalize scalar prediction
80
+ return stress.detach().cpu().numpy()
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0aebb1c82ef8734ca67b14fcb3b172033c4d2aa858297a594f305c18529cdc24
3
+ size 2340108
stress_operator/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Equilibrium-regularized linear neural operator for elastic stress fields.
2
+
3
+ Pipeline: reproduce Transolver baseline -> replace attention with LinearNO ->
4
+ add equilibrium-residual regularizer + OOD evaluation -> ship to Hugging Face.
5
+ """
6
+
7
+ __version__ = "0.1.0"
stress_operator/data/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .dataset import (
2
+ ElasticityDataset,
3
+ UnitTransformer,
4
+ load_raw_arrays,
5
+ build_splits,
6
+ build_splits_from_indices,
7
+ )
8
+
9
+ __all__ = [
10
+ "ElasticityDataset",
11
+ "UnitTransformer",
12
+ "load_raw_arrays",
13
+ "build_splits",
14
+ "build_splits_from_indices",
15
+ ]
stress_operator/data/dataset.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Geo-FNO Elasticity dataset.
2
+
3
+ Reconciled against the authoritative reference files (see ``docs/RECONCILIATION.md``):
4
+ - Geo-FNO ``elasticity/elas_geofno.py`` (raw .npy axis layout, filenames)
5
+ - Transolver ``exp_elas.py`` + ``utils/normalizer.py`` (split, output normalizer, loss scale)
6
+
7
+ Raw array layout (the **sample axis is LAST** in every raw ``.npy``):
8
+
9
+ Random_UnitCell_sigma_10.npy : (972, 2000) -> per-node von Mises stress (target)
10
+ Random_UnitCell_XY_10.npy : (972, 2, 2000) -> node coordinates (input)
11
+ Random_UnitCell_rr_10.npy : (42, 2000) -> void-boundary radii (geometry params; OOD)
12
+
13
+ Split (verbatim from ``exp_elas.py``): train = first ``ntrain`` samples, test = LAST ``ntest``,
14
+ out of 2000 total. The middle samples are unused.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from dataclasses import dataclass
20
+ from typing import Optional
21
+
22
+ import numpy as np
23
+ import torch
24
+ from torch.utils.data import Dataset
25
+
26
+ SIGMA_FILE = "Random_UnitCell_sigma_10.npy"
27
+ XY_FILE = "Random_UnitCell_XY_10.npy"
28
+ RR_FILE = "Random_UnitCell_rr_10.npy"
29
+
30
+ N_NODES = 972
31
+ N_TOTAL = 2000
32
+
33
+
34
+ class UnitTransformer:
35
+ """Global scalar z-score normalizer for the stress target.
36
+
37
+ Verbatim behavior of Transolver ``utils/normalizer.py::UnitTransformer``:
38
+ mean/std are reduced over dims ``(0, 1)`` (samples and nodes), giving a single
39
+ scalar mean and std. ``decode`` is applied to predictions before the metric.
40
+ """
41
+
42
+ def __init__(self, x: torch.Tensor):
43
+ self.mean = x.mean(dim=(0, 1), keepdim=True)
44
+ self.std = x.std(dim=(0, 1), keepdim=True) + 1e-8
45
+
46
+ def to(self, device) -> "UnitTransformer":
47
+ self.mean = self.mean.to(device)
48
+ self.std = self.std.to(device)
49
+ return self
50
+
51
+ def encode(self, x: torch.Tensor) -> torch.Tensor:
52
+ return (x - self.mean) / self.std
53
+
54
+ def decode(self, x: torch.Tensor) -> torch.Tensor:
55
+ return x * self.std + self.mean
56
+
57
+
58
+ def _find_file(data_dir: str, name: str) -> str:
59
+ """Locate ``name`` under ``data_dir`` (gdown may nest files in a subfolder)."""
60
+ direct = os.path.join(data_dir, name)
61
+ if os.path.isfile(direct):
62
+ return direct
63
+ for root, _dirs, files in os.walk(data_dir):
64
+ if name in files:
65
+ return os.path.join(root, name)
66
+ raise FileNotFoundError(
67
+ f"Could not find {name} under {data_dir!r}. "
68
+ f"Run `make data` (or python -m stress_operator.data.download --out {data_dir})."
69
+ )
70
+
71
+
72
+ def load_raw_arrays(data_dir: str):
73
+ """Load and reorient the raw arrays so the sample axis is first.
74
+
75
+ Returns float32 tensors:
76
+ coords : (N_TOTAL, 972, 2)
77
+ sigma : (N_TOTAL, 972) physical (de-normalized) stress
78
+ rr : (N_TOTAL, 42) geometry params (may be absent -> None)
79
+ """
80
+ sigma_np = np.load(_find_file(data_dir, SIGMA_FILE)) # (972, 2000)
81
+ xy_np = np.load(_find_file(data_dir, XY_FILE)) # (972, 2, 2000)
82
+
83
+ sigma = torch.tensor(sigma_np, dtype=torch.float).permute(1, 0).contiguous() # (2000, 972)
84
+ coords = torch.tensor(xy_np, dtype=torch.float).permute(2, 0, 1).contiguous() # (2000, 972, 2)
85
+
86
+ rr: Optional[torch.Tensor] = None
87
+ try:
88
+ rr_np = np.load(_find_file(data_dir, RR_FILE)) # (42, 2000)
89
+ rr = torch.tensor(rr_np, dtype=torch.float).permute(1, 0).contiguous() # (2000, 42)
90
+ except FileNotFoundError:
91
+ rr = None
92
+
93
+ return coords, sigma, rr
94
+
95
+
96
+ @dataclass
97
+ class Splits:
98
+ train_coords: torch.Tensor
99
+ train_sigma: torch.Tensor # physical
100
+ test_coords: torch.Tensor
101
+ test_sigma: torch.Tensor # physical
102
+ normalizer: UnitTransformer
103
+ train_rr: Optional[torch.Tensor] = None
104
+ test_rr: Optional[torch.Tensor] = None
105
+
106
+
107
+ def build_splits(data_dir: str, ntrain: int = 1000, ntest: int = 200) -> Splits:
108
+ """Build the train/test split exactly as ``exp_elas.py`` does.
109
+
110
+ train = first ``ntrain``; test = LAST ``ntest``. The normalizer is fit on the
111
+ (physical) train stress only.
112
+ """
113
+ coords, sigma, rr = load_raw_arrays(data_dir)
114
+
115
+ train_coords = coords[:ntrain]
116
+ train_sigma = sigma[:ntrain]
117
+ test_coords = coords[-ntest:]
118
+ test_sigma = sigma[-ntest:]
119
+
120
+ normalizer = UnitTransformer(train_sigma)
121
+
122
+ train_rr = rr[:ntrain] if rr is not None else None
123
+ test_rr = rr[-ntest:] if rr is not None else None
124
+
125
+ return Splits(
126
+ train_coords=train_coords,
127
+ train_sigma=train_sigma,
128
+ test_coords=test_coords,
129
+ test_sigma=test_sigma,
130
+ normalizer=normalizer,
131
+ train_rr=train_rr,
132
+ test_rr=test_rr,
133
+ )
134
+
135
+
136
+ def build_splits_from_indices(data_dir: str, train_idx, test_idx) -> Splits:
137
+ """Build a split from explicit sample indices over all 2000 samples (used by the OOD eval).
138
+
139
+ The normalizer is fit on the (physical) train stress only.
140
+ """
141
+ coords, sigma, rr = load_raw_arrays(data_dir)
142
+ train_idx = torch.as_tensor(np.asarray(train_idx), dtype=torch.long)
143
+ test_idx = torch.as_tensor(np.asarray(test_idx), dtype=torch.long)
144
+
145
+ train_sigma = sigma[train_idx]
146
+ normalizer = UnitTransformer(train_sigma)
147
+ return Splits(
148
+ train_coords=coords[train_idx],
149
+ train_sigma=train_sigma,
150
+ test_coords=coords[test_idx],
151
+ test_sigma=sigma[test_idx],
152
+ normalizer=normalizer,
153
+ train_rr=(rr[train_idx] if rr is not None else None),
154
+ test_rr=(rr[test_idx] if rr is not None else None),
155
+ )
156
+
157
+
158
+ class ElasticityDataset(Dataset):
159
+ """Yields ``(coords, sigma)`` per sample.
160
+
161
+ ``coords`` : (972, 2) node coordinates (model input)
162
+ ``sigma`` : (972, 1) **physical** stress target
163
+
164
+ Normalization is intentionally *not* applied here: the training loop predicts in
165
+ normalized space and decodes before the relative-L2 loss (identical to ``exp_elas.py``;
166
+ see ``docs/RECONCILIATION.md`` §4). Keeping physical targets in the dataset makes the
167
+ de-normalize-before-metric contract explicit and impossible to forget.
168
+ """
169
+
170
+ def __init__(self, coords: torch.Tensor, sigma: torch.Tensor):
171
+ assert coords.shape[0] == sigma.shape[0]
172
+ self.coords = coords
173
+ # ensure (n, 972, 1)
174
+ self.sigma = sigma if sigma.dim() == 3 else sigma.unsqueeze(-1)
175
+
176
+ def __len__(self) -> int:
177
+ return self.coords.shape[0]
178
+
179
+ def __getitem__(self, idx: int):
180
+ return self.coords[idx], self.sigma[idx]
stress_operator/data/download.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download + verify the Geo-FNO Elasticity dataset.
2
+
3
+ Source: the Geo-FNO Google Drive collection (license MIT, ``neuraloperator/Geo-FNO``).
4
+
5
+ IMPORTANT: the canonical Drive *folder*
6
+ ``https://drive.google.com/drive/folders/1YBuaoTdOSr_qzaow-G-iwvbUI7fiUzu8`` is the **entire**
7
+ neuraloperator dataset collection (~8 GB: airfoil, car-cfd, channel-shocks, elasticity, ...).
8
+ For this project we only need three files (~47 MB total), so we fetch them **by file id**
9
+ rather than downloading the whole folder. The ids below were resolved once from that folder
10
+ (``gdown.download_folder(..., skip_download=True)``) — see ``docs/RECONCILIATION.md``.
11
+
12
+ Usage:
13
+ python -m stress_operator.data.download --out data
14
+ python -m stress_operator.data.download --out data --report-only # just verify + print shapes
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import hashlib
20
+ import os
21
+ import sys
22
+
23
+ import numpy as np
24
+
25
+ # Resolved file ids for elasticity/Meshes/* inside the Geo-FNO Drive collection.
26
+ DRIVE_FILE_IDS = {
27
+ "Random_UnitCell_sigma_10.npy": "1Ia5izgUum-IQLdO6PW70HO8AdAqA_IVb",
28
+ "Random_UnitCell_XY_10.npy": "1I-fO-RsFvD3nqBuFrg67R0yqTFdD_gpA",
29
+ "Random_UnitCell_rr_10.npy": "1Pjliqhxegoe5VpoLrpBa9n3P4gX9MfTt",
30
+ }
31
+ # Mirror Transolver's expected layout: <out>/elasticity/Meshes/<file>.
32
+ SUBDIR = os.path.join("elasticity", "Meshes")
33
+
34
+ REQUIRED_FILES = ["Random_UnitCell_sigma_10.npy", "Random_UnitCell_XY_10.npy"]
35
+
36
+ # Authoritative raw shapes (sample axis LAST). See docs/RECONCILIATION.md.
37
+ EXPECTED_RAW_SHAPES = {
38
+ "Random_UnitCell_sigma_10.npy": (972, 2000),
39
+ "Random_UnitCell_XY_10.npy": (972, 2, 2000),
40
+ "Random_UnitCell_rr_10.npy": (42, 2000),
41
+ }
42
+
43
+
44
+ def _find(data_dir: str, name: str):
45
+ """Locate ``name`` anywhere under ``data_dir`` (handles nested layouts)."""
46
+ direct = os.path.join(data_dir, name)
47
+ if os.path.isfile(direct):
48
+ return direct
49
+ for root, _dirs, files in os.walk(data_dir):
50
+ if name in files:
51
+ return os.path.join(root, name)
52
+ return None
53
+
54
+
55
+ def have_all(data_dir: str) -> bool:
56
+ return all(_find(data_dir, f) is not None for f in REQUIRED_FILES)
57
+
58
+
59
+ def download(out_dir: str, force: bool = False) -> None:
60
+ import gdown
61
+
62
+ dest_dir = os.path.join(out_dir, SUBDIR)
63
+ os.makedirs(dest_dir, exist_ok=True)
64
+ for name, file_id in DRIVE_FILE_IDS.items():
65
+ existing = _find(out_dir, name)
66
+ if existing is not None and not force:
67
+ print(f"[download] {name}: already present ({existing}); skipping.")
68
+ continue
69
+ out_path = os.path.join(dest_dir, name)
70
+ print(f"[download] fetching {name} (id={file_id}) -> {out_path}")
71
+ gdown.download(id=file_id, output=out_path, quiet=False)
72
+
73
+
74
+ def _md5(path: str, chunk: int = 1 << 20) -> str:
75
+ h = hashlib.md5()
76
+ with open(path, "rb") as f:
77
+ for block in iter(lambda: f.read(chunk), b""):
78
+ h.update(block)
79
+ return h.hexdigest()
80
+
81
+
82
+ def report(data_dir: str) -> int:
83
+ """Print shape/dtype/stats and check against expected raw shapes. Returns exit code."""
84
+ ok = True
85
+ print(f"[report] scanning {data_dir!r}")
86
+ for name in DRIVE_FILE_IDS:
87
+ path = _find(data_dir, name)
88
+ required = name in REQUIRED_FILES
89
+ if path is None:
90
+ print(f" - {name}: {'MISSING (required)' if required else 'missing (optional)'}")
91
+ if required:
92
+ ok = False
93
+ continue
94
+ arr = np.load(path)
95
+ exp = EXPECTED_RAW_SHAPES.get(name)
96
+ flag = "" if (exp is None or tuple(arr.shape) == tuple(exp)) else f" <-- WARNING: expected {exp}"
97
+ print(
98
+ f" - {name}: shape={tuple(arr.shape)} dtype={arr.dtype} "
99
+ f"min={float(arr.min()):.4g} max={float(arr.max()):.4g} md5={_md5(path)[:8]}{flag}"
100
+ )
101
+ print("[report] OK" if ok else "[report] FAILED: required files missing")
102
+ return 0 if ok else 1
103
+
104
+
105
+ def main() -> int:
106
+ ap = argparse.ArgumentParser(description="Download/verify the Geo-FNO Elasticity dataset (3 files).")
107
+ ap.add_argument("--out", default="data", help="output directory")
108
+ ap.add_argument("--report-only", action="store_true", help="skip download, just verify + print shapes")
109
+ ap.add_argument("--force", action="store_true", help="re-download even if files exist")
110
+ args = ap.parse_args()
111
+
112
+ if not args.report_only:
113
+ download(args.out, force=args.force)
114
+ return report(args.out)
115
+
116
+
117
+ if __name__ == "__main__":
118
+ sys.exit(main())
stress_operator/data/ood_split.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Out-of-distribution (covariate-shift) split from void-geometry statistics (Gate D).
2
+
3
+ No new FEM is needed: we re-use the existing 2000 samples but split them by a per-sample
4
+ geometric statistic of the void boundary radii ``rr`` (shape ``(n, 42)``). The model trains on
5
+ the lower percentile (typical voids) and is tested on the upper percentile (large/extreme voids
6
+ it never saw) — a genuine covariate shift in geometry.
7
+
8
+ Split rule (documented exactly, master plan §2.6):
9
+ stat_i = STAT(rr_i) # default STAT = max radius ("void size")
10
+ threshold = quantile(stat, train_frac) # default train_frac = 0.80
11
+ in-distribution (train+id-test): stat_i <= threshold (lower ~80%)
12
+ out-of-distribution (ood-test) : stat_i > threshold (upper ~20%)
13
+ The in-distribution pool is further split into train / id-test by ``id_test_frac`` (held-out,
14
+ seeded) so in-dist and OOD errors are both measured on data unseen during training.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Dict
20
+
21
+ import numpy as np
22
+ import torch
23
+
24
+ STATS = {
25
+ "max": lambda rr: rr.max(axis=1), # largest radius -> void size (primary)
26
+ "mean": lambda rr: rr.mean(axis=1),
27
+ "std": lambda rr: rr.std(axis=1), # radius spread -> "lobiness"
28
+ "range": lambda rr: rr.max(axis=1) - rr.min(axis=1),
29
+ }
30
+
31
+
32
+ def geometry_stat(rr: torch.Tensor, kind: str = "max") -> np.ndarray:
33
+ if kind not in STATS:
34
+ raise ValueError(f"unknown stat {kind!r}; choose from {list(STATS)}")
35
+ return STATS[kind](rr.detach().cpu().numpy())
36
+
37
+
38
+ @dataclass
39
+ class OODSplit:
40
+ train_idx: np.ndarray # in-distribution, used for training
41
+ id_test_idx: np.ndarray # in-distribution, held out for testing
42
+ ood_test_idx: np.ndarray # out-of-distribution (extreme geometry), held out
43
+ threshold: float
44
+ stat_kind: str
45
+ info: Dict
46
+
47
+
48
+ def make_ood_split(
49
+ rr: torch.Tensor,
50
+ stat_kind: str = "max",
51
+ train_frac: float = 0.80,
52
+ id_test_frac: float = 0.20,
53
+ seed: int = 0,
54
+ ) -> OODSplit:
55
+ """Build a covariate-shift split from ``rr`` statistics. Deterministic given ``seed``."""
56
+ stat = geometry_stat(rr, stat_kind) # (n,)
57
+ n = stat.shape[0]
58
+ threshold = float(np.quantile(stat, train_frac))
59
+
60
+ in_dist = np.where(stat <= threshold)[0]
61
+ ood = np.where(stat > threshold)[0]
62
+
63
+ rng = np.random.default_rng(seed)
64
+ perm = rng.permutation(in_dist)
65
+ n_id_test = int(round(len(in_dist) * id_test_frac))
66
+ id_test_idx = np.sort(perm[:n_id_test])
67
+ train_idx = np.sort(perm[n_id_test:])
68
+
69
+ info = {
70
+ "n_total": int(n),
71
+ "n_train": int(train_idx.size),
72
+ "n_id_test": int(id_test_idx.size),
73
+ "n_ood_test": int(ood.size),
74
+ "threshold": threshold,
75
+ "stat_kind": stat_kind,
76
+ "stat_train_max": float(stat[train_idx].max()),
77
+ "stat_ood_min": float(stat[ood].min()),
78
+ "stat_ood_max": float(stat[ood].max()),
79
+ }
80
+ return OODSplit(
81
+ train_idx=train_idx,
82
+ id_test_idx=id_test_idx,
83
+ ood_test_idx=np.sort(ood),
84
+ threshold=threshold,
85
+ stat_kind=stat_kind,
86
+ info=info,
87
+ )
stress_operator/eval.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation: mean test relative-L2 on a split (de-normalized).
2
+
3
+ Mirrors the eval loop in Transolver ``exp_elas.py``: predictions are decoded with the
4
+ (train-fitted) normalizer before the metric; targets are physical.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import torch
9
+ from torch.utils.data import DataLoader
10
+
11
+ from .losses.relative_l2 import relative_l2
12
+
13
+
14
+ @torch.no_grad()
15
+ def evaluate(model, loader: DataLoader, normalizer, device) -> float:
16
+ """Return mean relative-L2 over the loader (physical units)."""
17
+ model.eval()
18
+ total = 0.0
19
+ n = 0
20
+ for coords, sigma in loader:
21
+ coords = coords.to(device)
22
+ sigma = sigma.to(device) # physical (B, N, 1)
23
+ out = model(coords, None) # (B, N, 1) normalized
24
+ out = normalizer.decode(out) # -> physical
25
+ total += relative_l2(out, sigma, reduction="sum").item()
26
+ n += coords.shape[0]
27
+ return total / max(n, 1)
stress_operator/losses/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .relative_l2 import relative_l2, RelativeL2Loss
2
+
3
+ __all__ = ["relative_l2", "RelativeL2Loss"]
stress_operator/losses/equilibrium.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Discrete equilibrium-residual operators on the unstructured point cloud (Gate C).
2
+
3
+ Static mechanical equilibrium with no body force requires the stress tensor to be
4
+ divergence-free in the interior: div(sigma) = 0, i.e.
5
+ d sigma_xx/dx + d sigma_xy/dy = 0
6
+ d sigma_xy/dx + d sigma_yy/dy = 0
7
+ This holds in the interior regardless of boundary conditions, so it is a valid soft constraint.
8
+
9
+ Two operators (master plan §2.6):
10
+ - Approach A (principled): per-node spatial gradient via a weighted moving-least-squares (MLS)
11
+ fit over the kNN neighborhood. Exact for linear fields -> validatable analytically. The model
12
+ outputs 3 channels (sigma_xx, sigma_yy, sigma_xy); the equilibrium residual is
13
+ L_eq = mean ||div(sigma)||^2 over interior nodes.
14
+ - Approach B (fallback): graph-Laplacian smoothness prior L_smooth = mean (L f)^2 — a
15
+ plausibility prior *motivated by* (not equal to) equilibrium.
16
+
17
+ MANDATORY: validate against an analytic field (tests/test_equilibrium.py) before using in the loss.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from typing import Optional, Tuple
22
+
23
+ import numpy as np
24
+ import torch
25
+
26
+
27
+ def knn_indices(coords: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
28
+ """Return (dist, idx) of the k nearest neighbors (excluding self) for each node."""
29
+ try:
30
+ from scipy.spatial import cKDTree
31
+
32
+ tree = cKDTree(coords)
33
+ dist, idx = tree.query(coords, k=k + 1)
34
+ except Exception: # pragma: no cover - scipy fallback
35
+ diff = coords[:, None, :] - coords[None, :, :]
36
+ d2 = (diff ** 2).sum(-1)
37
+ idx = np.argsort(d2, axis=1)[:, : k + 1]
38
+ dist = np.sqrt(np.take_along_axis(d2, idx, axis=1))
39
+ return dist[:, 1:], idx[:, 1:] # drop self (column 0)
40
+
41
+
42
+ def build_mls_gradient_operators(
43
+ coords: torch.Tensor, k: int = 12, eps: float = 1e-9
44
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
45
+ """Build dense (N, N) operators Gx, Gy with (Gx @ f) ~ df/dx and (Gy @ f) ~ df/dy.
46
+
47
+ MLS: per node i, minimize sum_j w_ij (f_j - f_i - g . (x_j - x_i))^2 for g = grad f_i.
48
+ The solution is linear in f, so the per-node stencil rows form constant matrices Gx, Gy
49
+ (depend only on the mesh). Exact for linear f (reproduces affine functions).
50
+ """
51
+ cpu = coords.detach().cpu().numpy().astype(np.float64)
52
+ n = cpu.shape[0]
53
+ dist, idx = knn_indices(cpu, k)
54
+ sigma2 = float((dist.mean()) ** 2) + eps # Gaussian bandwidth ~ mean neighbor distance
55
+
56
+ Gx = np.zeros((n, n), dtype=np.float64)
57
+ Gy = np.zeros((n, n), dtype=np.float64)
58
+ for i in range(n):
59
+ nbr = idx[i]
60
+ d = cpu[nbr] - cpu[i] # (k, 2)
61
+ w = np.exp(-(d ** 2).sum(1) / sigma2) # (k,)
62
+ A = np.einsum("k,ka,kb->ab", w, d, d) # (2, 2) weighted moment matrix
63
+ Ainv = np.linalg.pinv(A) # robust to near-degenerate neighborhoods
64
+ C = (w[:, None] * d) @ Ainv # (k, 2) coefficient per neighbor
65
+ Gx[i, nbr] += C[:, 0]
66
+ Gx[i, i] -= C[:, 0].sum()
67
+ Gy[i, nbr] += C[:, 1]
68
+ Gy[i, i] -= C[:, 1].sum()
69
+ return (
70
+ torch.tensor(Gx, dtype=torch.float32, device=coords.device),
71
+ torch.tensor(Gy, dtype=torch.float32, device=coords.device),
72
+ )
73
+
74
+
75
+ def build_graph_laplacian(coords: torch.Tensor, k: int = 12, eps: float = 1e-9) -> torch.Tensor:
76
+ """Unnormalized graph Laplacian L = D - W with Gaussian kNN weights (Approach B)."""
77
+ cpu = coords.detach().cpu().numpy().astype(np.float64)
78
+ n = cpu.shape[0]
79
+ dist, idx = knn_indices(cpu, k)
80
+ sigma2 = float((dist.mean()) ** 2) + eps
81
+ W = np.zeros((n, n), dtype=np.float64)
82
+ for i in range(n):
83
+ nbr = idx[i]
84
+ w = np.exp(-(dist[i] ** 2) / sigma2)
85
+ W[i, nbr] = w
86
+ W = 0.5 * (W + W.T) # symmetrize
87
+ L = np.diag(W.sum(1)) - W
88
+ return torch.tensor(L, dtype=torch.float32, device=coords.device)
89
+
90
+
91
+ def interior_mask(coords: torch.Tensor, tol: float = 0.03) -> torch.Tensor:
92
+ """Boolean mask of interior nodes: inside the [0,1]^2 outer box by ``tol`` on every side.
93
+
94
+ The outer-box boundary carries the applied traction / clamp, where div(sigma)=0 need not
95
+ hold; interior nodes (including those around the void) are valid for the constraint.
96
+ """
97
+ x, y = coords[..., 0], coords[..., 1]
98
+ return (x > tol) & (x < 1 - tol) & (y > tol) & (y < 1 - tol)
99
+
100
+
101
+ def divergence(stress3: torch.Tensor, Gx: torch.Tensor, Gy: torch.Tensor) -> torch.Tensor:
102
+ """Divergence of the symmetric 2x2 stress tensor field.
103
+
104
+ stress3: (..., N, 3) = (sigma_xx, sigma_yy, sigma_xy). Returns (..., N, 2) = (div_x, div_y).
105
+ """
106
+ sxx = stress3[..., 0]
107
+ syy = stress3[..., 1]
108
+ sxy = stress3[..., 2]
109
+ div_x = torch.einsum("ij,...j->...i", Gx, sxx) + torch.einsum("ij,...j->...i", Gy, sxy)
110
+ div_y = torch.einsum("ij,...j->...i", Gx, sxy) + torch.einsum("ij,...j->...i", Gy, syy)
111
+ return torch.stack([div_x, div_y], dim=-1)
112
+
113
+
114
+ def equilibrium_residual(
115
+ stress3: torch.Tensor,
116
+ Gx: torch.Tensor,
117
+ Gy: torch.Tensor,
118
+ mask: Optional[torch.Tensor] = None,
119
+ ) -> torch.Tensor:
120
+ """L_eq = mean ||div(sigma)||^2 over interior nodes. stress3: (..., N, 3)."""
121
+ div = divergence(stress3, Gx, Gy) # (..., N, 2)
122
+ sq = (div ** 2).sum(-1) # (..., N)
123
+ if mask is not None:
124
+ sq = sq[..., mask]
125
+ return sq.mean()
126
+
127
+
128
+ def laplacian_smoothness(field: torch.Tensor, L: torch.Tensor) -> torch.Tensor:
129
+ """Approach B: L_smooth = mean (L f)^2. field: (..., N) or (..., N, 1)."""
130
+ f = field[..., 0] if field.dim() >= 2 and field.shape[-1] == 1 else field
131
+ Lf = torch.einsum("ij,...j->...i", L, f)
132
+ return (Lf ** 2).mean()
133
+
134
+
135
+ def von_mises(stress3: torch.Tensor) -> torch.Tensor:
136
+ """von Mises stress from (sigma_xx, sigma_yy, sigma_xy): sqrt(sxx^2 - sxx*syy + syy^2 + 3 sxy^2)."""
137
+ sxx = stress3[..., 0]
138
+ syy = stress3[..., 1]
139
+ sxy = stress3[..., 2]
140
+ return torch.sqrt(torch.clamp(sxx ** 2 - sxx * syy + syy ** 2 + 3 * sxy ** 2, min=0.0) + 1e-12)
stress_operator/losses/relative_l2.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Relative-L2 metric and data loss.
2
+
3
+ Faithful to Transolver ``utils/testloss.py::TestLoss.rel`` (the number compared to the
4
+ published 0.0064 / 0.0050). Computed **after de-normalizing** predictions (see
5
+ ``docs/RECONCILIATION.md`` §4-5). No denominator epsilon is used by default, matching upstream.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import torch
10
+ import torch.nn as nn
11
+
12
+
13
+ def relative_l2(
14
+ pred: torch.Tensor,
15
+ true: torch.Tensor,
16
+ reduction: str = "mean",
17
+ eps: float = 0.0,
18
+ ) -> torch.Tensor:
19
+ """Per-sample relative L2: ``||pred - true||_2 / ||true||_2``.
20
+
21
+ Args:
22
+ pred, true: tensors shaped ``(B, ...)``; everything after dim 0 is flattened
23
+ per sample. Must be **de-normalized** (physical units).
24
+ reduction: ``"mean"`` (default), ``"sum"`` (matches ``size_average=False`` upstream
25
+ before the caller divides by ``n``), or ``"none"`` for the per-sample vector.
26
+ eps: optional denominator epsilon (default 0.0 to match upstream exactly).
27
+ """
28
+ b = pred.shape[0]
29
+ diff = torch.linalg.vector_norm(pred.reshape(b, -1) - true.reshape(b, -1), ord=2, dim=1)
30
+ den = torch.linalg.vector_norm(true.reshape(b, -1), ord=2, dim=1)
31
+ rel = diff / (den + eps)
32
+ if reduction == "mean":
33
+ return rel.mean()
34
+ if reduction == "sum":
35
+ return rel.sum()
36
+ if reduction == "none":
37
+ return rel
38
+ raise ValueError(f"unknown reduction {reduction!r}")
39
+
40
+
41
+ class RelativeL2Loss(nn.Module):
42
+ """Module wrapper around :func:`relative_l2`."""
43
+
44
+ def __init__(self, reduction: str = "mean", eps: float = 0.0):
45
+ super().__init__()
46
+ self.reduction = reduction
47
+ self.eps = eps
48
+
49
+ def forward(self, pred: torch.Tensor, true: torch.Tensor) -> torch.Tensor:
50
+ return relative_l2(pred, true, reduction=self.reduction, eps=self.eps)
stress_operator/models/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .transolver import StressOperator, build_model, count_parameters, make_attention
2
+
3
+ __all__ = ["StressOperator", "build_model", "count_parameters", "make_attention"]
stress_operator/models/blocks.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Building blocks: MLP and the pre-norm Transolver block.
2
+
3
+ Faithful to Transolver ``model/Transolver_Irregular_Mesh.py`` (MIT). The only structural
4
+ change is that the attention module is injected (so Stage 2 can swap Physics-Attention for
5
+ LinearNO without touching anything else).
6
+ """
7
+ import torch.nn as nn
8
+
9
+ ACTIVATION = {
10
+ "gelu": nn.GELU,
11
+ "tanh": nn.Tanh,
12
+ "sigmoid": nn.Sigmoid,
13
+ "relu": nn.ReLU,
14
+ "leaky_relu": lambda: nn.LeakyReLU(0.1),
15
+ "softplus": nn.Softplus,
16
+ "ELU": nn.ELU,
17
+ "silu": nn.SiLU,
18
+ }
19
+
20
+
21
+ class MLP(nn.Module):
22
+ """Transolver MLP: Linear->act (->[Linear->act]^n_layers) ->Linear, optional residual."""
23
+
24
+ def __init__(self, n_input, n_hidden, n_output, n_layers=1, act="gelu", res=True):
25
+ super().__init__()
26
+ if act not in ACTIVATION:
27
+ raise NotImplementedError(act)
28
+ act_cls = ACTIVATION[act]
29
+ self.n_layers = n_layers
30
+ self.res = res
31
+ self.linear_pre = nn.Sequential(nn.Linear(n_input, n_hidden), act_cls())
32
+ self.linear_post = nn.Linear(n_hidden, n_output)
33
+ self.linears = nn.ModuleList(
34
+ [nn.Sequential(nn.Linear(n_hidden, n_hidden), act_cls()) for _ in range(n_layers)]
35
+ )
36
+
37
+ def forward(self, x):
38
+ x = self.linear_pre(x)
39
+ for layer in self.linears:
40
+ x = layer(x) + x if self.res else layer(x)
41
+ return self.linear_post(x)
42
+
43
+
44
+ class TransolverBlock(nn.Module):
45
+ """Pre-norm transformer block; the last block also carries the decoder head.
46
+
47
+ fx = fx + Attn(LayerNorm(fx))
48
+ fx = fx + MLP(LayerNorm(fx))
49
+ (last block) return Linear(LayerNorm(fx)) -> out_dim
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ attention: nn.Module,
55
+ hidden_dim: int,
56
+ dropout: float = 0.0,
57
+ act: str = "gelu",
58
+ mlp_ratio: int = 1,
59
+ last_layer: bool = False,
60
+ out_dim: int = 1,
61
+ ):
62
+ super().__init__()
63
+ self.last_layer = last_layer
64
+ self.ln_1 = nn.LayerNorm(hidden_dim)
65
+ self.Attn = attention
66
+ self.ln_2 = nn.LayerNorm(hidden_dim)
67
+ self.mlp = MLP(hidden_dim, hidden_dim * mlp_ratio, hidden_dim, n_layers=0, res=False, act=act)
68
+ if last_layer:
69
+ self.ln_3 = nn.LayerNorm(hidden_dim)
70
+ self.mlp2 = nn.Linear(hidden_dim, out_dim)
71
+
72
+ def forward(self, fx):
73
+ fx = self.Attn(self.ln_1(fx)) + fx
74
+ fx = self.mlp(self.ln_2(fx)) + fx
75
+ if self.last_layer:
76
+ return self.mlp2(self.ln_3(fx))
77
+ return fx
stress_operator/models/linear_no.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LinearNO attention block (Stage 2 / Gate B).
2
+
3
+ Reimplemented from the equations of:
4
+ "Transolver is a Linear Transformer: Revisiting Physics-Attention through the Lens of
5
+ Linear Attention" — Hu, Liu, Qiao, Sun, Dou (NUDT), AAAI 2026, arXiv:2511.06294.
6
+ There is no official public repo; only the attention block differs from Transolver.
7
+
8
+ Core idea: Physics-Attention is the special case of linear attention
9
+ ``Attention(Q,K,V) ~ phi(Q) (psi^T(K) V)`` in which (a) phi and psi come from the SAME linear
10
+ layer (differing only by normalization) and (b) there is an extra slice self-attention step.
11
+ LinearNO removes BOTH constraints:
12
+ 1. learn Q and K projections independently (break weight sharing), and
13
+ 2. drop the slice self-attention (identity).
14
+
15
+ LinearNO(H) = phi(Q) @ ( psi(K)^T @ V )
16
+ Q = H Wq ; K = H Wk ; V = Linear_V(H)
17
+ phi(Q) = softmax_over_M( Linear_Q(Q) ) # (N, M) rows sum to 1 <- softmax along M
18
+ psi(K) = softmax_over_N( Linear_K(K) ) # (N, M) cols sum to 1 <- softmax along N
19
+
20
+ ### THE make-or-break detail (paper Table 6, Elasticity):
21
+ phi softmax over M (slices), psi softmax over N (points) -> 0.0050 (correct)
22
+ swapping these dims -> 0.0081..0.0112 (wrong). If LinearNO lands at 0.008-0.011, the
23
+ softmax dimensions are almost certainly swapped.
24
+
25
+ Associativity: compute ``psi^T V`` first (M x d), then ``phi @ (...)`` -> O(N*M*d), linear in N.
26
+
27
+ Two variants (see docs/RECONCILIATION.md for the parameter-count tension):
28
+ - "independent" (paper skeleton, default): separate Wq, Wk, Wv as full dim->inner
29
+ projections. Most literal to the plan's reference code. ~0.85M params (> baseline).
30
+ - "shared_qk": share one dim->inner base for the slice projections (phi, psi), keep a
31
+ separate dim->inner V; breaks slice-weight sharing via two separate small slice layers.
32
+ ~0.72M params (~= baseline). Use this to satisfy the Gate-B "<= baseline params" constraint.
33
+ An optional ``project_out=False`` drops the output projection (folds the per-head concat
34
+ directly), reaching ~0.59M (the plan's stated LinearNO size).
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import torch
39
+ import torch.nn as nn
40
+ from einops import rearrange
41
+
42
+
43
+ class LinearNO(nn.Module):
44
+ def __init__(
45
+ self,
46
+ dim,
47
+ heads=8,
48
+ dim_head=16,
49
+ slice_num=64,
50
+ dropout=0.0,
51
+ variant: str = "independent",
52
+ project_out: bool = True,
53
+ ):
54
+ super().__init__()
55
+ inner = heads * dim_head
56
+ self.h, self.m, self.dh = heads, slice_num, dim_head
57
+ self.variant = variant
58
+ self.project_out = project_out
59
+
60
+ if variant == "independent":
61
+ # Independent Q, K, V projections (paper modification 1, literal).
62
+ self.to_q = nn.Linear(dim, inner, bias=False)
63
+ self.to_k = nn.Linear(dim, inner, bias=False)
64
+ self.to_v = nn.Linear(dim, inner, bias=False)
65
+ elif variant == "shared_qk":
66
+ # Share one base projection for the slice space (Q==K base), keep V separate.
67
+ self.to_qk = nn.Linear(dim, inner, bias=False)
68
+ self.to_v = nn.Linear(dim, inner, bias=False)
69
+ else:
70
+ raise ValueError(f"unknown variant {variant!r} (expected 'independent' or 'shared_qk')")
71
+
72
+ # Asymmetric slice projections: query->slices and key->slices are SEPARATE weights.
73
+ self.lin_q = nn.Linear(dim_head, slice_num)
74
+ self.lin_k = nn.Linear(dim_head, slice_num)
75
+
76
+ if project_out:
77
+ self.to_out = nn.Sequential(nn.Linear(inner, dim), nn.Dropout(dropout))
78
+ else:
79
+ self.to_out = nn.Dropout(dropout)
80
+
81
+ def _heads(self, t, B, N):
82
+ return t.reshape(B, N, self.h, self.dh).permute(0, 2, 1, 3).contiguous() # (B,H,N,dh)
83
+
84
+ def forward(self, x): # x: (B, N, C)
85
+ B, N, C = x.shape
86
+ if self.variant == "independent":
87
+ q = self._heads(self.to_q(x), B, N) # (B,H,N,dh)
88
+ k = self._heads(self.to_k(x), B, N)
89
+ v = self._heads(self.to_v(x), B, N)
90
+ else: # shared_qk
91
+ base = self._heads(self.to_qk(x), B, N)
92
+ q = base
93
+ k = base
94
+ v = self._heads(self.to_v(x), B, N)
95
+
96
+ phi = self.lin_q(q).softmax(dim=-1) # (B,H,N,M) softmax OVER M (slices) <- rows sum to 1
97
+ psi = self.lin_k(k).softmax(dim=-2) # (B,H,N,M) softmax OVER N (points) <- cols sum to 1
98
+ kv = torch.einsum("bhnm,bhnd->bhmd", psi, v) # (B,H,M,dh) cheap inner product first
99
+ out = torch.einsum("bhnm,bhmd->bhnd", phi, kv) # (B,H,N,dh) linear in N
100
+ out = rearrange(out, "b h n d -> b n (h d)")
101
+ return self.to_out(out)
stress_operator/models/physics_attention.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Physics-Attention for irregular meshes / point clouds.
2
+
3
+ This module is reused **verbatim** from the Transolver project and is licensed under the MIT
4
+ License. Attribution preserved per the license terms.
5
+
6
+ Transolver: A Fast Transformer Solver for PDEs on General Geometries
7
+ Wu, Luo, Wang, Wang, Long (THUML / Tsinghua University), ICML 2024 Spotlight.
8
+ Source: https://github.com/thuml/Transolver (MIT License)
9
+ Paper: arXiv:2402.02366
10
+
11
+ Key constants preserved exactly: temperature init 0.5, orthogonal init of the slice
12
+ projection, slice-norm epsilon 1e-5, attention scale dim_head**-0.5.
13
+
14
+ Note: in the assembled model (see transolver.py), the operator's global ``trunc_normal_``
15
+ weight initialization runs *after* this block is constructed and overwrites the orthogonal
16
+ init of ``in_project_slice`` — this matches the upstream assembly order and is what produced
17
+ the published 0.0064. We keep the orthogonal init here to stay verbatim with upstream.
18
+ """
19
+ import torch
20
+ import torch.nn as nn
21
+ from einops import rearrange
22
+
23
+
24
+ class Physics_Attention_Irregular_Mesh(nn.Module):
25
+ ## for irregular meshes in 1D, 2D or 3D space
26
+ # NOTE: the dim_head=64 default is upstream's generic default. For the Elasticity config the
27
+ # assembler (transolver.py) always passes dim_head = n_hidden // n_heads = 16; the default here
28
+ # is never used in this repo. See docs/RECONCILIATION.md §3.
29
+ def __init__(self, dim, heads=8, dim_head=64, dropout=0., slice_num=64):
30
+ super().__init__()
31
+ inner_dim = dim_head * heads
32
+ self.dim_head = dim_head
33
+ self.heads = heads
34
+ self.scale = dim_head ** -0.5
35
+ self.softmax = nn.Softmax(dim=-1)
36
+ self.dropout = nn.Dropout(dropout)
37
+ self.temperature = nn.Parameter(torch.ones([1, heads, 1, 1]) * 0.5)
38
+
39
+ self.in_project_x = nn.Linear(dim, inner_dim)
40
+ self.in_project_fx = nn.Linear(dim, inner_dim)
41
+ self.in_project_slice = nn.Linear(dim_head, slice_num)
42
+ for l in [self.in_project_slice]:
43
+ torch.nn.init.orthogonal_(l.weight) # use a principled initialization
44
+ self.to_q = nn.Linear(dim_head, dim_head, bias=False)
45
+ self.to_k = nn.Linear(dim_head, dim_head, bias=False)
46
+ self.to_v = nn.Linear(dim_head, dim_head, bias=False)
47
+ self.to_out = nn.Sequential(
48
+ nn.Linear(inner_dim, dim),
49
+ nn.Dropout(dropout)
50
+ )
51
+
52
+ def forward(self, x):
53
+ # B N C
54
+ B, N, C = x.shape
55
+
56
+ ### (1) Slice
57
+ fx_mid = self.in_project_fx(x).reshape(B, N, self.heads, self.dim_head) \
58
+ .permute(0, 2, 1, 3).contiguous() # B H N C
59
+ x_mid = self.in_project_x(x).reshape(B, N, self.heads, self.dim_head) \
60
+ .permute(0, 2, 1, 3).contiguous() # B H N C
61
+ slice_weights = self.softmax(self.in_project_slice(x_mid) / self.temperature) # B H N G
62
+ slice_norm = slice_weights.sum(2) # B H G
63
+ slice_token = torch.einsum("bhnc,bhng->bhgc", fx_mid, slice_weights)
64
+ slice_token = slice_token / ((slice_norm + 1e-5)[:, :, :, None].repeat(1, 1, 1, self.dim_head))
65
+
66
+ ### (2) Attention among slice tokens
67
+ q_slice_token = self.to_q(slice_token)
68
+ k_slice_token = self.to_k(slice_token)
69
+ v_slice_token = self.to_v(slice_token)
70
+ dots = torch.matmul(q_slice_token, k_slice_token.transpose(-1, -2)) * self.scale
71
+ attn = self.softmax(dots)
72
+ attn = self.dropout(attn)
73
+ out_slice_token = torch.matmul(attn, v_slice_token) # B H G D
74
+
75
+ ### (3) Deslice
76
+ out_x = torch.einsum("bhgc,bhng->bhnc", out_slice_token, slice_weights)
77
+ out_x = rearrange(out_x, 'b h n d -> b n (h d)')
78
+ return self.to_out(out_x)
stress_operator/models/transolver.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assemble the stress operator: encoder -> N pre-norm blocks -> decoder head.
2
+
3
+ Faithful reproduction of Transolver ``model/Transolver_Irregular_Mesh.py::Model`` (MIT). The
4
+ attention type is configurable (``physics`` for Stage 1, ``linearno`` for Stage 2) and is the
5
+ *only* thing that changes between gates — a controlled comparison.
6
+
7
+ Reproduction note: ``initialize_weights()`` runs after the blocks are built, so the global
8
+ ``trunc_normal_(std=0.02)`` init is applied to every Linear, **overwriting** the orthogonal
9
+ init of each attention's ``in_project_slice``. This matches the upstream assembly order.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+ import torch
15
+ import torch.nn as nn
16
+
17
+ from .blocks import MLP, TransolverBlock
18
+ from .physics_attention import Physics_Attention_Irregular_Mesh
19
+
20
+
21
+ def make_attention(
22
+ kind: str,
23
+ dim,
24
+ heads,
25
+ dim_head,
26
+ dropout,
27
+ slice_num,
28
+ linearno_variant: str = "shared_qk",
29
+ linearno_project_out: bool = False,
30
+ ) -> nn.Module:
31
+ if kind == "physics":
32
+ return Physics_Attention_Irregular_Mesh(
33
+ dim, heads=heads, dim_head=dim_head, dropout=dropout, slice_num=slice_num
34
+ )
35
+ if kind == "linearno":
36
+ from .linear_no import LinearNO # lazy: only needed at Stage 2
37
+
38
+ return LinearNO(
39
+ dim,
40
+ heads=heads,
41
+ dim_head=dim_head,
42
+ slice_num=slice_num,
43
+ dropout=dropout,
44
+ variant=linearno_variant,
45
+ project_out=linearno_project_out,
46
+ )
47
+ raise ValueError(f"unknown attention kind {kind!r} (expected 'physics' or 'linearno')")
48
+
49
+
50
+ class StressOperator(nn.Module):
51
+ def __init__(
52
+ self,
53
+ attention: str = "physics",
54
+ space_dim: int = 2,
55
+ n_layers: int = 8,
56
+ n_hidden: int = 128,
57
+ dropout: float = 0.0,
58
+ n_heads: int = 8,
59
+ dim_head: int | None = None,
60
+ mlp_ratio: int = 1,
61
+ fun_dim: int = 0,
62
+ out_dim: int = 1,
63
+ slice_num: int = 64,
64
+ unified_pos: bool = False,
65
+ ref: int = 8,
66
+ act: str = "gelu",
67
+ linearno_variant: str = "shared_qk",
68
+ linearno_project_out: bool = False,
69
+ ):
70
+ super().__init__()
71
+ if dim_head is None:
72
+ dim_head = n_hidden // n_heads # = 16 for the Elasticity config (repo value)
73
+ self.attention_kind = attention
74
+ self.unified_pos = unified_pos
75
+ self.ref = ref
76
+ self.n_hidden = n_hidden
77
+
78
+ in_dim = (fun_dim + ref * ref) if unified_pos else (fun_dim + space_dim)
79
+ self.preprocess = MLP(in_dim, n_hidden * 2, n_hidden, n_layers=0, res=False, act=act)
80
+
81
+ self.blocks = nn.ModuleList(
82
+ [
83
+ TransolverBlock(
84
+ attention=make_attention(
85
+ attention, n_hidden, n_heads, dim_head, dropout, slice_num,
86
+ linearno_variant=linearno_variant,
87
+ linearno_project_out=linearno_project_out,
88
+ ),
89
+ hidden_dim=n_hidden,
90
+ dropout=dropout,
91
+ act=act,
92
+ mlp_ratio=mlp_ratio,
93
+ last_layer=(i == n_layers - 1),
94
+ out_dim=out_dim,
95
+ )
96
+ for i in range(n_layers)
97
+ ]
98
+ )
99
+ self.initialize_weights()
100
+ self.placeholder = nn.Parameter((1 / n_hidden) * torch.rand(n_hidden, dtype=torch.float))
101
+
102
+ def initialize_weights(self):
103
+ self.apply(self._init_weights)
104
+
105
+ @staticmethod
106
+ def _init_weights(m):
107
+ if isinstance(m, nn.Linear):
108
+ nn.init.trunc_normal_(m.weight, std=0.02)
109
+ if m.bias is not None:
110
+ nn.init.constant_(m.bias, 0)
111
+ elif isinstance(m, (nn.LayerNorm, nn.BatchNorm1d)):
112
+ nn.init.constant_(m.bias, 0)
113
+ nn.init.constant_(m.weight, 1.0)
114
+
115
+ def get_grid(self, x):
116
+ """Unified positional grid (only used when ``unified_pos`` is True). Device-agnostic."""
117
+ b = x.shape[0]
118
+ device = x.device
119
+ gx = torch.linspace(0, 1, self.ref, device=device).reshape(1, self.ref, 1, 1).repeat(b, 1, self.ref, 1)
120
+ gy = torch.linspace(0, 1, self.ref, device=device).reshape(1, 1, self.ref, 1).repeat(b, self.ref, 1, 1)
121
+ grid_ref = torch.cat((gx, gy), dim=-1).reshape(b, self.ref * self.ref, 2)
122
+ pos = torch.sqrt(((x[:, :, None, :] - grid_ref[:, None, :, :]) ** 2).sum(-1))
123
+ return pos.reshape(b, x.shape[1], self.ref * self.ref).contiguous()
124
+
125
+ def forward(self, x, fx=None):
126
+ # x: (B, N, space_dim) node coordinates; fx: optional extra input function
127
+ if self.unified_pos:
128
+ x = self.get_grid(x)
129
+ fx = self.preprocess(x if fx is None else torch.cat((x, fx), dim=-1))
130
+ fx = fx + self.placeholder[None, None, :]
131
+ for block in self.blocks:
132
+ fx = block(fx)
133
+ return fx # (B, N, out_dim)
134
+
135
+
136
+ def build_model(model_cfg: dict) -> StressOperator:
137
+ """Instantiate :class:`StressOperator` from a config dict (configs/*.yaml ``model`` block)."""
138
+ return StressOperator(
139
+ attention=model_cfg.get("attention", "physics"),
140
+ space_dim=model_cfg.get("space_dim", 2),
141
+ n_layers=model_cfg.get("n_layers", 8),
142
+ n_hidden=model_cfg.get("n_hidden", 128),
143
+ dropout=model_cfg.get("dropout", 0.0),
144
+ n_heads=model_cfg.get("n_heads", 8),
145
+ dim_head=model_cfg.get("dim_head", None),
146
+ mlp_ratio=model_cfg.get("mlp_ratio", 1),
147
+ fun_dim=model_cfg.get("fun_dim", 0),
148
+ out_dim=model_cfg.get("out_dim", 1),
149
+ slice_num=model_cfg.get("slice_num", 64),
150
+ unified_pos=model_cfg.get("unified_pos", False),
151
+ ref=model_cfg.get("ref", 8),
152
+ act=model_cfg.get("act", "gelu"),
153
+ linearno_variant=model_cfg.get("linearno_variant", "shared_qk"),
154
+ linearno_project_out=model_cfg.get("linearno_project_out", False),
155
+ )
156
+
157
+
158
+ def count_parameters(model: nn.Module) -> int:
159
+ return sum(p.numel() for p in model.parameters() if p.requires_grad)
stress_operator/seeds.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic seeding across python / numpy / torch."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import random
6
+
7
+
8
+ def set_seed(seed: int, deterministic: bool = True) -> None:
9
+ """Seed all RNGs used in training/eval.
10
+
11
+ Mirrors the upstream Transolver/Geo-FNO seeding (``torch.manual_seed``,
12
+ ``np.random.seed``, cudnn deterministic) and extends it to python's ``random``
13
+ and the ``PYTHONHASHSEED`` env var so runs are reproducible across seeds {0,1,2}.
14
+ """
15
+ import numpy as np
16
+ import torch
17
+
18
+ os.environ["PYTHONHASHSEED"] = str(seed)
19
+ random.seed(seed)
20
+ np.random.seed(seed)
21
+ torch.manual_seed(seed)
22
+ if torch.cuda.is_available():
23
+ torch.cuda.manual_seed_all(seed)
24
+ if deterministic:
25
+ torch.backends.cudnn.deterministic = True
26
+ torch.backends.cudnn.benchmark = False
27
+ # NOTE: torch.use_deterministic_algorithms(True) was tried but it markedly DEGRADED
28
+ # convergence of the Physics-Attention baseline (slice self-attention) on GPU (baseline
29
+ # eager landed ~0.0090 vs ~0.0068 without it), while leaving LinearNO unaffected. We
30
+ # therefore do not force it; reproducibility is handled by averaging seeds and disclosing
31
+ # run-to-run variance (README + model card). See docs/RECONCILIATION.md / PART 6 caveat 6.
stress_operator/train.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single training entrypoint; ``--config`` selects the stage.
2
+
3
+ Faithful to Transolver ``exp_elas.py``:
4
+ - AdamW (lr, weight_decay), CosineAnnealingLR(T_max=epochs)
5
+ - batch_size 1, gradient clipping at max_grad_norm (0.1)
6
+ - loss = relative-L2 in physical units: predictions are de-normalized before the loss;
7
+ targets are physical (decode(encode(s)) == s, so storing physical targets is equivalent).
8
+
9
+ Writes a run-log JSON to ``results/`` per master plan §0.2.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import time
16
+ from typing import Any, Dict, Optional
17
+
18
+ import torch
19
+ import yaml
20
+
21
+ from .data.dataset import build_splits
22
+ from .losses.relative_l2 import relative_l2
23
+ from .models.transolver import build_model, count_parameters
24
+ from .seeds import set_seed
25
+ from .utils.logging import MODAL_RATES_PER_SEC, write_run_log
26
+
27
+
28
+ def load_config(path: str) -> Dict[str, Any]:
29
+ with open(path) as f:
30
+ return yaml.safe_load(f)
31
+
32
+
33
+ def run_training(
34
+ config: Dict[str, Any],
35
+ seed: int,
36
+ data_dir: str,
37
+ device: Optional[str] = None,
38
+ gpu_name: str = "CPU",
39
+ results_path: Optional[str] = None,
40
+ ckpt_path: Optional[str] = None,
41
+ log_every: int = 50,
42
+ max_epochs: Optional[int] = None,
43
+ ntrain_override: Optional[int] = None,
44
+ splits=None,
45
+ ) -> Dict[str, Any]:
46
+ """Train one model for one seed; return final metrics and write a run-log JSON.
47
+
48
+ If ``ckpt_path`` is given, also save ``{state_dict, normalizer{mean,std}, config, seed,
49
+ metrics}`` (the normalizer stats are required to de-normalize predictions at inference).
50
+ If ``splits`` (a ``Splits`` from ``build_splits_from_indices``) is given, it overrides the
51
+ default first-1000/last-200 split (used by the OOD evaluation).
52
+ """
53
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
54
+ set_seed(seed)
55
+
56
+ data_cfg = config["data"]
57
+ train_cfg = config["train"]
58
+ model_cfg = config["model"]
59
+
60
+ if splits is None:
61
+ ntrain = ntrain_override or data_cfg.get("ntrain", 1000)
62
+ ntest = data_cfg.get("ntest", 200)
63
+ splits = build_splits(data_dir, ntrain=ntrain, ntest=ntest)
64
+ ntest = splits.test_coords.shape[0]
65
+ normalizer = splits.normalizer.to(device)
66
+
67
+ # GPU-resident dataset: the whole thing is tiny (~10 MB), so we keep it on-device and
68
+ # batch by index. This removes DataLoader + per-iteration host->device + per-iteration
69
+ # .item() sync overhead, which dominates wall-clock at batch_size 1. The math is identical
70
+ # to the DataLoader path (same batch_size, same loss, same seeded shuffle order).
71
+ batch_size = train_cfg.get("batch_size", 1)
72
+ eval_every = int(train_cfg.get("eval_every", 1))
73
+
74
+ def _3d(t):
75
+ return (t if t.dim() == 3 else t.unsqueeze(-1)).to(device)
76
+
77
+ train_coords = splits.train_coords.to(device) # (ntrain, 972, 2)
78
+ train_sigma = _3d(splits.train_sigma) # (ntrain, 972, 1) physical
79
+ test_coords = splits.test_coords.to(device)
80
+ test_sigma = _3d(splits.test_sigma)
81
+ ntrain_eff = train_coords.shape[0]
82
+
83
+ base_model = build_model(model_cfg).to(device)
84
+ n_params = count_parameters(base_model)
85
+
86
+ # Optional torch.compile (CUDA graphs) to cut per-iteration kernel-launch overhead, which
87
+ # dominates wall-clock at batch_size 1. Same math, static input shape (1, 972, 2). The
88
+ # checkpoint is saved from base_model so its state_dict keys stay clean (no _orig_mod prefix).
89
+ model = base_model
90
+ if bool(train_cfg.get("compile", False)) and device == "cuda":
91
+ try:
92
+ model = torch.compile(base_model, mode="reduce-overhead")
93
+ print(f"[seed {seed}] torch.compile enabled (reduce-overhead)", flush=True)
94
+ except Exception as e: # pragma: no cover
95
+ print(f"[seed {seed}] torch.compile failed ({e}); falling back to eager", flush=True)
96
+ model = base_model
97
+
98
+ lr = float(train_cfg.get("lr", 1e-3))
99
+ wd = float(train_cfg.get("weight_decay", 1e-5))
100
+ betas = tuple(train_cfg.get("betas", (0.9, 0.999)))
101
+ epochs = max_epochs or int(train_cfg.get("epochs", 500))
102
+ max_grad_norm = train_cfg.get("max_grad_norm", None)
103
+
104
+ optimizer = torch.optim.AdamW(base_model.parameters(), lr=lr, weight_decay=wd, betas=betas)
105
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
106
+
107
+ shuffle_gen = torch.Generator().manual_seed(seed) # reproducible per-epoch shuffle
108
+
109
+ @torch.no_grad()
110
+ def eval_test() -> float:
111
+ model.eval()
112
+ total = 0.0
113
+ for i in range(0, ntest, batch_size):
114
+ out = normalizer.decode(model(test_coords[i:i + batch_size], None))
115
+ total += relative_l2(out, test_sigma[i:i + batch_size], reduction="sum").item()
116
+ return total / ntest
117
+
118
+ t0 = time.time()
119
+ best_rel = float("inf")
120
+ test_rel = float("nan")
121
+ history = []
122
+ for ep in range(epochs):
123
+ model.train()
124
+ perm = torch.randperm(ntrain_eff, generator=shuffle_gen).to(device)
125
+ running = torch.zeros((), device=device)
126
+ for s in range(0, ntrain_eff, batch_size):
127
+ idx = perm[s:s + batch_size]
128
+ optimizer.zero_grad()
129
+ out = normalizer.decode(model(train_coords[idx], None)) # -> physical
130
+ loss = relative_l2(out, train_sigma[idx], reduction="sum")
131
+ loss.backward()
132
+ if max_grad_norm is not None:
133
+ torch.nn.utils.clip_grad_norm_(base_model.parameters(), max_grad_norm)
134
+ optimizer.step()
135
+ running += loss.detach()
136
+ scheduler.step()
137
+ train_rel = (running / ntrain_eff).item()
138
+
139
+ if (ep % eval_every == 0) or (ep >= epochs - 5):
140
+ test_rel = eval_test()
141
+ best_rel = min(best_rel, test_rel)
142
+ history.append({"epoch": ep, "train_rel": train_rel, "test_rel": test_rel})
143
+ if ep % log_every == 0 or ep == epochs - 1:
144
+ print(
145
+ f"[seed {seed}] epoch {ep:4d} train_rel={train_rel:.5f} test_rel={test_rel:.5f}",
146
+ flush=True,
147
+ )
148
+
149
+ wall = time.time() - t0
150
+ rate = MODAL_RATES_PER_SEC.get(gpu_name, 0.0)
151
+ est_cost = wall * rate
152
+
153
+ final_metrics = {
154
+ "test_rel_l2": round(test_rel, 6),
155
+ "best_test_rel_l2": round(best_rel, 6),
156
+ "train_rel_l2": round(train_rel, 6),
157
+ "n_params": n_params,
158
+ "epochs": epochs,
159
+ }
160
+
161
+ if ckpt_path is not None:
162
+ os.makedirs(os.path.dirname(ckpt_path) or ".", exist_ok=True)
163
+ torch.save(
164
+ {
165
+ "state_dict": base_model.state_dict(),
166
+ "normalizer": {
167
+ "mean": normalizer.mean.detach().cpu(),
168
+ "std": normalizer.std.detach().cpu(),
169
+ },
170
+ "config": config,
171
+ "seed": seed,
172
+ "metrics": final_metrics,
173
+ },
174
+ ckpt_path,
175
+ )
176
+ print(f"[seed {seed}] saved checkpoint -> {ckpt_path}")
177
+
178
+ if results_path is None:
179
+ os.makedirs("results", exist_ok=True)
180
+ results_path = os.path.join("results", f"{config.get('name','run')}_seed{seed}.json")
181
+ write_run_log(
182
+ path=results_path,
183
+ config=config,
184
+ seed=seed,
185
+ final_metrics=final_metrics,
186
+ wall_clock_sec=wall,
187
+ gpu=gpu_name,
188
+ est_cost_usd=est_cost,
189
+ extra={"history_tail": history[-5:]},
190
+ )
191
+ print(
192
+ f"[seed {seed}] DONE test_rel_l2={test_rel:.6f} best={best_rel:.6f} "
193
+ f"params={n_params} wall={wall:.0f}s gpu={gpu_name} est_cost=${est_cost:.4f}"
194
+ )
195
+ return final_metrics
196
+
197
+
198
+ def main() -> int:
199
+ ap = argparse.ArgumentParser(description="Train the stress operator (one seed).")
200
+ ap.add_argument("--config", required=True)
201
+ ap.add_argument("--seed", type=int, default=0)
202
+ ap.add_argument("--data-dir", default="data")
203
+ ap.add_argument("--device", default=None)
204
+ ap.add_argument("--gpu-name", default="CPU", help="for cost accounting (A10 / A100-40GB / CPU)")
205
+ ap.add_argument("--results-path", default=None)
206
+ ap.add_argument("--max-epochs", type=int, default=None, help="override epochs (local smoke runs)")
207
+ ap.add_argument("--ntrain", type=int, default=None, help="override ntrain (local smoke runs)")
208
+ args = ap.parse_args()
209
+
210
+ config = load_config(args.config)
211
+ run_training(
212
+ config=config,
213
+ seed=args.seed,
214
+ data_dir=args.data_dir,
215
+ device=args.device,
216
+ gpu_name=args.gpu_name,
217
+ results_path=args.results_path,
218
+ max_epochs=args.max_epochs,
219
+ ntrain_override=args.ntrain,
220
+ )
221
+ return 0
222
+
223
+
224
+ if __name__ == "__main__":
225
+ raise SystemExit(main())
stress_operator/train_eqreg.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training with the equilibrium-residual regularizer (Stage 3 / Gate C).
2
+
3
+ Two approaches (master plan §2.6), selected by ``config['equilibrium']['approach']``:
4
+
5
+ A (principled): model outputs 3 channels (sigma_xx, sigma_yy, sigma_xy). The data loss compares
6
+ the derived von Mises stress to the scalar target; the physics loss penalizes the discrete
7
+ divergence residual ||div(sigma)||^2 on interior nodes (operators validated analytically).
8
+ Target is scaled by a constant S so outputs stay O(1); relative-L2 is scale-invariant, so the
9
+ reported number equals the physical relative-L2.
10
+
11
+ B (fallback): model keeps the 1-channel scalar output (identical accuracy path to LinearNO);
12
+ the physics loss is a graph-Laplacian smoothness prior — a plausibility prior *motivated by*
13
+ (not equal to) equilibrium. Honest framing required.
14
+
15
+ The per-sample discrete operators depend only on the mesh, so they are precomputed once (sparse)
16
+ and reused every epoch. Total loss: L = data_loss + lambda * physics_loss.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import time
22
+ from typing import Any, Dict, List, Optional, Tuple
23
+
24
+ import torch
25
+
26
+ from .data.dataset import build_splits
27
+ from .losses.equilibrium import (
28
+ build_graph_laplacian,
29
+ build_mls_gradient_operators,
30
+ interior_mask,
31
+ von_mises,
32
+ )
33
+ from .losses.relative_l2 import relative_l2
34
+ from .models.transolver import build_model, count_parameters
35
+ from .seeds import set_seed
36
+ from .utils.logging import MODAL_RATES_PER_SEC, write_run_log
37
+
38
+
39
+ def _precompute_grad_ops(
40
+ coords: torch.Tensor, k: int, tol: float, device
41
+ ) -> List[Tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
42
+ """Per-sample (Gx_sparse, Gy_sparse, interior_mask) for Approach A. Built once (CPU build)."""
43
+ ops = []
44
+ for s in range(coords.shape[0]):
45
+ c = coords[s]
46
+ Gx, Gy = build_mls_gradient_operators(c, k=k)
47
+ mask = interior_mask(c, tol).to(device)
48
+ ops.append((Gx.to_sparse().to(device), Gy.to_sparse().to(device), mask))
49
+ return ops
50
+
51
+
52
+ def _precompute_laplacians(coords: torch.Tensor, k: int, device) -> List[torch.Tensor]:
53
+ """Per-sample sparse graph Laplacian for Approach B."""
54
+ return [build_graph_laplacian(coords[s], k=k).to_sparse().to(device) for s in range(coords.shape[0])]
55
+
56
+
57
+ def _sparse_div_residual(stress3: torch.Tensor, Gx_s, Gy_s, mask) -> torch.Tensor:
58
+ """L_eq = mean_interior ||div(sigma)||^2 for one sample. stress3: (N, 3)."""
59
+ sxx = stress3[:, 0:1]
60
+ syy = stress3[:, 1:2]
61
+ sxy = stress3[:, 2:3]
62
+ div_x = torch.sparse.mm(Gx_s, sxx) + torch.sparse.mm(Gy_s, sxy) # (N,1)
63
+ div_y = torch.sparse.mm(Gx_s, sxy) + torch.sparse.mm(Gy_s, syy)
64
+ sq = (div_x.squeeze(-1) ** 2 + div_y.squeeze(-1) ** 2) # (N,)
65
+ return sq[mask].mean()
66
+
67
+
68
+ def run_training_eqreg(
69
+ config: Dict[str, Any],
70
+ seed: int,
71
+ data_dir: str,
72
+ device: Optional[str] = None,
73
+ gpu_name: str = "CPU",
74
+ results_path: Optional[str] = None,
75
+ ckpt_path: Optional[str] = None,
76
+ log_every: int = 50,
77
+ max_epochs: Optional[int] = None,
78
+ splits=None,
79
+ lambda_override: Optional[float] = None,
80
+ ) -> Dict[str, Any]:
81
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
82
+ set_seed(seed)
83
+
84
+ data_cfg = config["data"]
85
+ train_cfg = config["train"]
86
+ model_cfg = config["model"]
87
+ eq_cfg = config["equilibrium"]
88
+ approach = eq_cfg.get("approach", "A").upper()
89
+ lam = float(lambda_override) if lambda_override is not None else float(eq_cfg.get("lambda", 0.05))
90
+ knn_k = int(eq_cfg.get("knn_k", 12))
91
+ tol = float(eq_cfg.get("interior_tol", 0.03))
92
+
93
+ if splits is None:
94
+ ntrain = data_cfg.get("ntrain", 1000)
95
+ ntest = data_cfg.get("ntest", 200)
96
+ splits = build_splits(data_dir, ntrain=ntrain, ntest=ntest)
97
+ ntest = splits.test_coords.shape[0]
98
+ normalizer = splits.normalizer.to(device)
99
+
100
+ train_coords = splits.train_coords.to(device)
101
+ test_coords = splits.test_coords.to(device)
102
+ train_sigma = (splits.train_sigma if splits.train_sigma.dim() == 3 else splits.train_sigma.unsqueeze(-1)).to(device)
103
+ test_sigma = (splits.test_sigma if splits.test_sigma.dim() == 3 else splits.test_sigma.unsqueeze(-1)).to(device)
104
+ n_train = train_coords.shape[0]
105
+
106
+ # Scale for Approach A: keep von Mises target O(1). relative-L2 is scale-invariant, so the
107
+ # reported metric equals the physical relative-L2 regardless of S.
108
+ S = float(splits.train_sigma.mean()) if approach == "A" else 1.0
109
+
110
+ print(f"[eqreg seed {seed}] approach={approach} lambda={lam} k={knn_k} S={S:.2f}", flush=True)
111
+ t_build = time.time()
112
+ if approach == "A":
113
+ train_ops = _precompute_grad_ops(splits.train_coords, knn_k, tol, device)
114
+ test_ops = _precompute_grad_ops(splits.test_coords, knn_k, tol, device)
115
+ else:
116
+ train_ops = _precompute_laplacians(splits.train_coords, knn_k, device)
117
+ test_ops = _precompute_laplacians(splits.test_coords, knn_k, device)
118
+ print(f"[eqreg seed {seed}] precomputed operators in {time.time()-t_build:.0f}s", flush=True)
119
+
120
+ model = build_model(model_cfg).to(device)
121
+ n_params = count_parameters(model)
122
+
123
+ lr = float(train_cfg.get("lr", 1e-3))
124
+ wd = float(train_cfg.get("weight_decay", 1e-5))
125
+ betas = tuple(train_cfg.get("betas", (0.9, 0.999)))
126
+ epochs = max_epochs or int(train_cfg.get("epochs", 500))
127
+ max_grad_norm = train_cfg.get("max_grad_norm", None)
128
+ eval_every = int(train_cfg.get("eval_every", 10))
129
+
130
+ optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=wd, betas=betas)
131
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
132
+ gen = torch.Generator().manual_seed(seed)
133
+
134
+ def data_and_phys(out, sigma_phys, ops_i):
135
+ """Return (data_loss, phys_residual) for one sample. out: (N, C).
136
+
137
+ Approach A scale convention (IMPORTANT — do NOT 'decode' ``out``):
138
+ ``out`` is the RAW 3-channel model output (no normalizer applied). It learns the physical
139
+ stress tensor divided by the constant ``S`` (a pure scale, NOT the affine z-score). Because
140
+ von Mises is homogeneous of degree 1 and relative-L2 is jointly scale-invariant,
141
+ relative_l2(von_mises(out), sigma_phys/S) == relative_l2(S*von_mises(out), sigma_phys)
142
+ i.e. the reported data loss EQUALS the physical relative-L2 of the prediction ``S*von_mises(out)``
143
+ against the physical target (verified numerically). Applying the scalar normalizer's
144
+ ``decode`` here would be WRONG: it would add the von-Mises mean (~187) to every tensor
145
+ component. The divergence residual below is therefore in scaled units (= physical/S^2);
146
+ it is converted to physical units only for reporting (see final_metrics).
147
+ """
148
+ if approach == "A":
149
+ vm = von_mises(out) # (N,) von Mises of the scaled tensor
150
+ data_loss = relative_l2(vm.unsqueeze(0).unsqueeze(-1), (sigma_phys / S).unsqueeze(0), reduction="mean")
151
+ Gx_s, Gy_s, mask = ops_i
152
+ phys = _sparse_div_residual(out, Gx_s, Gy_s, mask) # scaled units (physical/S^2)
153
+ else: # B: scalar output + Laplacian smoothness
154
+ pred = normalizer.decode(out) # (N,1) physical
155
+ data_loss = relative_l2(pred.unsqueeze(0), sigma_phys.unsqueeze(0), reduction="mean")
156
+ L_s = ops_i
157
+ Lf = torch.sparse.mm(L_s, out[:, :1]) # operate on the (normalized) scalar field
158
+ phys = (Lf ** 2).mean()
159
+ return data_loss, phys
160
+
161
+ @torch.no_grad()
162
+ def evaluate() -> Tuple[float, float]:
163
+ model.eval()
164
+ tot_data, tot_phys = 0.0, 0.0
165
+ for i in range(ntest):
166
+ out = model(test_coords[i:i + 1], None)[0] # (N, C)
167
+ dloss, phys = data_and_phys(out, test_sigma[i], test_ops[i])
168
+ tot_data += dloss.item()
169
+ tot_phys += phys.item()
170
+ return tot_data / ntest, tot_phys / ntest
171
+
172
+ t0 = time.time()
173
+ best_rel = float("inf")
174
+ test_rel = float("nan")
175
+ test_phys = float("nan")
176
+ history = []
177
+ for ep in range(epochs):
178
+ model.train()
179
+ perm = torch.randperm(n_train, generator=gen).tolist()
180
+ run_data = 0.0
181
+ for i in perm:
182
+ optimizer.zero_grad()
183
+ out = model(train_coords[i:i + 1], None)[0] # (N, C)
184
+ dloss, phys = data_and_phys(out, train_sigma[i], train_ops[i])
185
+ loss = dloss + lam * phys
186
+ loss.backward()
187
+ if max_grad_norm is not None:
188
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
189
+ optimizer.step()
190
+ run_data += dloss.item()
191
+ scheduler.step()
192
+ train_rel = run_data / n_train
193
+
194
+ if (ep % eval_every == 0) or (ep >= epochs - 5):
195
+ test_rel, test_phys = evaluate()
196
+ best_rel = min(best_rel, test_rel)
197
+ history.append({"epoch": ep, "train_rel": train_rel, "test_rel": test_rel, "test_phys": test_phys})
198
+ if ep % log_every == 0 or ep == epochs - 1:
199
+ print(f"[eqreg seed {seed}] epoch {ep:4d} train_rel={train_rel:.5f} "
200
+ f"test_rel={test_rel:.5f} test_resid={test_phys:.4e}", flush=True)
201
+
202
+ wall = time.time() - t0
203
+ rate = MODAL_RATES_PER_SEC.get(gpu_name, 0.0)
204
+ # Report the residual in physical units. Approach A computes div on the S-scaled tensor, so the
205
+ # physical residual is test_phys * S^2; Approach B's Laplacian residual is already in the
206
+ # (normalized) field's units (S == 1).
207
+ test_residual_phys = test_phys * (S ** 2) if approach == "A" else test_phys
208
+ final_metrics = {
209
+ "test_rel_l2": round(test_rel, 6),
210
+ "best_test_rel_l2": round(best_rel, 6),
211
+ "test_residual": test_residual_phys,
212
+ "test_residual_scaled": test_phys,
213
+ "scale_S": S,
214
+ "train_rel_l2": round(train_rel, 6),
215
+ "n_params": n_params,
216
+ "epochs": epochs,
217
+ "approach": approach,
218
+ "lambda": lam,
219
+ }
220
+ if ckpt_path is not None:
221
+ os.makedirs(os.path.dirname(ckpt_path) or ".", exist_ok=True)
222
+ torch.save(
223
+ {"state_dict": model.state_dict(),
224
+ "normalizer": {"mean": normalizer.mean.detach().cpu(), "std": normalizer.std.detach().cpu()},
225
+ "scale_S": S, "config": config, "seed": seed, "metrics": final_metrics},
226
+ ckpt_path,
227
+ )
228
+ if results_path is None:
229
+ results_path = os.path.join("results", f"{config.get('name','eqreg')}_seed{seed}.json")
230
+ write_run_log(results_path, config, seed, final_metrics, wall, gpu_name, wall * rate,
231
+ extra={"history_tail": history[-5:]})
232
+ print(f"[eqreg seed {seed}] DONE test_rel={test_rel:.6f} resid={test_phys:.4e} "
233
+ f"wall={wall:.0f}s est_cost=${wall*rate:.4f}", flush=True)
234
+ return final_metrics
stress_operator/utils/__init__.py ADDED
File without changes
stress_operator/utils/logging.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run logging: every run writes a JSON ``{config, git_commit, seed, final_metrics,
2
+ wall_clock, gpu, est_cost}`` to ``results/`` (master plan §0.2 principle 3)."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ from typing import Any, Dict, Optional
9
+
10
+
11
+ def git_commit(default: str = "unknown") -> str:
12
+ try:
13
+ out = subprocess.check_output(
14
+ ["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL
15
+ )
16
+ return out.decode().strip()
17
+ except Exception:
18
+ return default
19
+
20
+
21
+ def write_run_log(
22
+ path: str,
23
+ config: Dict[str, Any],
24
+ seed: int,
25
+ final_metrics: Dict[str, Any],
26
+ wall_clock_sec: float,
27
+ gpu: str,
28
+ est_cost_usd: float,
29
+ extra: Optional[Dict[str, Any]] = None,
30
+ ) -> str:
31
+ """Write a single run record as JSON and return the path."""
32
+ record = {
33
+ "config": config,
34
+ "git_commit": git_commit(),
35
+ "seed": seed,
36
+ "final_metrics": final_metrics,
37
+ "wall_clock_sec": round(float(wall_clock_sec), 2),
38
+ "gpu": gpu,
39
+ "est_cost_usd": round(float(est_cost_usd), 4),
40
+ }
41
+ if extra:
42
+ record.update(extra)
43
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
44
+ with open(path, "w") as f:
45
+ json.dump(record, f, indent=2)
46
+ return path
47
+
48
+
49
+ def estimate_cost(wall_clock_sec: float, rate_per_sec: float) -> float:
50
+ """GPU cost estimate = wall-clock seconds x per-second rate (master plan §2.7)."""
51
+ return wall_clock_sec * rate_per_sec
52
+
53
+
54
+ # Modal per-second GPU rates (USD), from master plan §2.7 (modal.com/pricing, June 2026).
55
+ MODAL_RATES_PER_SEC = {
56
+ "A10": 0.000306,
57
+ "A100-40GB": 0.000583,
58
+ "A100-80GB": 0.000694,
59
+ "H100": 0.001097,
60
+ "L4": 0.000222,
61
+ "T4": 0.000164,
62
+ "CPU": 0.0,
63
+ }
stress_operator/utils/viz.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared stress-field renderer (used by the Gate-0 sanity plot and the Gradio demo)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import numpy as np
7
+
8
+
9
+ def render_stress(
10
+ coords: np.ndarray,
11
+ sigma: np.ndarray,
12
+ title: str = "von Mises stress",
13
+ ax=None,
14
+ vmin: Optional[float] = None,
15
+ vmax: Optional[float] = None,
16
+ cmap: str = "coolwarm",
17
+ ):
18
+ """Scatter the per-node stress over the point cloud. Returns the matplotlib Figure.
19
+
20
+ coords: (N, 2) node coordinates; sigma: (N,) per-node stress.
21
+ """
22
+ import matplotlib.pyplot as plt
23
+
24
+ if ax is None:
25
+ fig, ax = plt.subplots(figsize=(5, 5))
26
+ else:
27
+ fig = ax.figure
28
+ sc = ax.scatter(
29
+ coords[:, 0], coords[:, 1], c=sigma, s=18, cmap=cmap,
30
+ vmin=vmin, vmax=vmax, edgecolor="w", lw=0.1,
31
+ )
32
+ ax.set_aspect("equal")
33
+ ax.set_title(title)
34
+ fig.colorbar(sc, ax=ax, shrink=0.8, label="sigma")
35
+ return fig