nvidia/physicsnemo-cfd-create-model-wrapper
>- Create a new model wrapper for the PhysicsNeMo CFD benchmarking workflow. Use when the user wants to add a new CFD model, write a CFDModel wrapper, integrate a new neural network architecture, or run a custom model through the benchmarking pipeline.
npx skills add https://github.com/NVIDIA/physicsnemo-cfd --skill physicsnemo-cfd-create-model-wrapper
Guide the user through adding a new CFD model to the benchmarking workflow
by writing a CFDModel subclass.
Spend a little effort up front collecting any existing artifacts that
reveal how the model actually behaves — but do not block on them.
Look (without stopping to ask the user) for:
preprocessing/normalization utilities it imports;
stats paths);
expected shapes).
Use these to pin down four things the wrapper must mirror exactly:
below).
fields, geometry/STL).
and in what channel order.
prepare_inputs/decode_outputs matchthe trained graph.
If some or all of these aren't available, that's fine — **proceed
anyway**: reconstruct from the checkpoint and stats file, pick the most
likely option, and build the wrapper now. Do not stop and wait for
answers before writing code. Just avoid *silently* guessing: a wrong
normalization scheme or channel order produces plausible-looking but
wrong predictions. So state each such assumption inline, and after
delivering the wrapper, **raise the still-uncertain choices as explicit
open items for the user to confirm** — e.g. normalization scheme, input
tier, and output fields/channel order. This keeps you unblocked while
making the risky decisions visible.
Start with the complete, ready-to-adapt templates bundled with this skill
— they are always available even when the PhysicsNeMo-CFD source tree is
not on disk:
references/example_wrapper.py — full surface and volumeCFDModel reference implementations (load, prepare_inputs, predict,
decode_outputs, registration). Copy and adapt one of these.
assets/global_stats.example.json — sample mean/std stats for bothsurface and volume.
When the PhysicsNeMo-CFD repo *is* present, also read these for the live
interface (verify paths against the actual tree):
physicsnemo/cfd/evaluation/models/model_registry.py — base class andregistry
physicsnemo/cfd/evaluation/datasets/schema.py — CanonicalCase,build_predictions_dict
physicsnemo/cfd/evaluation/models/wrappers/surface_baseline.py —simplest concrete surface wrapper
physicsnemo/cfd/evaluation/models/wrappers/volume_baseline.py —simplest concrete volume wrapper
physicsnemo/cfd/evaluation/models/wrappers/__init__.py — how wrappersare registered
physicsnemo/cfd/evaluation/common/io.py — mesh loading andnormalization stats helpers
workflows/benchmarking/notebooks/adding_a_new_model.ipynb —end-to-end tutorial
CFDModel interfaceEvery wrapper must set two class variables and implement four methods:
| Member | Purpose |
|--------|---------|
| INFERENCE_DOMAIN | "surface" or "volume" — which mesh manifold |
| OUTPUT_LOCATION | "point" or "cell" — where predictions live on the mesh |
| output_location (property) | Instance-level access to OUTPUT_LOCATION |
| load(checkpoint_path, stats_path, device, **kwargs) | Load weights and stats; return self |
| prepare_inputs(case: CanonicalCase) | Convert canonical case into model-specific tensors/graphs |
| predict(model_input) | Run forward pass; return raw output |
| decode_outputs(raw_output, case, model_input=None) | Denormalize and map to canonical predictions dict |
The engine calls load once, then `prepare_inputs → predict →
decode_outputs(raw, case, model_input) per case (model_input` is the
dict from prepare_inputs; use when decode must mirror inference
geometry).
**Always generate a new, complete wrapper class for the requested
model.** Existing wrappers (e.g. surface_baseline.py) are *references
to read*, not substitutes — when the user asks to write or create a
wrapper, produce a full new class file even if similar ones already
exist. Do not stop at "a wrapper already exists" or offer to reuse one
in place of writing the requested one. Always tell the user how to
register it: register_model(...) at import for a quick test, and an
entry in wrappers/__init__.py to make it permanent (Step 6).
even if git status shows a similar file, write the new class.
inverse gives plausible-but-wrong fields.
pressure + shear_stress — pass only the fields themodel predicts; add custom ones via **extra.
__init__.py — registering only inline and nevermentioning permanent registration.
tokens; reference, don't repeat.
Copy references/example_wrapper.py and adapt it — it has full
surface and volume implementations. Don't hand-write from scratch or
paste the whole template back to the user. The class skeleton is just:
class MyModelWrapper(CFDModel):
INFERENCE_DOMAIN: ClassVar[InferenceDomain] = "surface" # or "volume"
OUTPUT_LOCATION: ClassVar[OutputLocation] = "cell" # or "point"
@property
def output_location(self): return self.OUTPUT_LOCATION
def load(self, checkpoint_path, stats_path, device, **kwargs): ... # weights + stats; return self
def prepare_inputs(self, case): ... # CanonicalCase -> model input
def predict(self, model_input): ... # forward pass -> raw output
def decode_outputs(self, raw_output, case, model_input=None): ... # denormalize -> build_predictions_dict(...)
Keep responses terse: state the few model-specific decisions
(normalization scheme, input tier, output fields) and the file you
wrote — don't echo the interface table or the full reference file back.
Normalization (match the training script exactly): Most trained
models normalize inputs/outputs, and decode_outputs must apply the
*inverse* of whatever the model was trained with. First identify the
scheme:
x_norm = (x - mean) / std → inversex = x_norm * std + mean. This is the repo's built-in format. Use
load_global_stats(stats_path) from
physicsnemo/cfd/evaluation/common/io.py; it reads mean/std_dev
JSON and returns mean/std tensors.
x_norm = (x - min) / (max - min) → inversex = x_norm * (max - min) + min. There is no built-in helper for
this — store min/max (e.g. in your stats JSON) and apply the
inverse yourself in decode_outputs. Do not feed a min-max file to
load_global_stats; the keys won't match.
Confirm the scheme from the training/inference script or the stats file
rather than assuming mean-std. Applying the wrong inverse yields
wrong-but-plausible fields that still pass shape checks.
Inputs (handle the model's actual input tier): prepare_inputs
receives a CanonicalCase. Pull what the model needs:
case.mesh_path (vtp/vtu) viapv.read, as in the example — sufficient for many geometry-only
models.
number): read from case.metadata (or case.ground_truth for field
arrays). Broadcast/concatenate them onto the per-point features as the
training script did.
is typically on case.metadata; load it in prepare_inputs and build
the geometric encoding the model expects.
Inspect case.metadata and case.ground_truth keys for a real case
early — the dataset adapter decides what is available.
Outputs (predict only what the model produces, plus extras):
build_predictions_dict takes pressure, shear_stress, velocity,
turbulent_viscosity (all optional) and arbitrary extra
fields**. So:
omits the missing keys — pass only what it produces.
stagnation_pressure, temperature,mach) are passed as keyword args: `build_predictions_dict(pressure=p,
mach=m, temperature=t)`. Each becomes a prediction variable.
matching entry to output.mesh_field_names in the config (Step 4) and
a corresponding metric if you want it scored.
Output shape: pressure must be (N,) float32. shear_stress must
be (N, 3) float32 for surface. Volume fields: velocity is (N, 3),
turbulent_viscosity is (N,). Custom scalar fields are (N,), vector
fields (N, k).
Output location: If OUTPUT_LOCATION = "cell", return N =
mesh.n_cells values. If "point", return N = mesh.n_points values.
Batching: For large meshes, prepare_inputs may need to subsample
or batch. Use kwargs passed through load() (e.g.,
batch_resolution, geometry_sampling) to control this.
Your model needs a checkpoint file and optionally a global_stats.json:
# Checkpoint: save your model's state dict
torch.save(model.state_dict(), "checkpoint.pt")
# Stats: JSON with mean/std_dev for denormalization
# Surface format:
{
"mean": {"pressure": [0.0], "shear_stress": [0.0, 0.0, 0.0]},
"std_dev": {"pressure": [1.0], "shear_stress": [1.0, 1.0, 1.0]}
}
# Volume format:
{
"mean": {"pressure": [0.0], "velocity": [0.0, 0.0, 0.0], "turbulent_viscosity": [0.0]},
"std_dev": {"pressure": [1.0], "velocity": [1.0, 1.0, 1.0], "turbulent_viscosity": [1.0]}
}
This mean/std_dev layout is what load_global_stats() expects
(mean-std models). If your model was trained with min-max
normalization, this helper does not apply — persist min/max per
field in your own JSON and apply the inverse manually in
decode_outputs (see Normalization above).
register_model("my_model", MyModelWrapper)
# Load a case from any registered dataset adapter
from physicsnemo.cfd.evaluation.datasets.adapters.drivaerml import DrivAerMLAdapter
adapter = DrivAerMLAdapter(root="/path/to/data", inference_domain="surface")
case = adapter.load_case(adapter.list_cases()[0])
# Run the full inference pipeline
wrapper = MyModelWrapper()
wrapper.load(checkpoint_path="checkpoint.pt", stats_path="global_stats.json", device="cuda:0")
model_input = wrapper.prepare_inputs(case)
raw_output = wrapper.predict(model_input)
predictions = wrapper.decode_outputs(raw_output, case, model_input)
assert "pressure" in predictions
assert predictions["pressure"].shape[0] > 0
from physicsnemo.cfd.evaluation.config import Config
from physicsnemo.cfd.evaluation.benchmarks.engine import run_benchmark
config = Config.from_dict({
"run": {"device": "cuda:0", "output_dir": "results", "metrics_cache": {"enabled": False}},
"benchmark": {
"mode": "matrix",
"models": [{
"name": "my_model",
"inference_domain": "surface",
"checkpoint": "/path/to/checkpoint.pt",
"stats_path": "/path/to/global_stats.json",
"kwargs": {},
}],
"datasets": [{
"name": "drivaerml",
"root": "/path/to/drivaerml/data",
"case_ids": ["run_1", "run_11"],
"kwargs": {"align_ground_truth_to_model": True, "inference_domain": "surface"},
}],
"reproducibility": {"log_env": False, "save_artifacts": True},
},
"output": {"mesh_field_names": {"pressure": "pMeanTrimPred", "shear_stress": "wallShearStressMeanTrimPred"}},
"metrics": ["l2_pressure", "l2_shear_stress", "l2_pressure_area_weighted", "drag", "lift"],
"reports": {"enabled": False},
})
results = run_benchmark(config)
Results are written to benchmark_results.json (a JSON list of dicts,
one per model×dataset combo).
from physicsnemo.cfd.postprocessing_tools.visualization.utils import plot_fields, plot_field_comparisons
# Just the predicted fields (no GT comparison):
plotter = plot_fields(mesh, fields=["pMeanTrimPred"], view="xy", dtype="cell", window_size=[1800, 600])
plotter.screenshot("predicted_pressure.png")
plotter.close()
# Side-by-side with GT (GT | Pred | Error):
plotter = plot_field_comparisons(mesh, true_fields=["pMeanTrim"], pred_fields=["pMeanTrimPred"],
view="xy", dtype="cell", window_size=[1800, 600])
plotter.screenshot("comparison.png")
plotter.close()
Save the wrapper to
physicsnemo/cfd/evaluation/models/wrappers/my_model.py and register in
wrappers/__init__.py:
from physicsnemo.cfd.evaluation.models.wrappers.my_model import MyModelWrapper
register_model("my_model", MyModelWrapper)
Then use model.name: my_model in any YAML config.
DistributedManager.initialize(). In notebooks without torchrun,
set env vars first: WORLD_SIZE=1, RANK=0, LOCAL_RANK=0,
MASTER_ADDR=localhost, MASTER_PORT=12355.
weights_only=True: Use this flag with torch.load() for safedeserialization (PyTorch 2.6+ default).
model.INFERENCE_DOMAINmatches the dataset adapter's inference_domain_from_kwargs().
Mismatches are skipped in matrix mode or raise in single mode.
align_ground_truth_to_model: true in datasetkwargs, the engine converts GT data to match OUTPUT_LOCATION (point
↔ cell). This is automatic — the wrapper just needs correct class
vars.
benchmark_results.json is a plainlist[dict], not {"results": [...]}. Iterate directly:
for combo in report:.
references/example_wrapper.py — complete surface + volume CFDModeltemplates to copy and adapt (bundled; available without the repo on
disk).
assets/global_stats.example.json — sample mean/std stats layout forsurface and volume.
Take nvidia/physicsnemo-cfd-create-model-wrapper from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.