Model-focused card; release scope: EN-GJS-400-15 + G20Mn5, green sand; mesh-in API
Browse files- README.md +153 -256
- data/properties/README.md +22 -10
- data/properties/chills.csv +0 -5
- data/properties/exo_calibration.csv +0 -13
- data/properties/materials.csv +0 -10
- data/properties/molds.csv +0 -1
- src/assembly.py +0 -904
- src/inference_props.py +1 -1
- src/model_api.py +53 -0
- src/predict_casting.py +28 -396
- src/webapp.py +5 -371
- tests/test_assembly.py +0 -732
- tests/test_assembly_multisleeve.py +0 -105
- tests/test_inference_props.py +14 -11
- tests/test_model_api.py +25 -0
- tests/test_predict_casting_v4.py +4 -751
- tools/sleeves/__init__.py +0 -0
- tools/sleeves/sleeve_registry.csv +0 -15
README.md
CHANGED
|
@@ -17,7 +17,6 @@ tags:
|
|
| 17 |
- metal-casting
|
| 18 |
- foundry
|
| 19 |
- solidification
|
| 20 |
-
- shrinkage
|
| 21 |
- finite-element
|
| 22 |
- fenicsx
|
| 23 |
- pytorch-geometric
|
|
@@ -26,22 +25,19 @@ tags:
|
|
| 26 |
|
| 27 |
# Casting solidification GNN (v5.1)
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
|
| 34 |
-
A transient finite-element solve of the same field takes minutes to hours.
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
It does not replace a calibrated process simulation or physical validation. A hot spot marks
|
| 40 |
-
feeding difficulty and shrinkage risk. It is not a porosity label.
|
| 41 |
|
| 42 |
<p align="center">
|
| 43 |
<img src="assets/solidification.gif" alt="Predicted solidification playback" width="720"><br>
|
| 44 |
-
<em>
|
| 45 |
remaining liquid contracts toward the hot spot.</em>
|
| 46 |
</p>
|
| 47 |
|
|
@@ -49,262 +45,190 @@ feeding difficulty and shrinkage risk. It is not a porosity label.
|
|
| 49 |
|---|---|
|
| 50 |
| Version | v5.1, frozen on 31 July 2026 |
|
| 51 |
| Task | Per-node regression on a tetrahedral mesh (graph ML) |
|
| 52 |
-
|
|
| 53 |
-
|
|
| 54 |
-
| Architecture | 12-block MeshGraphNet-style GNN with FiLM conditioning,
|
| 55 |
-
|
|
| 56 |
-
|
|
| 57 |
| License | Apache-2.0 for code and weights |
|
| 58 |
-
|
|
| 59 |
| Author | Eugen Miknevic, independent foundry engineer |
|
| 60 |
|
| 61 |
## Contents
|
| 62 |
|
| 63 |
-
- [
|
| 64 |
-
- [
|
| 65 |
-
- [
|
|
|
|
| 66 |
- [Training data](#training-data)
|
| 67 |
- [Training procedure](#training-procedure)
|
| 68 |
- [Evaluation](#evaluation)
|
| 69 |
- [Intended use and limitations](#intended-use-and-limitations)
|
| 70 |
-
- [The node-order bug](#the-node-order-bug)
|
| 71 |
- [Reproducibility](#reproducibility)
|
| 72 |
- [License and citation](#license-and-citation)
|
| 73 |
|
| 74 |
-
##
|
| 75 |
|
| 76 |
-
The
|
| 77 |
-
alone.
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
| `weights/SHA256SUMS` | <1 KB | Checksums of the files above |
|
| 86 |
-
| `src/` | | The full pipeline: CAD healing and meshing, features, GNN, recomposition, shrinkage screening, VTU export, training scripts and a local web viewer |
|
| 87 |
-
| `tools/` | | Density-targeted STEP meshing, the property loader, the sleeve registry and the scripts that recover solver-to-mesh node order |
|
| 88 |
-
| `data/properties/` | | Material, mold, chill and exothermic-sleeve tables read at inference |
|
| 89 |
-
| `tests/` | | Deterministic unit tests that need neither weights nor simulation data |
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
[Reproducibility](#reproducibility)).
|
| 97 |
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
##
|
| 101 |
-
|
| 102 |
-
Python 3.11 is recommended. Install PyTorch 2.3 for your hardware first (CPU works for
|
| 103 |
-
inference), then the matching `torch-cluster` wheel, then the rest:
|
| 104 |
|
| 105 |
```bash
|
| 106 |
hf download eugenmik/castsolid-gnn --local-dir castsolid-gnn
|
| 107 |
cd castsolid-gnn
|
| 108 |
-
|
| 109 |
python -m venv .venv && source .venv/bin/activate
|
| 110 |
pip install "torch==2.3.0" --index-url https://download.pytorch.org/whl/cpu
|
| 111 |
pip install torch-cluster -f https://data.pyg.org/whl/torch-2.3.0+cpu.html
|
| 112 |
pip install -r requirements.txt
|
| 113 |
-
pytest -q # optional, no GPU needed
|
| 114 |
-
```
|
| 115 |
-
|
| 116 |
-
For a CUDA build, replace `cpu` with your CUDA tag (for example `cu121`) in both URLs.
|
| 117 |
-
The `gmsh` Python wheel handles CAD healing and tetrahedral meshing. `pyvista` and `trame` are
|
| 118 |
-
only needed for the browser viewer.
|
| 119 |
-
|
| 120 |
-
### 2. Predict from the command line
|
| 121 |
-
|
| 122 |
-
Run this from the repository root. The weight paths resolve on their own.
|
| 123 |
-
|
| 124 |
-
```bash
|
| 125 |
-
python -m src.predict_casting path/to/part.step --out-dir out \
|
| 126 |
-
--alloy EN-GJS-500-7 --mold green_sand
|
| 127 |
```
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
- `out/<name>.vtu` is the tetrahedral mesh with point fields `solidification_time` (s),
|
| 132 |
-
`hotspot` (the 10% of nodes that solidify last), `niyama`, `porosity_risk` and
|
| 133 |
-
`macro_region_id`. Open it in ParaView.
|
| 134 |
-
- `out/<name>_summary.txt` holds the total time, the coordinates of the five latest nodes, any
|
| 135 |
-
OOD warnings and the shrinkage screening summary.
|
| 136 |
-
- `out/<name>.msh` is the mesh the prediction actually used.
|
| 137 |
-
|
| 138 |
-
Meshing runs through the same pipeline that prepared the training parts: keep the largest
|
| 139 |
-
solid, heal it with OpenCASCADE, then search the element size until the mesh has about 8,500
|
| 140 |
-
nodes. A second run on the same STEP file reuses the cached mesh.
|
| 141 |
-
|
| 142 |
-
### 3. Predict from Python
|
| 143 |
|
| 144 |
```python
|
| 145 |
-
from src.
|
| 146 |
-
from src.inference_props import list_alloys, list_molds
|
| 147 |
-
|
| 148 |
-
print(list_alloys()) # 12 named grades: EN-GJL-200/250/300, EN-GJS-400-15/500-7/600-3/700-2,
|
| 149 |
-
# GS-38, GS-45, GS-52, G20Mn5, G17CrMo5-5
|
| 150 |
-
print(list_molds()) # ['green_sand', 'no_bake_furan_HTS']
|
| 151 |
-
|
| 152 |
-
r = predict_casting("part.step", out_dir="out", alloy="EN-GJS-500-7", mold="green_sand")
|
| 153 |
-
r["total_time_s"] # predicted total solidification time, s
|
| 154 |
-
r["hotspot_xyz"] # (5, 3) mm coordinates of the latest-solidifying nodes
|
| 155 |
-
r["ood_warnings"] # an empty list means the part is inside the training envelope
|
| 156 |
-
r["shrinkage"] # Niyama and macro-shrinkage screening, with a confidence label
|
| 157 |
-
```
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
|
|
|
| 161 |
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
"TL": 1220.0, "TS": 1150.0, "Tpour": 1320.0, "mold_effusivity": 1018.0}
|
| 165 |
```
|
| 166 |
|
| 167 |
-
```
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
### 4. Assemblies with chills or a feeder sleeve
|
| 172 |
-
|
| 173 |
-
By default the whole outer surface cools into a sand mold. For a multi-body STEP you can say
|
| 174 |
-
which solid is the casting and which ones are chills or a sleeve:
|
| 175 |
-
|
| 176 |
-
```bash
|
| 177 |
-
python -m src.assembly part.step # lists solids with id, volume, bounding box
|
| 178 |
-
python -m src.predict_casting part.step --bodies bodies.json
|
| 179 |
-
```
|
| 180 |
|
| 181 |
```json
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
{"solid_id": 3, "role": "chill", "params": {"material": "cast_iron"}}]
|
| 185 |
-
```
|
| 186 |
-
|
| 187 |
-
The roles are `casting`, `chill`, `insulating_sleeve`, `exo_sleeve` and `ignore`. Every
|
| 188 |
-
training case had chills only, one sleeve only, or neither, so the code rejects any other
|
| 189 |
-
combination instead of guessing. A sleeve is either named from
|
| 190 |
-
`tools/sleeves/sleeve_registry.csv` (`{"sleeve": "FOSECO_P12_15K"}`) or given a manual modulus
|
| 191 |
-
extension factor (`{"MEF": 1.55}`). Only `cast_iron` chills appear in the training data.
|
| 192 |
-
|
| 193 |
-
### 5. Browser viewer (optional)
|
| 194 |
-
|
| 195 |
-
```bash
|
| 196 |
-
python -m src.webapp # http://localhost:8080
|
| 197 |
```
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
| 202 |
|
| 203 |
-
##
|
| 204 |
|
| 205 |
-
The
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
|
| 211 |
```mermaid
|
| 212 |
flowchart LR
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
| 223 |
```
|
| 224 |
|
| 225 |
-
### Why three models
|
| 226 |
-
|
| 227 |
-
The GNN gets the spatial pattern of solidification right more reliably than its absolute size.
|
| 228 |
-
The size depends heavily on alloy, mold and part dimensions, and two small gradient-boosted
|
| 229 |
-
models estimate it better from a handful of global descriptors. On the original benchmark with
|
| 230 |
-
held-out geometry families, the hybrid brought nodal MAE down from 5.77 s (GNN alone) to
|
| 231 |
-
4.26 s.
|
| 232 |
-
|
| 233 |
### Graph network
|
| 234 |
|
| 235 |
Nodes are mesh vertices. Edges are the union of the tetrahedral mesh edges and the 16 nearest
|
| 236 |
neighbours of each node. Each edge carries its displacement vector, its length and a flag that
|
| 237 |
-
says whether it is a mesh edge
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
-
|
| 244 |
-
|
| 245 |
-
-
|
| 246 |
-
|
| 247 |
-
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
A vector `g` of five dimensionless numbers describes the alloy and the mold: Stefan number,
|
| 254 |
-
normalized freezing range, thermal diffusivity ratio, normalized superheat and normalized mold
|
| 255 |
-
effusivity. It conditions every processor block through FiLM (`h ← h·(1+γ(g)) + β(g)`).
|
| 256 |
|
| 257 |
The network follows the encode-process-decode design of
|
| 258 |
[MeshGraphNets](https://arxiv.org/abs/2010.03409). MLP encoders embed nodes and edges, 12
|
| 259 |
message-passing blocks (hidden width 128, residual connections, LayerNorm, mean aggregation)
|
| 260 |
process them, and a two-head decoder predicts the per-node deviation and a graph-level scale
|
| 261 |
-
separately. The
|
| 262 |
-
time (s) at which each node cools through the solidus.
|
| 263 |
|
| 264 |
### Gradient-boosted heads
|
| 265 |
|
| 266 |
-
Both heads are scikit-learn `HistGradientBoostingRegressor` models
|
| 267 |
-
alloy properties
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
head predicts `log1p` of the total solidification time; the spread head predicts `log1p` of the
|
| 272 |
-
standard deviation of the nodal field and is trained with absolute-error loss.
|
| 273 |
|
| 274 |
### Soft-anchor recomposition
|
| 275 |
|
| 276 |
-
The GNN field is scaled, shifted and clipped at zero
|
| 277 |
|
| 278 |
```
|
| 279 |
pred(a, m) = max(0, a · (gnn − mean(gnn)) + m)
|
| 280 |
```
|
| 281 |
|
| 282 |
-
|
| 283 |
|
| 284 |
```
|
| 285 |
J = w_total · ((q99.5(pred) − T_total) / T_total)² + w_std · ((std(pred) − S) / S)²
|
| 286 |
```
|
| 287 |
|
| 288 |
where `T_total` and `S` come from the two heads, `w_total = 1` and `w_std = 4`. When both
|
| 289 |
-
targets can be met exactly,
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
explicitly. The code is in `src/hybrid.py::hybrid_field_soft`.
|
| 293 |
|
| 294 |
-
##
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
## Training data
|
| 304 |
|
| 305 |
Every reference field was computed with [FEniCSx](https://fenicsproject.org/) (dolfinx) using an
|
| 306 |
-
enthalpy-method model of transient heat conduction with latent heat.
|
| 307 |
-
|
| 308 |
|
| 309 |
<p align="center">
|
| 310 |
<img src="assets/3dforms.png" alt="Examples of primitive training geometries" width="640"><br>
|
|
@@ -314,8 +238,8 @@ filling, flow, gas or stress.
|
|
| 314 |
| Set | Cases | Content |
|
| 315 |
|---|---:|---|
|
| 316 |
| Primitives (original regime) | 9,710 | 42 parametric geometry families, a single alloy, sand mold, different assignments of cooling and adiabatic faces |
|
| 317 |
-
| Multi-alloy
|
| 318 |
-
| Real
|
| 319 |
|
| 320 |
<p align="center">
|
| 321 |
<img src="assets/mesh.png" alt="Tetrahedral training mesh with tagged boundary groups" width="640"><br>
|
|
@@ -323,11 +247,9 @@ filling, flow, gas or stress.
|
|
| 323 |
Adiabatic.</em>
|
| 324 |
</p>
|
| 325 |
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
heat input is calibrated so that the modulus extension falls in vendor-published ranges. The
|
| 330 |
-
raw simulation corpus (about 100 GB) is not distributed.
|
| 331 |
|
| 332 |
## Training procedure
|
| 333 |
|
|
@@ -335,19 +257,18 @@ v5.1 came out of a chain of fine-tunes, not a single training run.
|
|
| 335 |
|
| 336 |
1. SP1 trained a 12-block two-head GNN from scratch on the primitive set, with the union graph,
|
| 337 |
local-modulus features and a loss on both nodal and total time.
|
| 338 |
-
2. FT2b fine-tuned it on the multi-alloy
|
| 339 |
-
|
| 340 |
-
3.
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
5. The BC-aware total head (v3), the absolute-error spread head (v2) and the soft anchor were
|
| 347 |
selected on held-out real-part validation rows.
|
| 348 |
|
| 349 |
Training ran on one RTX 3060 (12 GB) with batch size 2, at 15 to 20 minutes per epoch. To be
|
| 350 |
-
promoted, v5.1 had to pass four gates on held-out data: two gold-standard real
|
| 351 |
held-out real parts, an extrapolation split and a check against collapsed field variance.
|
| 352 |
|
| 353 |
## Evaluation
|
|
@@ -359,14 +280,12 @@ on real geometry they run to hundreds or thousands.
|
|
| 359 |
|---|---:|---:|---:|---|
|
| 360 |
| Held-out primitive geometry families | family split | 4.26 s | 0.917 | Generalization to unseen synthetic shape families in the original regime |
|
| 361 |
| Unseen real single-body castings | 6 | 80.3 s median | 0.738 median | Generalization to new real geometry, scored against a FEniCSx reference on the same mesh |
|
| 362 |
-
| Unseen real sleeve assemblies | 2 | 443.7 s median | 0.932 median | The hot regions rank well, but the field is too smooth (predicted spread is about 0.23 of the reference) and the absolute times are off |
|
| 363 |
|
| 364 |
Hot-spot overlap compares the 10% of nodes that solidify last in the prediction with the same
|
| 365 |
set in the reference. 1.0 means the two sets are identical.
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
hot-spot overlap of only 0.035, so the median says nothing certain about any one part.
|
| 370 |
|
| 371 |
> **Important.** The training data and the real-geometry references come from the same FEniCSx
|
| 372 |
> heat-transfer model, the same material database and the same boundary assumptions. These
|
|
@@ -377,7 +296,7 @@ hot-spot overlap of only 0.035, so the median says nothing certain about any one
|
|
| 377 |
|
| 378 |
Good uses:
|
| 379 |
|
| 380 |
-
- comparing early casting concepts
|
| 381 |
- finding candidate last-to-solidify regions;
|
| 382 |
- deciding which designs need a full process simulation;
|
| 383 |
- early quoting and design discussions, as long as the limitations stay visible.
|
|
@@ -392,41 +311,23 @@ Out of scope:
|
|
| 392 |
|
| 393 |
Known limitations:
|
| 394 |
|
| 395 |
-
1. The physics is pure conduction with latent heat, and the reference solver shares the
|
| 396 |
-
|
| 397 |
-
2.
|
| 398 |
-
|
| 399 |
-
3.
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
Treat each result as a screening hypothesis: where should an engineer look first, and is the
|
| 410 |
-
concept ready for a full simulation? Keep the mesh, the inputs, the model version and the
|
| 411 |
-
warnings together with every result. OOD, low-confidence and high-consequence cases belong in a
|
| 412 |
-
calibrated simulation and a physical review.
|
| 413 |
-
|
| 414 |
-
## The node-order bug
|
| 415 |
-
|
| 416 |
-
The worst defect in this project was outside the neural network. The solver stored
|
| 417 |
-
temperatures in dolfinx degree-of-freedom order, while coordinates and geometric features were
|
| 418 |
-
in gmsh node order. About 60% of the nodal targets ended up attached to the wrong positions,
|
| 419 |
-
silently, and every early model failure traced back to that.
|
| 420 |
-
|
| 421 |
-
`tools/recovery/` rebuilds the permutation between the two orderings, and
|
| 422 |
-
`src/build_dataset.py` applies it when it builds the graphs. If you work on simulation-to-ML
|
| 423 |
-
pipelines, check node identity across every solver and mesh interface before you start tuning a
|
| 424 |
-
model.
|
| 425 |
|
| 426 |
## Reproducibility
|
| 427 |
|
| 428 |
-
|
| 429 |
-
`src/release_io.py`:
|
| 430 |
|
| 431 |
| Release file | Internal artifact | Check |
|
| 432 |
|---|---|---|
|
|
@@ -434,19 +335,15 @@ model.
|
|
| 434 |
| `gbm_total_v3.skops` | `hgb_v3.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
|
| 435 |
| `gbm_std_v2.skops` | `hgb_std_v2.joblib` | Identical predictions on 10,002 probe rows spanning each feature's training split range |
|
| 436 |
|
| 437 |
-
On a real test
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
Software used: Python 3.11, PyTorch 2.3.0, PyTorch Geometric 2.7.0, scikit-learn 1.5.2, NumPy 2.x,
|
| 442 |
-
gmsh 4.13 or newer and SciPy. The gradient-boosted heads were trained with scikit-learn 1.5.2,
|
| 443 |
-
and other versions may refuse to load them or load them with changed behaviour.
|
| 444 |
|
| 445 |
## License and citation
|
| 446 |
|
| 447 |
Code and weights are released under the [Apache License 2.0](LICENSE). The tables in
|
| 448 |
-
`data/properties/` are modelling inputs, not certified material specifications.
|
| 449 |
-
against your actual alloy and process.
|
| 450 |
|
| 451 |
```bibtex
|
| 452 |
@software{miknevic_casting_gnn_2026,
|
|
|
|
| 17 |
- metal-casting
|
| 18 |
- foundry
|
| 19 |
- solidification
|
|
|
|
| 20 |
- finite-element
|
| 21 |
- fenicsx
|
| 22 |
- pytorch-geometric
|
|
|
|
| 25 |
|
| 26 |
# Casting solidification GNN (v5.1)
|
| 27 |
|
| 28 |
+
A surrogate model for the solidification of metal castings in a sand mold. It takes a
|
| 29 |
+
tetrahedral mesh of a casting together with the alloy and mold properties, and predicts the
|
| 30 |
+
time at which every mesh node solidifies. The nodes that solidify last are the hot spots, where
|
| 31 |
+
feeding problems and shrinkage defects usually start.
|
| 32 |
|
| 33 |
+
A transient finite-element solve of the same field takes minutes to hours. The model returns it
|
| 34 |
+
in seconds, which makes it useful for screening design variants before a full simulation. It
|
| 35 |
+
does not replace a calibrated process simulation or physical validation, and a hot spot is a
|
| 36 |
+
shrinkage-risk indicator, not a porosity label.
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
<p align="center">
|
| 39 |
<img src="assets/solidification.gif" alt="Predicted solidification playback" width="720"><br>
|
| 40 |
+
<em>A predicted field played back over time. Solidified material turns translucent while the
|
| 41 |
remaining liquid contracts toward the hot spot.</em>
|
| 42 |
</p>
|
| 43 |
|
|
|
|
| 45 |
|---|---|
|
| 46 |
| Version | v5.1, frozen on 31 July 2026 |
|
| 47 |
| Task | Per-node regression on a tetrahedral mesh (graph ML) |
|
| 48 |
+
| Input | Tetrahedral mesh in mm (gmsh 2.2 format) with the cooling surface tagged; alloy `EN-GJS-400-15` or `G20Mn5`, or a custom property set; green-sand mold |
|
| 49 |
+
| Output | Solidification time in seconds for every mesh node |
|
| 50 |
+
| Architecture | 12-block MeshGraphNet-style GNN with FiLM conditioning, combined with two gradient-boosted heads |
|
| 51 |
+
| Parameters | about 1.85 M (GNN) plus two `HistGradientBoostingRegressor` heads |
|
| 52 |
+
| Training data | FEniCSx enthalpy-method conduction simulations: about 9.7k primitive cases, 4.7k multi-alloy cases, 500 real-part cases |
|
| 53 |
| License | Apache-2.0 for code and weights |
|
| 54 |
+
| Pipeline | STEP import, meshing and result export are documented on [GitHub](https://github.com/eugenmik/castsolid-gnn) |
|
| 55 |
| Author | Eugen Miknevic, independent foundry engineer |
|
| 56 |
|
| 57 |
## Contents
|
| 58 |
|
| 59 |
+
- [Input and output](#input-and-output)
|
| 60 |
+
- [How to use](#how-to-use)
|
| 61 |
+
- [Model architecture](#model-architecture)
|
| 62 |
+
- [Files](#files)
|
| 63 |
- [Training data](#training-data)
|
| 64 |
- [Training procedure](#training-procedure)
|
| 65 |
- [Evaluation](#evaluation)
|
| 66 |
- [Intended use and limitations](#intended-use-and-limitations)
|
|
|
|
| 67 |
- [Reproducibility](#reproducibility)
|
| 68 |
- [License and citation](#license-and-citation)
|
| 69 |
|
| 70 |
+
## Input and output
|
| 71 |
|
| 72 |
+
The model reads one casting at a time.
|
|
|
|
| 73 |
|
| 74 |
+
**Mesh.** A tetrahedral volume mesh in millimetres, in gmsh 2.2 ASCII format (`.msh`). Surface
|
| 75 |
+
triangles that cool into the mold belong to a physical group named `Cooling`; in this release
|
| 76 |
+
that is the whole outer surface. The model was trained on meshes of roughly 7,500 to 10,000
|
| 77 |
+
nodes, and that is the density it should be given. It does not use node numbering or absolute
|
| 78 |
+
coordinates: node inputs are distances, flags and volume-type quantities in millimetres, and
|
| 79 |
+
edges carry only relative displacements between neighbouring nodes.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
+
**Alloy and mold.** Eight numbers describe the material and the mold: thermal conductivity,
|
| 82 |
+
density, specific heat, latent heat, liquidus, solidus, pouring temperature and mold effusivity.
|
| 83 |
+
Two alloys are tabulated in `data/properties/`, `EN-GJS-400-15` (ductile iron, the default) and
|
| 84 |
+
`G20Mn5` (cast steel), together with one mold, green sand. You can also pass your own eight
|
| 85 |
+
values.
|
|
|
|
| 86 |
|
| 87 |
+
**Output.** One number per node: the time in seconds at which that node cools through the
|
| 88 |
+
solidus. The maximum over all nodes is the total solidification time, and the 10% of nodes with
|
| 89 |
+
the largest times are the hot spots.
|
| 90 |
|
| 91 |
+
## How to use
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
```bash
|
| 94 |
hf download eugenmik/castsolid-gnn --local-dir castsolid-gnn
|
| 95 |
cd castsolid-gnn
|
|
|
|
| 96 |
python -m venv .venv && source .venv/bin/activate
|
| 97 |
pip install "torch==2.3.0" --index-url https://download.pytorch.org/whl/cpu
|
| 98 |
pip install torch-cluster -f https://data.pyg.org/whl/torch-2.3.0+cpu.html
|
| 99 |
pip install -r requirements.txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
```
|
| 101 |
|
| 102 |
+
Python 3.11 is recommended. For a GPU, replace `cpu` with your CUDA tag (for example `cu121`)
|
| 103 |
+
in both URLs. Then, from the repository root:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
```python
|
| 106 |
+
from src.model_api import CastSolidModel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
model = CastSolidModel() # loads the three weight files once
|
| 109 |
+
t = model.predict("part.msh") # EN-GJS-400-15 in green sand
|
| 110 |
+
t_steel = model.predict("part.msh", alloy="G20Mn5")
|
| 111 |
|
| 112 |
+
t.max() # total solidification time, s
|
| 113 |
+
hot = CastSolidModel.hotspots(t) # boolean mask, top 10% of nodes
|
|
|
|
| 114 |
```
|
| 115 |
|
| 116 |
+
`predict` returns a NumPy array in the node order of the `.msh` file. To use another alloy,
|
| 117 |
+
pass `props_json="my_alloy.json"` with exactly these keys (SI units, temperatures in °C;
|
| 118 |
+
1018 is the green-sand effusivity):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
```json
|
| 121 |
+
{"k": 32.9, "rho": 6800.0, "cp": 515.0, "L_kJ_kg": 220.0,
|
| 122 |
+
"TL": 1160.0, "TS": 1120.0, "Tpour": 1260.0, "mold_effusivity": 1018.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
```
|
| 124 |
|
| 125 |
+
If you start from a STEP file, the
|
| 126 |
+
[GitHub repository](https://github.com/eugenmik/castsolid-gnn) has the pipeline that heals the
|
| 127 |
+
CAD, builds a mesh at the right density, runs this model and writes the result for ParaView.
|
| 128 |
+
The same code is included here, so `python -m src.predict_casting part.step` works in this
|
| 129 |
+
repository too.
|
| 130 |
|
| 131 |
+
## Model architecture
|
| 132 |
|
| 133 |
+
The model is a hybrid of three trained parts. The GNN predicts the spatial shape of the field;
|
| 134 |
+
two gradient-boosted heads predict its overall level and its spread; a fixed recomposition step
|
| 135 |
+
combines them. The GNN alone is less reliable about absolute magnitude, which depends strongly
|
| 136 |
+
on alloy and part size. On the original benchmark of held-out geometry families, adding the
|
| 137 |
+
heads brought nodal MAE down from 5.77 s to 4.26 s.
|
| 138 |
|
| 139 |
```mermaid
|
| 140 |
flowchart LR
|
| 141 |
+
M[Tetrahedral mesh] --> F[15 node features<br/>+ union graph]
|
| 142 |
+
P[Alloy + mold<br/>properties] --> G[5-value conditioning vector]
|
| 143 |
+
P --> D
|
| 144 |
+
F --> N[12-block GNN<br/>field shape]
|
| 145 |
+
G --> N
|
| 146 |
+
F --> D[Part descriptors]
|
| 147 |
+
D --> T[GBM total head]
|
| 148 |
+
D --> S[GBM spread head]
|
| 149 |
+
N --> R[Soft-anchor<br/>recomposition]
|
| 150 |
+
T --> R
|
| 151 |
+
S --> R
|
| 152 |
+
R --> O[Time per node, s]
|
| 153 |
```
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
### Graph network
|
| 156 |
|
| 157 |
Nodes are mesh vertices. Edges are the union of the tetrahedral mesh edges and the 16 nearest
|
| 158 |
neighbours of each node. Each edge carries its displacement vector, its length and a flag that
|
| 159 |
+
says whether it is a mesh edge.
|
| 160 |
+
|
| 161 |
+
Each node gets 15 features computed from the mesh and its surface tags:
|
| 162 |
+
|
| 163 |
+
- geodesic distance through the mesh to the nearest cooling surface and to the nearest
|
| 164 |
+
adiabatic surface, plus straight-line distance to the surface and to adiabatic faces;
|
| 165 |
+
- cooling, adiabatic and interior flags;
|
| 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 from training, which are zero in this release because the
|
| 170 |
+
whole surface cools into the mold.
|
| 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
|
| 174 |
+
condition every processor block through FiLM (`h ← h·(1+γ(g)) + β(g)`).
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
The network follows the encode-process-decode design of
|
| 177 |
[MeshGraphNets](https://arxiv.org/abs/2010.03409). MLP encoders embed nodes and edges, 12
|
| 178 |
message-passing blocks (hidden width 128, residual connections, LayerNorm, mean aggregation)
|
| 179 |
process them, and a two-head decoder predicts the per-node deviation and a graph-level scale
|
| 180 |
+
separately. The training target is the standardized `log1p` of the solidification time.
|
|
|
|
| 181 |
|
| 182 |
### Gradient-boosted heads
|
| 183 |
|
| 184 |
+
Both heads are scikit-learn `HistGradientBoostingRegressor` models with one row per part: the
|
| 185 |
+
alloy properties, the part's volume-to-area ratio, the mold effusivity, and boundary-condition
|
| 186 |
+
descriptors that are fixed to the plain sand mold in this release. The total head (16 inputs)
|
| 187 |
+
predicts `log1p` of the total solidification time. The spread head (17 inputs, absolute-error
|
| 188 |
+
loss) predicts `log1p` of the standard deviation of the nodal field.
|
|
|
|
|
|
|
| 189 |
|
| 190 |
### Soft-anchor recomposition
|
| 191 |
|
| 192 |
+
The GNN field is scaled, shifted and clipped at zero,
|
| 193 |
|
| 194 |
```
|
| 195 |
pred(a, m) = max(0, a · (gnn − mean(gnn)) + m)
|
| 196 |
```
|
| 197 |
|
| 198 |
+
with `(a, m)` chosen to minimize
|
| 199 |
|
| 200 |
```
|
| 201 |
J = w_total · ((q99.5(pred) − T_total) / T_total)² + w_std · ((std(pred) − S) / S)²
|
| 202 |
```
|
| 203 |
|
| 204 |
where `T_total` and `S` come from the two heads, `w_total = 1` and `w_std = 4`. When both
|
| 205 |
+
targets can be met exactly, this equals the closed-form affine solve. When clipping makes that
|
| 206 |
+
impossible, the total anchor may move by a few percent so the spread stays close. The post-clip
|
| 207 |
+
spread is not monotone in `a`, so the solver brackets the smallest root explicitly.
|
|
|
|
| 208 |
|
| 209 |
+
## Files
|
| 210 |
|
| 211 |
+
| File | Size | Content |
|
| 212 |
+
|---|---:|---|
|
| 213 |
+
| `weights/gnn_v5.1.safetensors` | 7.4 MB | GNN weights, feature and target normalization, and the architecture config (in the safetensors metadata) |
|
| 214 |
+
| `weights/gbm_total_v3.skops` | 0.6 MB | Total-time head |
|
| 215 |
+
| `weights/gbm_std_v2.skops` | 0.8 MB | Spread head |
|
| 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 |
+
`tools/` and `tests/` is the supporting code, described on GitHub.
|
| 226 |
|
| 227 |
## Training data
|
| 228 |
|
| 229 |
Every reference field was computed with [FEniCSx](https://fenicsproject.org/) (dolfinx) using an
|
| 230 |
+
enthalpy-method model of transient heat conduction with latent heat. There is no mold filling,
|
| 231 |
+
flow, gas or stress in it.
|
| 232 |
|
| 233 |
<p align="center">
|
| 234 |
<img src="assets/3dforms.png" alt="Examples of primitive training geometries" width="640"><br>
|
|
|
|
| 238 |
| Set | Cases | Content |
|
| 239 |
|---|---:|---|
|
| 240 |
| Primitives (original regime) | 9,710 | 42 parametric geometry families, a single alloy, sand mold, different assignments of cooling and adiabatic faces |
|
| 241 |
+
| Multi-alloy (v4) | 4,727 | Realistic thick-walled castings in 12 named iron and steel grades plus sampled synthetic alloys, under several boundary-condition regimes |
|
| 242 |
+
| Real parts (E) | 500 | Prepared production geometries with varied boundary conditions |
|
| 243 |
|
| 244 |
<p align="center">
|
| 245 |
<img src="assets/mesh.png" alt="Tetrahedral training mesh with tagged boundary groups" width="640"><br>
|
|
|
|
| 247 |
Adiabatic.</em>
|
| 248 |
</p>
|
| 249 |
|
| 250 |
+
Training meshes have roughly 7,500 to 10,000 nodes. The target at each node is the time its
|
| 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 |
|
|
|
|
| 257 |
|
| 258 |
1. SP1 trained a 12-block two-head GNN from scratch on the primitive set, with the union graph,
|
| 259 |
local-modulus features and a loss on both nodal and total time.
|
| 260 |
+
2. FT2b fine-tuned it on the multi-alloy set. The FiLM layers started at zero, so training began
|
| 261 |
+
from exactly the SP1 function.
|
| 262 |
+
3. A follow-up pass re-encoded one sparse boundary input as a field diffused along the mesh,
|
| 263 |
+
because the network had learned to suppress it, and picked the checkpoint on gate metrics
|
| 264 |
+
instead of validation loss alone.
|
| 265 |
+
4. FT3 fine-tuned on real production geometries (set E) with the normalization statistics
|
| 266 |
+
frozen. Its epoch-21 checkpoint is the v5.1 GNN.
|
| 267 |
+
5. The total head (v3), the absolute-error spread head (v2) and the recomposition weights were
|
|
|
|
| 268 |
selected on held-out real-part validation rows.
|
| 269 |
|
| 270 |
Training ran on one RTX 3060 (12 GB) with batch size 2, at 15 to 20 minutes per epoch. To be
|
| 271 |
+
promoted, v5.1 had to pass four gates on held-out data: two gold-standard real parts, 24
|
| 272 |
held-out real parts, an extrapolation split and a check against collapsed field variance.
|
| 273 |
|
| 274 |
## Evaluation
|
|
|
|
| 280 |
|---|---:|---:|---:|---|
|
| 281 |
| Held-out primitive geometry families | family split | 4.26 s | 0.917 | Generalization to unseen synthetic shape families in the original regime |
|
| 282 |
| Unseen real single-body castings | 6 | 80.3 s median | 0.738 median | Generalization to new real geometry, scored against a FEniCSx reference on the same mesh |
|
|
|
|
| 283 |
|
| 284 |
Hot-spot overlap compares the 10% of nodes that solidify last in the prediction with the same
|
| 285 |
set in the reference. 1.0 means the two sets are identical.
|
| 286 |
|
| 287 |
+
One of the six real parts reached a hot-spot overlap of only 0.035, so the median says nothing
|
| 288 |
+
certain about any one part.
|
|
|
|
| 289 |
|
| 290 |
> **Important.** The training data and the real-geometry references come from the same FEniCSx
|
| 291 |
> heat-transfer model, the same material database and the same boundary assumptions. These
|
|
|
|
| 296 |
|
| 297 |
Good uses:
|
| 298 |
|
| 299 |
+
- comparing early casting concepts;
|
| 300 |
- finding candidate last-to-solidify regions;
|
| 301 |
- deciding which designs need a full process simulation;
|
| 302 |
- early quoting and design discussions, as long as the limitations stay visible.
|
|
|
|
| 311 |
|
| 312 |
Known limitations:
|
| 313 |
|
| 314 |
+
1. The physics is pure conduction with latent heat, and the reference solver shares the model's
|
| 315 |
+
property and boundary assumptions.
|
| 316 |
+
2. This release covers one setup: a single casting whose whole outer surface cools into a
|
| 317 |
+
green-sand mold, in `EN-GJS-400-15`, `G20Mn5` or a custom property set.
|
| 318 |
+
3. Meshes much coarser or finer than the training density (about 7,500 to 10,000 nodes) are
|
| 319 |
+
outside what the model has seen. At that density, large thin-walled parts can be
|
| 320 |
+
under-resolved.
|
| 321 |
+
4. The training envelope in `weights/ood_ranges.json` only covers part size and distance to
|
| 322 |
+
cooling. It is not a calibrated uncertainty estimate.
|
| 323 |
+
|
| 324 |
+
Treat each prediction as a screening hypothesis: where should an engineer look first, and is
|
| 325 |
+
the concept ready for a full simulation? Keep the mesh, the inputs and the model version with
|
| 326 |
+
every result, and send high-consequence cases to a calibrated simulation and a physical review.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
## Reproducibility
|
| 329 |
|
| 330 |
+
The published files were converted from the internal checkpoints by `tools/export_release.py`:
|
|
|
|
| 331 |
|
| 332 |
| Release file | Internal artifact | Check |
|
| 333 |
|---|---|---|
|
|
|
|
| 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 |
+
On a real test mesh, the published files and the internal checkpoints give the same field. The
|
| 339 |
+
heads were trained with scikit-learn 1.5.2, and other versions may refuse to load them or change
|
| 340 |
+
their output. Other versions used: Python 3.11, PyTorch 2.3.0, PyTorch Geometric 2.7.0,
|
| 341 |
+
NumPy 2.x.
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
## License and citation
|
| 344 |
|
| 345 |
Code and weights are released under the [Apache License 2.0](LICENSE). The tables in
|
| 346 |
+
`data/properties/` are modelling inputs, not certified material specifications.
|
|
|
|
| 347 |
|
| 348 |
```bibtex
|
| 349 |
@software{miknevic_casting_gnn_2026,
|
data/properties/README.md
CHANGED
|
@@ -1,13 +1,25 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
These
|
| 4 |
-
code. They are a curated runtime subset of the project's larger property record.
|
| 5 |
|
| 6 |
-
- `materials.csv`:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
The values
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Property tables
|
| 2 |
|
| 3 |
+
These two CSV files hold the material and mold rows that inference reads.
|
|
|
|
| 4 |
|
| 5 |
+
- `materials.csv` has two grades: `EN-GJS-400-15` (ductile iron, the default) and `G20Mn5`
|
| 6 |
+
(cast steel). Columns: thermal conductivity `k_W_mK`, density `rho_kg_m3`, specific heat
|
| 7 |
+
`cp_J_kgK`, latent heat `L_kJ_kg`, liquidus `TL_C`, solidus `TS_C` and the pouring
|
| 8 |
+
temperature range `Tpour_lo_C`/`Tpour_hi_C`. The prediction uses the midpoint of that range.
|
| 9 |
+
- `molds.csv` has one mold system, `green_sand`. The model reads its thermal effusivity
|
| 10 |
+
(`effusivity_W_s05_m2K`), which is the square root of the product of the conductivity,
|
| 11 |
+
density and specific heat in the same row.
|
| 12 |
|
| 13 |
+
The values come from handbook data. They are modelling inputs, not certified material
|
| 14 |
+
specifications. Check them against your actual alloy and process, and keep the exact row you
|
| 15 |
+
used with every result.
|
| 16 |
+
|
| 17 |
+
To try an alloy that is not listed, pass the properties directly instead of editing the table:
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
python -m src.predict_casting part.step --props my_alloy.json
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
`my_alloy.json` needs exactly these keys: `k`, `rho`, `cp`, `L_kJ_kg`, `TL`, `TS`, `Tpour`,
|
| 24 |
+
`mold_effusivity`. The model was trained on iron and steel grades in sand molds; properties far
|
| 25 |
+
from that range are extrapolation.
|
data/properties/chills.csv
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
material,k_W_mK,rho_kg_m3,cp_J_kgK,effusivity_W_s05_m2K
|
| 2 |
-
graphite,68.97,1922,1692,14978
|
| 3 |
-
cast_iron,38.9,7150,515,11968
|
| 4 |
-
steel,51.0,7860,486,13958
|
| 5 |
-
copper,OWNER,OWNER,OWNER,OWNER
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
data/properties/exo_calibration.csv
DELETED
|
@@ -1,13 +0,0 @@
|
|
| 1 |
-
MEF,q0_W_m2,duration_s,t_sand_s,t_exo_s,ratio,ratio_target
|
| 2 |
-
1.4,640000.0,105.68312000817032,352.2770666939011,639.1643789347195,1.814379757766347,1.9599999999999997
|
| 3 |
-
1.45,960000.0,105.68312000817032,352.2770666939011,754.0987323561276,2.1406410000892153,2.1025
|
| 4 |
-
1.49,960000.0,105.68312000817032,352.2770666939011,760.0587908504301,2.1575596674047954,2.2201
|
| 5 |
-
1.54,960000.0,105.68312000817032,352.2770666939011,766.9076476958378,2.1770013441216016,2.3716
|
| 6 |
-
1.58,1120000.0,105.68312000817032,352.2770666939011,827.4879309999664,2.348969062238114,2.4964000000000004
|
| 7 |
-
1.63,1280000.0,105.68312000817032,352.2770666939011,890.1572505029782,2.5268668745798584,2.6569
|
| 8 |
-
1.67,1280000.0,105.68312000817032,352.2770666939011,895.2697715970885,2.541379658912063,2.7889
|
| 9 |
-
1.72,1920000.0,105.68312000817032,352.2770666939011,1131.8937331840514,3.2130781143569904,2.9583999999999997
|
| 10 |
-
1.76,1920000.0,105.68312000817032,352.2770666939011,1138.063508699037,3.2305921000753584,3.0976
|
| 11 |
-
1.81,1920000.0,105.68312000817032,352.2770666939011,1145.2364712168662,3.2509538073677096,3.2761
|
| 12 |
-
1.85,1920000.0,105.68312000817032,352.2770666939011,1150.5882262573784,3.266145699053246,3.4225000000000003
|
| 13 |
-
1.9,1920000.0,105.68312000817032,352.2770666939011,1156.8270113469869,3.2838555805058167,3.61
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
data/properties/materials.csv
CHANGED
|
@@ -1,13 +1,3 @@
|
|
| 1 |
material_id,family,k_W_mK,rho_kg_m3,cp_J_kgK,L_kJ_kg,TL_C,TS_C,Tpour_lo_C,Tpour_hi_C
|
| 2 |
-
EN-GJL-200,grey_iron,51.9,7000,535,240,1175,1150,1265,1285
|
| 3 |
-
EN-GJL-250,grey_iron,41.5,7150,535,240,1220,1150,1310,1330
|
| 4 |
-
EN-GJL-300,grey_iron,36.3,7300,535,240,1245,1150,1335,1355
|
| 5 |
EN-GJS-400-15,ductile_iron,32.9,6800,515,220,1160,1120,1250,1270
|
| 6 |
-
EN-GJS-500-7,ductile_iron,36.0,7100,515,220,1160,1120,1250,1270
|
| 7 |
-
EN-GJS-600-3,ductile_iron,27.7,7250,515,220,1160,1120,1250,1270
|
| 8 |
-
EN-GJS-700-2,ductile_iron,26.0,7400,515,220,1160,1120,1250,1270
|
| 9 |
-
GS-38 (GE200),low_alloy_steel,51.0,7800,599,260,1530,1495,1620,1640
|
| 10 |
-
GS-45 (GP240GH),low_alloy_steel,50.8,7800,586,260,1530,1495,1620,1640
|
| 11 |
-
GS-52 (GE300),low_alloy_steel,50.7,7800,586,260,1530,1495,1620,1640
|
| 12 |
G20Mn5,low_alloy_steel,48.4,7800,590,260,1530,1495,1620,1640
|
| 13 |
-
G17CrMo5-5,low_alloy_steel,28.1,7800,595,251,1520,1420,1610,1630
|
|
|
|
| 1 |
material_id,family,k_W_mK,rho_kg_m3,cp_J_kgK,L_kJ_kg,TL_C,TS_C,Tpour_lo_C,Tpour_hi_C
|
|
|
|
|
|
|
|
|
|
| 2 |
EN-GJS-400-15,ductile_iron,32.9,6800,515,220,1160,1120,1250,1270
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
G20Mn5,low_alloy_steel,48.4,7800,590,260,1530,1495,1620,1640
|
|
|
data/properties/molds.csv
CHANGED
|
@@ -1,3 +1,2 @@
|
|
| 1 |
mold_system,k_W_mK,rho_kg_m3,cp_J_kgK,effusivity_W_s05_m2K
|
| 2 |
green_sand,0.607,1520,1123,1018
|
| 3 |
-
no_bake_furan_HTS,0.607,1520,1123,1018
|
|
|
|
| 1 |
mold_system,k_W_mK,rho_kg_m3,cp_J_kgK,effusivity_W_s05_m2K
|
| 2 |
green_sand,0.607,1520,1123,1018
|
|
|
src/assembly.py
DELETED
|
@@ -1,904 +0,0 @@
|
|
| 1 |
-
"""Multi-body STEP assembly tooling -- Part B Task 1.
|
| 2 |
-
|
| 3 |
-
Two pieces:
|
| 4 |
-
- `list_solids(step_path)` -- the assembly inspector: fresh gmsh/OCC
|
| 5 |
-
session, imports a (possibly multi-solid) STEP file with entity names
|
| 6 |
-
preserved (`Geometry.OCCImportLabels=1`, set BEFORE `importShapes` --
|
| 7 |
-
setting it after import is a no-op), and reports one row per 3-D solid
|
| 8 |
-
(id/name/volume/bbox/center-of-mass), sorted by volume descending. This
|
| 9 |
-
is the data source for the later "role menu" (casting / chill /
|
| 10 |
-
insulating sleeve / exothermic sleeve) that a human or heuristic
|
| 11 |
-
assigns per solid.
|
| 12 |
-
- `compose_assembly(base_step, attachments, out_step)` -- a test/fixture
|
| 13 |
-
helper (used by this task's own tests and by all later Part B fixtures,
|
| 14 |
-
e.g. proximity classification, BC-patch tagging) that builds a
|
| 15 |
-
synthetic multi-body STEP: import a base part, then place one OCC
|
| 16 |
-
primitive (box/cylinder) per attachment spec at one of the base's
|
| 17 |
-
bounding-box faces. NO boolean operations are performed -- the
|
| 18 |
-
attachment is written as an independent solid, deliberately overlapping
|
| 19 |
-
the base by `overlap_mm` (default 2.0mm) along the face normal. This is
|
| 20 |
-
intentional, not a bug: downstream, these bodies are never meshed
|
| 21 |
-
together (only the casting solid is meshed); the attachments exist
|
| 22 |
-
purely so their bounding boxes/faces can drive proximity/contact
|
| 23 |
-
classification against the casting, and a few mm of geometric overlap
|
| 24 |
-
is harmless for that purpose while guaranteeing the two solids actually
|
| 25 |
-
register as "touching" rather than floating a hair apart.
|
| 26 |
-
|
| 27 |
-
All lengths in mm, all gmsh sessions are fresh (`gmsh.initialize()` /
|
| 28 |
-
`gmsh.finalize()` in a try/finally per call) -- never share state across
|
| 29 |
-
calls, matching the pattern in `tools/doe/prep_parts.py` /
|
| 30 |
-
`src/step_mesh.py`.
|
| 31 |
-
"""
|
| 32 |
-
from __future__ import annotations
|
| 33 |
-
|
| 34 |
-
import argparse
|
| 35 |
-
import csv
|
| 36 |
-
from dataclasses import dataclass, field
|
| 37 |
-
from pathlib import Path
|
| 38 |
-
from typing import Sequence
|
| 39 |
-
|
| 40 |
-
import numpy as np
|
| 41 |
-
|
| 42 |
-
_AXIS_INDEX = {"x": 0, "y": 1, "z": 2}
|
| 43 |
-
_VALID_SHAPES = {"box", "cylinder"}
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
# --------------------------------------------------------------------------
|
| 47 |
-
# Assembly inspection
|
| 48 |
-
# --------------------------------------------------------------------------
|
| 49 |
-
def list_solids(step_path: str) -> list[dict]:
|
| 50 |
-
"""Import `step_path` in a fresh gmsh/OCC session and return one dict
|
| 51 |
-
per 3-D solid entity, sorted by `volume_mm3` descending (largest first
|
| 52 |
-
-- in practice the casting itself, since attached chills/sleeves are
|
| 53 |
-
normally much smaller):
|
| 54 |
-
|
| 55 |
-
{"solid_id": <gmsh tag, int>, "name": <STEP entity name or "">,
|
| 56 |
-
"volume_mm3": <float>, "bbox": [xmin, ymin, zmin, xmax, ymax, zmax],
|
| 57 |
-
"com": [cx, cy, cz]}
|
| 58 |
-
|
| 59 |
-
`Geometry.OCCImportLabels` MUST be set to 1 BEFORE `importShapes` --
|
| 60 |
-
that's the only way gmsh preserves STEP entity names (product names
|
| 61 |
-
from the CAD export) for `gmsh.model.getEntityName` to read; setting it
|
| 62 |
-
afterwards has no effect on an already-imported model. Solids with no
|
| 63 |
-
STEP name (or on any importer that doesn't preserve one) get `name=""`
|
| 64 |
-
rather than raising -- callers decide how to label an unnamed solid in
|
| 65 |
-
a role menu.
|
| 66 |
-
"""
|
| 67 |
-
import gmsh
|
| 68 |
-
|
| 69 |
-
gmsh.initialize()
|
| 70 |
-
try:
|
| 71 |
-
gmsh.option.setNumber("General.Terminal", 0)
|
| 72 |
-
gmsh.option.setNumber("Geometry.OCCImportLabels", 1)
|
| 73 |
-
gmsh.model.add("assembly_inspect")
|
| 74 |
-
gmsh.model.occ.importShapes(step_path)
|
| 75 |
-
gmsh.model.occ.synchronize()
|
| 76 |
-
|
| 77 |
-
rows: list[dict] = []
|
| 78 |
-
for (_dim, tag) in gmsh.model.getEntities(3):
|
| 79 |
-
name = gmsh.model.getEntityName(3, tag) or ""
|
| 80 |
-
volume_mm3 = float(gmsh.model.occ.getMass(3, tag))
|
| 81 |
-
bbox = list(gmsh.model.getBoundingBox(3, tag))
|
| 82 |
-
com = list(gmsh.model.occ.getCenterOfMass(3, tag))
|
| 83 |
-
rows.append({
|
| 84 |
-
"solid_id": tag,
|
| 85 |
-
"name": name,
|
| 86 |
-
"volume_mm3": volume_mm3,
|
| 87 |
-
"bbox": bbox,
|
| 88 |
-
"com": com,
|
| 89 |
-
})
|
| 90 |
-
rows.sort(key=lambda r: r["volume_mm3"], reverse=True)
|
| 91 |
-
return rows
|
| 92 |
-
finally:
|
| 93 |
-
gmsh.finalize()
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
# --------------------------------------------------------------------------
|
| 97 |
-
# Assembly composer (test/fixture helper)
|
| 98 |
-
# --------------------------------------------------------------------------
|
| 99 |
-
def _parse_face(at_face: str) -> tuple[int, float]:
|
| 100 |
-
"""`"+x"/"-x"/"+y"/"-y"/"+z"/"-z"` -> (axis_index, sign)."""
|
| 101 |
-
if len(at_face) != 2 or at_face[0] not in "+-" or at_face[1] not in _AXIS_INDEX:
|
| 102 |
-
raise ValueError(
|
| 103 |
-
f"invalid at_face {at_face!r}; expected one of +x -x +y -y +z -z")
|
| 104 |
-
return _AXIS_INDEX[at_face[1]], (1.0 if at_face[0] == "+" else -1.0)
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def _place_attachment(att: dict, bbox_min: Sequence[float], bbox_max: Sequence[float],
|
| 108 |
-
center: Sequence[float]) -> None:
|
| 109 |
-
"""Add ONE primitive (box or cylinder) to the currently-open OCC model,
|
| 110 |
-
positioned at the base's bbox face named by `att["at_face"]`, sunk
|
| 111 |
-
`att.get("overlap_mm", 2.0)` INTO the base along the face normal (see
|
| 112 |
-
module docstring for why a positive overlap is intentional). Does not
|
| 113 |
-
synchronize/write -- the caller does that once after all attachments
|
| 114 |
-
are placed."""
|
| 115 |
-
import gmsh
|
| 116 |
-
|
| 117 |
-
shape = att.get("shape")
|
| 118 |
-
if shape not in _VALID_SHAPES:
|
| 119 |
-
raise ValueError(f"invalid shape {shape!r}; expected one of {sorted(_VALID_SHAPES)}")
|
| 120 |
-
if "size" not in att:
|
| 121 |
-
raise ValueError(f"attachment missing required 'size': {att!r}")
|
| 122 |
-
if "at_face" not in att:
|
| 123 |
-
raise ValueError(f"attachment missing required 'at_face': {att!r}")
|
| 124 |
-
|
| 125 |
-
axis, sign = _parse_face(att["at_face"])
|
| 126 |
-
overlap_mm = float(att.get("overlap_mm", 2.0))
|
| 127 |
-
du, dv = att.get("offset", (0.0, 0.0))
|
| 128 |
-
perp_axes = [i for i in range(3) if i != axis]
|
| 129 |
-
face_coord = bbox_max[axis] if sign > 0 else bbox_min[axis]
|
| 130 |
-
|
| 131 |
-
if shape == "box":
|
| 132 |
-
dx, dy, dz = att["size"]
|
| 133 |
-
size_by_axis = [dx, dy, dz]
|
| 134 |
-
perp_size = size_by_axis[axis]
|
| 135 |
-
|
| 136 |
-
if sign > 0:
|
| 137 |
-
lo_axis = face_coord - overlap_mm
|
| 138 |
-
else:
|
| 139 |
-
lo_axis = face_coord + overlap_mm - perp_size
|
| 140 |
-
|
| 141 |
-
coords = [0.0, 0.0, 0.0]
|
| 142 |
-
extents = [0.0, 0.0, 0.0]
|
| 143 |
-
coords[axis] = lo_axis
|
| 144 |
-
extents[axis] = perp_size
|
| 145 |
-
for i, u in zip(perp_axes, (du, dv)):
|
| 146 |
-
extents[i] = size_by_axis[i]
|
| 147 |
-
coords[i] = center[i] - size_by_axis[i] / 2.0 + u
|
| 148 |
-
|
| 149 |
-
gmsh.model.occ.addBox(coords[0], coords[1], coords[2],
|
| 150 |
-
extents[0], extents[1], extents[2])
|
| 151 |
-
else: # cylinder
|
| 152 |
-
radius, height = att["size"]
|
| 153 |
-
base_point = [0.0, 0.0, 0.0]
|
| 154 |
-
axis_vec = [0.0, 0.0, 0.0]
|
| 155 |
-
base_point[axis] = face_coord - sign * overlap_mm
|
| 156 |
-
axis_vec[axis] = sign * height
|
| 157 |
-
for i, u in zip(perp_axes, (du, dv)):
|
| 158 |
-
base_point[i] = center[i] + u
|
| 159 |
-
|
| 160 |
-
gmsh.model.occ.addCylinder(base_point[0], base_point[1], base_point[2],
|
| 161 |
-
axis_vec[0], axis_vec[1], axis_vec[2], radius)
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
def compose_assembly(base_step: str, attachments: list[dict], out_step: str) -> int:
|
| 165 |
-
"""Build a synthetic multi-body STEP: import `base_step`, then add one
|
| 166 |
-
OCC primitive per `attachments` entry (see module docstring for the
|
| 167 |
-
overlap/no-boolean-ops contract), and write the result (all solids,
|
| 168 |
-
unmerged) to `out_step`. Returns the number of 3-D solids written
|
| 169 |
-
(1 + len(attachments) in the normal case where `base_step` itself is
|
| 170 |
-
single-solid).
|
| 171 |
-
|
| 172 |
-
Each attachment dict:
|
| 173 |
-
{"shape": "box" | "cylinder",
|
| 174 |
-
"size": (dx, dy, dz) for a box, (radius, height) for a cylinder,
|
| 175 |
-
"at_face": "+x" | "-x" | "+y" | "-y" | "+z" | "-z",
|
| 176 |
-
"overlap_mm": float, default 2.0,
|
| 177 |
-
"offset": (du, dv) in the face plane, default (0.0, 0.0) (centered)}
|
| 178 |
-
|
| 179 |
-
For a box, `size[axis]` (the dimension along the face normal) is the
|
| 180 |
-
part that gets sunk `overlap_mm` into the base; the other two `size`
|
| 181 |
-
entries lie in the face plane. For a cylinder, the axis runs along the
|
| 182 |
-
face normal (`height`) and `radius` is in the face plane. `offset`
|
| 183 |
-
shifts the attachment's center within the face plane, in the same
|
| 184 |
-
(u, v) order as the two non-normal world axes (e.g. for "+z"/"-z",
|
| 185 |
-
(u, v) = (x, y); for "+x"/"-x", (u, v) = (y, z); for "+y"/"-y",
|
| 186 |
-
(u, v) = (x, z)).
|
| 187 |
-
|
| 188 |
-
NOTE for `tag_contact_patches` callers: a positive `overlap_mm` is a
|
| 189 |
-
fixed geometric gap that `tol_mm` must exceed just to detect the patch
|
| 190 |
-
at all, and any `tol_mm` beyond that dilates the classified patch past
|
| 191 |
-
the true footprint edge (see `tag_contact_patches`'s CAVEAT). A
|
| 192 |
-
synthetic fixture whose `overlap_mm` is large relative to its footprint
|
| 193 |
-
size will over-classify noticeably; real assemblies (near-zero contact
|
| 194 |
-
or a sub-mm clearance, not several mm sunk in) only need a small
|
| 195 |
-
`tol_mm`, where this dilation is negligible.
|
| 196 |
-
"""
|
| 197 |
-
import gmsh
|
| 198 |
-
|
| 199 |
-
gmsh.initialize()
|
| 200 |
-
try:
|
| 201 |
-
gmsh.option.setNumber("General.Terminal", 0)
|
| 202 |
-
gmsh.model.add("assembly_compose")
|
| 203 |
-
gmsh.model.occ.importShapes(base_step)
|
| 204 |
-
gmsh.model.occ.synchronize()
|
| 205 |
-
|
| 206 |
-
xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(-1, -1)
|
| 207 |
-
bbox_min = [xmin, ymin, zmin]
|
| 208 |
-
bbox_max = [xmax, ymax, zmax]
|
| 209 |
-
center = [(bbox_min[i] + bbox_max[i]) / 2.0 for i in range(3)]
|
| 210 |
-
|
| 211 |
-
for att in attachments:
|
| 212 |
-
_place_attachment(att, bbox_min, bbox_max, center)
|
| 213 |
-
|
| 214 |
-
gmsh.model.occ.synchronize()
|
| 215 |
-
gmsh.write(out_step)
|
| 216 |
-
return len(gmsh.model.getEntities(3))
|
| 217 |
-
finally:
|
| 218 |
-
gmsh.finalize()
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
# --------------------------------------------------------------------------
|
| 222 |
-
# Body spec -- Part B Task 2: user-assigned solid roles -> the exact BC
|
| 223 |
-
# parameters the v4 model was trained on (BcPlan), via the same registries
|
| 224 |
-
# `tools/doe/doe.py` used to build the training manifest.
|
| 225 |
-
# --------------------------------------------------------------------------
|
| 226 |
-
_VALID_ROLES = {"casting", "chill", "insulating_sleeve", "exo_sleeve", "ignore"}
|
| 227 |
-
_SLEEVE_ROLES = {"insulating_sleeve", "exo_sleeve"}
|
| 228 |
-
_DEFAULT_CHILL_MATERIAL = "cast_iron"
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
@dataclass
|
| 232 |
-
class BcPlan:
|
| 233 |
-
"""The resolved boundary-condition plan for one casting body, in the
|
| 234 |
-
exact shape `_node_features(version=4)` and `gbm_v2_total_seconds`
|
| 235 |
-
expect (see `feature_spec`/`gbm_bc_spec`). Produced by `validate_bodies`
|
| 236 |
-
-- never construct this by hand from unvalidated user input."""
|
| 237 |
-
bc_config: str # "none" | "chills" | "insulating_sleeve" | "exo_sleeve"
|
| 238 |
-
casting_solid_id: int
|
| 239 |
-
role_solid_ids: list[int] = field(default_factory=list) # bodies forming the patch
|
| 240 |
-
n_chills: int = 0
|
| 241 |
-
chill_effusivity: float = 0.0
|
| 242 |
-
sleeve_MEF: float = 0.0
|
| 243 |
-
exo_q0: float = 0.0 # W/m^2 (training units)
|
| 244 |
-
# Part-B roles task (2026-08-03): per-BODY role, `{solid_id: role}` for
|
| 245 |
-
# every id in `role_solid_ids` (e.g. all-"chill" for the chills branch,
|
| 246 |
-
# the single sleeve id -> its bc_config for the sleeve branch, empty for
|
| 247 |
-
# "none"). Lets a caller (e.g. `predict_casting`'s feed-node selection)
|
| 248 |
-
# decide per BODY ROLE rather than per global `bc_config` -- necessary
|
| 249 |
-
# groundwork for a future mixed-role regime even though `validate_bodies`
|
| 250 |
-
# itself still only ever produces a single-role mapping today.
|
| 251 |
-
role_by_solid: dict[int, str] = field(default_factory=dict)
|
| 252 |
-
# SP4 ADDENDUM (2026-08-08): several sleeve bodies are the norm on real
|
| 253 |
-
# castings. When they all resolve to one MEF it is used exactly and these
|
| 254 |
-
# stay at their defaults. When they differ, `sleeve_MEF` is the
|
| 255 |
-
# contact-area-weighted mean, `mef_weighted` is True, and `mef_spread`
|
| 256 |
-
# carries max(MEF) - min(MEF) so the report can state the spread the
|
| 257 |
-
# single scalar channel is standing in for rather than hiding it.
|
| 258 |
-
mef_weighted: bool = False
|
| 259 |
-
mef_spread: float = 0.0
|
| 260 |
-
|
| 261 |
-
def feature_spec(self, mold_effusivity: float) -> dict:
|
| 262 |
-
"""The `spec` dict `src.features._node_features(version=4, spec=...)`
|
| 263 |
-
reads -- keys match `Global Constraints` in
|
| 264 |
-
`docs/superpowers/plans/2026-07-29-part-b-multibody.md` exactly."""
|
| 265 |
-
return {
|
| 266 |
-
"mold_effusivity": mold_effusivity,
|
| 267 |
-
"bc_config": self.bc_config,
|
| 268 |
-
"chill_effusivity": self.chill_effusivity,
|
| 269 |
-
"sleeve_MEF": self.sleeve_MEF,
|
| 270 |
-
"exo_q0": self.exo_q0,
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
def gbm_bc_spec(self) -> dict:
|
| 274 |
-
"""The `bc_spec` dict `src.gbm_ft2b.gbm_v2_total_seconds` reads."""
|
| 275 |
-
return {
|
| 276 |
-
"bc_config": self.bc_config,
|
| 277 |
-
"n_chills": self.n_chills,
|
| 278 |
-
"sleeve_MEF": self.sleeve_MEF,
|
| 279 |
-
"exo_q0_MW": self.exo_q0 / 1e6,
|
| 280 |
-
}
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
def _load_sleeve_registry(csv_path: str) -> dict[str, dict]:
|
| 284 |
-
"""{sleeve name: {"MEF": float, "type": str}} from
|
| 285 |
-
`tools/sleeves/sleeve_registry.csv` -- `type` is "exo" or "insulating"
|
| 286 |
-
and is used to cross-check a registry-resolved sleeve's physics against
|
| 287 |
-
the declared role (see `validate_bodies`)."""
|
| 288 |
-
out: dict[str, dict] = {}
|
| 289 |
-
with open(csv_path, newline="") as f:
|
| 290 |
-
for row in csv.DictReader(f):
|
| 291 |
-
out[row["name"]] = {"MEF": float(row["MEF"]), "type": row["type"]}
|
| 292 |
-
return out
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
# Declared role -> the sleeve_registry.csv `type` value it requires when the
|
| 296 |
-
# sleeve is resolved BY REGISTRY NAME. Without this check, a registry
|
| 297 |
-
# collision (two products landing on the same MEF, e.g. FOSECO_V88E
|
| 298 |
-
# type=insulating MEF=1.45 vs the exo_calibration.csv row at MEF=1.45) lets
|
| 299 |
-
# a plain insulating sleeve silently resolve a confident nonzero exo_q0 --
|
| 300 |
-
# a silent-wrong-physics path, not merely a cosmetic mismatch.
|
| 301 |
-
_ROLE_TO_SLEEVE_TYPE = {"exo_sleeve": "exo", "insulating_sleeve": "insulating"}
|
| 302 |
-
|
| 303 |
-
# Two sleeves whose MEFs differ by less than this are treated as identical --
|
| 304 |
-
# the registry stores MEF to 2 decimals, so anything below it is float noise,
|
| 305 |
-
# not a real difference in sleeve product.
|
| 306 |
-
_MEF_AGREEMENT_TOL = 1e-6
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
def _resolve_chill_material(params: dict | None) -> str:
|
| 310 |
-
params = params or {}
|
| 311 |
-
return (params.get("material") or _DEFAULT_CHILL_MATERIAL).strip()
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
def validate_bodies(bodies: list[dict],
|
| 315 |
-
chills_csv: str = "data/properties/chills.csv",
|
| 316 |
-
sleeve_registry_csv: str = "tools/sleeves/sleeve_registry.csv",
|
| 317 |
-
exo_calibration_csv: str = "data/properties/exo_calibration.csv",
|
| 318 |
-
tol: float = 0.05,
|
| 319 |
-
mef_weights: dict[int, float] | None = None) -> BcPlan:
|
| 320 |
-
"""Validate a user-assigned role list and resolve it into a `BcPlan`.
|
| 321 |
-
|
| 322 |
-
`bodies` rows: `{"solid_id": int, "role": <see _VALID_ROLES>,
|
| 323 |
-
"params": dict (optional, role-dependent)}`. `ignore` rows are dropped
|
| 324 |
-
before anything else. Enforces the training-regime restriction (owner,
|
| 325 |
-
2026-07-29): EXACTLY one casting; then either >=1 `chill` bodies
|
| 326 |
-
(bc_config="chills") OR one or more sleeve bodies that share a single role
|
| 327 |
-
(`insulating_sleeve`/`exo_sleeve`, bc_config = that role; mixing the two
|
| 328 |
-
roles raises) OR neither (bc_config="none").
|
| 329 |
-
Chills+sleeve mixed, two sleeves, or a casting count != 1 all raise a
|
| 330 |
-
loud `ValueError` explaining the restriction -- the trained model has no
|
| 331 |
-
BC channel for anything else, so silently coercing one regime into
|
| 332 |
-
another would produce a confidently wrong prediction.
|
| 333 |
-
|
| 334 |
-
Chill params: `{"material": str}` (default "cast_iron"); an unknown
|
| 335 |
-
material raises `ValueError` listing `load_chill_effusivities()`'s known
|
| 336 |
-
materials. Multiple chills MUST share one material -- the trained model
|
| 337 |
-
has a single scalar `chill_effusivity` channel, not one per chill, so
|
| 338 |
-
differing materials raise `ValueError` (single chill_effusivity per
|
| 339 |
-
training convention).
|
| 340 |
-
|
| 341 |
-
Sleeve params: exactly one of `{"sleeve": <sleeve_registry.csv name>}`
|
| 342 |
-
(MEF looked up in the registry) or `{"MEF": float}` (manual override) --
|
| 343 |
-
both or neither present raises `ValueError`. An unknown registry name
|
| 344 |
-
raises `ValueError` listing the known names. When resolved BY NAME, the
|
| 345 |
-
registry row's `type` column ("exo" or "insulating") MUST match the
|
| 346 |
-
declared role (`exo_sleeve` requires type=="exo", `insulating_sleeve`
|
| 347 |
-
requires type=="insulating") -- a mismatch raises `ValueError` naming
|
| 348 |
-
both, since two products can share the same MEF (a registry collision)
|
| 349 |
-
while being physically different (an insulating sleeve has no
|
| 350 |
-
exothermic heat release), and resolving one under the other's role
|
| 351 |
-
would silently produce a confidently wrong prediction. Manual
|
| 352 |
-
`{"MEF": float}` overrides BYPASS this check entirely (no registry row
|
| 353 |
-
to check against) -- this is expert mode: the caller is asserting the
|
| 354 |
-
physics directly and is trusted to pick the right role. For `bc_config
|
| 355 |
-
== "exo_sleeve"`, the resolved MEF is turned into a calibrated `exo_q0`
|
| 356 |
-
via `tools.doe.load_properties.resolve_exo_q0_by_mef` (same nearest-MEF
|
| 357 |
-
lookup `src.build_dataset._resolve_exo_q0_v4` uses for training data --
|
| 358 |
-
raises `ValueError` if no calibration point is within `tol`).
|
| 359 |
-
|
| 360 |
-
Several sleeves (SP4 ADDENDUM, 2026-08-08): real castings carry 3-8
|
| 361 |
-
sleeves, so N bodies of one role are accepted, mirroring the chills
|
| 362 |
-
branch. When every sleeve resolves to the same MEF it is used exactly
|
| 363 |
-
(`mef_weighted=False`, `mef_spread=0.0`). When they differ -- e.g. an
|
| 364 |
-
assembly with two sleeve sizes -- the model's single scalar channel
|
| 365 |
-
cannot hold both, so `mef_weights` (contact-patch area per solid_id,
|
| 366 |
-
exactly `tag_contact_patches`'s `per_body_patch_area_mm2`) MUST be
|
| 367 |
-
supplied and the effective MEF is their area-weighted mean, with
|
| 368 |
-
`mef_weighted=True` and `mef_spread` recording what was approximated.
|
| 369 |
-
Omitting the weights on a mixed set raises rather than silently picking
|
| 370 |
-
one sleeve's MEF or an unweighted mean.
|
| 371 |
-
"""
|
| 372 |
-
from tools.doe.load_properties import (load_chill_effusivities,
|
| 373 |
-
resolve_exo_q0_by_mef)
|
| 374 |
-
|
| 375 |
-
active = [b for b in bodies if (b.get("role") or "") != "ignore"]
|
| 376 |
-
|
| 377 |
-
for b in active:
|
| 378 |
-
role = b.get("role")
|
| 379 |
-
if role not in _VALID_ROLES:
|
| 380 |
-
raise ValueError(
|
| 381 |
-
f"body solid_id={b.get('solid_id')!r}: unknown role {role!r} "
|
| 382 |
-
f"(expected one of {sorted(_VALID_ROLES)})")
|
| 383 |
-
|
| 384 |
-
castings = [b for b in active if b["role"] == "casting"]
|
| 385 |
-
chills = [b for b in active if b["role"] == "chill"]
|
| 386 |
-
sleeves = [b for b in active if b["role"] in _SLEEVE_ROLES]
|
| 387 |
-
|
| 388 |
-
if len(castings) != 1:
|
| 389 |
-
raise ValueError(
|
| 390 |
-
f"training-regime restriction: expected exactly one body with "
|
| 391 |
-
f"role='casting', found {len(castings)}")
|
| 392 |
-
casting_solid_id = castings[0]["solid_id"]
|
| 393 |
-
|
| 394 |
-
if chills and sleeves:
|
| 395 |
-
raise ValueError(
|
| 396 |
-
"training-regime restriction: chills and a sleeve cannot be "
|
| 397 |
-
"combined -- the trained model has no mixed chills+sleeve BC "
|
| 398 |
-
"channel in the current training regimes")
|
| 399 |
-
if sleeves:
|
| 400 |
-
sleeve_roles = {b["role"] for b in sleeves}
|
| 401 |
-
if len(sleeve_roles) > 1:
|
| 402 |
-
raise ValueError(
|
| 403 |
-
f"training-regime restriction: all sleeve bodies must share "
|
| 404 |
-
f"one role, got {sorted(sleeve_roles)} -- the trained model "
|
| 405 |
-
f"has a single sleeve BC channel and cannot represent an "
|
| 406 |
-
f"insulating and an exothermic sleeve at once")
|
| 407 |
-
|
| 408 |
-
if chills:
|
| 409 |
-
materials = {_resolve_chill_material(b.get("params")) for b in chills}
|
| 410 |
-
effs = load_chill_effusivities(chills_csv)
|
| 411 |
-
for material in materials:
|
| 412 |
-
if material not in effs:
|
| 413 |
-
raise ValueError(
|
| 414 |
-
f"unknown chill material {material!r} (known: "
|
| 415 |
-
f"{sorted(effs)})")
|
| 416 |
-
if len(materials) > 1:
|
| 417 |
-
raise ValueError(
|
| 418 |
-
f"training-regime restriction: all chills must share one "
|
| 419 |
-
f"material (single chill_effusivity per training "
|
| 420 |
-
f"convention), got {sorted(materials)}")
|
| 421 |
-
material = next(iter(materials))
|
| 422 |
-
chill_ids = [b["solid_id"] for b in chills]
|
| 423 |
-
return BcPlan(
|
| 424 |
-
bc_config="chills",
|
| 425 |
-
casting_solid_id=casting_solid_id,
|
| 426 |
-
role_solid_ids=chill_ids,
|
| 427 |
-
n_chills=len(chills),
|
| 428 |
-
chill_effusivity=float(effs[material]),
|
| 429 |
-
role_by_solid={sid: "chill" for sid in chill_ids},
|
| 430 |
-
)
|
| 431 |
-
|
| 432 |
-
if sleeves:
|
| 433 |
-
bc_config = sleeves[0]["role"]
|
| 434 |
-
registry = None
|
| 435 |
-
mef_by_solid: dict[int, float] = {}
|
| 436 |
-
for sleeve in sleeves:
|
| 437 |
-
params = sleeve.get("params") or {}
|
| 438 |
-
has_name = "sleeve" in params
|
| 439 |
-
has_mef = "MEF" in params
|
| 440 |
-
if has_name and has_mef:
|
| 441 |
-
raise ValueError(
|
| 442 |
-
f"sleeve solid_id={sleeve['solid_id']!r}: specify either "
|
| 443 |
-
f"'sleeve' (registry name) or 'MEF' (manual override), not "
|
| 444 |
-
f"both")
|
| 445 |
-
if not has_name and not has_mef:
|
| 446 |
-
raise ValueError(
|
| 447 |
-
f"sleeve solid_id={sleeve['solid_id']!r}: params must "
|
| 448 |
-
f"contain either 'sleeve' (registry name) or 'MEF' (manual "
|
| 449 |
-
f"override)")
|
| 450 |
-
if has_name:
|
| 451 |
-
if registry is None:
|
| 452 |
-
registry = _load_sleeve_registry(sleeve_registry_csv)
|
| 453 |
-
name = params["sleeve"]
|
| 454 |
-
if name not in registry:
|
| 455 |
-
raise ValueError(
|
| 456 |
-
f"unknown sleeve name {name!r} (known: "
|
| 457 |
-
f"{sorted(registry)})")
|
| 458 |
-
entry = registry[name]
|
| 459 |
-
expected_type = _ROLE_TO_SLEEVE_TYPE[bc_config]
|
| 460 |
-
if entry["type"] != expected_type:
|
| 461 |
-
raise ValueError(
|
| 462 |
-
f"sleeve {name!r} is type={entry['type']} but was "
|
| 463 |
-
f"assigned role {bc_config} (expected "
|
| 464 |
-
f"type={expected_type!r})")
|
| 465 |
-
mef_by_solid[sleeve["solid_id"]] = float(entry["MEF"])
|
| 466 |
-
else:
|
| 467 |
-
mef_by_solid[sleeve["solid_id"]] = float(params["MEF"])
|
| 468 |
-
|
| 469 |
-
sleeve_ids = [b["solid_id"] for b in sleeves]
|
| 470 |
-
mefs = [mef_by_solid[sid] for sid in sleeve_ids]
|
| 471 |
-
spread = float(max(mefs) - min(mefs))
|
| 472 |
-
if spread <= _MEF_AGREEMENT_TOL:
|
| 473 |
-
sleeve_mef = float(mefs[0])
|
| 474 |
-
weighted = False
|
| 475 |
-
spread = 0.0
|
| 476 |
-
else:
|
| 477 |
-
if mef_weights is None:
|
| 478 |
-
raise ValueError(
|
| 479 |
-
f"sleeve bodies {sleeve_ids} resolve to differing MEF "
|
| 480 |
-
f"{sorted(set(mefs))} (spread {spread:.4f}) -- the model has "
|
| 481 |
-
f"a single scalar sleeve_MEF channel, so a mixed set needs "
|
| 482 |
-
f"explicit `mef_weights` (contact-patch area per solid_id, "
|
| 483 |
-
f"as returned by `tag_contact_patches`'s "
|
| 484 |
-
f"'per_body_patch_area_mm2'). Refusing to guess a "
|
| 485 |
-
f"weighting. Contact-patch areas only exist after meshing "
|
| 486 |
-
f"and tagging, so a caller that only has `bodies` (e.g. "
|
| 487 |
-
f"`predict_casting`'s CLI/webapp entry point) cannot supply "
|
| 488 |
-
f"them here -- mesh and tag the part first (`get_or_mesh` "
|
| 489 |
-
f"+ `tag_contact_patches`), then call `validate_bodies` "
|
| 490 |
-
f"again with `mef_weights=per_body_patch_area_mm2`, as "
|
| 491 |
-
f"`tools/sp4/prep.py` and `tools/sp4/score.py` do for the "
|
| 492 |
-
f"SP4 gold set.")
|
| 493 |
-
missing = [sid for sid in sleeve_ids if sid not in mef_weights]
|
| 494 |
-
if missing:
|
| 495 |
-
raise ValueError(
|
| 496 |
-
f"mef_weights is missing solid_id(s) {missing} -- every "
|
| 497 |
-
f"sleeve body needs a weight, got keys "
|
| 498 |
-
f"{sorted(mef_weights)}")
|
| 499 |
-
weights = [float(mef_weights[sid]) for sid in sleeve_ids]
|
| 500 |
-
if any(w <= 0.0 for w in weights):
|
| 501 |
-
raise ValueError(
|
| 502 |
-
f"mef_weights must all be positive contact areas, got "
|
| 503 |
-
f"{dict(zip(sleeve_ids, weights))}")
|
| 504 |
-
total_w = sum(weights)
|
| 505 |
-
sleeve_mef = float(sum(m * w for m, w in zip(mefs, weights)) / total_w)
|
| 506 |
-
weighted = True
|
| 507 |
-
|
| 508 |
-
exo_q0 = 0.0
|
| 509 |
-
if bc_config == "exo_sleeve":
|
| 510 |
-
exo_q0 = resolve_exo_q0_by_mef(sleeve_mef, exo_calibration_csv, tol)
|
| 511 |
-
|
| 512 |
-
return BcPlan(
|
| 513 |
-
bc_config=bc_config,
|
| 514 |
-
casting_solid_id=casting_solid_id,
|
| 515 |
-
role_solid_ids=sleeve_ids,
|
| 516 |
-
sleeve_MEF=sleeve_mef,
|
| 517 |
-
exo_q0=exo_q0,
|
| 518 |
-
role_by_solid={sid: bc_config for sid in sleeve_ids},
|
| 519 |
-
mef_weighted=weighted,
|
| 520 |
-
mef_spread=spread,
|
| 521 |
-
)
|
| 522 |
-
|
| 523 |
-
return BcPlan(bc_config="none", casting_solid_id=casting_solid_id, role_solid_ids=[])
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
# --------------------------------------------------------------------------
|
| 527 |
-
# Contact-patch tagging -- Part B Task 3: mesh the user-designated casting
|
| 528 |
-
# solid, classify its surface triangles by proximity to the role bodies'
|
| 529 |
-
# BReps, and rewrite the mesh's facet groups so the contact patch becomes
|
| 530 |
-
# "Adiabatic" (the exact BC-patch convention `_node_features(version=4)` /
|
| 531 |
-
# `fenicsx_solver.solve_case` read -- see the module docstring's B3 design
|
| 532 |
-
# reference).
|
| 533 |
-
# --------------------------------------------------------------------------
|
| 534 |
-
_ADIABATIC_PHYS_TAG = 3 # alongside prep_parts._VOLUME_PHYS_TAG=1 / _COOLING_PHYS_TAG=2
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
def _triangle_areas(node_xyz: np.ndarray, tris: np.ndarray) -> np.ndarray:
|
| 538 |
-
"""(M,) triangle areas (mm^2) via the standard half cross-product
|
| 539 |
-
magnitude -- `tris` is (M, 3) 0-based vertex indices into `node_xyz`."""
|
| 540 |
-
p0 = node_xyz[tris[:, 0]]
|
| 541 |
-
p1 = node_xyz[tris[:, 1]]
|
| 542 |
-
p2 = node_xyz[tris[:, 2]]
|
| 543 |
-
cross = np.cross(p1 - p0, p2 - p0)
|
| 544 |
-
return 0.5 * np.linalg.norm(cross, axis=1)
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
def _sample_solid_surface_points(solid_id: int) -> np.ndarray:
|
| 548 |
-
"""Assumes a fresh gmsh/OCC session already has the model imported,
|
| 549 |
-
synchronized, AND 2-D-meshed (`gmsh.model.mesh.generate(2)` already ran
|
| 550 |
-
-- done ONCE per `tag_contact_patches` call, not per body, since it
|
| 551 |
-
covers every solid's boundary surfaces in one pass). Returns (P, 3) the
|
| 552 |
-
coordinates of every mesh node lying on any boundary surface
|
| 553 |
-
(`gmsh.model.getBoundary`) of 3-D entity `solid_id`."""
|
| 554 |
-
import gmsh
|
| 555 |
-
|
| 556 |
-
boundary = gmsh.model.getBoundary([(3, solid_id)], oriented=False)
|
| 557 |
-
surf_tags = [t for (d, t) in boundary if d == 2]
|
| 558 |
-
if not surf_tags:
|
| 559 |
-
raise ValueError(f"role body {solid_id}: no boundary surfaces found (not a 3-D solid?)")
|
| 560 |
-
|
| 561 |
-
pts = []
|
| 562 |
-
for surf_tag in surf_tags:
|
| 563 |
-
_node_tags, coords, _param = gmsh.model.mesh.getNodes(2, surf_tag, includeBoundary=True)
|
| 564 |
-
pts.append(np.asarray(coords, dtype=np.float64).reshape(-1, 3))
|
| 565 |
-
points = np.concatenate(pts, axis=0)
|
| 566 |
-
if points.shape[0] == 0:
|
| 567 |
-
raise ValueError(f"role body {solid_id}: surface sampling yielded 0 points")
|
| 568 |
-
return points
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
def _distances_to_solid(points: np.ndarray, centroids: np.ndarray) -> np.ndarray:
|
| 572 |
-
"""(M,) nearest-neighbor distance from each row of `centroids` (M, 3, mm)
|
| 573 |
-
to the sampled boundary point cloud `points` (P, 3, mm) of one role
|
| 574 |
-
solid, via `scipy.spatial.cKDTree`.
|
| 575 |
-
|
| 576 |
-
WHY sampled points + KDTree, not `gmsh.model.getClosestPoint` directly
|
| 577 |
-
on the centroids: probed first (as the brief suggested trying), and
|
| 578 |
-
found to be not just slower but WRONG for axis-aligned primitive faces
|
| 579 |
-
(the chill/sleeve boxes and cylinders this module builds and that real
|
| 580 |
-
role bodies resemble) -- `getClosestPoint`'s orthogonal projection lands
|
| 581 |
-
on the face's UNDERLYING (untrimmed) surface, not the actual bounded
|
| 582 |
-
patch (`isInside()` on the returned point came back False in every case
|
| 583 |
-
probed), so a query point far outside a face's true extent can still
|
| 584 |
-
project to a deceptively small distance whenever it happens to share
|
| 585 |
-
that face's plane (e.g. a base box's OWN top face reported ~2mm to a
|
| 586 |
-
chill's bottom-face plane from a corner ~15mm from the chill's actual
|
| 587 |
-
footprint). Meshing the role solids' real (trimmed) boundary at a size
|
| 588 |
-
tied to `tol_mm` and taking nearest-neighbor distance to those sampled
|
| 589 |
-
points respects the true finite geometry and reproduces the expected
|
| 590 |
-
footprint exactly in this task's synthetic fixtures (measured
|
| 591 |
-
corner-case distance corrected from a wrong ~2mm to the correct
|
| 592 |
-
~11.2mm). Timing (composer fixture, `Mesh.MeshSizeMax = tol_mm/2`,
|
| 593 |
-
2-D-meshing the WHOLE imported model once): 0.16-0.9s total for
|
| 594 |
-
408-11019-node castings (656-8328 Cooling triangles) -- see
|
| 595 |
-
task-3-report.md for the full measurement."""
|
| 596 |
-
from scipy.spatial import cKDTree
|
| 597 |
-
|
| 598 |
-
tree = cKDTree(points)
|
| 599 |
-
dist, _ = tree.query(centroids)
|
| 600 |
-
return np.asarray(dist, dtype=np.float64)
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
def _parse_physical_names_block(lines: list[str], start_idx: int):
|
| 604 |
-
"""`lines[start_idx]` must be the bare `"$PhysicalNames"` marker line.
|
| 605 |
-
Returns `(count_idx, entries, end_idx)`: `count_idx` is the index of the
|
| 606 |
-
entry-count line, `entries` is a list of `(dim, tag, name)` (name
|
| 607 |
-
unquoted), and `end_idx` is the index of the `"$EndPhysicalNames"`
|
| 608 |
-
line."""
|
| 609 |
-
count_idx = start_idx + 1
|
| 610 |
-
cnt = int(lines[count_idx].strip())
|
| 611 |
-
entries = []
|
| 612 |
-
for k in range(cnt):
|
| 613 |
-
dim_s, tag_s, name_s = lines[count_idx + 1 + k].split(maxsplit=2)
|
| 614 |
-
entries.append((int(dim_s), int(tag_s), name_s.strip().strip('"')))
|
| 615 |
-
end_idx = count_idx + 1 + cnt
|
| 616 |
-
return count_idx, entries, end_idx
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
def rewrite_msh_adiabatic(msh_path: str, is_adiabatic: np.ndarray) -> None:
|
| 620 |
-
"""Rewrite `msh_path` in place so that the Cooling-tagged surface
|
| 621 |
-
triangles flagged True in `is_adiabatic` (same length AND order as
|
| 622 |
-
`src.msh_parse.parse_msh(msh_path).tris["Cooling"]` -- both this
|
| 623 |
-
function's own `$Elements` scan and `parse_msh` walk the file's
|
| 624 |
-
triangle elements in on-disk order, so the two arrays line up
|
| 625 |
-
positionally) move to a new "Adiabatic" physical group (pinned tag
|
| 626 |
-
`_ADIABATIC_PHYS_TAG`, reusing an existing "Adiabatic" group's tag if
|
| 627 |
-
the mesh already has one).
|
| 628 |
-
|
| 629 |
-
Deliberately edits the raw text rather than reimporting through gmsh:
|
| 630 |
-
`$Nodes` is never touched and every `$Elements` line is copied through
|
| 631 |
-
unchanged except the physical-group tag (first tag) of the flagged
|
| 632 |
-
triangle lines -- this is what guarantees node ids/coords and tet
|
| 633 |
-
connectivity survive EXACTLY (features/geodesics depend on it; verified
|
| 634 |
-
by the caller re-parsing and comparing `node_xyz`/`tets`). Raises
|
| 635 |
-
loudly (`ValueError`/`AssertionError`) on any structural surprise --
|
| 636 |
-
missing sections, a malformed `$PhysicalNames` block, tag-3 already
|
| 637 |
-
bound to a different name, or an `is_adiabatic` length that doesn't
|
| 638 |
-
match the number of Cooling-tagged triangle elements actually found --
|
| 639 |
-
rather than silently writing a mismatched mesh."""
|
| 640 |
-
from src.msh_parse import _TRI_TYPE
|
| 641 |
-
|
| 642 |
-
lines = Path(msh_path).read_text().splitlines()
|
| 643 |
-
|
| 644 |
-
try:
|
| 645 |
-
phn_idx = lines.index("$PhysicalNames")
|
| 646 |
-
except ValueError:
|
| 647 |
-
raise ValueError(f"{msh_path}: missing $PhysicalNames section")
|
| 648 |
-
count_idx, entries, end_idx = _parse_physical_names_block(lines, phn_idx)
|
| 649 |
-
if lines[end_idx].strip() != "$EndPhysicalNames":
|
| 650 |
-
raise ValueError(f"{msh_path}: malformed $PhysicalNames block (no $EndPhysicalNames "
|
| 651 |
-
f"at the expected offset)")
|
| 652 |
-
|
| 653 |
-
cooling_entries = [e for e in entries if e[0] == 2 and e[2] == "Cooling"]
|
| 654 |
-
if not cooling_entries:
|
| 655 |
-
raise ValueError(f"{msh_path}: no dim=2 'Cooling' physical group found")
|
| 656 |
-
cooling_tag = cooling_entries[0][1]
|
| 657 |
-
|
| 658 |
-
existing_adiabatic = [e for e in entries if e[0] == 2 and e[2] == "Adiabatic"]
|
| 659 |
-
if existing_adiabatic:
|
| 660 |
-
adiabatic_tag = existing_adiabatic[0][1]
|
| 661 |
-
new_entries = entries
|
| 662 |
-
else:
|
| 663 |
-
collision = [e for e in entries if e[0] == 2 and e[1] == _ADIABATIC_PHYS_TAG]
|
| 664 |
-
if collision:
|
| 665 |
-
raise ValueError(f"{msh_path}: physical tag {_ADIABATIC_PHYS_TAG} is already "
|
| 666 |
-
f"bound to {collision[0][2]!r}; cannot pin 'Adiabatic' there")
|
| 667 |
-
adiabatic_tag = _ADIABATIC_PHYS_TAG
|
| 668 |
-
new_entries = entries + [(2, adiabatic_tag, "Adiabatic")]
|
| 669 |
-
|
| 670 |
-
new_phys_lines = [str(len(new_entries))] + [
|
| 671 |
-
f'{d} {t} "{nm}"' for (d, t, nm) in new_entries
|
| 672 |
-
]
|
| 673 |
-
|
| 674 |
-
try:
|
| 675 |
-
elem_idx = lines.index("$Elements")
|
| 676 |
-
except ValueError:
|
| 677 |
-
raise ValueError(f"{msh_path}: missing $Elements section")
|
| 678 |
-
elem_count_idx = elem_idx + 1
|
| 679 |
-
elem_cnt = int(lines[elem_count_idx].strip())
|
| 680 |
-
elem_lines = lines[elem_count_idx + 1: elem_count_idx + 1 + elem_cnt]
|
| 681 |
-
|
| 682 |
-
flags = is_adiabatic.tolist()
|
| 683 |
-
n_flags = len(flags)
|
| 684 |
-
flag_i = 0
|
| 685 |
-
new_elem_lines = []
|
| 686 |
-
for ln in elem_lines:
|
| 687 |
-
parts = ln.split()
|
| 688 |
-
etype = int(parts[1])
|
| 689 |
-
if etype == _TRI_TYPE and int(parts[3]) == cooling_tag:
|
| 690 |
-
if flag_i >= n_flags:
|
| 691 |
-
raise AssertionError(
|
| 692 |
-
f"{msh_path}: is_adiabatic has only {n_flags} flag(s) -- fewer than the "
|
| 693 |
-
f"Cooling-tagged triangle elements found in $Elements -- refusing to write "
|
| 694 |
-
f"a mismatched mesh")
|
| 695 |
-
if flags[flag_i]:
|
| 696 |
-
parts[3] = str(adiabatic_tag)
|
| 697 |
-
ln = " ".join(parts)
|
| 698 |
-
flag_i += 1
|
| 699 |
-
new_elem_lines.append(ln)
|
| 700 |
-
if flag_i != n_flags:
|
| 701 |
-
raise AssertionError(
|
| 702 |
-
f"{msh_path}: is_adiabatic has {n_flags - flag_i} more flag(s) than Cooling-tagged "
|
| 703 |
-
f"triangle elements found in $Elements -- refusing to write a mismatched mesh")
|
| 704 |
-
|
| 705 |
-
new_lines = (
|
| 706 |
-
lines[:count_idx] + new_phys_lines + lines[end_idx:elem_count_idx]
|
| 707 |
-
+ [str(elem_cnt)] + new_elem_lines + lines[elem_count_idx + 1 + elem_cnt:]
|
| 708 |
-
)
|
| 709 |
-
Path(msh_path).write_text("\n".join(new_lines) + "\n")
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
# Back-compat alias -- this function was originally private (`_rewrite_msh_
|
| 713 |
-
# adiabatic`); FT3's `tools/doe/virtual_patches.py` reuses it under the
|
| 714 |
-
# promoted public name `rewrite_msh_adiabatic`, but existing call sites/tests
|
| 715 |
-
# (this module's own `tag_contact_patches`, `tests/test_assembly.py`) still
|
| 716 |
-
# reference the old private name, so both names resolve to the same function.
|
| 717 |
-
_rewrite_msh_adiabatic = rewrite_msh_adiabatic
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
def tag_contact_patches(msh_path: str, step_path: str, role_solid_ids: Sequence[int],
|
| 721 |
-
tol_mm: float | None = None) -> dict:
|
| 722 |
-
"""Classify the casting mesh's (`msh_path`) "Cooling" surface triangles
|
| 723 |
-
by proximity to each of `role_solid_ids`'s BReps in `step_path` (a fresh
|
| 724 |
-
gmsh/OCC session, distinct from the casting mesh's own session --
|
| 725 |
-
`step_path` is the ORIGINAL multi-body assembly STEP the role bodies'
|
| 726 |
-
tags came from, e.g. `list_solids`' `solid_id`, not the reduced
|
| 727 |
-
single-solid STEP the casting was meshed from); triangles within
|
| 728 |
-
`tol_mm` of ANY role body move from "Cooling" to a new "Adiabatic"
|
| 729 |
-
facet group (see `_rewrite_msh_adiabatic`), rewriting `msh_path` IN
|
| 730 |
-
PLACE. Distance-to-body samples each role solid's real (trimmed)
|
| 731 |
-
boundary surfaces into a point cloud (one shared `gmsh.model.mesh.
|
| 732 |
-
generate(2)` pass over the whole imported model, sized off `tol_mm`)
|
| 733 |
-
and takes the nearest-neighbor distance via `scipy.spatial.cKDTree`
|
| 734 |
-
(`_sample_solid_surface_points` + `_distances_to_solid`) -- see
|
| 735 |
-
`_distances_to_solid`'s docstring for why a direct
|
| 736 |
-
`gmsh.model.getClosestPoint` probe was rejected (not just slower, but
|
| 737 |
-
wrong for bounded planar faces).
|
| 738 |
-
|
| 739 |
-
`tol_mm`, if `None` (default), is `0.5 * elem_est` where `elem_est =
|
| 740 |
-
bbox_diag / n_nodes**(1/3)` of the casting mesh (all nodes, matching
|
| 741 |
-
`heal_and_mesh`'s own whole-model bbox convention) -- a rough local
|
| 742 |
-
element-size estimate, so the tolerance scales with mesh density rather
|
| 743 |
-
than being a fixed physical distance that's too tight for a coarse mesh
|
| 744 |
-
or too loose for a fine one.
|
| 745 |
-
|
| 746 |
-
CAVEAT -- tol_mm dilates the detected patch past the true contact
|
| 747 |
-
footprint: since classification is "distance to body <= tol_mm", any
|
| 748 |
-
positive tol_mm grows the patch outward from the geometric contact edge
|
| 749 |
-
by roughly `perimeter * tol_mm + pi * tol_mm**2` (a rectangular-
|
| 750 |
-
footprint Minkowski-sum estimate). This matters most for the SYNTHETIC
|
| 751 |
-
fixtures `compose_assembly` builds with a large POSITIVE `overlap_mm`
|
| 752 |
-
(sunk INTO the base -- see that function's docstring): `tol_mm` must
|
| 753 |
-
exceed `overlap_mm` just to detect the patch at all, and when
|
| 754 |
-
`overlap_mm` is a large fraction of the footprint size, the resulting
|
| 755 |
-
dilation can be a large fraction of the footprint's own area (measured
|
| 756 |
-
up to +140% on a 10x10mm footprint with a 2mm overlap -- see
|
| 757 |
-
task-3-report.md). Real assemblies -- where role bodies sit at
|
| 758 |
-
near-zero contact or a sub-mm clearance rather than several mm sunk in
|
| 759 |
-
-- only need a small `tol_mm` to bridge that gap, so this dilation
|
| 760 |
-
stays a small fraction of a realistic contact-patch area; it is a
|
| 761 |
-
synthetic-fixture-scale concern, not a production one.
|
| 762 |
-
|
| 763 |
-
A role body whose patch comes out EMPTY (no Cooling triangle centroid
|
| 764 |
-
within `tol_mm`) raises `ValueError` naming that body and `tol_mm` --
|
| 765 |
-
never a silent all-sand prediction for a body the caller explicitly
|
| 766 |
-
assigned a role to. Symmetrically, if EVERY Cooling triangle ends up
|
| 767 |
-
classified Adiabatic (role bodies + tol_mm cover the entire casting
|
| 768 |
-
surface), this raises `ValueError` too, rather than silently handing
|
| 769 |
-
downstream feature code (`geodesic_d_cool` et al., see
|
| 770 |
-
`src.features`) a casting mesh with no cooling surface at all to
|
| 771 |
-
measure geodesic distance to -- which would silently degrade to
|
| 772 |
-
bbox_diag-derived constants (confidently wrong physics, not a loud
|
| 773 |
-
failure).
|
| 774 |
-
|
| 775 |
-
Returns `{"per_body_patch_area_mm2": {solid_id: float}, "n_adiab_tris":
|
| 776 |
-
int, "nodes_by_solid": {solid_id: sorted list[int]}}`.
|
| 777 |
-
`per_body_patch_area_mm2` sums each body's own classified
|
| 778 |
-
triangle areas INDEPENDENTLY -- a triangle within `tol_mm` of more than
|
| 779 |
-
one role body (their tolerance zones overlap) is counted once per such
|
| 780 |
-
body, i.e. DOUBLE-COUNTED across `per_body_patch_area_mm2`'s entries,
|
| 781 |
-
even though it is relabeled to "Adiabatic" exactly once; `n_adiab_tris`
|
| 782 |
-
counts each such triangle once (via the union `is_adiabatic` mask), so
|
| 783 |
-
`sum(per_body_patch_area_mm2.values())` can exceed the true total
|
| 784 |
-
Adiabatic area when role bodies' patches overlap.
|
| 785 |
-
|
| 786 |
-
`nodes_by_solid` (Part-B roles task, 2026-08-03): the sorted-unique node
|
| 787 |
-
ids (0-based, into `mesh.node_xyz` -- the SAME indexing as every other
|
| 788 |
-
node id this module and `src.msh_parse` use) of each role body's own
|
| 789 |
-
classified triangles -- computed from the SAME per-body `close` mask as
|
| 790 |
-
`per_body_patch_area_mm2`, so it shares its double-counting property (a
|
| 791 |
-
triangle within `tol_mm` of more than one body contributes its nodes to
|
| 792 |
-
EVERY such body's entry). This is an IN-MEMORY-only piece of metadata:
|
| 793 |
-
it is never written into `msh_path` itself (the `.msh` physical groups,
|
| 794 |
-
node order, and this function's cache-relevant hashes/tags are all
|
| 795 |
-
unchanged by its addition) -- callers needing a role-aware node
|
| 796 |
-
selection (e.g. `predict_casting`'s shrinkage-screening feed-node logic)
|
| 797 |
-
combine this per-SOLID mapping with their own solid_id -> role lookup
|
| 798 |
-
(e.g. `BcPlan.role_by_solid`) themselves; `tag_contact_patches` has no
|
| 799 |
-
opinion on role semantics, only geometry. After rewriting,
|
| 800 |
-
re-parses `msh_path` and asserts `node_xyz` is unchanged (`np.allclose`)
|
| 801 |
-
and `tets` is unchanged (`np.array_equal`) -- the node-order/tet-
|
| 802 |
-
connectivity invariant features/geodesics depend on (see the module
|
| 803 |
-
docstring's caution and `_rewrite_msh_adiabatic`)."""
|
| 804 |
-
from src.msh_parse import parse_msh
|
| 805 |
-
import gmsh
|
| 806 |
-
|
| 807 |
-
mesh = parse_msh(msh_path)
|
| 808 |
-
cooling_tris = mesh.tris.get("Cooling")
|
| 809 |
-
if cooling_tris is None or cooling_tris.shape[0] == 0:
|
| 810 |
-
raise ValueError(f"{msh_path}: no 'Cooling' surface triangles to classify")
|
| 811 |
-
|
| 812 |
-
centroids = mesh.node_xyz[cooling_tris].mean(axis=1)
|
| 813 |
-
tri_areas = _triangle_areas(mesh.node_xyz, cooling_tris)
|
| 814 |
-
|
| 815 |
-
if tol_mm is None:
|
| 816 |
-
bbox_min = mesh.node_xyz.min(axis=0)
|
| 817 |
-
bbox_max = mesh.node_xyz.max(axis=0)
|
| 818 |
-
bbox_diag_mm = float(np.linalg.norm(bbox_max - bbox_min))
|
| 819 |
-
n_nodes = mesh.node_xyz.shape[0]
|
| 820 |
-
elem_est = bbox_diag_mm / (n_nodes ** (1.0 / 3.0))
|
| 821 |
-
tol_mm = 0.5 * elem_est
|
| 822 |
-
|
| 823 |
-
gmsh.initialize()
|
| 824 |
-
try:
|
| 825 |
-
gmsh.option.setNumber("General.Terminal", 0)
|
| 826 |
-
gmsh.model.add("tag_contact_patches")
|
| 827 |
-
gmsh.model.occ.importShapes(step_path)
|
| 828 |
-
gmsh.model.occ.synchronize()
|
| 829 |
-
|
| 830 |
-
# One shared 2-D surface mesh (not per-body) -- covers every role
|
| 831 |
-
# solid's boundary in a single pass. Sized off tol_mm/2 so sampled
|
| 832 |
-
# points are dense enough relative to the tolerance to not miss or
|
| 833 |
-
# blur the true footprint.
|
| 834 |
-
sample_size_mm = max(tol_mm / 2.0, 1e-3)
|
| 835 |
-
gmsh.option.setNumber("Mesh.MeshSizeMin", sample_size_mm)
|
| 836 |
-
gmsh.option.setNumber("Mesh.MeshSizeMax", sample_size_mm)
|
| 837 |
-
gmsh.model.mesh.generate(2)
|
| 838 |
-
|
| 839 |
-
dist_by_body = {}
|
| 840 |
-
for solid_id in role_solid_ids:
|
| 841 |
-
points = _sample_solid_surface_points(solid_id)
|
| 842 |
-
dist_by_body[solid_id] = _distances_to_solid(points, centroids)
|
| 843 |
-
finally:
|
| 844 |
-
gmsh.finalize()
|
| 845 |
-
|
| 846 |
-
is_adiabatic = np.zeros(cooling_tris.shape[0], dtype=bool)
|
| 847 |
-
per_body_patch_area_mm2: dict[int, float] = {}
|
| 848 |
-
nodes_by_solid: dict[int, list[int]] = {}
|
| 849 |
-
for solid_id, dist in dist_by_body.items():
|
| 850 |
-
close = dist <= tol_mm
|
| 851 |
-
area = float(tri_areas[close].sum())
|
| 852 |
-
if area <= 0.0:
|
| 853 |
-
raise ValueError(f"body {solid_id} does not touch the casting within "
|
| 854 |
-
f"tol={tol_mm:.4f}mm")
|
| 855 |
-
per_body_patch_area_mm2[solid_id] = area
|
| 856 |
-
nodes_by_solid[solid_id] = sorted(
|
| 857 |
-
int(n) for n in np.unique(cooling_tris[close].reshape(-1)))
|
| 858 |
-
is_adiabatic |= close
|
| 859 |
-
|
| 860 |
-
if bool(is_adiabatic.all()):
|
| 861 |
-
raise ValueError(
|
| 862 |
-
"role bodies cover the entire casting surface; no cooling surface remains "
|
| 863 |
-
f"-- check roles/tol (tol={tol_mm:.4f}mm)")
|
| 864 |
-
|
| 865 |
-
rewrite_msh_adiabatic(msh_path, is_adiabatic)
|
| 866 |
-
|
| 867 |
-
mesh_after = parse_msh(msh_path)
|
| 868 |
-
if not np.allclose(mesh_after.node_xyz, mesh.node_xyz):
|
| 869 |
-
raise AssertionError(f"{msh_path}: node_xyz changed after Adiabatic rewrite "
|
| 870 |
-
f"(node order/coords must be byte-identical)")
|
| 871 |
-
if mesh_after.tets.shape != mesh.tets.shape or not np.array_equal(mesh_after.tets, mesh.tets):
|
| 872 |
-
raise AssertionError(f"{msh_path}: tets changed after Adiabatic rewrite "
|
| 873 |
-
f"(tet connectivity must be byte-identical)")
|
| 874 |
-
|
| 875 |
-
return {"per_body_patch_area_mm2": per_body_patch_area_mm2,
|
| 876 |
-
"n_adiab_tris": int(is_adiabatic.sum()),
|
| 877 |
-
"nodes_by_solid": nodes_by_solid}
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
# --------------------------------------------------------------------------
|
| 881 |
-
# CLI printer
|
| 882 |
-
# --------------------------------------------------------------------------
|
| 883 |
-
def main(argv: list[str] | None = None) -> int:
|
| 884 |
-
ap = argparse.ArgumentParser(
|
| 885 |
-
description="List the 3-D solids in a (possibly multi-body) STEP file.")
|
| 886 |
-
ap.add_argument("step_path", help="path to a .step/.stp file")
|
| 887 |
-
args = ap.parse_args(argv)
|
| 888 |
-
|
| 889 |
-
rows = list_solids(args.step_path)
|
| 890 |
-
header = f"{'solid_id':>8} {'name':<24} {'volume_mm3':>14} {'bbox (mm)':<48} {'com (mm)':<30}"
|
| 891 |
-
print(header)
|
| 892 |
-
print("-" * len(header))
|
| 893 |
-
for row in rows:
|
| 894 |
-
bbox_str = "[" + ", ".join(f"{v:.1f}" for v in row["bbox"]) + "]"
|
| 895 |
-
com_str = "[" + ", ".join(f"{v:.1f}" for v in row["com"]) + "]"
|
| 896 |
-
name = row["name"] or "<unnamed>"
|
| 897 |
-
print(f"{row['solid_id']:>8} {name:<24} {row['volume_mm3']:>14.2f} "
|
| 898 |
-
f"{bbox_str:<48} {com_str:<30}")
|
| 899 |
-
print(f"\n{len(rows)} solid(s) total.")
|
| 900 |
-
return 0
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
if __name__ == "__main__":
|
| 904 |
-
raise SystemExit(main())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/inference_props.py
CHANGED
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
| 19 |
|
| 20 |
from tools.doe.load_properties import load_materials
|
| 21 |
|
| 22 |
-
DEFAULT_ALLOY = "EN-
|
| 23 |
|
| 24 |
_REQUIRED_KEYS = ("k", "rho", "cp", "L_kJ_kg", "TL", "TS", "Tpour", "mold_effusivity")
|
| 25 |
|
|
|
|
| 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 |
|
src/model_api.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mesh-in, field-out entry point for the published v5.1 model.
|
| 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`) and returns the solidification time in seconds for every
|
| 9 |
+
node, in the mesh's node order. Getting from a STEP file to such a mesh (CAD
|
| 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.inference_props import resolve_props
|
| 19 |
+
from src.msh_parse import Mesh, parse_msh
|
| 20 |
+
from src.predict_casting import DEFAULT_W_STD, DEFAULT_W_TOTAL, load_hybrid, predict_field
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class CastSolidModel:
|
| 24 |
+
def __init__(self, weights_dir: str = "weights", device: str | None = None):
|
| 25 |
+
w = lambda name: os.path.join(weights_dir, name)
|
| 26 |
+
self.device = device
|
| 27 |
+
(self.gnn, self.gbm_total, self.norm, self.cfg, _,
|
| 28 |
+
self.gbm_std) = load_hybrid(w("gnn_v5.1.safetensors"), w("gbm_total_v3.skops"),
|
| 29 |
+
device, hgb_std_path=w("gbm_std_v2.skops"))
|
| 30 |
+
self.device = device or next(self.gnn.parameters()).device.type
|
| 31 |
+
|
| 32 |
+
def predict(self, mesh: str | Mesh, alloy: str | None = None, mold: str = "green_sand",
|
| 33 |
+
props_json: str | None = None) -> np.ndarray:
|
| 34 |
+
"""Per-node solidification time (s) for `mesh`.
|
| 35 |
+
|
| 36 |
+
`alloy`/`mold` name rows of `data/properties/` (default EN-GJS-400-15
|
| 37 |
+
in green sand); `props_json` replaces both with a custom property set.
|
| 38 |
+
"""
|
| 39 |
+
if not isinstance(mesh, Mesh):
|
| 40 |
+
mesh = parse_msh(os.fspath(mesh))
|
| 41 |
+
props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
|
| 42 |
+
seconds, _ = predict_field(mesh, self.gnn, self.gbm_total, self.norm, cfg=self.cfg,
|
| 43 |
+
device=self.device, props=props, hgb_std=self.gbm_std,
|
| 44 |
+
w_total=DEFAULT_W_TOTAL, w_std=DEFAULT_W_STD)
|
| 45 |
+
return np.asarray(seconds)
|
| 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 (the same
|
| 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]
|
src/predict_casting.py
CHANGED
|
@@ -84,10 +84,7 @@ 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 |
-
`niyama_confidence` marking (spec ADDENDUM 2) is UNCHANGED by this -- still
|
| 89 |
-
keyed only on `plan.bc_config == "chills"`, regardless of `niyama_source`;
|
| 90 |
-
retiring that scoping is a later (promotion-step) task.
|
| 91 |
|
| 92 |
bc-reach Task 4 (cfg-driven v5 feature building at inference): the film
|
| 93 |
branch's `compute_features_inference` call now reads
|
|
@@ -102,8 +99,7 @@ independent of but in practice equal to `node_in`), so loading one through
|
|
| 102 |
`exo_mid`/`exo_long`/`chill_short`/`chill_mid`/`chill_long`) with NO other
|
| 103 |
change to this module -- `compute_bc_reach_channels` (features v5) already
|
| 104 |
resolves its chill/exo source patch off the SAME `spec`/`bc_spec` dicts
|
| 105 |
-
this module already builds from
|
| 106 |
-
literal, so the reach channels see EXACTLY the same source magnitudes the
|
| 107 |
training builder (`src.build_dataset.build_v5`) does. A v4 (or absent)
|
| 108 |
`cfg["feature_version"]` is byte-identical to the pre-Task-4 code (defaults
|
| 109 |
to `4`, same as before this dispatch existed)."""
|
|
@@ -230,18 +226,12 @@ def _std_row_for_predict_field(row_16: np.ndarray, X: np.ndarray, n_features: in
|
|
| 230 |
|
| 231 |
|
| 232 |
def predict_field(mesh, gnn_model, hgb_model, norm, cfg=None, device=None, tta=0,
|
| 233 |
-
k=16, baseline_norm=None, props=None,
|
| 234 |
lambda_shrink: float = 1.0, w_total: float = DEFAULT_W_TOTAL,
|
| 235 |
w_std: float = DEFAULT_W_STD) -> tuple:
|
| 236 |
-
"""
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
branch's `spec` stays the all-sand literal and `gbm_v2_total_seconds` is
|
| 240 |
-
called with `bc_spec=None` (its own "plain sand mold" default). When
|
| 241 |
-
given, the film branch's `spec` comes from `bc_plan.feature_spec(...)`
|
| 242 |
-
(carries `chill_effusivity`/`sleeve_MEF`/`exo_q0`/`bc_config`) and
|
| 243 |
-
`gbm_v2_total_seconds` gets `bc_spec=bc_plan.gbm_bc_spec()`. Ignored
|
| 244 |
-
entirely by the non-film (SP1) path -- SP1 has no BC channel at all.
|
| 245 |
|
| 246 |
`hgb_std` (variance-recomposition Task 3, recomposition swapped to
|
| 247 |
`hybrid_field_soft` by soft-anchor Task 3): an optional loaded GBM
|
|
@@ -311,13 +301,9 @@ def predict_field(mesh, gnn_model, hgb_model, norm, cfg=None, device=None, tta=0
|
|
| 311 |
"(the v2-total GBM scale head) -- got None")
|
| 312 |
_check_hgb_dim(hgb_model, N_GBM_V2_FEATURES, "film (v4)")
|
| 313 |
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
else:
|
| 318 |
-
spec = {"mold_effusivity": props["mold_effusivity"], "bc_config": "none",
|
| 319 |
-
"chill_effusivity": 0.0, "sleeve_MEF": 0.0, "exo_q0": 0.0}
|
| 320 |
-
bc_spec = None
|
| 321 |
feat = compute_features_inference(mesh, version=cfg.get("feature_version", 4), spec=spec)
|
| 322 |
X, pos = feat["X"], feat["pos"]
|
| 323 |
g_vec = alloy_g_vector(props)
|
|
@@ -437,156 +423,6 @@ def _shrinkage_surface_and_feed_nodes(mesh):
|
|
| 437 |
return surface_nodes.astype(np.int64), feed_nodes.astype(np.int64)
|
| 438 |
|
| 439 |
|
| 440 |
-
# --------------------------------------------------------------------------- #
|
| 441 |
-
# Part-B roles task (2026-08-03): role-aware feed-node selection, replacing
|
| 442 |
-
# review finding I1's coarse `plan.bc_config == "chills"` special case (which
|
| 443 |
-
# forced feed_nodes empty for the WHOLE input whenever any chill role was
|
| 444 |
-
# present, rather than excluding just the chill bodies' own patch nodes).
|
| 445 |
-
# `src.assembly.tag_contact_patches` relabels EVERY role body's contact patch
|
| 446 |
-
# "Adiabatic" regardless of role (chills included) -- these two pure helpers
|
| 447 |
-
# recover the per-BODY-role distinction from `tag_contact_patches`'s
|
| 448 |
-
# `nodes_by_solid` output (per-solid, geometry-only) + a solid_id -> role
|
| 449 |
-
# lookup (`BcPlan.role_by_solid`), so the exclusion decision is made per
|
| 450 |
-
# body, never per global `bc_config`.
|
| 451 |
-
#
|
| 452 |
-
# Investigation note (binding on the design below): in bodies mode the
|
| 453 |
-
# casting mesh handed to `tag_contact_patches` never carries a PRE-EXISTING
|
| 454 |
-
# "Adiabatic" group of its own. `bodies` requires `mesh_mode="prep"` (see the
|
| 455 |
-
# guard above `predict_casting` raises), and `tools.doe.prep_parts.
|
| 456 |
-
# heal_and_mesh` -- the only meshing path "prep" mode uses -- tags every
|
| 457 |
-
# casting surface "Cooling" uniformly with no Adiabatic seed at all (verified
|
| 458 |
-
# in `tools/doe/prep_parts.py` and `src/step_mesh.py`: neither ever calls
|
| 459 |
-
# `setPhysicalName(2, ..., "Adiabatic")`; the ONLY producer of an "Adiabatic"
|
| 460 |
-
# physical group anywhere in this codebase is `src.assembly.
|
| 461 |
-
# rewrite_msh_adiabatic`, i.e. THIS tagging pass itself, or FT3's training-
|
| 462 |
-
# data patch simulator `tools/doe/virtual_patches.py`, which never runs at
|
| 463 |
-
# inference time). So every "Adiabatic" triangle on a bodies-mode inference
|
| 464 |
-
# mesh is a role-body contact patch -- there is no separate "genuine riser"
|
| 465 |
-
# contribution that could coexist with role patches and would need unioning
|
| 466 |
-
# in on top of the per-role mapping below. The bare/all-sand path (no
|
| 467 |
-
# `bodies`) is therefore UNCHANGED by this task: `_shrinkage_surface_and_
|
| 468 |
-
# feed_nodes`'s "Adiabatic" union (if the mesh has one at all -- only
|
| 469 |
-
# possible via `mesh_mode="raw"` or a training `.msh` with real user-tagged
|
| 470 |
-
# riser faces) keeps meaning exactly what it always has.
|
| 471 |
-
# --------------------------------------------------------------------------- #
|
| 472 |
-
def _nodes_by_role_from_tagging(nodes_by_solid: dict, role_by_solid: dict) -> dict:
|
| 473 |
-
"""Group `tag_contact_patches`'s per-solid contact-patch node ids
|
| 474 |
-
(`nodes_by_solid`, `{solid_id: node ids}` -- solid_id keys may arrive as
|
| 475 |
-
`str` after a mesh-cache JSON round-trip on a cache HIT, handled here via
|
| 476 |
-
`int()`) by each solid's declared role (`role_by_solid`, `{solid_id:
|
| 477 |
-
role}`, e.g. `BcPlan.role_by_solid`).
|
| 478 |
-
|
| 479 |
-
A solid_id present in `nodes_by_solid` with no entry in `role_by_solid`
|
| 480 |
-
is dropped rather than raising, but LOUDLY (`logger.warning`, naming the
|
| 481 |
-
solid_id) -- review round 1, M1: silently dropping it would silently
|
| 482 |
-
mis-attribute (or simply lose) that body's contact-patch nodes. In the
|
| 483 |
-
normal `predict_casting` call path this warning never actually fires:
|
| 484 |
-
`_role_aware_feed_nodes` (review round 1, H1) only calls this function
|
| 485 |
-
after confirming `nodes_by_solid` covers every id in `plan.
|
| 486 |
-
role_solid_ids`, and `role_by_solid` covers exactly those same ids by
|
| 487 |
-
construction in `validate_bodies` -- this is a defense-in-depth net for
|
| 488 |
-
any future/direct caller that skips that completeness check, not a code
|
| 489 |
-
path this module exercises today.
|
| 490 |
-
|
| 491 |
-
Returns `{role: sorted node ids}`.
|
| 492 |
-
|
| 493 |
-
NOTE on shared-node precedence (L3, review round 1): if a node's
|
| 494 |
-
contact-patch membership spans more than one role (two role bodies'
|
| 495 |
-
`tol_mm` zones overlap on the casting surface -- see `tag_contact_
|
| 496 |
-
patches`'s documented double-counting of `per_body_patch_area_mm2`),
|
| 497 |
-
this function keeps it under EVERY role it was classified into, and
|
| 498 |
-
`_feed_nodes_from_role_patches` below then unions in whichever of those
|
| 499 |
-
roles is a sleeve role -- i.e. a node shared between a chill patch and a
|
| 500 |
-
sleeve patch counts as fed ("sleeve wins"), matching the spec's literal
|
| 501 |
-
per-role union rule. `validate_bodies` forbids a chills+sleeve mix today
|
| 502 |
-
so this can never actually occur via the validated path (only via a
|
| 503 |
-
directly-constructed mapping that bypasses it, e.g. this module's own
|
| 504 |
-
mixed-mapping unit test). A FUTURE mixed-role regime might instead want
|
| 505 |
-
"chill wins" for a shared node (the more physically conservative choice
|
| 506 |
-
-- proximity to a chill accelerates freezing there regardless of a
|
| 507 |
-
nearby sleeve) -- flagged here for that future design, not implemented
|
| 508 |
-
now."""
|
| 509 |
-
grouped: dict[str, set] = {}
|
| 510 |
-
for sid_key, nodes in nodes_by_solid.items():
|
| 511 |
-
sid = int(sid_key)
|
| 512 |
-
role = role_by_solid.get(sid)
|
| 513 |
-
if role is None:
|
| 514 |
-
logger.warning(
|
| 515 |
-
f"_nodes_by_role_from_tagging: solid_id={sid} has classified "
|
| 516 |
-
f"contact-patch nodes but no entry in role_by_solid -- "
|
| 517 |
-
f"dropping it from the per-role feed-node mapping rather than "
|
| 518 |
-
f"silently mis-attributing it to some other role")
|
| 519 |
-
continue
|
| 520 |
-
grouped.setdefault(role, set()).update(int(n) for n in nodes)
|
| 521 |
-
return {role: sorted(ids) for role, ids in grouped.items()}
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
def _feed_nodes_from_role_patches(nodes_by_role: dict) -> np.ndarray:
|
| 525 |
-
"""The sorted-unique union of every SLEEVE role's nodes
|
| 526 |
-
(`src.assembly._SLEEVE_ROLES` = `insulating_sleeve`/`exo_sleeve`) in
|
| 527 |
-
`nodes_by_role` (`{role: iterable of node ids}`) -- a chill patch, or any
|
| 528 |
-
other non-sleeve role, NEVER contributes a feed node. Pure function of
|
| 529 |
-
its argument, with NO `BcPlan`/`bc_config` involved at all: it stays
|
| 530 |
-
correct even fed a directly-constructed mapping mixing roles that
|
| 531 |
-
`validate_bodies` itself would reject (chills + a sleeve combined) --
|
| 532 |
-
the decision is per body role, not per global config. See
|
| 533 |
-
`_nodes_by_role_from_tagging`'s docstring (L3, review round 1) for the
|
| 534 |
-
shared-node ("sleeve wins") precedence this union implies when a node is
|
| 535 |
-
classified into more than one role."""
|
| 536 |
-
feed: set = set()
|
| 537 |
-
for role, nodes in nodes_by_role.items():
|
| 538 |
-
if role in _SLEEVE_ROLES:
|
| 539 |
-
feed.update(int(n) for n in nodes)
|
| 540 |
-
return np.asarray(sorted(feed), dtype=np.int64)
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
def _role_aware_feed_nodes(step_path: str, nodes_by_solid: dict, plan,
|
| 544 |
-
fallback_feed_nodes: np.ndarray) -> np.ndarray:
|
| 545 |
-
"""Review round 1, H1: `nodes_by_solid` is only present in `prep_info`
|
| 546 |
-
when `tag_contact_patches` actually ran on THIS call. A mesh-cache HIT
|
| 547 |
-
served from a side-car JSON written BEFORE the Part-B roles task never
|
| 548 |
-
had `nodes_by_solid` written into it at all (the key didn't exist yet),
|
| 549 |
-
and even a post-task entry could in principle cover only some of
|
| 550 |
-
`plan.role_solid_ids`. `MESH_PIPELINE_VERSION` is deliberately NOT
|
| 551 |
-
bumped for this task (cache keys are binding, per the task brief), so
|
| 552 |
-
such LEGACY entries are genuinely reachable today -- `runs/mesh_cache/`
|
| 553 |
-
already holds pre-task assembly entries, including both gold parts.
|
| 554 |
-
Naively grouping incomplete/absent `nodes_by_solid` data would silently
|
| 555 |
-
degrade `feed_nodes` to empty for EVERY `bc_config` (not just chills),
|
| 556 |
-
a silent regression on exactly the sleeve inputs this task's own byte-
|
| 557 |
-
identity test binds. `plan.role_solid_ids` MUST all be present as keys
|
| 558 |
-
of `nodes_by_solid` (after normalizing str keys) for the new per-role
|
| 559 |
-
path to run at all; otherwise this loudly (`logger.warning`, naming
|
| 560 |
-
`step_path`) falls back to the PRE-CHANGE rule: empty for `bc_config ==
|
| 561 |
-
"chills"`, the passed-through `fallback_feed_nodes` (the plain
|
| 562 |
-
"Adiabatic"-tag node union `_shrinkage_surface_and_feed_nodes` already
|
| 563 |
-
computed) for every other config -- i.e. exactly review finding I1's
|
| 564 |
-
original fix, byte-identical to `predict_casting`'s behavior before this
|
| 565 |
-
task. Recommends refreshing/clearing the mesh-cache entry for
|
| 566 |
-
`step_path` (see `src.mesh_cache.clear_mesh_cache`) so a later run gets
|
| 567 |
-
full per-role accuracy."""
|
| 568 |
-
required_ids = set(plan.role_solid_ids)
|
| 569 |
-
covered_ids = {int(k) for k in nodes_by_solid}
|
| 570 |
-
if not required_ids.issubset(covered_ids):
|
| 571 |
-
logger.warning(
|
| 572 |
-
f"predict_casting: role-aware contact-patch node metadata is "
|
| 573 |
-
f"missing or incomplete for step_path={step_path!r} "
|
| 574 |
-
f"(nodes_by_solid covers solid_id(s) {sorted(covered_ids)}, but "
|
| 575 |
-
f"plan.role_solid_ids requires {sorted(required_ids)}) -- this is "
|
| 576 |
-
f"almost certainly a LEGACY runs/mesh_cache/ entry written before "
|
| 577 |
-
f"the Part-B roles task (nodes_by_solid didn't exist yet). "
|
| 578 |
-
f"Falling back to the pre-change feed-node rule (empty for "
|
| 579 |
-
f"bc_config=='chills', the Adiabatic-tag union otherwise) rather "
|
| 580 |
-
f"than silently zeroing every config's feed_nodes. Recommend "
|
| 581 |
-
f"clearing/refreshing the mesh-cache entry for this STEP file "
|
| 582 |
-
f"(see src.mesh_cache.clear_mesh_cache) to restore full per-role "
|
| 583 |
-
f"feed-node accuracy on the next run.")
|
| 584 |
-
return (np.zeros(0, dtype=np.int64) if plan.bc_config == "chills"
|
| 585 |
-
else fallback_feed_nodes)
|
| 586 |
-
nodes_by_role = _nodes_by_role_from_tagging(nodes_by_solid, plan.role_by_solid)
|
| 587 |
-
return _feed_nodes_from_role_patches(nodes_by_role)
|
| 588 |
-
|
| 589 |
-
|
| 590 |
def _shrinkage_result_for_return(screen: dict) -> dict:
|
| 591 |
"""Directive 6: the `screen_casting` result MINUS its bulky per-node
|
| 592 |
arrays (`niyama`/`porosity_risk` dropped), plus summary counts +
|
|
@@ -633,37 +469,6 @@ def _write_shrinkage_summary_block(f, shrinkage) -> None:
|
|
| 633 |
return
|
| 634 |
f.write("shrinkage screening:\n")
|
| 635 |
f.write(f" niyama_source: {shrinkage['niyama_source']}\n")
|
| 636 |
-
if shrinkage.get("niyama_confidence") == "low":
|
| 637 |
-
# Spec ADDENDUM 2 (2026-08-01, owner-approved scoped ship): Gate C
|
| 638 |
-
# passed both floors on the all-sand slice (sleeves owner-accepted as
|
| 639 |
-
# intermediate: rho 0.768/0.691, below the 0.8 floor) but failed on the
|
| 640 |
-
# chill-bearing slice (Spearman rho 0.313) -- the proxy Niyama
|
| 641 |
-
# identity `Tdot_proxy = (TL-TS)/t_s` has no way to see a chill's
|
| 642 |
-
# locally-enhanced cooling rate, so its Niyama ranking on a
|
| 643 |
-
# chill-bearing input is not trustworthy the way it is on a bare or
|
| 644 |
-
# sleeve-only casting. Macro-shrinkage/sinks/feeders don't depend on
|
| 645 |
-
# Niyama at all and keep full confidence regardless.
|
| 646 |
-
#
|
| 647 |
-
# Learned-heads Task 3 review round 1, F3: the marking itself
|
| 648 |
-
# (`niyama_confidence == "low"` on any chill-bearing input) STAYS as
|
| 649 |
-
# is until the promote step (per the spec's own retirement plan) --
|
| 650 |
-
# only the CAVEAT TEXT is now conditional on `niyama_source`. The
|
| 651 |
-
# proxy's specific failure mode (and its Gate C rho number) is a
|
| 652 |
-
# claim about the PROXY identity, not about a learned checkpoint's
|
| 653 |
-
# heads -- citing "rho 0.313" next to a learned-source result would
|
| 654 |
-
# misattribute the proxy's own measured shortfall to a different
|
| 655 |
-
# (not yet slice-validated) source. A learned checkpoint gets a
|
| 656 |
-
# neutral caveat instead, pending its own Gate C learned-mode slice
|
| 657 |
-
# numbers (`tools/gate_c.py field_source="learned"`).
|
| 658 |
-
if shrinkage["niyama_source"] == "proxy":
|
| 659 |
-
f.write(" niyama confidence: LOW (chill-bearing input; proxy Niyama cannot see "
|
| 660 |
-
"chill-local cooling rates — Gate C chills slice rho 0.313)\n")
|
| 661 |
-
elif shrinkage["niyama_source"] == "learned":
|
| 662 |
-
f.write(" niyama confidence: LOW (chill-bearing input; learned-head Niyama "
|
| 663 |
-
"pending slice validation)\n")
|
| 664 |
-
else:
|
| 665 |
-
f.write(f" niyama confidence: LOW (chill-bearing input; "
|
| 666 |
-
f"niyama_source={shrinkage['niyama_source']!r})\n")
|
| 667 |
flagged_line = f" flagged nodes: {shrinkage['flagged_frac'] * 100.0:.1f}%"
|
| 668 |
if shrinkage["niyama_source"] == "proxy":
|
| 669 |
# Review round 1, finding 4: the proxy path's risk mask is
|
|
@@ -819,47 +624,16 @@ from src.step_mesh import mesh_step
|
|
| 819 |
from src.msh_parse import parse_msh
|
| 820 |
from src import ood as _ood
|
| 821 |
from src.mesh_cache import get_or_mesh
|
| 822 |
-
from src.assembly import validate_bodies, tag_contact_patches, _SLEEVE_ROLES
|
| 823 |
from tools.doe.prep_parts import DEFAULT_TARGET_NODES
|
| 824 |
|
| 825 |
|
| 826 |
-
def _bodies_cache_key(bodies: list, plan) -> str:
|
| 827 |
-
"""Part B Task 4: a normalized-content hash of `bodies`, folding in the
|
| 828 |
-
resolved `BcPlan` scalars (`chill_effusivity`/`sleeve_MEF`/`exo_q0`) for
|
| 829 |
-
whichever body ids are `plan.role_solid_ids` -- NOT the raw role/params
|
| 830 |
-
text the caller typed -- so a `sleeve_registry.csv`/`chills.csv` edit
|
| 831 |
-
changes the mesh-cache key even for a byte-identical `bodies.json` (the
|
| 832 |
-
key means "this exact resolved BC plan", not just "this exact JSON
|
| 833 |
-
text"). Order-invariant (sorted by `solid_id`) and role-sensitive (a
|
| 834 |
-
role change always changes that row, even for a body with no numeric
|
| 835 |
-
params of its own, e.g. `"casting"` or `"ignore"`)."""
|
| 836 |
-
role_ids = set(plan.role_solid_ids)
|
| 837 |
-
rows = []
|
| 838 |
-
for i, b in enumerate(bodies):
|
| 839 |
-
if "solid_id" not in b:
|
| 840 |
-
raise ValueError(
|
| 841 |
-
f"_bodies_cache_key: bodies[{i}] (role={b.get('role')!r}) is "
|
| 842 |
-
f"missing 'solid_id' -- every row, including 'ignore' rows, "
|
| 843 |
-
f"must carry a solid_id")
|
| 844 |
-
row = {"solid_id": b["solid_id"], "role": b.get("role")}
|
| 845 |
-
if b["solid_id"] in role_ids:
|
| 846 |
-
if plan.bc_config == "chills":
|
| 847 |
-
row["chill_effusivity"] = plan.chill_effusivity
|
| 848 |
-
elif plan.bc_config in ("insulating_sleeve", "exo_sleeve"):
|
| 849 |
-
row["sleeve_MEF"] = plan.sleeve_MEF
|
| 850 |
-
row["exo_q0"] = plan.exo_q0
|
| 851 |
-
rows.append(row)
|
| 852 |
-
rows.sort(key=lambda r: r["solid_id"])
|
| 853 |
-
return hashlib.sha256(json.dumps(rows, sort_keys=True).encode("utf-8")).hexdigest()
|
| 854 |
-
|
| 855 |
-
|
| 856 |
def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
| 857 |
mesh_mode="prep",
|
| 858 |
gnn_ckpt=DEFAULT_GNN_CKPT,
|
| 859 |
hgb_path=DEFAULT_HGB,
|
| 860 |
ood_json="data/features_v2/ood_ranges.json", device=None,
|
| 861 |
tta=0, alloy=None, mold="green_sand", props_json=None,
|
| 862 |
-
|
| 863 |
hgb_std_path=_USE_DEFAULT,
|
| 864 |
lambda_shrink: float = 1.0, w_total: float = DEFAULT_W_TOTAL,
|
| 865 |
w_std: float = DEFAULT_W_STD, screen_shrinkage: bool = True,
|
|
@@ -887,24 +661,6 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 887 |
explicit `elem_size` raises `ValueError` rather than being ignored --
|
| 888 |
`elem_size` forces "raw", which never runs the density search at all.
|
| 889 |
|
| 890 |
-
`bodies` (Part B Task 4): a multi-body STEP's role assignment -- either
|
| 891 |
-
a list of `{"solid_id": int, "role": ..., "params": {...}}` dicts (see
|
| 892 |
-
`src.assembly.validate_bodies`) or a `str`/path-like path to a JSON file
|
| 893 |
-
holding that list (loaded via `json.load`). `None` (the default) is
|
| 894 |
-
byte-identical to the pre-Part-B behavior: no `validate_bodies` call,
|
| 895 |
-
`keep_solid_id=None`/`bodies_key="none"` into `get_or_mesh`, no
|
| 896 |
-
`tag_contact_patches` call, and the film spec/gbm bc_spec stay at their
|
| 897 |
-
all-sand-mold defaults (see `predict_field`). When given, the flow is
|
| 898 |
-
`validate_bodies` -> `get_or_mesh(..., keep_solid_id=plan.casting_solid_id,
|
| 899 |
-
bodies_key=<normalized hash>, post_mesh=<tag_contact_patches closure>)`
|
| 900 |
-
-> `predict_field(..., bc_plan=plan)`; the resolved `bc_config` and any
|
| 901 |
-
contact-patch areas are added to the result dict and the summary file.
|
| 902 |
-
Requires `mesh_mode="prep"` (the effective mode after any `elem_size`
|
| 903 |
-
override) -- role-based casting-solid selection and contact tagging
|
| 904 |
-
only run through that pipeline; combining `bodies` with the "raw" mesh
|
| 905 |
-
path raises `ValueError` rather than silently ignoring the role
|
| 906 |
-
assignment.
|
| 907 |
-
|
| 908 |
`hgb_std_path` (variance-recomposition Task 3; default changed in Task 6;
|
| 909 |
recomposition swapped to `hybrid_field_soft` by soft-anchor Task 3): an
|
| 910 |
optional path to a `joblib`-dumped GBM std-head model
|
|
@@ -921,8 +677,8 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 921 |
form), to force the quantile-anchor recomposition on ANY checkpoint,
|
| 922 |
including the new default film one -- this is byte-identical to the
|
| 923 |
pre-Task-3 code. Passing an explicit path (or an already-loaded model
|
| 924 |
-
object) still requires a film-capable checkpoint (
|
| 925 |
-
|
| 926 |
otherwise, BEFORE meshing. When the soft-anchor recomposition is active,
|
| 927 |
the summary file gains a `soft recomposition: a=..., m=...,
|
| 928 |
target_unreachable=..., clip_frac=...` line (`target_unreachable`
|
|
@@ -981,29 +737,14 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 981 |
`shrinkage screening:` block in the summary, and a `"shrinkage"` key in
|
| 982 |
the return dict (the `screen_casting` result minus its bulky per-node
|
| 983 |
`niyama`/`porosity_risk` arrays, plus `flagged_frac`/`n_macro_regions`/
|
| 984 |
-
`n_sinks`/`n_feeders` counts).
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
chill's local cooling-rate enhancement). Whenever `bodies` resolves to a
|
| 993 |
-
`BcPlan` with `bc_config == "chills"`, `"niyama_confidence"` is `"low"`
|
| 994 |
-
and the summary gains a caveat line naming the Gate C number; bare/
|
| 995 |
-
all-sand and sleeve-only inputs (`bc_config` `"none"`/
|
| 996 |
-
`"insulating_sleeve"`/`"exo_sleeve"`) stay `"full"`. The `niyama`/
|
| 997 |
-
`porosity_risk` VTU fields and the return dict still populate either way
|
| 998 |
-
(screening stays informative, just honestly labeled) -- macro-shrinkage/
|
| 999 |
-
top-sink/feeder outputs never depended on Niyama and are unaffected by
|
| 1000 |
-
this label.
|
| 1001 |
-
`False` skips all of that entirely --
|
| 1002 |
-
`"shrinkage"` is `None` and the VTU/summary are byte-identical to the
|
| 1003 |
-
pre-Task-4 contract (no extra fields, no extra summary lines at all --
|
| 1004 |
-
the `shrinkage screening: disabled` line only appears when screening
|
| 1005 |
-
was attempted but internally skipped, e.g. a mesh with no surface nodes
|
| 1006 |
-
to place a feeder against, never for this explicit opt-out).
|
| 1007 |
|
| 1008 |
`gravity` (CLI `--gravity x,y,z`, parsed by `_parse_gravity`): forwarded
|
| 1009 |
straight to `screen_casting`'s `gravity_vec` (top-sink/feeder placement
|
|
@@ -1033,31 +774,6 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1033 |
# fail fast, before potentially lengthy meshing.
|
| 1034 |
props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
|
| 1035 |
|
| 1036 |
-
plan = None
|
| 1037 |
-
bodies_key = "none"
|
| 1038 |
-
if bodies is not None:
|
| 1039 |
-
if isinstance(bodies, (str, os.PathLike)):
|
| 1040 |
-
with open(osp.expanduser(bodies)) as f:
|
| 1041 |
-
bodies = json.load(f)
|
| 1042 |
-
# No `mef_weights` here (SP4 review I3, 2026-08-08): a mixed-MEF
|
| 1043 |
-
# sleeve set needs contact-patch areas per solid_id, but those only
|
| 1044 |
-
# exist after meshing + `tag_contact_patches`, which have not run
|
| 1045 |
-
# yet at this point -- `validate_bodies` is called here specifically
|
| 1046 |
-
# to build the mesh-cache key (`_bodies_cache_key` below) before any
|
| 1047 |
-
# mesh exists. This is not a regression (every mixed-MEF assembly
|
| 1048 |
-
# was rejected outright before the multi-sleeve ADDENDUM), but it
|
| 1049 |
-
# does mean this CLI/webapp entry point cannot resolve a mixed-MEF
|
| 1050 |
-
# assembly -- it raises naming `mef_weights`, a parameter neither
|
| 1051 |
-
# surface accepts. A caller that needs a mixed-MEF assembly must
|
| 1052 |
-
# mesh and tag first, then call `validate_bodies` again with
|
| 1053 |
-
# `mef_weights=per_body_patch_area_mm2`, exactly as `tools/sp4/
|
| 1054 |
-
# prep.py` + `tools/sp4/score.py` do for the SP4 gold set. Known
|
| 1055 |
-
# gap, not fixed here -- restructuring this function's meshing flow
|
| 1056 |
-
# to do that two-pass resolution is separate work; see the spec's
|
| 1057 |
-
# multi-sleeve ADDENDUM.
|
| 1058 |
-
plan = validate_bodies(bodies)
|
| 1059 |
-
bodies_key = _bodies_cache_key(bodies, plan)
|
| 1060 |
-
|
| 1061 |
effective_mode = "raw" if elem_size is not None else mesh_mode
|
| 1062 |
if target_nodes != DEFAULT_TARGET_NODES and effective_mode != "prep":
|
| 1063 |
raise ValueError(
|
|
@@ -1066,24 +782,6 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1066 |
f"elem_size={elem_size!r} forces mesh_mode='raw' -- pinning an "
|
| 1067 |
f"element size and searching towards a node count are mutually "
|
| 1068 |
f"exclusive requests. Drop one.")
|
| 1069 |
-
if bodies is not None and effective_mode != "prep":
|
| 1070 |
-
raise ValueError(
|
| 1071 |
-
f"predict_casting: `bodies` requires mesh_mode='prep' (got "
|
| 1072 |
-
f"mesh_mode={mesh_mode!r}, effective_mode={effective_mode!r} -- "
|
| 1073 |
-
f"an explicit elem_size forces 'raw') -- role-based casting-solid "
|
| 1074 |
-
f"selection and contact-patch tagging only run through the "
|
| 1075 |
-
f"prep/get_or_mesh pipeline")
|
| 1076 |
-
|
| 1077 |
-
# Loaded BEFORE meshing so a bodies/non-film-checkpoint mismatch (below)
|
| 1078 |
-
# is caught before tag_contact_patches ever runs -- not merely before
|
| 1079 |
-
# its result is used. A resolved BcPlan (chills/sleeve roles) means
|
| 1080 |
-
# tag_contact_patches would relabel the contact patch "Adiabatic", which
|
| 1081 |
-
# a non-film (SP1) checkpoint's geometry features read AS PHYSICAL
|
| 1082 |
-
# INSULATION. For a chill, that's backwards physics (a chill COOLS,
|
| 1083 |
-
# Adiabatic INSULATES); for a sleeve it's merely uncalibrated. Either
|
| 1084 |
-
# way, SP1 has no BC channel to honor the role assignment at all, so
|
| 1085 |
-
# silently proceeding would both report bc_config as if honored AND
|
| 1086 |
-
# leave a wrong-physics tagged mesh sitting in the mesh cache.
|
| 1087 |
gnn_ckpt, hgb_path = resolve_artifact(gnn_ckpt), resolve_artifact(hgb_path)
|
| 1088 |
gnn, hgb, norm, cfg, baseline_norm, hgb_std = load_hybrid(
|
| 1089 |
gnn_ckpt, hgb_path, device, hgb_std_path=hgb_std_path)
|
|
@@ -1096,11 +794,6 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1096 |
hgb_std_path_label = resolve_artifact(DEFAULT_HGB_STD)
|
| 1097 |
else:
|
| 1098 |
hgb_std_path_label = str(hgb_std_path)
|
| 1099 |
-
if plan is not None and not cfg.get("film", False):
|
| 1100 |
-
raise ValueError(
|
| 1101 |
-
"predict_casting: bodies/BC roles require the film-conditioned "
|
| 1102 |
-
"v4 stack; the selected checkpoint is not film-capable -- rerun "
|
| 1103 |
-
"without --bodies or with the v4 checkpoints")
|
| 1104 |
if hgb_std is not None and not cfg.get("film", False):
|
| 1105 |
raise ValueError(
|
| 1106 |
"predict_casting: --hgb-std requires the film-conditioned v4 "
|
|
@@ -1113,15 +806,7 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1113 |
minfo = {"n_nodes": raw_info["n_nodes"], "n_tets": raw_info["n_tets"],
|
| 1114 |
"elem_size": raw_info["elem_size"], "bbox_mm": raw_info["bbox_mm"]}
|
| 1115 |
elif effective_mode == "prep":
|
| 1116 |
-
|
| 1117 |
-
post_mesh = None
|
| 1118 |
-
if plan is not None and plan.role_solid_ids:
|
| 1119 |
-
def post_mesh(msh_path, _step_path=step_path, _role_ids=plan.role_solid_ids):
|
| 1120 |
-
return tag_contact_patches(msh_path, _step_path, _role_ids)
|
| 1121 |
-
|
| 1122 |
-
prep_info = get_or_mesh(step_path, out_msh, target_nodes=target_nodes,
|
| 1123 |
-
keep_solid_id=keep_solid_id, bodies_key=bodies_key,
|
| 1124 |
-
post_mesh=post_mesh)
|
| 1125 |
if not prep_info.get("ok", False):
|
| 1126 |
raise RuntimeError(
|
| 1127 |
f"predict_casting: prep meshing pipeline (get_or_mesh) failed for "
|
|
@@ -1131,17 +816,11 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1131 |
cache = prep_info.get("cache", "unknown")
|
| 1132 |
minfo = {"n_nodes": prep_info["n_nodes"], "n_tets": int(mesh.tets.shape[0]),
|
| 1133 |
"elem_size": None, "bbox_mm": bbox_mm, "cache": cache}
|
| 1134 |
-
if "per_body_patch_area_mm2" in prep_info:
|
| 1135 |
-
# A cache HIT round-trips through JSON, turning int dict keys
|
| 1136 |
-
# into str -- normalize back to int so callers (and the summary
|
| 1137 |
-
# below) see one consistent shape regardless of hit vs. miss.
|
| 1138 |
-
minfo["per_body_patch_area_mm2"] = {
|
| 1139 |
-
int(k): v for k, v in prep_info["per_body_patch_area_mm2"].items()}
|
| 1140 |
else:
|
| 1141 |
raise ValueError(f"predict_casting: mesh_mode must be 'prep' or 'raw', got {mesh_mode!r}")
|
| 1142 |
|
| 1143 |
seconds, feat = predict_field(mesh, gnn, hgb, norm, cfg=cfg, device=device, tta=tta,
|
| 1144 |
-
baseline_norm=baseline_norm, props=props,
|
| 1145 |
hgb_std=hgb_std, lambda_shrink=lambda_shrink,
|
| 1146 |
w_total=w_total, w_std=w_std)
|
| 1147 |
|
|
@@ -1162,21 +841,6 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1162 |
shrinkage_extra_point_data = None
|
| 1163 |
if screen_shrinkage:
|
| 1164 |
surface_nodes, feed_nodes = _shrinkage_surface_and_feed_nodes(mesh)
|
| 1165 |
-
if plan is not None and plan.role_solid_ids:
|
| 1166 |
-
# Part-B roles task (2026-08-03, replaces review finding I1's
|
| 1167 |
-
# coarse `bc_config == "chills"` special case): recompute
|
| 1168 |
-
# feed_nodes PER BODY ROLE rather than zeroing it out for the
|
| 1169 |
-
# whole input -- only the SLEEVE roles' nodes are ever a feed
|
| 1170 |
-
# source, a chill patch never is, regardless of `bc_config`. See
|
| 1171 |
-
# `_feed_nodes_from_role_patches`'s docstring and this module's
|
| 1172 |
-
# own investigation note (above `_nodes_by_role_from_tagging`)
|
| 1173 |
-
# for why no separate "pre-existing Adiabatic" contribution
|
| 1174 |
-
# needs unioning in here. `_role_aware_feed_nodes` (review round
|
| 1175 |
-
# 1, H1) additionally guards against a LEGACY mesh-cache HIT
|
| 1176 |
-
# whose side-car predates this task (no `nodes_by_solid` at
|
| 1177 |
-
# all) -- see its own docstring.
|
| 1178 |
-
nodes_by_solid = prep_info.get("nodes_by_solid", {})
|
| 1179 |
-
feed_nodes = _role_aware_feed_nodes(step_path, nodes_by_solid, plan, feed_nodes)
|
| 1180 |
if surface_nodes.size == 0:
|
| 1181 |
logger.warning(
|
| 1182 |
"predict_casting: no surface nodes found on the meshed part -- "
|
|
@@ -1212,18 +876,9 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1212 |
shrinkage["niyama_source"] = "learned"
|
| 1213 |
if alloy_family_assumed:
|
| 1214 |
shrinkage["alloy_family_assumed"] = True
|
| 1215 |
-
#
|
| 1216 |
-
#
|
| 1217 |
-
|
| 1218 |
-
# low-confidence whenever the input's BC plan has a CHILL role
|
| 1219 |
-
# (`plan.bc_config == "chills"`; bare/all-sand and sleeve-only
|
| 1220 |
-
# inputs, whose bc_config is "none"/"insulating_sleeve"/
|
| 1221 |
-
# "exo_sleeve", are unaffected and stay "full"). Macro-shrinkage/
|
| 1222 |
-
# sinks/feeders don't depend on Niyama at all and keep full scope
|
| 1223 |
-
# regardless -- only this confidence LABEL changes, no output is
|
| 1224 |
-
# withheld ("screening stays informative").
|
| 1225 |
-
shrinkage["niyama_confidence"] = (
|
| 1226 |
-
"low" if (plan is not None and plan.bc_config == "chills") else "full")
|
| 1227 |
shrinkage_extra_point_data = _shrinkage_extra_point_data(mesh, shrinkage_full)
|
| 1228 |
|
| 1229 |
vtu = osp.join(out_dir, f"{name}.vtu")
|
|
@@ -1241,8 +896,6 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1241 |
elem_size_label = (f"{minfo['elem_size']:.2f} mm" if minfo["elem_size"] is not None
|
| 1242 |
else "n/a (density-searched)")
|
| 1243 |
cache_label = minfo.get("cache") if effective_mode == "prep" else "n/a (raw mode)"
|
| 1244 |
-
bc_config = plan.bc_config if plan is not None else "none"
|
| 1245 |
-
patch_areas = minfo.get("per_body_patch_area_mm2", {})
|
| 1246 |
with open(summary, "w") as f:
|
| 1247 |
f.write(f"casting: {name}\n")
|
| 1248 |
f.write(f"total solidification time: {total:.1f} s\n")
|
|
@@ -1251,16 +904,6 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1251 |
f.write(f"mesh_mode: {effective_mode} cache: {cache_label} nodes: {minfo['n_nodes']} "
|
| 1252 |
f"tets: {minfo['n_tets']} elem_size: {elem_size_label} bbox_mm: "
|
| 1253 |
f"{tuple(round(v,1) for v in minfo['bbox_mm'])}\n")
|
| 1254 |
-
f.write(f"bc_config: {bc_config}\n")
|
| 1255 |
-
if plan is not None:
|
| 1256 |
-
for b in bodies:
|
| 1257 |
-
sid = b.get("solid_id")
|
| 1258 |
-
line = f" body solid_id={sid} role={b.get('role')}"
|
| 1259 |
-
if b.get("params"):
|
| 1260 |
-
line += f" params={b['params']}"
|
| 1261 |
-
if sid in patch_areas:
|
| 1262 |
-
line += f" patch_area_mm2={patch_areas[sid]:.1f}"
|
| 1263 |
-
f.write(line + "\n")
|
| 1264 |
if feat.get("recomposition") == "soft":
|
| 1265 |
info = feat["recomposition_info"]
|
| 1266 |
line = (f"soft recomposition: a={info['a']:.4f}, m={info['m']:.4f}, "
|
|
@@ -1310,8 +953,7 @@ def predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
|
| 1310 |
result = {"name": name, "vtu": vtu, "summary": summary, "total_time_s": total,
|
| 1311 |
"mesh_mode": effective_mode, "n_nodes": minfo["n_nodes"],
|
| 1312 |
"elem_size": minfo["elem_size"], "ood_warnings": warnings,
|
| 1313 |
-
"hotspot_xyz": hot_xyz,
|
| 1314 |
-
"per_body_patch_area_mm2": patch_areas,
|
| 1315 |
"recomposition": recomposition,
|
| 1316 |
"recomposition_info": recomposition_info,
|
| 1317 |
"shrinkage": shrinkage}
|
|
@@ -1370,16 +1012,6 @@ if __name__ == "__main__":
|
|
| 1370 |
help="named mold system; only used by film/v4 checkpoints")
|
| 1371 |
ap.add_argument("--props", dest="props_json", default=None,
|
| 1372 |
help="path to a JSON file overriding alloy/mold with raw material properties")
|
| 1373 |
-
ap.add_argument("--bodies", dest="bodies_json", default=None,
|
| 1374 |
-
help="path to a JSON file describing a multi-body STEP's role "
|
| 1375 |
-
"assignment (Part B): a list of {\"solid_id\": int, \"role\": "
|
| 1376 |
-
"\"casting\"|\"chill\"|\"insulating_sleeve\"|\"exo_sleeve\"|"
|
| 1377 |
-
"\"ignore\", \"params\": {...} (optional, role-dependent)}. "
|
| 1378 |
-
"Example: [{\"solid_id\": 1, \"role\": \"casting\"}, "
|
| 1379 |
-
"{\"solid_id\": 2, \"role\": \"chill\", \"params\": "
|
| 1380 |
-
"{\"material\": \"cast_iron\"}}]. Requires --mesh-mode prep "
|
| 1381 |
-
"(the default) -- role-based casting-solid selection and "
|
| 1382 |
-
"contact-patch tagging only run through that pipeline.")
|
| 1383 |
ap.add_argument("--no-shrinkage", dest="screen_shrinkage", action="store_false",
|
| 1384 |
help="disable shrinkage-porosity screening (SP2): by default, "
|
| 1385 |
"predict_casting runs src.shrinkage.screen_casting on the "
|
|
@@ -1399,11 +1031,11 @@ if __name__ == "__main__":
|
|
| 1399 |
mesh_mode=a.mesh_mode,
|
| 1400 |
gnn_ckpt=a.gnn_ckpt, hgb_path=a.hgb_path,
|
| 1401 |
alloy=a.alloy, mold=a.mold, props_json=a.props_json,
|
| 1402 |
-
|
| 1403 |
lambda_shrink=a.lambda_shrink, w_total=a.w_total, w_std=a.w_std,
|
| 1404 |
screen_shrinkage=a.screen_shrinkage,
|
| 1405 |
gravity=a.gravity)
|
| 1406 |
print(f"{r['name']}: total {r['total_time_s']:.1f}s mesh_mode {r['mesh_mode']} "
|
| 1407 |
-
f"nodes {r['n_nodes']}
|
| 1408 |
for w in r["ood_warnings"]:
|
| 1409 |
print("WARNING:", w)
|
|
|
|
| 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
|
|
|
|
| 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)."""
|
|
|
|
| 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
|
|
|
|
| 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)
|
|
|
|
| 423 |
return surface_nodes.astype(np.int64), feed_nodes.astype(np.int64)
|
| 424 |
|
| 425 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
def _shrinkage_result_for_return(screen: dict) -> dict:
|
| 427 |
"""Directive 6: the `screen_casting` result MINUS its bulky per-node
|
| 428 |
arrays (`niyama`/`porosity_risk` dropped), plus summary counts +
|
|
|
|
| 469 |
return
|
| 470 |
f.write("shrinkage screening:\n")
|
| 471 |
f.write(f" niyama_source: {shrinkage['niyama_source']}\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
flagged_line = f" flagged nodes: {shrinkage['flagged_frac'] * 100.0:.1f}%"
|
| 473 |
if shrinkage["niyama_source"] == "proxy":
|
| 474 |
# Review round 1, finding 4: the proxy path's risk mask is
|
|
|
|
| 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 predict_casting(step_path, out_dir="runs/castings", elem_size=None,
|
| 631 |
mesh_mode="prep",
|
| 632 |
gnn_ckpt=DEFAULT_GNN_CKPT,
|
| 633 |
hgb_path=DEFAULT_HGB,
|
| 634 |
ood_json="data/features_v2/ood_ranges.json", device=None,
|
| 635 |
tta=0, alloy=None, mold="green_sand", props_json=None,
|
| 636 |
+
target_nodes: int = DEFAULT_TARGET_NODES,
|
| 637 |
hgb_std_path=_USE_DEFAULT,
|
| 638 |
lambda_shrink: float = 1.0, w_total: float = DEFAULT_W_TOTAL,
|
| 639 |
w_std: float = DEFAULT_W_STD, screen_shrinkage: bool = True,
|
|
|
|
| 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
|
|
|
|
| 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`
|
|
|
|
| 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
|
|
|
|
| 774 |
# fail fast, before potentially lengthy meshing.
|
| 775 |
props = resolve_props(alloy=alloy, mold=mold, props_json=props_json)
|
| 776 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
effective_mode = "raw" if elem_size is not None else mesh_mode
|
| 778 |
if target_nodes != DEFAULT_TARGET_NODES and effective_mode != "prep":
|
| 779 |
raise ValueError(
|
|
|
|
| 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)
|
|
|
|
| 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 "
|
|
|
|
| 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 "
|
|
|
|
| 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 |
|
|
|
|
| 841 |
shrinkage_extra_point_data = None
|
| 842 |
if screen_shrinkage:
|
| 843 |
surface_nodes, feed_nodes = _shrinkage_surface_and_feed_nodes(mesh)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
if surface_nodes.size == 0:
|
| 845 |
logger.warning(
|
| 846 |
"predict_casting: no surface nodes found on the meshed part -- "
|
|
|
|
| 876 |
shrinkage["niyama_source"] = "learned"
|
| 877 |
if alloy_family_assumed:
|
| 878 |
shrinkage["alloy_family_assumed"] = True
|
| 879 |
+
# This release covers the all-sand regime only, where the
|
| 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")
|
|
|
|
| 896 |
elem_size_label = (f"{minfo['elem_size']:.2f} mm" if minfo["elem_size"] is not None
|
| 897 |
else "n/a (density-searched)")
|
| 898 |
cache_label = minfo.get("cache") if effective_mode == "prep" else "n/a (raw mode)"
|
|
|
|
|
|
|
| 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")
|
|
|
|
| 904 |
f.write(f"mesh_mode: {effective_mode} cache: {cache_label} nodes: {minfo['n_nodes']} "
|
| 905 |
f"tets: {minfo['n_tets']} elem_size: {elem_size_label} bbox_mm: "
|
| 906 |
f"{tuple(round(v,1) for v in minfo['bbox_mm'])}\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 907 |
if feat.get("recomposition") == "soft":
|
| 908 |
info = feat["recomposition_info"]
|
| 909 |
line = (f"soft recomposition: a={info['a']:.4f}, m={info['m']:.4f}, "
|
|
|
|
| 953 |
result = {"name": name, "vtu": vtu, "summary": summary, "total_time_s": total,
|
| 954 |
"mesh_mode": effective_mode, "n_nodes": minfo["n_nodes"],
|
| 955 |
"elem_size": minfo["elem_size"], "ood_warnings": warnings,
|
| 956 |
+
"hotspot_xyz": hot_xyz,
|
|
|
|
| 957 |
"recomposition": recomposition,
|
| 958 |
"recomposition_info": recomposition_info,
|
| 959 |
"shrinkage": shrinkage}
|
|
|
|
| 1012 |
help="named mold system; only used by film/v4 checkpoints")
|
| 1013 |
ap.add_argument("--props", dest="props_json", default=None,
|
| 1014 |
help="path to a JSON file overriding alloy/mold with raw material properties")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1015 |
ap.add_argument("--no-shrinkage", dest="screen_shrinkage", action="store_false",
|
| 1016 |
help="disable shrinkage-porosity screening (SP2): by default, "
|
| 1017 |
"predict_casting runs src.shrinkage.screen_casting on the "
|
|
|
|
| 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)
|
src/webapp.py
CHANGED
|
@@ -2,11 +2,8 @@
|
|
| 2 |
prediction, 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 |
-
|
| 6 |
-
|
| 7 |
-
auto-running the prediction; a single-solid upload keeps the original flow
|
| 8 |
-
untouched."""
|
| 9 |
-
import csv
|
| 10 |
import os
|
| 11 |
import os.path as osp
|
| 12 |
|
|
@@ -18,13 +15,10 @@ from trame.widgets import html, vuetify3
|
|
| 18 |
from pyvista.trame.ui import plotter_ui
|
| 19 |
|
| 20 |
from src import viz
|
| 21 |
-
from src.assembly import list_solids, validate_bodies
|
| 22 |
from src.inference_props import DEFAULT_ALLOY, list_alloys, list_molds
|
| 23 |
from src.predict_casting import predict_casting
|
| 24 |
-
from tools.doe.load_properties import load_chill_effusivities
|
| 25 |
|
| 26 |
DEFAULT_MOLD = "green_sand"
|
| 27 |
-
SLEEVE_REGISTRY_CSV = "tools/sleeves/sleeve_registry.csv"
|
| 28 |
|
| 29 |
pv.OFF_SCREEN = True
|
| 30 |
|
|
@@ -61,23 +55,6 @@ HOTSPOT_COLOR = "#0092b8" # deep cyan/teal marker: falls in the middle of turb
|
|
| 61 |
# range but is rendered as small spheres (not a field
|
| 62 |
# color), so it still reads as an overlay/alert
|
| 63 |
|
| 64 |
-
# --- assembly mode (Part B Task 5) -----------------------------------------
|
| 65 |
-
# One row per solid in a multi-body STEP upload: role dropdown (defaults
|
| 66 |
-
# largest solid = casting, everything else = ignore) + role-dependent params
|
| 67 |
-
# (chill material / sleeve name) feeding `src.assembly.validate_bodies` and
|
| 68 |
-
# `predict_casting(..., bodies=...)`. The 3-D preview colors each solid's
|
| 69 |
-
# coarse tessellation by its currently-selected role; "ignore" bodies stay
|
| 70 |
-
# the casting's neutral grey but at the same faint GHOST_OPACITY used for the
|
| 71 |
-
# solidified-region ghost actor elsewhere in this file.
|
| 72 |
-
ROLE_OPTIONS = ["casting", "chill", "insulating_sleeve", "exo_sleeve", "ignore"]
|
| 73 |
-
ROLE_COLORS = {
|
| 74 |
-
"casting": "#9e9e9e",
|
| 75 |
-
"chill": "#2196f3",
|
| 76 |
-
"insulating_sleeve": "#ffc107",
|
| 77 |
-
"exo_sleeve": "#f44336",
|
| 78 |
-
"ignore": "#9e9e9e",
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
_MONO_STACK = ('"JetBrains Mono", ui-monospace, "SFMono-Regular", '
|
| 82 |
"Menlo, Consolas, monospace")
|
| 83 |
_COND_STACK = '"Roboto Condensed", "Arial Narrow", sans-serif'
|
|
@@ -203,151 +180,16 @@ def summary_text(result: dict, alloy: str = None, mold: str = None) -> str:
|
|
| 203 |
INITIAL_SUMMARY = "(upload a STEP file)"
|
| 204 |
|
| 205 |
|
| 206 |
-
def _run_prediction(step_path, out_dir, alloy, mold
|
| 207 |
-
"""Thin wrapper around `predict_casting` forwarding alloy/mold conditioning
|
| 208 |
-
and (Part B Task 5) an optional multi-body role assignment.
|
| 209 |
|
| 210 |
Extracted so the upload handler's predict call is unit-testable without
|
| 211 |
driving the full trame upload flow (module-level lookup of
|
| 212 |
`predict_casting` so tests can monkeypatch `src.webapp.predict_casting`).
|
| 213 |
-
`bodies` is only forwarded as a keyword when given (not merely `None`),
|
| 214 |
-
so the pre-Part-B single-solid call shape (no `bodies` kwarg at all)
|
| 215 |
-
stays byte-identical -- some callers (this module's own existing tests)
|
| 216 |
-
monkeypatch `predict_casting` with a fake that doesn't accept `bodies`.
|
| 217 |
"""
|
| 218 |
-
if bodies is not None:
|
| 219 |
-
return predict_casting(step_path, out_dir=out_dir, alloy=alloy, mold=mold, bodies=bodies)
|
| 220 |
return predict_casting(step_path, out_dir=out_dir, alloy=alloy, mold=mold)
|
| 221 |
|
| 222 |
|
| 223 |
-
def _load_sleeve_names_by_type(csv_path=SLEEVE_REGISTRY_CSV):
|
| 224 |
-
"""{"exo": [name, ...], "insulating": [name, ...]} from
|
| 225 |
-
`tools/sleeves/sleeve_registry.csv`'s `name`/`type` columns, sorted --
|
| 226 |
-
the source of the sleeve-name dropdown options for the assembly panel,
|
| 227 |
-
pre-filtered by role so the UI never offers a sleeve `validate_bodies`
|
| 228 |
-
would reject for a type mismatch (see `src.assembly.validate_bodies`'s
|
| 229 |
-
`_ROLE_TO_SLEEVE_TYPE` cross-check)."""
|
| 230 |
-
out: dict[str, list[str]] = {"exo": [], "insulating": []}
|
| 231 |
-
try:
|
| 232 |
-
with open(csv_path, newline="") as f:
|
| 233 |
-
for row in csv.DictReader(f):
|
| 234 |
-
names = out.setdefault(row["type"], [])
|
| 235 |
-
names.append(row["name"])
|
| 236 |
-
except FileNotFoundError:
|
| 237 |
-
pass
|
| 238 |
-
for names in out.values():
|
| 239 |
-
names.sort()
|
| 240 |
-
return out
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
def _assemble_bodies_from_state(state_like) -> list:
|
| 244 |
-
"""Pure builder: the assembly panel's per-row state (`state_like.
|
| 245 |
-
assembly_bodies`, one dict per solid with at least `solid_id` and
|
| 246 |
-
`role`, plus an optional `params`) -> the plain `bodies` list
|
| 247 |
-
`src.assembly.validate_bodies`/`predict_casting(..., bodies=...)`
|
| 248 |
-
expect (`{"solid_id", "role", "params"}`, UI-only fields like `name`/
|
| 249 |
-
`volume_mm3` dropped). Takes anything with attribute access
|
| 250 |
-
(`state_like.assembly_bodies`) so it is testable with a plain
|
| 251 |
-
`SimpleNamespace`/fake as well as the real trame state -- no gmsh, no
|
| 252 |
-
validation (that's `validate_bodies`'s job, called separately as a
|
| 253 |
-
pre-flight check by the Run-assembly handler)."""
|
| 254 |
-
rows = state_like.assembly_bodies or []
|
| 255 |
-
bodies = []
|
| 256 |
-
for row in rows:
|
| 257 |
-
body = {"solid_id": row["solid_id"], "role": row.get("role", "ignore")}
|
| 258 |
-
params = row.get("params")
|
| 259 |
-
if params:
|
| 260 |
-
body["params"] = params
|
| 261 |
-
bodies.append(body)
|
| 262 |
-
return bodies
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
def _solid_preview_meshes(step_path):
|
| 266 |
-
"""Coarse per-solid surface tessellation for the assembly 3-D preview:
|
| 267 |
-
one fresh gmsh/OCC session imports `step_path` and generates a SINGLE
|
| 268 |
-
2-D surface mesh over the whole model, but sized PER SOLID (each
|
| 269 |
-
solid's own boundary points get their own target size, its OWN bbox
|
| 270 |
-
diagonal / 15, via `gmsh.model.mesh.setSize` + gmsh's default
|
| 271 |
-
`Mesh.MeshSizeExtendFromBoundary` sizing field) -- a large casting
|
| 272 |
-
tessellates at its own coarse scale while a small chill/sleeve still
|
| 273 |
-
gets enough triangles to read as a shape, instead of one uniform size
|
| 274 |
-
forcing every solid into whichever end of the size range is wrong for
|
| 275 |
-
it (a small uniform size on a big casting is a triangle-count blowup;
|
| 276 |
-
a uniform size sized for the casting leaves a small role body a
|
| 277 |
-
handful of degenerate facets). Returns one `(solid_id,
|
| 278 |
-
pyvista.PolyData)` per 3-D solid (its own boundary surfaces' triangles
|
| 279 |
-
+ nodes). `solid_id` matches what `list_solids` reports for the SAME
|
| 280 |
-
STEP file, since both walk a fresh `gmsh.model.occ.importShapes` +
|
| 281 |
-
`gmsh.model.getEntities(3)` and gmsh assigns solid tags
|
| 282 |
-
deterministically from import order.
|
| 283 |
-
|
| 284 |
-
Real-gmsh, no mocking -- callers should expect this to be slow-ish (a
|
| 285 |
-
full 2-D mesh pass) but bounded for the coarse sizes used here."""
|
| 286 |
-
import gmsh
|
| 287 |
-
|
| 288 |
-
gmsh.initialize()
|
| 289 |
-
try:
|
| 290 |
-
gmsh.option.setNumber("General.Terminal", 0)
|
| 291 |
-
gmsh.model.add("assembly_preview")
|
| 292 |
-
gmsh.model.occ.importShapes(step_path)
|
| 293 |
-
gmsh.model.occ.synchronize()
|
| 294 |
-
|
| 295 |
-
solids = gmsh.model.getEntities(3)
|
| 296 |
-
if not solids:
|
| 297 |
-
return []
|
| 298 |
-
|
| 299 |
-
size_by_tag = {}
|
| 300 |
-
for (_dim, tag) in solids:
|
| 301 |
-
xmin, ymin, zmin, xmax, ymax, zmax = gmsh.model.getBoundingBox(3, tag)
|
| 302 |
-
diag = float(np.linalg.norm([xmax - xmin, ymax - ymin, zmax - zmin]))
|
| 303 |
-
size_by_tag[tag] = max(diag / 15.0, 1e-3)
|
| 304 |
-
|
| 305 |
-
# Global bounds only fence in gmsh's implicit sizing AWAY from any
|
| 306 |
-
# explicitly-sized point (e.g. deep in a solid's interior); the
|
| 307 |
-
# per-solid setSize calls below are what actually drive each
|
| 308 |
-
# solid's own coarse scale.
|
| 309 |
-
gmsh.option.setNumber("Mesh.MeshSizeMin", min(size_by_tag.values()))
|
| 310 |
-
gmsh.option.setNumber("Mesh.MeshSizeMax", max(size_by_tag.values()))
|
| 311 |
-
gmsh.option.setNumber("Mesh.MeshSizeExtendFromBoundary", 1)
|
| 312 |
-
for (_dim, tag) in solids:
|
| 313 |
-
boundary_pts = gmsh.model.getBoundary([(3, tag)], recursive=True)
|
| 314 |
-
point_dimtags = [(d, t) for (d, t) in boundary_pts if d == 0]
|
| 315 |
-
if point_dimtags:
|
| 316 |
-
gmsh.model.mesh.setSize(point_dimtags, size_by_tag[tag])
|
| 317 |
-
gmsh.model.mesh.generate(2)
|
| 318 |
-
|
| 319 |
-
out = []
|
| 320 |
-
for (_dim, tag) in solids:
|
| 321 |
-
boundary = gmsh.model.getBoundary([(3, tag)], oriented=False)
|
| 322 |
-
surf_tags = [t for (d, t) in boundary if d == 2]
|
| 323 |
-
node_xyz_by_id = {}
|
| 324 |
-
tri_blocks = []
|
| 325 |
-
for surf_tag in surf_tags:
|
| 326 |
-
node_tags, coords, _param = gmsh.model.mesh.getNodes(
|
| 327 |
-
2, surf_tag, includeBoundary=True)
|
| 328 |
-
coords = np.asarray(coords, dtype=np.float64).reshape(-1, 3)
|
| 329 |
-
for nid, xyz in zip(node_tags, coords):
|
| 330 |
-
node_xyz_by_id.setdefault(int(nid), xyz)
|
| 331 |
-
elem_types, _elem_tags, elem_node_tags = gmsh.model.mesh.getElements(2, surf_tag)
|
| 332 |
-
for etype, enodes in zip(elem_types, elem_node_tags):
|
| 333 |
-
if etype != 2: # gmsh type 2 = 3-node triangle
|
| 334 |
-
continue
|
| 335 |
-
tri_blocks.append(np.asarray(enodes, dtype=np.int64).reshape(-1, 3))
|
| 336 |
-
if not tri_blocks or not node_xyz_by_id:
|
| 337 |
-
continue
|
| 338 |
-
tris = np.concatenate(tri_blocks, axis=0)
|
| 339 |
-
node_ids = sorted(node_xyz_by_id)
|
| 340 |
-
local_index = {nid: i for i, nid in enumerate(node_ids)}
|
| 341 |
-
points = np.array([node_xyz_by_id[nid] for nid in node_ids], dtype=np.float64)
|
| 342 |
-
local_tris = np.vectorize(local_index.get)(tris)
|
| 343 |
-
faces = np.hstack(
|
| 344 |
-
[np.full((local_tris.shape[0], 1), 3, dtype=np.int64), local_tris])
|
| 345 |
-
out.append((tag, pv.PolyData(points, faces)))
|
| 346 |
-
return out
|
| 347 |
-
finally:
|
| 348 |
-
gmsh.finalize()
|
| 349 |
-
|
| 350 |
-
|
| 351 |
def _rebuild_hotspots(app):
|
| 352 |
"""(Re)build the hot-spot point actor from the current cutoff/size state.
|
| 353 |
Called on load and whenever the size or cutoff slider moves."""
|
|
@@ -379,105 +221,12 @@ def _remove_scalar_bar(plotter):
|
|
| 379 |
pass
|
| 380 |
|
| 381 |
|
| 382 |
-
def _render_assembly_preview(app):
|
| 383 |
-
"""(Re)draw every cached assembly-preview mesh (`app["assembly_meshes"]`,
|
| 384 |
-
built once by `_enter_assembly_mode`), colored/opacity'd by each body's
|
| 385 |
-
CURRENT role in `state.assembly_bodies` (see `ROLE_COLORS`/
|
| 386 |
-
`GHOST_OPACITY`). Called on entering assembly mode and again whenever
|
| 387 |
-
`assembly_bodies` changes (role edits) -- cheap, since it only touches
|
| 388 |
-
actors, never re-tessellates."""
|
| 389 |
-
plotter, server = app["plotter"], app["server"]
|
| 390 |
-
state, ctrl = server.state, server.controller
|
| 391 |
-
plotter.clear_actors()
|
| 392 |
-
_remove_scalar_bar(plotter)
|
| 393 |
-
roles = {b["solid_id"]: b.get("role", "ignore") for b in (state.assembly_bodies or [])}
|
| 394 |
-
meshes = app.get("assembly_meshes") or []
|
| 395 |
-
for solid_id, mesh in meshes:
|
| 396 |
-
role = roles.get(solid_id, "ignore")
|
| 397 |
-
color = ROLE_COLORS.get(role, ROLE_COLORS["ignore"])
|
| 398 |
-
opacity = GHOST_OPACITY if role == "ignore" else 1.0
|
| 399 |
-
plotter.add_mesh(mesh, color=color, opacity=opacity,
|
| 400 |
-
name=f"assembly_solid_{solid_id}", smooth_shading=True)
|
| 401 |
-
if meshes:
|
| 402 |
-
plotter.reset_camera()
|
| 403 |
-
ctrl.view_update()
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
def _enter_assembly_mode(app, step_path, rows):
|
| 407 |
-
"""Switch the viewer into assembly mode for a multi-solid STEP upload:
|
| 408 |
-
builds the role-colored 3-D preview meshes FIRST (`_solid_preview_meshes`
|
| 409 |
-
into `app["assembly_meshes"]`), THEN seeds `state.assembly_bodies` from
|
| 410 |
-
`list_solids`' rows (default roles -- largest solid = casting, everything
|
| 411 |
-
else = ignore, per the brief) and renders (`_render_assembly_preview`).
|
| 412 |
-
This order matters: `state.assembly_bodies` is watched
|
| 413 |
-
(`_on_assembly_bodies_change` -> `_render_assembly_preview`), so setting
|
| 414 |
-
it BEFORE `app["assembly_meshes"]` is refreshed would briefly recolor
|
| 415 |
-
whatever meshes happened to be cached from a PRIOR upload (e.g. a
|
| 416 |
-
multi -> single -> multi upload sequence) against the new roles -- a
|
| 417 |
-
stale-preview flash. A preview failure (e.g. a STEP gmsh can list solids
|
| 418 |
-
for but not re-tessellate) is surfaced in `state.assembly_error` rather
|
| 419 |
-
than raised -- the body table/role panel still works without a 3-D
|
| 420 |
-
preview."""
|
| 421 |
-
server = app["server"]
|
| 422 |
-
state = server.state
|
| 423 |
-
app["grid"] = None
|
| 424 |
-
app["hotspot_actor"] = None
|
| 425 |
-
state.loaded = False
|
| 426 |
-
state.summary = INITIAL_SUMMARY
|
| 427 |
-
state.assembly_step_path = step_path
|
| 428 |
-
state.assembly_error = ""
|
| 429 |
-
|
| 430 |
-
try:
|
| 431 |
-
app["assembly_meshes"] = _solid_preview_meshes(step_path)
|
| 432 |
-
except Exception as e:
|
| 433 |
-
app["assembly_meshes"] = []
|
| 434 |
-
state.assembly_error = f"3D preview failed: {e}"
|
| 435 |
-
|
| 436 |
-
bodies = []
|
| 437 |
-
for i, row in enumerate(rows):
|
| 438 |
-
bodies.append({
|
| 439 |
-
"solid_id": row["solid_id"],
|
| 440 |
-
"name": row["name"] or f"solid {row['solid_id']}",
|
| 441 |
-
"volume_mm3": row["volume_mm3"],
|
| 442 |
-
"role": "casting" if i == 0 else "ignore",
|
| 443 |
-
"params": {},
|
| 444 |
-
})
|
| 445 |
-
state.assembly_mode = True
|
| 446 |
-
state.assembly_bodies = bodies
|
| 447 |
-
_render_assembly_preview(app)
|
| 448 |
-
|
| 449 |
-
|
| 450 |
def _process_upload(app, step_path, alloy, mold):
|
| 451 |
"""Core upload logic, extracted from the `uploaded_file` state-change
|
| 452 |
handler so it is testable without a browser/file-upload round trip:
|
| 453 |
-
|
| 454 |
-
(`_enter_assembly_mode` -- role panel + 3-D preview, prediction deferred
|
| 455 |
-
to the Run-assembly button); exactly 1 solid (or a STEP `list_solids`
|
| 456 |
-
itself can't parse -- fails soft here so it falls through to the SAME
|
| 457 |
-
meshing/healing pipeline `predict_casting` always used) runs the
|
| 458 |
-
original single-solid predict-and-render flow, byte-identical to
|
| 459 |
-
pre-Part-B behavior."""
|
| 460 |
server = app["server"]
|
| 461 |
state = server.state
|
| 462 |
-
try:
|
| 463 |
-
rows = list_solids(step_path)
|
| 464 |
-
except Exception:
|
| 465 |
-
rows = [] # fall back to the pre-Part-B single-body flow untouched
|
| 466 |
-
|
| 467 |
-
if len(rows) > 1:
|
| 468 |
-
_enter_assembly_mode(app, step_path, rows)
|
| 469 |
-
return
|
| 470 |
-
|
| 471 |
-
# Single-solid (or unlistable) upload: leave no assembly-mode residue
|
| 472 |
-
# behind -- mirrors clear_casting()'s reset of the same four fields, so
|
| 473 |
-
# a LATER multi-solid upload starts from a clean slate rather than
|
| 474 |
-
# _enter_assembly_mode briefly recoloring stale meshes/rows from a
|
| 475 |
-
# previous assembly session (see _enter_assembly_mode's docstring).
|
| 476 |
-
state.assembly_mode = False
|
| 477 |
-
state.assembly_bodies = []
|
| 478 |
-
state.assembly_error = ""
|
| 479 |
-
state.assembly_step_path = None
|
| 480 |
-
app["assembly_meshes"] = []
|
| 481 |
plotter = app["plotter"]
|
| 482 |
out_dir = "runs/castings"
|
| 483 |
try:
|
|
@@ -559,7 +308,6 @@ def build_app(server=None):
|
|
| 559 |
},
|
| 560 |
},
|
| 561 |
}
|
| 562 |
-
sleeve_names_by_type = _load_sleeve_names_by_type()
|
| 563 |
for k, v in dict(tau=0.0, tmin=0.0, tmax=1.0, show_hotspots=False,
|
| 564 |
summary=INITIAL_SUMMARY, loaded=False, uploaded_file=None,
|
| 565 |
loading=False, hotspot_size=DEFAULT_HOTSPOT_SIZE,
|
|
@@ -567,13 +315,6 @@ def build_app(server=None):
|
|
| 567 |
anim_uri="", anim_name="solidification.gif",
|
| 568 |
alloy=DEFAULT_ALLOY, mold=DEFAULT_MOLD,
|
| 569 |
alloy_options=list_alloys(), mold_options=list_molds(),
|
| 570 |
-
# Assembly mode (Part B Task 5): populated by
|
| 571 |
-
# _enter_assembly_mode() on a multi-solid STEP upload.
|
| 572 |
-
assembly_mode=False, assembly_bodies=[], assembly_error="",
|
| 573 |
-
assembly_step_path=None, role_options=ROLE_OPTIONS,
|
| 574 |
-
chill_material_options=sorted(load_chill_effusivities()),
|
| 575 |
-
exo_sleeve_options=sleeve_names_by_type["exo"],
|
| 576 |
-
insulating_sleeve_options=sleeve_names_by_type["insulating"],
|
| 577 |
).items():
|
| 578 |
state.setdefault(k, v)
|
| 579 |
|
|
@@ -621,64 +362,12 @@ def build_app(server=None):
|
|
| 621 |
state.loading = False
|
| 622 |
ctrl.view_update()
|
| 623 |
|
| 624 |
-
@state.change("assembly_bodies")
|
| 625 |
-
def _on_assembly_bodies_change(**kwargs):
|
| 626 |
-
# Fires on every role/param edit in the body table (trame syncs the
|
| 627 |
-
# whole reactive list back on any nested-item mutation) -- just
|
| 628 |
-
# recolor the already-tessellated preview, never re-tessellate.
|
| 629 |
-
if state.assembly_mode:
|
| 630 |
-
_render_assembly_preview(app)
|
| 631 |
-
|
| 632 |
-
def run_assembly_prediction():
|
| 633 |
-
"""The assembly panel's "Run" button: build the bodies list from the
|
| 634 |
-
current panel state, pre-flight it through `validate_bodies` so a
|
| 635 |
-
training-regime violation (see `src.assembly.validate_bodies`) shows
|
| 636 |
-
up in the panel (`state.assembly_error`) WITHOUT ever calling
|
| 637 |
-
`predict_casting` -- never a crash for a bad role combination.
|
| 638 |
-
`_assemble_bodies_from_state` itself is inside this same try/except:
|
| 639 |
-
a malformed row (e.g. a missing `solid_id`) is a caller/state bug,
|
| 640 |
-
not a training-regime violation, but it should surface in the panel
|
| 641 |
-
exactly the same way -- never a raw traceback, and never a
|
| 642 |
-
`predict_casting` call on a bodies list that failed to even build."""
|
| 643 |
-
try:
|
| 644 |
-
bodies = _assemble_bodies_from_state(state)
|
| 645 |
-
validate_bodies(bodies)
|
| 646 |
-
except Exception as e:
|
| 647 |
-
state.assembly_error = str(e)
|
| 648 |
-
ctrl.view_update()
|
| 649 |
-
return
|
| 650 |
-
state.assembly_error = ""
|
| 651 |
-
state.loading = True
|
| 652 |
-
state.flush()
|
| 653 |
-
try:
|
| 654 |
-
result = _run_prediction(state.assembly_step_path, "runs/castings",
|
| 655 |
-
state.alloy, state.mold, bodies=bodies)
|
| 656 |
-
grid = viz.load_prediction(result["vtu"])
|
| 657 |
-
app["name"] = result["name"]
|
| 658 |
-
state.assembly_mode = False
|
| 659 |
-
render_prediction(app, grid, summary_text(result, alloy=state.alloy, mold=state.mold))
|
| 660 |
-
except Exception as e: # keep the UI alive on a bad assembly/prediction
|
| 661 |
-
plotter.clear_actors()
|
| 662 |
-
_remove_scalar_bar(plotter)
|
| 663 |
-
app["grid"] = None
|
| 664 |
-
app["hotspot_actor"] = None
|
| 665 |
-
state.loaded = False
|
| 666 |
-
state.summary = f"Prediction failed:\n{e}"
|
| 667 |
-
finally:
|
| 668 |
-
state.loading = False
|
| 669 |
-
ctrl.view_update()
|
| 670 |
-
|
| 671 |
-
# Exposed on the controller so the Run button (and tests) can invoke it
|
| 672 |
-
# without a browser round trip.
|
| 673 |
-
ctrl.run_assembly_prediction = run_assembly_prediction
|
| 674 |
-
|
| 675 |
def clear_casting():
|
| 676 |
"""Drop the loaded casting and return to a ready-for-upload state."""
|
| 677 |
plotter.clear_actors()
|
| 678 |
_remove_scalar_bar(plotter)
|
| 679 |
app["grid"] = None
|
| 680 |
app["hotspot_actor"] = None
|
| 681 |
-
app["assembly_meshes"] = []
|
| 682 |
state.loaded = False
|
| 683 |
state.summary = INITIAL_SUMMARY
|
| 684 |
state.uploaded_file = None
|
|
@@ -687,10 +376,6 @@ def build_app(server=None):
|
|
| 687 |
state.anim_ready = False
|
| 688 |
state.anim_uri = ""
|
| 689 |
state.animating = False
|
| 690 |
-
state.assembly_mode = False
|
| 691 |
-
state.assembly_bodies = []
|
| 692 |
-
state.assembly_error = ""
|
| 693 |
-
state.assembly_step_path = None
|
| 694 |
ctrl.view_update()
|
| 695 |
|
| 696 |
# Exposed on the controller so tests (and any future non-UI caller) can
|
|
@@ -784,57 +469,6 @@ def build_app(server=None):
|
|
| 784 |
size="small", color="secondary", disabled=("!loaded",),
|
| 785 |
classes="mt-2", block=True)
|
| 786 |
|
| 787 |
-
# Assembly mode (Part B Task 5): shown instead of an
|
| 788 |
-
# auto-run prediction whenever the STEP upload has >1
|
| 789 |
-
# solid. One row per body -- role dropdown (defaults
|
| 790 |
-
# largest=casting/rest=ignore, set by _enter_assembly_mode)
|
| 791 |
-
# + a role-dependent params select (chill material / sleeve
|
| 792 |
-
# name, the latter pre-filtered by sleeve type so the UI
|
| 793 |
-
# never offers a pairing validate_bodies would reject).
|
| 794 |
-
# `v_model="body.<path>"` (a literal string, not a
|
| 795 |
-
# (name, default) tuple) is trame's standard per-row
|
| 796 |
-
# editable-table idiom: it binds directly to the `body`
|
| 797 |
-
# loop variable from `v_for`, and edits to `assembly_bodies`
|
| 798 |
-
# sync back to the server as a whole (see
|
| 799 |
-
# `_on_assembly_bodies_change`).
|
| 800 |
-
with html.Div(classes="neuralcast-section", v_show=("assembly_mode",)):
|
| 801 |
-
html.Div("Assembly (multi-body STEP)",
|
| 802 |
-
classes="neuralcast-section-label")
|
| 803 |
-
with html.Template(v_for="body in assembly_bodies", key="body.solid_id"):
|
| 804 |
-
with html.Div(classes="mb-3"):
|
| 805 |
-
html.Div(
|
| 806 |
-
"{{ body.name || ('solid ' + body.solid_id) }} "
|
| 807 |
-
"— {{ Number(body.volume_mm3).toFixed(0) }} mm³",
|
| 808 |
-
style=f"font-size:.75rem; color:{TEXT_MUTED}; "
|
| 809 |
-
"margin-bottom:2px;",
|
| 810 |
-
)
|
| 811 |
-
vuetify3.VSelect(v_model="body.role", items=("role_options",),
|
| 812 |
-
label="role", density="compact",
|
| 813 |
-
variant="outlined", hide_details=True,
|
| 814 |
-
classes="mb-1")
|
| 815 |
-
vuetify3.VSelect(v_model="body.params.material",
|
| 816 |
-
items=("chill_material_options",),
|
| 817 |
-
label="chill material", density="compact",
|
| 818 |
-
variant="outlined", hide_details=True,
|
| 819 |
-
v_if="body.role === 'chill'")
|
| 820 |
-
vuetify3.VSelect(v_model="body.params.sleeve",
|
| 821 |
-
items=("exo_sleeve_options",),
|
| 822 |
-
label="sleeve (exo)", density="compact",
|
| 823 |
-
variant="outlined", hide_details=True,
|
| 824 |
-
v_if="body.role === 'exo_sleeve'")
|
| 825 |
-
vuetify3.VSelect(v_model="body.params.sleeve",
|
| 826 |
-
items=("insulating_sleeve_options",),
|
| 827 |
-
label="sleeve (insulating)", density="compact",
|
| 828 |
-
variant="outlined", hide_details=True,
|
| 829 |
-
v_if="body.role === 'insulating_sleeve'")
|
| 830 |
-
html.Div("{{ assembly_error }}", v_show=("assembly_error",),
|
| 831 |
-
style=f"color:{ACCENT_MOLTEN}; font-size:.75rem; "
|
| 832 |
-
"margin:4px 0 8px;")
|
| 833 |
-
vuetify3.VBtn("Run Assembly Prediction",
|
| 834 |
-
click=ctrl.run_assembly_prediction,
|
| 835 |
-
variant="tonal", size="small", color="primary",
|
| 836 |
-
disabled=("loading",), loading=("loading",), block=True)
|
| 837 |
-
|
| 838 |
with html.Div(classes="neuralcast-section"):
|
| 839 |
html.Div("Playback", classes="neuralcast-section-label")
|
| 840 |
vuetify3.VSlider(v_model=("tau",), min=("tmin",), max=("tmax",),
|
|
|
|
| 2 |
prediction, 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
|
| 6 |
+
keeps the largest solid as the casting."""
|
|
|
|
|
|
|
|
|
|
| 7 |
import os
|
| 8 |
import os.path as osp
|
| 9 |
|
|
|
|
| 15 |
from pyvista.trame.ui import plotter_ui
|
| 16 |
|
| 17 |
from src import viz
|
|
|
|
| 18 |
from src.inference_props import DEFAULT_ALLOY, list_alloys, list_molds
|
| 19 |
from src.predict_casting import predict_casting
|
|
|
|
| 20 |
|
| 21 |
DEFAULT_MOLD = "green_sand"
|
|
|
|
| 22 |
|
| 23 |
pv.OFF_SCREEN = True
|
| 24 |
|
|
|
|
| 55 |
# range but is rendered as small spheres (not a field
|
| 56 |
# color), so it still reads as an overlay/alert
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
_MONO_STACK = ('"JetBrains Mono", ui-monospace, "SFMono-Regular", '
|
| 59 |
"Menlo, Consolas, monospace")
|
| 60 |
_COND_STACK = '"Roboto Condensed", "Arial Narrow", sans-serif'
|
|
|
|
| 180 |
INITIAL_SUMMARY = "(upload a STEP file)"
|
| 181 |
|
| 182 |
|
| 183 |
+
def _run_prediction(step_path, out_dir, alloy, mold):
|
| 184 |
+
"""Thin wrapper around `predict_casting` forwarding alloy/mold conditioning.
|
|
|
|
| 185 |
|
| 186 |
Extracted so the upload handler's predict call is unit-testable without
|
| 187 |
driving the full trame upload flow (module-level lookup of
|
| 188 |
`predict_casting` so tests can monkeypatch `src.webapp.predict_casting`).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
"""
|
|
|
|
|
|
|
| 190 |
return predict_casting(step_path, out_dir=out_dir, alloy=alloy, mold=mold)
|
| 191 |
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
def _rebuild_hotspots(app):
|
| 194 |
"""(Re)build the hot-spot point actor from the current cutoff/size state.
|
| 195 |
Called on load and whenever the size or cutoff slider moves."""
|
|
|
|
| 221 |
pass
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
def _process_upload(app, step_path, alloy, mold):
|
| 225 |
"""Core upload logic, extracted from the `uploaded_file` state-change
|
| 226 |
handler so it is testable without a browser/file-upload round trip:
|
| 227 |
+
run the prediction and render it, keeping the UI alive on failure."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
server = app["server"]
|
| 229 |
state = server.state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
plotter = app["plotter"]
|
| 231 |
out_dir = "runs/castings"
|
| 232 |
try:
|
|
|
|
| 308 |
},
|
| 309 |
},
|
| 310 |
}
|
|
|
|
| 311 |
for k, v in dict(tau=0.0, tmin=0.0, tmax=1.0, show_hotspots=False,
|
| 312 |
summary=INITIAL_SUMMARY, loaded=False, uploaded_file=None,
|
| 313 |
loading=False, hotspot_size=DEFAULT_HOTSPOT_SIZE,
|
|
|
|
| 315 |
anim_uri="", anim_name="solidification.gif",
|
| 316 |
alloy=DEFAULT_ALLOY, mold=DEFAULT_MOLD,
|
| 317 |
alloy_options=list_alloys(), mold_options=list_molds(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
).items():
|
| 319 |
state.setdefault(k, v)
|
| 320 |
|
|
|
|
| 362 |
state.loading = False
|
| 363 |
ctrl.view_update()
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
def clear_casting():
|
| 366 |
"""Drop the loaded casting and return to a ready-for-upload state."""
|
| 367 |
plotter.clear_actors()
|
| 368 |
_remove_scalar_bar(plotter)
|
| 369 |
app["grid"] = None
|
| 370 |
app["hotspot_actor"] = None
|
|
|
|
| 371 |
state.loaded = False
|
| 372 |
state.summary = INITIAL_SUMMARY
|
| 373 |
state.uploaded_file = None
|
|
|
|
| 376 |
state.anim_ready = False
|
| 377 |
state.anim_uri = ""
|
| 378 |
state.animating = False
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
ctrl.view_update()
|
| 380 |
|
| 381 |
# Exposed on the controller so tests (and any future non-UI caller) can
|
|
|
|
| 469 |
size="small", color="secondary", disabled=("!loaded",),
|
| 470 |
classes="mt-2", block=True)
|
| 471 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
with html.Div(classes="neuralcast-section"):
|
| 473 |
html.Div("Playback", classes="neuralcast-section-label")
|
| 474 |
vuetify3.VSlider(v_model=("tau",), min=("tmin",), max=("tmax",),
|
tests/test_assembly.py
DELETED
|
@@ -1,732 +0,0 @@
|
|
| 1 |
-
"""Tests for src/assembly.py -- Part B Task 1: assembly inspection
|
| 2 |
-
(`list_solids`) + the `compose_assembly` test/fixture helper used by later
|
| 3 |
-
Part B fixtures (proximity classification, BC-patch tagging, etc.), and
|
| 4 |
-
Part B Task 2: `validate_bodies -> BcPlan` (no gmsh needed for Task 2)."""
|
| 5 |
-
from __future__ import annotations
|
| 6 |
-
|
| 7 |
-
import numpy as np
|
| 8 |
-
import pytest
|
| 9 |
-
|
| 10 |
-
from src.assembly import (BcPlan, list_solids, compose_assembly, main, validate_bodies,
|
| 11 |
-
tag_contact_patches, _triangle_areas, _rewrite_msh_adiabatic)
|
| 12 |
-
from src.msh_parse import parse_msh
|
| 13 |
-
from tests.test_prep_parts import _make_one_box_step
|
| 14 |
-
from tools.doe.load_properties import resolve_exo_q0_by_mef
|
| 15 |
-
from tools.doe.prep_parts import heal_and_mesh
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def test_compose_and_list_two_solid_assembly(tmp_path):
|
| 19 |
-
"""Base box (30x20x10, the "casting") + a smaller box (10x10x6, a
|
| 20 |
-
"chill") attached at the +z face. `compose_assembly` places the chill
|
| 21 |
-
sunk `overlap_mm` into the base (default 2.0mm) -- NO boolean op, so the
|
| 22 |
-
chill's own reported volume is its full analytic 10*10*6, unaffected by
|
| 23 |
-
the intentional overlap with the base solid. `list_solids` must report
|
| 24 |
-
both solids, sorted by volume descending (casting first)."""
|
| 25 |
-
base_step = tmp_path / "base.step"
|
| 26 |
-
_make_one_box_step(base_step, 30.0, 20.0, 10.0)
|
| 27 |
-
|
| 28 |
-
out_step = tmp_path / "assembly.step"
|
| 29 |
-
attachments = [
|
| 30 |
-
{"shape": "box", "size": (10.0, 10.0, 6.0), "at_face": "+z"},
|
| 31 |
-
]
|
| 32 |
-
n = compose_assembly(str(base_step), attachments, str(out_step))
|
| 33 |
-
assert n == 2
|
| 34 |
-
|
| 35 |
-
rows = list_solids(str(out_step))
|
| 36 |
-
assert len(rows) == 2
|
| 37 |
-
assert rows[0]["volume_mm3"] == pytest.approx(30.0 * 20.0 * 10.0, rel=0.05)
|
| 38 |
-
assert rows[1]["volume_mm3"] == pytest.approx(10.0 * 10.0 * 6.0, rel=0.05)
|
| 39 |
-
# sorted desc by volume
|
| 40 |
-
assert rows[0]["volume_mm3"] >= rows[1]["volume_mm3"]
|
| 41 |
-
for row in rows:
|
| 42 |
-
assert set(row.keys()) == {"solid_id", "name", "volume_mm3", "bbox", "com"}
|
| 43 |
-
assert len(row["bbox"]) == 6
|
| 44 |
-
assert len(row["com"]) == 3
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def test_list_solids_single_solid(tmp_path):
|
| 48 |
-
"""A plain single-solid STEP (reusing the existing box fixture from
|
| 49 |
-
tests/test_prep_parts.py) must yield exactly 1 row."""
|
| 50 |
-
step = tmp_path / "one_box.step"
|
| 51 |
-
_make_one_box_step(step, 20.0, 20.0, 20.0)
|
| 52 |
-
|
| 53 |
-
rows = list_solids(str(step))
|
| 54 |
-
assert len(rows) == 1
|
| 55 |
-
assert rows[0]["volume_mm3"] == pytest.approx(20.0 ** 3, rel=0.05)
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def test_compose_assembly_cylinder_attachment(tmp_path):
|
| 59 |
-
"""Cylinder-shaped attachment (radius, height) at a different face,
|
| 60 |
-
exercised end to end -- also confirms non-"+z" faces work."""
|
| 61 |
-
base_step = tmp_path / "base.step"
|
| 62 |
-
_make_one_box_step(base_step, 30.0, 20.0, 10.0)
|
| 63 |
-
|
| 64 |
-
out_step = tmp_path / "assembly.step"
|
| 65 |
-
attachments = [
|
| 66 |
-
{"shape": "cylinder", "size": (3.0, 15.0), "at_face": "+x", "overlap_mm": 1.5},
|
| 67 |
-
]
|
| 68 |
-
n = compose_assembly(str(base_step), attachments, str(out_step))
|
| 69 |
-
assert n == 2
|
| 70 |
-
|
| 71 |
-
rows = list_solids(str(out_step))
|
| 72 |
-
assert len(rows) == 2
|
| 73 |
-
import math
|
| 74 |
-
cyl_vol = math.pi * 3.0 ** 2 * 15.0
|
| 75 |
-
volumes = sorted(row["volume_mm3"] for row in rows)
|
| 76 |
-
assert volumes[0] == pytest.approx(cyl_vol, rel=0.05)
|
| 77 |
-
assert volumes[1] == pytest.approx(30.0 * 20.0 * 10.0, rel=0.05)
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def test_main_prints_solid_ids(tmp_path, capsys):
|
| 81 |
-
"""`python -m src.assembly part.step` (exercised here via a direct
|
| 82 |
-
`main()` call rather than a real subprocess, to keep the test fast)
|
| 83 |
-
prints a table that includes both solids' ids."""
|
| 84 |
-
base_step = tmp_path / "base.step"
|
| 85 |
-
_make_one_box_step(base_step, 30.0, 20.0, 10.0)
|
| 86 |
-
out_step = tmp_path / "assembly.step"
|
| 87 |
-
compose_assembly(str(base_step),
|
| 88 |
-
[{"shape": "box", "size": (10.0, 10.0, 6.0), "at_face": "+z"}],
|
| 89 |
-
str(out_step))
|
| 90 |
-
|
| 91 |
-
rows = list_solids(str(out_step))
|
| 92 |
-
rc = main([str(out_step)])
|
| 93 |
-
captured = capsys.readouterr()
|
| 94 |
-
|
| 95 |
-
assert rc == 0
|
| 96 |
-
for row in rows:
|
| 97 |
-
assert str(row["solid_id"]) in captured.out
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
# ---------------------------------------------------------------------- #
|
| 101 |
-
# Part B Task 2 -- validate_bodies -> BcPlan (no gmsh; pure dict/CSV logic).
|
| 102 |
-
# All tests use the real, committed registries (data/properties/chills.csv,
|
| 103 |
-
# tools/sleeves/sleeve_registry.csv, data/properties/exo_calibration.csv)
|
| 104 |
-
# since those ARE the production registries and their values are pinned
|
| 105 |
-
# (see module docstrings): cast_iron effusivity 11968, sleeve "SD_4_7K"
|
| 106 |
-
# MEF 1.547 (2026-08-09 sleeve-mef-correction: catalog_klx/catalog_geom =
|
| 107 |
-
# 1.16/0.75, was the synthetic-spread value 1.49 before the fix -- see
|
| 108 |
-
# tools/sleeves/gen_sleeves.py's module docstring).
|
| 109 |
-
# ---------------------------------------------------------------------- #
|
| 110 |
-
def test_validate_bodies_casting_plus_two_chills():
|
| 111 |
-
bodies = [
|
| 112 |
-
{"solid_id": 1, "role": "casting"},
|
| 113 |
-
{"solid_id": 2, "role": "chill"},
|
| 114 |
-
{"solid_id": 3, "role": "chill"},
|
| 115 |
-
]
|
| 116 |
-
plan = validate_bodies(bodies)
|
| 117 |
-
assert isinstance(plan, BcPlan)
|
| 118 |
-
assert plan.bc_config == "chills"
|
| 119 |
-
assert plan.casting_solid_id == 1
|
| 120 |
-
assert sorted(plan.role_solid_ids) == [2, 3]
|
| 121 |
-
assert plan.n_chills == 2
|
| 122 |
-
assert plan.chill_effusivity == pytest.approx(11968.0)
|
| 123 |
-
assert plan.sleeve_MEF == 0.0
|
| 124 |
-
assert plan.exo_q0 == 0.0
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def test_validate_bodies_chills_explicit_material_matches_default():
|
| 128 |
-
bodies = [
|
| 129 |
-
{"solid_id": 1, "role": "casting"},
|
| 130 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}},
|
| 131 |
-
]
|
| 132 |
-
plan = validate_bodies(bodies)
|
| 133 |
-
assert plan.bc_config == "chills"
|
| 134 |
-
assert plan.n_chills == 1
|
| 135 |
-
assert plan.chill_effusivity == pytest.approx(11968.0)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def test_validate_bodies_chills_differing_materials_raises():
|
| 139 |
-
bodies = [
|
| 140 |
-
{"solid_id": 1, "role": "casting"},
|
| 141 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}},
|
| 142 |
-
{"solid_id": 3, "role": "chill", "params": {"material": "graphite"}},
|
| 143 |
-
]
|
| 144 |
-
with pytest.raises(ValueError, match="all chills must share one material"):
|
| 145 |
-
validate_bodies(bodies)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def test_validate_bodies_unknown_chill_material_raises_listing_options():
|
| 149 |
-
bodies = [
|
| 150 |
-
{"solid_id": 1, "role": "casting"},
|
| 151 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "unobtainium"}},
|
| 152 |
-
]
|
| 153 |
-
with pytest.raises(ValueError) as exc_info:
|
| 154 |
-
validate_bodies(bodies)
|
| 155 |
-
msg = str(exc_info.value)
|
| 156 |
-
assert "unobtainium" in msg
|
| 157 |
-
assert "cast_iron" in msg # a known material is listed
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def test_validate_bodies_exo_sleeve_by_registry_name():
|
| 161 |
-
bodies = [
|
| 162 |
-
{"solid_id": 1, "role": "casting"},
|
| 163 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {"sleeve": "SD_4_7K"}},
|
| 164 |
-
]
|
| 165 |
-
plan = validate_bodies(bodies)
|
| 166 |
-
assert plan.bc_config == "exo_sleeve"
|
| 167 |
-
assert plan.role_solid_ids == [2]
|
| 168 |
-
assert plan.sleeve_MEF == pytest.approx(1.547)
|
| 169 |
-
assert plan.exo_q0 > 0.0
|
| 170 |
-
assert plan.exo_q0 == pytest.approx(resolve_exo_q0_by_mef(1.547))
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def test_validate_bodies_exo_sleeve_manual_mef_override():
|
| 174 |
-
bodies = [
|
| 175 |
-
{"solid_id": 1, "role": "casting"},
|
| 176 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {"MEF": 1.49}},
|
| 177 |
-
]
|
| 178 |
-
plan = validate_bodies(bodies)
|
| 179 |
-
assert plan.bc_config == "exo_sleeve"
|
| 180 |
-
assert plan.sleeve_MEF == pytest.approx(1.49)
|
| 181 |
-
assert plan.exo_q0 == pytest.approx(resolve_exo_q0_by_mef(1.49))
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
def test_validate_bodies_insulating_sleeve_no_exo_q0():
|
| 185 |
-
# FOSECO_V90L is registry type=insulating -- matches role insulating_sleeve.
|
| 186 |
-
bodies = [
|
| 187 |
-
{"solid_id": 1, "role": "casting"},
|
| 188 |
-
{"solid_id": 2, "role": "insulating_sleeve", "params": {"sleeve": "FOSECO_V90L"}},
|
| 189 |
-
]
|
| 190 |
-
plan = validate_bodies(bodies)
|
| 191 |
-
assert plan.bc_config == "insulating_sleeve"
|
| 192 |
-
assert plan.sleeve_MEF == pytest.approx(1.15)
|
| 193 |
-
assert plan.exo_q0 == 0.0
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
def test_validate_bodies_sleeve_both_name_and_mef_raises():
|
| 197 |
-
bodies = [
|
| 198 |
-
{"solid_id": 1, "role": "casting"},
|
| 199 |
-
{"solid_id": 2, "role": "exo_sleeve",
|
| 200 |
-
"params": {"sleeve": "SD_4_7K", "MEF": 1.49}},
|
| 201 |
-
]
|
| 202 |
-
with pytest.raises(ValueError, match="not both"):
|
| 203 |
-
validate_bodies(bodies)
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
def test_validate_bodies_sleeve_neither_name_nor_mef_raises():
|
| 207 |
-
bodies = [
|
| 208 |
-
{"solid_id": 1, "role": "casting"},
|
| 209 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {}},
|
| 210 |
-
]
|
| 211 |
-
with pytest.raises(ValueError, match="'sleeve'.*'MEF'|'MEF'.*'sleeve'"):
|
| 212 |
-
validate_bodies(bodies)
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
def test_validate_bodies_exo_role_with_insulating_typed_sleeve_raises():
|
| 216 |
-
# FOSECO_V88E is registry type=insulating, MEF=1.45 -- collides with an
|
| 217 |
-
# exo_calibration.csv row at MEF=1.45, so without the type cross-check
|
| 218 |
-
# this would silently resolve a confident nonzero exo_q0 for a
|
| 219 |
-
# non-exothermic product.
|
| 220 |
-
bodies = [
|
| 221 |
-
{"solid_id": 1, "role": "casting"},
|
| 222 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {"sleeve": "FOSECO_V88E"}},
|
| 223 |
-
]
|
| 224 |
-
with pytest.raises(ValueError, match="FOSECO_V88E.*type=insulating"):
|
| 225 |
-
validate_bodies(bodies)
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
def test_validate_bodies_insulating_role_with_exo_typed_sleeve_raises():
|
| 229 |
-
# SD_4_7K is registry type=exo -- must not be accepted under
|
| 230 |
-
# insulating_sleeve.
|
| 231 |
-
bodies = [
|
| 232 |
-
{"solid_id": 1, "role": "casting"},
|
| 233 |
-
{"solid_id": 2, "role": "insulating_sleeve", "params": {"sleeve": "SD_4_7K"}},
|
| 234 |
-
]
|
| 235 |
-
with pytest.raises(ValueError, match="SD_4_7K.*type=exo"):
|
| 236 |
-
validate_bodies(bodies)
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
def test_validate_bodies_sleeve_type_matches_role_ok():
|
| 240 |
-
# exo role + exo-typed sleeve, and insulating role + insulating-typed
|
| 241 |
-
# sleeve, both resolve cleanly (matched pairs).
|
| 242 |
-
exo_bodies = [
|
| 243 |
-
{"solid_id": 1, "role": "casting"},
|
| 244 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {"sleeve": "SD_4_7K"}},
|
| 245 |
-
]
|
| 246 |
-
plan = validate_bodies(exo_bodies)
|
| 247 |
-
assert plan.bc_config == "exo_sleeve"
|
| 248 |
-
assert plan.sleeve_MEF == pytest.approx(1.547)
|
| 249 |
-
|
| 250 |
-
insulating_bodies = [
|
| 251 |
-
{"solid_id": 1, "role": "casting"},
|
| 252 |
-
{"solid_id": 2, "role": "insulating_sleeve", "params": {"sleeve": "FOSECO_V88E"}},
|
| 253 |
-
]
|
| 254 |
-
plan2 = validate_bodies(insulating_bodies)
|
| 255 |
-
assert plan2.bc_config == "insulating_sleeve"
|
| 256 |
-
assert plan2.sleeve_MEF == pytest.approx(1.45)
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
def test_validate_bodies_manual_mef_bypasses_type_check_either_role():
|
| 260 |
-
# Manual MEF override has no registry row to check against -- expert
|
| 261 |
-
# mode, allowed under either sleeve role.
|
| 262 |
-
exo_bodies = [
|
| 263 |
-
{"solid_id": 1, "role": "casting"},
|
| 264 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {"MEF": 1.45}},
|
| 265 |
-
]
|
| 266 |
-
plan = validate_bodies(exo_bodies)
|
| 267 |
-
assert plan.bc_config == "exo_sleeve"
|
| 268 |
-
assert plan.sleeve_MEF == pytest.approx(1.45)
|
| 269 |
-
|
| 270 |
-
insulating_bodies = [
|
| 271 |
-
{"solid_id": 1, "role": "casting"},
|
| 272 |
-
{"solid_id": 2, "role": "insulating_sleeve", "params": {"MEF": 1.49}},
|
| 273 |
-
]
|
| 274 |
-
plan2 = validate_bodies(insulating_bodies)
|
| 275 |
-
assert plan2.bc_config == "insulating_sleeve"
|
| 276 |
-
assert plan2.sleeve_MEF == pytest.approx(1.49)
|
| 277 |
-
assert plan2.exo_q0 == 0.0
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def test_validate_bodies_unknown_sleeve_name_raises_listing_options():
|
| 281 |
-
bodies = [
|
| 282 |
-
{"solid_id": 1, "role": "casting"},
|
| 283 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {"sleeve": "NOT_A_SLEEVE"}},
|
| 284 |
-
]
|
| 285 |
-
with pytest.raises(ValueError) as exc_info:
|
| 286 |
-
validate_bodies(bodies)
|
| 287 |
-
msg = str(exc_info.value)
|
| 288 |
-
assert "NOT_A_SLEEVE" in msg
|
| 289 |
-
assert "SD_4_7K" in msg
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
def test_validate_bodies_casting_only_bc_none():
|
| 293 |
-
bodies = [{"solid_id": 1, "role": "casting"}]
|
| 294 |
-
plan = validate_bodies(bodies)
|
| 295 |
-
assert plan.bc_config == "none"
|
| 296 |
-
assert plan.casting_solid_id == 1
|
| 297 |
-
assert plan.role_solid_ids == []
|
| 298 |
-
assert plan.n_chills == 0
|
| 299 |
-
assert plan.chill_effusivity == 0.0
|
| 300 |
-
assert plan.sleeve_MEF == 0.0
|
| 301 |
-
assert plan.exo_q0 == 0.0
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
def test_validate_bodies_ignore_bodies_dropped():
|
| 305 |
-
bodies = [
|
| 306 |
-
{"solid_id": 1, "role": "casting"},
|
| 307 |
-
{"solid_id": 2, "role": "ignore"},
|
| 308 |
-
{"solid_id": 3, "role": "ignore", "params": {"whatever": 1}},
|
| 309 |
-
]
|
| 310 |
-
plan = validate_bodies(bodies)
|
| 311 |
-
assert plan.bc_config == "none"
|
| 312 |
-
assert plan.casting_solid_id == 1
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
def test_validate_bodies_zero_castings_raises():
|
| 316 |
-
bodies = [{"solid_id": 2, "role": "chill"}]
|
| 317 |
-
with pytest.raises(ValueError, match="exactly one"):
|
| 318 |
-
validate_bodies(bodies)
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
def test_validate_bodies_two_castings_raises():
|
| 322 |
-
bodies = [
|
| 323 |
-
{"solid_id": 1, "role": "casting"},
|
| 324 |
-
{"solid_id": 2, "role": "casting"},
|
| 325 |
-
]
|
| 326 |
-
with pytest.raises(ValueError, match="exactly one"):
|
| 327 |
-
validate_bodies(bodies)
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
def test_validate_bodies_chills_plus_sleeve_mixed_raises():
|
| 331 |
-
bodies = [
|
| 332 |
-
{"solid_id": 1, "role": "casting"},
|
| 333 |
-
{"solid_id": 2, "role": "chill"},
|
| 334 |
-
{"solid_id": 3, "role": "exo_sleeve", "params": {"sleeve": "SD_4_7K"}},
|
| 335 |
-
]
|
| 336 |
-
with pytest.raises(ValueError, match="chills and a sleeve cannot be combined"):
|
| 337 |
-
validate_bodies(bodies)
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
def test_validate_bodies_mixed_sleeve_roles_raises():
|
| 341 |
-
# SP4 ADDENDUM (2026-08-08): N sleeve bodies are accepted now, but only
|
| 342 |
-
# when they share one role -- an exo_sleeve + an insulating_sleeve still
|
| 343 |
-
# raises, because the model has a single sleeve BC channel and cannot
|
| 344 |
-
# represent both kinds of sleeve heat exchange at once.
|
| 345 |
-
bodies = [
|
| 346 |
-
{"solid_id": 1, "role": "casting"},
|
| 347 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {"sleeve": "SD_4_7K"}},
|
| 348 |
-
{"solid_id": 3, "role": "insulating_sleeve", "params": {"sleeve": "FOSECO_V90L"}},
|
| 349 |
-
]
|
| 350 |
-
with pytest.raises(ValueError, match="one role"):
|
| 351 |
-
validate_bodies(bodies)
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
def test_validate_bodies_two_same_role_sleeves_resolve():
|
| 355 |
-
# SP4 ADDENDUM (2026-08-08): two sleeve bodies of the SAME role and the
|
| 356 |
-
# SAME registry-resolved MEF must now be accepted (this used to raise
|
| 357 |
-
# "at most one sleeve body is supported"), resolving to that single MEF
|
| 358 |
-
# exactly and covering both solid_ids in role_solid_ids.
|
| 359 |
-
bodies = [
|
| 360 |
-
{"solid_id": 1, "role": "casting"},
|
| 361 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {"sleeve": "SD_4_7K"}},
|
| 362 |
-
{"solid_id": 3, "role": "exo_sleeve", "params": {"sleeve": "SD_4_7K"}},
|
| 363 |
-
]
|
| 364 |
-
plan = validate_bodies(bodies)
|
| 365 |
-
assert plan.bc_config == "exo_sleeve"
|
| 366 |
-
assert sorted(plan.role_solid_ids) == [2, 3]
|
| 367 |
-
assert plan.sleeve_MEF == pytest.approx(1.547)
|
| 368 |
-
assert plan.mef_weighted is False
|
| 369 |
-
assert plan.mef_spread == pytest.approx(0.0)
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
def test_validate_bodies_unknown_role_raises():
|
| 373 |
-
bodies = [
|
| 374 |
-
{"solid_id": 1, "role": "casting"},
|
| 375 |
-
{"solid_id": 2, "role": "typo_role"},
|
| 376 |
-
]
|
| 377 |
-
with pytest.raises(ValueError, match="unknown role"):
|
| 378 |
-
validate_bodies(bodies)
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
def test_bcplan_feature_spec_keys_and_values():
|
| 382 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 383 |
-
n_chills=1, chill_effusivity=11968.0)
|
| 384 |
-
spec = plan.feature_spec(mold_effusivity=1250.0)
|
| 385 |
-
assert spec == {
|
| 386 |
-
"mold_effusivity": 1250.0,
|
| 387 |
-
"bc_config": "chills",
|
| 388 |
-
"chill_effusivity": 11968.0,
|
| 389 |
-
"sleeve_MEF": 0.0,
|
| 390 |
-
"exo_q0": 0.0,
|
| 391 |
-
}
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
def test_bcplan_gbm_bc_spec_keys_and_exo_q0_mw_conversion():
|
| 395 |
-
plan = BcPlan(bc_config="exo_sleeve", casting_solid_id=1, role_solid_ids=[2],
|
| 396 |
-
sleeve_MEF=1.49, exo_q0=960000.0)
|
| 397 |
-
spec = plan.gbm_bc_spec()
|
| 398 |
-
assert spec == {
|
| 399 |
-
"bc_config": "exo_sleeve",
|
| 400 |
-
"n_chills": 0,
|
| 401 |
-
"sleeve_MEF": 1.49,
|
| 402 |
-
"exo_q0_MW": pytest.approx(0.96),
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
# ---------------------------------------------------------------------- #
|
| 407 |
-
# Part B Task 3 -- tag_contact_patches: mesh the user-designated casting
|
| 408 |
-
# solid (via prep_parts' new `keep_solid_id`), classify its "Cooling"
|
| 409 |
-
# triangles by proximity to role bodies, and rewrite the mesh's Adiabatic
|
| 410 |
-
# facet group. Real gmsh throughout -- these build a fresh composer fixture
|
| 411 |
-
# each time (see `src.assembly.compose_assembly`'s module docstring for why
|
| 412 |
-
# a few mm of intentional overlap is the fixture convention, not a bug).
|
| 413 |
-
# ---------------------------------------------------------------------- #
|
| 414 |
-
def _elem_est_mm(mesh) -> float:
|
| 415 |
-
"""Same `bbox_diag / n_nodes**(1/3)` local element-size estimate
|
| 416 |
-
`tag_contact_patches` uses for its default `tol_mm` -- used here only to
|
| 417 |
-
size test assertion margins, not to drive the function itself."""
|
| 418 |
-
bbox_diag = float(np.linalg.norm(mesh.node_xyz.max(axis=0) - mesh.node_xyz.min(axis=0)))
|
| 419 |
-
return bbox_diag / (mesh.node_xyz.shape[0] ** (1.0 / 3.0))
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
def _build_chill_assembly(tmp_path, base_size=(30.0, 20.0, 10.0), chill_size=(10.0, 10.0, 6.0),
|
| 423 |
-
overlap_mm=2.0, target_nodes=500):
|
| 424 |
-
"""Base box (the "casting") + a smaller box (the "chill") attached at
|
| 425 |
-
the "+z" face, exactly the `compose_assembly` fixture pattern used
|
| 426 |
-
throughout this file. Meshes ONLY the casting (`keep_solid_id`, Task 3's
|
| 427 |
-
other new capability -- the casting need not be the largest solid in
|
| 428 |
-
general, though it is here) and returns
|
| 429 |
-
`(casting_msh_path, assembly_step_path, casting_id, chill_id,
|
| 430 |
-
heal_and_mesh_result)`."""
|
| 431 |
-
base_step = tmp_path / "base.step"
|
| 432 |
-
_make_one_box_step(base_step, *base_size)
|
| 433 |
-
|
| 434 |
-
assembly_step = tmp_path / "assembly.step"
|
| 435 |
-
attachments = [{"shape": "box", "size": chill_size, "at_face": "+z", "overlap_mm": overlap_mm}]
|
| 436 |
-
compose_assembly(str(base_step), attachments, str(assembly_step))
|
| 437 |
-
|
| 438 |
-
rows = list_solids(str(assembly_step))
|
| 439 |
-
casting_id = max(rows, key=lambda r: r["volume_mm3"])["solid_id"]
|
| 440 |
-
chill_id = min(rows, key=lambda r: r["volume_mm3"])["solid_id"]
|
| 441 |
-
|
| 442 |
-
out_msh = tmp_path / "casting.msh"
|
| 443 |
-
result = heal_and_mesh(str(assembly_step), str(out_msh), target_nodes=target_nodes,
|
| 444 |
-
keep_solid_id=casting_id)
|
| 445 |
-
assert result["ok"] is True
|
| 446 |
-
return str(out_msh), str(assembly_step), casting_id, chill_id, result
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
def test_tag_contact_patches_exact_contact_matches_chill_footprint(tmp_path):
|
| 450 |
-
"""Overlap 2mm box chill at "+z" center (compose_assembly's own default
|
| 451 |
-
overlap) -- the Adiabatic patch must land on the chill's footprint: every
|
| 452 |
-
Adiabatic centroid within the footprint's xy extent (+- a couple of
|
| 453 |
-
element widths, since a nonzero tol_mm necessarily dilates the detected
|
| 454 |
-
region a little past the true edge) and at the casting's top z; total
|
| 455 |
-
patch area within 30% of the chill's analytic contact-face area. A
|
| 456 |
-
large-relative-to-tol footprint (40x40mm chill on a 60x50x15mm base) is
|
| 457 |
-
used so the fixed ~2mm overlap gap doesn't force a tol_mm whose
|
| 458 |
-
Minkowski-dilation ring is a big fraction of a small footprint's area
|
| 459 |
-
(verified numerically while writing this test: a 10x10mm footprint blows
|
| 460 |
-
well past 30% at any tol_mm large enough to bridge a 2mm gap)."""
|
| 461 |
-
base_size = (60.0, 50.0, 15.0)
|
| 462 |
-
chill_size = (40.0, 40.0, 6.0)
|
| 463 |
-
out_msh, assembly_step, casting_id, chill_id, _result = _build_chill_assembly(
|
| 464 |
-
tmp_path, base_size=base_size, chill_size=chill_size, overlap_mm=2.0,
|
| 465 |
-
target_nodes=3000)
|
| 466 |
-
|
| 467 |
-
mesh_before = parse_msh(out_msh)
|
| 468 |
-
elem_est = _elem_est_mm(mesh_before)
|
| 469 |
-
tol_mm = 2.5 # > the 2mm overlap gap, small relative to the 40x40 footprint
|
| 470 |
-
|
| 471 |
-
tag_result = tag_contact_patches(out_msh, assembly_step, [chill_id], tol_mm=tol_mm)
|
| 472 |
-
|
| 473 |
-
assert tag_result["n_adiab_tris"] > 0
|
| 474 |
-
chill_contact_area = chill_size[0] * chill_size[1] # dx * dy of the chill footprint
|
| 475 |
-
assert tag_result["per_body_patch_area_mm2"][chill_id] == pytest.approx(
|
| 476 |
-
chill_contact_area, rel=0.30)
|
| 477 |
-
|
| 478 |
-
mesh_after = parse_msh(out_msh)
|
| 479 |
-
adiab = mesh_after.tris["Adiabatic"]
|
| 480 |
-
assert adiab.shape[0] == tag_result["n_adiab_tris"]
|
| 481 |
-
centroids = mesh_after.node_xyz[adiab].mean(axis=1)
|
| 482 |
-
|
| 483 |
-
# Footprint is centered on the base's top face: x in [10, 50], y in [5, 45]
|
| 484 |
-
# (base 60x50, chill 40x40 centered -> (60-40)/2=10, (50-40)/2=5).
|
| 485 |
-
margin = tol_mm + elem_est
|
| 486 |
-
fx_lo, fx_hi = (base_size[0] - chill_size[0]) / 2.0, (base_size[0] + chill_size[0]) / 2.0
|
| 487 |
-
fy_lo, fy_hi = (base_size[1] - chill_size[1]) / 2.0, (base_size[1] + chill_size[1]) / 2.0
|
| 488 |
-
assert np.all(centroids[:, 0] >= fx_lo - margin)
|
| 489 |
-
assert np.all(centroids[:, 0] <= fx_hi + margin)
|
| 490 |
-
assert np.all(centroids[:, 1] >= fy_lo - margin)
|
| 491 |
-
assert np.all(centroids[:, 1] <= fy_hi + margin)
|
| 492 |
-
# at the casting's top face
|
| 493 |
-
assert np.allclose(centroids[:, 2], base_size[2], atol=margin)
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
def test_tag_contact_patches_nodes_by_solid_matches_adiabatic_group(tmp_path):
|
| 497 |
-
"""Part-B roles task (2026-08-03): `nodes_by_solid` -- the in-memory
|
| 498 |
-
per-solid contact-patch node-id mapping `tag_contact_patches` now also
|
| 499 |
-
returns -- must, for a single role body, exactly equal the node ids of
|
| 500 |
-
the "Adiabatic" group the SAME call wrote to `out_msh` (with only one
|
| 501 |
-
body, that body's own `close` mask IS the Adiabatic union)."""
|
| 502 |
-
out_msh, assembly_step, casting_id, chill_id, _result = _build_chill_assembly(
|
| 503 |
-
tmp_path, overlap_mm=2.0, target_nodes=500)
|
| 504 |
-
|
| 505 |
-
tag_result = tag_contact_patches(out_msh, assembly_step, [chill_id], tol_mm=3.0)
|
| 506 |
-
|
| 507 |
-
assert set(tag_result["nodes_by_solid"]) == {chill_id}
|
| 508 |
-
mesh_after = parse_msh(out_msh)
|
| 509 |
-
adiab_nodes = sorted(set(mesh_after.tris["Adiabatic"].reshape(-1).tolist()))
|
| 510 |
-
assert tag_result["nodes_by_solid"][chill_id] == adiab_nodes
|
| 511 |
-
# sorted, unique, and does not mutate the .msh/node-order contract --
|
| 512 |
-
# covered independently by test_tag_contact_patches_preserves_node_
|
| 513 |
-
# order_and_tets; here just confirm it's genuinely sorted+unique.
|
| 514 |
-
assert tag_result["nodes_by_solid"][chill_id] == sorted(
|
| 515 |
-
set(tag_result["nodes_by_solid"][chill_id]))
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
def test_tag_contact_patches_small_clearance_still_found(tmp_path):
|
| 519 |
-
"""0.1mm CLEARANCE (overlap_mm=-0.1, a hair short of touching) with
|
| 520 |
-
tol_mm >= 0.5mm must still find the patch."""
|
| 521 |
-
out_msh, assembly_step, casting_id, chill_id, _result = _build_chill_assembly(
|
| 522 |
-
tmp_path, overlap_mm=-0.1, target_nodes=500)
|
| 523 |
-
|
| 524 |
-
tag_result = tag_contact_patches(out_msh, assembly_step, [chill_id], tol_mm=0.5)
|
| 525 |
-
|
| 526 |
-
assert tag_result["n_adiab_tris"] > 0
|
| 527 |
-
assert tag_result["per_body_patch_area_mm2"][chill_id] > 0.0
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
def test_tag_contact_patches_far_body_raises(tmp_path):
|
| 531 |
-
"""A body 10mm away with tol_mm=1mm must raise -- never a silent
|
| 532 |
-
all-sand prediction for a body the caller explicitly assigned a role
|
| 533 |
-
to."""
|
| 534 |
-
out_msh, assembly_step, casting_id, chill_id, _result = _build_chill_assembly(
|
| 535 |
-
tmp_path, overlap_mm=-10.0, target_nodes=500)
|
| 536 |
-
|
| 537 |
-
with pytest.raises(ValueError, match=f"body {chill_id} does not touch"):
|
| 538 |
-
tag_contact_patches(out_msh, assembly_step, [chill_id], tol_mm=1.0)
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
def test_tag_contact_patches_preserves_node_order_and_tets(tmp_path):
|
| 542 |
-
"""The rewritten mesh's node ids/coords and tet connectivity must be
|
| 543 |
-
EXACTLY unchanged -- features/geodesics depend on it (see the module
|
| 544 |
-
docstring's node-order caution). Parses before AND after, independently
|
| 545 |
-
of `tag_contact_patches`'s own internal round-trip assertion."""
|
| 546 |
-
out_msh, assembly_step, casting_id, chill_id, _result = _build_chill_assembly(
|
| 547 |
-
tmp_path, overlap_mm=2.0, target_nodes=500)
|
| 548 |
-
|
| 549 |
-
mesh_before = parse_msh(out_msh)
|
| 550 |
-
|
| 551 |
-
tag_contact_patches(out_msh, assembly_step, [chill_id], tol_mm=3.0)
|
| 552 |
-
|
| 553 |
-
mesh_after = parse_msh(out_msh)
|
| 554 |
-
assert np.allclose(mesh_after.node_xyz, mesh_before.node_xyz)
|
| 555 |
-
assert mesh_after.tets.shape == mesh_before.tets.shape
|
| 556 |
-
assert np.array_equal(mesh_after.tets, mesh_before.tets)
|
| 557 |
-
# sanity: some Cooling triangles actually migrated to Adiabatic
|
| 558 |
-
assert "Adiabatic" in mesh_after.tris
|
| 559 |
-
assert mesh_after.tris["Cooling"].shape[0] < mesh_before.tris["Cooling"].shape[0]
|
| 560 |
-
assert (mesh_before.tris["Cooling"].shape[0]
|
| 561 |
-
== mesh_after.tris["Cooling"].shape[0] + mesh_after.tris["Adiabatic"].shape[0])
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
def test_tag_contact_patches_unknown_role_solid_id_raises(tmp_path):
|
| 565 |
-
"""A role `solid_id` that doesn't exist in `step_path` at all must fail
|
| 566 |
-
loudly (whatever form that takes -- gmsh itself raises when asked for
|
| 567 |
-
the boundary of a nonexistent entity), never silently produce an empty
|
| 568 |
-
or wrong patch."""
|
| 569 |
-
out_msh, assembly_step, casting_id, chill_id, _result = _build_chill_assembly(tmp_path)
|
| 570 |
-
|
| 571 |
-
with pytest.raises(Exception):
|
| 572 |
-
tag_contact_patches(out_msh, assembly_step, [9999], tol_mm=3.0)
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
def test_heal_and_mesh_keep_solid_id_meshes_small_chill_from_assembly(tmp_path):
|
| 576 |
-
"""Part B Task 3's other change: `heal_and_mesh(..., keep_solid_id=...)`
|
| 577 |
-
meshes the SMALL (chill) solid out of a composed multi-body assembly
|
| 578 |
-
(n_nodes/volume consistent with its own size), while the largest-solid
|
| 579 |
-
default on the SAME input is untouched (still meshes the big casting)."""
|
| 580 |
-
base_step = tmp_path / "base.step"
|
| 581 |
-
_make_one_box_step(base_step, 30.0, 20.0, 10.0)
|
| 582 |
-
assembly_step = tmp_path / "assembly.step"
|
| 583 |
-
compose_assembly(str(base_step),
|
| 584 |
-
[{"shape": "box", "size": (10.0, 10.0, 6.0), "at_face": "+z"}],
|
| 585 |
-
str(assembly_step))
|
| 586 |
-
|
| 587 |
-
rows = list_solids(str(assembly_step))
|
| 588 |
-
casting_row = max(rows, key=lambda r: r["volume_mm3"])
|
| 589 |
-
chill_row = min(rows, key=lambda r: r["volume_mm3"])
|
| 590 |
-
|
| 591 |
-
out_msh_chill = tmp_path / "chill.msh"
|
| 592 |
-
result_chill = heal_and_mesh(str(assembly_step), str(out_msh_chill), target_nodes=300,
|
| 593 |
-
keep_solid_id=chill_row["solid_id"])
|
| 594 |
-
assert result_chill["ok"] is True
|
| 595 |
-
assert result_chill["volume_mm3"] == pytest.approx(chill_row["volume_mm3"], rel=0.05)
|
| 596 |
-
|
| 597 |
-
out_msh_default = tmp_path / "default.msh"
|
| 598 |
-
result_default = heal_and_mesh(str(assembly_step), str(out_msh_default), target_nodes=300)
|
| 599 |
-
assert result_default["ok"] is True
|
| 600 |
-
assert result_default["volume_mm3"] == pytest.approx(casting_row["volume_mm3"], rel=0.05)
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
# ---------------------------------------------------------------------- #
|
| 604 |
-
# Code-review follow-ups (2026-07-29): total-Cooling-consumption guard,
|
| 605 |
-
# per_body_patch_area_mm2's documented double-counting behavior, and the
|
| 606 |
-
# clean (no bare StopIteration) too-few-flags path in
|
| 607 |
-
# `_rewrite_msh_adiabatic`.
|
| 608 |
-
# ---------------------------------------------------------------------- #
|
| 609 |
-
def test_tag_contact_patches_enveloping_body_raises_no_cooling_remains(tmp_path):
|
| 610 |
-
"""A role body that tightly ENVELOPES the entire casting (every Cooling
|
| 611 |
-
triangle ends up within tol_mm of it) must raise loudly -- if every
|
| 612 |
-
triangle were silently relabeled Adiabatic, `geodesic_d_cool` (see
|
| 613 |
-
`src.features`) would have no "Cooling" surface left to measure
|
| 614 |
-
distance to and would silently degrade to bbox_diag-derived constants
|
| 615 |
-
(confidently wrong physics, not a loud failure)."""
|
| 616 |
-
base_step = tmp_path / "base.step"
|
| 617 |
-
_make_one_box_step(base_step, 30.0, 20.0, 10.0)
|
| 618 |
-
|
| 619 |
-
assembly_step = tmp_path / "assembly.step"
|
| 620 |
-
# A box attached at "+z" with overlap_mm >= its own along-normal size
|
| 621 |
-
# sinks past the base's bottom too, and a face-plane size well past the
|
| 622 |
-
# base's own x/y extent -- the result envelopes the base on all 6 sides
|
| 623 |
-
# with a uniform ~5mm margin (worked out analytically: dz=20,
|
| 624 |
-
# overlap_mm=15 -> z in [-5, 15] vs the base's [0, 10]; dx=40, dy=30
|
| 625 |
-
# centered on the base's own center -> x in [-5, 35] vs [0, 30], y in
|
| 626 |
-
# [-5, 25] vs [0, 20]).
|
| 627 |
-
envelope = {"shape": "box", "size": (40.0, 30.0, 20.0), "at_face": "+z", "overlap_mm": 15.0}
|
| 628 |
-
compose_assembly(str(base_step), [envelope], str(assembly_step))
|
| 629 |
-
|
| 630 |
-
rows = list_solids(str(assembly_step))
|
| 631 |
-
casting_id = max(rows, key=lambda r: r["volume_mm3"])["solid_id"]
|
| 632 |
-
envelope_id = min(rows, key=lambda r: r["volume_mm3"])["solid_id"]
|
| 633 |
-
|
| 634 |
-
out_msh = tmp_path / "casting.msh"
|
| 635 |
-
result = heal_and_mesh(str(assembly_step), str(out_msh), target_nodes=500,
|
| 636 |
-
keep_solid_id=casting_id)
|
| 637 |
-
assert result["ok"] is True
|
| 638 |
-
|
| 639 |
-
with pytest.raises(ValueError, match="cover the entire casting surface"):
|
| 640 |
-
tag_contact_patches(str(out_msh), str(assembly_step), [envelope_id], tol_mm=8.0)
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
def test_tag_contact_patches_two_bodies_double_count_area_single_count_tris(tmp_path):
|
| 644 |
-
"""Two role bodies whose tolerance zones overlap on the casting surface:
|
| 645 |
-
`per_body_patch_area_mm2` sums each body's own area INDEPENDENTLY (the
|
| 646 |
-
shared triangles counted once per body -- documented double-counting),
|
| 647 |
-
while `n_adiab_tris` (and the actual rewritten "Adiabatic" group) counts
|
| 648 |
-
each such triangle exactly once, via the union mask."""
|
| 649 |
-
base_step = tmp_path / "base.step"
|
| 650 |
-
_make_one_box_step(base_step, 60.0, 50.0, 15.0)
|
| 651 |
-
|
| 652 |
-
assembly_step = tmp_path / "assembly.step"
|
| 653 |
-
# Two 40x40x6 chills at "+z", offset +-10mm in x -- footprints
|
| 654 |
-
# [0,40] and [20,60] in x (both y in [5,45]) overlap in a 20mm-wide
|
| 655 |
-
# x in [20,40] band.
|
| 656 |
-
attachments = [
|
| 657 |
-
{"shape": "box", "size": (40.0, 40.0, 6.0), "at_face": "+z", "offset": (-10.0, 0.0)},
|
| 658 |
-
{"shape": "box", "size": (40.0, 40.0, 6.0), "at_face": "+z", "offset": (10.0, 0.0)},
|
| 659 |
-
]
|
| 660 |
-
compose_assembly(str(base_step), attachments, str(assembly_step))
|
| 661 |
-
|
| 662 |
-
rows = list_solids(str(assembly_step))
|
| 663 |
-
casting_id = max(rows, key=lambda r: r["volume_mm3"])["solid_id"]
|
| 664 |
-
role_ids = sorted(r["solid_id"] for r in rows if r["solid_id"] != casting_id)
|
| 665 |
-
assert len(role_ids) == 2
|
| 666 |
-
|
| 667 |
-
out_msh = tmp_path / "casting.msh"
|
| 668 |
-
result = heal_and_mesh(str(assembly_step), str(out_msh), target_nodes=3000,
|
| 669 |
-
keep_solid_id=casting_id)
|
| 670 |
-
assert result["ok"] is True
|
| 671 |
-
|
| 672 |
-
tag_result = tag_contact_patches(str(out_msh), str(assembly_step), role_ids, tol_mm=2.5)
|
| 673 |
-
|
| 674 |
-
areas = tag_result["per_body_patch_area_mm2"]
|
| 675 |
-
assert set(areas) == set(role_ids)
|
| 676 |
-
for solid_id in role_ids:
|
| 677 |
-
assert areas[solid_id] > 0.0
|
| 678 |
-
# symmetric footprints (mirrored offsets) -> roughly equal per-body areas
|
| 679 |
-
assert areas[role_ids[0]] == pytest.approx(areas[role_ids[1]], rel=0.05)
|
| 680 |
-
|
| 681 |
-
mesh_after = parse_msh(str(out_msh))
|
| 682 |
-
adiab = mesh_after.tris["Adiabatic"]
|
| 683 |
-
assert adiab.shape[0] == tag_result["n_adiab_tris"]
|
| 684 |
-
|
| 685 |
-
true_union_area = float(_triangle_areas(mesh_after.node_xyz, adiab).sum())
|
| 686 |
-
# The overlap region's triangles are counted in BOTH bodies' areas, so
|
| 687 |
-
# the sum across bodies must exceed the true (single-counted) union
|
| 688 |
-
# area -- pins the documented double-counting behavior.
|
| 689 |
-
assert sum(areas.values()) > true_union_area * 1.10
|
| 690 |
-
|
| 691 |
-
# nodes_by_solid (Part-B roles task): each body's own node set may share
|
| 692 |
-
# nodes with the other's (the overlap band's shared triangles), but the
|
| 693 |
-
# UNION across both bodies' node sets must equal exactly the true
|
| 694 |
-
# Adiabatic node set -- no node is dropped or invented.
|
| 695 |
-
assert set(tag_result["nodes_by_solid"]) == set(role_ids)
|
| 696 |
-
nodes_union = set()
|
| 697 |
-
for solid_id in role_ids:
|
| 698 |
-
nodes_union.update(tag_result["nodes_by_solid"][solid_id])
|
| 699 |
-
assert nodes_union == set(adiab.reshape(-1).tolist())
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
def test_rewrite_msh_adiabatic_too_few_flags_raises_clean_assertion_error(tmp_path):
|
| 703 |
-
"""A flag array SHORTER than the number of Cooling-tagged triangle
|
| 704 |
-
elements in the file must raise a clean `AssertionError` -- not a bare
|
| 705 |
-
`StopIteration` (the old `next(flags)`-inside-the-loop bug: exhausting
|
| 706 |
-
an iterator this way raises `StopIteration`, which is not a deliberate,
|
| 707 |
-
documented failure mode)."""
|
| 708 |
-
step = tmp_path / "box.step"
|
| 709 |
-
_make_one_box_step(step, 30.0, 20.0, 10.0)
|
| 710 |
-
out_msh = tmp_path / "box.msh"
|
| 711 |
-
result = heal_and_mesh(str(step), str(out_msh), target_nodes=300)
|
| 712 |
-
assert result["ok"] is True
|
| 713 |
-
|
| 714 |
-
n_cooling = parse_msh(str(out_msh)).tris["Cooling"].shape[0]
|
| 715 |
-
too_few = np.zeros(n_cooling - 1, dtype=bool) # one short
|
| 716 |
-
|
| 717 |
-
with pytest.raises(AssertionError, match="fewer"):
|
| 718 |
-
_rewrite_msh_adiabatic(str(out_msh), too_few)
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
def test_rewrite_msh_adiabatic_too_many_flags_raises_clean_assertion_error(tmp_path):
|
| 722 |
-
step = tmp_path / "box.step"
|
| 723 |
-
_make_one_box_step(step, 30.0, 20.0, 10.0)
|
| 724 |
-
out_msh = tmp_path / "box.msh"
|
| 725 |
-
result = heal_and_mesh(str(step), str(out_msh), target_nodes=300)
|
| 726 |
-
assert result["ok"] is True
|
| 727 |
-
|
| 728 |
-
n_cooling = parse_msh(str(out_msh)).tris["Cooling"].shape[0]
|
| 729 |
-
too_many = np.zeros(n_cooling + 1, dtype=bool) # one extra
|
| 730 |
-
|
| 731 |
-
with pytest.raises(AssertionError, match="more flag"):
|
| 732 |
-
_rewrite_msh_adiabatic(str(out_msh), too_many)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_assembly_multisleeve.py
DELETED
|
@@ -1,105 +0,0 @@
|
|
| 1 |
-
"""Multi-sleeve resolution (SP4 spec ADDENDUM).
|
| 2 |
-
|
| 3 |
-
Real castings carry several sleeves; the pre-SP4 code accepted exactly one.
|
| 4 |
-
These tests pin the three cases: identical sleeves resolve exactly, differing
|
| 5 |
-
sleeves need explicit weights, and mixed sleeve ROLES still raise.
|
| 6 |
-
"""
|
| 7 |
-
import pytest
|
| 8 |
-
|
| 9 |
-
from src.assembly import validate_bodies
|
| 10 |
-
|
| 11 |
-
CASTING = {"solid_id": 1, "role": "casting"}
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
def _exo(solid_id, sleeve):
|
| 15 |
-
return {"solid_id": solid_id, "role": "exo_sleeve", "params": {"sleeve": sleeve}}
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def test_three_identical_sleeves_resolve_exactly():
|
| 19 |
-
plan = validate_bodies([CASTING, _exo(2, "SD_4_7K"), _exo(3, "SD_4_7K"),
|
| 20 |
-
_exo(4, "SD_4_7K")])
|
| 21 |
-
assert plan.bc_config == "exo_sleeve"
|
| 22 |
-
assert sorted(plan.role_solid_ids) == [2, 3, 4]
|
| 23 |
-
assert plan.sleeve_MEF == pytest.approx(1.547)
|
| 24 |
-
assert plan.mef_weighted is False
|
| 25 |
-
assert plan.mef_spread == pytest.approx(0.0)
|
| 26 |
-
assert plan.exo_q0 > 0.0
|
| 27 |
-
assert plan.role_by_solid == {2: "exo_sleeve", 3: "exo_sleeve", 4: "exo_sleeve"}
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def test_differing_mef_without_weights_raises():
|
| 31 |
-
with pytest.raises(ValueError, match="differing MEF"):
|
| 32 |
-
validate_bodies([CASTING, _exo(2, "SD_4_7K"), _exo(3, "SD_5_8K")])
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def test_differing_mef_error_points_at_the_two_pass_path():
|
| 36 |
-
"""Review I3: `predict_casting` cannot supply `mef_weights` (contact-
|
| 37 |
-
patch areas don't exist until after meshing/tagging), so the message a
|
| 38 |
-
user actually sees must not just name the missing parameter -- it must
|
| 39 |
-
say where areas come from and which callers already do the two-pass
|
| 40 |
-
resolution correctly."""
|
| 41 |
-
with pytest.raises(ValueError, match="tag_contact_patches") as exc:
|
| 42 |
-
validate_bodies([CASTING, _exo(2, "SD_4_7K"), _exo(3, "SD_5_8K")])
|
| 43 |
-
msg = str(exc.value)
|
| 44 |
-
assert "tools/sp4/prep.py" in msg
|
| 45 |
-
assert "tools/sp4/score.py" in msg
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def test_differing_mef_area_weighted():
|
| 49 |
-
# SD_4_7K MEF 1.547 over 300 mm2, SD_5_8K MEF 1.579 over 100 mm2
|
| 50 |
-
# (2026-08-09 sleeve-mef-correction: catalog_klx/catalog_geom, was
|
| 51 |
-
# 1.49/1.63 before the fix) -> (1.547*300 + 1.579*100) / 400 = 1.555
|
| 52 |
-
plan = validate_bodies([CASTING, _exo(2, "SD_4_7K"), _exo(3, "SD_5_8K")],
|
| 53 |
-
mef_weights={2: 300.0, 3: 100.0})
|
| 54 |
-
assert plan.sleeve_MEF == pytest.approx(1.555)
|
| 55 |
-
assert plan.mef_weighted is True
|
| 56 |
-
assert plan.mef_spread == pytest.approx(1.579 - 1.547)
|
| 57 |
-
assert sorted(plan.role_solid_ids) == [2, 3]
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def test_weights_missing_a_sleeve_raises():
|
| 61 |
-
with pytest.raises(ValueError, match="mef_weights"):
|
| 62 |
-
validate_bodies([CASTING, _exo(2, "SD_4_7K"), _exo(3, "SD_5_8K")],
|
| 63 |
-
mef_weights={2: 300.0})
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def test_nonpositive_weight_raises():
|
| 67 |
-
with pytest.raises(ValueError, match="positive"):
|
| 68 |
-
validate_bodies([CASTING, _exo(2, "SD_4_7K"), _exo(3, "SD_5_8K")],
|
| 69 |
-
mef_weights={2: 300.0, 3: 0.0})
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def test_mixed_sleeve_roles_still_raise():
|
| 73 |
-
bodies = [CASTING, _exo(2, "SD_4_7K"),
|
| 74 |
-
{"solid_id": 3, "role": "insulating_sleeve",
|
| 75 |
-
"params": {"sleeve": "FOSECO_V90L"}}]
|
| 76 |
-
with pytest.raises(ValueError, match="one role"):
|
| 77 |
-
validate_bodies(bodies)
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def test_single_sleeve_unchanged():
|
| 81 |
-
"""The pre-SP4 single-sleeve path must be byte-identical."""
|
| 82 |
-
plan = validate_bodies([CASTING, _exo(2, "SD_4_7K")])
|
| 83 |
-
assert plan.role_solid_ids == [2]
|
| 84 |
-
assert plan.sleeve_MEF == pytest.approx(1.547)
|
| 85 |
-
assert plan.mef_weighted is False
|
| 86 |
-
assert plan.mef_spread == pytest.approx(0.0)
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def test_manual_mef_override_across_several_sleeves():
|
| 90 |
-
bodies = [CASTING,
|
| 91 |
-
{"solid_id": 2, "role": "exo_sleeve", "params": {"MEF": 1.49}},
|
| 92 |
-
{"solid_id": 3, "role": "exo_sleeve", "params": {"MEF": 1.49}}]
|
| 93 |
-
plan = validate_bodies(bodies)
|
| 94 |
-
assert plan.sleeve_MEF == pytest.approx(1.49)
|
| 95 |
-
assert plan.mef_weighted is False
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
def test_chills_path_unchanged():
|
| 99 |
-
bodies = [CASTING,
|
| 100 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}},
|
| 101 |
-
{"solid_id": 3, "role": "chill", "params": {"material": "cast_iron"}}]
|
| 102 |
-
plan = validate_bodies(bodies)
|
| 103 |
-
assert plan.bc_config == "chills"
|
| 104 |
-
assert plan.n_chills == 2
|
| 105 |
-
assert plan.mef_weighted is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_inference_props.py
CHANGED
|
@@ -5,20 +5,22 @@ from src.shrinkage import SHRINKAGE_PCT
|
|
| 5 |
|
| 6 |
REQ = {"k", "rho", "cp", "L_kJ_kg", "TL", "TS", "Tpour", "mold_effusivity"}
|
| 7 |
|
| 8 |
-
def
|
| 9 |
p = resolve_props()
|
| 10 |
assert set(p) == REQ and all(isinstance(v, float) for v in p.values())
|
| 11 |
-
assert p["TL"] ==
|
| 12 |
-
assert p["Tpour"] == pytest.approx((
|
| 13 |
assert p["mold_effusivity"] == pytest.approx(1018.0) # molds.csv, NOT 1250
|
| 14 |
|
| 15 |
def test_named_alloy_and_lists():
|
| 16 |
-
assert DEFAULT_ALLOY in list_alloys()
|
| 17 |
-
|
| 18 |
-
assert
|
|
|
|
|
|
|
| 19 |
|
| 20 |
def test_unknown_names_raise_listing_options():
|
| 21 |
-
with pytest.raises(ValueError, match="EN-
|
| 22 |
resolve_props(alloy="unobtainium")
|
| 23 |
with pytest.raises(ValueError, match="green_sand"):
|
| 24 |
resolve_props(mold="vacuum")
|
|
@@ -61,8 +63,9 @@ def test_named_alloy_tl_ts_still_valid_after_i2_guard():
|
|
| 61 |
# validated by tools.doe.load_properties.load_materials's own TS<TL<Tpour
|
| 62 |
# check at CSV-load time -- this just proves resolve_props's OWN check
|
| 63 |
# doesn't double-reject a legitimately-ordered grade).
|
| 64 |
-
|
| 65 |
-
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
# --------------------------------------------------------------------------- #
|
|
@@ -83,8 +86,8 @@ def test_resolve_alloy_family_known_prefixes():
|
|
| 83 |
|
| 84 |
|
| 85 |
def test_resolve_alloy_family_default_alloy_when_none():
|
| 86 |
-
# None -> DEFAULT_ALLOY (EN-
|
| 87 |
-
assert resolve_alloy_family(None) == ("
|
| 88 |
|
| 89 |
|
| 90 |
def test_resolve_alloy_family_unknown_prefix_falls_back_loud():
|
|
|
|
| 5 |
|
| 6 |
REQ = {"k", "rho", "cp", "L_kJ_kg", "TL", "TS", "Tpour", "mold_effusivity"}
|
| 7 |
|
| 8 |
+
def test_default_is_gjs400_green_sand():
|
| 9 |
p = resolve_props()
|
| 10 |
assert set(p) == REQ and all(isinstance(v, float) for v in p.values())
|
| 11 |
+
assert p["TL"] == 1160.0 and p["TS"] == 1120.0 # EN-GJS-400-15 row
|
| 12 |
+
assert p["Tpour"] == pytest.approx((1250 + 1270) / 2) # midpoint rule
|
| 13 |
assert p["mold_effusivity"] == pytest.approx(1018.0) # molds.csv, NOT 1250
|
| 14 |
|
| 15 |
def test_named_alloy_and_lists():
|
| 16 |
+
assert DEFAULT_ALLOY in list_alloys()
|
| 17 |
+
assert list_alloys() == ["EN-GJS-400-15", "G20Mn5"]
|
| 18 |
+
assert list_molds() == ["green_sand"]
|
| 19 |
+
p = resolve_props(alloy="G20Mn5")
|
| 20 |
+
assert p["TS"] == 1495.0
|
| 21 |
|
| 22 |
def test_unknown_names_raise_listing_options():
|
| 23 |
+
with pytest.raises(ValueError, match="EN-GJS-400-15"):
|
| 24 |
resolve_props(alloy="unobtainium")
|
| 25 |
with pytest.raises(ValueError, match="green_sand"):
|
| 26 |
resolve_props(mold="vacuum")
|
|
|
|
| 63 |
# validated by tools.doe.load_properties.load_materials's own TS<TL<Tpour
|
| 64 |
# check at CSV-load time -- this just proves resolve_props's OWN check
|
| 65 |
# doesn't double-reject a legitimately-ordered grade).
|
| 66 |
+
for grade in list_alloys():
|
| 67 |
+
p = resolve_props(alloy=grade)
|
| 68 |
+
assert p["TL"] > p["TS"]
|
| 69 |
|
| 70 |
|
| 71 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
def test_resolve_alloy_family_default_alloy_when_none():
|
| 89 |
+
# None -> DEFAULT_ALLOY (EN-GJS-400-15) -> ductile_iron, not assumed.
|
| 90 |
+
assert resolve_alloy_family(None) == ("ductile_iron", False)
|
| 91 |
|
| 92 |
|
| 93 |
def test_resolve_alloy_family_unknown_prefix_falls_back_loud():
|
tests/test_model_api.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CastSolidModel: mesh in, per-node seconds out (needs the published weights)."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from src.model_api import CastSolidModel
|
| 8 |
+
|
| 9 |
+
WEIGHTS = "weights/gnn_v5.1.safetensors"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_hotspots_matches_top_fraction():
|
| 13 |
+
t = np.arange(100, dtype=np.float32)
|
| 14 |
+
mask = CastSolidModel.hotspots(t)
|
| 15 |
+
assert mask.sum() == 10 and mask[-10:].all()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.mark.skipif(not os.path.exists(WEIGHTS), reason="published weights not present")
|
| 19 |
+
def test_predict_on_mesh(mini_msh_file):
|
| 20 |
+
model = CastSolidModel(device="cpu")
|
| 21 |
+
t_iron = model.predict(mini_msh_file)
|
| 22 |
+
t_steel = model.predict(mini_msh_file, alloy="G20Mn5")
|
| 23 |
+
assert t_iron.shape == t_steel.shape and t_iron.ndim == 1
|
| 24 |
+
assert np.all(np.isfinite(t_iron)) and np.all(t_iron >= 0)
|
| 25 |
+
assert not np.allclose(t_iron, t_steel)
|
tests/test_predict_casting_v4.py
CHANGED
|
@@ -13,13 +13,6 @@ by default (`mesh_mode="prep"`), with `mesh_mode="raw"` (or an explicit
|
|
| 13 |
path. The routing tests below monkeypatch both meshing functions as
|
| 14 |
recorders (plus `load_hybrid`/`predict_field`) -- no real gmsh/model
|
| 15 |
checkpoints are touched.
|
| 16 |
-
|
| 17 |
-
Part B Task 4: `predict_casting(bodies=...)` wiring -- `validate_bodies` ->
|
| 18 |
-
mesh via the cache with `keep_solid_id`/`bodies_key` -> `tag_contact_patches`
|
| 19 |
-
(cached as a `post_mesh` hook) -> the film spec/gbm bc_spec carry the
|
| 20 |
-
resolved `BcPlan`. All of it is exercised with monkeypatched recorders
|
| 21 |
-
(`validate_bodies`/`tag_contact_patches`/`get_or_mesh`/`predict_field`) --
|
| 22 |
-
no real gmsh/model checkpoints are touched here either.
|
| 23 |
"""
|
| 24 |
import json
|
| 25 |
import logging
|
|
@@ -135,8 +128,8 @@ def test_film_path_changing_alloy_changes_field():
|
|
| 135 |
gnn = _make_film_gnn(seed=0)
|
| 136 |
hgb = _StubHGB(n_features_in_=16, total_seconds=50.0)
|
| 137 |
|
| 138 |
-
props_a = resolve_props(alloy="EN-
|
| 139 |
-
props_b = resolve_props(alloy="
|
| 140 |
out_a, _ = predict_field(mesh, gnn, hgb, _norm15(), cfg=_film_cfg(),
|
| 141 |
device="cpu", props=props_a)
|
| 142 |
out_b, _ = predict_field(mesh, gnn, hgb, _norm15(), cfg=_film_cfg(),
|
|
@@ -151,50 +144,6 @@ def test_film_path_changing_alloy_changes_field():
|
|
| 151 |
assert not np.allclose(out_a, out_b)
|
| 152 |
|
| 153 |
|
| 154 |
-
def test_film_path_with_bc_plan_uses_plan_feature_spec_and_gbm_bc_spec(monkeypatch):
|
| 155 |
-
"""Part B Task 4: `predict_field(bc_plan=...)` feeds the film branch
|
| 156 |
-
`plan.feature_spec(...)` (the chill/sleeve spec) instead of the
|
| 157 |
-
all-sand literal, and `plan.gbm_bc_spec()` into `gbm_v2_total_seconds`.
|
| 158 |
-
Spies on the module-level `compute_features_inference` / `gbm_v2_total_seconds`
|
| 159 |
-
(both real, just observed) to capture exactly what spec/bc_spec were
|
| 160 |
-
built, without needing a real multi-body mesh."""
|
| 161 |
-
from src.assembly import BcPlan
|
| 162 |
-
|
| 163 |
-
mesh = _all_cooling_cube()
|
| 164 |
-
gnn = _make_film_gnn(seed=0)
|
| 165 |
-
hgb = _StubHGB(n_features_in_=16, total_seconds=50.0)
|
| 166 |
-
props = resolve_props()
|
| 167 |
-
|
| 168 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 169 |
-
n_chills=2, chill_effusivity=9876.5)
|
| 170 |
-
|
| 171 |
-
captured_spec = {}
|
| 172 |
-
real_compute = pc_mod.compute_features_inference
|
| 173 |
-
|
| 174 |
-
def spy_compute(mesh_, version=2, spec=None):
|
| 175 |
-
captured_spec["spec"] = spec
|
| 176 |
-
return real_compute(mesh_, version=version, spec=spec)
|
| 177 |
-
|
| 178 |
-
captured_bc_spec = {}
|
| 179 |
-
real_gbm = pc_mod.gbm_v2_total_seconds
|
| 180 |
-
|
| 181 |
-
def spy_gbm(hgb_model, props_, v_over_a, bc_spec=None):
|
| 182 |
-
captured_bc_spec["bc_spec"] = bc_spec
|
| 183 |
-
return real_gbm(hgb_model, props_, v_over_a, bc_spec=bc_spec)
|
| 184 |
-
|
| 185 |
-
monkeypatch.setattr(pc_mod, "compute_features_inference", spy_compute)
|
| 186 |
-
monkeypatch.setattr(pc_mod, "gbm_v2_total_seconds", spy_gbm)
|
| 187 |
-
|
| 188 |
-
predict_field(mesh, gnn, hgb, _norm15(), cfg=_film_cfg(), device="cpu",
|
| 189 |
-
props=props, bc_plan=plan)
|
| 190 |
-
|
| 191 |
-
assert captured_spec["spec"]["bc_config"] == "chills"
|
| 192 |
-
assert captured_spec["spec"]["chill_effusivity"] == pytest.approx(9876.5)
|
| 193 |
-
assert captured_spec["spec"]["mold_effusivity"] == pytest.approx(props["mold_effusivity"])
|
| 194 |
-
assert captured_bc_spec["bc_spec"]["bc_config"] == "chills"
|
| 195 |
-
assert captured_bc_spec["bc_spec"]["n_chills"] == 2
|
| 196 |
-
|
| 197 |
-
|
| 198 |
def test_film_path_without_bc_plan_spec_stays_all_sand_literal(monkeypatch):
|
| 199 |
"""`bc_plan=None` (the default) -- byte-identical to the pre-Part-B
|
| 200 |
behavior: the all-sand literal spec, and `bc_spec=None` into the GBM
|
|
@@ -261,22 +210,6 @@ def test_film_cfg_without_props_raises():
|
|
| 261 |
N_COLS_V5 = 20
|
| 262 |
|
| 263 |
|
| 264 |
-
def _cube_with_bc_patch(n=5, a=20.0):
|
| 265 |
-
"""Same fixture as tests/test_features_v5.py: a Delaunay cube with the
|
| 266 |
-
z=0 face tagged 'Adiabatic' (BC patch) and every other hull face
|
| 267 |
-
'Cooling' -- needed (unlike `_all_cooling_cube`) so a bc_plan's
|
| 268 |
-
chill/exo source patch has somewhere to sit."""
|
| 269 |
-
lin = np.linspace(0.0, a, n)
|
| 270 |
-
pts = np.array([[x, y, z] for x in lin for y in lin for z in lin], float)
|
| 271 |
-
dt = Delaunay(pts)
|
| 272 |
-
tets = dt.simplices.astype(np.int64)
|
| 273 |
-
hull = dt.convex_hull.astype(np.int64)
|
| 274 |
-
is_bottom = np.isclose(pts[hull, 2], 0.0).all(axis=1)
|
| 275 |
-
adiab_tri = hull[is_bottom]
|
| 276 |
-
cool_tri = hull[~is_bottom]
|
| 277 |
-
return Mesh(node_xyz=pts, tris={"Cooling": cool_tri, "Adiabatic": adiab_tri}, tets=tets)
|
| 278 |
-
|
| 279 |
-
|
| 280 |
def _make_film_gnn_v5(seed=0):
|
| 281 |
"""`_make_film_gnn` at node_in=N_COLS_V5 (20) -- otherwise identical
|
| 282 |
(non-zero-init FiLM last layer, so `g` is not a no-op)."""
|
|
@@ -358,56 +291,6 @@ def test_v5_cfg_film_path_builds_20_column_x_all_sand_last_five_zero():
|
|
| 358 |
assert (X[:, 15:] == 0.0).all()
|
| 359 |
|
| 360 |
|
| 361 |
-
def test_v5_cfg_bodies_exo_patch_populates_exo_channels_not_chill(monkeypatch):
|
| 362 |
-
"""(c) A bodies/BcPlan exo_sleeve patch resolved EXACTLY the way the
|
| 363 |
-
training builder does (`bc_plan.feature_spec(...)`, same spec keys)
|
| 364 |
-
must populate exo_mid/exo_long nonzero while leaving every chill
|
| 365 |
-
channel exactly zero -- the SAME spec resolution `compute_features_
|
| 366 |
-
inference`'s v4 branch already uses, now flowing into
|
| 367 |
-
`compute_bc_reach_channels` via the version=5 dispatch."""
|
| 368 |
-
from src.assembly import BcPlan
|
| 369 |
-
|
| 370 |
-
mesh = _cube_with_bc_patch()
|
| 371 |
-
gnn = _make_film_gnn_v5(seed=0)
|
| 372 |
-
hgb = _StubHGB(n_features_in_=16, total_seconds=50.0)
|
| 373 |
-
props = resolve_props()
|
| 374 |
-
plan = BcPlan(bc_config="exo_sleeve", casting_solid_id=1, role_solid_ids=[2],
|
| 375 |
-
sleeve_MEF=2.0, exo_q0=5000.0)
|
| 376 |
-
|
| 377 |
-
out, feat = predict_field(mesh, gnn, hgb, _norm20(), cfg=_film_cfg_v5(),
|
| 378 |
-
device="cpu", props=props, bc_plan=plan)
|
| 379 |
-
X = feat["X"]
|
| 380 |
-
idx = {n: i for i, n in enumerate(FEATURE_NAMES_V5)}
|
| 381 |
-
assert X[:, idx["exo_mid"]].max() > 0.0
|
| 382 |
-
assert X[:, idx["exo_long"]].max() > 0.0
|
| 383 |
-
assert (X[:, idx["chill_short"]] == 0.0).all()
|
| 384 |
-
assert (X[:, idx["chill_mid"]] == 0.0).all()
|
| 385 |
-
assert (X[:, idx["chill_long"]] == 0.0).all()
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
def test_v5_cfg_bodies_chill_patch_populates_chill_channels_not_exo(monkeypatch):
|
| 389 |
-
"""(c), vice versa: a chills BcPlan populates chill_short/mid/long
|
| 390 |
-
nonzero while exo_mid/exo_long stay exactly zero."""
|
| 391 |
-
from src.assembly import BcPlan
|
| 392 |
-
|
| 393 |
-
mesh = _cube_with_bc_patch()
|
| 394 |
-
gnn = _make_film_gnn_v5(seed=0)
|
| 395 |
-
hgb = _StubHGB(n_features_in_=16, total_seconds=50.0)
|
| 396 |
-
props = resolve_props()
|
| 397 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 398 |
-
n_chills=2, chill_effusivity=9876.5)
|
| 399 |
-
|
| 400 |
-
out, feat = predict_field(mesh, gnn, hgb, _norm20(), cfg=_film_cfg_v5(),
|
| 401 |
-
device="cpu", props=props, bc_plan=plan)
|
| 402 |
-
X = feat["X"]
|
| 403 |
-
idx = {n: i for i, n in enumerate(FEATURE_NAMES_V5)}
|
| 404 |
-
assert X[:, idx["chill_short"]].max() > 0.0
|
| 405 |
-
assert X[:, idx["chill_mid"]].max() > 0.0
|
| 406 |
-
assert X[:, idx["chill_long"]].max() > 0.0
|
| 407 |
-
assert (X[:, idx["exo_mid"]] == 0.0).all()
|
| 408 |
-
assert (X[:, idx["exo_long"]] == 0.0).all()
|
| 409 |
-
|
| 410 |
-
|
| 411 |
# --------------------------------------------------------------------------- #
|
| 412 |
# SP1 (non-film) path: byte-identical regression
|
| 413 |
# --------------------------------------------------------------------------- #
|
|
@@ -517,22 +400,6 @@ def _fake_get_or_mesh(calls, mini_msh_file, ok=True, cache="miss"):
|
|
| 517 |
return _fn
|
| 518 |
|
| 519 |
|
| 520 |
-
def _fake_tag_contact_patches(calls, per_body_patch_area_mm2=None, n_adiab_tris=7,
|
| 521 |
-
nodes_by_solid=None):
|
| 522 |
-
per_body_patch_area_mm2 = per_body_patch_area_mm2 or {2: 42.0}
|
| 523 |
-
|
| 524 |
-
def _fn(msh_path, step_path, role_solid_ids, tol_mm=None):
|
| 525 |
-
calls.setdefault("tag_contact_patches", []).append(
|
| 526 |
-
dict(msh_path=msh_path, step_path=step_path,
|
| 527 |
-
role_solid_ids=list(role_solid_ids), tol_mm=tol_mm))
|
| 528 |
-
result = {"per_body_patch_area_mm2": dict(per_body_patch_area_mm2),
|
| 529 |
-
"n_adiab_tris": n_adiab_tris}
|
| 530 |
-
if nodes_by_solid is not None:
|
| 531 |
-
result["nodes_by_solid"] = {k: list(v) for k, v in nodes_by_solid.items()}
|
| 532 |
-
return result
|
| 533 |
-
return _fn
|
| 534 |
-
|
| 535 |
-
|
| 536 |
def _fake_mesh_step(calls, mini_msh_file):
|
| 537 |
def _fn(step_path, out_msh, elem_size=None):
|
| 538 |
calls["mesh_step"] = dict(step_path=step_path, out_msh=out_msh, elem_size=elem_size)
|
|
@@ -617,618 +484,6 @@ def test_predict_casting_includes_cache_hit_in_result(
|
|
| 617 |
assert out["cache"] == "hit"
|
| 618 |
|
| 619 |
|
| 620 |
-
# --------------------------------------------------------------------------- #
|
| 621 |
-
# Part B Task 4: predict_casting(bodies=...) wiring -- monkeypatched
|
| 622 |
-
# recorders for validate_bodies/tag_contact_patches/get_or_mesh/predict_field,
|
| 623 |
-
# no real gmsh/model checkpoints touched.
|
| 624 |
-
# --------------------------------------------------------------------------- #
|
| 625 |
-
def _fake_validate_bodies(calls, plan):
|
| 626 |
-
def _fn(bodies, **kw):
|
| 627 |
-
calls["validate_bodies_arg"] = bodies
|
| 628 |
-
return plan
|
| 629 |
-
return _fn
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
def test_bodies_given_wires_keep_solid_id_bodies_key_and_tagging(
|
| 633 |
-
monkeypatch, mini_msh_file, tmp_path):
|
| 634 |
-
from src.assembly import BcPlan
|
| 635 |
-
|
| 636 |
-
calls = {}
|
| 637 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 638 |
-
n_chills=1, chill_effusivity=12345.0)
|
| 639 |
-
|
| 640 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 641 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 642 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 643 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches", _fake_tag_contact_patches(calls))
|
| 644 |
-
|
| 645 |
-
captured = {}
|
| 646 |
-
|
| 647 |
-
def fake_predict_field(mesh, gnn_model, hgb_model, norm, cfg=None, device=None, tta=0,
|
| 648 |
-
baseline_norm=None, props=None, bc_plan=None, hgb_std=None,
|
| 649 |
-
lambda_shrink=1.0, w_total=1.0, w_std=1.0):
|
| 650 |
-
captured["bc_plan"] = bc_plan
|
| 651 |
-
return np.arange(5, dtype=np.float64) + 1.0, {"X": np.zeros((5, 9))}
|
| 652 |
-
|
| 653 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 654 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 655 |
-
monkeypatch.setattr(pc_mod, "predict_field", fake_predict_field)
|
| 656 |
-
|
| 657 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 658 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}}]
|
| 659 |
-
|
| 660 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 661 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 662 |
-
|
| 663 |
-
assert calls["validate_bodies_arg"] == bodies
|
| 664 |
-
assert calls["get_or_mesh"]["keep_solid_id"] == 1
|
| 665 |
-
assert calls["get_or_mesh"]["bodies_key"] != "none"
|
| 666 |
-
assert "tag_contact_patches" in calls
|
| 667 |
-
assert calls["tag_contact_patches"][0]["role_solid_ids"] == [2]
|
| 668 |
-
assert captured["bc_plan"] is plan
|
| 669 |
-
assert out["bc_config"] == "chills"
|
| 670 |
-
assert out["per_body_patch_area_mm2"] == {2: 42.0}
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
# --------------------------------------------------------------------------- #
|
| 674 |
-
# Shrinkage-screening spec ADDENDUM 2 (2026-08-01, owner-approved SCOPED
|
| 675 |
-
# SHIP): Gate C passed on all-sand/sleeve-only inputs but FAILED on the
|
| 676 |
-
# chill-bearing slice (rho 0.313) -- a chill-bearing BC plan
|
| 677 |
-
# (`bc_config == "chills"`) must mark "niyama_confidence": "low" + a summary
|
| 678 |
-
# caveat; sleeve-only/bare inputs stay "full". Follows the same
|
| 679 |
-
# validate_bodies/BcPlan monkeypatch pattern as the Part B tests above.
|
| 680 |
-
# --------------------------------------------------------------------------- #
|
| 681 |
-
def test_bodies_chill_role_marks_niyama_confidence_low_with_summary_caveat(
|
| 682 |
-
monkeypatch, mini_msh_file, tmp_path):
|
| 683 |
-
from src.assembly import BcPlan
|
| 684 |
-
|
| 685 |
-
calls = {}
|
| 686 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 687 |
-
n_chills=1, chill_effusivity=12345.0)
|
| 688 |
-
|
| 689 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 690 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 691 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 692 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches", _fake_tag_contact_patches(calls))
|
| 693 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 694 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 695 |
-
monkeypatch.setattr(pc_mod, "predict_field",
|
| 696 |
-
lambda mesh, *a, **kw: (np.arange(5, dtype=np.float64) + 1.0,
|
| 697 |
-
{"X": np.zeros((5, 9))}))
|
| 698 |
-
|
| 699 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 700 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}}]
|
| 701 |
-
|
| 702 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 703 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 704 |
-
|
| 705 |
-
assert out["shrinkage"] is not None
|
| 706 |
-
assert out["shrinkage"]["niyama_confidence"] == "low"
|
| 707 |
-
with open(out["summary"]) as f:
|
| 708 |
-
summary_text = f.read()
|
| 709 |
-
assert "niyama confidence: LOW" in summary_text
|
| 710 |
-
assert "rho 0.313" in summary_text
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
def test_bodies_sleeve_only_role_keeps_niyama_confidence_full_no_caveat(
|
| 714 |
-
monkeypatch, mini_msh_file, tmp_path):
|
| 715 |
-
"""A sleeve-only BC plan (bc_config="insulating_sleeve", no chill role
|
| 716 |
-
at all) is UNAFFECTED by the chills-only Gate C gap -- stays "full",
|
| 717 |
-
no caveat line."""
|
| 718 |
-
from src.assembly import BcPlan
|
| 719 |
-
|
| 720 |
-
calls = {}
|
| 721 |
-
plan = BcPlan(bc_config="insulating_sleeve", casting_solid_id=1, role_solid_ids=[2])
|
| 722 |
-
|
| 723 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 724 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 725 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 726 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches", _fake_tag_contact_patches(calls))
|
| 727 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 728 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 729 |
-
monkeypatch.setattr(pc_mod, "predict_field",
|
| 730 |
-
lambda mesh, *a, **kw: (np.arange(5, dtype=np.float64) + 1.0,
|
| 731 |
-
{"X": np.zeros((5, 9))}))
|
| 732 |
-
|
| 733 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 734 |
-
{"solid_id": 2, "role": "insulating_sleeve"}]
|
| 735 |
-
|
| 736 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 737 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 738 |
-
|
| 739 |
-
assert out["shrinkage"] is not None
|
| 740 |
-
assert out["shrinkage"]["niyama_confidence"] == "full"
|
| 741 |
-
with open(out["summary"]) as f:
|
| 742 |
-
summary_text = f.read()
|
| 743 |
-
assert "niyama confidence: LOW" not in summary_text
|
| 744 |
-
assert "rho 0.313" not in summary_text
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
def test_bodies_chill_role_learned_niyama_source_uses_neutral_caveat(
|
| 748 |
-
monkeypatch, mini_msh_file, tmp_path):
|
| 749 |
-
"""Learned-heads Task 3 review round 1, F3: when `predict_field`'s
|
| 750 |
-
`feat` carries learned `gradT`/`dTdt` (so `niyama_source` is "learned",
|
| 751 |
-
not "proxy"), the chill-bearing low-confidence caveat text must be the
|
| 752 |
-
NEUTRAL "learned-head Niyama pending slice validation" -- NOT the
|
| 753 |
-
proxy-specific "rho 0.313" text, a claim about the PROXY identity's own
|
| 754 |
-
measured Gate C shortfall that has nothing to do with a learned
|
| 755 |
-
checkpoint's heads. The marking itself ("low") is UNCHANGED -- still
|
| 756 |
-
keyed only on `plan.bc_config == "chills"`, per the brief ("stays until
|
| 757 |
-
the promote step")."""
|
| 758 |
-
from src.assembly import BcPlan
|
| 759 |
-
|
| 760 |
-
calls = {}
|
| 761 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 762 |
-
n_chills=1, chill_effusivity=12345.0)
|
| 763 |
-
|
| 764 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 765 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 766 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 767 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches", _fake_tag_contact_patches(calls))
|
| 768 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 769 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 770 |
-
|
| 771 |
-
def fake_predict_field(mesh, *a, **kw):
|
| 772 |
-
n = 5
|
| 773 |
-
seconds = np.arange(n, dtype=np.float64) + 1.0
|
| 774 |
-
feat = {"X": np.zeros((n, 9)), "gradT": np.full(n, 500.0), "dTdt": np.full(n, 2.0)}
|
| 775 |
-
return seconds, feat
|
| 776 |
-
|
| 777 |
-
monkeypatch.setattr(pc_mod, "predict_field", fake_predict_field)
|
| 778 |
-
|
| 779 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 780 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}}]
|
| 781 |
-
|
| 782 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 783 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 784 |
-
|
| 785 |
-
assert out["shrinkage"] is not None
|
| 786 |
-
assert out["shrinkage"]["niyama_confidence"] == "low"
|
| 787 |
-
assert out["shrinkage"]["niyama_source"] == "learned"
|
| 788 |
-
with open(out["summary"]) as f:
|
| 789 |
-
summary_text = f.read()
|
| 790 |
-
assert "niyama confidence: LOW" in summary_text
|
| 791 |
-
assert "learned-head Niyama pending slice validation" in summary_text
|
| 792 |
-
assert "rho 0.313" not in summary_text
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
# --------------------------------------------------------------------------- #
|
| 796 |
-
# Part-B roles task (2026-08-03): role-aware feed-node selection, replacing
|
| 797 |
-
# review finding I1's coarse `plan.bc_config == "chills"` special case (which
|
| 798 |
-
# forced feed_nodes empty for the WHOLE input, rather than per body role).
|
| 799 |
-
# `tag_contact_patches` relabels EVERY role body's contact patch "Adiabatic"
|
| 800 |
-
# -- chills included -- so on a chill-bearing input every "Adiabatic"
|
| 801 |
-
# triangle is a chill footprint, not a riser/sleeve; on a sleeve-bearing
|
| 802 |
-
# input it genuinely is a feed patch. `mini_msh_file` already carries one
|
| 803 |
-
# real "Adiabatic" tri (0-indexed nodes 1,2,3 -- file node ids 2,3,4), so
|
| 804 |
-
# both branches are exercised against the SAME underlying mesh, with the
|
| 805 |
-
# fake `tag_contact_patches` now also returning `nodes_by_solid={2: [1, 2,
|
| 806 |
-
# 3]}` (what the real tagging pass would have found for that one role body)
|
| 807 |
-
# and each `BcPlan` carrying the matching `role_by_solid` -- only the ROLE
|
| 808 |
-
# differs between the two tests, not merely `bc_config`.
|
| 809 |
-
# --------------------------------------------------------------------------- #
|
| 810 |
-
def _spy_screen_casting(monkeypatch, captured):
|
| 811 |
-
real_screen_casting = pc_mod.screen_casting
|
| 812 |
-
|
| 813 |
-
def _fn(*args, **kwargs):
|
| 814 |
-
captured["feed_nodes"] = kwargs.get("feed_nodes")
|
| 815 |
-
return real_screen_casting(*args, **kwargs)
|
| 816 |
-
|
| 817 |
-
monkeypatch.setattr(pc_mod, "screen_casting", _fn)
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
def test_bodies_chill_role_forces_feed_nodes_empty(monkeypatch, mini_msh_file, tmp_path):
|
| 821 |
-
from src.assembly import BcPlan
|
| 822 |
-
|
| 823 |
-
calls = {}
|
| 824 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 825 |
-
n_chills=1, chill_effusivity=12345.0, role_by_solid={2: "chill"})
|
| 826 |
-
|
| 827 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 828 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 829 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 830 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches",
|
| 831 |
-
_fake_tag_contact_patches(calls, nodes_by_solid={2: [1, 2, 3]}))
|
| 832 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 833 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 834 |
-
monkeypatch.setattr(pc_mod, "predict_field",
|
| 835 |
-
lambda mesh, *a, **kw: (np.arange(5, dtype=np.float64) + 1.0,
|
| 836 |
-
{"X": np.zeros((5, 9))}))
|
| 837 |
-
captured = {}
|
| 838 |
-
_spy_screen_casting(monkeypatch, captured)
|
| 839 |
-
|
| 840 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 841 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}}]
|
| 842 |
-
|
| 843 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 844 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 845 |
-
|
| 846 |
-
# the chill body's own patch DID find nodes (1,2,3) -- they are simply
|
| 847 |
-
# never fed nodes, proving this is a real per-role exclusion and not an
|
| 848 |
-
# accident of an empty/missing nodes_by_solid.
|
| 849 |
-
assert captured["feed_nodes"].size == 0
|
| 850 |
-
# with feed_nodes forced empty, is_fed is always False -- EVERY hot
|
| 851 |
-
# region hot_regions itself finds must survive into macro_shrinkage
|
| 852 |
-
# (none silently dropped as "fed" by a chill contact patch).
|
| 853 |
-
from src.shrinkage import hot_regions
|
| 854 |
-
mesh = parse_msh(osp.join(str(tmp_path), f"{out['name']}.msh"))
|
| 855 |
-
seconds = np.arange(mesh.node_xyz.shape[0], dtype=np.float64) + 1.0
|
| 856 |
-
all_hot_regions = hot_regions(mesh.node_xyz, mesh.tets, seconds)
|
| 857 |
-
assert out["shrinkage"]["n_macro_regions"] == len(all_hot_regions)
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
def test_bodies_sleeve_role_keeps_adiabatic_feed_nodes(monkeypatch, mini_msh_file, tmp_path):
|
| 861 |
-
from src.assembly import BcPlan
|
| 862 |
-
|
| 863 |
-
calls = {}
|
| 864 |
-
plan = BcPlan(bc_config="insulating_sleeve", casting_solid_id=1, role_solid_ids=[2],
|
| 865 |
-
role_by_solid={2: "insulating_sleeve"})
|
| 866 |
-
|
| 867 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 868 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 869 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 870 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches",
|
| 871 |
-
_fake_tag_contact_patches(calls, nodes_by_solid={2: [1, 2, 3]}))
|
| 872 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 873 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 874 |
-
monkeypatch.setattr(pc_mod, "predict_field",
|
| 875 |
-
lambda mesh, *a, **kw: (np.arange(5, dtype=np.float64) + 1.0,
|
| 876 |
-
{"X": np.zeros((5, 9))}))
|
| 877 |
-
captured = {}
|
| 878 |
-
_spy_screen_casting(monkeypatch, captured)
|
| 879 |
-
|
| 880 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 881 |
-
{"solid_id": 2, "role": "insulating_sleeve"}]
|
| 882 |
-
|
| 883 |
-
pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 884 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 885 |
-
|
| 886 |
-
# sleeve assembly -> feed == that patch's nodes EXACTLY (spec §3).
|
| 887 |
-
assert captured["feed_nodes"].tolist() == [1, 2, 3]
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
def test_bodies_sleeve_role_feed_nodes_byte_identical_to_pre_change_adiabatic_union(
|
| 891 |
-
monkeypatch, mini_msh_file, tmp_path):
|
| 892 |
-
"""Screening byte-identity (spec §3): on a legal (single-role, non-mixed)
|
| 893 |
-
sleeve input, the NEW per-role feed_nodes selection must produce EXACTLY
|
| 894 |
-
the same result as the pre-change code (`_shrinkage_surface_and_feed_
|
| 895 |
-
nodes`'s plain "Adiabatic"-tag node union, which is what `predict_
|
| 896 |
-
casting` used unconditionally for any non-chills bc_config before this
|
| 897 |
-
task)."""
|
| 898 |
-
from src.assembly import BcPlan
|
| 899 |
-
from src.msh_parse import parse_msh as _parse_msh
|
| 900 |
-
|
| 901 |
-
calls = {}
|
| 902 |
-
plan = BcPlan(bc_config="exo_sleeve", casting_solid_id=1, role_solid_ids=[2],
|
| 903 |
-
role_by_solid={2: "exo_sleeve"})
|
| 904 |
-
|
| 905 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 906 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 907 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 908 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches",
|
| 909 |
-
_fake_tag_contact_patches(calls, nodes_by_solid={2: [1, 2, 3]}))
|
| 910 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 911 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 912 |
-
monkeypatch.setattr(pc_mod, "predict_field",
|
| 913 |
-
lambda mesh, *a, **kw: (np.arange(5, dtype=np.float64) + 1.0,
|
| 914 |
-
{"X": np.zeros((5, 9))}))
|
| 915 |
-
captured = {}
|
| 916 |
-
_spy_screen_casting(monkeypatch, captured)
|
| 917 |
-
|
| 918 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 919 |
-
{"solid_id": 2, "role": "exo_sleeve"}]
|
| 920 |
-
|
| 921 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 922 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 923 |
-
|
| 924 |
-
old_mesh = _parse_msh(osp.join(str(tmp_path), f"{out['name']}.msh"))
|
| 925 |
-
_old_surface, old_feed_nodes = pc_mod._shrinkage_surface_and_feed_nodes(old_mesh)
|
| 926 |
-
|
| 927 |
-
assert np.array_equal(captured["feed_nodes"], old_feed_nodes)
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
def test_bodies_legacy_cache_hit_sleeve_falls_back_to_adiabatic_union_with_warning(
|
| 931 |
-
monkeypatch, mini_msh_file, tmp_path, caplog):
|
| 932 |
-
"""Review round 1, H1: a mesh-cache HIT served from a side-car written
|
| 933 |
-
BEFORE the Part-B roles task never had `nodes_by_solid` at all --
|
| 934 |
-
`runs/mesh_cache/` holds exactly such legacy entries today (`per_body_
|
| 935 |
-
patch_area_mm2` present, `nodes_by_solid` absent), incl. both gold
|
| 936 |
-
parts. On a sleeve plan, feed_nodes must fall back to the PRE-CHANGE
|
| 937 |
-
rule (the plain "Adiabatic"-tag node union) rather than silently going
|
| 938 |
-
empty, and a loud warning naming the legacy situation must fire."""
|
| 939 |
-
from src.assembly import BcPlan
|
| 940 |
-
|
| 941 |
-
calls = {}
|
| 942 |
-
plan = BcPlan(bc_config="insulating_sleeve", casting_solid_id=1, role_solid_ids=[2],
|
| 943 |
-
role_by_solid={2: "insulating_sleeve"})
|
| 944 |
-
|
| 945 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh",
|
| 946 |
-
_fake_get_or_mesh_hit_with_str_keys(calls, mini_msh_file, {"2": 42.0}))
|
| 947 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 948 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 949 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches", _fake_tag_contact_patches(calls))
|
| 950 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 951 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 952 |
-
monkeypatch.setattr(pc_mod, "predict_field",
|
| 953 |
-
lambda mesh, *a, **kw: (np.arange(5, dtype=np.float64) + 1.0,
|
| 954 |
-
{"X": np.zeros((5, 9))}))
|
| 955 |
-
captured = {}
|
| 956 |
-
_spy_screen_casting(monkeypatch, captured)
|
| 957 |
-
|
| 958 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 959 |
-
{"solid_id": 2, "role": "insulating_sleeve"}]
|
| 960 |
-
|
| 961 |
-
with caplog.at_level(logging.WARNING):
|
| 962 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 963 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 964 |
-
|
| 965 |
-
mesh = parse_msh(osp.join(str(tmp_path), f"{out['name']}.msh"))
|
| 966 |
-
_surface, expected_adiab_union = pc_mod._shrinkage_surface_and_feed_nodes(mesh)
|
| 967 |
-
assert expected_adiab_union.size > 0 # sanity: mini_msh_file's real Adiabatic tri
|
| 968 |
-
assert np.array_equal(captured["feed_nodes"], expected_adiab_union)
|
| 969 |
-
assert any("legacy" in rec.message.lower() for rec in caplog.records)
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
def test_bodies_legacy_cache_hit_chills_falls_back_to_empty_with_warning(
|
| 973 |
-
monkeypatch, mini_msh_file, tmp_path, caplog):
|
| 974 |
-
"""Same legacy-cache-HIT scenario as above, chills plan: falls back to
|
| 975 |
-
empty feed_nodes (the pre-change chills rule), with the same loud
|
| 976 |
-
warning -- and the warning fires regardless of `bc_config`."""
|
| 977 |
-
from src.assembly import BcPlan
|
| 978 |
-
|
| 979 |
-
calls = {}
|
| 980 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 981 |
-
n_chills=1, chill_effusivity=12345.0, role_by_solid={2: "chill"})
|
| 982 |
-
|
| 983 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh",
|
| 984 |
-
_fake_get_or_mesh_hit_with_str_keys(calls, mini_msh_file, {"2": 42.0}))
|
| 985 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 986 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 987 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches", _fake_tag_contact_patches(calls))
|
| 988 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 989 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 990 |
-
monkeypatch.setattr(pc_mod, "predict_field",
|
| 991 |
-
lambda mesh, *a, **kw: (np.arange(5, dtype=np.float64) + 1.0,
|
| 992 |
-
{"X": np.zeros((5, 9))}))
|
| 993 |
-
captured = {}
|
| 994 |
-
_spy_screen_casting(monkeypatch, captured)
|
| 995 |
-
|
| 996 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 997 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}}]
|
| 998 |
-
|
| 999 |
-
with caplog.at_level(logging.WARNING):
|
| 1000 |
-
pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 1001 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 1002 |
-
|
| 1003 |
-
assert captured["feed_nodes"].size == 0
|
| 1004 |
-
assert any("legacy" in rec.message.lower() for rec in caplog.records)
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
def test_nodes_by_role_from_tagging_incomplete_role_by_solid_warns_and_drops(caplog):
|
| 1008 |
-
"""Review round 1, M1: a solid_id present in `nodes_by_solid` but absent
|
| 1009 |
-
from `role_by_solid` must be dropped LOUDLY (not silently) -- defense in
|
| 1010 |
-
depth for any caller other than `_role_aware_feed_nodes` (which, in the
|
| 1011 |
-
normal `predict_casting` path, never lets an incomplete mapping reach
|
| 1012 |
-
this function at all -- see its own docstring)."""
|
| 1013 |
-
with caplog.at_level(logging.WARNING):
|
| 1014 |
-
grouped = pc_mod._nodes_by_role_from_tagging(
|
| 1015 |
-
{2: [1, 2, 3], 5: [7, 8]}, {2: "insulating_sleeve"})
|
| 1016 |
-
|
| 1017 |
-
assert grouped == {"insulating_sleeve": [1, 2, 3]}
|
| 1018 |
-
assert any("solid_id=5" in rec.message for rec in caplog.records)
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
def test_feed_nodes_from_role_patches_mixed_mapping_bypassing_validate_bodies():
|
| 1022 |
-
"""A directly-constructed mixed role -> nodes mapping (spec §3) --
|
| 1023 |
-
`validate_bodies` itself forbids combining chills with a sleeve, but
|
| 1024 |
-
`_feed_nodes_from_role_patches` takes no `BcPlan`/`bc_config` at all, so
|
| 1025 |
-
it must still exclude the chill nodes and keep only the sleeve nodes
|
| 1026 |
-
even when both roles co-occur in the same mapping."""
|
| 1027 |
-
nodes_by_role = {"chill": [10, 11, 12], "insulating_sleeve": [11, 20],
|
| 1028 |
-
"exo_sleeve": [30]}
|
| 1029 |
-
|
| 1030 |
-
feed = pc_mod._feed_nodes_from_role_patches(nodes_by_role)
|
| 1031 |
-
|
| 1032 |
-
assert feed.tolist() == [11, 20, 30]
|
| 1033 |
-
assert 10 not in feed.tolist() and 12 not in feed.tolist()
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
def test_nodes_by_role_from_tagging_bare_mesh_empty_mapping_is_empty():
|
| 1037 |
-
"""Bare mesh (spec §3): no role bodies at all -> an empty `nodes_by_solid`
|
| 1038 |
-
(or `role_by_solid`) grouping produces an empty per-role mapping, and
|
| 1039 |
-
`_feed_nodes_from_role_patches` on that empty mapping stays empty --
|
| 1040 |
-
matching `predict_casting`'s `if plan is not None and plan.role_solid_
|
| 1041 |
-
ids:` guard, which never even enters this code path for a bare/bodies=
|
| 1042 |
-
None input."""
|
| 1043 |
-
assert pc_mod._nodes_by_role_from_tagging({}, {}) == {}
|
| 1044 |
-
assert pc_mod._feed_nodes_from_role_patches({}).size == 0
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
def test_nodes_by_role_from_tagging_normalizes_string_solid_id_keys():
|
| 1048 |
-
"""A mesh-cache HIT round-trips `nodes_by_solid` through JSON, turning
|
| 1049 |
-
int solid_id keys into str -- `_nodes_by_role_from_tagging` must still
|
| 1050 |
-
match them against `role_by_solid`'s int keys."""
|
| 1051 |
-
grouped = pc_mod._nodes_by_role_from_tagging(
|
| 1052 |
-
{"2": [1, 2, 3]}, {2: "insulating_sleeve"})
|
| 1053 |
-
assert grouped == {"insulating_sleeve": [1, 2, 3]}
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
def _fake_get_or_mesh_hit_with_str_keys(calls, mini_msh_file, per_body_patch_area_mm2):
|
| 1057 |
-
"""Simulates a real cache HIT: `per_body_patch_area_mm2` comes back with
|
| 1058 |
-
STRING solid_id keys (the JSON-round-trip artifact -- see
|
| 1059 |
-
tests/test_mesh_cache.py), and `post_mesh` is never called (a HIT never
|
| 1060 |
-
re-tags)."""
|
| 1061 |
-
def _fn(step_path, out_msh, target_nodes=None, cache_dir=None,
|
| 1062 |
-
keep_solid_id=None, bodies_key="none", post_mesh=None):
|
| 1063 |
-
calls["get_or_mesh"] = dict(step_path=step_path, out_msh=out_msh,
|
| 1064 |
-
target_nodes=target_nodes, cache_dir=cache_dir,
|
| 1065 |
-
keep_solid_id=keep_solid_id, bodies_key=bodies_key,
|
| 1066 |
-
post_mesh=post_mesh)
|
| 1067 |
-
shutil.copy(mini_msh_file, out_msh)
|
| 1068 |
-
return dict(n_solids=1, volume_mm3=1.0, bbox_diag_mm=17.3, n_nodes=5,
|
| 1069 |
-
ok=True, healed=False, notes="", cache="hit",
|
| 1070 |
-
per_body_patch_area_mm2=dict(per_body_patch_area_mm2), n_adiab_tris=3)
|
| 1071 |
-
return _fn
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
def test_bodies_cache_hit_normalizes_string_keys_to_int(
|
| 1075 |
-
monkeypatch, mini_msh_file, tmp_path):
|
| 1076 |
-
"""Review fix (LOW): a cache HIT's `per_body_patch_area_mm2` comes back
|
| 1077 |
-
with STRING dict keys (json.dump has no int-key mode) -- predict_casting
|
| 1078 |
-
must normalize these back to int in its result dict, and must NOT call
|
| 1079 |
-
tag_contact_patches again on a HIT."""
|
| 1080 |
-
from src.assembly import BcPlan
|
| 1081 |
-
|
| 1082 |
-
calls = {}
|
| 1083 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 1084 |
-
n_chills=1, chill_effusivity=12345.0)
|
| 1085 |
-
|
| 1086 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh",
|
| 1087 |
-
_fake_get_or_mesh_hit_with_str_keys(calls, mini_msh_file, {"2": 99.5}))
|
| 1088 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 1089 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 1090 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches", _fake_tag_contact_patches(calls))
|
| 1091 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 1092 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 1093 |
-
monkeypatch.setattr(pc_mod, "predict_field",
|
| 1094 |
-
lambda mesh, *a, **kw: (np.arange(5, dtype=np.float64) + 1.0,
|
| 1095 |
-
{"X": np.zeros((5, 9))}))
|
| 1096 |
-
|
| 1097 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 1098 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}}]
|
| 1099 |
-
|
| 1100 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 1101 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 1102 |
-
|
| 1103 |
-
assert "tag_contact_patches" not in calls # HIT never re-tags
|
| 1104 |
-
assert out["cache"] == "hit"
|
| 1105 |
-
assert out["per_body_patch_area_mm2"] == {2: 99.5}
|
| 1106 |
-
assert all(isinstance(k, int) for k in out["per_body_patch_area_mm2"])
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
def test_bodies_none_never_calls_tag_contact_patches_and_default_bc_config(
|
| 1110 |
-
monkeypatch, mini_msh_file, tmp_path):
|
| 1111 |
-
calls = {}
|
| 1112 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 1113 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 1114 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches", _fake_tag_contact_patches(calls))
|
| 1115 |
-
_stub_prediction_stage(monkeypatch, n_nodes=5)
|
| 1116 |
-
|
| 1117 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 1118 |
-
ood_json="does-not-exist.json")
|
| 1119 |
-
|
| 1120 |
-
assert "tag_contact_patches" not in calls
|
| 1121 |
-
assert calls["get_or_mesh"]["keep_solid_id"] is None
|
| 1122 |
-
assert calls["get_or_mesh"]["bodies_key"] == "none"
|
| 1123 |
-
assert out["bc_config"] == "none"
|
| 1124 |
-
assert out["per_body_patch_area_mm2"] == {}
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
def test_bodies_as_json_path_loads(monkeypatch, mini_msh_file, tmp_path):
|
| 1128 |
-
from src.assembly import BcPlan
|
| 1129 |
-
|
| 1130 |
-
calls = {}
|
| 1131 |
-
plan = BcPlan(bc_config="none", casting_solid_id=1, role_solid_ids=[])
|
| 1132 |
-
|
| 1133 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 1134 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 1135 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 1136 |
-
# bodies requires a film-capable checkpoint (see the guard test below) --
|
| 1137 |
-
# this test is only about JSON-path loading, so stub a film-capable cfg.
|
| 1138 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 1139 |
-
lambda *a, **kw: (None, None, {}, {"film": True}, None, None))
|
| 1140 |
-
monkeypatch.setattr(pc_mod, "predict_field",
|
| 1141 |
-
lambda mesh, *a, **kw: (np.arange(5, dtype=np.float64) + 1.0,
|
| 1142 |
-
{"X": np.zeros((5, 9))}))
|
| 1143 |
-
|
| 1144 |
-
bodies = [{"solid_id": 1, "role": "casting"}]
|
| 1145 |
-
bodies_path = tmp_path / "bodies.json"
|
| 1146 |
-
bodies_path.write_text(json.dumps(bodies))
|
| 1147 |
-
|
| 1148 |
-
out = pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 1149 |
-
ood_json="does-not-exist.json", bodies=str(bodies_path))
|
| 1150 |
-
|
| 1151 |
-
assert calls["validate_bodies_arg"] == bodies
|
| 1152 |
-
assert calls["get_or_mesh"]["keep_solid_id"] == 1
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
def test_bodies_with_raw_mesh_mode_raises(monkeypatch, mini_msh_file, tmp_path):
|
| 1156 |
-
"""`bodies` requires the prep/get_or_mesh pipeline (role-based casting
|
| 1157 |
-
solid selection + tagging) -- combining it with mesh_mode='raw' (or an
|
| 1158 |
-
explicit elem_size, which forces raw) must fail loudly rather than
|
| 1159 |
-
silently ignore the role assignment."""
|
| 1160 |
-
from src.assembly import BcPlan
|
| 1161 |
-
|
| 1162 |
-
calls = {}
|
| 1163 |
-
plan = BcPlan(bc_config="none", casting_solid_id=1, role_solid_ids=[])
|
| 1164 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 1165 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 1166 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 1167 |
-
_stub_prediction_stage(monkeypatch, n_nodes=5)
|
| 1168 |
-
|
| 1169 |
-
bodies = [{"solid_id": 1, "role": "casting"}]
|
| 1170 |
-
with pytest.raises(ValueError):
|
| 1171 |
-
pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 1172 |
-
ood_json="does-not-exist.json", bodies=bodies,
|
| 1173 |
-
mesh_mode="raw")
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
def test_bodies_with_non_film_checkpoint_raises_and_never_tags(
|
| 1177 |
-
monkeypatch, mini_msh_file, tmp_path):
|
| 1178 |
-
"""Review fix (MEDIUM): `bodies` with chill/sleeve roles + a NON-film
|
| 1179 |
-
checkpoint (e.g. SP1 via --gnn) must fail loudly, not silently proceed.
|
| 1180 |
-
A resolved BcPlan means tag_contact_patches would relabel the contact
|
| 1181 |
-
patch "Adiabatic" -- for a chill that's backwards physics (a chill
|
| 1182 |
-
COOLS, Adiabatic INSULATES) -- and SP1 has no BC channel to honor the
|
| 1183 |
-
role assignment at all. The check runs BEFORE meshing (load_hybrid is
|
| 1184 |
-
called first), so tag_contact_patches must never even be invoked."""
|
| 1185 |
-
from src.assembly import BcPlan
|
| 1186 |
-
|
| 1187 |
-
calls = {}
|
| 1188 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2],
|
| 1189 |
-
n_chills=1, chill_effusivity=12345.0)
|
| 1190 |
-
|
| 1191 |
-
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh(calls, mini_msh_file))
|
| 1192 |
-
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step(calls, mini_msh_file))
|
| 1193 |
-
monkeypatch.setattr(pc_mod, "validate_bodies", _fake_validate_bodies(calls, plan))
|
| 1194 |
-
monkeypatch.setattr(pc_mod, "tag_contact_patches", _fake_tag_contact_patches(calls))
|
| 1195 |
-
# A non-film (SP1-shaped) cfg -- no "film" key at all, matching a real
|
| 1196 |
-
# SP1 checkpoint's cfg.
|
| 1197 |
-
monkeypatch.setattr(pc_mod, "load_hybrid",
|
| 1198 |
-
lambda *a, **kw: (None, None, {}, {"hidden": 16}, None, None))
|
| 1199 |
-
|
| 1200 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 1201 |
-
{"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}}]
|
| 1202 |
-
|
| 1203 |
-
with pytest.raises(ValueError, match="film-conditioned"):
|
| 1204 |
-
pc_mod.predict_casting("fake_part.step", out_dir=str(tmp_path),
|
| 1205 |
-
ood_json="does-not-exist.json", bodies=bodies)
|
| 1206 |
-
|
| 1207 |
-
assert "tag_contact_patches" not in calls
|
| 1208 |
-
assert "get_or_mesh" not in calls
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
def test_bodies_cache_key_order_invariant_and_role_sensitive():
|
| 1212 |
-
from src.assembly import BcPlan
|
| 1213 |
-
|
| 1214 |
-
plan = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[2, 3],
|
| 1215 |
-
n_chills=2, chill_effusivity=12345.0)
|
| 1216 |
-
bodies = [{"solid_id": 1, "role": "casting"},
|
| 1217 |
-
{"solid_id": 2, "role": "chill"},
|
| 1218 |
-
{"solid_id": 3, "role": "chill"}]
|
| 1219 |
-
bodies_reordered = [bodies[2], bodies[0], bodies[1]]
|
| 1220 |
-
|
| 1221 |
-
k1 = pc_mod._bodies_cache_key(bodies, plan)
|
| 1222 |
-
k2 = pc_mod._bodies_cache_key(bodies_reordered, plan)
|
| 1223 |
-
assert k1 == k2
|
| 1224 |
-
|
| 1225 |
-
plan2 = BcPlan(bc_config="chills", casting_solid_id=1, role_solid_ids=[3],
|
| 1226 |
-
n_chills=1, chill_effusivity=12345.0)
|
| 1227 |
-
bodies_role_changed = [{"solid_id": 1, "role": "casting"},
|
| 1228 |
-
{"solid_id": 2, "role": "ignore"},
|
| 1229 |
-
{"solid_id": 3, "role": "chill"}]
|
| 1230 |
-
k3 = pc_mod._bodies_cache_key(bodies_role_changed, plan2)
|
| 1231 |
-
assert k3 != k1
|
| 1232 |
|
| 1233 |
|
| 1234 |
# --------------------------------------------------------------------------
|
|
@@ -1237,8 +492,7 @@ def test_bodies_cache_key_order_invariant_and_role_sensitive():
|
|
| 1237 |
# `get_or_mesh`/`heal_and_mesh` have honoured `target_nodes` all along and it is
|
| 1238 |
# part of the mesh-cache key, but `predict_casting` pinned it to the default, so
|
| 1239 |
# no caller could choose a mesh density without dropping to the legacy `raw`
|
| 1240 |
-
# path via `elem_size`
|
| 1241 |
-
# Density and per-body roles were therefore mutually exclusive from outside.
|
| 1242 |
# --------------------------------------------------------------------------
|
| 1243 |
|
| 1244 |
|
|
@@ -1260,8 +514,7 @@ def test_target_nodes_with_an_explicit_elem_size_raises(monkeypatch, mini_msh_fi
|
|
| 1260 |
|
| 1261 |
`elem_size` forces the `raw` path, which never calls `get_or_mesh`, so a
|
| 1262 |
silently ignored `target_nodes` would mean the caller asked for a mesh
|
| 1263 |
-
density and got a different one with no indication
|
| 1264 |
-
existing `bodies`-with-`raw` guard, and the same loud failure.
|
| 1265 |
"""
|
| 1266 |
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh({}, mini_msh_file))
|
| 1267 |
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step({}, mini_msh_file))
|
|
|
|
| 13 |
path. The routing tests below monkeypatch both meshing functions as
|
| 14 |
recorders (plus `load_hybrid`/`predict_field`) -- no real gmsh/model
|
| 15 |
checkpoints are touched.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
import json
|
| 18 |
import logging
|
|
|
|
| 128 |
gnn = _make_film_gnn(seed=0)
|
| 129 |
hgb = _StubHGB(n_features_in_=16, total_seconds=50.0)
|
| 130 |
|
| 131 |
+
props_a = resolve_props(alloy="EN-GJS-400-15")
|
| 132 |
+
props_b = resolve_props(alloy="G20Mn5")
|
| 133 |
out_a, _ = predict_field(mesh, gnn, hgb, _norm15(), cfg=_film_cfg(),
|
| 134 |
device="cpu", props=props_a)
|
| 135 |
out_b, _ = predict_field(mesh, gnn, hgb, _norm15(), cfg=_film_cfg(),
|
|
|
|
| 144 |
assert not np.allclose(out_a, out_b)
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
def test_film_path_without_bc_plan_spec_stays_all_sand_literal(monkeypatch):
|
| 148 |
"""`bc_plan=None` (the default) -- byte-identical to the pre-Part-B
|
| 149 |
behavior: the all-sand literal spec, and `bc_spec=None` into the GBM
|
|
|
|
| 210 |
N_COLS_V5 = 20
|
| 211 |
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
def _make_film_gnn_v5(seed=0):
|
| 214 |
"""`_make_film_gnn` at node_in=N_COLS_V5 (20) -- otherwise identical
|
| 215 |
(non-zero-init FiLM last layer, so `g` is not a no-op)."""
|
|
|
|
| 291 |
assert (X[:, 15:] == 0.0).all()
|
| 292 |
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
# --------------------------------------------------------------------------- #
|
| 295 |
# SP1 (non-film) path: byte-identical regression
|
| 296 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 400 |
return _fn
|
| 401 |
|
| 402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
def _fake_mesh_step(calls, mini_msh_file):
|
| 404 |
def _fn(step_path, out_msh, elem_size=None):
|
| 405 |
calls["mesh_step"] = dict(step_path=step_path, out_msh=out_msh, elem_size=elem_size)
|
|
|
|
| 484 |
assert out["cache"] == "hit"
|
| 485 |
|
| 486 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
|
| 488 |
|
| 489 |
# --------------------------------------------------------------------------
|
|
|
|
| 492 |
# `get_or_mesh`/`heal_and_mesh` have honoured `target_nodes` all along and it is
|
| 493 |
# part of the mesh-cache key, but `predict_casting` pinned it to the default, so
|
| 494 |
# no caller could choose a mesh density without dropping to the legacy `raw`
|
| 495 |
+
# path via `elem_size`.
|
|
|
|
| 496 |
# --------------------------------------------------------------------------
|
| 497 |
|
| 498 |
|
|
|
|
| 514 |
|
| 515 |
`elem_size` forces the `raw` path, which never calls `get_or_mesh`, so a
|
| 516 |
silently ignored `target_nodes` would mean the caller asked for a mesh
|
| 517 |
+
density and got a different one with no indication, so it fails loudly.
|
|
|
|
| 518 |
"""
|
| 519 |
monkeypatch.setattr(pc_mod, "get_or_mesh", _fake_get_or_mesh({}, mini_msh_file))
|
| 520 |
monkeypatch.setattr(pc_mod, "mesh_step", _fake_mesh_step({}, mini_msh_file))
|
tools/sleeves/__init__.py
DELETED
|
File without changes
|
tools/sleeves/sleeve_registry.csv
DELETED
|
@@ -1,15 +0,0 @@
|
|
| 1 |
-
name,source,type,d1_mm,d2_mm,H_mm,t_mm,V_cm3,A_cm2,M_geom_cm,MEF,M_eff_cm,mef_source
|
| 2 |
-
SYNTH_M050,synthetic,exo,27.952,24.202,42.950,5.710,22.978,45.956,0.5000,1.400,0.7000,synthetic_spread
|
| 3 |
-
SYNTH_M065,synthetic,exo,36.337,31.463,55.835,7.423,50.483,77.666,0.6500,1.650,1.0725,synthetic_spread
|
| 4 |
-
SD_4_7K,catalog,exo,41.000,35.500,63.000,8.375,72.517,98.877,0.7334,1.547,1.1343,catalog_ratio
|
| 5 |
-
SD_4_95K,catalog,exo,42.000,36.000,84.000,8.750,100.544,127.017,0.7916,1.562,1.2368,catalog_ratio
|
| 6 |
-
SD_5_8K,catalog,exo,51.000,47.500,69.500,10.250,132.455,145.716,0.9090,1.579,1.4353,catalog_ratio
|
| 7 |
-
FOSECO_P5_8K,foseco_step,exo,,,,,,,0.9500,1.580,1.5000,datasheet
|
| 8 |
-
SYNTH_M080,synthetic,exo,44.723,38.723,68.720,9.135,94.119,117.648,0.8000,1.900,1.5200,synthetic_spread
|
| 9 |
-
FOSECO_P7_10K,foseco_step,exo,,,,,,,1.2500,1.560,1.9500,datasheet
|
| 10 |
-
FOSECO_P8_11K,foseco_step,exo,,,,,,,1.4000,1.570,2.2000,datasheet
|
| 11 |
-
FOSECO_ZP912K14QC,foseco_step,exo,,,,,,,1.5500,1.550,2.4000,datasheet
|
| 12 |
-
FOSECO_P10_13K,foseco_step,exo,,,,,,,1.7500,1.540,2.7000,datasheet
|
| 13 |
-
FOSECO_P12_15K,foseco_step,exo,,,,,,,2.0000,1.550,3.1000,datasheet
|
| 14 |
-
FOSECO_V90L,foseco_step,insulating,,,,,,,,1.150,1.7000,unverified
|
| 15 |
-
FOSECO_V88E,foseco_step,insulating,,,,,,,,1.450,1.7000,unverified
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|