Source code for flow360.component.project

"""Project interface for setting up and running simulations"""

# pylint: disable=no-member, too-many-lines
# To be honest I do not know why pylint is insistent on treating
# ProjectMeta instances as FieldInfo, I'd rather not have this line
from __future__ import annotations

import json
from enum import Enum
from typing import Iterable, List, Literal, Optional, Union

import pydantic as pd
import typing_extensions
from pydantic import PositiveInt

from flow360.cloud.flow360_requests import LengthUnitType, RenameAssetRequestV2
from flow360.cloud.rest_api import RestApi
from flow360.component.case import Case
from flow360.component.geometry import Geometry
from flow360.component.interfaces import (
    GeometryInterface,
    ProjectInterface,
    SurfaceMeshInterfaceV2,
    VolumeMeshInterfaceV2,
)
from flow360.component.project_utils import (
    get_project_records,
    set_up_params_for_uploading,
    show_projects_with_keyword_filter,
    upload_imported_surfaces_to_draft,
    validate_params_with_context,
)
from flow360.component.resource_base import Flow360Resource
from flow360.component.simulation.folder import Folder
from flow360.component.simulation.simulation_params import SimulationParams
from flow360.component.simulation.unit_system import LengthType
from flow360.component.simulation.web.asset_base import AssetBase
from flow360.component.simulation.web.draft import Draft
from flow360.component.surface_mesh_v2 import SurfaceMeshV2
from flow360.component.utils import (
    AssetShortID,
    GeometryFiles,
    SurfaceMeshFile,
    VolumeMeshFile,
    formatting_validation_errors,
    get_short_asset_id,
    parse_datetime,
    wrapstring,
)
from flow360.component.volume_mesh import VolumeMeshV2
from flow360.exceptions import (
    Flow360ConfigError,
    Flow360FileError,
    Flow360ValueError,
    Flow360WebError,
)
from flow360.log import log
from flow360.plugins.report.report import get_default_report_summary_template
from flow360.version import __solver_version__

AssetOrResource = Union[type[AssetBase], type[Flow360Resource]]


class RootType(Enum):
    """
    Enum for root object types in the project.

    Attributes
    ----------
    GEOMETRY : str
        Represents a geometry root object.
    SURFACE_MESH : str
        Represents a surface mesh root object.
    VOLUME_MESH : str
        Represents a volume mesh root object.
    """

    GEOMETRY = "Geometry"
    SURFACE_MESH = "SurfaceMesh"
    VOLUME_MESH = "VolumeMesh"


class ProjectMeta(pd.BaseModel, extra="allow"):
    """
    Metadata class for a project.

    Attributes
    ----------
    user_id : str
        The user ID associated with the project.
    id : str
        The project ID.
    name : str
        The name of the project.
    tags : List[str]
        List of tags associated with the project.
    root_item_id : str
        ID of the root item in the project.
    root_item_type : RootType
        Type of the root item (Geometry or SurfaceMesh or VolumeMesh).
    """

    user_id: str = pd.Field(alias="userId")
    id: str = pd.Field()
    name: str = pd.Field()
    tags: List[str] = pd.Field(default_factory=list)
    root_item_id: str = pd.Field(alias="rootItemId")
    root_item_type: RootType = pd.Field(alias="rootItemType")


class ProjectTreeNode(pd.BaseModel):
    """
    ProjectTreeNode class containing the info of an asset item in a project tree.

    Attributes
    ----------
    asset_id : str
        ID of the asset.
    asset_name : str
        Name of the asset.
    asset_type : str
        Type of the asset.
    parent_id : Optional[str]
        ID of the parent asset.
    case_mesh_id : Optional[str]
        ID of the case's mesh.
    case_mesh_label : Optional[str]
        Label the mesh of a forked case using a different mesh.
    children : List
        List of the child assets of the current asset.
    min_length_short_id : int
        The minimum length of the short asset id, excluding
        hyphen and asset prefix.
    """

    asset_id: str = pd.Field()
    asset_name: str = pd.Field()
    asset_type: str = pd.Field()
    parent_id: Optional[str] = pd.Field(None)
    case_mesh_id: Optional[str] = pd.Field(None)
    case_mesh_label: Optional[str] = pd.Field(None)
    children: List = pd.Field([])
    min_length_short_id: PositiveInt = pd.Field(7)

    def construct_string(self, line_width):
        """Define the output info within when printing a project tree in the terminal"""
        title_line = "<<" + self.asset_type + ">>"
        name_line = f"name: {self.asset_name}"
        id_line = f"id:   {self.short_id}"

        # Dynamically compute the line_width for each asset block to ensure
        # 1. The asset type title always occupies a single line
        # 2. The id and name line width is no more than the input line_width but is as small as possible.
        max_line_width = min(line_width, max(len(name_line), len(id_line)))
        block_line_width = max(len(title_line), max_line_width)

        name_line = wrapstring(long_str=f"name: {self.asset_name}", str_length=block_line_width)
        id_line = wrapstring(long_str=f"id:   {self.short_id}", str_length=block_line_width)
        return f"{title_line.center(block_line_width)}\n{name_line}\n{id_line}"

    def add_child(self, child: ProjectTreeNode):
        """Add a child asset of the current asset"""
        self.children.append(child)

    def remove_child(self, child_to_remove: ProjectTreeNode):
        """Remove a child asset of the current asset"""
        self.children = [child for child in self.children if child is not child_to_remove]

    @property
    def short_id(self) -> str:
        """Compute short asset id"""
        return get_short_asset_id(
            full_asset_id=self.asset_id, num_character=self.min_length_short_id
        )

    @property
    def edge_label(self) -> str:
        """
        Add edge label in the printed project tree to
        display the different volume mesh used in a forked case.
        """
        if self.case_mesh_label:
            prefix = "Using VolumeMesh:\n"
            mesh_short_id = get_short_asset_id(
                full_asset_id=self.case_mesh_label,
                num_character=self.min_length_short_id,
            )
            return prefix + mesh_short_id.center(len(prefix))
        return None


