Source code for flexcompute.flow_report.spec

"""User-facing report configuration and internal config compilation."""

from __future__ import annotations

import math
import re
from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Any, Literal, TypeAlias
from urllib.parse import quote

import pydantic as pd

from . import models
from .resource_types import (
    CaseResourceSelector,
    ReportResourceInput,
    ReportResourceSelector,
)
from .scene import SceneViewpoint, SceneVisualizationSetting

SummaryResourceType = Literal["geometry", "surface_mesh", "volume_mesh", "case"]
GeometrySummaryField = Literal["bodies", "patches", "edges"]
SurfaceMeshSummaryField = Literal["max_edge_length", "nodes", "triangles", "quadrilaterals"]
VolumeMeshSummaryField = Literal[
    "first_layer_thickness",
    "nodes",
    "tetrahedrons",
    "prisms",
    "pyramids",
    "hexahedrons",
]
CaseSummaryField = Literal[
    "velocity",
    "alpha",
    "beta",
    "turbulence_model",
    "transition_model",
    "pseudo_steps",
    "physical_steps",
    "area",
    "oal",
    "oah",
    "oaw",
    "wb",
    "cl",
    "cd",
    "clf",
    "clr",
    "cs",
    "cd_area",
]
SummaryField: TypeAlias = (
    GeometrySummaryField | SurfaceMeshSummaryField | VolumeMeshSummaryField | CaseSummaryField
)
VisualizationLayout = Literal["grid", "single"]
VisualizationResolution = Literal["low", "high"]
ChartComparison = Literal["absolute", "delta"]
ChartStyle = Literal["case", "variable"]
ChartBackgroundView = Literal["left", "back", "top"]
ChartSeriesDisplay = Literal["individual", "cumulative", "summed"]
ForceScopeMode = Literal["total", "faces", "body_groups"]
CameraLengthUnit = Literal["m", "cm", "mm", "inch", "ft"]
BuiltInChartXVariable = Literal[
    "alpha",
    "beta",
    "velocity",
    "first_layer_thickness",
    "surface_max_edge_length",
    "pseudo_step",
    "physical_step",
    "coordinate_x",
    "coordinate_y",
]
BuiltInChartYVariable = Literal[
    "CL",
    "CD",
    "CFx",
    "CFy",
    "CFz",
    "CMx",
    "CMy",
    "CMz",
    "CLPressure",
    "CDPressure",
    "CFxPressure",
    "CFyPressure",
    "CFzPressure",
    "CMxPressure",
    "CMyPressure",
    "CMzPressure",
    "CLSkinFriction",
    "CDSkinFriction",
    "CFxSkinFriction",
    "CFySkinFriction",
    "CFzSkinFriction",
    "CMxSkinFriction",
    "CMySkinFriction",
    "CMzSkinFriction",
]
VisualizationFieldType = Literal["surface", "slice", "isosurface", "streamline"]
VisualizationClip = Literal["none", "above", "below", "range"]


class _ReportSpec(pd.BaseModel):
    """Base model for the small set of options exposed by the Report UI."""

    model_config = pd.ConfigDict(
        extra="forbid",
        arbitrary_types_allowed=True,
        validate_default=True,
        str_strip_whitespace=True,
        allow_inf_nan=False,
    )


_SUMMARY_FIELDS: dict[SummaryResourceType, dict[str, str]] = {
    "geometry": {
        "bodies": "bodies",
        "patches": "patches",
        "edges": "edges",
    },
    "surface_mesh": {
        "max_edge_length": "maxEdgeLength",
        "nodes": "nodes",
        "triangles": "triangles",
        "quadrilaterals": "quadrilaterals",
    },
    "volume_mesh": {
        "first_layer_thickness": "firstLayerThickness",
        "nodes": "nodes",
        "tetrahedrons": "tetrahedrons",
        "prisms": "prisms",
        "pyramids": "pyramids",
        "hexahedrons": "hexahedrons",
    },
    "case": {
        "velocity": "velocity",
        "alpha": "alphaAngle",
        "beta": "betaAngle",
        "turbulence_model": "turbulenceModel",
        "transition_model": "transitionModel",
        "pseudo_steps": "pseudoSteps",
        "physical_steps": "physicalSteps",
        "area": "area",
        "oal": "oal",
        "oah": "oah",
        "oaw": "oaw",
        "wb": "wb",
        "cl": "cl",
        "cd": "cd",
        "clf": "clf",
        "clr": "clr",
        "cs": "cs",
        "cd_area": "cdArea",
    },
}

_DEFAULT_CASE_SUMMARY_FIELDS = frozenset(
    {
        "velocity",
        "alpha",
        "beta",
        "turbulence_model",
        "transition_model",
        "pseudo_steps",
        "physical_steps",
        "cl",
        "cd",
    }
)


