diff --git a/.gitignore b/.gitignore index 25c7789..d6b9716 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .coverage* .pytest_cache* .ruff_cache* +.vscode/ diff --git a/examples/external_aerodynamics/External_Aero_Data_Processing_Reference.md b/examples/external_aerodynamics/External_Aero_Data_Processing_Reference.md index 70c2dcf..e556926 100644 --- a/examples/external_aerodynamics/External_Aero_Data_Processing_Reference.md +++ b/examples/external_aerodynamics/External_Aero_Data_Processing_Reference.md @@ -95,6 +95,8 @@ For more information, see the [Zarr project website](https://zarr.dev/). ├── stl_centers # Cell centers ├── stl_coordinates # Vertex coordinates ├── stl_faces # Face connectivity + ├── global_params_values # Simulation-specific global parameters (optional) + ├── global_params_reference # Reference values for global parameters (optional) ├── surface_areas # Cell areas (surface, optional) ├── surface_fields # Field data (surface, optional) ├── surface_mesh_centers # Cell centers (surface, optional) diff --git a/examples/external_aerodynamics/config/external_aero_etl_drivaerml.yaml b/examples/external_aerodynamics/config/external_aero_etl_drivaerml.yaml index fe583eb..b5da9a3 100644 --- a/examples/external_aerodynamics/config/external_aero_etl_drivaerml.yaml +++ b/examples/external_aerodynamics/config/external_aero_etl_drivaerml.yaml @@ -17,6 +17,7 @@ defaults: - /variables/surface: drivaerml - /variables/volume: drivaerml + - /variables/global: drivaerml - /serialization_format: zarr # Default value, can be overridden via CLI - /override_transformations: ${serialization_format} - _self_ @@ -98,6 +99,16 @@ etl: - _target_: external_aero_volume_data_processors.shuffle_volume_data _partial_: true + global_params_preprocessing: + _target_: data_transformations.ExternalAerodynamicsGlobalParamsTransformation + _convert_: all + global_parameters: ${etl.global_parameters} + # Regardless of whether there are any additional global params processors, + # We always apply the default global params processing. This ensure that there are global params references present. + global_params_processors: + - _target_: external_aero_global_params_data_processors.process_global_params + _partial_: true + write_ready_transformation: ${override_transformations.write_ready_transformation} sink: diff --git a/examples/external_aerodynamics/config/external_aero_etl_hlpw.yaml b/examples/external_aerodynamics/config/external_aero_etl_hlpw.yaml new file mode 100644 index 0000000..8601f16 --- /dev/null +++ b/examples/external_aerodynamics/config/external_aero_etl_hlpw.yaml @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +defaults: + - /external_aero_etl_drivaerml + - override /variables/surface: hlpw + - override /variables/volume: hlpw + - override /variables/global: hlpw + - _self_ + +etl: + common: + kind: hlpw + model_type: surface # produce data for which model? surface, volume, combined + + processing: + num_processes: 12 + + transformations: + surface_preprocessing: + # Use HLPW-specific transformation that handles N_BF field + _target_: data_transformations.ExternalAerodynamicsSurfaceTransformationHLPW + _convert_: all + nbf_field_name: "N_BF" + + surface_processors: + - _target_: external_aero_surface_data_processors.decimate_mesh + _partial_: true + algo: decimate_pro + reduction: 0.0 + preserve_topology: false + + # Note: normalize_surface_normals not needed - already done via N_BF normalization + + # HLPW-specific non-dimensionalization + - _target_: external_aero_surface_data_processors.non_dimensionalize_surface_fields_hlpw + _partial_: true + pref: 176.352 + tref: 518.67 + - _target_: external_aero_surface_data_processors.update_surface_data_to_float32 + _partial_: true + + volume_preprocessing: + _target_: data_transformations.ExternalAerodynamicsVolumeTransformation + _convert_: all + volume_processors: + - _target_: external_aero_volume_data_processors.non_dimensionalize_volume_fields_hlpw + _partial_: true + pref: 176.352 + tref: 518.67 + uref: 2679.505 + - _target_: external_aero_volume_data_processors.update_volume_data_to_float32 + _partial_: true + - _target_: external_aero_volume_data_processors.shuffle_volume_data + _partial_: true + + global_params_preprocessing: + _target_: data_transformations.ExternalAerodynamicsGlobalParamsTransformation + _convert_: all + global_parameters: ${etl.global_parameters} + global_params_processors: + - _target_: external_aero_global_params_data_processors.process_global_params_hlpw + _partial_: true diff --git a/examples/external_aerodynamics/config/variables/global/ahmedml.yaml b/examples/external_aerodynamics/config/variables/global/ahmedml.yaml new file mode 100644 index 0000000..9ae6a99 --- /dev/null +++ b/examples/external_aerodynamics/config/variables/global/ahmedml.yaml @@ -0,0 +1,27 @@ +# @package etl +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +global_parameters: + inlet_velocity: + type: vector + reference: [30.0] # Inlet velocity vector in m/s, if a 2D vector, give [30, 30], if a 3D vector give [30, 30, 30] + air_density: + type: scalar + reference: 1.205 # Air density in kg/m³ + pressure: + type: scalar + reference: 101325.0 # Reference pressure in Pa diff --git a/examples/external_aerodynamics/config/variables/global/drivaerml.yaml b/examples/external_aerodynamics/config/variables/global/drivaerml.yaml new file mode 100644 index 0000000..9ae6a99 --- /dev/null +++ b/examples/external_aerodynamics/config/variables/global/drivaerml.yaml @@ -0,0 +1,27 @@ +# @package etl +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +global_parameters: + inlet_velocity: + type: vector + reference: [30.0] # Inlet velocity vector in m/s, if a 2D vector, give [30, 30], if a 3D vector give [30, 30, 30] + air_density: + type: scalar + reference: 1.205 # Air density in kg/m³ + pressure: + type: scalar + reference: 101325.0 # Reference pressure in Pa diff --git a/examples/external_aerodynamics/config/variables/global/hlpw.yaml b/examples/external_aerodynamics/config/variables/global/hlpw.yaml new file mode 100644 index 0000000..b5e0091 --- /dev/null +++ b/examples/external_aerodynamics/config/variables/global/hlpw.yaml @@ -0,0 +1,21 @@ +# @package etl +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +global_parameters: + AoA: + type: scalar + reference: 22.0 # Angle of Attack in degrees diff --git a/examples/external_aerodynamics/config/variables/surface/hlpw.yaml b/examples/external_aerodynamics/config/variables/surface/hlpw.yaml new file mode 100644 index 0000000..29972b6 --- /dev/null +++ b/examples/external_aerodynamics/config/variables/surface/hlpw.yaml @@ -0,0 +1,23 @@ +# @package etl.transformations.surface_preprocessing +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +surface_variables: + PROJ(AVG(T)): scalar + PROJ(AVG(P)): scalar + AVG(TAU_WALL(0)): scalar + AVG(TAU_WALL(1)): scalar + AVG(TAU_WALL(2)): scalar diff --git a/examples/external_aerodynamics/config/variables/volume/hlpw.yaml b/examples/external_aerodynamics/config/variables/volume/hlpw.yaml new file mode 100644 index 0000000..d05dec1 --- /dev/null +++ b/examples/external_aerodynamics/config/variables/volume/hlpw.yaml @@ -0,0 +1,21 @@ +# @package etl.transformations.volume_preprocessing +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +volume_variables: + avg(P): scalar + avg(T): scalar + avg(u): vector diff --git a/examples/external_aerodynamics/constants.py b/examples/external_aerodynamics/constants.py index bba1c9b..a28f3c4 100644 --- a/examples/external_aerodynamics/constants.py +++ b/examples/external_aerodynamics/constants.py @@ -23,13 +23,22 @@ @dataclass(frozen=True) -class PhysicsConstants: - """Physical constants used in the simulation.""" +class PhysicsConstantsCarAerodynamics: + """Physical constants used in the simulation in DriveAerML or AhmedML""" AIR_DENSITY: float = 1.205 # kg/m³ STREAM_VELOCITY: float = 30.00 # m/s +@dataclass(frozen=True) +class PhysicsConstantsHLPW: + """Physical constants used in the simulation for HLPW dataset.""" + + PREF: float = 176.352 # HLPW reference pressure + UREF: float = 2679.505 # HLPW reference velocity + TREF: float = 518.67 # HLPW reference temperature + + class ModelType(str, Enum): """Types of models that can be processed.""" @@ -44,6 +53,7 @@ class DatasetKind(str, Enum): DRIVESIM = "drivesim" DRIVAERML = "drivaerml" AHMEDML = "ahmedml" + HLPW = "hlpw" @dataclass(frozen=True) @@ -52,3 +62,27 @@ class DefaultVariables: SURFACE: tuple[str, ...] = ("pMean", "wallShearStress") VOLUME: tuple[str, ...] = ("UMean", "pMean") + + +def get_physics_constants(kind: DatasetKind) -> dict[str, float]: + """Get physics constants dict based on dataset kind. Add a branch + to the if-elif pipeline below to populate metadata with values + used for non-dimensionalization. + + Args: + kind: The dataset kind (from config etl.common.kind) + + Returns: + Dictionary of physics constant names to values. + + Raises: + ValueError: If dataset kind is unknown. + """ + if kind in (DatasetKind.DRIVAERML, DatasetKind.AHMEDML, DatasetKind.DRIVESIM): + c = PhysicsConstantsCarAerodynamics() + return {"air_density": c.AIR_DENSITY, "stream_velocity": c.STREAM_VELOCITY} + elif kind == DatasetKind.HLPW: + c = PhysicsConstantsHLPW() + return {"pref": c.PREF, "uref": c.UREF, "tref": c.TREF} + else: + raise ValueError(f"Unknown dataset kind: {kind}") diff --git a/examples/external_aerodynamics/data_analysis.ipynb b/examples/external_aerodynamics/data_analysis.ipynb index 19ac185..9cf016e 100644 --- a/examples/external_aerodynamics/data_analysis.ipynb +++ b/examples/external_aerodynamics/data_analysis.ipynb @@ -56,19 +56,15 @@ "import numpy as np\n", "\n", "\n", - "def compute_feature_descriptor(\n", - " areas_zarr,\n", - " coords_zarr,\n", - " centers_zarr\n", - " ):\n", + "def compute_feature_descriptor(areas_zarr, coords_zarr, centers_zarr):\n", " \"\"\"\n", " Compute a statistical feature descriptor vector using CuPy.\n", - " \n", + "\n", " Parameters:\n", " areas_zarr: Zarr array of triangle areas\n", " coords_zarr: Zarr array of shape (N, 3) with all surface point coordinates\n", " centers_zarr: Zarr array of shape (M, 3) with triangle center coordinates\n", - " \n", + "\n", " Returns:\n", " descriptor (np.ndarray): shape (10,)\n", " \"\"\"\n", @@ -95,14 +91,19 @@ "\n", " pca_eigvals = eigvals_norm.get() # Convert to NumPy\n", "\n", - "\n", " # Final descriptor vector\n", - " descriptor = np.array([\n", - " surface_area_mean,\n", - " surface_area_std,\n", - " centroid_dist_mean, centroid_dist_std,\n", - " pca_eigvals[0], pca_eigvals[1], pca_eigvals[2],\n", - " ], dtype=np.float32)\n", + " descriptor = np.array(\n", + " [\n", + " surface_area_mean,\n", + " surface_area_std,\n", + " centroid_dist_mean,\n", + " centroid_dist_std,\n", + " pca_eigvals[0],\n", + " pca_eigvals[1],\n", + " pca_eigvals[2],\n", + " ],\n", + " dtype=np.float32,\n", + " )\n", "\n", " return descriptor" ] @@ -140,7 +141,7 @@ " n_neighbors=n_neighbors,\n", " min_dist=min_dist,\n", " random_state=42,\n", - " ) \n", + " )\n", " embedding_cp = umap_model.fit_transform(descriptors_cp)\n", "\n", " embedding_np = cp.asnumpy(embedding_cp)\n", @@ -168,7 +169,7 @@ "source": [ "clusterer = cuml.cluster.HDBSCAN(\n", " min_cluster_size=10,\n", - " metric='euclidean',\n", + " metric=\"euclidean\",\n", " prediction_data=True,\n", " cluster_selection_epsilon=1.5,\n", " allow_single_cluster=True,\n", @@ -206,35 +207,36 @@ "import zarr\n", "\n", "train_cluster_color_map = {\n", - " -1: 'blue',\n", - " 0: 'green',\n", - " 1: 'red',\n", + " -1: \"blue\",\n", + " 0: \"green\",\n", + " 1: \"red\",\n", "}\n", "test_cluster_color_map = {\n", - " -1: 'black',\n", - " 0: 'red',\n", - " 1: 'green',\n", - " 2: 'purple',\n", - " 3: 'orange',\n", - " 4: 'yellow',\n", - " 5: 'pink',\n", + " -1: \"black\",\n", + " 0: \"red\",\n", + " 1: \"green\",\n", + " 2: \"purple\",\n", + " 3: \"orange\",\n", + " 4: \"yellow\",\n", + " 5: \"pink\",\n", "}\n", "\n", + "\n", "def plot_umap_embeddings_with_probabilities(\n", - " training_dataset_embeddings,\n", - " training_dataset_name = \"Train dataset\",\n", - " test_dataset_embeddings = None,\n", - " test_dataset_name = \"Test dataset\",\n", - " train_dataset_cluster_labels = None,\n", - " test_dataset_cluster_labels = None,\n", - " train_dataset_probabilities = None,\n", - " test_dataset_probabilities = None,\n", - " title=\"UMAP Projection\", \n", - " outlier_threshold=0.5,\n", - " ):\n", + " training_dataset_embeddings,\n", + " training_dataset_name=\"Train dataset\",\n", + " test_dataset_embeddings=None,\n", + " test_dataset_name=\"Test dataset\",\n", + " train_dataset_cluster_labels=None,\n", + " test_dataset_cluster_labels=None,\n", + " train_dataset_probabilities=None,\n", + " test_dataset_probabilities=None,\n", + " title=\"UMAP Projection\",\n", + " outlier_threshold=0.5,\n", + "):\n", " \"\"\"\n", " Plot UMAP embeddings with probability-based outlier detection.\n", - " \n", + "\n", " Args:\n", " training_dataset_embeddings: Array of training dataset embeddings\n", " training_dataset_name: Name of the training dataset\n", @@ -248,10 +250,10 @@ " outlier_threshold: Probability threshold below which points are considered outliers\n", " \"\"\"\n", " plt.figure(figsize=(12, 10))\n", - " \n", + "\n", " # Define default colors for different datasets\n", - " train_dataset_color = 'blue'\n", - " test_dataset_color = 'red'\n", + " train_dataset_color = \"blue\"\n", + " test_dataset_color = \"red\"\n", "\n", " # Plot training dataset\n", " if train_dataset_cluster_labels is not None:\n", @@ -262,27 +264,46 @@ " training_dataset_embeddings[mask, 0],\n", " training_dataset_embeddings[mask, 1],\n", " c=[train_cluster_color_map[cluster_label]],\n", - " marker='o',\n", - " s=(30 + 70 * train_dataset_probabilities[mask]).astype(int), \n", - " edgecolor='k', alpha=0.8,\n", - " label=f'{training_dataset_name} (cluster {cluster_label})')\n", + " marker=\"o\",\n", + " s=(30 + 70 * train_dataset_probabilities[mask]).astype(int),\n", + " edgecolor=\"k\",\n", + " alpha=0.8,\n", + " label=f\"{training_dataset_name} (cluster {cluster_label})\",\n", + " )\n", " else:\n", " plt.scatter(\n", " training_dataset_embeddings[:, 0],\n", " training_dataset_embeddings[:, 1],\n", - " c=train_dataset_color, marker='o', s=80, \n", - " edgecolor='k', alpha=0.8, \n", - " label=f'{training_dataset_name}'\n", + " c=train_dataset_color,\n", + " marker=\"o\",\n", + " s=80,\n", + " edgecolor=\"k\",\n", + " alpha=0.8,\n", + " label=f\"{training_dataset_name}\",\n", " )\n", " # Add text annotations every 10 points\n", " for i, (x, y) in enumerate(training_dataset_embeddings):\n", " if i % 10 == 0:\n", - " plt.annotate(f'{i}', (x, y), \n", - " xytext=(5, 5), textcoords='offset points',\n", - " fontsize=8, fontweight='bold',\n", - " bbox=dict(boxstyle='round,pad=0.2', facecolor='white', alpha=0.8, edgecolor='black'),\n", - " arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0', \n", - " color=train_dataset_color, alpha=0.7))\n", + " plt.annotate(\n", + " f\"{i}\",\n", + " (x, y),\n", + " xytext=(5, 5),\n", + " textcoords=\"offset points\",\n", + " fontsize=8,\n", + " fontweight=\"bold\",\n", + " bbox=dict(\n", + " boxstyle=\"round,pad=0.2\",\n", + " facecolor=\"white\",\n", + " alpha=0.8,\n", + " edgecolor=\"black\",\n", + " ),\n", + " arrowprops=dict(\n", + " arrowstyle=\"->\",\n", + " connectionstyle=\"arc3,rad=0\",\n", + " color=train_dataset_color,\n", + " alpha=0.7,\n", + " ),\n", + " )\n", "\n", " # Plot test dataset\n", " if test_dataset_cluster_labels is not None:\n", @@ -292,104 +313,129 @@ " test_dataset_embeddings[:, 0],\n", " test_dataset_embeddings[:, 1],\n", " c=test_cluster_color_map[cluster_label],\n", - " marker='o',\n", - " s=(30 + 70 * test_dataset_probabilities).astype(int), \n", - " edgecolor='k', alpha=0.8, \n", - " label=f'{test_dataset_name} (cluster {cluster_label})'\n", - " ) \n", + " marker=\"o\",\n", + " s=(30 + 70 * test_dataset_probabilities).astype(int),\n", + " edgecolor=\"k\",\n", + " alpha=0.8,\n", + " label=f\"{test_dataset_name} (cluster {cluster_label})\",\n", + " )\n", "\n", " # Outlier detection\n", " mask = test_dataset_cluster_labels == cluster_label\n", " is_outlier = test_dataset_probabilities[mask] < outlier_threshold\n", " if is_outlier.sum() > 0:\n", " masked_outlier_points = test_dataset_embeddings[mask][is_outlier]\n", - " masked_test_dataset_probabilities = test_dataset_probabilities[mask][is_outlier]\n", + " masked_test_dataset_probabilities = test_dataset_probabilities[mask][\n", + " is_outlier\n", + " ]\n", " plt.scatter(\n", " masked_outlier_points[:, 0],\n", " masked_outlier_points[:, 1],\n", " c=test_cluster_color_map[-1],\n", - " marker='x',\n", - " s=(30 + 70 * masked_test_dataset_probabilities).astype(int), \n", - " linewidth=2, alpha=0.8, \n", - " label=f'{test_dataset_name} (outlier)'\n", + " marker=\"x\",\n", + " s=(30 + 70 * masked_test_dataset_probabilities).astype(int),\n", + " linewidth=2,\n", + " alpha=0.8,\n", + " label=f\"{test_dataset_name} (outlier)\",\n", " )\n", " if is_outlier.sum() < len(mask):\n", " masked_inlier_points = test_dataset_embeddings[mask][~is_outlier]\n", - " masked_inlier_probabilities = test_dataset_probabilities[mask][~is_outlier]\n", + " masked_inlier_probabilities = test_dataset_probabilities[mask][\n", + " ~is_outlier\n", + " ]\n", " plt.scatter(\n", " masked_inlier_points[:, 0],\n", " masked_inlier_points[:, 1],\n", " c=test_cluster_color_map[cluster_label],\n", - " marker='o', s=(30 + 70 * masked_inlier_probabilities).astype(int), \n", - " edgecolor='k', alpha=0.8, \n", - " label=f'{test_dataset_name} (cluster {cluster_label})'\n", + " marker=\"o\",\n", + " s=(30 + 70 * masked_inlier_probabilities).astype(int),\n", + " edgecolor=\"k\",\n", + " alpha=0.8,\n", + " label=f\"{test_dataset_name} (cluster {cluster_label})\",\n", " )\n", " else:\n", " plt.scatter(\n", " test_dataset_embeddings[:, 0],\n", " test_dataset_embeddings[:, 1],\n", - " c=test_dataset_color, marker='o', s=80, \n", - " edgecolor='k', alpha=0.8, \n", - " label=f'{test_dataset_name}'\n", + " c=test_dataset_color,\n", + " marker=\"o\",\n", + " s=80,\n", + " edgecolor=\"k\",\n", + " alpha=0.8,\n", + " label=f\"{test_dataset_name}\",\n", " )\n", "\n", " # Add text annotations every 10 points\n", " for i, (x, y) in enumerate(test_dataset_embeddings):\n", " if i % 10 == 0:\n", - " plt.annotate(f'{i}', (x, y), \n", - " xytext=(5, 5), textcoords='offset points',\n", - " fontsize=8, fontweight='bold',\n", - " bbox=dict(boxstyle='round,pad=0.2', facecolor='white', alpha=0.8, edgecolor='black'),\n", - " arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0', \n", - " color=test_dataset_color, alpha=0.7))\n", + " plt.annotate(\n", + " f\"{i}\",\n", + " (x, y),\n", + " xytext=(5, 5),\n", + " textcoords=\"offset points\",\n", + " fontsize=8,\n", + " fontweight=\"bold\",\n", + " bbox=dict(\n", + " boxstyle=\"round,pad=0.2\",\n", + " facecolor=\"white\",\n", + " alpha=0.8,\n", + " edgecolor=\"black\",\n", + " ),\n", + " arrowprops=dict(\n", + " arrowstyle=\"->\",\n", + " connectionstyle=\"arc3,rad=0\",\n", + " color=test_dataset_color,\n", + " alpha=0.7,\n", + " ),\n", + " )\n", "\n", " plt.title(title)\n", " plt.xlabel(\"UMAP 1\")\n", " plt.ylabel(\"UMAP 2\")\n", " plt.grid(True, alpha=0.3)\n", - " \n", + "\n", " # Create custom legend to avoid duplicates\n", " handles, labels = plt.gca().get_legend_handles_labels()\n", " by_label = dict(zip(labels, handles))\n", - " plt.legend(by_label.values(), by_label.keys(), loc='best', bbox_to_anchor=(1.05, 1))\n", - " \n", + " plt.legend(by_label.values(), by_label.keys(), loc=\"best\", bbox_to_anchor=(1.05, 1))\n", + "\n", " plt.tight_layout()\n", " plt.show()\n", "\n", + "\n", "# Load the data\n", "def extract_key_data(zarr_path, required_keys):\n", - "\n", - " stores = [s for s in os.listdir(zarr_path) if s.endswith('.zarr')]\n", + " stores = [s for s in os.listdir(zarr_path) if s.endswith(\".zarr\")]\n", " stores = sorted(stores)\n", "\n", " data = {}\n", " for key in required_keys:\n", " data[key] = []\n", - " data['valid_stores'] = []\n", + " data[\"valid_stores\"] = []\n", "\n", " for store_name in stores:\n", " try:\n", " store_path = os.path.join(zarr_path, store_name)\n", - " store = zarr.open(store_path, mode='r')\n", - " \n", + " store = zarr.open(store_path, mode=\"r\")\n", + "\n", " # Check if all required keys are accessible before appending anything\n", - " required_keys = ['stl_areas', 'stl_coordinates', 'stl_centers']\n", + " required_keys = [\"stl_areas\", \"stl_coordinates\", \"stl_centers\"]\n", " all_keys_valid = True\n", - " \n", + "\n", " for key in required_keys:\n", " if key not in store:\n", " print(f\"Missing key '{key}' in store: {store_name}\")\n", " all_keys_valid = False\n", " break\n", - " \n", + "\n", " # Only append data if all keys are valid\n", " if all_keys_valid:\n", " for key in required_keys:\n", " data[key].append(store[key])\n", - " data['valid_stores'].append(store_name)\n", + " data[\"valid_stores\"].append(store_name)\n", " else:\n", " print(f\"Skipping store {store_name} due to missing keys\")\n", - " \n", + "\n", " except Exception as e:\n", " print(\"Error in store_name: \", store_name)\n", " print(e)\n", @@ -397,31 +443,36 @@ " print(f\"Successfully loaded {len(data['valid_stores'])} stores\")\n", " return data\n", "\n", - "drivaerml_zarr_path = '/mnt/datasets/drivaerml_stl_zarr'\n", - "ahmedml_zarr_path = '/mnt/datasets/ahmedml_stl_zarr'\n", "\n", - "drivaerml_extracted_data = extract_key_data(drivaerml_zarr_path, ['stl_areas', 'stl_coordinates', 'stl_centers'])\n", - "ahmedml_extracted_data = extract_key_data(ahmedml_zarr_path, ['stl_areas', 'stl_coordinates', 'stl_centers'])\n", + "drivaerml_zarr_path = \"/mnt/datasets/drivaerml_stl_zarr\"\n", + "ahmedml_zarr_path = \"/mnt/datasets/ahmedml_stl_zarr\"\n", + "\n", + "drivaerml_extracted_data = extract_key_data(\n", + " drivaerml_zarr_path, [\"stl_areas\", \"stl_coordinates\", \"stl_centers\"]\n", + ")\n", + "ahmedml_extracted_data = extract_key_data(\n", + " ahmedml_zarr_path, [\"stl_areas\", \"stl_coordinates\", \"stl_centers\"]\n", + ")\n", "\n", "drivaerml_descriptors = []\n", "ahmedml_descriptors = []\n", "\n", - "for i in range(len(drivaerml_extracted_data['valid_stores'])):\n", + "for i in range(len(drivaerml_extracted_data[\"valid_stores\"])):\n", " descriptor = compute_feature_descriptor(\n", - " drivaerml_extracted_data['stl_areas'][i],\n", - " drivaerml_extracted_data['stl_coordinates'][i],\n", - " drivaerml_extracted_data['stl_centers'][i]\n", + " drivaerml_extracted_data[\"stl_areas\"][i],\n", + " drivaerml_extracted_data[\"stl_coordinates\"][i],\n", + " drivaerml_extracted_data[\"stl_centers\"][i],\n", " )\n", " drivaerml_descriptors.append(descriptor)\n", "\n", "drivaerml_descriptors = cp.asarray(drivaerml_descriptors)\n", "print(\"Computed descriptors for drivaerml\")\n", "\n", - "for i in range(len(ahmedml_extracted_data['valid_stores'])):\n", + "for i in range(len(ahmedml_extracted_data[\"valid_stores\"])):\n", " descriptor = compute_feature_descriptor(\n", - " ahmedml_extracted_data['stl_areas'][i],\n", - " ahmedml_extracted_data['stl_coordinates'][i],\n", - " ahmedml_extracted_data['stl_centers'][i]\n", + " ahmedml_extracted_data[\"stl_areas\"][i],\n", + " ahmedml_extracted_data[\"stl_coordinates\"][i],\n", + " ahmedml_extracted_data[\"stl_centers\"][i],\n", " )\n", " ahmedml_descriptors.append(descriptor)\n", "\n", @@ -446,7 +497,9 @@ "num_ahmedml_points_to_add = 10\n", "\n", "# Stack both datasets together for UMAP\n", - "combined_descriptors = np.vstack([drivaerml_descriptors, ahmedml_descriptors[:num_ahmedml_points_to_add]])\n", + "combined_descriptors = np.vstack(\n", + " [drivaerml_descriptors, ahmedml_descriptors[:num_ahmedml_points_to_add]]\n", + ")\n", "combined_embedding = run_umap_gpu(combined_descriptors)" ] }, @@ -480,8 +533,10 @@ "clusterer.fit(cp.asarray(combined_embedding))\n", "\n", "labels_all = cp.asnumpy(clusterer.labels_)\n", - "probs_all = cp.asnumpy(clusterer.probabilities_)\n", - "print(\"Label count per cluster: \", pd.Series(cp.asnumpy(clusterer.labels_)).value_counts())\n", + "probs_all = cp.asnumpy(clusterer.probabilities_)\n", + "print(\n", + " \"Label count per cluster: \", pd.Series(cp.asnumpy(clusterer.labels_)).value_counts()\n", + ")\n", "\n", "# Split the combined embedding back into separate arrays for plotting\n", "drivaerml_embedding_np = combined_embedding[:drivaerml_count]\n", @@ -489,8 +544,8 @@ "\n", "# Usage:\n", "embeddings = {\n", - " 'DrivAerML': drivaerml_embedding_np,\n", - " 'AhmedML': ahmedml_embedding_np,\n", + " \"DrivAerML\": drivaerml_embedding_np,\n", + " \"AhmedML\": ahmedml_embedding_np,\n", "}\n", "\n", "plot_umap_embeddings_with_probabilities(\n", @@ -503,7 +558,7 @@ " train_dataset_probabilities=probs_all[:drivaerml_count],\n", " test_dataset_probabilities=probs_all[drivaerml_count:],\n", " title=\"UMAP Projection with Outlier Detection\",\n", - " outlier_threshold=0.5\n", + " outlier_threshold=0.5,\n", ")" ] } diff --git a/examples/external_aerodynamics/data_sources.py b/examples/external_aerodynamics/data_sources.py index fffdb92..fea0713 100644 --- a/examples/external_aerodynamics/data_sources.py +++ b/examples/external_aerodynamics/data_sources.py @@ -23,7 +23,7 @@ import pyvista as pv import vtk import zarr -from constants import DatasetKind, ModelType +from constants import DatasetKind, ModelType, get_physics_constants from paths import get_path_getter from schemas import ( ExternalAerodynamicsExtractedDataInMemory, @@ -125,6 +125,7 @@ def read_file(self, dirname: str) -> ExternalAerodynamicsExtractedDataInMemory: metadata = ExternalAerodynamicsMetadata( filename=dirname, dataset_type=self.model_type, # surface, volume, combined + physics_constants=get_physics_constants(self.kind), ) return ExternalAerodynamicsExtractedDataInMemory( @@ -192,19 +193,25 @@ def _write_numpy( """ # Convert to dict for numpy storage save_dict = { - # Arrays + # Required arrays "stl_coordinates": data.stl_coordinates, "stl_centers": data.stl_centers, "stl_faces": data.stl_faces, "stl_areas": data.stl_areas, # Basic metadata "filename": data.metadata.filename, - "stream_velocity": data.metadata.stream_velocity, - "air_density": data.metadata.air_density, } - # Add optional arrays if present + # Add physics constants if present (pipeline-specific keys) + # Use getattr since ExternalAerodynamicsNumpyMetadata doesn't have physics_constants + physics_constants = getattr(data.metadata, "physics_constants", None) + if physics_constants: + save_dict.update(physics_constants) + + # Add optional arrays if present (same fields as Zarr I/O) for field in [ + "global_params_values", + "global_params_reference", "surface_mesh_centers", "surface_normals", "surface_areas", @@ -252,6 +259,8 @@ def _write_zarr( # Write optional arrays if present for field in [ + "global_params_values", + "global_params_reference", "surface_mesh_centers", "surface_normals", "surface_areas", @@ -270,6 +279,8 @@ def _write_zarr( array_info.compressor if array_info.compressor else None ), ) + else: + self.logger.warning(f"{field} is absent in the dataset") def should_skip(self, filename: str) -> bool: """Checks whether the file should be skipped. diff --git a/examples/external_aerodynamics/data_transformations.py b/examples/external_aerodynamics/data_transformations.py index 9e97d93..cfea1be 100644 --- a/examples/external_aerodynamics/data_transformations.py +++ b/examples/external_aerodynamics/data_transformations.py @@ -20,12 +20,16 @@ import numpy as np import zarr -from constants import PhysicsConstants +from constants import PhysicsConstantsCarAerodynamics from external_aero_geometry_data_processors import ( default_geometry_processing_for_external_aerodynamics, ) +from external_aero_global_params_data_processors import ( + default_global_params_processing_for_external_aerodynamics, +) from external_aero_surface_data_processors import ( default_surface_processing_for_external_aerodynamics, + default_surface_processing_for_external_aerodynamics_hlpw, ) from external_aero_utils import to_float32 from external_aero_volume_data_processors import ( @@ -66,8 +70,6 @@ def transform( # Create minimal metadata numpy_metadata = ExternalAerodynamicsNumpyMetadata( filename=data.metadata.filename, - stream_velocity=data.metadata.stream_velocity, - air_density=data.metadata.air_density, ) return ExternalAerodynamicsNumpyDataInMemory( @@ -82,6 +84,8 @@ def transform( surface_fields=to_float32(data.surface_fields), volume_mesh_centers=to_float32(data.volume_mesh_centers), volume_fields=to_float32(data.volume_fields), + global_params_values=to_float32(data.global_params_values), + global_params_reference=to_float32(data.global_params_reference), ) @@ -132,7 +136,7 @@ def __init__( self.surface_variables = surface_variables self.surface_processors = surface_processors - self.constants = PhysicsConstants() + self.constants = PhysicsConstantsCarAerodynamics() if surface_variables is None: self.logger.error("Surface variables are empty!") @@ -151,7 +155,6 @@ def transform( """Transform surface data for External Aerodynamics model.""" if data.surface_polydata is not None: - # Regardless of whether there are any additional surface processors, # we always apply the default surface processing. # This will ensure that the bare minimum criteria for surface data is met. @@ -170,6 +173,68 @@ def transform( return data +class ExternalAerodynamicsSurfaceTransformationHLPW(DataTransformation): + """Transforms surface data for HLPW and uses N_BF field for surface_area calculation""" + + def __init__( + self, + cfg: ProcessingConfig, + surface_variables: Optional[dict[str, str]] = None, + surface_processors: Optional[tuple[Callable, ...]] = None, + nbf_field_name: str = "N_BF", + ): + """Initialize the HLPW surface transformation. + + Args: + cfg: Processing configuration object. + surface_variables: Mapping of variable names for surface data. + surface_processors: Optional tuple of callable processors to apply. + nbf_field_name: Name of the field containing the area vector of cells + in the surface mesh. Defaults to "N_BF". + """ + super().__init__(cfg) + self.logger = logging.getLogger(__name__) + + self.surface_variables = surface_variables + self.surface_processors = surface_processors + self.nbf_field_name = nbf_field_name + self.constants = PhysicsConstantsCarAerodynamics() + + if surface_variables is None: + self.logger.error("Surface variables are empty!") + raise ValueError("Surface variables are empty!") + + self.logger.info( + f"Initializing ExternalAerodynamicsSurfaceTransformationHLPW with " + f"surface_variables: {surface_variables}, nbf_field_name: {nbf_field_name}, " + f"and surface_processors: {surface_processors}" + ) + self.logger.info( + "This will only be processed if the model_type is surface/combined." + ) + + def transform( + self, data: ExternalAerodynamicsExtractedDataInMemory + ) -> ExternalAerodynamicsExtractedDataInMemory: + """Transform surface data for HLPW using N_BF field. + The meaning of `N_BF` field is described above. + """ + + if data.surface_polydata is not None: + data = default_surface_processing_for_external_aerodynamics_hlpw( + data, self.surface_variables, self.nbf_field_name + ) + + if self.surface_processors is not None: + for processor in self.surface_processors: + data = processor(data) + + # Delete raw surface data to save memory + data.surface_polydata = None + + return data + + class ExternalAerodynamicsVolumeTransformation(DataTransformation): """Transforms volume data for External Aerodynamics model.""" @@ -182,7 +247,7 @@ def __init__( super().__init__(cfg) self.volume_variables = volume_variables self.volume_processors = volume_processors - self.constants = PhysicsConstants() + self.constants = PhysicsConstantsCarAerodynamics() self.logger = logging.getLogger(__name__) if volume_variables is None: @@ -199,9 +264,7 @@ def __init__( def transform( self, data: ExternalAerodynamicsExtractedDataInMemory ) -> ExternalAerodynamicsExtractedDataInMemory: - if data.volume_unstructured_grid is not None: - # Regardless of whether there are any additional volume processors, # we always apply the default volume processing. # This will ensure that the bare minimum criteria for volume data is met. @@ -220,6 +283,45 @@ def transform( return data +class ExternalAerodynamicsGlobalParamsTransformation(DataTransformation): + """Transforms global parameters values and references for External Aerodynamics model.""" + + def __init__( + self, + cfg: ProcessingConfig, + global_parameters: Optional[dict] = None, + global_params_processors: Optional[tuple[Callable, ...]] = None, + ): + super().__init__(cfg) + self.global_parameters = global_parameters + self.global_params_processors = global_params_processors + + if global_parameters is None: + raise ValueError( + "global_parameters is required but was not provided in config" + ) + + def transform( + self, data: ExternalAerodynamicsExtractedDataInMemory + ) -> ExternalAerodynamicsExtractedDataInMemory: + """Transform global_params data for External Aerodynamics model. + + Processes global parameter references from config and extracts values from simulation data. + """ + # Apply default processing to set up reference arrays from config + data = default_global_params_processing_for_external_aerodynamics( + data, self.global_parameters + ) + + # Apply any custom processors (e.g., extract values from simulation files) + # Pass global_parameters so processors know the types (vector vs scalar) + if self.global_params_processors is not None: + for processor in self.global_params_processors: + data = processor(data, self.global_parameters) + + return data + + class ExternalAerodynamicsZarrTransformation(DataTransformation): """Transforms External Aerodynamics data for Zarr storage format.""" @@ -306,6 +408,23 @@ def _prepare_array(self, array: np.ndarray) -> PreparedZarrArrayInfo: shards=shards, ) + def _prepare_array_no_compression(self, array: np.ndarray) -> PreparedZarrArrayInfo: + """Prepare small array for Zarr storage without compression. + + Used for small arrays like `global_params_reference` and `global_params_values` + """ + if array is None: + return None + + # Store entire array in a single chunk (no chunking for small arrays) + chunks = array.shape + + return PreparedZarrArrayInfo( + data=np.float32(array), + chunks=chunks, + compressor=None, # No compression + ) + def transform( self, data: ExternalAerodynamicsExtractedDataInMemory ) -> ExternalAerodynamicsZarrDataInMemory: @@ -328,6 +447,13 @@ def transform( stl_faces=self._prepare_array(data.stl_faces), stl_areas=self._prepare_array(data.stl_areas), metadata=data.metadata, + # `global_params_values` and `global_params_reference` are saved without compression + global_params_values=self._prepare_array_no_compression( + data.global_params_values + ), + global_params_reference=self._prepare_array_no_compression( + data.global_params_reference + ), surface_mesh_centers=self._prepare_array(data.surface_mesh_centers), surface_normals=self._prepare_array(data.surface_normals), surface_areas=self._prepare_array(data.surface_areas), diff --git a/examples/external_aerodynamics/external_aero_geometry_data_processors.py b/examples/external_aerodynamics/external_aero_geometry_data_processors.py index e5242ec..27c9f5e 100644 --- a/examples/external_aerodynamics/external_aero_geometry_data_processors.py +++ b/examples/external_aerodynamics/external_aero_geometry_data_processors.py @@ -114,7 +114,7 @@ def filter_geometry_invalid_faces( logger.info( f"Filtered {n_filtered_faces} invalid geometry faces " - f"({n_filtered_faces/n_total_faces*100:.2f}% of {n_total_faces} total faces):" + f"({n_filtered_faces / n_total_faces * 100:.2f}% of {n_total_faces} total faces):" ) logger.info(f" - {n_filtered_faces} faces with area <= {tolerance}") diff --git a/examples/external_aerodynamics/external_aero_global_params_data_processors.py b/examples/external_aerodynamics/external_aero_global_params_data_processors.py new file mode 100644 index 0000000..d618243 --- /dev/null +++ b/examples/external_aerodynamics/external_aero_global_params_data_processors.py @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +import numpy as np +from schemas import ExternalAerodynamicsExtractedDataInMemory + +logging.basicConfig( + format="%(asctime)s - Process %(process)d - %(levelname)s - %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) + + +def default_global_params_processing_for_external_aerodynamics( + data: ExternalAerodynamicsExtractedDataInMemory, + global_parameters: dict, +) -> ExternalAerodynamicsExtractedDataInMemory: + """Default global parameters processing for External Aerodynamics. + + Extracts and flattens global parameter references from config into a 1D numpy array. + Handles both vector and scalar parameter types. + + Args: + data: Container with simulation data and metadata + global_parameters: Dict from config with structure: + { + "param_name": { + "type": "vector" or "scalar", + "reference": value or list + } + } + + Returns: + Updated `data` with global_params_reference set + """ + + # Build dictionaries for types and reference values + global_params_types = { + name: params["type"] for name, params in global_parameters.items() + } + + global_params_reference_dict = { + name: params["reference"] for name, params in global_parameters.items() + } + + # Arrange global parameters reference in a list based on the type of the parameter + global_params_reference_list = [] + for name, param_type in global_params_types.items(): + if param_type == "vector": + global_params_reference_list.extend(global_params_reference_dict[name]) + elif param_type == "scalar": + global_params_reference_list.append(global_params_reference_dict[name]) + else: + raise ValueError( + f"Global parameter '{name}' has unsupported type '{param_type}'. " + f"Must be 'vector' or 'scalar'." + ) + + # Convert to numpy array and store in data container + data.global_params_reference = np.array( + global_params_reference_list, dtype=np.float32 + ) + + return data + + +def process_global_params( + data: ExternalAerodynamicsExtractedDataInMemory, + global_parameters: dict, +) -> ExternalAerodynamicsExtractedDataInMemory: + """Base processor for global parameters - to be overridden for specific datasets. + + This is a placeholder that should be replaced by dataset-specific implementations + (e.g., process_global_params_hlpw). + + By default, sets global_params_values equal to global_params_reference, + assuming simulation conditions match reference conditions. + + Args: + data: Container with simulation data and metadata + global_parameters: Dict from config with parameter definitions + + Returns: + Updated `data` with global_params_values set + """ + # Default behavior: assume simulation values match reference + data.global_params_values = data.global_params_reference.copy() + + return data + + +# ============================================================================ +# Case-Specific Processors +# ============================================================================ +# These functions demonstrate how to extract global_params_values from +# simulation data for specific datasets. Replace process_global_params above +# with these in your config for case-specific processing. + + +def process_global_params_hlpw( + data: ExternalAerodynamicsExtractedDataInMemory, + global_parameters: dict, +) -> ExternalAerodynamicsExtractedDataInMemory: + """Extract global parameters from HLPW simulation data. + + For HLPW, : + - AoA (Angle of Attack) varies per simulation and can be extracted from filename + + Args: + data: Container with simulation data and metadata + global_parameters: Dict from config with parameter definitions + + Returns: + Updated `data` with global_params_values extracted from simulation + """ + + # Build a dict of extracted values keyed by parameter name + extracted_values = {} + + # Extract AoA from filename (e.g., "geo_LHC001_AoA_16" -> 16.0) + filename = data.metadata.filename + if "AoA_" in filename: + # Extract string after "AoA_" + # Example: "geo_LHC001_AoA_16" -> "16" + # Example: "geo_LHC001_AoA_16_something" -> "16" + after_aoa = filename.split("AoA_")[1] + # Take everything up to next underscore or end of string + aoa_str = after_aoa.split("_")[0] if "_" in after_aoa else after_aoa + aoa = float(aoa_str) + extracted_values["AoA"] = aoa + logger.info(f"Extracted AoA={aoa} from filename: {filename}") + else: + raise ValueError(f"AoA pattern not found in filename '{filename}'.") + + # Build the flattened array using the same logic as reference processing + global_params_values_list = [] + for name, params in global_parameters.items(): + param_type = params["type"] + if name not in extracted_values: + raise ValueError( + f"Global parameter '{name}' was not extracted from simulation data." + ) + value = extracted_values[name] + + if param_type == "vector": + global_params_values_list.extend(value) + elif param_type == "scalar": + global_params_values_list.append(value) + else: + raise ValueError( + f"Global parameter '{name}' has unsupported type '{param_type}'. " + f"Must be 'vector' or 'scalar'." + ) + + data.global_params_values = np.array(global_params_values_list, dtype=np.float32) + + return data diff --git a/examples/external_aerodynamics/external_aero_surface_data_processors.py b/examples/external_aerodynamics/external_aero_surface_data_processors.py index bf046fa..f2d2a37 100644 --- a/examples/external_aerodynamics/external_aero_surface_data_processors.py +++ b/examples/external_aerodynamics/external_aero_surface_data_processors.py @@ -19,7 +19,10 @@ from typing import Optional import numpy as np -from constants import PhysicsConstants +from constants import ( + PhysicsConstantsCarAerodynamics, + PhysicsConstantsHLPW, +) from external_aero_utils import to_float32 from external_aero_validation_utils import ( check_field_statistics, @@ -55,6 +58,70 @@ def default_surface_processing_for_external_aerodynamics( return data +def default_surface_processing_for_external_aerodynamics_hlpw( + data: ExternalAerodynamicsExtractedDataInMemory, + surface_variables: list[str], + nbf_field_name: str = "N_BF", +) -> ExternalAerodynamicsExtractedDataInMemory: + """ + Default surface processing for HLPW dataset. + + Uses the N_BF flag field for computing normals and areas, + which is faster than computing them separately with PyVista for HLPW dataset. + + Important: Converts point data to cell data before processing, as HLPW + data may be stored at vertices rather than cell centers. + + Args: + data: External aerodynamics data with surface polydata + surface_variables: List of variable names to extract from surface data + nbf_field_name: Name of the area-weighted normal field (default: "N_BF") + + Returns: + Data with surface fields, mesh centers, normals, and areas extracted + """ + + # Convert point data to cell data (important for HLPW!) + # Data might be stored at vertices, need to move to cell centers + data.surface_polydata = data.surface_polydata.point_data_to_cell_data() + + # Extract surface fields (pressure, wall shear stress, etc.) + cell_data = (data.surface_polydata.cell_data[k] for k in surface_variables) + data.surface_fields = np.concatenate( + [d if d.ndim > 1 else d[:, np.newaxis] for d in cell_data], axis=-1 + ) + + # Extract mesh centers + data.surface_mesh_centers = np.array(data.surface_polydata.cell_centers().points) + + # Check if N_BF field exists - REQUIRED for HLPW + if nbf_field_name not in data.surface_polydata.cell_data: + logger.error( + f"Field '{nbf_field_name}' not found in surface cell_data. " + f"Available fields: {list(data.surface_polydata.cell_data.keys())}" + ) + raise ValueError( + f"Required field '{nbf_field_name}' not found in surface data. " + f"HLPW processing requires N_BF field for accurate normal and area computation." + ) + + # Use N_BF - HLPW-specific + # data.surface_polydata.cell_data['N_BF'] contains the area vector of each cell + surface_normals_area = np.array( + data.surface_polydata.cell_data[nbf_field_name] + ).astype(np.float32) + + # Compute areas as magnitude of N_BF + data.surface_areas = np.linalg.norm(surface_normals_area, axis=1).astype(np.float32) + + # Compute unit normals by normalizing N_BF + data.surface_normals = surface_normals_area / np.reshape( + data.surface_areas, (-1, 1) + ) + + return data + + def filter_invalid_surface_cells( data: ExternalAerodynamicsExtractedDataInMemory, tolerance: float = 1e-6, @@ -114,7 +181,7 @@ def filter_invalid_surface_cells( logger.info( f"Filtered {n_filtered} invalid surface cells " - f"({n_filtered/n_total*100:.2f}% of {n_total} total cells):" + f"({n_filtered / n_total * 100:.2f}% of {n_total} total cells):" ) logger.info(f" - {n_area_filtered} cells with area <= {tolerance}") logger.info(f" - {n_normal_filtered} cells with normal L2-norm <= {tolerance}") @@ -149,10 +216,13 @@ def normalize_surface_normals( def non_dimensionalize_surface_fields( data: ExternalAerodynamicsExtractedDataInMemory, - air_density: float = PhysicsConstants.AIR_DENSITY, - stream_velocity: float = PhysicsConstants.STREAM_VELOCITY, + air_density: float = PhysicsConstantsCarAerodynamics.AIR_DENSITY, + stream_velocity: float = PhysicsConstantsCarAerodynamics.STREAM_VELOCITY, ) -> ExternalAerodynamicsExtractedDataInMemory: - """Non-dimensionalize surface fields.""" + """ + Non-dimensionalize surface fields using PhysicsConstantsCarAerodynamics. + Note: Both DriveAerML and AhmedML use the same non-dimensional constants + """ if data.surface_fields.shape[0] == 0: logger.error(f"Surface fields are empty: {data.surface_fields}") @@ -166,9 +236,26 @@ def non_dimensionalize_surface_fields( # Non-dimensionalize surface fields data.surface_fields = data.surface_fields / (air_density * stream_velocity**2.0) - # Update metadata - data.metadata.air_density = air_density - data.metadata.stream_velocity = stream_velocity + return data + + +def non_dimensionalize_surface_fields_hlpw( + data: ExternalAerodynamicsExtractedDataInMemory, + pref: float = PhysicsConstantsHLPW.PREF, + tref: float = PhysicsConstantsHLPW.TREF, +) -> ExternalAerodynamicsExtractedDataInMemory: + """ + Non-dimensionalize surface fields using PhysicsConstantsHLPW. + """ + + if data.surface_fields is None or len(data.surface_fields) == 0: + logger.error(f"Surface fields are empty: {data.surface_fields}") + return data + # Non-dimensionalize temperature by TREF + data.surface_fields[:, 0:1] /= tref + + # Non-dimensionalize pressure and shear stress by PREF + data.surface_fields[:, 1:] /= pref return data diff --git a/examples/external_aerodynamics/external_aero_volume_data_processors.py b/examples/external_aerodynamics/external_aero_volume_data_processors.py index 6116533..9c71aa7 100644 --- a/examples/external_aerodynamics/external_aero_volume_data_processors.py +++ b/examples/external_aerodynamics/external_aero_volume_data_processors.py @@ -18,7 +18,10 @@ from typing import Optional import numpy as np -from constants import PhysicsConstants +from constants import ( + PhysicsConstantsCarAerodynamics, + PhysicsConstantsHLPW, +) from external_aero_utils import get_volume_data, to_float32 from external_aero_validation_utils import ( check_field_statistics, @@ -104,7 +107,7 @@ def filter_volume_invalid_cells( logger.info( f"Filtered {n_filtered} invalid volume cells " - f"({n_filtered/n_total*100:.2f}% of {n_total} total cells):" + f"({n_filtered / n_total * 100:.2f}% of {n_total} total cells):" ) logger.info(f" - {n_coords_filtered} cells with NaN in coordinates") logger.info(f" - {n_fields_filtered} cells with NaN/inf in fields") @@ -119,8 +122,8 @@ def filter_volume_invalid_cells( def non_dimensionalize_volume_fields( data: ExternalAerodynamicsExtractedDataInMemory, - air_density: float = PhysicsConstants.AIR_DENSITY, - stream_velocity: float = PhysicsConstants.STREAM_VELOCITY, + air_density: PhysicsConstantsCarAerodynamics.AIR_DENSITY, + stream_velocity: PhysicsConstantsCarAerodynamics.STREAM_VELOCITY, ) -> ExternalAerodynamicsExtractedDataInMemory: """Non-dimensionalize volume fields.""" @@ -143,9 +146,25 @@ def non_dimensionalize_volume_fields( stream_velocity * length_scale ) - # Update metadata - data.metadata.air_density = air_density - data.metadata.stream_velocity = stream_velocity + return data + + +def non_dimensionalize_volume_fields_hlpw( + data: ExternalAerodynamicsExtractedDataInMemory, + pref: PhysicsConstantsHLPW.PREF, + tref: PhysicsConstantsHLPW.TREF, + uref: PhysicsConstantsHLPW.UREF, +) -> ExternalAerodynamicsExtractedDataInMemory: + """Non-dimensionalize volume fields.""" + + # Pressure + data.volume_fields[:, :1] = data.volume_fields[:, :1] / pref + + # Temperature + data.volume_fields[:, 1:2] = data.volume_fields[:, 1:2] / tref + + # Velocity + data.volume_fields[:, 2:] = data.volume_fields[:, 2:] / uref return data diff --git a/examples/external_aerodynamics/paths.py b/examples/external_aerodynamics/paths.py index 652a1a3..82f10c0 100644 --- a/examples/external_aerodynamics/paths.py +++ b/examples/external_aerodynamics/paths.py @@ -140,6 +140,35 @@ def geometry_path(car_dir: Path) -> Path: return car_dir / f"ahmed_{index}.stl" +class HLPWPaths: + """Utility class for handling HLPW dataset file paths. + + HLPW file naming pattern: + - Directory: geo_LHC001_AoA_16 + - Geometry: geo_LHC001_AoA_16.stl + - Surface: boundary_geo_LHC001_AoA_16.vtu + - Volume: volume_geo_LHC001_AoA_16.vtu (NOT *_coarse.vtu) + """ + + @staticmethod + def geometry_path(car_dir: Path) -> Path: + """Returns geometry path for HLPW dataset.""" + dirname = car_dir.name + return car_dir / f"{dirname}.stl" + + @staticmethod + def surface_path(car_dir: Path) -> Path: + """Returns surface data path for HLPW dataset.""" + dirname = car_dir.name + return car_dir / f"boundary_{dirname}.vtu" + + @staticmethod + def volume_path(car_dir: Path) -> Path: + """Returns volume data path for HLPW dataset (NOT the coarse version).""" + dirname = car_dir.name + return car_dir / f"volume_{dirname}.vtu" + + def get_path_getter(kind: DatasetKind): """Returns path getter for a given dataset type.""" @@ -150,3 +179,5 @@ def get_path_getter(kind: DatasetKind): return DrivAerMLPaths case DatasetKind.DRIVESIM: return DriveSimPaths + case DatasetKind.HLPW: + return HLPWPaths diff --git a/examples/external_aerodynamics/schemas.py b/examples/external_aerodynamics/schemas.py index 80689ad..f905aca 100644 --- a/examples/external_aerodynamics/schemas.py +++ b/examples/external_aerodynamics/schemas.py @@ -30,15 +30,18 @@ class ExternalAerodynamicsMetadata: Version history: - 1.0: Initial version with expected metadata fields. + - 1.1: Added physics_constants dict for pipeline-specific constants. """ # Simulation identifiers filename: str dataset_type: ModelType - # Physical parameters - stream_velocity: Optional[float] = None - air_density: Optional[float] = None + # Physics constants - populated based on dataset kind from config. + # Keys/values vary by pipeline, e.g.: + # CarAerodynamics: {"air_density": 1.205, "stream_velocity": 30.0} + # HLPW: {"pref": 176.352, "uref": 2679.505, "tref": 518.67} + physics_constants: Optional[dict[str, float]] = None # Geometry bounds x_bound: Optional[tuple[float, float]] = None # xmin, xmax @@ -89,6 +92,15 @@ class ExternalAerodynamicsExtractedDataInMemory: volume_mesh_centers: Optional[np.ndarray] = None volume_fields: Optional[np.ndarray] = None + # Global parameters - simulation-wide global quantities used as conditioning inputs + # for ML models. These capture operating global conditions that affect the entire flow field. + + # global_params_values: Actual values of global parameters for this simulation + # Example: [stream_velocity, air_density, ...]. + # global_params_reference: Reference/normalization values for `global_params_values`, + global_params_values: Optional[np.ndarray] = None + global_params_reference: Optional[np.ndarray] = None + @dataclass(frozen=True) class PreparedZarrArrayInfo: @@ -111,6 +123,7 @@ class ExternalAerodynamicsZarrDataInMemory: Version history: - 1.0: Initial version with prepared arrays for Zarr storage + - 1.1: Added global_params_values and global_params_reference as top-level datasets """ # Metadata @@ -132,6 +145,12 @@ class ExternalAerodynamicsZarrDataInMemory: volume_mesh_centers: Optional[PreparedZarrArrayInfo] = None volume_fields: Optional[PreparedZarrArrayInfo] = None + # Global parameters + # Refer to the description provided in dataclass + # ExternalAerodynamicsExtractedDataInMemory above + global_params_values: Optional[PreparedZarrArrayInfo] = None + global_params_reference: Optional[PreparedZarrArrayInfo] = None + @dataclass(frozen=True) class ExternalAerodynamicsNumpyMetadata: @@ -141,8 +160,6 @@ class ExternalAerodynamicsNumpyMetadata: """ filename: str - stream_velocity: float - air_density: float @dataclass(frozen=True) @@ -172,3 +189,7 @@ class ExternalAerodynamicsNumpyDataInMemory: # Volume data volume_mesh_centers: Optional[np.ndarray] = None volume_fields: Optional[np.ndarray] = None + + # Global parameters + global_params_values: Optional[np.ndarray] = None + global_params_reference: Optional[np.ndarray] = None diff --git a/tests/test_examples/test_external_aerodynamics/test_constants.py b/tests/test_examples/test_external_aerodynamics/test_constants.py index d401271..509552e 100644 --- a/tests/test_examples/test_external_aerodynamics/test_constants.py +++ b/tests/test_examples/test_external_aerodynamics/test_constants.py @@ -17,13 +17,45 @@ import dataclasses import pytest -from constants import DatasetKind, ModelType, PhysicsConstants +from constants import ( + DatasetKind, + ModelType, + PhysicsConstantsCarAerodynamics, + PhysicsConstantsHLPW, + get_physics_constants, +) -def test_physics_constants_immutability(): - """Test that PhysicsConstants cannot be modified.""" +def test_physics_constants_car_aerodynamics_immutability(): + """Test that PhysicsConstantsCarAerodynamics cannot be modified.""" with pytest.raises(dataclasses.FrozenInstanceError): - PhysicsConstants().AIR_DENSITY = 2.0 + PhysicsConstantsCarAerodynamics().AIR_DENSITY = 2.0 + + +def test_physics_constants_hlpw_immutability(): + """Test that PhysicsConstantsHLPW cannot be modified.""" + with pytest.raises(dataclasses.FrozenInstanceError): + PhysicsConstantsHLPW().PREF = 200.0 + + +def test_get_physics_constants_drivaerml(): + """Test get_physics_constants returns correct dict for car aero datasets.""" + result = get_physics_constants(DatasetKind.DRIVAERML) + assert "air_density" in result + assert "stream_velocity" in result + assert result["air_density"] == PhysicsConstantsCarAerodynamics.AIR_DENSITY + assert result["stream_velocity"] == PhysicsConstantsCarAerodynamics.STREAM_VELOCITY + + +def test_get_physics_constants_hlpw(): + """Test get_physics_constants returns correct dict for HLPW dataset.""" + result = get_physics_constants(DatasetKind.HLPW) + assert "pref" in result + assert "uref" in result + assert "tref" in result + assert result["pref"] == PhysicsConstantsHLPW.PREF + assert result["uref"] == PhysicsConstantsHLPW.UREF + assert result["tref"] == PhysicsConstantsHLPW.TREF def test_model_type_validation(): diff --git a/tests/test_examples/test_external_aerodynamics/test_data_sources.py b/tests/test_examples/test_external_aerodynamics/test_data_sources.py index cf61103..15e879c 100644 --- a/tests/test_examples/test_external_aerodynamics/test_data_sources.py +++ b/tests/test_examples/test_external_aerodynamics/test_data_sources.py @@ -178,13 +178,13 @@ def test_drivaerml_write_numpy(self, temp_dir): test_data = ExternalAerodynamicsNumpyDataInMemory( metadata=ExternalAerodynamicsNumpyMetadata( filename="test_case", - stream_velocity=[30.0, 0.0, 0.0], - air_density=1.225, ), stl_coordinates=np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), stl_centers=np.array([[0.5, 0.5, 0.5]]), stl_faces=np.array([[0, 1, 2]]), stl_areas=np.array([1.0]), + global_params_values=np.array([30.0, 1.225], dtype=np.float32), + global_params_reference=np.array([30.0, 1.225], dtype=np.float32), ) source.write(test_data, "test_case") assert (temp_dir / "test_case.npz").exists() @@ -224,10 +224,9 @@ def test_drivaerml_write_zarr(self, temp_dir): compressor=compressor_for_test, ), metadata=ExternalAerodynamicsMetadata( - stream_velocity=[30.0, 0.0, 0.0], - air_density=1.225, filename="test_case", dataset_type=ModelType.COMBINED, + physics_constants={"stream_velocity": 30.0, "air_density": 1.225}, x_bound=(0.0, 1.0), y_bound=(0.0, 1.0), z_bound=(0.0, 1.0), @@ -236,6 +235,17 @@ def test_drivaerml_write_zarr(self, temp_dir): decimation_reduction=0.5, decimation_algo="decimate_pro", ), + # Global parameters (no compression for small 1xN arrays) + global_params_values=PreparedZarrArrayInfo( + data=np.array([30.0, 1.225], dtype=np.float32), + chunks=(2,), + compressor=None, + ), + global_params_reference=PreparedZarrArrayInfo( + data=np.array([30.0, 1.225], dtype=np.float32), + chunks=(2,), + compressor=None, + ), # Surface data surface_mesh_centers=PreparedZarrArrayInfo( data=np.array([[0.5, 0.5, 0.5]]), @@ -421,13 +431,13 @@ def test_write_numpy_uses_temp_then_rename(self, temp_dir): test_data = ExternalAerodynamicsNumpyDataInMemory( metadata=ExternalAerodynamicsNumpyMetadata( filename="test_case", - stream_velocity=[30.0, 0.0, 0.0], - air_density=1.225, ), stl_coordinates=np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), stl_centers=np.array([[0.5, 0.5, 0.5]]), stl_faces=np.array([[0, 1, 2]]), stl_areas=np.array([1.0]), + global_params_values=np.array([30.0, 1.225], dtype=np.float32), + global_params_reference=np.array([30.0, 1.225], dtype=np.float32), ) # Write should create final file, not temp file @@ -478,10 +488,20 @@ def test_write_zarr_uses_temp_then_rename(self, temp_dir): compressor=compressor_for_test, ), metadata=ExternalAerodynamicsMetadata( - stream_velocity=[30.0, 0.0, 0.0], - air_density=1.225, filename="test_case", dataset_type=ModelType.COMBINED, + physics_constants={"stream_velocity": 30.0, "air_density": 1.225}, + ), + # Global parameters (no compression for small 1xN arrays) + global_params_values=PreparedZarrArrayInfo( + data=np.array([30.0, 1.225], dtype=np.float32), + chunks=(2,), + compressor=None, + ), + global_params_reference=PreparedZarrArrayInfo( + data=np.array([30.0, 1.225], dtype=np.float32), + chunks=(2,), + compressor=None, ), ) diff --git a/tests/test_examples/test_external_aerodynamics/test_data_transformations.py b/tests/test_examples/test_external_aerodynamics/test_data_transformations.py index 79e1ea9..16fbcce 100644 --- a/tests/test_examples/test_external_aerodynamics/test_data_transformations.py +++ b/tests/test_examples/test_external_aerodynamics/test_data_transformations.py @@ -139,8 +139,7 @@ def sample_data_raw(temp_dir): metadata=ExternalAerodynamicsMetadata( filename="test_sample", dataset_type=ModelType.COMBINED, - stream_velocity=30.0, - air_density=1.205, + physics_constants={"stream_velocity": 30.0, "air_density": 1.205}, ), stl_polydata=stl_polydata, surface_polydata=surface_polydata, @@ -166,8 +165,7 @@ def sample_data_processed(): metadata=ExternalAerodynamicsMetadata( filename="run_1234", dataset_type=ModelType.COMBINED, - stream_velocity=30.0, - air_density=1.205, + physics_constants={"stream_velocity": 30.0, "air_density": 1.205}, x_bound=(0.0, 1.0), y_bound=(0.0, 1.0), z_bound=(0.0, 1.0), @@ -186,6 +184,8 @@ def sample_data_processed(): surface_fields=np.array([[1.0, 0.0, 0.0]], dtype=np.float64), volume_mesh_centers=np.array([[0, 0, 0]], dtype=np.float64), volume_fields=np.array([[1.0, 0.0, 0.0]], dtype=np.float64), + global_params_values=np.array([30.0, 1.205], dtype=np.float32), + global_params_reference=np.array([30.0, 1.205], dtype=np.float32), ) @@ -228,6 +228,15 @@ def test_transform(self, sample_data_processed): result.volume_fields, sample_data_processed.volume_fields ) + # Check global params fields + np.testing.assert_array_equal( + result.global_params_values, sample_data_processed.global_params_values + ) + np.testing.assert_array_equal( + result.global_params_reference, + sample_data_processed.global_params_reference, + ) + class TestExternalAerodynamicsZarrTransformation: """Test the ExternalAerodynamicsZarrTransformation class.""" @@ -279,6 +288,20 @@ def test_transform(self, sample_data_processed): assert result.volume_mesh_centers.chunks == (1, 3) assert result.volume_mesh_centers.compressor == transform.compressor + # Check global params fields (no compression for small 1xN arrays) + assert isinstance(result.global_params_values, PreparedZarrArrayInfo) + np.testing.assert_array_equal( + result.global_params_values.data, sample_data_processed.global_params_values + ) + assert result.global_params_values.compressor is None + + assert isinstance(result.global_params_reference, PreparedZarrArrayInfo) + np.testing.assert_array_equal( + result.global_params_reference.data, + sample_data_processed.global_params_reference, + ) + assert result.global_params_reference.compressor is None + def test_prepare_array(self): """Test array preparation for Zarr storage.""" config = ProcessingConfig(num_processes=1) @@ -425,8 +448,7 @@ def test_sharding_shape_for_large_arrays(self): metadata = ExternalAerodynamicsMetadata( filename="test_large", dataset_type=ModelType.COMBINED, - stream_velocity=30.0, - air_density=1.205, + physics_constants={"stream_velocity": 30.0, "air_density": 1.205}, ) large_data = ExternalAerodynamicsExtractedDataInMemory( @@ -879,14 +901,10 @@ def test_transform_with_default_processor_and_non_dimensionalization( # Check that the fields were non-dimensionalized assert result.surface_fields.shape == sample_data_raw.surface_fields.shape # Verify non-dimensionalization: result = original / (rho * V^2) - dynamic_pressure_factor = 1.00 * 10.00**2 # air_density # stream_velocity + dynamic_pressure_factor = 1.00 * 10.00**2 # air_density * stream_velocity^2 expected = np.array([[1.0, 0.5, 0.2, 101325.0]]) / dynamic_pressure_factor np.testing.assert_allclose(result.surface_fields, expected, rtol=1e-5) - # Verify that the metadata was updated - assert result.metadata.air_density == 1.00 - assert result.metadata.stream_velocity == 10.00 - def test_transform_with_default_processor_and_filter_invalid_surface_cells( self, sample_data_raw ): @@ -978,8 +996,12 @@ def test_validate_surface_sample_quality_with_valid_data(self, sample_data_raw): surface_processors=( partial( non_dimensionalize_surface_fields, - air_density=sample_data_raw.metadata.air_density, - stream_velocity=sample_data_raw.metadata.stream_velocity, + air_density=sample_data_raw.metadata.physics_constants[ + "air_density" + ], + stream_velocity=sample_data_raw.metadata.physics_constants[ + "stream_velocity" + ], ), ), ) @@ -1004,8 +1026,6 @@ def test_validate_surface_sample_quality_with_extreme_values(self): metadata = ExternalAerodynamicsMetadata( filename="test_extreme", dataset_type=ModelType.SURFACE, - stream_velocity=30.0, - air_density=1.205, ) data = ExternalAerodynamicsExtractedDataInMemory(metadata=metadata) @@ -1167,15 +1187,11 @@ def test_transform_with_default_processor_and_non_dimensionalization( / 10.00 # stream_velocity ) expected_pressure = np.array([[101325], [101300], [101320], [101310]]) / ( - 1.00 * 10.00**2 # air_density # stream_velocity + 1.00 * 10.00**2 # air_density * stream_velocity^2 ) expected = np.concatenate([expected_velocity, expected_pressure], axis=-1) np.testing.assert_allclose(result.volume_fields, expected, rtol=1e-5) - # Verify that the metadata was updated - assert result.metadata.air_density == 1.00 - assert result.metadata.stream_velocity == 10.00 - def test_transform_with_default_processor_and_shuffle_data(self, sample_data_raw): """Test volume transformation with shuffle on top of the default processor.""" config = ProcessingConfig(num_processes=1) @@ -1305,8 +1321,12 @@ def test_transform_with_default_processor_and_validate_volume_sample_quality_wit volume_processors=( partial( non_dimensionalize_volume_fields, - air_density=sample_data_raw.metadata.air_density, - stream_velocity=sample_data_raw.metadata.stream_velocity, + air_density=sample_data_raw.metadata.physics_constants[ + "air_density" + ], + stream_velocity=sample_data_raw.metadata.physics_constants[ + "stream_velocity" + ], ), ), ) @@ -1337,8 +1357,6 @@ def test_transform_with_default_processor_and_validate_volume_sample_quality_wit metadata = ExternalAerodynamicsMetadata( filename="test_outliers_velocity", dataset_type=ModelType.VOLUME, - stream_velocity=30.0, - air_density=1.205, ) data = ExternalAerodynamicsExtractedDataInMemory(metadata=metadata) @@ -1371,8 +1389,6 @@ def test_validate_volume_sample_quality_with_extreme_pressure(self): metadata = ExternalAerodynamicsMetadata( filename="test_extreme", dataset_type=ModelType.VOLUME, - stream_velocity=30.0, - air_density=1.205, ) data = ExternalAerodynamicsExtractedDataInMemory(metadata=metadata) diff --git a/tests/test_examples/test_external_aerodynamics/test_global_params_processors.py b/tests/test_examples/test_external_aerodynamics/test_global_params_processors.py new file mode 100644 index 0000000..0de16af --- /dev/null +++ b/tests/test_examples/test_external_aerodynamics/test_global_params_processors.py @@ -0,0 +1,208 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import numpy as np +import pytest +from constants import ModelType +from external_aero_global_params_data_processors import ( + default_global_params_processing_for_external_aerodynamics, + process_global_params, + process_global_params_hlpw, +) +from schemas import ( + ExternalAerodynamicsExtractedDataInMemory, + ExternalAerodynamicsMetadata, +) + + +@pytest.fixture +def sample_metadata(): + """Create sample metadata for testing.""" + return ExternalAerodynamicsMetadata( + filename="test_sample", + dataset_type=ModelType.COMBINED, + ) + + +@pytest.fixture +def sample_data(sample_metadata): + """Create sample extracted data container for testing.""" + return ExternalAerodynamicsExtractedDataInMemory( + metadata=sample_metadata, + ) + + +class TestDefaultGlobalParamsProcessing: + """Test the default_global_params_processing_for_external_aerodynamics function.""" + + def test_scalar_parameters(self, sample_data): + """Test processing with scalar parameters only.""" + global_parameters = { + "air_density": {"type": "scalar", "reference": 1.205}, + "pressure": {"type": "scalar", "reference": 101325.0}, + } + + result = default_global_params_processing_for_external_aerodynamics( + sample_data, global_parameters + ) + + # Check that global_params_reference is set + assert result.global_params_reference is not None + + # Check dtype is float32 + assert result.global_params_reference.dtype == np.float32 + + # Check values are flattened correctly: [1.205, 101325.0] + expected = np.array([1.205, 101325.0], dtype=np.float32) + np.testing.assert_array_almost_equal(result.global_params_reference, expected) + + def test_vector_parameters(self, sample_data): + """Test processing with vector parameters only. + + Vectors can have variable length: + - [30.0] represents velocity in x-direction only + - [30.0, 30.0] represents 2D velocity + - [30.0, 30.0, 30.0] represents full 3D velocity + """ + global_parameters = { + "inlet_velocity_1d": {"type": "vector", "reference": [30.0]}, + "inlet_velocity_2d": {"type": "vector", "reference": [25.0, 10.0]}, + } + + result = default_global_params_processing_for_external_aerodynamics( + sample_data, global_parameters + ) + + # Check dtype is float32 + assert result.global_params_reference.dtype == np.float32 + + # Vectors are flattened in order: [30.0] + [25.0, 10.0] = [30.0, 25.0, 10.0] + expected = np.array([30.0, 25.0, 10.0], dtype=np.float32) + np.testing.assert_array_almost_equal(result.global_params_reference, expected) + + def test_mixed_parameters(self, sample_data): + """Test processing with mixed scalar and vector parameters. + + Mirrors drivaerml.yaml structure: + - inlet_velocity: vector + - air_density: scalar + - pressure: scalar + """ + global_parameters = { + "inlet_velocity": {"type": "vector", "reference": [30.0]}, + "air_density": {"type": "scalar", "reference": 1.205}, + "pressure": {"type": "scalar", "reference": 101325.0}, + } + + result = default_global_params_processing_for_external_aerodynamics( + sample_data, global_parameters + ) + + # Check dtype is float32 + assert result.global_params_reference.dtype == np.float32 + + # Flattened in order: [30.0] + 1.205 + 101325.0 = [30.0, 1.205, 101325.0] + expected = np.array([30.0, 1.205, 101325.0], dtype=np.float32) + np.testing.assert_array_almost_equal(result.global_params_reference, expected) + + def test_invalid_type_raises_error(self, sample_data): + """Test that unsupported parameter type raises ValueError.""" + global_parameters = { + "temperature": {"type": "tensor", "reference": [[1, 2], [3, 4]]}, + } + + with pytest.raises(ValueError, match="unsupported type"): + default_global_params_processing_for_external_aerodynamics( + sample_data, global_parameters + ) + + +class TestProcessGlobalParams: + """Test the process_global_params function.""" + + def test_copies_reference_to_values(self, sample_data): + """Test that process_global_params copies reference to values. + + This is the default behavior when simulation conditions match reference. + """ + global_parameters = { + "inlet_velocity": {"type": "vector", "reference": [30.0]}, + "air_density": {"type": "scalar", "reference": 1.205}, + } + + # First, set up global_params_reference + data = default_global_params_processing_for_external_aerodynamics( + sample_data, global_parameters + ) + + # Then apply process_global_params + result = process_global_params(data, global_parameters) + + # Check that global_params_values is set + assert result.global_params_values is not None + + # Check that values equal reference + np.testing.assert_array_equal( + result.global_params_values, result.global_params_reference + ) + + # Verify it's a copy, not the same object + assert result.global_params_values is not result.global_params_reference + + +class TestProcessGlobalParamsHLPW: + """Test the process_global_params_hlpw function.""" + + @pytest.mark.parametrize( + "filename, expected_aoa", + [ + ("geo_LHC001_AoA_16", 16.0), # Two-digit AoA + ("geo_LHC002_AoA_4", 4.0), # Single-digit AoA + ], + ) + def test_extracts_aoa_from_filename(self, filename, expected_aoa): + """Test that AoA is correctly extracted from HLPW filename patterns.""" + metadata = ExternalAerodynamicsMetadata( + filename=filename, + dataset_type=ModelType.COMBINED, + ) + data = ExternalAerodynamicsExtractedDataInMemory(metadata=metadata) + + global_parameters = { + "AoA": {"type": "scalar", "reference": 22.0}, + } + + result = process_global_params_hlpw(data, global_parameters) + + # Check extracted value matches expected (not the reference 22.0) + assert result.global_params_values is not None + expected = np.array([expected_aoa], dtype=np.float32) + np.testing.assert_array_equal(result.global_params_values, expected) + + def test_missing_aoa_pattern_raises_error(self): + """Test that missing AoA pattern in filename raises ValueError.""" + metadata = ExternalAerodynamicsMetadata( + filename="geo_LHC001_no_angle_info", + dataset_type=ModelType.COMBINED, + ) + data = ExternalAerodynamicsExtractedDataInMemory(metadata=metadata) + + global_parameters = { + "AoA": {"type": "scalar", "reference": 22.0}, + } + + with pytest.raises(ValueError, match="AoA pattern not found"): + process_global_params_hlpw(data, global_parameters)