Source code for flow360.plugins.report.report

"""
Report generation interface
"""

import os
from typing import Annotated, Callable, List, Optional, Set, Union

import pydantic as pd
from flow360_schema.framework.base_model import Flow360BaseModel

# this plugin is optional, thus pylatex is not required: TODO add handling of installation of pylatex
# pylint: disable=import-error
from pylatex import Section, Subsection

from flow360.cloud.flow360_requests import NewReportRequest
from flow360.cloud.rest_api import RestApi
from flow360.component.case import Case
from flow360.component.interfaces import ReportInterface
from flow360.component.resource_base import AssetMetaBaseModel, Flow360Resource
from flow360.component.utils import validate_type
from flow360.environment import current_environment
from flow360.plugins.report.report_context import ReportContext
from flow360.plugins.report.report_doc import ReportDoc
from flow360.plugins.report.report_items import (
    Chart,
    Chart2D,
    Chart3D,
    FileNameStr,
    Inputs,
    NonlinearResiduals,
    Settings,
    Summary,
    Table,
)
from flow360.plugins.report.utils import (
    Average,
    DataItem,
    RequirementItem,
    get_requirements_from_data_path,
)
from flow360.plugins.report.uvf_shutter import ShutterBatchService
from flow360.version import __solver_version__


class Report(Flow360Resource):
    """
    Report component for interacting with cloud
    """

    # pylint: disable=redefined-builtin
    def __init__(self, id: str):
        super().__init__(
            interface=ReportInterface,
            meta_class=AssetMetaBaseModel,
            id=id,
        )

    @classmethod
    def _from_meta(cls, meta: AssetMetaBaseModel):
        validate_type(meta, "meta", AssetMetaBaseModel)
        report = cls(id=meta.id)
        report._set_meta(meta)
        return report

    def download(self, file_name, to_file=None, to_folder=".", overwrite: bool = True):
        """
        Download the file to the specified location.

        Parameters
        ----------
        file_name : str
            File name to download.
        to_file : str, optional
            The name of the file after downloading.
        to_folder : str, optional
            The folder where the file will be downloaded.
        overwrite : bool, optional
            Flag indicating whether to overwrite existing files.
        """
        # pylint: disable=no-member
        return self._download_file(
            file_name="results/" + file_name,
            to_file=to_file,
            to_folder=to_folder,
            overwrite=overwrite,
        )


# pylint: disable=too-few-public-methods
class ReportDraft:
    """
    ReportDraft interface
    """

    _webapi = RestApi(ReportInterface.endpoint, environment_provider=current_environment)

    # pylint: disable=too-many-arguments
    @classmethod
    def submit(
        cls,
        name: str,
        case_ids: List[str],
        config: str,
        landscape: bool = True,  # pylint: disable=unused-argument
        solver_version: str = None,
    ):
        """
        Submits a new report request.

        Parameters
        ----------
        name : str
            The name of the report.
        case_ids : List[str]
            List of case IDs to include in the report.
        config : str
            JSON configuration for report settings.
        landscape : bool, default=True
            Whether the report should be landscape-oriented (unused argument).
        solver_version : str, optional
            Version of the solver for report generation.

        Returns
        -------
        Response
            The response object from the web API post request.
        """

        request = NewReportRequest(
            name=name,
            resources=[{"type": "Case", "id": id} for id in case_ids],
            config_json=config,
            solver_version=solver_version,
        )
        resp = cls._webapi.post(json=request.dict())
        return Report(resp["id"])


ReportItemTypes = Annotated[
    Union[Summary, Inputs, Table, NonlinearResiduals, Chart2D, Chart3D],
    pd.Field(discriminator="type_name"),
]


