Source code for flexcompute.flow_report.capabilities

"""Manifest-backed visualization capabilities for Flow360 resources."""

from __future__ import annotations

import math
from collections.abc import Iterable, Mapping
from typing import Any, Literal, NamedTuple, TypeAlias

import pydantic as pd

from ._flow360_adapter import DEFAULT_FLOW360_ADAPTER
from .models import ReportBaseModel, ResourceType
from .resource_types import ReportResourceInput

VisualizationOutputType = Literal["surface", "slice", "isosurface", "streamline"]

_SOLID_GEOMETRY_TYPE = "SolidGeometry"
_STREAMLINE_ARRAY_TYPE = "StreamlineArray"
_ROOT_SURFACE_IDS = frozenset({"volumeMesh", "boundaries"})
_ROOT_EXCLUDED_SOLID_IDS = frozenset({"qcriterion", "slices", "isosurfaces"})
_FIELD_GROUP_TYPES: dict[str, VisualizationOutputType] = {
    "surfaces": "surface",
    "boundaries": "surface",
    "slices": "slice",
    "isosurfaces": "isosurface",
    "streamlines": "streamline",
}
_ALLOWED_OUTPUT_TYPES: dict[ResourceType, frozenset[VisualizationOutputType]] = {
    "Case": frozenset({"surface", "slice", "isosurface", "streamline"}),
    "Geometry": frozenset(),
    "SurfaceMesh": frozenset({"surface"}),
    "VolumeMesh": frozenset({"slice"}),
}
_NON_FIELD_SECTION_NAMES = frozenset(
    {
        "index",
        "indices",
        "position",
        "normal",
        "color",
        "edgePosition",
        "edgeIndices",
        "rotationAngle",
        "elementGroupId",
    }
)
_VECTOR_COMPONENTS = ("X", "Y", "Z")
_VOLUME_MESH_CONFIG_OWNER_ID = "mesh-diagnostic-volume"
FieldBounds: TypeAlias = tuple[float, float]

__all__ = [
    "ResourceVisualizationCapabilities",
    "VisualizationFieldCapability",
    "VisualizationOutputCapability",
    "VisualizationTargetCapability",
    "get_capabilities",
]


class _ManifestFieldBounds(NamedTuple):
    min_value: float | tuple[float, ...]
    max_value: float | tuple[float, ...]
    min_magnitude: float | None
    max_magnitude: float | None


class VisualizationFieldCapability(ReportBaseModel):
    """One field addressable under a visualization output."""

    name: str = pd.Field(min_length=1)
    dimension: int = pd.Field(default=1, ge=1)
    source_name: str | None = None
    component: Literal["X", "Y", "Z"] | None = None
    bounds: FieldBounds | None = None


class VisualizationTargetCapability(ReportBaseModel):
    """One visibility target addressable under a visualization output."""

    id: str = pd.Field(min_length=1)
    type: str | None = None
    controller_owner_id: str | None = None


class VisualizationOutputCapability(ReportBaseModel):
    """Fields and visibility targets scoped to one exact output owner ID."""

    id: str = pd.Field(min_length=1)
    type: VisualizationOutputType
    controller_owner_ids: list[str] = pd.Field(default_factory=list)
    fields: list[VisualizationFieldCapability] = pd.Field(default_factory=list)
    visibility_targets: list[VisualizationTargetCapability] = pd.Field(default_factory=list)

    @property
    def field_names(self) -> tuple[str, ...]:
        """Return field names in manifest order."""
        return tuple(field.name for field in self.fields)

    def get_field(self, field_name: str) -> VisualizationFieldCapability | None:
        """Return the capability for one exact field name."""
        return next((field for field in self.fields if field.name == field_name), None)

    @property
    def visibility_target_ids(self) -> tuple[str, ...]:
        """Return visibility target IDs in manifest order."""
        return tuple(target.id for target in self.visibility_targets)


class ResourceVisualizationCapabilities(ReportBaseModel):
    """Manifest-declared visualization capabilities for one cloud resource."""

    resource_id: str = pd.Field(min_length=1)
    resource_type: ResourceType
    manifest_version: str | None = None
    outputs: list[VisualizationOutputCapability] = pd.Field(default_factory=list)

    def get_output(self, output_id: str) -> VisualizationOutputCapability | None:
        """Return the capability for an exact output owner ID."""
        return next((output for output in self.outputs if output.id == output_id), None)


