Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions flow360/cloud/flow360_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down
17 changes: 12 additions & 5 deletions flow360/component/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand All @@ -485,7 +492,7 @@ def from_file(
length_unit=length_unit,
tags=tags,
folder=folder,
use_nextflow_pipelines=use_nextflow_pipelines,
workflow=workflow,
)

@classmethod
Expand Down
17 changes: 15 additions & 2 deletions flow360/component/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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
-------
Expand Down Expand Up @@ -1205,6 +1217,7 @@ def from_geometry(
tags=tags,
run_async=run_async,
folder=folder,
workflow=workflow,
)

@classmethod
Expand Down
136 changes: 136 additions & 0 deletions tests/simulation/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Loading