"""
report utils, utils.py
"""
# pylint: disable=too-many-lines
from __future__ import annotations
import ast
import math
import os
import posixpath
import re
import shutil
import uuid
from abc import ABCMeta, abstractmethod
from numbers import Number
from typing import Annotated, Any, ClassVar, List, Literal, Optional, Tuple, Union
import numpy as np
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 NoEscape, Package, Tabular
from flow360.component.case import Case, CaseMetaV2
from flow360.component.results import base_results, case_results
from flow360.component.volume_mesh import VolumeMeshDownloadable, VolumeMeshV2
from flow360.log import log
here = os.path.dirname(os.path.abspath(__file__))
class RequirementItem(pd.BaseModel):
"""
RequirementItem
"""
resource_type: Literal["case", "volume_mesh", "surface_mesh", "geometry"] = "case"
filename: str
model_config = {"frozen": True}
_base_case_attributes: ClassVar[list] = CaseMetaV2.model_fields.keys()
# pylint: disable=protected-access
_requirements_mapping: ClassVar[dict] = {
"params": {"resource_type": "case", "filename": "simulation.json"},
"cfl": {
"resource_type": "case",
"filename": case_results.CFLResultCSVModel()._remote_path(),
},
"total_forces": {
"resource_type": "case",
"filename": case_results.TotalForcesResultCSVModel()._remote_path(),
},
"surface_forces": {
"resource_type": "case",
"filename": case_results.SurfaceForcesResultCSVModel()._remote_path(),
},
"linear_residuals": {
"resource_type": "case",
"filename": case_results.LinearResidualsResultCSVModel()._remote_path(),
},
"nonlinear_residuals": {
"resource_type": "case",
"filename": case_results.NonlinearResidualsResultCSVModel()._remote_path(),
},
"x_slicing_force_distribution": {
"resource_type": "case",
"filename": case_results.XSlicingForceDistributionResultCSVModel()._remote_path(),
},
"y_slicing_force_distribution": {
"resource_type": "case",
"filename": case_results.YSlicingForceDistributionResultCSVModel()._remote_path(),
},
"volume_mesh": {"resource_type": "volume_mesh", "filename": "simulation.json"},
"volume_mesh/stats": {
"resource_type": "volume_mesh",
"filename": VolumeMeshV2._mesh_stats_file,
},
"volume_mesh/bounding_box": {
"resource_type": "volume_mesh",
"filename": VolumeMeshDownloadable.BOUNDING_BOX.value,
},
"surface_mesh": {"resource_type": "surface_mesh", "filename": "simulation.json"},
"geometry": {"resource_type": "geometry", "filename": "simulation.json"},
"bet_forces": {
"resource_type": "case",
"filename": case_results.BETForcesResultCSVModel()._remote_path(),
},
"bet_forces_radial_distribution": {
"resource_type": "case",
"filename": case_results.BETForcesRadialDistributionResultCSVModel()._remote_path(),
},
"actuator_disks": {
"resource_type": "case",
"filename": case_results.ActuatorDiskResultCSVModel()._remote_path(),
},
"aeroacoustics": {
"resource_type": "case",
"filename": case_results.AeroacousticsResultCSVModel()._remote_path(),
},
"surface_heat_transfer": {
"resource_type": "case",
"filename": case_results.SurfaceHeatTransferResultCSVModel()._remote_path(),
},
}
_named_results_mapping: ClassVar[dict] = {
"monitors": {
"resource_type": "case",
"pattern": case_results.CaseDownloadable.MONITOR_PATTERN.value,
},
"user_defined_dynamics": {
"resource_type": "case",
"pattern": case_results.CaseDownloadable.USER_DEFINED_DYNAMICS_PATTERN.value,
},
}
@classmethod
@pd.validate_call
def from_data_key(cls, data_key: str, results_name: Optional[str] = None):
"""
Return a RequirementItem object based on the data key (a type of data that is required).
Examples
--------
>>> RequirementItem.from_data_key(data_key="nonlinear_residuals")
>>> RequirementItem.from_data_key(data_key="surface_mesh")
>>> RequirementItem.from_data_key(data_key="monitors", results_name="massFluxExhaust")
"""
if data_key in cls._base_case_attributes:
return None
if data_key in cls._requirements_mapping:
return cls(
resource_type=cls._requirements_mapping.get(data_key).get("resource_type"),
filename=cls._requirements_mapping.get(data_key).get("filename"),
)
if data_key in cls._named_results_mapping:
if results_name is None:
raise ValueError(f"Results name is required for {data_key}.")
return cls(
resource_type=cls._named_results_mapping.get(data_key).get("resource_type"),
filename="results/"
+ cls._named_results_mapping.get(data_key)
.get("pattern")
.replace("(.+)", results_name),
)
raise NotImplementedError(
f"Unknown data key: {data_key}. Can not use this data for report generation yet"
+ " or wrong data key was specified."
)
def get_requirements_from_data_path(data_path: List) -> List[RequirementItem]:
"""
Retrieves requirements based on data path entries by mapping root paths
to their corresponding requirements.
Parameters
----------
data_path : iterable
An iterable containing data path entries to be checked.
Returns
-------
list
A list of unique requirements derived from the data path.
Raises
------
ValueError
If a root path in the data path does not have a corresponding requirement.
"""
requirements = set()
for item in data_path:
root_path = get_root_path(item)
root_path = split_path(root_path)
results_name = None
if root_path[0] == "results":
data_key = root_path[1]
results_name = root_path[2]
elif root_path[0] == "volume_mesh" and len(root_path) > 1:
data_key = root_path[0] + "/" + root_path[1]
else:
data_key = root_path[0]
requirement = RequirementItem.from_data_key(data_key=data_key, results_name=results_name)
requirements.add(requirement)
return list(requirements)
def detect_latex_compiler():
"""
Detects available LaTeX compilers on the system.
Returns:
compiler (str), compiler_args (list[str]): Name of the LaTeX compiler ('xelatex', 'latexmk', 'pdflatex').
Raises:
RuntimeError: If no LaTeX compiler is found.
"""
preferred_compilers = [("xelatex", []), ("latexmk", ["--pdf"]), ("pdflatex", [])]
for compiler, compiler_args in preferred_compilers:
if shutil.which(compiler):
return compiler, compiler_args
# If no compiler is found, raise an error
raise RuntimeError(
"No LaTeX compiler found. Please install a LaTeX distribution (e.g., TeX Live, MiKTeX)."
)
def check_landscape(doc):
"""
Checks if a document is in landscape orientation based on geometry package options.
Parameters
----------
doc : Document
The LaTeX document to check for landscape orientation.
Returns
-------
bool
True if the document is in landscape orientation, False otherwise.
"""
for package in doc.packages:
if "geometry" in str(package.arguments):
return "landscape" in str(package.options)
return False
def get_case_from_id(case_id: str, cases: list[Case]) -> Case:
"""
Retrieves a case by its unique identifier from a list of cases.
Parameters
----------
case_id : str
The unique identifier of the case to retrieve.
cases : list[Case]
A list of `Case` objects to search.
Returns
-------
Case
The `Case` object matching the specified `case_id`.
Raises
------
ValueError
If no cases are provided or the specified `case_id` is not found.
"""
if len(cases) == 0:
raise ValueError("No cases provided for `get_case_from_id`.")
for case in cases:
if case.id == case_id:
return case
raise ValueError(f"{case_id=} not found in {cases=}")
def get_root_path(data_path):
"""
Extracts the root path from a given data path.
If the provided `data_path` is of type `Delta`, the function retrieves
the path from `data_path.data`.
Parameters
----------
data_path : str or Delta or None
The data path to parse or a `Delta` object containing a data path.
Returns
-------
str or None
The root path as a string if `data_path` is valid, otherwise `None`.
"""
if data_path is not None:
if isinstance(data_path, (Delta, DataItem)):
data_path = data_path.data
if isinstance(data_path, DataItem):
data_path = data_path.data
return data_path
return None
def split_path(path):
"""
Split the path using both '/' and '.' as separators
"""
path_components = [comp for comp in re.split(r"[/.]", path) if comp]
return path_components
def path_variable_name(path):
"""
Get the last component of the path.
"""
return split_path(path)[-1]
# pylint: disable=too-many-return-statements,too-many-branches
def search_path(case: Case, component: str) -> Any:
"""
Case starts as a `Case` object but changes as it recurses through the path components
"""
# Check if component is an attribute
try:
return getattr(case, component)
except AttributeError:
pass
# Check if component is an attribute of case.results
# Convenience feature so the user doesn't have to include "results" in path
try:
return getattr(case.results, component)
except AttributeError:
pass
# Check if component is a key for a dictionary
try:
case = case[component]
# Have to test for int or str here otherwise...
if isinstance(case, (int, str)):
return case
# .. this raises a KeyError.
# This is a convenience that may be removed for if people want something other than the value
if "value" in case:
return case["value"]
return case
except TypeError:
pass
# Check if case is a list
# E.g. in user defined functions
if isinstance(case, list):
for obj in case:
try:
if obj.name == component:
return obj
if obj.type_name == component:
return obj
except AttributeError:
pass
if type(obj).__name__ == component:
return obj
# interpret component as an int index
try:
return case[int(component)]
except (ValueError, IndexError):
pass
# Check if case is a number
if isinstance(case, Number):
return case
# Check if component is a key of a value
try:
return case.values[component]
except KeyError as err:
raise ValueError(
f"Could not find path component: '{component}', available: {case.values.keys()}"
) from err
except AttributeError:
log.warning(f"unknown value for path: {case=}, {component=}")
return None
def data_from_path(
case: Case,
path: str,
cases: list[Case] = None,
case_by_case: bool = False,
filter_physical_steps_only: bool = False,
) -> Any:
"""
Retrieves data from a specified path within a `Case` object, with optional delta calculations.
Parameters
----------
case : Case
The primary `Case` object to search.
path : str
The path string indicating the nested attributes or dictionary keys.
cases : list[Case], optional
List of additional cases for delta calculations, default is an empty list.
case_by_case : bool, default=False
Flag for enabling case-by-case delta calculation when `path` is a `Delta` object.
filter_physical_steps_only: bool
Whether to return data asociated only with the converged physical steps (last pseudo_step in a time step).
Returns
-------
Any
The data extracted from the specified path within the `Case` object or calculated delta.
Raises
------
ValueError
If a specified path component is not found or cannot be accessed.
Notes
-----
This function splits the path into components and recursively searches through attributes,
dictionary keys, or list indices as indicated in each component. Supports delta calculation
using `Delta` objects and error handling for invalid paths.
"""
if cases is None:
cases = []
if isinstance(path, Delta):
if case_by_case:
return path.model_copy(update={"ref_index": None}).calculate(case, cases)
return path.calculate(case, cases)
if isinstance(path, DataItem):
return path.calculate(case, cases)
# Split path into components
path_components = split_path(path)
# Case variable is slightly misleading as this is only a case on the first iteration
for component in path_components:
if component == "time":
case.reload_data(include_time=True)
if filter_physical_steps_only and (component == path_components[-1]):
case.reload_data(filter_physical_steps_only=True)
case = search_path(case, component)
return case
def results_from_path(
case, path
) -> tuple[Union[base_results.ResultBaseModel, None], Union[str, None]]:
"""
Returns the last ResultsBaseModel object on the path.
Parameters
----------
path : str
The path string indicating the nested attributes or dictionary keys.
Returns
-------
returned_case, returned_component: tuple[Union[base_results.ResultBaseModel, None], Union[str, None]]
returned_case - the last ResultsBaseModel object on the path,
returned_component - component of the returned case or None if the case was a last part of the path
"""
path_components = split_path(path)
returned_case = None
returned_component = None
for component in path_components:
returned_component = component
case = search_path(case, component)
if isinstance(case, base_results.ResultBaseModel):
returned_case = case
returned_component = None
return returned_case, returned_component
class GenericOperation(Flow360BaseModel, metaclass=ABCMeta):
"""
Abstraction for operations
"""
@abstractmethod
def calculate(
self, data, case, cases, variables, new_variable_name
): # pylint: disable=too-many-arguments
"""
evaluate operation
"""
[docs]
class GetAttribute(GenericOperation):
"""
Retrieve an attribute from a data object.
This operation extracts the attribute specified by `attr_name` from the provided data object
using Python's built-in `getattr` function. If the attribute is not found, an `AttributeError`
is raised, providing a clear error message.
"""
attr_name: str = pd.Field(
description="The name of the attribute to retrieve from the data object."
)
type_name: Literal["GetAttribute"] = pd.Field("GetAttribute", frozen=True)
[docs]
def calculate(
self, data, case, cases, variables, new_variable_name
): # pylint: disable=too-many-arguments
"""
Retrieves the attribute specified by `attr_name` from the given data object.
Returns a tuple containing the original data, the cases list, and the extracted attribute value.
"""
try:
result = getattr(data, self.attr_name)
except AttributeError as err:
raise AttributeError(f"Attribute {self.attr_name} not found in {type(data)=}") from err
return data, cases, result
[docs]
class Average(GenericOperation):
"""
Represents an averaging operation on simulation results.
This operation calculates the average of a given data set over a specified range of steps, time,
or fraction of the dataset.
Raises
------
NotImplementedError
If the method of averaging (e.g., step-based or time-based) is not implemented or the data type is unsupported.
Example
-------
avg = Average(fraction=0.1)
result = avg.calculate(data, case, cases, variables, new_variable_name)
"""
start_step: Optional[pd.NonNegativeInt] = pd.Field(
None,
description="The starting step for averaging. If not specified, averaging starts from the beginning.",
)
end_step: Optional[pd.NonNegativeInt] = pd.Field(
None,
description="The ending step for averaging. If not specified, averaging continues to the end.",
)
start_time: Optional[pd.NonNegativeFloat] = pd.Field(
None,
description="The starting time for averaging. If not specified, averaging starts from the beginning.",
)
end_time: Optional[pd.NonNegativeFloat] = pd.Field(
None,
description="The ending time for averaging. If not specified, averaging continues to the end.",
)
fraction: Optional[pd.PositiveFloat] = pd.Field(
None,
le=1,
description="The fraction of the dataset to be averaged, ranging from 0 to 1."
+ " Only the fraction-based method is implemented.",
)
type_name: Literal["Average"] = pd.Field("Average", frozen=True)
@pd.model_validator(mode="before")
@classmethod
def _check_range_fields(cls, values):
"""Validate conflicting and required range fields."""
conflicts = [
("start_step", "start_time"),
("start_step", "fraction"),
("start_time", "fraction"),
("end_step", "end_time"),
("end_step", "fraction"),
("end_time", "fraction"),
]
for f1, f2 in conflicts:
if values.get(f1) is not None and values.get(f2) is not None:
raise ValueError(f"{f1} and {f2} cannot be specified at the same time.")
required = ["start_step", "start_time", "fraction"]
if not any(values.get(f) is not None for f in required):
raise ValueError(f"One of {required} is required.")
return values
[docs]
def calculate(
self, data, case, cases, variables, new_variable_name
): # pylint: disable=too-many-arguments
"""
Performs the averaging operation on the provided data.
"""
if isinstance(data, case_results.ResultCSVModel):
if self.fraction is None:
raise NotImplementedError('Only "fraction" average method implemented.')
averages = data.get_averages(average_fraction=self.fraction)
return data, cases, averages[new_variable_name]
raise NotImplementedError(
f"{self.__class__.__name__} not implemented for data type: {type(data)=}"
)
[docs]
class Variable(Flow360BaseModel):
"""
Variable model used in expressions
"""
name: str = pd.Field(description="Name of the variable.")
data: str = pd.Field(description="Data contained within the variable.")
[docs]
class Expression(GenericOperation):
"""
Represents a mathematical expression to be evaluated on simulation result data.
This operation allows for defining and calculating custom expressions using
variables extracted from simulation results. The results of the expression
evaluation can be added as a new column to a dataframe for further analysis.
Example
-------
expr = Expression(expr="totalCD * area")
result = expr.calculate(data, case, cases, variables, new_variable_name)
Raises
------
ValueError
If variables in the expression are missing from the dataframe or if the
expression cannot be evaluated due to syntax or other issues.
NotImplementedError
If the data type is unsupported by the `calculate` method.
"""
expr: str = pd.Field(
description="The mathematical expression to evaluate. It should be written in a syntax compatible"
+ " with the `numexpr` library, using variable names that correspond to"
+ " columns in the dataframe or user-defined variables."
)
type_name: Literal["Expression"] = pd.Field("Expression", frozen=True)
[docs]
@classmethod
def get_variables(cls, expr):
"""
Parses the given expression and returns a set of variable names used in it.
"""
tree = ast.parse(expr, mode="eval")
class VariableVisitor(ast.NodeVisitor):
"""
A custom NodeVisitor class that visits nodes in an abstract syntax tree (AST)
to collect variable names that are not part of known functions.
"""
# pylint: disable=invalid-name
def __init__(self):
self.variables = set()
self.known_functions = {name for name in dir(math) if callable(getattr(math, name))}
def visit_Name(self, node):
"""
Visit a Name node and add the variable name to the variables set if it's not
a known function.
Args:
node (ast.Name): The Name node to visit.
"""
if isinstance(node.ctx, ast.Load) and node.id not in self.known_functions:
self.variables.add(node.id)
def visit_Call(self, node):
"""
Visit a Call node and recursively visit its arguments and keyword arguments
to find any additional variable names.
Args:
node (ast.Call): The Call node to visit.
"""
for arg in node.args:
self.visit(arg)
for keyword in node.keywords:
self.visit(keyword.value)
visitor = VariableVisitor()
visitor.visit(tree)
return visitor.variables
[docs]
@classmethod
def evaluate_expression(
cls, df, expr, variables: List[Variable], new_variable_name, case
): # pylint: disable=too-many-arguments
"""
Evaluates the given expression on the provided dataframe, using the specified variables.
The result is added as a new column in the dataframe.
"""
import numexpr as ne # pylint: disable=import-outside-toplevel
expr_variables = cls.get_variables(expr)
missing_vars = expr_variables - set(df.columns)
found_variables = set()
for missing_var in missing_vars:
try:
if variables is not None:
for v in variables:
if missing_var == v.name:
df[missing_var] = data_from_path(case, v.data)
found_variables.add(missing_var)
except (ValueError, KeyError) as e:
log.warning(e)
missing_vars -= found_variables
if missing_vars:
raise ValueError(
f"The following variables are missing in the dataframe: {', '.join(missing_vars)}"
)
local_dict = {var: df[var].values for var in expr_variables}
try:
result = ne.evaluate(expr, local_dict)
except Exception as e:
raise ValueError(f"Error evaluating expression: {e}") from e
df[new_variable_name] = result
return df
[docs]
def calculate(
self, data, case, cases, variables, new_variable_name
): # pylint: disable=too-many-arguments
"""
Executes the expression evaluation for the given simulation data and returns the result.
"""
log.debug(f"evaluating expression {self.expr}, {case.id=}")
if isinstance(data, case_results.ResultCSVModel):
df = self.evaluate_expression(
data.as_dataframe(), self.expr, variables, new_variable_name, case
)
data.update(df)
return data, cases, data.values[new_variable_name]
raise NotImplementedError(
f"{self.__class__.__name__} not implemented for data type: {type(data)=}"
)
OperationTypes = Annotated[
Union[Average, Expression, GetAttribute], pd.Field(discriminator="type_name")
]
# pylint: disable=no-member
[docs]
class DataItem(Flow360BaseModel):
"""
Represents a retrievable data item that can be post-processed.
The `DataItem` class retrieves data from a specified path within a `Case` and allows for:
- Excluding specific boundaries (if applicable).
- Applying one or more post-processing operations (e.g., mathematical expressions, averaging).
Averaging should be applied as the last expression, accessing variables through `/averages` in path will
automatically append an averaging operation to the object's operations.
- Introducing additional variables for use in these operations.
"""
data: str = pd.Field(
description="Path to the data item to retrieve from a `Case`."
+ ' The path can include nested attributes and dictionary keys (e.g., "results.surface_forces").'
)
title: Optional[str] = pd.Field(
None,
description="A human-readable title for this data item."
+ " If omitted, the title defaults to the last component of the `data` path.",
)
include: Optional[List[str]] = pd.Field(
None,
description="Boundaries to be included in the retrieved data (e.g., certain surfaces)."
+ " Only applicable to some data types, such as surface forces or slicing force distributions.",
)
exclude: Optional[List[str]] = pd.Field(
None,
description="Boundaries to be excluded from the retrieved data (e.g., certain surfaces)."
+ " Only applicable to some data types, such as surface forces or slicing force distributions.",
)
operations: Optional[List[OperationTypes]] = pd.Field(
None,
description="A list of operations to apply to the retrieved data."
+ " Supported operations include: `Expression` and `Average`.",
)
variables: Optional[List[Variable]] = pd.Field(
None,
description="Additional user-defined variables that may be referenced in the `Expression` operations.",
)
type_name: Literal["DataItem"] = pd.Field("DataItem", frozen=True)
@pd.model_validator(mode="before")
@classmethod
def _validate_operations(cls, values):
if not isinstance(values, dict):
raise ValueError("Invalid input structure.")
operations = values.get("operations")
if operations is None:
values["operations"] = []
elif not isinstance(operations, list):
values["operations"] = [operations]
return values
@pd.model_validator(mode="after")
def _check_for_averages(self):
components = split_path(self.data)
if "averages" in components:
self.operations.append(Average(fraction=0.1))
components.remove("averages")
self.data = "/".join(components)
return self
def _preprocess_data(self, source, component):
if isinstance(source, case_results.ResultCSVModel):
new_variable_name = "opr_" + uuid.uuid4().hex[:8]
if component is not None:
self.operations.insert(0, Expression(expr=component))
return source, new_variable_name
raise NotImplementedError(
f"{self.__class__.__name__} not implemented for data type: data={self.data}, {type(source)=}"
)
[docs]
def calculate(self, case: Case, cases: List[Case]) -> float:
"""
Calculates the DataItem values based on specified operations.
Parameters
----------
case : Case
The target case for which the delta is calculated.
cases : List[Case]
A list of available cases, including the reference case.
Returns
-------
float
The computed data array.
"""
source, component = results_from_path(case, self.data)
if isinstance(source, base_results.ResultCSVModel):
if self.exclude is not None or self.include is not None:
if isinstance(source, case_results.PerEntityResultCSVModel):
source.filter(include=self.include, exclude=self.exclude)
else:
raise AttributeError(
"Include and exclude can be used only with some data types,"
+ " such as surface forces or slicing force distributions."
)
source, new_variable_name = self._preprocess_data(source=source, component=component)
if len(self.operations) > 0:
for opr in self.operations: # pylint: disable=not-an-iterable
source, cases, result = opr.calculate(
source, case, cases, self.variables, new_variable_name
)
return result
return source
raise NotImplementedError(
f"{self.__class__.__name__} not implemented for data type: {type(source)=}"
)
def __str__(self):
if self.title is not None:
return self.title
return path_variable_name(self.data)
[docs]
class Delta(Flow360BaseModel):
"""
Represents a delta calculation between a reference case and a target case based on specified data.
"""
data: Union[DataItem, str] = pd.Field(
description="Path to the data item used for delta calculation."
)
ref_index: Optional[pd.NonNegativeInt] = pd.Field(
0, description="Index of the reference case in the list of cases for comparison."
)
type_name: Literal["Delta"] = pd.Field("Delta", frozen=True)
[docs]
def calculate(self, case: Case, cases: List[Case]) -> float:
"""
Calculates the delta between the specified case and the reference case.
Parameters
----------
case : Case
The target case for which the delta is calculated.
cases : List[Case]
A list of available cases, including the reference case.
Returns
-------
float
The computed delta value between the case and reference case data.
Raises
------
ValueError
If `ref_index` is out of bounds or `None`, indicating a missing reference.
"""
if self.ref_index is None or self.ref_index >= len(cases):
return "Ref not found."
ref = cases[self.ref_index]
case_result = data_from_path(case, self.data)
ref_result = data_from_path(ref, self.data)
return case_result - ref_result
def __str__(self):
if isinstance(self.data, str):
data_str = path_variable_name(self.data)
else:
data_str = str(self.data)
return f"Delta {data_str}"
@property
def include(self):
"""Returns the include filter from the underlying DataItem, or None if data is not a DataItem."""
return self.data.include if isinstance(self.data, DataItem) else None
@property
def exclude(self):
"""Returns the exclude filter from the underlying DataItem, or None if data is not a DataItem."""
return self.data.exclude if isinstance(self.data, DataItem) else None
# pylint: disable=too-few-public-methods
class Tabulary(Tabular):
"""
The `tabulary` package works better than the existing pylatex implementations so this includes it in pylatex
"""
packages = [Package("tabulary")]
def __init__(self, *args, width_argument=NoEscape(r"\linewidth"), **kwargs):
"""
Args
----
width_argument:
The width of the table. By default the table is as wide as the
text.
"""
super().__init__(*args, start_arguments=width_argument, **kwargs)
[docs]
class Grouper(Flow360BaseModel):
"""
Class for objects responsible for grouping data into series in Chart2D.
Example
-------
- Data will be grouped by each turbulence model and then by the first tag,
if the first tag is "a" or "b" the data point will be assigned to group "bucket0",
if the first tag is "c" the data point will be assigned to "bucket1"
>>> Grouper(
... group_by=["params/models/Fluid/turbulence_model_solver/type_name", "info/tags/0"],
... buckets=[None, {"bucket0": ["a", "b"], "bucket1": ["c"]}],
... )
====
"""
group_by: Union[str, List[str], None, List[None]] = pd.Field(
description="The path to the data attribute (or paths to attributes in case of multi-level grouping)"
+ " by which the grouping should be done."
)
buckets: Optional[Union[dict[str, List], List[Union[dict[str, List], None]]]] = pd.Field(
None,
description="Dictionaries where key represents the name of the group and value is the list of values,"
+ " that belong to the group. If all the values should be unique, enter None for the corresponding bucket.",
)
_series_assignments: List[List[str]] = pd.PrivateAttr(default=None)
@pd.model_validator(mode="after")
def _handle_singular_inputs(self):
if not isinstance(self.group_by, List):
self.group_by = [self.group_by]
if not isinstance(self.buckets, List):
self.buckets = [self.buckets] * len(self.group_by)
return self
@pd.model_validator(mode="after")
def _check_argument_lengths(self):
if self.buckets is not None and len(self.group_by) != len(self.buckets):
raise pd.ValidationError(
"group_by and buckets must be the same length. "
+ "If a category should not be grouped into buckets enter None in the bucket's place."
)
return self
def _get_possible_assignments(self, category, cases):
assignments = []
for case in cases:
assignment = data_from_path(case, category, cases)
if assignment not in assignments:
assignments.append(assignment)
return assignments
[docs]
def initialize_arrays(self, cases, y_variables):
"""
Initializes data structures for x_data and y_data.
"""
self._series_assignments = [[path_variable_name(str(y))] for y in y_variables]
if self.group_by != [None]:
for category, bucket in zip(self.group_by, self.buckets):
if bucket is not None:
grouping_attributes = bucket.keys()
else:
grouping_attributes = self._get_possible_assignments(category, cases)
new_assignments = []
for assignment in self._series_assignments:
for attribute in grouping_attributes:
new_assignments.append(assignment + [attribute])
self._series_assignments = new_assignments
x_data = [[] for _ in range(len(self._series_assignments))]
y_data = [[] for _ in range(len(self._series_assignments))]
return x_data, y_data
# pylint: disable=inconsistent-return-statements
def _is_in_bucket(self, bucket_criteria, attribute) -> bool:
for criterion in bucket_criteria:
if attribute == criterion:
return True
if callable(criterion):
crit_result = criterion(attribute)
if isinstance(crit_result, bool):
return crit_result
raise AttributeError(
f"Bucket criterion must return bool, current returned is {type(crit_result)}."
)
# pylint: disable=too-many-arguments
[docs]
def arrange_data(self, case, x_data, y_data, x_data_point, y_data_point, y_variable):
"""
Sorts the data into appropriate series based on the case.
"""
point_attributes = [path_variable_name(str(y_variable))]
if self.group_by != [None]:
for category, bucket in zip(self.group_by, self.buckets):
attribute = data_from_path(case, category)
if bucket is not None:
for key, value in bucket.items():
if self._is_in_bucket(value, attribute):
point_attributes.append(key)
else:
point_attributes.append(attribute)
for idx, assignment in enumerate(self._series_assignments):
if point_attributes == assignment:
x_data[idx].append(x_data_point)
y_data[idx].append(y_data_point)
return x_data, y_data
return x_data, y_data
[docs]
def arrange_legend(self):
"""
Creates the legend for the defined grouping.
"""
legend = []
assignments = self._series_assignments.copy()
assignments_array = np.array(assignments)
if np.all(assignments_array[:, 0] == assignments_array[0, 0]):
assignments = assignments_array[:, 1:].tolist()
if assignments is None:
return None
for assignment in assignments:
legend.append(" - ".join(assignment))
return legend
def generate_colorbar_from_image(
image_filename=os.path.join(here, "img", "colorbar_rainbow_banded_30.png"),
limits: Tuple[float, float] = (0, 1),
field_name: str = "Field",
output_filename="colorbar_with_ticks.png",
height_px=25,
is_log_scale=False,
): # pylint: disable=too-many-arguments,too-many-locals
"""
Generate a color bar image from an existing PNG file with ticks and labels.
This function reads a colormap from a provided PNG file (a horizontal strip of
colors), and overlays ticks and labels according to the specified value limits
and scale type (linear or logarithmic).
For a linear scale, matplotlib automatically chooses the number and format of
ticks. For a log scale, a twin axis is used for proper tick placement without
distorting the color distribution.
Parameters
----------
image_filename : str
Path to the colormap PNG file (horizontal strip).
limits : tuple of float
A tuple (min_value, max_value) specifying the data range.
If `is_log_scale` is True, `max_value` must be greater than 0.
field_name : str
The field name for the label.
output_filename : str
The output filename for the resulting image with ticks.
height_px : int
The height in pixels for the color bar image.
is_log_scale : bool
If True, use a log scale axis for ticks and minor ticks.
Returns
-------
None
The resulting image is saved to `output_filename`.
Notes
-----
- On a log scale, the main colorbar axis remains linear to avoid deforming
the color distribution. A twin axis is used solely for log-scale labeling.
"""
import matplotlib.pyplot as plt # pylint: disable=import-outside-toplevel
from matplotlib.ticker import ( # pylint: disable=import-outside-toplevel
LogFormatterSciNotation,
)
from PIL import Image # pylint: disable=import-outside-toplevel
img = Image.open(image_filename)
original_width, _ = img.size
new_size = (original_width, height_px)
img_resized = img.resize(new_size, Image.LANCZOS) # pylint: disable=no-member
img_array = np.array(img_resized)
dpi = 200
width_inch = new_size[0] / dpi * 4
height_inch = height_px / dpi
fig, ax = plt.subplots(figsize=(width_inch, height_inch), dpi=dpi)
min_value = limits[0]
max_value = limits[1]
ax.imshow(img_array, aspect="auto", extent=[min_value, max_value, 0, 1])
ax.get_yaxis().set_visible(False)
if is_log_scale:
if min_value <= 0:
raise ValueError("min_value must be >0 for log scale.")
ax2 = ax.twiny()
ax2.set_xscale("log")
ax2.set_xlim(min_value, max_value)
ax2.xaxis.set_major_formatter(LogFormatterSciNotation())
ax2.tick_params(
axis="x", which="both", top=True, bottom=False, labeltop=True, labelbottom=False
)
ax.set_xticks([])
ax.set_xlabel(field_name, fontsize=10)
plt.savefig(output_filename, bbox_inches="tight", pad_inches=0.05)
plt.close(fig)
font_path = posixpath.join(here, "fonts/").replace("\\", "/")
font_definition = (
r"""
\setmainfont{TWKEverett}[
Path = """
+ font_path
+ r""",
Extension = .otf,
UprightFont = *-Regular,
ItalicFont = *-RegularItalic,
BoldFont = *-Bold,
BoldItalicFont = *-BoldItalic,
]
"""
)
def downsample_image_to_relative_width(input_path, output_path, relative_width=1.0, dpi=150):
"""
Downsample the image so that when displayed at 'relative_width' fraction of A4 horizontal width,
it corresponds approximately to the given DPI.
Parameters
----------
input_path : str
Path to the original high-resolution image.
output_path : str
Path to save the downsampled image.
relative_width : float
Fraction of the A4 horizontal width (297 mm) the image should occupy.
e.g., 1.0 means full width, 0.5 means half the width, etc.
dpi : int
Desired DPI (pixels per inch).
"""
from PIL import Image # pylint: disable=import-outside-toplevel
a4_width_in_mm = 297.0 # A4 width in mm in landscape orientation
mm_per_inch = 25.4
final_width_inches = (a4_width_in_mm * relative_width) / mm_per_inch
desired_pixel_width = final_width_inches * dpi
img = Image.open(input_path)
original_width, original_height = img.size
scale = desired_pixel_width / original_width
if scale < 1:
new_width = int(round(original_width * scale))
new_height = int(round(original_height * scale))
img = img.resize((new_width, new_height), Image.LANCZOS) # pylint: disable=no-member
img.save(output_path)