--- name: physicsnemo-cfd-create-custom-metric description: >- Create a custom metric for the PhysicsNeMo CFD benchmarking workflow. Use when the user wants to add a new evaluation metric, implement a custom error measure, compute force coefficients, or extend the benchmark with domain-specific quantities. license: Apache-2.0 --- # Create a Custom Metric Guide the user through adding a new metric to the benchmarking workflow. ## Reference files to read first - `physicsnemo/cfd/postprocessing_tools/metric_registry.py` — `register_metric`, `get_metric`, `MetricFn` - `physicsnemo/cfd/evaluation/metrics/builtin/forces.py` — `drag_error`, `lift_error` (dict-returning, mesh-based) - `physicsnemo/cfd/evaluation/metrics/builtin/l2.py` — L2 metrics (scalar-returning, numpy fallback) - `physicsnemo/cfd/evaluation/metrics/mesh_bridge.py` — `build_comparison_mesh`, `resolve_comparison_mesh_for_metric` - `physicsnemo/cfd/postprocessing_tools/metrics/aero_forces.py` — `compute_force_coefficients` (normals, areas, integration) - `workflows/benchmarking/notebooks/adding_a_new_metric.ipynb` — end-to-end tutorial ## Metric function signature Metrics are plain callables, no base class: ```python MetricFn = Callable[..., float | dict[str, float]] ``` **Modern signature** (accepts extended engine kwargs): ```python def my_metric( ground_truth: dict, # canonical GT: {"pressure": ..., "shear_stress": ...} predictions: dict, # canonical predictions from decode_outputs *, case: Any = None, # CanonicalCase from the dataset adapter comparison_mesh: Any = None, # PyVista mesh with GT + pred arrays attached metric_dtype: str | None = None, # "cell" or "point" output: Any = None, # OutputConfig with field name mappings **_: object, # absorb unknown kwargs ) -> float | dict[str, float]: ... ``` **Return types**: - `float` — single scalar value (e.g., L2 error) - `dict[str, float]` — multiple values; keys are auto-flattened by the engine: `{"error": 0.1, "pred": 42.0}` from metric `side_force` becomes `side_force_error` and `side_force_pred` in results ## Step 1: Write the metric function ### Simple array-based metric (no mesh needed) ```python import numpy as np def mae_pressure(ground_truth, predictions, **_): gt = np.asarray(ground_truth.get("pressure", []), dtype=np.float64).ravel() pred = np.asarray(predictions.get("pressure", []), dtype=np.float64).ravel() if gt.size == 0 or pred.size == 0 or gt.shape != pred.shape: return float("nan") return float(np.mean(np.abs(gt - pred))) ``` ### Mesh-based metric (uses normals, areas, geometry) Use ``resolve_comparison_mesh_for_metric`` (shared helper in ``mesh_bridge``; do not copy a local ``_resolve_mesh``) to get the comparison mesh, then access arrays: ```python from physicsnemo.cfd.evaluation.metrics.mesh_bridge import resolve_comparison_mesh_for_metric def my_force_metric(ground_truth, predictions, *, case=None, comparison_mesh=None, metric_dtype=None, output=None, **_): mesh, dtype = resolve_comparison_mesh_for_metric( predictions, case=case, comparison_mesh=comparison_mesh, metric_dtype=metric_dtype, output=output, ) if mesh is None or output is None: return float("nan") # Access fields by VTK array name from output config p = mesh.cell_data[output.mesh_field_names["pressure"]] wss = mesh.cell_data[output.mesh_field_names["shear_stress"]] # Access mesh geometry mesh = mesh.compute_normals().compute_cell_sizes() normals = mesh["Normals"] # (N, 3) areas = mesh["Area"] # (N,) # Compute your metric... return float(result) ``` ## Step 2: Register the metric ```python from physicsnemo.cfd.postprocessing_tools.metric_registry import register_metric register_metric("my_metric", my_metric_fn, domain="surface") # or "volume" or None ``` - `domain="surface"` — only used when model's inference domain is surface - `domain="volume"` — only used for volume inference - `domain=None` — domain-agnostic fallback - Same name can be registered for both domains with different functions (like `l2_pressure`) ## Step 3: Use in benchmark config Add the metric name to the `metrics` list: ```python config = Config.from_dict({ ... "metrics": ["l2_pressure", "drag", "lift", "my_metric"], ... }) ``` Or in YAML: ```yaml metrics: - l2_pressure - my_metric ``` Per-metric kwargs can be passed as a dict: ```yaml metrics: - name: my_metric some_param: 42 ``` ## Step 4: Make permanent (optional) Add to `physicsnemo/cfd/evaluation/metrics/builtin/` and register from `builtin/__init__.py`: ```python def register_my_metrics(): register_metric("my_metric", my_fn, domain="surface") # In __init__.py: def register_all_builtin_metrics(): register_l2_metrics() register_force_metrics() register_physics_metrics() register_my_metrics() # add this ``` ## Existing built-in metrics | Name | Domain(s) | Returns | |------|-----------|---------| | `l2_pressure` | surface, volume | `float` | | `l2_shear_stress` | surface | `dict` | | `l2_pressure_area_weighted` | surface | `float` | | `l2_velocity` | volume | `dict` | | `l2_turbulent_viscosity` | volume | `float` | | `drag` | surface | `dict` (error, true, pred) | | `lift` | surface | `dict` (error, true, pred) | | `continuity_residual_l2` | volume | `float` | | `momentum_residual_l2` | volume | `float` | ## Gotchas - **Dict flattening**: if metric returns `{"error": 0.1, "true": 5.0}`, engine stores as `metricname_error` and `metricname_true`. An empty string key `""` maps to just `metricname`. - **NaN handling**: return `float("nan")` for failures; engine accumulates NaN gracefully. - **Legacy fallback**: engine tries extended kwargs first; on `TypeError` it falls back to `fn(gt, predictions, **mkwargs)` only. Modern metrics should accept `**_` to absorb unknowns. - **Results JSON format**: `benchmark_results.json` is a plain `list[dict]`, not `{"results": [...]}`. - **OutputConfig field names**: surface uses `output.mesh_field_names` / `output.ground_truth_mesh_field_names`; volume uses `output.volume_mesh_field_names` / `output.ground_truth_volume_mesh_field_names`.