Source code for flow360.component.surface_mesh_v2

"""
Surface Mesh cloud asset (V2)
"""

from __future__ import annotations

import os
import threading
from enum import Enum
from functools import cached_property
from typing import Any, List, Optional

import pydantic as pd
from flow360_schema.models.entities.surface_entities import Surface
from flow360_schema.models.simulation.framework.updater_utils import (
    deprecation_reminder,
)

from flow360.cloud.flow360_requests import (
    LengthUnitType,
    NewSurfaceMeshDependencyRequest,
    NewSurfaceMeshRequestV2,
)
from flow360.cloud.heartbeat import post_upload_heartbeat
from flow360.cloud.rest_api import RestApi
from flow360.component.interfaces import SurfaceMeshInterfaceV2
from flow360.component.resource_base import (
    AssetMetaBaseModelV2,
    Flow360Resource,
    ResourceDraft,
    SubmissionMode,
)
from flow360.component.resource_status import is_final_resource_status
from flow360.component.simulation.folder import Folder
from flow360.component.simulation.web.asset_base import AssetBase
from flow360.component.utils import (
    MeshNameParser,
    SurfaceMeshFile,
    resolve_length_unit,
    shared_account_confirm_proceed,
)
from flow360.environment import current_environment
from flow360.exceptions import Flow360FileError, Flow360RuntimeError, Flow360ValueError
from flow360.log import log


class SurfaceMeshStatusV2(Enum):
    """Status of surface mesh resource, the is_final method is overloaded"""

    # pylint: disable=duplicate-code
    # We should unify the status enums for all resources and then remove the duplicate code warning.
    ERROR = "error"
    UPLOADED = "uploaded"
    UPLOADING = "uploading"
    RUNNING = "running"
    GENERATING = "generating"
    PROCESSED = "processed"
    DELETED = "deleted"
    PENDING = "pending"
    COMPLETED = "completed"

    def is_final(self):
        """
        Checks if status is final for geometry resource

        Returns
        -------
        bool
            True if status is final, False otherwise.
        """
        return is_final_resource_status("SurfaceMesh", self)


# pylint: disable=R0801
class SurfaceMeshMetaV2(AssetMetaBaseModelV2):
    """
    SurfaceMeshMeta component
    """

    file_name: Optional[str] = pd.Field(None, alias="fileName")
    status: SurfaceMeshStatusV2 = pd.Field()  # Overshadowing to ensure correct is_final() method
    dependency: bool = pd.Field(False)