[docs] def get_capabilities(resource: ReportResourceInput) -> ResourceVisualizationCapabilities: """Return Manifest-declared visualization capabilities for one resource. AI-generated workflows should call this function for every selected resource before choosing exact output, field, or visibility-target IDs. See ``get_agent_guide()`` for the required cross-resource selection workflow. """ adapter = DEFAULT_FLOW360_ADAPTER environment = adapter.current_environment() resource_type, resource_id = adapter.resource_identity(resource) manifest = adapter.get_visualization_manifest(resource, environment=environment) return parse_visualization_capabilities( resource_id=resource_id, resource_type=resource_type, manifest=manifest, )
class _OutputBinding: """Internal config-owner to controller-owner mapping matching Workbench.""" def __init__( self, *, config_owner_id: str, controller_owner_ids: list[str], output_type: VisualizationOutputType, member_ids_by_controller_owner: dict[str, list[str]], ): self.config_owner_id = config_owner_id self.controller_owner_ids = controller_owner_ids self.output_type = output_type self.member_ids_by_controller_owner = member_ids_by_controller_owner self.member_ids = list( dict.fromkeys( member_id for member_ids in member_ids_by_controller_owner.values() for member_id in member_ids ) ) def _as_record(value: Any) -> Mapping[str, Any] | None: return value if isinstance(value, Mapping) else None def _string_ids(value: Any) -> list[str]: if not isinstance(value, list): return [] return [str(item) for item in value] def _attribution_ids(node: Mapping[str, Any], name: str) -> list[str]: attributions = _as_record(node.get("attributions")) return _string_ids(attributions.get(name)) if attributions else [] def _is_root_group(node: Mapping[str, Any]) -> bool: properties = _as_record(node.get("properties")) return ( node.get("type") == "GeometryGroup" and properties is not None and properties.get("type") == 0 ) def _manifest_nodes(manifest: Any) -> list[Mapping[str, Any]]: if isinstance(manifest, Mapping): manifest = manifest.get("manifest") if not isinstance(manifest, list): raise ValueError("visualization manifest must be a list of nodes") nodes = [_as_record(node) for node in manifest] if any(node is None for node in nodes): raise ValueError("visualization manifest nodes must be objects") return [node for node in nodes if node is not None] def _categorize_outputs( nodes: list[Mapping[str, Any]], ) -> list[tuple[str, VisualizationOutputType]]: nodes_by_id = {str(node.get("id")): node for node in nodes if node.get("id") is not None} root = next((node for node in nodes if _is_root_group(node)), None) if root is None: raise ValueError("visualization manifest does not contain a root GeometryGroup") root_members = set(_attribution_ids(root, "members")) if not root_members: return [] outputs: list[tuple[str, VisualizationOutputType]] = [] visited: set[str] = set() def append(output_id: str, output_type: VisualizationOutputType) -> None: item = (output_id, output_type) if item not in outputs: outputs.append(item) def traverse(node_id: str, parent_id: str | None) -> None: if node_id in visited: return visited.add(node_id) node = nodes_by_id.get(node_id) if node is None: return manifest_type = node.get("type") members = _attribution_ids(node, "members") is_in_root = node_id in root_members if manifest_type == _STREAMLINE_ARRAY_TYPE and is_in_root: append(node_id, "streamline") return if node_id in _ROOT_SURFACE_IDS and manifest_type == _SOLID_GEOMETRY_TYPE and is_in_root: append(node_id, "surface") return resources = _as_record(node.get("resources")) if ( manifest_type == _SOLID_GEOMETRY_TYPE and is_in_root and resources is not None and resources.get("buffers") is not None and node_id not in _ROOT_EXCLUDED_SOLID_IDS ): append(node_id, "surface") return if node_id == "qcriterion" and manifest_type == _SOLID_GEOMETRY_TYPE and is_in_root: append(node_id, "isosurface") return if node_id == "isosurfaces" and is_in_root and _has_solid_members(node, nodes_by_id): append(node_id, "isosurface") return if node_id == "slices" and manifest_type == _SOLID_GEOMETRY_TYPE and is_in_root: append(node_id, "slice") return if node_id == "slices" and is_in_root and _has_solid_members(node, nodes_by_id): append(node_id, "slice") return if parent_id == "boundaries" and manifest_type == _SOLID_GEOMETRY_TYPE: append(node_id, "surface") return if parent_id in _FIELD_GROUP_TYPES and manifest_type == "GeometryGroup": append(node_id, _FIELD_GROUP_TYPES[parent_id]) return for member_id in members: traverse(member_id, node_id) traverse(str(root.get("id")), None) return outputs def _has_solid_members( node: Mapping[str, Any], nodes_by_id: Mapping[str, Mapping[str, Any]] ) -> bool: return any( nodes_by_id.get(member_id, {}).get("type") == _SOLID_GEOMETRY_TYPE for member_id in _attribution_ids(node, "members") ) def _collect_descendant_ids( nodes_by_id: Mapping[str, Mapping[str, Any]], root_id: str, *, manifest_type: str, ) -> list[str]: visited: set[str] = set() result: list[str] = [] def traverse(node_id: str) -> None: if node_id in visited: return visited.add(node_id) node = nodes_by_id.get(node_id) if node is None: return if node.get("type") == manifest_type: result.append(node_id) return for member_id in _attribution_ids(node, "members"): traverse(member_id) traverse(root_id) return result def _visibility_member_ids( nodes_by_id: Mapping[str, Mapping[str, Any]], output_id: str, output_type: VisualizationOutputType, ) -> list[str]: node = nodes_by_id.get(output_id) if node is None: return [] if output_type == "streamline": return [output_id] if output_type in ("slice", "isosurface"): return _collect_descendant_ids(nodes_by_id, output_id, manifest_type=_SOLID_GEOMETRY_TYPE) solid_ids = ( [output_id] if node.get("type") == _SOLID_GEOMETRY_TYPE else _collect_descendant_ids(nodes_by_id, output_id, manifest_type=_SOLID_GEOMETRY_TYPE) ) member_ids: list[str] = [] seen: set[str] = set() for solid_id in solid_ids: solid = nodes_by_id.get(solid_id) if solid is None: continue for member_id in ( *_attribution_ids(solid, "faces"), *_attribution_ids(solid, "edges"), ): if member_id not in seen: seen.add(member_id) member_ids.append(member_id) return member_ids def _build_bindings( nodes: list[Mapping[str, Any]], resource_type: ResourceType ) -> list[_OutputBinding]: nodes_by_id = {str(node.get("id")): node for node in nodes if node.get("id") is not None} allowed_types = _ALLOWED_OUTPUT_TYPES[resource_type] outputs = [item for item in _categorize_outputs(nodes) if item[1] in allowed_types] if resource_type == "VolumeMesh": slice_ids = [output_id for output_id, output_type in outputs if output_type == "slice"] if not slice_ids: return [] return [ _OutputBinding( config_owner_id=_VOLUME_MESH_CONFIG_OWNER_ID, controller_owner_ids=slice_ids, output_type="slice", member_ids_by_controller_owner={ output_id: _visibility_member_ids(nodes_by_id, output_id, "slice") for output_id in slice_ids }, ) ] return [ _OutputBinding( config_owner_id=output_id, controller_owner_ids=[output_id], output_type=output_type, member_ids_by_controller_owner={ output_id: _visibility_member_ids(nodes_by_id, output_id, output_type) }, ) for output_id, output_type in outputs ] def _iter_buffer_records(buffers: Any) -> Iterable[Mapping[str, Any]]: record = _as_record(buffers) if record is None: return yield record levels = record.get("levels") if isinstance(levels, list): for level in levels: level_record = _as_record(level) if level_record is not None: yield from _iter_buffer_records(level_record.get("resource", level_record)) animation = _as_record(record.get("animation")) frames = animation.get("frames") if animation else record.get("frames") if isinstance(frames, list): for frame in frames: frame_record = _as_record(frame) if frame_record is not None: yield from _iter_buffer_records(frame_record.get("resource")) def _field_dimensions(node: Mapping[str, Any]) -> dict[str, int]: resources = _as_record(node.get("resources")) if resources is None: return {} dimensions: dict[str, int] = {} for buffer_record in _iter_buffer_records(resources.get("buffers")): sections = buffer_record.get("sections") if not isinstance(sections, list): continue for section in sections: section_record = _as_record(section) if section_record is None: continue name = section_record.get("name") if not isinstance(name, str) or not name or name in _NON_FIELD_SECTION_NAMES: continue dimension = section_record.get("dimension") dimensions[name] = max( dimensions.get(name, 1), dimension if isinstance(dimension, int) and dimension > 0 else 1, ) return dimensions def _finite_bounds_value(value: Any) -> float | tuple[float, ...] | None: if isinstance(value, bool): return None if isinstance(value, (int, float)): return float(value) if math.isfinite(value) else None if not isinstance(value, list) or not value: return None values: list[float] = [] for item in value: if isinstance(item, bool) or not isinstance(item, (int, float)) or not math.isfinite(item): return None values.append(float(item)) return tuple(values) def _parse_bounds_records(value: Any) -> dict[str, _ManifestFieldBounds]: if not isinstance(value, list): return {} parsed: dict[str, _ManifestFieldBounds] = {} for item in value: record = _as_record(item) if record is None: continue name = record.get("name") if not isinstance(name, str) or not name or name in parsed: continue min_value = _finite_bounds_value(record.get("minVal", record.get("min_value"))) max_value = _finite_bounds_value(record.get("maxVal", record.get("max_value"))) if min_value is None or max_value is None: continue min_magnitude = _finite_bounds_value(record.get("minMag")) max_magnitude = _finite_bounds_value(record.get("maxMag")) parsed[name] = _ManifestFieldBounds( min_value=min_value, max_value=max_value, min_magnitude=min_magnitude if isinstance(min_magnitude, float) else None, max_magnitude=max_magnitude if isinstance(max_magnitude, float) else None, ) return parsed def _field_bounds_by_name(node: Mapping[str, Any]) -> dict[str, _ManifestFieldBounds]: resources = _as_record(node.get("resources")) buffers = _as_record(resources.get("buffers")) if resources else None direct = _parse_bounds_records(buffers.get("bounds") if buffers else None) if direct: return direct properties = _as_record(node.get("properties")) buffer_locations = _as_record(properties.get("bufferLocations")) if properties else None indices = buffer_locations.get("indices") if buffer_locations else None if not isinstance(indices, list) or not indices: return {} first_index = _as_record(indices[0]) return _parse_bounds_records(first_index.get("bounds") if first_index else None) def _select_field_bounds( bounds: _ManifestFieldBounds, component_index: int | None, ) -> FieldBounds | None: min_value = bounds.min_value max_value = bounds.max_value if component_index is not None: selected_min = ( min_value[component_index] if isinstance(min_value, tuple) and component_index < len(min_value) else min_value if isinstance(min_value, float) else None ) selected_max = ( max_value[component_index] if isinstance(max_value, tuple) and component_index < len(max_value) else max_value if isinstance(max_value, float) else None ) elif isinstance(min_value, tuple) or isinstance(max_value, tuple): selected_min = bounds.min_magnitude selected_max = bounds.max_magnitude else: selected_min = min_value selected_max = max_value if selected_min is None or selected_max is None: return None return (min(selected_min, selected_max), max(selected_min, selected_max)) def _aggregate_field_bounds( bounds_by_model: Iterable[Mapping[str, _ManifestFieldBounds]], field_name: str, component_index: int | None = None, ) -> FieldBounds | None: minimum = math.inf maximum = -math.inf found = False for model_bounds in bounds_by_model: manifest_bounds = model_bounds.get(field_name) if manifest_bounds is None: continue selected = _select_field_bounds(manifest_bounds, component_index) if selected is None: continue found = True minimum = min(minimum, selected[0]) maximum = max(maximum, selected[1]) return (minimum, maximum) if found else None def _field_model_ids( nodes_by_id: Mapping[str, Mapping[str, Any]], owner_id: str, output_type: VisualizationOutputType, ) -> list[str]: if output_type == "streamline": return [owner_id] owner = nodes_by_id.get(owner_id) if owner is None: return [] if owner.get("type") == _SOLID_GEOMETRY_TYPE: return [owner_id] return _collect_descendant_ids(nodes_by_id, owner_id, manifest_type=_SOLID_GEOMETRY_TYPE) def _available_base_fields( nodes_by_id: Mapping[str, Mapping[str, Any]], binding: _OutputBinding ) -> tuple[list[str], dict[str, int]]: available: list[str] | None = None dimension_by_name: dict[str, int] = {} for owner_id in binding.controller_owner_ids: owner_names: list[str] = [] owner_seen: set[str] = set() owner_dimensions: dict[str, int] = {} for model_id in _field_model_ids(nodes_by_id, owner_id, binding.output_type): node = nodes_by_id.get(model_id) if node is None: continue for name, dimension in _field_dimensions(node).items(): if name not in owner_seen: owner_seen.add(name) owner_names.append(name) owner_dimensions[name] = max(owner_dimensions.get(name, 1), dimension) if available is None: available = owner_names dimension_by_name = owner_dimensions continue owner_name_set = set(owner_names) available = [name for name in available if name in owner_name_set] for name in list(dimension_by_name): if name not in owner_name_set: dimension_by_name.pop(name, None) continue dimension_by_name[name] = min( dimension_by_name[name], owner_dimensions[name], ) return available or [], dimension_by_name def _field_capabilities( nodes_by_id: Mapping[str, Mapping[str, Any]], binding: _OutputBinding ) -> list[VisualizationFieldCapability]: names, dimensions = _available_base_fields(nodes_by_id, binding) model_ids: list[str] = [] seen_model_ids: set[str] = set() for owner_id in binding.controller_owner_ids: for model_id in _field_model_ids(nodes_by_id, owner_id, binding.output_type): if model_id not in seen_model_ids: seen_model_ids.add(model_id) model_ids.append(model_id) bounds_by_model = [ _field_bounds_by_name(nodes_by_id[model_id]) for model_id in model_ids if model_id in nodes_by_id ] fields: list[VisualizationFieldCapability] = [] for name in names: dimension = dimensions.get(name, 1) fields.append( VisualizationFieldCapability( name=name, dimension=dimension, bounds=_aggregate_field_bounds(bounds_by_model, name), ) ) components = _VECTOR_COMPONENTS[: min(dimension, 3)] if dimension > 1 else () for component_index, component in enumerate(components): fields.append( VisualizationFieldCapability( name=f"{name}_{component}", dimension=1, source_name=name, component=component, bounds=_aggregate_field_bounds( bounds_by_model, name, component_index, ), ) ) return fields def parse_visualization_capabilities( *, resource_id: str, resource_type: ResourceType, manifest: Any, ) -> ResourceVisualizationCapabilities: """Parse one visualization manifest into Report-addressable capabilities.""" nodes = _manifest_nodes(manifest) nodes_by_id = {str(node.get("id")): node for node in nodes if node.get("id") is not None} root = next((node for node in nodes if _is_root_group(node)), None) bindings = _build_bindings(nodes, resource_type) outputs: list[VisualizationOutputCapability] = [] for binding in bindings: controller_owner_id_by_member_id: dict[str, str] = {} for controller_owner_id, member_ids in binding.member_ids_by_controller_owner.items(): for member_id in member_ids: controller_owner_id_by_member_id.setdefault(member_id, controller_owner_id) targets = [ VisualizationTargetCapability( id=member_id, controller_owner_id=controller_owner_id_by_member_id.get(member_id), type=( str(nodes_by_id[member_id].get("type")) if member_id in nodes_by_id and nodes_by_id[member_id].get("type") is not None else None ), ) for member_id in binding.member_ids ] outputs.append( VisualizationOutputCapability( id=binding.config_owner_id, type=binding.output_type, controller_owner_ids=binding.controller_owner_ids, fields=_field_capabilities(nodes_by_id, binding), visibility_targets=targets, ) ) manifest_version = root.get("version") if root is not None else None return ResourceVisualizationCapabilities( resource_id=resource_id, resource_type=resource_type, manifest_version=manifest_version if isinstance(manifest_version, str) else None, outputs=outputs, )