[docs] class ReportTemplate(Flow360BaseModel): """ A model representing a report containing various components such as summaries, inputs, tables, and charts in both 2D and 3D. """ title: Optional[str] = pd.Field(None, description="Title of report, shown on the first page.") items: List[ReportItemTypes] = pd.Field( description="A list of report items, each of which can be a summary, input data, table, 2D chart, or 3D chart." ) include_case_by_case: bool = pd.Field( False, description="Flag indicating whether to include a case-by-case analysis in the report.", ) settings: Optional[Settings] = Settings()
[docs] @pd.model_validator(mode="after") def check_fig_names(self): """Validate and assign unique fig_names to report items.""" used_fig_names: Set[str] = set() for idx, item in enumerate(self.items): if hasattr(item, "fig_name"): fig_name = getattr(item, "fig_name", None) if not fig_name: class_name = item.__class__.__name__ fig_name = f"{class_name}_{idx}" item.fig_name = fig_name else: fig_name = item.fig_name if fig_name in used_fig_names: raise ValueError( f"Duplicate fig_name '{fig_name}' found in item at index {idx}" ) used_fig_names.add(fig_name) return self
# pylint: disable=protected-access def _generate_shutter_screenshots(self, context: ReportContext): service = ShutterBatchService() for chart in self.items: # pylint: disable=not-an-iterable if isinstance(chart, Chart3D): for case in context.cases: if not chart._fig_exist(case.id, context.data_storage): req = chart._get_shutter_request(case) service.add_request(req) elif isinstance(chart, Chart2D): chart3d, reference_case = chart.get_background_chart3d(context.cases) if chart3d is not None: if not chart3d._fig_exist(reference_case.id, context.data_storage): req = chart3d._get_shutter_request(reference_case) if req is not None: service.add_request(req) service.process_requests(context) def _get_baseline_requirements(self): return get_requirements_from_data_path( ["volume_mesh", "surface_mesh", "geometry", "params"] )
[docs] def get_requirements(self) -> List[RequirementItem]: """ Collects and returns unique requirements from all items. This method iterates over all items, retrieves each item's requirements, and aggregates them into a unique list. Returns ------- list A list of unique requirements aggregated from all items. """ requirements = set() for item in self.items: # pylint: disable=not-an-iterable for req in item.get_requirements(): requirements.add(req) for req in self._get_baseline_requirements(): requirements.add(req) return list(requirements)
# pylint: disable=unused-argument
[docs] def create_in_cloud( self, name: str, cases: list[Case], landscape: bool = False, solver_version: str = __solver_version__, ): """ Creates a report in the cloud for a specified set of cases. Parameters ---------- name : str The name of the report to create. cases : list[Case] A list of `Case` instances to include in the report. landscape : bool, default=False Orientation of the report, where `True` represents landscape. solver_version : str, optional Version of the solver for report generation. Returns ------- Response The response from the Report API submission. """ return ReportDraft.submit( name=name, case_ids=[case.id for case in cases], config=self.model_dump_json(), solver_version=solver_version, )
# pylint: disable=too-many-arguments
[docs] @pd.validate_call(config={"arbitrary_types_allowed": True}) def create_pdf( self, filename: FileNameStr, cases: list[Case], landscape: bool = True, data_storage: str = ".", shutter_url: str = None, shutter_access_token: str = None, shutter_screenshot_process_function: Callable = None, process_screenshot_in_parallel: bool = True, ) -> None: """ Generates a PDF report for a specified set of cases. Parameters ---------- filename : str The name of the output PDF file. cases : list[Case] A list of `Case` instances to include in the PDF report. landscape : bool, default=False Orientation of the report, where `True` represents landscape. data_storage : str, default="." Directory where the PDF file will be saved. Returns ------- None """ os.makedirs(data_storage, exist_ok=True) report_doc = ReportDoc(title=self.title, landscape=landscape) context = ReportContext( cases=cases, doc=report_doc.doc, data_storage=data_storage, process_screenshot_in_parallel=process_screenshot_in_parallel, shutter_url=shutter_url, shutter_access_token=shutter_access_token, shutter_screenshot_process_function=shutter_screenshot_process_function, ) case_context = context.model_copy( update={ "section_func": Subsection, "case_by_case": True, } ) self._generate_shutter_screenshots(context) for item in self.items: # pylint: disable=not-an-iterable item.get_doc_item(context, self.settings) if self.include_case_by_case is True: with report_doc.doc.create(Section("Appendix", numbering=False)): for case in cases: with report_doc.doc.create(Section(f"Case: {case.id}")): case_context = case_context.model_copy(update={"cases": [case]}) for item in self.items: # pylint: disable=not-an-iterable if isinstance(item, Chart) and item.select_indices is not None: selected_case_ids = [cases[i].id for i in item.select_indices] if case.id not in selected_case_ids: continue item.get_doc_item(case_context, self.settings) report_doc.generate_pdf(os.path.join(data_storage, filename))
def get_default_report_summary_template() -> ReportTemplate: """ Returns default report template for result summary. """ avg = Average(fraction=0.1) data = [ "volume_mesh/bounding_box/length", "volume_mesh/bounding_box/height", "volume_mesh/bounding_box/width", "params/reference_geometry/moment_length", "params/reference_geometry/area", DataItem(data="surface_forces/totalCL", title="CL", operations=avg), DataItem(data="surface_forces/totalCD", title="CD", operations=avg), DataItem(data="surface_forces/totalCFy", title="CFy", operations=avg), DataItem(data="surface_forces/totalCMx", title="CMx", operations=avg), DataItem(data="surface_forces/totalCMy", title="CMy", operations=avg), DataItem(data="surface_forces/totalCMz", title="CMz", operations=avg), ] headers = [ "OAL", "OAH", "OAW", "Reference Length", "Reference Area", "CL", "CD", "CFy", "CMx", "CMy", "CMz", ] table = Table(data=data, section_title="result_summary", headers=headers) return ReportTemplate(items=[table], settings=Settings(dump_table_csv=True))