class ProjectTree(pd.BaseModel):
    """
    ProjectTree class containing the project tree.

    Attributes
    ----------
    root : ProjectTreeNode
        Root item of the project.
    nodes : dict[str, ProjectTreeNode]
        Dict of all nodes in the project tree.
    short_id_map: dict[str, List[str]]
        Dict of short_id to full_id mapping, used to ensure every short_id is unique in the project.
    """

    root: ProjectTreeNode = pd.Field(None)
    nodes: dict[str, ProjectTreeNode] = pd.Field({})
    short_id_map: dict[str, List[str]] = pd.Field({})

    def _update_case_mesh_label(self):
        """Check and remove unnecessary case mesh label"""
        for node_id in self._get_asset_ids_by_type(asset_type="Case"):
            node = self.nodes.get(node_id)
            parent_node = self._get_parent_node(node=node)
            if not parent_node:
                continue
            if parent_node.asset_type != "Case" or node.case_mesh_id == parent_node.case_mesh_id:
                node.case_mesh_label = None

    def _update_node_short_id(self):
        """Update the minimum length of short ID to ensure each node has a unique short ID"""
        if len(self.nodes) == len(self.short_id_map):
            pass
        full_id_to_update = []
        short_id_duplicate = []
        for short_id, full_ids in self.short_id_map.items():
            if len(full_ids) > 1:
                short_id_duplicate.append(short_id)
                common_prefix = full_ids[0]
                for full_id in full_ids[1:]:
                    while not full_id.startswith(common_prefix):
                        common_prefix = common_prefix[:-1]
                common_prefix_processed = "".join(common_prefix.split("-")[1:])
                for full_id in full_ids:
                    # pylint: disable=unsubscriptable-object
                    self.nodes[full_id].min_length_short_id = len(common_prefix_processed) + 1
                    full_id_to_update.append(full_id)
        for full_id in full_id_to_update:
            # pylint: disable=unsubscriptable-object
            self.short_id_map.update({self.nodes[full_id].short_id: [full_id]})
        for short_id in short_id_duplicate:
            self.short_id_map.pop(short_id, None)

    def _get_parent_node(self, node: ProjectTreeNode):
        """Get the parent node of the input node"""
        if not node.parent_id:
            return None
        return self.nodes.get(node.parent_id, None)

    def _has_node(self, asset_id: str) -> bool:
        """Use asset_id to check if the asset already exists in the project tree"""
        if asset_id in self.nodes.keys():
            return True
        return False

    def _get_asset_ids_by_type(
        self, asset_type: str = Literal["Geometry", "SurfaceMesh", "VolumeMesh", "Case"]
    ):
        """Get the list of asset_ids in the project tree by asset_type."""
        return [node.asset_id for node in self.nodes.values() if node.asset_type == asset_type]

    @classmethod
    def _create_new_node(cls, asset_record: dict):
        """Create a new node based on the asset record from API call"""
        parent_id = (
            asset_record["parentCaseId"]
            if asset_record["parentCaseId"]
            else asset_record["parentId"]
        )
        case_mesh_id = asset_record["parentId"] if asset_record["type"] == "Case" else None

        new_node = ProjectTreeNode(
            asset_id=asset_record["id"],
            asset_name=asset_record["name"],
            asset_type=asset_record["type"],
            parent_id=parent_id,
            case_mesh_id=case_mesh_id,
            case_mesh_label=case_mesh_id,
        )

        return new_node

    def _update_short_id_map(self, new_node: ProjectTreeNode):
        # pylint: disable=unsupported-assignment-operation,unsubscriptable-object
        if new_node.short_id not in self.short_id_map.keys():
            self.short_id_map[new_node.short_id] = []
        self.short_id_map[new_node.short_id].append(new_node.asset_id)

    def add(self, asset_record: dict):
        """Add new node to the existing project tree"""
        if self._has_node(asset_id=asset_record["id"]):
            return False

        new_node = ProjectTree._create_new_node(asset_record)
        self._update_short_id_map(new_node)
        if new_node.parent_id is None:
            self.root = new_node
        for node in self.nodes.values():
            if node.parent_id == new_node.asset_id:
                new_node.add_child(child=node)
            if node.asset_id == new_node.parent_id:
                node.add_child(child=new_node)
        self.nodes.update({new_node.asset_id: new_node})
        self._update_node_short_id()
        self._update_case_mesh_label()
        return True

    def remove_node(self, node_id: str):
        """Remove node from the tree"""
        node = self.nodes.get(node_id)
        if not node:
            return
        if node.parent_id and self._has_node(node.parent_id):
            # pylint: disable=unsubscriptable-object
            self.nodes[node.parent_id].remove_child(node)
        self.nodes.pop(node.asset_id)

    def construct_tree(self, asset_records: List[dict]):
        """Construct the entire project tree"""
        for asset_record in asset_records:
            new_node = ProjectTree._create_new_node(asset_record)
            self._update_short_id_map(new_node)
            if new_node.parent_id is None:
                self.root = new_node
            self.nodes.update({new_node.asset_id: new_node})

        for node in self.nodes.values():
            if node.parent_id and self._has_node(node.parent_id):
                # pylint: disable=unsubscriptable-object
                self.nodes[node.parent_id].add_child(node)
        self._update_node_short_id()
        self._update_case_mesh_label()

    @pd.validate_call
    def get_full_asset_id(self, query_asset: AssetShortID) -> str:
        """
        Returns full asset id of a certain asset type given the query_id.

        Raises
        ------
        Flow360ValueError
            1. If derived asset type from query_id does not match the asset type.
            2. If query_id is too short.
            3. If query_id is does not exist in the project tree.

        Returns
        -------
        The full asset id.
        """

        asset_type_ids = self._get_asset_ids_by_type(asset_type=query_asset.asset_type)
        if len(asset_type_ids) == 0:
            raise Flow360ValueError(f"No {query_asset.asset_type} is available in this project.")

        if query_asset.asset_id is None:
            # The latest asset of this asset_type will be returned.
            return asset_type_ids[-1]

        for asset_id in asset_type_ids:
            if asset_id.startswith(query_asset.asset_id):
                return asset_id
        raise Flow360ValueError(
            f"This asset does not exist in this project. Please check the input asset ID ({query_asset.asset_id})."
        )


