Inference-only code: remove training, evaluation and experimental code
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +3 -8
- README.md +19 -7
- requirements.txt +2 -3
- src/baseline.py +0 -63
- src/build_dataset.py +0 -1199
- src/compare_d_parts.py +0 -214
- src/dataload.py +0 -28
- src/evaluate_ft2b.py +0 -630
- src/evaluate_gnn.py +0 -150
- src/features.py +36 -261
- src/gbm_ft2b.py +0 -598
- src/gbm_heads.py +69 -0
- src/geometry.py +5 -129
- src/gnn_dataset.py +0 -51
- src/graph_build.py +0 -52
- src/hybrid.py +53 -405
- src/inference_props.py +82 -114
- src/mesh_cache.py +30 -138
- src/meshing.py +368 -0
- src/metrics.py +0 -50
- src/model_api.py +52 -25
- src/model_gnn.py +91 -91
- src/msh_parse.py +4 -4
- src/ood.py +3 -29
- src/predict_casting.py +150 -908
- src/release_io.py +15 -99
- src/run_baseline.py +0 -150
- src/select_ckpt_ft2b.py +0 -331
- src/shrinkage.py +100 -503
- src/splits.py +0 -61
- src/step_mesh.py +0 -45
- src/targets.py +0 -39
- src/train_ft2b.py +0 -627
- src/train_gnn.py +0 -300
- src/train_heads.py +0 -278
- src/utils.py +0 -115
- src/warm_start.py +0 -239
- src/webapp.py +2 -2
- tests/test_features.py +55 -0
- tests/test_features_inference.py +0 -16
- tests/test_features_v3.py +0 -36
- tests/test_features_v4.py +0 -275
- tests/test_film.py +0 -46
- tests/test_gbm_heads.py +55 -0
- tests/test_geometry.py +1 -69
- tests/test_graph_build.py +0 -52
- tests/test_hybrid.py +29 -209
- tests/test_hybrid_max_anchor.py +4 -12
- tests/test_inference_props.py +2 -28
- tests/test_input_encoder_extension.py +0 -9
.gitignore
CHANGED
|
@@ -1,15 +1,10 @@
|
|
| 1 |
-
#
|
| 2 |
data/*
|
| 3 |
!data/properties/
|
| 4 |
-
!data/properties/*
|
| 5 |
-
#
|
| 6 |
runs/
|
| 7 |
-
# Model checkpoints anywhere
|
| 8 |
-
*.pt
|
| 9 |
-
# SDD scratch (progress ledger, briefs, review packages)
|
| 10 |
-
.superpowers/
|
| 11 |
# Python
|
| 12 |
__pycache__/
|
| 13 |
*.pyc
|
| 14 |
-
.ipynb_checkpoints/
|
| 15 |
.pytest_cache/
|
|
|
|
| 1 |
+
# Data stays out of the repository except the property tables.
|
| 2 |
data/*
|
| 3 |
!data/properties/
|
| 4 |
+
!data/properties/*
|
| 5 |
+
# Prediction output and the mesh cache
|
| 6 |
runs/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
# Python
|
| 8 |
__pycache__/
|
| 9 |
*.pyc
|
|
|
|
| 10 |
.pytest_cache/
|
README.md
CHANGED
|
@@ -166,8 +166,9 @@ Each node gets 15 features computed from the mesh and its surface tags:
|
|
| 166 |
- part volume, surface area and the fraction of surface that cools;
|
| 167 |
- local modulus, the volume-to-surface ratio inside geodesic balls of 5, 15 and 40 mm, which is
|
| 168 |
a local version of Chvorinov's modulus;
|
| 169 |
-
- two boundary-condition channels
|
| 170 |
-
|
|
|
|
| 171 |
|
| 172 |
The alloy and mold enter as five dimensionless numbers: Stefan number, normalized freezing
|
| 173 |
range, thermal diffusivity ratio, normalized superheat and normalized mold effusivity. They
|
|
@@ -216,13 +217,15 @@ spread is not monotone in `a`, so the solver brackets the smallest root explicit
|
|
| 216 |
| `weights/ood_ranges.json` | <1 KB | Training envelope (part volume, size, distance to cooling) for out-of-distribution warnings |
|
| 217 |
| `weights/SHA256SUMS` | <1 KB | Checksums |
|
| 218 |
| `src/model_api.py` | | `CastSolidModel`, the mesh-in, field-out interface shown above |
|
|
|
|
| 219 |
| `data/properties/` | | Alloy and mold tables ([README](data/properties/README.md)) |
|
| 220 |
|
| 221 |
None of the weight files can execute code when loaded. The GNN is stored as
|
| 222 |
[safetensors](https://github.com/huggingface/safetensors) and the heads as
|
| 223 |
[skops](https://skops.readthedocs.io/); before loading a skops file, `src/release_io.py` checks
|
| 224 |
-
every type it names against an allowlist of scikit-learn and NumPy types. The rest of `src/`
|
| 225 |
-
|
|
|
|
| 226 |
|
| 227 |
## Training data
|
| 228 |
|
|
@@ -251,6 +254,13 @@ Training meshes have roughly 7,500 to 10,000 nodes. The target at each node is t
|
|
| 251 |
temperature drops through the solidus. Material properties come from handbook data. The raw
|
| 252 |
simulation corpus (about 100 GB) is not distributed.
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
## Training procedure
|
| 255 |
|
| 256 |
v5.1 came out of a chain of fine-tunes, not a single training run.
|
|
@@ -327,7 +337,8 @@ every result, and send high-consequence cases to a calibrated simulation and a p
|
|
| 327 |
|
| 328 |
## Reproducibility
|
| 329 |
|
| 330 |
-
The published files were converted from the internal checkpoints
|
|
|
|
| 331 |
|
| 332 |
| Release file | Internal artifact | Check |
|
| 333 |
|---|---|---|
|
|
@@ -335,8 +346,9 @@ The published files were converted from the internal checkpoints by `tools/expor
|
|
| 335 |
| `gbm_total_v3.skops` | `hgb_v3.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
|
| 336 |
| `gbm_std_v2.skops` | `hgb_std_v2.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
|
|
|
| 340 |
their output. Other versions used: Python 3.11, PyTorch 2.3.0, PyTorch Geometric 2.7.0,
|
| 341 |
NumPy 2.x.
|
| 342 |
|
|
|
|
| 166 |
- part volume, surface area and the fraction of surface that cools;
|
| 167 |
- local modulus, the volume-to-surface ratio inside geodesic balls of 5, 15 and 40 mm, which is
|
| 168 |
a local version of Chvorinov's modulus;
|
| 169 |
+
- two boundary-condition channels: the mold effusivity on surface nodes (zero inside the
|
| 170 |
+
part), and a boundary heat-input channel, which is zero because a sand-mold surface adds no
|
| 171 |
+
heat.
|
| 172 |
|
| 173 |
The alloy and mold enter as five dimensionless numbers: Stefan number, normalized freezing
|
| 174 |
range, thermal diffusivity ratio, normalized superheat and normalized mold effusivity. They
|
|
|
|
| 217 |
| `weights/ood_ranges.json` | <1 KB | Training envelope (part volume, size, distance to cooling) for out-of-distribution warnings |
|
| 218 |
| `weights/SHA256SUMS` | <1 KB | Checksums |
|
| 219 |
| `src/model_api.py` | | `CastSolidModel`, the mesh-in, field-out interface shown above |
|
| 220 |
+
| `src/features.py`, `src/model_gnn.py`, `src/gbm_heads.py`, `src/hybrid.py` | | The model: node features, graph network, gradient-boosted heads, recomposition |
|
| 221 |
| `data/properties/` | | Alloy and mold tables ([README](data/properties/README.md)) |
|
| 222 |
|
| 223 |
None of the weight files can execute code when loaded. The GNN is stored as
|
| 224 |
[safetensors](https://github.com/huggingface/safetensors) and the heads as
|
| 225 |
[skops](https://skops.readthedocs.io/); before loading a skops file, `src/release_io.py` checks
|
| 226 |
+
every type it names against an allowlist of scikit-learn and NumPy types. The rest of `src/`
|
| 227 |
+
(meshing a STEP file, shrinkage screening, result export, a browser viewer) and `tests/` are the
|
| 228 |
+
supporting pipeline, described on GitHub. Training code is not part of this release.
|
| 229 |
|
| 230 |
## Training data
|
| 231 |
|
|
|
|
| 254 |
temperature drops through the solidus. Material properties come from handbook data. The raw
|
| 255 |
simulation corpus (about 100 GB) is not distributed.
|
| 256 |
|
| 257 |
+
One data issue shaped this project more than any model choice. The solver stored temperatures
|
| 258 |
+
in dolfinx degree-of-freedom order, while coordinates and geometric features were in gmsh node
|
| 259 |
+
order, so about 60% of the nodal targets were silently attached to the wrong positions. Every
|
| 260 |
+
early model failure traced back to it. The training set was rebuilt with a recovered
|
| 261 |
+
permutation between the two orderings. If you train on solver output, check node identity across
|
| 262 |
+
every solver and mesh interface before tuning a model.
|
| 263 |
+
|
| 264 |
## Training procedure
|
| 265 |
|
| 266 |
v5.1 came out of a chain of fine-tunes, not a single training run.
|
|
|
|
| 337 |
|
| 338 |
## Reproducibility
|
| 339 |
|
| 340 |
+
The published files were converted from the internal training checkpoints and checked against
|
| 341 |
+
them:
|
| 342 |
|
| 343 |
| Release file | Internal artifact | Check |
|
| 344 |
|---|---|---|
|
|
|
|
| 346 |
| `gbm_total_v3.skops` | `hgb_v3.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
|
| 347 |
| `gbm_std_v2.skops` | `hgb_std_v2.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
|
| 348 |
|
| 349 |
+
The code in this repository was also run side by side with the internal pipeline on five
|
| 350 |
+
production parts (two of them with both alloys), plus a fixed-size mesh with a custom property
|
| 351 |
+
set: every output field matched exactly. The heads were trained with scikit-learn 1.5.2, and other versions may refuse to load them or change
|
| 352 |
their output. Other versions used: Python 3.11, PyTorch 2.3.0, PyTorch Geometric 2.7.0,
|
| 353 |
NumPy 2.x.
|
| 354 |
|
requirements.txt
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
-
# Install PyTorch 2.3 for your platform first
|
| 2 |
-
#
|
| 3 |
# pip install "torch==2.3.0" --index-url https://download.pytorch.org/whl/cpu
|
| 4 |
# pip install torch-cluster -f https://data.pyg.org/whl/torch-2.3.0+cpu.html
|
| 5 |
numpy>=2.0
|
| 6 |
scipy>=1.11
|
| 7 |
scikit-learn==1.5.2
|
| 8 |
-
joblib>=1.3
|
| 9 |
skops>=0.10
|
| 10 |
safetensors>=0.4
|
| 11 |
torch-geometric==2.7.0
|
|
|
|
| 1 |
+
# Install PyTorch 2.3 for your platform first, plus the matching torch-cluster
|
| 2 |
+
# wheel, for example on CPU:
|
| 3 |
# pip install "torch==2.3.0" --index-url https://download.pytorch.org/whl/cpu
|
| 4 |
# pip install torch-cluster -f https://data.pyg.org/whl/torch-2.3.0+cpu.html
|
| 5 |
numpy>=2.0
|
| 6 |
scipy>=1.11
|
| 7 |
scikit-learn==1.5.2
|
|
|
|
| 8 |
skops>=0.10
|
| 9 |
safetensors>=0.4
|
| 10 |
torch-geometric==2.7.0
|
src/baseline.py
DELETED
|
@@ -1,63 +0,0 @@
|
|
| 1 |
-
"""Per-node regression baselines: gradient boosting (primary) and a small MLP.
|
| 2 |
-
|
| 3 |
-
Both predict standardized log1p(solidification_time) from standardized
|
| 4 |
-
physics features. predict_seconds inverts the transform back to seconds.
|
| 5 |
-
"""
|
| 6 |
-
import numpy as np
|
| 7 |
-
import torch
|
| 8 |
-
import torch.nn as nn
|
| 9 |
-
from sklearn.ensemble import HistGradientBoostingRegressor
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def train_hgb(X_std, y_log, max_iter: int = 300):
|
| 13 |
-
model = HistGradientBoostingRegressor(
|
| 14 |
-
max_iter=max_iter, learning_rate=0.1, max_leaf_nodes=63,
|
| 15 |
-
l2_regularization=1.0, early_stopping=True, validation_fraction=0.1,
|
| 16 |
-
random_state=0)
|
| 17 |
-
model.fit(X_std, y_log) # trained directly on log1p(y)
|
| 18 |
-
return model
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
class _MLP(nn.Module):
|
| 22 |
-
def __init__(self, in_dim):
|
| 23 |
-
super().__init__()
|
| 24 |
-
self.net = nn.Sequential(
|
| 25 |
-
nn.Linear(in_dim, 128), nn.ReLU(),
|
| 26 |
-
nn.Linear(128, 64), nn.ReLU(),
|
| 27 |
-
nn.Linear(64, 1))
|
| 28 |
-
|
| 29 |
-
def forward(self, x):
|
| 30 |
-
return self.net(x).squeeze(-1)
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def train_mlp(X_std, y_log, epochs: int = 30, device: str = "cpu", batch: int = 8192):
|
| 34 |
-
torch.manual_seed(0)
|
| 35 |
-
X = torch.tensor(np.asarray(X_std), dtype=torch.float32, device=device)
|
| 36 |
-
y = torch.tensor(np.asarray(y_log), dtype=torch.float32, device=device)
|
| 37 |
-
model = _MLP(X.shape[1]).to(device)
|
| 38 |
-
opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-5)
|
| 39 |
-
loss_fn = nn.SmoothL1Loss()
|
| 40 |
-
n = X.shape[0]
|
| 41 |
-
for _ in range(epochs):
|
| 42 |
-
perm = torch.randperm(n, device=device)
|
| 43 |
-
for i in range(0, n, batch):
|
| 44 |
-
idx = perm[i:i + batch]
|
| 45 |
-
opt.zero_grad()
|
| 46 |
-
loss = loss_fn(model(X[idx]), y[idx])
|
| 47 |
-
loss.backward()
|
| 48 |
-
opt.step()
|
| 49 |
-
model.eval()
|
| 50 |
-
return model
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
def predict_seconds(model, X_std, norm) -> np.ndarray:
|
| 54 |
-
if isinstance(model, nn.Module):
|
| 55 |
-
dev = next(model.parameters()).device
|
| 56 |
-
with torch.no_grad():
|
| 57 |
-
pred_log = model(
|
| 58 |
-
torch.tensor(np.asarray(X_std), dtype=torch.float32, device=dev)
|
| 59 |
-
).cpu().numpy()
|
| 60 |
-
else:
|
| 61 |
-
pred_log = model.predict(X_std)
|
| 62 |
-
# Both models predict raw log1p(seconds); invert uniformly.
|
| 63 |
-
return np.clip(np.expm1(pred_log), 0.0, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/build_dataset.py
DELETED
|
@@ -1,1199 +0,0 @@
|
|
| 1 |
-
"""Reprocess raw .pt + paired .msh into cached per-node feature files.
|
| 2 |
-
|
| 3 |
-
Writes one .npz per casting under <data_root>/features/graphs/ plus a global
|
| 4 |
-
meta.npz and norm_stats.npz. Parallelised across CPU cores.
|
| 5 |
-
"""
|
| 6 |
-
import os
|
| 7 |
-
# CPU-hygiene: cap every BLAS backend to 1 thread PER WORKER PROCESS, set
|
| 8 |
-
# before numpy (or anything that transitively imports it) is first imported
|
| 9 |
-
# in this process. This must run before `import numpy as np` below to take
|
| 10 |
-
# effect for numpy's own BLAS init; ProcessPoolExecutor workers either
|
| 11 |
-
# re-import this module from scratch ("spawn", re-running this) or fork from
|
| 12 |
-
# a parent that already applied it ("fork", inherited via copy-on-write) --
|
| 13 |
-
# mirrors tools/doe/doe.py's identical top-of-file pattern.
|
| 14 |
-
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS",
|
| 15 |
-
"MKL_NUM_THREADS", "NUMEXPR_NUM_THREADS"):
|
| 16 |
-
os.environ.setdefault(_v, "1")
|
| 17 |
-
|
| 18 |
-
import csv
|
| 19 |
-
import itertools
|
| 20 |
-
import json
|
| 21 |
-
import os.path as osp
|
| 22 |
-
import re
|
| 23 |
-
import shutil
|
| 24 |
-
import sys
|
| 25 |
-
from glob import glob
|
| 26 |
-
|
| 27 |
-
import meshio
|
| 28 |
-
import numpy as np
|
| 29 |
-
import torch
|
| 30 |
-
from scipy.spatial import cKDTree
|
| 31 |
-
|
| 32 |
-
sys.path.insert(0, osp.dirname(osp.dirname(__file__)))
|
| 33 |
-
from src.msh_parse import parse_msh
|
| 34 |
-
from src.features import (compute_features, FEATURE_NAMES, FEATURE_NAMES_V3,
|
| 35 |
-
FEATURE_NAMES_V4, FEATURE_NAMES_V5, G_VECTOR_NAMES,
|
| 36 |
-
_node_features, alloy_g_vector, chvorinov_t_ref)
|
| 37 |
-
from src.geometry import mesh_volume, total_area
|
| 38 |
-
|
| 39 |
-
_FACE_RE = re.compile(r'_[+-][XYZ]')
|
| 40 |
-
_MSH_SETS = ("msh_none", "msh_half", "msh_all")
|
| 41 |
-
|
| 42 |
-
# Canonical raw temperature backup (dolfinx-DOF-order snapshots_nodal), on the
|
| 43 |
-
# large Media drive. Used when data/pt/*.pt has been deleted to free space.
|
| 44 |
-
# The .pt temps are byte-identical to these npz (verified). Override if moved.
|
| 45 |
-
RAW_NPZ_DIRS = ("data/raw",)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def find_msh(stem: str, data_root: str = "data"):
|
| 49 |
-
for s in _MSH_SETS:
|
| 50 |
-
hits = glob(osp.join(data_root, s, "*", f"{stem}.msh"))
|
| 51 |
-
if hits:
|
| 52 |
-
return hits[0]
|
| 53 |
-
return None
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def _load_raw_temps(stem: str, data_root: str = "data"):
|
| 57 |
-
"""Return (temps_dof (T,N) float32, times (T,) float32) or None.
|
| 58 |
-
|
| 59 |
-
Prefers data/pt/<stem>.pt; falls back to the canonical raw npz backup so the
|
| 60 |
-
pipeline still rebuilds after data/pt is deleted."""
|
| 61 |
-
ptp = osp.join(data_root, "pt", f"{stem}.pt")
|
| 62 |
-
if osp.exists(ptp):
|
| 63 |
-
raw = torch.load(ptp, weights_only=False)
|
| 64 |
-
return raw["temps"].numpy().astype(np.float32), raw["times"].numpy().astype(np.float32)
|
| 65 |
-
for d in RAW_NPZ_DIRS:
|
| 66 |
-
hits = glob(osp.join(d, "*", f"{stem}.npz"))
|
| 67 |
-
if hits:
|
| 68 |
-
z = np.load(hits[0])
|
| 69 |
-
return z["snapshots_nodal"].astype(np.float32), z["times"].astype(np.float32)
|
| 70 |
-
return None
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def process_one(stem: str, data_root: str = "data", feature_version: int = 2):
|
| 74 |
-
mpath = find_msh(stem, data_root)
|
| 75 |
-
if mpath is None:
|
| 76 |
-
print(f" WARN missing msh for {stem}")
|
| 77 |
-
return None
|
| 78 |
-
src = _load_raw_temps(stem, data_root)
|
| 79 |
-
if src is None:
|
| 80 |
-
print(f" WARN no raw temps for {stem} (.pt or npz backup)")
|
| 81 |
-
return None
|
| 82 |
-
temps_dof, times = src
|
| 83 |
-
mesh = parse_msh(mpath)
|
| 84 |
-
n_pt = temps_dof.shape[1]
|
| 85 |
-
if mesh.node_xyz.shape[0] != n_pt:
|
| 86 |
-
print(f" WARN node count mismatch {stem}: msh={mesh.node_xyz.shape[0]} raw={n_pt}")
|
| 87 |
-
return None
|
| 88 |
-
|
| 89 |
-
# Apply the node-ordering recovery permutation: the raw temps are in dolfinx
|
| 90 |
-
# DOF order, which is scrambled relative to the gmsh node order used by pos/
|
| 91 |
-
# mesh labels. perm g maps DOF->gmsh node: temps_gmsh[:, g] = temps_dof.
|
| 92 |
-
# Files without a validated perm are excluded (their per-node target is
|
| 93 |
-
# unrecoverable). See docs spec §1.5.
|
| 94 |
-
perm_path = osp.join(data_root, "features", "perms", f"{stem}.npy")
|
| 95 |
-
if not osp.exists(perm_path):
|
| 96 |
-
print(f" WARN no recovery perm for {stem} (excluded)")
|
| 97 |
-
return None
|
| 98 |
-
g = np.load(perm_path)
|
| 99 |
-
if g.shape[0] != n_pt:
|
| 100 |
-
print(f" WARN perm/temps size mismatch {stem}: perm={g.shape[0]} temps_N={n_pt}")
|
| 101 |
-
return None
|
| 102 |
-
temps = np.empty_like(temps_dof)
|
| 103 |
-
temps[:, g] = temps_dof # reorder into gmsh node order
|
| 104 |
-
|
| 105 |
-
feat = compute_features(mesh, temps, times, version=feature_version)
|
| 106 |
-
|
| 107 |
-
n_adiab = len(_FACE_RE.findall(stem))
|
| 108 |
-
prefix = stem.split("_")[0]
|
| 109 |
-
return {
|
| 110 |
-
"stem": stem,
|
| 111 |
-
"X": feat["X"],
|
| 112 |
-
"y": feat["y"],
|
| 113 |
-
"pos": feat["pos"],
|
| 114 |
-
"mesh_edges": feat["mesh_edges"],
|
| 115 |
-
"y_global": feat["y_global"],
|
| 116 |
-
"prefix": prefix,
|
| 117 |
-
"n_adiab": n_adiab,
|
| 118 |
-
"is_fully_cooled": n_adiab == 0,
|
| 119 |
-
"n_nodes": n_pt,
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
import argparse
|
| 124 |
-
from concurrent.futures import ProcessPoolExecutor
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _init_worker():
|
| 128 |
-
"""ProcessPoolExecutor initializer: lower scheduling priority + force
|
| 129 |
-
every BLAS backend down to 1 thread at RUNTIME via threadpoolctl. The
|
| 130 |
-
module-top env vars already cover a fresh/"spawn" worker's own numpy
|
| 131 |
-
import; this is the belt-and-braces fallback for a "fork" worker that
|
| 132 |
-
inherited an ALREADY-initialized BLAS library from the parent (in which
|
| 133 |
-
case env vars set post-fork would be too late) -- mirrors
|
| 134 |
-
tools/doe/doe.py's `_init_worker`."""
|
| 135 |
-
try:
|
| 136 |
-
os.nice(10)
|
| 137 |
-
except Exception:
|
| 138 |
-
pass
|
| 139 |
-
try:
|
| 140 |
-
import threadpoolctl
|
| 141 |
-
threadpoolctl.threadpool_limits(1)
|
| 142 |
-
except Exception:
|
| 143 |
-
pass
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def write_record(rec, out_dir):
|
| 147 |
-
gdir = osp.join(out_dir, "graphs")
|
| 148 |
-
os.makedirs(gdir, exist_ok=True)
|
| 149 |
-
np.savez(osp.join(gdir, f"{rec['stem']}.npz"), X=rec["X"], y=rec["y"], pos=rec["pos"], mesh_edges=rec["mesh_edges"])
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
def _all_stems(data_root):
|
| 153 |
-
"""Enumerate casting stems. Prefer data/pt; after it is deleted, fall back to
|
| 154 |
-
the recovery perms (the exact recoverable set), then the raw npz backup."""
|
| 155 |
-
stems = [osp.splitext(osp.basename(p))[0]
|
| 156 |
-
for p in glob(osp.join(data_root, "pt", "*.pt"))]
|
| 157 |
-
if not stems:
|
| 158 |
-
stems = [osp.splitext(osp.basename(p))[0]
|
| 159 |
-
for p in glob(osp.join(data_root, "features", "perms", "*.npy"))]
|
| 160 |
-
if not stems:
|
| 161 |
-
for d in RAW_NPZ_DIRS:
|
| 162 |
-
stems = [osp.splitext(osp.basename(p))[0]
|
| 163 |
-
for p in glob(osp.join(d, "*", "*.npz"))]
|
| 164 |
-
if stems:
|
| 165 |
-
break
|
| 166 |
-
return sorted(stems)
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
# ============================================================================
|
| 170 |
-
# feature_version == 4: join raw FEniCSx solves (`data/raw_v4/<run_id>.npz`,
|
| 171 |
-
# {t,gradT,dTdt} already in gmsh node order -- see tools/doe/adapters.py's
|
| 172 |
-
# map_to_gmsh) with the DOE manifest (per-run spec: material, mold/chill/
|
| 173 |
-
# sleeve BC, hold_out). One graph per manifest row; `run_id` is the cache key
|
| 174 |
-
# ("stem").
|
| 175 |
-
#
|
| 176 |
-
# IMPORTANT interface note: `src.features.compute_features(mesh, temps, times,
|
| 177 |
-
# version=4, spec=...)` (Task 3) still expects raw (T,N) temperature snapshots
|
| 178 |
-
# + times, and internally derives y via `solidification_time` -- that is the
|
| 179 |
-
# OLD raw-pipeline contract (v2/v3). The FT2 raw solves are NOT snapshot
|
| 180 |
-
# histories: `fenicsx_solver.py` already outputs the per-node crossing time
|
| 181 |
-
# `t` directly (see its module docstring). So this v4 path does NOT call
|
| 182 |
-
# `compute_features`; it composes the same lower-level building blocks
|
| 183 |
-
# (`_node_features`, `alloy_g_vector`, `chvorinov_t_ref`) directly, using the
|
| 184 |
-
# solver-provided `t` array as `y` with no re-derivation.
|
| 185 |
-
# ============================================================================
|
| 186 |
-
def _read_manifest_rows(manifest_path: str) -> list:
|
| 187 |
-
with open(manifest_path, newline="") as f:
|
| 188 |
-
return list(csv.DictReader(f))
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
def _archive_mesh_work_dir(data_root: str) -> str:
|
| 192 |
-
d = osp.join(data_root, "mesh_work_v4")
|
| 193 |
-
os.makedirs(d, exist_ok=True)
|
| 194 |
-
return d
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def _find_msh_v4(row: dict, data_root: str = "data"):
|
| 198 |
-
"""Resolve a manifest row's geometry to a .msh path.
|
| 199 |
-
- "path": geometry_id verbatim (a full .msh path).
|
| 200 |
-
- "primitive"/"scaled": reuse the existing 42-primitive lookup
|
| 201 |
-
(`find_msh`) by geometry_id.
|
| 202 |
-
- "archive": geometry_id is a STEP file path; mesh it via
|
| 203 |
-
`src.step_mesh.mesh_step` (same tool `tools.doe.adapters.mesh()` uses
|
| 204 |
-
for dataset D) into `<data_root>/mesh_work_v4/<run_id>.msh`, cached
|
| 205 |
-
across calls (the pre-pass Chvorinov-C fit and the main pass would
|
| 206 |
-
otherwise mesh the same archive part twice). Returns None (with a
|
| 207 |
-
printed WARN) if the STEP import/meshing itself fails -- a bad archive
|
| 208 |
-
part must WARN+skip its row, not crash the batch.
|
| 209 |
-
`scale_factor` (if != 1.0) is applied to the resolved mesh's node
|
| 210 |
-
coordinates by the caller, not here.
|
| 211 |
-
|
| 212 |
-
ft2a feature-BC fix: a non-empty `mesh_variant` column (written by
|
| 213 |
-
`tools/doe/augment_manifest_bc.py` for chill/sleeve rows) is an explicit
|
| 214 |
-
face-tagged msh_half path that OVERRIDES every other resolution branch
|
| 215 |
-
below -- mirrors `tools.doe.adapters.mesh()`'s identical check, which the
|
| 216 |
-
SOLVER side already uses. msh_none (what `find_msh` would otherwise
|
| 217 |
-
return) has no "Adiabatic" (BC-patch) facet tag, so without this override
|
| 218 |
-
the chill/sleeve/exo BC-dependent feature channels (is_adiabatic,
|
| 219 |
-
boundary_extraction, exothermic_input) would be computed as if the row
|
| 220 |
-
were plain sand, even though the row's SOLVER target used the tagged
|
| 221 |
-
msh_half mesh -- silently dropping the BC input signal from X. msh_none
|
| 222 |
-
and its msh_half variant share the same node order/coordinates (verified
|
| 223 |
-
-- only the facet tagging differs), so this override changes only the
|
| 224 |
-
tag-derived feature channels, not the geometric features or the
|
| 225 |
-
per-node alignment with the (msh_half-based) solver targets.
|
| 226 |
-
|
| 227 |
-
Task 3 (ft3-assembly-finetune): dataset=="E" rows (assembly/integration)
|
| 228 |
-
have mesh_variant as a literal direct path. For consistency with FT2a's
|
| 229 |
-
batch-resilience pattern, missing mesh_variant is WARN+skip (row-level
|
| 230 |
-
failure, not batch-killing), same as non-E rows."""
|
| 231 |
-
mesh_variant = (row.get("mesh_variant") or "").strip()
|
| 232 |
-
if mesh_variant:
|
| 233 |
-
if not osp.exists(mesh_variant):
|
| 234 |
-
print(f" WARN mesh_variant {mesh_variant!r} (run_id="
|
| 235 |
-
f"{row.get('run_id')!r}) does not exist -- stale path?")
|
| 236 |
-
return None
|
| 237 |
-
return mesh_variant
|
| 238 |
-
source = row.get("geometry_source") or "primitive"
|
| 239 |
-
gid = row["geometry_id"]
|
| 240 |
-
if source == "path":
|
| 241 |
-
return gid if osp.exists(gid) else None
|
| 242 |
-
if source == "archive":
|
| 243 |
-
out_msh = osp.join(_archive_mesh_work_dir(data_root),
|
| 244 |
-
f"{row.get('run_id', 'archive')}.msh")
|
| 245 |
-
if osp.exists(out_msh):
|
| 246 |
-
return out_msh
|
| 247 |
-
try:
|
| 248 |
-
from src.step_mesh import mesh_step
|
| 249 |
-
mesh_step(gid, out_msh)
|
| 250 |
-
except Exception as e: # noqa
|
| 251 |
-
print(f" WARN mesh_step failed for run_id={row.get('run_id')} "
|
| 252 |
-
f"(STEP={gid!r}): {e}")
|
| 253 |
-
return None
|
| 254 |
-
return out_msh if osp.exists(out_msh) else None
|
| 255 |
-
return find_msh(gid, data_root)
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
def _resolve_material_props_v4(material_id: str, manifest_dir: str) -> dict:
|
| 259 |
-
"""material_id -> {k, rho, cp, L_kJ_kg, TL, TS, Tpour}, mirroring
|
| 260 |
-
`tools.doe.adapters._resolve_material`'s 3-step fallback: the
|
| 261 |
-
owner-verified CSV, then `materials_synthetic.csv` next to the manifest
|
| 262 |
-
(SYN_* ids), then the 12 named anchor grades."""
|
| 263 |
-
from tools.doe.load_properties import load_materials
|
| 264 |
-
for m in load_materials():
|
| 265 |
-
if m.material_id == material_id:
|
| 266 |
-
return dict(k=m.k, rho=m.rho, cp=m.cp, L_kJ_kg=m.L,
|
| 267 |
-
TL=m.TL, TS=m.TS, Tpour=m.Tpour)
|
| 268 |
-
|
| 269 |
-
synth_csv = osp.join(manifest_dir, "materials_synthetic.csv")
|
| 270 |
-
if osp.exists(synth_csv):
|
| 271 |
-
with open(synth_csv, newline="") as f:
|
| 272 |
-
for row in csv.DictReader(f):
|
| 273 |
-
if row.get("material_id") == material_id:
|
| 274 |
-
return dict(
|
| 275 |
-
k=float(row["k_W_mK"]), rho=float(row["rho_kg_m3"]),
|
| 276 |
-
cp=float(row["cp_J_kgK"]), L_kJ_kg=float(row["L_kJ_kg"]),
|
| 277 |
-
TL=float(row["TL_C"]), TS=float(row["TS_C"]),
|
| 278 |
-
Tpour=float(row["Tpour_C"]))
|
| 279 |
-
|
| 280 |
-
from tools.doe.doe import _anchor_materials
|
| 281 |
-
for m in _anchor_materials():
|
| 282 |
-
if m.material_id == material_id:
|
| 283 |
-
return dict(k=m.k, rho=m.rho, cp=m.cp, L_kJ_kg=m.L,
|
| 284 |
-
TL=m.TL, TS=m.TS, Tpour=m.Tpour)
|
| 285 |
-
|
| 286 |
-
raise KeyError(
|
| 287 |
-
f"material_id {material_id!r} not resolvable via load_properties, "
|
| 288 |
-
f"{synth_csv}, or tools.doe.doe._anchor_materials()")
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
def _resolve_chill_effusivity_v4(row: dict) -> float:
|
| 292 |
-
"""0.0 unless bc_config=='chills', in which case
|
| 293 |
-
`tools.doe.doe.get_chill_effusivities()[chill_material]` -- raises
|
| 294 |
-
ValueError (never a silent 0.0) if chill_material is empty/unknown."""
|
| 295 |
-
if (row.get("bc_config") or "none") != "chills":
|
| 296 |
-
return 0.0
|
| 297 |
-
from tools.doe.doe import get_chill_effusivities
|
| 298 |
-
chill_material = (row.get("chill_material") or "").strip()
|
| 299 |
-
effs = get_chill_effusivities()
|
| 300 |
-
if not chill_material or chill_material not in effs:
|
| 301 |
-
raise ValueError(
|
| 302 |
-
f"run_id={row.get('run_id')!r}: bc_config=='chills' but "
|
| 303 |
-
f"chill_material={chill_material!r} not in get_chill_effusivities() "
|
| 304 |
-
f"(known: {sorted(effs)}) -- refusing to silently use 0.0")
|
| 305 |
-
return float(effs[chill_material])
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
def _resolve_exo_q0_v4(row: dict,
|
| 309 |
-
exo_calibration_csv: str = "data/properties/exo_calibration.csv",
|
| 310 |
-
tol: float = 0.05) -> float:
|
| 311 |
-
"""0.0 unless bc_config=='exo_sleeve', in which case the nearest-MEF
|
| 312 |
-
`q0_W_m2` row of `exo_calibration_csv` (the sweep output of
|
| 313 |
-
`tools/doe/calibrate_exo.py`), resolved via the shared
|
| 314 |
-
`tools.doe.load_properties.resolve_exo_q0_by_mef` lookup (also used by
|
| 315 |
-
`src.assembly.validate_bodies` for Part B) -- raises ValueError (never a
|
| 316 |
-
silent 0.0) if the calibration table is missing or has no row within
|
| 317 |
-
`tol` of the row's sleeve_MEF, mirroring
|
| 318 |
-
`tools.doe.adapters._resolve_exo_params`. The ValueError message is
|
| 319 |
-
re-wrapped here with this row's run_id for context."""
|
| 320 |
-
if (row.get("bc_config") or "none") != "exo_sleeve":
|
| 321 |
-
return 0.0
|
| 322 |
-
from tools.doe.load_properties import resolve_exo_q0_by_mef
|
| 323 |
-
mef = float(row.get("sleeve_MEF") or 0.0)
|
| 324 |
-
try:
|
| 325 |
-
return resolve_exo_q0_by_mef(mef, exo_calibration_csv, tol)
|
| 326 |
-
except ValueError as e:
|
| 327 |
-
raise ValueError(f"run_id={row.get('run_id')!r}: {e}") from e
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
# --------------------------------------------------------------------- #
|
| 331 |
-
# I1 (ft2a-final-review): archive/DF feature-mesh coordinate correspondence.
|
| 332 |
-
#
|
| 333 |
-
# For archive rows the FEATURE builder (`_find_msh_v4`, above) re-meshes the
|
| 334 |
-
# row's STEP file via `mesh_step` INDEPENDENTLY of the SOLVER side, which
|
| 335 |
-
# also calls `mesh_step` (via `tools.doe.adapters.mesh()`) but into a
|
| 336 |
-
# DIFFERENT work directory (`<doe_dir>/mesh_work/` vs this module's
|
| 337 |
-
# `<data_root>/mesh_work_v4/`) -- two separate meshing invocations of the
|
| 338 |
-
# same STEP file. Until now only the resulting node COUNT was checked to
|
| 339 |
-
# match the raw npz's array length (see the `t.shape[0] != n_pt` check in
|
| 340 |
-
# `_process_row_stage1`); node ORDER/coordinates were never verified. The
|
| 341 |
-
# solver's `t`/`gradT`/`dTdt`/`t_liquidus` raw_v4 arrays are in the node
|
| 342 |
-
# order of THAT solve-time mesh (`fenicsx_solver._dof_to_gmsh_perm`
|
| 343 |
-
# coordinate-matches its dolfinx DOFs onto it, at ~1e-9 m tolerance); if a
|
| 344 |
-
# re-mesh of the identical STEP ever produced the same node count in a
|
| 345 |
-
# DIFFERENT order (a gmsh non-determinism this repo has not observed but
|
| 346 |
-
# cannot rule out -- different gmsh version, different meshing-algorithm
|
| 347 |
-
# code path, etc.), targets and features would silently misalign for that
|
| 348 |
-
# row with no other symptom. This mirrors the strict bijection
|
| 349 |
-
# `tools.doe.adapters.map_to_gmsh` already enforces on the solver side, on
|
| 350 |
-
# the feature side.
|
| 351 |
-
# --------------------------------------------------------------------- #
|
| 352 |
-
_ARCHIVE_COORD_TOL_MM = 1e-6
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
def _archive_reference_mesh_path(row: dict, manifest_dir: str) -> str:
|
| 356 |
-
"""The ORIGINAL solve-time .msh for an archive/DF row, as cached by
|
| 357 |
-
`tools.doe.adapters.mesh()` under `<doe_dir>/mesh_work/<run_id>[_scaled]
|
| 358 |
-
.msh` (`adapters._work_dir()`'s naming convention). `manifest_dir`
|
| 359 |
-
(where manifest.csv lives) IS `<doe_dir>` in the standard layout (both
|
| 360 |
-
default to "doe_out"), so no separate config knob is needed."""
|
| 361 |
-
run_id = row.get("run_id", "<unknown>")
|
| 362 |
-
scale = float(row.get("scale_factor") or 1.0)
|
| 363 |
-
suffix = "_scaled" if scale != 1.0 else ""
|
| 364 |
-
return osp.join(manifest_dir, "mesh_work", f"{run_id}{suffix}.msh")
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
def _assert_archive_mesh_coord_correspondence(mesh, row: dict, manifest_dir: str) -> None:
|
| 368 |
-
"""No-op unless `row["geometry_source"] == "archive"` (msh_none/msh_half
|
| 369 |
-
primitive rows already share node order with the raw npz by construction
|
| 370 |
-
-- verified -- so this cost is deliberately not paid there).
|
| 371 |
-
|
| 372 |
-
For archive rows, raises a loud `ValueError` naming the run_id --
|
| 373 |
-
NEVER silently proceeds -- when:
|
| 374 |
-
- the reference solve-time mesh can't be found (an archive row with a
|
| 375 |
-
raw_v4 solve implies `tools.doe.adapters.mesh()` ran and cached one;
|
| 376 |
-
its absence means this check literally cannot be performed, which is
|
| 377 |
-
treated as a failure, not a silent pass);
|
| 378 |
-
- node counts differ; or
|
| 379 |
-
- node coordinates fail to correspond AT THE SAME INDEX, within
|
| 380 |
-
`_ARCHIVE_COORD_TOL_MM`.
|
| 381 |
-
The last check is deliberately NOT a plain nearest-neighbour SET
|
| 382 |
-
membership test (which a pure reordering of the identical point set
|
| 383 |
-
would pass): it uses a KDTree to find, for every feature-mesh node, its
|
| 384 |
-
nearest reference-mesh node (mirroring `fenicsx_solver.
|
| 385 |
-
_dof_to_gmsh_perm`'s own coordinate-matching approach) but then requires
|
| 386 |
-
BOTH that the match be within tolerance AND that the matched index equal
|
| 387 |
-
the feature node's own index (identity correspondence) -- since the two
|
| 388 |
-
meshes are expected to already share node order (same deterministic
|
| 389 |
-
`mesh_step` call on the same STEP), a same-count/same-position-set but
|
| 390 |
-
DIFFERENT-order mesh (exactly the silent-misalignment risk I1 flags)
|
| 391 |
-
must fail this check, not pass it."""
|
| 392 |
-
if (row.get("geometry_source") or "primitive") != "archive":
|
| 393 |
-
return
|
| 394 |
-
run_id = row.get("run_id", "<unknown>")
|
| 395 |
-
ref_path = _archive_reference_mesh_path(row, manifest_dir)
|
| 396 |
-
if not osp.exists(ref_path):
|
| 397 |
-
raise ValueError(
|
| 398 |
-
f"run_id={run_id!r}: archive-row coordinate-correspondence check "
|
| 399 |
-
f"cannot find the original solve-time mesh at {ref_path!r} (the "
|
| 400 |
-
f"one tools.doe.adapters.mesh() caches for the FEniCSx solve) -- "
|
| 401 |
-
"refusing to trust the feature builder's independently-remeshed "
|
| 402 |
-
"STEP without verifying it against it")
|
| 403 |
-
|
| 404 |
-
ref_pts = meshio.read(ref_path).points[:, :3].astype(np.float64)
|
| 405 |
-
feat_pts = np.asarray(mesh.node_xyz, dtype=np.float64)[:, :3]
|
| 406 |
-
if ref_pts.shape[0] != feat_pts.shape[0]:
|
| 407 |
-
raise ValueError(
|
| 408 |
-
f"run_id={run_id!r}: archive feature-mesh node count "
|
| 409 |
-
f"({feat_pts.shape[0]}) != solve-time mesh node count "
|
| 410 |
-
f"({ref_pts.shape[0]}) at {ref_path!r} -- a gmsh remesh of the "
|
| 411 |
-
"same STEP produced a DIFFERENT mesh; targets<->features cannot "
|
| 412 |
-
"be trusted to align for this row")
|
| 413 |
-
|
| 414 |
-
dist, idx = cKDTree(ref_pts).query(feat_pts)
|
| 415 |
-
max_dist = float(dist.max())
|
| 416 |
-
if max_dist > _ARCHIVE_COORD_TOL_MM:
|
| 417 |
-
raise ValueError(
|
| 418 |
-
f"run_id={run_id!r}: archive feature-mesh node coordinates do "
|
| 419 |
-
f"NOT correspond to the solve-time mesh at {ref_path!r} within "
|
| 420 |
-
f"{_ARCHIVE_COORD_TOL_MM} mm (max nearest-neighbour distance = "
|
| 421 |
-
f"{max_dist:.6g} mm) -- a gmsh remesh of the same STEP produced "
|
| 422 |
-
"a mesh whose node positions don't match; targets<->features "
|
| 423 |
-
"would silently misalign for this row")
|
| 424 |
-
if not np.array_equal(idx, np.arange(feat_pts.shape[0])):
|
| 425 |
-
n_reordered = int((idx != np.arange(feat_pts.shape[0])).sum())
|
| 426 |
-
raise ValueError(
|
| 427 |
-
f"run_id={run_id!r}: archive feature-mesh node ORDER does not "
|
| 428 |
-
f"match the solve-time mesh at {ref_path!r} -- {n_reordered} "
|
| 429 |
-
"node(s) matched a DIFFERENT index by nearest-neighbour "
|
| 430 |
-
"coordinate (same node positions, different order) -- a gmsh "
|
| 431 |
-
"remesh of the same STEP produced the same node count/positions "
|
| 432 |
-
"but a DIFFERENT order; targets<->features would silently "
|
| 433 |
-
"misalign for this row")
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
def _load_mesh_for_row(row: dict, data_root: str):
|
| 437 |
-
"""Resolve + parse a manifest row's mesh, applying scale_factor (if any)
|
| 438 |
-
to node coordinates. Returns None (with a printed warning) if the .msh
|
| 439 |
-
can't be found."""
|
| 440 |
-
mpath = _find_msh_v4(row, data_root)
|
| 441 |
-
if mpath is None:
|
| 442 |
-
print(f" WARN missing msh for run_id={row.get('run_id')} "
|
| 443 |
-
f"(geometry_id={row.get('geometry_id')!r})")
|
| 444 |
-
return None
|
| 445 |
-
mesh = parse_msh(mpath)
|
| 446 |
-
scale = float(row.get("scale_factor") or 1.0)
|
| 447 |
-
if scale != 1.0:
|
| 448 |
-
mesh.node_xyz = mesh.node_xyz * scale
|
| 449 |
-
return mesh
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
def _fit_chvorinov_c_per_family(rows: list, raw_dir: str, data_root: str) -> dict:
|
| 453 |
-
"""Per alloy_family, a fixed-exponent (Chvorinov exponent==2) Chvorinov
|
| 454 |
-
constant C fit from the generated (t_total, V/A) pairs: log(C) =
|
| 455 |
-
mean(log(t_total) - 2*log(M)) -- the geometric mean of the per-row ratio
|
| 456 |
-
t_total/M^2. This is the "underlying fit" `gate_b.fit_chvorinov` performs
|
| 457 |
-
a free two-parameter (slope+intercept) regression of, specialized to a
|
| 458 |
-
FIXED slope of 2 (the assumed t=C*M^2 model): more robust than the free
|
| 459 |
-
regression when a family has few/degenerate (M, t) points (e.g. a single
|
| 460 |
-
geometry solved once -- `gate_b.fit_chvorinov` requires >=3 non-degenerate
|
| 461 |
-
points and would raise), which is expected for early/small manifests."""
|
| 462 |
-
# M-4 (ft2a-branch-review): dataset=="S" (sanity cross-check pairs, always
|
| 463 |
-
# a duplicate of an existing A/B/C row) rows are EXCLUDED here -- they
|
| 464 |
-
# must not double-weight their base row's (M, t_total) point in the
|
| 465 |
-
# per-family geometric-mean fit, mirroring `_build_splits_v4`'s existing
|
| 466 |
-
# S exclusion from train/test.
|
| 467 |
-
fam_points = {}
|
| 468 |
-
for row in rows:
|
| 469 |
-
if row.get("dataset") == "S":
|
| 470 |
-
continue
|
| 471 |
-
run_id = row["run_id"]
|
| 472 |
-
mesh = _load_mesh_for_row(row, data_root)
|
| 473 |
-
if mesh is None:
|
| 474 |
-
continue
|
| 475 |
-
raw_path = osp.join(raw_dir, f"{run_id}.npz")
|
| 476 |
-
if not osp.exists(raw_path):
|
| 477 |
-
continue
|
| 478 |
-
with np.load(raw_path) as z:
|
| 479 |
-
if "t" not in z.files:
|
| 480 |
-
continue
|
| 481 |
-
t_total = float(np.max(z["t"]))
|
| 482 |
-
cool_tri = mesh.tris.get("Cooling", np.empty((0, 3), np.int64))
|
| 483 |
-
adiab_tri = mesh.tris.get("Adiabatic", np.empty((0, 3), np.int64))
|
| 484 |
-
area = total_area(mesh.node_xyz, cool_tri) + total_area(mesh.node_xyz, adiab_tri)
|
| 485 |
-
vol = mesh_volume(mesh.node_xyz, mesh.tets)
|
| 486 |
-
if area <= 0 or vol <= 0 or t_total <= 0:
|
| 487 |
-
continue
|
| 488 |
-
M = vol / area
|
| 489 |
-
fam = row.get("alloy_family") or "unknown"
|
| 490 |
-
fam_points.setdefault(fam, []).append((M, t_total))
|
| 491 |
-
|
| 492 |
-
return _fit_c_from_points(fam_points)
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
def _fit_c_from_points(fam_points: dict) -> dict:
|
| 496 |
-
"""Shared core of the per-family Chvorinov-C geometric-mean fit: given
|
| 497 |
-
{alloy_family: [(M, t_total), ...]} (M = volume/area), returns
|
| 498 |
-
{alloy_family: C}. Factored out so `_fit_chvorinov_c_per_family`
|
| 499 |
-
(mesh-parsing, standalone/tested directly) and `build_v4`'s parallel
|
| 500 |
-
stage-1 pipeline (which already has volume/area/t_total from stage 1 and
|
| 501 |
-
must NOT re-parse the mesh to get them -- see M-3) can never silently
|
| 502 |
-
diverge in the actual fit math."""
|
| 503 |
-
C_by_family = {}
|
| 504 |
-
for fam, pts in fam_points.items():
|
| 505 |
-
M = np.array([p[0] for p in pts], dtype=np.float64)
|
| 506 |
-
T = np.array([p[1] for p in pts], dtype=np.float64)
|
| 507 |
-
C_by_family[fam] = float(np.exp(np.mean(np.log(T) - 2.0 * np.log(M))))
|
| 508 |
-
return C_by_family
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
# ----------------------------------------------------------------------- #
|
| 512 |
-
# M-3 (ft2a-branch-review): build_v4 used to parse each row's .msh TWICE --
|
| 513 |
-
# once inside `_fit_chvorinov_c_per_family`'s pre-pass (for volume/area, to
|
| 514 |
-
# compute M=V/A) and again inside `process_one_v4`'s main pass (for X/pos/
|
| 515 |
-
# mesh_edges) -- and did both passes SERIALLY, row by row, which is the slow
|
| 516 |
-
# path for the ~4725-row FT2 manifest. Fixed by splitting the per-row work
|
| 517 |
-
# into two stages:
|
| 518 |
-
# stage 1 (`_process_row_stage1`, embarrassingly parallel, one mesh parse
|
| 519 |
-
# per row): resolve+parse the mesh ONCE, load the raw solve, resolve the
|
| 520 |
-
# BC/material spec, and compute X/pos/mesh_edges/volume/area/t_total.
|
| 521 |
-
# None of this needs the cross-row Chvorinov C fit -- `_node_features`
|
| 522 |
-
# never reads `spec["C_material"]` (only `chvorinov_t_ref` does, in stage
|
| 523 |
-
# 2), so X can be computed here without a fitted C yet.
|
| 524 |
-
# (serial aggregate, cheap, no mesh reparse): fit C_by_family straight from
|
| 525 |
-
# stage 1's (volume, area, t_total) triples via `_fit_c_from_points` --
|
| 526 |
-
# the same fit math `_fit_chvorinov_c_per_family` uses, so the two paths
|
| 527 |
-
# can never silently diverge.
|
| 528 |
-
# stage 2 (`_finalize_row_stage2`, pure arithmetic, no mesh/IO): plugs the
|
| 529 |
-
# fitted C into t_ref/y_nondim/g and assembles the final record.
|
| 530 |
-
# `build_v4` calls the two stages directly (stage 1 parallelised across the
|
| 531 |
-
# whole manifest, once) instead of going through `process_one_v4`, which
|
| 532 |
-
# remains as a back-compat single-row wrapper composing both stages in one
|
| 533 |
-
# call for anyone calling it directly.
|
| 534 |
-
# ----------------------------------------------------------------------- #
|
| 535 |
-
def _process_row_stage1(row: dict, manifest_dir: str, raw_dir: str, data_root: str,
|
| 536 |
-
exo_calibration_csv: str = "data/properties/exo_calibration.csv",
|
| 537 |
-
feature_version: int = 4):
|
| 538 |
-
"""Parallel-safe, picklable: parses the mesh exactly once. Returns a dict
|
| 539 |
-
of picklable (numpy/scalar) results for stage 2, or None (+ printed WARN)
|
| 540 |
-
if this row must be skipped -- every skip condition here is byte-for-byte
|
| 541 |
-
the same check `process_one_v4` used to make (missing mesh/raw/t, node-
|
| 542 |
-
count mismatch, unknown material_id, unresolvable chill/exo BC props).
|
| 543 |
-
|
| 544 |
-
`feature_version` (Task 2, bc-reach): threaded straight into
|
| 545 |
-
`_node_features` -- 4 (default, v4's 15 cols) or 5 (v4's 15 cols +
|
| 546 |
-
Task 1's 5 bc-reach cols, `build_v5`'s 20-wide X). Nothing else in this
|
| 547 |
-
function depends on the feature version; the BC-spec resolution above
|
| 548 |
-
is shared verbatim by both."""
|
| 549 |
-
run_id = row["run_id"]
|
| 550 |
-
mesh = _load_mesh_for_row(row, data_root)
|
| 551 |
-
if mesh is None:
|
| 552 |
-
return None
|
| 553 |
-
|
| 554 |
-
raw_path = osp.join(raw_dir, f"{run_id}.npz")
|
| 555 |
-
if not osp.exists(raw_path):
|
| 556 |
-
print(f" WARN no raw_v4 npz for run_id={run_id}: {raw_path}")
|
| 557 |
-
return None
|
| 558 |
-
with np.load(raw_path) as z:
|
| 559 |
-
if "t" not in z.files:
|
| 560 |
-
print(f" WARN raw_v4 npz for {run_id} has no 't' field (keys={z.files})")
|
| 561 |
-
return None
|
| 562 |
-
t = z["t"].astype(np.float32)
|
| 563 |
-
gradT = z["gradT"].astype(np.float32) if "gradT" in z.files else np.zeros_like(t)
|
| 564 |
-
dTdt = z["dTdt"].astype(np.float32) if "dTdt" in z.files else np.zeros_like(t)
|
| 565 |
-
# t_liquidus (FT2a: liquidus-crossing time, enables t - t_liquidus =
|
| 566 |
-
# local solidification time without re-simulating) is OPTIONAL --
|
| 567 |
-
# older raw_v4 npz files predate this field. Tolerate its absence
|
| 568 |
-
# (or a malformed length) by simply not carrying it forward; never
|
| 569 |
-
# crash and never fabricate a value (unlike gradT/dTdt, a missing
|
| 570 |
-
# t_liquidus has no safe zero-fallback -- see _finalize_row_stage2).
|
| 571 |
-
t_liquidus = None
|
| 572 |
-
if "t_liquidus" in z.files:
|
| 573 |
-
_tl = z["t_liquidus"].astype(np.float32)
|
| 574 |
-
if _tl.shape[0] == t.shape[0]:
|
| 575 |
-
t_liquidus = _tl
|
| 576 |
-
else:
|
| 577 |
-
print(f" WARN t_liquidus length mismatch run_id={run_id}: "
|
| 578 |
-
f"t={t.shape[0]} t_liquidus={_tl.shape[0]} -- skipping "
|
| 579 |
-
"t_liquidus/local_solidification_time for this row")
|
| 580 |
-
|
| 581 |
-
n_pt = mesh.node_xyz.shape[0]
|
| 582 |
-
if t.shape[0] != n_pt:
|
| 583 |
-
print(f" WARN node count mismatch run_id={run_id}: msh={n_pt} raw={t.shape[0]}")
|
| 584 |
-
return None
|
| 585 |
-
|
| 586 |
-
# I1 (ft2a-final-review): archive/DF rows only -- see the function's
|
| 587 |
-
# docstring above. Deliberately NOT wrapped in try/except: a mismatch (or
|
| 588 |
-
# a missing reference mesh) here is a hard, batch-aborting failure, not a
|
| 589 |
-
# row-level WARN+skip like every other check in this function -- it
|
| 590 |
-
# signals a potential systemic feature<->target misalignment, not a bad
|
| 591 |
-
# individual row.
|
| 592 |
-
_assert_archive_mesh_coord_correspondence(mesh, row, manifest_dir)
|
| 593 |
-
|
| 594 |
-
try:
|
| 595 |
-
spec = _resolve_material_props_v4(row["material_id"], manifest_dir)
|
| 596 |
-
except KeyError as e:
|
| 597 |
-
print(f" WARN {e}")
|
| 598 |
-
return None
|
| 599 |
-
|
| 600 |
-
bc_config = row.get("bc_config") or "none"
|
| 601 |
-
spec["mold_effusivity"] = float(row["mold_effusivity"])
|
| 602 |
-
spec["bc_config"] = bc_config
|
| 603 |
-
spec["sleeve_MEF"] = float(row.get("sleeve_MEF") or 0.0)
|
| 604 |
-
# Row-level BC-property resolution failures (unknown chill_material, an
|
| 605 |
-
# exo_sleeve with no calibrated MEF / no exo_calibration.csv yet) must
|
| 606 |
-
# WARN+skip this one row, exactly like every other row-level failure
|
| 607 |
-
# above (missing mesh/raw/t, node-count mismatch, unknown material_id) --
|
| 608 |
-
# NOT abort the whole build_v4 batch. _resolve_chill_effusivity_v4 /
|
| 609 |
-
# _resolve_exo_q0_v4 raise ValueError by design (never a silent 0.0); this
|
| 610 |
-
# is where that "loud" behavior is turned into a "skip THIS row" outcome
|
| 611 |
-
# instead of letting it propagate out of build_v4 mid-manifest.
|
| 612 |
-
try:
|
| 613 |
-
spec["chill_effusivity"] = _resolve_chill_effusivity_v4(row)
|
| 614 |
-
spec["exo_q0"] = _resolve_exo_q0_v4(row, exo_calibration_csv)
|
| 615 |
-
except ValueError as e:
|
| 616 |
-
print(f" WARN {e}")
|
| 617 |
-
return None
|
| 618 |
-
|
| 619 |
-
# `spec` here deliberately has NO "C_material" key yet -- `_node_features`
|
| 620 |
-
# (version=4/5) never reads it (only mold_effusivity/bc_config/
|
| 621 |
-
# chill_effusivity/sleeve_MEF/exo_q0), so X/pos/mesh_edges are fully
|
| 622 |
-
# computable before the cross-row Chvorinov fit exists.
|
| 623 |
-
X, pos, mesh_edges = _node_features(mesh, version=feature_version, spec=spec)
|
| 624 |
-
|
| 625 |
-
volume = float(X[0, FEATURE_NAMES.index("volume_mm3")])
|
| 626 |
-
area = float(X[0, FEATURE_NAMES.index("surface_area_mm2")])
|
| 627 |
-
fam = row.get("alloy_family") or "unknown"
|
| 628 |
-
|
| 629 |
-
return {
|
| 630 |
-
"run_id": run_id, "dataset": row.get("dataset", ""),
|
| 631 |
-
"alloy_family": fam, "bc_config": bc_config,
|
| 632 |
-
"hold_out": row.get("hold_out", ""),
|
| 633 |
-
"X": X, "pos": pos, "mesh_edges": mesh_edges,
|
| 634 |
-
"spec": spec, "volume": volume, "area": area,
|
| 635 |
-
"t": t, "gradT": gradT, "dTdt": dTdt, "t_liquidus": t_liquidus,
|
| 636 |
-
"t_total": float(np.max(t)), "n_nodes": n_pt,
|
| 637 |
-
}
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
def _finalize_row_stage2(s1: dict, C_by_family: dict):
|
| 641 |
-
"""Pure arithmetic + dict assembly, no mesh/IO: plugs the fitted
|
| 642 |
-
per-family Chvorinov C into stage 1's output. Returns None (+ printed
|
| 643 |
-
WARN) if no C was fitted for this row's family (e.g. every row in that
|
| 644 |
-
family failed stage 1)."""
|
| 645 |
-
fam = s1["alloy_family"]
|
| 646 |
-
run_id = s1["run_id"]
|
| 647 |
-
if fam not in C_by_family:
|
| 648 |
-
print(f" WARN no Chvorinov C fitted for alloy_family={fam!r} "
|
| 649 |
-
f"(run_id={run_id}) -- skipped")
|
| 650 |
-
return None
|
| 651 |
-
|
| 652 |
-
spec = dict(s1["spec"])
|
| 653 |
-
spec["C_material"] = C_by_family[fam]
|
| 654 |
-
t = s1["t"]
|
| 655 |
-
t_ref = chvorinov_t_ref(s1["volume"], s1["area"], spec["C_material"])
|
| 656 |
-
g = alloy_g_vector(spec)
|
| 657 |
-
y_nondim = (t.astype(np.float64) / max(t_ref, 1e-9)).astype(np.float32)
|
| 658 |
-
|
| 659 |
-
rec = {
|
| 660 |
-
"stem": run_id, "run_id": run_id, "dataset": s1["dataset"],
|
| 661 |
-
"alloy_family": fam, "bc_config": s1["bc_config"],
|
| 662 |
-
"hold_out": s1["hold_out"],
|
| 663 |
-
"X": s1["X"], "pos": s1["pos"], "mesh_edges": s1["mesh_edges"],
|
| 664 |
-
"g": g, "y": t, "y_nondim": y_nondim, "t_ref": t_ref,
|
| 665 |
-
"gradT": s1["gradT"], "dTdt": s1["dTdt"],
|
| 666 |
-
"y_global": float(np.max(t)), "n_nodes": s1["n_nodes"],
|
| 667 |
-
}
|
| 668 |
-
t_liquidus = s1["t_liquidus"]
|
| 669 |
-
if t_liquidus is not None:
|
| 670 |
-
# local solidification time = t_solidus - t_liquidus, clipped at 0
|
| 671 |
-
# (the two independent threshold-crossing interpolations can, in
|
| 672 |
-
# principle, disagree by a tiny numerical amount right at a node
|
| 673 |
-
# that crosses both thresholds within the same accepted step).
|
| 674 |
-
rec["t_liquidus"] = t_liquidus
|
| 675 |
-
rec["local_solidification_time"] = np.clip(t - t_liquidus, 0.0, None).astype(np.float32)
|
| 676 |
-
return rec
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
def process_one_v4(row: dict, manifest_dir: str, raw_dir: str, data_root: str,
|
| 680 |
-
C_by_family: dict,
|
| 681 |
-
exo_calibration_csv: str = "data/properties/exo_calibration.csv"):
|
| 682 |
-
"""Back-compat single-row entrypoint (still parses the mesh exactly
|
| 683 |
-
once). `build_v4` does NOT call this -- it calls stage 1 across the
|
| 684 |
-
whole manifest first (parallelised) and stage 2 after the C fit, so a
|
| 685 |
-
row's mesh is parsed once per BUILD, not once per stage."""
|
| 686 |
-
s1 = _process_row_stage1(row, manifest_dir, raw_dir, data_root, exo_calibration_csv)
|
| 687 |
-
if s1 is None:
|
| 688 |
-
return None
|
| 689 |
-
return _finalize_row_stage2(s1, C_by_family)
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
def write_record_v4(rec, out_dir):
|
| 693 |
-
gdir = osp.join(out_dir, "graphs")
|
| 694 |
-
os.makedirs(gdir, exist_ok=True)
|
| 695 |
-
extra = {}
|
| 696 |
-
if "t_liquidus" in rec:
|
| 697 |
-
extra["t_liquidus"] = rec["t_liquidus"]
|
| 698 |
-
extra["local_solidification_time"] = rec["local_solidification_time"]
|
| 699 |
-
np.savez(osp.join(gdir, f"{rec['stem']}.npz"),
|
| 700 |
-
X=rec["X"], pos=rec["pos"], mesh_edges=rec["mesh_edges"],
|
| 701 |
-
g=rec["g"], y=rec["y"], y_raw=rec["y"], y_nondim=rec["y_nondim"],
|
| 702 |
-
t_ref=np.float64(rec["t_ref"]),
|
| 703 |
-
gradT=rec["gradT"], dTdt=rec["dTdt"], **extra)
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
def _build_splits_v4(meta: dict) -> dict:
|
| 707 |
-
"""{"interp": {"train","test"}, "extrap": {"train","test"}} from the
|
| 708 |
-
manifest's hold_out column. dataset=="S" (sanity cross-check pairs) rows
|
| 709 |
-
are EXCLUDED from every train (and test) list here, regardless of their
|
| 710 |
-
hold_out value -- they are not part of the GNN train/val protocol."""
|
| 711 |
-
stems = list(meta["stem"]); dataset = list(meta["dataset"]); hold_out = list(meta["hold_out"])
|
| 712 |
-
def _stems_where(pred):
|
| 713 |
-
return sorted(s for s, d, h in zip(stems, dataset, hold_out)
|
| 714 |
-
if d != "S" and pred(h))
|
| 715 |
-
train = _stems_where(lambda h: h == "train")
|
| 716 |
-
val_interp = _stems_where(lambda h: h == "val_interp")
|
| 717 |
-
val_extrap = _stems_where(lambda h: h == "val_extrap")
|
| 718 |
-
return {
|
| 719 |
-
"interp": {"train": train, "test": val_interp},
|
| 720 |
-
"extrap": {"train": train, "test": val_extrap},
|
| 721 |
-
}
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
def _init_norm_accum(feature_version: int = 4, n_feat: int = None):
|
| 725 |
-
"""Fresh accumulator state for the norm-stats running sums, shared by
|
| 726 |
-
`build_v4`/`build_v5`'s Phase 2 fold and `recompute_norm_stats_v4` (so
|
| 727 |
-
these can never silently diverge in the accumulation math).
|
| 728 |
-
|
| 729 |
-
`n_feat`, if given, overrides the width implied by `feature_version`
|
| 730 |
-
(used by `recompute_norm_stats_v4` to size the accumulator from an
|
| 731 |
-
on-disk graph's ACTUAL X width -- 15 for a v4 cache, 20 for a v5 one --
|
| 732 |
-
instead of assuming a version from the caller)."""
|
| 733 |
-
if n_feat is None:
|
| 734 |
-
n_feat = len(FEATURE_NAMES_V5) if feature_version >= 5 else len(FEATURE_NAMES_V4)
|
| 735 |
-
n_g = len(G_VECTOR_NAMES)
|
| 736 |
-
return {
|
| 737 |
-
"fsum": np.zeros(n_feat), "fsq": np.zeros(n_feat), "fcnt": 0,
|
| 738 |
-
"gsum": np.zeros(n_g), "gsq": np.zeros(n_g), "gcnt": 0,
|
| 739 |
-
"ylsum": 0.0, "ylsq": 0.0, "ycnt": 0,
|
| 740 |
-
}
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
def _accumulate_norm_stats(acc: dict, X: np.ndarray, g: np.ndarray, y: np.ndarray) -> None:
|
| 744 |
-
"""Fold one row's (X, g, y) into `acc` in place."""
|
| 745 |
-
X = X.astype(np.float64)
|
| 746 |
-
acc["fsum"] += X.sum(0); acc["fsq"] += (X ** 2).sum(0); acc["fcnt"] += X.shape[0]
|
| 747 |
-
g = g.astype(np.float64)
|
| 748 |
-
acc["gsum"] += g; acc["gsq"] += g ** 2; acc["gcnt"] += 1
|
| 749 |
-
yl = np.log1p(y.astype(np.float64))
|
| 750 |
-
acc["ylsum"] += yl.sum(); acc["ylsq"] += (yl ** 2).sum(); acc["ycnt"] += yl.size
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
def _finalize_norm_stats(acc: dict) -> dict:
|
| 754 |
-
"""Turn the running sums into the final {feat,g,y_log}_{mean,std} dict --
|
| 755 |
-
exactly the arithmetic `build_v4` used to do inline."""
|
| 756 |
-
feat_mean = acc["fsum"] / max(acc["fcnt"], 1)
|
| 757 |
-
feat_std = np.sqrt(np.clip(acc["fsq"] / max(acc["fcnt"], 1) - feat_mean ** 2, 1e-12, None))
|
| 758 |
-
g_mean = acc["gsum"] / max(acc["gcnt"], 1)
|
| 759 |
-
g_std = np.sqrt(np.clip(acc["gsq"] / max(acc["gcnt"], 1) - g_mean ** 2, 1e-12, None))
|
| 760 |
-
y_log_mean = acc["ylsum"] / max(acc["ycnt"], 1)
|
| 761 |
-
y_log_std = float(np.sqrt(max(acc["ylsq"] / max(acc["ycnt"], 1) - y_log_mean ** 2, 1e-12)))
|
| 762 |
-
return {
|
| 763 |
-
"feat_mean": feat_mean, "feat_std": feat_std,
|
| 764 |
-
"g_mean": g_mean, "g_std": g_std,
|
| 765 |
-
"y_log_mean": np.float64(y_log_mean), "y_log_std": np.float64(y_log_std),
|
| 766 |
-
}
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
def recompute_norm_stats_v4(features_dir: str) -> dict:
|
| 770 |
-
"""Recompute `norm_stats.npz` from EVERY graph `.npz` currently on disk
|
| 771 |
-
under `<features_dir>/graphs/`, using exactly the same accumulation
|
| 772 |
-
conventions as `build_v4`'s Phase 2 (`_accumulate_norm_stats` /
|
| 773 |
-
`_finalize_norm_stats` are the SAME helpers build_v4 folds each row
|
| 774 |
-
through -- this cannot silently diverge from a full build's math).
|
| 775 |
-
|
| 776 |
-
dataset=="S" rows are excluded, mirroring build_v4's own exclusion --
|
| 777 |
-
looked up via `<features_dir>/meta.npz`'s stem/dataset columns (still
|
| 778 |
-
the full, untouched manifest-wide meta written by the last full build;
|
| 779 |
-
a selective `only_stems` rebuild deliberately never rewrites it).
|
| 780 |
-
|
| 781 |
-
Overwrites `<features_dir>/norm_stats.npz` in place and returns the new
|
| 782 |
-
stats dict. Intended for use AFTER a selective (`only_stems`) rebuild,
|
| 783 |
-
to fold the rebuilt rows' (possibly changed) features back into the
|
| 784 |
-
global normalization without re-running the whole manifest.
|
| 785 |
-
|
| 786 |
-
Version-agnostic (v4 15-wide or v5 20-wide cache): the accumulator's
|
| 787 |
-
feature width is read off the FIRST graph `.npz` actually found on disk
|
| 788 |
-
(`X.shape[1]`), not assumed from a hardcoded FEATURE_NAMES_V4 -- so this
|
| 789 |
-
one function serves both a v4 and a v5 `features_dir` without a
|
| 790 |
-
parameter, and can never disagree with what's actually cached there."""
|
| 791 |
-
meta_path = osp.join(features_dir, "meta.npz")
|
| 792 |
-
dataset_by_stem = {}
|
| 793 |
-
if osp.exists(meta_path):
|
| 794 |
-
with np.load(meta_path, allow_pickle=True) as mz:
|
| 795 |
-
for stem, ds in zip(mz["stem"], mz["dataset"]):
|
| 796 |
-
dataset_by_stem[str(stem)] = str(ds)
|
| 797 |
-
|
| 798 |
-
gpaths = sorted(glob(osp.join(features_dir, "graphs", "*.npz")))
|
| 799 |
-
n_feat = None
|
| 800 |
-
if gpaths:
|
| 801 |
-
with np.load(gpaths[0]) as z0:
|
| 802 |
-
n_feat = int(z0["X"].shape[1])
|
| 803 |
-
acc = _init_norm_accum(n_feat=n_feat)
|
| 804 |
-
for gp in gpaths:
|
| 805 |
-
stem = osp.splitext(osp.basename(gp))[0]
|
| 806 |
-
if dataset_by_stem.get(stem) == "S":
|
| 807 |
-
continue
|
| 808 |
-
with np.load(gp) as z:
|
| 809 |
-
_accumulate_norm_stats(acc, z["X"], z["g"], z["y"])
|
| 810 |
-
|
| 811 |
-
stats = _finalize_norm_stats(acc)
|
| 812 |
-
np.savez(osp.join(features_dir, "norm_stats.npz"), **stats)
|
| 813 |
-
return stats
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
def _recover_c_by_family_from_cache(rows: list, out_dir: str) -> dict:
|
| 817 |
-
"""For a SELECTIVE (`only_stems`) rebuild: recover each needed
|
| 818 |
-
alloy_family's Chvorinov C by inverting `t_ref = C * (V/A)**2` from the
|
| 819 |
-
row's OWN currently-cached graph `.npz` (read BEFORE this rebuild
|
| 820 |
-
overwrites it). Volume/area are geometry-only and unchanged by Task 1's
|
| 821 |
-
exothermic_input re-encoding, so this exactly reproduces the ORIGINAL
|
| 822 |
-
full-manifest fit's C for that family -- to floating-point precision,
|
| 823 |
-
not a re-fit from the (possibly much smaller/skewed) `only_stems`
|
| 824 |
-
subset, which would silently shift t_ref/y_nondim for every row in that
|
| 825 |
-
family (see the Task 2 brief's Chvorinov-C correctness point).
|
| 826 |
-
|
| 827 |
-
One point per family suffices (algebraic inversion of a constant, not a
|
| 828 |
-
regression); if more than one row of a family is present among `rows`,
|
| 829 |
-
their recovered C values are averaged purely as a sanity cross-check
|
| 830 |
-
(they should already agree to float precision)."""
|
| 831 |
-
v_idx = FEATURE_NAMES.index("volume_mm3")
|
| 832 |
-
a_idx = FEATURE_NAMES.index("surface_area_mm2")
|
| 833 |
-
fam_c_vals = {}
|
| 834 |
-
for row in rows:
|
| 835 |
-
run_id = row["run_id"]
|
| 836 |
-
fam = row.get("alloy_family") or "unknown"
|
| 837 |
-
gpath = osp.join(out_dir, "graphs", f"{run_id}.npz")
|
| 838 |
-
if not osp.exists(gpath):
|
| 839 |
-
continue
|
| 840 |
-
with np.load(gpath) as z:
|
| 841 |
-
if "t_ref" not in z.files or "X" not in z.files:
|
| 842 |
-
continue
|
| 843 |
-
X = z["X"]
|
| 844 |
-
t_ref_old = float(z["t_ref"])
|
| 845 |
-
V = float(X[0, v_idx]); A = float(X[0, a_idx])
|
| 846 |
-
if V <= 0 or A <= 0 or t_ref_old <= 0:
|
| 847 |
-
continue
|
| 848 |
-
M = V / A
|
| 849 |
-
fam_c_vals.setdefault(fam, []).append(t_ref_old / (M ** 2))
|
| 850 |
-
return {fam: float(np.mean(vals)) for fam, vals in fam_c_vals.items()}
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
def _run_stage1_v4(rows: list, manifest_dir: str, raw_dir: str, data_root: str,
|
| 854 |
-
exo_calibration_csv: str, n_workers, feature_version: int = 4) -> list:
|
| 855 |
-
"""Stage 1 across the whole manifest. Returns a list of `dict | None`,
|
| 856 |
-
ONE PER ROW, in the SAME ORDER as `rows` (required for the serial
|
| 857 |
-
aggregation phase below to be byte-identical to the pre-parallel serial
|
| 858 |
-
build, which folded rows into norm_stats/meta in manifest order).
|
| 859 |
-
`n_workers in (None, 1)` runs serially in-process (no pool overhead,
|
| 860 |
-
easiest to debug); otherwise a bounded `ProcessPoolExecutor` -- BLAS
|
| 861 |
-
capped (module-top env vars + `_init_worker`'s threadpoolctl fallback)
|
| 862 |
-
and niced, mirroring tools/doe/doe.py's execute() pool. `ex.map` (not
|
| 863 |
-
submit+as_completed) is used specifically because it yields results in
|
| 864 |
-
submission order regardless of completion order.
|
| 865 |
-
|
| 866 |
-
`feature_version` (Task 2, bc-reach): forwarded to every
|
| 867 |
-
`_process_row_stage1` call (serial or pooled) -- `build_v4` passes 4
|
| 868 |
-
(the default), `build_v5` passes 5."""
|
| 869 |
-
if n_workers is None or n_workers <= 1:
|
| 870 |
-
return [_process_row_stage1(r, manifest_dir, raw_dir, data_root,
|
| 871 |
-
exo_calibration_csv, feature_version)
|
| 872 |
-
for r in rows]
|
| 873 |
-
with ProcessPoolExecutor(max_workers=n_workers, initializer=_init_worker) as ex:
|
| 874 |
-
return list(ex.map(
|
| 875 |
-
_process_row_stage1, rows,
|
| 876 |
-
itertools.repeat(manifest_dir), itertools.repeat(raw_dir),
|
| 877 |
-
itertools.repeat(data_root), itertools.repeat(exo_calibration_csv),
|
| 878 |
-
itertools.repeat(feature_version)))
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
def _build_v4_family(manifest_path: str, feature_version: int, raw_dir: str,
|
| 882 |
-
data_root: str, out_dir: str, limit: int,
|
| 883 |
-
exo_calibration_csv: str, n_workers, only_stems,
|
| 884 |
-
write_splits) -> None:
|
| 885 |
-
"""Shared implementation behind `build_v4` (feature_version=4, 15-wide
|
| 886 |
-
X) and `build_v5` (feature_version=5, 20-wide X, Task 2/bc-reach).
|
| 887 |
-
Everything here is feature-version-agnostic: X's width is entirely
|
| 888 |
-
determined by `_process_row_stage1`/`_node_features(version=
|
| 889 |
-
feature_version)` in stage 1 -- the per-family Chvorinov-C fit (or, for
|
| 890 |
-
a selective `only_stems` rebuild, its cache-recovery + same-family
|
| 891 |
-
fallback), stage-2 finalize + write, and norm-stats/meta accumulation
|
| 892 |
-
never read a hardcoded column count.
|
| 893 |
-
|
| 894 |
-
`write_splits(meta, out_dir)` is the ONE place the two callers diverge:
|
| 895 |
-
called only for a FULL (non-selective) build, after meta.npz/
|
| 896 |
-
norm_stats.npz are already written, it decides how splits.json is
|
| 897 |
-
produced -- `build_v4` fits it from this build's own meta
|
| 898 |
-
(`_build_splits_v4`); `build_v5` instead copies an existing v4 cache's
|
| 899 |
-
splits.json VERBATIM (same manifest/rows, so the split membership is
|
| 900 |
-
identical by construction, never independently re-derived and risking
|
| 901 |
-
divergence). A selective (`only_stems`) rebuild never calls
|
| 902 |
-
`write_splits` at all -- splits.json is left untouched, same as
|
| 903 |
-
meta.npz/norm_stats.npz.
|
| 904 |
-
|
| 905 |
-
`only_stems` (optional set/iterable of `run_id`): a SELECTIVE rebuild --
|
| 906 |
-
process only the manifest rows whose `run_id` is in `only_stems`, write
|
| 907 |
-
(only) their graph `.npz`, and skip `meta.npz`/`norm_stats.npz`/
|
| 908 |
-
`splits.json` entirely (those describe the WHOLE manifest and a partial
|
| 909 |
-
rebuild must never truncate/corrupt them). The per-family Chvorinov C
|
| 910 |
-
each selected row needs is NOT re-fit from the (possibly tiny/skewed)
|
| 911 |
-
subset -- it is recovered from the already-cached graph .npz on disk
|
| 912 |
-
(`_recover_c_by_family_from_cache`), so t_ref/y_nondim for unaffected
|
| 913 |
-
rows never drifts. Use `recompute_norm_stats_v4` afterwards to fold the
|
| 914 |
-
rebuilt rows back into `norm_stats.npz`."""
|
| 915 |
-
rows = _read_manifest_rows(manifest_path)
|
| 916 |
-
if limit:
|
| 917 |
-
rows = rows[:limit]
|
| 918 |
-
rows_full = rows
|
| 919 |
-
if only_stems is not None:
|
| 920 |
-
only_stems = set(only_stems)
|
| 921 |
-
rows = [r for r in rows if r["run_id"] in only_stems]
|
| 922 |
-
manifest_dir = osp.dirname(osp.abspath(manifest_path))
|
| 923 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 924 |
-
|
| 925 |
-
# Phase 1 (parallel, embarrassingly-parallel per row -- M-3): resolve +
|
| 926 |
-
# parse each row's mesh EXACTLY ONCE (previously parsed twice: once in
|
| 927 |
-
# `_fit_chvorinov_c_per_family`'s pre-pass, once in `process_one_v4`'s
|
| 928 |
-
# main pass), load its raw solve, resolve its BC/material spec, and
|
| 929 |
-
# compute X/pos/mesh_edges/volume/area/t_total -- everything that does
|
| 930 |
-
# NOT depend on the cross-row Chvorinov C fit.
|
| 931 |
-
stage1 = _run_stage1_v4(rows, manifest_dir, raw_dir, data_root,
|
| 932 |
-
exo_calibration_csv, n_workers,
|
| 933 |
-
feature_version=feature_version)
|
| 934 |
-
|
| 935 |
-
# Phase 1.5 (serial aggregate, cheap -- no mesh reparse, just a fold over
|
| 936 |
-
# already-computed scalars): fit the per-family Chvorinov C, EXCLUDING
|
| 937 |
-
# dataset=="S" rows (M-4, mirrored from `_fit_chvorinov_c_per_family`)
|
| 938 |
-
# from the fit, using the exact same fit math (`_fit_c_from_points`) so
|
| 939 |
-
# this can never silently diverge from the standalone/tested function.
|
| 940 |
-
# Selective (only_stems) rebuild: NEVER re-fit C from this row subset --
|
| 941 |
-
# recover the ORIGINAL full-manifest C per family from the already-cached
|
| 942 |
-
# graph .npz on disk instead (see build_v4's docstring + the Task 2
|
| 943 |
-
# brief's Chvorinov-C correctness point).
|
| 944 |
-
if only_stems is not None:
|
| 945 |
-
C_by_family = _recover_c_by_family_from_cache(rows, out_dir)
|
| 946 |
-
# NEW stems (no graph .npz of their own cached yet) contribute no
|
| 947 |
-
# point to the lookup above, so a family made up ENTIRELY of new
|
| 948 |
-
# stems comes back with no C at all -- fall back to recovering that
|
| 949 |
-
# family's C from any OTHER (already-cached, from a prior full or
|
| 950 |
-
# selective build) row of the same family across the FULL manifest,
|
| 951 |
-
# excluding dataset=="S" rows (mirrors the fit's own exclusion, see
|
| 952 |
-
# `_fit_chvorinov_c_per_family`). This is still the same algebraic
|
| 953 |
-
# inversion of a per-family constant, just sourced from a wider row
|
| 954 |
-
# set -- never a re-fit from the (possibly tiny/skewed) only_stems
|
| 955 |
-
# subset.
|
| 956 |
-
needed = {r.get("alloy_family") or "unknown" for r in rows}
|
| 957 |
-
missing = needed - set(C_by_family)
|
| 958 |
-
if missing:
|
| 959 |
-
fallback = [r for r in rows_full
|
| 960 |
-
if (r.get("alloy_family") or "unknown") in missing
|
| 961 |
-
and r.get("dataset") != "S"]
|
| 962 |
-
C_by_family.update(_recover_c_by_family_from_cache(fallback, out_dir))
|
| 963 |
-
# If a needed family STILL has no C after the full-manifest fallback,
|
| 964 |
-
# every one of its rows would otherwise be silently skipped by
|
| 965 |
-
# `_finalize_row_stage2`'s warn-and-skip -- a selective rebuild must
|
| 966 |
-
# fail loudly instead of quietly writing zero graphs.
|
| 967 |
-
still_missing = needed - set(C_by_family)
|
| 968 |
-
if still_missing:
|
| 969 |
-
raise RuntimeError(
|
| 970 |
-
f"build_v{feature_version}(only_stems=...): no cached graph "
|
| 971 |
-
f"found anywhere in {out_dir!r} to recover Chvorinov C for "
|
| 972 |
-
f"alloy_family(s) {sorted(still_missing)!r} -- run a full "
|
| 973 |
-
f"(non-selective) build_v{feature_version} first so at least "
|
| 974 |
-
f"one graph of each family is cached.")
|
| 975 |
-
else:
|
| 976 |
-
fam_points = {}
|
| 977 |
-
for s1 in stage1:
|
| 978 |
-
if s1 is None or s1["dataset"] == "S":
|
| 979 |
-
continue
|
| 980 |
-
fam_points.setdefault(s1["alloy_family"], []).append(
|
| 981 |
-
(s1["volume"] / s1["area"], s1["t_total"]))
|
| 982 |
-
C_by_family = _fit_c_from_points(fam_points)
|
| 983 |
-
|
| 984 |
-
# Phase 2 (serial -- pure arithmetic + file writes, no mesh parsing or
|
| 985 |
-
# other heavy work, so parallelising it would only add IPC/pickling
|
| 986 |
-
# overhead): finalize each row now that C_by_family is known, write its
|
| 987 |
-
# graph .npz, and fold it into the running norm-stats/meta accumulators
|
| 988 |
-
# in the SAME `rows` order the old serial build used -- byte-identical
|
| 989 |
-
# accumulation order (floating-point sums are order-sensitive in
|
| 990 |
-
# principle, though not in practice at this row count/precision).
|
| 991 |
-
names = FEATURE_NAMES_V5 if feature_version >= 5 else FEATURE_NAMES_V4
|
| 992 |
-
acc = _init_norm_accum(feature_version=feature_version)
|
| 993 |
-
meta = {k: [] for k in ("stem", "run_id", "dataset", "alloy_family",
|
| 994 |
-
"bc_config", "hold_out", "y_global", "n_nodes")}
|
| 995 |
-
n_written = 0
|
| 996 |
-
|
| 997 |
-
for s1 in stage1:
|
| 998 |
-
if s1 is None:
|
| 999 |
-
continue
|
| 1000 |
-
rec = _finalize_row_stage2(s1, C_by_family)
|
| 1001 |
-
if rec is None:
|
| 1002 |
-
continue
|
| 1003 |
-
write_record_v4(rec, out_dir)
|
| 1004 |
-
n_written += 1
|
| 1005 |
-
if only_stems is not None:
|
| 1006 |
-
# Selective rebuild: meta/norm_stats/splits describe the WHOLE
|
| 1007 |
-
# manifest and are deliberately left untouched (see
|
| 1008 |
-
# `_build_v4_family`'s docstring) -- use `recompute_norm_stats_v4`
|
| 1009 |
-
# afterwards instead.
|
| 1010 |
-
continue
|
| 1011 |
-
# M-4 (ft2a-branch-review): dataset=="S" rows still get a cached
|
| 1012 |
-
# graph .npz (and a meta.npz row, so tooling that looks them up by
|
| 1013 |
-
# run_id keeps working) but must NOT fold into norm_stats -- they
|
| 1014 |
-
# duplicate an existing A/B/C geometry+material and would otherwise
|
| 1015 |
-
# mildly double-weight that point in feature/g/y normalization,
|
| 1016 |
-
# mirroring their existing exclusion from the Chvorinov-C fit above
|
| 1017 |
-
# and from train/test splits (`_build_splits_v4`).
|
| 1018 |
-
if rec["dataset"] != "S":
|
| 1019 |
-
_accumulate_norm_stats(acc, rec["X"], rec["g"], rec["y"])
|
| 1020 |
-
for k in meta:
|
| 1021 |
-
meta[k].append(rec[k])
|
| 1022 |
-
|
| 1023 |
-
if only_stems is not None:
|
| 1024 |
-
print(f"Rebuilt {n_written} v{feature_version} graphs (of {len(rows)} "
|
| 1025 |
-
f"selected stems) -> {out_dir}/graphs (meta.npz/norm_stats.npz/"
|
| 1026 |
-
"splits.json untouched -- run recompute_norm_stats_v4 separately)")
|
| 1027 |
-
return
|
| 1028 |
-
|
| 1029 |
-
stats = _finalize_norm_stats(acc)
|
| 1030 |
-
|
| 1031 |
-
np.savez(osp.join(out_dir, "meta.npz"),
|
| 1032 |
-
stem=np.array(meta["stem"]), run_id=np.array(meta["run_id"]),
|
| 1033 |
-
dataset=np.array(meta["dataset"]),
|
| 1034 |
-
alloy_family=np.array(meta["alloy_family"]),
|
| 1035 |
-
bc_config=np.array(meta["bc_config"]),
|
| 1036 |
-
hold_out=np.array(meta["hold_out"]),
|
| 1037 |
-
y_global=np.array(meta["y_global"], dtype=np.float32),
|
| 1038 |
-
n_nodes=np.array(meta["n_nodes"]),
|
| 1039 |
-
feature_names=np.array(names))
|
| 1040 |
-
np.savez(osp.join(out_dir, "norm_stats.npz"), **stats)
|
| 1041 |
-
|
| 1042 |
-
write_splits(meta, out_dir)
|
| 1043 |
-
|
| 1044 |
-
print(f"Built {len(meta['stem'])} v{feature_version} graphs "
|
| 1045 |
-
f"(of {len(rows)} manifest rows) -> {out_dir}")
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
def build_v4(manifest_path: str, raw_dir: str = "data/raw_v4", data_root: str = "data",
|
| 1049 |
-
out_dir: str = "data/features_v4", limit: int = None,
|
| 1050 |
-
exo_calibration_csv: str = "data/properties/exo_calibration.csv",
|
| 1051 |
-
n_workers=1, only_stems=None):
|
| 1052 |
-
"""v4 (15-wide X) feature-cache build. See `_build_v4_family`'s
|
| 1053 |
-
docstring for the full contract (stage 1/2 split, only_stems selective
|
| 1054 |
-
rebuild + Chvorinov-C cache-recovery). splits.json is fit from this
|
| 1055 |
-
build's own meta via `_build_splits_v4` -- the v4-specific case of
|
| 1056 |
-
`_build_v4_family`'s `write_splits` hook."""
|
| 1057 |
-
def _write_splits_v4(meta: dict, out_dir: str) -> None:
|
| 1058 |
-
splits = _build_splits_v4(meta)
|
| 1059 |
-
with open(osp.join(out_dir, "splits.json"), "w") as f:
|
| 1060 |
-
json.dump(splits, f, indent=2)
|
| 1061 |
-
|
| 1062 |
-
return _build_v4_family(manifest_path, 4, raw_dir, data_root, out_dir,
|
| 1063 |
-
limit, exo_calibration_csv, n_workers, only_stems,
|
| 1064 |
-
_write_splits_v4)
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
def build_v5(manifest_path: str, raw_dir: str = "data/raw_v4", data_root: str = "data",
|
| 1068 |
-
out_dir: str = "data/features_v5", limit: int = None,
|
| 1069 |
-
exo_calibration_csv: str = "data/properties/exo_calibration.csv",
|
| 1070 |
-
n_workers=1, only_stems=None,
|
| 1071 |
-
v4_splits_path: str = "data/features_v4/splits.json"):
|
| 1072 |
-
"""Task 2 (bc-reach): the 20-channel feature-cache build -- `build_v4`
|
| 1073 |
-
parameterized to feature_version=5 via the shared `_build_v4_family`
|
| 1074 |
-
implementation (see its docstring for the full stage 1/2 + only_stems
|
| 1075 |
-
contract, unchanged here). X is 20-wide: v4's 15 columns UNCHANGED
|
| 1076 |
-
(byte-identical to a v4 build of the same row -- Task 1's
|
| 1077 |
-
`compute_bc_reach_channels`/`FEATURE_NAMES_V5` only APPEND 5 new
|
| 1078 |
-
columns) + the 5 bc-reach columns from Task 1.
|
| 1079 |
-
|
| 1080 |
-
Two deliberate differences from `build_v4`:
|
| 1081 |
-
- norm_stats.npz is FRESH over the full 20-wide X -- NOT v4's stats
|
| 1082 |
-
reused or zero-padded. `_init_norm_accum(feature_version=5)` sizes
|
| 1083 |
-
the accumulator to `len(FEATURE_NAMES_V5) == 20` and every row's
|
| 1084 |
-
real 20-column X is folded into it (`_accumulate_norm_stats`,
|
| 1085 |
-
unchanged -- it never assumes a column count).
|
| 1086 |
-
- splits.json is NOT computed from this build's own meta -- v5 uses
|
| 1087 |
-
the IDENTICAL manifest/rows/hold_out protocol as the v4 cache it's
|
| 1088 |
-
built alongside, so `splits.json` is copied VERBATIM from
|
| 1089 |
-
`v4_splits_path` (default `data/features_v4/splits.json`, the
|
| 1090 |
-
production v4 cache) instead of re-deriving it a second time --
|
| 1091 |
-
guaranteeing byte-for-byte the same split membership (incl. e_val)
|
| 1092 |
-
with zero risk of the two derivations silently diverging. Raises
|
| 1093 |
-
`FileNotFoundError` if `v4_splits_path` doesn't exist -- a v5 build
|
| 1094 |
-
requires the v4 cache to already exist; this never silently skips
|
| 1095 |
-
or fabricates a split. Selective (`only_stems`) rebuilds never call
|
| 1096 |
-
this at all (same as `build_v4`), so `v4_splits_path` need not even
|
| 1097 |
-
be reachable for those."""
|
| 1098 |
-
def _copy_splits_from_v4(meta: dict, out_dir: str) -> None:
|
| 1099 |
-
if not osp.exists(v4_splits_path):
|
| 1100 |
-
raise FileNotFoundError(
|
| 1101 |
-
f"build_v5: v4_splits_path {v4_splits_path!r} does not exist "
|
| 1102 |
-
"-- build_v5 copies splits.json VERBATIM from an existing v4 "
|
| 1103 |
-
"cache (same manifest/rows) instead of re-deriving it; run "
|
| 1104 |
-
"build_v4 on this manifest first, or pass the correct "
|
| 1105 |
-
"v4_splits_path")
|
| 1106 |
-
shutil.copyfile(v4_splits_path, osp.join(out_dir, "splits.json"))
|
| 1107 |
-
|
| 1108 |
-
return _build_v4_family(manifest_path, 5, raw_dir, data_root, out_dir,
|
| 1109 |
-
limit, exo_calibration_csv, n_workers, only_stems,
|
| 1110 |
-
_copy_splits_from_v4)
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
def build(data_root="data", out_dir="data/features", n_workers=None, limit=None,
|
| 1114 |
-
feature_version=2, manifest_path=None, raw_dir="data/raw_v4",
|
| 1115 |
-
exo_calibration_csv="data/properties/exo_calibration.csv"):
|
| 1116 |
-
if feature_version >= 4:
|
| 1117 |
-
if manifest_path is None:
|
| 1118 |
-
raise ValueError("build(feature_version=4) requires manifest_path")
|
| 1119 |
-
return build_v4(manifest_path=manifest_path, raw_dir=raw_dir,
|
| 1120 |
-
data_root=data_root, out_dir=out_dir, limit=limit,
|
| 1121 |
-
exo_calibration_csv=exo_calibration_csv,
|
| 1122 |
-
n_workers=(n_workers if n_workers is not None else 1))
|
| 1123 |
-
stems = _all_stems(data_root)
|
| 1124 |
-
if limit:
|
| 1125 |
-
stems = stems[:limit]
|
| 1126 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 1127 |
-
|
| 1128 |
-
# Determine feature names based on version
|
| 1129 |
-
names = FEATURE_NAMES_V3 if feature_version >= 3 else FEATURE_NAMES
|
| 1130 |
-
|
| 1131 |
-
# accumulators for global normalization (Welford-free: sum, sumsq, count)
|
| 1132 |
-
fsum = np.zeros(len(names)); fsq = np.zeros(len(names)); fcnt = 0
|
| 1133 |
-
ylsum = ylsq = ycnt = 0.0
|
| 1134 |
-
meta = {k: [] for k in ("stem", "prefix", "n_adiab", "is_fully_cooled",
|
| 1135 |
-
"y_global", "n_nodes")}
|
| 1136 |
-
|
| 1137 |
-
def _accumulate(rec):
|
| 1138 |
-
nonlocal fsum, fsq, fcnt, ylsum, ylsq, ycnt
|
| 1139 |
-
write_record(rec, out_dir)
|
| 1140 |
-
X = rec["X"].astype(np.float64)
|
| 1141 |
-
fsum += X.sum(0); fsq += (X ** 2).sum(0); fcnt += X.shape[0]
|
| 1142 |
-
yl = np.log1p(rec["y"].astype(np.float64))
|
| 1143 |
-
ylsum += yl.sum(); ylsq += (yl ** 2).sum(); ycnt += yl.size
|
| 1144 |
-
for k in meta:
|
| 1145 |
-
meta[k].append(rec[k])
|
| 1146 |
-
|
| 1147 |
-
worker = process_one
|
| 1148 |
-
if n_workers == 1:
|
| 1149 |
-
for i, s in enumerate(stems):
|
| 1150 |
-
rec = worker(s, data_root, feature_version)
|
| 1151 |
-
if rec is not None:
|
| 1152 |
-
_accumulate(rec)
|
| 1153 |
-
if (i + 1) % 500 == 0:
|
| 1154 |
-
print(f" {i+1}/{len(stems)}")
|
| 1155 |
-
else:
|
| 1156 |
-
with ProcessPoolExecutor(max_workers=n_workers) as ex:
|
| 1157 |
-
futs = {ex.submit(worker, s, data_root, feature_version): s for s in stems}
|
| 1158 |
-
done = 0
|
| 1159 |
-
for fut in futs:
|
| 1160 |
-
rec = fut.result()
|
| 1161 |
-
done += 1
|
| 1162 |
-
if rec is not None:
|
| 1163 |
-
_accumulate(rec)
|
| 1164 |
-
if done % 500 == 0:
|
| 1165 |
-
print(f" {done}/{len(stems)}")
|
| 1166 |
-
|
| 1167 |
-
feat_mean = fsum / max(fcnt, 1)
|
| 1168 |
-
feat_std = np.sqrt(np.clip(fsq / max(fcnt, 1) - feat_mean ** 2, 1e-12, None))
|
| 1169 |
-
y_log_mean = ylsum / max(ycnt, 1)
|
| 1170 |
-
y_log_std = float(np.sqrt(max(ylsq / max(ycnt, 1) - y_log_mean ** 2, 1e-12)))
|
| 1171 |
-
|
| 1172 |
-
np.savez(osp.join(out_dir, "meta.npz"),
|
| 1173 |
-
stem=np.array(meta["stem"]),
|
| 1174 |
-
prefix=np.array(meta["prefix"]),
|
| 1175 |
-
n_adiab=np.array(meta["n_adiab"]),
|
| 1176 |
-
is_fully_cooled=np.array(meta["is_fully_cooled"]),
|
| 1177 |
-
y_global=np.array(meta["y_global"], dtype=np.float32),
|
| 1178 |
-
n_nodes=np.array(meta["n_nodes"]),
|
| 1179 |
-
feature_names=np.array(names))
|
| 1180 |
-
np.savez(osp.join(out_dir, "norm_stats.npz"),
|
| 1181 |
-
feat_mean=feat_mean, feat_std=feat_std,
|
| 1182 |
-
y_log_mean=np.float64(y_log_mean), y_log_std=np.float64(y_log_std))
|
| 1183 |
-
print(f"Built {len(meta['stem'])} graphs → {out_dir}")
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
if __name__ == "__main__":
|
| 1187 |
-
ap = argparse.ArgumentParser()
|
| 1188 |
-
ap.add_argument("--data-root", default="data")
|
| 1189 |
-
ap.add_argument("--out-dir", default="data/features")
|
| 1190 |
-
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 2) - 2))
|
| 1191 |
-
ap.add_argument("--limit", type=int, default=None)
|
| 1192 |
-
ap.add_argument("--feature-version", type=int, default=2)
|
| 1193 |
-
ap.add_argument("--manifest", default=None,
|
| 1194 |
-
help="DOE manifest.csv (required for --feature-version 4)")
|
| 1195 |
-
ap.add_argument("--raw-dir", default="data/raw_v4",
|
| 1196 |
-
help="raw FEniCSx solve npz dir ({run_id}.npz -> t/gradT/dTdt), v4 only")
|
| 1197 |
-
a = ap.parse_args()
|
| 1198 |
-
build(a.data_root, a.out_dir, a.workers, a.limit, a.feature_version,
|
| 1199 |
-
manifest_path=a.manifest, raw_dir=a.raw_dir)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/compare_d_parts.py
DELETED
|
@@ -1,214 +0,0 @@
|
|
| 1 |
-
"""FT2b v4-inference Task 5: D-parts v4-vs-SP1 comparison.
|
| 2 |
-
|
| 3 |
-
Before promoting the v4 hybrid (FiLM GNN + multi-alloy GBM, `src.evaluate_ft2b`)
|
| 4 |
-
over the SP1 production hybrid (`runs/sp1_run3_final/best_gnn.pt` +
|
| 5 |
-
`runs/baseline_v2/hgb_A.joblib`), both stacks are compared head-to-head on
|
| 6 |
-
the real-part "dataset D" geometries (`tools/doe/prep_parts.py`'s cleaned
|
| 7 |
-
STEP parts) that have a FEniCSx-ground-truth v4 feature cache. This is the
|
| 8 |
-
*same* geometry fed through *both* stacks -- exactly the `_no_regression_check`
|
| 9 |
-
technique in `src.evaluate_ft2b` (v4_X[:, :13]/[:9] through SP1's own norm
|
| 10 |
-
stats), just scoped to dataset=="D" instead of dataset=="A", and reported
|
| 11 |
-
per-stem as well as aggregated.
|
| 12 |
-
|
| 13 |
-
Reused, not reimplemented: `load_ft2b_gnn`/`predict_graph_seconds` (GNN
|
| 14 |
-
loading+inference, film-aware), `_evaluate_stems_v4` (v4 quantile-anchor hybrid
|
| 15 |
-
per stem), `_sp1_hybrid_predict_stem` (SP1 mean-anchor hybrid on the v4
|
| 16 |
-
cache's geometry), `hotspot_overlap` -- all from `src.evaluate_ft2b`, the
|
| 17 |
-
single source of truth for both compositions (see that module's docstring
|
| 18 |
-
for the quantile-anchor-vs-mean-anchor rationale).
|
| 19 |
-
"""
|
| 20 |
-
from __future__ import annotations
|
| 21 |
-
|
| 22 |
-
import json
|
| 23 |
-
import os
|
| 24 |
-
import os.path as osp
|
| 25 |
-
|
| 26 |
-
import joblib
|
| 27 |
-
import numpy as np
|
| 28 |
-
import torch
|
| 29 |
-
|
| 30 |
-
from src import evaluate_ft2b as ft2b
|
| 31 |
-
from src import metrics
|
| 32 |
-
from src.dataload import load_norm
|
| 33 |
-
from src.gbm_ft2b import _load_manifest_rows
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def _d_stems(manifest_rows: dict, features_dir: str) -> tuple[list[str], int]:
|
| 37 |
-
"""Sorted `run_id`s with `dataset=="D"` AND a v4 graph cache on disk, plus
|
| 38 |
-
the raw count of `dataset=="D"` manifest rows (cache present or not) --
|
| 39 |
-
the latter lets callers tell "no D rows in the manifest at all" apart
|
| 40 |
-
from "D rows exist but none have been built into the v4 cache yet"."""
|
| 41 |
-
d_rows = [stem for stem, row in manifest_rows.items() if row.get("dataset") == "D"]
|
| 42 |
-
out = [s for s in d_rows if osp.exists(osp.join(features_dir, "graphs", f"{s}.npz"))]
|
| 43 |
-
return sorted(out), len(d_rows)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def _stem_metrics(y_true: np.ndarray, y_pred: np.ndarray, xyz: np.ndarray) -> dict:
|
| 47 |
-
gid = np.zeros(y_true.shape[0], dtype=np.int64)
|
| 48 |
-
return {
|
| 49 |
-
"mae": metrics.mae(y_true, y_pred),
|
| 50 |
-
"hotspot": ft2b.hotspot_overlap(y_true, y_pred, xyz, gid),
|
| 51 |
-
"std_ratio": metrics.pred_std_ratio(y_true, y_pred, gid),
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def _aggregate_metrics(records: list[dict]) -> dict:
|
| 56 |
-
"""Node-weighted aggregate over `{"y_true", "y_hybrid", "xyz"}` records.
|
| 57 |
-
|
| 58 |
-
Delegates to `ft2b._overall_metrics` (single source of truth for the
|
| 59 |
-
node-weighted concat/gid glue -- don't re-derive it here and risk it
|
| 60 |
-
drifting out of sync), just renaming `hotspot_overlap` -> `hotspot` to
|
| 61 |
-
match this module's own key convention."""
|
| 62 |
-
agg = ft2b._overall_metrics(records)
|
| 63 |
-
return {"mae": agg["mae"], "hotspot": agg["hotspot_overlap"],
|
| 64 |
-
"std_ratio": agg["std_ratio"]}
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def compare_d_parts(v4_ckpt, v4_gbm,
|
| 68 |
-
sp1_ckpt=ft2b.DEFAULT_SP1_CKPT, sp1_hgb=ft2b.DEFAULT_SP1_HGB,
|
| 69 |
-
features_dir="data/features_v4", manifest="doe_out/manifest.csv",
|
| 70 |
-
out_json="runs/v4_inference/d_parts_comparison.json",
|
| 71 |
-
baseline_features_dir=ft2b.DEFAULT_BASELINE_FEATURES_DIR,
|
| 72 |
-
hgb_std=None,
|
| 73 |
-
lambda_shrink: float = 1.0,
|
| 74 |
-
w_total: float = 1.0,
|
| 75 |
-
w_std: float = 1.0,
|
| 76 |
-
device=None) -> dict:
|
| 77 |
-
"""Head-to-head v4-hybrid vs SP1-hybrid comparison on dataset D stems.
|
| 78 |
-
|
| 79 |
-
Args:
|
| 80 |
-
v4_ckpt: path to the v4 GNN checkpoint, or an already-loaded
|
| 81 |
-
checkpoint dict (`ft2b.load_ft2b_gnn`'s own convention).
|
| 82 |
-
v4_gbm: path to the refit multi-alloy GBM (`src.gbm_ft2b.refit_gbm`),
|
| 83 |
-
or an already-loaded estimator.
|
| 84 |
-
sp1_ckpt / sp1_hgb: the SP1 production hybrid stack.
|
| 85 |
-
features_dir: v4 feature cache directory (`graphs/<stem>.npz`).
|
| 86 |
-
manifest: DOE manifest CSV (`run_id`==stem; needs `dataset`/
|
| 87 |
-
`geometry_id`/`alloy_family`/`bc_config` columns).
|
| 88 |
-
out_json: where to write the result as JSON (falsy skips the write;
|
| 89 |
-
the parent directory is created when writing).
|
| 90 |
-
baseline_features_dir: SP1 HGB norm stats (`norm_stats.npz`, 9 v2
|
| 91 |
-
features) -- same default as `evaluate_ft2b`.
|
| 92 |
-
hgb_std (variance-recomposition Task 3): an optional path to (or
|
| 93 |
-
already-loaded) GBM std-head model
|
| 94 |
-
(`src.gbm_ft2b.refit_gbm_std`'s output), forwarded ONLY to the
|
| 95 |
-
v4 side's `ft2b._evaluate_stems_v4` (the SP1 side has no
|
| 96 |
-
std-head analog and is unaffected). `None` (the default) is
|
| 97 |
-
byte-identical to before this argument existed (the v4 side
|
| 98 |
-
stays on the quantile-anchor). The returned dict gains a
|
| 99 |
-
top-level `"recomposition": "soft"|"anchor"` field (soft-anchor
|
| 100 |
-
Task 3: was `"affine"|"anchor"` -- `ft2b._evaluate_stems_v4` now
|
| 101 |
-
recomposes via `src.hybrid.hybrid_field_soft` instead of
|
| 102 |
-
`hybrid_field_affine` whenever `hgb_std` is given).
|
| 103 |
-
lambda_shrink (variance-recomposition amendment 2): forwarded
|
| 104 |
-
straight to the v4 side's `ft2b._evaluate_stems_v4` (ignored
|
| 105 |
-
when `hgb_std` is `None`). `1.0` (the default) is byte-identical
|
| 106 |
-
to before this argument existed.
|
| 107 |
-
w_total / w_std (soft-anchor Task 3): forwarded straight to the v4
|
| 108 |
-
side's `ft2b._evaluate_stems_v4`/`hybrid_field_soft`'s own
|
| 109 |
-
weights (ignored when `hgb_std` is `None`). `1.0`/`1.0` (the
|
| 110 |
-
defaults) reproduce `hybrid_field_soft`'s own default weighting.
|
| 111 |
-
device: "cuda"/"cpu"; default picks cuda if available.
|
| 112 |
-
|
| 113 |
-
Returns: `{"rows": [{"stem", "geometry_id", "alloy_family",
|
| 114 |
-
"v4": {"mae", "hotspot", "std_ratio"}, "sp1": {...}}, ...],
|
| 115 |
-
"v4": {...aggregate...}, "sp1": {...aggregate...}, "n_stems",
|
| 116 |
-
"n_d_stems_in_manifest", "verdict": {"mae_ok", "hotspot_ok"[, "note"]},
|
| 117 |
-
"recomposition"}`
|
| 118 |
-
where `mae_ok = v4.mae <= sp1.mae` and `hotspot_ok = v4.hotspot >=
|
| 119 |
-
sp1.hotspot` (both computed on the node-weighted aggregates,
|
| 120 |
-
mirroring `evaluate_ft2b`'s `_overall_metrics`) -- EXCEPT when
|
| 121 |
-
`n_stems == 0`, which forces BOTH verdict fields to `False` (an
|
| 122 |
-
empty comparison is not evidence of no-regression: `0.0 <= 0.0` and
|
| 123 |
-
`0.0 >= 0.0` would otherwise vacuously pass) and adds a `note`
|
| 124 |
-
explaining why, mentioning `n_stems`/`n_d_stems_in_manifest`.
|
| 125 |
-
`n_d_stems_in_manifest` is the raw count of `dataset=="D"` manifest
|
| 126 |
-
rows (cache present or not), for skip-visibility -- `n_stems` (<=
|
| 127 |
-
it) is how many of those actually had a `features_dir` graph cache
|
| 128 |
-
and were compared.
|
| 129 |
-
"""
|
| 130 |
-
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 131 |
-
manifest_dir = osp.dirname(osp.abspath(manifest)) or "."
|
| 132 |
-
manifest_rows = _load_manifest_rows(manifest)
|
| 133 |
-
stems, n_d_in_manifest = _d_stems(manifest_rows, features_dir)
|
| 134 |
-
|
| 135 |
-
# -- v4 hybrid (GNN shape, quantile-anchor GBM scale -- or, when `hgb_std`
|
| 136 |
-
# is given, the soft-anchor recomposition) -- exactly `evaluate_ft2b`'s
|
| 137 |
-
# own per-stem composition, reused verbatim.
|
| 138 |
-
v4_model, v4_cfg, v4_norm = ft2b.load_ft2b_gnn(v4_ckpt, device)
|
| 139 |
-
v4_gbm_model = joblib.load(v4_gbm) if isinstance(v4_gbm, (str, os.PathLike)) else v4_gbm
|
| 140 |
-
hgb_std_model = joblib.load(hgb_std) if isinstance(hgb_std, (str, os.PathLike)) else hgb_std
|
| 141 |
-
v4_records = ft2b._evaluate_stems_v4(v4_model, v4_cfg, v4_norm, v4_gbm_model, stems,
|
| 142 |
-
manifest_rows, manifest_dir, features_dir, device,
|
| 143 |
-
hgb_std=hgb_std_model, lambda_shrink=lambda_shrink,
|
| 144 |
-
w_total=w_total, w_std=w_std)
|
| 145 |
-
v4_by_stem = {r["stem"]: r for r in v4_records}
|
| 146 |
-
|
| 147 |
-
# -- SP1 hybrid (GNN shape, mean-anchor HGB scale) on the SAME v4-cache
|
| 148 |
-
# geometry, fed through SP1's own norm stats -- `_sp1_hybrid_predict_stem`'s
|
| 149 |
-
# own contract (see `evaluate_ft2b`'s `_no_regression_check` docstring for
|
| 150 |
-
# the full rationale/caveats of this "plug the v4 cache into the SP1
|
| 151 |
-
# stack" technique).
|
| 152 |
-
sp1_model, sp1_cfg, sp1_norm = ft2b.load_ft2b_gnn(sp1_ckpt, device)
|
| 153 |
-
hgb = joblib.load(sp1_hgb) if isinstance(sp1_hgb, (str, os.PathLike)) else sp1_hgb
|
| 154 |
-
hgb_norm = load_norm(baseline_features_dir)
|
| 155 |
-
sp1_by_stem = {}
|
| 156 |
-
for stem in stems:
|
| 157 |
-
y_true, hyb, pos_np = ft2b._sp1_hybrid_predict_stem(
|
| 158 |
-
stem, sp1_model, sp1_cfg, sp1_norm, hgb, hgb_norm, features_dir, device)
|
| 159 |
-
sp1_by_stem[stem] = {"y_true": y_true, "y_hybrid": hyb, "xyz": pos_np}
|
| 160 |
-
|
| 161 |
-
rows = []
|
| 162 |
-
for stem in stems:
|
| 163 |
-
v4r, sp1r = v4_by_stem[stem], sp1_by_stem[stem]
|
| 164 |
-
row = manifest_rows.get(stem, {})
|
| 165 |
-
rows.append({
|
| 166 |
-
"stem": stem,
|
| 167 |
-
"geometry_id": row.get("geometry_id") or "",
|
| 168 |
-
"alloy_family": v4r.get("family") or "unknown",
|
| 169 |
-
"v4": _stem_metrics(v4r["y_true"], v4r["y_hybrid"], v4r["xyz"]),
|
| 170 |
-
"sp1": _stem_metrics(sp1r["y_true"], sp1r["y_hybrid"], sp1r["xyz"]),
|
| 171 |
-
})
|
| 172 |
-
|
| 173 |
-
v4_agg = _aggregate_metrics([v4_by_stem[s] for s in stems])
|
| 174 |
-
sp1_agg = _aggregate_metrics([sp1_by_stem[s] for s in stems])
|
| 175 |
-
|
| 176 |
-
if stems:
|
| 177 |
-
verdict = {
|
| 178 |
-
"mae_ok": bool(v4_agg["mae"] <= sp1_agg["mae"]),
|
| 179 |
-
"hotspot_ok": bool(v4_agg["hotspot"] >= sp1_agg["hotspot"]),
|
| 180 |
-
}
|
| 181 |
-
else:
|
| 182 |
-
# An empty comparison must never read as a pass: with no records,
|
| 183 |
-
# both aggregates are all-zero, so a naive `0.0 <= 0.0` /
|
| 184 |
-
# `0.0 >= 0.0` would vacuously satisfy both gates. Force an
|
| 185 |
-
# explicit fail instead, with a note pointing at WHY (0 of
|
| 186 |
-
# n_d_in_manifest dataset=="D" manifest rows had a cached graph).
|
| 187 |
-
verdict = {
|
| 188 |
-
"mae_ok": False,
|
| 189 |
-
"hotspot_ok": False,
|
| 190 |
-
"note": (f"no D stems compared (n_stems=0 of "
|
| 191 |
-
f"n_d_stems_in_manifest={n_d_in_manifest} dataset==\"D\" "
|
| 192 |
-
"manifest rows have a features_dir graph cache) -- "
|
| 193 |
-
"vacuous run, verdict forced to fail rather than "
|
| 194 |
-
"trivially passing on empty aggregates"),
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
result = {
|
| 198 |
-
"rows": rows,
|
| 199 |
-
"v4": v4_agg,
|
| 200 |
-
"sp1": sp1_agg,
|
| 201 |
-
"n_stems": len(stems),
|
| 202 |
-
"n_d_stems_in_manifest": n_d_in_manifest,
|
| 203 |
-
"verdict": verdict,
|
| 204 |
-
"recomposition": "soft" if hgb_std_model is not None else "anchor",
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
if out_json:
|
| 208 |
-
out_dir = osp.dirname(out_json)
|
| 209 |
-
if out_dir:
|
| 210 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 211 |
-
with open(out_json, "w") as f:
|
| 212 |
-
json.dump(result, f, indent=2)
|
| 213 |
-
|
| 214 |
-
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/dataload.py
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
"""Load cached per-node features for a set of stems into flat matrices."""
|
| 2 |
-
import os.path as osp
|
| 3 |
-
import numpy as np
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
def load_norm(features_dir: str) -> dict:
|
| 7 |
-
d = np.load(osp.join(features_dir, "norm_stats.npz"))
|
| 8 |
-
return {"feat_mean": d["feat_mean"], "feat_std": d["feat_std"],
|
| 9 |
-
"y_log_mean": float(d["y_log_mean"]), "y_log_std": float(d["y_log_std"])}
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def load_matrix(features_dir: str, stems, max_nodes_per_graph=None, seed=0):
|
| 13 |
-
rng = np.random.default_rng(seed)
|
| 14 |
-
Xs, ys, gids = [], [], []
|
| 15 |
-
for gi, stem in enumerate(stems):
|
| 16 |
-
d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
|
| 17 |
-
X, y = d["X"], d["y"]
|
| 18 |
-
if max_nodes_per_graph is not None and X.shape[0] > max_nodes_per_graph:
|
| 19 |
-
idx = rng.choice(X.shape[0], max_nodes_per_graph, replace=False)
|
| 20 |
-
X, y = X[idx], y[idx]
|
| 21 |
-
Xs.append(X)
|
| 22 |
-
ys.append(y)
|
| 23 |
-
gids.append(np.full(X.shape[0], gi, dtype=np.int64))
|
| 24 |
-
return (np.concatenate(Xs), np.concatenate(ys), np.concatenate(gids))
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def standardize(X, norm) -> np.ndarray:
|
| 28 |
-
return (X - norm["feat_mean"]) / norm["feat_std"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/evaluate_ft2b.py
DELETED
|
@@ -1,630 +0,0 @@
|
|
| 1 |
-
"""FT2b Task 8: evaluation of the multi-alloy/multi-BC hybrid predictor.
|
| 2 |
-
|
| 3 |
-
Consumes a trained v4 checkpoint (FiLM, 15 node features, `g`-conditioned,
|
| 4 |
-
`node_in=15`) + the refit multi-alloy GBM scale-head (`src.gbm_ft2b`) and
|
| 5 |
-
reports, on `interp.test` AND `extrap.test`: per-`alloy_family` and per-
|
| 6 |
-
`bc_config` hybrid MAE (physical seconds), hot-spot top-10% overlap, and
|
| 7 |
-
`std_ratio = std(pred)/std(true)`; a no-regression comparison against the
|
| 8 |
-
SP1 hybrid on the all-sand subset; a Chvorinov-scaling sanity check; and a
|
| 9 |
-
variance-collapse flag.
|
| 10 |
-
|
| 11 |
-
=== y / y_raw / t_ref inversion chain (read before touching this file) ===
|
| 12 |
-
`src/build_dataset.py` (`_finalize_row_stage2`, `write_record_v4`) writes,
|
| 13 |
-
per v4 graph cache:
|
| 14 |
-
y = t (PHYSICAL seconds, per-node solidification time)
|
| 15 |
-
y_raw = t (same array, duplicate key for API clarity)
|
| 16 |
-
y_nondim = t / t_ref (t_ref = C_family * (V/A)^2, Chvorinov reference)
|
| 17 |
-
t_ref = C_family * (V/A)^2 (scalar, physical seconds)
|
| 18 |
-
`build_v4`'s norm_stats.npz computes `y_log_mean`/`y_log_std` from
|
| 19 |
-
`log1p(rec["y"])` i.e. `log1p(t)` -- log1p of the PHYSICAL target, exactly
|
| 20 |
-
the same convention `features_v2`/`features_v3` use. `SolidGraphDataset`
|
| 21 |
-
(`src/gnn_dataset.py`) trains on `standardize(log1p(d["y"]))` i.e.
|
| 22 |
-
standardized log1p(PHYSICAL seconds) -- NOT the non-dim `y_nondim` field.
|
| 23 |
-
Therefore the GNN's raw output, once unstandardized and expm1'd, is ALREADY
|
| 24 |
-
physical seconds -- the exact same inversion `src.evaluate_gnn.
|
| 25 |
-
predict_seconds_gnn` performs for SP1. No `t_ref` multiplication is needed
|
| 26 |
-
to get physical seconds out of the v4 GNN. `y_nondim`/`t_ref`/`g` are real,
|
| 27 |
-
separately-stored cache fields used elsewhere (the Chvorinov sanity check
|
| 28 |
-
below reads `t_ref`; `g` feeds FiLM) -- they are NOT an alternate target
|
| 29 |
-
space the model was trained on.
|
| 30 |
-
|
| 31 |
-
=== v4 hybrid recomposition: QUANTILE-anchor (q=0.995), not MEAN-anchor ===
|
| 32 |
-
`src.hybrid.hybrid_field(gnn, baseline)` (SP1's convention) re-anchors to the
|
| 33 |
-
baseline's MEAN: `pred = gnn - mean(gnn) + mean(baseline)`. That is correct
|
| 34 |
-
for SP1 because SP1's HGB baseline predicts a genuine PER-NODE value, so
|
| 35 |
-
`mean(baseline)` is a real per-graph mean estimate. The FT2b GBM
|
| 36 |
-
(`src.gbm_ft2b`) is different by design (Task 6): it predicts a single
|
| 37 |
-
per-graph SCALAR, `log1p(total/max solidification time)` -- see its module
|
| 38 |
-
docstring, "Target: per-graph `log1p(y_raw.max())`". Broadcasting that
|
| 39 |
-
scalar to every node and then MEAN-anchoring would force
|
| 40 |
-
`mean(pred) == gbm_total`, inflating every node by
|
| 41 |
-
`(true_max - true_mean)` -- confirmed in review to blow up MAE by ~60x on an
|
| 42 |
-
otherwise near-perfect GNN. `_max_anchor_hybrid` (an alias of
|
| 43 |
-
`src.hybrid.hybrid_field_max_anchor`, the single source -- see that
|
| 44 |
-
function's docstring) anchors to a high QUANTILE of the GNN field instead
|
| 45 |
-
(default `q=0.995`; `q=1.0` reproduces the original exact-MAX behavior
|
| 46 |
-
bit-for-bit): `pred_i = gnn_i - anchor + gbm_total` where
|
| 47 |
-
`anchor = np.quantile(gnn, q)`, which makes `max(pred) ~= gbm_total` (the
|
| 48 |
-
quantity the GBM actually predicts) while still preserving the GNN's field
|
| 49 |
-
shape (its deviations from that anchor). The anchor moved off the exact
|
| 50 |
-
max (Task 7) because it is fragile to a handful of outlier nodes -- a
|
| 51 |
-
promotion-gate smoke test found a few inflated GNN log-space predictions
|
| 52 |
-
on a real casting dragging ~90% of the field below the zero clip, since the
|
| 53 |
-
single max node alone set the whole graph's additive offset; anchoring to
|
| 54 |
-
the 99.5th percentile instead is robust to that. This function is used for
|
| 55 |
-
EVERY v4 hybrid composition in this module (`_evaluate_stems_v4`, and
|
| 56 |
-
therefore every per-family/per-bc metric, the v4 side of `no_regression`,
|
| 57 |
-
and `chvorinov`'s `pred_total`). The SP1 side of `no_regression`
|
| 58 |
-
(`_sp1_hybrid_predict_stem`) deliberately keeps `src.hybrid.hybrid_field`'s
|
| 59 |
-
MEAN-anchor unchanged -- it is SP1's own genuine per-node HGB baseline, not
|
| 60 |
-
a broadcast scalar, so the MEAN-anchor is correct there.
|
| 61 |
-
"""
|
| 62 |
-
from __future__ import annotations
|
| 63 |
-
|
| 64 |
-
import json
|
| 65 |
-
import os
|
| 66 |
-
import os.path as osp
|
| 67 |
-
from collections import defaultdict
|
| 68 |
-
|
| 69 |
-
import joblib
|
| 70 |
-
import numpy as np
|
| 71 |
-
import torch
|
| 72 |
-
|
| 73 |
-
from src import metrics
|
| 74 |
-
from src.baseline import predict_seconds
|
| 75 |
-
from src.dataload import load_norm, standardize
|
| 76 |
-
from src.evaluate_gnn import predict_seconds_gnn
|
| 77 |
-
from src.gbm_ft2b import (_load_manifest_rows, _row_features, predict_std_seconds,
|
| 78 |
-
std_feature_row)
|
| 79 |
-
from src.graph_build import (edge_attr, knn_edge_index, union_edge_attr,
|
| 80 |
-
union_edge_index)
|
| 81 |
-
from src.hybrid import hybrid_field, hybrid_field_soft
|
| 82 |
-
from src.hybrid import hybrid_field_max_anchor as _max_anchor_hybrid
|
| 83 |
-
from src.model_gnn import SolidGNN
|
| 84 |
-
|
| 85 |
-
# The HGB v2/SP1 baseline was trained on the 9 v2 features = first 9 columns
|
| 86 |
-
# of any newer feature version (ordering contract, SP1 spec §3). The SP1
|
| 87 |
-
# production GNN was trained on the 13 v3 features = first 13 columns of the
|
| 88 |
-
# v4 cache's X (`src/features.py`: "v4: v3's 13 columns UNCHANGED").
|
| 89 |
-
N_V2_FEATURES = 9
|
| 90 |
-
N_V3_FEATURES = 13
|
| 91 |
-
|
| 92 |
-
DEFAULT_OUT_PATH = "runs/ft2b_stage3/eval.json"
|
| 93 |
-
DEFAULT_SP1_CKPT = "runs/sp1_run3_final/best_gnn.pt"
|
| 94 |
-
DEFAULT_SP1_HGB = "runs/baseline_v2/hgb_A.joblib"
|
| 95 |
-
DEFAULT_BASELINE_FEATURES_DIR = "data/features_v2"
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def hotspot_overlap(y_true, y_pred, xyz, graph_id, top_frac: float = 0.10,
|
| 99 |
-
tol_mm: float = 5.0) -> float:
|
| 100 |
-
"""Top-`top_frac` latest-solidifying-node overlap, within `tol_mm`.
|
| 101 |
-
|
| 102 |
-
Thin, deliberately-not-reimplemented wrapper over
|
| 103 |
-
`src.metrics.hotspot_accuracy` -- the README-documented hot-spot metric
|
| 104 |
-
("overlap of predicted vs. true top-10% latest-solidifying nodes, within
|
| 105 |
-
a tolerance"). Exposed under this module's own name so callers of
|
| 106 |
-
`evaluate_ft2b` don't need to reach into `src.metrics` directly.
|
| 107 |
-
"""
|
| 108 |
-
return metrics.hotspot_accuracy(y_true, y_pred, xyz, graph_id,
|
| 109 |
-
top_frac=top_frac, tol_mm=tol_mm)
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
# --------------------------------------------------------------------------- #
|
| 113 |
-
# GNN loading / prediction (v4-aware: film + g)
|
| 114 |
-
# --------------------------------------------------------------------------- #
|
| 115 |
-
def load_ft2b_gnn(ckpt, device):
|
| 116 |
-
"""Load a `{model, cfg, norm}` checkpoint into a `SolidGNN` (film-aware).
|
| 117 |
-
|
| 118 |
-
`ckpt` may be a path (loaded via `torch.load`) or an already-loaded
|
| 119 |
-
checkpoint dict -- convenient for tests that build a ckpt in-process.
|
| 120 |
-
|
| 121 |
-
Learned-heads Task 3: `cfg.get("deriv_heads")` truthy builds the model
|
| 122 |
-
with `deriv_heads=True` (the `SolidGNN` gains `grad_head`/`dtdt_head`,
|
| 123 |
-
see `src/model_gnn.py`) before the strict `load_state_dict` below -- a
|
| 124 |
-
heads checkpoint's saved `state["model"]` already carries the two heads'
|
| 125 |
-
weights (`src/train_heads.py` saves the full `model.state_dict()` of a
|
| 126 |
-
`deriv_heads=True` model), so this is still a plain strict load, not
|
| 127 |
-
`attach_deriv_heads` (that helper is only for loading a HEADLESS
|
| 128 |
-
state_dict into a heads-shaped model, e.g. inside the trainer). A
|
| 129 |
-
headless checkpoint (`cfg.get("deriv_heads")` absent/falsy, the default)
|
| 130 |
-
builds `deriv_heads=False` exactly as before -- byte-identical path.
|
| 131 |
-
"""
|
| 132 |
-
state = torch.load(ckpt, map_location=device, weights_only=False) \
|
| 133 |
-
if isinstance(ckpt, (str, os.PathLike)) else ckpt
|
| 134 |
-
cfg, norm = state["cfg"], state["norm"]
|
| 135 |
-
model = SolidGNN(node_in=cfg.get("node_in", 15), edge_in=cfg.get("edge_in", 4),
|
| 136 |
-
hidden=cfg["hidden"], n_blocks=cfg["n_blocks"],
|
| 137 |
-
two_head=cfg.get("two_head", False),
|
| 138 |
-
film=cfg.get("film", False), g_dim=cfg.get("g_dim", 5),
|
| 139 |
-
deriv_heads=cfg.get("deriv_heads", False)).to(device)
|
| 140 |
-
model.load_state_dict(state["model"])
|
| 141 |
-
model.eval()
|
| 142 |
-
return model, cfg, norm
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
def _build_edges(pos, cfg, mesh_edges=None):
|
| 146 |
-
if cfg.get("graph", "knn") == "union":
|
| 147 |
-
if mesh_edges is None:
|
| 148 |
-
raise ValueError("checkpoint expects a union graph but mesh_edges is missing")
|
| 149 |
-
ei, is_mesh = union_edge_index(pos, mesh_edges, k=cfg["k"])
|
| 150 |
-
return ei, union_edge_attr(pos, ei, is_mesh)
|
| 151 |
-
ei = knn_edge_index(pos, k=cfg["k"])
|
| 152 |
-
return ei, edge_attr(pos, ei)
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def _run_model(model, x, ei, ea, zeros, g, device):
|
| 156 |
-
"""Shared forward-pass plumbing for `predict_graph_seconds`/
|
| 157 |
-
`predict_graph_derivs` (learned-heads Task 3): moves the already-built
|
| 158 |
-
tensors to `device` and calls the model once. Returns whatever tuple the
|
| 159 |
-
model itself returns -- 2-tuple `(y_nodal, y_global)` for a headless
|
| 160 |
-
model, 3-tuple `(y_nodal, y_global, deriv)` for a `deriv_heads=True` one
|
| 161 |
-
-- so callers must index (`out[0]`), never unpack-by-position, to stay
|
| 162 |
-
correct for both."""
|
| 163 |
-
with torch.no_grad():
|
| 164 |
-
return model(x.to(device), ei.to(device), ea.to(device), zeros, g=g)
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
def _prepare_graph_inputs(cfg, norm, X, pos_np, mesh_edges, g_vec, device):
|
| 168 |
-
"""Shared tensor-building for `predict_graph_seconds`/`predict_graph_derivs`
|
| 169 |
-
(learned-heads Task 3): standardizes `X`, builds the edge set per `cfg`,
|
| 170 |
-
and the FiLM `g` vector (if applicable). Returns `(x, ei, ea, zeros, g)`,
|
| 171 |
-
all ready to feed straight into `_run_model`."""
|
| 172 |
-
fmean = torch.tensor(norm["feat_mean"], dtype=torch.float32)
|
| 173 |
-
fstd = torch.tensor(norm["feat_std"], dtype=torch.float32)
|
| 174 |
-
x = (torch.tensor(X, dtype=torch.float32) - fmean) / fstd
|
| 175 |
-
pos = torch.tensor(pos_np, dtype=torch.float32)
|
| 176 |
-
me = torch.tensor(mesh_edges, dtype=torch.long) if mesh_edges is not None else None
|
| 177 |
-
ei, ea = _build_edges(pos, cfg, me)
|
| 178 |
-
zeros = torch.zeros(x.shape[0], dtype=torch.long, device=device)
|
| 179 |
-
g = None
|
| 180 |
-
if cfg.get("film", False) and g_vec is not None:
|
| 181 |
-
g = torch.tensor(np.asarray(g_vec), dtype=torch.float32).reshape(1, -1).to(device)
|
| 182 |
-
return x, ei, ea, zeros, g
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
def predict_graph_seconds(model, cfg, norm, X, pos_np, mesh_edges=None, g_vec=None,
|
| 186 |
-
device="cpu") -> np.ndarray:
|
| 187 |
-
"""One graph's per-node hybrid-shape input -> physical-seconds GNN prediction.
|
| 188 |
-
|
| 189 |
-
`g_vec`: the cache's raw (5,) alloy/mould vector, forwarded un-standardized
|
| 190 |
-
(mirrors `SolidGraphDataset`, which does not standardize `g`) -- only used
|
| 191 |
-
when `cfg["film"]` is set.
|
| 192 |
-
|
| 193 |
-
Learned-heads Task 3: `model` may be a `deriv_heads=True` `SolidGNN`
|
| 194 |
-
(forward returns a 3-tuple `(y_nodal, y_global, deriv)` instead of the
|
| 195 |
-
headless 2-tuple) -- this function's OWN return contract is unchanged
|
| 196 |
-
either way (always just the `(N,)` physical-seconds array from
|
| 197 |
-
`y_nodal`); the extra `deriv` output (if any) is simply ignored here.
|
| 198 |
-
Use the sibling `predict_graph_derivs` to get the derivative heads'
|
| 199 |
-
predictions.
|
| 200 |
-
"""
|
| 201 |
-
x, ei, ea, zeros, g = _prepare_graph_inputs(cfg, norm, X, pos_np, mesh_edges, g_vec, device)
|
| 202 |
-
out = _run_model(model, x, ei, ea, zeros, g, device)
|
| 203 |
-
yn = out[0]
|
| 204 |
-
return predict_seconds_gnn(yn.cpu().numpy(), norm)
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
def predict_graph_derivs(model, cfg, norm, X, pos_np, mesh_edges=None, g_vec=None,
|
| 208 |
-
device="cpu"):
|
| 209 |
-
"""One graph's per-node hybrid-shape input -> physical-units learned
|
| 210 |
-
gradT (K/m) / dTdt (K/s), the two `deriv_heads` outputs (learned-heads
|
| 211 |
-
Task 3). Sibling of `predict_graph_seconds`, sharing its exact tensor
|
| 212 |
-
construction (standardized `X`, `cfg`-selected edge set, FiLM `g`) --
|
| 213 |
-
only the output side differs.
|
| 214 |
-
|
| 215 |
-
`model` MUST be a `deriv_heads=True` SolidGNN (forward returns a 3-tuple
|
| 216 |
-
`(y_nodal, y_global, deriv)`, `deriv` shape `(N, 2)` = standardized
|
| 217 |
-
`[log1p(gradT), log1p(|dTdt|)]`) -- a headless (2-tuple) model raises a
|
| 218 |
-
loud `ValueError` naming the mismatch rather than crashing on a tuple-
|
| 219 |
-
index error deeper in the call, or silently returning garbage.
|
| 220 |
-
|
| 221 |
-
`norm` must carry `deriv_log_mean`/`deriv_log_std` (each shape `(2,)`,
|
| 222 |
-
written by `src.train_heads.train_deriv_heads` into the checkpoint's
|
| 223 |
-
`norm` dict) -- the per-head log-space standardization stats the model
|
| 224 |
-
was trained against.
|
| 225 |
-
|
| 226 |
-
Inverse transform (pinned exactly, mirrors `predict_seconds_gnn`'s own
|
| 227 |
-
`pred_log = pred_std_log * std + mean; expm1`, applied per-head):
|
| 228 |
-
`deriv_std * z + deriv_mean -> expm1 -> clip(min=0)`. Both outputs are
|
| 229 |
-
non-negative by construction: `gradT` is a gradient MAGNITUDE (never
|
| 230 |
-
signed), and `dTdt` is returned as a positive magnitude in K/s -- this
|
| 231 |
-
matches the cache's own `dTdt` convention (`tools/doe/crossing_capture.py`
|
| 232 |
-
stores `dTdt_at = (T_prev - T_now) / dt`, i.e. a positive cooling rate by
|
| 233 |
-
construction on a monotonically-cooling casting; `src.train_heads`'s
|
| 234 |
-
`DerivGraphDataset` additionally takes `.abs()` defensively before
|
| 235 |
-
log-standardizing) -- and `src.shrinkage.niyama_exact` itself also takes
|
| 236 |
-
`np.abs(dTdt_K_per_s)` internally, so this function's non-negative
|
| 237 |
-
`dTdt` is compatible either way.
|
| 238 |
-
|
| 239 |
-
Returns `(gradT_K_per_m, dTdt_K_per_s)`, each an `(N,)` float64 array.
|
| 240 |
-
"""
|
| 241 |
-
x, ei, ea, zeros, g = _prepare_graph_inputs(cfg, norm, X, pos_np, mesh_edges, g_vec, device)
|
| 242 |
-
out = _run_model(model, x, ei, ea, zeros, g, device)
|
| 243 |
-
if len(out) != 3:
|
| 244 |
-
raise ValueError(
|
| 245 |
-
"predict_graph_derivs: model did not return a deriv-heads output "
|
| 246 |
-
f"(expected a 3-tuple (y_nodal, y_global, deriv), got a "
|
| 247 |
-
f"{len(out)}-tuple) -- pass a deriv_heads=True SolidGNN / a heads "
|
| 248 |
-
"checkpoint (cfg['deriv_heads'] truthy, loaded via load_ft2b_gnn)")
|
| 249 |
-
deriv = out[2].cpu().numpy().astype(np.float64)
|
| 250 |
-
mean = np.asarray(norm["deriv_log_mean"], dtype=np.float64)
|
| 251 |
-
std = np.asarray(norm["deriv_log_std"], dtype=np.float64)
|
| 252 |
-
log_vals = deriv * std + mean
|
| 253 |
-
vals = np.clip(np.expm1(log_vals), 0.0, None)
|
| 254 |
-
return vals[:, 0], vals[:, 1]
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
# --------------------------------------------------------------------------- #
|
| 258 |
-
# Per-stem v4 hybrid prediction (GNN shape x GBM total-time scale)
|
| 259 |
-
# --------------------------------------------------------------------------- #
|
| 260 |
-
def _gbm_seconds_for_stem(gbm, stem, manifest_rows, manifest_dir, features_dir) -> float:
|
| 261 |
-
x_row, _y_log_true = _row_features(stem, manifest_rows, manifest_dir, features_dir)
|
| 262 |
-
log_pred = float(gbm.predict(x_row.reshape(1, -1))[0])
|
| 263 |
-
return float(np.clip(np.expm1(log_pred), 0.0, None))
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
def _evaluate_stems_v4(model, cfg, norm, gbm, stems, manifest_rows, manifest_dir,
|
| 267 |
-
features_dir, device, hgb_std=None, lambda_shrink: float = 1.0,
|
| 268 |
-
w_total: float = 1.0, w_std: float = 1.0
|
| 269 |
-
) -> list[dict]:
|
| 270 |
-
"""One record per stem: per-node y_true/y_hybrid/xyz + per-stem metadata
|
| 271 |
-
(alloy_family, bc_config, t_ref, predicted total time).
|
| 272 |
-
|
| 273 |
-
`hgb_std` (variance-recomposition Task 3): an optional loaded GBM
|
| 274 |
-
std-head model (`src.gbm_ft2b.refit_gbm_std`'s output). `None` (the
|
| 275 |
-
default) is byte-identical to before this argument existed --
|
| 276 |
-
`_max_anchor_hybrid` (quantile-anchor). When given, each stem's row
|
| 277 |
-
features are rebuilt via `src.gbm_ft2b.std_feature_row` (soft-anchor
|
| 278 |
-
Task 3: width-dispatched off `hgb_std`'s own `n_features_in_`, 16 or 17
|
| 279 |
-
-- a SEPARATE call from the one `_gbm_seconds_for_stem` makes internally
|
| 280 |
-
for the total, kept separate, rather than refactored to share one call,
|
| 281 |
-
so existing tests that monkeypatch `_gbm_seconds_for_stem` wholesale
|
| 282 |
-
keep working unmodified) and recomposition uses
|
| 283 |
-
`src.hybrid.hybrid_field_soft` (soft-anchor Task 3; was
|
| 284 |
-
`hybrid_field_affine`) instead; each such record also carries
|
| 285 |
-
`recomposition_info` (that function's `info` dict, now carrying
|
| 286 |
-
`"m"`/`"target_unreachable"`/... in place of affine's `"a_clamped"`).
|
| 287 |
-
|
| 288 |
-
`lambda_shrink` (variance-recomposition amendment 2): forwarded straight
|
| 289 |
-
to `hybrid_field_soft` (ignored when `hgb_std` is `None`, same as every
|
| 290 |
-
other soft-only knob here). `1.0` (the default) is byte-identical to
|
| 291 |
-
before this argument existed.
|
| 292 |
-
|
| 293 |
-
`w_total`/`w_std` (soft-anchor Task 3): forwarded straight to
|
| 294 |
-
`hybrid_field_soft`'s own weights (ignored when `hgb_std` is `None`).
|
| 295 |
-
`1.0`/`1.0` (the defaults) reproduce `hybrid_field_soft`'s own default
|
| 296 |
-
weighting."""
|
| 297 |
-
records = []
|
| 298 |
-
for stem in stems:
|
| 299 |
-
d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
|
| 300 |
-
X, pos_np = d["X"], d["pos"]
|
| 301 |
-
y_true = d["y_raw"] if "y_raw" in d.files else d["y"]
|
| 302 |
-
mesh_edges = d["mesh_edges"] if "mesh_edges" in d.files else None
|
| 303 |
-
g_vec = d["g"] if "g" in d.files else None
|
| 304 |
-
gnn_seconds = predict_graph_seconds(model, cfg, norm, X, pos_np, mesh_edges,
|
| 305 |
-
g_vec, device=device)
|
| 306 |
-
gbm_seconds = _gbm_seconds_for_stem(gbm, stem, manifest_rows, manifest_dir,
|
| 307 |
-
features_dir)
|
| 308 |
-
recomp_info = None
|
| 309 |
-
if hgb_std is not None:
|
| 310 |
-
n_std_features = int(getattr(hgb_std, "n_features_in_", 16))
|
| 311 |
-
x_row = std_feature_row(stem, manifest_rows, manifest_dir, features_dir,
|
| 312 |
-
n_std_features)
|
| 313 |
-
std_seconds = predict_std_seconds(hgb_std, x_row)
|
| 314 |
-
hyb, recomp_info = hybrid_field_soft(gnn_seconds, gbm_seconds, std_seconds,
|
| 315 |
-
lambda_shrink=lambda_shrink,
|
| 316 |
-
w_total=w_total, w_std=w_std)
|
| 317 |
-
else:
|
| 318 |
-
hyb = _max_anchor_hybrid(gnn_seconds, gbm_seconds)
|
| 319 |
-
row = manifest_rows.get(stem, {})
|
| 320 |
-
t_ref = float(d["t_ref"]) if "t_ref" in d.files else None
|
| 321 |
-
rec = {
|
| 322 |
-
"stem": stem,
|
| 323 |
-
"family": row.get("alloy_family") or "unknown",
|
| 324 |
-
"bc": row.get("bc_config") or "none",
|
| 325 |
-
"dataset": row.get("dataset") or "",
|
| 326 |
-
"y_true": y_true, "y_hybrid": hyb, "xyz": pos_np,
|
| 327 |
-
"gbm_seconds": gbm_seconds, "t_ref": t_ref,
|
| 328 |
-
"pred_total": float(np.max(hyb)),
|
| 329 |
-
}
|
| 330 |
-
if recomp_info is not None:
|
| 331 |
-
rec["recomposition_info"] = recomp_info
|
| 332 |
-
records.append(rec)
|
| 333 |
-
return records
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
def _group_metrics(records, key: str) -> dict:
|
| 337 |
-
groups = defaultdict(list)
|
| 338 |
-
for r in records:
|
| 339 |
-
groups[r[key]].append(r)
|
| 340 |
-
out = {}
|
| 341 |
-
for gval, recs in groups.items():
|
| 342 |
-
y = np.concatenate([r["y_true"] for r in recs])
|
| 343 |
-
p = np.concatenate([r["y_hybrid"] for r in recs])
|
| 344 |
-
xyz = np.concatenate([r["xyz"] for r in recs])
|
| 345 |
-
gid = np.concatenate([np.full(r["y_true"].shape[0], i, dtype=np.int64)
|
| 346 |
-
for i, r in enumerate(recs)])
|
| 347 |
-
out[str(gval)] = {
|
| 348 |
-
"n_stems": len(recs),
|
| 349 |
-
"n_nodes": int(y.shape[0]),
|
| 350 |
-
"mae": metrics.mae(y, p),
|
| 351 |
-
"hotspot_overlap": hotspot_overlap(y, p, xyz, gid),
|
| 352 |
-
"std_ratio": metrics.pred_std_ratio(y, p, gid),
|
| 353 |
-
}
|
| 354 |
-
return out
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
def _overall_metrics(records) -> dict:
|
| 358 |
-
if not records:
|
| 359 |
-
return {"n_stems": 0, "n_nodes": 0, "mae": 0.0, "hotspot_overlap": 0.0,
|
| 360 |
-
"std_ratio": 0.0}
|
| 361 |
-
y = np.concatenate([r["y_true"] for r in records])
|
| 362 |
-
p = np.concatenate([r["y_hybrid"] for r in records])
|
| 363 |
-
xyz = np.concatenate([r["xyz"] for r in records])
|
| 364 |
-
gid = np.concatenate([np.full(r["y_true"].shape[0], i, dtype=np.int64)
|
| 365 |
-
for i, r in enumerate(records)])
|
| 366 |
-
return {
|
| 367 |
-
"n_stems": len(records), "n_nodes": int(y.shape[0]),
|
| 368 |
-
"mae": metrics.mae(y, p),
|
| 369 |
-
"hotspot_overlap": hotspot_overlap(y, p, xyz, gid),
|
| 370 |
-
"std_ratio": metrics.pred_std_ratio(y, p, gid),
|
| 371 |
-
}
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
# --------------------------------------------------------------------------- #
|
| 375 |
-
# No-regression check vs the SP1 hybrid
|
| 376 |
-
# --------------------------------------------------------------------------- #
|
| 377 |
-
# DESIGN CHOICE (documented per Task 8 brief): the no-regression subset is
|
| 378 |
-
# manifest dataset=="A" (all-sand, no BC variation -- the closest analog to
|
| 379 |
-
# the OLD single-alloy regime SP1 was trained/validated on) intersected with
|
| 380 |
-
# the union of interp.test/extrap.test. SP1 itself cannot be run on this
|
| 381 |
-
# subset "as originally trained" (SP1 has no v4 cache -- its own
|
| 382 |
-
# `data/features_v3` cache is a DIFFERENT, single-alloy dataset with
|
| 383 |
-
# different geometries/splits). Instead we run the SP1 GNN + SP1 HGB THE SAME
|
| 384 |
-
# WAY they are normally run (`src.evaluate_gnn.evaluate`): standardize with
|
| 385 |
-
# SP1's OWN norm stats and feed it whatever 13/9-column feature vector it's
|
| 386 |
-
# given. Since `FEATURE_NAMES_V4 == FEATURE_NAMES_V3 + [2 BC cols]` and
|
| 387 |
-
# `FEATURE_NAMES_V3 == FEATURE_NAMES + [4 local-modulus/geodesic cols]`
|
| 388 |
-
# (`src/features.py`), `v4_X[:, :13]` IS the v3 feature vector and
|
| 389 |
-
# `v4_X[:, :9]` IS the v2 feature vector for the SAME geometry -- so this is
|
| 390 |
-
# a legitimate "plug the v4 cache's geometry into the SP1 stack" comparison,
|
| 391 |
-
# not an apples-to-oranges one. CAVEATS: (1) the v4 A-subset's alloy
|
| 392 |
-
# properties vary (synthetic LHS-sampled alloys, not the single fixed alloy
|
| 393 |
-
# SP1's target distribution assumes) -- SP1 has no way to know this, so its
|
| 394 |
-
# scale prediction (HGB) is evaluated fully out-of-its-training-distribution
|
| 395 |
-
# here; a "regression" in this comparison could reflect the alloy variety
|
| 396 |
-
# alone, not the FT2b GNN's shape quality. (2) mesh/graph identity is
|
| 397 |
-
# guaranteed (same X/pos/mesh_edges array), so any MAE delta is NOT a
|
| 398 |
-
# geometry-mismatch artifact. (3) small subset sizes (a handful of A-rows in
|
| 399 |
-
# the actual test splits) make this a noisy signal, not a hard gate by
|
| 400 |
-
# itself -- read alongside per-family/per-bc numbers above.
|
| 401 |
-
def _no_regression_subset(splits_d: dict, manifest_rows: dict) -> list[str]:
|
| 402 |
-
test_stems = set(splits_d["interp"]["test"]) | set(splits_d["extrap"]["test"])
|
| 403 |
-
return sorted(s for s in test_stems if manifest_rows.get(s, {}).get("dataset") == "A")
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
def _sp1_hybrid_predict_stem(stem, sp1_model, sp1_cfg, sp1_norm, hgb, hgb_norm,
|
| 407 |
-
features_dir, device):
|
| 408 |
-
d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
|
| 409 |
-
X, pos_np = d["X"], d["pos"]
|
| 410 |
-
y_true = d["y_raw"] if "y_raw" in d.files else d["y"]
|
| 411 |
-
mesh_edges = d["mesh_edges"] if "mesh_edges" in d.files else None
|
| 412 |
-
X13 = X[:, :N_V3_FEATURES]
|
| 413 |
-
gnn_seconds = predict_graph_seconds(sp1_model, sp1_cfg, sp1_norm, X13, pos_np,
|
| 414 |
-
mesh_edges, g_vec=None, device=device)
|
| 415 |
-
Xb = standardize(X[:, :N_V2_FEATURES], hgb_norm)
|
| 416 |
-
baseline_seconds = predict_seconds(hgb, Xb, hgb_norm)
|
| 417 |
-
hyb = hybrid_field(gnn_seconds, baseline_seconds)
|
| 418 |
-
return y_true, hyb, pos_np
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
def _no_regression_check(splits_d, manifest_rows, features_dir, v4_records_by_stem,
|
| 422 |
-
sp1_ckpt, sp1_hgb, baseline_features_dir, device,
|
| 423 |
-
tolerance: float = 1.15) -> dict:
|
| 424 |
-
subset = _no_regression_subset(splits_d, manifest_rows)
|
| 425 |
-
note = ("subset = manifest dataset=='A' (all-sand) stems present in "
|
| 426 |
-
"interp.test | extrap.test; SP1 hybrid run on the SAME v4-cache "
|
| 427 |
-
"geometry via v4_X[:, :13] (v3 features) / v4_X[:, :9] (v2 "
|
| 428 |
-
"features) fed through SP1's own norm stats -- see this module's "
|
| 429 |
-
"`_no_regression_subset`/`_no_regression_check` docstring for the "
|
| 430 |
-
"full design rationale + caveats (alloy variety is OOD for SP1's "
|
| 431 |
-
"HGB; small-subset noise).")
|
| 432 |
-
if not subset:
|
| 433 |
-
zero = {"n_stems": 0, "mae": 0.0, "hotspot_overlap": 0.0, "std_ratio": 0.0}
|
| 434 |
-
return {"n_stems": 0, "v4_hybrid": zero, "sp1_hybrid": dict(zero),
|
| 435 |
-
"mae_ratio": 0.0, "regression_flag": False,
|
| 436 |
-
"note": note + " EMPTY SUBSET: no dataset=='A' test stems found."}
|
| 437 |
-
|
| 438 |
-
v4_recs = [v4_records_by_stem[s] for s in subset if s in v4_records_by_stem]
|
| 439 |
-
v4_metrics = _overall_metrics(v4_recs)
|
| 440 |
-
|
| 441 |
-
sp1_model, sp1_cfg, sp1_norm = load_ft2b_gnn(sp1_ckpt, device)
|
| 442 |
-
hgb = joblib.load(sp1_hgb) if isinstance(sp1_hgb, (str, os.PathLike)) else sp1_hgb
|
| 443 |
-
hgb_norm = load_norm(baseline_features_dir)
|
| 444 |
-
|
| 445 |
-
ys, ps, xyzs = [], [], []
|
| 446 |
-
for stem in subset:
|
| 447 |
-
y_true, hyb, pos_np = _sp1_hybrid_predict_stem(
|
| 448 |
-
stem, sp1_model, sp1_cfg, sp1_norm, hgb, hgb_norm, features_dir, device)
|
| 449 |
-
ys.append(y_true); ps.append(hyb); xyzs.append(pos_np)
|
| 450 |
-
y = np.concatenate(ys); p = np.concatenate(ps); xyz = np.concatenate(xyzs)
|
| 451 |
-
gid = np.concatenate([np.full(ys[i].shape[0], i, dtype=np.int64) for i in range(len(ys))])
|
| 452 |
-
sp1_metrics = {
|
| 453 |
-
"n_stems": len(subset), "n_nodes": int(y.shape[0]),
|
| 454 |
-
"mae": metrics.mae(y, p),
|
| 455 |
-
"hotspot_overlap": hotspot_overlap(y, p, xyz, gid),
|
| 456 |
-
"std_ratio": metrics.pred_std_ratio(y, p, gid),
|
| 457 |
-
}
|
| 458 |
-
|
| 459 |
-
mae_ratio = (v4_metrics["mae"] / sp1_metrics["mae"]) if sp1_metrics["mae"] > 1e-9 else 0.0
|
| 460 |
-
return {
|
| 461 |
-
"n_stems": len(subset),
|
| 462 |
-
"v4_hybrid": v4_metrics,
|
| 463 |
-
"sp1_hybrid": sp1_metrics,
|
| 464 |
-
"mae_ratio": float(mae_ratio),
|
| 465 |
-
"regression_flag": bool(v4_metrics["mae"] > sp1_metrics["mae"] * tolerance),
|
| 466 |
-
"note": note,
|
| 467 |
-
}
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
# --------------------------------------------------------------------------- #
|
| 471 |
-
# Chvorinov-scaling sanity check
|
| 472 |
-
# --------------------------------------------------------------------------- #
|
| 473 |
-
def _linfit(x: np.ndarray, y: np.ndarray) -> dict:
|
| 474 |
-
"""Least-squares y = slope*x + intercept + R^2. Always finite: falls
|
| 475 |
-
back to a degenerate-but-finite {0,0,0} fit when there are fewer than 2
|
| 476 |
-
points or `x` is (numerically) constant, rather than raising or NaN'ing."""
|
| 477 |
-
n = len(x)
|
| 478 |
-
if n < 2 or np.ptp(x) < 1e-9:
|
| 479 |
-
return {"n": int(n), "slope": 0.0, "intercept": 0.0, "r2": 0.0}
|
| 480 |
-
slope, intercept = np.polyfit(x, y, 1)
|
| 481 |
-
y_hat = slope * x + intercept
|
| 482 |
-
ss_res = float(np.sum((y - y_hat) ** 2))
|
| 483 |
-
ss_tot = float(np.sum((y - np.mean(y)) ** 2))
|
| 484 |
-
r2 = 1.0 - ss_res / ss_tot if ss_tot > 1e-12 else 0.0
|
| 485 |
-
return {"n": int(n), "slope": float(slope), "intercept": float(intercept), "r2": float(r2)}
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
def _chvorinov_check(records: list[dict]) -> dict:
|
| 489 |
-
"""Regress the model's predicted total solidification time (`pred_total`
|
| 490 |
-
= max of the per-node hybrid field, per stem) against each stem's cached
|
| 491 |
-
analytic Chvorinov reference `t_ref = C_family * (V/A)^2`
|
| 492 |
-
(`src/build_dataset.py::_finalize_row_stage2`). A clean per-family fit
|
| 493 |
-
(slope near 1, high R^2) means the scale head still tracks Chvorinov
|
| 494 |
-
scaling after fine-tuning; reported per family AND overall."""
|
| 495 |
-
usable = [r for r in records if r["t_ref"] is not None and np.isfinite(r["t_ref"])]
|
| 496 |
-
groups = defaultdict(list)
|
| 497 |
-
for r in usable:
|
| 498 |
-
groups[r["family"]].append(r)
|
| 499 |
-
out = {}
|
| 500 |
-
for fam, recs in groups.items():
|
| 501 |
-
x = np.array([r["t_ref"] for r in recs], dtype=np.float64)
|
| 502 |
-
y = np.array([r["pred_total"] for r in recs], dtype=np.float64)
|
| 503 |
-
out[str(fam)] = _linfit(x, y)
|
| 504 |
-
x_all = np.array([r["t_ref"] for r in usable], dtype=np.float64)
|
| 505 |
-
y_all = np.array([r["pred_total"] for r in usable], dtype=np.float64)
|
| 506 |
-
out["overall"] = _linfit(x_all, y_all)
|
| 507 |
-
return out
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
# --------------------------------------------------------------------------- #
|
| 511 |
-
# Top-level entry point
|
| 512 |
-
# --------------------------------------------------------------------------- #
|
| 513 |
-
def _load_splits(splits) -> dict:
|
| 514 |
-
if isinstance(splits, (str, os.PathLike)):
|
| 515 |
-
with open(splits) as f:
|
| 516 |
-
return json.load(f)
|
| 517 |
-
return splits
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
def evaluate_ft2b(ckpt, gbm, features_dir, manifest, splits,
|
| 521 |
-
sp1_ckpt=DEFAULT_SP1_CKPT, sp1_hgb=DEFAULT_SP1_HGB,
|
| 522 |
-
baseline_features_dir=DEFAULT_BASELINE_FEATURES_DIR,
|
| 523 |
-
out_path=DEFAULT_OUT_PATH,
|
| 524 |
-
variance_collapse_threshold: float = 0.9,
|
| 525 |
-
no_regression_tolerance: float = 1.15,
|
| 526 |
-
hgb_std=None,
|
| 527 |
-
lambda_shrink: float = 1.0,
|
| 528 |
-
w_total: float = 1.0,
|
| 529 |
-
w_std: float = 1.0,
|
| 530 |
-
device=None) -> dict:
|
| 531 |
-
"""Evaluate a trained FT2b v4 checkpoint + refit GBM hybrid predictor.
|
| 532 |
-
|
| 533 |
-
Args:
|
| 534 |
-
ckpt: path to the v4 GNN checkpoint (`{model, cfg, norm}`), or an
|
| 535 |
-
already-loaded checkpoint dict.
|
| 536 |
-
gbm: path to the refit multi-alloy GBM (`src.gbm_ft2b.refit_gbm`'s
|
| 537 |
-
`out_path`), or an already-loaded estimator.
|
| 538 |
-
features_dir: v4 feature cache directory (`graphs/<stem>.npz`).
|
| 539 |
-
manifest: path to the DOE manifest CSV (`run_id`==stem; needs
|
| 540 |
-
`dataset`/`alloy_family`/`bc_config`/`material_id`/
|
| 541 |
-
`mold_effusivity` columns).
|
| 542 |
-
splits: path to `features_dir/splits.json`-shaped JSON, or an
|
| 543 |
-
already-loaded `{"interp": {"train","test"}, "extrap":
|
| 544 |
-
{"train","test"}}` dict.
|
| 545 |
-
sp1_ckpt / sp1_hgb / baseline_features_dir: the SP1 production
|
| 546 |
-
hybrid stack, for the no-regression comparison.
|
| 547 |
-
out_path: where to write the result as JSON (set `None`/falsy to
|
| 548 |
-
skip writing).
|
| 549 |
-
variance_collapse_threshold: any per-family/per-bc `std_ratio` below
|
| 550 |
-
this trips `variance_collapse_flag`.
|
| 551 |
-
no_regression_tolerance: the v4 hybrid may be at most this factor
|
| 552 |
-
worse (MAE) than the SP1 hybrid on the all-sand subset before
|
| 553 |
-
`no_regression.regression_flag` trips.
|
| 554 |
-
hgb_std (variance-recomposition Task 3): an optional path to (or
|
| 555 |
-
already-loaded) GBM std-head model
|
| 556 |
-
(`src.gbm_ft2b.refit_gbm_std`'s output). `None` (the default)
|
| 557 |
-
keeps every v4 hybrid composition in this module on the
|
| 558 |
-
quantile-anchor (`src.hybrid.hybrid_field_max_anchor`) --
|
| 559 |
-
byte-identical to before this argument existed. When given,
|
| 560 |
-
every v4 composition (`_evaluate_stems_v4`, and therefore every
|
| 561 |
-
per-family/per-bc metric, the v4 side of `no_regression`, and
|
| 562 |
-
`chvorinov`'s `pred_total`) uses the soft-anchor (soft-anchor
|
| 563 |
-
Task 3; was affine) recomposition instead
|
| 564 |
-
(`src.hybrid.hybrid_field_soft`); the SP1 side of
|
| 565 |
-
`no_regression` is unaffected (SP1 has no std-head analog).
|
| 566 |
-
lambda_shrink (variance-recomposition amendment 2): forwarded
|
| 567 |
-
straight to `_evaluate_stems_v4`/`hybrid_field_soft` (ignored
|
| 568 |
-
when `hgb_std` is `None`). `1.0` (the default) is byte-identical
|
| 569 |
-
to before this argument existed.
|
| 570 |
-
w_total / w_std (soft-anchor Task 3): forwarded straight to
|
| 571 |
-
`_evaluate_stems_v4`/`hybrid_field_soft`'s own weights (ignored
|
| 572 |
-
when `hgb_std` is `None`). `1.0`/`1.0` (the defaults) reproduce
|
| 573 |
-
`hybrid_field_soft`'s own default weighting.
|
| 574 |
-
device: "cuda"/"cpu"; default picks cuda if available.
|
| 575 |
-
|
| 576 |
-
Returns: nested dict with `interp`/`extrap` (each `per_alloy_family`,
|
| 577 |
-
`per_bc_config`, `overall`), `no_regression`, `chvorinov`,
|
| 578 |
-
`variance_collapse_flag`, `recomposition` ("soft" iff `hgb_std`
|
| 579 |
-
was given, else "anchor").
|
| 580 |
-
"""
|
| 581 |
-
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 582 |
-
model, cfg, norm = load_ft2b_gnn(ckpt, device)
|
| 583 |
-
gbm_model = joblib.load(gbm) if isinstance(gbm, (str, os.PathLike)) else gbm
|
| 584 |
-
hgb_std_model = joblib.load(hgb_std) if isinstance(hgb_std, (str, os.PathLike)) else hgb_std
|
| 585 |
-
manifest_dir = osp.dirname(osp.abspath(manifest)) or "."
|
| 586 |
-
manifest_rows = _load_manifest_rows(manifest)
|
| 587 |
-
splits_d = _load_splits(splits)
|
| 588 |
-
|
| 589 |
-
result: dict = {}
|
| 590 |
-
records_by_stem: dict = {}
|
| 591 |
-
for split_name in ("interp", "extrap"):
|
| 592 |
-
test_stems = splits_d[split_name]["test"]
|
| 593 |
-
records = _evaluate_stems_v4(model, cfg, norm, gbm_model, test_stems,
|
| 594 |
-
manifest_rows, manifest_dir, features_dir, device,
|
| 595 |
-
hgb_std=hgb_std_model, lambda_shrink=lambda_shrink,
|
| 596 |
-
w_total=w_total, w_std=w_std)
|
| 597 |
-
for r in records:
|
| 598 |
-
records_by_stem[r["stem"]] = r
|
| 599 |
-
result[split_name] = {
|
| 600 |
-
"n_stems": len(records),
|
| 601 |
-
"per_alloy_family": _group_metrics(records, "family"),
|
| 602 |
-
"per_bc_config": _group_metrics(records, "bc"),
|
| 603 |
-
"overall": _overall_metrics(records),
|
| 604 |
-
}
|
| 605 |
-
|
| 606 |
-
variance_collapse = False
|
| 607 |
-
for split_res in result.values():
|
| 608 |
-
for grouping in ("per_alloy_family", "per_bc_config"):
|
| 609 |
-
for m in split_res[grouping].values():
|
| 610 |
-
if m["std_ratio"] < variance_collapse_threshold:
|
| 611 |
-
variance_collapse = True
|
| 612 |
-
result["variance_collapse_flag"] = variance_collapse
|
| 613 |
-
|
| 614 |
-
result["no_regression"] = _no_regression_check(
|
| 615 |
-
splits_d, manifest_rows, features_dir, records_by_stem,
|
| 616 |
-
sp1_ckpt, sp1_hgb, baseline_features_dir, device,
|
| 617 |
-
tolerance=no_regression_tolerance)
|
| 618 |
-
|
| 619 |
-
result["chvorinov"] = _chvorinov_check(list(records_by_stem.values()))
|
| 620 |
-
|
| 621 |
-
result["recomposition"] = "soft" if hgb_std_model is not None else "anchor"
|
| 622 |
-
|
| 623 |
-
if out_path:
|
| 624 |
-
out_dir = osp.dirname(out_path)
|
| 625 |
-
if out_dir:
|
| 626 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 627 |
-
with open(out_path, "w") as f:
|
| 628 |
-
json.dump(result, f, indent=2)
|
| 629 |
-
|
| 630 |
-
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/evaluate_gnn.py
DELETED
|
@@ -1,150 +0,0 @@
|
|
| 1 |
-
"""Evaluate a trained GNN on cache stems: GNN-only and (optionally) hybrid,
|
| 2 |
-
optional rotation TTA, per-split comparison table. No augmentation leakage:
|
| 3 |
-
TTA rotations are seeded and averaged in seconds-space."""
|
| 4 |
-
import argparse
|
| 5 |
-
import json
|
| 6 |
-
import os.path as osp
|
| 7 |
-
|
| 8 |
-
import numpy as np
|
| 9 |
-
import torch
|
| 10 |
-
|
| 11 |
-
from src.graph_build import (knn_edge_index, edge_attr, random_rotation_matrix, rotate,
|
| 12 |
-
union_edge_index, union_edge_attr)
|
| 13 |
-
from src.model_gnn import SolidGNN
|
| 14 |
-
from src.dataload import load_norm, standardize
|
| 15 |
-
from src.splits import load_splits
|
| 16 |
-
from src.baseline import predict_seconds
|
| 17 |
-
from src.hybrid import hybrid_field
|
| 18 |
-
from src import metrics
|
| 19 |
-
|
| 20 |
-
# The HGB baseline was trained on the 9 v2 features = first 9 columns of any
|
| 21 |
-
# newer feature version (ordering contract, SP1 spec §3).
|
| 22 |
-
N_BASELINE_FEATURES = 9
|
| 23 |
-
SPLIT_NAMES = ("interpolation", "family", "fully_cooled")
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def predict_seconds_gnn(pred_std_log, norm) -> np.ndarray:
|
| 27 |
-
pred_log = pred_std_log * norm["y_log_std"] + norm["y_log_mean"]
|
| 28 |
-
return np.clip(np.expm1(pred_log), 0.0, None)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def load_gnn(ckpt, device):
|
| 32 |
-
state = torch.load(ckpt, map_location=device, weights_only=False)
|
| 33 |
-
cfg, norm = state["cfg"], state["norm"]
|
| 34 |
-
model = SolidGNN(node_in=cfg.get("node_in", 9), edge_in=cfg.get("edge_in", 4),
|
| 35 |
-
hidden=cfg["hidden"], n_blocks=cfg["n_blocks"],
|
| 36 |
-
two_head=cfg.get("two_head", False)).to(device)
|
| 37 |
-
model.load_state_dict(state["model"])
|
| 38 |
-
model.eval()
|
| 39 |
-
return model, cfg, norm
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _build_edges(pos, cfg, mesh_edges=None):
|
| 43 |
-
if cfg.get("graph", "knn") == "union":
|
| 44 |
-
if mesh_edges is None:
|
| 45 |
-
raise ValueError("checkpoint expects a union graph but mesh_edges is missing")
|
| 46 |
-
ei, is_mesh = union_edge_index(pos, mesh_edges, k=cfg["k"])
|
| 47 |
-
return ei, union_edge_attr(pos, ei, is_mesh)
|
| 48 |
-
ei = knn_edge_index(pos, k=cfg["k"])
|
| 49 |
-
return ei, edge_attr(pos, ei)
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def predict_graph_seconds(model, cfg, norm, x, pos, mesh_edges=None, device="cpu", tta=0):
|
| 53 |
-
"""Mean GNN prediction (seconds) over identity + `tta` seeded random rotations."""
|
| 54 |
-
outs = []
|
| 55 |
-
rots = [None] + [random_rotation_matrix(seed=s) for s in range(tta)]
|
| 56 |
-
zeros = torch.zeros(x.shape[0], dtype=torch.long, device=device)
|
| 57 |
-
for R in rots:
|
| 58 |
-
p = pos if R is None else rotate(pos, R)
|
| 59 |
-
ei, ea = _build_edges(p, cfg, mesh_edges)
|
| 60 |
-
with torch.no_grad():
|
| 61 |
-
yn, _ = model(x.to(device), ei.to(device), ea.to(device), zeros)
|
| 62 |
-
outs.append(predict_seconds_gnn(yn.cpu().numpy(), norm))
|
| 63 |
-
return np.mean(outs, axis=0)
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def _metric_dict(y, p, xyz, gid) -> dict:
|
| 67 |
-
return {
|
| 68 |
-
"mae": metrics.mae(y, p),
|
| 69 |
-
"rmse": metrics.rmse(y, p),
|
| 70 |
-
"median_rel_error": metrics.median_rel_error(y, p),
|
| 71 |
-
"pred_std_ratio": metrics.pred_std_ratio(y, p, gid),
|
| 72 |
-
"hotspot_accuracy": metrics.hotspot_accuracy(y, p, xyz, gid),
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def evaluate(ckpt, features_dir, test_stems, device=None, tta=0,
|
| 77 |
-
hgb_path=None, baseline_features_dir="data/features_v2") -> dict:
|
| 78 |
-
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 79 |
-
model, cfg, norm = load_gnn(ckpt, device)
|
| 80 |
-
fmean = torch.tensor(norm["feat_mean"], dtype=torch.float32)
|
| 81 |
-
fstd = torch.tensor(norm["feat_std"], dtype=torch.float32)
|
| 82 |
-
hgb = hgb_norm = None
|
| 83 |
-
if hgb_path is not None:
|
| 84 |
-
import joblib
|
| 85 |
-
hgb = joblib.load(hgb_path)
|
| 86 |
-
hgb_norm = load_norm(baseline_features_dir)
|
| 87 |
-
|
| 88 |
-
ys, preds, hyb, xyz, gid = [], [], [], [], []
|
| 89 |
-
for i, stem in enumerate(test_stems):
|
| 90 |
-
d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
|
| 91 |
-
X, y, pos_np = d["X"], d["y"], d["pos"]
|
| 92 |
-
x = (torch.tensor(X, dtype=torch.float32) - fmean) / fstd
|
| 93 |
-
pos = torch.tensor(pos_np, dtype=torch.float32)
|
| 94 |
-
me = (torch.tensor(d["mesh_edges"], dtype=torch.long)
|
| 95 |
-
if "mesh_edges" in d.files else None)
|
| 96 |
-
p = predict_graph_seconds(model, cfg, norm, x, pos, me, device=device, tta=tta)
|
| 97 |
-
preds.append(p); ys.append(y); xyz.append(pos_np)
|
| 98 |
-
gid.append(np.full(y.shape[0], i, dtype=np.int64))
|
| 99 |
-
if hgb is not None:
|
| 100 |
-
Xb = standardize(X[:, :N_BASELINE_FEATURES], hgb_norm)
|
| 101 |
-
hyb.append(hybrid_field(p, predict_seconds(hgb, Xb, hgb_norm)))
|
| 102 |
-
|
| 103 |
-
y = np.concatenate(ys); p = np.concatenate(preds)
|
| 104 |
-
xyz = np.concatenate(xyz); gid = np.concatenate(gid)
|
| 105 |
-
out = _metric_dict(y, p, xyz, gid)
|
| 106 |
-
if hgb is not None:
|
| 107 |
-
out["hybrid"] = _metric_dict(y, np.concatenate(hyb), xyz, gid)
|
| 108 |
-
return out
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def format_table(results) -> str:
|
| 112 |
-
lines = [f"{'split':<15}{'model':<8}{'MAE':>8}{'RMSE':>8}{'hot-spot':>10}{'std-ratio':>11}"]
|
| 113 |
-
for split, res in results.items():
|
| 114 |
-
rows = [("gnn", res)] + ([("hybrid", res["hybrid"])] if "hybrid" in res else [])
|
| 115 |
-
for label, m in rows:
|
| 116 |
-
lines.append(f"{split:<15}{label:<8}{m['mae']:>8.2f}{m['rmse']:>8.2f}"
|
| 117 |
-
f"{m['hotspot_accuracy']:>10.3f}{m['pred_std_ratio']:>11.3f}")
|
| 118 |
-
return "\n".join(lines)
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def evaluate_all_splits(ckpt, features_dir, splits_path, hgb_path=None, tta=0,
|
| 122 |
-
device=None, out_json=None,
|
| 123 |
-
baseline_features_dir="data/features_v2") -> dict:
|
| 124 |
-
splits = load_splits(splits_path)
|
| 125 |
-
results = {}
|
| 126 |
-
for name in SPLIT_NAMES:
|
| 127 |
-
results[name] = evaluate(ckpt, features_dir, splits[name]["test"], device=device,
|
| 128 |
-
tta=tta, hgb_path=hgb_path,
|
| 129 |
-
baseline_features_dir=baseline_features_dir)
|
| 130 |
-
if out_json:
|
| 131 |
-
with open(out_json, "w") as f:
|
| 132 |
-
json.dump(results, f, indent=2)
|
| 133 |
-
print(format_table(results))
|
| 134 |
-
return results
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
if __name__ == "__main__":
|
| 138 |
-
ap = argparse.ArgumentParser()
|
| 139 |
-
ap.add_argument("--ckpt", required=True)
|
| 140 |
-
ap.add_argument("--features-dir", default="data/features_v2")
|
| 141 |
-
ap.add_argument("--splits", default=None, help="default: <features-dir>/splits.json")
|
| 142 |
-
ap.add_argument("--hgb", default=None)
|
| 143 |
-
ap.add_argument("--baseline-features-dir", default="data/features_v2")
|
| 144 |
-
ap.add_argument("--tta", type=int, default=0)
|
| 145 |
-
ap.add_argument("--out", default=None)
|
| 146 |
-
a = ap.parse_args()
|
| 147 |
-
evaluate_all_splits(a.ckpt, a.features_dir,
|
| 148 |
-
a.splits or osp.join(a.features_dir, "splits.json"),
|
| 149 |
-
hgb_path=a.hgb, tta=a.tta, out_json=a.out,
|
| 150 |
-
baseline_features_dir=a.baseline_features_dir)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/features.py
CHANGED
|
@@ -1,88 +1,33 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
+ facet labels only.
|
| 7 |
"""
|
| 8 |
-
import logging
|
| 9 |
-
|
| 10 |
import numpy as np
|
| 11 |
|
| 12 |
-
from src.geometry import (
|
| 13 |
-
|
| 14 |
-
edges_from_tets, geodesic_distance, local_modulus,
|
| 15 |
-
geodesic_diameter_two_sweep)
|
| 16 |
-
from src.targets import solidification_time, global_time
|
| 17 |
-
|
| 18 |
-
logger = logging.getLogger(__name__)
|
| 19 |
|
| 20 |
FEATURE_NAMES = [
|
| 21 |
"geodesic_d_cool", "d_surf", "d_adiab",
|
| 22 |
"is_cooling", "is_adiabatic", "is_interior",
|
| 23 |
"volume_mm3", "surface_area_mm2", "cooling_area_frac",
|
| 24 |
-
]
|
| 25 |
-
|
| 26 |
-
FEATURE_NAMES_V3 = FEATURE_NAMES + [
|
| 27 |
"geodesic_d_adiab",
|
| 28 |
"local_modulus_r5", "local_modulus_r15", "local_modulus_r40",
|
|
|
|
| 29 |
]
|
| 30 |
LOCAL_MODULUS_RADII = (5.0, 15.0, 40.0)
|
| 31 |
|
| 32 |
-
#
|
| 33 |
-
# baseline + backward-compat contract), with two continuous BC channels
|
| 34 |
-
# APPENDED at the end (replacing is_cooling/is_adiabatic/is_interior as MODEL
|
| 35 |
-
# input signal, though those binary tags stay in the first 9 columns as-is).
|
| 36 |
-
FEATURE_NAMES_V4 = FEATURE_NAMES_V3 + ["boundary_extraction", "exothermic_input"]
|
| 37 |
-
|
| 38 |
-
# v5: v4's 15 columns UNCHANGED, with five multi-scale part-normalized BC
|
| 39 |
-
# influence channels APPENDED -- exo_mid/exo_long extend the v4
|
| 40 |
-
# exothermic_input ("exo_short") reach to 0.15*D_geo/0.40*D_geo, and
|
| 41 |
-
# chill_short/chill_mid/chill_long give chills the same three-scale
|
| 42 |
-
# treatment (chills previously only had the surface-local
|
| 43 |
-
# boundary_extraction). See BC_REACH_MID_FRAC/BC_REACH_LONG_FRAC above.
|
| 44 |
-
FEATURE_NAMES_V5 = FEATURE_NAMES_V4 + [
|
| 45 |
-
"exo_mid", "exo_long", "chill_short", "chill_mid", "chill_long",
|
| 46 |
-
]
|
| 47 |
-
|
| 48 |
-
# Geodesic-diffusion length scale (mm) for the exothermic_input channel: the
|
| 49 |
-
# sleeve's calibrated heat flux q0 decays as exp(-d_geo/lambda) away from the
|
| 50 |
-
# adiab_only (BC-patch) source nodes, instead of being painted only on those
|
| 51 |
-
# 1-2% of nodes as a sparse indicator (see docs/superpowers exo-fix design).
|
| 52 |
-
# This is also reused, unchanged, as the "short" reach scale for the v5
|
| 53 |
-
# chill_short channel below (bc-reach spec: same short-range mesh-scale
|
| 54 |
-
# decay length, not part-size-normalized).
|
| 55 |
-
EXO_DIFFUSION_LAMBDA_MM = 15.0
|
| 56 |
-
|
| 57 |
-
# v5 "mid"/"long" BC-influence reach fractions -- PART-NORMALIZED (of
|
| 58 |
-
# D_geo = the part's GEODESIC DIAMETER: a two-sweep-Dijkstra estimate over
|
| 59 |
-
# the mesh-edge graph -- `geodesic_diameter_two_sweep` -- patch-INDEPENDENT
|
| 60 |
-
# and through-metal), unlike the fixed EXO_DIFFUSION_LAMBDA_MM above.
|
| 61 |
-
# CORRECTION (2026-08-02, Task-1 review round 1, F1/F3): D_geo was
|
| 62 |
-
# originally (wrongly) `max(geodesic_d_cool)` -- that is max WALL DEPTH
|
| 63 |
-
# (~35-94mm on real parts, and BC-PATCH-DEPENDENT since it depends on which
|
| 64 |
-
# facets are tagged Cooling), not part size; it made L_long 6-17x too
|
| 65 |
-
# short (shorter even than the v4 fixed 15mm scale on some rows), inverting
|
| 66 |
-
# the intended short<mid<long ladder. The geodesic diameter is a single
|
| 67 |
-
# per-part constant, identical across every BC row of the same geometry.
|
| 68 |
-
# Motivation (bc-reach investigation): the fixed 15mm short scale decays to
|
| 69 |
-
# ~0 by ~140mm, but a real exo sleeve's influence on a 600mm-class casting
|
| 70 |
-
# extends past 400mm -- normalizing by the part's own geodesic diameter
|
| 71 |
-
# lets the encoding transfer across part sizes instead of a single global
|
| 72 |
-
# mm constant under/over-reaching.
|
| 73 |
-
BC_REACH_MID_FRAC = 0.15
|
| 74 |
-
BC_REACH_LONG_FRAC = 0.40
|
| 75 |
-
|
| 76 |
-
# Global dimensionless alloy+mould vector (broadcast to every node by the
|
| 77 |
-
# cache builder / FT2b; computed here RAW -- not standardized).
|
| 78 |
G_VECTOR_NAMES = ["Ste", "dTf_norm", "alpha_ratio", "superheat_norm", "mold_eff_norm"]
|
| 79 |
-
T_REF = 1000.0 # deg C, normalizes temperature
|
| 80 |
-
ALPHA_REF = 1e-5 # m^2/s,
|
| 81 |
-
MOLD_EFF_REF = 1250.0 # W*s^0.5/(m^2*K),
|
| 82 |
|
| 83 |
|
| 84 |
def alloy_g_vector(props: dict) -> np.ndarray:
|
| 85 |
-
"""
|
| 86 |
|
| 87 |
`props` keys: k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity."""
|
| 88 |
k = float(props["k"]); rho = float(props["rho"]); cp = float(props["cp"])
|
|
@@ -95,119 +40,12 @@ def alloy_g_vector(props: dict) -> np.ndarray:
|
|
| 95 |
superheat_norm = (Tpour - TL) / T_REF
|
| 96 |
mold_eff_norm = b_mold / MOLD_EFF_REF
|
| 97 |
return np.array([Ste, dTf_norm, alpha_ratio, superheat_norm, mold_eff_norm],
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def chvorinov_t_ref(volume_mm3, area_mm2, C_material) -> float:
|
| 102 |
-
"""Chvorinov total-solidification-time estimate: C * (V/A)^2."""
|
| 103 |
-
return float(C_material) * (float(volume_mm3) / float(area_mm2)) ** 2
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def geodesic_diffuse(source_values: np.ndarray, length_mm: float, N: int,
|
| 107 |
-
edges: np.ndarray, edge_len: np.ndarray) -> np.ndarray:
|
| 108 |
-
"""Multi-source geodesic exponential-decay diffusion, factored out of
|
| 109 |
-
the original inline `exothermic_input` construction.
|
| 110 |
-
|
| 111 |
-
For every node: the max over distinct nonzero source magnitudes of
|
| 112 |
-
(magnitude * exp(-geodesic distance to the nearest node carrying that
|
| 113 |
-
magnitude / length_mm)). Distinct magnitudes are grouped and Dijkstra'd
|
| 114 |
-
independently, then combined by elementwise max -- for the single
|
| 115 |
-
uniform-magnitude source group this codebase actually uses (every BC
|
| 116 |
-
source node carries the same scalar peak), this reduces EXACTLY to
|
| 117 |
-
`peak * exp(-d_geo/length_mm)` via one ordinary multi-source Dijkstra
|
| 118 |
-
call, byte-identical to the original `exothermic_input` formula --
|
| 119 |
-
PROVIDED that peak is non-negative. The `out = np.maximum(out, ...)`
|
| 120 |
-
combine is an implicit >= 0 clip: a genuinely negative source magnitude
|
| 121 |
-
would get clamped toward 0 instead of decaying to a negative value (a
|
| 122 |
-
node's own zero-init `out` beats any negative contribution). No caller
|
| 123 |
-
in this codebase produces a negative peak today (`exo_q0/1e6` and
|
| 124 |
-
`(chill_eff-mold_eff)/mold_eff` are both >= 0 in every resolved spec),
|
| 125 |
-
so this is currently unreachable, not a live bug -- documented so a
|
| 126 |
-
future negative-magnitude source doesn't rediscover it the hard way.
|
| 127 |
-
|
| 128 |
-
Returns all-zeros WITHOUT invoking the Dijkstra solver when there are
|
| 129 |
-
no nonzero sources or `length_mm <= 0` (cheap all-sand / degenerate
|
| 130 |
-
path -- `np.exp(-inf) == 0.0` would otherwise handle disconnected mesh
|
| 131 |
-
components fine, but a zero/negative length is not physically
|
| 132 |
-
meaningful and must not divide-by-zero).
|
| 133 |
-
"""
|
| 134 |
-
source_values = np.asarray(source_values, dtype=np.float64)
|
| 135 |
-
src_idx = np.flatnonzero(source_values != 0.0)
|
| 136 |
-
if src_idx.size == 0 or length_mm <= 0:
|
| 137 |
-
return np.zeros(N, dtype=np.float32)
|
| 138 |
-
out = np.zeros(N, dtype=np.float64)
|
| 139 |
-
for val in np.unique(source_values[src_idx]):
|
| 140 |
-
grp = src_idx[source_values[src_idx] == val]
|
| 141 |
-
d = geodesic_distance(N, edges, edge_len, grp)
|
| 142 |
-
# np.exp(-inf) == 0.0 handles disconnected components
|
| 143 |
-
out = np.maximum(out, val * np.exp(-d / length_mm))
|
| 144 |
-
return out.astype(np.float32)
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
def compute_bc_reach_channels(N: int, edges: np.ndarray, edge_len: np.ndarray,
|
| 148 |
-
adiab_only: np.ndarray, bc_config: str,
|
| 149 |
-
mold_eff: float, chill_eff: float,
|
| 150 |
-
exo_q0: float) -> np.ndarray:
|
| 151 |
-
"""The five v5 multi-scale part-normalized BC-influence channels:
|
| 152 |
-
`[exo_mid, exo_long, chill_short, chill_mid, chill_long]`, shape (N, 5)
|
| 153 |
-
float32.
|
| 154 |
|
| 155 |
-
`D_geo` (the part-normalizing length) is the part's GEODESIC DIAMETER --
|
| 156 |
-
`geodesic_diameter_two_sweep(N, edges, edge_len, start=0)`, a two-sweep
|
| 157 |
-
Dijkstra estimate over the SAME mesh-edge graph the other geodesic
|
| 158 |
-
features use. This is patch-INDEPENDENT (unlike `geodesic_d_cool`,
|
| 159 |
-
whose max is wall DEPTH, not part size -- see the CORRECTION note above
|
| 160 |
-
`BC_REACH_MID_FRAC`) and identical across every BC row of the same
|
| 161 |
-
geometry. exo_mid/exo_long reuse the exo_sleeve's `adiab_only` source
|
| 162 |
-
patch and peak (`exo_q0/1e6`, matching `exothermic_input`'s "exo_short"
|
| 163 |
-
scale) at `BC_REACH_MID_FRAC`/`BC_REACH_LONG_FRAC` * D_geo.
|
| 164 |
-
chill_short/chill_mid/chill_long give the chills bc_config the same
|
| 165 |
-
three-scale treatment, sourced at the chill contact nodes (`adiab_only`)
|
| 166 |
-
with peak magnitude `(chill_eff - mold_eff) / mold_eff` -- chill_short
|
| 167 |
-
reuses the fixed `EXO_DIFFUSION_LAMBDA_MM` short scale, chill_mid/
|
| 168 |
-
chill_long use the same part-normalized fractions as the exo channels.
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
solver call, same contract as `exothermic_input`/`geodesic_diffuse`. A
|
| 174 |
-
degenerate/disconnected mesh (D_geo <= 0) logs a warning and leaves the
|
| 175 |
-
mid/long channels at zero (short-scale channels are unaffected, since
|
| 176 |
-
they don't depend on D_geo).
|
| 177 |
-
"""
|
| 178 |
-
out = np.zeros((N, 5), dtype=np.float32)
|
| 179 |
-
if bc_config not in ("exo_sleeve", "chills"):
|
| 180 |
-
return out
|
| 181 |
-
|
| 182 |
-
d_geo = geodesic_diameter_two_sweep(N, edges, edge_len, start=0)
|
| 183 |
-
if d_geo <= 0:
|
| 184 |
-
logger.warning(
|
| 185 |
-
"compute_bc_reach_channels: D_geo (geodesic diameter) <= 0 for "
|
| 186 |
-
"bc_config=%r -- degenerate/disconnected mesh, zeroing the "
|
| 187 |
-
"part-normalized mid/long BC-reach channels", bc_config)
|
| 188 |
-
lambda_mid = BC_REACH_MID_FRAC * d_geo
|
| 189 |
-
lambda_long = BC_REACH_LONG_FRAC * d_geo
|
| 190 |
-
|
| 191 |
-
if bc_config == "exo_sleeve":
|
| 192 |
-
src = np.zeros(N, dtype=np.float64)
|
| 193 |
-
src[adiab_only] = exo_q0 / 1e6
|
| 194 |
-
out[:, 0] = geodesic_diffuse(src, lambda_mid, N, edges, edge_len)
|
| 195 |
-
out[:, 1] = geodesic_diffuse(src, lambda_long, N, edges, edge_len)
|
| 196 |
-
|
| 197 |
-
if bc_config == "chills":
|
| 198 |
-
chill_val = (chill_eff - mold_eff) / mold_eff if mold_eff != 0 else 0.0
|
| 199 |
-
src = np.zeros(N, dtype=np.float64)
|
| 200 |
-
src[adiab_only] = chill_val
|
| 201 |
-
out[:, 2] = geodesic_diffuse(src, EXO_DIFFUSION_LAMBDA_MM, N, edges, edge_len)
|
| 202 |
-
out[:, 3] = geodesic_diffuse(src, lambda_mid, N, edges, edge_len)
|
| 203 |
-
out[:, 4] = geodesic_diffuse(src, lambda_long, N, edges, edge_len)
|
| 204 |
-
|
| 205 |
-
return out
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
def _node_features(mesh, version=2, spec=None):
|
| 209 |
-
"""Compute the (N,9/13/15/20) feature matrix (v2/v3/v4/v5) + pos (mm) +
|
| 210 |
-
mesh edges for a mesh. No target."""
|
| 211 |
xyz = mesh.node_xyz
|
| 212 |
N = xyz.shape[0]
|
| 213 |
cool_tri = mesh.tris.get("Cooling", np.empty((0, 3), np.int64))
|
|
@@ -240,6 +78,18 @@ def _node_features(mesh, version=2, spec=None):
|
|
| 240 |
cool_frac = area_cool / area_total if area_total > 0 else 0.0
|
| 241 |
volume = mesh_volume(xyz, mesh.tets)
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
cols = {
|
| 244 |
"geodesic_d_cool": g_cool, "d_surf": d_surf, "d_adiab": d_adiab,
|
| 245 |
"is_cooling": cool_mask.astype(np.float32),
|
|
@@ -248,87 +98,12 @@ def _node_features(mesh, version=2, spec=None):
|
|
| 248 |
"volume_mm3": np.full(N, volume),
|
| 249 |
"surface_area_mm2": np.full(N, area_total),
|
| 250 |
"cooling_area_frac": np.full(N, cool_frac),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
}
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
g_adiab = np.where(np.isinf(g_adiab), d_adiab, g_adiab)
|
| 255 |
-
lm = local_modulus(xyz, mesh.tets, cool_tri, edges, edge_len, LOCAL_MODULUS_RADII)
|
| 256 |
-
cols.update({
|
| 257 |
-
"geodesic_d_adiab": g_adiab,
|
| 258 |
-
"local_modulus_r5": lm[:, 0],
|
| 259 |
-
"local_modulus_r15": lm[:, 1],
|
| 260 |
-
"local_modulus_r40": lm[:, 2],
|
| 261 |
-
})
|
| 262 |
-
if version >= 4:
|
| 263 |
-
if spec is None:
|
| 264 |
-
raise ValueError("_node_features(version=4) requires a `spec` dict "
|
| 265 |
-
"(BC info: mold_effusivity, bc_config, "
|
| 266 |
-
"chill_effusivity, sleeve_MEF, exo_q0)")
|
| 267 |
-
mold_eff = float(spec["mold_effusivity"])
|
| 268 |
-
bc_config = spec["bc_config"]
|
| 269 |
-
chill_eff = float(spec.get("chill_effusivity", 0.0))
|
| 270 |
-
sleeve_mef = float(spec.get("sleeve_MEF", 0.0))
|
| 271 |
-
exo_q0 = float(spec.get("exo_q0", 0.0))
|
| 272 |
-
|
| 273 |
-
# Mirrors tools/doe/fenicsx_solver.py's tag convention: tag "Cooling"
|
| 274 |
-
# is always the ordinary sand-mold Robin term; tag "Adiabatic" is the
|
| 275 |
-
# BC-patch footprint, reinterpreted per bc_config. Nodes shared
|
| 276 |
-
# between the two triangle groups follow the same cooling-takes-
|
| 277 |
-
# priority convention as `is_adiabatic` (adiab_only).
|
| 278 |
-
boundary_extraction = np.zeros(N, dtype=np.float32)
|
| 279 |
-
boundary_extraction[cool_mask] = mold_eff
|
| 280 |
-
if bc_config == "chills":
|
| 281 |
-
adiab_val = chill_eff
|
| 282 |
-
elif bc_config in ("insulating_sleeve", "exo_sleeve"):
|
| 283 |
-
adiab_val = mold_eff / max(sleeve_mef ** 2, 1.0)
|
| 284 |
-
else: # "none" -- BC patch treated as ordinary mold (matches solver)
|
| 285 |
-
adiab_val = mold_eff
|
| 286 |
-
boundary_extraction[adiab_only] = adiab_val
|
| 287 |
-
|
| 288 |
-
exothermic_input = np.zeros(N, dtype=np.float32)
|
| 289 |
-
if bc_config == "exo_sleeve":
|
| 290 |
-
exo_src_vals = np.zeros(N, dtype=np.float64)
|
| 291 |
-
exo_src_vals[adiab_only] = exo_q0 / 1e6
|
| 292 |
-
exothermic_input = geodesic_diffuse(exo_src_vals, EXO_DIFFUSION_LAMBDA_MM,
|
| 293 |
-
N, edges, edge_len)
|
| 294 |
-
|
| 295 |
-
cols.update({
|
| 296 |
-
"boundary_extraction": boundary_extraction,
|
| 297 |
-
"exothermic_input": exothermic_input,
|
| 298 |
-
})
|
| 299 |
-
|
| 300 |
-
if version >= 5:
|
| 301 |
-
bc_reach = compute_bc_reach_channels(
|
| 302 |
-
N, edges, edge_len, adiab_only, bc_config,
|
| 303 |
-
mold_eff, chill_eff, exo_q0)
|
| 304 |
-
cols.update({
|
| 305 |
-
"exo_mid": bc_reach[:, 0], "exo_long": bc_reach[:, 1],
|
| 306 |
-
"chill_short": bc_reach[:, 2], "chill_mid": bc_reach[:, 3],
|
| 307 |
-
"chill_long": bc_reach[:, 4],
|
| 308 |
-
})
|
| 309 |
-
|
| 310 |
-
names = (FEATURE_NAMES_V5 if version >= 5 else
|
| 311 |
-
FEATURE_NAMES_V4 if version >= 4 else
|
| 312 |
-
FEATURE_NAMES_V3 if version >= 3 else FEATURE_NAMES)
|
| 313 |
-
X = np.stack([cols[n] for n in names], axis=1).astype(np.float32)
|
| 314 |
-
return X, xyz.astype(np.float32), edges.astype(np.int64)
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
def compute_features_inference(mesh, version=2, spec=None) -> dict:
|
| 318 |
-
X, pos, edges = _node_features(mesh, version, spec=spec)
|
| 319 |
-
return {"X": X, "pos": pos, "mesh_edges": edges}
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
def compute_features(mesh, temps, times, version=2, spec=None) -> dict:
|
| 323 |
-
X, pos, edges = _node_features(mesh, version, spec=spec)
|
| 324 |
-
y = solidification_time(temps, times)
|
| 325 |
-
out = {"X": X, "y": y.astype(np.float32), "y_global": global_time(y),
|
| 326 |
-
"pos": pos, "mesh_edges": edges}
|
| 327 |
-
if version >= 4:
|
| 328 |
-
g = alloy_g_vector(spec)
|
| 329 |
-
volume = float(X[0, FEATURE_NAMES.index("volume_mm3")])
|
| 330 |
-
area = float(X[0, FEATURE_NAMES.index("surface_area_mm2")])
|
| 331 |
-
t_ref = chvorinov_t_ref(volume, area, spec["C_material"])
|
| 332 |
-
y_nondim = y.astype(np.float64) / max(t_ref, 1e-9)
|
| 333 |
-
out.update({"g": g, "t_ref": t_ref, "y_nondim": y_nondim.astype(np.float32)})
|
| 334 |
-
return out
|
|
|
|
| 1 |
+
"""Node features and the alloy/mold conditioning vector.
|
| 2 |
|
| 3 |
+
Every feature comes from the mesh and its surface tags ("Cooling", optionally
|
| 4 |
+
"Adiabatic"), in millimetres. Nothing depends on node numbering, file names or
|
| 5 |
+
absolute position.
|
|
|
|
| 6 |
"""
|
|
|
|
|
|
|
| 7 |
import numpy as np
|
| 8 |
|
| 9 |
+
from src.geometry import (total_area, mesh_volume, nearest_distance, boundary_node_mask,
|
| 10 |
+
edges_from_tets, geodesic_distance, local_modulus)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
FEATURE_NAMES = [
|
| 13 |
"geodesic_d_cool", "d_surf", "d_adiab",
|
| 14 |
"is_cooling", "is_adiabatic", "is_interior",
|
| 15 |
"volume_mm3", "surface_area_mm2", "cooling_area_frac",
|
|
|
|
|
|
|
|
|
|
| 16 |
"geodesic_d_adiab",
|
| 17 |
"local_modulus_r5", "local_modulus_r15", "local_modulus_r40",
|
| 18 |
+
"boundary_extraction", "boundary_heat_input",
|
| 19 |
]
|
| 20 |
LOCAL_MODULUS_RADII = (5.0, 15.0, 40.0)
|
| 21 |
|
| 22 |
+
# Alloy/mold conditioning vector (raw, not standardized).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
G_VECTOR_NAMES = ["Ste", "dTf_norm", "alpha_ratio", "superheat_norm", "mold_eff_norm"]
|
| 24 |
+
T_REF = 1000.0 # deg C, normalizes temperature differences
|
| 25 |
+
ALPHA_REF = 1e-5 # m^2/s, typical alloy thermal diffusivity
|
| 26 |
+
MOLD_EFF_REF = 1250.0 # W*s^0.5/(m^2*K), reference mold effusivity
|
| 27 |
|
| 28 |
|
| 29 |
def alloy_g_vector(props: dict) -> np.ndarray:
|
| 30 |
+
"""Five dimensionless numbers describing alloy and mold.
|
| 31 |
|
| 32 |
`props` keys: k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity."""
|
| 33 |
k = float(props["k"]); rho = float(props["rho"]); cp = float(props["cp"])
|
|
|
|
| 40 |
superheat_norm = (Tpour - TL) / T_REF
|
| 41 |
mold_eff_norm = b_mold / MOLD_EFF_REF
|
| 42 |
return np.array([Ste, dTf_norm, alpha_ratio, superheat_norm, mold_eff_norm],
|
| 43 |
+
dtype=np.float64)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
def node_features(mesh, mold_effusivity: float) -> dict:
|
| 47 |
+
"""The (N, 15) float32 feature matrix `X`, node positions `pos` (mm) and
|
| 48 |
+
the unique tetrahedral mesh edges `mesh_edges` for one casting."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
xyz = mesh.node_xyz
|
| 50 |
N = xyz.shape[0]
|
| 51 |
cool_tri = mesh.tris.get("Cooling", np.empty((0, 3), np.int64))
|
|
|
|
| 78 |
cool_frac = area_cool / area_total if area_total > 0 else 0.0
|
| 79 |
volume = mesh_volume(xyz, mesh.tets)
|
| 80 |
|
| 81 |
+
g_adiab = geodesic_distance(N, edges, edge_len, np.flatnonzero(adiab_mask))
|
| 82 |
+
g_adiab = np.where(np.isinf(g_adiab), d_adiab, g_adiab)
|
| 83 |
+
lm = local_modulus(xyz, mesh.tets, cool_tri, edges, edge_len, LOCAL_MODULUS_RADII)
|
| 84 |
+
|
| 85 |
+
# Boundary channels. Surface nodes carry the mold effusivity (how fast the
|
| 86 |
+
# mold draws heat); interior nodes carry 0. The heat-input channel is for
|
| 87 |
+
# heat sources on the boundary, and a plain sand-mold surface has none.
|
| 88 |
+
boundary_extraction = np.zeros(N, dtype=np.float32)
|
| 89 |
+
boundary_extraction[cool_mask] = mold_effusivity
|
| 90 |
+
boundary_extraction[adiab_only] = mold_effusivity
|
| 91 |
+
boundary_heat_input = np.zeros(N, dtype=np.float32)
|
| 92 |
+
|
| 93 |
cols = {
|
| 94 |
"geodesic_d_cool": g_cool, "d_surf": d_surf, "d_adiab": d_adiab,
|
| 95 |
"is_cooling": cool_mask.astype(np.float32),
|
|
|
|
| 98 |
"volume_mm3": np.full(N, volume),
|
| 99 |
"surface_area_mm2": np.full(N, area_total),
|
| 100 |
"cooling_area_frac": np.full(N, cool_frac),
|
| 101 |
+
"geodesic_d_adiab": g_adiab,
|
| 102 |
+
"local_modulus_r5": lm[:, 0],
|
| 103 |
+
"local_modulus_r15": lm[:, 1],
|
| 104 |
+
"local_modulus_r40": lm[:, 2],
|
| 105 |
+
"boundary_extraction": boundary_extraction,
|
| 106 |
+
"boundary_heat_input": boundary_heat_input,
|
| 107 |
}
|
| 108 |
+
X = np.stack([cols[n] for n in FEATURE_NAMES], axis=1).astype(np.float32)
|
| 109 |
+
return {"X": X, "pos": xyz.astype(np.float32), "mesh_edges": edges.astype(np.int64)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/gbm_ft2b.py
DELETED
|
@@ -1,598 +0,0 @@
|
|
| 1 |
-
"""FT2b Task 6: multi-alloy GBM scale-head refit.
|
| 2 |
-
|
| 3 |
-
The production predictor is a HYBRID: GNN field *shape* x a per-graph
|
| 4 |
-
*scale* from a HistGradientBoostingRegressor (see `src/hybrid.py`,
|
| 5 |
-
`runs/baseline_v2/hgb_A.joblib`). That GBM was trained on `src/features.py`
|
| 6 |
-
v2 physics features from the SINGLE-alloy dataset and knows nothing about
|
| 7 |
-
material properties, so it cannot generalize its scale prediction across
|
| 8 |
-
alloys/mold systems.
|
| 9 |
-
|
| 10 |
-
This module refits a NEW scale-head GBM directly on raw material properties
|
| 11 |
-
+ geometry scale + mold-system effusivity -- exactly the quantities that
|
| 12 |
-
determine Chvorinov-style total solidification time -- resolved per training
|
| 13 |
-
run_id from the FT2a/FT2b DOE manifest via the SAME resolution path used to
|
| 14 |
-
build the v4 feature cache (`src.build_dataset._resolve_material_props_v4`).
|
| 15 |
-
|
| 16 |
-
Feature row layout (see FEATURE_NAMES): [k, rho, cp, L_kJ_kg, TL, TS, Tpour,
|
| 17 |
-
V_over_A, mold_effusivity] (9 material/geometry columns) + [bc_none, bc_chills,
|
| 18 |
-
bc_insulating_sleeve, bc_exo_sleeve] (4 BC one-hot from manifest.bc_config) +
|
| 19 |
-
[n_chills, sleeve_MEF, exo_q0_MW] (3 BC scalar fields) = 16 columns. `Tpour` IS a
|
| 20 |
-
per-material property in this project's DB (`tools.doe.load_properties.load_materials`
|
| 21 |
-
midpoints Tpour_lo_C/Tpour_hi_C for the 12 named anchor grades; the LHS-sampled
|
| 22 |
-
synthetic alloys carry their own `Tpour_C` in `materials_synthetic.csv`) -- so it is
|
| 23 |
-
resolved per material_id, not hardcoded as a constant.
|
| 24 |
-
|
| 25 |
-
Target: per-graph `log1p(y_raw.max())` -- the total solidification time
|
| 26 |
-
(scale), read straight from the v4 graph cache (`y_raw`, physical seconds).
|
| 27 |
-
"""
|
| 28 |
-
from __future__ import annotations
|
| 29 |
-
|
| 30 |
-
import csv
|
| 31 |
-
import os
|
| 32 |
-
import os.path as osp
|
| 33 |
-
|
| 34 |
-
import joblib
|
| 35 |
-
import numpy as np
|
| 36 |
-
from sklearn.ensemble import HistGradientBoostingRegressor
|
| 37 |
-
from sklearn.model_selection import train_test_split
|
| 38 |
-
|
| 39 |
-
from src.build_dataset import (FEATURE_NAMES_V4, _resolve_exo_q0_v4,
|
| 40 |
-
_resolve_material_props_v4)
|
| 41 |
-
from src.features import FEATURE_NAMES as _NODE_FEATURE_NAMES
|
| 42 |
-
|
| 43 |
-
# volume_mm3/surface_area_mm2 are constant-per-node columns of the v2/v3/v4
|
| 44 |
-
# node-feature matrix X (see src/features.py FEATURE_NAMES and the v4
|
| 45 |
-
# comment there: "v3's 13 columns UNCHANGED (X[:, :9] == v2, X[:, :13] ==
|
| 46 |
-
# v3)" -- so these indices are valid for the v4 cache too). Verified live
|
| 47 |
-
# against src/build_dataset.py:622-623, which reads the SAME two indices
|
| 48 |
-
# out of the SAME FEATURE_NAMES list to get volume/area for the Chvorinov
|
| 49 |
-
# t_ref fit:
|
| 50 |
-
# volume = float(X[0, FEATURE_NAMES.index("volume_mm3")])
|
| 51 |
-
# area = float(X[0, FEATURE_NAMES.index("surface_area_mm2")])
|
| 52 |
-
_VOL_IDX = _NODE_FEATURE_NAMES.index("volume_mm3")
|
| 53 |
-
_AREA_IDX = _NODE_FEATURE_NAMES.index("surface_area_mm2")
|
| 54 |
-
|
| 55 |
-
# bc_area_frac (soft-anchor Task 2, std head v2 17th feature) column indices --
|
| 56 |
-
# resolved off `src.build_dataset.FEATURE_NAMES_V4` (never hardcoded silently)
|
| 57 |
-
# with a cross-check assertion against the brief's stated indices
|
| 58 |
-
# (is_cooling=3, is_adiabatic=4): if FEATURE_NAMES_V4 ever changes upstream,
|
| 59 |
-
# this fails loudly instead of silently reading the wrong columns.
|
| 60 |
-
_IS_COOLING_IDX = FEATURE_NAMES_V4.index("is_cooling")
|
| 61 |
-
_IS_ADIABATIC_IDX = FEATURE_NAMES_V4.index("is_adiabatic")
|
| 62 |
-
assert (_IS_COOLING_IDX, _IS_ADIABATIC_IDX) == (3, 4), (
|
| 63 |
-
f"bc_area_frac column indices drifted from the Global Constraints formula's "
|
| 64 |
-
f"is_cooling=3/is_adiabatic=4 (FEATURE_NAMES_V4 now gives "
|
| 65 |
-
f"is_cooling={_IS_COOLING_IDX}, is_adiabatic={_IS_ADIABATIC_IDX}) -- "
|
| 66 |
-
f"FEATURE_NAMES_V4 changed upstream; update this module before trusting "
|
| 67 |
-
f"bc_area_frac_from_graph")
|
| 68 |
-
|
| 69 |
-
# GBM row feature layout -- exposed for the Task 8 evaluator so it can
|
| 70 |
-
# rebuild rows the exact same way at inference time.
|
| 71 |
-
#
|
| 72 |
-
# BC-aware extension (exo-fix Task 3): the original 9 material/geometry
|
| 73 |
-
# columns are blind to boundary conditions (chills/sleeves change total
|
| 74 |
-
# solidification time independently of material/geometry), so the scale
|
| 75 |
-
# head could not tell a chilled casting from a plain sand one. The 7 new
|
| 76 |
-
# columns make it BC-aware: a one-hot over the manifest's `bc_config`
|
| 77 |
-
# values (`none`/`chills`/`insulating_sleeve`/`exo_sleeve` -- the only
|
| 78 |
-
# values `tools/doe/doe.py` ever writes), plus the raw `n_chills` and
|
| 79 |
-
# `sleeve_MEF` manifest fields, plus the calibrated exothermic-sleeve heat
|
| 80 |
-
# release `exo_q0_MW` (0.0 for non-exo rows, by `_resolve_exo_q0_v4`'s own
|
| 81 |
-
# contract).
|
| 82 |
-
FEATURE_NAMES = [
|
| 83 |
-
"k", "rho", "cp", "L_kJ_kg", "TL", "TS", "Tpour",
|
| 84 |
-
"V_over_A", "mold_effusivity",
|
| 85 |
-
"bc_none", "bc_chills", "bc_insulating_sleeve", "bc_exo_sleeve",
|
| 86 |
-
"n_chills", "sleeve_MEF", "exo_q0_MW",
|
| 87 |
-
]
|
| 88 |
-
|
| 89 |
-
# The only bc_config values `tools/doe/doe.py` ever writes to the manifest
|
| 90 |
-
# (verified live against doe_out/manifest.csv: Counter gives exactly these
|
| 91 |
-
# 4 keys). An empty string means "no BC row was ever assigned" and is
|
| 92 |
-
# treated as "none" (mirrors `_resolve_exo_q0_v4`'s own
|
| 93 |
-
# `row.get("bc_config") or "none"` convention). Anything else is an
|
| 94 |
-
# unrecognized/typo'd value -- raise loudly rather than silently ignoring it
|
| 95 |
-
# in the one-hot.
|
| 96 |
-
_BC_VALUES = ("none", "chills", "insulating_sleeve", "exo_sleeve")
|
| 97 |
-
|
| 98 |
-
_HGB_DEFAULTS = dict(learning_rate=0.1, max_leaf_nodes=63,
|
| 99 |
-
l2_regularization=1.0, random_state=0)
|
| 100 |
-
|
| 101 |
-
# HGB's own early_stopping carves off validation_fraction (default 0.1) of
|
| 102 |
-
# whatever it's given -- on tiny row counts (e.g. a ~20-stem unit test) that
|
| 103 |
-
# leaves 1-2 validation rows, which stops training almost immediately and
|
| 104 |
-
# badly underfits. Only turn early stopping on once there's enough data for
|
| 105 |
-
# it to be a meaningful signal; production refits (thousands of stems) still
|
| 106 |
-
# get it.
|
| 107 |
-
_MIN_ROWS_FOR_EARLY_STOPPING = 50
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
def _load_manifest_rows(manifest_path: str) -> dict:
|
| 111 |
-
rows = {}
|
| 112 |
-
with open(manifest_path, newline="") as f:
|
| 113 |
-
for row in csv.DictReader(f):
|
| 114 |
-
rows[row["run_id"]] = row
|
| 115 |
-
return rows
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def _num_or_zero(raw, field: str, stem: str) -> float:
|
| 119 |
-
"""float(raw), except '' / None -> 0.0 (the brief's explicit rule for
|
| 120 |
-
n_chills/sleeve_MEF, mirroring how the manifest encodes N/A for rows
|
| 121 |
-
with no chill/sleeve BC). A present-but-non-numeric value still raises
|
| 122 |
-
loudly (e.g. a typo) -- only blank is treated as "not applicable"."""
|
| 123 |
-
if raw is None or str(raw).strip() == "":
|
| 124 |
-
return 0.0
|
| 125 |
-
try:
|
| 126 |
-
return float(raw)
|
| 127 |
-
except (TypeError, ValueError) as e:
|
| 128 |
-
raise ValueError(f"run_id={stem!r}: {field}={raw!r} is not a number") from e
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
def _row_features_and_graph(stem: str, manifest_rows: dict, manifest_dir: str,
|
| 132 |
-
features_dir: str) -> tuple[np.ndarray, "np.lib.npyio.NpzFile"]:
|
| 133 |
-
"""Shared row-assembly: one BC-aware feature row (see `FEATURE_NAMES`) +
|
| 134 |
-
the loaded graph-cache npz. Factored out of `_row_features` so the
|
| 135 |
-
std-target head (`_row_features_std`) reuses the IDENTICAL X (same
|
| 136 |
-
material/BC resolution, same I/O) and swaps only the target it derives
|
| 137 |
-
from the already-open graph -- never re-deriving or duplicating the
|
| 138 |
-
resolution logic.
|
| 139 |
-
|
| 140 |
-
Loud-fails (raises) on anything unresolvable -- a missing manifest row,
|
| 141 |
-
a missing/unresolvable material_id, a missing mold_effusivity, an
|
| 142 |
-
unrecognized bc_config value, an uncalibrated exo_sleeve row, or a
|
| 143 |
-
missing graph-cache file -- NEVER silently drops or fabricates a value.
|
| 144 |
-
The one EXCEPTION (per the brief) is n_chills/sleeve_MEF: '' -> 0.0,
|
| 145 |
-
mirroring how the manifest encodes "no chill/sleeve BC" for this row.
|
| 146 |
-
"""
|
| 147 |
-
row = manifest_rows.get(stem)
|
| 148 |
-
if row is None:
|
| 149 |
-
raise KeyError(f"stem {stem!r} not found in manifest ({manifest_dir!r})")
|
| 150 |
-
|
| 151 |
-
material_id = (row.get("material_id") or "").strip()
|
| 152 |
-
if not material_id:
|
| 153 |
-
raise KeyError(f"run_id={stem!r}: manifest row has no material_id")
|
| 154 |
-
# Raises KeyError if unresolvable via any of the 3 fallback sources
|
| 155 |
-
# (owner-verified CSV / materials_synthetic.csv / named anchor grades) --
|
| 156 |
-
# see src.build_dataset._resolve_material_props_v4's own docstring.
|
| 157 |
-
props = _resolve_material_props_v4(material_id, manifest_dir)
|
| 158 |
-
|
| 159 |
-
mold_eff_raw = row.get("mold_effusivity")
|
| 160 |
-
if mold_eff_raw is None or str(mold_eff_raw).strip() == "":
|
| 161 |
-
raise KeyError(f"run_id={stem!r}: manifest row has no mold_effusivity")
|
| 162 |
-
mold_effusivity = float(mold_eff_raw)
|
| 163 |
-
|
| 164 |
-
bc_config = (row.get("bc_config") or "").strip() or "none"
|
| 165 |
-
if bc_config not in _BC_VALUES:
|
| 166 |
-
raise ValueError(
|
| 167 |
-
f"run_id={stem!r}: unrecognized bc_config={bc_config!r} "
|
| 168 |
-
f"(expected one of {_BC_VALUES})")
|
| 169 |
-
one_hot = [1.0 if bc_config == v else 0.0 for v in _BC_VALUES]
|
| 170 |
-
|
| 171 |
-
n_chills = _num_or_zero(row.get("n_chills"), "n_chills", stem)
|
| 172 |
-
sleeve_MEF = _num_or_zero(row.get("sleeve_MEF"), "sleeve_MEF", stem)
|
| 173 |
-
|
| 174 |
-
# 0.0 for non-exo rows by its own contract; raises loudly for
|
| 175 |
-
# bc_config=="exo_sleeve" rows with no calibrated MEF match -- see
|
| 176 |
-
# src.build_dataset._resolve_exo_q0_v4's docstring.
|
| 177 |
-
exo_q0_MW = _resolve_exo_q0_v4(row) / 1e6
|
| 178 |
-
|
| 179 |
-
gpath = osp.join(features_dir, "graphs", f"{stem}.npz")
|
| 180 |
-
if not osp.exists(gpath):
|
| 181 |
-
raise FileNotFoundError(f"graph cache missing for stem={stem!r}: {gpath}")
|
| 182 |
-
d = np.load(gpath)
|
| 183 |
-
volume = float(d["X"][0, _VOL_IDX])
|
| 184 |
-
area = float(d["X"][0, _AREA_IDX])
|
| 185 |
-
if area <= 0:
|
| 186 |
-
raise ValueError(f"run_id={stem!r}: non-positive surface_area_mm2={area}")
|
| 187 |
-
v_over_a = volume / area
|
| 188 |
-
|
| 189 |
-
x = np.array([
|
| 190 |
-
props["k"], props["rho"], props["cp"], props["L_kJ_kg"],
|
| 191 |
-
props["TL"], props["TS"], props["Tpour"],
|
| 192 |
-
v_over_a, mold_effusivity,
|
| 193 |
-
*one_hot,
|
| 194 |
-
n_chills, sleeve_MEF, exo_q0_MW,
|
| 195 |
-
], dtype=np.float64)
|
| 196 |
-
return x, d
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
def _row_features(stem: str, manifest_rows: dict, manifest_dir: str,
|
| 200 |
-
features_dir: str) -> tuple[np.ndarray, float]:
|
| 201 |
-
"""One BC-aware feature row (see `FEATURE_NAMES`) + log1p(y_global).
|
| 202 |
-
|
| 203 |
-
See `_row_features_and_graph` for the loud-fail contract this shares.
|
| 204 |
-
"""
|
| 205 |
-
x, d = _row_features_and_graph(stem, manifest_rows, manifest_dir, features_dir)
|
| 206 |
-
y_raw = d["y_raw"] if "y_raw" in d.files else d["y"]
|
| 207 |
-
y_log = float(np.log1p(float(np.max(y_raw))))
|
| 208 |
-
return x, y_log
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
def _row_features_std(stem: str, manifest_rows: dict, manifest_dir: str,
|
| 212 |
-
features_dir: str) -> tuple[np.ndarray, float]:
|
| 213 |
-
"""The SAME BC-aware feature row as `_row_features` (see
|
| 214 |
-
`_row_features_and_graph`) + log1p(std(y_nodal)) -- the per-graph
|
| 215 |
-
field-spread target for the GBM std head (variance-recomposition Task 2).
|
| 216 |
-
|
| 217 |
-
`y_nodal` is the graph npz's `y` field (per-node solidification time,
|
| 218 |
-
seconds) -- raises loudly (KeyError) if a requested stem's graph cache
|
| 219 |
-
has no `y`, never silently skips it.
|
| 220 |
-
"""
|
| 221 |
-
x, d = _row_features_and_graph(stem, manifest_rows, manifest_dir, features_dir)
|
| 222 |
-
if "y" not in d.files:
|
| 223 |
-
raise KeyError(f"run_id={stem!r}: graph cache in {features_dir!r} has no 'y' field")
|
| 224 |
-
y_std_log = float(np.log1p(float(np.std(d["y"]))))
|
| 225 |
-
return x, y_std_log
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
# std head v2 (soft-anchor Task 2): the v1 row (`FEATURE_NAMES`, 16 columns)
|
| 229 |
-
# + one graph-derived 17th column, `bc_area_frac`. Kept as its own module
|
| 230 |
-
# constant (rather than mutating `FEATURE_NAMES`) so the 16-feature total
|
| 231 |
-
# head stays UNTOUCHED -- see `std_feature_row`'s width-dispatch contract.
|
| 232 |
-
FEATURE_NAMES_STD_V2 = FEATURE_NAMES + ["bc_area_frac"]
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
def bc_area_frac_from_graph(graph) -> float:
|
| 236 |
-
"""The std-head-v2 17th feature (Global Constraints formula, soft-anchor
|
| 237 |
-
spec Sec 2): the fraction of BC-tagged surface nodes that are adiabatic
|
| 238 |
-
(vs. cooling) -- `sum(is_adiabatic) / max(sum(is_adiabatic) +
|
| 239 |
-
sum(is_cooling), 1)`, read straight off the graph's node feature matrix
|
| 240 |
-
`graph["X"]` at `_IS_ADIABATIC_IDX`/`_IS_COOLING_IDX` (columns 4/3 of
|
| 241 |
-
`FEATURE_NAMES_V4`, cross-checked at import time above -- never
|
| 242 |
-
hardcoded silently a second time here).
|
| 243 |
-
|
| 244 |
-
`graph` is whatever `_row_features_and_graph` returns as its second
|
| 245 |
-
element (an open graph-cache npz) -- or, for tests, any object
|
| 246 |
-
supporting `graph["X"]` indexing (e.g. a plain dict).
|
| 247 |
-
|
| 248 |
-
A node-count proxy for the true tri-area fraction (no per-node area
|
| 249 |
-
weight is available in `X`), acceptable per the spec. All-sand rows
|
| 250 |
-
(no adiabatic surface at all, e.g. the fully-cooled regime) -> exactly
|
| 251 |
-
0.0, since the numerator is 0 regardless of the denominator; a graph
|
| 252 |
-
with neither cooling nor adiabatic nodes tagged also -> 0.0 (the `max(
|
| 253 |
-
..., 1)` floor avoids a 0/0 division).
|
| 254 |
-
"""
|
| 255 |
-
X = graph["X"]
|
| 256 |
-
n_adiabatic = float(np.sum(X[:, _IS_ADIABATIC_IDX]))
|
| 257 |
-
n_cooling = float(np.sum(X[:, _IS_COOLING_IDX]))
|
| 258 |
-
return n_adiabatic / max(n_adiabatic + n_cooling, 1.0)
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
def std_feature_row(stem: str, manifest_rows: dict, manifest_dir: str,
|
| 262 |
-
features_dir: str, n_features: int) -> np.ndarray:
|
| 263 |
-
"""Width-dispatching feature-row builder for the std head (soft-anchor
|
| 264 |
-
Task 2) -- lets a caller (Task 3's `predict_casting`/`evaluate_ft2b`)
|
| 265 |
-
build the right-width row for whichever std-head model it loaded,
|
| 266 |
-
keyed off that model's own `n_features_in_`, without duplicating
|
| 267 |
-
`_row_features_and_graph`'s resolution logic.
|
| 268 |
-
|
| 269 |
-
- `n_features == 16` -> the v1 row (`_row_features_and_graph`'s X,
|
| 270 |
-
unchanged -- byte-identical to `_row_features`'s own row).
|
| 271 |
-
- `n_features == 17` -> the v1 row + `bc_area_frac_from_graph` of the
|
| 272 |
-
SAME already-open graph appended (never a second graph load).
|
| 273 |
-
- anything else -> a loud `ValueError` naming the two supported widths
|
| 274 |
-
(never a silent truncation/pad).
|
| 275 |
-
"""
|
| 276 |
-
if n_features not in (16, 17):
|
| 277 |
-
raise ValueError(
|
| 278 |
-
f"std_feature_row: unsupported n_features={n_features!r} for "
|
| 279 |
-
f"stem={stem!r} -- the std head only supports 16 (v1) or 17 "
|
| 280 |
-
f"(v2, v1 + bc_area_frac) feature columns")
|
| 281 |
-
|
| 282 |
-
x, d = _row_features_and_graph(stem, manifest_rows, manifest_dir, features_dir)
|
| 283 |
-
if n_features == 16:
|
| 284 |
-
return x
|
| 285 |
-
area_frac = bc_area_frac_from_graph(d)
|
| 286 |
-
return np.concatenate([x, [area_frac]])
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
def build_v2_feature_row(props: dict, v_over_a: float,
|
| 290 |
-
bc_spec: dict | None = None) -> np.ndarray:
|
| 291 |
-
"""One BC-aware v2-total-GBM feature row (`FEATURE_NAMES` order) -- the
|
| 292 |
-
bare-mesh-inference counterpart to `_row_features_and_graph`'s
|
| 293 |
-
manifest/graph-cache-driven row (that one resolves `props`/`v_over_a`
|
| 294 |
-
from a manifest row + graph cache; this one takes them straight from
|
| 295 |
-
the caller, for real-part inference where there is no manifest row).
|
| 296 |
-
|
| 297 |
-
Public (no leading underscore) and factored out of
|
| 298 |
-
`gbm_v2_total_seconds` -- rather than duplicated wherever a caller needs
|
| 299 |
-
the total head's row (`src.predict_casting.predict_field`'s optional std
|
| 300 |
-
head, variance-recomposition Task 3) -- so the total head
|
| 301 |
-
(`gbm_v2_total_seconds`) and any std head scored from the SAME row
|
| 302 |
-
(`predict_std_seconds`) can never silently desync on a `FEATURE_NAMES`
|
| 303 |
-
reorder. Mirrors `_row_features_and_graph`'s own "so the two heads can
|
| 304 |
-
never drift apart" rationale (Task 2).
|
| 305 |
-
|
| 306 |
-
Builds the row from `props` (Task 1's `src.inference_props.
|
| 307 |
-
resolve_props()` 8-key dict: k/rho/cp/L_kJ_kg/TL/TS/Tpour/
|
| 308 |
-
mold_effusivity), `v_over_a` (volume/surface-area, mm), and `bc_spec`:
|
| 309 |
-
- `None` (the default, "plain sand mold" case) -> `bc_config`
|
| 310 |
-
"none" one-hot, `n_chills`/`sleeve_MEF`/`exo_q0_MW` all 0.0.
|
| 311 |
-
- a dict with keys `bc_config`/`n_chills`/`sleeve_MEF`/`exo_q0_MW`
|
| 312 |
-
(Part B's BC-aware inference entry point) -> that `bc_config`'s
|
| 313 |
-
one-hot + its 3 raw scalar fields, verbatim.
|
| 314 |
-
|
| 315 |
-
An unrecognized `bc_config` raises `ValueError` (mirrors `_row_features`'
|
| 316 |
-
own convention for a manifest row with a typo'd `bc_config`).
|
| 317 |
-
"""
|
| 318 |
-
if bc_spec is None:
|
| 319 |
-
bc_config = "none"
|
| 320 |
-
n_chills = 0.0
|
| 321 |
-
sleeve_MEF = 0.0
|
| 322 |
-
exo_q0_MW = 0.0
|
| 323 |
-
else:
|
| 324 |
-
bc_config = bc_spec["bc_config"]
|
| 325 |
-
n_chills = float(bc_spec["n_chills"])
|
| 326 |
-
sleeve_MEF = float(bc_spec["sleeve_MEF"])
|
| 327 |
-
exo_q0_MW = float(bc_spec["exo_q0_MW"])
|
| 328 |
-
|
| 329 |
-
if bc_config not in _BC_VALUES:
|
| 330 |
-
raise ValueError(
|
| 331 |
-
f"unrecognized bc_config={bc_config!r} (expected one of {_BC_VALUES})")
|
| 332 |
-
one_hot = [1.0 if bc_config == v else 0.0 for v in _BC_VALUES]
|
| 333 |
-
|
| 334 |
-
return np.array([
|
| 335 |
-
props["k"], props["rho"], props["cp"], props["L_kJ_kg"],
|
| 336 |
-
props["TL"], props["TS"], props["Tpour"],
|
| 337 |
-
float(v_over_a), props["mold_effusivity"],
|
| 338 |
-
*one_hot,
|
| 339 |
-
n_chills, sleeve_MEF, exo_q0_MW,
|
| 340 |
-
], dtype=np.float64)
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
def gbm_v2_total_seconds(hgb, props: dict, v_over_a: float,
|
| 344 |
-
bc_spec: dict | None = None) -> float:
|
| 345 |
-
"""Bare-mesh inference entry point: one feature row -> total seconds.
|
| 346 |
-
|
| 347 |
-
Builds the row via `build_v2_feature_row` (see that function for the
|
| 348 |
-
full `props`/`v_over_a`/`bc_spec` contract) and returns
|
| 349 |
-
`float(np.expm1(hgb.predict(row)[0]))` -- physical seconds, inverting
|
| 350 |
-
the same `log1p(y_raw.max())` target `_row_features`/`refit_gbm` train
|
| 351 |
-
on.
|
| 352 |
-
"""
|
| 353 |
-
x = build_v2_feature_row(props, v_over_a, bc_spec)
|
| 354 |
-
log_pred = float(hgb.predict(x.reshape(1, -1))[0])
|
| 355 |
-
return float(np.expm1(log_pred))
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
def _assemble_matrix(stems, manifest_path: str, features_dir: str):
|
| 359 |
-
"""[len(stems), len(FEATURE_NAMES)] feature matrix + log1p(y_global) target vector.
|
| 360 |
-
|
| 361 |
-
Raises (does not skip) on any row that cannot be resolved -- see
|
| 362 |
-
`_row_features`.
|
| 363 |
-
"""
|
| 364 |
-
manifest_dir = osp.dirname(osp.abspath(manifest_path)) or "."
|
| 365 |
-
manifest_rows = _load_manifest_rows(manifest_path)
|
| 366 |
-
|
| 367 |
-
X_rows, y_rows, used = [], [], []
|
| 368 |
-
for stem in stems:
|
| 369 |
-
x, y_log = _row_features(stem, manifest_rows, manifest_dir, features_dir)
|
| 370 |
-
X_rows.append(x)
|
| 371 |
-
y_rows.append(y_log)
|
| 372 |
-
used.append(stem)
|
| 373 |
-
|
| 374 |
-
X = np.stack(X_rows) if X_rows else np.empty((0, len(FEATURE_NAMES)))
|
| 375 |
-
y = np.array(y_rows, dtype=np.float64)
|
| 376 |
-
return X, y, used
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
def _assemble_matrix_std(stems, manifest_path: str, features_dir: str):
|
| 380 |
-
"""[len(stems), len(FEATURE_NAMES)] feature matrix + log1p(std(y_nodal))
|
| 381 |
-
target vector -- the std-head counterpart to `_assemble_matrix`. Same X
|
| 382 |
-
(via `_row_features_std` -> `_row_features_and_graph`), different target.
|
| 383 |
-
|
| 384 |
-
Raises (does not skip) on any row that cannot be resolved -- see
|
| 385 |
-
`_row_features_std`.
|
| 386 |
-
"""
|
| 387 |
-
manifest_dir = osp.dirname(osp.abspath(manifest_path)) or "."
|
| 388 |
-
manifest_rows = _load_manifest_rows(manifest_path)
|
| 389 |
-
|
| 390 |
-
X_rows, y_rows, used = [], [], []
|
| 391 |
-
for stem in stems:
|
| 392 |
-
x, y_log = _row_features_std(stem, manifest_rows, manifest_dir, features_dir)
|
| 393 |
-
X_rows.append(x)
|
| 394 |
-
y_rows.append(y_log)
|
| 395 |
-
used.append(stem)
|
| 396 |
-
|
| 397 |
-
X = np.stack(X_rows) if X_rows else np.empty((0, len(FEATURE_NAMES)))
|
| 398 |
-
y = np.array(y_rows, dtype=np.float64)
|
| 399 |
-
return X, y, used
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
def _fit_hgb_with_metrics(X: np.ndarray, y: np.ndarray, out_path: str,
|
| 403 |
-
max_iter: int, holdout_frac: float,
|
| 404 |
-
min_samples_leaf: int, random_state: int,
|
| 405 |
-
caller: str, loss_kwargs: dict | None = None) -> dict:
|
| 406 |
-
"""Shared HGB-fit/holdout/save logic behind `refit_gbm`, `refit_gbm_std`,
|
| 407 |
-
and `refit_gbm_std_v2` -- identical modeling code, only the assembled
|
| 408 |
-
(X, y) (and, for std v2, the loss) differ between heads. Factored out so
|
| 409 |
-
the heads can never drift apart in their fitting/metrics logic;
|
| 410 |
-
`refit_gbm`'s and `refit_gbm_std`'s own public behavior is unchanged by
|
| 411 |
-
this factoring (see the guard tests in `tests/test_gbm_ft2b.py`).
|
| 412 |
-
|
| 413 |
-
`min_samples_leaf` (sklearn's HGB default is 20) must be turned down for
|
| 414 |
-
very small row counts (e.g. a ~20-row unit test) -- HGB cannot split a
|
| 415 |
-
node at all once a candidate split would leave either side with fewer
|
| 416 |
-
than `min_samples_leaf` samples, so with the sklearn default and only
|
| 417 |
-
~20 training rows every tree degenerates to a single leaf (the
|
| 418 |
-
unconditional mean). Production refits (thousands of stems) should keep
|
| 419 |
-
the default.
|
| 420 |
-
|
| 421 |
-
`loss_kwargs` (soft-anchor Task 2): optional extra
|
| 422 |
-
`HistGradientBoostingRegressor` constructor kwargs (e.g.
|
| 423 |
-
`{"loss": "quantile", "quantile": 0.5}` for the std-head-v2 median
|
| 424 |
-
objective), merged into BOTH the holdout probe and the final model so a
|
| 425 |
-
caller never has to duplicate this fit/holdout/save logic just to swap
|
| 426 |
-
the loss. `None` (the default) reproduces the exact pre-existing
|
| 427 |
-
behavior (sklearn's default squared-error loss) -- `refit_gbm`'s and
|
| 428 |
-
`refit_gbm_std`'s own output is therefore byte-identical to before this
|
| 429 |
-
parameter existed.
|
| 430 |
-
|
| 431 |
-
Returns a dict of finite metrics: `n_train`, `train_mae_log1p`,
|
| 432 |
-
`train_mae_seconds`, and (when there are enough rows to spare a
|
| 433 |
-
holdout split) `holdout_mae_log1p`.
|
| 434 |
-
"""
|
| 435 |
-
if X.shape[0] == 0:
|
| 436 |
-
raise ValueError(f"{caller}: no training stems resolved -- nothing to fit")
|
| 437 |
-
|
| 438 |
-
metrics: dict = {"n_train": X.shape[0]}
|
| 439 |
-
|
| 440 |
-
# Bonus: a quick internal holdout split for a generalization signal.
|
| 441 |
-
# Skipped for tiny row counts (both HGB's own internal validation split
|
| 442 |
-
# and this outer split would starve each other of data).
|
| 443 |
-
if X.shape[0] >= 10 and holdout_frac > 0:
|
| 444 |
-
X_tr, X_ho, y_tr, y_ho = train_test_split(
|
| 445 |
-
X, y, test_size=holdout_frac, random_state=random_state)
|
| 446 |
-
probe_params = dict(_HGB_DEFAULTS)
|
| 447 |
-
probe_params["random_state"] = random_state
|
| 448 |
-
if loss_kwargs:
|
| 449 |
-
probe_params.update(loss_kwargs)
|
| 450 |
-
probe = HistGradientBoostingRegressor(
|
| 451 |
-
max_iter=max_iter,
|
| 452 |
-
early_stopping=(len(X_tr) >= _MIN_ROWS_FOR_EARLY_STOPPING),
|
| 453 |
-
validation_fraction=0.1, min_samples_leaf=min_samples_leaf,
|
| 454 |
-
**probe_params)
|
| 455 |
-
probe.fit(X_tr, y_tr)
|
| 456 |
-
ho_mae = float(np.mean(np.abs(probe.predict(X_ho) - y_ho)))
|
| 457 |
-
if np.isfinite(ho_mae):
|
| 458 |
-
metrics["holdout_mae_log1p"] = ho_mae
|
| 459 |
-
|
| 460 |
-
# Final production model: fit on ALL resolved training rows.
|
| 461 |
-
model_params = dict(_HGB_DEFAULTS)
|
| 462 |
-
model_params["random_state"] = random_state
|
| 463 |
-
if loss_kwargs:
|
| 464 |
-
model_params.update(loss_kwargs)
|
| 465 |
-
model = HistGradientBoostingRegressor(
|
| 466 |
-
max_iter=max_iter,
|
| 467 |
-
early_stopping=(X.shape[0] >= _MIN_ROWS_FOR_EARLY_STOPPING),
|
| 468 |
-
validation_fraction=0.1, min_samples_leaf=min_samples_leaf,
|
| 469 |
-
**model_params)
|
| 470 |
-
model.fit(X, y)
|
| 471 |
-
|
| 472 |
-
train_pred = model.predict(X)
|
| 473 |
-
metrics["train_mae_log1p"] = float(np.mean(np.abs(train_pred - y)))
|
| 474 |
-
metrics["train_mae_seconds"] = float(
|
| 475 |
-
np.mean(np.abs(np.expm1(train_pred) - np.expm1(y))))
|
| 476 |
-
|
| 477 |
-
out_dir = osp.dirname(out_path)
|
| 478 |
-
if out_dir:
|
| 479 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 480 |
-
joblib.dump(model, out_path)
|
| 481 |
-
metrics["out_path"] = out_path
|
| 482 |
-
|
| 483 |
-
return metrics
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
def refit_gbm(features_dir: str, train_stems, manifest_path: str, out_path: str,
|
| 487 |
-
max_iter: int = 300, holdout_frac: float = 0.2,
|
| 488 |
-
min_samples_leaf: int = 20, random_state: int = 0) -> dict:
|
| 489 |
-
"""Refit the multi-alloy GBM scale-head (total solidification time target,
|
| 490 |
-
`log1p(y_global)`) and save it to `out_path`. See `_fit_hgb_with_metrics`
|
| 491 |
-
for the shared fitting/metrics logic.
|
| 492 |
-
|
| 493 |
-
Returns a dict of finite metrics: `n_train`, `train_mae_log1p`,
|
| 494 |
-
`train_mae_seconds`, and (when there are enough rows to spare a
|
| 495 |
-
holdout split) `holdout_mae_log1p`.
|
| 496 |
-
"""
|
| 497 |
-
X, y, used = _assemble_matrix(train_stems, manifest_path, features_dir)
|
| 498 |
-
return _fit_hgb_with_metrics(
|
| 499 |
-
X, y, out_path, max_iter=max_iter, holdout_frac=holdout_frac,
|
| 500 |
-
min_samples_leaf=min_samples_leaf, random_state=random_state,
|
| 501 |
-
caller="refit_gbm")
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
def refit_gbm_std(features_dir: str, train_stems, manifest_path: str, out_path: str,
|
| 505 |
-
max_iter: int = 300, holdout_frac: float = 0.2,
|
| 506 |
-
min_samples_leaf: int = 20, random_state: int = 0) -> dict:
|
| 507 |
-
"""Refit the GBM std-head (per-row field-spread target,
|
| 508 |
-
`log1p(std(y_nodal))`) and save it to `out_path` -- the counterpart to
|
| 509 |
-
`refit_gbm`'s total-time head, for variance-preserving hybrid
|
| 510 |
-
recomposition (see `src.hybrid.hybrid_field_affine`, Task 1).
|
| 511 |
-
|
| 512 |
-
Consumes the SAME feature row layout (`FEATURE_NAMES`) as `refit_gbm`,
|
| 513 |
-
built via `_assemble_matrix_std` (which reuses `_row_features_and_graph`
|
| 514 |
-
-- identical material/BC resolution, only the target differs). See
|
| 515 |
-
`_fit_hgb_with_metrics` for the shared fitting/metrics logic.
|
| 516 |
-
|
| 517 |
-
Returns a dict of finite metrics: `n_train`, `train_mae_log1p`,
|
| 518 |
-
`train_mae_seconds`, and (when there are enough rows to spare a
|
| 519 |
-
holdout split) `holdout_mae_log1p`.
|
| 520 |
-
"""
|
| 521 |
-
X, y, used = _assemble_matrix_std(train_stems, manifest_path, features_dir)
|
| 522 |
-
return _fit_hgb_with_metrics(
|
| 523 |
-
X, y, out_path, max_iter=max_iter, holdout_frac=holdout_frac,
|
| 524 |
-
min_samples_leaf=min_samples_leaf, random_state=random_state,
|
| 525 |
-
caller="refit_gbm_std")
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
def _assemble_matrix_std_v2(stems, manifest_path: str, features_dir: str):
|
| 529 |
-
"""[len(stems), 17] feature matrix (v1 row + `bc_area_frac`) +
|
| 530 |
-
`log1p(std(y_nodal))` target vector -- the std-head-v2 counterpart to
|
| 531 |
-
`_assemble_matrix_std`. Reuses `_row_features_and_graph` for the shared
|
| 532 |
-
v1 row AND derives `bc_area_frac_from_graph` off that SAME already-open
|
| 533 |
-
graph (never a second graph load per row, mirroring
|
| 534 |
-
`_row_features_and_graph`'s own "so the two heads can never drift
|
| 535 |
-
apart" rationale).
|
| 536 |
-
|
| 537 |
-
Raises (does not skip) on any row that cannot be resolved -- see
|
| 538 |
-
`_row_features_and_graph`; also raises `KeyError` if a stem's graph
|
| 539 |
-
cache has no `y` field (mirrors `_row_features_std`).
|
| 540 |
-
"""
|
| 541 |
-
manifest_dir = osp.dirname(osp.abspath(manifest_path)) or "."
|
| 542 |
-
manifest_rows = _load_manifest_rows(manifest_path)
|
| 543 |
-
|
| 544 |
-
X_rows, y_rows, used = [], [], []
|
| 545 |
-
for stem in stems:
|
| 546 |
-
x, d = _row_features_and_graph(stem, manifest_rows, manifest_dir, features_dir)
|
| 547 |
-
if "y" not in d.files:
|
| 548 |
-
raise KeyError(f"run_id={stem!r}: graph cache in {features_dir!r} has no 'y' field")
|
| 549 |
-
area_frac = bc_area_frac_from_graph(d)
|
| 550 |
-
x17 = np.concatenate([x, [area_frac]])
|
| 551 |
-
y_log = float(np.log1p(float(np.std(d["y"]))))
|
| 552 |
-
X_rows.append(x17)
|
| 553 |
-
y_rows.append(y_log)
|
| 554 |
-
used.append(stem)
|
| 555 |
-
|
| 556 |
-
X = np.stack(X_rows) if X_rows else np.empty((0, len(FEATURE_NAMES_STD_V2)))
|
| 557 |
-
y = np.array(y_rows, dtype=np.float64)
|
| 558 |
-
return X, y, used
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
def refit_gbm_std_v2(features_dir: str, train_stems, manifest_path: str, out_path: str,
|
| 562 |
-
max_iter: int = 300, holdout_frac: float = 0.2,
|
| 563 |
-
min_samples_leaf: int = 20, random_state: int = 0) -> dict:
|
| 564 |
-
"""Refit the std-head v2 (soft-anchor Task 2): the SAME per-row
|
| 565 |
-
field-spread target as `refit_gbm_std` (`log1p(std(y_nodal))`), but on
|
| 566 |
-
17 features (`FEATURE_NAMES_STD_V2` = the v1 16 + graph-derived
|
| 567 |
-
`bc_area_frac`) and with a median (quantile=0.5) loss instead of squared
|
| 568 |
-
error -- tail-robust against the per-stem noise that squared-error
|
| 569 |
-
over-weights (see the soft-anchor design spec Sec 2).
|
| 570 |
-
|
| 571 |
-
Built via `_assemble_matrix_std_v2` (identical material/BC/graph
|
| 572 |
-
resolution to `_assemble_matrix_std`, plus the extra column). See
|
| 573 |
-
`_fit_hgb_with_metrics` for the shared fitting/metrics logic; this is
|
| 574 |
-
the one caller that passes `loss_kwargs`.
|
| 575 |
-
|
| 576 |
-
Returns a dict of finite metrics: `n_train`, `train_mae_log1p`,
|
| 577 |
-
`train_mae_seconds`, and (when there are enough rows to spare a
|
| 578 |
-
holdout split) `holdout_mae_log1p`.
|
| 579 |
-
"""
|
| 580 |
-
X, y, used = _assemble_matrix_std_v2(train_stems, manifest_path, features_dir)
|
| 581 |
-
return _fit_hgb_with_metrics(
|
| 582 |
-
X, y, out_path, max_iter=max_iter, holdout_frac=holdout_frac,
|
| 583 |
-
min_samples_leaf=min_samples_leaf, random_state=random_state,
|
| 584 |
-
caller="refit_gbm_std_v2", loss_kwargs={"loss": "quantile", "quantile": 0.5})
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
def predict_std_seconds(model, X_row) -> float:
|
| 588 |
-
"""One feature row (`FEATURE_NAMES` order, shape `(len(FEATURE_NAMES),)`
|
| 589 |
-
or `(1, len(FEATURE_NAMES))`) -> predicted std(y_nodal) in seconds.
|
| 590 |
-
|
| 591 |
-
Inverts the `log1p(std(y_nodal))` target `refit_gbm_std` trains on via
|
| 592 |
-
`expm1`, floored at 0.0 (a predicted std can never be physically
|
| 593 |
-
negative; `expm1` of a very negative log-prediction could underflow to
|
| 594 |
-
a tiny negative float).
|
| 595 |
-
"""
|
| 596 |
-
x = np.asarray(X_row, dtype=np.float64).reshape(1, -1)
|
| 597 |
-
log_pred = float(model.predict(x)[0])
|
| 598 |
-
return max(0.0, float(np.expm1(log_pred)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/gbm_heads.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The two gradient-boosted heads: total solidification time and field spread.
|
| 2 |
+
|
| 3 |
+
Both are scikit-learn HistGradientBoostingRegressor models that see one row
|
| 4 |
+
per part and predict log1p of their target in seconds.
|
| 5 |
+
|
| 6 |
+
Row layout (16 columns for the total head, 17 for the spread head):
|
| 7 |
+
0-6 alloy: k, rho, cp, L_kJ_kg, TL, TS, Tpour
|
| 8 |
+
7 part volume / surface area (mm)
|
| 9 |
+
8 mold effusivity
|
| 10 |
+
9-15 boundary-condition descriptors from training. A casting that cools
|
| 11 |
+
into the sand mold over its whole surface is the regime encoded as
|
| 12 |
+
[1, 0, 0, 0, 0, 0, 0]; this release always uses it.
|
| 13 |
+
16 (spread head only) fraction of surface nodes tagged adiabatic
|
| 14 |
+
"""
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
from src.features import FEATURE_NAMES
|
| 18 |
+
|
| 19 |
+
_IS_COOLING_IDX = FEATURE_NAMES.index("is_cooling")
|
| 20 |
+
_IS_ADIABATIC_IDX = FEATURE_NAMES.index("is_adiabatic")
|
| 21 |
+
_VOL_IDX = FEATURE_NAMES.index("volume_mm3")
|
| 22 |
+
_AREA_IDX = FEATURE_NAMES.index("surface_area_mm2")
|
| 23 |
+
|
| 24 |
+
_SAND_MOLD_BOUNDARY = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
| 25 |
+
N_TOTAL_FEATURES = 16
|
| 26 |
+
N_SPREAD_FEATURES = 17
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def total_row(props: dict, X: np.ndarray) -> np.ndarray:
|
| 30 |
+
"""The 16-column row for the total-time head."""
|
| 31 |
+
v_over_a = float(X[0, _VOL_IDX]) / float(X[0, _AREA_IDX])
|
| 32 |
+
return np.array([
|
| 33 |
+
props["k"], props["rho"], props["cp"], props["L_kJ_kg"],
|
| 34 |
+
props["TL"], props["TS"], props["Tpour"],
|
| 35 |
+
float(v_over_a), props["mold_effusivity"],
|
| 36 |
+
*_SAND_MOLD_BOUNDARY,
|
| 37 |
+
], dtype=np.float64)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def adiabatic_node_fraction(X: np.ndarray) -> float:
|
| 41 |
+
"""Adiabatic surface nodes / (adiabatic + cooling surface nodes)."""
|
| 42 |
+
n_adiabatic = float(np.sum(X[:, _IS_ADIABATIC_IDX]))
|
| 43 |
+
n_cooling = float(np.sum(X[:, _IS_COOLING_IDX]))
|
| 44 |
+
return n_adiabatic / max(n_adiabatic + n_cooling, 1.0)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def spread_row(row_total: np.ndarray, X: np.ndarray) -> np.ndarray:
|
| 48 |
+
"""The 17-column row for the spread head."""
|
| 49 |
+
return np.concatenate([row_total, [adiabatic_node_fraction(X)]])
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def predict_total_seconds(model, row: np.ndarray) -> float:
|
| 53 |
+
total = float(np.expm1(float(model.predict(row.reshape(1, -1))[0])))
|
| 54 |
+
if total <= 0:
|
| 55 |
+
raise ValueError(f"total-time head returned a non-positive total ({total!r})")
|
| 56 |
+
return total
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def predict_spread_seconds(model, row: np.ndarray) -> float:
|
| 60 |
+
x = np.asarray(row, dtype=np.float64).reshape(1, -1)
|
| 61 |
+
log_pred = float(model.predict(x)[0])
|
| 62 |
+
return max(0.0, float(np.expm1(log_pred)))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def check_head(model, n_features: int, name: str) -> None:
|
| 66 |
+
n = getattr(model, "n_features_in_", None)
|
| 67 |
+
if n is not None and int(n) != n_features:
|
| 68 |
+
raise ValueError(f"{name} head expects {n} input columns, this code builds "
|
| 69 |
+
f"{n_features}")
|
src/geometry.py
CHANGED
|
@@ -80,53 +80,6 @@ def geodesic_distance(n_nodes: int, edges: np.ndarray, edge_len: np.ndarray,
|
|
| 80 |
return d
|
| 81 |
|
| 82 |
|
| 83 |
-
def geodesic_diameter_two_sweep(n_nodes: int, edges: np.ndarray,
|
| 84 |
-
edge_len: np.ndarray, start: int = 0) -> float:
|
| 85 |
-
"""Two-sweep (double-Dijkstra) approximation of the mesh's geodesic
|
| 86 |
-
diameter, over the SAME mesh-edge graph `geodesic_distance` already uses
|
| 87 |
-
(not a per-BC-patch quantity -- patch-independent, through-metal, a
|
| 88 |
-
single per-part constant): sweep 1 finds the farthest node from `start`;
|
| 89 |
-
sweep 2 finds the farthest node from THAT node; the sweep-2 distance is
|
| 90 |
-
returned as D_geo. This is the standard double-sweep heuristic for graph
|
| 91 |
-
diameter (exact on trees, a lower bound in general, but a good practical
|
| 92 |
-
estimate for a roughly part-shaped mesh graph).
|
| 93 |
-
|
| 94 |
-
`start` is a fixed node INDEX (default 0), not searched or randomized --
|
| 95 |
-
deterministic and reproducible for the same mesh (same node ordering).
|
| 96 |
-
|
| 97 |
-
Returns 0.0 (never NaN/inf) for an edgeless mesh, or if either sweep's
|
| 98 |
-
farthest reachable node has zero finite distance (degenerate/single-node
|
| 99 |
-
or fully-disconnected mesh) -- callers must treat 0.0 as "no valid
|
| 100 |
-
part-normalized length available" rather than a real zero-size part.
|
| 101 |
-
|
| 102 |
-
CAVEAT (bc-reach re-review N2): the two-sweep heuristic is a LOWER
|
| 103 |
-
BOUND on the true graph diameter in general (exact only on trees), and
|
| 104 |
-
is start-sensitive -- on a BUKSA-like real casting mesh, re-running
|
| 105 |
-
with different random start nodes measured ~15% spread in the reported
|
| 106 |
-
D_geo across starts. Fixing `start=0` makes the result perfectly
|
| 107 |
-
DETERMINISTIC for a given mesh (bit-identical across repeated calls,
|
| 108 |
-
verified in tests/test_geometry.py), which is what the cache-build and
|
| 109 |
-
inference paths both rely on (the same mesh must always yield the same
|
| 110 |
-
D_geo) -- but it does NOT make the estimate stable under a RE-MESH of
|
| 111 |
-
the same part (a different tetrahedralization can shift which node
|
| 112 |
-
lands at index 0, and therefore the estimate, by roughly that same
|
| 113 |
-
~15%). Closing that gap would need iterating sweeps to convergence
|
| 114 |
-
(repeat from the new farthest node until the distance stops growing)
|
| 115 |
-
or a multi-start-and-take-max scheme -- deliberately NOT done here:
|
| 116 |
-
the part-normalized reach channels only need a stable-per-mesh
|
| 117 |
-
O(part-size) scale, not a tight diameter bound, and re-mesh stability
|
| 118 |
-
was judged out of scope for this task."""
|
| 119 |
-
if edges.shape[0] == 0 or n_nodes == 0:
|
| 120 |
-
return 0.0
|
| 121 |
-
start = int(min(max(start, 0), n_nodes - 1))
|
| 122 |
-
d1 = geodesic_distance(n_nodes, edges, edge_len, np.array([start], dtype=np.int64))
|
| 123 |
-
finite1 = np.isfinite(d1)
|
| 124 |
-
if not finite1.any():
|
| 125 |
-
return 0.0
|
| 126 |
-
far1 = int(np.flatnonzero(finite1)[np.argmax(d1[finite1])])
|
| 127 |
-
d2 = geodesic_distance(n_nodes, edges, edge_len, np.array([far1], dtype=np.int64))
|
| 128 |
-
finite2 = d2[np.isfinite(d2)]
|
| 129 |
-
return float(finite2.max()) if finite2.size else 0.0
|
| 130 |
|
| 131 |
|
| 132 |
def _local_modulus_for_query(tets: np.ndarray, cool_tri: np.ndarray, radii: np.ndarray,
|
|
@@ -149,79 +102,17 @@ def _local_modulus_for_query(tets: np.ndarray, cool_tri: np.ndarray, radii: np.n
|
|
| 149 |
return vals
|
| 150 |
|
| 151 |
|
| 152 |
-
_IDW_K = 4 # neighbors for inverse-distance-weighted subsample fill
|
| 153 |
-
_IDW_EPS = 1e-9 # distance floor to avoid divide-by-zero
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
def _idw_geodesic_fill(vals: np.ndarray, dist_from_computed: np.ndarray,
|
| 157 |
-
missing: np.ndarray, bbox_diag: float,
|
| 158 |
-
k: int = _IDW_K) -> np.ndarray:
|
| 159 |
-
"""Inverse-distance-weighted fill for `missing` nodes from the values
|
| 160 |
-
(`vals`, one row per computed node) at the computed/query nodes, using
|
| 161 |
-
geodesic (mesh-edge) distances (`dist_from_computed`, shape
|
| 162 |
-
(n_computed, N)) to the k nearest computed nodes. Smoother than a
|
| 163 |
-
single-nearest-node copy for a continuously-varying field. Nodes
|
| 164 |
-
unreachable from every computed node (inf distance -- disconnected mesh
|
| 165 |
-
component) fall back to the bbox-diagonal sentinel."""
|
| 166 |
-
n_computed = vals.shape[0]
|
| 167 |
-
k_eff = max(1, min(k, n_computed))
|
| 168 |
-
sub = dist_from_computed[:, missing] # (n_computed, n_missing)
|
| 169 |
-
part = np.argpartition(sub, k_eff - 1, axis=0)[:k_eff] # (k_eff, n_missing)
|
| 170 |
-
d = np.take_along_axis(sub, part, axis=0) # (k_eff, n_missing)
|
| 171 |
-
finite = np.isfinite(d)
|
| 172 |
-
w = np.where(finite, 1.0 / np.maximum(d, _IDW_EPS), 0.0) # (k_eff, n_missing)
|
| 173 |
-
wsum = w.sum(axis=0) # (n_missing,)
|
| 174 |
-
reachable = wsum > 0
|
| 175 |
-
w_norm = w / np.where(reachable, wsum, 1.0) # (k_eff, n_missing)
|
| 176 |
-
gathered = vals[part] # (k_eff, n_missing, R)
|
| 177 |
-
result = np.einsum('kn,knr->nr', w_norm, gathered) # (n_missing, R)
|
| 178 |
-
result[~reachable] = bbox_diag
|
| 179 |
-
return result
|
| 180 |
|
| 181 |
|
| 182 |
def local_modulus(node_xyz: np.ndarray, tets: np.ndarray, cool_tri: np.ndarray,
|
| 183 |
edges: np.ndarray, edge_len: np.ndarray, radii,
|
| 184 |
-
chunk: int = 128
|
| 185 |
"""Local cooling modulus per node: V/A over geodesic balls (mm).
|
| 186 |
|
| 187 |
For each node and radius r: (sum of volumes of tets whose 4 nodes all lie
|
| 188 |
within geodesic distance r of the node) / (area of cooling triangles whose
|
| 189 |
3 nodes all lie within r). Balls with no cooling area get the bbox-diagonal
|
| 190 |
-
sentinel (consistent with the other distance sentinels).
|
| 191 |
-
|
| 192 |
-
`exact=True` (default): the reference per-node computation -- byte-identical
|
| 193 |
-
to the original (pre-subsample) implementation regardless of `subsample`.
|
| 194 |
-
|
| 195 |
-
`exact=False` with `subsample=k` (int): compute the exact ball modulus only
|
| 196 |
-
on every k-th node ("computed" nodes), then fill every other node by
|
| 197 |
-
inverse-distance-weighted (IDW) interpolation over its `_IDW_K` (=4)
|
| 198 |
-
nearest computed nodes, measured by mesh-edge geodesic distance
|
| 199 |
-
(multi-source Dijkstra over `edges`/`edge_len`, reusing `_edge_csr`; see
|
| 200 |
-
`_idw_geodesic_fill`). This trades a small amount of accuracy for roughly
|
| 201 |
-
a `k`-fold cut in the number of expensive geodesic-ball evaluations (the
|
| 202 |
-
dense-mesh bottleneck). If `subsample >= N` (or is None/<=1) this
|
| 203 |
-
degenerates to the exact path.
|
| 204 |
-
|
| 205 |
-
HONEST ACCURACY NOTE (see tests/test_local_modulus.py::
|
| 206 |
-
test_subsample_per_radius_column_error and task-2-report.md for the full
|
| 207 |
-
sweep): at the production radii (5/15/40 mm) and subsample=4, per-radius-
|
| 208 |
-
column median relative error on a real ~4k-node training mesh (casting-
|
| 209 |
-
scale bbox) is roughly 0% at r=5mm but ~25-30% at r=15mm and ~6% at
|
| 210 |
-
r=40mm -- i.e. the mid radius does NOT meet a 5% target. This is because
|
| 211 |
-
local_modulus is a ball-membership STEP FUNCTION of geodesic position
|
| 212 |
-
(a tet/triangle is either fully inside a ball or not), not a smoothly-
|
| 213 |
-
varying field -- averaging several dissimilar neighbors (larger IDW k)
|
| 214 |
-
measurably makes the mid-radius error WORSE, not better (single-nearest,
|
| 215 |
-
k=1, beats IDW k=4-8 there); k=4 is used as the best compromise within
|
| 216 |
-
the requested range, not because it minimizes error in isolation.
|
| 217 |
-
CONSEQUENCE: this subsample path is intended for DENSE-mesh inference
|
| 218 |
-
(FT3), where it is a useful order-of-magnitude speed/accuracy trade
|
| 219 |
-
against an otherwise-prohibitive `exact` cost -- it is NOT a validated
|
| 220 |
-
drop-in replacement for `exact=True` in the training-density feature
|
| 221 |
-
cache (FT2a), where `exact` is already fast enough (seconds per mesh)
|
| 222 |
-
and should stay the default. Do not switch `build_dataset`'s v4 feature
|
| 223 |
-
cache to `exact=False` without re-validating per-radius accuracy on the
|
| 224 |
-
actual dense meshes it will be used on."""
|
| 225 |
N = node_xyz.shape[0]
|
| 226 |
radii = np.asarray(radii, dtype=float)
|
| 227 |
bbox_diag = float(np.linalg.norm(node_xyz.max(0) - node_xyz.min(0)))
|
|
@@ -231,22 +122,7 @@ def local_modulus(node_xyz: np.ndarray, tets: np.ndarray, cool_tri: np.ndarray,
|
|
| 231 |
tvol = tet_volumes(node_xyz, tets)
|
| 232 |
tarea = triangle_areas(node_xyz, cool_tri)
|
| 233 |
g = _edge_csr(N, edges, edge_len)
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
else np.arange(N, dtype=np.int64))
|
| 238 |
-
|
| 239 |
-
vals = _local_modulus_for_query(tets, cool_tri, radii, chunk, query_idx, g,
|
| 240 |
-
tvol, tarea, bbox_diag)
|
| 241 |
-
out[query_idx] = vals
|
| 242 |
-
|
| 243 |
-
if use_subsample and query_idx.shape[0] < N:
|
| 244 |
-
missing = np.setdiff1d(np.arange(N), query_idx, assume_unique=True)
|
| 245 |
-
if missing.shape[0]:
|
| 246 |
-
# inverse-distance-weighted fill from the k nearest computed
|
| 247 |
-
# nodes, along mesh-edge geodesics (smoother than single-nearest)
|
| 248 |
-
dist_from_computed = dijkstra(g, directed=False, indices=query_idx,
|
| 249 |
-
min_only=False) # (M, N)
|
| 250 |
-
out[missing] = _idw_geodesic_fill(vals, dist_from_computed, missing, bbox_diag)
|
| 251 |
-
|
| 252 |
return out.astype(np.float32)
|
|
|
|
| 80 |
return d
|
| 81 |
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
def _local_modulus_for_query(tets: np.ndarray, cool_tri: np.ndarray, radii: np.ndarray,
|
|
|
|
| 102 |
return vals
|
| 103 |
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
def local_modulus(node_xyz: np.ndarray, tets: np.ndarray, cool_tri: np.ndarray,
|
| 108 |
edges: np.ndarray, edge_len: np.ndarray, radii,
|
| 109 |
+
chunk: int = 128) -> np.ndarray:
|
| 110 |
"""Local cooling modulus per node: V/A over geodesic balls (mm).
|
| 111 |
|
| 112 |
For each node and radius r: (sum of volumes of tets whose 4 nodes all lie
|
| 113 |
within geodesic distance r of the node) / (area of cooling triangles whose
|
| 114 |
3 nodes all lie within r). Balls with no cooling area get the bbox-diagonal
|
| 115 |
+
sentinel (consistent with the other distance sentinels)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
N = node_xyz.shape[0]
|
| 117 |
radii = np.asarray(radii, dtype=float)
|
| 118 |
bbox_diag = float(np.linalg.norm(node_xyz.max(0) - node_xyz.min(0)))
|
|
|
|
| 122 |
tvol = tet_volumes(node_xyz, tets)
|
| 123 |
tarea = triangle_areas(node_xyz, cool_tri)
|
| 124 |
g = _edge_csr(N, edges, edge_len)
|
| 125 |
+
query_idx = np.arange(N, dtype=np.int64)
|
| 126 |
+
out[query_idx] = _local_modulus_for_query(tets, cool_tri, radii, chunk, query_idx, g,
|
| 127 |
+
tvol, tarea, bbox_diag)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
return out.astype(np.float32)
|
src/gnn_dataset.py
DELETED
|
@@ -1,51 +0,0 @@
|
|
| 1 |
-
"""PyG dataset over the v2/v3 feature cache. Builds either a kNN graph or the
|
| 2 |
-
SP1 union graph (mesh edges union kNN, `graph="union"`, read from the cache's
|
| 3 |
-
`mesh_edges`) with mm edge attributes on load; optional random-rotation
|
| 4 |
-
augmentation. Features and the log1p target are standardized with the cache's
|
| 5 |
-
global stats."""
|
| 6 |
-
import os.path as osp
|
| 7 |
-
import numpy as np
|
| 8 |
-
import torch
|
| 9 |
-
from torch.utils.data import Dataset
|
| 10 |
-
from torch_geometric.data import Data
|
| 11 |
-
|
| 12 |
-
from src.graph_build import (knn_edge_index, edge_attr, random_rotation_matrix, rotate,
|
| 13 |
-
union_edge_index, union_edge_attr)
|
| 14 |
-
from src.dataload import load_norm
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
class SolidGraphDataset(Dataset):
|
| 18 |
-
def __init__(self, features_dir, stems, k=16, augment=False, norm=None, graph="knn"):
|
| 19 |
-
self.dir = features_dir
|
| 20 |
-
self.stems = list(stems)
|
| 21 |
-
self.k = k
|
| 22 |
-
self.augment = augment
|
| 23 |
-
self.graph = graph
|
| 24 |
-
self.norm = norm if norm is not None else load_norm(features_dir)
|
| 25 |
-
self.fmean = torch.tensor(self.norm["feat_mean"], dtype=torch.float32)
|
| 26 |
-
self.fstd = torch.tensor(self.norm["feat_std"], dtype=torch.float32)
|
| 27 |
-
self.ym = float(self.norm["y_log_mean"]); self.ys = float(self.norm["y_log_std"])
|
| 28 |
-
|
| 29 |
-
def __len__(self):
|
| 30 |
-
return len(self.stems)
|
| 31 |
-
|
| 32 |
-
def __getitem__(self, i):
|
| 33 |
-
d = np.load(osp.join(self.dir, "graphs", f"{self.stems[i]}.npz"))
|
| 34 |
-
x = (torch.tensor(d["X"], dtype=torch.float32) - self.fmean) / self.fstd
|
| 35 |
-
pos = torch.tensor(d["pos"], dtype=torch.float32)
|
| 36 |
-
y_log = torch.log1p(torch.tensor(d["y"], dtype=torch.float32))
|
| 37 |
-
y = (y_log - self.ym) / self.ys
|
| 38 |
-
g = torch.tensor(d["g"], dtype=torch.float).reshape(1, -1) if "g" in d.files else None
|
| 39 |
-
if self.augment:
|
| 40 |
-
pos = rotate(pos, random_rotation_matrix())
|
| 41 |
-
if self.graph == "union":
|
| 42 |
-
if "mesh_edges" not in d.files:
|
| 43 |
-
raise KeyError(f"{self.stems[i]}: cache lacks mesh_edges — "
|
| 44 |
-
f"rebuild with build_dataset --feature-version 3")
|
| 45 |
-
me = torch.tensor(d["mesh_edges"], dtype=torch.long)
|
| 46 |
-
ei, is_mesh = union_edge_index(pos, me, k=self.k)
|
| 47 |
-
ea = union_edge_attr(pos, ei, is_mesh)
|
| 48 |
-
else:
|
| 49 |
-
ei = knn_edge_index(pos, k=self.k)
|
| 50 |
-
ea = edge_attr(pos, ei)
|
| 51 |
-
return Data(x=x, edge_index=ei, edge_attr=ea, y=y, pos=pos, g=g)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/graph_build.py
DELETED
|
@@ -1,52 +0,0 @@
|
|
| 1 |
-
"""Mesh-agnostic graph construction: kNN edges with millimetre edge attributes,
|
| 2 |
-
and random 3D-rotation augmentation (physics is rotation-invariant)."""
|
| 3 |
-
import torch
|
| 4 |
-
from torch_geometric.nn import knn_graph
|
| 5 |
-
from torch_geometric.utils import to_undirected
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def knn_edge_index(pos: torch.Tensor, k: int = 16) -> torch.Tensor:
|
| 9 |
-
ei = knn_graph(pos, k=k, loop=False) # directed k-nearest
|
| 10 |
-
return to_undirected(ei) # symmetric
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def edge_attr(pos: torch.Tensor, edge_index: torch.Tensor) -> torch.Tensor:
|
| 14 |
-
src, dst = edge_index
|
| 15 |
-
d = pos[dst] - pos[src] # (E,3) mm
|
| 16 |
-
return torch.cat([d, d.norm(dim=1, keepdim=True)], dim=1) # (E,4)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def random_rotation_matrix(seed: int | None = None) -> torch.Tensor:
|
| 20 |
-
g = torch.Generator().manual_seed(seed) if seed is not None else None
|
| 21 |
-
# QR of a random Gaussian matrix gives a Haar-random orthogonal matrix
|
| 22 |
-
a = torch.randn(3, 3, generator=g)
|
| 23 |
-
q, r = torch.linalg.qr(a)
|
| 24 |
-
q = q * torch.sign(torch.diagonal(r)) # fix signs
|
| 25 |
-
if torch.det(q) < 0: # ensure proper rotation (det +1)
|
| 26 |
-
q[:, 0] = -q[:, 0]
|
| 27 |
-
return q
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def rotate(pos: torch.Tensor, R: torch.Tensor) -> torch.Tensor:
|
| 31 |
-
return pos @ R.to(pos.device, pos.dtype).t()
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def union_edge_index(pos: torch.Tensor, mesh_edges: torch.Tensor, k: int = 16):
|
| 35 |
-
"""Undirected union of tet-mesh edges and kNN edges, deduplicated.
|
| 36 |
-
|
| 37 |
-
mesh_edges: (M,2) long tensor of unique node pairs (any orientation).
|
| 38 |
-
Returns (edge_index (2,E) long, is_mesh (E,) float32 flag)."""
|
| 39 |
-
n = pos.shape[0]
|
| 40 |
-
knn = knn_edge_index(pos, k=k).to(torch.long)
|
| 41 |
-
me = mesh_edges.t().to(torch.long)
|
| 42 |
-
me = torch.cat([me, me.flip(0)], dim=1) # both directions
|
| 43 |
-
mesh_key = me[0] * n + me[1]
|
| 44 |
-
keys = torch.unique(torch.cat([mesh_key, knn[0] * n + knn[1]]))
|
| 45 |
-
ei = torch.stack([keys // n, keys % n])
|
| 46 |
-
is_mesh = torch.isin(keys, mesh_key).to(torch.float32)
|
| 47 |
-
return ei, is_mesh
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def union_edge_attr(pos: torch.Tensor, edge_index: torch.Tensor,
|
| 51 |
-
is_mesh: torch.Tensor) -> torch.Tensor:
|
| 52 |
-
return torch.cat([edge_attr(pos, edge_index), is_mesh.unsqueeze(1)], dim=1) # (E,5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/hybrid.py
CHANGED
|
@@ -1,15 +1,17 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
The GNN
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
per-graph level to the baseline's per-graph mean:
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
import logging
|
| 15 |
|
|
@@ -18,52 +20,9 @@ import numpy as np
|
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
|
| 20 |
|
| 21 |
-
def hybrid_field(gnn_seconds: np.ndarray, baseline_seconds: np.ndarray) -> np.ndarray:
|
| 22 |
-
"""Combine one casting's per-node predictions (both in seconds, same node order).
|
| 23 |
-
|
| 24 |
-
Shape from `gnn_seconds`, absolute level from `baseline_seconds`. Clipped at 0.
|
| 25 |
-
"""
|
| 26 |
-
g = np.asarray(gnn_seconds, dtype=np.float64)
|
| 27 |
-
b = np.asarray(baseline_seconds, dtype=np.float64)
|
| 28 |
-
out = g - g.mean() + b.mean()
|
| 29 |
-
return np.clip(out, 0.0, None)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
def hybrid_field_max_anchor(gnn_seconds: np.ndarray, total_seconds: float,
|
| 33 |
q: float = 0.995) -> np.ndarray:
|
| 34 |
-
"""
|
| 35 |
-
|
| 36 |
-
`pred_i = gnn_i - anchor + total`, clipped at 0, where
|
| 37 |
-
`anchor = np.quantile(gnn_seconds, q)`. Unlike `hybrid_field`'s
|
| 38 |
-
MEAN-anchor (correct for a genuine per-node baseline), the FT2b GBM scale
|
| 39 |
-
head (`src.gbm_ft2b`) predicts a single per-graph SCALAR -- the total
|
| 40 |
-
solidification time (the field's true max), not a per-node mean.
|
| 41 |
-
Broadcasting that scalar and MEAN-anchoring would force
|
| 42 |
-
`mean(pred) == total`, inflating every node by `(true_max - true_mean)`.
|
| 43 |
-
Anchoring to a high quantile of the GNN field instead makes
|
| 44 |
-
`max(pred) ~= total` (the quantity actually predicted) while preserving
|
| 45 |
-
the GNN's field shape (its deviations from that anchor).
|
| 46 |
-
|
| 47 |
-
`q=1.0` reproduces the previous exact-max behavior bit-for-bit
|
| 48 |
-
(`np.quantile(x, 1.0) == x.max()`): `max(pred) == total` exactly. The
|
| 49 |
-
DEFAULT `q=0.995` exists because the exact-max anchor is fragile to a
|
| 50 |
-
handful of outlier nodes -- a promotion-gate smoke test found a few
|
| 51 |
-
inflated GNN log-space predictions on a real casting dragging ~90% of
|
| 52 |
-
the field below the zero clip, because the single max node set the
|
| 53 |
-
entire graph's offset. Anchoring to the 99.5th percentile instead makes
|
| 54 |
-
the offset robust to a small number of outliers, at the cost that nodes
|
| 55 |
-
ABOVE the anchor (up to ~0.5% of nodes, by construction) may now exceed
|
| 56 |
-
`total` slightly. This does not affect hot-spot ranking or field shape:
|
| 57 |
-
the anchor choice is still a per-graph ADDITIVE CONSTANT shift, so
|
| 58 |
-
within-graph node ordering (`argsort`) is unchanged, and only the
|
| 59 |
-
reported `float(pred.max())` total-time summary can differ slightly
|
| 60 |
-
from `total`. See `src/evaluate_ft2b.py`'s module docstring ("v4 hybrid
|
| 61 |
-
recomposition: QUANTILE-anchor (q=0.995), not MEAN-anchor") for the full anchor-choice
|
| 62 |
-
rationale -- this is the single source; `evaluate_ft2b` imports this
|
| 63 |
-
function under its former private name, and the same default `q` applies
|
| 64 |
-
everywhere (gate evaluation, checkpoint selection, and production
|
| 65 |
-
inference all share this one function).
|
| 66 |
-
"""
|
| 67 |
g = np.asarray(gnn_seconds, dtype=np.float64)
|
| 68 |
anchor = np.quantile(g, q)
|
| 69 |
out = g - anchor + float(total_seconds)
|
|
@@ -72,169 +31,31 @@ def hybrid_field_max_anchor(gnn_seconds: np.ndarray, total_seconds: float,
|
|
| 72 |
|
| 73 |
def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
|
| 74 |
std_seconds: float, q: float = 0.995,
|
| 75 |
-
a_clamp: tuple = (0.3, 5.0)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
level-shift cannot repair that -- e.g. the report's BUKSA case, where a
|
| 87 |
-
refit GBM anchor overshot the true total by ~+190 s because the GNN field's
|
| 88 |
-
shape carried a whole-field bias that a level-only anchor could not absorb.
|
| 89 |
-
This function additionally SCALES the field's deviations from its own mean
|
| 90 |
-
by a factor `a`, then re-anchors so `np.quantile(pred, q) == total_seconds`
|
| 91 |
-
(same quantile-anchor rationale as `hybrid_field_max_anchor` -- robust to a
|
| 92 |
-
handful of outlier nodes, at the cost that nodes above the anchor may
|
| 93 |
-
slightly exceed `total_seconds`). Scaling deviations by a positive constant
|
| 94 |
-
and then shifting by an additive constant is a strictly increasing affine
|
| 95 |
-
map, so within-graph node ordering (`argsort`, hot-spot ranking) is
|
| 96 |
-
unchanged from the raw GNN field -- exactly as for `hybrid_field_max_anchor`.
|
| 97 |
-
|
| 98 |
-
AMENDMENT (owner-approved 2026-07-31, see the "AMENDMENT" block in
|
| 99 |
-
`docs/superpowers/specs/2026-07-31-variance-recomposition-design.md`): the
|
| 100 |
-
field is 0-bounded (solidification time can't be negative) but this affine
|
| 101 |
-
map is symmetric around the field's own mean, so on real (skewed, clip-prone)
|
| 102 |
-
parts a naive `a = std_seconds / std(gnn_seconds)` collapses under its own
|
| 103 |
-
clip. The e_val eval matrix found 21-34% of nodes clipping and 33-35% of
|
| 104 |
-
chills/exo stems clamp-saturated at the old `a<=3.0` ceiling, leaving group
|
| 105 |
-
std_ratio at ~0.83 against the 0.9 floor. `a` is therefore solved so the
|
| 106 |
-
POST-CLIP field matches `std_seconds`, not the naive pre-clip ratio:
|
| 107 |
-
|
| 108 |
-
c = gnn_seconds - mean(gnn_seconds)
|
| 109 |
-
d = c - quantile(c, q) # constant vector; independent of a
|
| 110 |
-
raw(a) = a * d + total_seconds # quantile(raw(a), q) == total_seconds for any a>0
|
| 111 |
-
f(a) = std(clip(raw(a), 0, None))
|
| 112 |
-
a = solve f(a) == std_seconds over a in a_clamp
|
| 113 |
-
|
| 114 |
-
(`raw(a) = a*d + total_seconds` follows from the quantile-anchor property:
|
| 115 |
-
anchoring `a*c` at `total_seconds` gives `m(a) = total_seconds - a*quantile(c, q)`,
|
| 116 |
-
so `a*c + m(a) = a*(c - quantile(c, q)) + total_seconds = a*d + total_seconds`.)
|
| 117 |
-
|
| 118 |
-
CORRECTION (2026-07-31 review): `f` is **NOT monotone** on realistic
|
| 119 |
-
fields. Below `a_clipstart = total_seconds / max(-d.min(), eps)` nothing
|
| 120 |
-
clips and `f(a) = a * std(gnn_seconds)` exactly (the pre-amendment closed
|
| 121 |
-
form). PAST `a_clipstart`, clipping starts eating the field's low tail;
|
| 122 |
-
on real (23/25 sampled `data/features_v4` graphs) `y` fields this makes
|
| 123 |
-
`f` RISE to an interior peak just past `a_clipstart` and then FALL as
|
| 124 |
-
growing clip mass collapses variance faster than the surviving nodes'
|
| 125 |
-
growing spread can compensate. A naive `f(hi) < std_target => clamp to
|
| 126 |
-
hi` pre-check is therefore wrong: it can fire even when a perfectly good
|
| 127 |
-
(much-less-clipped) root exists on the RISING part of the curve, close to
|
| 128 |
-
`a_clipstart` -- on real data this false-clamp inflated MAE from 85.1 (at
|
| 129 |
-
the true root) to 359.5 (at the falsely-clamped `a=5.0`), because clamping
|
| 130 |
-
to `hi` clips 61-90% of the field instead of the true root's ~5-30%.
|
| 131 |
-
|
| 132 |
-
Solve semantics, in order:
|
| 133 |
-
1. If `a_raw = std_seconds / std(gnn_seconds) <= a_clipstart`: nothing
|
| 134 |
-
clips at `a_raw`, so use the closed form directly (clamped to
|
| 135 |
-
`a_clamp` -- fast path, identical to the pre-amendment closed form
|
| 136 |
-
to floating-point precision).
|
| 137 |
-
2. Otherwise, coarse-scan `a` over `[max(a_clipstart, a_clamp[0]),
|
| 138 |
-
a_clamp[1]]` (64 points) for the FIRST sign change of
|
| 139 |
-
`f(a) - std_seconds`. Because `f` starts below `std_seconds` at
|
| 140 |
-
`a_clipstart` (by construction of this branch) and is scanned
|
| 141 |
-
low-to-high, the first crossing is the SMALLEST root -- i.e. the
|
| 142 |
-
LEAST-clipped `a` that hits the target, always preferred over any
|
| 143 |
-
larger root further out on the peak's falling side. Bisect inside
|
| 144 |
-
that bracket to refine it; `info["a_clamped"]` is `False`.
|
| 145 |
-
3. If no crossing exists anywhere on the scan (`std_seconds` exceeds
|
| 146 |
-
even the interior peak, or the scan range is empty), the target is
|
| 147 |
-
genuinely unreachable: pick the SCANNED POINT whose `f` is closest
|
| 148 |
-
to `std_seconds` (not blindly `a_clamp[1]` -- the peak is often a
|
| 149 |
-
much better fit than the ceiling, exactly the failure mode above)
|
| 150 |
-
and set `info["a_clamped"] = True`.
|
| 151 |
-
|
| 152 |
-
`info["clip_frac"]` reports how much of the FINAL field hit the 0 floor,
|
| 153 |
-
so callers can flag graphs where the GBM's total/std pair is inconsistent
|
| 154 |
-
with the GNN's shape; if `clip_frac > 0.5` (a majority of the field
|
| 155 |
-
clipped -- a strong scale/anchor-inconsistency signal) this also logs a
|
| 156 |
-
warning.
|
| 157 |
-
|
| 158 |
-
Degenerate fallback: if the GNN field is (numerically) flat, `a` is
|
| 159 |
-
undefined (division by ~0), so this falls back to
|
| 160 |
-
`hybrid_field_max_anchor(gnn_seconds, total_seconds, q=q)` and logs a
|
| 161 |
-
warning; `info["degenerate_fallback"]` is `True` in that case and `a` is
|
| 162 |
-
reported as `1.0` (a no-op scale, since there is no shape to rescale).
|
| 163 |
-
This guard is unaffected by the amendment above -- it is checked before
|
| 164 |
-
any `a`-solve is attempted.
|
| 165 |
-
|
| 166 |
-
AMENDMENT 2 (owner-approved 2026-07-31, see the "AMENDMENT 2" block in
|
| 167 |
-
`docs/superpowers/specs/2026-07-31-variance-recomposition-design.md`):
|
| 168 |
-
the corrected solver exposed the next layer of error -- the GBM std
|
| 169 |
-
head's own per-part noise (p10-p90 ~ 0.66-1.36x; BUKSA gold
|
| 170 |
-
over-predicted at ~1.3x) feeds straight into MAE through the spread it
|
| 171 |
-
now honestly hits. `lambda_shrink` (default `1.0`, fully trusting
|
| 172 |
-
`std_seconds`) lets a caller partially discount that noisy target via
|
| 173 |
-
GEOMETRIC interpolation between the GNN's OWN spread and the GBM's
|
| 174 |
-
predicted spread, applied BEFORE the (unchanged) clip-aware solve above:
|
| 175 |
-
|
| 176 |
-
std_eff = std(gnn_seconds)**(1 - lambda_shrink) * std_seconds**lambda_shrink
|
| 177 |
-
|
| 178 |
-
`lambda_shrink=1.0` reproduces the full-trust behavior exactly (`std_eff
|
| 179 |
-
== std_seconds`) -- this is the ONLY code path change, gated by `if
|
| 180 |
-
lambda_shrink != 1.0:` so the default is untouched byte-for-bit.
|
| 181 |
-
`lambda_shrink=0.0` degenerates to `std_eff == std(gnn_seconds)`, i.e.
|
| 182 |
-
the solve targets the GNN's own (unscaled) spread -- in a no-clip case
|
| 183 |
-
this makes `a == 1.0` and the result IS `hybrid_field_max_anchor`'s
|
| 184 |
-
output exactly (no spread rescaling at all, quantile-anchor-only level
|
| 185 |
-
shift). Intermediate `lambda_shrink` partially shrinks the GBM's spread
|
| 186 |
-
prediction toward the GNN's own, geometric-mean-style. Selection
|
| 187 |
-
protocol (owner): `lambda_shrink` is tuned on `e_val` only (per-BC
|
| 188 |
-
`std_ratio` vs. the 0.9 floor + MAE); the GOLD assemblies stay a pure
|
| 189 |
-
held-out test, evaluated once at the chosen value.
|
| 190 |
-
|
| 191 |
-
`std_seconds <= 0` is a degenerate SCALE target (the geometric
|
| 192 |
-
interpolation is undefined/nonsensical for a non-positive base) --
|
| 193 |
-
ONLY WHEN `lambda_shrink != 1.0` (the default path never inspects
|
| 194 |
-
`std_seconds`'s sign, matching pre-amendment-2 behavior exactly), this
|
| 195 |
-
falls back to `hybrid_field_max_anchor` with the same loud-warning
|
| 196 |
-
pattern as the degenerate-GNN-field guard above.
|
| 197 |
-
|
| 198 |
-
Returns `(pred, info)` where `info = {"a": float, "a_clamped": bool,
|
| 199 |
-
"degenerate_fallback": bool, "clip_frac": float, "lambda_shrink": float,
|
| 200 |
-
"std_target_effective": float}`. NOTE `a_clamped` semantics since the
|
| 201 |
-
smallest-root correction: True means "the requested std target was
|
| 202 |
-
UNREACHABLE" (closest-fit `a` returned, which may be an interior scan
|
| 203 |
-
point) -- it no longer implies `a` sits exactly on an `a_clamp`
|
| 204 |
-
boundary. `std_target_effective` is `std_eff` above (`== std_seconds`
|
| 205 |
-
when `lambda_shrink == 1.0`).
|
| 206 |
-
"""
|
| 207 |
g = np.asarray(gnn_seconds, dtype=np.float64)
|
| 208 |
s = float(np.std(g))
|
| 209 |
-
lambda_shrink = float(lambda_shrink)
|
| 210 |
if s < 1e-9:
|
| 211 |
logger.warning("hybrid_field_affine: degenerate flat GNN field (std=%.3g) -- "
|
| 212 |
"falling back to quantile-anchor recomposition", s)
|
| 213 |
pred = hybrid_field_max_anchor(g, total_seconds, q=q)
|
| 214 |
return pred, {"a": 1.0, "a_clamped": False, "degenerate_fallback": True,
|
| 215 |
"clip_frac": float((pred == 0.0).mean()),
|
| 216 |
-
"lambda_shrink": lambda_shrink,
|
| 217 |
"std_target_effective": float(std_seconds)}
|
| 218 |
|
| 219 |
std_target = float(std_seconds)
|
| 220 |
-
if lambda_shrink != 1.0:
|
| 221 |
-
if std_target <= 0.0:
|
| 222 |
-
logger.warning("hybrid_field_affine: degenerate std target "
|
| 223 |
-
"(std_seconds=%.3g, lambda_shrink=%.3g) -- falling back to "
|
| 224 |
-
"quantile-anchor recomposition", std_target, lambda_shrink)
|
| 225 |
-
pred = hybrid_field_max_anchor(g, total_seconds, q=q)
|
| 226 |
-
return pred, {"a": 1.0, "a_clamped": False, "degenerate_fallback": True,
|
| 227 |
-
"clip_frac": float((pred == 0.0).mean()),
|
| 228 |
-
"lambda_shrink": lambda_shrink,
|
| 229 |
-
"std_target_effective": std_target}
|
| 230 |
-
std_target = (s ** (1.0 - lambda_shrink)) * (std_target ** lambda_shrink)
|
| 231 |
-
std_target_effective = std_target
|
| 232 |
-
|
| 233 |
lo, hi = float(a_clamp[0]), float(a_clamp[1])
|
| 234 |
total = float(total_seconds)
|
| 235 |
|
| 236 |
-
# `d` and `a_clipstart` depend only on the (fixed) GNN field, the quantile
|
| 237 |
-
# `q`, and `total` -- computed ONCE, not inside any search loop.
|
| 238 |
c = g - g.mean()
|
| 239 |
shift = float(np.quantile(c, q))
|
| 240 |
d = c - shift
|
|
@@ -250,17 +71,12 @@ def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
|
|
| 250 |
a_raw = std_target / s
|
| 251 |
|
| 252 |
if a_raw <= a_clipstart:
|
| 253 |
-
#
|
| 254 |
-
#
|
| 255 |
-
# pre-amendment behavior to floating-point precision). NOTE: if
|
| 256 |
-
# a_raw < lo the clamp to lo can still clip (total << spread).
|
| 257 |
a = float(np.clip(a_raw, lo, hi))
|
| 258 |
a_clamped = bool(a != a_raw)
|
| 259 |
raw = _raw_for_a(a)
|
| 260 |
else:
|
| 261 |
-
# a_raw would clip. f is not monotone past a_clipstart -- it rises to
|
| 262 |
-
# an interior peak, then falls. Coarse-scan for the smallest (first,
|
| 263 |
-
# least-clipping) root and bisect to refine it.
|
| 264 |
lo_search = max(a_clipstart, lo)
|
| 265 |
if lo_search >= hi:
|
| 266 |
a, a_clamped = hi, True
|
|
@@ -294,9 +110,8 @@ def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
|
|
| 294 |
a_clamped = False
|
| 295 |
raw = _raw_for_a(a)
|
| 296 |
else:
|
| 297 |
-
#
|
| 298 |
-
#
|
| 299 |
-
# CLOSEST to the target rather than blindly a_clamp[1].
|
| 300 |
best_idx = int(np.argmin(np.abs(diffs)))
|
| 301 |
a = float(scan_a[best_idx])
|
| 302 |
a_clamped = True
|
|
@@ -309,166 +124,40 @@ def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
|
|
| 309 |
"with the GNN shape", clip_frac)
|
| 310 |
return np.clip(raw, 0.0, None), {"a": a, "a_clamped": a_clamped,
|
| 311 |
"degenerate_fallback": False, "clip_frac": clip_frac,
|
| 312 |
-
"
|
| 313 |
-
"std_target_effective": std_target_effective}
|
| 314 |
|
| 315 |
|
| 316 |
def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds: float,
|
| 317 |
q: float = 0.995, a_clamp: tuple = (0.3, 5.0),
|
| 318 |
-
w_total: float = 1.0, w_std: float = 1.0
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
together form a ceiling: on some real parts (e_val exo stems, std_ratio
|
| 329 |
-
0.874 < the 0.9 floor) there is NO `a` in `a_clamp` for which the
|
| 330 |
-
post-clip field can reach `std_seconds` while ALSO satisfying the hard
|
| 331 |
-
top-anchor exactly -- the affine solve already returns the closest-fit
|
| 332 |
-
root, and that is provably the best available under the hard constraint.
|
| 333 |
-
Relaxing the top anchor into a soft (penalized) term recovers a strictly
|
| 334 |
-
larger solution space: allowing `quantile(pred, q)` to sit a bit off
|
| 335 |
-
`total_seconds` can unlock a much less-clipped field whose spread lands
|
| 336 |
-
far closer to `std_seconds`, at a small, bounded cost to the total.
|
| 337 |
-
|
| 338 |
-
Objective (both residuals normalized by their own target, so the weights
|
| 339 |
-
are scale-free across parts of wildly different absolute magnitude):
|
| 340 |
-
|
| 341 |
-
pred(a, m) = clip(a * (gnn - mean(gnn)) + m, 0, None)
|
| 342 |
-
r_total = (quantile(pred, q) - total_seconds) / total_seconds
|
| 343 |
-
r_std = (std(pred) - std_target_eff) / std_target_eff
|
| 344 |
-
J(a, m) = w_total * r_total**2 + w_std * r_std**2
|
| 345 |
-
|
| 346 |
-
where `std_target_eff` is `std_seconds` shrunk toward the GNN's own
|
| 347 |
-
spread by `lambda_shrink` (identical convention to `hybrid_field_affine`;
|
| 348 |
-
delegated to it -- see its AMENDMENT 2 docstring). `a` ranges over
|
| 349 |
-
`a_clamp`; `m` is a free additive constant (no longer tied to the hard
|
| 350 |
-
anchor's `m(a) = total - a*quantile(centered_gnn, q)`, though that
|
| 351 |
-
formula is exactly where the SEED point comes from, below).
|
| 352 |
-
|
| 353 |
-
Solver: `hybrid_field_affine`'s own clip-aware smallest-root solve
|
| 354 |
-
supplies the SEED `(a_seed, m_seed)` -- `a_seed` is its returned `a`;
|
| 355 |
-
`m_seed` is recovered from the identity `m(a) = total_seconds -
|
| 356 |
-
a*quantile(gnn - mean(gnn), q)` that its solve always satisfies (true in
|
| 357 |
-
every branch, including both degenerate fallbacks: `hybrid_field_max_anchor`
|
| 358 |
-
is itself `a=1, m = total - quantile(centered_gnn, q)` after the same
|
| 359 |
-
quantile-shift-equivariance identity). A coarse grid -- 64 log-spaced `a`
|
| 360 |
-
over `a_clamp` x 33 linear `m` CENTERED ON THE SEED, `m_seed +
|
| 361 |
-
total_seconds*[-0.3, +0.3]` -- is evaluated (vectorized per row: one
|
| 362 |
-
clip+quantile+std pass per `a`, across all `m` at once), PLUS the single
|
| 363 |
-
exact seed point `(a_seed, m_seed)` folded into the running best (not
|
| 364 |
-
cross-producted against either axis -- that would silently widen the
|
| 365 |
-
search past the intended band). REVIEW FIX (2026-07-31, round 1,
|
| 366 |
-
CRITICAL-1): the window was originally the ABSOLUTE
|
| 367 |
-
`total_seconds*[0.7,1.3]` (centered on `total_seconds`, not on
|
| 368 |
-
`m_seed`) -- measured on 58 real `features_v4` fields, `m_seed/total in
|
| 369 |
-
[0.03, 0.61]`, i.e. `m_seed` was NEVER inside that window, so the grid
|
| 370 |
-
could only ever explore points strictly worse than the seed and the
|
| 371 |
-
solver never engaged on the exo regime it exists for. Centering the
|
| 372 |
-
window on `m_seed` is what actually lets the search reach nearby, less-
|
| 373 |
-
clipped `(a, m)` combinations. ONE 9x9 local refinement grid (log-spaced
|
| 374 |
-
`a` / linear `m`, windowed to one coarse grid cell around whichever point
|
| 375 |
-
-- grid or seed -- is best so far, bounded within the same centered
|
| 376 |
-
window) is then evaluated the same way.
|
| 377 |
-
|
| 378 |
-
DEGENERATION GUARANTEE: `J_seed` is computed EXACTLY at `(a_seed, m_seed)`
|
| 379 |
-
before any grid search runs (not interpolated from a grid cell -- it is a
|
| 380 |
-
direct, separate evaluation). A grid or refinement candidate is accepted
|
| 381 |
-
ONLY if it beats the seed by a strict margin (`J_candidate < J_seed -
|
| 382 |
-
1e-12`); otherwise this returns the SEED array unchanged (same object,
|
| 383 |
-
not a recomputation) with the seed's own residuals. This is the guard
|
| 384 |
-
against the v5 track's earlier critical finding that `f` (the affine
|
| 385 |
-
solve's post-clip std, as a function of `a`) is NOT monotone/convex on
|
| 386 |
-
real fields -- the same non-convexity applies to `J(a, m)` here, so the
|
| 387 |
-
grid+refinement scheme never ASSUMES a single interior optimum; it only
|
| 388 |
-
ever accepts a point that is verifiably better than the seed by direct
|
| 389 |
-
evaluation. On any field where the affine seed already satisfies both
|
| 390 |
-
targets (no-clip case), this makes `hybrid_field_soft` numerically
|
| 391 |
-
identical to `hybrid_field_affine` -- and, as a PERFORMANCE consequence
|
| 392 |
-
(REVIEW FIX, CRITICAL-2a), when `J_seed < 1e-12` this returns immediately
|
| 393 |
-
WITHOUT building or evaluating any grid at all (healthy/no-clip parts pay
|
| 394 |
-
only the affine solve's own cost). When the grid does run (REVIEW FIX,
|
| 395 |
-
CRITICAL-2b), its per-`a` quantile term is computed via the closed-form
|
| 396 |
-
`a*shift + m` (exact whenever `clip_frac < 1 - q`, which always holds in
|
| 397 |
-
practice -- see the in-code comment above `_grid_eval`) instead of a per-
|
| 398 |
-
point `np.quantile`/`np.partition` call, and the grid's winning candidate
|
| 399 |
-
is re-evaluated with the exact `_residuals()` helper before the
|
| 400 |
-
accept/reject decision, so no approximation reaches the returned
|
| 401 |
-
`info`/prediction -- this keeps a 10k-node solve inside the
|
| 402 |
-
~200ms budget even when the search engages.
|
| 403 |
-
|
| 404 |
-
`lambda_shrink` and the degenerate-field/degenerate-std-target fallbacks
|
| 405 |
-
are delegated verbatim to `hybrid_field_affine` (same warnings, same
|
| 406 |
-
`hybrid_field_max_anchor` escape hatch) -- when `info["degenerate_fallback"]`
|
| 407 |
-
comes back `True` there is no field shape left to trade off, so this
|
| 408 |
-
returns that fallback's prediction unchanged (no grid search).
|
| 409 |
-
|
| 410 |
-
Returns `(pred, info)` where `info = {"a": float, "m": float,
|
| 411 |
-
"target_unreachable": bool, "degenerate_fallback": bool,
|
| 412 |
-
"clip_frac": float, "total_deviation_frac": float, "j_total_resid": float,
|
| 413 |
-
"j_std_resid": float, "lambda_shrink": float,
|
| 414 |
-
"std_target_effective": float}`. `target_unreachable` repurposes
|
| 415 |
-
`hybrid_field_affine`'s `a_clamped` (`True` means the HARD two-target
|
| 416 |
-
problem the affine seed solved was itself unreachable at some `a` -- the
|
| 417 |
-
signal that motivated relaxing to a soft objective in the first place).
|
| 418 |
-
`clip_frac` is measured on the FINAL (returned) field. `total_deviation_frac
|
| 419 |
-
= r_total` of the final field (positive = the top anchor rose above
|
| 420 |
-
`total_seconds`). `j_total_resid`/`j_std_resid` are `J`'s two weighted,
|
| 421 |
-
squared terms at the final `(a, m)` (`J == j_total_resid + j_std_resid`).
|
| 422 |
-
|
| 423 |
-
REVIEW FIX (round 1, LOW): `w_total`/`w_std` must both be `>= 0` (a
|
| 424 |
-
negative weight REWARDS moving away from that target instead of
|
| 425 |
-
penalizing deviation from it -- a reviewer-demonstrated `w_total=-1.0`
|
| 426 |
-
silently drove `a` to the `a_clamp` ceiling with a +273% total
|
| 427 |
-
deviation, since minimizing `J` then means MAXIMIZING `r_total**2`) and
|
| 428 |
-
may not both be exactly `0` (the objective `J(a, m)` would be
|
| 429 |
-
identically zero everywhere, leaving the grid/refinement search no
|
| 430 |
-
signal to optimize against at all) -- either violation raises a loud
|
| 431 |
-
`ValueError` before any solve is attempted.
|
| 432 |
-
"""
|
| 433 |
g = np.asarray(gnn_seconds, dtype=np.float64)
|
| 434 |
total = float(total_seconds)
|
| 435 |
q = float(q)
|
| 436 |
w_total = float(w_total)
|
| 437 |
w_std = float(w_std)
|
| 438 |
|
| 439 |
-
# REVIEW FIX (round 1, LOW): a negative weight would reward the search
|
| 440 |
-
# for moving AWAY from that target rather than penalizing deviation from
|
| 441 |
-
# it (minimizing J then means MAXIMIZING that residual's square), and
|
| 442 |
-
# both-zero weights make J identically zero everywhere -- fail loudly
|
| 443 |
-
# rather than silently returning a meaningless/adversarial optimum.
|
| 444 |
if w_total < 0.0 or w_std < 0.0:
|
| 445 |
raise ValueError(
|
| 446 |
f"hybrid_field_soft: w_total/w_std must be >= 0 (got "
|
| 447 |
-
f"w_total={w_total!r}, w_std={w_std!r})
|
| 448 |
-
f"reward moving AWAY from that target instead of penalizing "
|
| 449 |
-
f"deviation from it")
|
| 450 |
if w_total == 0.0 and w_std == 0.0:
|
| 451 |
-
raise ValueError(
|
| 452 |
-
"hybrid_field_soft: w_total and w_std cannot both be 0 -- the "
|
| 453 |
-
"objective J(a, m) would be identically zero everywhere, leaving "
|
| 454 |
-
"the search no signal to optimize against")
|
| 455 |
-
|
| 456 |
-
# REVIEW FIX (LOW-6): np.logspace(log10(a_clamp[0]), ...) below silently
|
| 457 |
-
# produces NaN/-inf for a non-positive lower clamp; fail loudly instead,
|
| 458 |
-
# regardless of whether this call ends up taking the no-grid fast path.
|
| 459 |
if float(a_clamp[0]) <= 0.0:
|
| 460 |
raise ValueError(f"hybrid_field_soft: a_clamp[0] must be > 0 (got {a_clamp[0]!r}) "
|
| 461 |
-
"-- the a-grid is log-spaced
|
| 462 |
-
"lower bound")
|
| 463 |
|
| 464 |
-
pred_seed, info_affine = hybrid_field_affine(g, total, std_seconds, q=q,
|
| 465 |
-
a_clamp=a_clamp,
|
| 466 |
-
lambda_shrink=lambda_shrink)
|
| 467 |
|
| 468 |
-
#
|
| 469 |
-
# solve, including both its degenerate fallbacks (each is exactly a=1,
|
| 470 |
-
# m = total - shift, by the quantile-shift-equivariance identity used in
|
| 471 |
-
# `hybrid_field_affine`'s own AMENDMENT derivation).
|
| 472 |
c = g - g.mean()
|
| 473 |
shift = float(np.quantile(c, q))
|
| 474 |
a_seed = float(info_affine["a"])
|
|
@@ -494,7 +183,6 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
|
|
| 494 |
"total_deviation_frac": final_rt,
|
| 495 |
"j_total_resid": float(w_total * final_rt ** 2),
|
| 496 |
"j_std_resid": float(w_std * final_rs ** 2),
|
| 497 |
-
"lambda_shrink": info_affine["lambda_shrink"],
|
| 498 |
"std_target_effective": std_target_eff,
|
| 499 |
}
|
| 500 |
|
|
@@ -508,18 +196,13 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
|
|
| 508 |
"total_deviation_frac": r_total_seed,
|
| 509 |
"j_total_resid": float(w_total * r_total_seed ** 2),
|
| 510 |
"j_std_resid": float(w_std * r_std_seed ** 2),
|
| 511 |
-
"lambda_shrink": info_affine["lambda_shrink"],
|
| 512 |
"std_target_effective": std_target_eff,
|
| 513 |
}
|
| 514 |
|
| 515 |
target_unreachable = bool(info_affine["a_clamped"])
|
| 516 |
seed_clip_frac = float((c * a_seed + m_seed < 0.0).mean())
|
| 517 |
|
| 518 |
-
#
|
| 519 |
-
# (J_seed ~ 0), skip the grid/refinement search entirely -- healthy,
|
| 520 |
-
# no-clip parts then pay only the affine solve's own cost (~ms), not the
|
| 521 |
-
# grid's. This is the common case in production (most parts do not need
|
| 522 |
-
# the soft trade-off at all).
|
| 523 |
if j_seed < 1e-12:
|
| 524 |
return pred_seed, _build_info(a_seed, m_seed, r_total_seed, r_std_seed,
|
| 525 |
seed_clip_frac, target_unreachable)
|
|
@@ -527,39 +210,15 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
|
|
| 527 |
lo, hi = float(a_clamp[0]), float(a_clamp[1])
|
| 528 |
n_a, n_m = 64, 33
|
| 529 |
a_grid = np.logspace(np.log10(lo), np.log10(hi), n_a)
|
| 530 |
-
#
|
| 531 |
-
# (`m_seed + total*[-0.3, +0.3]`), not on the absolute `total*[0.7, 1.3]`
|
| 532 |
-
# -- see the docstring's "REVIEW FIX (2026-07-31, round 1, CRITICAL-1)"
|
| 533 |
-
# note for why the absolute window never contained `m_seed` on real
|
| 534 |
-
# fields and so never let the solver engage.
|
| 535 |
m_lo, m_hi = sorted((m_seed - 0.3 * total, m_seed + 0.3 * total))
|
| 536 |
m_grid = np.linspace(m_lo, m_hi, n_m)
|
| 537 |
|
| 538 |
-
#
|
| 539 |
-
#
|
| 540 |
-
#
|
| 541 |
-
#
|
| 542 |
-
#
|
| 543 |
-
# the UNCLIPPED `raw` -- which is just `a*shift + m` (`shift =
|
| 544 |
-
# quantile(c, q)`, computed once above) -- exactly, as long as the number
|
| 545 |
-
# of clipped nodes is <= floor((n-1)*q), i.e. clip_frac up to ~q = 0.995
|
| 546 |
-
# (review-verified exact at clip_frac 0.00 / 0.43 / 0.83 on real fields;
|
| 547 |
-
# rel err <= 1.7e-16). The identity only breaks at clip_frac == 1.0, and
|
| 548 |
-
# there it is ONE-SIDED SAFE: the closed form returns the negative raw
|
| 549 |
-
# quantile vs the true 0, so r_total < -1 and J is OVER-estimated --
|
| 550 |
-
# such a point can never be falsely selected for any total > 0. This
|
| 551 |
-
# turns an O(n_nodes log n_nodes)-per-point cost into an O(1)-per-point
|
| 552 |
-
# closed form; the winning candidate is re-scored through the exact
|
| 553 |
-
# `_residuals()` before acceptance regardless. `np.std` still needs the actual clipped per-node values (clip
|
| 554 |
-
# changes variance nontrivially), so it remains the only per-node
|
| 555 |
-
# computation in the search; the search's own (a, m) argmin is therefore
|
| 556 |
-
# APPROXIMATE only in the sense that its objective is now built from an
|
| 557 |
-
# exact-closed-form quantile + an exact std -- i.e. not approximate at
|
| 558 |
-
# all for the quantile term, and exact for std. Regardless, the FINAL
|
| 559 |
-
# accept/reject decision below re-evaluates the winning candidate's
|
| 560 |
-
# residuals via the exact `_residuals()` helper (a single `np.quantile`
|
| 561 |
-
# call, not a grid-wide one) before comparing to `j_seed`, so no
|
| 562 |
-
# approximation ever reaches the returned `info`/decision.
|
| 563 |
def _grid_eval(a_vals, m_vals):
|
| 564 |
n_av, n_mv = len(a_vals), len(m_vals)
|
| 565 |
J = np.empty((n_av, n_mv))
|
|
@@ -577,12 +236,6 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
|
|
| 577 |
Rs[i] = rs
|
| 578 |
return J, Rt, Rs
|
| 579 |
|
| 580 |
-
# Coarse 64x33 (a, m) grid, PLUS the single explicit seed point (NOT a
|
| 581 |
-
# cross-product extension of either axis -- crossing m_seed against every
|
| 582 |
-
# `a` would silently widen the m-search far outside the intended window).
|
| 583 |
-
# The seed's own (J_seed, r_total_seed, r_std_seed) were already computed
|
| 584 |
-
# exactly above, so it is folded into the "best so far" comparison
|
| 585 |
-
# directly rather than re-evaluated here.
|
| 586 |
J_coarse, Rt_coarse, Rs_coarse = _grid_eval(a_grid, m_grid)
|
| 587 |
idx = np.unravel_index(int(np.argmin(J_coarse)), J_coarse.shape)
|
| 588 |
best_a, best_m = float(a_grid[idx[0]]), float(m_grid[idx[1]])
|
|
@@ -593,9 +246,8 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
|
|
| 593 |
best_a, best_m, best_J = a_seed, m_seed, j_seed
|
| 594 |
best_rt, best_rs = r_total_seed, r_std_seed
|
| 595 |
|
| 596 |
-
# One
|
| 597 |
-
#
|
| 598 |
-
# (log-spaced in a, linear in m), bounded within the same centered window.
|
| 599 |
log_lo, log_hi = np.log10(lo), np.log10(hi)
|
| 600 |
a_step = (log_hi - log_lo) / max(n_a - 1, 1)
|
| 601 |
log_best_a = np.log10(best_a)
|
|
@@ -613,11 +265,7 @@ def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds
|
|
| 613 |
best_J = float(J_ref[idx_r])
|
| 614 |
best_rt, best_rs = float(Rt_ref[idx_r]), float(Rs_ref[idx_r])
|
| 615 |
|
| 616 |
-
#
|
| 617 |
-
# `np.quantile` + `np.std` call, not a grid-wide pass) before the
|
| 618 |
-
# accept/reject decision -- the search above is already exact for
|
| 619 |
-
# everything but relies on the closed-form quantile identity holding, so
|
| 620 |
-
# this is a cheap final confirmation, not a correction of a known bias.
|
| 621 |
if (best_a, best_m) != (a_seed, m_seed):
|
| 622 |
best_pred = np.clip(best_a * c + best_m, 0.0, None)
|
| 623 |
best_rt, best_rs = _residuals(best_pred)
|
|
|
|
| 1 |
+
"""Recomposition: combine the GNN field shape with the two heads' targets.
|
| 2 |
|
| 3 |
+
The GNN predicts the spatial pattern of the field well but is less reliable
|
| 4 |
+
about its absolute level and spread. The heads predict the part's total time
|
| 5 |
+
(used as the 99.5% quantile of the field) and the standard deviation of the
|
| 6 |
+
field. `hybrid_field_soft` maps the GNN field affinely,
|
|
|
|
| 7 |
|
| 8 |
+
pred(a, m) = clip(a * (gnn - mean(gnn)) + m, 0, None)
|
| 9 |
|
| 10 |
+
and picks (a, m) to minimize
|
| 11 |
+
|
| 12 |
+
J = w_total * r_total**2 + w_std * r_std**2,
|
| 13 |
+
r_total = (quantile(pred, q) - total) / total
|
| 14 |
+
r_std = (std(pred) - std_target) / std_target.
|
| 15 |
"""
|
| 16 |
import logging
|
| 17 |
|
|
|
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def hybrid_field_max_anchor(gnn_seconds: np.ndarray, total_seconds: float,
|
| 24 |
q: float = 0.995) -> np.ndarray:
|
| 25 |
+
"""Shift the GNN field so its q-quantile equals `total_seconds`, clip at 0."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
g = np.asarray(gnn_seconds, dtype=np.float64)
|
| 27 |
anchor = np.quantile(g, q)
|
| 28 |
out = g - anchor + float(total_seconds)
|
|
|
|
| 31 |
|
| 32 |
def hybrid_field_affine(gnn_seconds: np.ndarray, total_seconds: float,
|
| 33 |
std_seconds: float, q: float = 0.995,
|
| 34 |
+
a_clamp: tuple = (0.3, 5.0)) -> tuple:
|
| 35 |
+
"""Hard two-target fit: the q-quantile is anchored exactly at
|
| 36 |
+
`total_seconds` and the scale `a` is solved so that the clipped field's
|
| 37 |
+
standard deviation matches `std_seconds`.
|
| 38 |
+
|
| 39 |
+
Anchoring the quantile gives raw(a) = a * d + total with d fixed by the
|
| 40 |
+
GNN field. Below a_clipstart nothing is clipped and a = std/std(gnn) in
|
| 41 |
+
closed form. Past it, the post-clip spread is not monotone in a (it rises
|
| 42 |
+
to an interior peak and then falls), so the smallest root is found by a
|
| 43 |
+
coarse scan followed by bisection; if no root exists the closest scanned
|
| 44 |
+
point is used and `a_clamped` is set. Returns (field, info)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
g = np.asarray(gnn_seconds, dtype=np.float64)
|
| 46 |
s = float(np.std(g))
|
|
|
|
| 47 |
if s < 1e-9:
|
| 48 |
logger.warning("hybrid_field_affine: degenerate flat GNN field (std=%.3g) -- "
|
| 49 |
"falling back to quantile-anchor recomposition", s)
|
| 50 |
pred = hybrid_field_max_anchor(g, total_seconds, q=q)
|
| 51 |
return pred, {"a": 1.0, "a_clamped": False, "degenerate_fallback": True,
|
| 52 |
"clip_frac": float((pred == 0.0).mean()),
|
|
|
|
| 53 |
"std_target_effective": float(std_seconds)}
|
| 54 |
|
| 55 |
std_target = float(std_seconds)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
lo, hi = float(a_clamp[0]), float(a_clamp[1])
|
| 57 |
total = float(total_seconds)
|
| 58 |
|
|
|
|
|
|
|
| 59 |
c = g - g.mean()
|
| 60 |
shift = float(np.quantile(c, q))
|
| 61 |
d = c - shift
|
|
|
|
| 71 |
a_raw = std_target / s
|
| 72 |
|
| 73 |
if a_raw <= a_clipstart:
|
| 74 |
+
# Nothing is clipped at a_raw, so the closed form is exact. The clamp
|
| 75 |
+
# to `lo` can still clip when the total is small next to the spread.
|
|
|
|
|
|
|
| 76 |
a = float(np.clip(a_raw, lo, hi))
|
| 77 |
a_clamped = bool(a != a_raw)
|
| 78 |
raw = _raw_for_a(a)
|
| 79 |
else:
|
|
|
|
|
|
|
|
|
|
| 80 |
lo_search = max(a_clipstart, lo)
|
| 81 |
if lo_search >= hi:
|
| 82 |
a, a_clamped = hi, True
|
|
|
|
| 110 |
a_clamped = False
|
| 111 |
raw = _raw_for_a(a)
|
| 112 |
else:
|
| 113 |
+
# The target exceeds the reachable spread: take the closest
|
| 114 |
+
# scanned point instead of the upper clamp.
|
|
|
|
| 115 |
best_idx = int(np.argmin(np.abs(diffs)))
|
| 116 |
a = float(scan_a[best_idx])
|
| 117 |
a_clamped = True
|
|
|
|
| 124 |
"with the GNN shape", clip_frac)
|
| 125 |
return np.clip(raw, 0.0, None), {"a": a, "a_clamped": a_clamped,
|
| 126 |
"degenerate_fallback": False, "clip_frac": clip_frac,
|
| 127 |
+
"std_target_effective": std_target}
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
def hybrid_field_soft(gnn_seconds: np.ndarray, total_seconds: float, std_seconds: float,
|
| 131 |
q: float = 0.995, a_clamp: tuple = (0.3, 5.0),
|
| 132 |
+
w_total: float = 1.0, w_std: float = 1.0) -> tuple:
|
| 133 |
+
"""Soft two-target fit (see the module docstring for J).
|
| 134 |
+
|
| 135 |
+
Seeded by `hybrid_field_affine`. When the seed already meets both
|
| 136 |
+
targets it is returned unchanged; otherwise a 64 x 33 grid over (a, m),
|
| 137 |
+
a 9 x 9 refinement around the best point and an exact re-check decide
|
| 138 |
+
whether letting the total move buys a better spread match. Returns
|
| 139 |
+
(field, info) with info keys a, m, target_unreachable,
|
| 140 |
+
degenerate_fallback, clip_frac, total_deviation_frac, j_total_resid,
|
| 141 |
+
j_std_resid, std_target_effective."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
g = np.asarray(gnn_seconds, dtype=np.float64)
|
| 143 |
total = float(total_seconds)
|
| 144 |
q = float(q)
|
| 145 |
w_total = float(w_total)
|
| 146 |
w_std = float(w_std)
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
if w_total < 0.0 or w_std < 0.0:
|
| 149 |
raise ValueError(
|
| 150 |
f"hybrid_field_soft: w_total/w_std must be >= 0 (got "
|
| 151 |
+
f"w_total={w_total!r}, w_std={w_std!r})")
|
|
|
|
|
|
|
| 152 |
if w_total == 0.0 and w_std == 0.0:
|
| 153 |
+
raise ValueError("hybrid_field_soft: w_total and w_std cannot both be 0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
if float(a_clamp[0]) <= 0.0:
|
| 155 |
raise ValueError(f"hybrid_field_soft: a_clamp[0] must be > 0 (got {a_clamp[0]!r}) "
|
| 156 |
+
"-- the a-grid is log-spaced")
|
|
|
|
| 157 |
|
| 158 |
+
pred_seed, info_affine = hybrid_field_affine(g, total, std_seconds, q=q, a_clamp=a_clamp)
|
|
|
|
|
|
|
| 159 |
|
| 160 |
+
# For every branch of the affine solve, m(a) = total - a * quantile(c, q).
|
|
|
|
|
|
|
|
|
|
| 161 |
c = g - g.mean()
|
| 162 |
shift = float(np.quantile(c, q))
|
| 163 |
a_seed = float(info_affine["a"])
|
|
|
|
| 183 |
"total_deviation_frac": final_rt,
|
| 184 |
"j_total_resid": float(w_total * final_rt ** 2),
|
| 185 |
"j_std_resid": float(w_std * final_rs ** 2),
|
|
|
|
| 186 |
"std_target_effective": std_target_eff,
|
| 187 |
}
|
| 188 |
|
|
|
|
| 196 |
"total_deviation_frac": r_total_seed,
|
| 197 |
"j_total_resid": float(w_total * r_total_seed ** 2),
|
| 198 |
"j_std_resid": float(w_std * r_std_seed ** 2),
|
|
|
|
| 199 |
"std_target_effective": std_target_eff,
|
| 200 |
}
|
| 201 |
|
| 202 |
target_unreachable = bool(info_affine["a_clamped"])
|
| 203 |
seed_clip_frac = float((c * a_seed + m_seed < 0.0).mean())
|
| 204 |
|
| 205 |
+
# Both targets already met: no search needed (the common case).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
if j_seed < 1e-12:
|
| 207 |
return pred_seed, _build_info(a_seed, m_seed, r_total_seed, r_std_seed,
|
| 208 |
seed_clip_frac, target_unreachable)
|
|
|
|
| 210 |
lo, hi = float(a_clamp[0]), float(a_clamp[1])
|
| 211 |
n_a, n_m = 64, 33
|
| 212 |
a_grid = np.logspace(np.log10(lo), np.log10(hi), n_a)
|
| 213 |
+
# The m-window is centred on the seed.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
m_lo, m_hi = sorted((m_seed - 0.3 * total, m_seed + 0.3 * total))
|
| 215 |
m_grid = np.linspace(m_lo, m_hi, n_m)
|
| 216 |
|
| 217 |
+
# raw(a, m) = a*c + m is order-preserving and clipping only touches the
|
| 218 |
+
# low tail, so quantile(clip(raw), q) == a*shift + m in closed form as
|
| 219 |
+
# long as fewer than ~q of the nodes are clipped. Where that fails (all
|
| 220 |
+
# nodes clipped) J is over-estimated, so such a point is never selected.
|
| 221 |
+
# The winning candidate is re-scored exactly below.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
def _grid_eval(a_vals, m_vals):
|
| 223 |
n_av, n_mv = len(a_vals), len(m_vals)
|
| 224 |
J = np.empty((n_av, n_mv))
|
|
|
|
| 236 |
Rs[i] = rs
|
| 237 |
return J, Rt, Rs
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
J_coarse, Rt_coarse, Rs_coarse = _grid_eval(a_grid, m_grid)
|
| 240 |
idx = np.unravel_index(int(np.argmin(J_coarse)), J_coarse.shape)
|
| 241 |
best_a, best_m = float(a_grid[idx[0]]), float(m_grid[idx[1]])
|
|
|
|
| 246 |
best_a, best_m, best_J = a_seed, m_seed, j_seed
|
| 247 |
best_rt, best_rs = r_total_seed, r_std_seed
|
| 248 |
|
| 249 |
+
# One refinement pass on a 9 x 9 sub-grid about one coarse cell wide
|
| 250 |
+
# around the best point so far (log-spaced in a, linear in m).
|
|
|
|
| 251 |
log_lo, log_hi = np.log10(lo), np.log10(hi)
|
| 252 |
a_step = (log_hi - log_lo) / max(n_a - 1, 1)
|
| 253 |
log_best_a = np.log10(best_a)
|
|
|
|
| 265 |
best_J = float(J_ref[idx_r])
|
| 266 |
best_rt, best_rs = float(Rt_ref[idx_r]), float(Rs_ref[idx_r])
|
| 267 |
|
| 268 |
+
# Exact re-check of the winner before accepting it over the seed.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
if (best_a, best_m) != (a_seed, m_seed):
|
| 270 |
best_pred = np.clip(best_a * c + best_m, 0.0, None)
|
| 271 |
best_rt, best_rs = _residuals(best_pred)
|
src/inference_props.py
CHANGED
|
@@ -1,112 +1,125 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Turns a named alloy
|
| 4 |
-
|
| 5 |
-
|
| 6 |
|
| 7 |
{k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity}
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
`data/properties/molds.csv`. A `props_json` path bypasses both tables
|
| 12 |
-
entirely and must already contain all 8 keys.
|
| 13 |
"""
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import csv
|
| 17 |
import json
|
|
|
|
| 18 |
from pathlib import Path
|
| 19 |
|
| 20 |
-
from tools.doe.load_properties import load_materials
|
| 21 |
-
|
| 22 |
DEFAULT_ALLOY = "EN-GJS-400-15"
|
|
|
|
| 23 |
|
| 24 |
_REQUIRED_KEYS = ("k", "rho", "cp", "L_kJ_kg", "TL", "TS", "Tpour", "mold_effusivity")
|
| 25 |
|
| 26 |
_MATERIALS_PATH = "data/properties/materials.csv"
|
| 27 |
_MOLDS_PATH = "data/properties/molds.csv"
|
| 28 |
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
_molds_cache: dict[str, float] | None = None
|
| 31 |
|
| 32 |
|
| 33 |
-
def _materials() -> dict[str,
|
| 34 |
-
"""Module-level cached {material_id: Material}."""
|
| 35 |
global _materials_cache
|
| 36 |
if _materials_cache is None:
|
| 37 |
-
_materials_cache = {m.material_id: m for m in load_materials(
|
| 38 |
return _materials_cache
|
| 39 |
|
| 40 |
|
| 41 |
def _molds() -> dict[str, float]:
|
| 42 |
-
"""Module-level cached {mold_system: effusivity_W_s05_m2K}."""
|
| 43 |
global _molds_cache
|
| 44 |
if _molds_cache is None:
|
| 45 |
-
|
| 46 |
-
p = Path(_MOLDS_PATH)
|
| 47 |
-
if p.exists():
|
| 48 |
-
with p.open(newline="") as f:
|
| 49 |
-
for row in csv.DictReader(f):
|
| 50 |
-
system = row.get("mold_system")
|
| 51 |
-
raw = row.get("effusivity_W_s05_m2K")
|
| 52 |
-
if system and raw not in (None, ""):
|
| 53 |
-
molds[system] = float(raw)
|
| 54 |
-
_molds_cache = molds
|
| 55 |
return _molds_cache
|
| 56 |
|
| 57 |
|
| 58 |
def list_alloys() -> list[str]:
|
| 59 |
-
"""Named material_ids available (from `data/properties/materials.csv`)."""
|
| 60 |
return list(_materials().keys())
|
| 61 |
|
| 62 |
|
| 63 |
def list_molds() -> list[str]:
|
| 64 |
-
"""Named mold systems available (from `data/properties/molds.csv`)."""
|
| 65 |
return list(_molds().keys())
|
| 66 |
|
| 67 |
|
| 68 |
def _validate_tl_ts(props: dict, source: str) -> None:
|
| 69 |
-
"""
|
| 70 |
-
|
| 71 |
-
UNvalidated for a `--props` custom-JSON override -- `resolve_props` only
|
| 72 |
-
checked key *presence*, never ordering. `src.shrinkage.niyama_proxy`
|
| 73 |
-
clamps its `tdot_proxy` term to `>= 0.0`, so an inverted pair silently
|
| 74 |
-
yields `Ny == 0` for every node, and `porosity_risk(mode="percentile")`
|
| 75 |
-
then flags 100% of nodes as at-risk -- no warning, no exception, just a
|
| 76 |
-
self-contradictory "100% flagged (bottom decile by construction)"
|
| 77 |
-
summary line. Named grades are protected by `tools.doe.load_properties.
|
| 78 |
-
load_materials`'s own `TS < TL < Tpour` check at CSV-load time, but that
|
| 79 |
-
guard never runs for a `--props` override (it bypasses the materials
|
| 80 |
-
table entirely) -- and SP2 makes shrinkage screening default-ON, so this
|
| 81 |
-
path is live in production, not dead code. Raises loudly, naming both
|
| 82 |
-
values and which of `props_json`/`alloy` triggered it.
|
| 83 |
-
"""
|
| 84 |
TL, TS = props["TL"], props["TS"]
|
| 85 |
if TL <= TS:
|
| 86 |
raise ValueError(
|
| 87 |
f"resolve_props ({source}): TL ({TL!r}) must be strictly greater than "
|
| 88 |
-
f"TS ({TS!r})
|
| 89 |
-
f"the Niyama proxy (src.shrinkage.niyama_proxy) collapse to 0 "
|
| 90 |
-
f"everywhere, silently flagging 100% of nodes as at-risk")
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def resolve_props(alloy: str | None = None, mold: str = "green_sand",
|
| 94 |
-
props_json: str | None = None) -> dict:
|
| 95 |
-
"""Resolve the 8-key conditioning-property dict.
|
| 96 |
|
| 97 |
-
`props_json`, if given, overrides `alloy`/`mold` entirely: the file must
|
| 98 |
-
be a JSON object containing all 8 required keys (missing keys raise
|
| 99 |
-
ValueError naming them).
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
shrinkage screening's Niyama proxy to "0 everywhere / 100% flagged".
|
| 109 |
-
"""
|
| 110 |
if props_json is not None:
|
| 111 |
with open(props_json) as f:
|
| 112 |
data = json.load(f)
|
|
@@ -130,7 +143,6 @@ def resolve_props(alloy: str | None = None, mold: str = "green_sand",
|
|
| 130 |
if mold not in molds:
|
| 131 |
raise ValueError(
|
| 132 |
f"unknown mold system '{mold}'; available mold systems: {sorted(molds.keys())}")
|
| 133 |
-
mold_effusivity = molds[mold]
|
| 134 |
|
| 135 |
props = {
|
| 136 |
"k": float(material.k),
|
|
@@ -140,63 +152,19 @@ def resolve_props(alloy: str | None = None, mold: str = "green_sand",
|
|
| 140 |
"TL": float(material.TL),
|
| 141 |
"TS": float(material.TS),
|
| 142 |
"Tpour": float(material.Tpour),
|
| 143 |
-
"mold_effusivity": float(
|
| 144 |
}
|
| 145 |
_validate_tl_ts(props, f"alloy={alloy_id!r}")
|
| 146 |
return props
|
| 147 |
|
| 148 |
|
| 149 |
-
# --------------------------------------------------------------------------- #
|
| 150 |
-
# resolve_alloy_family (SP2 shrinkage Task 4, controller directive 2):
|
| 151 |
-
# `resolve_props`'s returned 8-key dict has no alloy-family field -- it is
|
| 152 |
-
# BUILT from `data/properties/materials.csv`'s own `family` column
|
| 153 |
-
# (`tools.doe.load_properties.load_materials` stores it on `Material.
|
| 154 |
-
# alloy_family`), but that value never makes it into `resolve_props`'s
|
| 155 |
-
# return dict, and `src.shrinkage.screen_casting` needs a family key from
|
| 156 |
-
# `src.shrinkage.SHRINKAGE_PCT` to look up the alloy's volumetric shrinkage
|
| 157 |
-
# percentage. Rather than reaching back into the materials table (which
|
| 158 |
-
# doesn't exist at all for a `--props` custom-JSON override), this maps the
|
| 159 |
-
# alloy GRADE NAME directly by prefix -- every named grade in
|
| 160 |
-
# `materials.csv` today falls into one of `SHRINKAGE_PCT`'s 4 keys via this
|
| 161 |
-
# same prefix convention (EN-GJL-* -> grey_iron, EN-GJS-* -> ductile_iron,
|
| 162 |
-
# GS-*/G17*/G20* -> low_alloy_steel), so this is not a guess for any grade
|
| 163 |
-
# actually in the property DB -- only for a name this helper has never seen
|
| 164 |
-
# (a future grade, a typo, or a `--props` override with no named grade at
|
| 165 |
-
# all) does it fall back, loudly (the caller records `alloy_family_assumed`
|
| 166 |
-
# rather than silently proceeding as if the mapping were exact).
|
| 167 |
-
# --------------------------------------------------------------------------- #
|
| 168 |
-
_ALLOY_FAMILY_PREFIXES = (
|
| 169 |
-
("EN-GJS", "ductile_iron"),
|
| 170 |
-
("EN-GJL", "grey_iron"),
|
| 171 |
-
("GS-", "low_alloy_steel"),
|
| 172 |
-
("G17", "low_alloy_steel"),
|
| 173 |
-
("G20", "low_alloy_steel"),
|
| 174 |
-
)
|
| 175 |
-
|
| 176 |
-
# The conservative (highest-shrinkage-percentage) fallback family for a grade
|
| 177 |
-
# name this helper doesn't recognize, or for a `--props` custom-JSON override
|
| 178 |
-
# (no grade name to map from at all) -- see the module comment above.
|
| 179 |
-
_UNKNOWN_ALLOY_FAMILY_FALLBACK = "steel"
|
| 180 |
-
|
| 181 |
-
|
| 182 |
def resolve_alloy_family(alloy: str | None, props_json: str | None = None) -> tuple[str, bool]:
|
| 183 |
-
"""
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
Returns `(family, assumed)`: `assumed` is `True` when the mapping is a
|
| 187 |
-
documented fallback rather than an exact name-prefix match -- either
|
| 188 |
-
because `props_json` was given (a raw-properties override has no named
|
| 189 |
-
grade to map from) or because `alloy` (or `DEFAULT_ALLOY`, if `alloy`
|
| 190 |
-
is `None`) doesn't start with any of the known prefixes. The caller is
|
| 191 |
-
expected to surface `assumed` (e.g. as `alloy_family_assumed` in the
|
| 192 |
-
shrinkage result) rather than silently treat a defaulted family as if it
|
| 193 |
-
were an exact match.
|
| 194 |
-
"""
|
| 195 |
if props_json is not None:
|
| 196 |
return _UNKNOWN_ALLOY_FAMILY_FALLBACK, True
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
return family, False
|
| 202 |
-
return _UNKNOWN_ALLOY_FAMILY_FALLBACK, True
|
|
|
|
| 1 |
+
"""Alloy and mold properties.
|
| 2 |
|
| 3 |
+
Turns a named alloy and mold (rows of `data/properties/materials.csv` and
|
| 4 |
+
`molds.csv`), or a JSON file with raw values, into the 8-key dict the model
|
| 5 |
+
reads:
|
| 6 |
|
| 7 |
{k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity}
|
| 8 |
|
| 9 |
+
Units: W/(m K), kg/m^3, J/(kg K), kJ/kg, deg C, W s^0.5/(m^2 K). The pouring
|
| 10 |
+
temperature of a named alloy is the midpoint of its tabulated range.
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import csv
|
| 15 |
import json
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
from pathlib import Path
|
| 18 |
|
|
|
|
|
|
|
| 19 |
DEFAULT_ALLOY = "EN-GJS-400-15"
|
| 20 |
+
DEFAULT_MOLD = "green_sand"
|
| 21 |
|
| 22 |
_REQUIRED_KEYS = ("k", "rho", "cp", "L_kJ_kg", "TL", "TS", "Tpour", "mold_effusivity")
|
| 23 |
|
| 24 |
_MATERIALS_PATH = "data/properties/materials.csv"
|
| 25 |
_MOLDS_PATH = "data/properties/molds.csv"
|
| 26 |
|
| 27 |
+
# Family used for shrinkage severity when the alloy family is unknown (a raw
|
| 28 |
+
# property file has no grade). Steel has the largest shrinkage, so this errs
|
| 29 |
+
# on the side of flagging.
|
| 30 |
+
_UNKNOWN_ALLOY_FAMILY_FALLBACK = "steel"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class Material:
|
| 35 |
+
material_id: str
|
| 36 |
+
alloy_family: str
|
| 37 |
+
k: float
|
| 38 |
+
rho: float
|
| 39 |
+
cp: float
|
| 40 |
+
L: float
|
| 41 |
+
TL: float
|
| 42 |
+
TS: float
|
| 43 |
+
Tpour: float
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def load_materials(path: str = _MATERIALS_PATH) -> list[Material]:
|
| 47 |
+
"""Rows of the materials table; rejects rows that are not TS < TL < Tpour."""
|
| 48 |
+
materials: list[Material] = []
|
| 49 |
+
with Path(path).open(newline="") as handle:
|
| 50 |
+
for row in csv.DictReader(handle):
|
| 51 |
+
material_id = row["material_id"]
|
| 52 |
+
TL = float(row["TL_C"])
|
| 53 |
+
TS = float(row["TS_C"])
|
| 54 |
+
tpour = 0.5 * (float(row["Tpour_lo_C"]) + float(row["Tpour_hi_C"]))
|
| 55 |
+
if not TS < TL < tpour:
|
| 56 |
+
raise ValueError(
|
| 57 |
+
f"material {material_id!r}: expected TS < TL < Tpour, "
|
| 58 |
+
f"got {TS}, {TL}, {tpour}")
|
| 59 |
+
materials.append(Material(
|
| 60 |
+
material_id=material_id,
|
| 61 |
+
alloy_family=row["family"],
|
| 62 |
+
k=float(row["k_W_mK"]),
|
| 63 |
+
rho=float(row["rho_kg_m3"]),
|
| 64 |
+
cp=float(row["cp_J_kgK"]),
|
| 65 |
+
L=float(row["L_kJ_kg"]),
|
| 66 |
+
TL=TL,
|
| 67 |
+
TS=TS,
|
| 68 |
+
Tpour=tpour,
|
| 69 |
+
))
|
| 70 |
+
return materials
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def load_molds(path: str = _MOLDS_PATH) -> dict[str, float]:
|
| 74 |
+
"""{mold_system: effusivity} from the molds table."""
|
| 75 |
+
with Path(path).open(newline="") as f:
|
| 76 |
+
return {row["mold_system"]: float(row["effusivity_W_s05_m2K"])
|
| 77 |
+
for row in csv.DictReader(f)}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
_materials_cache: dict[str, Material] | None = None
|
| 81 |
_molds_cache: dict[str, float] | None = None
|
| 82 |
|
| 83 |
|
| 84 |
+
def _materials() -> dict[str, Material]:
|
|
|
|
| 85 |
global _materials_cache
|
| 86 |
if _materials_cache is None:
|
| 87 |
+
_materials_cache = {m.material_id: m for m in load_materials()}
|
| 88 |
return _materials_cache
|
| 89 |
|
| 90 |
|
| 91 |
def _molds() -> dict[str, float]:
|
|
|
|
| 92 |
global _molds_cache
|
| 93 |
if _molds_cache is None:
|
| 94 |
+
_molds_cache = load_molds()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
return _molds_cache
|
| 96 |
|
| 97 |
|
| 98 |
def list_alloys() -> list[str]:
|
|
|
|
| 99 |
return list(_materials().keys())
|
| 100 |
|
| 101 |
|
| 102 |
def list_molds() -> list[str]:
|
|
|
|
| 103 |
return list(_molds().keys())
|
| 104 |
|
| 105 |
|
| 106 |
def _validate_tl_ts(props: dict, source: str) -> None:
|
| 107 |
+
"""An inverted or equal liquidus/solidus pair would make the Niyama proxy
|
| 108 |
+
zero everywhere and flag every node, so it is rejected up front."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
TL, TS = props["TL"], props["TS"]
|
| 110 |
if TL <= TS:
|
| 111 |
raise ValueError(
|
| 112 |
f"resolve_props ({source}): TL ({TL!r}) must be strictly greater than "
|
| 113 |
+
f"TS ({TS!r})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
+
def resolve_props(alloy: str | None = None, mold: str = DEFAULT_MOLD,
|
| 117 |
+
props_json: str | None = None) -> dict:
|
| 118 |
+
"""The 8-key property dict.
|
| 119 |
|
| 120 |
+
`props_json` replaces `alloy` and `mold`; the file must hold all 8 keys.
|
| 121 |
+
Otherwise `alloy` (default `DEFAULT_ALLOY`) and `mold` are looked up in
|
| 122 |
+
the tables, and unknown names raise ValueError listing the options."""
|
|
|
|
|
|
|
| 123 |
if props_json is not None:
|
| 124 |
with open(props_json) as f:
|
| 125 |
data = json.load(f)
|
|
|
|
| 143 |
if mold not in molds:
|
| 144 |
raise ValueError(
|
| 145 |
f"unknown mold system '{mold}'; available mold systems: {sorted(molds.keys())}")
|
|
|
|
| 146 |
|
| 147 |
props = {
|
| 148 |
"k": float(material.k),
|
|
|
|
| 152 |
"TL": float(material.TL),
|
| 153 |
"TS": float(material.TS),
|
| 154 |
"Tpour": float(material.Tpour),
|
| 155 |
+
"mold_effusivity": float(molds[mold]),
|
| 156 |
}
|
| 157 |
_validate_tl_ts(props, f"alloy={alloy_id!r}")
|
| 158 |
return props
|
| 159 |
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
def resolve_alloy_family(alloy: str | None, props_json: str | None = None) -> tuple[str, bool]:
|
| 162 |
+
"""(family, assumed) for shrinkage severity. The family comes from the
|
| 163 |
+
materials table; `assumed` is True when it had to fall back to steel
|
| 164 |
+
(a raw property file, or an alloy that is not in the table)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
if props_json is not None:
|
| 166 |
return _UNKNOWN_ALLOY_FAMILY_FALLBACK, True
|
| 167 |
+
material = _materials().get(alloy if alloy is not None else DEFAULT_ALLOY)
|
| 168 |
+
if material is None:
|
| 169 |
+
return _UNKNOWN_ALLOY_FAMILY_FALLBACK, True
|
| 170 |
+
return material.alloy_family, False
|
|
|
|
|
|
src/mesh_cache.py
CHANGED
|
@@ -1,57 +1,11 @@
|
|
| 1 |
-
"""Content-
|
| 2 |
-
|
| 3 |
-
Inference meshing (`tools.doe.prep_parts.heal_and_mesh`) takes minutes and is
|
| 4 |
-
routinely repeated for byte-identical STEP inputs (re-runs, batch backends,
|
| 5 |
-
concurrent workers). This module memoizes that call on disk, keyed on the
|
| 6 |
-
SHA-256 of the STEP file's bytes + `target_nodes` + `MESH_PIPELINE_VERSION`,
|
| 7 |
-
so any change to the meshing pipeline invalidates the cache automatically
|
| 8 |
-
(bump `MESH_PIPELINE_VERSION` in `tools/doe/prep_parts.py` whenever the mesh
|
| 9 |
-
output could change).
|
| 10 |
-
|
| 11 |
-
`heal_and_mesh` and `MESH_PIPELINE_VERSION` are imported at module level and
|
| 12 |
-
referenced dynamically (via `globals()`/module attribute lookup) so tests can
|
| 13 |
-
monkeypatch `mesh_cache.heal_and_mesh` / `mesh_cache.MESH_PIPELINE_VERSION`
|
| 14 |
-
without needing a real gmsh install.
|
| 15 |
-
|
| 16 |
-
Cache layout (under `cache_dir`, default `runs/mesh_cache/`):
|
| 17 |
-
<key>.msh -- the cached gmsh 2.2 mesh (only written when ok=True)
|
| 18 |
-
<key>.json -- meta dict (ok, n_nodes, ..., mesh_wall_s, pipeline_version,
|
| 19 |
-
source_path, created_at)
|
| 20 |
-
|
| 21 |
-
Writes are atomic and per-writer-unique (`<key>.msh.<pid>.<uuid>.tmp` /
|
| 22 |
-
`<key>.json.<pid>.<uuid>.tmp`, then `os.replace` onto the shared final path)
|
| 23 |
-
so concurrent workers racing on the same cache miss never share a tmp inode
|
| 24 |
-
(which would let their writes interleave and corrupt it) and never observe a
|
| 25 |
-
partially written file -- whichever replace lands last simply wins, and both
|
| 26 |
-
writers produced an equally valid, uncorrupted mesh for the same key.
|
| 27 |
-
|
| 28 |
-
A failed `heal_and_mesh` call (`ok=False`) is never cached: a fixed STEP file
|
| 29 |
-
(or a differently-tuned pipeline, which would change the key anyway) must be
|
| 30 |
-
retried, not permanently remembered as unmeshable.
|
| 31 |
-
|
| 32 |
-
Defeaturing auto-trigger (mesh-accel Task 5, 2026-07-29): `get_or_mesh`
|
| 33 |
-
decides whether to pass `defeature=True` to `heal_and_mesh` via a
|
| 34 |
-
deterministic, input-only policy, symmetric in both directions -- a STEP
|
| 35 |
-
file whose on-disk byte size exceeds `_BIG_FILE_THRESHOLD_BYTES` (8 MiB)
|
| 36 |
-
goes straight to a `defeature=True` attempt first (large real parts are,
|
| 37 |
-
empirically, the ones that need it -- see
|
| 38 |
-
`tools/doe/prep_parts.py::_apply_defeature`), and
|
| 39 |
-
only if that attempt fails outright (`ok=False`) retries ONCE more with
|
| 40 |
-
`defeature=False`; a smaller file does the mirror image -- plain
|
| 41 |
-
(`defeature=False`) first, retrying ONCE with `defeature=True` only on
|
| 42 |
-
outright failure. Either way there is at most one retry, ever. Because the
|
| 43 |
-
policy is a pure function of the input (file size, then a same-input
|
| 44 |
-
retry-on-failure), a given STEP file always resolves to the same
|
| 45 |
-
`defeature` choice across runs/workers -- so `defeature` does NOT need to
|
| 46 |
-
be (and is not) part of `mesh_cache_key`; the cache key stays exactly as
|
| 47 |
-
before this task. The returned meta dict always carries `defeature` (bool,
|
| 48 |
-
whichever value actually produced the returned result) and `retry` (bool,
|
| 49 |
-
True only when a second attempt actually ran, i.e. the first attempt --
|
| 50 |
-
big-file `defeature=True` or small-file `defeature=False` -- failed
|
| 51 |
-
outright; False whenever the first attempt already succeeded or the input
|
| 52 |
-
size never triggers the alternate branch).
|
| 53 |
-
"""
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
from __future__ import annotations
|
| 56 |
|
| 57 |
import hashlib
|
|
@@ -61,15 +15,13 @@ import shutil
|
|
| 61 |
import time
|
| 62 |
import uuid
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
|
| 66 |
_HASH_CHUNK_SIZE = 1 << 20 # 1 MiB
|
| 67 |
-
#
|
| 68 |
-
#
|
| 69 |
-
# of `mesh_cache_key`, so changing this value changes what mesh a given STEP
|
| 70 |
-
# file resolves to WITHOUT changing its cache key -- any change here requires
|
| 71 |
-
# bumping `MESH_PIPELINE_VERSION` (tools/doe/prep_parts.py) to invalidate
|
| 72 |
-
# stale cache entries. Cross-file contract; keep in sync with prep_parts.py.
|
| 73 |
_BIG_FILE_THRESHOLD_BYTES = 8 * 1024 * 1024 # 8 MiB
|
| 74 |
|
| 75 |
|
|
@@ -84,35 +36,16 @@ def _sha256_file(path: str) -> str:
|
|
| 84 |
return h.hexdigest()
|
| 85 |
|
| 86 |
|
| 87 |
-
def mesh_cache_key(step_path: str, target_nodes: int = DEFAULT_TARGET_NODES
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
MESH_PIPELINE_VERSION + bodies_key, so the key changes if any of those
|
| 91 |
-
change. Reads `MESH_PIPELINE_VERSION` dynamically from module globals so
|
| 92 |
-
tests can monkeypatch it.
|
| 93 |
-
|
| 94 |
-
`bodies_key` (Part B Task 4): a caller-supplied string folding in the
|
| 95 |
-
Part-B multi-body role assignment (see
|
| 96 |
-
`src.predict_casting._bodies_cache_key`) -- default `"none"` for the
|
| 97 |
-
ordinary single-body inference path, so an ordinary call's key is
|
| 98 |
-
unaffected by this parameter's mere existence (a bodies-aware and a
|
| 99 |
-
bodies-less call for the SAME step_path/target_nodes only collide if
|
| 100 |
-
the bodies-aware caller explicitly passes `bodies_key="none"`, which it
|
| 101 |
-
never does -- `_bodies_cache_key` always produces a real hash).
|
| 102 |
-
"""
|
| 103 |
digest = _sha256_file(step_path)
|
| 104 |
pipeline_version = globals()["MESH_PIPELINE_VERSION"]
|
| 105 |
-
key_input = f"{digest}:{int(target_nodes)}:{int(pipeline_version)}
|
| 106 |
return hashlib.sha256(key_input).hexdigest()
|
| 107 |
|
| 108 |
|
| 109 |
def _unique_tmp_path(final_path: str) -> str:
|
| 110 |
-
"""A per-writer-unique tmp sibling of `final_path`. Two workers racing on
|
| 111 |
-
the same cache miss must never write the same tmp inode (that would let
|
| 112 |
-
their writes interleave and corrupt it before either `os.replace` runs) --
|
| 113 |
-
each caller gets its own pid+uuid-suffixed tmp file, then atomically
|
| 114 |
-
replaces the SAME shared final path (last replace wins, and both writers
|
| 115 |
-
produced a fully-formed, uncorrupted file)."""
|
| 116 |
return f"{final_path}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
|
| 117 |
|
| 118 |
|
|
@@ -124,45 +57,17 @@ def _paths(cache_dir: str, key: str) -> tuple[str, str]:
|
|
| 124 |
|
| 125 |
|
| 126 |
def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET_NODES,
|
| 127 |
-
cache_dir: str = "runs/mesh_cache"
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
the miss that created it, see below).
|
| 136 |
-
- Miss + `ok=True`: call `heal_and_mesh` (forwarding `keep_solid_id`),
|
| 137 |
-
then -- if `post_mesh` is given -- call `post_mesh(out_msh) -> dict`
|
| 138 |
-
and merge its return into the meta, THEN atomically store both the
|
| 139 |
-
(now possibly `post_mesh`-modified) mesh and its meta json under the
|
| 140 |
-
cache key, and return the meta dict plus `{"cache": "miss"}`.
|
| 141 |
-
- Miss + `ok=False`: store nothing, return `heal_and_mesh`'s dict
|
| 142 |
-
unchanged plus `{"cache": "miss"}` -- failures are never cached, and
|
| 143 |
-
`post_mesh` is never called on a failed mesh.
|
| 144 |
-
|
| 145 |
-
`keep_solid_id` (Part B Task 4): the user-designated casting solid, when
|
| 146 |
-
a multi-body STEP's role assignment has already picked which solid IS
|
| 147 |
-
the casting -- forwarded verbatim to `heal_and_mesh`, in place of the
|
| 148 |
-
largest-solid default (`None`, unchanged).
|
| 149 |
-
|
| 150 |
-
`bodies_key` (Part B Task 4): folded into `mesh_cache_key` (see there)
|
| 151 |
-
so a role-assignment change (a different `bodies.json`, a registry edit
|
| 152 |
-
that changes a resolved BC scalar, etc.) is a genuinely different cache
|
| 153 |
-
entry, never a stale hit.
|
| 154 |
-
|
| 155 |
-
`post_mesh` (Part B Task 4): an optional `(out_msh) -> dict` hook run
|
| 156 |
-
on a cache MISS, after `heal_and_mesh` succeeds and BEFORE the mesh is
|
| 157 |
-
stored -- e.g. `src.assembly.tag_contact_patches`, which rewrites
|
| 158 |
-
`out_msh` in place (Cooling -> Adiabatic contact patch) and returns
|
| 159 |
-
`{"per_body_patch_area_mm2": ..., "n_adiab_tris": ...}`. Tagging cost is
|
| 160 |
-
not trivial on big meshes, so the TAGGED mesh is what gets cached (the
|
| 161 |
-
bodies hash is already in the key) -- a later hit for the same
|
| 162 |
-
`bodies_key` returns the tagged mesh directly, with no re-tagging.
|
| 163 |
"""
|
| 164 |
os.makedirs(cache_dir, exist_ok=True)
|
| 165 |
-
key = mesh_cache_key(step_path, target_nodes
|
| 166 |
msh_path, json_path = _paths(cache_dir, key)
|
| 167 |
|
| 168 |
if os.path.exists(msh_path) and os.path.exists(json_path):
|
|
@@ -177,26 +82,16 @@ def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET
|
|
| 177 |
t0 = time.time()
|
| 178 |
big_file = os.path.getsize(step_path) > _BIG_FILE_THRESHOLD_BYTES
|
| 179 |
if big_file:
|
| 180 |
-
|
| 181 |
-
# point burning a near-certain-to-fail plain pass first. But if
|
| 182 |
-
# even that fails outright, retry ONCE with defeature=False --
|
| 183 |
-
# symmetric with the small-file policy below, so a big file is
|
| 184 |
-
# never left with only one attempt while a small file gets two.
|
| 185 |
-
meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=True,
|
| 186 |
-
keep_solid_id=keep_solid_id)
|
| 187 |
defeature_used, retry = True, False
|
| 188 |
if not meta.get("ok"):
|
| 189 |
-
meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=False
|
| 190 |
-
keep_solid_id=keep_solid_id)
|
| 191 |
defeature_used, retry = False, True
|
| 192 |
else:
|
| 193 |
-
meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=False
|
| 194 |
-
keep_solid_id=keep_solid_id)
|
| 195 |
defeature_used, retry = False, False
|
| 196 |
if not meta.get("ok"):
|
| 197 |
-
|
| 198 |
-
meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=True,
|
| 199 |
-
keep_solid_id=keep_solid_id)
|
| 200 |
defeature_used, retry = True, True
|
| 201 |
wall_s = time.time() - t0
|
| 202 |
|
|
@@ -207,10 +102,7 @@ def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET
|
|
| 207 |
result["retry"] = retry
|
| 208 |
return result
|
| 209 |
|
| 210 |
-
post_meta = post_mesh(out_msh) if post_mesh is not None else {}
|
| 211 |
-
|
| 212 |
stored_meta = dict(meta)
|
| 213 |
-
stored_meta.update(post_meta)
|
| 214 |
stored_meta["mesh_wall_s"] = wall_s
|
| 215 |
stored_meta["pipeline_version"] = globals()["MESH_PIPELINE_VERSION"]
|
| 216 |
stored_meta["source_path"] = os.path.abspath(step_path)
|
|
@@ -220,7 +112,7 @@ def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET
|
|
| 220 |
|
| 221 |
msh_tmp = _unique_tmp_path(msh_path)
|
| 222 |
json_tmp = _unique_tmp_path(json_path)
|
| 223 |
-
shutil.copyfile(out_msh, msh_tmp)
|
| 224 |
os.replace(msh_tmp, msh_path)
|
| 225 |
with open(json_tmp, "w") as f:
|
| 226 |
json.dump(stored_meta, f)
|
|
|
|
| 1 |
+
"""Content-addressed on-disk cache for `src.meshing.heal_and_mesh`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
The key is sha256(STEP bytes) + target node count + `MESH_PIPELINE_VERSION`,
|
| 4 |
+
so a byte-identical STEP file is meshed once. Each entry is a pair
|
| 5 |
+
`<key>.msh` / `<key>.json` (the mesh and its meta dict), written through a
|
| 6 |
+
per-writer temporary file and `os.replace`, so concurrent writers never leave
|
| 7 |
+
a half-written entry. Failed meshes are not cached.
|
| 8 |
+
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import hashlib
|
|
|
|
| 15 |
import time
|
| 16 |
import uuid
|
| 17 |
|
| 18 |
+
# MESH_PIPELINE_VERSION and heal_and_mesh are read through globals() so tests
|
| 19 |
+
# can monkeypatch them.
|
| 20 |
+
from src.meshing import DEFAULT_TARGET_NODES, MESH_PIPELINE_VERSION, heal_and_mesh # noqa: F401
|
| 21 |
|
| 22 |
_HASH_CHUNK_SIZE = 1 << 20 # 1 MiB
|
| 23 |
+
# STEP files above this size get the defeaturing attempt first. Changing it
|
| 24 |
+
# changes which mesh a file resolves to, so bump MESH_PIPELINE_VERSION too.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
_BIG_FILE_THRESHOLD_BYTES = 8 * 1024 * 1024 # 8 MiB
|
| 26 |
|
| 27 |
|
|
|
|
| 36 |
return h.hexdigest()
|
| 37 |
|
| 38 |
|
| 39 |
+
def mesh_cache_key(step_path: str, target_nodes: int = DEFAULT_TARGET_NODES) -> str:
|
| 40 |
+
"""sha256 over the file digest, `target_nodes` and the pipeline version.
|
| 41 |
+
The version is read from module globals so tests can monkeypatch it."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
digest = _sha256_file(step_path)
|
| 43 |
pipeline_version = globals()["MESH_PIPELINE_VERSION"]
|
| 44 |
+
key_input = f"{digest}:{int(target_nodes)}:{int(pipeline_version)}".encode("utf-8")
|
| 45 |
return hashlib.sha256(key_input).hexdigest()
|
| 46 |
|
| 47 |
|
| 48 |
def _unique_tmp_path(final_path: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
return f"{final_path}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
|
| 50 |
|
| 51 |
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
def get_or_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET_NODES,
|
| 60 |
+
cache_dir: str = "runs/mesh_cache") -> dict:
|
| 61 |
+
"""`heal_and_mesh`'s meta dict for `step_path`, plus a `"cache"` key.
|
| 62 |
+
|
| 63 |
+
On a hit the cached mesh is copied to `out_msh` (`"cache": "hit"`). On a
|
| 64 |
+
miss the part is meshed; a successful mesh is stored and returned with
|
| 65 |
+
`"cache": "miss"`, a failed one is returned unchanged and not stored.
|
| 66 |
+
Small files try a plain pass first and retry once with defeaturing; files
|
| 67 |
+
above 8 MiB try the defeatured pass first and retry once without it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
"""
|
| 69 |
os.makedirs(cache_dir, exist_ok=True)
|
| 70 |
+
key = mesh_cache_key(step_path, target_nodes)
|
| 71 |
msh_path, json_path = _paths(cache_dir, key)
|
| 72 |
|
| 73 |
if os.path.exists(msh_path) and os.path.exists(json_path):
|
|
|
|
| 82 |
t0 = time.time()
|
| 83 |
big_file = os.path.getsize(step_path) > _BIG_FILE_THRESHOLD_BYTES
|
| 84 |
if big_file:
|
| 85 |
+
meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
defeature_used, retry = True, False
|
| 87 |
if not meta.get("ok"):
|
| 88 |
+
meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=False)
|
|
|
|
| 89 |
defeature_used, retry = False, True
|
| 90 |
else:
|
| 91 |
+
meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=False)
|
|
|
|
| 92 |
defeature_used, retry = False, False
|
| 93 |
if not meta.get("ok"):
|
| 94 |
+
meta = heal_and_mesh_fn(step_path, out_msh, target_nodes=target_nodes, defeature=True)
|
|
|
|
|
|
|
| 95 |
defeature_used, retry = True, True
|
| 96 |
wall_s = time.time() - t0
|
| 97 |
|
|
|
|
| 102 |
result["retry"] = retry
|
| 103 |
return result
|
| 104 |
|
|
|
|
|
|
|
| 105 |
stored_meta = dict(meta)
|
|
|
|
| 106 |
stored_meta["mesh_wall_s"] = wall_s
|
| 107 |
stored_meta["pipeline_version"] = globals()["MESH_PIPELINE_VERSION"]
|
| 108 |
stored_meta["source_path"] = os.path.abspath(step_path)
|
|
|
|
| 112 |
|
| 113 |
msh_tmp = _unique_tmp_path(msh_path)
|
| 114 |
json_tmp = _unique_tmp_path(json_path)
|
| 115 |
+
shutil.copyfile(out_msh, msh_tmp)
|
| 116 |
os.replace(msh_tmp, msh_path)
|
| 117 |
with open(json_tmp, "w") as f:
|
| 118 |
json.dump(stored_meta, f)
|
src/meshing.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""STEP to tetrahedral mesh with Gmsh (OpenCASCADE kernel).
|
| 2 |
+
|
| 3 |
+
`heal_and_mesh` is the default path: import the STEP file, keep the largest
|
| 4 |
+
solid, tag every surface "Cooling" and the volume "Volume", and search the
|
| 5 |
+
element size until the mesh lands near `target_nodes` (the density the model
|
| 6 |
+
was trained on). If the unhealed geometry cannot be meshed, a second pass heals
|
| 7 |
+
it and retries. `mesh_step` meshes at one fixed element size instead.
|
| 8 |
+
|
| 9 |
+
Both write gmsh 2.2 ASCII meshes, the format `src.msh_parse` reads. All
|
| 10 |
+
lengths are in millimetres.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
DEFAULT_TARGET_NODES = 8500
|
| 17 |
+
# Part of the mesh-cache key (src/mesh_cache.py): bump it whenever a change
|
| 18 |
+
# here would produce a different mesh for the same STEP file.
|
| 19 |
+
MESH_PIPELINE_VERSION = 3
|
| 20 |
+
|
| 21 |
+
# Element-size seed: sqrt(K_SURF * surface_area / target_nodes), calibrated so
|
| 22 |
+
# the first guess usually lands within the density search's tolerance.
|
| 23 |
+
K_SURF = 2.5
|
| 24 |
+
|
| 25 |
+
# Physical-group tags are pinned (not auto-assigned) so that repeated meshing
|
| 26 |
+
# attempts in one Gmsh session keep the "Volume"/"Cooling" names bound.
|
| 27 |
+
_VOLUME_PHYS_TAG = 1
|
| 28 |
+
_COOLING_PHYS_TAG = 2
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _reduce_to_largest_solid():
|
| 32 |
+
"""Keep only the largest solid of the imported OCC model.
|
| 33 |
+
|
| 34 |
+
Returns (n_solids_in, kept_tag, kept_volume_mm3)."""
|
| 35 |
+
import gmsh
|
| 36 |
+
|
| 37 |
+
vols = [t for (d, t) in gmsh.model.occ.getEntities(3)]
|
| 38 |
+
n_in = len(vols)
|
| 39 |
+
if n_in == 0:
|
| 40 |
+
raise ValueError("no 3D solids found in STEP import")
|
| 41 |
+
masses = [(t, gmsh.model.occ.getMass(3, t)) for t in vols]
|
| 42 |
+
kept_tag, kept_vol = max(masses, key=lambda tv: tv[1])
|
| 43 |
+
if n_in > 1:
|
| 44 |
+
drop = [(3, t) for (t, _v) in masses if t != kept_tag]
|
| 45 |
+
gmsh.model.occ.remove(drop, recursive=True)
|
| 46 |
+
gmsh.model.occ.synchronize()
|
| 47 |
+
return n_in, kept_tag, float(kept_vol)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _import_and_reduce(step_path: str) -> tuple[int, float, str | None]:
|
| 51 |
+
"""Import `step_path` into the current Gmsh session and keep its largest
|
| 52 |
+
solid. Returns (n_solids_in, kept_volume_mm3, error_or_None)."""
|
| 53 |
+
import gmsh
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
gmsh.model.occ.importShapes(step_path)
|
| 57 |
+
gmsh.model.occ.synchronize()
|
| 58 |
+
except Exception as e:
|
| 59 |
+
return 0, 0.0, f"import failed: {e}"
|
| 60 |
+
try:
|
| 61 |
+
n_solids, _kept_tag, volume_mm3 = _reduce_to_largest_solid()
|
| 62 |
+
except Exception as e:
|
| 63 |
+
return 0, 0.0, f"largest-solid reduction failed: {e}"
|
| 64 |
+
return n_solids, volume_mm3, None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _elem_size(bbox_diag_mm: float) -> float:
|
| 68 |
+
"""Fallback element-size seed: bounding-box diagonal / 28."""
|
| 69 |
+
return bbox_diag_mm / 28.0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _elem_size_from_area(area_mm2: float, target_nodes: int) -> float:
|
| 73 |
+
"""Element-size seed from the total surface area of the model."""
|
| 74 |
+
return float(np.sqrt(K_SURF * area_mm2 / target_nodes))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _total_surface_area_mm2() -> float:
|
| 78 |
+
"""Total area of all surface entities of the current OCC model."""
|
| 79 |
+
import gmsh
|
| 80 |
+
|
| 81 |
+
surfs = [t for (d, t) in gmsh.model.occ.getEntities(2)]
|
| 82 |
+
return float(sum(gmsh.model.occ.getMass(2, t) for t in surfs))
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _first_guess_elem_size(bbox_diag_mm: float, target_nodes: int) -> float:
|
| 86 |
+
"""Surface-area seed, or the bounding-box seed if the area query fails."""
|
| 87 |
+
try:
|
| 88 |
+
area_mm2 = _total_surface_area_mm2()
|
| 89 |
+
except Exception as e:
|
| 90 |
+
print(f"[meshing] WARNING: OCC surface-area query failed ({e}); "
|
| 91 |
+
f"falling back to bbox_diag/28 seed", flush=True)
|
| 92 |
+
return _elem_size(bbox_diag_mm)
|
| 93 |
+
if area_mm2 <= 0:
|
| 94 |
+
print(f"[meshing] WARNING: OCC surface-area query returned "
|
| 95 |
+
f"{area_mm2!r} (<=0); falling back to bbox_diag/28 seed", flush=True)
|
| 96 |
+
return _elem_size(bbox_diag_mm)
|
| 97 |
+
return _elem_size_from_area(area_mm2, target_nodes)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _search_elem_size(build_fn, out_msh: str, target_nodes: int,
|
| 101 |
+
max_iter: int = 3) -> tuple[int, float]:
|
| 102 |
+
"""Refine the element size assuming the node count scales as h^-3."""
|
| 103 |
+
n_nodes, elem_size = build_fn(out_msh, None)
|
| 104 |
+
for _ in range(max_iter - 1):
|
| 105 |
+
if n_nodes <= 0:
|
| 106 |
+
break
|
| 107 |
+
elem_size = elem_size * (n_nodes / target_nodes) ** (1.0 / 3.0)
|
| 108 |
+
n_nodes, elem_size = build_fn(out_msh, elem_size)
|
| 109 |
+
return n_nodes, elem_size
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _search_target_nodes(build_fn, out_msh: str, target_nodes: int,
|
| 113 |
+
tol: float = 0.35, max_iter: int = 3) -> tuple[int, float, int]:
|
| 114 |
+
"""Density search towards `target_nodes`.
|
| 115 |
+
|
| 116 |
+
`build_fn(out_msh, elem_size) -> (n_nodes, elem_size_used)`, with
|
| 117 |
+
`elem_size=None` meaning "use the seed" and `n_nodes == 0` meaning the
|
| 118 |
+
mesh failed outright. Stops after the first attempt if it is already
|
| 119 |
+
within `tol` of the target (or failed); otherwise refines for up to
|
| 120 |
+
`max_iter` attempts. Returns (n_nodes, elem_size_used, attempts)."""
|
| 121 |
+
calls = {"n": 0}
|
| 122 |
+
|
| 123 |
+
def counted_build_fn(msh_path, elem_size):
|
| 124 |
+
calls["n"] += 1
|
| 125 |
+
return build_fn(msh_path, elem_size)
|
| 126 |
+
|
| 127 |
+
n0, es0 = counted_build_fn(out_msh, None)
|
| 128 |
+
if n0 <= 0 or abs(n0 - target_nodes) <= tol * target_nodes:
|
| 129 |
+
return n0, es0, calls["n"]
|
| 130 |
+
n, es = _search_elem_size(counted_build_fn, out_msh, target_nodes, max_iter=max_iter)
|
| 131 |
+
return n, es, calls["n"]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _tag_and_mesh(size: float, out_msh: str) -> tuple[bool, int, str]:
|
| 135 |
+
"""Tag the current model ("Volume" + every surface as "Cooling") and mesh
|
| 136 |
+
it at element size `size`. Returns (ok, n_nodes, note).
|
| 137 |
+
|
| 138 |
+
The tag numbers are pinned: Gmsh keeps a removed group's *name* bound to
|
| 139 |
+
its old tag, so auto-assigned tags on a second attempt in the same
|
| 140 |
+
session would leave the written mesh with unnamed groups."""
|
| 141 |
+
import gmsh
|
| 142 |
+
|
| 143 |
+
gmsh.model.mesh.clear()
|
| 144 |
+
gmsh.model.removePhysicalGroups()
|
| 145 |
+
vols = [t for (d, t) in gmsh.model.getEntities(3)]
|
| 146 |
+
surfs = [t for (d, t) in gmsh.model.getEntities(2)]
|
| 147 |
+
if not vols:
|
| 148 |
+
return False, 0, "no volume entities to mesh"
|
| 149 |
+
pv = gmsh.model.addPhysicalGroup(3, vols, tag=_VOLUME_PHYS_TAG)
|
| 150 |
+
gmsh.model.setPhysicalName(3, pv, "Volume")
|
| 151 |
+
pc = gmsh.model.addPhysicalGroup(2, surfs, tag=_COOLING_PHYS_TAG)
|
| 152 |
+
gmsh.model.setPhysicalName(2, pc, "Cooling")
|
| 153 |
+
# The mesh reader matches elements by group name, so an unnamed group
|
| 154 |
+
# would silently look like a mesh without boundary. Fail loudly instead.
|
| 155 |
+
if (gmsh.model.getPhysicalName(3, pv) != "Volume"
|
| 156 |
+
or gmsh.model.getPhysicalName(2, pc) != "Cooling"):
|
| 157 |
+
return False, 0, ("physical-group tagging did not register "
|
| 158 |
+
"(Volume/Cooling name binding missing)")
|
| 159 |
+
gmsh.option.setNumber("Mesh.MeshSizeMin", size)
|
| 160 |
+
gmsh.option.setNumber("Mesh.MeshSizeMax", size)
|
| 161 |
+
try:
|
| 162 |
+
gmsh.model.mesh.generate(3)
|
| 163 |
+
node_tags, _, _ = gmsh.model.mesh.getNodes()
|
| 164 |
+
try:
|
| 165 |
+
tet_tags = gmsh.model.mesh.getElementsByType(4)[0]
|
| 166 |
+
except Exception:
|
| 167 |
+
tet_tags = []
|
| 168 |
+
if len(node_tags) > 0 and len(tet_tags) > 0:
|
| 169 |
+
gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
|
| 170 |
+
gmsh.write(out_msh)
|
| 171 |
+
return True, len(node_tags), ""
|
| 172 |
+
return False, 0, f"empty mesh at size={size:.2f}mm"
|
| 173 |
+
except Exception as e:
|
| 174 |
+
return False, 0, f"mesh generate failed at size={size:.2f}mm: {e}"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _apply_heal() -> tuple[bool, str]:
|
| 178 |
+
"""Run OCC shape healing. `healShapes()` can finish without raising and
|
| 179 |
+
still leave no solid, so a heal only counts if a volume survives.
|
| 180 |
+
Returns (effective, note)."""
|
| 181 |
+
import gmsh
|
| 182 |
+
|
| 183 |
+
try:
|
| 184 |
+
gmsh.model.occ.healShapes()
|
| 185 |
+
gmsh.model.occ.synchronize()
|
| 186 |
+
except Exception as e:
|
| 187 |
+
return False, f"healShapes raised: {e}"
|
| 188 |
+
n_vols = len(gmsh.model.getEntities(3))
|
| 189 |
+
if n_vols == 0:
|
| 190 |
+
return False, ("healShapes ran but yielded 0 volume entities (solid could not be "
|
| 191 |
+
"reconstructed -- likely a self-intersecting B-rep)")
|
| 192 |
+
return True, ""
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _apply_defeature() -> tuple[bool, str]:
|
| 196 |
+
"""Merge literally duplicate OCC entities (`removeAllDuplicates`), a
|
| 197 |
+
cheap clean-up for CAD exports. Returns (ok, note); not ok if the call
|
| 198 |
+
raises or the solid is lost."""
|
| 199 |
+
import gmsh
|
| 200 |
+
|
| 201 |
+
n_vols_before = len(gmsh.model.getEntities(3))
|
| 202 |
+
try:
|
| 203 |
+
gmsh.model.occ.removeAllDuplicates()
|
| 204 |
+
gmsh.model.occ.synchronize()
|
| 205 |
+
except Exception as e:
|
| 206 |
+
return False, f"defeature (removeAllDuplicates) raised: {e}"
|
| 207 |
+
n_vols_after = len(gmsh.model.getEntities(3))
|
| 208 |
+
if n_vols_before > 0 and n_vols_after == 0:
|
| 209 |
+
return False, "defeature (removeAllDuplicates) yielded 0 volume entities (solid lost)"
|
| 210 |
+
return True, ""
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def heal_and_mesh(step_path: str, out_msh: str, target_nodes: int = DEFAULT_TARGET_NODES,
|
| 214 |
+
defeature: bool = False) -> dict:
|
| 215 |
+
"""Mesh the largest solid of `step_path` near `target_nodes` nodes.
|
| 216 |
+
|
| 217 |
+
Pass 1 meshes the geometry as imported (healing can destroy a solid
|
| 218 |
+
that would have meshed fine, so it is not applied by default). Only if
|
| 219 |
+
pass 1 fails outright does pass 2, in a fresh Gmsh session, heal the
|
| 220 |
+
geometry and retry the same density search. `defeature=True` runs
|
| 221 |
+
`_apply_defeature` before meshing in both passes.
|
| 222 |
+
|
| 223 |
+
Returns {n_solids, volume_mm3, bbox_diag_mm, n_nodes, ok, healed, notes,
|
| 224 |
+
attempts}; `notes` collects human-readable diagnostics.
|
| 225 |
+
"""
|
| 226 |
+
import gmsh
|
| 227 |
+
|
| 228 |
+
notes: list[str] = []
|
| 229 |
+
|
| 230 |
+
# ---- Pass 1: unhealed ----
|
| 231 |
+
gmsh.initialize()
|
| 232 |
+
try:
|
| 233 |
+
gmsh.option.setNumber("General.Terminal", 0)
|
| 234 |
+
gmsh.model.add("part")
|
| 235 |
+
n_solids, volume_mm3, err = _import_and_reduce(step_path)
|
| 236 |
+
if err is not None:
|
| 237 |
+
notes.append(err)
|
| 238 |
+
return dict(n_solids=0, volume_mm3=0.0, bbox_diag_mm=0.0, n_nodes=0,
|
| 239 |
+
ok=False, healed=False, notes="; ".join(notes), attempts=0)
|
| 240 |
+
if n_solids > 1:
|
| 241 |
+
notes.append(f"multi-body: kept largest of {n_solids} solids (vol={volume_mm3:.1f} mm3)")
|
| 242 |
+
|
| 243 |
+
if defeature:
|
| 244 |
+
defeature_ok, defeature_note = _apply_defeature()
|
| 245 |
+
if defeature_note:
|
| 246 |
+
notes.append(defeature_note)
|
| 247 |
+
if not defeature_ok:
|
| 248 |
+
return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=0.0,
|
| 249 |
+
n_nodes=0, ok=False, healed=False, notes="; ".join(notes),
|
| 250 |
+
attempts=0)
|
| 251 |
+
|
| 252 |
+
xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
|
| 253 |
+
bbox_diag_mm = float(np.linalg.norm([xmax - xmin, ymax - ymin, zmax - zmin]))
|
| 254 |
+
base_size = _first_guess_elem_size(bbox_diag_mm, target_nodes)
|
| 255 |
+
|
| 256 |
+
fail_notes: list[str] = []
|
| 257 |
+
|
| 258 |
+
def build_fn(msh_path, elem_size):
|
| 259 |
+
size = elem_size if elem_size is not None else base_size
|
| 260 |
+
ok_, n_nodes_, note_ = _tag_and_mesh(size, msh_path)
|
| 261 |
+
if not ok_:
|
| 262 |
+
fail_notes.append(note_)
|
| 263 |
+
return (n_nodes_ if ok_ else 0), size
|
| 264 |
+
|
| 265 |
+
n_nodes, _used_size, attempts = _search_target_nodes(build_fn, out_msh, target_nodes)
|
| 266 |
+
if n_nodes > 0:
|
| 267 |
+
return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
|
| 268 |
+
n_nodes=n_nodes, ok=True, healed=False, notes="; ".join(notes),
|
| 269 |
+
attempts=attempts)
|
| 270 |
+
notes.append(f"unhealed mesh attempt failed: {fail_notes[-1] if fail_notes else 'unknown'}")
|
| 271 |
+
finally:
|
| 272 |
+
gmsh.finalize()
|
| 273 |
+
|
| 274 |
+
# ---- Pass 2: heal + density-searched retry (fresh session) ----
|
| 275 |
+
healed = False
|
| 276 |
+
gmsh.initialize()
|
| 277 |
+
try:
|
| 278 |
+
gmsh.option.setNumber("General.Terminal", 0)
|
| 279 |
+
gmsh.model.add("part_healed")
|
| 280 |
+
n_solids2, volume_mm32, err2 = _import_and_reduce(step_path)
|
| 281 |
+
if err2 is not None:
|
| 282 |
+
notes.append(f"pass-2 {err2}")
|
| 283 |
+
return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
|
| 284 |
+
n_nodes=0, ok=False, healed=False, notes="; ".join(notes),
|
| 285 |
+
attempts=attempts)
|
| 286 |
+
|
| 287 |
+
if defeature:
|
| 288 |
+
defeature_ok2, defeature_note2 = _apply_defeature()
|
| 289 |
+
if defeature_note2:
|
| 290 |
+
notes.append(defeature_note2)
|
| 291 |
+
if not defeature_ok2:
|
| 292 |
+
return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
|
| 293 |
+
n_nodes=0, ok=False, healed=False, notes="; ".join(notes),
|
| 294 |
+
attempts=attempts)
|
| 295 |
+
|
| 296 |
+
heal_ok, heal_note = _apply_heal()
|
| 297 |
+
if heal_note:
|
| 298 |
+
notes.append(heal_note)
|
| 299 |
+
if not heal_ok:
|
| 300 |
+
notes.append("meshing failed after heal attempt")
|
| 301 |
+
return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
|
| 302 |
+
n_nodes=0, ok=False, healed=False, notes="; ".join(notes),
|
| 303 |
+
attempts=attempts)
|
| 304 |
+
healed = True
|
| 305 |
+
|
| 306 |
+
fail_notes2: list[str] = []
|
| 307 |
+
|
| 308 |
+
def build_fn2(msh_path, elem_size):
|
| 309 |
+
size = elem_size if elem_size is not None else base_size
|
| 310 |
+
ok_, n_nodes_, note_ = _tag_and_mesh(size, msh_path)
|
| 311 |
+
if not ok_:
|
| 312 |
+
fail_notes2.append(note_)
|
| 313 |
+
return (n_nodes_ if ok_ else 0), size
|
| 314 |
+
|
| 315 |
+
n_nodes2, _used_size2, attempts2 = _search_target_nodes(build_fn2, out_msh, target_nodes)
|
| 316 |
+
if n_nodes2 > 0:
|
| 317 |
+
notes.append("meshed after heal")
|
| 318 |
+
return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
|
| 319 |
+
n_nodes=n_nodes2, ok=True, healed=True, notes="; ".join(notes),
|
| 320 |
+
attempts=attempts + attempts2)
|
| 321 |
+
notes.append(f"post-heal mesh attempt failed: {fail_notes2[-1] if fail_notes2 else 'unknown'}")
|
| 322 |
+
notes.append("meshing failed after heal attempt")
|
| 323 |
+
return dict(n_solids=n_solids, volume_mm3=volume_mm3, bbox_diag_mm=bbox_diag_mm,
|
| 324 |
+
n_nodes=0, ok=False, healed=healed, notes="; ".join(notes),
|
| 325 |
+
attempts=attempts + attempts2)
|
| 326 |
+
finally:
|
| 327 |
+
gmsh.finalize()
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def auto_elem_size(bbox_diag: float) -> float:
|
| 331 |
+
"""Default fixed element size for `mesh_step`: bounding-box diagonal / 60."""
|
| 332 |
+
return bbox_diag / 60.0
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def mesh_step(step_path: str, out_msh: str, elem_size: float | None = None) -> dict:
|
| 336 |
+
"""Mesh the whole STEP model at one fixed element size (no healing, no
|
| 337 |
+
density search). Every surface is tagged "Cooling"."""
|
| 338 |
+
import gmsh
|
| 339 |
+
|
| 340 |
+
gmsh.initialize()
|
| 341 |
+
try:
|
| 342 |
+
gmsh.option.setNumber("General.Terminal", 0)
|
| 343 |
+
gmsh.model.add("casting")
|
| 344 |
+
gmsh.model.occ.importShapes(step_path)
|
| 345 |
+
gmsh.model.occ.synchronize()
|
| 346 |
+
|
| 347 |
+
xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
|
| 348 |
+
bbox = (xmax - xmin, ymax - ymin, zmax - zmin)
|
| 349 |
+
diag = float(np.linalg.norm(bbox))
|
| 350 |
+
size = float(elem_size) if elem_size is not None else auto_elem_size(diag)
|
| 351 |
+
gmsh.option.setNumber("Mesh.MeshSizeMin", size)
|
| 352 |
+
gmsh.option.setNumber("Mesh.MeshSizeMax", size)
|
| 353 |
+
|
| 354 |
+
vols = [t for (d, t) in gmsh.model.getEntities(3)]
|
| 355 |
+
surfs = [t for (d, t) in gmsh.model.getEntities(2)]
|
| 356 |
+
pv = gmsh.model.addPhysicalGroup(3, vols); gmsh.model.setPhysicalName(3, pv, "Volume")
|
| 357 |
+
pc = gmsh.model.addPhysicalGroup(2, surfs); gmsh.model.setPhysicalName(2, pc, "Cooling")
|
| 358 |
+
|
| 359 |
+
gmsh.model.mesh.generate(3)
|
| 360 |
+
gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
|
| 361 |
+
gmsh.write(out_msh)
|
| 362 |
+
|
| 363 |
+
node_tags, _, _ = gmsh.model.mesh.getNodes()
|
| 364 |
+
tet_tags = gmsh.model.mesh.getElementsByType(4)[0]
|
| 365 |
+
return {"n_nodes": len(node_tags), "n_tets": len(tet_tags),
|
| 366 |
+
"bbox_mm": bbox, "elem_size": size}
|
| 367 |
+
finally:
|
| 368 |
+
gmsh.finalize()
|
src/metrics.py
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
"""Evaluation metrics for the solidification-time field (all in seconds)."""
|
| 2 |
-
import numpy as np
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
def mae(y_true, y_pred) -> float:
|
| 6 |
-
return float(np.mean(np.abs(y_true - y_pred)))
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def rmse(y_true, y_pred) -> float:
|
| 10 |
-
return float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def median_rel_error(y_true, y_pred, eps: float = 1.0) -> float:
|
| 14 |
-
return float(np.median(np.abs(y_true - y_pred) / (y_true + eps)))
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def _per_graph(graph_id):
|
| 18 |
-
order = np.argsort(graph_id, kind="stable")
|
| 19 |
-
gid = graph_id[order]
|
| 20 |
-
bounds = np.flatnonzero(np.diff(gid)) + 1
|
| 21 |
-
return order, np.split(order, bounds)
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def pred_std_ratio(y_true, y_pred, graph_id) -> float:
|
| 25 |
-
_, groups = _per_graph(np.asarray(graph_id))
|
| 26 |
-
ratios = []
|
| 27 |
-
for idx in groups:
|
| 28 |
-
st = np.std(y_true[idx])
|
| 29 |
-
if st > 1e-6:
|
| 30 |
-
ratios.append(np.std(y_pred[idx]) / st)
|
| 31 |
-
return float(np.mean(ratios)) if ratios else 0.0
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def hotspot_accuracy(y_true, y_pred, xyz, graph_id,
|
| 35 |
-
top_frac: float = 0.10, tol_mm: float = 5.0) -> float:
|
| 36 |
-
_, groups = _per_graph(np.asarray(graph_id))
|
| 37 |
-
accs = []
|
| 38 |
-
for idx in groups:
|
| 39 |
-
n = len(idx)
|
| 40 |
-
k = max(1, int(round(n * top_frac)))
|
| 41 |
-
true_top = idx[np.argsort(y_true[idx])[-k:]]
|
| 42 |
-
pred_top = idx[np.argsort(y_pred[idx])[-k:]]
|
| 43 |
-
true_xyz = xyz[true_top]
|
| 44 |
-
hits = 0
|
| 45 |
-
for p in pred_top:
|
| 46 |
-
d = np.linalg.norm(true_xyz - xyz[p], axis=1)
|
| 47 |
-
if d.min() <= tol_mm:
|
| 48 |
-
hits += 1
|
| 49 |
-
accs.append(hits / k)
|
| 50 |
-
return float(np.mean(accs)) if accs else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/model_api.py
CHANGED
|
@@ -1,53 +1,80 @@
|
|
| 1 |
-
"""Mesh
|
| 2 |
|
| 3 |
from src.model_api import CastSolidModel
|
| 4 |
model = CastSolidModel() # loads weights/ once
|
| 5 |
seconds = model.predict("part.msh", alloy="G20Mn5")
|
| 6 |
|
| 7 |
-
`predict` takes a gmsh 2.2 ASCII tetrahedral mesh (path or a parsed
|
| 8 |
-
`src.msh_parse.Mesh`)
|
| 9 |
-
node, in the mesh's node order.
|
| 10 |
-
healing, density-targeted meshing) is the job of `src.predict_casting`.
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import os
|
| 15 |
|
| 16 |
import numpy as np
|
|
|
|
| 17 |
|
| 18 |
-
from src
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from src.msh_parse import Mesh, parse_msh
|
| 20 |
-
from src.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class CastSolidModel:
|
| 24 |
def __init__(self, weights_dir: str = "weights", device: str | None = None):
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
| 33 |
props_json: str | None = None) -> np.ndarray:
|
| 34 |
-
"""Per-node solidification time (s)
|
| 35 |
|
| 36 |
`alloy`/`mold` name rows of `data/properties/` (default EN-GJS-400-15
|
| 37 |
-
in green sand); `props_json` replaces both with
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
if not isinstance(mesh, Mesh):
|
| 40 |
mesh = parse_msh(os.fspath(mesh))
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
@staticmethod
|
| 48 |
def hotspots(seconds: np.ndarray, top_frac: float = 0.1) -> np.ndarray:
|
| 49 |
-
"""Boolean mask of the `top_frac` latest-solidifying nodes
|
| 50 |
-
rule as the `hotspot` field `src.predict_casting.write_vtu` writes)."""
|
| 51 |
seconds = np.asarray(seconds, dtype=np.float32)
|
| 52 |
k = max(1, int(round(seconds.shape[0] * top_frac)))
|
| 53 |
return seconds >= np.sort(seconds)[-k]
|
|
|
|
| 1 |
+
"""Mesh in, solidification-time field out.
|
| 2 |
|
| 3 |
from src.model_api import CastSolidModel
|
| 4 |
model = CastSolidModel() # loads weights/ once
|
| 5 |
seconds = model.predict("part.msh", alloy="G20Mn5")
|
| 6 |
|
| 7 |
+
`predict` takes a gmsh 2.2 ASCII tetrahedral mesh (a path or a parsed
|
| 8 |
+
`src.msh_parse.Mesh`) whose cooling surface is tagged "Cooling", and returns
|
| 9 |
+
the solidification time in seconds for every node, in the mesh's node order.
|
|
|
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import os
|
| 14 |
|
| 15 |
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
|
| 18 |
+
from src import gbm_heads
|
| 19 |
+
from src.features import alloy_g_vector, node_features
|
| 20 |
+
from src.hybrid import hybrid_field_soft
|
| 21 |
+
from src.inference_props import DEFAULT_MOLD, resolve_props
|
| 22 |
+
from src.model_gnn import load_gnn, predict_graph_seconds
|
| 23 |
from src.msh_parse import Mesh, parse_msh
|
| 24 |
+
from src.release_io import load_gbm, load_gnn_state
|
| 25 |
+
|
| 26 |
+
# Recomposition weights of the published model (see src/hybrid.py).
|
| 27 |
+
W_TOTAL = 1.0
|
| 28 |
+
W_STD = 4.0
|
| 29 |
+
|
| 30 |
+
GNN_FILE = "gnn_v5.1.safetensors"
|
| 31 |
+
TOTAL_HEAD_FILE = "gbm_total_v3.skops"
|
| 32 |
+
SPREAD_HEAD_FILE = "gbm_std_v2.skops"
|
| 33 |
|
| 34 |
|
| 35 |
class CastSolidModel:
|
| 36 |
def __init__(self, weights_dir: str = "weights", device: str | None = None):
|
| 37 |
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 38 |
+
state = load_gnn_state(os.path.join(weights_dir, GNN_FILE), self.device)
|
| 39 |
+
self.gnn, self.cfg, self.norm = load_gnn(state, self.device)
|
| 40 |
+
self.total_head = load_gbm(os.path.join(weights_dir, TOTAL_HEAD_FILE))
|
| 41 |
+
self.spread_head = load_gbm(os.path.join(weights_dir, SPREAD_HEAD_FILE))
|
| 42 |
+
gbm_heads.check_head(self.total_head, gbm_heads.N_TOTAL_FEATURES, "total-time")
|
| 43 |
+
gbm_heads.check_head(self.spread_head, gbm_heads.N_SPREAD_FEATURES, "spread")
|
| 44 |
+
|
| 45 |
+
def predict(self, mesh: str | Mesh, alloy: str | None = None, mold: str = DEFAULT_MOLD,
|
| 46 |
props_json: str | None = None) -> np.ndarray:
|
| 47 |
+
"""Per-node solidification time (s).
|
| 48 |
|
| 49 |
`alloy`/`mold` name rows of `data/properties/` (default EN-GJS-400-15
|
| 50 |
+
in green sand); `props_json` replaces both with your own values."""
|
| 51 |
+
props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
|
| 52 |
+
return self.predict_with_props(mesh, props)[0]
|
| 53 |
+
|
| 54 |
+
def predict_with_props(self, mesh: str | Mesh, props: dict) -> tuple[np.ndarray, dict]:
|
| 55 |
+
"""(seconds, details) for an already-resolved property dict.
|
| 56 |
+
|
| 57 |
+
`details` holds the node feature matrix `X`, the heads' `total_s` and
|
| 58 |
+
`spread_s`, and the recomposition diagnostics `recomposition`."""
|
| 59 |
if not isinstance(mesh, Mesh):
|
| 60 |
mesh = parse_msh(os.fspath(mesh))
|
| 61 |
+
feat = node_features(mesh, props["mold_effusivity"])
|
| 62 |
+
X = feat["X"]
|
| 63 |
+
gnn_seconds = predict_graph_seconds(self.gnn, self.cfg, self.norm, X, feat["pos"],
|
| 64 |
+
feat["mesh_edges"], alloy_g_vector(props),
|
| 65 |
+
device=self.device)
|
| 66 |
+
row = gbm_heads.total_row(props, X)
|
| 67 |
+
total = gbm_heads.predict_total_seconds(self.total_head, row)
|
| 68 |
+
spread = gbm_heads.predict_spread_seconds(self.spread_head,
|
| 69 |
+
gbm_heads.spread_row(row, X))
|
| 70 |
+
seconds, info = hybrid_field_soft(gnn_seconds, total, spread,
|
| 71 |
+
w_total=W_TOTAL, w_std=W_STD)
|
| 72 |
+
return seconds, {"X": X, "total_s": total, "spread_s": spread,
|
| 73 |
+
"recomposition": info}
|
| 74 |
|
| 75 |
@staticmethod
|
| 76 |
def hotspots(seconds: np.ndarray, top_frac: float = 0.1) -> np.ndarray:
|
| 77 |
+
"""Boolean mask of the `top_frac` latest-solidifying nodes."""
|
|
|
|
| 78 |
seconds = np.asarray(seconds, dtype=np.float32)
|
| 79 |
k = max(1, int(round(seconds.shape[0] * top_frac)))
|
| 80 |
return seconds >= np.sort(seconds)[-k]
|
src/model_gnn.py
CHANGED
|
@@ -1,29 +1,25 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import torch
|
| 4 |
import torch.nn as nn
|
| 5 |
-
from torch_geometric.
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def _mlp(i, h, o):
|
| 9 |
return nn.Sequential(nn.Linear(i, h), nn.ReLU(), nn.Linear(h, o))
|
| 10 |
|
| 11 |
|
| 12 |
-
def zero_init_input_columns(model, n_new):
|
| 13 |
-
"""Zero the trailing n_new weight columns of the first Linear layer in node_encoder.
|
| 14 |
-
|
| 15 |
-
This is used when extending the input dim (e.g., 13 → 15 for new BC channels)
|
| 16 |
-
to ensure new columns start at zero and contribute nothing at initialization.
|
| 17 |
-
|
| 18 |
-
Args:
|
| 19 |
-
model: SolidGNN instance
|
| 20 |
-
n_new: Number of trailing columns to zero in the first Linear weight matrix
|
| 21 |
-
"""
|
| 22 |
-
with torch.no_grad():
|
| 23 |
-
model.node_encoder[0][0].weight[:, -n_new:] = 0
|
| 24 |
-
|
| 25 |
-
|
| 26 |
class ProcessorBlock(nn.Module):
|
|
|
|
|
|
|
| 27 |
def __init__(self, h):
|
| 28 |
super().__init__()
|
| 29 |
self.edge_mlp = _mlp(3 * h, h, h)
|
|
@@ -40,12 +36,12 @@ class ProcessorBlock(nn.Module):
|
|
| 40 |
|
| 41 |
|
| 42 |
class FiLM(nn.Module):
|
| 43 |
-
"""g -> (gamma, beta)
|
|
|
|
| 44 |
def __init__(self, g_dim, hidden):
|
| 45 |
super().__init__()
|
| 46 |
self.net = nn.Sequential(nn.Linear(g_dim, hidden), nn.SiLU(),
|
| 47 |
-
|
| 48 |
-
nn.init.zeros_(self.net[-1].weight); nn.init.zeros_(self.net[-1].bias)
|
| 49 |
|
| 50 |
def forward(self, h, g_nodes):
|
| 51 |
gamma, beta = self.net(g_nodes).chunk(2, dim=-1)
|
|
@@ -53,84 +49,88 @@ class FiLM(nn.Module):
|
|
| 53 |
|
| 54 |
|
| 55 |
class SolidGNN(nn.Module):
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
super().__init__()
|
| 59 |
self.node_encoder = nn.Sequential(_mlp(node_in, hidden, hidden), nn.LayerNorm(hidden))
|
| 60 |
self.edge_encoder = nn.Sequential(_mlp(edge_in, hidden, hidden), nn.LayerNorm(hidden))
|
| 61 |
self.blocks = nn.ModuleList([ProcessorBlock(hidden) for _ in range(n_blocks)])
|
| 62 |
self.decoder = _mlp(hidden, hidden // 2, 1)
|
| 63 |
-
self.
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
self.films = nn.ModuleList([FiLM(g_dim, hidden) for _ in range(n_blocks)]) if film else None
|
| 68 |
-
self.deriv_heads = deriv_heads
|
| 69 |
-
if deriv_heads:
|
| 70 |
-
# Optional gradT/dTdt output heads. They hang off the SAME final
|
| 71 |
-
# hidden state h that the decoder consumes (after the FiLM/blocks
|
| 72 |
-
# loop, before the two_head mean/scale recomposition) -- they read
|
| 73 |
-
# h but never modify it, so the frozen y_nodal/y_global path below
|
| 74 |
-
# is unaffected by their presence.
|
| 75 |
-
self.grad_head = _mlp(hidden, hidden // 2, 1)
|
| 76 |
-
self.dtdt_head = _mlp(hidden, hidden // 2, 1)
|
| 77 |
-
|
| 78 |
-
def forward(self, x, edge_index, edge_attr, batch, g=None):
|
| 79 |
h = self.node_encoder(x)
|
| 80 |
e = self.edge_encoder(edge_attr)
|
| 81 |
-
g_nodes = g[batch]
|
| 82 |
for i, blk in enumerate(self.blocks):
|
| 83 |
h, e = blk(h, edge_index, e)
|
| 84 |
-
|
| 85 |
-
h = self.films[i](h, g_nodes)
|
| 86 |
-
if self.deriv_heads:
|
| 87 |
-
deriv = torch.cat([self.grad_head(h), self.dtdt_head(h)], dim=-1)
|
| 88 |
y_nodal = self.decoder(h).squeeze(-1)
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def
|
| 100 |
-
"""
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The graph network: an encode-process-decode GNN in the style of
|
| 2 |
+
MeshGraphNets, conditioned on the alloy/mold vector through FiLM.
|
| 3 |
+
|
| 4 |
+
It predicts the standardized log1p solidification time of every node. The
|
| 5 |
+
graph is the union of the tetrahedral mesh edges and each node's k nearest
|
| 6 |
+
neighbours; an edge carries its displacement vector (mm), its length and a
|
| 7 |
+
flag telling whether it is a mesh edge.
|
| 8 |
+
"""
|
| 9 |
+
import numpy as np
|
| 10 |
import torch
|
| 11 |
import torch.nn as nn
|
| 12 |
+
from torch_geometric.nn import knn_graph
|
| 13 |
+
from torch_geometric.utils import scatter, to_undirected
|
| 14 |
|
| 15 |
|
| 16 |
def _mlp(i, h, o):
|
| 17 |
return nn.Sequential(nn.Linear(i, h), nn.ReLU(), nn.Linear(h, o))
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
class ProcessorBlock(nn.Module):
|
| 21 |
+
"""One message-passing step with residual edge and node updates."""
|
| 22 |
+
|
| 23 |
def __init__(self, h):
|
| 24 |
super().__init__()
|
| 25 |
self.edge_mlp = _mlp(3 * h, h, h)
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
class FiLM(nn.Module):
|
| 39 |
+
"""g -> per-channel (gamma, beta); applied as h * (1 + gamma) + beta."""
|
| 40 |
+
|
| 41 |
def __init__(self, g_dim, hidden):
|
| 42 |
super().__init__()
|
| 43 |
self.net = nn.Sequential(nn.Linear(g_dim, hidden), nn.SiLU(),
|
| 44 |
+
nn.Linear(hidden, 2 * hidden))
|
|
|
|
| 45 |
|
| 46 |
def forward(self, h, g_nodes):
|
| 47 |
gamma, beta = self.net(g_nodes).chunk(2, dim=-1)
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
class SolidGNN(nn.Module):
|
| 52 |
+
"""12 processor blocks (hidden width 128 in the published model), FiLM
|
| 53 |
+
after each block, and a two-head decoder: a per-node deviation head and
|
| 54 |
+
a graph-level scale head on the mean node embedding."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, node_in=15, edge_in=5, hidden=128, n_blocks=12, g_dim=5):
|
| 57 |
super().__init__()
|
| 58 |
self.node_encoder = nn.Sequential(_mlp(node_in, hidden, hidden), nn.LayerNorm(hidden))
|
| 59 |
self.edge_encoder = nn.Sequential(_mlp(edge_in, hidden, hidden), nn.LayerNorm(hidden))
|
| 60 |
self.blocks = nn.ModuleList([ProcessorBlock(hidden) for _ in range(n_blocks)])
|
| 61 |
self.decoder = _mlp(hidden, hidden // 2, 1)
|
| 62 |
+
self.scale_head = _mlp(hidden, hidden // 2, 1)
|
| 63 |
+
self.films = nn.ModuleList([FiLM(g_dim, hidden) for _ in range(n_blocks)])
|
| 64 |
+
|
| 65 |
+
def forward(self, x, edge_index, edge_attr, batch, g):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
h = self.node_encoder(x)
|
| 67 |
e = self.edge_encoder(edge_attr)
|
| 68 |
+
g_nodes = g[batch]
|
| 69 |
for i, blk in enumerate(self.blocks):
|
| 70 |
h, e = blk(h, edge_index, e)
|
| 71 |
+
h = self.films[i](h, g_nodes)
|
|
|
|
|
|
|
|
|
|
| 72 |
y_nodal = self.decoder(h).squeeze(-1)
|
| 73 |
+
dev = y_nodal - scatter(y_nodal, batch, dim=0, reduce="mean")[batch]
|
| 74 |
+
scale = self.scale_head(scatter(h, batch, dim=0, reduce="mean")).squeeze(-1)
|
| 75 |
+
return dev + scale[batch]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def knn_edge_index(pos: torch.Tensor, k: int = 16) -> torch.Tensor:
|
| 79 |
+
ei = knn_graph(pos, k=k, loop=False) # directed k-nearest
|
| 80 |
+
return to_undirected(ei) # symmetric
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def union_edge_index(pos: torch.Tensor, mesh_edges: torch.Tensor, k: int = 16):
|
| 84 |
+
"""Undirected union of the mesh edges and kNN edges, deduplicated.
|
| 85 |
+
|
| 86 |
+
mesh_edges: (M, 2) long tensor of unique node pairs (any orientation).
|
| 87 |
+
Returns (edge_index (2, E) long, is_mesh (E,) float32 flag)."""
|
| 88 |
+
n = pos.shape[0]
|
| 89 |
+
knn = knn_edge_index(pos, k=k).to(torch.long)
|
| 90 |
+
me = mesh_edges.t().to(torch.long)
|
| 91 |
+
me = torch.cat([me, me.flip(0)], dim=1) # both directions
|
| 92 |
+
mesh_key = me[0] * n + me[1]
|
| 93 |
+
keys = torch.unique(torch.cat([mesh_key, knn[0] * n + knn[1]]))
|
| 94 |
+
ei = torch.stack([keys // n, keys % n])
|
| 95 |
+
is_mesh = torch.isin(keys, mesh_key).to(torch.float32)
|
| 96 |
+
return ei, is_mesh
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def union_edge_attr(pos: torch.Tensor, edge_index: torch.Tensor,
|
| 100 |
+
is_mesh: torch.Tensor) -> torch.Tensor:
|
| 101 |
+
"""(E, 5): displacement (3), length, mesh-edge flag."""
|
| 102 |
+
src, dst = edge_index
|
| 103 |
+
d = pos[dst] - pos[src]
|
| 104 |
+
return torch.cat([d, d.norm(dim=1, keepdim=True), is_mesh.unsqueeze(1)], dim=1)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def load_gnn(state: dict, device):
|
| 108 |
+
"""Build a `SolidGNN` from a `{model, norm, cfg}` dict (see
|
| 109 |
+
`src.release_io.load_gnn_state`). Returns (model, cfg, norm)."""
|
| 110 |
+
cfg, norm = state["cfg"], state["norm"]
|
| 111 |
+
model = SolidGNN(node_in=cfg["node_in"], edge_in=cfg["edge_in"], hidden=cfg["hidden"],
|
| 112 |
+
n_blocks=cfg["n_blocks"], g_dim=cfg.get("g_dim", 5)).to(device)
|
| 113 |
+
model.load_state_dict(state["model"])
|
| 114 |
+
model.eval()
|
| 115 |
+
return model, cfg, norm
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def predict_graph_seconds(model, cfg, norm, X, pos_np, mesh_edges, g_vec,
|
| 119 |
+
device="cpu") -> np.ndarray:
|
| 120 |
+
"""Per-node solidification time (s) predicted by the GNN alone.
|
| 121 |
+
|
| 122 |
+
`X` is the raw (N, 15) feature matrix, `g_vec` the raw (5,) alloy/mold
|
| 123 |
+
vector; standardization uses the statistics stored with the weights."""
|
| 124 |
+
fmean = torch.tensor(norm["feat_mean"], dtype=torch.float32)
|
| 125 |
+
fstd = torch.tensor(norm["feat_std"], dtype=torch.float32)
|
| 126 |
+
x = (torch.tensor(X, dtype=torch.float32) - fmean) / fstd
|
| 127 |
+
pos = torch.tensor(pos_np, dtype=torch.float32)
|
| 128 |
+
me = torch.tensor(mesh_edges, dtype=torch.long)
|
| 129 |
+
ei, is_mesh = union_edge_index(pos, me, k=cfg["k"])
|
| 130 |
+
ea = union_edge_attr(pos, ei, is_mesh)
|
| 131 |
+
zeros = torch.zeros(x.shape[0], dtype=torch.long, device=device)
|
| 132 |
+
g = torch.tensor(np.asarray(g_vec), dtype=torch.float32).reshape(1, -1).to(device)
|
| 133 |
+
with torch.no_grad():
|
| 134 |
+
yn = model(x.to(device), ei.to(device), ea.to(device), zeros, g)
|
| 135 |
+
pred_log = yn.cpu().numpy() * norm["y_log_std"] + norm["y_log_mean"]
|
| 136 |
+
return np.clip(np.expm1(pred_log), 0.0, None)
|
src/msh_parse.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Extracts node coordinates (mm, in
|
| 4 |
-
|
| 5 |
-
tetrahedra.
|
| 6 |
"""
|
| 7 |
from dataclasses import dataclass
|
| 8 |
import numpy as np
|
|
|
|
| 1 |
+
"""Reader for gmsh 2.2 ASCII meshes.
|
| 2 |
|
| 3 |
+
Extracts node coordinates (mm, in the file's node order), surface triangles
|
| 4 |
+
grouped by physical name ("Cooling", optionally "Adiabatic") and volume
|
| 5 |
+
tetrahedra. Connectivity is remapped to 0-based node indices.
|
| 6 |
"""
|
| 7 |
from dataclasses import dataclass
|
| 8 |
import numpy as np
|
src/ood.py
CHANGED
|
@@ -1,33 +1,7 @@
|
|
| 1 |
-
"""Out-of-distribution size check: compare a casting's
|
| 2 |
-
|
|
|
|
| 3 |
import json
|
| 4 |
-
import os.path as osp
|
| 5 |
-
from glob import glob
|
| 6 |
-
|
| 7 |
-
import numpy as np
|
| 8 |
-
|
| 9 |
-
from src.features import FEATURE_NAMES
|
| 10 |
-
|
| 11 |
-
_VOL = FEATURE_NAMES.index("volume_mm3")
|
| 12 |
-
_GC = FEATURE_NAMES.index("geodesic_d_cool")
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def build_ood_ranges(features_dir: str) -> dict:
|
| 16 |
-
vol_max = bbox_max = gc_max = 0.0
|
| 17 |
-
for p in glob(osp.join(features_dir, "graphs", "*.npz")):
|
| 18 |
-
d = np.load(p)
|
| 19 |
-
X, pos = d["X"], d["pos"]
|
| 20 |
-
vol_max = max(vol_max, float(X[0, _VOL]))
|
| 21 |
-
gc_max = max(gc_max, float(X[:, _GC].max()))
|
| 22 |
-
diag = float(np.linalg.norm(pos.max(0) - pos.min(0)))
|
| 23 |
-
bbox_max = max(bbox_max, diag)
|
| 24 |
-
return {"volume_mm3_max": vol_max, "bbox_diag_max": bbox_max,
|
| 25 |
-
"geodesic_d_cool_max": gc_max}
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def save_ranges(ranges: dict, path: str):
|
| 29 |
-
with open(path, "w") as f:
|
| 30 |
-
json.dump(ranges, f, indent=2)
|
| 31 |
|
| 32 |
|
| 33 |
def load_ranges(path: str) -> dict:
|
|
|
|
| 1 |
+
"""Out-of-distribution size check: compare a casting's volume, bounding-box
|
| 2 |
+
diagonal and largest distance to cooling with the training maxima
|
| 3 |
+
(`weights/ood_ranges.json`) and warn when any of them is exceeded."""
|
| 4 |
import json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
def load_ranges(path: str) -> dict:
|
src/predict_casting.py
CHANGED
|
@@ -1,387 +1,40 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
(src/hybrid.py). write_vtu exports a ParaView file. load_hybrid loads the
|
| 5 |
-
Phase-1 checkpoints. The predict_casting orchestrator (Task 5) ties in meshing.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
default `q=0.995` -- outlier-robust; `q=1.0` is the original exact-MAX
|
| 14 |
-
anchor) -- NOT the SP1 MEAN-anchor, which assumes a genuine per-node
|
| 15 |
-
baseline (see `src/evaluate_ft2b.py`'s module docstring for the full
|
| 16 |
-
rationale). The non-film path below is functionally unchanged (plus an explicit HGB-dimension guard).
|
| 17 |
|
| 18 |
-
|
| 19 |
-
refit_gbm_std`'s output, loaded via `load_hybrid`'s `hgb_std_path` /
|
| 20 |
-
`predict_casting`'s `--hgb-std`) can be threaded through the film path so
|
| 21 |
-
`predict_field` recomposes via `src.hybrid.hybrid_field_affine` (rescales
|
| 22 |
-
the GNN field's deviations to match a per-graph SCALE target too, not just
|
| 23 |
-
a per-graph LEVEL) instead of `hybrid_field_max_anchor`. `None` (the
|
| 24 |
-
default, everywhere) is byte-identical to the pre-Task-3 code.
|
| 25 |
-
|
| 26 |
-
variance-recomposition amendment 2: `predict_casting`'s `--affine-lambda` /
|
| 27 |
-
`predict_field`'s `lambda_shrink` (default `1.0`, forwarded straight to
|
| 28 |
-
`hybrid_field_affine`) geometrically shrinks the affine spread target
|
| 29 |
-
toward the GNN's own spread -- see `src.hybrid.hybrid_field_affine`'s
|
| 30 |
-
docstring for the math/rationale. `1.0` everywhere is byte-identical to
|
| 31 |
-
before this argument existed.
|
| 32 |
-
|
| 33 |
-
soft-anchor Task 3: `hybrid_field_affine` at the std-head call site above
|
| 34 |
-
is REPLACED by `src.hybrid.hybrid_field_soft` (a weighted two-target
|
| 35 |
-
objective that lets the total anchor itself deviate a bit when that trade
|
| 36 |
-
buys a much closer spread match -- see that function's docstring). The
|
| 37 |
-
degeneration guarantee built into `hybrid_field_soft` means any case where
|
| 38 |
-
the hard affine solve already satisfied both targets recomposes
|
| 39 |
-
IDENTICALLY (same array object, same `a`) -- only genuinely stuck cases
|
| 40 |
-
(the ones that motivated this track) actually change. `feat["recomposition"]`
|
| 41 |
-
is now `"soft"` (was `"affine"`) whenever `hgb_std` is active; the info dict
|
| 42 |
-
gains `"m"`/`"target_unreachable"`/`"total_deviation_frac"`/`"j_total_resid"`/
|
| 43 |
-
`"j_std_resid"` (see `hybrid_field_soft`'s own docstring for the full key
|
| 44 |
-
list) in place of affine's `"a_clamped"`. New `w_total`/`w_std` params
|
| 45 |
-
(`predict_field`, `predict_casting`, CLI `--w-total`/`--w-std`; production
|
| 46 |
-
defaults `DEFAULT_W_TOTAL=1.0` / `DEFAULT_W_STD=4.0` since the v5.1
|
| 47 |
-
promotion) forward straight to `hybrid_field_soft`'s own weights, letting a
|
| 48 |
-
caller trade total-anchor fidelity against spread fidelity. The GBM std
|
| 49 |
-
head's own row width is now dispatched off the loaded model's
|
| 50 |
-
`n_features_in_` (16 -> the v1 row unchanged, 17 -> the v1 row + a
|
| 51 |
-
graph-derived `bc_area_frac` 17th column computed from the SAME in-memory
|
| 52 |
-
per-node feature array `predict_field` already has -- see
|
| 53 |
-
`_std_row_for_predict_field`); anything else raises a loud `ValueError`.
|
| 54 |
-
The no-std-head (quantile-anchor) path is completely untouched by any of
|
| 55 |
-
this.
|
| 56 |
-
|
| 57 |
-
variance-recomposition Task 6 (the production flip, 2026-07-31):
|
| 58 |
-
`DEFAULT_GNN_CKPT`/`DEFAULT_HGB` now point at the ft3 stack
|
| 59 |
-
(`runs/ft3_stage1/ckpt_ep021.pt` x `runs/ft3_gbm/hgb_v3.joblib`) and a new
|
| 60 |
-
`DEFAULT_HGB_STD` (now `runs/ft3_gbm/hgb_std_v2.joblib` since the v5.1
|
| 61 |
-
promotion) exists. `hgb_std_path`
|
| 62 |
-
no longer defaults to plain `None` on `predict_casting`/CLI `--hgb-std`:
|
| 63 |
-
both default to the module-level `_USE_DEFAULT` sentinel, which
|
| 64 |
-
`load_hybrid` resolves to `DEFAULT_HGB_STD` for a film-capable checkpoint
|
| 65 |
-
or to `None` for a non-film one (see `load_hybrid`'s docstring) -- so the
|
| 66 |
-
production call, with zero extra flags, now recomposes via
|
| 67 |
-
`hybrid_field_affine` (`lambda_shrink=1.0`) instead of the quantile-anchor
|
| 68 |
-
(superseded by soft-anchor Task 3 below -- the zero-flag production call
|
| 69 |
-
recomposes via `hybrid_field_soft` today; this paragraph is kept as the
|
| 70 |
-
Task 6 historical record of when the std head first became the default),
|
| 71 |
-
while the SP1 rollback (`--gnn`/`--hgb` pointed at a non-film checkpoint)
|
| 72 |
-
is untouched: the sentinel silently resolves to `None` there, exactly as
|
| 73 |
-
if `hgb_std_path` had never been passed. The case-insensitive string
|
| 74 |
-
`"none"` always disables the std head regardless of checkpoint kind (the
|
| 75 |
-
explicit rollback switch to the quantile-anchor, usable on any film
|
| 76 |
-
checkpoint, including the new default one) -- passing an explicit path or
|
| 77 |
-
loaded model object still loads/uses it unconditionally, exactly as
|
| 78 |
-
before Task 6.
|
| 79 |
-
|
| 80 |
-
learned-heads Task 3 (`--gnn` pointed at a `deriv_heads`-capable checkpoint):
|
| 81 |
-
`predict_field`'s film branch also runs `src.evaluate_ft2b.predict_graph_derivs`
|
| 82 |
-
and stashes the result in `feat["gradT"]`/`feat["dTdt"]` (K/m / K/s); `predict_casting`
|
| 83 |
-
then feeds those into `src.shrinkage.screen_casting`'s exact Niyama branch
|
| 84 |
-
instead of the time-only proxy, and relabels the result `niyama_source=
|
| 85 |
-
"learned"` (screen_casting's own "exact" label is reserved for a real cached
|
| 86 |
-
solver field). Absent entirely for a headless checkpoint -- byte-identical
|
| 87 |
-
to the pre-Task-3 proxy-only behavior.
|
| 88 |
-
|
| 89 |
-
bc-reach Task 4 (cfg-driven v5 feature building at inference): the film
|
| 90 |
-
branch's `compute_features_inference` call now reads
|
| 91 |
-
`version=cfg.get("feature_version", 4)` instead of hardcoding `version=4`
|
| 92 |
-
-- the SAME `cfg.get("feature_version", ...)` pattern the non-film branch
|
| 93 |
-
already used (this module, the SP1/non-film path). A v5 (bc-reach)
|
| 94 |
-
checkpoint's saved cfg carries `feature_version=5` (`src.train_gnn`'s
|
| 95 |
-
`_build_checkpoint_dict`, echoed verbatim from `train(feature_version=...)`,
|
| 96 |
-
independent of but in practice equal to `node_in`), so loading one through
|
| 97 |
-
`load_hybrid`/`load_ft2b_gnn` and calling `predict_field` now builds the
|
| 98 |
-
20-column v5 `X` (`src.features.FEATURE_NAMES_V5`: v4's 15 columns +
|
| 99 |
-
`exo_mid`/`exo_long`/`chill_short`/`chill_mid`/`chill_long`) with NO other
|
| 100 |
-
change to this module -- `compute_bc_reach_channels` (features v5) already
|
| 101 |
-
resolves its chill/exo source patch off the SAME `spec`/`bc_spec` dicts
|
| 102 |
-
this module already builds from the all-sand literal, so the reach channels see EXACTLY the same source magnitudes the
|
| 103 |
-
training builder (`src.build_dataset.build_v5`) does. A v4 (or absent)
|
| 104 |
-
`cfg["feature_version"]` is byte-identical to the pre-Task-4 code (defaults
|
| 105 |
-
to `4`, same as before this dispatch existed)."""
|
| 106 |
-
import hashlib
|
| 107 |
-
import json
|
| 108 |
import logging
|
|
|
|
| 109 |
import os.path as osp
|
| 110 |
-
import numpy as np
|
| 111 |
-
import torch
|
| 112 |
-
import joblib
|
| 113 |
|
| 114 |
-
|
| 115 |
|
| 116 |
-
from src
|
| 117 |
-
|
| 118 |
-
from src.
|
| 119 |
-
from src.
|
| 120 |
-
from src.
|
| 121 |
-
from src.
|
| 122 |
-
from src.
|
| 123 |
-
from src.gbm_ft2b import (gbm_v2_total_seconds, FEATURE_NAMES as GBM_FEATURE_NAMES,
|
| 124 |
-
predict_std_seconds, build_v2_feature_row,
|
| 125 |
-
bc_area_frac_from_graph)
|
| 126 |
-
from src.hybrid import hybrid_field, hybrid_field_max_anchor, hybrid_field_soft
|
| 127 |
-
from src.inference_props import resolve_props, DEFAULT_ALLOY, resolve_alloy_family
|
| 128 |
from src.shrinkage import screen_casting
|
| 129 |
-
from src.release_io import resolve_artifact, load_gbm, load_gnn_any
|
| 130 |
-
|
| 131 |
-
N_GBM_V2_FEATURES = len(GBM_FEATURE_NAMES)
|
| 132 |
-
_V4_VOL_IDX = FEATURE_NAMES_V4.index("volume_mm3")
|
| 133 |
-
_V4_AREA_IDX = FEATURE_NAMES_V4.index("surface_area_mm2")
|
| 134 |
-
|
| 135 |
-
# Production v5.1 hybrid stack (promoted 2026-07-31, soft-anchor Task 6 -- see
|
| 136 |
-
# docs/superpowers/specs/2026-07-31-soft-anchor-design.md's ADDENDUM) -- module
|
| 137 |
-
# constants so CLI/kwarg defaults live in one place instead of being duplicated
|
| 138 |
-
# string literals. GNN field *shape* x GBM *scale* (total) x GBM *spread* (std,
|
| 139 |
-
# std_v2 -- median-loss HGB, 17 features incl. bc_area_frac), recomposed via the
|
| 140 |
-
# soft-anchor map (src.hybrid.hybrid_field_soft) at w_total=1.0, w_std=4.0
|
| 141 |
-
# (the e_val sweep winner: v2+soft takes gold decisively -- BUKSA 90.2 s/0.700,
|
| 142 |
-
# H17 120.1 s/0.895, best-ever MAE on both -- missing only the (owner-revised,
|
| 143 |
-
# see tools/ft3_gates.py's DEFAULT_E_VAL_STD_RATIO_FLOOR) e_val exo std_ratio floor).
|
| 144 |
-
#
|
| 145 |
-
# Rollback #1 (v5, 2026-07-31 same day, pre-soft-anchor-sweep): ckpt_ep021 x
|
| 146 |
-
# hgb_v3 x hgb_std_v1 (squared-error, 16 features), w_total=1.0, w_std=1.0 --
|
| 147 |
-
# reachable via `--hgb-std runs/ft3_gbm/hgb_std_v1.joblib --w-std 1.0`
|
| 148 |
-
# (hgb_std_v1.joblib is left untouched on disk by this promotion).
|
| 149 |
-
# Rollback #2 (previous production, 2026-07-28..2026-07-31): ckpt_ep054 x hgb_v2,
|
| 150 |
-
# quantile-anchor recomposition (no std head) -- reachable via
|
| 151 |
-
# `--gnn runs/ft2b_stage2c/ckpt_ep054.pt --hgb runs/ft2b_gbm/hgb_v2.joblib --hgb-std none`.
|
| 152 |
-
# Rollback #3 (SP1, still on disk): runs/sp1_run3_final/best_gnn.pt +
|
| 153 |
-
# runs/baseline_v2/hgb_A.joblib, non-film -- reachable via --gnn/--hgb; --hgb-std needs
|
| 154 |
-
# no extra flag here, `load_hybrid` auto-resolves the defaulted std head to None for a
|
| 155 |
-
# non-film checkpoint (see `load_hybrid`'s docstring / the `_USE_DEFAULT` sentinel below).
|
| 156 |
-
DEFAULT_GNN_CKPT = "runs/ft3_stage1/ckpt_ep021.pt"
|
| 157 |
-
DEFAULT_HGB = "runs/ft3_gbm/hgb_v3.joblib"
|
| 158 |
-
DEFAULT_HGB_STD = "runs/ft3_gbm/hgb_std_v2.joblib"
|
| 159 |
-
DEFAULT_W_TOTAL = 1.0
|
| 160 |
-
DEFAULT_W_STD = 4.0
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
class _UseDefaultHgbStd:
|
| 164 |
-
"""Sentinel for `predict_casting`'s `hgb_std_path` param / CLI `--hgb-std`:
|
| 165 |
-
means "the caller did not specify" -- `load_hybrid` resolves this to
|
| 166 |
-
`DEFAULT_HGB_STD` when the selected checkpoint is film-capable, or to
|
| 167 |
-
`None` when it isn't (so the SP1 rollback via `--gnn`/`--hgb` needs no
|
| 168 |
-
extra flag). Distinct from an explicit `None` or the string `"none"`
|
| 169 |
-
(case-insensitive), both of which ALWAYS disable the std head -- the
|
| 170 |
-
explicit rollback switch to the quantile-anchor recomposition, usable on
|
| 171 |
-
any film checkpoint including the new default one."""
|
| 172 |
-
|
| 173 |
-
def __repr__(self):
|
| 174 |
-
return "<DEFAULT_HGB_STD>"
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
_USE_DEFAULT = _UseDefaultHgbStd()
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
def _check_hgb_dim(hgb_model, expected: int, ckpt_kind: str) -> None:
|
| 181 |
-
"""Loud, clear failure on a mismatched (film vs. SP1) GNN/HGB pairing.
|
| 182 |
-
|
| 183 |
-
`hgb.n_features_in_` is set by sklearn on `.fit()`; a stub without it
|
| 184 |
-
(shouldn't happen for a real fitted estimator) is treated as "unknown"
|
| 185 |
-
and NOT checked, rather than crashing the attribute lookup itself.
|
| 186 |
-
"""
|
| 187 |
-
n_have = getattr(hgb_model, "n_features_in_", None)
|
| 188 |
-
if n_have is None or n_have == expected:
|
| 189 |
-
return
|
| 190 |
-
raise ValueError(
|
| 191 |
-
f"predict_field: {ckpt_kind} checkpoint expects an HGB baseline with "
|
| 192 |
-
f"{expected} input features, but hgb_model.n_features_in_={n_have!r} -- "
|
| 193 |
-
f"this looks like the wrong baseline for this checkpoint (a v2-total "
|
| 194 |
-
f"GBM has {N_GBM_V2_FEATURES} features, an SP1 per-node HGB has "
|
| 195 |
-
f"{N_BASELINE_FEATURES}); pass the matching --hgb for this --gnn checkpoint.")
|
| 196 |
|
|
|
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
(soft-anchor Task 3): width-dispatches the std-head row off the loaded
|
| 202 |
-
std model's own `n_features_in_`, without a manifest stem or graph-cache
|
| 203 |
-
file to read from -- `predict_field` already has everything both widths
|
| 204 |
-
need in memory.
|
| 205 |
-
|
| 206 |
-
- `n_features == 16` -> `row_16` unchanged (the SAME 16-column row
|
| 207 |
-
`predict_field`'s total head already scores -- byte-identical to the
|
| 208 |
-
pre-soft-anchor code).
|
| 209 |
-
- `n_features == 17` -> `row_16` + `src.gbm_ft2b.bc_area_frac_from_graph`'s
|
| 210 |
-
17th column, computed from the SAME in-memory per-node v4 feature
|
| 211 |
-
array `X` (`bc_area_frac_from_graph` only ever indexes `graph["X"]`, so
|
| 212 |
-
a plain `{"X": X}` dict satisfies its contract without a real graph
|
| 213 |
-
npz).
|
| 214 |
-
- anything else -> a loud `ValueError` naming the two supported widths
|
| 215 |
-
(mirrors `std_feature_row`'s own contract).
|
| 216 |
-
"""
|
| 217 |
-
if n_features not in (16, 17):
|
| 218 |
-
raise ValueError(
|
| 219 |
-
f"predict_field: unsupported std-head n_features_in_={n_features!r} -- "
|
| 220 |
-
f"the std head only supports 16 (v1) or 17 (v2, v1 + bc_area_frac) "
|
| 221 |
-
f"feature columns")
|
| 222 |
-
if n_features == 16:
|
| 223 |
-
return row_16
|
| 224 |
-
area_frac = bc_area_frac_from_graph({"X": X})
|
| 225 |
-
return np.concatenate([row_16, [area_frac]])
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
def predict_field(mesh, gnn_model, hgb_model, norm, cfg=None, device=None, tta=0,
|
| 229 |
-
k=16, baseline_norm=None, props=None, hgb_std=None,
|
| 230 |
-
lambda_shrink: float = 1.0, w_total: float = DEFAULT_W_TOTAL,
|
| 231 |
-
w_std: float = DEFAULT_W_STD) -> tuple:
|
| 232 |
-
"""The film branch always uses the all-sand boundary spec: the whole
|
| 233 |
-
outer surface cools into the mold, and the model's chill/sleeve input
|
| 234 |
-
channels stay at zero (this release supports the sand-mold regime only).
|
| 235 |
-
|
| 236 |
-
`hgb_std` (variance-recomposition Task 3, recomposition swapped to
|
| 237 |
-
`hybrid_field_soft` by soft-anchor Task 3): an optional loaded GBM
|
| 238 |
-
std-head model (`src.gbm_ft2b.refit_gbm_std`/`refit_gbm_std_v2`'s
|
| 239 |
-
output -- e.g. loaded via `load_hybrid`'s `hgb_std_path`). Only
|
| 240 |
-
consulted on the film (v4) path, and only once the total head has
|
| 241 |
-
produced a valid `total`: the 16-column total-head row is built ONCE
|
| 242 |
-
(`src.gbm_ft2b.build_v2_feature_row`, the SAME public row-builder
|
| 243 |
-
`gbm_v2_total_seconds` itself uses, so a `FEATURE_NAMES` reorder can
|
| 244 |
-
never silently desync the two heads) and fed to the total head
|
| 245 |
-
(`hgb_model.predict` directly, mathematically identical to
|
| 246 |
-
`gbm_v2_total_seconds`); the std head is scored on a SEPARATE row,
|
| 247 |
-
width-dispatched off `hgb_std.n_features_in_` (16 -> that same row
|
| 248 |
-
unchanged, 17 -> that row + a graph-derived `bc_area_frac` 17th column,
|
| 249 |
-
via `_std_row_for_predict_field` -- else a loud `ValueError`), then
|
| 250 |
-
recomposed via `src.hybrid.hybrid_field_soft` instead of
|
| 251 |
-
`hybrid_field_max_anchor`.
|
| 252 |
-
`None` (the default) is BYTE-IDENTICAL to the pre-Task-3 code (the
|
| 253 |
-
`total = gbm_v2_total_seconds(...)` / `hybrid_field_max_anchor` path
|
| 254 |
-
below is untouched). When active, the returned `feat` dict gains
|
| 255 |
-
`feat["recomposition"] = "soft"` and `feat["recomposition_info"]`
|
| 256 |
-
(`hybrid_field_soft`'s `info`: `a`/`m`/`target_unreachable`/
|
| 257 |
-
`degenerate_fallback`/`clip_frac`/`total_deviation_frac`/
|
| 258 |
-
`j_total_resid`/`j_std_resid`/`lambda_shrink`/`std_target_effective`)
|
| 259 |
-
-- absent entirely when `hgb_std` is `None`. Ignored entirely by the
|
| 260 |
-
non-film (SP1) path -- SP1 has no scale-head std target.
|
| 261 |
-
|
| 262 |
-
`lambda_shrink` (variance-recomposition amendment 2): forwarded straight
|
| 263 |
-
to `hybrid_field_soft` (ignored when `hgb_std` is `None`, same as every
|
| 264 |
-
other soft-anchor-only knob here). `1.0` (the default) is byte-identical
|
| 265 |
-
to before this argument existed.
|
| 266 |
-
|
| 267 |
-
`w_total`/`w_std` (soft-anchor Task 3; defaults changed to `DEFAULT_W_TOTAL`/
|
| 268 |
-
`DEFAULT_W_STD` by the soft-anchor Task 6 promotion, 2026-07-31): forwarded
|
| 269 |
-
straight to `src.hybrid.hybrid_field_soft`'s own weights (ignored when
|
| 270 |
-
`hgb_std` is `None`). The defaults here (`1.0`/`4.0`) are the e_val-sweep
|
| 271 |
-
winner recorded in the spec addendum -- NOT `hybrid_field_soft`'s own
|
| 272 |
-
function-level default of `1.0`/`1.0` (that default is unchanged; a
|
| 273 |
-
caller invoking `hybrid_field_soft` directly, bypassing `predict_field`,
|
| 274 |
-
still gets equal weighting unless it passes these explicitly).
|
| 275 |
-
|
| 276 |
-
Learned-heads Task 3: when `cfg["deriv_heads"]` is truthy (a heads
|
| 277 |
-
checkpoint, film path only -- SP1 has no deriv heads at all), `feat`
|
| 278 |
-
additionally carries `feat["gradT"]`/`feat["dTdt"]` -- the checkpoint's
|
| 279 |
-
OWN learned per-node gradT (K/m) / dTdt (K/s) predictions
|
| 280 |
-
(`src.evaluate_ft2b.predict_graph_derivs`), on the SAME features the
|
| 281 |
-
shape head just scored. This is a `feat`-dict addition only -- the
|
| 282 |
-
`(pred, feat)` 2-tuple return contract is unchanged, so every existing
|
| 283 |
-
`out, feat = predict_field(...)` call site keeps working unmodified.
|
| 284 |
-
Absent entirely for a headless checkpoint (the default): `"gradT"`/
|
| 285 |
-
`"dTdt"` simply aren't keys of `feat` then, exactly as before this
|
| 286 |
-
feature existed. `predict_casting` reads these (if present) and feeds
|
| 287 |
-
them to `screen_casting`'s exact Niyama branch, labeling the result
|
| 288 |
-
`niyama_source="learned"` (see that function's own docstring)."""
|
| 289 |
-
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 290 |
-
cfg = cfg or {"k": k, "graph": "knn", "feature_version": 2}
|
| 291 |
-
gnn_model = gnn_model.to(device).eval()
|
| 292 |
-
|
| 293 |
-
if cfg.get("film", False):
|
| 294 |
-
if props is None:
|
| 295 |
-
raise ValueError(
|
| 296 |
-
"predict_field: cfg indicates a film (v4) checkpoint but no "
|
| 297 |
-
"`props` (alloy/mould conditioning dict) were given")
|
| 298 |
-
if hgb_model is None:
|
| 299 |
-
raise ValueError(
|
| 300 |
-
"predict_field: a film (v4) checkpoint requires an hgb_model "
|
| 301 |
-
"(the v2-total GBM scale head) -- got None")
|
| 302 |
-
_check_hgb_dim(hgb_model, N_GBM_V2_FEATURES, "film (v4)")
|
| 303 |
-
|
| 304 |
-
spec = {"mold_effusivity": props["mold_effusivity"], "bc_config": "none",
|
| 305 |
-
"chill_effusivity": 0.0, "sleeve_MEF": 0.0, "exo_q0": 0.0}
|
| 306 |
-
bc_spec = None
|
| 307 |
-
feat = compute_features_inference(mesh, version=cfg.get("feature_version", 4), spec=spec)
|
| 308 |
-
X, pos = feat["X"], feat["pos"]
|
| 309 |
-
g_vec = alloy_g_vector(props)
|
| 310 |
-
gnn_seconds = predict_graph_seconds_v4(gnn_model, cfg, norm, X, pos,
|
| 311 |
-
feat["mesh_edges"], g_vec=g_vec,
|
| 312 |
-
device=device)
|
| 313 |
-
|
| 314 |
-
# Learned-heads Task 3: a deriv_heads-capable checkpoint additionally
|
| 315 |
-
# predicts per-node gradT (K/m) / dTdt (K/s) off the SAME features --
|
| 316 |
-
# stashed into `feat` (not the return tuple's arity, keeping every
|
| 317 |
-
# existing `out, feat = predict_field(...)` call site unchanged) so
|
| 318 |
-
# `predict_casting` can feed them to `screen_casting`'s exact branch
|
| 319 |
-
# instead of the time-only proxy. Absent entirely (no "gradT"/"dTdt"
|
| 320 |
-
# keys in `feat`) for a headless checkpoint -- byte-identical to the
|
| 321 |
-
# pre-Task-3 code for every existing (non-heads) checkpoint.
|
| 322 |
-
if cfg.get("deriv_heads", False):
|
| 323 |
-
gradT_pred, dTdt_pred = predict_graph_derivs_v4(
|
| 324 |
-
gnn_model, cfg, norm, X, pos, feat["mesh_edges"], g_vec=g_vec,
|
| 325 |
-
device=device)
|
| 326 |
-
feat["gradT"] = gradT_pred
|
| 327 |
-
feat["dTdt"] = dTdt_pred
|
| 328 |
-
|
| 329 |
-
v_over_a = float(X[0, _V4_VOL_IDX]) / float(X[0, _V4_AREA_IDX])
|
| 330 |
-
|
| 331 |
-
if hgb_std is not None:
|
| 332 |
-
row = build_v2_feature_row(props, v_over_a, bc_spec)
|
| 333 |
-
total = float(np.expm1(float(hgb_model.predict(row.reshape(1, -1))[0])))
|
| 334 |
-
if total <= 0:
|
| 335 |
-
raise ValueError(
|
| 336 |
-
f"predict_field: the v2-total GBM head returned a non-positive "
|
| 337 |
-
f"total ({total!r}) for props={props!r} -- the v2-total GBM is "
|
| 338 |
-
f"broken for this input")
|
| 339 |
-
n_std_features = int(getattr(hgb_std, "n_features_in_", 16))
|
| 340 |
-
std_row = _std_row_for_predict_field(row, X, n_std_features)
|
| 341 |
-
std_seconds = predict_std_seconds(hgb_std, std_row)
|
| 342 |
-
pred, info = hybrid_field_soft(gnn_seconds, total, std_seconds,
|
| 343 |
-
lambda_shrink=lambda_shrink,
|
| 344 |
-
w_total=w_total, w_std=w_std)
|
| 345 |
-
feat["recomposition"] = "soft"
|
| 346 |
-
feat["recomposition_info"] = info
|
| 347 |
-
return pred, feat
|
| 348 |
-
|
| 349 |
-
total = gbm_v2_total_seconds(hgb_model, props, v_over_a, bc_spec=bc_spec)
|
| 350 |
-
if total <= 0:
|
| 351 |
-
raise ValueError(
|
| 352 |
-
f"predict_field: gbm_v2_total_seconds returned a non-positive "
|
| 353 |
-
f"total ({total!r}) for props={props!r} -- the v2-total GBM is "
|
| 354 |
-
f"broken for this input")
|
| 355 |
-
return hybrid_field_max_anchor(gnn_seconds, total), feat
|
| 356 |
-
|
| 357 |
-
# --- non-film (SP1) path: functionally unchanged (plus explicit HGB-dimension guard) ---
|
| 358 |
-
if hgb_model is not None:
|
| 359 |
-
_check_hgb_dim(hgb_model, N_BASELINE_FEATURES, "non-film (SP1)")
|
| 360 |
-
feat = compute_features_inference(mesh, version=cfg.get("feature_version", 2))
|
| 361 |
-
X, pos = feat["X"], feat["pos"]
|
| 362 |
-
Xs = standardize(X, norm)
|
| 363 |
-
x = torch.tensor(Xs, dtype=torch.float32)
|
| 364 |
-
pos_t = torch.tensor(pos, dtype=torch.float32)
|
| 365 |
-
me = (torch.tensor(feat["mesh_edges"], dtype=torch.long)
|
| 366 |
-
if cfg.get("graph", "knn") == "union" else None)
|
| 367 |
-
g = predict_graph_seconds(gnn_model, cfg, norm, x, pos_t, me, device=device, tta=tta)
|
| 368 |
-
if hgb_model is None:
|
| 369 |
-
return g, feat
|
| 370 |
-
bn = baseline_norm if baseline_norm is not None else norm
|
| 371 |
-
b = predict_seconds(hgb_model, standardize(X[:, :N_BASELINE_FEATURES], bn), bn)
|
| 372 |
-
return hybrid_field(g, b), feat
|
| 373 |
|
| 374 |
|
| 375 |
def write_vtu(out_path, node_xyz, tets, seconds, top_frac=0.1, extra_point_data=None):
|
| 376 |
-
"""
|
| 377 |
-
`
|
| 378 |
-
dict alongside the original `solidification_time`/`hotspot` fields --
|
| 379 |
-
e.g. `{"niyama": ..., "porosity_risk": ..., "macro_region_id": ...}`
|
| 380 |
-
from `src.shrinkage.screen_casting`. `None` (the default) is
|
| 381 |
-
byte-identical to the pre-Task-4 contract (only the original two
|
| 382 |
-
fields are written) -- this is a generic extension point, not a
|
| 383 |
-
hardcoded shrinkage-only special case, so any future per-node field
|
| 384 |
-
can reuse it the same way."""
|
| 385 |
import meshio
|
| 386 |
seconds = np.asarray(seconds, dtype=np.float32)
|
| 387 |
n = seconds.shape[0]
|
|
@@ -397,37 +50,20 @@ def write_vtu(out_path, node_xyz, tets, seconds, top_frac=0.1, extra_point_data=
|
|
| 397 |
m.write(out_path)
|
| 398 |
|
| 399 |
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
# result to a return-dict-friendly shape, and formatting the summary's risk
|
| 404 |
-
# block. Kept as free functions (rather than inlined in `predict_casting`)
|
| 405 |
-
# so each piece is independently testable and the main function stays
|
| 406 |
-
# readable.
|
| 407 |
-
# --------------------------------------------------------------------------- #
|
| 408 |
-
def _shrinkage_surface_and_feed_nodes(mesh):
|
| 409 |
-
"""Controller directive 1 (owner-adjudicated 2026-08-01): `surface_nodes`
|
| 410 |
-
= unique nodes of ALL surface triangles (every physical-group tag the
|
| 411 |
-
mesh carries -- "Cooling" union "Adiabatic" -- matching `screen_casting`'s
|
| 412 |
-
own contract of "the whole free surface", used for sink/feeder
|
| 413 |
-
placement). `feed_nodes` = unique nodes of "Adiabatic"-tagged triangles
|
| 414 |
-
ONLY (riser/sleeve faces, per `src.shrinkage.is_fed`'s documented
|
| 415 |
-
convention) -- empty when the group is absent or empty (a bare sand
|
| 416 |
-
casting with no risers, conservatively "everything unfed")."""
|
| 417 |
tri_arrays = [tri for tri in mesh.tris.values() if tri.size]
|
| 418 |
surface_nodes = (np.unique(np.concatenate(tri_arrays, axis=0).reshape(-1))
|
| 419 |
if tri_arrays else np.zeros(0, dtype=np.int64))
|
| 420 |
adiab_tri = mesh.tris.get("Adiabatic")
|
| 421 |
feed_nodes = (np.unique(adiab_tri.reshape(-1))
|
| 422 |
-
|
| 423 |
return surface_nodes.astype(np.int64), feed_nodes.astype(np.int64)
|
| 424 |
|
| 425 |
|
| 426 |
def _shrinkage_result_for_return(screen: dict) -> dict:
|
| 427 |
-
"""
|
| 428 |
-
arrays (`niyama`/`porosity_risk` dropped), plus summary counts +
|
| 429 |
-
`flagged_frac` -- everything else (`macro_shrinkage`/`top_sinks`/
|
| 430 |
-
`feeders`/`material`/`niyama_source`/`per_orientation`) is kept as-is."""
|
| 431 |
result = {k: v for k, v in screen.items() if k not in ("niyama", "porosity_risk")}
|
| 432 |
risk = screen["porosity_risk"]
|
| 433 |
result["flagged_frac"] = float(np.mean(risk)) if risk.size else 0.0
|
|
@@ -437,10 +73,7 @@ def _shrinkage_result_for_return(screen: dict) -> dict:
|
|
| 437 |
return result
|
| 438 |
|
| 439 |
|
| 440 |
-
def
|
| 441 |
-
"""The 3 VTU point fields directive 4 asks for: `niyama` (float),
|
| 442 |
-
`porosity_risk` (uint8 0/1), and `macro_region_id` (-1 = no macro
|
| 443 |
-
region, else that region's own `region_id`)."""
|
| 444 |
n_nodes = mesh.node_xyz.shape[0]
|
| 445 |
macro_region_id = np.full(n_nodes, -1, dtype=np.int64)
|
| 446 |
for region in screen["macro_shrinkage"]:
|
|
@@ -452,44 +85,19 @@ def _shrinkage_extra_point_data(mesh, screen: dict) -> dict:
|
|
| 452 |
}
|
| 453 |
|
| 454 |
|
| 455 |
-
def
|
| 456 |
-
"""Directive 5's exact skeleton: a `shrinkage screening:` section with
|
| 457 |
-
niyama_source, flagged-node %, N macro regions (worst severity +
|
| 458 |
-
centroid), N sinks, and feeder recommendations (location + min
|
| 459 |
-
modulus) -- or a single `shrinkage screening: disabled` line.
|
| 460 |
-
|
| 461 |
-
Only called when screening was ATTEMPTED (`screen_shrinkage=True`) but
|
| 462 |
-
either produced a result (`shrinkage` is the trimmed dict) or was
|
| 463 |
-
internally skipped (`shrinkage` is `None`, e.g. no surface nodes to
|
| 464 |
-
place a feeder against) -- `screen_shrinkage=False` (the explicit
|
| 465 |
-
opt-out) never calls this at all, so that path stays byte-identical to
|
| 466 |
-
the pre-Task-4 summary (see `predict_casting`'s own docstring)."""
|
| 467 |
if shrinkage is None:
|
| 468 |
f.write("shrinkage screening: disabled\n")
|
| 469 |
return
|
| 470 |
f.write("shrinkage screening:\n")
|
| 471 |
f.write(f" niyama_source: {shrinkage['niyama_source']}\n")
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
# construction (src.shrinkage's default `pct=10.0`), a within-part
|
| 477 |
-
# relative ranking, not an absolute risk level. Without this note a
|
| 478 |
-
# reader could mistake "10.0%" for a measured severity rather than
|
| 479 |
-
# the fixed cutoff it structurally always is on this path.
|
| 480 |
-
flagged_line += " (bottom decile by construction)"
|
| 481 |
-
f.write(flagged_line + "\n")
|
| 482 |
macro = shrinkage["macro_shrinkage"]
|
| 483 |
if macro:
|
| 484 |
-
#
|
| 485 |
-
# (`region["sub_resolution"]`, set by `src.shrinkage.hot_regions`)
|
| 486 |
-
# are still listed here (their volume/severity stay meaningful) but
|
| 487 |
-
# reported SEPARATELY from the well-resolved count -- they never
|
| 488 |
-
# get a feeder recommendation below (their V/A modulus diverges
|
| 489 |
-
# without bound at this resolution; see `feeder_recommendations`'s
|
| 490 |
-
# docstring), so folding them into one undifferentiated N would
|
| 491 |
-
# overstate how many of the N regions actually have a usable
|
| 492 |
-
# feeder placement.
|
| 493 |
n_sub_resolution = sum(1 for r in macro if r.get("sub_resolution"))
|
| 494 |
region_count = str(len(macro))
|
| 495 |
if n_sub_resolution:
|
|
@@ -497,8 +105,8 @@ def _write_shrinkage_summary_block(f, shrinkage) -> None:
|
|
| 497 |
worst = max(macro, key=lambda r: r["severity"])
|
| 498 |
c = worst["centroid"]
|
| 499 |
f.write(f" macro shrinkage regions: {region_count} "
|
| 500 |
-
|
| 501 |
-
|
| 502 |
else:
|
| 503 |
f.write(" macro shrinkage regions: 0\n")
|
| 504 |
f.write(f" sinks: {len(shrinkage['top_sinks'])}\n")
|
|
@@ -508,424 +116,114 @@ def _write_shrinkage_summary_block(f, shrinkage) -> None:
|
|
| 508 |
for feeder in feeders:
|
| 509 |
loc = feeder["location"]
|
| 510 |
f.write(f" - location=({loc[0]:.1f},{loc[1]:.1f},{loc[2]:.1f}) mm "
|
| 511 |
-
|
| 512 |
else:
|
| 513 |
f.write(" feeder recommendations: none\n")
|
| 514 |
if shrinkage.get("alloy_family_assumed"):
|
| 515 |
f.write(f" alloy_family_assumed: {shrinkage['material']['alloy_family']} "
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
def _parse_gravity(s: str) -> tuple:
|
| 520 |
-
"""CLI `--gravity` parser: `"x,y,z"` -> `(float(x), float(y), float(z))`.
|
| 521 |
-
A small free function so it's unit-testable without going through
|
| 522 |
-
`argparse`/`sys.argv` -- and (review round 1, finding 5) passed directly
|
| 523 |
-
as `argparse`'s own `type=` callable, so a malformed `--gravity` value
|
| 524 |
-
surfaces as argparse's normal `ValueError`-driven usage error (exit
|
| 525 |
-
code 2, "argument --gravity: invalid _parse_gravity value: ...") rather
|
| 526 |
-
than an uncaught traceback deep inside `predict_casting`.
|
| 527 |
-
|
| 528 |
-
Rejects the zero vector loudly (`ValueError`): `screen_casting`'s
|
| 529 |
-
`top_sinks`/`feeder_recommendations` normalize `gravity_vec` by its own
|
| 530 |
-
norm and would divide by zero silently otherwise (`up = up / norm`
|
| 531 |
-
guarded only by `norm > 0`, which degrades to a NEVER-normalized,
|
| 532 |
-
physically meaningless direction rather than raising) -- a caller
|
| 533 |
-
typo'ing `0,0,0` deserves a clear error, not a silently-wrong feeder
|
| 534 |
-
placement."""
|
| 535 |
-
parts = [p.strip() for p in s.split(",")]
|
| 536 |
-
if len(parts) != 3:
|
| 537 |
-
raise ValueError(f"--gravity must be 'x,y,z' (3 comma-separated numbers), got {s!r}")
|
| 538 |
-
vec = tuple(float(p) for p in parts)
|
| 539 |
-
if all(v == 0.0 for v in vec):
|
| 540 |
-
raise ValueError(
|
| 541 |
-
f"--gravity must not be the zero vector (got {s!r}) -- gravity_vec is "
|
| 542 |
-
f"normalized by its own norm for top-sink/feeder placement, and the zero "
|
| 543 |
-
f"vector has no direction")
|
| 544 |
-
return vec
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
def load_hybrid(gnn_ckpt, hgb_path, device=None,
|
| 548 |
-
baseline_features_dir="data/features_v2", hgb_std_path=None):
|
| 549 |
-
"""Load the GNN + (optional) HGB baseline (+ optional GBM std-head) for
|
| 550 |
-
`predict_field`.
|
| 551 |
-
|
| 552 |
-
Film (v4) checkpoints load via `src.evaluate_ft2b.load_ft2b_gnn` and skip
|
| 553 |
-
`baseline_norm` entirely (v4's HGB is the v2-total scale GBM, standardized
|
| 554 |
-
by `src.gbm_ft2b` itself -- there is no separate per-node baseline-feature
|
| 555 |
-
norm to load). Non-film checkpoints are loaded exactly as before
|
| 556 |
-
(`src.evaluate_gnn.load_gnn`) -- byte-identical to the pre-v4 code.
|
| 557 |
-
|
| 558 |
-
`hgb_std_path` (variance-recomposition Task 3): an optional path to a
|
| 559 |
-
`joblib`-dumped GBM std-head model (`src.gbm_ft2b.refit_gbm_std`'s
|
| 560 |
-
output), loaded exactly like `hgb_path` (`joblib.load` if given, else
|
| 561 |
-
`None`). `None` (the default -- unchanged by Task 6, see below) means
|
| 562 |
-
the returned `hgb_std` is `None` and every other return value / code
|
| 563 |
-
path is byte-identical to before this argument existed.
|
| 564 |
-
|
| 565 |
-
`hgb_std_path` may also be (variance-recomposition Task 6):
|
| 566 |
-
- the module-level `_USE_DEFAULT` sentinel: resolved HERE, using the
|
| 567 |
-
GNN checkpoint's own `cfg["film"]` flag (already being determined
|
| 568 |
-
below to pick the loader) -- `DEFAULT_HGB_STD` for a film-capable
|
| 569 |
-
checkpoint, `None` for a non-film one. This is what lets
|
| 570 |
-
`predict_casting`'s own sentinel default reach the new production
|
| 571 |
-
std head with zero flags, while leaving a non-film (SP1) `--gnn`/
|
| 572 |
-
`--hgb` rollback untouched: no attempt is made to load
|
| 573 |
-
`DEFAULT_HGB_STD` at all in that case, so a missing/irrelevant v4-only
|
| 574 |
-
artifact never breaks a non-film call.
|
| 575 |
-
- the case-insensitive string `"none"`: always resolved to `None`,
|
| 576 |
-
regardless of film-ness -- the explicit rollback switch to the
|
| 577 |
-
quantile-anchor recomposition (usable on any film checkpoint,
|
| 578 |
-
including the new default one).
|
| 579 |
-
An explicit path (or an already-loaded model object, via `hgb_path`'s
|
| 580 |
-
own convention) is still loaded/used unconditionally either way, exactly
|
| 581 |
-
as before Task 6 -- `load_hybrid` never judges whether a checkpoint/
|
| 582 |
-
std-head pairing makes sense; that is `predict_casting`'s job (it raises
|
| 583 |
-
if the resulting `hgb_std` is non-`None` for a non-film checkpoint).
|
| 584 |
-
|
| 585 |
-
Returns a 6-tuple (was 5, before this argument existed): `(gnn, hgb,
|
| 586 |
-
norm, cfg, baseline_norm, hgb_std)` -- every existing call site was
|
| 587 |
-
updated to the new arity when this argument was added; any NEW call site
|
| 588 |
-
must unpack all 6.
|
| 589 |
-
"""
|
| 590 |
-
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 591 |
-
gnn_ckpt, hgb_path = resolve_artifact(gnn_ckpt), resolve_artifact(hgb_path)
|
| 592 |
-
hgb = load_gbm(hgb_path)
|
| 593 |
-
state = load_gnn_any(gnn_ckpt, device)
|
| 594 |
-
is_film = state.get("cfg", {}).get("film", False)
|
| 595 |
-
|
| 596 |
-
if hgb_std_path is _USE_DEFAULT:
|
| 597 |
-
hgb_std_path = DEFAULT_HGB_STD if is_film else None
|
| 598 |
-
elif isinstance(hgb_std_path, str) and hgb_std_path.strip().lower() == "none":
|
| 599 |
-
hgb_std_path = None
|
| 600 |
-
hgb_std_path = resolve_artifact(hgb_std_path)
|
| 601 |
-
try:
|
| 602 |
-
hgb_std = load_gbm(hgb_std_path)
|
| 603 |
-
except FileNotFoundError as e:
|
| 604 |
-
raise FileNotFoundError(
|
| 605 |
-
f"GBM std head not found: {hgb_std_path!r}. Since the 2026-07-31 v5 "
|
| 606 |
-
f"promotion the std artifact ({DEFAULT_HGB_STD}) is a PRODUCTION "
|
| 607 |
-
f"requirement of the default affine recomposition -- provision it "
|
| 608 |
-
f"alongside the GNN/GBM artifacts, or pass --hgb-std none (API: "
|
| 609 |
-
f"hgb_std_path=None) to fall back to the anchor recomposition.") from e
|
| 610 |
-
|
| 611 |
-
if is_film:
|
| 612 |
-
gnn, cfg, norm = load_ft2b_gnn(state, device)
|
| 613 |
-
return gnn, hgb, norm, cfg, None, hgb_std
|
| 614 |
-
gnn, cfg, norm = load_gnn(gnn_ckpt, device)
|
| 615 |
-
from src.dataload import load_norm
|
| 616 |
-
baseline_norm = load_norm(baseline_features_dir) if hgb is not None else None
|
| 617 |
-
return gnn, hgb, norm, cfg, baseline_norm, hgb_std
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
import os
|
| 621 |
-
import argparse
|
| 622 |
-
|
| 623 |
-
from src.step_mesh import mesh_step
|
| 624 |
-
from src.msh_parse import parse_msh
|
| 625 |
-
from src import ood as _ood
|
| 626 |
-
from src.mesh_cache import get_or_mesh
|
| 627 |
-
from tools.doe.prep_parts import DEFAULT_TARGET_NODES
|
| 628 |
|
| 629 |
|
| 630 |
-
def
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
target_nodes: int = DEFAULT_TARGET_NODES,
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
"""`
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
`target_nodes` (app Phase A, 2026-08-01): the node-count target the
|
| 654 |
-
"prep" density search aims at, forwarded to `src.mesh_cache.get_or_mesh`
|
| 655 |
-
(and thus part of the mesh-cache key, so two densities of the same STEP
|
| 656 |
-
cache separately). Defaults to `DEFAULT_TARGET_NODES` (8500), which is
|
| 657 |
-
byte-identical to the previous hardcoded behaviour. The trained density
|
| 658 |
-
band is ~7.5-10k nodes (DECISIONS-2026-07-22 §9); values outside it put
|
| 659 |
-
the mesh outside what the model saw, and callers exposing this to a user
|
| 660 |
-
should bound it there. Passing a non-default value together with an
|
| 661 |
-
explicit `elem_size` raises `ValueError` rather than being ignored --
|
| 662 |
-
`elem_size` forces "raw", which never runs the density search at all.
|
| 663 |
-
|
| 664 |
-
`hgb_std_path` (variance-recomposition Task 3; default changed in Task 6;
|
| 665 |
-
recomposition swapped to `hybrid_field_soft` by soft-anchor Task 3): an
|
| 666 |
-
optional path to a `joblib`-dumped GBM std-head model
|
| 667 |
-
(`src.gbm_ft2b.refit_gbm_std`/`refit_gbm_std_v2`'s output, 16 or 17
|
| 668 |
-
features -- `predict_field` width-dispatches off the loaded model's own
|
| 669 |
-
`n_features_in_`), forwarded to `load_hybrid`/`predict_field`. Defaults
|
| 670 |
-
to the module-level `_USE_DEFAULT` sentinel (not `None`, since Task 6):
|
| 671 |
-
`load_hybrid` resolves this to `DEFAULT_HGB_STD` for a film-capable
|
| 672 |
-
checkpoint (the production call, zero extra flags, now recomposes via
|
| 673 |
-
`hybrid_field_soft`) or to `None` for a non-film one (the SP1 rollback
|
| 674 |
-
via `--gnn`/`--hgb` is unaffected -- no attempt is made to load
|
| 675 |
-
`DEFAULT_HGB_STD` for a non-film checkpoint at all). Pass `None`
|
| 676 |
-
explicitly, or the case-insensitive string `"none"` (the CLI-friendly
|
| 677 |
-
form), to force the quantile-anchor recomposition on ANY checkpoint,
|
| 678 |
-
including the new default film one -- this is byte-identical to the
|
| 679 |
-
pre-Task-3 code. Passing an explicit path (or an already-loaded model
|
| 680 |
-
object) still requires a film-capable checkpoint (SP1 has no
|
| 681 |
-
scale-head std target) -- raises `ValueError`
|
| 682 |
-
otherwise, BEFORE meshing. When the soft-anchor recomposition is active,
|
| 683 |
-
the summary file gains a `soft recomposition: a=..., m=...,
|
| 684 |
-
target_unreachable=..., clip_frac=...` line (`target_unreachable`
|
| 685 |
-
surfaces whether the underlying hard (affine) two-target problem the
|
| 686 |
-
soft solve seeds from was itself unreachable at some `a` -- see
|
| 687 |
-
`src.hybrid.hybrid_field_soft`), further appending a trailing
|
| 688 |
-
`total_dev=+x.x%` field (final review I-1, 2026-08-01) whenever
|
| 689 |
-
`abs(info["total_deviation_frac"]) > 0.001` -- signed, one decimal
|
| 690 |
-
(e.g. `total_dev=+4.7%`) -- surfacing the soft anchor's defining
|
| 691 |
-
trade-off (letting the total deviate from the GBM total to buy a closer
|
| 692 |
-
spread match) in the human-readable summary, not just the return dict's
|
| 693 |
-
`recomposition_info`; omitted entirely below that threshold so a healthy
|
| 694 |
-
part's summary stays clean. Otherwise (no active std head) it gains a
|
| 695 |
-
`recomposition: anchor ...` line (film checkpoints only).
|
| 696 |
-
|
| 697 |
-
`lambda_shrink` (variance-recomposition amendment 2, CLI `--affine-
|
| 698 |
-
lambda`): forwarded straight to `predict_field`/`hybrid_field_soft`
|
| 699 |
-
(ignored unless `hgb_std_path` is also given). `1.0` (the default) is
|
| 700 |
-
byte-identical to before this argument existed. When active AND
|
| 701 |
-
`lambda_shrink != 1.0`, the summary's recomposition line gains a
|
| 702 |
-
trailing `lambda_shrink=...` field.
|
| 703 |
-
|
| 704 |
-
`w_total`/`w_std` (soft-anchor Task 3, CLI `--w-total`/`--w-std`; defaults
|
| 705 |
-
changed to `DEFAULT_W_TOTAL`/`DEFAULT_W_STD` (`1.0`/`4.0`) by the
|
| 706 |
-
soft-anchor Task 6 promotion, 2026-07-31 -- the e_val-sweep winner
|
| 707 |
-
recorded in the spec addendum): forwarded straight to
|
| 708 |
-
`predict_field`/`hybrid_field_soft`'s own weights (ignored unless
|
| 709 |
-
`hgb_std_path` is also given). When either differs from `1.0`/`1.0` (NOT
|
| 710 |
-
from the new defaults -- this compares against `hybrid_field_soft`'s own
|
| 711 |
-
equal-weighting baseline, so the new production default line still
|
| 712 |
-
prints its weights), the summary's recomposition line gains a trailing
|
| 713 |
-
`w_total=..., w_std=...` field.
|
| 714 |
-
|
| 715 |
-
`screen_shrinkage` (SP2 shrinkage Task 4, CLI `--no-shrinkage`; learned
|
| 716 |
-
source added by learned-heads Task 3): runs `src.shrinkage.screen_casting`
|
| 717 |
-
on the recomposed hybrid field (`t_s`) AFTER recomposition, BEFORE the
|
| 718 |
-
VTU write -- Niyama (the time-only proxy, UNLESS `gnn_ckpt` is a
|
| 719 |
-
deriv_heads-capable checkpoint, in which case `predict_field` has already
|
| 720 |
-
populated `feat["gradT"]`/`feat["dTdt"]` with the checkpoint's own learned
|
| 721 |
-
per-node predictions, routing `screen_casting` through its exact branch
|
| 722 |
-
instead; the returned `shrinkage["niyama_source"]` is then relabeled
|
| 723 |
-
`"learned"` here -- NOT `screen_casting`'s own `"exact"`, which is
|
| 724 |
-
reserved for a real cached solver field, e.g. Gate C's per-row
|
| 725 |
-
comparison), macro-shrinkage region detection, top-surface sinks, and
|
| 726 |
-
feeder recommendations. `surface_nodes`/`feed_nodes` come straight off
|
| 727 |
-
the parsed mesh's physical-group triangles (`_shrinkage_surface_and_
|
| 728 |
-
feed_nodes` -- ALL surface tris union for `surface_nodes`, "Adiabatic"
|
| 729 |
-
tris only for `feed_nodes`, empty for a bare sand casting with no
|
| 730 |
-
risers); `TL`/`TS` come from the already-resolved `props`; `alloy_family`
|
| 731 |
-
comes from `src.inference_props.resolve_alloy_family` (a name-prefix map
|
| 732 |
-
onto `src.shrinkage.SHRINKAGE_PCT`'s family keys -- an unrecognized grade
|
| 733 |
-
name, or any `--props` custom-JSON override, falls back loudly to
|
| 734 |
-
`"steel"` and is recorded as `alloy_family_assumed` in the result rather
|
| 735 |
-
than crashing or silently mis-scaling severities). `True` (the default)
|
| 736 |
-
adds `niyama`/`porosity_risk`/`macro_region_id` VTU point fields, a
|
| 737 |
-
`shrinkage screening:` block in the summary, and a `"shrinkage"` key in
|
| 738 |
-
the return dict (the `screen_casting` result minus its bulky per-node
|
| 739 |
-
`niyama`/`porosity_risk` arrays, plus `flagged_frac`/`n_macro_regions`/
|
| 740 |
-
`n_sinks`/`n_feeders` counts). The shrinkage dict carries
|
| 741 |
-
`"niyama_confidence": "full"` (this release supports the all-sand regime
|
| 742 |
-
only, where the proxy passed its validation gate). `False` skips all of
|
| 743 |
-
that entirely -- `"shrinkage"` is `None` and the VTU/summary carry no
|
| 744 |
-
extra fields (the `shrinkage screening: disabled` line only appears when
|
| 745 |
-
screening was attempted but internally skipped, e.g. a mesh with no
|
| 746 |
-
surface nodes to place a feeder against, never for this explicit
|
| 747 |
-
opt-out).
|
| 748 |
-
|
| 749 |
-
`gravity` (CLI `--gravity x,y,z`, parsed by `_parse_gravity`): forwarded
|
| 750 |
-
straight to `screen_casting`'s `gravity_vec` (top-sink/feeder placement
|
| 751 |
-
direction; conduction/Niyama/macro-region *detection* don't depend on
|
| 752 |
-
it). `(0, 0, -1)` (the default) is the same "gravity along -z" convention
|
| 753 |
-
used elsewhere in this module. Ignored entirely when `screen_shrinkage`
|
| 754 |
-
is `False`.
|
| 755 |
-
|
| 756 |
-
Returns a dict that (soft-anchor Task 4, v5 review M-1) always carries
|
| 757 |
-
`"recomposition"` and `"recomposition_info"` alongside the existing keys:
|
| 758 |
-
`"recomposition"` is `"soft"` (the affine two-target solve via
|
| 759 |
-
`hybrid_field_soft` is active -- `hgb_std_path` resolved to a real std
|
| 760 |
-
head), `"anchor"` (a film/v4 checkpoint recomposed via the plain
|
| 761 |
-
quantile-anchor -- `hgb_std_path` resolved to `None`, e.g. via `"none"`),
|
| 762 |
-
or `"sp1"` (a non-film checkpoint -- no recomposition-mode concept
|
| 763 |
-
applies at all). `"recomposition_info"` is `hybrid_field_soft`'s own
|
| 764 |
-
info dict (`a`/`m`/`target_unreachable`/`clip_frac`/
|
| 765 |
-
`total_deviation_frac`/... -- see `predict_field`'s docstring) when
|
| 766 |
-
`"recomposition"` is `"soft"`, else `None` -- there is no equivalent info
|
| 767 |
-
dict on the anchor or SP1 paths.
|
| 768 |
-
"""
|
| 769 |
os.makedirs(out_dir, exist_ok=True)
|
| 770 |
name = osp.splitext(osp.basename(step_path))[0]
|
| 771 |
out_msh = osp.join(out_dir, f"{name}.msh")
|
| 772 |
|
| 773 |
-
# Resolve properties
|
| 774 |
-
# fail fast, before potentially lengthy meshing.
|
| 775 |
props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
|
|
|
|
| 776 |
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
f"predict_casting: `target_nodes` ({target_nodes}) applies to the "
|
| 781 |
-
f"prep/get_or_mesh density search only, but an explicit "
|
| 782 |
-
f"elem_size={elem_size!r} forces mesh_mode='raw' -- pinning an "
|
| 783 |
-
f"element size and searching towards a node count are mutually "
|
| 784 |
-
f"exclusive requests. Drop one.")
|
| 785 |
-
gnn_ckpt, hgb_path = resolve_artifact(gnn_ckpt), resolve_artifact(hgb_path)
|
| 786 |
-
gnn, hgb, norm, cfg, baseline_norm, hgb_std = load_hybrid(
|
| 787 |
-
gnn_ckpt, hgb_path, device, hgb_std_path=hgb_std_path)
|
| 788 |
-
# Provenance label for the summary txt: the RESOLVED std artifact (the
|
| 789 |
-
# v5.1 stack is defined by it + the weights), mirroring load_hybrid's own
|
| 790 |
-
# sentinel/"none" resolution without re-running it.
|
| 791 |
-
if hgb_std is None:
|
| 792 |
-
hgb_std_path_label = "none"
|
| 793 |
-
elif hgb_std_path is _USE_DEFAULT:
|
| 794 |
-
hgb_std_path_label = resolve_artifact(DEFAULT_HGB_STD)
|
| 795 |
-
else:
|
| 796 |
-
hgb_std_path_label = str(hgb_std_path)
|
| 797 |
-
if hgb_std is not None and not cfg.get("film", False):
|
| 798 |
-
raise ValueError(
|
| 799 |
-
"predict_casting: --hgb-std requires the film-conditioned v4 "
|
| 800 |
-
"stack; the selected checkpoint is not film-capable -- rerun "
|
| 801 |
-
"without --hgb-std or with the v4 checkpoints")
|
| 802 |
-
|
| 803 |
-
if effective_mode == "raw":
|
| 804 |
-
raw_info = mesh_step(step_path, out_msh, elem_size)
|
| 805 |
-
mesh = parse_msh(out_msh)
|
| 806 |
-
minfo = {"n_nodes": raw_info["n_nodes"], "n_tets": raw_info["n_tets"],
|
| 807 |
-
"elem_size": raw_info["elem_size"], "bbox_mm": raw_info["bbox_mm"]}
|
| 808 |
-
elif effective_mode == "prep":
|
| 809 |
-
prep_info = get_or_mesh(step_path, out_msh, target_nodes=target_nodes)
|
| 810 |
-
if not prep_info.get("ok", False):
|
| 811 |
-
raise RuntimeError(
|
| 812 |
-
f"predict_casting: prep meshing pipeline (get_or_mesh) failed for "
|
| 813 |
-
f"{step_path!r}: {prep_info.get('notes', '(no notes)')}")
|
| 814 |
-
mesh = parse_msh(out_msh)
|
| 815 |
-
bbox_mm = tuple((mesh.node_xyz.max(0) - mesh.node_xyz.min(0)).tolist())
|
| 816 |
-
cache = prep_info.get("cache", "unknown")
|
| 817 |
-
minfo = {"n_nodes": prep_info["n_nodes"], "n_tets": int(mesh.tets.shape[0]),
|
| 818 |
-
"elem_size": None, "bbox_mm": bbox_mm, "cache": cache}
|
| 819 |
-
else:
|
| 820 |
-
raise ValueError(f"predict_casting: mesh_mode must be 'prep' or 'raw', got {mesh_mode!r}")
|
| 821 |
-
|
| 822 |
-
seconds, feat = predict_field(mesh, gnn, hgb, norm, cfg=cfg, device=device, tta=tta,
|
| 823 |
-
baseline_norm=baseline_norm, props=props,
|
| 824 |
-
hgb_std=hgb_std, lambda_shrink=lambda_shrink,
|
| 825 |
-
w_total=w_total, w_std=w_std)
|
| 826 |
|
| 827 |
warnings = []
|
| 828 |
-
ood_json =
|
| 829 |
if osp.exists(ood_json):
|
| 830 |
-
ranges = _ood.load_ranges(ood_json)
|
| 831 |
-
X = feat["X"][:, :N_BASELINE_FEATURES]
|
| 832 |
-
vol = float(X[0, FEATURE_NAMES.index("volume_mm3")])
|
| 833 |
-
gcmax = float(X[:, FEATURE_NAMES.index("geodesic_d_cool")].max())
|
| 834 |
diag = float(np.linalg.norm(mesh.node_xyz.max(0) - mesh.node_xyz.min(0)))
|
| 835 |
-
warnings = _ood.ood_flags(
|
|
|
|
| 836 |
|
| 837 |
-
# SP2 shrinkage Task 4: screen the recomposed hybrid field for shrinkage-
|
| 838 |
-
# porosity risk, AFTER recomposition, BEFORE the VTU write -- see
|
| 839 |
-
# predict_casting's own docstring for the full contract.
|
| 840 |
shrinkage = None
|
| 841 |
-
|
| 842 |
if screen_shrinkage:
|
| 843 |
-
surface_nodes, feed_nodes =
|
| 844 |
if surface_nodes.size == 0:
|
| 845 |
-
logger.warning(
|
| 846 |
-
|
| 847 |
-
"shrinkage screening skipped (a feeder location requires a free "
|
| 848 |
-
"surface)")
|
| 849 |
else:
|
| 850 |
-
alloy_family,
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
learned_dTdt = feat.get("dTdt")
|
| 859 |
-
shrinkage_full = screen_casting(
|
| 860 |
-
pos=mesh.node_xyz, tets=mesh.tets, surface_nodes=surface_nodes,
|
| 861 |
-
t_s=seconds, TL=props["TL"], TS=props["TS"], alloy_family=alloy_family,
|
| 862 |
-
gravity_vec=gravity, feed_nodes=feed_nodes,
|
| 863 |
-
gradT=learned_gradT, dTdt=learned_dTdt)
|
| 864 |
-
shrinkage = _shrinkage_result_for_return(shrinkage_full)
|
| 865 |
-
if learned_gradT is not None and learned_dTdt is not None:
|
| 866 |
-
# `screen_casting` itself only distinguishes "exact" (real
|
| 867 |
-
# cached solver fields, e.g. Gate C's per-row comparison)
|
| 868 |
-
# from "proxy" -- it has no notion of "predicted by a
|
| 869 |
-
# checkpoint at inference time". Relabel HERE, at the
|
| 870 |
-
# inference call site, rather than teaching `screen_casting`
|
| 871 |
-
# a third source string: that keeps `src.shrinkage`'s own
|
| 872 |
-
# tests (which pass real cached gradT/dTdt and expect
|
| 873 |
-
# "exact") untouched, while giving inference-time callers an
|
| 874 |
-
# honest label distinguishing a MODEL PREDICTION from a true
|
| 875 |
-
# solver field.
|
| 876 |
-
shrinkage["niyama_source"] = "learned"
|
| 877 |
-
if alloy_family_assumed:
|
| 878 |
shrinkage["alloy_family_assumed"] = True
|
| 879 |
-
|
| 880 |
-
# proxy Niyama passed its validation gate.
|
| 881 |
-
shrinkage["niyama_confidence"] = "full"
|
| 882 |
-
shrinkage_extra_point_data = _shrinkage_extra_point_data(mesh, shrinkage_full)
|
| 883 |
|
| 884 |
vtu = osp.join(out_dir, f"{name}.vtu")
|
| 885 |
-
write_vtu(vtu, mesh.node_xyz, mesh.tets, seconds, extra_point_data=
|
| 886 |
|
| 887 |
-
# hot-spot summary: top-5 latest-solidifying nodes
|
| 888 |
order = np.argsort(seconds)[::-1][:5]
|
| 889 |
hot_xyz = mesh.node_xyz[order]
|
| 890 |
total = float(seconds.max())
|
|
|
|
| 891 |
summary = osp.join(out_dir, f"{name}_summary.txt")
|
| 892 |
-
alloy_label =
|
| 893 |
-
|
| 894 |
-
alloy_label = f"custom ({props_json})"
|
| 895 |
mold_label = mold if props_json is None else "n/a (props_json)"
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
with open(summary, "w") as f:
|
| 900 |
f.write(f"casting: {name}\n")
|
| 901 |
f.write(f"total solidification time: {total:.1f} s\n")
|
| 902 |
-
f.write(f"alloy: {alloy_label} mold: {mold_label}
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
f"{
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
line =
|
| 910 |
-
|
| 911 |
-
f"clip_frac={info['clip_frac']:.4f}")
|
| 912 |
-
if info.get("lambda_shrink", 1.0) != 1.0:
|
| 913 |
-
line += f", lambda_shrink={info['lambda_shrink']:.4f}"
|
| 914 |
-
if w_total != 1.0 or w_std != 1.0:
|
| 915 |
-
line += f", w_total={w_total:.4f}, w_std={w_std:.4f}"
|
| 916 |
-
total_dev = info.get("total_deviation_frac")
|
| 917 |
-
if total_dev is not None and abs(total_dev) > 0.001:
|
| 918 |
-
line += f", total_dev={total_dev * 100.0:+.1f}%"
|
| 919 |
-
f.write(line + "\n")
|
| 920 |
-
elif cfg.get("film", False):
|
| 921 |
-
# variance-recomposition Task 6: the film path without an active
|
| 922 |
-
# std head (hgb_std_path="none", or a film checkpoint used
|
| 923 |
-
# without one) recomposes via the quantile-anchor -- state that
|
| 924 |
-
# explicitly, mirroring the affine line above, rather than
|
| 925 |
-
# leaving the recomposition mode unstated.
|
| 926 |
-
f.write("recomposition: anchor (quantile, q=0.995)\n")
|
| 927 |
if screen_shrinkage:
|
| 928 |
-
|
| 929 |
f.write("top hot spots (x,y,z mm : t_solidus s):\n")
|
| 930 |
for xyz_i, i in zip(hot_xyz, order):
|
| 931 |
f.write(f" ({xyz_i[0]:.1f},{xyz_i[1]:.1f},{xyz_i[2]:.1f}) : {seconds[i]:.1f}\n")
|
|
@@ -934,108 +232,52 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 934 |
for w in warnings:
|
| 935 |
f.write(f" - {w}\n")
|
| 936 |
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
# concept at all).
|
| 943 |
-
if feat.get("recomposition") == "soft":
|
| 944 |
-
recomposition = "soft"
|
| 945 |
-
recomposition_info = feat["recomposition_info"]
|
| 946 |
-
elif cfg.get("film", False):
|
| 947 |
-
recomposition = "anchor"
|
| 948 |
-
recomposition_info = None
|
| 949 |
-
else:
|
| 950 |
-
recomposition = "sp1"
|
| 951 |
-
recomposition_info = None
|
| 952 |
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
return result
|
| 963 |
|
| 964 |
|
| 965 |
-
|
| 966 |
-
ap = argparse.ArgumentParser()
|
| 967 |
ap.add_argument("step_path")
|
| 968 |
ap.add_argument("--out-dir", default="runs/castings")
|
| 969 |
-
ap.add_argument("--elem-size", type=float, default=None,
|
| 970 |
-
help="explicit uniform element size (mm) -- implies --mesh-mode raw, "
|
| 971 |
-
"regardless of --mesh-mode")
|
| 972 |
-
ap.add_argument("--mesh-mode", choices=["prep", "raw"], default="prep",
|
| 973 |
-
help="'prep' (default): route meshing through the same largest-solid + "
|
| 974 |
-
"OCC heal + density-searched pipeline used to prep training/dataset-D "
|
| 975 |
-
"parts (tools.doe.prep_parts.heal_and_mesh), served through the "
|
| 976 |
-
"on-disk mesh cache (runs/mesh_cache/) so a repeated byte-identical "
|
| 977 |
-
"STEP file is not re-meshed. 'raw': the old single-pass "
|
| 978 |
-
"uniform-density src.step_mesh.mesh_step path (escape hatch). Ignored "
|
| 979 |
-
"(forced to 'raw') when --elem-size is given.")
|
| 980 |
-
ap.add_argument("--gnn", dest="gnn_ckpt", default=DEFAULT_GNN_CKPT,
|
| 981 |
-
help="GNN checkpoint path (SP1 or a film/v4 checkpoint)")
|
| 982 |
-
ap.add_argument("--hgb", dest="hgb_path", default=DEFAULT_HGB,
|
| 983 |
-
help="HGB baseline path (SP1 per-node baseline or the v4 v2-total GBM)")
|
| 984 |
-
ap.add_argument("--hgb-std", dest="hgb_std_path", default=_USE_DEFAULT,
|
| 985 |
-
help="path to an optional GBM std-head model "
|
| 986 |
-
"(src.gbm_ft2b.refit_gbm_std's output); when active, recomposition "
|
| 987 |
-
"uses the affine (variance-preserving) map instead of the "
|
| 988 |
-
"quantile-anchor -- only consulted for film/v4 checkpoints. Default: "
|
| 989 |
-
f"{DEFAULT_HGB_STD} for a film/v4 checkpoint (auto-disabled, no extra "
|
| 990 |
-
"flag needed, for a non-film/SP1 checkpoint e.g. via --gnn/--hgb "
|
| 991 |
-
"rollback); pass 'none' (case-insensitive) to force the old "
|
| 992 |
-
"quantile-anchor recomposition on a film/v4 stack too")
|
| 993 |
-
ap.add_argument("--affine-lambda", dest="lambda_shrink", type=float, default=1.0,
|
| 994 |
-
help="geometric-interpolation shrinkage of the affine spread target "
|
| 995 |
-
"(variance-recomposition amendment 2): 1.0 (default) fully trusts "
|
| 996 |
-
"--hgb-std's predicted spread; 0.0 targets the GNN's own spread "
|
| 997 |
-
"(no rescaling); only used together with --hgb-std on film/v4 "
|
| 998 |
-
"checkpoints")
|
| 999 |
-
ap.add_argument("--w-total", dest="w_total", type=float, default=DEFAULT_W_TOTAL,
|
| 1000 |
-
help="soft-anchor weight on the total-time residual in "
|
| 1001 |
-
"src.hybrid.hybrid_field_soft's objective; default "
|
| 1002 |
-
f"{DEFAULT_W_TOTAL} (the e_val-sweep winner, soft-anchor Task 6 "
|
| 1003 |
-
"promotion, 2026-07-31) -- only used together with --hgb-std")
|
| 1004 |
-
ap.add_argument("--w-std", dest="w_std", type=float, default=DEFAULT_W_STD,
|
| 1005 |
-
help="soft-anchor weight on the spread residual in "
|
| 1006 |
-
"src.hybrid.hybrid_field_soft's objective; default "
|
| 1007 |
-
f"{DEFAULT_W_STD} (the e_val-sweep winner, soft-anchor Task 6 "
|
| 1008 |
-
"promotion, 2026-07-31) -- only used together with --hgb-std")
|
| 1009 |
ap.add_argument("--alloy", default=None,
|
| 1010 |
-
help=f"
|
| 1011 |
-
ap.add_argument("--mold", default=
|
| 1012 |
-
help="
|
| 1013 |
ap.add_argument("--props", dest="props_json", default=None,
|
| 1014 |
-
help="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1015 |
ap.add_argument("--no-shrinkage", dest="screen_shrinkage", action="store_false",
|
| 1016 |
-
help="
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
"screen_casting's top-sink/feeder placement; ignored with "
|
| 1025 |
-
"--no-shrinkage. Parsed by _parse_gravity as argparse's own "
|
| 1026 |
-
"type= callable (review round 1, finding 5), so a malformed "
|
| 1027 |
-
"value (wrong arity, non-numeric, or the zero vector) is a "
|
| 1028 |
-
"normal argparse usage error, not a traceback.")
|
| 1029 |
-
a = ap.parse_args()
|
| 1030 |
-
r = predict_casting(a.step_path, out_dir=a.out_dir, elem_size=a.elem_size,
|
| 1031 |
-
mesh_mode=a.mesh_mode,
|
| 1032 |
-
gnn_ckpt=a.gnn_ckpt, hgb_path=a.hgb_path,
|
| 1033 |
-
alloy=a.alloy, mold=a.mold, props_json=a.props_json,
|
| 1034 |
-
hgb_std_path=a.hgb_std_path,
|
| 1035 |
-
lambda_shrink=a.lambda_shrink, w_total=a.w_total, w_std=a.w_std,
|
| 1036 |
-
screen_shrinkage=a.screen_shrinkage,
|
| 1037 |
-
gravity=a.gravity)
|
| 1038 |
-
print(f"{r['name']}: total {r['total_time_s']:.1f}s mesh_mode {r['mesh_mode']} "
|
| 1039 |
-
f"nodes {r['n_nodes']} -> {r['vtu']}")
|
| 1040 |
for w in r["ood_warnings"]:
|
| 1041 |
print("WARNING:", w)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""STEP file in, solidification field out.
|
| 2 |
|
| 3 |
+
python -m src.predict_casting part.step --out-dir out [--alloy G20Mn5]
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
Steps: mesh the STEP file (healed, density-searched and cached, or at a fixed
|
| 6 |
+
element size with --elem-size), run the model, check the training envelope,
|
| 7 |
+
screen for shrinkage, and write `<name>.vtu`, `<name>_summary.txt` and the
|
| 8 |
+
mesh `<name>.msh` to the output directory.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
import argparse
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
import logging
|
| 14 |
+
import os
|
| 15 |
import os.path as osp
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
import numpy as np
|
| 18 |
|
| 19 |
+
from src import ood as _ood
|
| 20 |
+
from src.features import FEATURE_NAMES
|
| 21 |
+
from src.inference_props import DEFAULT_ALLOY, DEFAULT_MOLD, resolve_alloy_family, resolve_props
|
| 22 |
+
from src.mesh_cache import get_or_mesh
|
| 23 |
+
from src.meshing import DEFAULT_TARGET_NODES, mesh_step
|
| 24 |
+
from src.model_api import CastSolidModel
|
| 25 |
+
from src.msh_parse import parse_msh
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
from src.shrinkage import screen_casting
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
|
| 30 |
+
MODEL_VERSION = "v5.1"
|
| 31 |
+
_VOL_IDX = FEATURE_NAMES.index("volume_mm3")
|
| 32 |
+
_GCOOL_IDX = FEATURE_NAMES.index("geodesic_d_cool")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def write_vtu(out_path, node_xyz, tets, seconds, top_frac=0.1, extra_point_data=None):
|
| 36 |
+
"""Write the mesh with `solidification_time` (s) and `hotspot` (the
|
| 37 |
+
`top_frac` latest-solidifying nodes) point fields, plus any extras."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
import meshio
|
| 39 |
seconds = np.asarray(seconds, dtype=np.float32)
|
| 40 |
n = seconds.shape[0]
|
|
|
|
| 50 |
m.write(out_path)
|
| 51 |
|
| 52 |
|
| 53 |
+
def _surface_and_feed_nodes(mesh):
|
| 54 |
+
"""All tagged surface nodes, and the adiabatic-tagged ones that can feed
|
| 55 |
+
a shrinkage region (none on a STEP meshed by this pipeline)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
tri_arrays = [tri for tri in mesh.tris.values() if tri.size]
|
| 57 |
surface_nodes = (np.unique(np.concatenate(tri_arrays, axis=0).reshape(-1))
|
| 58 |
if tri_arrays else np.zeros(0, dtype=np.int64))
|
| 59 |
adiab_tri = mesh.tris.get("Adiabatic")
|
| 60 |
feed_nodes = (np.unique(adiab_tri.reshape(-1))
|
| 61 |
+
if adiab_tri is not None and adiab_tri.size else np.zeros(0, dtype=np.int64))
|
| 62 |
return surface_nodes.astype(np.int64), feed_nodes.astype(np.int64)
|
| 63 |
|
| 64 |
|
| 65 |
def _shrinkage_result_for_return(screen: dict) -> dict:
|
| 66 |
+
"""The screening result without its per-node arrays, plus counts."""
|
|
|
|
|
|
|
|
|
|
| 67 |
result = {k: v for k, v in screen.items() if k not in ("niyama", "porosity_risk")}
|
| 68 |
risk = screen["porosity_risk"]
|
| 69 |
result["flagged_frac"] = float(np.mean(risk)) if risk.size else 0.0
|
|
|
|
| 73 |
return result
|
| 74 |
|
| 75 |
|
| 76 |
+
def _shrinkage_point_data(mesh, screen: dict) -> dict:
|
|
|
|
|
|
|
|
|
|
| 77 |
n_nodes = mesh.node_xyz.shape[0]
|
| 78 |
macro_region_id = np.full(n_nodes, -1, dtype=np.int64)
|
| 79 |
for region in screen["macro_shrinkage"]:
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
|
| 88 |
+
def _write_shrinkage_summary(f, shrinkage) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
if shrinkage is None:
|
| 90 |
f.write("shrinkage screening: disabled\n")
|
| 91 |
return
|
| 92 |
f.write("shrinkage screening:\n")
|
| 93 |
f.write(f" niyama_source: {shrinkage['niyama_source']}\n")
|
| 94 |
+
# The risk mask is the lowest Niyama decile of the part, so this
|
| 95 |
+
# percentage is fixed by construction, not a measured severity.
|
| 96 |
+
f.write(f" flagged nodes: {shrinkage['flagged_frac'] * 100.0:.1f}%"
|
| 97 |
+
f" (bottom decile by construction)\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
macro = shrinkage["macro_shrinkage"]
|
| 99 |
if macro:
|
| 100 |
+
# Regions under 4 nodes are listed but get no feeder suggestion.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
n_sub_resolution = sum(1 for r in macro if r.get("sub_resolution"))
|
| 102 |
region_count = str(len(macro))
|
| 103 |
if n_sub_resolution:
|
|
|
|
| 105 |
worst = max(macro, key=lambda r: r["severity"])
|
| 106 |
c = worst["centroid"]
|
| 107 |
f.write(f" macro shrinkage regions: {region_count} "
|
| 108 |
+
f"(worst severity={worst['severity']:.4g} mm^3 @ "
|
| 109 |
+
f"({c[0]:.1f},{c[1]:.1f},{c[2]:.1f}))\n")
|
| 110 |
else:
|
| 111 |
f.write(" macro shrinkage regions: 0\n")
|
| 112 |
f.write(f" sinks: {len(shrinkage['top_sinks'])}\n")
|
|
|
|
| 116 |
for feeder in feeders:
|
| 117 |
loc = feeder["location"]
|
| 118 |
f.write(f" - location=({loc[0]:.1f},{loc[1]:.1f},{loc[2]:.1f}) mm "
|
| 119 |
+
f"min_modulus_mm={feeder['min_modulus_mm']:.4f}\n")
|
| 120 |
else:
|
| 121 |
f.write(" feeder recommendations: none\n")
|
| 122 |
if shrinkage.get("alloy_family_assumed"):
|
| 123 |
f.write(f" alloy_family_assumed: {shrinkage['material']['alloy_family']} "
|
| 124 |
+
f"(alloy not in the materials table -- conservative fallback)\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
+
def _mesh(step_path, out_msh, elem_size, target_nodes, cache_dir):
|
| 128 |
+
if elem_size is not None:
|
| 129 |
+
info = mesh_step(step_path, out_msh, elem_size)
|
| 130 |
+
mesh = parse_msh(out_msh)
|
| 131 |
+
return mesh, {"mode": "fixed", "n_nodes": info["n_nodes"], "n_tets": info["n_tets"],
|
| 132 |
+
"elem_size": info["elem_size"], "cache": "n/a"}
|
| 133 |
+
info = get_or_mesh(step_path, out_msh, target_nodes=target_nodes, cache_dir=cache_dir)
|
| 134 |
+
if not info.get("ok", False):
|
| 135 |
+
raise RuntimeError(f"meshing failed for {step_path!r}: {info.get('notes', '(no notes)')}")
|
| 136 |
+
mesh = parse_msh(out_msh)
|
| 137 |
+
return mesh, {"mode": "density-searched", "n_nodes": info["n_nodes"],
|
| 138 |
+
"n_tets": int(mesh.tets.shape[0]), "elem_size": None,
|
| 139 |
+
"cache": info.get("cache", "unknown")}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def predict_casting(step_path, out_dir="runs/castings", alloy=None, mold=DEFAULT_MOLD,
|
| 143 |
+
props_json=None, elem_size=None,
|
| 144 |
target_nodes: int = DEFAULT_TARGET_NODES,
|
| 145 |
+
screen_shrinkage: bool = True, gravity: tuple = (0, 0, -1),
|
| 146 |
+
model: CastSolidModel | None = None, weights_dir: str = "weights",
|
| 147 |
+
ood_json: str | None = None, cache_dir: str = "runs/mesh_cache",
|
| 148 |
+
device: str | None = None) -> dict:
|
| 149 |
+
"""Mesh `step_path`, predict, and write the result files.
|
| 150 |
+
|
| 151 |
+
`alloy`/`mold` name rows of `data/properties/` (default EN-GJS-400-15 in
|
| 152 |
+
green sand); `props_json` replaces both. `elem_size` (mm) meshes at a
|
| 153 |
+
fixed size instead of searching for `target_nodes`. Pass a loaded
|
| 154 |
+
`model` to reuse it across calls.
|
| 155 |
+
|
| 156 |
+
Returns {name, vtu, summary, msh, total_time_s, hotspot_xyz (5 x 3 mm),
|
| 157 |
+
ood_warnings, n_nodes, mesh_mode, elem_size, cache, recomposition_info,
|
| 158 |
+
shrinkage}."""
|
| 159 |
+
if elem_size is not None and target_nodes != DEFAULT_TARGET_NODES:
|
| 160 |
+
raise ValueError("predict_casting: give either elem_size or target_nodes, not both")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
os.makedirs(out_dir, exist_ok=True)
|
| 162 |
name = osp.splitext(osp.basename(step_path))[0]
|
| 163 |
out_msh = osp.join(out_dir, f"{name}.msh")
|
| 164 |
|
| 165 |
+
# Resolve properties before meshing so a bad alloy name fails fast.
|
|
|
|
| 166 |
props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
|
| 167 |
+
model = model or CastSolidModel(weights_dir=weights_dir, device=device)
|
| 168 |
|
| 169 |
+
mesh, minfo = _mesh(step_path, out_msh, elem_size, target_nodes, cache_dir)
|
| 170 |
+
seconds, details = model.predict_with_props(mesh, props)
|
| 171 |
+
X = details["X"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
warnings = []
|
| 174 |
+
ood_json = ood_json or osp.join(weights_dir, "ood_ranges.json")
|
| 175 |
if osp.exists(ood_json):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
diag = float(np.linalg.norm(mesh.node_xyz.max(0) - mesh.node_xyz.min(0)))
|
| 177 |
+
warnings = _ood.ood_flags(float(X[0, _VOL_IDX]), diag, float(X[:, _GCOOL_IDX].max()),
|
| 178 |
+
_ood.load_ranges(ood_json))
|
| 179 |
|
|
|
|
|
|
|
|
|
|
| 180 |
shrinkage = None
|
| 181 |
+
point_data = None
|
| 182 |
if screen_shrinkage:
|
| 183 |
+
surface_nodes, feed_nodes = _surface_and_feed_nodes(mesh)
|
| 184 |
if surface_nodes.size == 0:
|
| 185 |
+
logger.warning("predict_casting: no surface nodes on the mesh -- "
|
| 186 |
+
"shrinkage screening skipped")
|
|
|
|
|
|
|
| 187 |
else:
|
| 188 |
+
alloy_family, assumed = resolve_alloy_family(alloy, props_json)
|
| 189 |
+
screen = screen_casting(pos=mesh.node_xyz, tets=mesh.tets,
|
| 190 |
+
surface_nodes=surface_nodes, t_s=seconds,
|
| 191 |
+
TL=props["TL"], TS=props["TS"],
|
| 192 |
+
alloy_family=alloy_family, gravity_vec=gravity,
|
| 193 |
+
feed_nodes=feed_nodes)
|
| 194 |
+
shrinkage = _shrinkage_result_for_return(screen)
|
| 195 |
+
if assumed:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
shrinkage["alloy_family_assumed"] = True
|
| 197 |
+
point_data = _shrinkage_point_data(mesh, screen)
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
vtu = osp.join(out_dir, f"{name}.vtu")
|
| 200 |
+
write_vtu(vtu, mesh.node_xyz, mesh.tets, seconds, extra_point_data=point_data)
|
| 201 |
|
|
|
|
| 202 |
order = np.argsort(seconds)[::-1][:5]
|
| 203 |
hot_xyz = mesh.node_xyz[order]
|
| 204 |
total = float(seconds.max())
|
| 205 |
+
info = details["recomposition"]
|
| 206 |
summary = osp.join(out_dir, f"{name}_summary.txt")
|
| 207 |
+
alloy_label = (f"custom ({props_json})" if props_json is not None
|
| 208 |
+
else alloy if alloy is not None else DEFAULT_ALLOY)
|
|
|
|
| 209 |
mold_label = mold if props_json is None else "n/a (props_json)"
|
| 210 |
+
bbox_mm = tuple(round(float(v), 1) for v in mesh.node_xyz.max(0) - mesh.node_xyz.min(0))
|
| 211 |
+
elem_label = (f"{minfo['elem_size']:.2f} mm" if minfo["elem_size"] is not None
|
| 212 |
+
else "n/a (density-searched)")
|
| 213 |
with open(summary, "w") as f:
|
| 214 |
f.write(f"casting: {name}\n")
|
| 215 |
f.write(f"total solidification time: {total:.1f} s\n")
|
| 216 |
+
f.write(f"alloy: {alloy_label} mold: {mold_label} model: {MODEL_VERSION}\n")
|
| 217 |
+
f.write(f"mesh: {minfo['mode']} cache: {minfo['cache']} nodes: {minfo['n_nodes']} "
|
| 218 |
+
f"tets: {minfo['n_tets']} elem_size: {elem_label} bbox_mm: {bbox_mm}\n")
|
| 219 |
+
line = (f"recomposition: a={info['a']:.4f}, m={info['m']:.4f}, "
|
| 220 |
+
f"target_unreachable={info['target_unreachable']}, "
|
| 221 |
+
f"clip_frac={info['clip_frac']:.4f}")
|
| 222 |
+
if abs(info.get("total_deviation_frac", 0.0)) > 0.001:
|
| 223 |
+
line += f", total_dev={info['total_deviation_frac'] * 100.0:+.1f}%"
|
| 224 |
+
f.write(line + "\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
if screen_shrinkage:
|
| 226 |
+
_write_shrinkage_summary(f, shrinkage)
|
| 227 |
f.write("top hot spots (x,y,z mm : t_solidus s):\n")
|
| 228 |
for xyz_i, i in zip(hot_xyz, order):
|
| 229 |
f.write(f" ({xyz_i[0]:.1f},{xyz_i[1]:.1f},{xyz_i[2]:.1f}) : {seconds[i]:.1f}\n")
|
|
|
|
| 232 |
for w in warnings:
|
| 233 |
f.write(f" - {w}\n")
|
| 234 |
|
| 235 |
+
return {"name": name, "vtu": vtu, "summary": summary, "msh": out_msh,
|
| 236 |
+
"total_time_s": total, "hotspot_xyz": hot_xyz, "ood_warnings": warnings,
|
| 237 |
+
"n_nodes": minfo["n_nodes"], "mesh_mode": minfo["mode"],
|
| 238 |
+
"elem_size": minfo["elem_size"], "cache": minfo["cache"],
|
| 239 |
+
"recomposition_info": info, "shrinkage": shrinkage}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
+
|
| 242 |
+
def _parse_gravity(s: str) -> tuple:
|
| 243 |
+
parts = [p.strip() for p in s.split(",")]
|
| 244 |
+
if len(parts) != 3:
|
| 245 |
+
raise ValueError(f"--gravity must be 'x,y,z' (3 comma-separated numbers), got {s!r}")
|
| 246 |
+
vec = tuple(float(p) for p in parts)
|
| 247 |
+
if all(v == 0.0 for v in vec):
|
| 248 |
+
raise ValueError(f"--gravity must not be the zero vector (got {s!r})")
|
| 249 |
+
return vec
|
|
|
|
| 250 |
|
| 251 |
|
| 252 |
+
def main(argv=None):
|
| 253 |
+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
| 254 |
ap.add_argument("step_path")
|
| 255 |
ap.add_argument("--out-dir", default="runs/castings")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
ap.add_argument("--alloy", default=None,
|
| 257 |
+
help=f"alloy from data/properties/materials.csv (default {DEFAULT_ALLOY})")
|
| 258 |
+
ap.add_argument("--mold", default=DEFAULT_MOLD,
|
| 259 |
+
help=f"mold from data/properties/molds.csv (default {DEFAULT_MOLD})")
|
| 260 |
ap.add_argument("--props", dest="props_json", default=None,
|
| 261 |
+
help="JSON file with k, rho, cp, L_kJ_kg, TL, TS, Tpour, mold_effusivity "
|
| 262 |
+
"(replaces --alloy and --mold)")
|
| 263 |
+
ap.add_argument("--elem-size", type=float, default=None,
|
| 264 |
+
help="mesh at this fixed element size in mm instead of searching for "
|
| 265 |
+
f"about {DEFAULT_TARGET_NODES} nodes")
|
| 266 |
+
ap.add_argument("--gravity", type=_parse_gravity, default=(0, 0, -1),
|
| 267 |
+
help="gravity direction 'x,y,z' for sinks and feeder placement "
|
| 268 |
+
"(default 0,0,-1)")
|
| 269 |
ap.add_argument("--no-shrinkage", dest="screen_shrinkage", action="store_false",
|
| 270 |
+
help="skip the shrinkage screening")
|
| 271 |
+
ap.add_argument("--weights-dir", default="weights")
|
| 272 |
+
a = ap.parse_args(argv)
|
| 273 |
+
r = predict_casting(a.step_path, out_dir=a.out_dir, alloy=a.alloy, mold=a.mold,
|
| 274 |
+
props_json=a.props_json, elem_size=a.elem_size,
|
| 275 |
+
screen_shrinkage=a.screen_shrinkage, gravity=a.gravity,
|
| 276 |
+
weights_dir=a.weights_dir)
|
| 277 |
+
print(f"{r['name']}: total {r['total_time_s']:.1f}s nodes {r['n_nodes']} -> {r['vtu']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
for w in r["ood_warnings"]:
|
| 279 |
print("WARNING:", w)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
if __name__ == "__main__":
|
| 283 |
+
main()
|
src/release_io.py
CHANGED
|
@@ -1,89 +1,25 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
`
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
`norm.<key>`, and a JSON `cfg` plus the norm entries' original container
|
| 9 |
-
type in the safetensors metadata header. `load_gnn_state` rebuilds the
|
| 10 |
-
exact `{model, norm, cfg}` dict `torch.load` would have returned, so every
|
| 11 |
-
downstream loader (`src.evaluate_ft2b.load_ft2b_gnn`, `load_hybrid`) takes
|
| 12 |
-
it unchanged.
|
| 13 |
-
- GBM -> `.skops` (sklearn's safe serialization). Loading checks the file's
|
| 14 |
-
untrusted types against a fixed sklearn/numpy allowlist instead of
|
| 15 |
-
trusting whatever the file names.
|
| 16 |
-
|
| 17 |
-
`resolve_artifact` maps the internal default paths (`runs/...`) onto the
|
| 18 |
-
release layout (`weights/...`) when only the latter exists, so the public
|
| 19 |
-
snapshot runs `python -m src.predict_casting part.step` with zero flags
|
| 20 |
-
while the internal checkout keeps using its original files.
|
| 21 |
"""
|
| 22 |
from __future__ import annotations
|
| 23 |
|
| 24 |
import json
|
| 25 |
import os
|
| 26 |
|
| 27 |
-
import numpy as np
|
| 28 |
-
import torch
|
| 29 |
-
|
| 30 |
GNN_FORMAT = "foundry-gnn-release/1"
|
| 31 |
|
| 32 |
-
# Internal default path -> public release path (repo-root relative).
|
| 33 |
-
RELEASE_PATHS = {
|
| 34 |
-
"runs/ft3_stage1/ckpt_ep021.pt": "weights/gnn_v5.1.safetensors",
|
| 35 |
-
"runs/ft3_gbm/hgb_v3.joblib": "weights/gbm_total_v3.skops",
|
| 36 |
-
"runs/ft3_gbm/hgb_std_v2.joblib": "weights/gbm_std_v2.skops",
|
| 37 |
-
"data/features_v2/ood_ranges.json": "weights/ood_ranges.json",
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
# Types a release `.skops` GBM file may name. Anything else fails the load.
|
| 41 |
_SKOPS_ALLOWED_PREFIXES = ("sklearn.", "numpy.")
|
| 42 |
|
| 43 |
|
| 44 |
-
def resolve_artifact(path):
|
| 45 |
-
"""`path` unchanged unless it is a known internal default that is absent
|
| 46 |
-
on disk while its release counterpart exists."""
|
| 47 |
-
if isinstance(path, (str, os.PathLike)):
|
| 48 |
-
p = os.fspath(path)
|
| 49 |
-
alt = RELEASE_PATHS.get(p)
|
| 50 |
-
if alt is not None and not os.path.exists(p) and os.path.exists(alt):
|
| 51 |
-
return alt
|
| 52 |
-
return path
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def _norm_to_tensor(value):
|
| 56 |
-
if isinstance(value, torch.Tensor):
|
| 57 |
-
return value.detach().cpu().contiguous(), "torch"
|
| 58 |
-
if isinstance(value, np.ndarray):
|
| 59 |
-
return torch.from_numpy(np.ascontiguousarray(value)), "numpy"
|
| 60 |
-
return None, None
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def save_gnn_release(state: dict, out_path: str, extra_metadata: dict | None = None) -> None:
|
| 64 |
-
"""Write a `{model, norm, cfg}` checkpoint dict as one safetensors file."""
|
| 65 |
-
from safetensors.torch import save_file
|
| 66 |
-
tensors, norm_kinds, norm_scalars = {}, {}, {}
|
| 67 |
-
for k, v in state["model"].items():
|
| 68 |
-
tensors[f"model.{k}"] = v.detach().cpu().contiguous()
|
| 69 |
-
for k, v in state["norm"].items():
|
| 70 |
-
t, kind = _norm_to_tensor(v)
|
| 71 |
-
if t is None:
|
| 72 |
-
norm_scalars[k] = v
|
| 73 |
-
else:
|
| 74 |
-
tensors[f"norm.{k}"] = t
|
| 75 |
-
norm_kinds[k] = kind
|
| 76 |
-
meta = {"format": GNN_FORMAT,
|
| 77 |
-
"cfg": json.dumps(state["cfg"], sort_keys=True),
|
| 78 |
-
"norm_kinds": json.dumps(norm_kinds, sort_keys=True),
|
| 79 |
-
"norm_scalars": json.dumps(norm_scalars, sort_keys=True)}
|
| 80 |
-
for k, v in (extra_metadata or {}).items():
|
| 81 |
-
meta[k] = v if isinstance(v, str) else json.dumps(v)
|
| 82 |
-
save_file(tensors, out_path, metadata=meta)
|
| 83 |
-
|
| 84 |
-
|
| 85 |
def load_gnn_state(path: str, device="cpu") -> dict:
|
| 86 |
-
"""
|
| 87 |
from safetensors import safe_open
|
| 88 |
with safe_open(path, framework="pt", device="cpu") as f:
|
| 89 |
meta = f.metadata() or {}
|
|
@@ -101,31 +37,11 @@ def load_gnn_state(path: str, device="cpu") -> dict:
|
|
| 101 |
return {"model": model, "norm": norm, "cfg": json.loads(meta["cfg"])}
|
| 102 |
|
| 103 |
|
| 104 |
-
def save_gbm_release(model, out_path: str) -> None:
|
| 105 |
-
import skops.io as sio
|
| 106 |
-
sio.dump(model, out_path)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
def load_gbm(path):
|
| 110 |
-
"""
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
if
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
if bad:
|
| 118 |
-
raise ValueError(f"{path}: refusing to load unexpected types {bad}")
|
| 119 |
-
return sio.load(path, trusted=untrusted)
|
| 120 |
-
import joblib
|
| 121 |
-
return joblib.load(path)
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def load_gnn_any(path, device):
|
| 125 |
-
"""A GNN checkpoint path (`.safetensors` release or internal `.pt`) as
|
| 126 |
-
the `{model, norm, cfg}` dict; an already-loaded dict passes through."""
|
| 127 |
-
if not isinstance(path, (str, os.PathLike)):
|
| 128 |
-
return path
|
| 129 |
-
if os.fspath(path).endswith(".safetensors"):
|
| 130 |
-
return load_gnn_state(path, device)
|
| 131 |
-
return torch.load(path, map_location=device, weights_only=False)
|
|
|
|
| 1 |
+
"""Loading the published weight files.
|
| 2 |
|
| 3 |
+
- GNN: one safetensors file with tensors `model.<state_dict key>` and
|
| 4 |
+
`norm.<key>`; the architecture config (`cfg`) and the container type of
|
| 5 |
+
each normalization entry are JSON strings in the safetensors metadata.
|
| 6 |
+
- Gradient-boosted heads: skops files. Before loading, every type a file
|
| 7 |
+
names is checked against an allowlist (scikit-learn and NumPy only).
|
| 8 |
|
| 9 |
+
Neither format runs code when loaded.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import json
|
| 14 |
import os
|
| 15 |
|
|
|
|
|
|
|
|
|
|
| 16 |
GNN_FORMAT = "foundry-gnn-release/1"
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
_SKOPS_ALLOWED_PREFIXES = ("sklearn.", "numpy.")
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def load_gnn_state(path: str, device="cpu") -> dict:
|
| 22 |
+
"""The `{model, norm, cfg}` dict stored in a GNN safetensors file."""
|
| 23 |
from safetensors import safe_open
|
| 24 |
with safe_open(path, framework="pt", device="cpu") as f:
|
| 25 |
meta = f.metadata() or {}
|
|
|
|
| 37 |
return {"model": model, "norm": norm, "cfg": json.loads(meta["cfg"])}
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
def load_gbm(path):
|
| 41 |
+
"""A gradient-boosted head from a skops file, after the type check."""
|
| 42 |
+
import skops.io as sio
|
| 43 |
+
untrusted = sio.get_untrusted_types(file=os.fspath(path))
|
| 44 |
+
bad = [t for t in untrusted if not t.startswith(_SKOPS_ALLOWED_PREFIXES)]
|
| 45 |
+
if bad:
|
| 46 |
+
raise ValueError(f"{path}: refusing to load unexpected types {bad}")
|
| 47 |
+
return sio.load(os.fspath(path), trusted=untrusted)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/run_baseline.py
DELETED
|
@@ -1,150 +0,0 @@
|
|
| 1 |
-
"""End-to-end Phase-0 baseline: build splits, train HGB (+ ablation) and MLP,
|
| 2 |
-
evaluate across splits, write a Markdown report and per-prefix MAE plot."""
|
| 3 |
-
import os
|
| 4 |
-
import os.path as osp
|
| 5 |
-
|
| 6 |
-
import numpy as np
|
| 7 |
-
import torch
|
| 8 |
-
import matplotlib
|
| 9 |
-
matplotlib.use("Agg")
|
| 10 |
-
import matplotlib.pyplot as plt
|
| 11 |
-
|
| 12 |
-
from src.dataload import load_matrix, load_norm, standardize
|
| 13 |
-
from src.baseline import train_hgb, train_mlp, predict_seconds
|
| 14 |
-
from src.splits import build_splits, save_splits, load_splits
|
| 15 |
-
from src import metrics
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def _xyz_mm(features_dir, data_root, stem, n):
|
| 19 |
-
"""Per-node positions in mm. Prefer the feature cache (v2 stores `pos` in mm);
|
| 20 |
-
fall back to the raw .pt (metres) when the cache lacks positions."""
|
| 21 |
-
gpath = osp.join(features_dir, "graphs", f"{stem}.npz")
|
| 22 |
-
d = np.load(gpath)
|
| 23 |
-
if "pos" in d.files:
|
| 24 |
-
return d["pos"][:n] # already mm
|
| 25 |
-
raw = torch.load(osp.join(data_root, "pt", f"{stem}.pt"), weights_only=False)
|
| 26 |
-
return raw["pos"].numpy()[:n] * 1000.0
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def evaluate_model(model, features_dir, test_stems, norm, data_root="data"):
|
| 30 |
-
X, y, gid = load_matrix(features_dir, test_stems)
|
| 31 |
-
# Both model types are trained on standardized features (see run()), so
|
| 32 |
-
# standardize uniformly here too.
|
| 33 |
-
Xs = standardize(X, norm)
|
| 34 |
-
pred = predict_seconds(model, Xs, norm)
|
| 35 |
-
xyz = np.concatenate([_xyz_mm(features_dir, data_root, s, (gid == i).sum())
|
| 36 |
-
for i, s in enumerate(test_stems)])
|
| 37 |
-
return {
|
| 38 |
-
"mae": metrics.mae(y, pred),
|
| 39 |
-
"rmse": metrics.rmse(y, pred),
|
| 40 |
-
"median_rel_error": metrics.median_rel_error(y, pred),
|
| 41 |
-
"pred_std_ratio": metrics.pred_std_ratio(y, pred, gid),
|
| 42 |
-
"hotspot_accuracy": metrics.hotspot_accuracy(y, pred, xyz, gid),
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def per_prefix_mae(model, features_dir, test_stems, norm):
|
| 47 |
-
by = {}
|
| 48 |
-
for s in test_stems:
|
| 49 |
-
X, y, _ = load_matrix(features_dir, [s])
|
| 50 |
-
pred = predict_seconds(model, standardize(X, norm), norm)
|
| 51 |
-
by.setdefault(s.split("_")[0], []).append(metrics.mae(y, pred))
|
| 52 |
-
return {k: float(np.mean(v)) for k, v in sorted(by.items())}
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def _balanced(train_stems, meta_lookup):
|
| 56 |
-
"""Config C: downsample so each n_adiab regime has ~equal stem count."""
|
| 57 |
-
from collections import defaultdict
|
| 58 |
-
import random
|
| 59 |
-
groups = defaultdict(list)
|
| 60 |
-
for s in train_stems:
|
| 61 |
-
groups[meta_lookup[s]].append(s)
|
| 62 |
-
if not groups:
|
| 63 |
-
return train_stems
|
| 64 |
-
k = min(len(v) for v in groups.values())
|
| 65 |
-
rng = random.Random(0)
|
| 66 |
-
out = []
|
| 67 |
-
for v in groups.values():
|
| 68 |
-
out.extend(rng.sample(v, k))
|
| 69 |
-
return out
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def run(features_dir="data/features", splits_path=None, out_dir="runs/baseline",
|
| 73 |
-
ablation=True, max_nodes_per_graph=2000, data_root="data"):
|
| 74 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 75 |
-
splits_path = splits_path or osp.join(features_dir, "splits.json")
|
| 76 |
-
meta = np.load(osp.join(features_dir, "meta.npz"), allow_pickle=True)
|
| 77 |
-
if not osp.exists(splits_path):
|
| 78 |
-
save_splits(build_splits(meta), splits_path)
|
| 79 |
-
sp = load_splits(splits_path)
|
| 80 |
-
norm = load_norm(features_dir)
|
| 81 |
-
meta_nad = {str(s): int(n) for s, n in zip(meta["stem"], meta["n_adiab"])}
|
| 82 |
-
fully = {str(s) for s, f in zip(meta["stem"], meta["is_fully_cooled"]) if f}
|
| 83 |
-
|
| 84 |
-
configs = {"A_all": sp["interpolation"]["train"]}
|
| 85 |
-
if ablation:
|
| 86 |
-
configs["B_cooled_only"] = [s for s in sp["interpolation"]["train"] if s in fully]
|
| 87 |
-
configs["C_balanced"] = _balanced(sp["interpolation"]["train"], meta_nad)
|
| 88 |
-
|
| 89 |
-
eval_sets = {"interpolation": sp["interpolation"]["test"],
|
| 90 |
-
"family": sp["family"]["test"],
|
| 91 |
-
"fully_cooled": sp["fully_cooled"]["test"]}
|
| 92 |
-
|
| 93 |
-
lines = ["# Phase 0 Baseline Report\n"]
|
| 94 |
-
results = {}
|
| 95 |
-
for cfg, train_stems in configs.items():
|
| 96 |
-
if not train_stems:
|
| 97 |
-
print(f" skipping config {cfg}: no training stems")
|
| 98 |
-
continue
|
| 99 |
-
Xtr, ytr, _ = load_matrix(features_dir, train_stems,
|
| 100 |
-
max_nodes_per_graph=max_nodes_per_graph)
|
| 101 |
-
model = train_hgb(standardize(Xtr, norm), np.log1p(ytr))
|
| 102 |
-
lines.append(f"\n## HGB — config {cfg} (train graphs: {len(train_stems)})\n")
|
| 103 |
-
lines.append("| split | MAE(s) | RMSE(s) | med.rel | pred/GT std | hotspot |")
|
| 104 |
-
lines.append("|---|---|---|---|---|---|")
|
| 105 |
-
for name, stems in eval_sets.items():
|
| 106 |
-
if not stems:
|
| 107 |
-
continue
|
| 108 |
-
r = evaluate_model(model, features_dir, stems, norm, data_root)
|
| 109 |
-
results[(cfg, name)] = r
|
| 110 |
-
lines.append(f"| {name} | {r['mae']:.2f} | {r['rmse']:.2f} | "
|
| 111 |
-
f"{r['median_rel_error']:.3f} | {r['pred_std_ratio']:.3f} | "
|
| 112 |
-
f"{r['hotspot_accuracy']:.3f} |")
|
| 113 |
-
if cfg == "A_all":
|
| 114 |
-
import joblib
|
| 115 |
-
joblib.dump(model, osp.join(out_dir, "hgb_A.joblib"))
|
| 116 |
-
# per-prefix plot on interpolation test
|
| 117 |
-
pmae = per_prefix_mae(model, features_dir, eval_sets["interpolation"], norm)
|
| 118 |
-
plt.figure(figsize=(10, 4))
|
| 119 |
-
plt.bar(list(pmae.keys()), list(pmae.values()))
|
| 120 |
-
plt.ylabel("MAE (s)"); plt.xticks(rotation=90); plt.tight_layout()
|
| 121 |
-
plt.savefig(osp.join(out_dir, "per_prefix_mae.png"), dpi=120)
|
| 122 |
-
plt.close()
|
| 123 |
-
|
| 124 |
-
# MLP on config A
|
| 125 |
-
if not configs["A_all"]:
|
| 126 |
-
print(f" skipping MLP training: config A_all has no training stems")
|
| 127 |
-
else:
|
| 128 |
-
Xtr, ytr, _ = load_matrix(features_dir, configs["A_all"],
|
| 129 |
-
max_nodes_per_graph=max_nodes_per_graph)
|
| 130 |
-
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 131 |
-
mlp = train_mlp(standardize(Xtr, norm), np.log1p(ytr), device=device)
|
| 132 |
-
lines.append("\n## MLP — config A_all\n")
|
| 133 |
-
lines.append("| split | MAE(s) | RMSE(s) | med.rel | pred/GT std | hotspot |")
|
| 134 |
-
lines.append("|---|---|---|---|---|---|")
|
| 135 |
-
for name, stems in eval_sets.items():
|
| 136 |
-
if not stems:
|
| 137 |
-
continue
|
| 138 |
-
r = evaluate_model(mlp, features_dir, stems, norm, data_root)
|
| 139 |
-
lines.append(f"| {name} | {r['mae']:.2f} | {r['rmse']:.2f} | "
|
| 140 |
-
f"{r['median_rel_error']:.3f} | {r['pred_std_ratio']:.3f} | "
|
| 141 |
-
f"{r['hotspot_accuracy']:.3f} |")
|
| 142 |
-
|
| 143 |
-
with open(osp.join(out_dir, "report.md"), "w") as f:
|
| 144 |
-
f.write("\n".join(lines) + "\n")
|
| 145 |
-
print(f"Report → {osp.join(out_dir, 'report.md')}")
|
| 146 |
-
return results
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
if __name__ == "__main__":
|
| 150 |
-
run()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/select_ckpt_ft2b.py
DELETED
|
@@ -1,331 +0,0 @@
|
|
| 1 |
-
"""FT2b Task 7: gate-metric checkpoint selector.
|
| 2 |
-
|
| 3 |
-
`src.train_gnn.train(..., save_every_epoch=True)` (Task 6) writes one
|
| 4 |
-
checkpoint per epoch (`<out_dir>/ckpt_ep{ep:03d}.pt`, payload
|
| 5 |
-
`{model, norm, cfg}`). Picking among them by validation loss alone can pick a
|
| 6 |
-
variance-collapsed checkpoint (near-constant predictions can still have low
|
| 7 |
-
smooth-L1 loss on a small/skewed val set) -- exactly the failure mode the
|
| 8 |
-
FT2b gate metrics (`src.evaluate_ft2b`'s `std_ratio`/`hotspot_overlap`) exist
|
| 9 |
-
to catch. This module re-ranks the per-epoch checkpoints by those SAME gate
|
| 10 |
-
metrics instead.
|
| 11 |
-
|
| 12 |
-
=== Why no GBM is needed here (key insight from the Task 7 brief) ===
|
| 13 |
-
`src.evaluate_ft2b._max_anchor_hybrid` recomposes the hybrid prediction as
|
| 14 |
-
`pred_i = gnn_i - anchor + gbm_total`, i.e. a per-graph ADDITIVE SHIFT of
|
| 15 |
-
the GNN field by a constant (`gbm_total - anchor`, the same shift for every
|
| 16 |
-
node in that graph), where `anchor = np.quantile(gnn, q)` (default
|
| 17 |
-
`q=0.995`, a high quantile rather than the exact max -- see
|
| 18 |
-
`src.hybrid.hybrid_field_max_anchor`'s docstring for why: the exact-max
|
| 19 |
-
anchor is fragile to a handful of outlier nodes). Both metrics this
|
| 20 |
-
selector ranks by are shift-invariant per graph regardless of which
|
| 21 |
-
quantile the anchor uses:
|
| 22 |
-
- `std_ratio = std(pred)/std(true)` (`src.metrics.pred_std_ratio`): std of a
|
| 23 |
-
per-graph-shifted array is unchanged by the shift.
|
| 24 |
-
- `hotspot_overlap` (`src.metrics.hotspot_accuracy`): picks the top-`k`
|
| 25 |
-
LATEST-solidifying nodes by rank; a per-graph constant shift does not
|
| 26 |
-
reorder nodes within a graph, so the top-k set (and thus the overlap) is
|
| 27 |
-
unchanged.
|
| 28 |
-
So GNN-only seconds and the eventual hybrid produce IDENTICAL std_ratio/
|
| 29 |
-
hotspot_overlap numbers -- this selector never needs the GBM (which also
|
| 30 |
-
means it never needs the DOE manifest's alloy/BC feature-row resolution that
|
| 31 |
-
`src.gbm_ft2b._row_features` performs; only `alloy_family`/`bc_config` for
|
| 32 |
-
grouping are read from the manifest).
|
| 33 |
-
|
| 34 |
-
=== Reuse, not reimplementation ===
|
| 35 |
-
Everything gate-metric-shaped is imported from `src.evaluate_ft2b`, which is
|
| 36 |
-
already exercised by `tests/test_evaluate_ft2b.py`:
|
| 37 |
-
- `load_ft2b_gnn` -- checkpoint -> `(model, cfg, norm)`, film-aware.
|
| 38 |
-
- `predict_graph_seconds` -- one graph's cached `X`/`pos`/`mesh_edges`/`g`
|
| 39 |
-
-> physical-seconds GNN prediction (the ckpt's own norm-inversion chain).
|
| 40 |
-
- `_group_metrics` / `_overall_metrics` -- the EXACT per-group (and
|
| 41 |
-
overall) `{mae, hotspot_overlap, std_ratio}` computation `evaluate_ft2b`
|
| 42 |
-
uses for its own `eval.json` (pool all nodes of every stem in the group,
|
| 43 |
-
build a per-stem `graph_id` array, hand the pooled arrays to
|
| 44 |
-
`src.metrics.pred_std_ratio`/`hotspot_overlap`). This selector builds the
|
| 45 |
-
same `{"family", "bc", "y_true", "y_hybrid", "xyz"}`-shaped records (with
|
| 46 |
-
`y_hybrid` set to the GNN-only seconds, per the shift-invariance above)
|
| 47 |
-
and calls those two functions UNCHANGED, so its numbers are directly
|
| 48 |
-
comparable to `evaluate_ft2b`'s `eval.json` for the same stems/ckpt.
|
| 49 |
-
- `src.gbm_ft2b._load_manifest_rows` -- manifest CSV -> `{run_id: row}`.
|
| 50 |
-
|
| 51 |
-
=== Ranking (fix pass -- two-sided calibration, not one-sided "biggest std") ===
|
| 52 |
-
For each checkpoint, pool `per_alloy_family` and `per_bc_config` group
|
| 53 |
-
metrics (`bc_config UNION alloy_family`, i.e. every group from BOTH
|
| 54 |
-
partitions, not their cross product) into one flat collection, then rank
|
| 55 |
-
checkpoints by, in order:
|
| 56 |
-
1. FEWEST of those groups with `std_ratio < std_ratio_threshold` (default
|
| 57 |
-
0.9, matching `evaluate_ft2b`'s own `variance_collapse_threshold`
|
| 58 |
-
default) -- fewer variance-collapsed groups is better.
|
| 59 |
-
2. SMALLEST `calib = max over groups of abs(log(std_ratio))` -- a
|
| 60 |
-
TWO-SIDED mis-calibration score. `std_ratio` measures the same
|
| 61 |
-
miscalibration whether it collapses (std_ratio << 1) or over-disperses
|
| 62 |
-
(std_ratio >> 1); `log` makes the two directions symmetric
|
| 63 |
-
(`log(0.5) == -log(2)`) so neither a collapsed nor an inflated group can
|
| 64 |
-
win by having a numerically larger raw ratio.
|
| 65 |
-
ORIGINAL (WRONG) CRITERION, replaced here: "largest minimum group
|
| 66 |
-
std_ratio" -- that criterion is UNBOUNDED ABOVE and actively rewards
|
| 67 |
-
over-dispersion (a checkpoint where every group is 2.3x over-dispersed
|
| 68 |
-
beats one where every group is a well-calibrated ~0.95, because 2.3 >
|
| 69 |
-
0.95 as a raw ratio). Confirmed on a real 64-checkpoint run: the old
|
| 70 |
-
criterion picked ep019 (min_group_std_ratio=2.34, EVERY group
|
| 71 |
-
over-dispersed) over ep063 (all groups >= 0.9, calibrated). `calib`
|
| 72 |
-
fixes this: ep019's `calib = abs(log(2.34)) ~= 0.85`; a calibrated
|
| 73 |
-
ep063 with all ratios near 1 has `calib` near 0 and wins.
|
| 74 |
-
3. HIGHEST overall `hotspot_overlap` (pooled over every given stem).
|
| 75 |
-
4. Deterministic tie-break: LOWEST epoch number (prefers the earlier,
|
| 76 |
-
cheaper-to-have-trained checkpoint when the gate metrics are tied).
|
| 77 |
-
|
| 78 |
-
`min_group_std_ratio`/`max_group_std_ratio` are still computed and stored on
|
| 79 |
-
every ranking row (for human inspection / plotting the calibration spread)
|
| 80 |
-
-- they are just no longer part of the sort key beyond criterion (1)'s
|
| 81 |
-
threshold count.
|
| 82 |
-
|
| 83 |
-
=== `select_ckpt` vs `rerank_selection` ===
|
| 84 |
-
`select_ckpt` runs the (slow -- GPU forward pass per checkpoint) evaluation
|
| 85 |
-
and produces `selection.json`. `rerank_selection` re-applies the ranking
|
| 86 |
-
rule above to an ALREADY-WRITTEN `selection.json` (the per-group numbers for
|
| 87 |
-
every checkpoint are already in that file) with NO forward passes --
|
| 88 |
-
needed because a real run over many checkpoints takes hours, and a ranking
|
| 89 |
-
bugfix must not force re-running it. Both route through the same
|
| 90 |
-
`_rank_entries` so the two can never silently diverge.
|
| 91 |
-
"""
|
| 92 |
-
from __future__ import annotations
|
| 93 |
-
|
| 94 |
-
import glob
|
| 95 |
-
import json
|
| 96 |
-
import math
|
| 97 |
-
import os
|
| 98 |
-
import os.path as osp
|
| 99 |
-
import re
|
| 100 |
-
|
| 101 |
-
import numpy as np
|
| 102 |
-
import torch
|
| 103 |
-
|
| 104 |
-
from src.evaluate_ft2b import (_group_metrics, _overall_metrics,
|
| 105 |
-
load_ft2b_gnn, predict_graph_seconds)
|
| 106 |
-
from src.gbm_ft2b import _load_manifest_rows
|
| 107 |
-
|
| 108 |
-
DEFAULT_STD_RATIO_THRESHOLD = 0.9
|
| 109 |
-
|
| 110 |
-
# Floor applied to std_ratio before taking log(), so a fully-collapsed group
|
| 111 |
-
# (std_ratio == 0.0 exactly) yields a large-but-finite `calib` instead of
|
| 112 |
-
# `inf` (which `json.dump` would emit as the non-standard token `Infinity`).
|
| 113 |
-
_CALIB_STD_RATIO_FLOOR = 1e-6
|
| 114 |
-
|
| 115 |
-
_CKPT_EPOCH_RE = re.compile(r"ckpt_ep(\d+)\.pt$")
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def _epoch_of(ckpt_path: str) -> int:
|
| 119 |
-
m = _CKPT_EPOCH_RE.search(osp.basename(ckpt_path))
|
| 120 |
-
if not m:
|
| 121 |
-
raise ValueError(f"not a ckpt_ep###.pt path: {ckpt_path!r}")
|
| 122 |
-
return int(m.group(1))
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def _list_ckpts(ckpt_dir: str) -> list[str]:
|
| 126 |
-
paths = glob.glob(osp.join(ckpt_dir, "ckpt_ep*.pt"))
|
| 127 |
-
return sorted(paths, key=_epoch_of)
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
def _default_stems(features_dir: str) -> list[str]:
|
| 131 |
-
with open(osp.join(features_dir, "splits.json")) as f:
|
| 132 |
-
splits_d = json.load(f)
|
| 133 |
-
return list(splits_d["extrap"]["test"])
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def _gnn_only_records(model, cfg, norm, stems, manifest_rows, features_dir,
|
| 137 |
-
device) -> list[dict]:
|
| 138 |
-
"""One record per stem: `{family, bc, y_true, y_hybrid, xyz}`, shaped
|
| 139 |
-
exactly for `src.evaluate_ft2b._group_metrics`/`_overall_metrics` --
|
| 140 |
-
`y_hybrid` here holds the GNN-ONLY seconds prediction (see this module's
|
| 141 |
-
docstring for why that's metric-equivalent to the eventual hybrid)."""
|
| 142 |
-
records = []
|
| 143 |
-
for stem in stems:
|
| 144 |
-
d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
|
| 145 |
-
X, pos_np = d["X"], d["pos"]
|
| 146 |
-
y_true = d["y_raw"] if "y_raw" in d.files else d["y"]
|
| 147 |
-
mesh_edges = d["mesh_edges"] if "mesh_edges" in d.files else None
|
| 148 |
-
g_vec = d["g"] if "g" in d.files else None
|
| 149 |
-
gnn_seconds = predict_graph_seconds(model, cfg, norm, X, pos_np, mesh_edges,
|
| 150 |
-
g_vec, device=device)
|
| 151 |
-
row = manifest_rows.get(stem, {})
|
| 152 |
-
records.append({
|
| 153 |
-
"stem": stem,
|
| 154 |
-
"family": row.get("alloy_family") or "unknown",
|
| 155 |
-
"bc": row.get("bc_config") or "none",
|
| 156 |
-
"y_true": y_true, "y_hybrid": gnn_seconds, "xyz": pos_np,
|
| 157 |
-
})
|
| 158 |
-
return records
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
def _combined_groups(per_family: dict, per_bc: dict) -> dict:
|
| 162 |
-
"""Union (not cross product) of the two partitions' groups, disambiguated
|
| 163 |
-
by a `"family:"`/`"bc:"` prefix so same-named groups in each partition
|
| 164 |
-
(e.g. a `bc_config` and an `alloy_family` both happening to be `"none"`)
|
| 165 |
-
don't collide."""
|
| 166 |
-
out = {}
|
| 167 |
-
for k, v in per_family.items():
|
| 168 |
-
out[f"family:{k}"] = v
|
| 169 |
-
for k, v in per_bc.items():
|
| 170 |
-
out[f"bc:{k}"] = v
|
| 171 |
-
return out
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
def _calib_score(std_ratio: float) -> float:
|
| 175 |
-
"""Two-sided mis-calibration score for one group's `std_ratio`.
|
| 176 |
-
|
| 177 |
-
`abs(log(std_ratio))`, floored at `_CALIB_STD_RATIO_FLOOR` before the log
|
| 178 |
-
so an exact-zero (fully collapsed) ratio gives a large finite number
|
| 179 |
-
instead of `inf`. 0 at perfect calibration (`std_ratio == 1`), symmetric
|
| 180 |
-
between collapse and over-dispersion (`calib(0.5) == calib(2.0)`)."""
|
| 181 |
-
r = max(float(std_ratio), _CALIB_STD_RATIO_FLOOR)
|
| 182 |
-
return abs(math.log(r))
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
def _rank_entries(entries: list[dict], std_ratio_threshold: float) -> list[dict]:
|
| 186 |
-
"""Shared scoring + sort for both `select_ckpt` and `rerank_selection`.
|
| 187 |
-
|
| 188 |
-
`entries`: one dict per checkpoint, each with (at least)
|
| 189 |
-
`{"ckpt", "epoch", "overall", "per_alloy_family", "per_bc_config"}` --
|
| 190 |
-
exactly what both callers already have (freshly computed, or loaded back
|
| 191 |
-
out of a previously-written `selection.json`). Recomputes the derived
|
| 192 |
-
scoring fields (`n_fail_groups`/`min_group_std_ratio`/
|
| 193 |
-
`max_group_std_ratio`/`calib`/`overall_hotspot`) from the stored
|
| 194 |
-
per-group numbers -- no forward pass, no re-evaluation -- and returns a
|
| 195 |
-
NEW list of entries (original dicts are not mutated), sorted best-first
|
| 196 |
-
per this module's ranking rule (see module docstring)."""
|
| 197 |
-
scored = []
|
| 198 |
-
for e in entries:
|
| 199 |
-
combined = _combined_groups(e["per_alloy_family"], e["per_bc_config"])
|
| 200 |
-
std_ratios = [m["std_ratio"] for m in combined.values()]
|
| 201 |
-
n_fail_groups = sum(1 for r in std_ratios if r < std_ratio_threshold)
|
| 202 |
-
min_group_std_ratio = float(min(std_ratios)) if std_ratios else 0.0
|
| 203 |
-
max_group_std_ratio = float(max(std_ratios)) if std_ratios else 0.0
|
| 204 |
-
calib = max((_calib_score(r) for r in std_ratios), default=0.0)
|
| 205 |
-
overall_hotspot = float(e["overall"]["hotspot_overlap"])
|
| 206 |
-
|
| 207 |
-
new_entry = dict(e)
|
| 208 |
-
new_entry.update({
|
| 209 |
-
"n_fail_groups": int(n_fail_groups),
|
| 210 |
-
"min_group_std_ratio": min_group_std_ratio,
|
| 211 |
-
"max_group_std_ratio": max_group_std_ratio,
|
| 212 |
-
"calib": float(calib),
|
| 213 |
-
"overall_hotspot": overall_hotspot,
|
| 214 |
-
})
|
| 215 |
-
scored.append(new_entry)
|
| 216 |
-
|
| 217 |
-
scored.sort(key=lambda r: (r["n_fail_groups"], r["calib"],
|
| 218 |
-
-r["overall_hotspot"], r["epoch"]))
|
| 219 |
-
return scored
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
def rerank_selection(json_path, out_json=None,
|
| 223 |
-
std_ratio_threshold: float = DEFAULT_STD_RATIO_THRESHOLD) -> dict:
|
| 224 |
-
"""Re-rank an ALREADY-WRITTEN `select_ckpt` output (`json_path`) with the
|
| 225 |
-
current ranking rule, WITHOUT re-running any GNN forward pass -- every
|
| 226 |
-
checkpoint's per-group numbers are already stored in `json_path`'s
|
| 227 |
-
`"ranking"` entries (`per_alloy_family`/`per_bc_config`/`overall`), so
|
| 228 |
-
this only recomputes the derived scoring fields and re-sorts. Exists so a
|
| 229 |
-
ranking-rule bugfix never forces re-running a real (hours-long,
|
| 230 |
-
many-checkpoint) `select_ckpt` call.
|
| 231 |
-
|
| 232 |
-
Args:
|
| 233 |
-
json_path: path to a JSON file shaped like `select_ckpt`'s return
|
| 234 |
-
value (`{"best", "ranking": [...]}`) -- typically its own
|
| 235 |
-
previous `out_json` output.
|
| 236 |
-
out_json: path to write the re-ranked result (falsy skips writing).
|
| 237 |
-
std_ratio_threshold: same meaning as `select_ckpt`'s.
|
| 238 |
-
|
| 239 |
-
Returns: `{"best": <ckpt path>, "ranking": [...]}`, re-sorted.
|
| 240 |
-
"""
|
| 241 |
-
with open(json_path) as f:
|
| 242 |
-
data = json.load(f)
|
| 243 |
-
ranking = _rank_entries(data["ranking"], std_ratio_threshold)
|
| 244 |
-
result = {"best": ranking[0]["ckpt"], "ranking": ranking}
|
| 245 |
-
|
| 246 |
-
if out_json:
|
| 247 |
-
out_dir = osp.dirname(out_json)
|
| 248 |
-
if out_dir:
|
| 249 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 250 |
-
with open(out_json, "w") as f:
|
| 251 |
-
json.dump(result, f, indent=2)
|
| 252 |
-
|
| 253 |
-
return result
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
def select_ckpt(ckpt_dir, features_dir, manifest, stems=None, out_json=None,
|
| 257 |
-
device=None, std_ratio_threshold: float = DEFAULT_STD_RATIO_THRESHOLD) -> dict:
|
| 258 |
-
"""Rank every `<ckpt_dir>/ckpt_ep*.pt` checkpoint by the gate metrics
|
| 259 |
-
(per-group `std_ratio` calibration + `hotspot_overlap`), NOT val loss.
|
| 260 |
-
|
| 261 |
-
Args:
|
| 262 |
-
ckpt_dir: directory containing `ckpt_ep{ep:03d}.pt` files (Task 6's
|
| 263 |
-
`train(..., save_every_epoch=True)` output).
|
| 264 |
-
features_dir: v4 feature cache directory (`graphs/<stem>.npz` +
|
| 265 |
-
`splits.json`, used only when `stems` is None).
|
| 266 |
-
manifest: path to the DOE manifest CSV (`run_id`==stem; needs
|
| 267 |
-
`alloy_family`/`bc_config` columns).
|
| 268 |
-
stems: stems to evaluate on. Default (None): `splits.json`'s
|
| 269 |
-
`extrap.test` list (the OOD-generalization split -- the one the
|
| 270 |
-
gate metrics matter most on).
|
| 271 |
-
out_json: path to write the ranked result as JSON (falsy skips
|
| 272 |
-
writing).
|
| 273 |
-
device: "cuda"/"cpu"; default picks cuda if available.
|
| 274 |
-
std_ratio_threshold: a group's `std_ratio` below this counts as
|
| 275 |
-
"failing" for ranking criterion (1). Default 0.9, matching
|
| 276 |
-
`evaluate_ft2b`'s own `variance_collapse_threshold` default.
|
| 277 |
-
|
| 278 |
-
Returns: `{"best": <ckpt path>, "ranking": [<per-ckpt dict>, ...]}`,
|
| 279 |
-
`ranking` sorted best-first per this module's docstring's 4-step
|
| 280 |
-
ranking rule. Each ranking entry also carries `per_alloy_family`/
|
| 281 |
-
`per_bc_config`/`overall` (same shape as `evaluate_ft2b`'s `eval.json`
|
| 282 |
-
groups) so the numbers are directly inspectable/comparable.
|
| 283 |
-
"""
|
| 284 |
-
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 285 |
-
ckpts = _list_ckpts(ckpt_dir)
|
| 286 |
-
if not ckpts:
|
| 287 |
-
raise FileNotFoundError(f"no ckpt_ep*.pt files found in {ckpt_dir!r}")
|
| 288 |
-
|
| 289 |
-
if stems is None:
|
| 290 |
-
stems = _default_stems(features_dir)
|
| 291 |
-
manifest_rows = _load_manifest_rows(manifest)
|
| 292 |
-
|
| 293 |
-
raw_entries = []
|
| 294 |
-
n = len(ckpts)
|
| 295 |
-
for i, ckpt_path in enumerate(ckpts):
|
| 296 |
-
epoch = _epoch_of(ckpt_path)
|
| 297 |
-
print(f"[select_ckpt] ({i + 1}/{n}) evaluating {osp.basename(ckpt_path)} "
|
| 298 |
-
f"on {len(stems)} stems ...", flush=True)
|
| 299 |
-
model, cfg, norm = load_ft2b_gnn(ckpt_path, device)
|
| 300 |
-
records = _gnn_only_records(model, cfg, norm, stems, manifest_rows,
|
| 301 |
-
features_dir, device)
|
| 302 |
-
entry = {
|
| 303 |
-
"ckpt": ckpt_path,
|
| 304 |
-
"epoch": epoch,
|
| 305 |
-
"overall": _overall_metrics(records),
|
| 306 |
-
"per_alloy_family": _group_metrics(records, "family"),
|
| 307 |
-
"per_bc_config": _group_metrics(records, "bc"),
|
| 308 |
-
}
|
| 309 |
-
# Per-entry pass through the shared scorer just for the progress
|
| 310 |
-
# print -- `select_ckpt`'s own final ranking below re-derives this
|
| 311 |
-
# (identically) via `_rank_entries` over the whole batch, so the two
|
| 312 |
-
# can never disagree.
|
| 313 |
-
scored = _rank_entries([entry], std_ratio_threshold)[0]
|
| 314 |
-
print(f"[select_ckpt] epoch {epoch:3d}: n_fail_groups={scored['n_fail_groups']} "
|
| 315 |
-
f"calib={scored['calib']:.4f} "
|
| 316 |
-
f"min_group_std_ratio={scored['min_group_std_ratio']:.4f} "
|
| 317 |
-
f"max_group_std_ratio={scored['max_group_std_ratio']:.4f} "
|
| 318 |
-
f"overall_hotspot={scored['overall_hotspot']:.4f}", flush=True)
|
| 319 |
-
raw_entries.append(entry)
|
| 320 |
-
|
| 321 |
-
ranking = _rank_entries(raw_entries, std_ratio_threshold)
|
| 322 |
-
result = {"best": ranking[0]["ckpt"], "ranking": ranking}
|
| 323 |
-
|
| 324 |
-
if out_json:
|
| 325 |
-
out_dir = osp.dirname(out_json)
|
| 326 |
-
if out_dir:
|
| 327 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 328 |
-
with open(out_json, "w") as f:
|
| 329 |
-
json.dump(result, f, indent=2)
|
| 330 |
-
|
| 331 |
-
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/shrinkage.py
CHANGED
|
@@ -1,37 +1,20 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Pure numpy/scipy
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
needed when converting a gradient/rate, only the K/m -> degC/mm scale
|
| 19 |
-
(divide by 1000).
|
| 20 |
-
|
| 21 |
-
Two ways to obtain Ny, per the ADDENDUM:
|
| 22 |
-
- **Exact path** (training/validation rows with cached `gradT`/`dTdt`):
|
| 23 |
-
`niyama_exact` computes Ny directly from the solver's own gradient and
|
| 24 |
-
cooling-rate fields, and is compared against literature absolute
|
| 25 |
-
thresholds (`NY_CRIT`, `porosity_risk(mode="absolute")`).
|
| 26 |
-
- **Proxy path** (GNN-inference rows, where only the predicted
|
| 27 |
-
solidification-time field `t_s` exists): the exact identity
|
| 28 |
-
`grad(T) = -Tdot * grad(t_s)` (differentiate `T(x, t_s(x)) = T_S`) gives
|
| 29 |
-
`G = |Tdot| * |grad(t_s)|` and `Ny = G / sqrt(|Tdot|) = sqrt(|Tdot|) *
|
| 30 |
-
|grad(t_s)|`. `Tdot` itself is not available on this path and is proxied
|
| 31 |
-
by the characteristic freezing-interval rate `(TL - TS) / max(t_s, eps)`.
|
| 32 |
-
`niyama_proxy` implements this. Because it is a proxy, it is only a
|
| 33 |
-
*relative* risk ranking within one part -- use
|
| 34 |
-
`porosity_risk(mode="percentile")`, never the absolute thresholds.
|
| 35 |
"""
|
| 36 |
import logging
|
| 37 |
|
|
@@ -41,12 +24,9 @@ from scipy.sparse.csgraph import connected_components
|
|
| 41 |
|
| 42 |
logger = logging.getLogger(__name__)
|
| 43 |
|
| 44 |
-
#
|
| 45 |
-
#
|
| 46 |
-
# expansion
|
| 47 |
-
# shrinkage, hence 0.0 -- macro-shrinkage regions are still detected/reported
|
| 48 |
-
# for grey iron (the geometry-only isolation criterion doesn't depend on
|
| 49 |
-
# alloy), just with zero severity.
|
| 50 |
SHRINKAGE_PCT = {
|
| 51 |
"steel": 2.5,
|
| 52 |
"low_alloy_steel": 2.5,
|
|
@@ -54,45 +34,16 @@ SHRINKAGE_PCT = {
|
|
| 54 |
"grey_iron": 0.0,
|
| 55 |
}
|
| 56 |
|
| 57 |
-
#
|
| 58 |
-
# Below this value -> flagged as at-risk of micro-shrinkage porosity.
|
| 59 |
-
NY_CRIT = {
|
| 60 |
-
"steel": 1.0,
|
| 61 |
-
"low_alloy_steel": 1.0,
|
| 62 |
-
"grey_iron": 0.75,
|
| 63 |
-
"ductile_iron": 0.75,
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
# Small epsilon guards against division-by-zero / sqrt(0) without perturbing
|
| 67 |
-
# any physically meaningful value (cooling rates and solidification times of
|
| 68 |
-
# interest are always many orders of magnitude larger than this).
|
| 69 |
_EPS = 1e-9
|
| 70 |
|
| 71 |
|
| 72 |
def node_gradient(pos: np.ndarray, tets: np.ndarray, field: np.ndarray) -> np.ndarray:
|
| 73 |
-
"""(N,3)
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
J @ g = rhs, J[i] = x_{i+1} - x0, rhs[i] = f_{i+1} - f0 (i=0,1,2)
|
| 79 |
-
i.e. J's rows are the tet's three edge vectors from vertex 0, and rhs is
|
| 80 |
-
the corresponding field differences -- the standard closed form for a
|
| 81 |
-
linear tet element's gradient (no shape-function basis needed beyond
|
| 82 |
-
this, since a P1 gradient is uniquely determined by 3 independent edge
|
| 83 |
-
derivatives).
|
| 84 |
-
|
| 85 |
-
The constant per-tet gradient is then averaged onto each of its 4 nodes,
|
| 86 |
-
weighted by tet volume (a volume-weighted average over all tets incident
|
| 87 |
-
to a node), which is the standard nodal-recovery scheme for
|
| 88 |
-
element-constant gradients on unstructured tet meshes.
|
| 89 |
-
|
| 90 |
-
Units: `pos` in mm; the returned gradient is in [field units] / mm.
|
| 91 |
-
|
| 92 |
-
Degenerate (zero-volume) tets contribute zero weight and zero gradient
|
| 93 |
-
(guarded, never divide by zero); a node touched by no tets (or an empty
|
| 94 |
-
`tets` array) gets a zero gradient.
|
| 95 |
-
"""
|
| 96 |
pos = np.asarray(pos, dtype=np.float64)
|
| 97 |
tets = np.asarray(tets, dtype=np.int64)
|
| 98 |
field = np.asarray(field, dtype=np.float64)
|
|
@@ -135,37 +86,12 @@ def node_gradient(pos: np.ndarray, tets: np.ndarray, field: np.ndarray) -> np.nd
|
|
| 135 |
return node_grad_sum / np.maximum(node_vol_sum, _EPS)[:, None]
|
| 136 |
|
| 137 |
|
| 138 |
-
def niyama_exact(gradT_K_per_m: np.ndarray, dTdt_K_per_s: np.ndarray) -> np.ndarray:
|
| 139 |
-
"""Ny in (degC*s)^0.5/mm: G[degC/mm] / sqrt(|Tdot|[degC/s]), eps-guarded.
|
| 140 |
-
|
| 141 |
-
Exact path: uses the solver's own cached `gradT` (K/m) and `dTdt` (K/s)
|
| 142 |
-
fields directly (see module docstring). 1 K/m = 1e-3 degC/mm (a
|
| 143 |
-
difference/gradient, not an absolute temperature, so no offset).
|
| 144 |
-
"""
|
| 145 |
-
gradT_K_per_m = np.asarray(gradT_K_per_m, dtype=np.float64)
|
| 146 |
-
dTdt_K_per_s = np.asarray(dTdt_K_per_s, dtype=np.float64)
|
| 147 |
-
|
| 148 |
-
G_degC_per_mm = gradT_K_per_m / 1000.0
|
| 149 |
-
tdot_abs = np.abs(dTdt_K_per_s)
|
| 150 |
-
return G_degC_per_mm / np.sqrt(np.maximum(tdot_abs, _EPS))
|
| 151 |
-
|
| 152 |
-
|
| 153 |
def niyama_proxy(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
|
| 154 |
TL: float, TS: float) -> np.ndarray:
|
| 155 |
-
"""sqrt((TL-TS)/max(t_s,eps)) * |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
cooling rate `Tdot_proxy = (TL - TS) / max(t_s, eps)` (the characteristic
|
| 160 |
-
freezing-interval rate) stands in for the true `Tdot`, giving
|
| 161 |
-
`Ny_proxy = sqrt(Tdot_proxy) * |grad(t_s)|`. `TL`, `TS` in degC; `t_s` in
|
| 162 |
-
s; `pos` in mm (so `grad(t_s)` is in s/mm, matching the exact path's
|
| 163 |
-
(degC*s)^0.5/mm unit once multiplied by sqrt(degC/s)).
|
| 164 |
-
|
| 165 |
-
This is a *relative* ranking only, not comparable to the literature
|
| 166 |
-
`NY_CRIT` absolute thresholds -- flag risk within one part via
|
| 167 |
-
`porosity_risk(mode="percentile")`.
|
| 168 |
-
"""
|
| 169 |
t_s = np.asarray(t_s, dtype=np.float64)
|
| 170 |
grad = node_gradient(pos, tets, t_s)
|
| 171 |
grad_mag = np.linalg.norm(grad, axis=1)
|
|
@@ -175,76 +101,21 @@ def niyama_proxy(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
|
|
| 175 |
return np.sqrt(np.maximum(tdot_proxy, 0.0)) * grad_mag
|
| 176 |
|
| 177 |
|
| 178 |
-
def porosity_risk(ny: np.ndarray,
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
mode="absolute" (exact path): `ny < NY_CRIT[alloy_family]`. `alloy_family`
|
| 183 |
-
must be a key of `NY_CRIT`; an unknown or missing (`None`) family raises
|
| 184 |
-
`KeyError` -- there is no silent default, because guessing an alloy
|
| 185 |
-
family would silently mis-threshold the risk call.
|
| 186 |
-
|
| 187 |
-
mode="percentile" (proxy path): flags the within-part bottom-`pct`%
|
| 188 |
-
lowest Niyama values, i.e. `ny <= numpy.percentile(ny, pct)` (numpy's
|
| 189 |
-
default linear-interpolation percentile). Tie handling is deterministic
|
| 190 |
-
and order-independent: every value tied at (or below) the threshold is
|
| 191 |
-
flagged, so if duplicate values straddle the threshold the flagged
|
| 192 |
-
fraction can be slightly *more* than `pct`% (never less, and never
|
| 193 |
-
dependent on array order).
|
| 194 |
-
"""
|
| 195 |
ny = np.asarray(ny, dtype=np.float64)
|
|
|
|
|
|
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
return ny < crit
|
| 200 |
-
elif mode == "percentile":
|
| 201 |
-
threshold = np.percentile(ny, pct)
|
| 202 |
-
return ny <= threshold
|
| 203 |
-
else:
|
| 204 |
-
raise ValueError(f"unknown porosity_risk mode: {mode!r}")
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
# ---------------------------------------------------------------------------
|
| 208 |
-
# Macro-shrinkage regions, top-surface sinks, feeder recommendations.
|
| 209 |
-
#
|
| 210 |
-
# See docs/superpowers/specs/2026-07-24-shrinkage-porosity-screening-design.md
|
| 211 |
-
# section 2-4 and the OWNER-ADJUDICATED correction in
|
| 212 |
-
# docs/superpowers/plans/2026-08-01-sp2-shrinkage.md (2026-08-01, review round
|
| 213 |
-
# 1, finding 1): a hot spot is a local maximum of solidification time `t_s`
|
| 214 |
-
# (last metal to freeze). The ORIGINAL "monotonically non-decreasing t_s path
|
| 215 |
-
# to any surface node" feeding-path definition was proved degenerate (a
|
| 216 |
-
# region can never escape its own hot-node superlevel-set component under
|
| 217 |
-
# that rule, making it tautologically equivalent to "region touches the
|
| 218 |
-
# domain surface" -- replacing the whole search with `return False` left
|
| 219 |
-
# every test green). The adjudicated definition instead: a region is fed iff
|
| 220 |
-
# a node path over tet adjacency connects it to a caller-supplied FEED node
|
| 221 |
-
# (e.g. nodes on Adiabatic-tagged riser/sleeve faces -- NOT the generic mold
|
| 222 |
-
# surface; a bare sand casting with no risers passes an empty `feed_nodes`,
|
| 223 |
-
# so every region is conservatively unfed) with EVERY node on that path
|
| 224 |
-
# (endpoints included) at or above `corridor_frac * region["t_max"]` -- a
|
| 225 |
-
# warm corridor that stays liquid until the region itself finishes freezing,
|
| 226 |
-
# with a 5% relative tolerance. This breaks the old degeneracy because the
|
| 227 |
-
# corridor threshold is relative to the REGION's own peak, not the mesh-wide
|
| 228 |
-
# hot cut, so a feed node need not itself be part of the region's hot-node
|
| 229 |
-
# superlevel set.
|
| 230 |
-
#
|
| 231 |
-
# Severity scales with the alloy's volumetric shrinkage percentage
|
| 232 |
-
# (`SHRINKAGE_PCT`) times the region's volume. A region whose highest point
|
| 233 |
-
# against gravity is itself a free surface sinks that surface instead of
|
| 234 |
-
# voiding internally. A feeder placed above the region must have modulus
|
| 235 |
-
# (V/A, Chvorinov's rule) >= 1.2x the region's own modulus to freeze after it.
|
| 236 |
-
# ---------------------------------------------------------------------------
|
| 237 |
-
|
| 238 |
-
# Local vertex-index pairs/triples of a tetrahedron (0,1,2,3), used to walk a
|
| 239 |
-
# tet's 6 edges / 4 faces without re-deriving the combinatorics inline.
|
| 240 |
_TET_EDGE_PAIRS = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
|
| 241 |
_TET_FACES = [(0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)]
|
| 242 |
|
| 243 |
|
| 244 |
def _tet_volumes(pos: np.ndarray, tets: np.ndarray) -> np.ndarray:
|
| 245 |
-
"""(M,) unsigned tet volumes in mm^3
|
| 246 |
-
used inside `node_gradient`, kept private and separate so Task 1's
|
| 247 |
-
function is never touched)."""
|
| 248 |
if tets.shape[0] == 0:
|
| 249 |
return np.zeros(0)
|
| 250 |
p0, p1, p2, p3 = pos[tets[:, 0]], pos[tets[:, 1]], pos[tets[:, 2]], pos[tets[:, 3]]
|
|
@@ -253,14 +124,10 @@ def _tet_volumes(pos: np.ndarray, tets: np.ndarray) -> np.ndarray:
|
|
| 253 |
|
| 254 |
|
| 255 |
def _induced_components(n_nodes: int, tets: np.ndarray, mask: np.ndarray):
|
| 256 |
-
"""Connected components of the tet-adjacency graph
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
Shared by `hot_regions` (mask = the global top-`top_frac` hot cut) and
|
| 261 |
-
`is_fed` (mask = the per-region warm corridor, `t_s >= corridor_frac *
|
| 262 |
-
region_t_max`) -- same graph-labeling primitive, different masks.
|
| 263 |
-
"""
|
| 264 |
idx = np.flatnonzero(mask)
|
| 265 |
labels_full = np.full(n_nodes, -1, dtype=np.int64)
|
| 266 |
if idx.size == 0:
|
|
@@ -290,28 +157,8 @@ def _induced_components(n_nodes: int, tets: np.ndarray, mask: np.ndarray):
|
|
| 290 |
|
| 291 |
|
| 292 |
def _boundary_faces(tets: np.ndarray) -> np.ndarray:
|
| 293 |
-
"""(F, 3)
|
| 294 |
-
|
| 295 |
-
by exactly one tet, i.e. with nothing "on the other side".
|
| 296 |
-
|
| 297 |
-
Review finding I4: `_region_surface_area`'s domain-boundary term used to
|
| 298 |
-
rebuild this WHOLE-MESH structure via a pure-Python double loop over
|
| 299 |
-
every tet's 4 faces, on EVERY call -- but it depends only on `tets`, not
|
| 300 |
-
on any particular region's `nodes`/`threshold`, so `feeder_recommendations`
|
| 301 |
-
(this module's only caller of `_region_surface_area`) was redoing the
|
| 302 |
-
exact same O(n_tets) work once per region. Measured on BUKSA (32138
|
| 303 |
-
tets, 16 regions): 4.74 s total screening time, ~8x the documented
|
| 304 |
-
~0.5-0.6 s (a figure that had only ever been measured on a single-region
|
| 305 |
-
part). Hoisted here so it runs ONCE per `feeder_recommendations` call,
|
| 306 |
-
vectorized via `np.unique(..., axis=0, return_counts=True)` (both the
|
| 307 |
-
dict-free dedup AND -- inside `_region_surface_area` -- the boundary-term
|
| 308 |
-
summation itself are now array ops, not a Python loop).
|
| 309 |
-
|
| 310 |
-
Every tet contributes 4 candidate faces; a face shared by exactly 2 tets
|
| 311 |
-
(an interior face) is discarded, one shared by exactly 1 (nothing on the
|
| 312 |
-
other side) is kept -- a face can never be shared by >2 tets in a
|
| 313 |
-
conforming tet mesh, so `counts == 1` is the correct (and only
|
| 314 |
-
necessary) boundary test."""
|
| 315 |
tets = np.asarray(tets, dtype=np.int64)
|
| 316 |
if tets.shape[0] == 0:
|
| 317 |
return np.zeros((0, 3), dtype=np.int64)
|
|
@@ -327,66 +174,17 @@ def _boundary_faces(tets: np.ndarray) -> np.ndarray:
|
|
| 327 |
def _region_surface_area(pos: np.ndarray, tets: np.ndarray, nodes: np.ndarray,
|
| 328 |
t_s: np.ndarray, threshold: float,
|
| 329 |
boundary_faces: np.ndarray = None) -> float:
|
| 330 |
-
"""
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
the
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
ANISOTROPIC discrete-perimeter measure that plateaus ~24-29% high on
|
| 342 |
-
curved/diagonal boundaries (axis-aligned cuts happened to look fine only
|
| 343 |
-
because a midpoint crossing is coincidentally correct when the true
|
| 344 |
-
level surface IS flat and axis-aligned).
|
| 345 |
-
|
| 346 |
-
This version runs marching tetrahedra directly on the CONTINUOUS `t_s`
|
| 347 |
-
field, cut at `threshold` -- literally "the same cut that defines the
|
| 348 |
-
region" (the percentile cutoff `hot_regions` used, stored on each region
|
| 349 |
-
dict as `hot_threshold`), so the interpolated crossing point on each tet
|
| 350 |
-
edge sits at its true fractional position (`(threshold-f_a)/(f_b-f_a)`),
|
| 351 |
-
not an assumed 0.5. Per tet, classified by how many of its 4 vertices
|
| 352 |
-
have `t_s >= threshold` (`k`):
|
| 353 |
-
- k in {0, 4}: no crossing, no contribution.
|
| 354 |
-
- k in {1, 3}: the crossing is a single triangle through the 3 edges
|
| 355 |
-
connecting the "odd one out" vertex to the other 3 (interpolated
|
| 356 |
-
edge points) -- the standard 1-3 marching-tetrahedra case.
|
| 357 |
-
- k == 2: the crossing is a planar quadrilateral through the 4 edges
|
| 358 |
-
connecting each of the 2 "above" vertices to each of the 2 "below"
|
| 359 |
-
vertices (`t_s` is affine per tet, so ANY level set within a tet is
|
| 360 |
-
a plane intersected with the tet -- always exactly planar,
|
| 361 |
-
regardless of the specific values -- computed via the standard
|
| 362 |
-
diagonal cross-product quadrilateral-area formula).
|
| 363 |
-
A tet is attributed to THIS region iff any of its vertices is in
|
| 364 |
-
`nodes` (well-defined: two vertices with `t_s >= threshold` sharing a
|
| 365 |
-
tet are adjacent by `hot_regions`' own definition, so a mixed tet's
|
| 366 |
-
"above" vertices, if any, all belong to exactly one region already).
|
| 367 |
-
|
| 368 |
-
Domain-boundary term (KEPT, unchanged, review-verified exact for a
|
| 369 |
-
region whose boundary coincides with the mesh's own outer surface): a
|
| 370 |
-
tet face touching no other tet (nothing "on the other side" for the
|
| 371 |
-
interior marching-tets term to see) is credited separately, weighted by
|
| 372 |
-
the (binary, `nodes`-membership) hot fraction of its own 3 vertices.
|
| 373 |
-
|
| 374 |
-
See tests/test_shrinkage.py for the acceptance suite this targets:
|
| 375 |
-
sphere-field area converging toward the analytic 4*pi*R^2 with
|
| 376 |
-
refinement (not plateauing), a diagonal-plane cut and an orientation
|
| 377 |
-
sweep both landing far below the ~29-36% anisotropy the binarized
|
| 378 |
-
co-area formula showed, and the axis-aligned hand-check (now computed
|
| 379 |
-
against a genuine continuous field, not an arbitrarily-thresholded
|
| 380 |
-
binary block) landing near the Chvorinov M=side/6 identity for a cube.
|
| 381 |
-
|
| 382 |
-
`boundary_faces` (review finding I4): the mesh's own domain-boundary
|
| 383 |
-
faces (`_boundary_faces(tets)`'s output), region-INDEPENDENT -- a single
|
| 384 |
-
call's worth of work shared by every region in one
|
| 385 |
-
`feeder_recommendations` invocation instead of being rebuilt (a
|
| 386 |
-
pure-Python double loop over ALL tets) on every one of this function's
|
| 387 |
-
calls. `None` (the default) computes it internally -- exactly the old
|
| 388 |
-
behavior, for any direct caller that doesn't have one precomputed.
|
| 389 |
-
"""
|
| 390 |
n_nodes = pos.shape[0]
|
| 391 |
is_member = np.zeros(n_nodes, dtype=bool)
|
| 392 |
is_member[np.asarray(nodes, dtype=np.int64)] = True
|
|
@@ -433,10 +231,8 @@ def _region_surface_area(pos: np.ndarray, tets: np.ndarray, nodes: np.ndarray,
|
|
| 433 |
if boundary_faces is None:
|
| 434 |
boundary_faces = _boundary_faces(tets)
|
| 435 |
if boundary_faces.shape[0] > 0:
|
| 436 |
-
#
|
| 437 |
-
#
|
| 438 |
-
# only faces with a nonzero fraction contribute a (fraction-
|
| 439 |
-
# weighted) triangle area.
|
| 440 |
member_counts = is_member[boundary_faces].sum(axis=1).astype(np.float64) # (F,)
|
| 441 |
frac = member_counts / 3.0
|
| 442 |
nonzero = frac > 0
|
|
@@ -450,48 +246,15 @@ def _region_surface_area(pos: np.ndarray, tets: np.ndarray, nodes: np.ndarray,
|
|
| 450 |
|
| 451 |
def hot_regions(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
|
| 452 |
top_frac: float = 0.10) -> list:
|
| 453 |
-
"""Connected
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
Returns a list of dicts sorted by `t_max` descending, each carrying a
|
| 464 |
-
stable `region_id` = its index in THIS sorted list (0-based) -- the id
|
| 465 |
-
downstream functions (`macro_shrinkage`, `top_sinks`,
|
| 466 |
-
`feeder_recommendations`) read back off the dict rather than
|
| 467 |
-
re-deriving positionally, so it stays coherent across filtering:
|
| 468 |
-
`{"nodes": sorted int index array, "centroid": (3,) mm, "volume_mm3":
|
| 469 |
-
float, "t_max": float, "region_id": int, "hot_threshold": float,
|
| 470 |
-
"sub_resolution": bool}`. `sub_resolution` (review finding C1) is `True`
|
| 471 |
-
iff the region has fewer than 4 nodes -- below this floor
|
| 472 |
-
`_region_surface_area`'s iso-surface area and the lumped-nodal
|
| 473 |
-
`volume_mm3` computed below diverge without bound as `t_max` approaches
|
| 474 |
-
`hot_threshold`; `feeder_recommendations` reads this flag back to skip
|
| 475 |
-
such regions rather than report a physically meaningless modulus (see
|
| 476 |
-
its own docstring), while `macro_shrinkage`/`top_sinks` still list them
|
| 477 |
-
unfiltered (their volume/severity/centroid stay meaningful).
|
| 478 |
-
`hot_threshold` is the same percentile cutoff (`t_s >= hot_threshold`)
|
| 479 |
-
used to select every region in THIS call -- `feeder_recommendations`
|
| 480 |
-
(review round 3) reads it back to run marching tetrahedra on the
|
| 481 |
-
continuous `t_s` field at exactly "the cut that defines the region",
|
| 482 |
-
rather than re-deriving or guessing it downstream.
|
| 483 |
-
|
| 484 |
-
`volume_mm3` is a fraction-weighted sum over ALL tets touching the
|
| 485 |
-
region: each tet contributes `tet_volume * n_hot_vertices/4` to the
|
| 486 |
-
component of its hot vertices (well-defined -- any two hot vertices
|
| 487 |
-
sharing a tet are adjacent by this function's own definition, so a
|
| 488 |
-
tet's hot vertices, if any, all belong to the same component). This is
|
| 489 |
-
mesh-convergent (equivalent to summing each hot node's lumped nodal
|
| 490 |
-
volume, `sum(tet_volume/4)` over its incident tets) and replaces an
|
| 491 |
-
earlier all-4-vertices-hot rule that measurably undercounted volume
|
| 492 |
-
(35-65% on smooth fields, review round 1 finding 2) by discarding every
|
| 493 |
-
tet straddling the region boundary instead of weighting it.
|
| 494 |
-
"""
|
| 495 |
top_frac = float(top_frac)
|
| 496 |
if not (0.0 < top_frac <= 1.0):
|
| 497 |
raise ValueError(f"hot_regions: top_frac must be in (0, 1], got {top_frac!r}")
|
|
@@ -536,48 +299,22 @@ def hot_regions(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
|
|
| 536 |
for i, r in enumerate(regions):
|
| 537 |
r["region_id"] = i
|
| 538 |
r["hot_threshold"] = float(threshold)
|
| 539 |
-
#
|
| 540 |
-
#
|
| 541 |
-
# area and the lumped-nodal `volume_mm3` above agree even
|
| 542 |
-
# approximately -- as `t_max` approaches `hot_threshold` the true
|
| 543 |
-
# iso-surface area shrinks toward zero while the lumped volume stays
|
| 544 |
-
# O(h^3), so V/A (the feeder modulus) diverges without bound.
|
| 545 |
-
# `feeder_recommendations` reads this flag back to skip such regions
|
| 546 |
-
# rather than emit a physically-impossible modulus; `volume_mm3`/
|
| 547 |
-
# `severity` remain meaningful (only the surface-area-derived
|
| 548 |
-
# modulus is the part that diverges), so `macro_shrinkage` still
|
| 549 |
-
# lists these regions unfiltered.
|
| 550 |
r["sub_resolution"] = len(r["nodes"]) < 4
|
| 551 |
return regions
|
| 552 |
|
| 553 |
|
| 554 |
def is_fed(region: dict, t_s: np.ndarray, tets: np.ndarray,
|
| 555 |
feed_nodes: np.ndarray, corridor_frac: float = 0.95) -> bool:
|
| 556 |
-
"""
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
The corridor threshold is relative to the REGION's own peak, not the
|
| 566 |
-
mesh-wide hot cut -- a feed node several hops away through nodes just
|
| 567 |
-
below the region's own hot-selection threshold (but still within
|
| 568 |
-
`corridor_frac` of its peak) now correctly counts as fed, which the old
|
| 569 |
-
"escape the hot superlevel set" definition could never recognize.
|
| 570 |
-
|
| 571 |
-
Guard (review round 2, finding NEW-2): if `region["t_max"] <= 0`, the
|
| 572 |
-
corridor threshold `corridor_frac * t_max` would be `<= 0`, which is
|
| 573 |
-
vacuously satisfied by every non-negative `t_s` value (solidification
|
| 574 |
-
times are never negative) -- i.e. the "warm corridor" would silently
|
| 575 |
-
become the WHOLE mesh, making every region trivially "fed" through any
|
| 576 |
-
non-empty `feed_nodes` regardless of actual connectivity. This isn't a
|
| 577 |
-
physically meaningful region in the first place (its peak already
|
| 578 |
-
solidified at t=0), so it's reported unfed (`False`) rather than let the
|
| 579 |
-
threshold degenerate.
|
| 580 |
-
"""
|
| 581 |
t_s = np.asarray(t_s, dtype=np.float64)
|
| 582 |
tets = np.asarray(tets, dtype=np.int64)
|
| 583 |
feed_nodes = np.asarray(feed_nodes, dtype=np.int64)
|
|
@@ -610,16 +347,8 @@ def is_fed(region: dict, t_s: np.ndarray, tets: np.ndarray,
|
|
| 610 |
|
| 611 |
def macro_shrinkage(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
|
| 612 |
feed_nodes: np.ndarray, shrinkage_pct: float) -> list:
|
| 613 |
-
"""
|
| 614 |
-
|
| 615 |
-
faces, NOT the generic mold/cooling surface), each with `severity =
|
| 616 |
-
shrinkage_pct/100 * volume_mm3` added. `region_id` is propagated
|
| 617 |
-
unchanged from `hot_regions` (its index in the FULL sorted list, not
|
| 618 |
-
renumbered by this function's filtering) so ids stay coherent if this
|
| 619 |
-
output is chained into `top_sinks`/`feeder_recommendations`. A bare sand
|
| 620 |
-
casting with no risers passes an empty `feed_nodes` -- every hot region
|
| 621 |
-
is then unfed, the conservative screen (see `is_fed`).
|
| 622 |
-
"""
|
| 623 |
regions = hot_regions(pos, tets, t_s)
|
| 624 |
out = []
|
| 625 |
for r in regions:
|
|
@@ -632,13 +361,8 @@ def macro_shrinkage(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
|
|
| 632 |
|
| 633 |
def top_sinks(regions: list, pos: np.ndarray, surface_nodes: np.ndarray,
|
| 634 |
gravity_vec) -> list:
|
| 635 |
-
"""Regions whose highest
|
| 636 |
-
|
| 637 |
-
`{"region_id": the region dict's own "region_id" (propagated, NOT a
|
| 638 |
-
positional re-index -- see `hot_regions`/`macro_shrinkage`), "location":
|
| 639 |
-
(3,) mm of that surface node, "volume_mm3": the region's own volume}` for
|
| 640 |
-
each qualifying region.
|
| 641 |
-
"""
|
| 642 |
pos = np.asarray(pos, dtype=np.float64)
|
| 643 |
gravity_vec = np.asarray(gravity_vec, dtype=np.float64)
|
| 644 |
up = -gravity_vec
|
|
@@ -663,51 +387,13 @@ def top_sinks(regions: list, pos: np.ndarray, surface_nodes: np.ndarray,
|
|
| 663 |
|
| 664 |
def feeder_recommendations(regions: list, pos: np.ndarray, tets: np.ndarray,
|
| 665 |
surface_nodes: np.ndarray, gravity_vec, t_s: np.ndarray) -> list:
|
| 666 |
-
"""Per region: `
|
| 667 |
-
|
| 668 |
-
the
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
the marching-tetrahedra area computation needs the continuous field, not
|
| 674 |
-
just the region's own node membership).
|
| 675 |
-
|
| 676 |
-
`location` is the surface node minimizing perpendicular distance to the
|
| 677 |
-
vertical line (direction `-gravity_vec`) through the centroid, restricted
|
| 678 |
-
to surface nodes at or above the centroid along that direction (falling
|
| 679 |
-
back to all surface nodes if none qualify, e.g. a degenerate/zero-height
|
| 680 |
-
domain) -- a nearest-existing-mesh-node stand-in for "directly above".
|
| 681 |
-
|
| 682 |
-
Raises `ValueError` if `surface_nodes` is empty (no candidate feeder
|
| 683 |
-
location exists -- a mesh with no free surface is not a valid casting).
|
| 684 |
-
A region with zero volume or zero boundary area (a degenerate/thin
|
| 685 |
-
region under the fraction-weighted volume rule) gets a logged warning
|
| 686 |
-
and `min_modulus_mm = 0.0` rather than a silently meaningless value.
|
| 687 |
-
|
| 688 |
-
Review finding C1 (sub-resolution regions): a region with fewer than 4
|
| 689 |
-
nodes (`region.get("sub_resolution", False)`, set by `hot_regions`) is
|
| 690 |
-
SKIPPED entirely -- no feeder recommendation is produced for it. For such
|
| 691 |
-
a region the iso-surface area shrinks toward zero as `t_max` approaches
|
| 692 |
-
the region's own selection threshold (an unbounded-in-the-limit
|
| 693 |
-
`M = V/A`, since the lumped-nodal `volume_mm3` stays O(h^3) while the
|
| 694 |
-
true iso-surface area vanishes), so `min_modulus_mm` is not a meaningful
|
| 695 |
-
number at this resolution -- reproduced on the branch's own BUKSA smoke
|
| 696 |
-
artifact (`runs/gate_c/smoke/BUKSA_cleaned_summary.txt`): a 1-node
|
| 697 |
-
region's modulus of 185 mm printed a `min_modulus_mm=222` feeder
|
| 698 |
-
recommendation on a 382x317x254 mm part. `macro_shrinkage` still lists
|
| 699 |
-
these regions (their `volume_mm3`/`severity` are fine -- only the
|
| 700 |
-
surface-area-derived modulus is the part that diverges), and the region
|
| 701 |
-
still contributes to `top_sinks`. The number of regions skipped this way
|
| 702 |
-
is logged once (`logger.info`) rather than per-region, so a part with
|
| 703 |
-
many marginal regions doesn't flood the log.
|
| 704 |
-
|
| 705 |
-
Review finding I4 (performance): the mesh's boundary-face structure
|
| 706 |
-
(`_boundary_faces(tets)`) is region-INDEPENDENT and is now computed ONCE
|
| 707 |
-
here and passed to every `_region_surface_area` call, instead of being
|
| 708 |
-
rebuilt (a pure-Python double loop over every tet) once per region --
|
| 709 |
-
see `_region_surface_area`'s own docstring for the before/after cost.
|
| 710 |
-
"""
|
| 711 |
pos = np.asarray(pos, dtype=np.float64)
|
| 712 |
tets = np.asarray(tets, dtype=np.int64)
|
| 713 |
t_s = np.asarray(t_s, dtype=np.float64)
|
|
@@ -723,7 +409,7 @@ def feeder_recommendations(regions: list, pos: np.ndarray, tets: np.ndarray,
|
|
| 723 |
"feeder location exists (a mesh with no free surface is not a "
|
| 724 |
"valid casting)")
|
| 725 |
surface_pos = pos[surface_nodes]
|
| 726 |
-
boundary_faces = _boundary_faces(tets) #
|
| 727 |
|
| 728 |
recs = []
|
| 729 |
n_skipped_sub_resolution = 0
|
|
@@ -769,83 +455,17 @@ def feeder_recommendations(regions: list, pos: np.ndarray, tets: np.ndarray,
|
|
| 769 |
return recs
|
| 770 |
|
| 771 |
|
| 772 |
-
# ---------------------------------------------------------------------------
|
| 773 |
-
# screen_casting -- one-call orchestrator (Task 3). See
|
| 774 |
-
# docs/superpowers/specs/2026-07-24-shrinkage-porosity-screening-design.md
|
| 775 |
-
# section "Multi-orientation augmentation": the conduction thermal field (and
|
| 776 |
-
# hence Niyama, output 1, and macro-shrinkage region *detection*) does not
|
| 777 |
-
# depend on gravity -- only the gravity-conditioned outputs (top sinks and
|
| 778 |
-
# feeder placement, outputs 2-4 in the spec's numbering) do. `orientations=
|
| 779 |
-
# "axes"` sweeps those over the 6 signed axis directions at zero extra
|
| 780 |
-
# thermal-solve cost, per the spec.
|
| 781 |
-
# ---------------------------------------------------------------------------
|
| 782 |
-
|
| 783 |
-
# Signed unit axis directions for the `orientations="axes"` sweep, keyed by
|
| 784 |
-
# the label used in `per_orientation`.
|
| 785 |
-
_AXIS_SWEEP = {
|
| 786 |
-
"+x": (1.0, 0.0, 0.0), "-x": (-1.0, 0.0, 0.0),
|
| 787 |
-
"+y": (0.0, 1.0, 0.0), "-y": (0.0, -1.0, 0.0),
|
| 788 |
-
"+z": (0.0, 0.0, 1.0), "-z": (0.0, 0.0, -1.0),
|
| 789 |
-
}
|
| 790 |
-
|
| 791 |
-
|
| 792 |
def screen_casting(pos: np.ndarray, tets: np.ndarray, surface_nodes: np.ndarray,
|
| 793 |
t_s: np.ndarray, TL: float, TS: float, alloy_family: str,
|
| 794 |
-
gravity_vec=(0, 0, -1),
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
does not list this parameter, but `macro_shrinkage`/`is_fed` (Task 2,
|
| 804 |
-
review round 1) require an explicit array of ADIABATIC-tagged riser/
|
| 805 |
-
sleeve nodes to know what "feeds" a region -- this is NOT derivable from
|
| 806 |
-
`surface_nodes` (the whole free surface, used only for sink/feeder
|
| 807 |
-
placement -- a different, larger set that includes ordinary mold-cooling
|
| 808 |
-
surface). Omitting it here would either silently misuse `surface_nodes`
|
| 809 |
-
as feed nodes (every region trivially "self-fed" wherever it touches any
|
| 810 |
-
surface, defeating the whole unfed/fed distinction) or hardcode an empty
|
| 811 |
-
array with no way for a caller to ever supply real risers. `feed_nodes=
|
| 812 |
-
None` defaults to an empty array -- the same "bare sand casting, no
|
| 813 |
-
risers, everything conservatively unfed" convention `macro_shrinkage`/
|
| 814 |
-
`is_fed` already document.
|
| 815 |
-
|
| 816 |
-
Niyama: uses `niyama_exact` + `porosity_risk(mode="absolute")` whenever
|
| 817 |
-
both `gradT` and `dTdt` are given (`niyama_source="exact"`); otherwise
|
| 818 |
-
`niyama_proxy` + `porosity_risk(mode="percentile")`
|
| 819 |
-
(`niyama_source="proxy"`) -- see the module docstring for why the two
|
| 820 |
-
paths use different risk-thresholding modes (the proxy is only a
|
| 821 |
-
within-part relative ranking, never comparable to the literature
|
| 822 |
-
absolute thresholds).
|
| 823 |
-
|
| 824 |
-
Material: `alloy_family` must be a key of `SHRINKAGE_PCT`. An unknown (or
|
| 825 |
-
missing) family raises a loud `KeyError` naming the known families --
|
| 826 |
-
mirroring `porosity_risk`'s own loud-failure style for the identical
|
| 827 |
-
reason: silently defaulting would silently mis-scale every downstream
|
| 828 |
-
severity/threshold number.
|
| 829 |
-
|
| 830 |
-
Orientations: `orientations=None` (default) computes `macro_shrinkage`,
|
| 831 |
-
`top_sinks`, and `feeders` once, using `gravity_vec` as given, and
|
| 832 |
-
`per_orientation` is `None`. `orientations="axes"` additionally sweeps
|
| 833 |
-
`top_sinks`/`feeders` (against the SAME `macro_shrinkage` regions --
|
| 834 |
-
region *detection* is gravity-independent, see the module-level comment
|
| 835 |
-
above) over the 6 signed axis directions, populating `per_orientation`
|
| 836 |
-
with one `{"macro_shrinkage", "top_sinks", "feeders"}` entry per axis
|
| 837 |
-
label (`"+x"`, `"-x"`, `"+y"`, `"-y"`, `"+z"`, `"-z"`); the top-level
|
| 838 |
-
`macro_shrinkage`/`top_sinks`/`feeders` keys are still populated from the
|
| 839 |
-
single `gravity_vec` argument regardless of `orientations`, so the
|
| 840 |
-
`"-z"` entry is numerically identical to calling `screen_casting` again
|
| 841 |
-
with `orientations=None, gravity_vec=(0, 0, -1)` (the default) --
|
| 842 |
-
verified in `tests/test_shrinkage.py`.
|
| 843 |
-
|
| 844 |
-
Returns `{"niyama": (N,), "porosity_risk": (N,) bool, "niyama_source":
|
| 845 |
-
str, "macro_shrinkage": [...], "top_sinks": [...], "feeders": [...],
|
| 846 |
-
"per_orientation": {axis: {...}} | None, "material": {"alloy_family",
|
| 847 |
-
"shrinkage_pct"}}`.
|
| 848 |
-
"""
|
| 849 |
if alloy_family not in SHRINKAGE_PCT:
|
| 850 |
raise KeyError(
|
| 851 |
f"screen_casting: unknown alloy_family {alloy_family!r} -- known "
|
|
@@ -859,43 +479,20 @@ def screen_casting(pos: np.ndarray, tets: np.ndarray, surface_nodes: np.ndarray,
|
|
| 859 |
feed_nodes_arr = (np.zeros(0, dtype=np.int64) if feed_nodes is None
|
| 860 |
else np.asarray(feed_nodes, dtype=np.int64))
|
| 861 |
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
risk = porosity_risk(ny, mode="absolute", alloy_family=alloy_family)
|
| 865 |
-
niyama_source = "exact"
|
| 866 |
-
else:
|
| 867 |
-
ny = niyama_proxy(pos, tets, t_s, TL, TS)
|
| 868 |
-
risk = porosity_risk(ny, mode="percentile")
|
| 869 |
-
niyama_source = "proxy"
|
| 870 |
|
| 871 |
shrinkage_pct = SHRINKAGE_PCT[alloy_family]
|
| 872 |
macro = macro_shrinkage(pos, tets, t_s, feed_nodes_arr, shrinkage_pct)
|
| 873 |
sinks = top_sinks(macro, pos, surface_nodes, gravity_vec)
|
| 874 |
feeders = feeder_recommendations(macro, pos, tets, surface_nodes, gravity_vec, t_s)
|
| 875 |
|
| 876 |
-
per_orientation = None
|
| 877 |
-
if orientations == "axes":
|
| 878 |
-
per_orientation = {}
|
| 879 |
-
for label, axis_vec in _AXIS_SWEEP.items():
|
| 880 |
-
per_orientation[label] = {
|
| 881 |
-
"macro_shrinkage": macro,
|
| 882 |
-
"top_sinks": top_sinks(macro, pos, surface_nodes, axis_vec),
|
| 883 |
-
"feeders": feeder_recommendations(macro, pos, tets, surface_nodes,
|
| 884 |
-
axis_vec, t_s),
|
| 885 |
-
}
|
| 886 |
-
elif orientations is not None:
|
| 887 |
-
raise ValueError(
|
| 888 |
-
f"screen_casting: unknown orientations mode {orientations!r} "
|
| 889 |
-
f"(expected None or 'axes')"
|
| 890 |
-
)
|
| 891 |
-
|
| 892 |
return {
|
| 893 |
"niyama": ny,
|
| 894 |
"porosity_risk": risk,
|
| 895 |
-
"niyama_source":
|
| 896 |
"macro_shrinkage": macro,
|
| 897 |
"top_sinks": sinks,
|
| 898 |
"feeders": feeders,
|
| 899 |
-
"per_orientation": per_orientation,
|
| 900 |
"material": {"alloy_family": alloy_family, "shrinkage_pct": shrinkage_pct},
|
| 901 |
}
|
|
|
|
| 1 |
+
"""Shrinkage screening on a predicted solidification-time field.
|
| 2 |
+
|
| 3 |
+
Pure numpy/scipy, working on node positions `pos` (mm), tetrahedra `tets` and
|
| 4 |
+
the per-node solidification time `t_s` (s). Four outputs:
|
| 5 |
+
|
| 6 |
+
- A Niyama-style criterion. With only `t_s` available, the identity
|
| 7 |
+
grad(T) = -Tdot * grad(t_s) gives Ny = sqrt(|Tdot|) * |grad(t_s)|, and the
|
| 8 |
+
cooling rate is approximated by the freezing-interval rate
|
| 9 |
+
(TL - TS) / t_s. The result is a relative ranking within one part, so the
|
| 10 |
+
risk mask flags its lowest decile rather than applying absolute
|
| 11 |
+
literature thresholds.
|
| 12 |
+
- Macro-shrinkage regions: connected groups of the latest-solidifying nodes
|
| 13 |
+
that no feed source can reach through metal that is still liquid.
|
| 14 |
+
- Sinks: regions whose highest point (against gravity) is on the free
|
| 15 |
+
surface, which would draw the surface in rather than void internally.
|
| 16 |
+
- Feeder suggestions: a surface point above each region and the minimum
|
| 17 |
+
feeder modulus (1.2 x the region's V/A, Chvorinov's rule).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
import logging
|
| 20 |
|
|
|
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
| 27 |
+
# Volumetric solidification shrinkage per alloy family, in percent. Severity
|
| 28 |
+
# of a region = shrinkage_pct / 100 * region volume. Grey iron's graphite
|
| 29 |
+
# expansion offsets its shrinkage, hence 0 (regions are still reported).
|
|
|
|
|
|
|
|
|
|
| 30 |
SHRINKAGE_PCT = {
|
| 31 |
"steel": 2.5,
|
| 32 |
"low_alloy_steel": 2.5,
|
|
|
|
| 34 |
"grey_iron": 0.0,
|
| 35 |
}
|
| 36 |
|
| 37 |
+
# Guards divisions and square roots without moving any physical value.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
_EPS = 1e-9
|
| 39 |
|
| 40 |
|
| 41 |
def node_gradient(pos: np.ndarray, tets: np.ndarray, field: np.ndarray) -> np.ndarray:
|
| 42 |
+
"""(N, 3) nodal gradient of a P1 field on a tet mesh, in [field units]/mm.
|
| 43 |
+
|
| 44 |
+
The gradient is constant per tet (solve J g = df with J the tet's edge
|
| 45 |
+
vectors from vertex 0), then averaged onto the nodes weighted by tet
|
| 46 |
+
volume. Degenerate tets contribute nothing; isolated nodes get zero."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
pos = np.asarray(pos, dtype=np.float64)
|
| 48 |
tets = np.asarray(tets, dtype=np.int64)
|
| 49 |
field = np.asarray(field, dtype=np.float64)
|
|
|
|
| 86 |
return node_grad_sum / np.maximum(node_vol_sum, _EPS)[:, None]
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def niyama_proxy(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
|
| 90 |
TL: float, TS: float) -> np.ndarray:
|
| 91 |
+
"""sqrt((TL - TS) / max(t_s, eps)) * |grad(t_s)|, per node.
|
| 92 |
+
|
| 93 |
+
`TL`, `TS` in deg C, `t_s` in s, `pos` in mm. A relative ranking within
|
| 94 |
+
one part; see the module docstring."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
t_s = np.asarray(t_s, dtype=np.float64)
|
| 96 |
grad = node_gradient(pos, tets, t_s)
|
| 97 |
grad_mag = np.linalg.norm(grad, axis=1)
|
|
|
|
| 101 |
return np.sqrt(np.maximum(tdot_proxy, 0.0)) * grad_mag
|
| 102 |
|
| 103 |
|
| 104 |
+
def porosity_risk(ny: np.ndarray, pct: float = 10.0) -> np.ndarray:
|
| 105 |
+
"""Boolean mask of the within-part lowest `pct`% Niyama values
|
| 106 |
+
(`ny <= percentile(ny, pct)`; ties at the threshold are all flagged)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
ny = np.asarray(ny, dtype=np.float64)
|
| 108 |
+
threshold = np.percentile(ny, pct)
|
| 109 |
+
return ny <= threshold
|
| 110 |
|
| 111 |
+
|
| 112 |
+
# Local vertex-index pairs/triples of a tetrahedron.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
_TET_EDGE_PAIRS = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
|
| 114 |
_TET_FACES = [(0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)]
|
| 115 |
|
| 116 |
|
| 117 |
def _tet_volumes(pos: np.ndarray, tets: np.ndarray) -> np.ndarray:
|
| 118 |
+
"""(M,) unsigned tet volumes in mm^3."""
|
|
|
|
|
|
|
| 119 |
if tets.shape[0] == 0:
|
| 120 |
return np.zeros(0)
|
| 121 |
p0, p1, p2, p3 = pos[tets[:, 0]], pos[tets[:, 1]], pos[tets[:, 2]], pos[tets[:, 3]]
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
def _induced_components(n_nodes: int, tets: np.ndarray, mask: np.ndarray):
|
| 127 |
+
"""Connected components of the tet-adjacency graph restricted to `mask`.
|
| 128 |
+
|
| 129 |
+
Returns (n_comp, labels) with labels of shape (n_nodes,): the component
|
| 130 |
+
id for masked nodes, -1 elsewhere."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
idx = np.flatnonzero(mask)
|
| 132 |
labels_full = np.full(n_nodes, -1, dtype=np.int64)
|
| 133 |
if idx.size == 0:
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
def _boundary_faces(tets: np.ndarray) -> np.ndarray:
|
| 160 |
+
"""(F, 3) vertex triples of the mesh's outer faces: faces that belong to
|
| 161 |
+
exactly one tet, in their original per-tet vertex order."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
tets = np.asarray(tets, dtype=np.int64)
|
| 163 |
if tets.shape[0] == 0:
|
| 164 |
return np.zeros((0, 3), dtype=np.int64)
|
|
|
|
| 174 |
def _region_surface_area(pos: np.ndarray, tets: np.ndarray, nodes: np.ndarray,
|
| 175 |
t_s: np.ndarray, threshold: float,
|
| 176 |
boundary_faces: np.ndarray = None) -> float:
|
| 177 |
+
"""Surface area (mm^2) of a hot region, for its V/A modulus.
|
| 178 |
+
|
| 179 |
+
The interior part is the iso-surface `t_s == threshold` (the cut that
|
| 180 |
+
selected the region), built by marching tetrahedra on the continuous
|
| 181 |
+
field: a tet with 1 or 3 vertices above the threshold contributes a
|
| 182 |
+
triangle, one with 2 contributes a planar quadrilateral, and crossing
|
| 183 |
+
points sit at their interpolated position on each edge. A tet counts for
|
| 184 |
+
this region if any of its vertices is in `nodes`. Outer faces of the
|
| 185 |
+
mesh are added separately, weighted by the fraction of their 3 vertices
|
| 186 |
+
in the region. `boundary_faces` can be passed in to avoid recomputing
|
| 187 |
+
`_boundary_faces(tets)` for every region."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
n_nodes = pos.shape[0]
|
| 189 |
is_member = np.zeros(n_nodes, dtype=bool)
|
| 190 |
is_member[np.asarray(nodes, dtype=np.int64)] = True
|
|
|
|
| 231 |
if boundary_faces is None:
|
| 232 |
boundary_faces = _boundary_faces(tets)
|
| 233 |
if boundary_faces.shape[0] > 0:
|
| 234 |
+
# Each outer face counts with the fraction of its 3 vertices in
|
| 235 |
+
# the region.
|
|
|
|
|
|
|
| 236 |
member_counts = is_member[boundary_faces].sum(axis=1).astype(np.float64) # (F,)
|
| 237 |
frac = member_counts / 3.0
|
| 238 |
nonzero = frac > 0
|
|
|
|
| 246 |
|
| 247 |
def hot_regions(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
|
| 248 |
top_frac: float = 0.10) -> list:
|
| 249 |
+
"""Connected groups (via tet adjacency) of the top-`top_frac` latest-
|
| 250 |
+
solidifying nodes, sorted by `t_max` descending.
|
| 251 |
+
|
| 252 |
+
Each region is a dict: `nodes` (sorted indices), `centroid` (mm),
|
| 253 |
+
`volume_mm3`, `t_max`, `region_id` (its index in this list),
|
| 254 |
+
`hot_threshold` (the percentile cut used) and `sub_resolution` (fewer
|
| 255 |
+
than 4 nodes, too small for a meaningful V/A modulus). The volume sums,
|
| 256 |
+
over all tets touching the region, tet volume times the fraction of its
|
| 257 |
+
vertices that are hot, which converges with mesh refinement."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
top_frac = float(top_frac)
|
| 259 |
if not (0.0 < top_frac <= 1.0):
|
| 260 |
raise ValueError(f"hot_regions: top_frac must be in (0, 1], got {top_frac!r}")
|
|
|
|
| 299 |
for i, r in enumerate(regions):
|
| 300 |
r["region_id"] = i
|
| 301 |
r["hot_threshold"] = float(threshold)
|
| 302 |
+
# Below 4 nodes the iso-surface area and the lumped volume no longer
|
| 303 |
+
# agree, so the V/A modulus is not meaningful.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
r["sub_resolution"] = len(r["nodes"]) < 4
|
| 305 |
return regions
|
| 306 |
|
| 307 |
|
| 308 |
def is_fed(region: dict, t_s: np.ndarray, tets: np.ndarray,
|
| 309 |
feed_nodes: np.ndarray, corridor_frac: float = 0.95) -> bool:
|
| 310 |
+
"""Whether a feed source can still reach the region through liquid metal.
|
| 311 |
+
|
| 312 |
+
True iff some region node and some `feed_nodes` node are connected
|
| 313 |
+
through nodes with `t_s >= corridor_frac * region["t_max"]` (a corridor
|
| 314 |
+
that stays liquid until the region itself is nearly solid). Feed nodes
|
| 315 |
+
are adiabatic-tagged surface nodes, for example a riser face. With no
|
| 316 |
+
feed nodes every region is unfed, which is the conservative answer. A
|
| 317 |
+
region with t_max <= 0 is reported unfed."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
t_s = np.asarray(t_s, dtype=np.float64)
|
| 319 |
tets = np.asarray(tets, dtype=np.int64)
|
| 320 |
feed_nodes = np.asarray(feed_nodes, dtype=np.int64)
|
|
|
|
| 347 |
|
| 348 |
def macro_shrinkage(pos: np.ndarray, tets: np.ndarray, t_s: np.ndarray,
|
| 349 |
feed_nodes: np.ndarray, shrinkage_pct: float) -> list:
|
| 350 |
+
"""The hot regions that are not fed, each with `severity = shrinkage_pct /
|
| 351 |
+
100 * volume_mm3`. `region_id` keeps its value from `hot_regions`."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
regions = hot_regions(pos, tets, t_s)
|
| 353 |
out = []
|
| 354 |
for r in regions:
|
|
|
|
| 361 |
|
| 362 |
def top_sinks(regions: list, pos: np.ndarray, surface_nodes: np.ndarray,
|
| 363 |
gravity_vec) -> list:
|
| 364 |
+
"""Regions whose highest node along -gravity is a surface node. Returns
|
| 365 |
+
`{"region_id", "location" (mm), "volume_mm3"}` for each."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
pos = np.asarray(pos, dtype=np.float64)
|
| 367 |
gravity_vec = np.asarray(gravity_vec, dtype=np.float64)
|
| 368 |
up = -gravity_vec
|
|
|
|
| 387 |
|
| 388 |
def feeder_recommendations(regions: list, pos: np.ndarray, tets: np.ndarray,
|
| 389 |
surface_nodes: np.ndarray, gravity_vec, t_s: np.ndarray) -> list:
|
| 390 |
+
"""Per region: a feeder `location` and `min_modulus_mm = 1.2 * V/A`.
|
| 391 |
+
|
| 392 |
+
The location is the surface node closest to the vertical line (along
|
| 393 |
+
-gravity) through the region's centroid, among surface nodes at or above
|
| 394 |
+
the centroid (all surface nodes if none are). Sub-resolution regions are
|
| 395 |
+
skipped. A region with zero volume or area gets a warning and
|
| 396 |
+
`min_modulus_mm = 0.0`. Raises ValueError if `surface_nodes` is empty."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
pos = np.asarray(pos, dtype=np.float64)
|
| 398 |
tets = np.asarray(tets, dtype=np.int64)
|
| 399 |
t_s = np.asarray(t_s, dtype=np.float64)
|
|
|
|
| 409 |
"feeder location exists (a mesh with no free surface is not a "
|
| 410 |
"valid casting)")
|
| 411 |
surface_pos = pos[surface_nodes]
|
| 412 |
+
boundary_faces = _boundary_faces(tets) # shared by every region
|
| 413 |
|
| 414 |
recs = []
|
| 415 |
n_skipped_sub_resolution = 0
|
|
|
|
| 455 |
return recs
|
| 456 |
|
| 457 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
def screen_casting(pos: np.ndarray, tets: np.ndarray, surface_nodes: np.ndarray,
|
| 459 |
t_s: np.ndarray, TL: float, TS: float, alloy_family: str,
|
| 460 |
+
gravity_vec=(0, 0, -1), feed_nodes: np.ndarray = None) -> dict:
|
| 461 |
+
"""All four screening outputs for one part.
|
| 462 |
+
|
| 463 |
+
`alloy_family` must be a key of `SHRINKAGE_PCT`. `surface_nodes` are all
|
| 464 |
+
surface nodes (for sinks and feeder placement); `feed_nodes` are the
|
| 465 |
+
nodes that can feed a region (None means none).
|
| 466 |
+
|
| 467 |
+
Returns {"niyama", "porosity_risk", "niyama_source": "proxy",
|
| 468 |
+
"macro_shrinkage", "top_sinks", "feeders", "material"}."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
if alloy_family not in SHRINKAGE_PCT:
|
| 470 |
raise KeyError(
|
| 471 |
f"screen_casting: unknown alloy_family {alloy_family!r} -- known "
|
|
|
|
| 479 |
feed_nodes_arr = (np.zeros(0, dtype=np.int64) if feed_nodes is None
|
| 480 |
else np.asarray(feed_nodes, dtype=np.int64))
|
| 481 |
|
| 482 |
+
ny = niyama_proxy(pos, tets, t_s, TL, TS)
|
| 483 |
+
risk = porosity_risk(ny)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
|
| 485 |
shrinkage_pct = SHRINKAGE_PCT[alloy_family]
|
| 486 |
macro = macro_shrinkage(pos, tets, t_s, feed_nodes_arr, shrinkage_pct)
|
| 487 |
sinks = top_sinks(macro, pos, surface_nodes, gravity_vec)
|
| 488 |
feeders = feeder_recommendations(macro, pos, tets, surface_nodes, gravity_vec, t_s)
|
| 489 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
return {
|
| 491 |
"niyama": ny,
|
| 492 |
"porosity_risk": risk,
|
| 493 |
+
"niyama_source": "proxy",
|
| 494 |
"macro_shrinkage": macro,
|
| 495 |
"top_sinks": sinks,
|
| 496 |
"feeders": feeders,
|
|
|
|
| 497 |
"material": {"alloy_family": alloy_family, "shrinkage_pct": shrinkage_pct},
|
| 498 |
}
|
src/splits.py
DELETED
|
@@ -1,61 +0,0 @@
|
|
| 1 |
-
"""Reproducible train/test splits over the feature cache.
|
| 2 |
-
|
| 3 |
-
Three splits, each with an explicit train/test stem list:
|
| 4 |
-
interpolation — hold out (prefix,dims) groups (interpolate within families)
|
| 5 |
-
family — hold out whole geometry prefixes (generalize to new shapes)
|
| 6 |
-
fully_cooled — evaluate only on 0-adiabatic castings (real-inference regime)
|
| 7 |
-
"""
|
| 8 |
-
import json
|
| 9 |
-
import re
|
| 10 |
-
|
| 11 |
-
import numpy as np
|
| 12 |
-
|
| 13 |
-
HELDOUT_FAMILIES = ["BO", "T45", "WH", "RR"]
|
| 14 |
-
_NUM_RE = re.compile(r"^\d+$")
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def dims_of(stem: str) -> tuple:
|
| 18 |
-
return tuple(t for t in stem.split("_")[1:] if _NUM_RE.match(t))
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def build_splits(meta, seed: int = 0, test_frac: float = 0.1) -> dict:
|
| 22 |
-
stems = list(map(str, meta["stem"]))
|
| 23 |
-
prefix = {s: p for s, p in zip(stems, map(str, meta["prefix"]))}
|
| 24 |
-
fully = {s: bool(v) for s, v in zip(stems, meta["is_fully_cooled"])}
|
| 25 |
-
|
| 26 |
-
non_family = [s for s in stems if prefix[s] not in HELDOUT_FAMILIES]
|
| 27 |
-
family_test = [s for s in stems if prefix[s] in HELDOUT_FAMILIES]
|
| 28 |
-
|
| 29 |
-
# interpolation: hold out groups keyed by (prefix, dims)
|
| 30 |
-
groups = {}
|
| 31 |
-
for s in non_family:
|
| 32 |
-
groups.setdefault((prefix[s], dims_of(s)), []).append(s)
|
| 33 |
-
keys = sorted(groups.keys())
|
| 34 |
-
rng = np.random.default_rng(seed)
|
| 35 |
-
rng.shuffle(keys)
|
| 36 |
-
n_test = max(1, int(round(len(keys) * test_frac)))
|
| 37 |
-
test_keys = set(keys[:n_test])
|
| 38 |
-
|
| 39 |
-
interp_train, interp_test = [], []
|
| 40 |
-
for k, members in groups.items():
|
| 41 |
-
(interp_test if k in test_keys else interp_train).extend(members)
|
| 42 |
-
|
| 43 |
-
# fully_cooled: restrict to 0-adiabatic; test = fully-cooled in the held-out groups
|
| 44 |
-
fc_train = [s for s in interp_train if fully[s]]
|
| 45 |
-
fc_test = [s for s in interp_test if fully[s]]
|
| 46 |
-
|
| 47 |
-
return {
|
| 48 |
-
"interpolation": {"train": sorted(interp_train), "test": sorted(interp_test)},
|
| 49 |
-
"family": {"train": sorted(non_family), "test": sorted(family_test)},
|
| 50 |
-
"fully_cooled": {"train": sorted(fc_train), "test": sorted(fc_test)},
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def save_splits(splits: dict, path: str):
|
| 55 |
-
with open(path, "w") as f:
|
| 56 |
-
json.dump(splits, f, indent=2)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def load_splits(path: str) -> dict:
|
| 60 |
-
with open(path) as f:
|
| 61 |
-
return json.load(f)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/step_mesh.py
DELETED
|
@@ -1,45 +0,0 @@
|
|
| 1 |
-
"""STEP -> tetrahedral gmsh 2.2 mesh with all external surface tagged 'Cooling'.
|
| 2 |
-
|
| 3 |
-
Uses the gmsh Python API (bundled OpenCASCADE) to import a STEP solid, tag the
|
| 4 |
-
volume and its full boundary, mesh uniformly, and export in the gmsh 2.2 format
|
| 5 |
-
consumed by src/msh_parse.py. All lengths in mm."""
|
| 6 |
-
import numpy as np
|
| 7 |
-
import gmsh
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
def auto_elem_size(bbox_diag: float) -> float:
|
| 11 |
-
"""Target element size (mm): bbox diagonal / 60, the validated Phase 2A
|
| 12 |
-
density chosen to give training-comparable node density on real castings."""
|
| 13 |
-
return bbox_diag / 60.0
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def mesh_step(step_path: str, out_msh: str, elem_size: float | None = None) -> dict:
|
| 17 |
-
gmsh.initialize()
|
| 18 |
-
try:
|
| 19 |
-
gmsh.option.setNumber("General.Terminal", 0)
|
| 20 |
-
gmsh.model.add("casting")
|
| 21 |
-
gmsh.model.occ.importShapes(step_path)
|
| 22 |
-
gmsh.model.occ.synchronize()
|
| 23 |
-
|
| 24 |
-
xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
|
| 25 |
-
bbox = (xmax - xmin, ymax - ymin, zmax - zmin)
|
| 26 |
-
diag = float(np.linalg.norm(bbox))
|
| 27 |
-
size = float(elem_size) if elem_size is not None else auto_elem_size(diag)
|
| 28 |
-
gmsh.option.setNumber("Mesh.MeshSizeMin", size)
|
| 29 |
-
gmsh.option.setNumber("Mesh.MeshSizeMax", size)
|
| 30 |
-
|
| 31 |
-
vols = [t for (d, t) in gmsh.model.getEntities(3)]
|
| 32 |
-
surfs = [t for (d, t) in gmsh.model.getEntities(2)]
|
| 33 |
-
pv = gmsh.model.addPhysicalGroup(3, vols); gmsh.model.setPhysicalName(3, pv, "Volume")
|
| 34 |
-
pc = gmsh.model.addPhysicalGroup(2, surfs); gmsh.model.setPhysicalName(2, pc, "Cooling")
|
| 35 |
-
|
| 36 |
-
gmsh.model.mesh.generate(3)
|
| 37 |
-
gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
|
| 38 |
-
gmsh.write(out_msh)
|
| 39 |
-
|
| 40 |
-
node_tags, _, _ = gmsh.model.mesh.getNodes()
|
| 41 |
-
tet_tags = gmsh.model.mesh.getElementsByType(4)[0]
|
| 42 |
-
return {"n_nodes": len(node_tags), "n_tets": len(tet_tags),
|
| 43 |
-
"bbox_mm": bbox, "elem_size": size}
|
| 44 |
-
finally:
|
| 45 |
-
gmsh.finalize()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/targets.py
DELETED
|
@@ -1,39 +0,0 @@
|
|
| 1 |
-
"""Per-node solidification time from temperature snapshots.
|
| 2 |
-
|
| 3 |
-
For each node, find the first time its temperature crosses T_SOLIDUS
|
| 4 |
-
(cooling, high->low) and linearly interpolate the crossing time. Nodes that
|
| 5 |
-
never cross use the last simulation time. Ported from the verified logic in
|
| 6 |
-
the original dataset.py::_compute_y_nodal, vectorised in NumPy."""
|
| 7 |
-
import numpy as np
|
| 8 |
-
|
| 9 |
-
T_SOLIDUS = 1120.0
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def solidification_time(temps: np.ndarray, times: np.ndarray,
|
| 13 |
-
t_solidus: float = T_SOLIDUS) -> np.ndarray:
|
| 14 |
-
temps = np.asarray(temps, dtype=np.float64) # (T, N)
|
| 15 |
-
times = np.asarray(times, dtype=np.float64) # (T,)
|
| 16 |
-
T, N = temps.shape
|
| 17 |
-
|
| 18 |
-
below = temps <= t_solidus # (T, N)
|
| 19 |
-
any_below = below.any(axis=0) # (N,)
|
| 20 |
-
first_s = below.argmax(axis=0) # (N,) first True row, 0 if none
|
| 21 |
-
s = np.clip(first_s, 1, None) # avoid index -1
|
| 22 |
-
ni = np.arange(N)
|
| 23 |
-
|
| 24 |
-
t_hi = temps[s, ni] # temp at crossing snapshot
|
| 25 |
-
t_lo = temps[s - 1, ni] # temp at previous snapshot
|
| 26 |
-
dt = t_hi - t_lo
|
| 27 |
-
dt = np.where(np.abs(dt) < 1e-10, -1.0, dt)
|
| 28 |
-
alpha = (t_solidus - t_lo) / dt
|
| 29 |
-
y = times[s - 1] + alpha * (times[s] - times[s - 1])
|
| 30 |
-
|
| 31 |
-
# already below at first snapshot -> time[0]
|
| 32 |
-
y = np.where((first_s == 0) & below[0], times[0], y)
|
| 33 |
-
# never crosses -> last time
|
| 34 |
-
y = np.where(~any_below, times[-1], y)
|
| 35 |
-
return y.astype(np.float32)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def global_time(y_nodal: np.ndarray) -> float:
|
| 39 |
-
return float(np.asarray(y_nodal).max())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/train_ft2b.py
DELETED
|
@@ -1,627 +0,0 @@
|
|
| 1 |
-
"""FT2b Task 7: 3-stage staged-training driver (scale -> BC -> calibrate).
|
| 2 |
-
|
| 3 |
-
Orchestrates the already-built FT2b pieces on top of the SP1 production GNN:
|
| 4 |
-
- Task 4 (`src.warm_start`): zero-init FiLM/BC-column warm-start (+ the
|
| 5 |
-
stage-chaining `build_or_load_v4` entry point, used implicitly here since
|
| 6 |
-
`src.train_gnn.train`'s `init_ckpt` calls it).
|
| 7 |
-
- Task 5 (`src.train_gnn`): `train(...)`'s g/FiLM/freeze hooks.
|
| 8 |
-
- Task 6 (`src.gbm_ft2b`): `refit_gbm(...)`, the multi-alloy scale-head GBM.
|
| 9 |
-
|
| 10 |
-
Three stages, each warm-starting from the previous stage's own checkpoint
|
| 11 |
-
(NOT always from the original SP1 checkpoint -- see `src.warm_start.
|
| 12 |
-
build_or_load_v4` for how `train()`'s `init_ckpt` handles both a genuine SP1
|
| 13 |
-
source and an already-v4 FT2b source):
|
| 14 |
-
|
| 15 |
-
1. **scale/alloy adaptation** -- train stems = the v4 train pool restricted
|
| 16 |
-
to `dataset == "A"` (all-sand, no BC variation). FiLM-only trainable
|
| 17 |
-
(`freeze_backbone=True`), warm-started from `sp1_ckpt`, `lr=3e-4`. Also
|
| 18 |
-
refits the scale-head GBM on the same stems -> `runs/ft2b_gbm/hgb.joblib`
|
| 19 |
-
(the artifact Task 8/9 evaluate against by default).
|
| 20 |
-
2. **BC generalization** -- train stems = train pool with
|
| 21 |
-
`dataset in {A, B, C}` (chills / insulating & exothermic sleeves added).
|
| 22 |
-
Full backbone unfrozen, chained from stage 1's checkpoint, `lr=1e-4`.
|
| 23 |
-
3. **calibration** -- train stems = train pool rows that are either
|
| 24 |
-
named-grade (`alloy_family != "synthetic"`) or `dataset == "D"` (real
|
| 25 |
-
parts). Only the last 2 processor blocks + `scale_head` trainable
|
| 26 |
-
(`freeze_mode="all_but_last2"`), chained from stage 2's checkpoint,
|
| 27 |
-
`lr=1e-4`. Refits the GBM again on the calibration stems into a
|
| 28 |
-
SEPARATE artifact, `runs/ft2b_gbm/hgb_stage3.joblib` -- `hgb.joblib`
|
| 29 |
-
from stage 1 is intentionally left untouched; Task 8/9's evaluation
|
| 30 |
-
decides which of the two GBMs the final hybrid predictor should use.
|
| 31 |
-
|
| 32 |
-
Reality check this driver must survive: in the actual FT2a/FT2b DOE
|
| 33 |
-
manifest + `data/features_v4/splits.json`, named-grade and dataset-D rows
|
| 34 |
-
are ENTIRELY reserved for validation (`val_interp`/`val_extrap`) -- none are
|
| 35 |
-
in the train pool. Stage 3 can therefore legitimately have zero train stems.
|
| 36 |
-
When that happens this driver logs loudly and SKIPS stage 3 (no crash, no
|
| 37 |
-
silent no-op train() call, no stage-3 GBM refit): `paths["final_ckpt"]` falls
|
| 38 |
-
back to stage 2's checkpoint.
|
| 39 |
-
|
| 40 |
-
All early-stopping/checkpoint-selection across all 3 stages uses the same
|
| 41 |
-
`extrap.test` split (`early_stop_val_stems`), per the FINETUNING plan.
|
| 42 |
-
|
| 43 |
-
Artifact isolation: this module writes ONLY under
|
| 44 |
-
`runs/ft2b_stage{1,2,3}/` and `runs/ft2b_gbm/` (all nested under `out_root`,
|
| 45 |
-
default `"runs"`).
|
| 46 |
-
"""
|
| 47 |
-
from __future__ import annotations
|
| 48 |
-
|
| 49 |
-
import csv
|
| 50 |
-
import json
|
| 51 |
-
import logging
|
| 52 |
-
import os
|
| 53 |
-
import os.path as osp
|
| 54 |
-
|
| 55 |
-
import torch
|
| 56 |
-
|
| 57 |
-
from src.gbm_ft2b import refit_gbm
|
| 58 |
-
from src.train_gnn import train
|
| 59 |
-
|
| 60 |
-
logger = logging.getLogger(__name__)
|
| 61 |
-
|
| 62 |
-
DEFAULT_OUT_ROOT = "runs"
|
| 63 |
-
|
| 64 |
-
# Loss config the FT2b plan mandates for every stage (train()'s own defaults
|
| 65 |
-
# are graph="knn", global_weight=0.0 -- both wrong for FT2b, so pinned here
|
| 66 |
-
# rather than relying on train()'s defaults ever staying in sync).
|
| 67 |
-
_LOSS_DEFAULTS = {"graph": "union", "global_weight": 0.3, "feature_version": 4}
|
| 68 |
-
|
| 69 |
-
_STAGE1_DATASETS = {"A"}
|
| 70 |
-
_STAGE2_DATASETS = {"A", "B", "C"}
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _load_manifest_rows(manifest_path: str) -> dict:
|
| 74 |
-
with open(manifest_path, newline="") as f:
|
| 75 |
-
return {row["run_id"]: row for row in csv.DictReader(f)}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def _load_splits(features_dir: str) -> dict:
|
| 79 |
-
with open(osp.join(features_dir, "splits.json")) as f:
|
| 80 |
-
return json.load(f)
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def _stage1_stems(train_pool, manifest_rows, manifest_path: str = None) -> list:
|
| 84 |
-
"""dataset == A (all-sand, no BC/sleeve/chill variation).
|
| 85 |
-
|
| 86 |
-
Raises KeyError if any stem in train_pool is missing from manifest_rows.
|
| 87 |
-
"""
|
| 88 |
-
missing = [s for s in train_pool if s not in manifest_rows]
|
| 89 |
-
if missing:
|
| 90 |
-
msg = f"Missing from manifest"
|
| 91 |
-
if manifest_path:
|
| 92 |
-
msg += f" {manifest_path}"
|
| 93 |
-
msg += f": {missing}"
|
| 94 |
-
raise KeyError(msg)
|
| 95 |
-
return [s for s in train_pool
|
| 96 |
-
if manifest_rows[s].get("dataset") in _STAGE1_DATASETS]
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def _stage2_stems(train_pool, manifest_rows, manifest_path: str = None) -> list:
|
| 100 |
-
"""dataset in {A, B, C} (adds chills + sleeve BC variation).
|
| 101 |
-
|
| 102 |
-
Raises KeyError if any stem in train_pool is missing from manifest_rows.
|
| 103 |
-
"""
|
| 104 |
-
missing = [s for s in train_pool if s not in manifest_rows]
|
| 105 |
-
if missing:
|
| 106 |
-
msg = f"Missing from manifest"
|
| 107 |
-
if manifest_path:
|
| 108 |
-
msg += f" {manifest_path}"
|
| 109 |
-
msg += f": {missing}"
|
| 110 |
-
raise KeyError(msg)
|
| 111 |
-
return [s for s in train_pool
|
| 112 |
-
if manifest_rows[s].get("dataset") in _STAGE2_DATASETS]
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def _stage3_stems(train_pool, manifest_rows, manifest_path: str = None) -> list:
|
| 116 |
-
"""Named-grade rows (any dataset) or dataset == D (real parts).
|
| 117 |
-
|
| 118 |
-
Raises KeyError if any stem in train_pool is missing from manifest_rows.
|
| 119 |
-
"""
|
| 120 |
-
missing = [s for s in train_pool if s not in manifest_rows]
|
| 121 |
-
if missing:
|
| 122 |
-
msg = f"Missing from manifest"
|
| 123 |
-
if manifest_path:
|
| 124 |
-
msg += f" {manifest_path}"
|
| 125 |
-
msg += f": {missing}"
|
| 126 |
-
raise KeyError(msg)
|
| 127 |
-
stems = []
|
| 128 |
-
for s in train_pool:
|
| 129 |
-
row = manifest_rows[s]
|
| 130 |
-
if row.get("alloy_family", "synthetic") != "synthetic" or row.get("dataset") == "D":
|
| 131 |
-
stems.append(s)
|
| 132 |
-
return stems
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def run_ft2b(features_dir, manifest, sp1_ckpt, out_root: str = DEFAULT_OUT_ROOT,
|
| 136 |
-
epochs: int = 200, lr_stage1: float = 3e-4, lr_stage2: float = 1e-4,
|
| 137 |
-
lr_stage3: float = 1e-4, train_kwargs: dict | None = None,
|
| 138 |
-
gbm_kwargs: dict | None = None) -> dict:
|
| 139 |
-
"""Run the 3-stage FT2b staged training, returning a dict of output paths.
|
| 140 |
-
|
| 141 |
-
Args:
|
| 142 |
-
features_dir: v4 feature cache directory (must contain `splits.json`
|
| 143 |
-
with `{"interp": {"train": [...], "test": [...]},
|
| 144 |
-
"extrap": {"train": [...], "test": [...]}}`; `interp.train` ==
|
| 145 |
-
`extrap.train`, the single training pool).
|
| 146 |
-
manifest: path to the DOE manifest CSV (`run_id` == stem; needs
|
| 147 |
-
`dataset` and `alloy_family` columns to partition stems by stage).
|
| 148 |
-
sp1_ckpt: path to the production SP1 checkpoint (node_in=13, no
|
| 149 |
-
FiLM) that stage 1 warm-starts from.
|
| 150 |
-
out_root: base directory; stage dirs and the GBM dir are created
|
| 151 |
-
under it (`<out_root>/ft2b_stage{1,2,3}/`, `<out_root>/ft2b_gbm/`).
|
| 152 |
-
epochs: shared epoch budget passed to every `train()` call (early
|
| 153 |
-
stopping/patience are `train()`'s own defaults unless overridden
|
| 154 |
-
via `train_kwargs`).
|
| 155 |
-
lr_stage{1,2,3}: per-stage learning rate.
|
| 156 |
-
train_kwargs: extra kwargs forwarded to every `train()` call (e.g.
|
| 157 |
-
`n_blocks`, `hidden`, `batch_size`, `patience`, `num_workers`,
|
| 158 |
-
`device`) -- must NOT include `lr` (set via `lr_stage*` instead)
|
| 159 |
-
or the stage-specific wiring args (`init_ckpt`, `freeze_backbone`,
|
| 160 |
-
`freeze_mode`, `out_dir`, `early_stop_val_stems`, `film`), which
|
| 161 |
-
this function always sets itself. `graph`/`global_weight`/
|
| 162 |
-
`feature_version` default to the FT2b plan's values but may be
|
| 163 |
-
overridden here.
|
| 164 |
-
gbm_kwargs: extra kwargs forwarded to every `refit_gbm()` call (e.g.
|
| 165 |
-
`max_iter`, `min_samples_leaf`, `holdout_frac`).
|
| 166 |
-
|
| 167 |
-
Returns:
|
| 168 |
-
dict with `stage{1,2,3}_dir`, `stage{1,2,3}_ckpt` (stage 3's may be
|
| 169 |
-
`None` if it was skipped -- see module docstring), `final_ckpt` (the
|
| 170 |
-
last stage actually run), `gbm_stage1_path`/`gbm_stage1_metrics`,
|
| 171 |
-
and `gbm_stage3_path`/`gbm_stage3_metrics` (also `None` if stage 3
|
| 172 |
-
was skipped).
|
| 173 |
-
"""
|
| 174 |
-
train_kwargs = dict(train_kwargs or {})
|
| 175 |
-
for k, v in _LOSS_DEFAULTS.items():
|
| 176 |
-
train_kwargs.setdefault(k, v)
|
| 177 |
-
train_kwargs.setdefault("epochs", epochs)
|
| 178 |
-
gbm_kwargs = dict(gbm_kwargs or {})
|
| 179 |
-
|
| 180 |
-
manifest_rows = _load_manifest_rows(manifest)
|
| 181 |
-
splits = _load_splits(features_dir)
|
| 182 |
-
train_pool_interp = splits["interp"]["train"]
|
| 183 |
-
train_pool_extrap = splits["extrap"]["train"]
|
| 184 |
-
|
| 185 |
-
# Guard: interp and extrap train pools must be identical
|
| 186 |
-
if train_pool_interp != train_pool_extrap:
|
| 187 |
-
raise ValueError(
|
| 188 |
-
f"interp['train'] and extrap['train'] must be identical "
|
| 189 |
-
f"(single training pool assumption). Found {len(train_pool_interp)} "
|
| 190 |
-
f"interp and {len(train_pool_extrap)} extrap stems with "
|
| 191 |
-
f"{len(set(train_pool_interp) - set(train_pool_extrap))} only-in-interp "
|
| 192 |
-
f"and {len(set(train_pool_extrap) - set(train_pool_interp))} only-in-extrap.")
|
| 193 |
-
|
| 194 |
-
train_pool = train_pool_interp
|
| 195 |
-
val_stems = splits["interp"]["test"]
|
| 196 |
-
early_stop_val_stems = splits["extrap"]["test"]
|
| 197 |
-
|
| 198 |
-
gbm_dir = osp.join(out_root, "ft2b_gbm")
|
| 199 |
-
os.makedirs(gbm_dir, exist_ok=True)
|
| 200 |
-
|
| 201 |
-
paths: dict = {}
|
| 202 |
-
|
| 203 |
-
# ---- Stage 1: scale/alloy adaptation (FiLM only, warm-start from SP1) ----
|
| 204 |
-
stage1_dir = osp.join(out_root, "ft2b_stage1")
|
| 205 |
-
stage1_stems = _stage1_stems(train_pool, manifest_rows, manifest_path=manifest)
|
| 206 |
-
logger.info("FT2b stage 1 (scale/alloy, dataset=A): %d train stems", len(stage1_stems))
|
| 207 |
-
if not stage1_stems:
|
| 208 |
-
raise RuntimeError(
|
| 209 |
-
"FT2b stage 1: 0 train stems resolved for dataset=A in the train "
|
| 210 |
-
"pool -- cannot proceed (check the manifest/splits inputs)")
|
| 211 |
-
stage1_result = train(
|
| 212 |
-
features_dir, stage1_stems, val_stems, out_dir=stage1_dir, lr=lr_stage1,
|
| 213 |
-
film=True, init_ckpt=sp1_ckpt, freeze_backbone=True,
|
| 214 |
-
early_stop_val_stems=early_stop_val_stems, **train_kwargs,
|
| 215 |
-
)
|
| 216 |
-
paths["stage1_dir"] = stage1_dir
|
| 217 |
-
paths["stage1_ckpt"] = stage1_result["ckpt"]
|
| 218 |
-
paths["final_ckpt"] = paths["stage1_ckpt"]
|
| 219 |
-
|
| 220 |
-
hgb_path = osp.join(gbm_dir, "hgb.joblib")
|
| 221 |
-
paths["gbm_stage1_metrics"] = refit_gbm(
|
| 222 |
-
features_dir=features_dir, train_stems=stage1_stems,
|
| 223 |
-
manifest_path=manifest, out_path=hgb_path, **gbm_kwargs)
|
| 224 |
-
paths["gbm_stage1_path"] = hgb_path
|
| 225 |
-
|
| 226 |
-
# ---- Stage 2: BC generalization (full backbone, chained from stage 1) ----
|
| 227 |
-
stage2_dir = osp.join(out_root, "ft2b_stage2")
|
| 228 |
-
stage2_stems = _stage2_stems(train_pool, manifest_rows, manifest_path=manifest)
|
| 229 |
-
logger.info("FT2b stage 2 (BC, dataset in A/B/C): %d train stems", len(stage2_stems))
|
| 230 |
-
if not stage2_stems:
|
| 231 |
-
raise RuntimeError(
|
| 232 |
-
"FT2b stage 2: 0 train stems resolved for dataset in {A,B,C} in "
|
| 233 |
-
"the train pool -- cannot proceed (check the manifest/splits inputs)")
|
| 234 |
-
stage2_result = train(
|
| 235 |
-
features_dir, stage2_stems, val_stems, out_dir=stage2_dir, lr=lr_stage2,
|
| 236 |
-
film=True, init_ckpt=paths["stage1_ckpt"], freeze_backbone=False,
|
| 237 |
-
early_stop_val_stems=early_stop_val_stems, **train_kwargs,
|
| 238 |
-
)
|
| 239 |
-
paths["stage2_dir"] = stage2_dir
|
| 240 |
-
paths["stage2_ckpt"] = stage2_result["ckpt"]
|
| 241 |
-
paths["final_ckpt"] = paths["stage2_ckpt"]
|
| 242 |
-
|
| 243 |
-
# ---- Stage 3: calibration (named-grade + real parts) -- may be empty ----
|
| 244 |
-
stage3_stems = _stage3_stems(train_pool, manifest_rows, manifest_path=manifest)
|
| 245 |
-
logger.info("FT2b stage 3 (calibrate, named-grade + dataset=D): %d train stems",
|
| 246 |
-
len(stage3_stems))
|
| 247 |
-
if not stage3_stems:
|
| 248 |
-
logger.warning(
|
| 249 |
-
"FT2b stage 3 SKIPPED: 0 train stems resolved for named-grade/"
|
| 250 |
-
"dataset-D rows in the train pool (they may be entirely held out "
|
| 251 |
-
"for validation in this manifest/splits, as in the production "
|
| 252 |
-
"FT2a v4 cache). Final checkpoint stays stage 2's: %s",
|
| 253 |
-
paths["stage2_ckpt"])
|
| 254 |
-
paths["stage3_dir"] = None
|
| 255 |
-
paths["stage3_ckpt"] = None
|
| 256 |
-
paths["gbm_stage3_path"] = None
|
| 257 |
-
paths["gbm_stage3_metrics"] = None
|
| 258 |
-
return paths
|
| 259 |
-
|
| 260 |
-
stage3_dir = osp.join(out_root, "ft2b_stage3")
|
| 261 |
-
stage3_result = train(
|
| 262 |
-
features_dir, stage3_stems, val_stems, out_dir=stage3_dir, lr=lr_stage3,
|
| 263 |
-
film=True, init_ckpt=paths["stage2_ckpt"], freeze_mode="all_but_last2",
|
| 264 |
-
early_stop_val_stems=early_stop_val_stems, **train_kwargs,
|
| 265 |
-
)
|
| 266 |
-
paths["stage3_dir"] = stage3_dir
|
| 267 |
-
paths["stage3_ckpt"] = stage3_result["ckpt"]
|
| 268 |
-
paths["final_ckpt"] = paths["stage3_ckpt"]
|
| 269 |
-
|
| 270 |
-
hgb_stage3_path = osp.join(gbm_dir, "hgb_stage3.joblib")
|
| 271 |
-
paths["gbm_stage3_metrics"] = refit_gbm(
|
| 272 |
-
features_dir=features_dir, train_stems=stage3_stems,
|
| 273 |
-
manifest_path=manifest, out_path=hgb_stage3_path, **gbm_kwargs)
|
| 274 |
-
paths["gbm_stage3_path"] = hgb_stage3_path
|
| 275 |
-
|
| 276 |
-
return paths
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
def run_stage2b(features_dir, manifest, stage1_ckpt, out_dir: str = "runs/ft2b_stage2b",
|
| 280 |
-
oversample_exo: int = 3, lr: float = 1e-4, epochs: int = 200,
|
| 281 |
-
train_kwargs: dict | None = None) -> dict:
|
| 282 |
-
"""Exo-channel remediation: repeat FT2b stage 2 (BC generalization) on the
|
| 283 |
-
fixed feature cache, oversampling exo-sleeve stems so the (previously
|
| 284 |
-
corrupted) exothermic-sleeve BC channel gets proportionally more gradient
|
| 285 |
-
signal than its raw row count in the train pool would give it.
|
| 286 |
-
|
| 287 |
-
Same stem selection as `run_ft2b`'s stage 2 (`_stage2_stems`: dataset in
|
| 288 |
-
{A, B, C}), then the train list actually passed to `train()` is that
|
| 289 |
-
stem list PLUS `(oversample_exo - 1)` extra copies of every stem whose
|
| 290 |
-
manifest row has `bc_config == "exo_sleeve"` (duplicates appended after
|
| 291 |
-
the base list, so ordering is deterministic).
|
| 292 |
-
|
| 293 |
-
Chains from `stage1_ckpt` (a stage-1 FT2b checkpoint, NOT the original
|
| 294 |
-
SP1 checkpoint), full backbone unfrozen (`freeze_backbone=False`), same
|
| 295 |
-
as `run_ft2b`'s stage 2. Early-stop/checkpoint-selection uses
|
| 296 |
-
`extrap.test`, same as every other FT2b stage.
|
| 297 |
-
|
| 298 |
-
Args:
|
| 299 |
-
features_dir: v4 feature cache directory (must contain `splits.json`
|
| 300 |
-
with `interp.train == extrap.train`, the single training pool).
|
| 301 |
-
manifest: path to the DOE manifest CSV (`run_id` == stem; needs
|
| 302 |
-
`dataset` and `bc_config` columns).
|
| 303 |
-
stage1_ckpt: path to the checkpoint this run warm-starts/chains from.
|
| 304 |
-
out_dir: directory `train()` writes its checkpoint into.
|
| 305 |
-
oversample_exo: total multiplicity for each exo-sleeve stem in the
|
| 306 |
-
train list (1 = no oversampling; the base stem is always
|
| 307 |
-
present once, plus `oversample_exo - 1` duplicates).
|
| 308 |
-
lr: learning rate passed to `train()`.
|
| 309 |
-
epochs: epoch budget passed to `train()` (overridden by
|
| 310 |
-
`train_kwargs["epochs"]` if given there).
|
| 311 |
-
train_kwargs: extra kwargs forwarded to `train()` -- must NOT
|
| 312 |
-
include `lr` or the stage-wiring args (`init_ckpt`,
|
| 313 |
-
`freeze_backbone`, `out_dir`, `early_stop_val_stems`, `film`,
|
| 314 |
-
`val_stems`), which this function always sets itself. `epochs` IS
|
| 315 |
-
overridable via train_kwargs["epochs"] (setdefault).
|
| 316 |
-
`graph`/`global_weight`/`feature_version` default to the FT2b
|
| 317 |
-
plan's values (`_LOSS_DEFAULTS`) but may be overridden here.
|
| 318 |
-
|
| 319 |
-
Returns:
|
| 320 |
-
dict with `ckpt` (the trained checkpoint path), `best_val` (from
|
| 321 |
-
`train()`'s return), `n_train` (length of the train list, including
|
| 322 |
-
oversampled duplicates), and `n_exo` (count of distinct exo-sleeve
|
| 323 |
-
stems that were oversampled).
|
| 324 |
-
"""
|
| 325 |
-
train_kwargs = dict(train_kwargs or {})
|
| 326 |
-
for k, v in _LOSS_DEFAULTS.items():
|
| 327 |
-
train_kwargs.setdefault(k, v)
|
| 328 |
-
train_kwargs.setdefault("epochs", epochs)
|
| 329 |
-
|
| 330 |
-
manifest_rows = _load_manifest_rows(manifest)
|
| 331 |
-
splits = _load_splits(features_dir)
|
| 332 |
-
train_pool_interp = splits["interp"]["train"]
|
| 333 |
-
train_pool_extrap = splits["extrap"]["train"]
|
| 334 |
-
|
| 335 |
-
# Guard: interp and extrap train pools must be identical
|
| 336 |
-
if train_pool_interp != train_pool_extrap:
|
| 337 |
-
raise ValueError(
|
| 338 |
-
f"interp['train'] and extrap['train'] must be identical "
|
| 339 |
-
f"(single training pool assumption). Found {len(train_pool_interp)} "
|
| 340 |
-
f"interp and {len(train_pool_extrap)} extrap stems with "
|
| 341 |
-
f"{len(set(train_pool_interp) - set(train_pool_extrap))} only-in-interp "
|
| 342 |
-
f"and {len(set(train_pool_extrap) - set(train_pool_interp))} only-in-extrap.")
|
| 343 |
-
|
| 344 |
-
train_pool = train_pool_interp
|
| 345 |
-
val_stems = splits["interp"]["test"]
|
| 346 |
-
early_stop_val_stems = splits["extrap"]["test"]
|
| 347 |
-
|
| 348 |
-
stage2_stems = _stage2_stems(train_pool, manifest_rows, manifest_path=manifest)
|
| 349 |
-
exo_stems = [s for s in stage2_stems if manifest_rows[s].get("bc_config") == "exo_sleeve"]
|
| 350 |
-
train_list = list(stage2_stems) + exo_stems * (oversample_exo - 1)
|
| 351 |
-
logger.info(
|
| 352 |
-
"FT2b stage 2b (exo-oversample, dataset in A/B/C): %d train stems "
|
| 353 |
-
"(%d distinct exo-sleeve, oversampled x%d)",
|
| 354 |
-
len(train_list), len(exo_stems), oversample_exo)
|
| 355 |
-
|
| 356 |
-
result = train(
|
| 357 |
-
features_dir, train_list, val_stems=val_stems, out_dir=out_dir, lr=lr,
|
| 358 |
-
film=True, init_ckpt=stage1_ckpt, freeze_backbone=False,
|
| 359 |
-
early_stop_val_stems=early_stop_val_stems, **train_kwargs,
|
| 360 |
-
)
|
| 361 |
-
|
| 362 |
-
return {
|
| 363 |
-
"ckpt": result["ckpt"],
|
| 364 |
-
"best_val": result["best_val"],
|
| 365 |
-
"n_train": len(train_list),
|
| 366 |
-
"n_exo": len(exo_stems),
|
| 367 |
-
}
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
def run_ft3(features_dir, manifest, base_ckpt: str = "runs/ft2b_stage2c/ckpt_ep054.pt",
|
| 371 |
-
out_dir: str = "runs/ft3_stage1", oversample_e: int = 3, lr: float = 5e-5,
|
| 372 |
-
epochs: int = 200, train_kwargs: dict | None = None) -> dict:
|
| 373 |
-
"""FT3: frozen-norm fine-tune of the production v4 checkpoint onto the new
|
| 374 |
-
dataset-E (multi-body assembly) rows, mixed into the existing FT2b stage-2
|
| 375 |
-
train pool (dataset in {A, B, C}) with dataset-E stems oversampled.
|
| 376 |
-
|
| 377 |
-
Loads `base_ckpt`'s own `norm` (the production checkpoint's frozen
|
| 378 |
-
feature/target normalization -- recomputing norm_stats from the mixed
|
| 379 |
-
A+B+C+E pool would shift every standardization the checkpoint was
|
| 380 |
-
trained with) and forwards it to `train()` as `norm_override`, so every
|
| 381 |
-
SolidGraphDataset construction inside `train()` (train/val/early-stop-val)
|
| 382 |
-
standardizes against the SAME stats the checkpoint already expects.
|
| 383 |
-
|
| 384 |
-
Train stems = `_stage2_stems` (A+B+C ∩ train pool, same selection as
|
| 385 |
-
FT2b stage 2/2b) PLUS dataset-E stems in the train pool, each repeated
|
| 386 |
-
`oversample_e` times. Unlike `run_stage2b`'s exo-sleeve subset (already
|
| 387 |
-
present once inside its A/B/C stage-2 selection, so it tops up with
|
| 388 |
-
`oversample_exo - 1` extra copies), dataset "E" is NOT one of
|
| 389 |
-
`_stage2_stems`'s selected datasets ({A, B, C}) -- so E stems are
|
| 390 |
-
entirely new content here, added as a straight `x oversample_e` (not
|
| 391 |
-
`x (oversample_e - 1)`).
|
| 392 |
-
|
| 393 |
-
Chains from `base_ckpt` (`init_ckpt=base_ckpt`), full backbone unfrozen
|
| 394 |
-
(`freeze_backbone=False` -- FT3 fine-tunes the whole model, not just
|
| 395 |
-
FiLM), `film=True`, `save_every_epoch=True` (per-epoch checkpoints for
|
| 396 |
-
gate-metric checkpoint selection, mirroring the exo-fix stage-2c
|
| 397 |
-
precedent). Early-stop/checkpoint-selection uses `extrap.test`, same as
|
| 398 |
-
every other FT2b/FT3 stage; the `run_ft2b`/`run_stage2b` interp==extrap
|
| 399 |
-
train-pool guard applies here too (Task 4's `extend_splits_with_e` keeps
|
| 400 |
-
it true by extending both pools equally with E-train stems).
|
| 401 |
-
|
| 402 |
-
Args:
|
| 403 |
-
features_dir: v4 feature cache directory (`splits.json` with
|
| 404 |
-
`interp.train == extrap.train`, the single training pool,
|
| 405 |
-
extended with dataset-E train stems by Task 4's
|
| 406 |
-
`extend_splits_with_e`).
|
| 407 |
-
manifest: path to the DOE manifest CSV (`run_id` == stem; needs a
|
| 408 |
-
`dataset` column, with `"E"` rows for the new assembly castings).
|
| 409 |
-
base_ckpt: the production checkpoint this run warm-starts/chains
|
| 410 |
-
from AND whose `norm` is frozen for training (default: the FT2b
|
| 411 |
-
exo-fix production checkpoint, `runs/ft2b_stage2c/ckpt_ep054.pt`).
|
| 412 |
-
out_dir: directory `train()` writes its checkpoints into.
|
| 413 |
-
oversample_e: total multiplicity for each dataset-E train stem in the
|
| 414 |
-
train list (1 = no oversampling).
|
| 415 |
-
lr: learning rate passed to `train()`.
|
| 416 |
-
epochs: epoch budget passed to `train()` (overridden by
|
| 417 |
-
`train_kwargs["epochs"]` if given there).
|
| 418 |
-
train_kwargs: extra kwargs forwarded to `train()` -- must NOT include
|
| 419 |
-
`lr`, `norm_override`, or the stage-wiring args (`init_ckpt`,
|
| 420 |
-
`freeze_backbone`, `out_dir`, `early_stop_val_stems`, `film`,
|
| 421 |
-
`val_stems`, `save_every_epoch`), which this function always sets
|
| 422 |
-
itself. `epochs` IS overridable via `train_kwargs["epochs"]`
|
| 423 |
-
(setdefault); same for `batch_size` (default 2 to prevent OOM: train()'s
|
| 424 |
-
own default batch_size=8 OOMs the RTX 3060 12GB, incident 2026-07-31 FT3
|
| 425 |
-
execution) and `num_workers` (default 4). Explicit caller values in
|
| 426 |
-
`train_kwargs` override all defaults. `graph`/`global_weight`/
|
| 427 |
-
`feature_version` default to the FT2b plan's values (`_LOSS_DEFAULTS`)
|
| 428 |
-
but may be overridden here.
|
| 429 |
-
|
| 430 |
-
Returns:
|
| 431 |
-
dict with `ckpt` (the trained checkpoint path), `best_val` (from
|
| 432 |
-
`train()`'s return), `n_train` (length of the train list, including
|
| 433 |
-
oversampled E duplicates), and `n_e` (count of distinct dataset-E
|
| 434 |
-
stems that were oversampled).
|
| 435 |
-
"""
|
| 436 |
-
train_kwargs = dict(train_kwargs or {})
|
| 437 |
-
train_kwargs.setdefault("batch_size", 2)
|
| 438 |
-
train_kwargs.setdefault("num_workers", 4)
|
| 439 |
-
for k, v in _LOSS_DEFAULTS.items():
|
| 440 |
-
train_kwargs.setdefault(k, v)
|
| 441 |
-
train_kwargs.setdefault("epochs", epochs)
|
| 442 |
-
|
| 443 |
-
base_norm = torch.load(base_ckpt, map_location="cpu")["norm"]
|
| 444 |
-
|
| 445 |
-
manifest_rows = _load_manifest_rows(manifest)
|
| 446 |
-
splits = _load_splits(features_dir)
|
| 447 |
-
train_pool_interp = splits["interp"]["train"]
|
| 448 |
-
train_pool_extrap = splits["extrap"]["train"]
|
| 449 |
-
|
| 450 |
-
# Guard: interp and extrap train pools must be identical
|
| 451 |
-
if train_pool_interp != train_pool_extrap:
|
| 452 |
-
raise ValueError(
|
| 453 |
-
f"interp['train'] and extrap['train'] must be identical "
|
| 454 |
-
f"(single training pool assumption). Found {len(train_pool_interp)} "
|
| 455 |
-
f"interp and {len(train_pool_extrap)} extrap stems with "
|
| 456 |
-
f"{len(set(train_pool_interp) - set(train_pool_extrap))} only-in-interp "
|
| 457 |
-
f"and {len(set(train_pool_extrap) - set(train_pool_interp))} only-in-extrap.")
|
| 458 |
-
|
| 459 |
-
train_pool = train_pool_interp
|
| 460 |
-
val_stems = splits["interp"]["test"]
|
| 461 |
-
early_stop_val_stems = splits["extrap"]["test"]
|
| 462 |
-
|
| 463 |
-
stage2_stems = _stage2_stems(train_pool, manifest_rows, manifest_path=manifest)
|
| 464 |
-
e_train_stems = [s for s in train_pool if manifest_rows[s].get("dataset") == "E"]
|
| 465 |
-
train_list = list(stage2_stems) + e_train_stems * oversample_e
|
| 466 |
-
logger.info(
|
| 467 |
-
"FT3 (assembly fine-tune, dataset A/B/C + E oversampled x%d): %d train "
|
| 468 |
-
"stems (%d distinct dataset-E)", oversample_e, len(train_list), len(e_train_stems))
|
| 469 |
-
|
| 470 |
-
result = train(
|
| 471 |
-
features_dir, train_list, val_stems=val_stems, out_dir=out_dir, lr=lr,
|
| 472 |
-
film=True, init_ckpt=base_ckpt, freeze_backbone=False,
|
| 473 |
-
early_stop_val_stems=early_stop_val_stems, save_every_epoch=True,
|
| 474 |
-
norm_override=base_norm, **train_kwargs,
|
| 475 |
-
)
|
| 476 |
-
|
| 477 |
-
return {
|
| 478 |
-
"ckpt": result["ckpt"],
|
| 479 |
-
"best_val": result["best_val"],
|
| 480 |
-
"n_train": len(train_list),
|
| 481 |
-
"n_e": len(e_train_stems),
|
| 482 |
-
}
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
# Loss config for bc-reach (Task 3): same graph/global_weight convention as
|
| 486 |
-
# FT2b/FT3, but `feature_version=5` (the 20-column BC-reach cache, Task 1/2)
|
| 487 |
-
# instead of FT2b/FT3's 4.
|
| 488 |
-
_BCR_LOSS_DEFAULTS = {"graph": "union", "global_weight": 0.3, "feature_version": 5}
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
def run_bcr(features_dir, manifest, base_ckpt: str = "runs/ft3_stage1/ckpt_ep021.pt",
|
| 492 |
-
out_dir: str = "runs/bcr_stage1", oversample_e: int = 3, lr: float = 5e-5,
|
| 493 |
-
epochs: int = 200, train_kwargs: dict | None = None) -> dict:
|
| 494 |
-
"""bc-reach: warm-started fine-tune of the production v4 checkpoint onto
|
| 495 |
-
the v5 (20-column, BC-reach) feature cache -- node_in 15 -> 20.
|
| 496 |
-
|
| 497 |
-
Same stem assembly as `run_ft3` (train stems = `_stage2_stems`, A+B+C ∩
|
| 498 |
-
train pool, PLUS dataset-E train-pool stems each repeated `oversample_e`
|
| 499 |
-
times), the same `film=True`/`freeze_backbone=False`/`save_every_epoch=True`
|
| 500 |
-
wiring, and the same `extrap.test` early-stop/checkpoint-selection
|
| 501 |
-
convention as every other FT2b/FT3/bc-reach stage.
|
| 502 |
-
|
| 503 |
-
UNLIKE `run_ft3`, this does NOT pass `norm_override`: `run_ft3` freezes
|
| 504 |
-
the fine-tune onto its `base_ckpt`'s own (15-wide) norm because it keeps
|
| 505 |
-
`node_in` unchanged (15 -> 15). Here `node_in` genuinely changes (15 ->
|
| 506 |
-
20, Task 1/2's 5 new BC-reach columns), so `base_ckpt`'s 15-wide norm
|
| 507 |
-
stats cannot standardize the v5 cache's 20-wide `X` at all -- `train()`
|
| 508 |
-
is left to load `features_dir`'s OWN fresh `norm_stats.npz` (Task 2's
|
| 509 |
-
`build_v5` output), the default `norm_override=None` behavior.
|
| 510 |
-
|
| 511 |
-
`train()`'s own `init_ckpt` dispatch (see its docstring) routes this
|
| 512 |
-
warm-start through `src.warm_start.build_or_load_v5` instead of
|
| 513 |
-
`build_or_load_v4`, because the actual v5 cache data has `node_in=20`:
|
| 514 |
-
`base_ckpt` (`node_in=15`, film=True -- e.g. the production
|
| 515 |
-
`ckpt_ep021.pt`) has its node encoder's first Linear rebuilt for
|
| 516 |
-
`in_features=20` (existing 15 columns' weights copied, the 5 new
|
| 517 |
-
trailing columns zero-init, bias copied verbatim); every other weight
|
| 518 |
-
(edge_encoder, processor blocks, FiLM, decoder, scale_head) is
|
| 519 |
-
strict-copied. The resulting checkpoint's `cfg` therefore carries
|
| 520 |
-
`node_in=20` (computed by `train()` from the actual data, same as every
|
| 521 |
-
other stage) and `feature_version=5` (via `_BCR_LOSS_DEFAULTS` below).
|
| 522 |
-
|
| 523 |
-
Args:
|
| 524 |
-
features_dir: v5 feature cache directory (`splits.json` with
|
| 525 |
-
`interp.train == extrap.train`, the single training pool --
|
| 526 |
-
Task 2's `build_v5` copies this verbatim from the v4 cache, so
|
| 527 |
-
it already carries the same E-train stems FT3's
|
| 528 |
-
`extend_splits_with_e` added).
|
| 529 |
-
manifest: path to the DOE manifest CSV (`run_id` == stem; needs a
|
| 530 |
-
`dataset` column, with `"E"` rows for the assembly castings).
|
| 531 |
-
base_ckpt: the v4 (node_in=15, film=True) checkpoint this run warm-
|
| 532 |
-
starts from (default: the production FT3 checkpoint,
|
| 533 |
-
`runs/ft3_stage1/ckpt_ep021.pt`).
|
| 534 |
-
out_dir: directory `train()` writes its checkpoints into.
|
| 535 |
-
oversample_e: total multiplicity for each dataset-E train stem in the
|
| 536 |
-
train list (1 = no oversampling), same convention as `run_ft3`.
|
| 537 |
-
lr: learning rate passed to `train()` (default `5e-5`, per the
|
| 538 |
-
bc-reach plan).
|
| 539 |
-
epochs: epoch budget passed to `train()` (overridden by
|
| 540 |
-
`train_kwargs["epochs"]` if given there); `train()`'s own
|
| 541 |
-
`patience=25` default (checked against `extrap.test`, via
|
| 542 |
-
`early_stop_val_stems`) is left untouched, per the bc-reach plan
|
| 543 |
-
("epochs<=200 patience 25 on extrap.test").
|
| 544 |
-
train_kwargs: extra kwargs forwarded to `train()` -- must NOT
|
| 545 |
-
include `lr`, `norm_override`, or the stage-wiring args
|
| 546 |
-
(`init_ckpt`, `freeze_backbone`, `out_dir`,
|
| 547 |
-
`early_stop_val_stems`, `film`, `val_stems`, `save_every_epoch`),
|
| 548 |
-
which this function always sets itself. `epochs` IS overridable
|
| 549 |
-
via `train_kwargs["epochs"]` (setdefault); same for `batch_size`
|
| 550 |
-
(default 2, mirroring FT3's OOM-safe pin) and `num_workers`
|
| 551 |
-
(default 4). Explicit caller values in `train_kwargs` override
|
| 552 |
-
all defaults. `graph`/`global_weight`/`feature_version` default
|
| 553 |
-
to `_BCR_LOSS_DEFAULTS` but may be overridden here. Every other
|
| 554 |
-
disallowed key above fails loudly on its own (a plain `TypeError`
|
| 555 |
-
from `train()`'s call below, "got multiple values for keyword
|
| 556 |
-
argument") since this function also passes it explicitly;
|
| 557 |
-
`norm_override` has no such corresponding explicit call kwarg to
|
| 558 |
-
collide with (this function never passes it at all), so it is
|
| 559 |
-
guarded by an explicit `ValueError` instead (review round 1, F1).
|
| 560 |
-
|
| 561 |
-
Returns:
|
| 562 |
-
dict with `ckpt` (the trained checkpoint path), `best_val` (from
|
| 563 |
-
`train()`'s return), `n_train` (length of the train list, including
|
| 564 |
-
oversampled E duplicates), and `n_e` (count of distinct dataset-E
|
| 565 |
-
stems that were oversampled).
|
| 566 |
-
"""
|
| 567 |
-
train_kwargs = dict(train_kwargs or {})
|
| 568 |
-
train_kwargs.setdefault("batch_size", 2)
|
| 569 |
-
train_kwargs.setdefault("num_workers", 4)
|
| 570 |
-
for k, v in _BCR_LOSS_DEFAULTS.items():
|
| 571 |
-
train_kwargs.setdefault(k, v)
|
| 572 |
-
train_kwargs.setdefault("epochs", epochs)
|
| 573 |
-
# F1 (review round 1): every OTHER pinned kwarg (init_ckpt, freeze_backbone,
|
| 574 |
-
# out_dir, early_stop_val_stems, film, val_stems, save_every_epoch, lr) already
|
| 575 |
-
# fails loudly if train_kwargs duplicates it -- train()'s call below raises a
|
| 576 |
-
# plain TypeError ("got multiple values for keyword argument"). norm_override
|
| 577 |
-
# is the ONE pinned divergence from run_ft3 (this driver deliberately never
|
| 578 |
-
# passes it -- v5's fresh norm_stats.npz, not the 15-wide base_ckpt's, must be
|
| 579 |
-
# what train() standardizes against) that has NO corresponding call kwarg to
|
| 580 |
-
# collide with, so it would otherwise slip through train_kwargs silently and
|
| 581 |
-
# freeze the wrong (15-wide, base_ckpt) normalization onto the v5 run. Guard
|
| 582 |
-
# it explicitly, matching the loud-fail convention every other pinned kwarg
|
| 583 |
-
# already gets for free.
|
| 584 |
-
if "norm_override" in train_kwargs:
|
| 585 |
-
raise ValueError(
|
| 586 |
-
"run_bcr: train_kwargs must not include 'norm_override' -- v5 uses "
|
| 587 |
-
"its own fresh norm_stats.npz (Task 2's build_v5), never a frozen "
|
| 588 |
-
"norm from base_ckpt (unlike run_ft3's frozen-norm fine-tune).")
|
| 589 |
-
|
| 590 |
-
manifest_rows = _load_manifest_rows(manifest)
|
| 591 |
-
splits = _load_splits(features_dir)
|
| 592 |
-
train_pool_interp = splits["interp"]["train"]
|
| 593 |
-
train_pool_extrap = splits["extrap"]["train"]
|
| 594 |
-
|
| 595 |
-
# Guard: interp and extrap train pools must be identical
|
| 596 |
-
if train_pool_interp != train_pool_extrap:
|
| 597 |
-
raise ValueError(
|
| 598 |
-
f"interp['train'] and extrap['train'] must be identical "
|
| 599 |
-
f"(single training pool assumption). Found {len(train_pool_interp)} "
|
| 600 |
-
f"interp and {len(train_pool_extrap)} extrap stems with "
|
| 601 |
-
f"{len(set(train_pool_interp) - set(train_pool_extrap))} only-in-interp "
|
| 602 |
-
f"and {len(set(train_pool_extrap) - set(train_pool_interp))} only-in-extrap.")
|
| 603 |
-
|
| 604 |
-
train_pool = train_pool_interp
|
| 605 |
-
val_stems = splits["interp"]["test"]
|
| 606 |
-
early_stop_val_stems = splits["extrap"]["test"]
|
| 607 |
-
|
| 608 |
-
stage2_stems = _stage2_stems(train_pool, manifest_rows, manifest_path=manifest)
|
| 609 |
-
e_train_stems = [s for s in train_pool if manifest_rows[s].get("dataset") == "E"]
|
| 610 |
-
train_list = list(stage2_stems) + e_train_stems * oversample_e
|
| 611 |
-
logger.info(
|
| 612 |
-
"bc-reach (node_in 15->20, dataset A/B/C + E oversampled x%d): %d train "
|
| 613 |
-
"stems (%d distinct dataset-E)", oversample_e, len(train_list), len(e_train_stems))
|
| 614 |
-
|
| 615 |
-
result = train(
|
| 616 |
-
features_dir, train_list, val_stems=val_stems, out_dir=out_dir, lr=lr,
|
| 617 |
-
film=True, init_ckpt=base_ckpt, freeze_backbone=False,
|
| 618 |
-
early_stop_val_stems=early_stop_val_stems, save_every_epoch=True,
|
| 619 |
-
**train_kwargs,
|
| 620 |
-
)
|
| 621 |
-
|
| 622 |
-
return {
|
| 623 |
-
"ckpt": result["ckpt"],
|
| 624 |
-
"best_val": result["best_val"],
|
| 625 |
-
"n_train": len(train_list),
|
| 626 |
-
"n_e": len(e_train_stems),
|
| 627 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/train_gnn.py
DELETED
|
@@ -1,300 +0,0 @@
|
|
| 1 |
-
"""GNN training: loss, training loop, checkpointing. Trains on standardized
|
| 2 |
-
log1p targets with rotation augmentation; validates on a held-out fraction."""
|
| 3 |
-
import torch
|
| 4 |
-
import torch.nn.functional as F
|
| 5 |
-
from torch_geometric.utils import scatter
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def gnn_loss(pred, target, batch, spatial_weight: float = 0.3, global_weight: float = 0.0):
|
| 9 |
-
base = F.smooth_l1_loss(pred, target)
|
| 10 |
-
pm = scatter(pred, batch, dim=0, reduce="mean")
|
| 11 |
-
tm = scatter(target, batch, dim=0, reduce="mean")
|
| 12 |
-
spatial = F.mse_loss(pred - pm[batch], target - tm[batch])
|
| 13 |
-
glob = F.smooth_l1_loss(pm, tm)
|
| 14 |
-
return base + spatial_weight * spatial + global_weight * glob
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
import os
|
| 18 |
-
import os.path as osp
|
| 19 |
-
from torch.optim import AdamW
|
| 20 |
-
from torch.optim.lr_scheduler import ReduceLROnPlateau
|
| 21 |
-
from torch_geometric.loader import DataLoader
|
| 22 |
-
|
| 23 |
-
from src.gnn_dataset import SolidGraphDataset
|
| 24 |
-
from src.model_gnn import SolidGNN
|
| 25 |
-
from src.warm_start import build_or_load_v4, build_or_load_v5
|
| 26 |
-
|
| 27 |
-
# bc-reach (Task 1/2): the v5 BC-reach feature cache's per-node width --
|
| 28 |
-
# FEATURE_NAMES_V4 (15) + the 5 new multi-scale BC-reach channels
|
| 29 |
-
# (exo_mid/exo_long/chill_short/chill_mid/chill_long, `src.features.
|
| 30 |
-
# FEATURE_NAMES_V5`). `train()`'s `init_ckpt` dispatch below compares the
|
| 31 |
-
# ACTUAL training data's own feature width (`node_in`, computed from the
|
| 32 |
-
# built `tr` dataset -- never the checkpoint's) against this constant to
|
| 33 |
-
# decide whether to warm-start through `build_or_load_v5` (rebuild the node
|
| 34 |
-
# encoder for 20 columns) instead of `build_or_load_v4` (the existing
|
| 35 |
-
# 15-column FT2b/FT3 path, unaffected). Named here (review round 1, F3)
|
| 36 |
-
# instead of an inline literal `20`, so the pin's provenance is legible at
|
| 37 |
-
# the call site.
|
| 38 |
-
_V5_NODE_IN = 20
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def _freeze_backbone_params(model):
|
| 42 |
-
"""Freeze everything except FiLM (used for FT2b stage-1 training: only the
|
| 43 |
-
per-block gamma/beta conditioning is trainable, so warm-started SP1 shape
|
| 44 |
-
knowledge is preserved while the model learns to respond to the material
|
| 45 |
-
g-vector). The new BC input columns live inside node_encoder's first
|
| 46 |
-
Linear, so they are frozen too in stage 1 -- they get unfrozen (along with
|
| 47 |
-
the rest of the backbone) in stage 2 per the FINETUNING plan's stage table.
|
| 48 |
-
"""
|
| 49 |
-
for name, p in model.named_parameters():
|
| 50 |
-
p.requires_grad = name.startswith("films.")
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
# Public name for tests/other callers. `train()` below takes a same-named
|
| 54 |
-
# `freeze_backbone` bool parameter, which would shadow this at module scope
|
| 55 |
-
# inside that function's body -- so `train()` calls `_freeze_backbone_params`
|
| 56 |
-
# directly instead of this alias.
|
| 57 |
-
freeze_backbone = _freeze_backbone_params
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def _freeze_all_but_last2_and_scale_head(model):
|
| 61 |
-
"""FT2b stage-3 (calibrate) freeze scope: everything is frozen except the
|
| 62 |
-
LAST 2 processor blocks and the scale head -- the small, late-stage
|
| 63 |
-
capacity that plausibly still needs to adapt to a handful of named-grade
|
| 64 |
-
(steel/ductile-iron/grey-iron) + real-part calibration rows, without
|
| 65 |
-
letting so few rows overwrite the shape knowledge learned in stages 1-2.
|
| 66 |
-
FiLM is deliberately NOT included here (unlike stage 1's `freeze_backbone`,
|
| 67 |
-
which trains FiLM only) -- stage 3 is calibrating the backbone's late
|
| 68 |
-
representation + scale, not re-learning material conditioning.
|
| 69 |
-
"""
|
| 70 |
-
n_blocks = len(model.blocks)
|
| 71 |
-
keep_prefixes = tuple(f"blocks.{i}." for i in range(max(n_blocks - 2, 0), n_blocks))
|
| 72 |
-
keep_prefixes = keep_prefixes + ("scale_head.",)
|
| 73 |
-
for name, p in model.named_parameters():
|
| 74 |
-
p.requires_grad = name.startswith(keep_prefixes)
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
# Public name, mirroring `freeze_backbone` above.
|
| 78 |
-
freeze_all_but_last2_and_scale_head = _freeze_all_but_last2_and_scale_head
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def _run_epoch(model, loader, device, opt=None, scaler=None, spatial_weight=0.3, global_weight=0.0):
|
| 82 |
-
train_mode = opt is not None
|
| 83 |
-
model.train(train_mode)
|
| 84 |
-
total, n = 0.0, 0
|
| 85 |
-
for batch in loader:
|
| 86 |
-
batch = batch.to(device)
|
| 87 |
-
g = getattr(batch, "g", None)
|
| 88 |
-
if train_mode:
|
| 89 |
-
opt.zero_grad()
|
| 90 |
-
with torch.autocast(device_type=device.split(":")[0], enabled=scaler is not None):
|
| 91 |
-
yn, _ = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch, g=g)
|
| 92 |
-
loss = gnn_loss(yn, batch.y, batch.batch, spatial_weight, global_weight)
|
| 93 |
-
if scaler is not None:
|
| 94 |
-
scaler.scale(loss).backward(); scaler.step(opt); scaler.update()
|
| 95 |
-
else:
|
| 96 |
-
loss.backward(); opt.step()
|
| 97 |
-
else:
|
| 98 |
-
with torch.no_grad():
|
| 99 |
-
yn, _ = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch, g=g)
|
| 100 |
-
loss = gnn_loss(yn, batch.y, batch.batch, spatial_weight, global_weight)
|
| 101 |
-
total += float(loss) * batch.num_graphs; n += batch.num_graphs
|
| 102 |
-
return total / max(n, 1)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def _build_checkpoint_dict(model, norm, hidden, n_blocks, k, node_in, edge_in,
|
| 106 |
-
spatial_weight, global_weight, graph, feature_version,
|
| 107 |
-
two_head, film, g_dim):
|
| 108 |
-
"""Build the checkpoint dict payload (same format as best_gnn.pt).
|
| 109 |
-
|
| 110 |
-
Args:
|
| 111 |
-
model: the SolidGNN model.
|
| 112 |
-
norm: normalization stats (SolidGraphDataset.norm).
|
| 113 |
-
All other args: hyperparameters/config to store.
|
| 114 |
-
|
| 115 |
-
Returns:
|
| 116 |
-
dict with keys {model, norm, cfg}.
|
| 117 |
-
"""
|
| 118 |
-
return {
|
| 119 |
-
"model": model.state_dict(),
|
| 120 |
-
"norm": norm,
|
| 121 |
-
"cfg": {
|
| 122 |
-
"hidden": hidden,
|
| 123 |
-
"n_blocks": n_blocks,
|
| 124 |
-
"k": k,
|
| 125 |
-
"node_in": node_in,
|
| 126 |
-
"edge_in": edge_in,
|
| 127 |
-
"spatial_weight": spatial_weight,
|
| 128 |
-
"global_weight": global_weight,
|
| 129 |
-
"graph": graph,
|
| 130 |
-
"feature_version": feature_version,
|
| 131 |
-
"two_head": two_head,
|
| 132 |
-
"film": film,
|
| 133 |
-
"g_dim": g_dim,
|
| 134 |
-
},
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def train(features_dir, train_stems, val_stems, epochs=200, k=16, hidden=128,
|
| 139 |
-
n_blocks=12, batch_size=8, lr=1e-3, device=None, out_dir="runs/gnn",
|
| 140 |
-
augment=True, patience=25, spatial_weight=0.3, global_weight=0.0,
|
| 141 |
-
graph="knn", feature_version=2, num_workers=0, two_head=False,
|
| 142 |
-
film=False, g_dim=5, freeze_backbone: bool = False, init_ckpt=None,
|
| 143 |
-
early_stop_val_stems=None, freeze_mode=None, save_every_epoch=False,
|
| 144 |
-
norm_override=None):
|
| 145 |
-
"""
|
| 146 |
-
film: build the model with FiLM (per-block gamma/beta from data.g); forced
|
| 147 |
-
True when init_ckpt is given (warm-start is always onto a FiLM model).
|
| 148 |
-
g_dim: FiLM conditioning-vector width (ignored unless film/init_ckpt).
|
| 149 |
-
freeze_backbone: freeze node_encoder/edge_encoder/blocks/decoder/scale_head,
|
| 150 |
-
leaving only FiLM params trainable (FT2b stage-1: shape/scale knowledge
|
| 151 |
-
from warm-start is preserved while FiLM learns to respond to `g`).
|
| 152 |
-
Back-compat boolean from Task 5; equivalent to `freeze_mode="backbone"`.
|
| 153 |
-
freeze_mode: FT2b's more general freeze-scope switch, superset of
|
| 154 |
-
`freeze_backbone`. One of:
|
| 155 |
-
- None (default): use `freeze_backbone`'s boolean behavior (freeze
|
| 156 |
-
everything but FiLM if True, nothing if False) -- unchanged from
|
| 157 |
-
the pre-FT2b/Task-5 code path.
|
| 158 |
-
- "backbone": same as `freeze_backbone=True` (only FiLM trainable).
|
| 159 |
-
- "all_but_last2": only the last 2 processor blocks + scale_head are
|
| 160 |
-
trainable (FT2b stage 3 / calibrate -- see
|
| 161 |
-
`_freeze_all_but_last2_and_scale_head`'s docstring).
|
| 162 |
-
`freeze_mode` takes precedence over `freeze_backbone` when both are given.
|
| 163 |
-
init_ckpt: path to an SP1 checkpoint (node_in=13, no FiLM) OR an earlier
|
| 164 |
-
FT2b-stage checkpoint (node_in=15, FiLM already present -- stage 2
|
| 165 |
-
chains from stage 1's `best_gnn.pt`, stage 3 from stage 2's) to warm-
|
| 166 |
-
start from via `src.warm_start.build_or_load_v4`, instead of a
|
| 167 |
-
randomly initialized model. `build_or_load_v4` dispatches on the
|
| 168 |
-
checkpoint's own `cfg` (splice-from-SP1 vs. direct-load-an-already-v4-
|
| 169 |
-
checkpoint) -- see its docstring. Implies film=True. bc-reach: when
|
| 170 |
-
the ACTUAL training data (`node_in`, computed below from the built
|
| 171 |
-
`tr` dataset's own feature width) equals `_V5_NODE_IN` (20) -- i.e.
|
| 172 |
-
`features_dir` is a v5 (BC-reach) cache -- this dispatches to
|
| 173 |
-
`src.warm_start.build_or_load_v5` instead, which rebuilds the node
|
| 174 |
-
encoder's first Linear for the 5 new trailing columns (node_in 15 ->
|
| 175 |
-
20) from a v4 `init_ckpt` (e.g. the production `ckpt_ep021.pt`), or
|
| 176 |
-
direct-loads an already-v5 checkpoint. Every other `node_in` (in
|
| 177 |
-
particular the existing 15-wide FT2b/FT3 path) is unaffected -- still
|
| 178 |
-
routed through `build_or_load_v4`, byte-identical to before this
|
| 179 |
-
dispatch existed.
|
| 180 |
-
NOTE on normalization: the checkpoint's own `norm` (the SOURCE
|
| 181 |
-
checkpoint's feature/target stats -- SP1's v3 stats for the original
|
| 182 |
-
13->15 warm-start, or a v4 checkpoint's 15-wide stats for the
|
| 183 |
-
15->20 bc-reach one) is NOT reused here -- the training data in
|
| 184 |
-
`features_dir` (whichever cache version it actually is: v3/v4/v5)
|
| 185 |
-
is normalized (by `SolidGraphDataset`) with *its own*
|
| 186 |
-
`norm_stats.npz` (`tr.norm`), since the model always consumes
|
| 187 |
-
THAT cache's own normalized features, not the source checkpoint's.
|
| 188 |
-
The saved checkpoint's `norm` field must match what the *data* was
|
| 189 |
-
actually standardized with, so we always save `tr.norm`, discarding
|
| 190 |
-
the `_sp1_norm` that `build_or_load_v4`/`build_or_load_v5` return
|
| 191 |
-
(that value exists for callers who need to reconcile the two spaces
|
| 192 |
-
explicitly, e.g. a sanity-gate check at warm-start time -- not for
|
| 193 |
-
the trained ckpt).
|
| 194 |
-
early_stop_val_stems: if given, checkpoint-selection/early-stopping use a
|
| 195 |
-
loss computed on this stem list instead of `val_stems` (e.g. a
|
| 196 |
-
val_extrap split held out for OOD generalization signal). When None,
|
| 197 |
-
behavior is identical to the pre-FT2b code path (single val set/loader
|
| 198 |
-
serving both roles).
|
| 199 |
-
save_every_epoch: if True, save a checkpoint to `<out_dir>/ckpt_ep{ep:03d}.pt`
|
| 200 |
-
after EVERY epoch (in the same format as best_gnn.pt: {model, norm, cfg}),
|
| 201 |
-
in addition to the unchanged best_gnn.pt logic. Default False preserves
|
| 202 |
-
byte-identical behavior (no ckpt_ep* files).
|
| 203 |
-
norm_override: if given (a norm dict, e.g. a production checkpoint's own
|
| 204 |
-
`ckpt["norm"]"), freeze normalization to it instead of computing/loading
|
| 205 |
-
the feature cache's own `norm_stats.npz` (FT3: fine-tuning must not
|
| 206 |
-
shift any standardization the production checkpoint was trained
|
| 207 |
-
with). Passed as `norm=norm_override` into `tr`'s SolidGraphDataset
|
| 208 |
-
construction; `va`/`va_es` already reuse `tr.norm` downstream, so all
|
| 209 |
-
three dataset constructions end up standardized with `norm_override`,
|
| 210 |
-
and the saved checkpoint's `norm` field (`tr.norm`) is `norm_override`
|
| 211 |
-
too. Default None preserves byte-identical behavior (tr loads the
|
| 212 |
-
cache's own norm_stats.npz, as before).
|
| 213 |
-
"""
|
| 214 |
-
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 215 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 216 |
-
tr = SolidGraphDataset(features_dir, train_stems, k=k, augment=augment, graph=graph,
|
| 217 |
-
norm=norm_override)
|
| 218 |
-
va = SolidGraphDataset(features_dir, val_stems, k=k, augment=False, norm=tr.norm, graph=graph)
|
| 219 |
-
trl = DataLoader(tr, batch_size=batch_size, shuffle=True, num_workers=num_workers, persistent_workers=num_workers > 0)
|
| 220 |
-
val = DataLoader(va, batch_size=batch_size, num_workers=num_workers, persistent_workers=num_workers > 0)
|
| 221 |
-
|
| 222 |
-
if early_stop_val_stems is not None:
|
| 223 |
-
va_es = SolidGraphDataset(features_dir, early_stop_val_stems, k=k, augment=False,
|
| 224 |
-
norm=tr.norm, graph=graph)
|
| 225 |
-
val_es = DataLoader(va_es, batch_size=batch_size, num_workers=num_workers,
|
| 226 |
-
persistent_workers=num_workers > 0)
|
| 227 |
-
else:
|
| 228 |
-
val_es = None
|
| 229 |
-
|
| 230 |
-
sample = tr[0]
|
| 231 |
-
node_in, edge_in = sample.x.shape[1], sample.edge_attr.shape[1]
|
| 232 |
-
|
| 233 |
-
if init_ckpt is not None:
|
| 234 |
-
film = True
|
| 235 |
-
if node_in == _V5_NODE_IN:
|
| 236 |
-
model, _sp1_norm = build_or_load_v5(init_ckpt, hidden=hidden, n_blocks=n_blocks, g_dim=g_dim)
|
| 237 |
-
else:
|
| 238 |
-
model, _sp1_norm = build_or_load_v4(init_ckpt, hidden=hidden, n_blocks=n_blocks, g_dim=g_dim)
|
| 239 |
-
model = model.to(device)
|
| 240 |
-
# build_or_load_v4 (like build_warm_started_v4 before it) prefers the
|
| 241 |
-
# *source* checkpoint's own cfg (hidden/n_blocks/two_head) over the
|
| 242 |
-
# args passed here (two_head isn't even forwarded to it), so
|
| 243 |
-
# re-derive them from the actual constructed model -- the saved cfg
|
| 244 |
-
# below must describe the model we trained, not just echo back the
|
| 245 |
-
# caller's arguments.
|
| 246 |
-
hidden = model.decoder[0].in_features
|
| 247 |
-
n_blocks = len(model.blocks)
|
| 248 |
-
two_head = model.two_head
|
| 249 |
-
else:
|
| 250 |
-
model = SolidGNN(node_in=node_in, edge_in=edge_in, hidden=hidden, n_blocks=n_blocks,
|
| 251 |
-
two_head=two_head, film=film, g_dim=g_dim).to(device)
|
| 252 |
-
|
| 253 |
-
if freeze_mode == "all_but_last2":
|
| 254 |
-
_freeze_all_but_last2_and_scale_head(model)
|
| 255 |
-
elif freeze_mode == "backbone" or (freeze_mode is None and freeze_backbone):
|
| 256 |
-
_freeze_backbone_params(model)
|
| 257 |
-
elif freeze_mode not in (None,):
|
| 258 |
-
raise ValueError(f"train(): unknown freeze_mode={freeze_mode!r}")
|
| 259 |
-
|
| 260 |
-
trainable_params = [p for p in model.parameters() if p.requires_grad]
|
| 261 |
-
opt = AdamW(trainable_params, lr=lr, weight_decay=1e-5)
|
| 262 |
-
sched = ReduceLROnPlateau(opt, mode="min", factor=0.5, patience=10)
|
| 263 |
-
scaler = torch.cuda.amp.GradScaler() if device.startswith("cuda") else None
|
| 264 |
-
|
| 265 |
-
best, best_ep, since = float("inf"), 0, 0
|
| 266 |
-
ckpt = osp.join(out_dir, "best_gnn.pt")
|
| 267 |
-
ep = -1
|
| 268 |
-
for ep in range(epochs):
|
| 269 |
-
tr_loss = _run_epoch(model, trl, device, opt=opt, scaler=scaler, spatial_weight=spatial_weight, global_weight=global_weight)
|
| 270 |
-
# Checkpoint selection/early-stop use shape-only val loss: the global term is
|
| 271 |
-
# noisy at batch=2 and masks shape improvements (Run-1 lesson).
|
| 272 |
-
if val_es is not None:
|
| 273 |
-
vloss = _run_epoch(model, val_es, device, spatial_weight=spatial_weight, global_weight=0.0)
|
| 274 |
-
else:
|
| 275 |
-
vloss = _run_epoch(model, val, device, spatial_weight=spatial_weight, global_weight=0.0)
|
| 276 |
-
sched.step(vloss)
|
| 277 |
-
lr = opt.param_groups[0]["lr"]
|
| 278 |
-
print(f" epoch {ep:3d} train {tr_loss:.4f} val {vloss:.4f} lr {lr:.2e}", flush=True)
|
| 279 |
-
|
| 280 |
-
# Build checkpoint dict (used for both best_gnn.pt and per-epoch saves)
|
| 281 |
-
ckpt_dict = _build_checkpoint_dict(
|
| 282 |
-
model, tr.norm, hidden, n_blocks, k, node_in, edge_in,
|
| 283 |
-
spatial_weight, global_weight, graph, feature_version,
|
| 284 |
-
two_head, film, g_dim
|
| 285 |
-
)
|
| 286 |
-
|
| 287 |
-
# Save per-epoch checkpoint if requested
|
| 288 |
-
if save_every_epoch:
|
| 289 |
-
ep_ckpt = osp.join(out_dir, f"ckpt_ep{ep:03d}.pt")
|
| 290 |
-
torch.save(ckpt_dict, ep_ckpt)
|
| 291 |
-
|
| 292 |
-
# Save best checkpoint if this is a new best
|
| 293 |
-
if vloss < best - 1e-5:
|
| 294 |
-
best, best_ep, since = vloss, ep, 0
|
| 295 |
-
torch.save(ckpt_dict, ckpt)
|
| 296 |
-
else:
|
| 297 |
-
since += 1
|
| 298 |
-
if since >= patience:
|
| 299 |
-
break
|
| 300 |
-
return {"best_val": best, "epochs_run": ep + 1, "ckpt": ckpt}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/train_heads.py
DELETED
|
@@ -1,278 +0,0 @@
|
|
| 1 |
-
"""Learned-heads Task 2: frozen-backbone trainer for the gradT/dTdt deriv
|
| 2 |
-
heads (Task 1's `SolidGNN(deriv_heads=True)` / `attach_deriv_heads`).
|
| 3 |
-
|
| 4 |
-
Loads a headless production checkpoint (`{model, norm, cfg}`), attaches the
|
| 5 |
-
two new heads (`grad_head`/`dtdt_head`), freezes EVERY other parameter, and
|
| 6 |
-
trains only the ~8 head tensors to regress standardized log1p(gradT) /
|
| 7 |
-
log1p(|dTdt|) targets read straight from the v4 feature cache (each
|
| 8 |
-
`data/features_v4/graphs/*.npz` already carries raw per-node `gradT` (K/m)
|
| 9 |
-
and `dTdt` (K/s) arrays, see `src/build_dataset.py`). Base weights are never
|
| 10 |
-
touched (verified by an assertion here, and by the caller's own
|
| 11 |
-
byte-identity check).
|
| 12 |
-
"""
|
| 13 |
-
import json
|
| 14 |
-
import logging
|
| 15 |
-
import os
|
| 16 |
-
import os.path as osp
|
| 17 |
-
|
| 18 |
-
import numpy as np
|
| 19 |
-
import torch
|
| 20 |
-
import torch.nn.functional as F
|
| 21 |
-
from torch.optim import AdamW
|
| 22 |
-
from torch_geometric.loader import DataLoader
|
| 23 |
-
|
| 24 |
-
from src.gnn_dataset import SolidGraphDataset
|
| 25 |
-
from src.model_gnn import SolidGNN, attach_deriv_heads
|
| 26 |
-
|
| 27 |
-
logger = logging.getLogger(__name__)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
class DerivGraphDataset(SolidGraphDataset):
|
| 31 |
-
"""`SolidGraphDataset` plus a standardized `y_deriv` (N, 2) node target:
|
| 32 |
-
`[(log1p(gradT) - mean) / std, (log1p(|dTdt|) - mean) / std]`, read from
|
| 33 |
-
the same graph cache. Callers must pre-filter `stems` to ones known to
|
| 34 |
-
carry valid `gradT`/`dTdt` (see `_filter_stems_with_deriv`) -- this class
|
| 35 |
-
does not re-validate.
|
| 36 |
-
"""
|
| 37 |
-
|
| 38 |
-
def __init__(self, features_dir, stems, deriv_mean, deriv_std, k=16,
|
| 39 |
-
graph="union", norm=None):
|
| 40 |
-
super().__init__(features_dir, stems, k=k, augment=False, norm=norm, graph=graph)
|
| 41 |
-
self.deriv_mean = torch.tensor(np.asarray(deriv_mean), dtype=torch.float32)
|
| 42 |
-
self.deriv_std = torch.tensor(np.asarray(deriv_std), dtype=torch.float32)
|
| 43 |
-
|
| 44 |
-
def __getitem__(self, i):
|
| 45 |
-
data = super().__getitem__(i)
|
| 46 |
-
raw = np.load(osp.join(self.dir, "graphs", f"{self.stems[i]}.npz"))
|
| 47 |
-
gradT = torch.tensor(raw["gradT"], dtype=torch.float32).clamp(min=0.0)
|
| 48 |
-
dTdt = torch.tensor(raw["dTdt"], dtype=torch.float32).abs()
|
| 49 |
-
deriv_log = torch.stack([torch.log1p(gradT), torch.log1p(dTdt)], dim=-1)
|
| 50 |
-
data.y_deriv = (deriv_log - self.deriv_mean) / self.deriv_std
|
| 51 |
-
return data
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def _stem_has_valid_deriv(features_dir, stem):
|
| 55 |
-
"""A stem is usable if its cached graph has finite gradT/dTdt arrays,
|
| 56 |
-
each with exactly one value per node (matching X's row count -- a
|
| 57 |
-
length-mismatched stem would otherwise broadcast silently into the
|
| 58 |
-
loss). A missing `.npz` file is also treated as an exclusion (counted
|
| 59 |
-
and logged by the caller), not a raw crash."""
|
| 60 |
-
path = osp.join(features_dir, "graphs", f"{stem}.npz")
|
| 61 |
-
try:
|
| 62 |
-
d = np.load(path)
|
| 63 |
-
except FileNotFoundError:
|
| 64 |
-
return False
|
| 65 |
-
if "gradT" not in d.files or "dTdt" not in d.files or "X" not in d.files:
|
| 66 |
-
return False
|
| 67 |
-
gradT, dTdt, X = d["gradT"], d["dTdt"], d["X"]
|
| 68 |
-
if gradT.shape != dTdt.shape or gradT.shape[0] != X.shape[0]:
|
| 69 |
-
return False
|
| 70 |
-
return bool(np.all(np.isfinite(gradT)) and np.all(np.isfinite(dTdt)))
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _filter_stems_with_deriv(features_dir, stems):
|
| 74 |
-
"""Split `stems` into (ok, n_excluded), logging loudly (never silently
|
| 75 |
-
dropping) every stem missing/invalid gradT/dTdt."""
|
| 76 |
-
ok, excluded = [], []
|
| 77 |
-
for stem in stems:
|
| 78 |
-
if _stem_has_valid_deriv(features_dir, stem):
|
| 79 |
-
ok.append(stem)
|
| 80 |
-
else:
|
| 81 |
-
excluded.append(stem)
|
| 82 |
-
if excluded:
|
| 83 |
-
logger.warning(
|
| 84 |
-
"train_heads: excluding %d/%d stem(s) missing/invalid gradT/dTdt: %s",
|
| 85 |
-
len(excluded), len(stems), excluded)
|
| 86 |
-
return ok, len(excluded)
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def _compute_deriv_stats(features_dir, stems):
|
| 90 |
-
"""Streamed (single accumulation pass, one stem's arrays in memory at a
|
| 91 |
-
time -- never the whole train-pool's per-node values at once) mean/std
|
| 92 |
-
of `[log1p(gradT), log1p(|dTdt|)]` over every node in `stems`."""
|
| 93 |
-
n = 0
|
| 94 |
-
s1 = np.zeros(2, dtype=np.float64)
|
| 95 |
-
s2 = np.zeros(2, dtype=np.float64)
|
| 96 |
-
for stem in stems:
|
| 97 |
-
d = np.load(osp.join(features_dir, "graphs", f"{stem}.npz"))
|
| 98 |
-
gradT = np.clip(d["gradT"].astype(np.float64), 0.0, None)
|
| 99 |
-
dTdt = np.abs(d["dTdt"].astype(np.float64))
|
| 100 |
-
vals = np.stack([np.log1p(gradT), np.log1p(dTdt)], axis=-1)
|
| 101 |
-
n += vals.shape[0]
|
| 102 |
-
s1 += vals.sum(axis=0)
|
| 103 |
-
s2 += (vals ** 2).sum(axis=0)
|
| 104 |
-
if n == 0:
|
| 105 |
-
raise ValueError("train_heads: no valid stems to compute deriv_log_mean/std from")
|
| 106 |
-
mean = s1 / n
|
| 107 |
-
var = np.maximum(s2 / n - mean ** 2, 1e-12)
|
| 108 |
-
std = np.sqrt(var)
|
| 109 |
-
return mean.astype(np.float32), std.astype(np.float32)
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def _freeze_all_but_deriv_heads(model):
|
| 113 |
-
"""Freeze every parameter except `grad_head.*`/`dtdt_head.*`. Exposed
|
| 114 |
-
standalone (mirrors `train_gnn._freeze_all_but_last2_and_scale_head`) so
|
| 115 |
-
the freeze scope can be unit-tested directly."""
|
| 116 |
-
for name, p in model.named_parameters():
|
| 117 |
-
p.requires_grad = name.startswith("grad_head.") or name.startswith("dtdt_head.")
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _run_epoch_heads(model, loader, device, opt=None):
|
| 121 |
-
train_mode = opt is not None
|
| 122 |
-
model.train(train_mode)
|
| 123 |
-
total, n = 0.0, 0
|
| 124 |
-
for batch in loader:
|
| 125 |
-
batch = batch.to(device)
|
| 126 |
-
g = getattr(batch, "g", None)
|
| 127 |
-
if train_mode:
|
| 128 |
-
opt.zero_grad()
|
| 129 |
-
_, _, deriv = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch, g=g)
|
| 130 |
-
loss = F.mse_loss(deriv, batch.y_deriv)
|
| 131 |
-
loss.backward()
|
| 132 |
-
opt.step()
|
| 133 |
-
else:
|
| 134 |
-
with torch.no_grad():
|
| 135 |
-
_, _, deriv = model(batch.x, batch.edge_index, batch.edge_attr, batch.batch, g=g)
|
| 136 |
-
loss = F.mse_loss(deriv, batch.y_deriv)
|
| 137 |
-
total += float(loss) * batch.num_graphs
|
| 138 |
-
n += batch.num_graphs
|
| 139 |
-
return total / max(n, 1)
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def train_deriv_heads(features_dir: str, base_ckpt: str, out_dir: str = "runs/lh_stage1",
|
| 143 |
-
train_stems=None, val_stems=None, epochs: int = 60, lr: float = 1e-3,
|
| 144 |
-
batch_size: int = 2, num_workers: int = 4, patience: int = 10,
|
| 145 |
-
device=None) -> dict:
|
| 146 |
-
"""Freeze the entire base model and train only the deriv (gradT/dTdt) heads.
|
| 147 |
-
|
| 148 |
-
Args:
|
| 149 |
-
features_dir: v4 feature cache dir (`graphs/*.npz` carry X/pos/y/
|
| 150 |
-
mesh_edges/g plus raw `gradT`/`dTdt`).
|
| 151 |
-
base_ckpt: path to a headless `{model, norm, cfg}` production
|
| 152 |
-
checkpoint (Task 1's `attach_deriv_heads` contract: only
|
| 153 |
-
grad_head/dtdt_head keys may be missing from it).
|
| 154 |
-
out_dir: where `heads_gnn.pt` (best-val checkpoint) and per-epoch
|
| 155 |
-
`ckpt_ep{ep:03d}.pt` snapshots are written.
|
| 156 |
-
train_stems, val_stems: stem lists; default to `splits.json`'s
|
| 157 |
-
`interp.train` / **`interp.test`** (NOT `extrap.test`: per the
|
| 158 |
-
spec's 2026-08-01 Task-2-review correction, `extrap.test` is
|
| 159 |
-
72% of the Gate C evaluation set, so early-stopping/checkpoint
|
| 160 |
-
selection on it would leak into that pre-authorized gate;
|
| 161 |
-
`interp.test` is disjoint from both Gate C evaluation lists
|
| 162 |
-
(`extrap.test` + `e_val`)).
|
| 163 |
-
epochs, lr, batch_size, num_workers, patience: as in `src.train_gnn.train`.
|
| 164 |
-
device: torch device string; default cuda if available else cpu.
|
| 165 |
-
|
| 166 |
-
Freezes ALL base parameters (`requires_grad=False`), asserting the
|
| 167 |
-
optimizer's trainable-parameter set is EXACTLY the head tensors
|
| 168 |
-
(`grad_head.*`/`dtdt_head.*`). Computes `deriv_log_mean`/`deriv_log_std`
|
| 169 |
-
over the (filtered) train pool ONCE, streamed, and stores them in the
|
| 170 |
-
saved checkpoint's `norm` dict (base norm + these two (2,) arrays). Loss
|
| 171 |
-
is MSE over the two standardized heads. Stems whose cached graph is
|
| 172 |
-
missing/length-mismatched gradT/dTdt (or has non-finite values), or
|
| 173 |
-
whose `.npz` file is simply absent, are excluded loudly and counted in
|
| 174 |
-
`n_excluded`, rather than silently dropped or crashing the run. The
|
| 175 |
-
graph mode/kNN-k are read verbatim from the base checkpoint's own cfg
|
| 176 |
-
(`cfg["graph"]`/`cfg["k"]`, no divergent defaults -- a cfg missing
|
| 177 |
-
either raises `KeyError` loudly rather than silently training/serving
|
| 178 |
-
with a mismatched graph construction). Saves a checkpoint every epoch
|
| 179 |
-
(`<out_dir>/ckpt_ep{ep:03d}.pt`, gate-metric checkpoint-selection
|
| 180 |
-
precedent from the exo-fix retrain) IN ADDITION to early-stopping on val
|
| 181 |
-
loss and keeping the MSE-best as `<out_dir>/heads_gnn.pt`, with `cfg` =
|
| 182 |
-
base cfg + `{"deriv_heads": True}`.
|
| 183 |
-
|
| 184 |
-
Returns:
|
| 185 |
-
{"ckpt": str, "best_val": float, "n_train": int, "n_excluded": int}
|
| 186 |
-
"""
|
| 187 |
-
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 188 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 189 |
-
|
| 190 |
-
if train_stems is None or val_stems is None:
|
| 191 |
-
with open(osp.join(features_dir, "splits.json")) as f:
|
| 192 |
-
splits = json.load(f)
|
| 193 |
-
if train_stems is None:
|
| 194 |
-
train_stems = splits["interp"]["train"]
|
| 195 |
-
if val_stems is None:
|
| 196 |
-
val_stems = splits["interp"]["test"]
|
| 197 |
-
|
| 198 |
-
train_ok, n_exc_train = _filter_stems_with_deriv(features_dir, train_stems)
|
| 199 |
-
val_ok, n_exc_val = _filter_stems_with_deriv(features_dir, val_stems)
|
| 200 |
-
n_excluded = n_exc_train + n_exc_val
|
| 201 |
-
if not train_ok:
|
| 202 |
-
raise ValueError("train_deriv_heads: no train stems with valid gradT/dTdt")
|
| 203 |
-
if not val_ok:
|
| 204 |
-
raise ValueError("train_deriv_heads: no val stems with valid gradT/dTdt")
|
| 205 |
-
|
| 206 |
-
deriv_mean, deriv_std = _compute_deriv_stats(features_dir, train_ok)
|
| 207 |
-
|
| 208 |
-
base = torch.load(base_ckpt, map_location="cpu", weights_only=False)
|
| 209 |
-
cfg = dict(base["cfg"])
|
| 210 |
-
base_norm = base["norm"]
|
| 211 |
-
|
| 212 |
-
model = SolidGNN(node_in=cfg.get("node_in", 15), edge_in=cfg.get("edge_in", 4),
|
| 213 |
-
hidden=cfg["hidden"], n_blocks=cfg["n_blocks"],
|
| 214 |
-
two_head=cfg.get("two_head", False), film=cfg.get("film", False),
|
| 215 |
-
g_dim=cfg.get("g_dim", 5), deriv_heads=True)
|
| 216 |
-
attach_deriv_heads(model, base["model"])
|
| 217 |
-
model = model.to(device)
|
| 218 |
-
|
| 219 |
-
_freeze_all_but_deriv_heads(model)
|
| 220 |
-
|
| 221 |
-
trainable_names = {n for n, p in model.named_parameters() if p.requires_grad}
|
| 222 |
-
expected_names = {n for n, _ in model.named_parameters()
|
| 223 |
-
if n.startswith("grad_head.") or n.startswith("dtdt_head.")}
|
| 224 |
-
assert trainable_names == expected_names, (
|
| 225 |
-
f"train_deriv_heads: optimizer param set must be exactly the deriv-head "
|
| 226 |
-
f"tensors -- unexpected trainable: {sorted(trainable_names - expected_names)}, "
|
| 227 |
-
f"missing: {sorted(expected_names - trainable_names)}")
|
| 228 |
-
trainable_params = [p for n, p in model.named_parameters() if n in trainable_names]
|
| 229 |
-
|
| 230 |
-
opt = AdamW(trainable_params, lr=lr, weight_decay=1e-5)
|
| 231 |
-
|
| 232 |
-
# No divergent .get(default) here: the graph mode/k must match whatever
|
| 233 |
-
# the base checkpoint was actually trained/normalized with, or the deriv
|
| 234 |
-
# heads would be trained (and later served) on a mismatched graph
|
| 235 |
-
# construction. A base cfg missing either key is a loud KeyError.
|
| 236 |
-
graph = cfg["graph"]
|
| 237 |
-
k = cfg["k"]
|
| 238 |
-
|
| 239 |
-
tr_ds = DerivGraphDataset(features_dir, train_ok, deriv_mean, deriv_std,
|
| 240 |
-
k=k, graph=graph, norm=base_norm)
|
| 241 |
-
va_ds = DerivGraphDataset(features_dir, val_ok, deriv_mean, deriv_std,
|
| 242 |
-
k=k, graph=graph, norm=base_norm)
|
| 243 |
-
trl = DataLoader(tr_ds, batch_size=batch_size, shuffle=True,
|
| 244 |
-
num_workers=num_workers, persistent_workers=num_workers > 0)
|
| 245 |
-
val = DataLoader(va_ds, batch_size=batch_size, num_workers=num_workers,
|
| 246 |
-
persistent_workers=num_workers > 0)
|
| 247 |
-
|
| 248 |
-
saved_norm = dict(base_norm)
|
| 249 |
-
saved_norm["deriv_log_mean"] = deriv_mean
|
| 250 |
-
saved_norm["deriv_log_std"] = deriv_std
|
| 251 |
-
saved_cfg = dict(cfg)
|
| 252 |
-
saved_cfg["deriv_heads"] = True
|
| 253 |
-
|
| 254 |
-
ckpt_path = osp.join(out_dir, "heads_gnn.pt")
|
| 255 |
-
best, since = float("inf"), 0
|
| 256 |
-
ep = -1
|
| 257 |
-
for ep in range(epochs):
|
| 258 |
-
tr_loss = _run_epoch_heads(model, trl, device, opt=opt)
|
| 259 |
-
vloss = _run_epoch_heads(model, val, device, opt=None)
|
| 260 |
-
print(f" epoch {ep:3d} train {tr_loss:.4f} val {vloss:.4f}", flush=True)
|
| 261 |
-
|
| 262 |
-
ckpt_dict = {"model": model.state_dict(), "norm": saved_norm, "cfg": saved_cfg}
|
| 263 |
-
|
| 264 |
-
# Per-epoch checkpoint (gate-metric checkpoint-selection precedent:
|
| 265 |
-
# the exo-fix retrain needed to compare 8 candidates), independent
|
| 266 |
-
# of the best-val bookkeeping below.
|
| 267 |
-
torch.save(ckpt_dict, osp.join(out_dir, f"ckpt_ep{ep:03d}.pt"))
|
| 268 |
-
|
| 269 |
-
if vloss < best - 1e-6:
|
| 270 |
-
best, since = vloss, 0
|
| 271 |
-
torch.save(ckpt_dict, ckpt_path)
|
| 272 |
-
else:
|
| 273 |
-
since += 1
|
| 274 |
-
if since >= patience:
|
| 275 |
-
break
|
| 276 |
-
|
| 277 |
-
return {"ckpt": ckpt_path, "best_val": best, "n_train": len(train_ok),
|
| 278 |
-
"n_excluded": n_excluded}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/utils.py
DELETED
|
@@ -1,115 +0,0 @@
|
|
| 1 |
-
import re
|
| 2 |
-
from pathlib import Path
|
| 3 |
-
|
| 4 |
-
ALL_FACES = ['+X', '-X', '+Y', '-Y', '+Z', '-Z']
|
| 5 |
-
_FACE_RE = re.compile(r'^[+\-][XYZ]$')
|
| 6 |
-
|
| 7 |
-
# Integer encoding for geometry type (used as global feature)
|
| 8 |
-
GEOMETRY_ENCODING = {
|
| 9 |
-
'BC': 0, 'RIB': 1, 'L': 2, 'T': 3, 'X': 4,
|
| 10 |
-
'WL': 5, 'UL': 6, 'UM': 7, 'US': 8, 'BH': 9,
|
| 11 |
-
'BO': 10, 'PB': 11, 'PF': 12, 'PH': 13,
|
| 12 |
-
'RPB':14, 'RPF':15, 'RPH':16,
|
| 13 |
-
# everything else → 17 (other)
|
| 14 |
-
}
|
| 15 |
-
GEOMETRY_OTHER = 17
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def parse_filename(path: str) -> dict:
|
| 19 |
-
"""Parse any <PREFIX>_<tokens...>.pt filename used in the Foundry dataset.
|
| 20 |
-
|
| 21 |
-
Token classification (applied to tokens after the prefix):
|
| 22 |
-
- numeric (all digits) → appended to dims list
|
| 23 |
-
- matches [+-][XYZ] → adiabatic face
|
| 24 |
-
- anything else → ignored (angle suffixes like 30, 45, 135
|
| 25 |
-
are already absorbed into the prefix token)
|
| 26 |
-
|
| 27 |
-
Returns:
|
| 28 |
-
prefix — geometry type string (e.g. 'BC', 'RIB', 'L')
|
| 29 |
-
dims — list of numeric dim values in mm (may be 0–3 ints)
|
| 30 |
-
adiabatic_faces — list of face labels that are adiabatic
|
| 31 |
-
cooling_faces — complement of adiabatic_faces over ALL_FACES
|
| 32 |
-
volume_mm3 — product of dims (mm³); 0.0 if no dims
|
| 33 |
-
geometry_type — integer encoding (GEOMETRY_ENCODING, 17 = other)
|
| 34 |
-
"""
|
| 35 |
-
stem = Path(path).stem
|
| 36 |
-
tokens = stem.split('_')
|
| 37 |
-
prefix = tokens[0]
|
| 38 |
-
|
| 39 |
-
dims = []
|
| 40 |
-
adiabatic = []
|
| 41 |
-
for tok in tokens[1:]:
|
| 42 |
-
if tok.isdigit():
|
| 43 |
-
dims.append(int(tok))
|
| 44 |
-
elif _FACE_RE.match(tok):
|
| 45 |
-
adiabatic.append(tok)
|
| 46 |
-
# else: skip (e.g. stray letters, already part of prefix)
|
| 47 |
-
|
| 48 |
-
cooling = [f for f in ALL_FACES if f not in set(adiabatic)]
|
| 49 |
-
volume = float(eval('*'.join(str(d) for d in dims))) if dims else 0.0
|
| 50 |
-
geo_type = GEOMETRY_ENCODING.get(prefix, GEOMETRY_OTHER)
|
| 51 |
-
|
| 52 |
-
return {
|
| 53 |
-
'prefix': prefix,
|
| 54 |
-
'dims': dims,
|
| 55 |
-
'adiabatic_faces': adiabatic,
|
| 56 |
-
'cooling_faces': cooling,
|
| 57 |
-
'volume_mm3': volume,
|
| 58 |
-
'geometry_type': geo_type,
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def global_feature_vec(info: dict) -> list:
|
| 63 |
-
"""6-element global feature vector from a parse_filename result.
|
| 64 |
-
|
| 65 |
-
Layout: [dim1, dim2, dim3, volume_mm3, n_cooling_faces, geometry_type]
|
| 66 |
-
dims are zero-padded to length 3.
|
| 67 |
-
"""
|
| 68 |
-
d = (info['dims'] + [0, 0, 0])[:3]
|
| 69 |
-
return [
|
| 70 |
-
float(d[0]),
|
| 71 |
-
float(d[1]),
|
| 72 |
-
float(d[2]),
|
| 73 |
-
info['volume_mm3'],
|
| 74 |
-
float(len(info['cooling_faces'])),
|
| 75 |
-
float(info['geometry_type']),
|
| 76 |
-
]
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
if __name__ == '__main__':
|
| 80 |
-
test_cases = [
|
| 81 |
-
# BC — 3 numeric dims
|
| 82 |
-
'BC_06_08_14_+X_+Z.pt',
|
| 83 |
-
'BC_06_08_14.pt',
|
| 84 |
-
# RIB — 3 dims
|
| 85 |
-
'RIB_06_06_06.pt',
|
| 86 |
-
# L — 2 dims
|
| 87 |
-
'L_06_06.pt',
|
| 88 |
-
# T — 2 dims with faces
|
| 89 |
-
'T_06_08_+X_+Y.pt',
|
| 90 |
-
# X — 2 dims
|
| 91 |
-
'X_06_06_+Y.pt',
|
| 92 |
-
# WL — 2 dims
|
| 93 |
-
'WL_06_06.pt',
|
| 94 |
-
# BH — 2 dims
|
| 95 |
-
'BH_06_06_+X.pt',
|
| 96 |
-
# CYL — 1 dim
|
| 97 |
-
'CYL_06.pt',
|
| 98 |
-
# KON — 1 dim
|
| 99 |
-
'KON_25.pt',
|
| 100 |
-
# angle-suffix prefix
|
| 101 |
-
'L135_06_06.pt',
|
| 102 |
-
'TR30_06_06.pt',
|
| 103 |
-
]
|
| 104 |
-
|
| 105 |
-
header = f"{'File':<35s} {'prefix':<8} {'dims':<14} {'adiab':<18} {'vol':>8} {'geo':>4}"
|
| 106 |
-
print(header)
|
| 107 |
-
print('-' * len(header))
|
| 108 |
-
for p in test_cases:
|
| 109 |
-
r = parse_filename(p)
|
| 110 |
-
gf = global_feature_vec(r)
|
| 111 |
-
print(
|
| 112 |
-
f"{Path(p).name:<35s} {r['prefix']:<8} {str(r['dims']):<14} "
|
| 113 |
-
f"{str(r['adiabatic_faces']):<18} {r['volume_mm3']:>8.0f} {r['geometry_type']:>4d}"
|
| 114 |
-
)
|
| 115 |
-
print(f" global_features: {gf}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/warm_start.py
DELETED
|
@@ -1,239 +0,0 @@
|
|
| 1 |
-
"""Warm-start loader: build a v4 (node_in=15, FiLM) SolidGNN from an SP1
|
| 2 |
-
(node_in=13) checkpoint with zero learned knowledge lost.
|
| 3 |
-
|
| 4 |
-
The v4 model adds two trailing per-node input columns (boundary_extraction,
|
| 5 |
-
exothermic_input) and FiLM conditioning (per-block gamma/beta from a material
|
| 6 |
-
g-vector). Both extensions are zero-init: the new input columns are zeroed in
|
| 7 |
-
the node-encoder's first Linear, and FiLM's last layer is zero-init (see
|
| 8 |
-
`src.model_gnn.FiLM`) so gamma=0, beta=0 => identity. Together this means the
|
| 9 |
-
freshly warm-started v4 model reproduces the SP1 model's output exactly (see
|
| 10 |
-
`sanity_gate` / tests/test_warm_start.py), before any FT2b training happens.
|
| 11 |
-
|
| 12 |
-
torch 2.3 note: `load_state_dict(..., strict=False)` does NOT silently skip
|
| 13 |
-
shape-mismatched tensors -- it raises `RuntimeError: size mismatch` even with
|
| 14 |
-
strict=False (verified empirically). So we pre-filter the SP1 state_dict to
|
| 15 |
-
keys whose shape matches the v4 model before calling load_state_dict, and
|
| 16 |
-
splice the one shape-mismatched tensor (the node_encoder's first Linear
|
| 17 |
-
weight) by hand.
|
| 18 |
-
"""
|
| 19 |
-
import logging
|
| 20 |
-
|
| 21 |
-
import torch
|
| 22 |
-
|
| 23 |
-
from src.model_gnn import SolidGNN
|
| 24 |
-
|
| 25 |
-
logger = logging.getLogger(__name__)
|
| 26 |
-
|
| 27 |
-
_ENC_W = "node_encoder.0.0.weight"
|
| 28 |
-
_ENC_B = "node_encoder.0.0.bias"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def splice_node_encoder(v4, sp1_sd, n_old=13):
|
| 32 |
-
"""Copy SP1's node-encoder first-Linear weight into v4's leading n_old
|
| 33 |
-
input columns; zero the trailing (new) columns. Bias copied verbatim.
|
| 34 |
-
|
| 35 |
-
Args:
|
| 36 |
-
v4: SolidGNN instance (node_in > n_old).
|
| 37 |
-
sp1_sd: SP1 state_dict (or any mapping with the same keys).
|
| 38 |
-
n_old: number of leading input columns coming from SP1 (13).
|
| 39 |
-
"""
|
| 40 |
-
with torch.no_grad():
|
| 41 |
-
w_new = v4.node_encoder[0][0].weight
|
| 42 |
-
w_new[:, :n_old] = sp1_sd[_ENC_W]
|
| 43 |
-
w_new[:, n_old:] = 0.0
|
| 44 |
-
v4.node_encoder[0][0].bias.copy_(sp1_sd[_ENC_B])
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def build_warm_started_v4(sp1_ckpt_path, hidden=128, n_blocks=12, g_dim=5,
|
| 48 |
-
node_in_new=15, n_old=13):
|
| 49 |
-
"""Build a film=True, two_head=True SolidGNN(node_in=node_in_new) warm-
|
| 50 |
-
started from an SP1 checkpoint (node_in=n_old).
|
| 51 |
-
|
| 52 |
-
All shape-matching parameters (edge_encoder, processor blocks, decoder,
|
| 53 |
-
scale_head) are loaded verbatim from SP1. The node_encoder's first Linear
|
| 54 |
-
is spliced (SP1 columns 0:n_old, zeros n_old:). FiLM stays at its
|
| 55 |
-
zero-init (identity) construction.
|
| 56 |
-
|
| 57 |
-
Args:
|
| 58 |
-
sp1_ckpt_path: path to the SP1 `{model, norm, cfg}` checkpoint.
|
| 59 |
-
hidden, n_blocks, g_dim: v4 model hyperparameters. If the checkpoint's
|
| 60 |
-
`cfg` carries `hidden`/`n_blocks`/`edge_in`/`two_head`, those take
|
| 61 |
-
precedence (the warm-started model must match SP1's architecture
|
| 62 |
-
to load its weights) -- the arguments here are fallback defaults
|
| 63 |
-
for checkpoints without a `cfg`.
|
| 64 |
-
node_in_new: v4 input feature count (13 v3 features + 2 new BC cols).
|
| 65 |
-
n_old: SP1 input feature count.
|
| 66 |
-
|
| 67 |
-
Returns:
|
| 68 |
-
(model, sp1_norm): the warm-started v4 SolidGNN (train mode, as
|
| 69 |
-
constructed) and the SP1 checkpoint's `norm` dict (feature/target
|
| 70 |
-
normalization stats), so callers can decide how to reconcile it with
|
| 71 |
-
v4's own (different) normalization -- not this function's concern.
|
| 72 |
-
"""
|
| 73 |
-
ckpt = torch.load(sp1_ckpt_path, map_location="cpu", weights_only=False)
|
| 74 |
-
sp1_sd = ckpt["model"]
|
| 75 |
-
cfg = ckpt.get("cfg", {})
|
| 76 |
-
edge_in = cfg.get("edge_in", 4)
|
| 77 |
-
two_head = cfg.get("two_head", True)
|
| 78 |
-
hidden = cfg.get("hidden", hidden)
|
| 79 |
-
n_blocks = cfg.get("n_blocks", n_blocks)
|
| 80 |
-
|
| 81 |
-
v4 = SolidGNN(node_in=node_in_new, edge_in=edge_in, hidden=hidden,
|
| 82 |
-
n_blocks=n_blocks, two_head=two_head, film=True, g_dim=g_dim)
|
| 83 |
-
|
| 84 |
-
v4_sd = v4.state_dict()
|
| 85 |
-
matching = {k: v for k, v in sp1_sd.items()
|
| 86 |
-
if k in v4_sd and v4_sd[k].shape == v.shape}
|
| 87 |
-
load_result = v4.load_state_dict(matching, strict=False)
|
| 88 |
-
|
| 89 |
-
# Everything skipped by the shape filter above must be exactly the
|
| 90 |
-
# spliced input-encoder tensors (handled below by splice_node_encoder).
|
| 91 |
-
skipped_sp1_keys = [k for k in sp1_sd if k not in matching]
|
| 92 |
-
unexpected_skips = set(skipped_sp1_keys) - {_ENC_W, _ENC_B}
|
| 93 |
-
if unexpected_skips:
|
| 94 |
-
raise RuntimeError(
|
| 95 |
-
f"warm_start: unexpected SP1 keys skipped by shape filter "
|
| 96 |
-
f"(expected only the node_encoder input layer): {sorted(unexpected_skips)}"
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
-
# Everything load_state_dict reports "missing" in v4 must be exactly the
|
| 100 |
-
# spliced input-encoder tensors plus the new FiLM params (no SP1 counterpart).
|
| 101 |
-
unexpected_missing = [
|
| 102 |
-
k for k in load_result.missing_keys
|
| 103 |
-
if k not in (_ENC_W, _ENC_B) and not k.startswith("films.")
|
| 104 |
-
]
|
| 105 |
-
if unexpected_missing:
|
| 106 |
-
raise RuntimeError(
|
| 107 |
-
f"warm_start: unexpected missing keys after load "
|
| 108 |
-
f"(expected only node_encoder input layer + films.*): {unexpected_missing}"
|
| 109 |
-
)
|
| 110 |
-
if load_result.unexpected_keys:
|
| 111 |
-
raise RuntimeError(
|
| 112 |
-
f"warm_start: unexpected extra keys in SP1 checkpoint: "
|
| 113 |
-
f"{load_result.unexpected_keys}"
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
logger.info("warm_start: SP1 keys spliced by hand (input encoder): %s", skipped_sp1_keys)
|
| 117 |
-
logger.info("warm_start: new v4-only keys left at zero-init (FiLM): %s",
|
| 118 |
-
[k for k in load_result.missing_keys if k.startswith("films.")])
|
| 119 |
-
|
| 120 |
-
splice_node_encoder(v4, sp1_sd, n_old=n_old)
|
| 121 |
-
|
| 122 |
-
return v4, ckpt["norm"]
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def build_or_load_v4(ckpt_path, hidden=128, n_blocks=12, g_dim=5,
|
| 126 |
-
node_in_new=15, n_old=13):
|
| 127 |
-
"""Warm-start entry point used by FT2b's staged-training driver, which
|
| 128 |
-
chains checkpoints across stages (stage 2 warm-starts from stage 1's own
|
| 129 |
-
output, stage 3 from stage 2's) -- NOT always from the original SP1
|
| 130 |
-
checkpoint.
|
| 131 |
-
|
| 132 |
-
Dispatches on the checkpoint's own `cfg`:
|
| 133 |
-
- `cfg["node_in"] == node_in_new and cfg["film"]` => this is ALREADY a
|
| 134 |
-
v4 FT2b checkpoint (e.g. a previous stage's `best_gnn.pt`): same
|
| 135 |
-
architecture end to end, so it is loaded directly with a plain
|
| 136 |
-
`strict=True` `load_state_dict` -- no splicing, since there is no
|
| 137 |
-
column-count mismatch to bridge.
|
| 138 |
-
- otherwise => this is an SP1-shaped checkpoint (`node_in=n_old`, no
|
| 139 |
-
FiLM), so it is warm-started via `build_warm_started_v4` exactly as
|
| 140 |
-
before (the node-encoder splice + zero-init FiLM path).
|
| 141 |
-
|
| 142 |
-
`build_warm_started_v4` itself is intentionally left untouched -- it always
|
| 143 |
-
assumes an SP1-shaped source and unconditionally splices, which would
|
| 144 |
-
corrupt (or crash on, due to the column-count mismatch) an
|
| 145 |
-
already-15-column source encoder. This function is the one place that
|
| 146 |
-
decides which path applies.
|
| 147 |
-
|
| 148 |
-
Returns:
|
| 149 |
-
(model, norm): same contract as `build_warm_started_v4` -- the
|
| 150 |
-
`norm` is the SOURCE checkpoint's own `norm` dict, for callers to
|
| 151 |
-
reconcile against the current training data's normalization (as
|
| 152 |
-
`train()` already does for the build_warm_started_v4 path).
|
| 153 |
-
"""
|
| 154 |
-
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 155 |
-
cfg = ckpt.get("cfg", {})
|
| 156 |
-
if cfg.get("node_in") == node_in_new and cfg.get("film"):
|
| 157 |
-
edge_in = cfg.get("edge_in", 4)
|
| 158 |
-
two_head = cfg.get("two_head", True)
|
| 159 |
-
hidden = cfg.get("hidden", hidden)
|
| 160 |
-
n_blocks = cfg.get("n_blocks", n_blocks)
|
| 161 |
-
model = SolidGNN(node_in=node_in_new, edge_in=edge_in, hidden=hidden,
|
| 162 |
-
n_blocks=n_blocks, two_head=two_head, film=True, g_dim=g_dim)
|
| 163 |
-
model.load_state_dict(ckpt["model"], strict=True)
|
| 164 |
-
logger.info("build_or_load_v4: %s is already a v4 FT2b checkpoint "
|
| 165 |
-
"(node_in=%d, film=True) -- loaded directly, no splice.",
|
| 166 |
-
ckpt_path, node_in_new)
|
| 167 |
-
return model, ckpt["norm"]
|
| 168 |
-
return build_warm_started_v4(ckpt_path, hidden=hidden, n_blocks=n_blocks,
|
| 169 |
-
g_dim=g_dim, node_in_new=node_in_new, n_old=n_old)
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
def build_or_load_v5(ckpt_path, hidden=128, n_blocks=12, g_dim=5,
|
| 173 |
-
node_in_new=20, n_old=15):
|
| 174 |
-
"""bc-reach warm-start entry point: rebuild the node encoder for the 5 new
|
| 175 |
-
BC-reach columns (node_in 15 -> 20), or load an already-v5 checkpoint
|
| 176 |
-
directly.
|
| 177 |
-
|
| 178 |
-
`build_or_load_v4`'s dispatch logic (splice-from-a-smaller-node_in-source
|
| 179 |
-
vs. direct-load-an-already-this-shape checkpoint, `torch 2.3` shape-filter
|
| 180 |
-
pre-check, `load_state_dict(..., strict=False)` caveat) is already fully
|
| 181 |
-
generic in its `node_in_new`/`n_old` arguments -- it makes no assumption
|
| 182 |
-
that the source is specifically SP1-shaped, only that its node_encoder's
|
| 183 |
-
first-Linear weight is the sole shape mismatch. This function is a thin,
|
| 184 |
-
v5-flavored entry point over it: given the production `ckpt_ep021.pt`
|
| 185 |
-
(node_in=15, film=True -- itself already a fine-tuned FT2b/FT3 checkpoint,
|
| 186 |
-
NOT a fresh warm-start, so its FiLM/blocks/decoder/scale_head weights are
|
| 187 |
-
real learned values, not zero-init), it:
|
| 188 |
-
- copies `weight[:, :15]` (the node encoder's first Linear) verbatim,
|
| 189 |
-
- zero-inits the 5 new trailing columns (`weight[:, 15:20]`),
|
| 190 |
-
- copies the bias verbatim (unaffected by the input-width change --
|
| 191 |
-
Linear bias only depends on `hidden`, never on `node_in`),
|
| 192 |
-
- strict-copies everything else (edge_encoder, processor blocks, FiLM,
|
| 193 |
-
decoder, scale_head) verbatim, since `hidden`/`n_blocks`/`g_dim` are
|
| 194 |
-
unchanged from v4 to v5.
|
| 195 |
-
Dispatches to a direct `strict=True` load instead (no splice) when
|
| 196 |
-
`ckpt_path`'s own `cfg` already says `node_in==node_in_new and film` --
|
| 197 |
-
e.g. a later bc-reach stage chaining off an earlier bc-reach checkpoint.
|
| 198 |
-
|
| 199 |
-
Returns:
|
| 200 |
-
(model, norm): same contract as `build_or_load_v4` -- `norm` is the
|
| 201 |
-
SOURCE checkpoint's own `norm` dict (15-wide for a genuine v4 source),
|
| 202 |
-
for callers to reconcile against the CURRENT training data's own
|
| 203 |
-
(fresh, 20-wide) normalization -- `src.train_ft2b.run_bcr` does NOT
|
| 204 |
-
forward this as a `norm_override` (unlike FT3's frozen-norm
|
| 205 |
-
fine-tune): bc-reach's v5 feature cache gets its own fresh norm_stats
|
| 206 |
-
(Task 2, `build_v5`), so `train()` standardizes against that cache's
|
| 207 |
-
own stats, not `ckpt_ep021`'s 15-wide ones.
|
| 208 |
-
"""
|
| 209 |
-
return build_or_load_v4(ckpt_path, hidden=hidden, n_blocks=n_blocks, g_dim=g_dim,
|
| 210 |
-
node_in_new=node_in_new, n_old=n_old)
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
def sanity_gate(model_v4, sp1_model, sample):
|
| 214 |
-
"""Run both models (eval, no_grad) on the same reference sample; return
|
| 215 |
-
the max abs diff between their outputs (y_nodal and y_global combined).
|
| 216 |
-
|
| 217 |
-
`sample` is a dict with keys:
|
| 218 |
-
x_v4: (N, node_in_v4) input features for the v4 model.
|
| 219 |
-
x_sp1: (N, node_in_sp1) input features for the SP1 model.
|
| 220 |
-
edge_index: (2, E) shared graph connectivity.
|
| 221 |
-
edge_attr: (E, edge_in) shared edge features.
|
| 222 |
-
batch: (N,) graph-membership vector, shared.
|
| 223 |
-
g: (B, g_dim) material g-vector for v4's FiLM, optional.
|
| 224 |
-
|
| 225 |
-
This function makes no assumption about how x_v4/x_sp1 relate (e.g.
|
| 226 |
-
whether x_v4 is x_sp1 zero-padded, or real v4-normalized features) --
|
| 227 |
-
that reconciliation is the caller's job.
|
| 228 |
-
"""
|
| 229 |
-
model_v4.eval()
|
| 230 |
-
sp1_model.eval()
|
| 231 |
-
with torch.no_grad():
|
| 232 |
-
yn_sp1, yg_sp1 = sp1_model(sample["x_sp1"], sample["edge_index"],
|
| 233 |
-
sample["edge_attr"], sample["batch"])
|
| 234 |
-
yn_v4, yg_v4 = model_v4(sample["x_v4"], sample["edge_index"],
|
| 235 |
-
sample["edge_attr"], sample["batch"],
|
| 236 |
-
g=sample.get("g"))
|
| 237 |
-
node_diff = (yn_v4 - yn_sp1).abs().max().item()
|
| 238 |
-
global_diff = (yg_v4 - yg_sp1).abs().max().item()
|
| 239 |
-
return max(node_diff, global_diff)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/webapp.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
"""Local trame + PyVista web app: upload a STEP casting, run the
|
| 2 |
-
|
| 3 |
ghost, liquid = opaque) and a hot-spot toggle. Run: python -m src.webapp
|
| 4 |
|
| 5 |
A multi-solid STEP is meshed through the same prep pipeline as the CLI, which
|
|
|
|
| 1 |
+
"""Local trame + PyVista web app: upload a STEP casting, run the prediction,
|
| 2 |
+
and view the solidification field with a time slider (solidified =
|
| 3 |
ghost, liquid = opaque) and a hot-spot toggle. Run: python -m src.webapp
|
| 4 |
|
| 5 |
A multi-solid STEP is meshed through the same prep pipeline as the CLI, which
|
tests/test_features.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
from src.features import FEATURE_NAMES, alloy_g_vector, node_features
|
| 5 |
+
from src.msh_parse import parse_msh
|
| 6 |
+
|
| 7 |
+
MOLD_EFF = 1018.0
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_shapes_and_order(mini_msh_file):
|
| 11 |
+
mesh = parse_msh(mini_msh_file)
|
| 12 |
+
f = node_features(mesh, MOLD_EFF)
|
| 13 |
+
assert f["X"].shape == (5, len(FEATURE_NAMES)) == (5, 15)
|
| 14 |
+
assert f["X"].dtype == np.float32
|
| 15 |
+
assert f["pos"].shape == (5, 3)
|
| 16 |
+
assert f["mesh_edges"].shape == (6, 2) # one tet -> 6 edges
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_flags_and_boundary_channels(mini_msh_file):
|
| 20 |
+
X = node_features(parse_msh(mini_msh_file), MOLD_EFF)["X"]
|
| 21 |
+
col = {n: X[:, i] for i, n in enumerate(FEATURE_NAMES)}
|
| 22 |
+
# nodes 0..3 lie on the tagged surface, node 4 (index 4) is not in any tet
|
| 23 |
+
assert col["is_cooling"][:4].tolist() == [1, 1, 1, 1]
|
| 24 |
+
assert col["is_adiabatic"].sum() == 0 # adiabatic nodes are also cooling here
|
| 25 |
+
assert col["is_interior"][4] == 1
|
| 26 |
+
np.testing.assert_allclose(col["boundary_extraction"][:4], MOLD_EFF)
|
| 27 |
+
assert col["boundary_extraction"][4] == 0
|
| 28 |
+
assert np.all(col["boundary_heat_input"] == 0)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_global_columns_are_constant(mini_msh_file):
|
| 32 |
+
X = node_features(parse_msh(mini_msh_file), MOLD_EFF)["X"]
|
| 33 |
+
for name in ("volume_mm3", "surface_area_mm2", "cooling_area_frac"):
|
| 34 |
+
c = X[:, FEATURE_NAMES.index(name)]
|
| 35 |
+
assert np.all(c == c[0])
|
| 36 |
+
assert X[0, FEATURE_NAMES.index("volume_mm3")] == pytest.approx(1000.0 / 6.0)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_translation_invariance(mini_msh_file):
|
| 40 |
+
mesh = parse_msh(mini_msh_file)
|
| 41 |
+
a = node_features(mesh, MOLD_EFF)["X"]
|
| 42 |
+
mesh.node_xyz = mesh.node_xyz + np.array([100.0, -50.0, 7.0])
|
| 43 |
+
b = node_features(mesh, MOLD_EFF)["X"]
|
| 44 |
+
np.testing.assert_allclose(a, b, rtol=1e-5, atol=1e-4)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_g_vector():
|
| 48 |
+
props = {"k": 32.9, "rho": 6800.0, "cp": 515.0, "L_kJ_kg": 220.0,
|
| 49 |
+
"TL": 1160.0, "TS": 1120.0, "Tpour": 1260.0, "mold_effusivity": 1250.0}
|
| 50 |
+
g = alloy_g_vector(props)
|
| 51 |
+
assert g.shape == (5,)
|
| 52 |
+
assert g[0] == pytest.approx(515.0 * 140.0 / 220000.0) # Stefan number
|
| 53 |
+
assert g[1] == pytest.approx(0.04) and g[3] == pytest.approx(0.1)
|
| 54 |
+
assert g[2] == pytest.approx(32.9 / (6800.0 * 515.0) / 1e-5)
|
| 55 |
+
assert g[4] == pytest.approx(1.0)
|
tests/test_features_inference.py
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
import numpy as np
|
| 2 |
-
from src.msh_parse import parse_msh
|
| 3 |
-
from src.features import compute_features, compute_features_inference, FEATURE_NAMES
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
def test_inference_matches_training_features(mini_msh_file):
|
| 7 |
-
mesh = parse_msh(mini_msh_file)
|
| 8 |
-
N = mesh.node_xyz.shape[0]
|
| 9 |
-
times = np.array([0.0, 10.0]); temps = np.full((2, N), 1200.0); temps[1] = 1100.0
|
| 10 |
-
full = compute_features(mesh, temps, times)
|
| 11 |
-
inf = compute_features_inference(mesh)
|
| 12 |
-
assert inf["X"].shape == (N, len(FEATURE_NAMES))
|
| 13 |
-
assert "y" not in inf
|
| 14 |
-
np.testing.assert_array_equal(inf["X"], full["X"]) # identical feature columns
|
| 15 |
-
np.testing.assert_array_equal(inf["pos"], full["pos"])
|
| 16 |
-
assert np.isfinite(inf["X"]).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_features_v3.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 1 |
-
import numpy as np
|
| 2 |
-
|
| 3 |
-
from src.msh_parse import parse_msh
|
| 4 |
-
from src.features import (_node_features, compute_features_inference,
|
| 5 |
-
FEATURE_NAMES, FEATURE_NAMES_V3, LOCAL_MODULUS_RADII)
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def test_v3_names_extend_v2():
|
| 9 |
-
assert FEATURE_NAMES_V3[:9] == FEATURE_NAMES and len(FEATURE_NAMES_V3) == 13
|
| 10 |
-
assert LOCAL_MODULUS_RADII == (5.0, 15.0, 40.0)
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def test_v3_first_columns_match_v2(mini_msh_file):
|
| 14 |
-
mesh = parse_msh(mini_msh_file)
|
| 15 |
-
X2, pos2, e2 = _node_features(mesh, version=2)
|
| 16 |
-
X3, pos3, e3 = _node_features(mesh, version=3)
|
| 17 |
-
assert X2.shape[1] == 9 and X3.shape[1] == 13
|
| 18 |
-
assert np.allclose(X3[:, :9], X2) and np.array_equal(e2, e3)
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def test_geodesic_d_adiab_values(mini_msh_file):
|
| 22 |
-
mesh = parse_msh(mini_msh_file)
|
| 23 |
-
X3, _, _ = _node_features(mesh, version=3)
|
| 24 |
-
col = FEATURE_NAMES_V3.index("geodesic_d_adiab")
|
| 25 |
-
# 0-based nodes 1,2,3 lie on the Adiabatic triangle -> 0; node 0 reaches one
|
| 26 |
-
# of them along a 10 mm tet edge
|
| 27 |
-
assert np.allclose(X3[[1, 2, 3], col], 0.0)
|
| 28 |
-
assert np.isclose(X3[0, col], 10.0)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def test_inference_dict_has_mesh_edges(mini_msh_file):
|
| 32 |
-
mesh = parse_msh(mini_msh_file)
|
| 33 |
-
out = compute_features_inference(mesh, version=3)
|
| 34 |
-
assert out["X"].shape[1] == 13
|
| 35 |
-
me = out["mesh_edges"]
|
| 36 |
-
assert me.ndim == 2 and me.shape[1] == 2 and me.dtype == np.int64
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_features_v4.py
DELETED
|
@@ -1,275 +0,0 @@
|
|
| 1 |
-
import numpy as np
|
| 2 |
-
import pytest
|
| 3 |
-
from scipy.spatial import Delaunay
|
| 4 |
-
|
| 5 |
-
from src.msh_parse import parse_msh, Mesh
|
| 6 |
-
from src.geometry import edges_from_tets
|
| 7 |
-
from src.features import (
|
| 8 |
-
_node_features, compute_features,
|
| 9 |
-
FEATURE_NAMES, FEATURE_NAMES_V3, FEATURE_NAMES_V4,
|
| 10 |
-
G_VECTOR_NAMES, T_REF, ALPHA_REF, MOLD_EFF_REF,
|
| 11 |
-
EXO_DIFFUSION_LAMBDA_MM,
|
| 12 |
-
alloy_g_vector, chvorinov_t_ref,
|
| 13 |
-
)
|
| 14 |
-
|
| 15 |
-
MOLD_EFFUSIVITY = 1250.0
|
| 16 |
-
CHILL_EFFUSIVITY = 11000.0
|
| 17 |
-
SLEEVE_MEF = 2.0
|
| 18 |
-
EXO_Q0 = 5000.0
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def _base_spec(bc_config="none", exo_q0=0.0):
|
| 22 |
-
return {
|
| 23 |
-
"mold_effusivity": MOLD_EFFUSIVITY,
|
| 24 |
-
"bc_config": bc_config,
|
| 25 |
-
"chill_effusivity": CHILL_EFFUSIVITY,
|
| 26 |
-
"sleeve_MEF": SLEEVE_MEF,
|
| 27 |
-
"exo_q0": exo_q0,
|
| 28 |
-
# alloy props (for g / t_ref -- only needed by compute_features tests)
|
| 29 |
-
"k": 35.0, "rho": 7100.0, "cp": 500.0, "L_kJ_kg": 220.0,
|
| 30 |
-
"TL": 1180.0, "TS": 1140.0, "Tpour": 1380.0,
|
| 31 |
-
"C_material": 6.5e5,
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def _cube_with_bc_patch(n=5, a=20.0):
|
| 36 |
-
"""A Delaunay-tetrahedralized cube where the z=0 face is tagged
|
| 37 |
-
'Adiabatic' (the BC-patch footprint for chills/sleeves) and every other
|
| 38 |
-
hull face is tagged 'Cooling'. Interior points of the z=0 face (not on
|
| 39 |
-
a cube edge) are touched ONLY by Adiabatic triangles -- a clean
|
| 40 |
-
adiab_only region, unlike the tiny conftest mini_msh where every
|
| 41 |
-
Adiabatic node is also a Cooling node."""
|
| 42 |
-
lin = np.linspace(0.0, a, n)
|
| 43 |
-
pts = np.array([[x, y, z] for x in lin for y in lin for z in lin], float)
|
| 44 |
-
dt = Delaunay(pts)
|
| 45 |
-
tets = dt.simplices.astype(np.int64)
|
| 46 |
-
hull = dt.convex_hull.astype(np.int64)
|
| 47 |
-
is_bottom = np.isclose(pts[hull, 2], 0.0).all(axis=1)
|
| 48 |
-
adiab_tri = hull[is_bottom]
|
| 49 |
-
cool_tri = hull[~is_bottom]
|
| 50 |
-
mesh = Mesh(node_xyz=pts, tris={"Cooling": cool_tri, "Adiabatic": adiab_tri}, tets=tets)
|
| 51 |
-
interior_bottom = (np.isclose(pts[:, 2], 0.0)
|
| 52 |
-
& (pts[:, 0] > 1e-9) & (pts[:, 0] < a - 1e-9)
|
| 53 |
-
& (pts[:, 1] > 1e-9) & (pts[:, 1] < a - 1e-9))
|
| 54 |
-
on_cool = ~np.isclose(pts[:, 2], 0.0) | ~interior_bottom
|
| 55 |
-
return mesh, interior_bottom
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
# --------------------------------------------------------------------- #
|
| 59 |
-
# Names / shape contract
|
| 60 |
-
# --------------------------------------------------------------------- #
|
| 61 |
-
def test_v4_names_extend_v3():
|
| 62 |
-
assert FEATURE_NAMES_V4[:9] == FEATURE_NAMES
|
| 63 |
-
assert FEATURE_NAMES_V4[:13] == FEATURE_NAMES_V3
|
| 64 |
-
assert len(FEATURE_NAMES_V4) == 15
|
| 65 |
-
assert FEATURE_NAMES_V4[13:] == ["boundary_extraction", "exothermic_input"]
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def test_g_vector_names_and_constants():
|
| 69 |
-
assert G_VECTOR_NAMES == ["Ste", "dTf_norm", "alpha_ratio", "superheat_norm", "mold_eff_norm"]
|
| 70 |
-
assert T_REF == 1000.0
|
| 71 |
-
assert ALPHA_REF == 1e-5
|
| 72 |
-
assert MOLD_EFF_REF == 1250.0
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
# --------------------------------------------------------------------- #
|
| 76 |
-
# version==4 requires spec
|
| 77 |
-
# --------------------------------------------------------------------- #
|
| 78 |
-
def test_version4_without_spec_raises(mini_msh_file):
|
| 79 |
-
mesh = parse_msh(mini_msh_file)
|
| 80 |
-
with pytest.raises(ValueError):
|
| 81 |
-
_node_features(mesh, version=4)
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def test_compute_features_version4_without_spec_raises(mini_msh_file):
|
| 85 |
-
mesh = parse_msh(mini_msh_file)
|
| 86 |
-
N = mesh.node_xyz.shape[0]
|
| 87 |
-
times = np.array([0.0, 10.0]); temps = np.full((2, N), 1200.0); temps[1] = 1100.0
|
| 88 |
-
with pytest.raises(ValueError):
|
| 89 |
-
compute_features(mesh, temps, times, version=4)
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
# --------------------------------------------------------------------- #
|
| 93 |
-
# Backward-compat: all-sand (bc_config="none", exo_q0=0)
|
| 94 |
-
# --------------------------------------------------------------------- #
|
| 95 |
-
def test_v4_backward_compat_first13_columns_match_v3(mini_msh_file):
|
| 96 |
-
mesh = parse_msh(mini_msh_file)
|
| 97 |
-
spec = _base_spec(bc_config="none", exo_q0=0.0)
|
| 98 |
-
X4, pos4, e4 = _node_features(mesh, version=4, spec=spec)
|
| 99 |
-
X3, pos3, e3 = _node_features(mesh, version=3)
|
| 100 |
-
assert X4.shape[1] == 15
|
| 101 |
-
assert np.array_equal(X4[:, :13], X3)
|
| 102 |
-
assert np.array_equal(pos4, pos3) and np.array_equal(e4, e3)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def test_v4_backward_compat_boundary_extraction_and_exo(mini_msh_file):
|
| 106 |
-
mesh = parse_msh(mini_msh_file)
|
| 107 |
-
spec = _base_spec(bc_config="none", exo_q0=0.0)
|
| 108 |
-
X4, _, _ = _node_features(mesh, version=4, spec=spec)
|
| 109 |
-
idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
|
| 110 |
-
is_interior = X4[:, idx["is_interior"]].astype(bool)
|
| 111 |
-
extraction = X4[:, idx["boundary_extraction"]]
|
| 112 |
-
exo = X4[:, idx["exothermic_input"]]
|
| 113 |
-
# all boundary nodes get the baseline mold effusivity, interior nodes 0
|
| 114 |
-
assert np.allclose(extraction[~is_interior], MOLD_EFFUSIVITY)
|
| 115 |
-
assert np.allclose(extraction[is_interior], 0.0)
|
| 116 |
-
assert np.allclose(exo, 0.0)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def test_v4_backward_compat_on_cube_with_distinct_adiab_patch():
|
| 120 |
-
# Same all-sand check, but on a mesh with a genuinely distinct Adiabatic
|
| 121 |
-
# footprint (not overlapping Cooling), to make sure "none" really treats
|
| 122 |
-
# the BC patch as ordinary mold (matches fenicsx_solver's bc_config="none"
|
| 123 |
-
# branch: patch shares the SAME h_mold term).
|
| 124 |
-
mesh, interior_bottom = _cube_with_bc_patch()
|
| 125 |
-
spec = _base_spec(bc_config="none", exo_q0=0.0)
|
| 126 |
-
X4, _, _ = _node_features(mesh, version=4, spec=spec)
|
| 127 |
-
X3, _, _ = _node_features(mesh, version=3)
|
| 128 |
-
assert np.array_equal(X4[:, :13], X3)
|
| 129 |
-
idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
|
| 130 |
-
extraction = X4[:, idx["boundary_extraction"]]
|
| 131 |
-
is_interior = X4[:, idx["is_interior"]].astype(bool)
|
| 132 |
-
assert np.allclose(extraction[interior_bottom], MOLD_EFFUSIVITY)
|
| 133 |
-
assert np.allclose(extraction[is_interior], 0.0)
|
| 134 |
-
assert np.allclose(X4[:, idx["exothermic_input"]], 0.0)
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
# --------------------------------------------------------------------- #
|
| 138 |
-
# BC channel mapping: chills / insulating_sleeve / exo_sleeve
|
| 139 |
-
# --------------------------------------------------------------------- #
|
| 140 |
-
def test_v4_chills_mapping():
|
| 141 |
-
mesh, interior_bottom = _cube_with_bc_patch()
|
| 142 |
-
spec = _base_spec(bc_config="chills", exo_q0=0.0)
|
| 143 |
-
X4, _, _ = _node_features(mesh, version=4, spec=spec)
|
| 144 |
-
idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
|
| 145 |
-
extraction = X4[:, idx["boundary_extraction"]]
|
| 146 |
-
is_interior = X4[:, idx["is_interior"]].astype(bool)
|
| 147 |
-
assert np.allclose(extraction[interior_bottom], CHILL_EFFUSIVITY)
|
| 148 |
-
assert np.allclose(extraction[is_interior], 0.0)
|
| 149 |
-
assert np.allclose(X4[:, idx["exothermic_input"]], 0.0)
|
| 150 |
-
# cooling-only nodes still get the mold baseline
|
| 151 |
-
cool_only = ~is_interior & ~interior_bottom
|
| 152 |
-
assert np.allclose(extraction[cool_only], MOLD_EFFUSIVITY)
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def test_v4_insulating_sleeve_mapping():
|
| 156 |
-
mesh, interior_bottom = _cube_with_bc_patch()
|
| 157 |
-
spec = _base_spec(bc_config="insulating_sleeve", exo_q0=0.0)
|
| 158 |
-
X4, _, _ = _node_features(mesh, version=4, spec=spec)
|
| 159 |
-
idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
|
| 160 |
-
extraction = X4[:, idx["boundary_extraction"]]
|
| 161 |
-
expected = MOLD_EFFUSIVITY / max(SLEEVE_MEF ** 2, 1.0)
|
| 162 |
-
assert np.allclose(extraction[interior_bottom], expected)
|
| 163 |
-
assert np.allclose(X4[:, idx["exothermic_input"]], 0.0)
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def test_v4_exo_sleeve_mapping():
|
| 167 |
-
# NEW contract (geodesic-diffused field, not a sparse indicator): peak
|
| 168 |
-
# exo_q0/1e6 AT the patch nodes, decaying smoothly with geodesic distance
|
| 169 |
-
# -- see test_exo_channel_is_diffused_field/test_exo_channel_zero_for_non_exo
|
| 170 |
-
# below for the full shape/decay assertions. This test still checks that
|
| 171 |
-
# boundary_extraction (unrelated column) keeps its sleeve-scaled mapping
|
| 172 |
-
# and that the peak value at the patch is exactly exo_q0/1e6.
|
| 173 |
-
mesh, interior_bottom = _cube_with_bc_patch()
|
| 174 |
-
spec = _base_spec(bc_config="exo_sleeve", exo_q0=EXO_Q0)
|
| 175 |
-
X4, _, _ = _node_features(mesh, version=4, spec=spec)
|
| 176 |
-
idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
|
| 177 |
-
extraction = X4[:, idx["boundary_extraction"]]
|
| 178 |
-
exo = X4[:, idx["exothermic_input"]]
|
| 179 |
-
expected = MOLD_EFFUSIVITY / max(SLEEVE_MEF ** 2, 1.0)
|
| 180 |
-
assert np.allclose(extraction[interior_bottom], expected)
|
| 181 |
-
assert np.isclose(exo[interior_bottom].max(), EXO_Q0 / 1e6, rtol=1e-5)
|
| 182 |
-
# diffused, not sparse: some nodes OUTSIDE the patch are nonzero too.
|
| 183 |
-
outside_patch = ~interior_bottom
|
| 184 |
-
assert (exo[outside_patch] > 0).any()
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def test_exo_channel_is_diffused_field():
|
| 188 |
-
mesh, interior_bottom = _cube_with_bc_patch()
|
| 189 |
-
X, _, _ = _node_features(mesh, version=4, spec=_base_spec("exo_sleeve", exo_q0=EXO_Q0))
|
| 190 |
-
ei = X[:, FEATURE_NAMES_V4.index("exothermic_input")]
|
| 191 |
-
peak = EXO_Q0 / 1e6
|
| 192 |
-
assert np.isclose(ei[interior_bottom].max(), peak, rtol=1e-5) # peak at patch
|
| 193 |
-
far = np.isclose(mesh.node_xyz[:, 2], 20.0) # opposite face
|
| 194 |
-
assert (ei[far] < peak * np.exp(-20.0 / EXO_DIFFUSION_LAMBDA_MM) * 1.5).all() # decayed far away
|
| 195 |
-
assert (ei[far] > 0).any() # but still nonzero (diffused)
|
| 196 |
-
mid = np.isclose(mesh.node_xyz[:, 2], 10.0)
|
| 197 |
-
assert ei[mid].max() < ei[interior_bottom].max() # monotone trend
|
| 198 |
-
assert ei[mid].max() > ei[far].max()
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
def test_exo_channel_zero_for_non_exo():
|
| 202 |
-
mesh, _ = _cube_with_bc_patch()
|
| 203 |
-
for bc in ("none", "chills", "insulating_sleeve"):
|
| 204 |
-
X, _, _ = _node_features(mesh, version=4, spec=_base_spec(bc))
|
| 205 |
-
assert (X[:, FEATURE_NAMES_V4.index("exothermic_input")] == 0).all()
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
def test_v4_sleeve_mef_floor_at_one():
|
| 209 |
-
# max(MEF**2, 1) guards MEF < 1 from amplifying extraction above baseline
|
| 210 |
-
mesh, interior_bottom = _cube_with_bc_patch()
|
| 211 |
-
spec = _base_spec(bc_config="insulating_sleeve", exo_q0=0.0)
|
| 212 |
-
spec["sleeve_MEF"] = 0.5
|
| 213 |
-
X4, _, _ = _node_features(mesh, version=4, spec=spec)
|
| 214 |
-
idx = {n: i for i, n in enumerate(FEATURE_NAMES_V4)}
|
| 215 |
-
extraction = X4[:, idx["boundary_extraction"]]
|
| 216 |
-
assert np.allclose(extraction[interior_bottom], MOLD_EFFUSIVITY) # divided by 1, not 0.25
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
# --------------------------------------------------------------------- #
|
| 220 |
-
# alloy_g_vector
|
| 221 |
-
# --------------------------------------------------------------------- #
|
| 222 |
-
def test_alloy_g_vector_hand_computed():
|
| 223 |
-
props = {
|
| 224 |
-
"k": 35.0, "rho": 7100.0, "cp": 500.0, "L_kJ_kg": 220.0,
|
| 225 |
-
"TL": 1180.0, "TS": 1140.0, "Tpour": 1380.0,
|
| 226 |
-
"mold_effusivity": MOLD_EFFUSIVITY,
|
| 227 |
-
}
|
| 228 |
-
g = alloy_g_vector(props)
|
| 229 |
-
assert g.shape == (5,) and g.dtype == np.float64
|
| 230 |
-
expected_Ste = 500.0 * (1380.0 - 1140.0) / (220.0 * 1000.0)
|
| 231 |
-
expected_dTf_norm = (1180.0 - 1140.0) / T_REF
|
| 232 |
-
expected_alpha_ratio = (35.0 / (7100.0 * 500.0)) / ALPHA_REF
|
| 233 |
-
expected_superheat_norm = (1380.0 - 1180.0) / T_REF
|
| 234 |
-
expected_mold_eff_norm = MOLD_EFFUSIVITY / MOLD_EFF_REF
|
| 235 |
-
np.testing.assert_allclose(
|
| 236 |
-
g, [expected_Ste, expected_dTf_norm, expected_alpha_ratio,
|
| 237 |
-
expected_superheat_norm, expected_mold_eff_norm])
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
# --------------------------------------------------------------------- #
|
| 241 |
-
# chvorinov_t_ref
|
| 242 |
-
# --------------------------------------------------------------------- #
|
| 243 |
-
def test_chvorinov_t_ref_monotonic_in_modulus_squared():
|
| 244 |
-
C = 6.5e5
|
| 245 |
-
area = 100.0
|
| 246 |
-
t_small = chvorinov_t_ref(volume_mm3=500.0, area_mm2=area, C_material=C)
|
| 247 |
-
t_large = chvorinov_t_ref(volume_mm3=2000.0, area_mm2=area, C_material=C)
|
| 248 |
-
assert t_large > t_small
|
| 249 |
-
# exact formula
|
| 250 |
-
assert np.isclose(t_small, C * (500.0 / area) ** 2)
|
| 251 |
-
assert np.isclose(t_large, C * (2000.0 / area) ** 2)
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
# --------------------------------------------------------------------- #
|
| 255 |
-
# compute_features(version=4): g / t_ref / y_nondim
|
| 256 |
-
# --------------------------------------------------------------------- #
|
| 257 |
-
def test_compute_features_v4_adds_g_tref_ynondim(mini_msh_file):
|
| 258 |
-
mesh = parse_msh(mini_msh_file)
|
| 259 |
-
N = mesh.node_xyz.shape[0]
|
| 260 |
-
times = np.array([0.0, 10.0]); temps = np.full((2, N), 1200.0); temps[1] = 1100.0
|
| 261 |
-
spec = _base_spec(bc_config="none", exo_q0=0.0)
|
| 262 |
-
out = compute_features(mesh, temps, times, version=4, spec=spec)
|
| 263 |
-
assert out["X"].shape == (N, 15)
|
| 264 |
-
assert "y" in out and "g" in out and "t_ref" in out and "y_nondim" in out
|
| 265 |
-
assert out["g"].shape == (5,) and out["g"].dtype == np.float64
|
| 266 |
-
expected_g = alloy_g_vector(spec)
|
| 267 |
-
np.testing.assert_allclose(out["g"], expected_g)
|
| 268 |
-
|
| 269 |
-
volume = out["X"][0, FEATURE_NAMES.index("volume_mm3")]
|
| 270 |
-
area = out["X"][0, FEATURE_NAMES.index("surface_area_mm2")]
|
| 271 |
-
expected_t_ref = chvorinov_t_ref(volume, area, spec["C_material"])
|
| 272 |
-
assert np.isclose(out["t_ref"], expected_t_ref)
|
| 273 |
-
|
| 274 |
-
expected_y_nondim = out["y"] / max(expected_t_ref, 1e-9)
|
| 275 |
-
np.testing.assert_allclose(out["y_nondim"], expected_y_nondim, rtol=1e-5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_film.py
DELETED
|
@@ -1,46 +0,0 @@
|
|
| 1 |
-
import torch
|
| 2 |
-
from src.model_gnn import FiLM, SolidGNN
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
def test_film_zero_init_is_identity():
|
| 6 |
-
f = FiLM(g_dim=5, hidden=16)
|
| 7 |
-
h = torch.randn(7, 16); g = torch.randn(7, 5)
|
| 8 |
-
assert torch.allclose(f(h, g), h, atol=1e-6)
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def test_solidgnn_film_zero_init_matches_no_film():
|
| 12 |
-
"""SolidGNN(film=True) with zero-init FiLM params must reproduce
|
| 13 |
-
SolidGNN(film=False) output exactly, given identical base weights,
|
| 14 |
-
identical inputs, a real batch vector, and a real (non-None) g tensor."""
|
| 15 |
-
N, E, B = 20, 120, 3
|
| 16 |
-
x = torch.randn(N, 9)
|
| 17 |
-
edge_index = torch.randint(0, N, (2, E))
|
| 18 |
-
edge_attr = torch.randn(E, 4)
|
| 19 |
-
batch = torch.tensor([0] * 7 + [1] * 7 + [2] * 6, dtype=torch.long)
|
| 20 |
-
g = torch.randn(B, 5)
|
| 21 |
-
|
| 22 |
-
torch.manual_seed(42)
|
| 23 |
-
base = SolidGNN(n_blocks=3, hidden=16, film=False)
|
| 24 |
-
|
| 25 |
-
torch.manual_seed(42)
|
| 26 |
-
filmed = SolidGNN(n_blocks=3, hidden=16, film=True, g_dim=5)
|
| 27 |
-
# base weights (encoder/blocks/decoder) are seeded identically; FiLM layers
|
| 28 |
-
# are zero-init by construction (asserted separately in the test above), so
|
| 29 |
-
# filmed's non-FiLM parameters must match base's exactly.
|
| 30 |
-
for (n1, p1), (n2, p2) in zip(base.named_parameters(), filmed.named_parameters()):
|
| 31 |
-
assert n1 == n2
|
| 32 |
-
torch.testing.assert_close(p1, p2)
|
| 33 |
-
|
| 34 |
-
base.eval(); filmed.eval()
|
| 35 |
-
with torch.no_grad():
|
| 36 |
-
yn_base, yg_base = base(x, edge_index, edge_attr, batch)
|
| 37 |
-
yn_film, yg_film = filmed(x, edge_index, edge_attr, batch, g=g)
|
| 38 |
-
|
| 39 |
-
torch.testing.assert_close(yn_film, yn_base, atol=1e-6, rtol=1e-6)
|
| 40 |
-
torch.testing.assert_close(yg_film, yg_base, atol=1e-6, rtol=1e-6)
|
| 41 |
-
|
| 42 |
-
# also check g=None on the filmed model is identical (old positional call still works)
|
| 43 |
-
with torch.no_grad():
|
| 44 |
-
yn_film_noG, yg_film_noG = filmed(x, edge_index, edge_attr, batch)
|
| 45 |
-
torch.testing.assert_close(yn_film_noG, yn_base, atol=1e-6, rtol=1e-6)
|
| 46 |
-
torch.testing.assert_close(yg_film_noG, yg_base, atol=1e-6, rtol=1e-6)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_gbm_heads.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
from src import gbm_heads
|
| 5 |
+
from src.features import FEATURE_NAMES
|
| 6 |
+
|
| 7 |
+
PROPS = {"k": 32.9, "rho": 6800.0, "cp": 515.0, "L_kJ_kg": 220.0,
|
| 8 |
+
"TL": 1160.0, "TS": 1120.0, "Tpour": 1260.0, "mold_effusivity": 1018.0}
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _X(n=10, vol=8000.0, area=2400.0, n_adiab=0):
|
| 12 |
+
X = np.zeros((n, len(FEATURE_NAMES)), np.float32)
|
| 13 |
+
X[:, FEATURE_NAMES.index("volume_mm3")] = vol
|
| 14 |
+
X[:, FEATURE_NAMES.index("surface_area_mm2")] = area
|
| 15 |
+
X[:6, FEATURE_NAMES.index("is_cooling")] = 1
|
| 16 |
+
X[6:6 + n_adiab, FEATURE_NAMES.index("is_adiabatic")] = 1
|
| 17 |
+
return X
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_total_row_layout():
|
| 21 |
+
row = gbm_heads.total_row(PROPS, _X())
|
| 22 |
+
assert row.shape == (gbm_heads.N_TOTAL_FEATURES,)
|
| 23 |
+
assert row[:7].tolist() == [32.9, 6800.0, 515.0, 220.0, 1160.0, 1120.0, 1260.0]
|
| 24 |
+
assert row[7] == pytest.approx(8000.0 / 2400.0)
|
| 25 |
+
assert row[8] == 1018.0
|
| 26 |
+
assert row[9:].tolist() == [1, 0, 0, 0, 0, 0, 0]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_spread_row_appends_adiabatic_fraction():
|
| 30 |
+
X = _X(n_adiab=2)
|
| 31 |
+
row = gbm_heads.spread_row(gbm_heads.total_row(PROPS, X), X)
|
| 32 |
+
assert row.shape == (gbm_heads.N_SPREAD_FEATURES,)
|
| 33 |
+
assert row[-1] == pytest.approx(2 / 8)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class _Stub:
|
| 37 |
+
def __init__(self, value, n):
|
| 38 |
+
self.value, self.n_features_in_ = value, n
|
| 39 |
+
|
| 40 |
+
def predict(self, x):
|
| 41 |
+
return np.full(len(x), self.value)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_predictions_are_expm1_of_log_output():
|
| 45 |
+
row = np.zeros(16)
|
| 46 |
+
assert gbm_heads.predict_total_seconds(_Stub(np.log1p(500.0), 16), row) == pytest.approx(500.0)
|
| 47 |
+
assert gbm_heads.predict_spread_seconds(_Stub(-5.0, 17), np.zeros(17)) == 0.0
|
| 48 |
+
with pytest.raises(ValueError):
|
| 49 |
+
gbm_heads.predict_total_seconds(_Stub(-1.0, 16), row)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_check_head_rejects_wrong_width():
|
| 53 |
+
gbm_heads.check_head(_Stub(0.0, 16), 16, "total-time")
|
| 54 |
+
with pytest.raises(ValueError):
|
| 55 |
+
gbm_heads.check_head(_Stub(0.0, 9), 16, "total-time")
|
tests/test_geometry.py
CHANGED
|
@@ -1,9 +1,6 @@
|
|
| 1 |
import numpy as np
|
| 2 |
-
import pytest
|
| 3 |
-
from unittest.mock import patch
|
| 4 |
from src.geometry import (triangle_areas, total_area, mesh_volume,
|
| 5 |
-
nearest_distance, boundary_node_mask
|
| 6 |
-
geodesic_diameter_two_sweep)
|
| 7 |
|
| 8 |
|
| 9 |
def test_triangle_area_right_triangle():
|
|
@@ -37,68 +34,3 @@ def test_boundary_node_mask():
|
|
| 37 |
tri = np.array([[0, 1, 2], [1, 2, 3]])
|
| 38 |
m = boundary_node_mask(5, tri)
|
| 39 |
np.testing.assert_array_equal(m, [True, True, True, True, False])
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
# --------------------------------------------------------------------- #
|
| 43 |
-
# geodesic_diameter_two_sweep (bc-reach re-review N1): direct unit tests.
|
| 44 |
-
# The bc-reach features-v5 tests only ever use this helper as an
|
| 45 |
-
# expected-value oracle on real/synthetic meshes -- a "d2 = d1" single-
|
| 46 |
-
# sweep mutant (skip the second Dijkstra entirely, just report sweep-1's
|
| 47 |
-
# own max) passes all of them, because none of those fixtures put the
|
| 48 |
-
# fixed `start=0` at a graph MIDPOINT where a single sweep provably can't
|
| 49 |
-
# see the true diameter. These tests target that property directly.
|
| 50 |
-
# --------------------------------------------------------------------- #
|
| 51 |
-
def _mid_start_path_graph(edge_len_mm=10.0):
|
| 52 |
-
"""A 5-node path graph -- the chain order (by position) is
|
| 53 |
-
node1-node2-node0-node3-node4 -- where node INDEX 0 (this function's
|
| 54 |
-
default `start`) sits at the graph's MIDDLE, not at either end. True
|
| 55 |
-
diameter (node1 <-> node4, both ends) = 4 hops = 4*edge_len_mm. A
|
| 56 |
-
single Dijkstra sweep from node0 only reaches 2 hops in either
|
| 57 |
-
direction (both ends are tied at that distance) -- it structurally
|
| 58 |
-
cannot see the two ends' mutual distance -- so a "d2 = d1" mutant
|
| 59 |
-
(reporting sweep-1's own max instead of re-sweeping from sweep-1's
|
| 60 |
-
farthest node) returns 2*edge_len_mm here: HALF the true diameter."""
|
| 61 |
-
edges = np.array([[1, 2], [2, 0], [0, 3], [3, 4]], dtype=np.int64)
|
| 62 |
-
edge_len = np.full(4, edge_len_mm, dtype=float)
|
| 63 |
-
return 5, edges, edge_len
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def test_geodesic_diameter_two_sweep_mid_start_finds_true_diameter():
|
| 67 |
-
n_nodes, edges, edge_len = _mid_start_path_graph(edge_len_mm=10.0)
|
| 68 |
-
d_geo = geodesic_diameter_two_sweep(n_nodes, edges, edge_len) # default start=0
|
| 69 |
-
# 4 hops * 10mm = 40.0 -- a single-sweep-from-the-middle mutant would
|
| 70 |
-
# instead report 20.0 (2 hops, the farthest EITHER end appears from
|
| 71 |
-
# the midpoint), so this assertion fails under that mutant.
|
| 72 |
-
assert d_geo == pytest.approx(40.0)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def test_geodesic_diameter_two_sweep_edgeless_returns_zero():
|
| 76 |
-
edges = np.empty((0, 2), dtype=np.int64)
|
| 77 |
-
edge_len = np.empty(0, dtype=float)
|
| 78 |
-
assert geodesic_diameter_two_sweep(5, edges, edge_len) == 0.0
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def test_geodesic_diameter_two_sweep_zero_nodes_returns_zero():
|
| 82 |
-
edges = np.empty((0, 2), dtype=np.int64)
|
| 83 |
-
edge_len = np.empty(0, dtype=float)
|
| 84 |
-
assert geodesic_diameter_two_sweep(0, edges, edge_len) == 0.0
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def test_geodesic_diameter_two_sweep_no_finite_distances_returns_zero():
|
| 88 |
-
# The documented "no finite distances" guard is unreachable through any
|
| 89 |
-
# REAL Dijkstra call (a node's distance to itself is always 0, so
|
| 90 |
-
# sweep-1 always has at least one finite entry) -- exercised directly
|
| 91 |
-
# here by forcing the underlying primitive to report all-inf, to prove
|
| 92 |
-
# the guard degrades to 0.0 instead of crashing (e.g. np.argmax over an
|
| 93 |
-
# all-False finite mask) if that invariant is ever violated.
|
| 94 |
-
n_nodes, edges, edge_len = _mid_start_path_graph()
|
| 95 |
-
with patch("src.geometry.geodesic_distance",
|
| 96 |
-
return_value=np.full(n_nodes, np.inf)):
|
| 97 |
-
assert geodesic_diameter_two_sweep(n_nodes, edges, edge_len) == 0.0
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def test_geodesic_diameter_two_sweep_deterministic():
|
| 101 |
-
n_nodes, edges, edge_len = _mid_start_path_graph()
|
| 102 |
-
d_a = geodesic_diameter_two_sweep(n_nodes, edges, edge_len, start=0)
|
| 103 |
-
d_b = geodesic_diameter_two_sweep(n_nodes, edges, edge_len, start=0)
|
| 104 |
-
assert d_a == d_b # bit-equal: deterministic, fixed start, no RNG
|
|
|
|
| 1 |
import numpy as np
|
|
|
|
|
|
|
| 2 |
from src.geometry import (triangle_areas, total_area, mesh_volume,
|
| 3 |
+
nearest_distance, boundary_node_mask)
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
def test_triangle_area_right_triangle():
|
|
|
|
| 34 |
tri = np.array([[0, 1, 2], [1, 2, 3]])
|
| 35 |
m = boundary_node_mask(5, tri)
|
| 36 |
np.testing.assert_array_equal(m, [True, True, True, True, False])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_graph_build.py
DELETED
|
@@ -1,52 +0,0 @@
|
|
| 1 |
-
import torch
|
| 2 |
-
from src.graph_build import knn_edge_index, edge_attr, random_rotation_matrix, rotate
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
def test_knn_symmetric_no_selfloops():
|
| 6 |
-
pos = torch.tensor([[0., 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]])
|
| 7 |
-
ei = knn_edge_index(pos, k=1)
|
| 8 |
-
assert ei.shape[0] == 2
|
| 9 |
-
assert (ei[0] != ei[1]).all() # no self-loops
|
| 10 |
-
pairs = {(int(a), int(b)) for a, b in ei.t()}
|
| 11 |
-
assert all((b, a) in pairs for a, b in pairs) # symmetric
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
def test_edge_attr_mm():
|
| 15 |
-
pos = torch.tensor([[0., 0, 0], [3, 4, 0]])
|
| 16 |
-
ei = torch.tensor([[0], [1]])
|
| 17 |
-
ea = edge_attr(pos, ei)
|
| 18 |
-
torch.testing.assert_close(ea[0], torch.tensor([3., 4., 0., 5.])) # dst-src + norm
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def test_rotation_is_orthonormal_and_preserves_distances():
|
| 22 |
-
R = random_rotation_matrix(seed=0)
|
| 23 |
-
torch.testing.assert_close(R @ R.t(), torch.eye(3), atol=1e-5, rtol=1e-5)
|
| 24 |
-
pos = torch.randn(10, 3)
|
| 25 |
-
rp = rotate(pos, R)
|
| 26 |
-
d0 = torch.cdist(pos, pos); d1 = torch.cdist(rp, rp)
|
| 27 |
-
torch.testing.assert_close(d0, d1, atol=1e-4, rtol=1e-4) # rotation preserves distances
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def test_union_contains_mesh_edges_flagged():
|
| 31 |
-
from src.graph_build import union_edge_index, union_edge_attr
|
| 32 |
-
pos = torch.tensor([[0., 0, 0], [1, 0, 0], [2, 0, 0], [10, 10, 10]])
|
| 33 |
-
me = torch.tensor([[0, 3]]) # pair kNN(k=1) would never pick
|
| 34 |
-
ei, ism = union_edge_index(pos, me, k=1)
|
| 35 |
-
pairs = {(int(a), int(b)): float(f) for (a, b), f in zip(ei.t(), ism)}
|
| 36 |
-
assert pairs[(0, 3)] == 1.0 and pairs[(3, 0)] == 1.0 # both directions, flagged
|
| 37 |
-
assert all(f == 0.0 for p, f in pairs.items() if p not in {(0, 3), (3, 0)})
|
| 38 |
-
key = ei[0] * pos.shape[0] + ei[1]
|
| 39 |
-
assert len(torch.unique(key)) == ei.shape[1] # no duplicates
|
| 40 |
-
assert all((b, a) in pairs for a, b in pairs) # symmetric
|
| 41 |
-
ea = union_edge_attr(pos, ei, ism)
|
| 42 |
-
assert ea.shape == (ei.shape[1], 5)
|
| 43 |
-
torch.testing.assert_close(ea[:, 4], ism)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def test_union_dedups_overlap():
|
| 47 |
-
from src.graph_build import union_edge_index
|
| 48 |
-
pos = torch.tensor([[0., 0, 0], [1, 0, 0]])
|
| 49 |
-
me = torch.tensor([[0, 1]]) # same pair kNN also finds
|
| 50 |
-
ei, ism = union_edge_index(pos, me, k=1)
|
| 51 |
-
assert ei.shape[1] == 2 # (0,1) and (1,0), once each
|
| 52 |
-
assert (ism == 1.0).all() # mesh flag wins on overlap
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_hybrid.py
CHANGED
|
@@ -1,34 +1,14 @@
|
|
| 1 |
import logging
|
| 2 |
-
import os
|
| 3 |
|
| 4 |
import numpy as np
|
| 5 |
import pytest
|
| 6 |
-
from src.hybrid import
|
| 7 |
-
hybrid_field_soft)
|
| 8 |
|
| 9 |
|
| 10 |
-
def test_hybrid_takes_shape_from_gnn_level_from_baseline():
|
| 11 |
-
# GNN shape [-1,0,+1] around mean 5; baseline mean 20 (shape irrelevant)
|
| 12 |
-
gnn = np.array([4.0, 5.0, 6.0]) # deviations -1,0,+1
|
| 13 |
-
base = np.array([18.0, 22.0, 20.0]) # mean 20
|
| 14 |
-
out = hybrid_field(gnn, base)
|
| 15 |
-
# deviations preserved from GNN, level set to baseline mean (20)
|
| 16 |
-
np.testing.assert_allclose(out, [19.0, 20.0, 21.0])
|
| 17 |
|
| 18 |
|
| 19 |
-
def test_hybrid_clips_negative():
|
| 20 |
-
gnn = np.array([0.0, 10.0]) # mean 5, devs -5,+5
|
| 21 |
-
base = np.array([1.0, 1.0]) # mean 1 -> level 1: -4, +6 -> clip -4 to 0
|
| 22 |
-
out = hybrid_field(gnn, base)
|
| 23 |
-
np.testing.assert_allclose(out, [0.0, 6.0])
|
| 24 |
|
| 25 |
|
| 26 |
-
def test_hybrid_preserves_within_graph_ranking():
|
| 27 |
-
# hot-spot ranking (argsort) must come from the GNN shape, unchanged by re-leveling
|
| 28 |
-
gnn = np.array([3.0, 1.0, 9.0, 5.0])
|
| 29 |
-
base = np.array([100.0, 100.0, 100.0, 100.0])
|
| 30 |
-
out = hybrid_field(gnn, base)
|
| 31 |
-
assert list(np.argsort(out)) == list(np.argsort(gnn))
|
| 32 |
|
| 33 |
|
| 34 |
def test_hybrid_affine_spread_match():
|
|
@@ -59,9 +39,8 @@ def test_hybrid_affine_preserves_ranking():
|
|
| 59 |
|
| 60 |
def test_hybrid_affine_clamp_high():
|
| 61 |
# gnn std is 10; std_seconds=100 -> a_raw=10.0, clamped down to the a_clamp
|
| 62 |
-
# ceiling
|
| 63 |
-
#
|
| 64 |
-
# (pre-amendment closed-form) path's own clamp handling.
|
| 65 |
gnn = np.array([0.0, 20.0])
|
| 66 |
total = 1000.0
|
| 67 |
pred, info = hybrid_field_affine(gnn, total, std_seconds=100.0)
|
|
@@ -96,8 +75,8 @@ def test_hybrid_affine_clip_negative():
|
|
| 96 |
|
| 97 |
|
| 98 |
def test_hybrid_affine_heavy_clip_achievable():
|
| 99 |
-
# Real fields
|
| 100 |
-
#
|
| 101 |
# entirely, so its implied `a` can leave the delivered std far short of
|
| 102 |
# the target. Construct such a case (right-skewed field; the naive
|
| 103 |
# closed-form `a` clips ~30% of nodes) and confirm the clip-aware solve
|
|
@@ -123,8 +102,7 @@ def test_hybrid_affine_heavy_clip_achievable():
|
|
| 123 |
def test_hybrid_affine_unachievable_due_to_clipping():
|
| 124 |
# std_seconds=8.0 exceeds this field's interior PEAK (f rises past
|
| 125 |
# a_clipstart to a peak around a~1.78, std~3.82, then falls -- it never
|
| 126 |
-
# reaches 8.0 anywhere in the clamp range).
|
| 127 |
-
# correction, the unreachable case must pick the scanned point CLOSEST to
|
| 128 |
# the target (here, at/near the peak) rather than blindly clamping to
|
| 129 |
# a_clamp[1]=5.0 -- clamping to 5.0 would give a worse fit (std~3.45,
|
| 130 |
# clip_frac 0.7) than the peak (std~3.82, clip_frac 0.4).
|
|
@@ -142,30 +120,6 @@ def test_hybrid_affine_unachievable_due_to_clipping():
|
|
| 142 |
assert abs(achieved - 8.0) < abs(np.std(at_hi) - 8.0)
|
| 143 |
|
| 144 |
|
| 145 |
-
_A_00968 = "data/features_v4/graphs/A_00968.npz"
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
@pytest.mark.skipif(not os.path.exists(_A_00968),
|
| 149 |
-
reason="requires the FT2a v4 feature cache "
|
| 150 |
-
"(data/features_v4/graphs/A_00968.npz) on disk")
|
| 151 |
-
def test_hybrid_affine_real_field_returns_smallest_root_not_ceiling():
|
| 152 |
-
# Regression for the 2026-07-31 review finding: on a REAL per-node `y`
|
| 153 |
-
# field, f(a) has an interior peak (~a=2.67, std~420.5) then falls
|
| 154 |
-
# (f(5.0)~390.8). A naive `f(hi) < std_target => clamp` pre-check would
|
| 155 |
-
# wrongly land on a=5.0 (clip_frac~0.48) even though a much-less-clipped
|
| 156 |
-
# true root exists near the peak's rising edge (a~2.6, clip_frac~0.07).
|
| 157 |
-
# The clip-aware solve must find that smaller root, not the ceiling.
|
| 158 |
-
data = np.load(_A_00968)
|
| 159 |
-
y = data["y"].astype(np.float64)
|
| 160 |
-
total = 1000.0
|
| 161 |
-
target = 419.0 # inside the narrow (f(a_clipstart), peak_f) crossing window
|
| 162 |
-
|
| 163 |
-
pred, info = hybrid_field_affine(y, total, target)
|
| 164 |
-
|
| 165 |
-
assert info["a_clamped"] is False
|
| 166 |
-
assert info["a"] != pytest.approx(5.0)
|
| 167 |
-
assert info["clip_frac"] < 0.3
|
| 168 |
-
assert np.std(pred) == pytest.approx(target, rel=1e-3)
|
| 169 |
|
| 170 |
|
| 171 |
def test_hybrid_affine_no_clip_matches_pre_amendment_closed_form():
|
|
@@ -193,87 +147,18 @@ def test_hybrid_affine_no_clip_matches_pre_amendment_closed_form():
|
|
| 193 |
np.testing.assert_allclose(pred_new, pred_old, rtol=1e-6)
|
| 194 |
|
| 195 |
|
| 196 |
-
# --------------------------------------------------------------------------- #
|
| 197 |
-
# AMENDMENT 2: lambda-shrinkage of the affine spread target (2026-07-31)
|
| 198 |
-
# --------------------------------------------------------------------------- #
|
| 199 |
-
def test_hybrid_affine_lambda_default_bit_identical_on_clip_heavy_case():
|
| 200 |
-
# lambda_shrink=1.0 (the default) must be byte-for-bit identical to
|
| 201 |
-
# calling hybrid_field_affine with no lambda_shrink argument at all --
|
| 202 |
-
# on a CLIP-HEAVY case (the heavy-clip-achievable field from round 1),
|
| 203 |
-
# to prove the guard skips the new code path even when the solve
|
| 204 |
-
# actually clips (not just in a trivial no-clip case).
|
| 205 |
-
g = np.random.default_rng(1).exponential(5.0, size=200)
|
| 206 |
-
total = 8.0
|
| 207 |
-
target = 0.3 * np.std(g)
|
| 208 |
|
| 209 |
-
pred_default, info_default = hybrid_field_affine(g, total, target)
|
| 210 |
-
pred_lambda1, info_lambda1 = hybrid_field_affine(g, total, target, lambda_shrink=1.0)
|
| 211 |
|
| 212 |
-
assert np.array_equal(pred_default, pred_lambda1)
|
| 213 |
-
assert info_default["a"] == info_lambda1["a"]
|
| 214 |
-
assert info_default["clip_frac"] == info_lambda1["clip_frac"]
|
| 215 |
-
assert info_lambda1["lambda_shrink"] == 1.0
|
| 216 |
-
assert info_lambda1["std_target_effective"] == pytest.approx(target, rel=1e-12)
|
| 217 |
|
| 218 |
|
| 219 |
-
def test_hybrid_affine_lambda_zero_targets_gnn_own_spread():
|
| 220 |
-
# lambda_shrink=0.0 -> std_eff = std(gnn)**1 * std_target**0 = std(gnn)
|
| 221 |
-
# exactly, regardless of std_target's value. In a no-clip case this
|
| 222 |
-
# makes a==1.0 (no spread rescaling at all), so the result must equal
|
| 223 |
-
# hybrid_field_max_anchor's pure quantile-anchor level shift exactly.
|
| 224 |
-
gnn = np.array([3.0, 1.0, 9.0, 5.0])
|
| 225 |
-
total = 1000.0
|
| 226 |
-
pred, info = hybrid_field_affine(gnn, total, std_seconds=999.0, lambda_shrink=0.0)
|
| 227 |
-
expected = hybrid_field_max_anchor(gnn, total)
|
| 228 |
-
assert info["a"] == pytest.approx(1.0, rel=1e-9)
|
| 229 |
-
assert info["a_clamped"] is False
|
| 230 |
-
assert info["std_target_effective"] == pytest.approx(np.std(gnn), rel=1e-9)
|
| 231 |
-
np.testing.assert_allclose(pred, expected, rtol=1e-9)
|
| 232 |
|
| 233 |
|
| 234 |
-
def test_hybrid_affine_lambda_half_is_geometric_mean_of_stds():
|
| 235 |
-
# lambda_shrink=0.5 -> std_eff = sqrt(std(gnn) * std_target). In a
|
| 236 |
-
# no-clip case the achieved std equals std_eff exactly (fast path).
|
| 237 |
-
gnn = np.array([0.0, 20.0]) # std(gnn) == 10
|
| 238 |
-
total = 1000.0 # large enough that nothing clips
|
| 239 |
-
std_target = 20.0
|
| 240 |
-
s = np.std(gnn)
|
| 241 |
|
| 242 |
-
pred, info = hybrid_field_affine(gnn, total, std_target, lambda_shrink=0.5)
|
| 243 |
|
| 244 |
-
expected_std_eff = np.sqrt(s * std_target)
|
| 245 |
-
assert info["std_target_effective"] == pytest.approx(expected_std_eff, rel=1e-9)
|
| 246 |
-
assert info["a_clamped"] is False
|
| 247 |
-
assert np.std(pred) == pytest.approx(expected_std_eff, rel=1e-9)
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
def test_hybrid_affine_lambda_info_carries_new_keys():
|
| 251 |
-
gnn = np.array([3.0, 1.0, 9.0, 5.0])
|
| 252 |
-
pred, info = hybrid_field_affine(gnn, 1000.0, 5.0, lambda_shrink=0.7)
|
| 253 |
-
assert "lambda_shrink" in info
|
| 254 |
-
assert "std_target_effective" in info
|
| 255 |
-
assert info["lambda_shrink"] == pytest.approx(0.7)
|
| 256 |
-
assert isinstance(info["std_target_effective"], float)
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
def test_hybrid_affine_lambda_nondefault_degenerate_std_target_falls_back(caplog):
|
| 260 |
-
# std_seconds <= 0 is only inspected when lambda_shrink != 1.0 (the
|
| 261 |
-
# default path never checks its sign) -- with a non-default lambda it
|
| 262 |
-
# must fall back to the quantile-anchor with the same loud-warning
|
| 263 |
-
# pattern as the degenerate-GNN-field guard.
|
| 264 |
-
gnn = np.array([3.0, 1.0, 9.0, 5.0])
|
| 265 |
-
total = 1000.0
|
| 266 |
-
with caplog.at_level(logging.WARNING):
|
| 267 |
-
pred, info = hybrid_field_affine(gnn, total, std_seconds=0.0, lambda_shrink=0.5)
|
| 268 |
-
expected = hybrid_field_max_anchor(gnn, total)
|
| 269 |
-
np.testing.assert_allclose(pred, expected)
|
| 270 |
-
assert info["degenerate_fallback"] is True
|
| 271 |
-
assert info["lambda_shrink"] == 0.5
|
| 272 |
-
assert any("degenerate" in rec.message.lower() for rec in caplog.records)
|
| 273 |
|
| 274 |
|
| 275 |
# --------------------------------------------------------------------------- #
|
| 276 |
-
# hybrid_field_soft: weighted two-target soft recomposition
|
| 277 |
# --------------------------------------------------------------------------- #
|
| 278 |
def _squeezed_case():
|
| 279 |
# A field with a small (3%) group of low-value outlier nodes and a tight
|
|
@@ -349,77 +234,20 @@ def test_hybrid_soft_degenerate_flat_field_falls_back(caplog):
|
|
| 349 |
assert any("degenerate" in rec.message.lower() for rec in caplog.records)
|
| 350 |
|
| 351 |
|
| 352 |
-
def test_hybrid_soft_info_dict_carries_all_keys():
|
| 353 |
-
g, total, target = _squeezed_case()
|
| 354 |
-
pred, info = hybrid_field_soft(g, total, target)
|
| 355 |
-
expected_keys = {"a", "m", "target_unreachable", "degenerate_fallback", "clip_frac",
|
| 356 |
-
"total_deviation_frac", "j_total_resid", "j_std_resid",
|
| 357 |
-
"lambda_shrink", "std_target_effective"}
|
| 358 |
-
assert expected_keys <= set(info.keys())
|
| 359 |
-
# clip_frac must be measured on the FINAL (returned) field.
|
| 360 |
-
a, m = info["a"], info["m"]
|
| 361 |
-
c = g - g.mean()
|
| 362 |
-
final_raw = a * c + m
|
| 363 |
-
assert info["clip_frac"] == pytest.approx(float((final_raw < 0.0).mean()))
|
| 364 |
|
| 365 |
|
| 366 |
-
def test_hybrid_soft_lambda_shrink_matches_v5_convention():
|
| 367 |
-
gnn = np.array([0.0, 20.0])
|
| 368 |
-
total = 1000.0
|
| 369 |
-
std_seconds = 20.0
|
| 370 |
-
s = np.std(gnn)
|
| 371 |
-
pred, info = hybrid_field_soft(gnn, total, std_seconds, lambda_shrink=0.5)
|
| 372 |
-
expected = (s ** 0.5) * (std_seconds ** 0.5)
|
| 373 |
-
assert info["std_target_effective"] == pytest.approx(expected, rel=1e-9)
|
| 374 |
|
| 375 |
|
| 376 |
# --------------------------------------------------------------------------- #
|
| 377 |
-
#
|
| 378 |
-
#
|
| 379 |
-
# (a_clamp[0]<=0 guard), LOW-7 (realistic-scale degeneration case).
|
| 380 |
# --------------------------------------------------------------------------- #
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
@pytest.mark.parametrize("path", _REAL_FIELD_PATHS)
|
| 386 |
-
def test_hybrid_soft_engages_with_interior_optimum_on_real_fields(path):
|
| 387 |
-
# Reviewer-mandated re-verification for CRITICAL-1: on real per-node `y`
|
| 388 |
-
# fields with std_t just past the affine solve's own reach (1.1-1.5x
|
| 389 |
-
# std(g)), the soft solve at DEFAULT weights must engage (not just return
|
| 390 |
-
# the seed) with an INTERIOR `a` (strictly inside a_clamp, not pinned to
|
| 391 |
-
# either boundary) and a single-digit-percent total deviation -- verified
|
| 392 |
-
# interactively before encoding: e.g. A_00968 at 1.3x gives a~1.28 (vs
|
| 393 |
-
# a_clamp (0.3, 5.0)) and total_deviation_frac ~3.8%.
|
| 394 |
-
if not os.path.exists(path):
|
| 395 |
-
pytest.skip(f"requires the FT2a v4 feature cache ({path}) on disk")
|
| 396 |
-
data = np.load(path)
|
| 397 |
-
y = data["y"].astype(np.float64)
|
| 398 |
-
s = float(np.std(y))
|
| 399 |
-
total = float(np.quantile(y, 0.995))
|
| 400 |
-
|
| 401 |
-
engaged = False
|
| 402 |
-
for mult in (1.1, 1.2, 1.3, 1.4, 1.5):
|
| 403 |
-
target = mult * s
|
| 404 |
-
pred_affine, info_affine = hybrid_field_affine(y, total, target)
|
| 405 |
-
if not info_affine["a_clamped"]:
|
| 406 |
-
continue # affine already reaches this target -- nothing to trade
|
| 407 |
-
engaged = True
|
| 408 |
-
pred_soft, info_soft = hybrid_field_soft(y, total, target)
|
| 409 |
-
assert info_soft["target_unreachable"] is True
|
| 410 |
-
a_clamp = (0.3, 5.0)
|
| 411 |
-
assert a_clamp[0] * 1.001 < info_soft["a"] < a_clamp[1] * 0.999, (
|
| 412 |
-
f"expected an interior optimum, got a={info_soft['a']!r}")
|
| 413 |
-
assert abs(info_soft["total_deviation_frac"]) < 0.10
|
| 414 |
-
# the soft solve must never do WORSE than the affine seed on std:
|
| 415 |
-
d_affine = abs(np.std(pred_affine) - target)
|
| 416 |
-
d_soft = abs(np.std(pred_soft) - target)
|
| 417 |
-
assert d_soft <= d_affine + 1e-9
|
| 418 |
-
assert engaged, f"{path}: no mult in (1.1..1.5) made the affine solve a_clamped=True"
|
| 419 |
|
| 420 |
|
| 421 |
def test_hybrid_soft_realistic_scale_exact_degeneration_matches_affine():
|
| 422 |
-
#
|
| 423 |
# this adds a realistic-scale (n=5000), right-skewed synthetic field with
|
| 424 |
# an EASILY achievable std target (no clipping needed at all), so the
|
| 425 |
# affine seed hits both targets and the soft solve must reduce to it
|
|
@@ -447,7 +275,7 @@ def test_hybrid_soft_a_clamp_lo_nonpositive_raises():
|
|
| 447 |
|
| 448 |
|
| 449 |
def test_hybrid_soft_negative_weight_raises():
|
| 450 |
-
#
|
| 451 |
# target (minimizing J then maximizes the residual's square) -- must
|
| 452 |
# raise loudly rather than silently drive a to a nonsensical optimum.
|
| 453 |
gnn = np.array([1.0, 2.0, 3.0])
|
|
@@ -458,7 +286,7 @@ def test_hybrid_soft_negative_weight_raises():
|
|
| 458 |
|
| 459 |
|
| 460 |
def test_hybrid_soft_both_weights_zero_raises():
|
| 461 |
-
#
|
| 462 |
# everywhere -- no signal for the search to optimize against.
|
| 463 |
gnn = np.array([1.0, 2.0, 3.0])
|
| 464 |
with pytest.raises(ValueError, match="w_total|w_std"):
|
|
@@ -476,7 +304,7 @@ def test_hybrid_soft_one_weight_zero_is_allowed():
|
|
| 476 |
|
| 477 |
|
| 478 |
def test_hybrid_soft_accepted_optimum_majority_clipped_warns(caplog):
|
| 479 |
-
#
|
| 480 |
# soft path must emit the analogous warning when the ACCEPTED soft
|
| 481 |
# optimum (which can differ from the affine seed) itself clips a
|
| 482 |
# majority of the field.
|
|
@@ -492,25 +320,17 @@ def test_hybrid_soft_accepted_optimum_majority_clipped_warns(caplog):
|
|
| 492 |
for rec in caplog.records)
|
| 493 |
|
| 494 |
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
hybrid_field_soft(y, total, target) # warm-up (import/JIT-adjacent costs)
|
| 510 |
-
times = []
|
| 511 |
-
for _ in range(3):
|
| 512 |
-
t0 = time.perf_counter()
|
| 513 |
-
hybrid_field_soft(y, total, target)
|
| 514 |
-
times.append(time.perf_counter() - t0)
|
| 515 |
-
budget_s = 0.200 * (len(y) / 10_000) * 2 # scale budget to n, then 2x slack
|
| 516 |
-
assert min(times) < budget_s, f"{path}: {min(times)*1000:.1f}ms exceeds {budget_s*1000:.1f}ms budget"
|
|
|
|
| 1 |
import logging
|
|
|
|
| 2 |
|
| 3 |
import numpy as np
|
| 4 |
import pytest
|
| 5 |
+
from src.hybrid import hybrid_field_affine, hybrid_field_max_anchor, hybrid_field_soft
|
|
|
|
| 6 |
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def test_hybrid_affine_spread_match():
|
|
|
|
| 39 |
|
| 40 |
def test_hybrid_affine_clamp_high():
|
| 41 |
# gnn std is 10; std_seconds=100 -> a_raw=10.0, clamped down to the a_clamp
|
| 42 |
+
# ceiling 5.0. No clipping occurs at a=5.0 here (total=1000 is large), so
|
| 43 |
+
# this exercises the closed-form path's own clamp handling.
|
|
|
|
| 44 |
gnn = np.array([0.0, 20.0])
|
| 45 |
total = 1000.0
|
| 46 |
pred, info = hybrid_field_affine(gnn, total, std_seconds=100.0)
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
def test_hybrid_affine_heavy_clip_achievable():
|
| 78 |
+
# Real fields sometimes need heavy scaling to hit the spread head's
|
| 79 |
+
# target; a closed form that ignores clipping
|
| 80 |
# entirely, so its implied `a` can leave the delivered std far short of
|
| 81 |
# the target. Construct such a case (right-skewed field; the naive
|
| 82 |
# closed-form `a` clips ~30% of nodes) and confirm the clip-aware solve
|
|
|
|
| 102 |
def test_hybrid_affine_unachievable_due_to_clipping():
|
| 103 |
# std_seconds=8.0 exceeds this field's interior PEAK (f rises past
|
| 104 |
# a_clipstart to a peak around a~1.78, std~3.82, then falls -- it never
|
| 105 |
+
# reaches 8.0 anywhere in the clamp range). The unreachable case must pick the scanned point CLOSEST to
|
|
|
|
| 106 |
# the target (here, at/near the peak) rather than blindly clamping to
|
| 107 |
# a_clamp[1]=5.0 -- clamping to 5.0 would give a worse fit (std~3.45,
|
| 108 |
# clip_frac 0.7) than the peak (std~3.82, clip_frac 0.4).
|
|
|
|
| 120 |
assert abs(achieved - 8.0) < abs(np.std(at_hi) - 8.0)
|
| 121 |
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
|
| 125 |
def test_hybrid_affine_no_clip_matches_pre_amendment_closed_form():
|
|
|
|
| 147 |
np.testing.assert_allclose(pred_new, pred_old, rtol=1e-6)
|
| 148 |
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
|
|
|
|
|
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
|
|
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
|
| 160 |
# --------------------------------------------------------------------------- #
|
| 161 |
+
# hybrid_field_soft: weighted two-target soft recomposition
|
| 162 |
# --------------------------------------------------------------------------- #
|
| 163 |
def _squeezed_case():
|
| 164 |
# A field with a small (3%) group of low-value outlier nodes and a tight
|
|
|
|
| 234 |
assert any("degenerate" in rec.message.lower() for rec in caplog.records)
|
| 235 |
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
|
| 241 |
# --------------------------------------------------------------------------- #
|
| 242 |
+
# Edge cases: the a_clamp[0] <= 0 guard and degeneration to the affine solve
|
| 243 |
+
# at a realistic scale.
|
|
|
|
| 244 |
# --------------------------------------------------------------------------- #
|
| 245 |
+
|
| 246 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
|
| 249 |
def test_hybrid_soft_realistic_scale_exact_degeneration_matches_affine():
|
| 250 |
+
# The exact-degeneration test above uses a 2-node toy field;
|
| 251 |
# this adds a realistic-scale (n=5000), right-skewed synthetic field with
|
| 252 |
# an EASILY achievable std target (no clipping needed at all), so the
|
| 253 |
# affine seed hits both targets and the soft solve must reduce to it
|
|
|
|
| 275 |
|
| 276 |
|
| 277 |
def test_hybrid_soft_negative_weight_raises():
|
| 278 |
+
# LOW: a negative weight rewards moving AWAY from that
|
| 279 |
# target (minimizing J then maximizes the residual's square) -- must
|
| 280 |
# raise loudly rather than silently drive a to a nonsensical optimum.
|
| 281 |
gnn = np.array([1.0, 2.0, 3.0])
|
|
|
|
| 286 |
|
| 287 |
|
| 288 |
def test_hybrid_soft_both_weights_zero_raises():
|
| 289 |
+
# LOW: w_total == w_std == 0 makes J identically zero
|
| 290 |
# everywhere -- no signal for the search to optimize against.
|
| 291 |
gnn = np.array([1.0, 2.0, 3.0])
|
| 292 |
with pytest.raises(ValueError, match="w_total|w_std"):
|
|
|
|
| 304 |
|
| 305 |
|
| 306 |
def test_hybrid_soft_accepted_optimum_majority_clipped_warns(caplog):
|
| 307 |
+
# The affine path already warns when ITS OWN clip_frac > 0.5; the
|
| 308 |
# soft path must emit the analogous warning when the ACCEPTED soft
|
| 309 |
# optimum (which can differ from the affine seed) itself clips a
|
| 310 |
# majority of the field.
|
|
|
|
| 320 |
for rec in caplog.records)
|
| 321 |
|
| 322 |
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def test_hybrid_soft_info_dict_carries_all_keys():
|
| 326 |
+
g, total, target = _squeezed_case()
|
| 327 |
+
pred, info = hybrid_field_soft(g, total, target)
|
| 328 |
+
expected_keys = {"a", "m", "target_unreachable", "degenerate_fallback", "clip_frac",
|
| 329 |
+
"total_deviation_frac", "j_total_resid", "j_std_resid",
|
| 330 |
+
"std_target_effective"}
|
| 331 |
+
assert expected_keys <= set(info.keys())
|
| 332 |
+
# clip_frac must be measured on the FINAL (returned) field.
|
| 333 |
+
a, m = info["a"], info["m"]
|
| 334 |
+
c = g - g.mean()
|
| 335 |
+
final_raw = a * c + m
|
| 336 |
+
assert info["clip_frac"] == pytest.approx(float((final_raw < 0.0).mean()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_hybrid_max_anchor.py
CHANGED
|
@@ -1,11 +1,6 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
MAX-anchor, not MEAN-anchor") for why this differs from hybrid_field's
|
| 5 |
-
MEAN-anchor. This module is the single source; evaluate_ft2b re-exports it
|
| 6 |
-
under its old private name via an alias import.
|
| 7 |
-
|
| 8 |
-
Task 7: the exact-max anchor is fragile to a handful of outlier nodes (one
|
| 9 |
inflated GNN log-space prediction can drag the entire rest of the field
|
| 10 |
below the zero clip). The anchor moved to a high quantile
|
| 11 |
(`np.quantile(gnn, q)`, default `q=0.995`); `q=1.0` reproduces the old
|
|
@@ -17,7 +12,7 @@ from src.hybrid import hybrid_field_max_anchor
|
|
| 17 |
|
| 18 |
|
| 19 |
def test_max_anchor_q1_sets_exact_max_and_preserves_shape():
|
| 20 |
-
"""q=1.0 regression pin: reproduces the
|
| 21 |
g = np.array([1.0, 4.0, 2.5])
|
| 22 |
out = hybrid_field_max_anchor(g, 10.0, q=1.0)
|
| 23 |
assert out.max() == 10.0
|
|
@@ -37,13 +32,10 @@ def test_max_anchor_clips_at_zero():
|
|
| 37 |
assert hybrid_field_max_anchor(np.array([0.0, 100.0]), 10.0)[0] == 0.0
|
| 38 |
|
| 39 |
|
| 40 |
-
def test_evaluate_ft2b_uses_the_same_function():
|
| 41 |
-
from src import evaluate_ft2b, hybrid
|
| 42 |
-
assert evaluate_ft2b._max_anchor_hybrid is hybrid.hybrid_field_max_anchor
|
| 43 |
|
| 44 |
|
| 45 |
def test_max_anchor_q1_reproduces_old_exact_max_on_random_vector():
|
| 46 |
-
"""Regression pin: q=1.0 bit-for-bit equals the
|
| 47 |
`clip(g - g.max() + total, 0, None)` on an arbitrary random vector."""
|
| 48 |
rng = np.random.default_rng(42)
|
| 49 |
g = rng.normal(loc=50.0, scale=20.0, size=200)
|
|
|
|
| 1 |
+
"""max/quantile-anchor hybrid recomposition, in src/hybrid.py.
|
| 2 |
|
| 3 |
+
The exact-max anchor is fragile to a handful of outlier nodes (one
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
inflated GNN log-space prediction can drag the entire rest of the field
|
| 5 |
below the zero clip). The anchor moved to a high quantile
|
| 6 |
(`np.quantile(gnn, q)`, default `q=0.995`); `q=1.0` reproduces the old
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def test_max_anchor_q1_sets_exact_max_and_preserves_shape():
|
| 15 |
+
"""q=1.0 regression pin: reproduces the earlier exact-max behavior."""
|
| 16 |
g = np.array([1.0, 4.0, 2.5])
|
| 17 |
out = hybrid_field_max_anchor(g, 10.0, q=1.0)
|
| 18 |
assert out.max() == 10.0
|
|
|
|
| 32 |
assert hybrid_field_max_anchor(np.array([0.0, 100.0]), 10.0)[0] == 0.0
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
def test_max_anchor_q1_reproduces_old_exact_max_on_random_vector():
|
| 38 |
+
"""Regression pin: q=1.0 bit-for-bit equals the earlier formula
|
| 39 |
`clip(g - g.max() + total, 0, None)` on an arbitrary random vector."""
|
| 40 |
rng = np.random.default_rng(42)
|
| 41 |
g = rng.normal(loc=50.0, scale=20.0, size=200)
|
tests/test_inference_props.py
CHANGED
|
@@ -36,8 +36,7 @@ def test_props_json_override_and_missing_keys(tmp_path):
|
|
| 36 |
|
| 37 |
|
| 38 |
# --------------------------------------------------------------------------- #
|
| 39 |
-
#
|
| 40 |
-
# custom-JSON override with TL <= TS was previously unvalidated -- silently
|
| 41 |
# collapsing src.shrinkage.niyama_proxy to 0 everywhere (100% flagged).
|
| 42 |
# --------------------------------------------------------------------------- #
|
| 43 |
def test_props_json_tl_less_equal_ts_raises(tmp_path):
|
|
@@ -68,21 +67,6 @@ def test_named_alloy_tl_ts_still_valid_after_i2_guard():
|
|
| 68 |
assert p["TL"] > p["TS"]
|
| 69 |
|
| 70 |
|
| 71 |
-
# --------------------------------------------------------------------------- #
|
| 72 |
-
# resolve_alloy_family (SP2 shrinkage Task 4, controller directive 2):
|
| 73 |
-
# resolve_props's returned 8-key dict has no family field, so shrinkage
|
| 74 |
-
# screening maps the ALLOY GRADE NAME to one of src.shrinkage.SHRINKAGE_PCT's
|
| 75 |
-
# family keys via a small name-prefix helper, rather than crashing or
|
| 76 |
-
# silently guessing on an unrecognized grade.
|
| 77 |
-
# --------------------------------------------------------------------------- #
|
| 78 |
-
def test_resolve_alloy_family_known_prefixes():
|
| 79 |
-
assert resolve_alloy_family("EN-GJL-250") == ("grey_iron", False)
|
| 80 |
-
assert resolve_alloy_family("EN-GJL-200") == ("grey_iron", False)
|
| 81 |
-
assert resolve_alloy_family("EN-GJS-400-15") == ("ductile_iron", False)
|
| 82 |
-
assert resolve_alloy_family("GS-38 (GE200)") == ("low_alloy_steel", False)
|
| 83 |
-
assert resolve_alloy_family("GS-45 (GP240GH)") == ("low_alloy_steel", False)
|
| 84 |
-
assert resolve_alloy_family("G20Mn5") == ("low_alloy_steel", False)
|
| 85 |
-
assert resolve_alloy_family("G17CrMo5-5") == ("low_alloy_steel", False)
|
| 86 |
|
| 87 |
|
| 88 |
def test_resolve_alloy_family_default_alloy_when_none():
|
|
@@ -95,22 +79,12 @@ def test_resolve_alloy_family_unknown_prefix_falls_back_loud():
|
|
| 95 |
assert family == "steel" and assumed is True
|
| 96 |
|
| 97 |
|
| 98 |
-
def test_resolve_alloy_family_props_json_always_assumed_steel():
|
| 99 |
-
# A custom --props JSON bypasses the named-material table entirely -- no
|
| 100 |
-
# grade name to map from -- so the documented fallback is "steel",
|
| 101 |
-
# flagged as assumed regardless of what `alloy` happens to be.
|
| 102 |
-
family, assumed = resolve_alloy_family("EN-GJL-250", props_json="/some/path.json")
|
| 103 |
-
assert family == "steel" and assumed is True
|
| 104 |
|
| 105 |
|
| 106 |
-
def test_resolve_alloy_family_every_mapped_family_is_a_known_shrinkage_pct_key():
|
| 107 |
-
for grade in ("EN-GJL-250", "EN-GJS-400-15", "GS-38 (GE200)", "unknown"):
|
| 108 |
-
family, _ = resolve_alloy_family(grade)
|
| 109 |
-
assert family in SHRINKAGE_PCT
|
| 110 |
|
| 111 |
|
| 112 |
def test_resolve_alloy_family_covers_every_catalog_grade_exactly():
|
| 113 |
-
"""
|
| 114 |
DB (`data/properties/materials.csv` via `list_alloys()`) -- every grade
|
| 115 |
actually on file today must be an EXACT prefix match (`assumed is
|
| 116 |
False`), not a fallback. This is the loud-failure trip-wire for a
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
# --------------------------------------------------------------------------- #
|
| 39 |
+
# A `--props` custom-JSON override with TL <= TS was previously unvalidated -- silently
|
|
|
|
| 40 |
# collapsing src.shrinkage.niyama_proxy to 0 everywhere (100% flagged).
|
| 41 |
# --------------------------------------------------------------------------- #
|
| 42 |
def test_props_json_tl_less_equal_ts_raises(tmp_path):
|
|
|
|
| 67 |
assert p["TL"] > p["TS"]
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
def test_resolve_alloy_family_default_alloy_when_none():
|
|
|
|
| 79 |
assert family == "steel" and assumed is True
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
def test_resolve_alloy_family_covers_every_catalog_grade_exactly():
|
| 87 |
+
"""a data-driven guard over the REAL property
|
| 88 |
DB (`data/properties/materials.csv` via `list_alloys()`) -- every grade
|
| 89 |
actually on file today must be an EXACT prefix match (`assumed is
|
| 90 |
False`), not a fallback. This is the loud-failure trip-wire for a
|
tests/test_input_encoder_extension.py
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
import torch
|
| 2 |
-
from src.model_gnn import SolidGNN, zero_init_input_columns
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
def test_zero_init_last_columns():
|
| 6 |
-
m = SolidGNN(node_in=15, hidden=16, n_blocks=1)
|
| 7 |
-
zero_init_input_columns(m, n_new=2)
|
| 8 |
-
w = m.node_encoder[0][0].weight # first Linear of the node_encoder _mlp
|
| 9 |
-
assert torch.count_nonzero(w[:, -2:]) == 0 and torch.count_nonzero(w[:, :13]) > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|