[docs] class SurfaceMeshStats(pd.BaseModel): """ Statistics describing the contents of a surface mesh. """ n_nodes: int = pd.Field( alias="nNodes", description="Total number of nodes (vertices) in the surface mesh." ) n_triangles: int = pd.Field( alias="nTriangles", description="Number of triangular surface elements." ) n_quadrilaterals: int = pd.Field( alias="nQuadrilaterals", description="Number of quadrilateral surface elements." ) version: Optional[str] = pd.Field( None, description="Version identifier of the statistics record, when available." )
class SurfaceMeshDraftV2(ResourceDraft): """ Unified Surface Mesh Draft component for uploading surface mesh files. This class handles both: - Creating a new project with surface mesh as the root asset - Adding surface mesh as a dependency to an existing project The submission mode is determined by how the draft is created (via factory methods on the SurfaceMeshV2 class) and affects the behavior of the submit() method. All surface meshes are conceptually equivalent - they are components that can be used to create the final mesh for simulation. The distinction between "root" and "dependency" is only about where the surface mesh is uploaded (new project vs existing project), not about any fundamental difference in the surface mesh itself. """ # pylint: disable=too-many-arguments, too-many-instance-attributes def __init__( self, file_names: str, project_name: str = None, solver_version: str = None, length_unit: Optional[LengthUnitType] = None, tags: List[str] = None, folder: Optional[Folder] = None, ): """ Initialize a SurfaceMeshDraftV2 with common attributes. For creating a new project (root surface mesh): Use SurfaceMeshV2.from_file() which sets project_name, solver_version, folder For adding to existing project (dependency surface mesh): Use SurfaceMeshV2.import_to_project() which sets the dependency context Parameters ---------- file_names : str Path to the surface mesh file length_unit : LengthUnitType, optional Unit of length (default is "m") tags : List[str], optional Tags to assign to the surface mesh (default is None) project_name : str, optional Name of the project (for project root mode) solver_version : str, optional Solver version (for project root mode) folder : Optional[Folder], optional Parent folder (for project root mode) """ self._file_name = file_names self.project_name = project_name self.tags = tags if tags is not None else [] self.length_unit = resolve_length_unit(length_unit) self.solver_version = solver_version self.folder = folder # pylint: disable=fixme # TODO: create a DependableResourceDraft for GeometryDraft and SurfaceMeshDraft self.dependency_name = None self.dependency_project_id = None self._submission_mode: SubmissionMode = SubmissionMode.PROJECT_ROOT self._validate_surface_mesh() ResourceDraft.__init__(self) def _validate_surface_mesh(self): """Validate surface mesh file and length unit.""" if self._file_name is not None: try: SurfaceMeshFile(file_names=self._file_name) except pd.ValidationError as e: raise Flow360FileError(str(e)) from e if self.length_unit not in LengthUnitType.__args__: raise Flow360ValueError( f"specified length_unit : {self.length_unit} is invalid. " f"Valid options are: {list(LengthUnitType.__args__)}" ) def _set_default_project_name(self): """Set default project name if not provided for project creation.""" if self.project_name is None: self.project_name = os.path.splitext(os.path.basename(self._file_name))[0] log.warning( "`project_name` is not provided. " f"Using the file name {self.project_name} as project name." ) def _validate_submission_context(self): """Validate context for submission based on mode.""" if self._submission_mode is None: raise ValueError("[Internal] Surface Mesh Submission context not set.") if self._submission_mode == SubmissionMode.PROJECT_ROOT and self.solver_version is None: raise Flow360ValueError("solver_version field is required.") if self._submission_mode == SubmissionMode.PROJECT_DEPENDENCY: if self.dependency_name is None or self.dependency_project_id is None: raise ValueError( "[Internal] Dependency name and project ID must be set for surface mesh dependency submission." ) def set_dependency_context( self, name: str, project_id: str, ) -> None: """ Configure this draft to add surface mesh to an existing project. Called internally by SurfaceMeshV2.import_to_project(). """ self._submission_mode = SubmissionMode.PROJECT_DEPENDENCY self.dependency_name = name self.dependency_project_id = project_id def _create_project_root_resource(self, description: str = "") -> SurfaceMeshMetaV2: """Create a new surface mesh resource that will be the root of a new project.""" self._set_default_project_name() req = NewSurfaceMeshRequestV2( name=self.project_name, solver_version=self.solver_version, tags=self.tags, file_name=self._file_name, parent_folder_id=self.folder.id if self.folder else "ROOT.FLOW360", length_unit=self.length_unit, description=description, ) resp = RestApi( SurfaceMeshInterfaceV2.endpoint, environment_provider=current_environment ).post(req.dict()) return SurfaceMeshMetaV2(**resp) def _create_dependency_resource( self, description: str = "", draft_id: str = "", icon: str = "" ) -> SurfaceMeshMetaV2: """Create a surface mesh resource as a dependency in an existing project.""" req = NewSurfaceMeshDependencyRequest( name=self.dependency_name, project_id=self.dependency_project_id, draft_id=draft_id, file_name=self._file_name, length_unit=self.length_unit, tags=self.tags, description=description, icon=icon, ) resp = RestApi( SurfaceMeshInterfaceV2.endpoint, environment_provider=current_environment ).post(req.dict(), method="dependency") return SurfaceMeshMetaV2(**resp) def _upload_files( self, info: SurfaceMeshMetaV2, progress_callback=None, ) -> SurfaceMeshV2: """Upload surface mesh files to the cloud.""" # pylint: disable=protected-access surface_mesh = SurfaceMeshV2(info.id) heartbeat_info = {"resourceId": info.id, "resourceType": "SurfaceMesh", "stop": False} # Keep posting the heartbeat to keep server patient about uploading. heartbeat_thread = threading.Thread(target=post_upload_heartbeat, args=(heartbeat_info,)) heartbeat_thread.start() try: surface_mesh._webapi._upload_file( remote_file_name=info.file_name, file_name=self._file_name, progress_callback=progress_callback, ) mesh_parser = MeshNameParser(self._file_name) remote_mesh_parser = MeshNameParser(info.file_name) if mesh_parser.is_ugrid(): # Upload the mapbc file too. expected_local_mapbc_file = mesh_parser.get_associated_mapbc_filename() if os.path.isfile(expected_local_mapbc_file): surface_mesh._webapi._upload_file( remote_file_name=remote_mesh_parser.get_associated_mapbc_filename(), file_name=mesh_parser.get_associated_mapbc_filename(), progress_callback=progress_callback, ) else: log.warning( f"The expected mapbc file {expected_local_mapbc_file} specifying " "user-specified boundary names doesn't exist." ) finally: heartbeat_info["stop"] = True heartbeat_thread.join() # Kick off pipeline surface_mesh._webapi._complete_upload() # Setting _id will disable "WARNING: You have not submitted..." warning message self._id = info.id return surface_mesh # pylint: disable=protected-access # pylint: disable=duplicate-code def submit( self, description: str = "", progress_callback=None, run_async: bool = False, draft_id: str = "", icon: str = "", ) -> SurfaceMeshV2: """ Submit surface mesh to cloud. The behavior depends on how this draft was created: - If created via SurfaceMeshV2.from_file(): Creates a new project with this surface mesh as root - If created via SurfaceMeshV2.import_to_project(): Adds surface mesh to an existing project Parameters ---------- description : str, optional Description of the surface mesh/project (default is "") progress_callback : callback, optional Use for custom progress bar (default is None) run_async : bool, optional Whether to return immediately after upload without waiting for processing (default is False) draft_id : str, optional ID of the draft to add surface mesh to (only used for dependency mode, default is "") icon : str, optional Icon for the surface mesh (only used for dependency mode, default is "") Returns ------- SurfaceMeshV2 SurfaceMeshV2 object with id Raises ------ Flow360ValueError If submission context is not set or user aborts """ self._validate_surface_mesh() self._validate_submission_context() if not shared_account_confirm_proceed(): raise Flow360ValueError("User aborted resource submit.") # Create the surface mesh resource based on submission mode if self._submission_mode == SubmissionMode.PROJECT_ROOT: info = self._create_project_root_resource(description) log_message = "Surface mesh successfully submitted" else: info = self._create_dependency_resource(description, draft_id, icon) log_message = "New surface mesh successfully submitted to the project" # Upload files surface_mesh = self._upload_files(info, progress_callback) log.info(f"{log_message}: {surface_mesh.short_description()}") if run_async: return surface_mesh log.info("Waiting for surface mesh to be processed.") surface_mesh._webapi.get_info() # uses from_cloud to ensure all metadata is ready before yielding the object return SurfaceMeshV2.from_cloud(info.id) class SurfaceMeshV2(AssetBase): """ Surface mesh component for workbench (simulation V2) """ _interface_class = SurfaceMeshInterfaceV2 _meta_class = SurfaceMeshMetaV2 _draft_class = SurfaceMeshDraftV2 _web_api_class = Flow360Resource _mesh_stats_file = "metadata/surfaceMeshMetaData.json" _cloud_resource_type_name = "SurfaceMesh" _default_download_patterns = [ f"*{pattern}" for pattern in MeshNameParser.all_patterns("surface") ] + ["*.mapbc"] @cached_property def stats(self) -> SurfaceMeshStats: """ Surface mesh statistics, including the node count and the number of triangular and quadrilateral surface elements. The statistics are fetched from the cloud the first time this property is accessed and cached thereafter. The surface mesh must have finished processing for the statistics to be available. Returns ------- SurfaceMeshStats Object exposing ``n_nodes``, ``n_triangles``, ``n_quadrilaterals`` and ``version``. Raises ------ Flow360RuntimeError If the statistics cannot be retrieved, for example when the surface mesh is not ready yet. """ try: # pylint: disable=protected-access data = self._webapi._parse_json_from_cloud(self._mesh_stats_file) except Exception as exc: try: status_str = self._webapi.status.value except Exception: # pylint: disable=broad-exception-caught status_str = "unknown" raise Flow360RuntimeError( "Could not retrieve surface mesh stats. The surface mesh may not be ready yet " f"(status={status_str})." ) from exc return SurfaceMeshStats(**data) @classmethod # pylint: disable=redefined-builtin def from_cloud(cls, id: str, **kwargs) -> SurfaceMeshV2: """ Parameters ---------- id : str ID of the surface mesh resource in the cloud Returns ------- SurfaceMeshV2 Surface mesh object """ asset_obj = super().from_cloud(id, **kwargs) return asset_obj @classmethod def from_local_storage( cls, mesh_id: str = None, local_storage_path="", meta_data: SurfaceMeshMetaV2 = None ) -> SurfaceMeshV2: """ Parameters ---------- mesh_id : str ID of the surface mesh resource local_storage_path: The folder of the project, defaults to current working directory Returns ------- SurfaceMeshV2 Surface mesh object """ return super()._from_local_storage( asset_id=mesh_id, local_storage_path=local_storage_path, meta_data=meta_data ) @classmethod # pylint: disable=too-many-arguments,arguments-renamed def from_file( cls, file_name: str, project_name: str = None, solver_version: str = None, length_unit: Optional[LengthUnitType] = None, tags: List[str] = None, folder: Optional[Folder] = None, ) -> SurfaceMeshDraftV2: """ Parameters ---------- file_name : str The name of the input surface mesh file (``*.cgns``, ``*.ugrid``) project_name : str, optional The name of the newly created project, defaults to file name if empty solver_version: str Solver version to use for the project length_unit: LengthUnitType Length unit to use for the project ("m", "mm", "cm", "inch", "ft") tags: List[str] List of string tags to be added to the project upon creation folder : Optional[Folder], optional Parent folder for the project. If None, creates in root. Returns ------- SurfaceMeshDraftV2 Draft of the surface mesh to be submitted """ # For type hint only but proper fix is to fully abstract the Draft class too. return super().from_file( file_names=file_name, project_name=project_name, solver_version=solver_version, length_unit=length_unit, tags=tags, folder=folder, ) @classmethod # pylint: disable=too-many-arguments def import_to_project( cls, name: str, file_name: str, project_id: str, length_unit: Optional[LengthUnitType] = None, tags: List[str] = None, ) -> SurfaceMeshDraftV2: """ Create a surface mesh draft for adding to an existing project. This creates a surface mesh that will be added as a supplementary component (dependency) to an existing project, rather than creating a new project. Parameters ---------- name : str Name for the surface mesh component file_name : str Path to the surface mesh file (``*.cgns``, ``*.ugrid``) project_id : str ID of the existing project to add this surface mesh to length_unit : LengthUnitType, optional Unit of length (default is "m") tags : List[str], optional Tags to assign to the surface mesh (default is None) Returns ------- SurfaceMeshDraftV2 A draft configured for submission to an existing project """ draft = SurfaceMeshDraftV2( file_names=file_name, length_unit=length_unit, tags=tags, ) draft.set_dependency_context(name=name, project_id=project_id) return draft # pylint: disable=useless-parent-delegation def _apply_dynamic_default_settings(self, simulation_dict: dict): """Populate the default surface mesh settings from the simulation dict""" return super()._apply_dynamic_default_settings(simulation_dict) @property def boundary_names(self) -> List[str]: """ Retrieve all boundary names available in this surface mesh as a list Returns ------- List[str] List of boundary names contained within the surface mesh """ self.internal_registry = self._entity_info.get_persistent_entity_registry( self.internal_registry ) # pylint: disable=protected-access return [surface.name for surface in self.internal_registry.view(Surface)._entities] @deprecation_reminder( "accessing entities via `surface_mesh[...]` is deprecated and will be replaced " "by a new API in a future release.", ) def __getitem__(self, key: str): """ Parameters ---------- key : str The name of the entity to be found .. deprecated:: Accessing entities via `surface_mesh[...]` will be replaced by a new API in a future release. Returns ------- Surface The boundary object """ # pylint: disable=import-outside-toplevel from flow360.component.simulation.draft_context import get_active_draft if get_active_draft() is not None: log.warning( "Accessing entities via asset[key] while a DraftContext is active. " "Use draft.surfaces[key] instead to ensure " "modifications are tracked in the draft's entity_info." ) if isinstance(key, str) is False: raise Flow360ValueError(f"Entity naming pattern: {key} is not a string.") self.internal_registry = self._entity_info.get_persistent_entity_registry( self.internal_registry ) return self.internal_registry.find_by_naming_pattern( key, enforce_output_as_list=False, error_when_no_match=True ) def __setitem__(self, key: str, value: Any): raise NotImplementedError("Assigning/setting entities is not supported.")