# pylint: disable=too-many-public-methods
[docs] class Project(pd.BaseModel): """ Project class containing the interface for creating and running simulations. Attributes ---------- metadata : ProjectMeta Metadata of the project. solver_version : str Version of the solver being used. """ metadata: ProjectMeta = pd.Field() project_tree: ProjectTree = pd.Field() solver_version: str = pd.Field(frozen=True) _root_asset: Union[Geometry, SurfaceMeshV2, VolumeMeshV2] = pd.PrivateAttr(None) _root_webapi: Optional[RestApi] = pd.PrivateAttr(None) _project_webapi: Optional[RestApi] = pd.PrivateAttr(None) _root_simulation_json: Optional[dict] = pd.PrivateAttr(None)
[docs] @classmethod def show_remote(cls, search_keyword: Union[None, str] = None): """ Shows all projects on the cloud. Parameters ---------- search_keyword : str, optional """ show_projects_with_keyword_filter(search_keyword)
@property def id(self) -> str: """ Returns the ID of the project. Returns ------- str The project ID. """ return self.metadata.id @property def tags(self) -> List[str]: """ Returns the tags of the project. Returns ------- List[str] List of the project's tags. """ return self.metadata.tags @property def length_unit(self) -> LengthType.Positive: """ Returns the length unit of the project. Returns ------- LengthType.Positive The length unit. """ defaults = self._root_simulation_json cache_key = "private_attribute_asset_cache" length_key = "project_length_unit" if cache_key not in defaults or length_key not in defaults[cache_key]: raise Flow360ValueError("[Internal] Simulation params do not contain length unit info.") return LengthType.validate(defaults[cache_key][length_key]) @property def geometry(self) -> Geometry: """ Returns the geometry asset of the project. There is always only one geometry asset per project. Raises ------ Flow360ValueError If the geometry asset is not available for the project. Returns ------- Geometry The geometry asset. """ self._check_initialized() if self.metadata.root_item_type is not RootType.GEOMETRY: raise Flow360ValueError( "Geometry asset is only present in projects initialized from geometry." ) return self._root_asset
[docs] def get_surface_mesh(self, asset_id: str = None) -> SurfaceMeshV2: """ Returns the surface mesh asset of the project. Parameters ---------- asset_id : str, optional The ID of the asset from among the generated assets in this project instance. If not provided, the property contains the most recently run asset. Raises ------ Flow360ValueError If the surface mesh asset is not available for the project. Returns ------- SurfaceMeshV2 The surface mesh asset. """ self._check_initialized() asset_id = self.project_tree.get_full_asset_id( query_asset=AssetShortID(asset_id=asset_id, asset_type="SurfaceMesh") ) return SurfaceMeshV2.from_cloud(id=asset_id)
@property def surface_mesh(self) -> SurfaceMeshV2: """ Returns the last used surface mesh asset of the project. If the project is initialized from surface mesh, the surface mesh asset is the root asset. Raises ------ Flow360ValueError If the surface mesh asset is not available for the project. Returns ------- SurfaceMeshV2 The surface mesh asset. """ if self.metadata.root_item_type is RootType.SURFACE_MESH: return self._root_asset log.warning( f"Accessing surface mesh from a project initialized from {self.metadata.root_item_type.name}. " "Please use the root asset for assigning entities to SimulationParams." ) return self.get_surface_mesh()
[docs] def get_volume_mesh(self, asset_id: str = None) -> VolumeMeshV2: """ Returns the volume mesh asset of the project. Parameters ---------- asset_id : str, optional The ID of the asset from among the generated assets in this project instance. If not provided, the property contains the most recently run asset. Raises ------ Flow360ValueError If the volume mesh asset is not available for the project. Returns ------- VolumeMeshV2 The volume mesh asset. """ self._check_initialized() asset_id = self.project_tree.get_full_asset_id( query_asset=AssetShortID(asset_id=asset_id, asset_type="VolumeMesh") ) return VolumeMeshV2.from_cloud(id=asset_id)
@property def volume_mesh(self) -> VolumeMeshV2: """ Returns the last used volume mesh asset of the project. Raises ------ Flow360ValueError If the volume mesh asset is not available for the project. Returns ------- VolumeMeshV2 The volume mesh asset. """ if self.metadata.root_item_type is RootType.VOLUME_MESH: return self._root_asset log.warning( f"Accessing volume mesh from a project initialized from {self.metadata.root_item_type.name}. " "Please use the root asset for assigning entities to SimulationParams." ) return self.get_volume_mesh()
[docs] def get_case(self, asset_id: str = None) -> Case: """ Returns the last used case asset of the project. Parameters ---------- asset_id : str, optional The ID of the asset from among the generated assets in this project instance. If not provided, the property contains the most recently run asset. Raises ------ Flow360ValueError If the case asset is not available for the project. Returns ------- Case The case asset. """ self._check_initialized() asset_id = self.project_tree.get_full_asset_id( query_asset=AssetShortID(asset_id=asset_id, asset_type="Case") ) return Case.from_cloud(case_id=asset_id)
@property def case(self): """ Returns the case asset of the project. Raises ------ Flow360ValueError If the case asset is not available for the project. Returns ------- Case The case asset. """ return self.get_case()
[docs] def get_surface_mesh_ids(self) -> Iterable[str]: """ Returns the available IDs of surface meshes in the project Returns ------- Iterable[str] An iterable of asset IDs. """ # pylint: disable=protected-access return self.project_tree._get_asset_ids_by_type(asset_type="SurfaceMesh")
[docs] def get_volume_mesh_ids(self): """ Returns the available IDs of volume meshes in the project Returns ------- Iterable[str] An iterable of asset IDs. """ # pylint: disable=protected-access return self.project_tree._get_asset_ids_by_type(asset_type="VolumeMesh")
[docs] def get_case_ids(self, tags: Optional[List[str]] = None) -> List[str]: """ Returns the available IDs of cases in the project, optionally filtered by tags. Parameters ---------- tags : List[str], optional List of tags to filter cases by. If None or empty tags list, returns all case IDs. Returns ------- Iterable[str] An iterable of case IDs. If tags are provided, filters to return only case IDs that have at least one matching tag. """ # pylint: disable=protected-access all_case_ids = self.project_tree._get_asset_ids_by_type(asset_type="Case") if not tags: return all_case_ids # Filter cases by tags filtered_case_ids = [] for case_id in all_case_ids: case = self.get_case(asset_id=case_id) if set(tags) & set(case.info_v2.tags): filtered_case_ids.append(case_id) return filtered_case_ids
[docs] @classmethod def get_project_ids(cls, tags: Optional[List[str]] = None) -> List[str]: """ Returns the available IDs of projects, optionally filtered by tags. Parameters ---------- tags : List[str], optional List of tags to filter projects by. If None, returns all project IDs. Returns ------- List[str] A list of project IDs. If tags are provided, filters to return only project IDs that have at least one matching tag. """ project_records, _ = get_project_records("", tags=tags) return [record.project_id for record in project_records.records]
# pylint: disable=too-many-arguments @classmethod def _create_project_from_files( cls, *, files: Union[GeometryFiles, SurfaceMeshFile, VolumeMeshFile], name: str = None, solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, run_async: bool = False, folder: Optional[Folder] = None, ): """ Initializes a project from a file. Parameters ---------- files : Union[GeometryFiles, SurfaceMeshFile, VolumeMeshFile] Path to the files. name : str, optional Name of the project (default is None). solver_version : str, optional Version of the solver (default is None). length_unit : LengthUnitType, optional Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). run_async : bool, optional Whether to create the project asynchronously (default is False). folder : Optional[Folder], optional Parent folder for the project. If None, creates in root. Returns ------- Project An instance of the project. Or Project ID when run_async is True. Raises ------ Flow360ValueError If the project cannot be initialized from the file. """ root_asset = None # pylint:disable = protected-access files._check_files_existence() if isinstance(files, GeometryFiles): draft = Geometry.from_file( files.file_names, name, solver_version, length_unit, tags, folder=folder ) elif isinstance(files, SurfaceMeshFile): draft = SurfaceMeshV2.from_file( files.file_names, name, solver_version, length_unit, tags, folder=folder ) elif isinstance(files, VolumeMeshFile): draft = VolumeMeshV2.from_file( files.file_names, name, solver_version, length_unit, tags, folder=folder ) else: raise Flow360FileError( "Cannot detect the intended project root with the given file(s)." ) root_asset = draft.submit(run_async=run_async) if run_async: log.info( f"The input file(s) has been successfully uploaded to project: {root_asset.project_id} " "and is being processed on cloud. Only the project ID string is returned. " "To retrieve this project later, use 'Project.from_cloud(project_id)'. " ) return root_asset.project_id if not root_asset: raise Flow360ValueError(f"Couldn't initialize asset from {files.file_names}") project_id = root_asset.project_id project_api = RestApi(ProjectInterface.endpoint, id=project_id) info = project_api.get() if not info: raise Flow360ValueError(f"Couldn't retrieve project info for {project_id}") project = Project( metadata=ProjectMeta(**info), project_tree=ProjectTree(), solver_version=root_asset.solver_version, ) project._project_webapi = project_api if isinstance(files, GeometryFiles): project._root_webapi = RestApi(GeometryInterface.endpoint, id=root_asset.id) elif isinstance(files, SurfaceMeshFile): project._root_webapi = RestApi(SurfaceMeshInterfaceV2.endpoint, id=root_asset.id) elif isinstance(files, VolumeMeshFile): project._root_webapi = RestApi(VolumeMeshInterfaceV2.endpoint, id=root_asset.id) project._root_asset = root_asset project._get_root_simulation_json() project._get_tree_from_cloud() return project
[docs] @classmethod @pd.validate_call( config={ "arbitrary_types_allowed": True } # Folder (v2: component/simulation/folder.py) does not have validate() defined ) def from_geometry( cls, files: Union[str, list[str]], /, name: str = None, solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, run_async: bool = False, folder: Optional[Folder] = None, ): """ Initializes a project from local geometry files. Parameters ---------- files : Union[str, list[str]] (positional argument only) Geometry file paths. name : str, optional Name of the project (default is None). solver_version : str, optional Version of the solver (default is None). length_unit : LengthUnitType, optional Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). run_async : bool, optional Whether to create project asynchronously (default is False). folder : Optional[Folder], optional Parent folder for the project. If None, creates in root. Returns ------- Project An instance of the project. Or Project ID when run_async is True. Raises ------ Flow360FileError If the project cannot be initialized from the file. Example ------- >>> my_project = fl.Project.from_geometry( ... "/path/to/my/geometry/my_geometry.csm", ... name="My_Project_name", ... solver_version="release-Major.Minor" ... length_unit="cm" ... tags=["Quarter 1", "Revision 2"] ... ) ==== """ try: validated_files = GeometryFiles(file_names=files) except pd.ValidationError as err: # pylint:disable = raise-missing-from raise Flow360FileError(f"Geometry file error: {str(err)}") return cls._create_project_from_files( files=validated_files, name=name, solver_version=solver_version, length_unit=length_unit, tags=tags, run_async=run_async, folder=folder, )
[docs] @classmethod @pd.validate_call(config={"arbitrary_types_allowed": True}) def from_surface_mesh( cls, file: str, /, name: str = None, solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, run_async: bool = False, folder: Optional[Folder] = None, ): """ Initializes a project from a local surface mesh file. Parameters ---------- file : str (positional argument only) Surface mesh file path. For UGRID file the mapbc file needs to be renamed with the same prefix under same folder. name : str, optional Name of the project (default is None). solver_version : str, optional Version of the solver (default is None). length_unit : LengthUnitType, optional Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). run_async : bool, optional Whether to create project asynchronously (default is False). folder : Optional[Folder], optional Parent folder for the project. If None, creates in root. Returns ------- Project An instance of the project. Or Project ID when run_async is True. Raises ------ Flow360FileError If the project cannot be initialized from the file. Example ------- >>> my_project = fl.Project.from_surface_mesh( ... "/path/to/my/mesh/my_mesh.ugrid", ... name="My_Project_name", ... solver_version="release-Major.Minor" ... length_unit="inch" ... tags=["Quarter 1", "Revision 2"] ... ) ==== """ try: validated_files = SurfaceMeshFile(file_names=file) except pd.ValidationError as err: # pylint:disable = raise-missing-from raise Flow360FileError(f"Surface mesh file error: {str(err)}") return cls._create_project_from_files( files=validated_files, name=name, solver_version=solver_version, length_unit=length_unit, tags=tags, run_async=run_async, folder=folder, )
[docs] @classmethod @pd.validate_call(config={"arbitrary_types_allowed": True}) def from_volume_mesh( cls, file: str, /, name: str = None, solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, run_async: bool = False, folder: Optional[Folder] = None, ): """ Initializes a project from a local volume mesh file. Parameters ---------- file : str (positional argument only) Volume mesh file path. For UGRID file the mapbc file needs to be renamed with the same prefix under same folder. name : str, optional Name of the project (default is None). solver_version : str, optional Version of the solver (default is None). length_unit : LengthUnitType, optional Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). run_async : bool, optional Whether to create project asynchronously (default is False). folder : Optional[Folder], optional Parent folder for the project. If None, creates in root. Returns ------- Project An instance of the project. Raises ------ Flow360FileError If the project cannot be initialized from the file. Or Project ID when run_async is True. Example ------- >>> my_project = fl.Project.from_volume_mesh( ... "/path/to/my/mesh/my_mesh.cgns", ... name="My_Project_name", ... solver_version="release-Major.Minor" ... length_unit="inch" ... tags=["Quarter 1", "Revision 2"] ... ) ==== """ try: validated_files = VolumeMeshFile(file_names=file) except pd.ValidationError as err: # pylint:disable = raise-missing-from raise Flow360FileError(f"Volume mesh file error: {str(err)}") return cls._create_project_from_files( files=validated_files, name=name, solver_version=solver_version, length_unit=length_unit, tags=tags, run_async=run_async, folder=folder, )
[docs] @classmethod @typing_extensions.deprecated( "Creating project with `from_file` is deprecated. Please use `from_geometry()`, " "`from_surface_mesh()` or `from_volume_mesh()` instead.", category=None, ) @pd.validate_call def from_file( cls, file: Union[str, list[str]], name: str = None, solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, run_async: bool = False, ): """ [Deprecated function] Initializes a project from a file. Parameters ---------- file : str Path to the file. name : str, optional Name of the project (default is None). solver_version : str, optional Version of the solver (default is None). length_unit : LengthUnitType, optional Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). run_async : bool, optional Whether to create project asynchronously (default is False). Returns ------- Project An instance of the project. Or Project ID when run_async is True. Raises ------ Flow360ValueError If the project cannot be initialized from the file. """ log.warning( "DeprecationWarning: Creating project with `from_file` is deprecated. " + "Please use `from_geometry()`, `from_surface_mesh()` " + "or `from_volume_mesh()` instead." ) def _detect_input_file_type(file: Union[str, list[str]]): errors = [] for model in [GeometryFiles, VolumeMeshFile]: try: return model(file_names=file) except pd.ValidationError as e: errors.append(e) raise Flow360FileError(f"Input file {file} cannot be recognized.\nErrors: {errors}") return cls._create_project_from_files( files=_detect_input_file_type(file=file), name=name, solver_version=solver_version, length_unit=length_unit, tags=tags, run_async=run_async, )
@classmethod def _get_user_requested_entity_info( cls, *, current_project_id: str, new_run_from: Optional[Union[Geometry, SurfaceMeshV2, VolumeMeshV2, Case]] = None, ): """ Get the entity info requested by the users when they specify `new_run_from` when calling Project.from_cloud() """ user_requested_entity_info = None if new_run_from is None: return user_requested_entity_info if new_run_from.project_id is None: # Can only happen to case created using V1 interface. raise ValueError( "The supplied case resource for `new_run_from` was created using old interface and " "cannot be used as the starting point of a new run." ) if current_project_id != new_run_from.project_id: raise ValueError( "The supplied cloud resource for `new_run_from` does not belong to the project." ) if isinstance(new_run_from, Case): user_requested_entity_info = new_run_from.get_simulation_params() if isinstance(new_run_from, (Geometry, SurfaceMeshV2, VolumeMeshV2)): user_requested_entity_info = new_run_from.params return user_requested_entity_info
[docs] @classmethod @pd.validate_call( config={"arbitrary_types_allowed": True} # Geometry etc do not have validate() defined ) def from_cloud( cls, project_id: str, *, new_run_from: Optional[Union[Geometry, SurfaceMeshV2, VolumeMeshV2, Case]] = None, ): """ Loads a project from the cloud. Parameters ---------- project_id : str ID of the project. new_run_from: Optional[Union[Geometry, SurfaceMeshV2, VolumeMeshV2, Case]] The cloud resource that the current run should be based on. The root asset will use entity settings (grouping, transformation etc) from this resource. This results in the same behavior when user clicks New run on webUI. By default this will be the root asset (what user uploaded) of the project. TODO: We can add 'last' as one option to automatically start from the latest created asset within the project. Returns ------- Project An instance of the project. Raises ------ Flow360WebError If the project cannot be loaded from the cloud. Flow360ValueError If the root asset cannot be retrieved for the project. """ project_info = AssetShortID(asset_id=project_id, asset_type="Project") project_api = RestApi(ProjectInterface.endpoint, id=project_info.asset_id) info = project_api.get() if not isinstance(info, dict): raise Flow360WebError( f"Cannot load project {project_info.asset_id}, missing project data." ) if not info: raise Flow360WebError(f"Couldn't retrieve project info for {project_info.asset_id}") meta = ProjectMeta(**info) root_asset = None root_type = meta.root_item_type if ( new_run_from is not None and isinstance(new_run_from, (Geometry, SurfaceMeshV2, VolumeMeshV2, Case)) is False ): # Should have been caught by the validate_call? raise ValueError( "The supplied `new_run_from` is not valid. Please check the function description for more details." ) entity_info_param = cls._get_user_requested_entity_info( current_project_id=project_info.asset_id, new_run_from=new_run_from ) if root_type == RootType.GEOMETRY: root_asset = Geometry.from_cloud(meta.root_item_id, entity_info_param=entity_info_param) elif root_type == RootType.SURFACE_MESH: root_asset = SurfaceMeshV2.from_cloud( meta.root_item_id, entity_info_param=entity_info_param ) elif root_type == RootType.VOLUME_MESH: root_asset = VolumeMeshV2.from_cloud( meta.root_item_id, entity_info_param=entity_info_param ) if not root_asset: raise Flow360ValueError(f"Couldn't retrieve root asset for {project_info.asset_id}") project = Project( metadata=meta, project_tree=ProjectTree(), solver_version=root_asset.solver_version ) project._project_webapi = project_api if root_type == RootType.GEOMETRY: project._root_asset = root_asset project._root_webapi = RestApi(GeometryInterface.endpoint, id=root_asset.id) elif root_type == RootType.SURFACE_MESH: project._root_asset = root_asset project._root_webapi = RestApi(SurfaceMeshInterfaceV2.endpoint, id=root_asset.id) elif root_type == RootType.VOLUME_MESH: project._root_asset = root_asset project._root_webapi = RestApi(VolumeMeshInterfaceV2.endpoint, id=root_asset.id) project._get_root_simulation_json() project._get_tree_from_cloud() return project
def _check_initialized(self): """ Checks if the project instance has been initialized correctly. Raises ------ Flow360ValueError If the project is not initialized. """ if not self.metadata or not self.solver_version or not self._root_asset: raise Flow360ValueError( "Project not initialized - use Project.from_file or Project.from_cloud" ) # pylint: disable=protected-access def _get_tree_from_cloud(self, destination_obj: AssetOrResource = None): """ Get the project tree from cloud. Parameters ---------- destination_obj : AssetOrResource The destination asset after submitting a job. If provided, only assets along the path to this asset will be fetched to update the local project tree. Otherwise, the entire project tree will be refreshed using the latest tree from cloud. """ self._check_initialized() asset_records = [] if destination_obj: method = "path" resp = self._project_webapi.get( method=method, params={ "itemId": destination_obj.id, "itemType": destination_obj._cloud_resource_type_name, }, ) for key, val in resp.items(): if not val: continue if key == "cases": asset_records += resp["cases"] continue asset_records.append(val) else: method = "tree" resp = self._project_webapi.get(method=method) asset_records = resp["records"] self.project_tree = ProjectTree() asset_records = sorted( asset_records, key=lambda d: parse_datetime(d["updatedAt"]), ) if method == "tree": self.project_tree.construct_tree(asset_records=asset_records) return False is_duplicate = True for record in asset_records: success = self.project_tree.add(asset_record=record) if success: is_duplicate = False return is_duplicate
[docs] def refresh_project_tree(self): """Refresh the local project tree by fetching the latest project tree from cloud.""" return self._get_tree_from_cloud()
[docs] def rename(self, new_name: str): """ Rename the current project. Parameters ---------- new_name : str The new name for the project. """ RestApi(ProjectInterface.endpoint).patch( RenameAssetRequestV2(name=new_name).dict(), method=self.id )
[docs] def print_project_tree(self, line_width: int = 30, is_horizontal: bool = True): """Print the project tree to the terminal. Parameters ---------- line_width : str The maximum number of characters in each line. is_horizontal : bool Choose if the project tree is printed in horizontal (default) or vertical direction. """ # pylint: disable=import-outside-toplevel # Defer importing since this package introduces 2 empty lines of output in the Jupyter Notebook when imported.. from PrettyPrint import PrettyPrintTree PrettyPrintTree( get_children=lambda x: x.children, get_val=lambda x: x.construct_string(line_width=line_width), get_label=lambda x: x.edge_label if x.edge_label else None, color="", border=True, orientation=PrettyPrintTree.Horizontal if is_horizontal else PrettyPrintTree.Vertical, )( self.project_tree.root, )
def _get_root_simulation_json(self): """ Loads the default simulation JSON for the project based on the root asset type. Raises ------ Flow360ValueError If the root item type or ID is missing from project metadata. Flow360WebError If the simulation JSON cannot be retrieved. """ self._check_initialized() root_type = self.metadata.root_item_type root_id = self.metadata.root_item_id if not root_type or not root_id: raise Flow360ValueError("Root item type or ID is missing from project metadata") resp = self._root_webapi.get(method="simulation/file", params={"type": "simulation"}) if not isinstance(resp, dict) or "simulationJson" not in resp: raise Flow360WebError("Couldn't retrieve default simulation JSON for the project") simulation_json = json.loads(resp["simulationJson"]) self._root_simulation_json = simulation_json # pylint: disable=too-many-arguments, too-many-locals def _run( self, *, params: SimulationParams, target: AssetOrResource, draft_name: str, fork_from: Case, interpolate_to_mesh: VolumeMeshV2, run_async: bool, solver_version: str, use_beta_mesher: bool, use_geometry_AI: bool, raise_on_error: bool, tags: List[str], draft_only: bool, **kwargs, ): """ Runs a simulation for the project. Parameters ---------- params : SimulationParams The simulation parameters to use for the run target : AssetOrResource The target asset or resource to run the simulation against. draft_name : str, optional The name of the draft to create for the simulation run (default is None). fork_from : Case, optional The parent case to fork from if fork (default is None). interpolate_to_mesh : VolumeMeshV2, optional If specified, forked case will interpolate parent case's results to this mesh before running solver. run_async : bool, optional Specifies whether the simulation should run asynchronously (default is True). use_beta_mesher : bool, optional Whether to use the beta mesher (default is None). Must be True when using Geometry AI. use_geometry_AI : bool, optional Whether to use the Geometry AI (default is False). raise_on_error: bool, optional Option to raise if submission error occurs tags: List[str], optional A list of tags to add to the target asset. draft_only: bool, optional Whether to only create and submit a draft and not run the simulation. Returns ------- AssetOrResource The destination asset or the draft if `draft_only` is True. Raises ------ Flow360ValueError If the simulation parameters lack required length unit information, or if the root asset (Geometry or VolumeMesh) is not initialized. """ # pylint: disable=too-many-branches if use_beta_mesher is None: if use_geometry_AI is True: log.info("Beta mesher is enabled to use Geometry AI.") use_beta_mesher = True else: use_beta_mesher = False if use_geometry_AI is True and use_beta_mesher is False: raise Flow360ValueError("Enabling Geometry AI requires also enabling beta mesher.") params = set_up_params_for_uploading( params=params, root_asset=self._root_asset, length_unit=self.length_unit, use_beta_mesher=use_beta_mesher, use_geometry_AI=use_geometry_AI, ) params, errors = validate_params_with_context( params=params, root_item_type=self.metadata.root_item_type.value, up_to=target._cloud_resource_type_name, ) if errors is not None: log.error( f"Validation error found during local validation: {formatting_validation_errors(errors=errors)}" ) if raise_on_error: raise ValueError("Submission terminated due to local validation error.") return None source_item_type = self.metadata.root_item_type.value if fork_from is None else "Case" start_from = kwargs.get("start_from", None) job_tags = kwargs.get("job_tags", None) all_tags = [] if tags is not None: all_tags += tags if job_tags is not None: all_tags += job_tags draft = Draft.create( name=draft_name, project_id=self.metadata.id, source_item_id=self.metadata.root_item_id if fork_from is None else fork_from.id, source_item_type=source_item_type, solver_version=solver_version if solver_version else self.solver_version, fork_case=fork_from is not None, interpolation_volume_mesh_id=interpolate_to_mesh.id if interpolate_to_mesh else None, tags=all_tags, ).submit() params.pre_submit_summary() draft.update_simulation_params(params) upload_imported_surfaces_to_draft(params, draft, fork_from) if draft_only: # pylint: disable=import-outside-toplevel import click log.info("Draft submitted, copy the link to browser to view the draft:") # Not using log.info to avoid the link being wrapped and thus not clickable. click.secho(draft.web_url, fg="blue", underline=True) return draft try: destination_id = draft.run_up_to_target_asset( target, source_item_type=source_item_type, use_beta_mesher=use_beta_mesher, use_geometry_AI=use_geometry_AI, start_from=start_from, ) except RuntimeError as exception: if raise_on_error: raise ValueError("Submission terminated due to validation error.") from exception return None self._project_webapi.patch( # pylint: disable=protected-access json={ "lastOpenItemId": destination_id, "lastOpenItemType": target._cloud_resource_type_name, } ) destination_obj = target.from_cloud(destination_id) # Remove when converting Case to V2 kwargs = {} if isinstance(destination_obj, Case): kwargs = {"project_id": destination_obj.project_id} log.info(f"Successfully submitted: {destination_obj.short_description(**kwargs)}") if not run_async: destination_obj.wait() is_duplicate = self._get_tree_from_cloud(destination_obj=destination_obj) if is_duplicate: target_asset_type = target._cloud_resource_type_name log.warning( f"The {target_asset_type} that matches the input already exists in project. " f"No new {target_asset_type} will be generated." ) return destination_obj
[docs] @pd.validate_call def generate_surface_mesh( self, params: SimulationParams, name: str = "SurfaceMesh", run_async: bool = True, solver_version: str = None, use_beta_mesher: bool = None, use_geometry_AI: bool = False, # pylint: disable=invalid-name raise_on_error: bool = True, tags: List[str] = None, draft_only: bool = False, **kwargs, ): """ Runs the surface mesher for the project. Parameters ---------- params : SimulationParams Simulation parameters for running the mesher. name : str, optional Name of the surface mesh (default is "SurfaceMesh"). run_async : bool, optional Whether to run the mesher asynchronously (default is True). solver_version : str, optional Optional solver version to use during this run (defaults to the project solver version) use_beta_mesher : bool, optional Whether to use the beta mesher (default is None). Must be True when using Geometry AI. use_geometry_AI : bool, optional Whether to use the Geometry AI (default is False). raise_on_error: bool, optional Option to raise if submission error occurs (default is True) tags: List[str], optional A list of tags to add to the generated surface mesh. draft_only: bool, optional Whether to only create and submit a draft and not generate the surface mesh. Raises ------ Flow360ValueError If the root item type is not Geometry. Returns ------- SurfaceMeshV2 | Draft The surface mesh asset or the draft if `draft_only` is True. """ self._check_initialized() if self.metadata.root_item_type is not RootType.GEOMETRY: raise Flow360ValueError( "Surface mesher can only be run by projects with a geometry root asset" ) surface_mesh = self._run( params=params, target=SurfaceMeshV2, draft_name=name, run_async=run_async, fork_from=None, interpolate_to_mesh=None, solver_version=solver_version, use_beta_mesher=use_beta_mesher, use_geometry_AI=use_geometry_AI, raise_on_error=raise_on_error, tags=tags, draft_only=draft_only, **kwargs, ) return surface_mesh
[docs] @pd.validate_call def generate_volume_mesh( self, params: SimulationParams, name: str = "VolumeMesh", run_async: bool = True, solver_version: str = None, use_beta_mesher: bool = None, use_geometry_AI: bool = False, # pylint: disable=invalid-name raise_on_error: bool = True, tags: List[str] = None, draft_only: bool = False, **kwargs, ): """ Runs the volume mesher for the project. Parameters ---------- params : SimulationParams Simulation parameters for running the mesher. name : str, optional Name of the volume mesh (default is "VolumeMesh"). run_async : bool, optional Whether to run the mesher asynchronously (default is True). solver_version : str, optional Optional solver version to use during this run (defaults to the project solver version) use_beta_mesher : bool, optional Whether to use the beta mesher (default is None). Must be True when using Geometry AI. use_geometry_AI : bool, optional Whether to use the Geometry AI (default is False). raise_on_error: bool, optional Option to raise if submission error occurs (default is True) tags: List[str], optional A list of tags to add to the generated volume mesh. draft_only: bool, optional Whether to only create and submit a draft and not generate the volume mesh. Raises ------ Flow360ValueError If the root item type is not Geometry. Returns ------- VolumeMeshV2 | Draft The volume mesh asset or the draft if `draft_only` is True. """ self._check_initialized() if ( self.metadata.root_item_type is not RootType.GEOMETRY and self.metadata.root_item_type is not RootType.SURFACE_MESH ): raise Flow360ValueError( "Volume mesher can only be run by projects with a geometry or surface mesh root asset" ) volume_mesh_or_draft = self._run( params=params, target=VolumeMeshV2, draft_name=name, run_async=run_async, fork_from=None, interpolate_to_mesh=None, solver_version=solver_version, use_beta_mesher=use_beta_mesher, use_geometry_AI=use_geometry_AI, raise_on_error=raise_on_error, tags=tags, draft_only=draft_only, **kwargs, ) if draft_only: draft = volume_mesh_or_draft return draft volume_mesh = volume_mesh_or_draft return volume_mesh
[docs] @pd.validate_call(config={"arbitrary_types_allowed": True}) def run_case( self, params: SimulationParams, name: str = "Case", run_async: bool = True, fork_from: Optional[Case] = None, interpolate_to_mesh: Optional[VolumeMeshV2] = None, solver_version: str = None, use_beta_mesher: bool = None, use_geometry_AI: bool = False, # pylint: disable=invalid-name raise_on_error: bool = True, tags: List[str] = None, draft_only: bool = False, **kwargs, ): """ Runs a case for the project. Parameters ---------- params : SimulationParams Simulation parameters for running the case. name : str, optional Name of the case (default is "Case"). run_async : bool, optional Whether to run the case asynchronously (default is True). fork_from : Case, optional Which Case we should fork from (if fork). interpolate_to_mesh : VolumeMeshV2, optional If specified, forked case will interpolate parent case results to this mesh before running solver. solver_version : str, optional Optional solver version to use during this run (defaults to the project solver version) use_beta_mesher : bool, optional Whether to use the beta mesher (default is None). Must be True when using Geometry AI. use_geometry_AI : bool, optional Whether to use the Geometry AI (default is False). raise_on_error: bool, optional Option to raise if submission error occurs (default is True) tags: List[str], optional A list of tags to add to the case. draft_only: bool, optional Whether to only create and submit a draft and not run the case. Returns ------- Case | Draft The case asset or the draft if `draft_only` is True. """ if interpolate_to_mesh is not None and fork_from is None: raise Flow360ConfigError( "Interpolation to mesh is only supported when forking from a case." ) self._check_initialized() case_or_draft = self._run( params=params, target=Case, draft_name=name, run_async=run_async, fork_from=fork_from, interpolate_to_mesh=interpolate_to_mesh, solver_version=solver_version, use_beta_mesher=use_beta_mesher, use_geometry_AI=use_geometry_AI, raise_on_error=raise_on_error, tags=tags, draft_only=draft_only, **kwargs, ) if draft_only: draft = case_or_draft return draft case = case_or_draft report_template = get_default_report_summary_template() report_template.create_in_cloud( name=f"{name}-summary", cases=[case], solver_version=solver_version if solver_version else self.solver_version, ) return case