diff --git a/flow360/component/project_utils.py b/flow360/component/project_utils.py index 2c12a4a0d..1d7e2325c 100644 --- a/flow360/component/project_utils.py +++ b/flow360/component/project_utils.py @@ -32,6 +32,7 @@ Surface, ) from flow360.component.simulation.services_utils import ( + strip_implicit_edge_split_layers_inplace, strip_selector_matches_and_broken_entities_inplace, ) from flow360.component.simulation.simulation_params import SimulationParams @@ -725,6 +726,7 @@ def validate_params_with_context(params, root_item_type, up_to): ) params_as_dict = params.model_dump(mode="json", exclude_none=True) + params_as_dict = strip_implicit_edge_split_layers_inplace(params, params_as_dict) params, errors, warnings = services.validate_model( params_as_dict=params_as_dict, diff --git a/flow360/component/simulation/meshing_param/meshing_specs.py b/flow360/component/simulation/meshing_param/meshing_specs.py index 27216e848..bc110cd50 100644 --- a/flow360/component/simulation/meshing_param/meshing_specs.py +++ b/flow360/component/simulation/meshing_param/meshing_specs.py @@ -182,6 +182,8 @@ class MeshingDefaults(Flow360BaseModel): edge_split_layers: int = pd.Field( 1, ge=0, + # Skip default-value validation so warnings are emitted only when users explicitly set this field. + validate_default=False, description="The number of layers that are considered for edge splitting in the boundary layer mesh." + "This only affects beta mesher.", ) diff --git a/flow360/component/simulation/services_utils.py b/flow360/component/simulation/services_utils.py index bdf9c2012..f5816f975 100644 --- a/flow360/component/simulation/services_utils.py +++ b/flow360/component/simulation/services_utils.py @@ -1,6 +1,6 @@ """Utility functions for the simulation services.""" -from typing import Any +from typing import TYPE_CHECKING, Any from flow360.component.simulation.framework.entity_expansion_utils import ( get_registry_from_params, @@ -12,6 +12,39 @@ _MIRRORED_ENTITY_TYPE_NAMES = ("MirroredSurface", "MirroredGeometryBodyGroup") +if TYPE_CHECKING: + from flow360.component.simulation.simulation_params import SimulationParams + + +def strip_implicit_edge_split_layers_inplace(params: "SimulationParams", params_dict: dict) -> dict: + """ + Remove implicitly injected `edge_split_layers` from serialized params. + This extra and specific function was added due to a change in schema during lifecycle of a release (uncommon) + + Why not use `exclude_unset` or `exclude_defaults` globally during `model_dump()`? + - `exclude_unset` strips many unrelated defaulted fields and can affect downstream workflows. + - `exclude_defaults` also strips explicitly user-set values that equal the default. + """ + meshing = getattr(params, "meshing", None) + defaults = getattr(meshing, "defaults", None) + if defaults is None: + return params_dict + + if "edge_split_layers" in defaults.model_fields_set: + # Keep explicit user setting (including explicit value equal to default). + return params_dict + + meshing_dict = params_dict.get("meshing") + if not isinstance(meshing_dict, dict): + return params_dict + + defaults_dict = meshing_dict.get("defaults") + if not isinstance(defaults_dict, dict): + return params_dict + + defaults_dict.pop("edge_split_layers", None) + return params_dict + def strip_selector_matches_and_broken_entities_inplace(params) -> Any: """ diff --git a/flow360/component/simulation/web/draft.py b/flow360/component/simulation/web/draft.py index 5ef8355b2..8aeae1613 100644 --- a/flow360/component/simulation/web/draft.py +++ b/flow360/component/simulation/web/draft.py @@ -21,6 +21,9 @@ from flow360.component.simulation.framework.entity_selector import ( collect_and_tokenize_selectors_in_place, ) +from flow360.component.simulation.services_utils import ( + strip_implicit_edge_split_layers_inplace, +) from flow360.component.utils import formatting_validation_errors, validate_type from flow360.environment import Env from flow360.exceptions import Flow360RuntimeError, Flow360WebError @@ -132,6 +135,7 @@ def from_cloud(cls, draft_id: IDStringType) -> Draft: def update_simulation_params(self, params): """update the SimulationParams of the draft""" params_dict = params.model_dump(mode="json", exclude_none=True) + params_dict = strip_implicit_edge_split_layers_inplace(params, params_dict) params_dict = collect_and_tokenize_selectors_in_place(params_dict) self.post( diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 8482f0e86..b9488d17f 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -2482,6 +2482,28 @@ def test_beta_mesher_only_features(mock_validation_context): assert errors[0]["loc"] == () +def test_edge_split_layers_default_no_warning_for_dict_input(): + non_beta_context = ParamsValidationInfo({}, []) + non_beta_context.is_beta_mesher = False + + with SI_unit_system, ValidationContext(VOLUME_MESH, non_beta_context) as validation_context: + defaults = MeshingDefaults.model_validate({"boundary_layer_first_layer_thickness": 1e-4}) + + assert "edge_split_layers" not in defaults.model_fields_set + assert validation_context.validation_warnings == [] + + +def test_edge_split_layers_default_no_warning_for_constructor_input(): + non_beta_context = ParamsValidationInfo({}, []) + non_beta_context.is_beta_mesher = False + + with SI_unit_system, ValidationContext(VOLUME_MESH, non_beta_context) as validation_context: + defaults = MeshingDefaults(boundary_layer_first_layer_thickness=1e-4) + + assert "edge_split_layers" not in defaults.model_fields_set + assert validation_context.validation_warnings == [] + + def test_geometry_AI_only_features(): with SI_unit_system: params = SimulationParams( diff --git a/tests/simulation/services/test_services_utils.py b/tests/simulation/services/test_services_utils.py new file mode 100644 index 000000000..495242047 --- /dev/null +++ b/tests/simulation/services/test_services_utils.py @@ -0,0 +1,132 @@ +import json +from types import SimpleNamespace + +import flow360 as fl +from flow360.component.project_utils import validate_params_with_context +from flow360.component.simulation.framework.param_utils import AssetCache +from flow360.component.simulation.meshing_param.meshing_specs import MeshingDefaults +from flow360.component.simulation.services_utils import ( + strip_implicit_edge_split_layers_inplace, +) +from flow360.component.simulation.web.draft import Draft + + +def _build_dummy_params(defaults: MeshingDefaults): + return SimpleNamespace(meshing=SimpleNamespace(defaults=defaults)) + + +def _build_simulation_params(*, edge_split_layers=None): + defaults_kwargs = dict( + boundary_layer_first_layer_thickness=1e-4, + surface_max_edge_length=1e-2, + ) + if edge_split_layers is not None: + defaults_kwargs["edge_split_layers"] = edge_split_layers + + with fl.SI_unit_system: + return fl.SimulationParams( + meshing=fl.MeshingParams( + defaults=fl.MeshingDefaults(**defaults_kwargs), + volume_zones=[fl.AutomatedFarfield()], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=False), + ) + + +def test_strip_implicit_edge_split_layers_removes_default_injected_field(): + with fl.SI_unit_system: + defaults = MeshingDefaults(boundary_layer_first_layer_thickness=1e-4) + + params = _build_dummy_params(defaults) + params_dict = { + "meshing": {"defaults": {"edge_split_layers": 1, "surface_edge_growth_rate": 1.2}} + } + + out = strip_implicit_edge_split_layers_inplace(params, params_dict) + + assert out is params_dict + assert "edge_split_layers" not in out["meshing"]["defaults"] + assert out["meshing"]["defaults"]["surface_edge_growth_rate"] == 1.2 + + +def test_strip_implicit_edge_split_layers_keeps_explicit_default_value(): + with fl.SI_unit_system: + defaults = MeshingDefaults(boundary_layer_first_layer_thickness=1e-4, edge_split_layers=1) + + params = _build_dummy_params(defaults) + params_dict = { + "meshing": {"defaults": {"edge_split_layers": 1, "surface_edge_growth_rate": 1.2}} + } + + out = strip_implicit_edge_split_layers_inplace(params, params_dict) + + assert out["meshing"]["defaults"]["edge_split_layers"] == 1 + + +def test_strip_implicit_edge_split_layers_keeps_explicit_non_default_value(): + with fl.SI_unit_system: + defaults = MeshingDefaults(boundary_layer_first_layer_thickness=1e-4, edge_split_layers=3) + + params = _build_dummy_params(defaults) + params_dict = { + "meshing": {"defaults": {"edge_split_layers": 3, "surface_edge_growth_rate": 1.2}} + } + + out = strip_implicit_edge_split_layers_inplace(params, params_dict) + + assert out["meshing"]["defaults"]["edge_split_layers"] == 3 + + +def test_validate_params_with_context_no_warning_for_implicit_default(): + params = _build_simulation_params() + + _, errors, warnings = validate_params_with_context(params, "Geometry", "VolumeMesh") + + assert errors is None + assert warnings == [] + + +def test_validate_params_with_context_warning_for_explicit_default_value(): + params = _build_simulation_params(edge_split_layers=1) + + _, errors, warnings = validate_params_with_context(params, "Geometry", "VolumeMesh") + + assert errors is None + assert len(warnings) == 1 + assert warnings[0]["msg"] == ( + "`edge_split_layers` is only supported by the beta mesher; this setting will be ignored." + ) + + +def test_draft_upload_payload_omits_implicit_default_edge_split_layers(monkeypatch): + params = _build_simulation_params() + uploaded_payload = {} + + def _capture_post(self, *, json=None, method=None, **_kwargs): + uploaded_payload["json"] = json + uploaded_payload["method"] = method + return {} + + monkeypatch.setattr(Draft, "post", _capture_post, raising=True) + Draft(draft_id="00000000-0000-0000-0000-000000000000").update_simulation_params(params) + + assert uploaded_payload["method"] == "simulation/file" + uploaded_dict = json.loads(uploaded_payload["json"]["data"]) + assert "edge_split_layers" not in uploaded_dict["meshing"]["defaults"] + + +def test_draft_upload_payload_keeps_explicit_default_edge_split_layers(monkeypatch): + params = _build_simulation_params(edge_split_layers=1) + uploaded_payload = {} + + def _capture_post(self, *, json=None, method=None, **_kwargs): + uploaded_payload["json"] = json + uploaded_payload["method"] = method + return {} + + monkeypatch.setattr(Draft, "post", _capture_post, raising=True) + Draft(draft_id="00000000-0000-0000-0000-000000000000").update_simulation_params(params) + + assert uploaded_payload["method"] == "simulation/file" + uploaded_dict = json.loads(uploaded_payload["json"]["data"]) + assert uploaded_dict["meshing"]["defaults"]["edge_split_layers"] == 1