[docs] class ReportSummary(_ReportSpec): """Customize one of the four summary tables included in every report.""" resource_type: SummaryResourceType title: str | None = pd.Field(default=None, min_length=1) fields: list[SummaryField] | None = None force_scope: ForceScopeMode | None = None force_scope_ids: list[str] | None = None @pd.model_validator(mode="before") @classmethod def _normalize_fields(cls, values): if not isinstance(values, Mapping): return values resource_type = values.get("resource_type") fields = values.get("fields") if fields is None or resource_type not in _SUMMARY_FIELDS: return values if isinstance(fields, str) or not isinstance(fields, (list, tuple)): return values normalized = _normalize_unique_strings(list(fields), "fields") allowed = _SUMMARY_FIELDS[resource_type] canonical_by_case = {field.casefold(): field for field in allowed} canonical_fields: list[str] = [] unsupported: list[str] = [] for field in normalized: canonical = canonical_by_case.get(field.casefold()) if canonical is None: unsupported.append(field) else: canonical_fields.append(canonical) if unsupported: raise ValueError( f"unsupported {resource_type} summary fields: " + ", ".join(unsupported) + "; choose from " + ", ".join(allowed) ) if len(set(canonical_fields)) != len(canonical_fields): raise ValueError("fields must not contain duplicate values") return {**values, "fields": canonical_fields}
[docs] @classmethod def supported_fields(cls, resource_type: SummaryResourceType) -> tuple[SummaryField, ...]: """Return the accepted field names for one summary resource type.""" return tuple(_SUMMARY_FIELDS[resource_type])
@pd.field_validator("force_scope_ids") @classmethod def _normalize_force_scope_ids(cls, values: list[str] | None) -> list[str] | None: if values is None: return None return _normalize_unique_strings(values, "force_scope_ids") @pd.model_validator(mode="after") def _validate_force_scope(self): if ( self.force_scope is not None or self.force_scope_ids is not None ) and self.resource_type != "case": raise ValueError("force_scope options are available only for case summaries") _validate_force_scope_parameters(self.force_scope, self.force_scope_ids) return self
[docs] class ReportCamera(_ReportSpec): """Camera used by linked views, with optional input units for length values.""" position: tuple[float, float, float] | None = None look_at: tuple[float, float, float] | None = None pan_target: tuple[float, float, float] | None = None up: tuple[float, float, float] | None = None dimension: float | None = pd.Field(default=None, gt=0) dimension_dir: Literal["width", "height", "diagonal"] | None = None unit: CameraLengthUnit | None = None
[docs] class ReportVisualizationSetup(_ReportSpec): """Configure one surface, slice, isosurface, or streamline output. Use exact IDs returned by ``get_capabilities(resource)`` and check this setup against that resource before creating or updating a Report. Name normalization can suggest candidates but does not produce authoritative IDs. """ output: str = pd.Field(min_length=1) type: VisualizationFieldType field: str | None = pd.Field(default=None, min_length=1) show: list[str] | None = None hide: list[str] | None = None log_scale: bool | None = None range: tuple[float, float] | None = None color_range: tuple[float, float] | None = None theme: str | None = pd.Field(default=None, min_length=1) contour_mode: Literal["surface", "contours", "both"] | None = None contour_steps: int | None = pd.Field(default=None, gt=0) contour_line_color: int | None = pd.Field(default=None, ge=0) solid_color: int | None = pd.Field(default=None, ge=0) clip: VisualizationClip | None = None clip_value: float | None = None clip_range: tuple[float, float] | None = None show_color_map: bool | None = None streamline_direction: Literal["upstream", "downstream", "both"] | None = None render_type: Literal["ribbon", "line"] | None = None tube_width: float | None = pd.Field(default=None, gt=0) tube_width_unit: str | None = pd.Field(default=None, min_length=1) ribbon_width: float | None = pd.Field(default=None, gt=0) ribbon_width_unit: str | None = pd.Field(default=None, min_length=1) ribbon_angle_scale: float | None = pd.Field(default=None, gt=0) visualizer: Literal["lic"] | None = None unit: str | None = pd.Field(default=None, min_length=1) use_local_value: bool | None = None time_frame: int | None = pd.Field(default=None, ge=0) slice_variants: dict[str, Literal["flat", "crinkled"]] | None = None wireframes: dict[str, bool] | None = None custom_colors: list[tuple[float, str]] | None = pd.Field(default=None, min_length=2) @pd.field_validator("show", "hide") @classmethod def _validate_visibility_ids(cls, values: list[str] | None, info) -> list[str] | None: if values is None: return None return _normalize_unique_strings(values, info.field_name) @pd.field_validator("slice_variants", "wireframes") @classmethod def _validate_id_mapping(cls, values: dict[str, Any] | None, info): if values is None: return None normalized = {} for item_id, value in values.items(): if not isinstance(item_id, str) or not item_id.strip(): raise TypeError(f"{info.field_name} keys must be non-empty strings") normalized[item_id.strip()] = value if len(normalized) != len(values): raise ValueError(f"{info.field_name} must not contain duplicate IDs") return normalized @pd.field_validator("custom_colors") @classmethod def _validate_custom_colors( cls, values: list[tuple[float, str]] | None ) -> list[tuple[float, str]] | None: if values is None: return None positions: set[float] = set() normalized = [] for position, color in values: if position < 0 or position > 100: raise ValueError("custom color positions must be between 0 and 100") if position in positions: raise ValueError("custom color positions must be unique") if re.fullmatch(r"#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?", color) is None: raise ValueError("custom colors must use #RRGGBB or #RRGGBBAA") positions.add(position) normalized.append((position, color.lower())) return sorted(normalized) @pd.model_validator(mode="after") def _validate_setup(self): _validate_range(self.range, "range") _validate_range(self.color_range, "color_range") _validate_range(self.clip_range, "clip_range") overlap = set(self.show or []) & set(self.hide or []) if overlap: raise ValueError("show and hide must not contain the same IDs") if self.clip == "range" and self.clip_range is None: raise ValueError("clip_range is required when clip is 'range'") if self.clip != "range" and self.clip_range is not None: raise ValueError("clip_range is available only when clip is 'range'") if self.clip not in ("above", "below") and self.clip_value is not None: raise ValueError("clip_value is available only when clip is 'above' or 'below'") contour_options = (self.contour_mode, self.contour_steps, self.contour_line_color) if self.type not in ("surface", "slice") and any( value is not None for value in contour_options ): raise ValueError("contour options are available only for surface or slice outputs") streamline_options = ( self.streamline_direction, self.render_type, self.tube_width, self.tube_width_unit, self.ribbon_width, self.ribbon_width_unit, self.ribbon_angle_scale, ) if self.type != "streamline" and any(value is not None for value in streamline_options): raise ValueError("streamline options are available only for streamline outputs") if self.type != "slice" and self.slice_variants is not None: raise ValueError("slice_variants is available only for slice outputs") field_options = ( self.log_scale, self.range, self.color_range, self.theme, *contour_options, self.clip, self.clip_value, self.clip_range, self.show_color_map, self.visualizer, self.unit, self.use_local_value, self.time_frame, self.custom_colors, ) if self.field is None and any(value is not None for value in field_options): raise ValueError("field is required when field display options are configured") if self.custom_colors is not None: if self.theme not in (None, "custom"): raise ValueError("theme must be 'custom' when custom_colors is configured") object.__setattr__(self, "theme", "custom") return self
def _accept_visualization_setups(value): if isinstance(value, SceneVisualizationSetting): return value if value is None: return [] if isinstance(value, (ReportVisualizationSetup, Mapping)): return [value] return value def _validate_visualization_setups(setups: list[ReportVisualizationSetup]) -> None: outputs = [setup.output for setup in setups] if len(set(outputs)) != len(outputs): raise ValueError("setup must not configure the same output more than once") if sum(setup.custom_colors is not None for setup in setups) > 1: raise ValueError("custom_colors can be configured by only one visualization setup")
[docs] class ReportVisualizationView(_ReportSpec): """Validated independent state stored for one visualization resource view.""" setup: list[ReportVisualizationSetup] | SceneVisualizationSetting = pd.Field( default_factory=list ) camera: ReportCamera | SceneViewpoint | None = None @pd.field_validator("setup", mode="before") @classmethod def _accept_single_setup(cls, value): return _accept_visualization_setups(value) @pd.model_validator(mode="after") def _validate_config(self): if isinstance(self.setup, list): _validate_visualization_setups(self.setup) return self
[docs] class ReportVisualization(_ReportSpec): """Add a configured 3D resource comparison section to a report. AI-generated workflows should first load ``get_agent_guide()``. Before reusing one setup across resources, obtain each resource's capabilities and call ``check_visualization_setup`` before creating or updating the Report. """ title: str = pd.Field(default="Visualization", min_length=1) # The full config compiler resolves and validates resource objects or IDs against its pool. resources: list[pd.SkipValidation[ReportResourceSelector]] | None = None layout: VisualizationLayout = "grid" resolution: VisualizationResolution | None = None setup: list[ReportVisualizationSetup] | SceneVisualizationSetting = pd.Field( default_factory=list ) camera: ReportCamera | SceneViewpoint | None = None views: dict[str, ReportVisualizationView] = pd.Field(default_factory=dict) @pd.field_validator("setup", mode="before") @classmethod def _accept_single_setup(cls, value): return _accept_visualization_setups(value) @pd.field_validator("views", mode="before") @classmethod def _validate_views(cls, value): if value is None: return {} if not isinstance(value, Mapping): raise TypeError("views must map resource IDs to setup and camera configuration") normalized = {} for resource_id, config in value.items(): if not isinstance(resource_id, str) or not resource_id.strip(): raise TypeError("views keys must be non-empty resource ID strings") resource_id = resource_id.strip() if resource_id in normalized: raise ValueError("views must not contain duplicate resource IDs") normalized[resource_id] = ReportVisualizationView.model_validate(config) return normalized @pd.model_validator(mode="after") def _validate_setups(self): if isinstance(self.setup, list): _validate_visualization_setups(self.setup) return self
def _encode_axis_id_segment(value: str) -> str: """Match JavaScript's encodeURIComponent for persisted Chart2D axis ids.""" return quote(value, safe="-_.!~*'()") _X_AXIS_IDS = { "alpha": "axis:x:simulation:alpha-angle", "beta": "axis:x:simulation:beta-angle", "velocity": "axis:x:simulation:velocity", "first_layer_thickness": "axis:x:simulation:first-layer-thickness", "surface_max_edge_length": "axis:x:simulation:surface-max-edge-length", "pseudo_step": "axis:x:step:pseudo", "physical_step": "axis:x:step:physical", "coordinate_x": "axis:x:spatial:X", "coordinate_y": "axis:x:spatial:Y", } _FORCE_VARIABLES = ("CL", "CD", "CFx", "CFy", "CFz", "CMx", "CMy", "CMz") _FORCE_HISTORY_VARIABLES = ( *_FORCE_VARIABLES, *(f"{variable}Pressure" for variable in _FORCE_VARIABLES), *(f"{variable}SkinFriction" for variable in _FORCE_VARIABLES), ) _FORCE_Y_AXIS_IDS = { variable.lower(): f"axis:y:force:{_encode_axis_id_segment(variable)}" for variable in _FORCE_HISTORY_VARIABLES } _SERIES_VALUE_COLUMN = "__series__" _Y_AXIS_IDS = _FORCE_Y_AXIS_IDS _Y_VARIABLE_NAMES_BY_NORMALIZED = { variable.lower(): variable for variable in _FORCE_HISTORY_VARIABLES }
[docs] class ReportChartVariable(_ReportSpec): """A CSV or user-defined-dynamics variable selected in the Report UI.""" source: Literal["csv", "udd"] = "csv" file_name: str = pd.Field(min_length=1) column: str = pd.Field(min_length=1) model_config = pd.ConfigDict(frozen=True) @pd.field_validator("file_name") @classmethod def _normalize_file_name(cls, value: str) -> str: normalized = value.lstrip("/") if normalized.startswith("results/"): normalized = normalized[len("results/") :] if not normalized: raise ValueError("file_name must not be empty") return normalized
[docs] @classmethod def csv(cls, file_name: str, column: str) -> "ReportChartVariable": """Select a CSV output column.""" return cls(source="csv", file_name=file_name, column=column)
[docs] @classmethod def udd(cls, file_name: str, column: str) -> "ReportChartVariable": """Select a user-defined-dynamics output column.""" return cls(source="udd", file_name=file_name, column=column)
[docs] @classmethod def csv_series(cls, file_name: str) -> "ReportChartVariable": """Select a metadata-defined series group from a CSV output.""" return cls(source="csv", file_name=file_name, column=_SERIES_VALUE_COLUMN)
[docs] @classmethod def udd_series(cls, file_name: str) -> "ReportChartVariable": """Select a metadata-defined series group from a UDD output.""" return cls(source="udd", file_name=file_name, column=_SERIES_VALUE_COLUMN)
[docs] class ReportChart(_ReportSpec): """Add a 2D chart section using the controls available in the Report UI.""" title: str = pd.Field(default="2D Chart", min_length=1) # The full config compiler resolves and validates Case objects or IDs against its pool. resources: list[pd.SkipValidation[CaseResourceSelector]] | None = None x: BuiltInChartXVariable = "alpha" y: list[BuiltInChartYVariable | ReportChartVariable] = pd.Field( default_factory=lambda: ["CL"], min_length=1, max_length=5 ) comparison: ChartComparison = "absolute" x_range: tuple[float, float] | None = None y_ranges: dict[BuiltInChartYVariable | ReportChartVariable, tuple[float, float]] | None = None log_scale: bool = False style: ChartStyle = "case" background_view: ChartBackgroundView | None = None force_scope: ForceScopeMode | None = None force_scope_ids: list[str] | None = None series: list[str] | None = None series_display: ChartSeriesDisplay | None = None @pd.field_validator("x", mode="before") @classmethod def _normalize_x_variable(cls, value): if not isinstance(value, str): return value return _canonical_x_variable(value) @pd.field_validator("y", mode="before") @classmethod def _normalize_y_variables(cls, value): if isinstance(value, (str, ReportChartVariable)): value = [value] if not isinstance(value, (list, tuple)): return value return [ _canonical_y_variable(variable, "y") if isinstance(variable, str) else variable for variable in value ] @pd.field_validator("y_ranges", mode="before") @classmethod def _normalize_y_range_variables(cls, value): if value is None or not isinstance(value, Mapping): return value normalized = {} for variable, value_range in value.items(): canonical = ( _canonical_y_variable(variable, "y_ranges") if isinstance(variable, str) else variable ) if canonical in normalized: raise ValueError(f"y_ranges contains duplicate y variable {variable!r}") normalized[canonical] = value_range return normalized @pd.field_validator("force_scope_ids", "series") @classmethod def _normalize_id_list(cls, values: list[str] | None, info) -> list[str] | None: if values is None: return None return _normalize_unique_strings(values, info.field_name)
[docs] @classmethod def supported_x_variables(cls) -> tuple[BuiltInChartXVariable, ...]: """Return the built-in x-axis variables.""" return tuple(_X_AXIS_IDS)
[docs] @classmethod def supported_y_variables(cls) -> tuple[BuiltInChartYVariable, ...]: """Return the built-in y-axis variables.""" return _FORCE_HISTORY_VARIABLES
@pd.model_validator(mode="after") def _validate_chart_options(self): _validate_force_scope_parameters(self.force_scope, self.force_scope_ids) x_axis_id = _x_axis_id(self.x) y_axis_ids = [_y_axis_id(value, "y") for value in self.y] if len(set(y_axis_ids)) != len(y_axis_ids): raise ValueError("y must not contain duplicate variables") _validate_chart_variables(self.x, self.y) _validate_range(self.x_range, "x_range") if self.y_ranges is not None: normalized_ranges: dict[str, tuple[float, float]] = {} for variable, value_range in self.y_ranges.items(): y_axis_id = _y_axis_id(variable, "y_ranges") if y_axis_id not in y_axis_ids: raise ValueError(f"y_ranges contains unselected y variable {variable!r}") if y_axis_id in normalized_ranges: raise ValueError(f"y_ranges contains duplicate y variable {variable!r}") _validate_range(value_range, f"y_ranges[{variable!r}]") normalized_ranges[y_axis_id] = value_range if self.comparison == "delta" and self.log_scale: raise ValueError("log_scale is unavailable when comparison is 'delta'") if self.background_view is not None: allowed_views = { "axis:x:spatial:X": {"left", "top"}, "axis:x:spatial:Y": {"back", "top"}, }.get(x_axis_id) if allowed_views is None: raise ValueError( "background_view is only available for coordinate_x or coordinate_y charts" ) if self.background_view not in allowed_views: raise ValueError( f"background_view for {self.x} must be one of " + ", ".join(sorted(allowed_views)) ) is_step_history = self.x in {"pseudo_step", "physical_step"} has_force_variable = any( isinstance(variable, str) and variable in _FORCE_HISTORY_VARIABLES for variable in self.y ) if self.force_scope is not None and not (is_step_history and has_force_variable): raise ValueError( "force_scope requires pseudo_step or physical_step with a force Y variable" ) has_metadata_variable = any( isinstance(variable, ReportChartVariable) for variable in self.y ) if ( self.series is not None or self.series_display is not None ) and not has_metadata_variable: raise ValueError("series options require a CSV, UDD, or solver-metric Y variable") return self
ReportSectionSpec: TypeAlias = ReportSummary | ReportVisualization | ReportChart def _normalize_unique_strings(values: list[str], field_name: str) -> list[str]: normalized: list[str] = [] for value in values: if not isinstance(value, str): raise TypeError(f"{field_name} must contain only strings") cleaned = value.strip() if not cleaned: raise ValueError(f"{field_name} must not contain empty values") if cleaned in normalized: raise ValueError(f"{field_name} must not contain duplicate values") normalized.append(cleaned) return normalized def _validate_force_scope_parameters( mode: ForceScopeMode | None, included_ids: list[str] | None ) -> None: if mode in (None, "total") and included_ids: raise ValueError("force_scope_ids must be empty when force_scope is unset or 'total'") if mode in ("faces", "body_groups") and not included_ids: raise ValueError(f"force_scope_ids must not be empty when force_scope is {mode!r}") def _normalize_variable(value: str, field_name: str) -> str: if not isinstance(value, str): raise TypeError(f"{field_name} variables must be strings") normalized = value.strip() if not normalized: raise ValueError(f"{field_name} variables must not be empty") return normalized def _x_axis_id(value: str) -> str: return _X_AXIS_IDS[_canonical_x_variable(value)] def _y_axis_id(value: str | ReportChartVariable, field_name: str) -> str: if isinstance(value, ReportChartVariable): return ( f"axis:y:{value.source}:" f"{_encode_axis_id_segment(value.file_name)}:" f"{_encode_axis_id_segment(value.column)}" ) canonical = _canonical_y_variable(value, field_name) return _Y_AXIS_IDS[canonical.lower()] def _canonical_x_variable(value: str) -> BuiltInChartXVariable: normalized = _normalize_variable(value, "x") canonical = next( (variable for variable in _X_AXIS_IDS if variable.lower() == normalized.lower()), None ) if canonical is not None: return canonical raise ValueError(f"unsupported x variable {value!r}; choose from " + ", ".join(_X_AXIS_IDS)) def _canonical_y_variable(value: str, field_name: str) -> BuiltInChartYVariable: normalized = _normalize_variable(value, field_name) canonical = _Y_VARIABLE_NAMES_BY_NORMALIZED.get(normalized.lower()) if canonical is not None: return canonical raise ValueError( f"unsupported {field_name} variable {value!r}; choose from " + ", ".join(_FORCE_HISTORY_VARIABLES) + ", or use ReportChartVariable for CSV and UDD outputs" ) def _validate_chart_variables( x_variable: BuiltInChartXVariable, y_variables: list[BuiltInChartYVariable | ReportChartVariable], ) -> None: if x_variable in { "alpha", "beta", "velocity", "first_layer_thickness", "surface_max_edge_length", }: incompatible_variables = [ value for value in y_variables if not (isinstance(value, str) and value in _FORCE_VARIABLES) ] elif x_variable in {"coordinate_x", "coordinate_y"}: incompatible_variables = [ value for value in y_variables if not isinstance(value, ReportChartVariable) ] else: incompatible_variables = [ value for value in y_variables if not ( isinstance(value, ReportChartVariable) or (isinstance(value, str) and value in _FORCE_HISTORY_VARIABLES) ) ] incompatible = [_y_axis_id(value, "y") for value in incompatible_variables] if incompatible: raise ValueError( f"y variables are not available for x variable {_x_axis_id(x_variable)}: " + ", ".join(incompatible) ) def _validate_range(value_range: tuple[float, float] | None, field_name: str) -> None: if value_range is not None and value_range[0] >= value_range[1]: raise ValueError(f"{field_name} minimum must be less than its maximum") def _resource_value(asset: ReportResourceInput, name: str) -> Any: try: return getattr(asset, name, None) except (AttributeError, NotImplementedError): return None def _normalize_aliases(aliases: Mapping[str, str] | None) -> dict[str, str]: if aliases is None: return {} if not isinstance(aliases, Mapping): raise TypeError("aliases must map cloud resource IDs to display names") normalized: dict[str, str] = {} for resource_id, alias in aliases.items(): if not isinstance(resource_id, str) or not resource_id.strip(): raise TypeError("aliases keys must be cloud resource IDs") if not isinstance(alias, str) or not alias.strip(): raise TypeError("aliases values must be non-empty strings") normalized[resource_id.strip()] = alias.strip() return normalized def _normalize_reference_id(reference: CaseResourceSelector | None) -> str | None: if reference is None: return None reference_id = reference if isinstance(reference, str) else _resource_value(reference, "id") if not isinstance(reference_id, str) or not reference_id.strip(): raise ValueError("reference must provide a non-empty cloud resource ID") return reference_id.strip() def _assign_default_reference( resources: list[models.ReportResource], reference_id: str | None ) -> list[models.ReportResource]: if reference_id is not None: return resources for index, resource in enumerate(resources): if resource.type == "Case": resources[index] = resource.model_copy(update={"is_reference": True}) break return resources def _build_resources( assets: Iterable[ReportResourceInput], aliases: Mapping[str, str] | None, reference: CaseResourceSelector | None, ) -> list[models.ReportResource]: if isinstance(assets, (str, bytes)): raise TypeError("resources must be an iterable of Flow360 cloud resources") asset_list = list(assets) if not asset_list: raise ValueError("resources must contain at least one Flow360 cloud resource") normalized_aliases = _normalize_aliases(aliases) reference_id = _normalize_reference_id(reference) resources: list[models.ReportResource] = [] for asset in asset_list: resource_type = _resource_value(asset, "_cloud_resource_type_name") if resource_type not in ("Case", "Geometry", "SurfaceMesh", "VolumeMesh"): raise TypeError( "resources must contain only Flow360 Case, Geometry, SurfaceMesh, " "or VolumeMesh objects" ) resource_id = _resource_value(asset, "id") if not isinstance(resource_id, str) or not resource_id.strip(): raise ValueError("every report resource must have a cloud resource ID") resource_id = resource_id.strip() original_name = _resource_value(asset, "name") if not isinstance(original_name, str) or not original_name.strip(): raise ValueError(f"report resource {resource_id} must have a name") original_name = original_name.strip() resources.append( models.ReportResource( type=resource_type, id=resource_id, project_id=_resource_value(asset, "project_id"), original_name=original_name, name=normalized_aliases.get(resource_id, original_name), is_reference=resource_id == reference_id, ) ) known_ids = {resource.id for resource in resources} unknown_aliases = set(normalized_aliases) - known_ids if unknown_aliases: raise ValueError( "aliases contains unknown resource IDs: " + ", ".join(sorted(unknown_aliases)) ) if reference_id is not None: reference_resource = next( (resource for resource in resources if resource.id == reference_id), None ) if reference_resource is None: raise ValueError("reference must be one of the report resources") if reference_resource.type != "Case": raise ValueError("reference must be a Flow360 Case") return _assign_default_reference(resources, reference_id) def _selected_resources( selected: Sequence[ReportResourceSelector] | None, report_resources: list[models.ReportResource], *, section_name: str, ) -> list[models.ReportResource]: if selected is None: cases = [resource for resource in report_resources if resource.type == "Case"] if cases: return cases first_type = report_resources[0].type return [resource for resource in report_resources if resource.type == first_type] if not selected: raise ValueError(f"{section_name} resources must not be empty") resources_by_id = {resource.id: resource for resource in report_resources} resolved: list[models.ReportResource] = [] seen: set[str] = set() for item in selected: resource_id = item.strip() if isinstance(item, str) else _resource_value(item, "id") if not isinstance(resource_id, str) or not resource_id.strip(): raise TypeError( f"{section_name} resources must be report resource objects or resource IDs" ) resource_id = resource_id.strip() if resource_id in seen: raise ValueError(f"{section_name} resources must not contain duplicates") resource = resources_by_id.get(resource_id) if resource is None: raise ValueError(f"{section_name} selects resource {resource_id} not in the report") seen.add(resource_id) resolved.append(resource) return resolved def _compile_force_scope( mode: ForceScopeMode | None, included_ids: list[str] | None ) -> models.ReportForceScope: if mode is None or mode == "total": return models.ReportTotalForceScope() return models.ReportSelectedForceScope( mode="bodyGroups" if mode == "body_groups" else "faces", included_ids=included_ids or [], ) def _compile_summary(section: ReportSummary): field_map = _SUMMARY_FIELDS[section.resource_type] if section.fields is not None: selected_fields = set(section.fields) elif section.resource_type == "case": selected_fields = set(_DEFAULT_CASE_SUMMARY_FIELDS) else: selected_fields = set(field_map) hidden_fields = [ internal for public, internal in field_map.items() if public not in selected_fields ] if section.resource_type == "geometry": return models.GeometrySummarySection( title=section.title or "Geometry summary", config=models.GeometrySummarySectionConfig( table=models.GeometrySummaryTableConfig( hidden_field_keys=hidden_fields, ) ), ) if section.resource_type == "surface_mesh": return models.SurfaceMeshSummarySection( title=section.title or "Surface mesh summary", config=models.SurfaceMeshSummarySectionConfig( table=models.SurfaceMeshSummaryTableConfig( hidden_field_keys=hidden_fields, ) ), ) if section.resource_type == "volume_mesh": return models.VolumeMeshSummarySection( title=section.title or "Volume mesh summary", config=models.VolumeMeshSummarySectionConfig( table=models.VolumeMeshSummaryTableConfig( hidden_field_keys=hidden_fields, ) ), ) return models.CaseSummarySection( title=section.title or "Case summary", config=models.CaseSummarySectionConfig( table=models.CaseSummaryTableConfig( hidden_field_keys=hidden_fields, ), force_scope=_compile_force_scope(section.force_scope, section.force_scope_ids), ), ) _VISUALIZATION_FIELD_KEYS = { "surface": "surfaces", "slice": "slices", "isosurface": "isosurfaces", "streamline": "streamlines", } _VISUALIZATION_TYPES_BY_RESOURCE = { "Case": frozenset(_VISUALIZATION_FIELD_KEYS), "Geometry": frozenset(), "SurfaceMesh": frozenset({"surface"}), "VolumeMesh": frozenset({"slice"}), } _CLIP_TYPES = {"none": 0, "above": 1, "below": 2, "range": 3} def _merge_setup_mapping(target: dict[str, Any], source: Mapping[str, Any], name: str) -> None: for item_id, value in source.items(): if item_id in target and target[item_id] != value: raise ValueError(f"{name} configures conflicting values for {item_id!r}") target[item_id] = value def _compile_visualization_setup( # pylint: disable=too-many-locals,too-many-branches setups: list[ReportVisualizationSetup] | SceneVisualizationSetting, resource_type: models.ResourceType, ) -> models.VisualizationViewSetup | None: if isinstance(setups, SceneVisualizationSetting): if setups.resource_type != resource_type: raise ValueError( f"scene visualization setting for {setups.resource_type} cannot be applied " f"to a {resource_type} visualization; apply its viewpoint without its setting" ) return setups._as_report_setup() if not setups: return None allowed_types = _VISUALIZATION_TYPES_BY_RESOURCE[resource_type] unsupported = sorted({setup.type for setup in setups} - allowed_types) if unsupported: if resource_type == "Geometry": raise ValueError("Geometry visualizations do not expose setup controls in the web UI") raise ValueError( f"{resource_type} visualizations support only " + ", ".join(sorted(allowed_types)) + " setup outputs" ) visible_setting: dict[str, bool] = {} visible_setting_by_field: dict[str, dict[str, bool]] = {} field_setting: dict[str, models.FieldCacheData] = {} time_frame_setting: dict[str, int] = {} slice_variant_setting: dict[str, Literal["flat", "crinkled"]] = {} wireframe_setting: dict[str, bool] = {} custom_color_nodes = None for setup in setups: scoped_visibility: dict[str, bool] = {} for item_id in setup.show or []: scoped_visibility[item_id] = True for item_id in setup.hide or []: scoped_visibility[item_id] = False if scoped_visibility: _merge_setup_mapping(visible_setting, scoped_visibility, "show/hide") field_key = f"{_VISUALIZATION_FIELD_KEYS[setup.type]}::{setup.output}" visible_setting_by_field[field_key] = scoped_visibility field_data = models.FieldCacheData( field_name=setup.field, log_scale=setup.log_scale, range=setup.range, color_scale_min_max=setup.color_range, theme=setup.theme, contour_steps=setup.contour_steps, contour_line_color=setup.contour_line_color, clip_type=_CLIP_TYPES[setup.clip] if setup.clip is not None else None, clip_value=setup.clip_value, clip_value_range=setup.clip_range, solid_color=setup.solid_color, show_color_map=setup.show_color_map, contour_mode=setup.contour_mode, tube_width=setup.tube_width, tube_width_unit=setup.tube_width_unit, ribbon_width=setup.ribbon_width, ribbon_width_unit=setup.ribbon_width_unit, streamline_direction=( setup.streamline_direction.title() if setup.streamline_direction is not None else None ), render_type=setup.render_type.title() if setup.render_type is not None else None, ribbon_angle_scale=setup.ribbon_angle_scale, visualizer=setup.visualizer, selected_unit=setup.unit, use_local_value=setup.use_local_value, time_frame=setup.time_frame, ) if field_data.model_dump(exclude_none=True): field_setting[setup.output] = field_data if setup.time_frame is not None: time_frame_setting[setup.output] = setup.time_frame if setup.slice_variants is not None: _merge_setup_mapping(slice_variant_setting, setup.slice_variants, "slice_variants") if setup.wireframes is not None: _merge_setup_mapping(wireframe_setting, setup.wireframes, "wireframes") if setup.custom_colors is not None: custom_color_nodes = [ models.ColorNode(position=position, color=color) for position, color in setup.custom_colors ] return models.VisualizationViewSetup( visible_setting=visible_setting or None, field_setting=field_setting or None, color_map_setting=( models.ColorMapSetting(custom_color_nodes=custom_color_nodes) if custom_color_nodes is not None else None ), time_frame_setting=time_frame_setting or None, slice_variant_setting=slice_variant_setting or None, wireframe_setting=wireframe_setting or None, visible_setting_by_field=visible_setting_by_field or None, ) _CAMERA_LENGTH_UNIT_TO_METERS: dict[CameraLengthUnit, float] = { "m": 1.0, "cm": 0.01, "mm": 0.001, "inch": 0.0254, "ft": 0.3048, } def _meters_per_model_unit(simulation_json: Mapping[str, Any]) -> float: """Resolve the Workbench model scale with the same invalid-value fallbacks as the UI.""" asset_cache = simulation_json.get("private_attribute_asset_cache") if not isinstance(asset_cache, Mapping): return 1.0 project_length_unit = asset_cache.get("project_length_unit") if not isinstance(project_length_unit, Mapping): return 1.0 raw_unit = project_length_unit.get("units") if isinstance(raw_unit, str) and raw_unit in _CAMERA_LENGTH_UNIT_TO_METERS: unit = raw_unit else: unit = "m" raw_factor = project_length_unit.get("value") try: factor = float(raw_factor) if not isinstance(raw_factor, bool) else 1.0 except (TypeError, ValueError): factor = 1.0 if not math.isfinite(factor) or factor <= 0: factor = 1.0 return factor * _CAMERA_LENGTH_UNIT_TO_METERS[unit] def _compile_physical_camera_for_resource( camera: ReportCamera | SceneViewpoint, simulation_json: Mapping[str, Any], ) -> models.ReportCameraParams: """Convert physical camera lengths into one target resource's model coordinates.""" if isinstance(camera, SceneViewpoint): values = camera._as_report_camera().model_dump() meters_per_input_unit = 1.0 else: values = camera.model_dump(exclude={"unit"}) if camera.unit is None: raise ValueError("a physical per-view Report.Camera requires an explicit unit") meters_per_input_unit = _CAMERA_LENGTH_UNIT_TO_METERS[camera.unit] scale = meters_per_input_unit / _meters_per_model_unit(simulation_json) for field_name in ("look_at", "pan_target"): vector = values.get(field_name) if vector is not None: values[field_name] = tuple(component * scale for component in vector) if values.get("dimension") is not None: values["dimension"] *= scale return models.ReportCameraParams(**values) def _compile_camera( camera: ReportCamera | SceneViewpoint | None, *, convert_lengths_to_meters: bool ) -> models.ReportCameraParams | None: if camera is None: return None if isinstance(camera, SceneViewpoint): return camera._as_report_camera() values = camera.model_dump(exclude={"unit"}) if convert_lengths_to_meters: scale = _CAMERA_LENGTH_UNIT_TO_METERS[camera.unit or "m"] for field_name in ("look_at", "pan_target"): vector = values.get(field_name) if vector is not None: values[field_name] = tuple(component * scale for component in vector) if values.get("dimension") is not None: values["dimension"] *= scale return models.ReportCameraParams(**values) def _compile_visualization_views( views: dict[str, ReportVisualizationView], selected_resources: list[models.ReportResource], resource_type: models.ResourceType, global_setup: models.VisualizationViewSetup | None, resource_assets_by_id: Mapping[str, ReportResourceInput], simulation_json_getter: Callable[[ReportResourceInput], Mapping[str, Any]] | None, simulation_json_cache: dict[str, Mapping[str, Any]], ) -> dict[str, models.VisualizationViewState]: selected_ids = {resource.id for resource in selected_resources} compiled: dict[str, models.VisualizationViewState] = {} for resource_id, view in views.items(): if resource_id not in selected_ids: raise ValueError( f"visualization view resource {resource_id} is not selected by the section" ) if view.setup: view_setup = _compile_visualization_setup(view.setup, resource_type) else: view_setup = global_setup.model_copy(deep=True) if global_setup is not None else None camera_requires_unit_conversion = isinstance(view.camera, SceneViewpoint) or ( isinstance(view.camera, ReportCamera) and view.camera.unit is not None ) if camera_requires_unit_conversion: if simulation_json_getter is None: raise ValueError( "a per-view Scene viewpoint or Report.Camera with unit requires a " "SimulationJSON getter for unit conversion" ) if resource_id not in simulation_json_cache: simulation_json = simulation_json_getter(resource_assets_by_id[resource_id]) if not isinstance(simulation_json, Mapping): raise TypeError( f"SimulationJSON for resource {resource_id!r} must be a mapping" ) simulation_json_cache[resource_id] = simulation_json view_camera = _compile_physical_camera_for_resource( view.camera, simulation_json_cache[resource_id], ) else: view_camera = _compile_camera(view.camera, convert_lengths_to_meters=False) compiled[resource_id] = models.VisualizationViewState( linked=False, camera_params=view_camera, setup=view_setup, ) return compiled def _compile_visualization( section: ReportVisualization, report_resources: list[models.ReportResource], resource_assets_by_id: Mapping[str, ReportResourceInput], simulation_json_getter: Callable[[ReportResourceInput], Mapping[str, Any]] | None, simulation_json_cache: dict[str, Mapping[str, Any]], ) -> models.VisualizationSection: resources = _selected_resources( section.resources, report_resources, section_name="visualization", ) if section.resources is None: resources = resources[: models.MAX_VISUALIZATION_RESOURCES] resource_types = {resource.type for resource in resources} if len(resource_types) != 1: raise ValueError("a visualization section can contain only one resource type") if len(resources) > models.MAX_VISUALIZATION_RESOURCES: raise ValueError( "a visualization section supports at most " f"{models.MAX_VISUALIZATION_RESOURCES} resources" ) resource_type = resources[0].type if resource_type != "Case" and section.resolution == "low": raise ValueError("low resolution is available only for Case visualizations") lod_level = 0 if resource_type != "Case" or section.resolution == "high" else 1 setup = _compile_visualization_setup(section.setup, resource_type) camera = _compile_camera(section.camera, convert_lengths_to_meters=True) views = _compile_visualization_views( section.views, resources, resource_type, setup, resource_assets_by_id, simulation_json_getter, simulation_json_cache, ) return models.VisualizationSection( title=section.title, config=models.VisualizationSectionConfig( resources=models.VisualizationSectionResourcesConfig( type=resource_type, selected_ids=[resource.id for resource in resources], ), display=models.VisualizationSectionDisplayConfig( layout=section.layout, lod_level=lod_level, ), sync=models.VisualizationSectionSyncConfig( camera=models.VisualizationCameraSyncConfig(global_params=camera), settings=models.VisualizationSettingsSyncConfig(global_setup=setup), ), views_by_resource_id=views, ), ) def _chart_background( x_axis_id: str, background_view: ChartBackgroundView | None ) -> models.Chart2DBackgroundConfig | None: if background_view is None: return None viewpoint_by_axis = { ("axis:x:spatial:X", "left"): "chart2d:left", ("axis:x:spatial:X", "top"): "chart2d:x-top", ("axis:x:spatial:Y", "back"): "chart2d:back", ("axis:x:spatial:Y", "top"): "chart2d:y-top", } return models.Chart2DBackgroundConfig( viewpoint_id=viewpoint_by_axis[(x_axis_id, background_view)] ) def _compile_chart( section: ReportChart, report_resources: list[models.ReportResource] ) -> models.Chart2DSection: resources = _selected_resources(section.resources, report_resources, section_name="chart") if any(resource.type != "Case" for resource in resources): raise ValueError("chart sections can contain only Case resources") if section.resources is None: resources = resources[: models.MAX_CHART2D_CASES] if len(resources) > models.MAX_CHART2D_CASES: raise ValueError(f"a chart section supports at most {models.MAX_CHART2D_CASES} cases") reference = next((resource for resource in report_resources if resource.is_reference), None) if reference is not None and all(resource.id != reference.id for resource in resources): if len(resources) == models.MAX_CHART2D_CASES: resources = resources[:-1] resources.append(reference) x_axis_id = _x_axis_id(section.x) y_axis_ids = [_y_axis_id(variable, "y") for variable in section.y] y_ranges = { _y_axis_id(variable, "y_ranges"): value_range for variable, value_range in (section.y_ranges or {}).items() } y_axes = [ models.Chart2DAxisConfig( range=( models.Chart2DManualRange(values=y_ranges[y_axis_id]) if y_axis_id in y_ranges else models.Chart2DGlobalAutoRange() ), scale="log" if section.log_scale else "linear", ) for y_axis_id in y_axis_ids ] x_range = ( models.Chart2DManualRange(values=section.x_range) if section.x_range is not None else models.Chart2DGlobalAutoRange() ) style_mapping = ( "colorByCaseLineByVariable" if section.style == "case" else "colorByVariableLineByCase" ) return models.Chart2DSection( title=section.title, config=models.Chart2DSectionConfig( resources=models.Chart2DSectionResourcesConfig( selected_ids=[resource.id for resource in resources] ), shared=models.Chart2DSharedConfig( data=models.Chart2DSharedDataConfig( x_axis_id=x_axis_id, force_scope=( _compile_force_scope(section.force_scope, section.force_scope_ids) if section.force_scope is not None else None ), series_selection=( models.Chart2DSeriesSelectionConfig(included_ids=section.series) if section.series is not None else None ), series_display=section.series_display, ), comparison=models.Chart2DComparisonConfig(mode=section.comparison), axes=models.Chart2DAxesConfig(x=models.Chart2DAxisConfig(range=x_range)), background=_chart_background(x_axis_id, section.background_view), display=models.Chart2DDisplayConfig(style_mapping=style_mapping), ), charts=[ models.Chart2DChartConfig( data=models.Chart2DChartDataConfig(y_axis_ids=y_axis_ids), axes=models.Chart2DAxesConfig(y=y_axes), ) ], ), ) def build_report_config( *, resources: Iterable[ReportResourceInput], sections: Iterable[ReportSectionSpec] | None = None, reference: CaseResourceSelector | None = None, aliases: Mapping[str, str] | None = None, simulation_json_getter: Callable[[ReportResourceInput], Mapping[str, Any]] | None = None, ) -> models.ReportConfig: """Compile the public Report API into the web application's persisted config.""" if isinstance(resources, (str, bytes)): raise TypeError("resources must be an iterable of Flow360 cloud resources") resource_assets = list(resources) report_resources = _build_resources(resource_assets, aliases, reference) resource_assets_by_id = { str(_resource_value(resource, "id")).strip(): resource for resource in resource_assets } simulation_json_cache: dict[str, Mapping[str, Any]] = {} compiled_sections = [] seen_summaries: set[SummaryResourceType] = set() for section in sections or []: if isinstance(section, ReportSummary): if section.resource_type in seen_summaries: raise ValueError(f"only one {section.resource_type} summary can be configured") seen_summaries.add(section.resource_type) compiled_sections.append(_compile_summary(section)) elif isinstance(section, ReportVisualization): compiled_sections.append( _compile_visualization( section, report_resources, resource_assets_by_id, simulation_json_getter, simulation_json_cache, ) ) elif isinstance(section, ReportChart): compiled_sections.append(_compile_chart(section, report_resources)) else: raise TypeError( "sections must contain only ReportSummary, ReportVisualization, or ReportChart" ) return models.ReportConfig(resources=report_resources, sections=compiled_sections)