diff --git a/flow360/component/project.py b/flow360/component/project.py index 30b138bf2..5252fa8ad 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -56,6 +56,7 @@ SurfaceMeshFile, VolumeMeshFile, formatting_validation_errors, + formatting_validation_warnings, get_short_asset_id, parse_datetime, wrapstring, @@ -1510,12 +1511,18 @@ def _run( use_geometry_AI=use_geometry_AI, ) - params, errors = validate_params_with_context( + params, errors, warnings = validate_params_with_context( params=params, root_item_type=self.metadata.root_item_type.value, up_to=target._cloud_resource_type_name, ) + if warnings: + log.warning( + f"Validation warnings found during local validation: " + f"{formatting_validation_warnings(warnings=warnings)}" + ) + if errors is not None: log.error( f"Validation error found during local validation: {formatting_validation_errors(errors=errors)}" diff --git a/flow360/component/project_utils.py b/flow360/component/project_utils.py index 07806803c..5c341ac1a 100644 --- a/flow360/component/project_utils.py +++ b/flow360/component/project_utils.py @@ -667,14 +667,16 @@ def validate_params_with_context(params, root_item_type, up_to): root_item_type=root_item_type, up_to=up_to ) - params, errors, _ = services.validate_model( - params_as_dict=params.model_dump(mode="json", exclude_none=True), + params_as_dict = params.model_dump(mode="json", exclude_none=True) + + params, errors, warnings = services.validate_model( + params_as_dict=params_as_dict, validated_by=services.ValidationCalledBy.LOCAL, root_item_type=root_item_type, validation_level=validation_level, ) - return params, errors + return params, errors, warnings def _get_imported_surface_file_names(params, basename_only=False): diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index f617013e0..a1b2a707e 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -29,6 +29,7 @@ from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, + add_validation_warning, contextual_field_validator, contextual_model_validator, get_validation_info, @@ -468,6 +469,7 @@ def _validate_domain_type_bbox(cls, value): if ( value not in ("half_body_positive_y", "half_body_negative_y") or validation_info.global_bounding_box is None + or validation_info.planar_face_tolerance is None ): return value @@ -500,11 +502,15 @@ def _validate_domain_type_bbox(cls, value): if y_max <= tolerance: return value - raise ValueError( + message = ( f"The model does not cross the symmetry plane (Y=0) with tolerance {tolerance:.2g}. " f"Model Y range: [{y_min:.2g}, {y_max:.2g}]. " "Please check if `domain_type` is set correctly." ) + if getattr(validation_info, "entity_transformation_detected", False): + add_validation_warning(message) + return value + raise ValueError(message) class AutomatedFarfield(_FarfieldBase): diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index d36e5931a..f812d98db 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -4,7 +4,17 @@ import json import os from enum import Enum -from typing import Any, Collection, Dict, Iterable, Literal, Optional, Tuple, Union +from typing import ( + Any, + Collection, + Dict, + Iterable, + List, + Literal, + Optional, + Tuple, + Union, +) import pydantic as pd from pydantic_core import ErrorDetails @@ -428,7 +438,7 @@ def validate_model( # pylint: disable=too-many-locals validation_level: Union[ Literal["SurfaceMesh", "VolumeMesh", "Case", "All"], list, None ] = ALL, # Fix implicit string concatenation -) -> Tuple[Optional[SimulationParams], Optional[list], Optional[list]]: +) -> Tuple[Optional[SimulationParams], Optional[list], List[Dict[str, Any]]]: """ Validate a params dict against the pydantic model. @@ -449,8 +459,8 @@ def validate_model( # pylint: disable=too-many-locals The validated parameters if successful, otherwise None. validation_errors : list or None A list of validation errors if any occurred. - validation_warnings : list or None - A list of validation warnings if any occurred. + validation_warnings : list + A list of validation warnings (empty list if no warnings were recorded). """ def handle_multi_constructor_model(params_as_dict: dict) -> dict: @@ -509,8 +519,9 @@ def dict_preprocessing(params_as_dict: dict) -> dict: return params_as_dict validation_errors = None - validation_warnings = None + validation_warnings: List[Dict[str, Any]] = [] validated_param = None + validation_context: Optional[ValidationContext] = None params_as_dict = clean_unrelated_setting_from_params_dict(params_as_dict, root_item_type) @@ -541,7 +552,8 @@ def dict_preprocessing(params_as_dict: dict) -> dict: with ValidationContext( levels=validation_levels_to_use, info=validation_info, - ): + ) as context: + validation_context = context unit_system = updated_param_as_dict.get("unit_system") with UnitSystem.from_dict( # pylint: disable=not-context-manager verbose=False, **unit_system @@ -562,6 +574,9 @@ def dict_preprocessing(params_as_dict: dict) -> dict: validation_errors = err.errors() except Exception as err: # pylint: disable=broad-exception-caught validation_errors = handle_generic_exception(err, validation_errors) + finally: + if validation_context is not None: + validation_warnings = list(validation_context.validation_warnings) if validation_errors is not None: validation_errors = validate_error_locations(validation_errors, params_as_dict) diff --git a/flow360/component/simulation/validation/validation_context.py b/flow360/component/simulation/validation/validation_context.py index 404963323..ed0698765 100644 --- a/flow360/component/simulation/validation/validation_context.py +++ b/flow360/component/simulation/validation/validation_context.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-lines """ Module for validation context handling in the simulation component of Flow360. @@ -104,6 +105,7 @@ def __init__(self, param_as_dict: dict): _validation_level_ctx = contextvars.ContextVar("validation_levels", default=None) _validation_info_ctx = contextvars.ContextVar("validation_info", default=None) +_validation_warnings_ctx = contextvars.ContextVar("validation_warnings", default=None) class ParamsValidationInfo: # pylint:disable=too-few-public-methods,too-many-instance-attributes @@ -587,20 +589,25 @@ def __init__(self, levels: Union[str, List[str]], info: ParamsValidationInfo = N ): self.levels = levels self.level_token = None + self.validation_warnings = [] else: raise ValueError(f"Invalid validation level: {levels}") self.info = info self.info_token = None + self.warnings_token = None def __enter__(self): self.level_token = _validation_level_ctx.set(self.levels) self.info_token = _validation_info_ctx.set(self.info) + self.warnings_token = _validation_warnings_ctx.set(self.validation_warnings) return self def __exit__(self, exc_type, exc_val, exc_tb): _validation_level_ctx.reset(self.level_token) _validation_info_ctx.reset(self.info_token) + if self.warnings_token is not None: + _validation_warnings_ctx.reset(self.warnings_token) def get_validation_levels() -> list: @@ -623,6 +630,38 @@ def get_validation_info() -> ParamsValidationInfo: return _validation_info_ctx.get() +def add_validation_warning(message: str) -> None: + """ + Append a validation warning message to the active ValidationContext. + + Parameters + ---------- + message : str + Warning message to record. Converted to string if needed. + + Notes + ----- + No action is taken if there is no active ValidationContext. + """ + warnings_list = _validation_warnings_ctx.get() + if warnings_list is None: + return + message_str = str(message) + if any( + isinstance(existing, dict) and existing.get("msg") == message_str + for existing in warnings_list + ): + return + warnings_list.append( + { + "loc": (), + "msg": message_str, + "type": "value_error", + "ctx": {}, + } + ) + + # pylint: disable=invalid-name def ContextField( default=None, *, context: Literal["SurfaceMesh", "VolumeMesh", "Case"] = None, **kwargs diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 8da473c36..065d1c143 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -50,6 +50,7 @@ ALL, CASE, ParamsValidationInfo, + add_validation_warning, get_validation_levels, ) from flow360.component.simulation.validation.validation_utils import EntityUsageMap @@ -358,30 +359,35 @@ def _validate_cht_has_heat_transfer(params): return params -def _check_complete_boundary_condition_and_unknown_surface( - params, param_info -): # pylint:disable=too-many-branches, too-many-locals,too-many-statements - ## Step 1: Get all boundaries patches from asset cache - current_lvls = get_validation_levels() if get_validation_levels() else [] - if all(level not in current_lvls for level in (ALL, CASE)): - return params +def _collect_volume_zones(params) -> list: + """Collect volume zones from meshing config in a schema-compatible way.""" + if isinstance(params.meshing, MeshingParams): + return params.meshing.volume_zones or [] + if isinstance(params.meshing, ModularMeshingWorkflow): + return params.meshing.zones or [] + return [] + - asset_boundary_entities = params.private_attribute_asset_cache.boundaries # Persistent ones +def _collect_asset_boundary_entities(params, param_info: ParamsValidationInfo) -> list: + """Collect boundary entities that should be considered valid for BC completeness checks. - # Filter out the ones that will be deleted by mesher + This includes: + - Persistent boundaries from asset cache + - Farfield-related ghost boundaries, conditional on farfield method + - Wind tunnel ghost surfaces (when applicable) + """ + # IMPORTANT: + # AssetCache.boundaries may return a direct reference into EntityInfo internal lists + # (e.g. GeometryEntityInfo.grouped_faces[*]). Always copy before appending to avoid + # mutating entity_info and corrupting subsequent serialization/validation. + asset_boundary_entities = list(params.private_attribute_asset_cache.boundaries or []) farfield_method = params.meshing.farfield_method if params.meshing else None - volume_zones = [] - if isinstance(params.meshing, MeshingParams): - volume_zones = params.meshing.volume_zones - if isinstance(params.meshing, ModularMeshingWorkflow): - volume_zones = params.meshing.zones - if farfield_method: - if param_info.entity_transformation_detected: - # If transformed then `_will_be_deleted_by_mesher()` will no longer be accurate - # since we do not know the final bounding box for each surface and global model. - return params + if not farfield_method: + return asset_boundary_entities + # Filter out the ones that will be deleted by mesher (only when reliable) + if not param_info.entity_transformation_detected: # pylint:disable=protected-access,duplicate-code asset_boundary_entities = [ item @@ -398,56 +404,68 @@ def _check_complete_boundary_condition_and_unknown_surface( is False ] - if farfield_method == "auto": - asset_boundary_entities += [ - item - for item in params.private_attribute_asset_cache.project_entity_info.ghost_entities - if item.name in ("farfield", "symmetric") and item.exists(param_info) - ] - elif farfield_method in ("quasi-3d", "quasi-3d-periodic"): - asset_boundary_entities += [ - item - for item in params.private_attribute_asset_cache.project_entity_info.ghost_entities - if item.name in ("farfield", "symmetric-1", "symmetric-2") - ] - elif farfield_method in ("user-defined", "wind-tunnel"): - if param_info.will_generate_forced_symmetry_plane(): - asset_boundary_entities += [ - item - for item in params.private_attribute_asset_cache.project_entity_info.ghost_entities - if item.name == "symmetric" - ] - if farfield_method == "wind-tunnel": - asset_boundary_entities += WindTunnelFarfield._get_valid_ghost_surfaces( - params.meshing.volume_zones[0].floor_type.type_name, - params.meshing.volume_zones[0].domain_type, - ) + ghost_entities = getattr( + params.private_attribute_asset_cache.project_entity_info, "ghost_entities", [] + ) + + if farfield_method == "auto": + asset_boundary_entities += [ + item + for item in ghost_entities + if item.name in ("farfield", "symmetric") + and (param_info.entity_transformation_detected or item.exists(param_info)) + ] + elif farfield_method in ("quasi-3d", "quasi-3d-periodic"): + asset_boundary_entities += [ + item + for item in ghost_entities + if item.name in ("farfield", "symmetric-1", "symmetric-2") + ] + elif farfield_method in ("user-defined", "wind-tunnel"): + if param_info.will_generate_forced_symmetry_plane(): + asset_boundary_entities += [item for item in ghost_entities if item.name == "symmetric"] + if farfield_method == "wind-tunnel": + # pylint: disable=protected-access + asset_boundary_entities += WindTunnelFarfield._get_valid_ghost_surfaces( + params.meshing.volume_zones[0].floor_type.type_name, + params.meshing.volume_zones[0].domain_type, + ) + return asset_boundary_entities + + +def _collect_zone_zone_interfaces( + *, param_info: ParamsValidationInfo, volume_zones: list +) -> tuple[set, bool]: + """Collect potential zone-zone interfaces and snappy multizone flag.""" snappy_multizone = False - potential_zone_zone_interfaces = set() - if param_info.farfield_method == "user-defined": - for zones in volume_zones: - # Support new CustomZones container - if not isinstance(zones, CustomZones): - continue - for custom_volume in zones.entities.stored_entities: - if isinstance(custom_volume, CustomVolume): - expanded = param_info.expand_entity_list(custom_volume.boundaries) - for boundary in expanded: - potential_zone_zone_interfaces.add(boundary.name) - if isinstance(custom_volume, SeedpointVolume): - ## disable missing boundaries with snappy multizone - snappy_multizone = True + potential_zone_zone_interfaces: set[str] = set() - if asset_boundary_entities is None or asset_boundary_entities == []: - raise ValueError("[Internal] Failed to retrieve asset boundaries") + if param_info.farfield_method != "user-defined": + return potential_zone_zone_interfaces, snappy_multizone - asset_boundaries = {boundary.name for boundary in asset_boundary_entities} - ## Step 2: Collect all used boundaries from the models + for zones in volume_zones: + # Support new CustomZones container + if not isinstance(zones, CustomZones): + continue + for custom_volume in zones.entities.stored_entities: + if isinstance(custom_volume, CustomVolume): + expanded = param_info.expand_entity_list(custom_volume.boundaries) + for boundary in expanded: + potential_zone_zone_interfaces.add(boundary.name) + if isinstance(custom_volume, SeedpointVolume): + # Disable missing boundaries with snappy multizone + snappy_multizone = True + + return potential_zone_zone_interfaces, snappy_multizone + + +def _collect_used_boundary_names(params, param_info: ParamsValidationInfo) -> set: + """Collect all boundary names referenced in Surface BC models.""" if len(params.models) == 1 and isinstance(params.models[0], Fluid): raise ValueError("No boundary conditions are defined in the `models` section.") - used_boundaries = set() + used_boundaries: set[str] = set() for model in params.models: if not isinstance(model, get_args(SurfaceModelTypes)): @@ -455,7 +473,6 @@ def _check_complete_boundary_condition_and_unknown_surface( if isinstance(model, PorousJump): continue - entities = [] # pylint: disable=protected-access if hasattr(model, "entities"): entities = param_info.expand_entity_list(model.entities) @@ -463,20 +480,37 @@ def _check_complete_boundary_condition_and_unknown_surface( entities = [ pair for surface_pair in model.entity_pairs.items for pair in surface_pair.pair ] + else: + entities = [] for entity in entities: used_boundaries.add(entity.name) - ## Step 3: Use set operations to find missing and unknown boundaries + return used_boundaries + + +def _validate_boundary_completeness( + *, + asset_boundaries: set, + used_boundaries: set, + potential_zone_zone_interfaces: set, + snappy_multizone: bool, + entity_transformation_detected: bool, +) -> None: + """Validate missing/unknown boundary references with error/warning policy.""" missing_boundaries = asset_boundaries - used_boundaries - potential_zone_zone_interfaces unknown_boundaries = used_boundaries - asset_boundaries if missing_boundaries and not snappy_multizone: missing_list = ", ".join(sorted(missing_boundaries)) - raise ValueError( + message = ( f"The following boundaries do not have a boundary condition: {missing_list}. " "Please add them to a boundary condition model in the `models` section." ) + if entity_transformation_detected: + add_validation_warning(message) + else: + raise ValueError(message) if unknown_boundaries: unknown_list = ", ".join(sorted(unknown_boundaries)) @@ -485,6 +519,41 @@ def _check_complete_boundary_condition_and_unknown_surface( f"entities but appear in the `models` section: {unknown_list}." ) + +def _check_complete_boundary_condition_and_unknown_surface( + params, param_info +): # pylint:disable=too-many-branches, too-many-locals,too-many-statements + # Step 1: Determine whether this check should run + current_lvls = get_validation_levels() if get_validation_levels() else [] + if all(level not in current_lvls for level in (ALL, CASE)): + return params + + # Step 2: Collect asset boundaries + asset_boundary_entities = _collect_asset_boundary_entities(params, param_info) + if asset_boundary_entities is None or asset_boundary_entities == []: + raise ValueError("[Internal] Failed to retrieve asset boundaries") + + asset_boundaries = {boundary.name for boundary in asset_boundary_entities} + mirror_status = getattr(params.private_attribute_asset_cache, "mirror_status", None) + if mirror_status is not None and getattr(mirror_status, "mirrored_surfaces", None): + asset_boundaries |= {entity.name for entity in mirror_status.mirrored_surfaces} + + # Step 3: Compute special-case interfaces and used boundaries + volume_zones = _collect_volume_zones(params) + potential_zone_zone_interfaces, snappy_multizone = _collect_zone_zone_interfaces( + param_info=param_info, volume_zones=volume_zones + ) + used_boundaries = _collect_used_boundary_names(params, param_info) + + # Step 4: Validate set differences with policy + _validate_boundary_completeness( + asset_boundaries=asset_boundaries, + used_boundaries=used_boundaries, + potential_zone_zone_interfaces=potential_zone_zone_interfaces, + snappy_multizone=snappy_multizone, + entity_transformation_detected=param_info.entity_transformation_detected, + ) + return params diff --git a/flow360/component/utils.py b/flow360/component/utils.py index d07b24f3e..f8f014f0c 100644 --- a/flow360/component/utils.py +++ b/flow360/component/utils.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-lines """ Utility functions """ @@ -984,6 +985,33 @@ def formatting_validation_errors(errors): return error_msg +def formatting_validation_warnings(warnings: List) -> str: + """ + Format validation warnings to a human readable string. + + Parameters + ---------- + warnings : List + Collection of warning entries. Each entry can be a string message or a dict + with keys: loc/msg/type/ctx. + + Returns + ------- + str + Formatted warning output with numbering per line. + """ + if not warnings: + return "" + + warning_msg = "" + for idx, warning in enumerate(warnings, start=1): + if isinstance(warning, dict): + warning_msg += f"\n\t({idx}) {warning.get('msg', str(warning))}" + else: + warning_msg += f"\n\t({idx}) {warning}" + return warning_msg + + def check_existence_of_one_file(file_path: str): """Check existence of a file""" diff --git a/tests/simulation/draft_context/test_draft_context_end_to_end_submit_roundtrip.py b/tests/simulation/draft_context/test_draft_context_end_to_end_submit_roundtrip.py index 61df253c3..544938e98 100644 --- a/tests/simulation/draft_context/test_draft_context_end_to_end_submit_roundtrip.py +++ b/tests/simulation/draft_context/test_draft_context_end_to_end_submit_roundtrip.py @@ -127,10 +127,11 @@ def test_draft_end_to_end_selector_and_draft_entity_roundtrip(mock_surface_mesh, assert wall_model.entities.stored_entities == [] # Local validation stage (validate_model should pass). - params, errors = validate_params_with_context( + params, errors, warnings = validate_params_with_context( params=params, root_item_type="SurfaceMesh", up_to="VolumeMesh" ) assert errors is None + assert warnings == [] # Mimic upload by calling Draft.update_simulation_params() but without any Draft submit API. uploaded_payload: dict = {} diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index da4d3d858..eaa32e87f 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -111,6 +111,7 @@ GhostSphere, GhostSurface, MirroredGeometryBodyGroup, + MirroredSurface, SeedpointVolume, Surface, SurfacePrivateAttributes, @@ -3000,3 +3001,147 @@ def test_mirroring_requires_geometry_ai(): # No error about mirroring assert errors is None or not any("Mirroring" in str(e) for e in errors) + + +def test_mirror_missing_boundary_condition_downgraded_to_warning(): + """Missing BCs should be downgraded to warnings when mirroring/transformations are detected.""" + mirror_plane = MirrorPlane( + name="test_plane", + normal=(0, 1, 0), + center=[0, 0, 0] * u.m, + private_attribute_id="mp-1", + ) + + front = Surface(name="front", private_attribute_is_interface=False, private_attribute_id="s-1") + mirrored_front = MirroredSurface( + name="front_", + surface_id="s-1", + mirror_plane_id="mp-1", + private_attribute_id="ms-1", + ) + + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=VolumeMeshEntityInfo(boundaries=[front]), + mirror_status=MirrorStatus( + mirror_planes=[mirror_plane], + mirrored_geometry_body_groups=[], + mirrored_surfaces=[mirrored_front], + ), + ) + + with SI_unit_system: + params = SimulationParams( + models=[Fluid(), Wall(entities=[front])], + private_attribute_asset_cache=asset_cache, + ) + + _validated, errors, warnings = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="VolumeMesh", + validation_level="All", + ) + + assert errors is None + assert any("front_" in w.get("msg", "") for w in warnings), warnings + + +def test_mirror_unknown_boundary_still_raises_error(): + """Unknown boundary names should remain hard errors even when mirroring is detected.""" + mirror_plane = MirrorPlane( + name="test_plane", + normal=(0, 1, 0), + center=[0, 0, 0] * u.m, + private_attribute_id="mp-1", + ) + + front = Surface(name="front", private_attribute_is_interface=False, private_attribute_id="s-1") + mirrored_front = MirroredSurface( + name="front_", + surface_id="s-1", + mirror_plane_id="mp-1", + private_attribute_id="ms-1", + ) + + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=VolumeMeshEntityInfo(boundaries=[front]), + mirror_status=MirrorStatus( + mirror_planes=[mirror_plane], + mirrored_geometry_body_groups=[], + mirrored_surfaces=[mirrored_front], + ), + ) + + with SI_unit_system: + params = SimulationParams( + models=[ + Fluid(), + # Use mirrored surface (should be known once we include it in the valid boundary pool) + Wall(entities=[mirrored_front, Surface(name="typo_surface")]), + ], + private_attribute_asset_cache=asset_cache, + ) + + _validated, errors, _warnings = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="VolumeMesh", + validation_level="All", + ) + + assert errors is not None + assert any("typo_surface" in str(e) for e in errors) + + +def test_domain_type_bbox_mismatch_downgraded_to_warning_when_transformed(): + """domain_type bbox mismatch should be a warning when transformations are detected.""" + cs_status = CoordinateSystemStatus( + coordinate_systems=[CoordinateSystem(name="cs", private_attribute_id="cs-1")], + parents=[], + assignments=[CoordinateSystemAssignmentGroup(coordinate_system_id="cs-1", entities=[])], + ) + + # Global bbox fully on -Y side; choosing half_body_positive_y should normally raise. + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=SurfaceMeshEntityInfo( + boundaries=[], + global_bounding_box=[[-1, -10, -1], [1, -5, 1]], + ), + coordinate_system_status=cs_status, + ) + + auto_farfield = AutomatedFarfield(name="my_farfield", domain_type="half_body_positive_y") + + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-10, + geometry_accuracy=1e-10 * u.m, + surface_max_edge_length=1e-10, + ), + volume_zones=[auto_farfield], + ), + private_attribute_asset_cache=asset_cache, + ) + + _validated, errors, warnings = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type=None, + validation_level=None, + ) + + assert errors is None + assert any( + "domain_type" in w.get("msg", "") or "symmetry plane" in w.get("msg", "") for w in warnings + ), warnings diff --git a/tests/simulation/test_validation_context.py b/tests/simulation/test_validation_context.py index d709218ab..e8bdda981 100644 --- a/tests/simulation/test_validation_context.py +++ b/tests/simulation/test_validation_context.py @@ -6,13 +6,14 @@ """ import pytest -from pydantic import ValidationError +from pydantic import ValidationError, field_validator from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.unit_system import SI_unit_system from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, ValidationContext, + add_validation_warning, contextual_field_validator, ) @@ -197,3 +198,52 @@ def test_entity_transformation_detected_both(self): } info = ParamsValidationInfo(param_dict, referenced_expressions=[]) assert info.entity_transformation_detected is True + + +def test_add_validation_warning_collects_messages_without_errors(): + """Ensures validation warnings are recorded when no validation errors occur.""" + + class WarningModel(Flow360BaseModel): + value: int + + @field_validator("value") + @classmethod + def _warn(cls, value): + add_validation_warning("value inspected") + return value + + mock_context = ValidationContext( + levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) + ) + + with SI_unit_system, mock_context: + WarningModel(value=1) + + assert mock_context.validation_warnings == [ + {"loc": (), "msg": "value inspected", "type": "value_error", "ctx": {}} + ] + + +def test_add_validation_warning_preserves_messages_on_error(): + """Ensures warnings raised prior to a validation error are retained.""" + + class WarningModel(Flow360BaseModel): + value: int + + @field_validator("value") + @classmethod + def _warn(cls, value): + add_validation_warning("value invalid") + raise ValueError("boom") + + mock_context = ValidationContext( + levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) + ) + + with SI_unit_system, mock_context: + with pytest.raises(ValidationError): + WarningModel(value=-1) + + assert mock_context.validation_warnings == [ + {"loc": (), "msg": "value invalid", "type": "value_error", "ctx": {}} + ] diff --git a/tests/simulation/translator/test_mirrored_entity_translation.py b/tests/simulation/translator/test_mirrored_entity_translation.py index 4ef4dd9e4..45f0d9722 100644 --- a/tests/simulation/translator/test_mirrored_entity_translation.py +++ b/tests/simulation/translator/test_mirrored_entity_translation.py @@ -210,10 +210,11 @@ def test_mirrored_surface_translation(): ) # Dump and compare with surface mesh ref JSON - surface_mesh_param, err = validate_params_with_context( + surface_mesh_param, err, warnings = validate_params_with_context( simulation_param, "Geometry", "SurfaceMesh" ) assert err is None, f"Surface mesh validation error: {err}" + assert warnings == [], f"Unexpected warnings for surface mesh validation: {warnings}" surface_mesh_translated = get_surface_meshing_json(surface_mesh_param, mesh_unit=mesh_unit) surface_mesh_ref_file = os.path.join( os.path.dirname(os.path.abspath(__file__)), @@ -228,10 +229,11 @@ def test_mirrored_surface_translation(): ), "Surface mesh translation does not match reference" # Dump and compare with volume mesh ref JSON - volume_mesh_param, err = validate_params_with_context( + volume_mesh_param, err, warnings = validate_params_with_context( simulation_param, "Geometry", "VolumeMesh" ) assert err is None, f"Volume mesh validation error: {err}" + assert warnings == [], f"Unexpected warnings for volume mesh validation: {warnings}" volume_mesh_translated = get_volume_meshing_json(volume_mesh_param, mesh_unit=mesh_unit) volume_mesh_ref_file = os.path.join( os.path.dirname(os.path.abspath(__file__)), diff --git a/tests/simulation/translator/test_surface_meshing_translator.py b/tests/simulation/translator/test_surface_meshing_translator.py index 31b800bf2..e9b4759a3 100644 --- a/tests/simulation/translator/test_surface_meshing_translator.py +++ b/tests/simulation/translator/test_surface_meshing_translator.py @@ -976,8 +976,9 @@ def snappy_settings_off_position(): def _translate_and_compare(param, mesh_unit, ref_json_file: str, atol=1e-15): - param, err = validate_params_with_context(param, "Geometry", "SurfaceMesh") + param, err, warnings = validate_params_with_context(param, "Geometry", "SurfaceMesh") assert err is None, f"Validation error: {err}" + assert warnings == [], f"Unexpected warnings during validation: {warnings}" translated = get_surface_meshing_json(param, mesh_unit=mesh_unit) with open( os.path.join( @@ -1330,8 +1331,9 @@ def test_sliding_interface_tolerance_gai(): private_attribute_asset_cache=asset_cache, ) - _, err = validate_params_with_context(params, "Geometry", "SurfaceMesh") + _, err, warnings = validate_params_with_context(params, "Geometry", "SurfaceMesh") assert err is None, f"Validation error: {err}" + assert warnings == [], f"Unexpected warnings during validation: {warnings}" translated = get_surface_meshing_json(params, mesh_unit=1 * u.m) # Verify sliding_interface_tolerance is in the translated JSON @@ -1448,8 +1450,9 @@ def test_gai_mirror_status_translation(): private_attribute_asset_cache=asset_cache, ) - _, err = validate_params_with_context(params, "Geometry", "SurfaceMesh") + _, err, warnings = validate_params_with_context(params, "Geometry", "SurfaceMesh") assert err is None, f"Validation error: {err}" + assert warnings == [], f"Unexpected warnings during validation: {warnings}" translated = get_surface_meshing_json(params, mesh_unit=1 * u.m) @@ -1580,8 +1583,9 @@ def test_gai_mirror_status_translation_idempotency(): private_attribute_asset_cache=asset_cache, ) - _, err = validate_params_with_context(params, "Geometry", "SurfaceMesh") + _, err, warnings = validate_params_with_context(params, "Geometry", "SurfaceMesh") assert err is None, f"Validation error: {err}" + assert warnings == [], f"Unexpected warnings during validation: {warnings}" translated = get_surface_meshing_json(params, mesh_unit=1 * u.m) translated_jsons.append(translated)