eugenmik commited on
Commit
73ef2a7
·
verified ·
1 Parent(s): 2965d3b

Model-focused card; release scope: EN-GJS-400-15 + G20Mn5, green sand; mesh-in API

Browse files
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
- This is a graph neural network surrogate for screening how metal castings solidify. You give it
30
- a STEP model, an alloy and a mold condition. The pipeline meshes the part, predicts the
31
- solidification time at every mesh node and marks the regions that freeze last. Those hot spots
32
- are where feeding problems and shrinkage defects usually start.
33
 
34
- A transient finite-element solve of the same field takes minutes to hours. Once the part is
35
- meshed, this model returns the field in seconds, which makes it useful early in design: you can
36
- compare concepts, see where the hot regions are, and decide which variants are worth a full
37
- simulation.
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>Predicted solidification playback. Solidified material turns translucent while the
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
- | Inputs | STEP geometry in mm, alloy grade, mold system, optional chill and sleeve body roles |
53
- | Outputs | Solidification time per node, total time, top-10% hot-spot mask, out-of-distribution warnings, shrinkage screening fields |
54
- | Architecture | 12-block MeshGraphNet-style GNN with FiLM conditioning, plus two gradient-boosted heads |
55
- | Training data | FEniCSx enthalpy-method conduction simulations: about 9.7k primitive cases, 4.7k multi-alloy and multi-BC cases, 500 real-part cases |
56
- | Framework | PyTorch 2.3, PyTorch Geometric 2.7, scikit-learn 1.5.2, NumPy 2 |
57
  | License | Apache-2.0 for code and weights |
