diff --git a/flow360/cloud/flow360_requests.py b/flow360/cloud/flow360_requests.py index c3c0fc0a0..deb727aec 100644 --- a/flow360/cloud/flow360_requests.py +++ b/flow360/cloud/flow360_requests.py @@ -118,10 +118,9 @@ class NewGeometryRequest(Flow360RequestsV2): alias="lengthUnit", description="project length unit" ) description: str = pd_v2.Field(default="", description="project description") - use_nextflow: bool = pd_v2.Field( + use_catalyst: bool = pd_v2.Field( default=False, - alias="useNextflow", - description="Route geometry processing through Nextflow pipeline instead of legacy system", + description="Use the Catalyst workflow for geometry processing", ) diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index c19e3c66e..b68eccbeb 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -39,6 +39,8 @@ from flow360.exceptions import Flow360FileError, Flow360ValueError from flow360.log import log +GeometryWorkflow = Literal["standard", "catalyst"] + class GeometryStatus(Enum): """Status of geometry resource, the is_final method is overloaded""" @@ -107,7 +109,7 @@ def __init__( length_unit: LengthUnitType = "m", tags: List[str] = None, folder: Optional[Folder] = None, - use_nextflow_pipelines: bool = False, + workflow: GeometryWorkflow = "standard", ): """ Initialize a GeometryDraft with common attributes. @@ -139,7 +141,7 @@ def __init__( self.length_unit = length_unit self.solver_version = solver_version self.folder = folder - self.use_nextflow_pipelines = use_nextflow_pipelines + self.workflow = workflow # pylint: disable=fixme # TODO: create a DependableResourceDraft for GeometryDraft and SurfaceMeshDraft @@ -169,6 +171,11 @@ def _validate_geometry(self): f"specified length_unit : {self.length_unit} is invalid. " f"Valid options are: {list(LengthUnitType.__args__)}" ) + if self.workflow not in ("standard", "catalyst"): + raise Flow360ValueError( + f"specified workflow : {self.workflow} is invalid. " + "Valid options are: ['standard', 'catalyst']" + ) def _set_default_project_name(self): """Set default project name if not provided for project creation.""" @@ -243,7 +250,7 @@ def _create_project_root_resource( parent_folder_id=self.folder.id if self.folder else "ROOT.FLOW360", length_unit=self.length_unit, description=description, - use_nextflow=self.use_nextflow_pipelines, + use_catalyst=self.workflow == "catalyst", ) resp = RestApi(GeometryInterface.endpoint).post(req.dict()) @@ -476,7 +483,7 @@ def from_file( length_unit: LengthUnitType = "m", tags: List[str] = None, folder: Optional[Folder] = None, - use_nextflow_pipelines: bool = False, + workflow: GeometryWorkflow = "standard", ) -> GeometryDraft: return GeometryDraft( file_names=file_names, @@ -485,7 +492,7 @@ def from_file( length_unit=length_unit, tags=tags, folder=folder, - use_nextflow_pipelines=use_nextflow_pipelines, + workflow=workflow, ) @classmethod diff --git a/flow360/component/project.py b/flow360/component/project.py index b27011a5e..3c2709eaf 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -27,7 +27,7 @@ fetch_examples, find_example_by_name, ) -from flow360.component.geometry import Geometry +from flow360.component.geometry import Geometry, GeometryWorkflow from flow360.component.interfaces import ( GeometryInterface, ProjectInterface, @@ -931,6 +931,7 @@ def _create_project_from_files( tags: List[str] = None, run_async: bool = False, folder: Optional[Folder] = None, + workflow: GeometryWorkflow = "standard", ): """ Initializes a project from a file. @@ -969,7 +970,13 @@ def _create_project_from_files( if isinstance(files, GeometryFiles): draft = Geometry.from_file( - files.file_names, name, solver_version, length_unit, tags, folder=folder + files.file_names, + name, + solver_version, + length_unit, + tags, + folder=folder, + workflow=workflow, ) elif isinstance(files, SurfaceMeshFile): draft = SurfaceMeshV2.from_file( @@ -1150,6 +1157,7 @@ def from_geometry( tags: List[str] = None, run_async: bool = False, folder: Optional[Folder] = None, + workflow: GeometryWorkflow = "standard", ): """ Initializes a project from local geometry files. @@ -1170,6 +1178,10 @@ def from_geometry( Whether to create project asynchronously (default is False). folder : Optional[Folder], optional Parent folder for the project. If None, creates in root. + workflow : {"standard", "catalyst"}, optional + Workflow used for project geometry preparation. Use `"catalyst"` + for geometry preparation recommended for GAI and snappy workflows + (default is `"standard"`). Returns ------- @@ -1205,6 +1217,7 @@ def from_geometry( tags=tags, run_async=run_async, folder=folder, + workflow=workflow, ) @classmethod diff --git a/tests/simulation/test_project.py b/tests/simulation/test_project.py index 7fa8a9c39..cede19573 100644 --- a/tests/simulation/test_project.py +++ b/tests/simulation/test_project.py @@ -16,6 +16,7 @@ from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.volume_mesh import VolumeMeshV2 +from flow360.examples import Cylinder3D from flow360.exceptions import Flow360ConfigurationError, Flow360ValueError log.set_logging_level("DEBUG") @@ -57,6 +58,141 @@ def test_from_cloud(mock_id, mock_response): project.get_case(asset_id=current_case_id) +def test_from_geometry_passes_workflow(monkeypatch): + Cylinder3D.get_files() + captured = {} + + class _MockDraft: + def submit(self, run_async=False): + assert run_async is True + return MagicMock(project_id="prj-test-project-id") + + def _mock_from_file( + file_names, + project_name=None, + solver_version=None, + length_unit="m", + tags=None, + folder=None, + workflow="standard", + ): + captured["file_names"] = file_names + captured["project_name"] = project_name + captured["solver_version"] = solver_version + captured["length_unit"] = length_unit + captured["workflow"] = workflow + return _MockDraft() + + monkeypatch.setattr("flow360.component.project.Geometry.from_file", _mock_from_file) + + project_id = fl.Project.from_geometry( + Cylinder3D.geometry, + name="catalyst-project", + solver_version="release-test", + length_unit="cm", + run_async=True, + workflow="catalyst", + ) + + assert project_id == "prj-test-project-id" + assert captured["file_names"] == Cylinder3D.geometry + assert captured["project_name"] == "catalyst-project" + assert captured["solver_version"] == "release-test" + assert captured["length_unit"] == "cm" + assert captured["workflow"] == "catalyst" + + +def _fake_geometry_api_response(geo_id: str = "geo-test-0001", prj_id: str = "prj-test-payload"): + return { + "id": geo_id, + "name": "test-geo", + "userId": "user-test", + "status": "uploaded", + "projectId": prj_id, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "deleted": False, + "tags": [], + } + + +def _mock_upload_files(self, *args, **kwargs): + geo = MagicMock() + geo.short_description.return_value = "test-geo (geo-test)" + geo.id = "geo-test-0001" + geo.project_id = "prj-test-payload" + return geo + + +def test_catalyst_workflow_reaches_api_payload(monkeypatch): + Cylinder3D.get_files() + captured_payload: dict = {} + + class _FakeRestApi: + def __init__(self, endpoint, **kwargs): + pass + + def post(self, json_body): + captured_payload.update(json_body) + return _fake_geometry_api_response() + + monkeypatch.setattr("flow360.component.geometry.RestApi", _FakeRestApi) + monkeypatch.setattr("os.path.exists", lambda _: True) + monkeypatch.setattr( + "flow360.component.geometry.GeometryDraft._upload_files", _mock_upload_files + ) + + draft = Geometry.from_file( + file_names=Cylinder3D.geometry, + project_name="payload-test", + solver_version="release-test", + length_unit="cm", + workflow="catalyst", + ) + + assert draft.workflow == "catalyst" + draft.submit(run_async=True) + + assert ( + captured_payload.get("useCatalyst") is True + ), f"Expected Catalyst workflow to set useCatalyst=true, got: {captured_payload}" + assert set(captured_payload) >= {"useCatalyst"} + + +def test_standard_workflow_is_default(monkeypatch): + Cylinder3D.get_files() + captured_payload: dict = {} + + class _FakeRestApi: + def __init__(self, endpoint, **kwargs): + pass + + def post(self, json_body): + captured_payload.update(json_body) + return _fake_geometry_api_response(geo_id="geo-test-0002", prj_id="prj-test-default") + + monkeypatch.setattr("flow360.component.geometry.RestApi", _FakeRestApi) + monkeypatch.setattr("os.path.exists", lambda _: True) + monkeypatch.setattr( + "flow360.component.geometry.GeometryDraft._upload_files", _mock_upload_files + ) + + draft = Geometry.from_file( + file_names=Cylinder3D.geometry, + project_name="default-test", + solver_version="release-test", + length_unit="cm", + ) + + assert draft.workflow == "standard" + draft.submit(run_async=True) + + assert ( + captured_payload.get("useCatalyst") is False + ), f"Expected standard workflow to keep useCatalyst=false, got: {captured_payload}" + assert set(captured_payload) >= {"useCatalyst"} + + def test_root_asset_entity_change_reflection(mock_id, mock_response): project = fl.Project.from_cloud(project_id="prj-41d2333b-85fd-4bed-ae13-15dcb6da519e") geo = project.geometry