58
- | Code | Included in this repository. Showcase on [GitHub](https://github.com/eugenmik/castsolid-gnn) |
59
  | Author | Eugen Miknevic, independent foundry engineer |
60
 
61
  ## Contents
62
 
63
- - [What is in this repository](#what-is-in-this-repository)
64
- - [Quick start](#quick-start)
65
- - [How the predictor works](#how-the-predictor-works)
 
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
- ## What is in this repository
75
 
76
- The predictor combines three trained models. Use them together; none of them is meant to run
77
- alone.
78
 
79
- | File | Size | Role |
80
- |---|---:|---|
81
- | `weights/gnn_v5.1.safetensors` | 7.4 MB | GNN weights, feature and target normalization, and the architecture config (stored in the safetensors metadata). Predicts the shape of the field |
82
- | `weights/gbm_total_v3.skops` | 0.6 MB | `HistGradientBoostingRegressor` on 16 features. Predicts the total solidification time of the part, which sets the absolute scale |
83
- | `weights/gbm_std_v2.skops` | 0.8 MB | `HistGradientBoostingRegressor` with absolute-error loss on 17 features. Predicts the spread (standard deviation) of the field |
84
- | `weights/ood_ranges.json` | <1 KB | Training envelope used by the geometry out-of-distribution check |
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
- None of the weight files can execute code when loaded. The GNN is stored as
92
- [safetensors](https://github.com/huggingface/safetensors) and the gradient-boosted heads as
93
- [skops](https://skops.readthedocs.io/). Before loading a skops file, `src/release_io.py` checks
94
- every type it names against an allowlist of scikit-learn and NumPy types. All three files were
95
- converted from the internal PyTorch and joblib pickles and checked against them (see
96
- [Reproducibility](#reproducibility)).
97
 
98
- ## Quick start
 
 
99
 
100
- ### 1. Install
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
- It writes three files:
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.predict_casting import predict_casting
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
- To use an alloy that is not in the table, pass a JSON file with exactly these keys (SI units,
160
- temperatures in °C):
 
161
 
162
- ```json
163
- {"k": 41.5, "rho": 7150.0, "cp": 535.0, "L_kJ_kg": 240.0,
164
- "TL": 1220.0, "TS": 1150.0, "Tpour": 1320.0, "mold_effusivity": 1018.0}
165
  ```
166
 
167
- ```bash
168
- python -m src.predict_casting part.step --props my_alloy.json
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
- [{"solid_id": 1, "role": "casting"},
183
- {"solid_id": 2, "role": "chill", "params": {"material": "cast_iron"}},
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
- This is a local trame and PyVista app. You upload a STEP file and run the prediction, then move
200
- a time slider (solidified material turns translucent), toggle the hot spots, or export a
201
- solidification GIF from the current camera angle.
 
 
202
 
203
- ### Runtime
204
 
205
- The GNN itself runs in seconds on a CPU or GPU. CAD healing and meshing take most of the time,
206
- from seconds to a few minutes for typical parts. Very large or very detailed STEP files, with
207
- thousands of small faces, can run past the meshing time limit.
208
-
209
- ## How the predictor works
210
 
211
  ```mermaid
212
  flowchart LR
213
- A[STEP + alloy + mold<br/>+ optional body roles] --> B[CAD healing +<br/>tetrahedral mesh]
214
- B --> C[15 node features<br/>+ 5-value alloy/BC vector]
215
- C --> D1[12-block GNN<br/>field shape]
216
- C --> D2[GBM total head<br/>absolute scale]
217
- C --> D3[GBM spread head<br/>field contrast]
218
- D1 --> E[Soft-anchor<br/>recomposition]
219
- D2 --> E
220
- D3 --> E
221
- E --> F[Time field + hot spots<br/>+ OOD + shrinkage screening]
222
- F --> G[VTU / browser viewer]
 
 
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, so the model sees geometry in millimetres but never absolute
238
- coordinates.
239
-
240
- Each node has 15 features. All of them come from the mesh and its boundary labels; file names
241
- and part identity are never used.
242
-
243
- - Geodesic distance through the mesh to the nearest cooling surface and to the nearest
244
- adiabatic surface, plus straight-line distance to the surface and to adiabatic faces.
245
- - Cooling, adiabatic and interior flags.
246
- - Part volume, surface area and the fraction of surface that cools.
247
- - Local modulus: the volume-to-surface ratio inside geodesic balls of 5, 15 and 40 mm. This is
248
- a local version of Chvorinov's modulus.
249
- - Two boundary-condition channels. `boundary_extraction` encodes heat drawn out by chills.
250
- `exothermic_input` encodes heat from an exothermic sleeve, spread along the mesh with a
251
- 15 mm decay length.
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 model has about 1.85 M parameters. Its target is the standardized `log1p` of 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. They see one row per part:
267
- alloy properties (k, ρ, cp, latent heat, liquidus, solidus, pouring temperature), the part's
268
- volume-to-area ratio, mold effusivity, a one-hot boundary regime (none, chills, insulating
269
- sleeve or exothermic sleeve), the number of chills, the sleeve MEF and the exothermic heat flux.
270
- The spread head also gets the fraction of boundary area covered by chills or a sleeve. The total
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
- The pair `(a, m)` minimizes
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, the result equals the closed-form affine solve. When clipping makes
290
- that impossible, the total anchor is allowed to move by a few percent so the spread stays close.
291
- The post-clip spread is not monotone in `a`, so the solver brackets the smallest root
292
- explicitly. The code is in `src/hybrid.py::hybrid_field_soft`.
293
 
294
- ### Shrinkage screening
295
 
296
- `src/shrinkage.py` post-processes the predicted field. It computes a time-based Niyama proxy,
297
- finds macro-shrinkage regions (hot regions that liquid can no longer reach to feed them), places
298
- gravity-aware sinks and suggests feeder moduli. It is deterministic and adds about half a second
299
- per part. On parts with chills the Niyama result carries `niyama_confidence: "low"`, because a
300
- proxy built only from time cannot see the extra heat a chill pulls out locally. Pass
301
- `--no-shrinkage` to skip it.
 
 
 
 
 
 
 
 
 
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. The model has no mold
307
- filling, flow, gas or stress.
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 and multi-BC (v4) | 4,727 | Realistic thick-walled castings in 12 named iron and steel grades plus sampled synthetic alloys. Boundary regimes: sand only, chills, insulating sleeves, exothermic sleeves |
318
- | Real-part assemblies (E) | 500 | Prepared production geometries with virtual chill and sleeve contact patches |
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
- The meshes are gmsh tetrahedral meshes with roughly 7,500 to 10,000 nodes. The target at each
327
- node is the time its temperature drops through the solidus. Material properties come from
328
- handbook data, and the subset used at inference is in `data/properties/`. The exothermic sleeve
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, multi-BC set. The FiLM layers started at zero, so
339
- training began from exactly the SP1 function.
340
- 3. Exo-fix re-encoded the exothermic channel. As a sparse indicator with +19σ spikes it had
341
- been suppressed by the network; as a geodesic-diffused field it is used. Exothermic cases were
342
- oversampled three times, and the checkpoint was picked on gate metrics instead of validation
343
- loss alone.
344
- 4. FT3 fine-tuned on the real-part assemblies (set E) with the normalization statistics frozen.
345
- Its epoch-21 checkpoint is the v5.1 GNN.
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 assemblies, 24
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
- The real-geometry study started from 10 production CAD models. Two had self-intersecting
368
- B-reps and could not be meshed, which left eight scored parts. One single-body part reached a
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 and gating or feeding variants;
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
- surrogate's property and boundary assumptions.
397
- 2. STEP files must be in millimetres; units are not detected. STL is not supported, because
398
- healing and face tagging need a B-rep.
399
- 3. Invalid or very detailed B-reps can fail to mesh or run past the meshing time limit.
400
- 4. The default density of about 8,500 nodes can under-resolve large thin-walled parts.
401
- 5. On assemblies the predicted field is over-smoothed, even when the hot regions are ranked
402
- well.
403
- 6. The OOD check only covers part size and distance to cooling. It is not a calibrated
404
- uncertainty estimate.
405
- 7. The two mold systems currently share the same interim thermal properties, so choosing one
406
- over the other does not change the prediction.
407
- 8. The Niyama proxy has low confidence on parts with chills and is flagged that way.
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
- `tools/export_release.py` converted the internal checkpoints with the functions in
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 part, the release files and the internal pickles give the same predicted field.
438
- The training scripts are in `src/` (`train_gnn.py`, `train_ft2b.py`, `gbm_ft2b.py`), but the
439
- simulation corpus is not part of this release.
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. Check them
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
- # Public runtime property tables
2
 
3
- These compact CSV files contain the numeric rows required by the public inference and unit-test
4
- code. They are a curated runtime subset of the project's larger property record.
5
 
6
- - `materials.csv`: 12 named iron and steel grades.
7
- - `molds.csv`: two UI labels currently sharing the same interim thermal properties.
8
- - `chills.csv`: example chill materials; incomplete rows are skipped by the loader.
9
- - `exo_calibration.csv`: the MEF-to-heat-flux calibration used by the example sleeve path.
 
 
 
10
 
11
- The values remain modeling inputs, not certified material specifications. A production user
12
- should replace or verify them for the actual alloy, mold and process and should retain the exact
13
- property row with every result.
 
 
 
 
 
 
 
 
 
 
 
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-GJL-250"
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. The chill low-confidence
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 `bc_plan.feature_spec(...)`/the all-sand
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, bc_plan=None, hgb_std=None,
234
  lambda_shrink: float = 1.0, w_total: float = DEFAULT_W_TOTAL,
235
  w_std: float = DEFAULT_W_STD) -> tuple:
236
- """`bc_plan` (Part B Task 4): an optional `src.assembly.BcPlan`, resolved
237
- by `validate_bodies` from a multi-body role assignment. `None` (the
238
- default) is byte-identical to the pre-Part-B behavior -- the film
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
- if bc_plan is not None:
315
- spec = bc_plan.feature_spec(props["mold_effusivity"])
316
- bc_spec = bc_plan.gbm_bc_spec()
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
- bodies=None, target_nodes: int = DEFAULT_TARGET_NODES,
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 (same reasoning as
925
- `bodies`: SP1 has no scale-head std target) -- raises `ValueError`
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
- Shrinkage-screening spec ADDENDUM 2 (2026-08-01, owner-approved SCOPED
987
- SHIP): the shrinkage dict always carries `"niyama_confidence"` (`"full"`
988
- or `"low"`). Gate C (the proxy-Niyama validation) passed both floors on
989
- all-sand only (sleeve slices are an owner-accepted intermediate: rho
990
- 0.768/0.691, below the 0.8 floor) and FAILED on the chill-bearing slice (Spearman rho
991
- 0.313 -- the proxy `Tdot ~ (TL-TS)/t_s` identity has no way to see a
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
- keep_solid_id = plan.casting_solid_id if plan is not None else None
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, bc_plan=plan,
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
- # Spec ADDENDUM 2 (2026-08-01, owner-approved scoped ship): Gate C
1216
- # failed on the chill-bearing slice (rho 0.313) -- proxy Niyama
1217
- # cannot see a chill's local cooling-rate enhancement, so mark it
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, "bc_config": bc_config,
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
- bodies=a.bodies_json, hgb_std_path=a.hgb_std_path,
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']} bc_config {r['bc_config']} -> {r['vtu']}")
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
- Part B Task 5 adds an assembly mode: a multi-solid STEP upload shows a body
6
- table (role + params per solid) and a role-colored 3-D preview instead of
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, bodies=None):
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
- inspects `step_path` via `list_solids`; >1 solid enters assembly mode
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 test_default_is_gjl250_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"] == 1220.0 and p["TS"] == 1150.0 # EN-GJL-250 row
12
- assert p["Tpour"] == pytest.approx((1310 + 1330) / 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() and "green_sand" in list_molds()
17
- p = resolve_props(alloy="EN-GJS-400-15")
18
- assert p["TS"] == 1120.0
 
 
19
 
20
  def test_unknown_names_raise_listing_options():
21
- with pytest.raises(ValueError, match="EN-GJL-250"):
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
- p = resolve_props(alloy="EN-GJL-250")
65
- assert p["TL"] > p["TS"]
 
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-GJL-250) -> grey_iron, not assumed.
87
- assert resolve_alloy_family(None) == ("grey_iron", False)
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-GJL-250")
139
- props_b = resolve_props(alloy="EN-GJS-500-7")
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` -- which in turn is refused when `bodies` is present.
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. Same reasoning as the
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