From 2c3709de658a4500faa25c40195077d2e55bbcbd Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Mon, 16 Mar 2026 13:46:50 -0400 Subject: [PATCH 01/25] feat: Centralize schema by migrating base models, types, and unit system to flow360-schema Migrate Flow360BaseModel, length types, and operation condition models to the external flow360-schema package. Remove now-redundant mixin classes and update dependencies. Add CodeArtifact CI setup and coverage reporting. --- .../setup-codeartifact-poetry-auth/action.yml | 52 +++ .github/scripts/coverage_summary.py | 234 +++++++++++++ .github/workflows/codestyle.yml | 20 ++ .github/workflows/pypi-publish.yml | 8 + .github/workflows/test.yml | 42 +++ .gitignore | 3 +- flow360/component/simulation/entity_info.py | 6 +- .../simulation/framework/base_model.py | 323 +----------------- .../simulation/framework/base_model_config.py | 11 +- .../framework/entity_materializer.py | 4 +- .../framework/multi_constructor_model_base.py | 8 +- .../component/simulation/framework/updater.py | 40 +-- .../migration/extra_operating_condition.py | 4 +- .../simulation/models/solver_numerics.py | 25 +- .../simulation/models/volume_models.py | 4 +- .../operating_condition.py | 108 +++--- .../component/simulation/outputs/outputs.py | 55 +-- flow360/component/simulation/primitives.py | 9 +- flow360/component/simulation/services.py | 30 +- .../component/simulation/simulation_params.py | 49 +-- .../translator/solver_translator.py | 8 +- flow360/component/simulation/unit_system.py | 88 ++--- .../simulation/user_code/core/types.py | 12 +- .../validation/validation_context.py | 4 +- flow360/component/v1/updater.py | 1 - flow360/error_messages.py | 8 - flow360/plugins/report/utils.py | 36 +- flow360/version.py | 2 +- plan_sort_json.markdown | 22 ++ poetry.lock | 25 +- pyproject.toml | 12 +- .../ref/simulation/service_init_geometry.json | 44 +-- .../simulation/service_init_surface_mesh.json | 44 +-- .../simulation/service_init_volume_mesh.json | 44 +-- tests/report/test_report_items.py | 36 +- tests/simulation/conftest.py | 36 ++ tests/simulation/converter/ref/ref_c81.json | 28 +- tests/simulation/converter/ref/ref_dfdc.json | 28 +- .../converter/ref/ref_single_bet_disk.json | 56 +-- tests/simulation/converter/ref/ref_xfoil.json | 28 +- .../simulation/converter/ref/ref_xrotor.json | 28 +- .../test_bet_disk_flow360_converter.py | 7 +- .../framework/test_base_model_v2.py | 33 +- .../framework/test_multi_constructor_model.py | 7 +- .../params/test_bet_disk_from_file.py | 64 ++++ .../params/test_simulation_params.py | 4 +- .../params/test_validators_bet_disk.py | 22 +- .../params/test_validators_output.py | 42 --- .../params/test_validators_params.py | 26 +- .../simulation_with_project_variables.json | 49 +-- tests/simulation/service/test_services_v2.py | 45 ++- .../test_entity_processing_service.py | 15 +- tests/simulation/test_conftest.py | 8 + tests/simulation/test_expressions.py | 15 +- tests/simulation/test_krylov_solver.py | 11 +- tests/simulation/test_updater.py | 82 ----- tests/simulation/test_value_or_expression.py | 2 +- .../test_surface_meshing_translator.py | 8 +- tests/test_artifact_import.py | 6 + tests/test_current_flow360_version.py | 2 +- tests/utils.py | 1 - 61 files changed, 978 insertions(+), 1096 deletions(-) create mode 100644 .github/actions/setup-codeartifact-poetry-auth/action.yml create mode 100644 .github/scripts/coverage_summary.py create mode 100644 plan_sort_json.markdown create mode 100644 tests/simulation/test_conftest.py create mode 100644 tests/test_artifact_import.py diff --git a/.github/actions/setup-codeartifact-poetry-auth/action.yml b/.github/actions/setup-codeartifact-poetry-auth/action.yml new file mode 100644 index 000000000..4fb5ef2a0 --- /dev/null +++ b/.github/actions/setup-codeartifact-poetry-auth/action.yml @@ -0,0 +1,52 @@ +name: Setup CodeArtifact Poetry Auth +description: Configure AWS credentials, fetch CodeArtifact token, and set Poetry auth env vars. + +inputs: + aws-access-key-id: + description: AWS access key id for CodeArtifact access. + required: true + aws-secret-access-key: + description: AWS secret access key for CodeArtifact access. + required: true + aws-region: + description: AWS region of the CodeArtifact domain. + required: false + default: us-east-1 + domain: + description: CodeArtifact domain name. + required: false + default: flexcompute + domain-owner: + description: AWS account id that owns the CodeArtifact domain. + required: false + default: "625554095313" + +outputs: + token: + description: CodeArtifact authorization token. + value: ${{ steps.get-token.outputs.token }} + +runs: + using: composite + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ inputs.aws-access-key-id }} + aws-secret-access-key: ${{ inputs.aws-secret-access-key }} + aws-region: ${{ inputs.aws-region }} + + - name: Get CodeArtifact token and set Poetry env + id: get-token + shell: bash + run: | + TOKEN=$(aws codeartifact get-authorization-token \ + --domain "${{ inputs.domain }}" \ + --domain-owner "${{ inputs.domain-owner }}" \ + --region "${{ inputs.aws-region }}" \ + --query authorizationToken \ + --output text) + echo "::add-mask::$TOKEN" + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + echo "POETRY_HTTP_BASIC_CODEARTIFACT_USERNAME=aws" >> "$GITHUB_ENV" + echo "POETRY_HTTP_BASIC_CODEARTIFACT_PASSWORD=$TOKEN" >> "$GITHUB_ENV" diff --git a/.github/scripts/coverage_summary.py b/.github/scripts/coverage_summary.py new file mode 100644 index 000000000..c67e21991 --- /dev/null +++ b/.github/scripts/coverage_summary.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Generate a coverage summary from Cobertura XML, with optional diff coverage.""" + +import os +import re +import subprocess +import sys +import xml.etree.ElementTree as ET +from collections import defaultdict + + +def make_bar(pct, width=20): + pct = max(0.0, min(100.0, pct)) + filled = round(pct / 100 * width) + return "\u2593" * filled + "\u2591" * (width - filled) + + +def status_icon(pct): + if pct >= 80: + return "\U0001f7e2" + if pct >= 60: + return "\U0001f7e1" + return "\U0001f534" + + +def _get_repo_root(): + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +_repo_root = None + + +def _normalize_filename(filename, source_roots): + """Normalize coverage XML filename to repo-relative path. + + Coverage XML records paths relative to roots, while git diff + produces paths relative to the repo root. This joins the two and strips + the repo root prefix so both sides use the same reference frame. + """ + global _repo_root + if _repo_root is None: + _repo_root = _get_repo_root() + repo_prefix = _repo_root + "/" + + for root in source_roots: + if os.path.isabs(filename): + if filename.startswith(repo_prefix): + return filename[len(repo_prefix) :] + else: + full = os.path.join(root, filename) + if full.startswith(repo_prefix): + return full[len(repo_prefix) :] + return filename + + +def parse_coverage_xml(xml_path, depth): + """Parse coverage.xml, return (groups_dict, file_line_coverage_dict).""" + tree = ET.parse(xml_path) + root = tree.getroot() + + source_roots = [s.text.rstrip("/") for s in root.findall(".//source") if s.text] + + groups = defaultdict(lambda: {"hits": 0, "lines": 0}) + file_coverage = defaultdict(dict) + + for pkg in root.findall(".//package"): + name = pkg.get("name", "") + parts = name.split(".") + key = ".".join(parts[:depth]) if len(parts) >= depth else name + + for cls in pkg.findall(".//class"): + filename = cls.get("filename", "") + filename = _normalize_filename(filename, source_roots) + for line in cls.findall(".//line"): + line_num = int(line.get("number", "0")) + hit = int(line.get("hits", "0")) > 0 + file_coverage[filename][line_num] = ( + file_coverage[filename].get(line_num, False) or hit + ) + groups[key]["lines"] += 1 + if hit: + groups[key]["hits"] += 1 + + return groups, file_coverage + + +def get_changed_lines(diff_branch): + """Run git diff and return {filepath: set_of_changed_line_numbers} for non-test .py files.""" + result = subprocess.run( + ["git", "diff", "--unified=0", diff_branch, "--", "*.py"], + capture_output=True, + text=True, + check=True, + ) + + changed = defaultdict(set) + current_file = None + hunk_re = re.compile(r"^@@ .+?\+(\d+)(?:,(\d+))? @@") + + for line in result.stdout.splitlines(): + if line.startswith("+++ b/"): + current_file = line[6:] + elif line.startswith("@@") and current_file: + m = hunk_re.match(line) + if m: + start = int(m.group(1)) + count = int(m.group(2)) if m.group(2) else 1 + if count > 0: + for i in range(start, start + count): + changed[current_file].add(i) + + return { + f: lines + for f, lines in changed.items() + if f.endswith(".py") and not f.startswith("tests/") and f.startswith("flow360/") + } + + +def build_diff_coverage_md(changed_lines, file_coverage): + """Build diff coverage markdown section.""" + if not changed_lines: + return "## Diff Coverage\n\nNo implementation files changed.\n" + + total_covered = 0 + total_changed = 0 + + file_stats = [] + for filepath, line_nums in sorted(changed_lines.items()): + cov_map = file_coverage.get(filepath, {}) + executable = {ln for ln in line_nums if ln in cov_map} + covered = {ln for ln in executable if cov_map[ln]} + missing = sorted(executable - covered) + + n_exec = len(executable) + n_cov = len(covered) + total_covered += n_cov + total_changed += n_exec + pct = (n_cov / n_exec * 100) if n_exec else -1 + file_stats.append((filepath, pct, n_cov, n_exec, missing)) + + file_stats.sort(key=lambda x: x[1]) + total_pct = (total_covered / total_changed * 100) if total_changed else 100 + + lines = [] + lines.append(f"## {status_icon(total_pct)} Diff Coverage — {total_pct:.0f}%") + lines.append("") + lines.append( + f"`{make_bar(total_pct, 30)}` **{total_pct:.1f}%** ({total_covered} / {total_changed} changed lines covered)" + ) + lines.append("") + lines.append("| File | Coverage | Lines | Missing |") + lines.append("|:-----|:--------:|:-----:|:--------|") + + for filepath, pct, n_cov, n_exec, missing in file_stats: + icon = status_icon(pct) if pct >= 0 else "\u26aa" + pct_str = f"{pct:.0f}%" if pct >= 0 else "N/A" + missing_str = ", ".join(f"L{ln}" for ln in missing[:20]) + if len(missing) > 20: + missing_str += f" \u2026 +{len(missing) - 20} more" + lines.append(f"| `{filepath}` | {icon} {pct_str} | {n_cov} / {n_exec} | {missing_str} |") + + lines.append(f"| **Total** | **{total_pct:.1f}%** | **{total_covered} / {total_changed}** | |") + lines.append("") + return "\n".join(lines) + + +def build_full_coverage_md(groups): + """Build full coverage markdown section (wrapped in
, collapsed by default).""" + total_lines = sum(g["lines"] for g in groups.values()) + total_hits = sum(g["hits"] for g in groups.values()) + total_pct = (total_hits / total_lines * 100) if total_lines else 0 + + sorted_groups = sorted( + groups.items(), + key=lambda x: (x[1]["hits"] / x[1]["lines"] * 100) if x[1]["lines"] else 0, + ) + + lines = [] + lines.append("
") + lines.append( + f"

{status_icon(total_pct)} Full Coverage Report — {total_pct:.0f}% ({total_hits} / {total_lines} lines)

" + ) + lines.append("") + lines.append( + f"`{make_bar(total_pct, 30)}` **{total_pct:.1f}%** ({total_hits} / {total_lines} lines)" + ) + lines.append("") + lines.append("| Package | Coverage | Progress | Lines |") + lines.append("|:--------|:--------:|:---------|------:|") + + for key, g in sorted_groups: + pct = (g["hits"] / g["lines"] * 100) if g["lines"] else 0 + icon = status_icon(pct) + lines.append( + f"| `{key}` | {icon} {pct:.1f}% | `{make_bar(pct)}` | {g['hits']} / {g['lines']} |" + ) + + lines.append(f"| **Total** | **{total_pct:.1f}%** | | **{total_hits} / {total_lines}** |") + lines.append("") + lines.append("
") + lines.append("") + return "\n".join(lines) + + +def main(): + xml_path = sys.argv[1] if len(sys.argv) > 1 else "coverage.xml" + output_path = sys.argv[2] if len(sys.argv) > 2 else "coverage-summary.md" + depth = int(sys.argv[3]) if len(sys.argv) > 3 else 2 + diff_branch = sys.argv[4] if len(sys.argv) > 4 else None + + groups, file_coverage = parse_coverage_xml(xml_path, depth) + + parts = [] + + if diff_branch: + changed_lines = get_changed_lines(diff_branch) + parts.append(build_diff_coverage_md(changed_lines, file_coverage)) + + parts.append(build_full_coverage_md(groups)) + + with open(output_path, "w") as f: + f.write("\n".join(parts)) + + print(f"Coverage summary written to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/codestyle.yml b/.github/workflows/codestyle.yml index a8a840045..68b13fe0d 100644 --- a/.github/workflows/codestyle.yml +++ b/.github/workflows/codestyle.yml @@ -2,6 +2,11 @@ name: Codestyle checking on: workflow_call: + secrets: + AWS_CODEARTIFACT_READ_ACCESS_KEY: + required: true + AWS_CODEARTIFACT_READ_ACCESS_SECRET: + required: true workflow_dispatch: jobs: @@ -15,6 +20,11 @@ jobs: with: python-version: '3.10' cache: 'poetry' + - name: Setup CodeArtifact auth for Poetry + uses: ./.github/actions/setup-codeartifact-poetry-auth + with: + aws-access-key-id: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_SECRET }} - name: Install black run: poetry install - name: Run black @@ -30,6 +40,11 @@ jobs: with: python-version: '3.10' cache: 'poetry' + - name: Setup CodeArtifact auth for Poetry + uses: ./.github/actions/setup-codeartifact-poetry-auth + with: + aws-access-key-id: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_SECRET }} - name: Install isort run: poetry install - name: Check isort version @@ -57,6 +72,11 @@ jobs: with: python-version: '3.10' cache: 'poetry' + - name: Setup CodeArtifact auth for Poetry + uses: ./.github/actions/setup-codeartifact-poetry-auth + with: + aws-access-key-id: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_SECRET }} - name: Install dependencies run: poetry install - name: Run pylint diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 9b7166c1c..8821b7f30 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -328,6 +328,14 @@ jobs: with: python-version: '3.10' cache: 'poetry' + - name: Setup CodeArtifact auth for Poetry + uses: ./.github/actions/setup-codeartifact-poetry-auth + with: + aws-access-key-id: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_SECRET }} + aws-region: us-east-1 + domain: flexcompute + domain-owner: "625554095313" - name: Install dependencies run: poetry install - name: Pump version number diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 65a7a9dd8..99fa1a1bd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,10 +7,20 @@ on: pull_request: types: [ opened, synchronize, reopened, ready_for_review ] workflow_call: + secrets: + AWS_CODEARTIFACT_READ_ACCESS_KEY: + required: true + AWS_CODEARTIFACT_READ_ACCESS_SECRET: + required: true + +permissions: + contents: read + pull-requests: write jobs: code-style: uses: ./.github/workflows/codestyle.yml + secrets: inherit testing: needs: code-style name: test ${{ matrix.python-version }} - ${{ matrix.platform }} @@ -46,11 +56,43 @@ jobs: virtualenvs-in-project: true virtualenvs-create: true + - name: Setup CodeArtifact auth for Poetry + uses: ./.github/actions/setup-codeartifact-poetry-auth + with: + aws-access-key-id: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_CODEARTIFACT_READ_ACCESS_SECRET }} + - name: Install dependencies run: poetry install + # Non-coverage matrix combinations: run tests normally - name: Run simulation_params tests + if: ${{ !(matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest') }} run: poetry run pytest -rA tests/simulation -vv - name: Run flow360_params tests + if: ${{ !(matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest') }} run: poetry run pytest -rA --ignore tests/simulation -vv + + # Coverage matrix combination (Python 3.10 + ubuntu-latest): run tests with coverage + - name: Run simulation_params tests with coverage + if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' + run: poetry run pytest -rA tests/simulation -vv --cov=flow360 --cov-report=term-missing:skip-covered + + - name: Run flow360_params tests with coverage + if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' + run: poetry run pytest -rA --ignore tests/simulation -vv --cov=flow360 --cov-append --cov-report=term-missing:skip-covered --cov-report=xml:coverage.xml + + - name: Fetch base branch for diff coverage + if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' && github.event_name == 'pull_request' + run: git fetch origin ${{ github.base_ref }} --depth=1 + + - name: Generate coverage summary + if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' && github.event_name == 'pull_request' + run: python .github/scripts/coverage_summary.py coverage.xml coverage-summary.md 2 origin/${{ github.base_ref }} + + - name: Post coverage comment + if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' && github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + path: coverage-summary.md diff --git a/.gitignore b/.gitignore index 009a8fb8f..4ae4fdc01 100644 --- a/.gitignore +++ b/.gitignore @@ -339,4 +339,5 @@ flow360/examples/cylinder2D/flow360mesh.json #Claude: CLAUDE.md -.claude/ \ No newline at end of file +.claude/ +docs/plans/ \ No newline at end of file diff --git a/flow360/component/simulation/entity_info.py b/flow360/component/simulation/entity_info.py index d90345cc0..f521ee39d 100644 --- a/flow360/component/simulation/entity_info.py +++ b/flow360/component/simulation/entity_info.py @@ -5,6 +5,7 @@ from typing import Annotated, Any, Dict, List, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.validation.context import DeserializationContext from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_registry import ( @@ -726,13 +727,14 @@ def get_persistent_entity_registry(self, internal_registry, **_) -> EntityRegist ] -def parse_entity_info_model(data) -> EntityInfoUnion: +def parse_entity_info_model(data: dict) -> EntityInfoUnion: """ parse entity info data and return one of [GeometryEntityInfo, VolumeMeshEntityInfo, SurfaceMeshEntityInfo] # TODO: Add a fast mode by popping entities that are not needed due to wrong grouping tags before deserialization. """ - return pd.TypeAdapter(EntityInfoUnion).validate_python(data) + with DeserializationContext(): + return pd.TypeAdapter(EntityInfoUnion).validate_python(data) def merge_geometry_entity_info( diff --git a/flow360/component/simulation/framework/base_model.py b/flow360/component/simulation/framework/base_model.py index deb53de23..4986f36c3 100644 --- a/flow360/component/simulation/framework/base_model.py +++ b/flow360/component/simulation/framework/base_model.py @@ -4,32 +4,24 @@ import hashlib import json -from functools import lru_cache from itertools import chain -from typing import Any, List, Literal, get_args, get_origin +from typing import List import pydantic as pd import rich import unyt as u import yaml -from pydantic._internal._decorators import Decorator, FieldValidatorDecoratorInfo -from pydantic_core import InitErrorDetails +from flow360_schema.framework.base_model import Flow360BaseModel as _SchemaBaseModel +from flow360_schema.framework.validation.context import ( + DeserializationContext, + unit_system_manager, +) from flow360.component.simulation.conversion import need_conversion -from flow360.component.simulation.framework.base_model_config import base_model_config -from flow360.component.simulation.validation import validation_context from flow360.error_messages import do_not_modify_file_manually_msg from flow360.exceptions import Flow360FileError from flow360.log import log -DISCRIMINATOR_NAMES = [ - "type", - "type_name", - "refinement_type", - "output_type", - "private_attribute_entity_type_name", -] - def _preprocess_nested_list(value, required_by, params, exclude, flow360_unit_system): new_list = [] @@ -60,41 +52,13 @@ def _preprocess_nested_list(value, required_by, params, exclude, flow360_unit_sy return new_list -class Conflicts(pd.BaseModel): - """ - Wrapper for handling fields that cannot be specified simultaneously - """ - - field1: str - field2: str - - -class Flow360BaseModel(pd.BaseModel): +class Flow360BaseModel(_SchemaBaseModel): """Base pydantic (V2) model that all Flow360 components inherit from. - Defines configuration for handling data structures - as well as methods for importing, exporting, and hashing Flow360 objects. - For more details on pydantic base models, see: - `Pydantic Models ` + Extends the schema-layer Flow360BaseModel with SDK features: + file I/O, hash tracking, unit conversion (preprocess), and rich help output. """ - def __init__(self, filename: str = None, **kwargs): - model_dict = self._handle_file(filename=filename, **kwargs) - try: - super().__init__(**model_dict) - except pd.ValidationError as e: - validation_errors = e.errors() - for i, error in enumerate(validation_errors): - ctx = error.get("ctx") - if not isinstance(ctx, dict) or ctx.get("relevant_for") is None: - loc_tuple = tuple(error.get("loc", ())) - rf = self.__class__._infer_relevant_for_cached(tuple(loc_tuple)) - if rf is not None: - new_ctx = {} if not isinstance(ctx, dict) else dict(ctx) - new_ctx["relevant_for"] = list(rf) - validation_errors[i]["ctx"] = new_ctx - raise pd.ValidationError.from_exception_data( - title=self.__class__.__name__, line_errors=validation_errors - ) + # -- SDK-only methods: dict / file handling -- @classmethod def _handle_dict(cls, **kwargs): @@ -117,273 +81,14 @@ def _handle_file(cls, filename: str = None, **kwargs): return cls._dict_from_file(filename=filename) return kwargs - @classmethod - def __pydantic_init_subclass__(cls, **kwargs) -> None: - """Things that are done to each of the models.""" - need_to_rebuild = cls._handle_conditional_validators() - if need_to_rebuild is True: - cls.model_rebuild(force=True) - super().__pydantic_init_subclass__(**kwargs) # Correct use of super - - model_config = base_model_config - - def __setattr__(self, name, value): - # pylint: disable=unsupported-membership-test, unsubscriptable-object - if name in self.__class__.model_fields: - is_frozen = self.__class__.model_fields[name].frozen - if is_frozen is not None and is_frozen is True: - raise ValueError(f"Cannot modify immutable/frozen fields: {name}") - super().__setattr__(name, value) - - @pd.model_validator(mode="before") - @classmethod - def one_of(cls, values): - """ - root validator for require one of - """ - if cls.model_config["require_one_of"]: - set_values = [key for key, v in values.items() if v is not None] - aliases = [ - cls._get_field_alias(field_name=name) for name in cls.model_config["require_one_of"] - ] - aliases = [item for item in aliases if item is not None] - intersection = list(set(set_values) & set(cls.model_config["require_one_of"] + aliases)) - if len(intersection) == 0: - raise ValueError(f"One of {cls.model_config['require_one_of']} is required.") - return values - - # pylint: disable=no-self-argument - # pylint: disable=duplicate-code - @pd.model_validator(mode="before") - @classmethod - def handle_conflicting_fields(cls, values): - """ - root validator to handle deprecated aliases and fields - which cannot be simultaneously defined in the model - """ - if cls.model_config["conflicting_fields"]: - for conflicting_field in cls.model_config["conflicting_fields"]: - values = cls._handle_conflicting_fields(values, conflicting_field) - return values - - @classmethod - def _handle_conflicting_fields(cls, values, conflicting_field: Conflicts = None): - conflicting_field1_value = values.get(conflicting_field.field1, None) - conflicting_field2_value = values.get(conflicting_field.field2, None) - - if conflicting_field1_value is None: - field1_alias = cls._get_field_alias(field_name=conflicting_field.field1) - conflicting_field1_value = values.get(field1_alias, None) - - if conflicting_field2_value is None: - field2_alias = cls._get_field_alias(field_name=conflicting_field.field2) - conflicting_field2_value = values.get(field2_alias, None) - - if conflicting_field1_value is not None and conflicting_field2_value is not None: - raise ValueError( - f"{conflicting_field.field1} and {conflicting_field.field2} cannot be specified at the same time." - ) - - return values - - @classmethod - def _get_field_alias(cls, field_name: str = None): - if field_name is not None: - alias = [ - info.alias - for name, info in cls.model_fields.items() - if name == field_name and info.alias is not None - ] - if len(alias) > 0: - return alias[0] - return None - - @classmethod - def _get_field_context(cls, info, context_key): - if info.field_name is not None: - # pylint:disable = unsubscriptable-object - field_info = cls.model_fields[info.field_name] - if isinstance(field_info.json_schema_extra, dict): - return field_info.json_schema_extra.get(context_key) - - return None - - @classmethod - def _handle_conditional_validators(cls): - """ - Applies `before` validators to selected fields while excluding discriminator fields. - - **Purpose**: - - Dynamically determines if a field is optional depending on the current validation context. - - **How it works**: - - Iterates over model fields, excluding discriminator fields. - - Applies validators dynamically to the remaining fields to ensure compatibility. - - """ - - validators = [ - ("before", "validate_conditionally_required_field"), - ] - fields_to_validate = [] - need_to_rebuild = False - - for field_name, field in cls.model_fields.items(): - # Ignore discriminator validators - # pylint: disable=comparison-with-callable - if get_origin(field.annotation) == Literal and field_name in DISCRIMINATOR_NAMES: - need_to_rebuild = True - continue - - fields_to_validate.append(field_name) - - if need_to_rebuild is True: - for mode, method in validators: - info = FieldValidatorDecoratorInfo( - fields=tuple(fields_to_validate), - mode=mode, - check_fields=None, - json_schema_input_type=Any, - ) - deco = Decorator.build(cls, cls_var_name=method, info=info, shim=None) - cls.__pydantic_decorators__.field_validators[method] = deco - return need_to_rebuild - - @pd.field_validator("*", mode="before") - @classmethod - def validate_conditionally_required_field(cls, value, info): - """ - this validator checks for conditionally required fields depending on context - """ - validation_levels = validation_context.get_validation_levels() - if validation_levels is None: - return value - - conditionally_required = cls._get_field_context(info, "conditionally_required") - relevant_for = cls._get_field_context(info, "relevant_for") - - all_relevant_levels = () - if isinstance(relevant_for, list): - all_relevant_levels = tuple(relevant_for + [validation_context.ALL]) - else: - all_relevant_levels = (relevant_for, validation_context.ALL) - - if ( - conditionally_required is True - and any(lvl in all_relevant_levels for lvl in validation_levels) - and value is None - ): - raise pd.ValidationError.from_exception_data( - "validation error", [InitErrorDetails(type="missing")] - ) - - return value - - @classmethod - @lru_cache(maxsize=4096) - def _infer_relevant_for_cached(cls, loc: tuple) -> tuple | None: - """Infer relevant_for along the loc path starting at this model class. - - Returns a tuple of strings or None if not found. - """ - model: type = cls - last_relevant = None - for seg in loc: - if not (isinstance(model, type) and issubclass(model, Flow360BaseModel)): - break - fields = getattr(model, "model_fields", None) - if ( - not isinstance(seg, str) - or not fields - or seg not in fields # pylint: disable=unsupported-membership-test - ): - break - field_info = fields[seg] # pylint: disable=unsubscriptable-object - extra = getattr(field_info, "json_schema_extra", None) - if isinstance(extra, dict): - rf = extra.get("relevant_for") - if rf is not None: - last_relevant = rf - - next_model = cls._first_model_type_from(field_info) - if next_model is None: - break - model = next_model - - if last_relevant is None: - return None - if isinstance(last_relevant, list): - return tuple(last_relevant) - return (last_relevant,) - - @staticmethod - def _first_model_type_from(field_info) -> type | None: - """Extract first Flow360BaseModel subclass from a field's annotation.""" - annotation = getattr(field_info, "annotation", None) - return Flow360BaseModel._extract_model_type(annotation) - - @staticmethod - def _extract_model_type(tp) -> type | None: - # pylint: disable=too-many-branches, too-many-return-statements - if tp is None: - return None - if isinstance(tp, type): - try: - if issubclass(tp, Flow360BaseModel): - return tp - except TypeError: - return None - return None - origin = get_origin(tp) - if origin is None: - return None - # typing.Annotated - if str(origin) == "typing.Annotated": - args = get_args(tp) - if args: - return Flow360BaseModel._extract_model_type(args[0]) - return None - # Optional/Union - if origin is Literal: - return None - if str(origin) == "typing.Union": - for arg in get_args(tp): - mt = Flow360BaseModel._extract_model_type(arg) - if mt is not None: - return mt - return None - # Containers: List[T], Dict[K,V], Tuple[...] (take first value-like arg) - args = get_args(tp) - if not args: - return None - # For Dict[K,V], prefer V; else iterate args - dict_types = (dict,) - from typing import Dict as TypingDict # pylint: disable=import-outside-toplevel - - if origin in (*dict_types, TypingDict) and len(args) == 2: - start_index = 1 - else: - start_index = 0 - for arg in args[start_index:]: - mt = Flow360BaseModel._extract_model_type(arg) - if mt is not None: - return mt - return None - - # Note: to_solver architecture will be reworked in favor of splitting the models between - # the user-side and solver-side models (see models.py and models_avl.py for reference - # in the design360 repo) - # - # for now the to_solver functionality is removed, although some of the logic - # (recursive definition) will probably carry over. - def copy(self, update=None, **kwargs) -> Flow360BaseModel: """Copy a Flow360BaseModel. With ``deep=True`` as default.""" if "deep" in kwargs and kwargs["deep"] is False: raise ValueError("Can't do shallow copy of component, set `deep=True` in copy().") new_copy = pd.BaseModel.model_copy(self, update=update, deep=True, **kwargs) data = new_copy.model_dump(exclude={"private_attribute_id"}) - return self.model_validate(data) + with unit_system_manager.suspended(), DeserializationContext(): + return self.model_validate(data) def help(self, methods: bool = False) -> None: """Prints message describing the fields and methods of a :class:`Flow360BaseModel`. @@ -417,7 +122,9 @@ def from_file(cls, filename: str) -> Flow360BaseModel: ------- >>> params = Flow360BaseModel.from_file(filename='folder/sim.json') # doctest: +SKIP """ - return cls(filename=filename) + model_dict = cls._handle_file(filename=filename) + with DeserializationContext(): + return cls.model_validate(model_dict) @classmethod def _dict_from_file(cls, filename: str) -> dict: diff --git a/flow360/component/simulation/framework/base_model_config.py b/flow360/component/simulation/framework/base_model_config.py index f11e1563f..6f73b1682 100644 --- a/flow360/component/simulation/framework/base_model_config.py +++ b/flow360/component/simulation/framework/base_model_config.py @@ -34,21 +34,12 @@ def snake_to_camel(string: str) -> str: base_model_config = pd.ConfigDict( - ##:: Pydantic kwargs - arbitrary_types_allowed=True, # ? + arbitrary_types_allowed=True, extra="forbid", frozen=False, populate_by_name=True, validate_assignment=True, validate_default=True, - ##:: Custom keys - require_one_of=[], - allow_but_remove=[], - conflicting_fields=[], - include_hash=False, - include_defaults_in_schema=True, - # pylint: disable=fixme - # TODO: Remove alias_generator since it is only for translator alias_generator=pd.AliasGenerator( serialization_alias=snake_to_camel, ), diff --git a/flow360/component/simulation/framework/entity_materializer.py b/flow360/component/simulation/framework/entity_materializer.py index 5d89e9d83..cb0968623 100644 --- a/flow360/component/simulation/framework/entity_materializer.py +++ b/flow360/component/simulation/framework/entity_materializer.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional import pydantic as pd +from flow360_schema.framework.validation.context import DeserializationContext from flow360.component.simulation.draft_context.mirror import MirrorPlane from flow360.component.simulation.framework.entity_materialization_context import ( @@ -87,7 +88,8 @@ def _build_entity_instance(entity_dict: dict): cls = ENTITY_TYPE_MAP.get(type_name) if cls is None: raise ValueError(f"[Internal] Unknown entity type: {type_name}") - return pd.TypeAdapter(cls).validate_python(entity_dict) + with DeserializationContext(): + return pd.TypeAdapter(cls).validate_python(entity_dict) def _build_registry_index(registry: EntityRegistry) -> dict[tuple[str, str], Any]: diff --git a/flow360/component/simulation/framework/multi_constructor_model_base.py b/flow360/component/simulation/framework/multi_constructor_model_base.py index 23b4933b9..2d09b762c 100644 --- a/flow360/component/simulation/framework/multi_constructor_model_base.py +++ b/flow360/component/simulation/framework/multi_constructor_model_base.py @@ -11,6 +11,7 @@ from typing import Any, Callable, Literal, Optional, Union, get_args, get_origin import pydantic as pd +from flow360_schema.framework.validation.context import DeserializationContext from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.utils import model_attribute_unlock @@ -204,9 +205,10 @@ def is_optional_argument(annotation) -> bool: ): input_kwargs_filtered[arg_name] = None try: - model_dict = constructor(**input_kwargs_filtered).model_dump( - mode="json", exclude_none=True - ) + with DeserializationContext(): + model_dict = constructor(**input_kwargs_filtered).model_dump( + mode="json", exclude_none=True + ) # Make sure we do not generate a new ID. if "private_attribute_id" in model_as_dict: model_dict["private_attribute_id"] = model_as_dict["private_attribute_id"] diff --git a/flow360/component/simulation/framework/updater.py b/flow360/component/simulation/framework/updater.py index 926556b92..7b987444f 100644 --- a/flow360/component/simulation/framework/updater.py +++ b/flow360/component/simulation/framework/updater.py @@ -593,12 +593,12 @@ def _get_all_wind_tunnel_ghost_surfaces(): def fix_write_single_file_for_paraview_format(params_as_dict): """ - Fix write_single_file incompatibility with paraview-only format. + Fix write_single_file incompatibility with Paraview format. Before validation was added, users could set write_single_file=True with - output_format="paraview". This is not supported for paraview-only output. - Silently reset write_single_file to False when paraview-only format is used. - write_single_file is supported by tecplot, vtkhdf, and combination formats. + output_format="paraview". This is invalid because write_single_file only + works with Tecplot format. Silently reset write_single_file to False when + Paraview-only format is used. Also handles the edge case where output_format is missing from JSON (e.g., hand-edited files or very old JSONs), in which case we assume @@ -789,37 +789,6 @@ def _to_25_9_3(params_as_dict): return params_as_dict -def _to_25_10_0(params_as_dict): - """Migrate to 25.10.0: output_format string to list, add vtkhdf/ensight support.""" - - def _migrate_output_format_to_list(params_as_dict): - """Convert string ``output_format`` values to list form. - - ``"both"`` becomes ``["paraview", "tecplot"]``, comma-separated strings are - split, and bare strings are wrapped in a list. - """ - outputs = params_as_dict.get("outputs") - if not outputs: - return - - for output in outputs: - fmt = output.get("output_format") - if isinstance(fmt, list): - output["output_format"] = sorted(set(fmt)) - continue - if not isinstance(fmt, str): - continue - if fmt == "both": - output["output_format"] = ["paraview", "tecplot"] - elif "," in fmt: - output["output_format"] = sorted(set(v.strip() for v in fmt.split(","))) - else: - output["output_format"] = [fmt] - - _migrate_output_format_to_list(params_as_dict) - return params_as_dict - - VERSION_MILESTONES = [ (Flow360Version("24.11.1"), _to_24_11_1), (Flow360Version("24.11.7"), _to_24_11_7), @@ -843,7 +812,6 @@ def _migrate_output_format_to_list(params_as_dict): (Flow360Version("25.9.1"), _to_25_9_1), (Flow360Version("25.9.2"), _to_25_9_2), (Flow360Version("25.9.3"), _to_25_9_3), - (Flow360Version("25.10.0"), _to_25_10_0), ] # A list of the Python API version tuple with their corresponding updaters. diff --git a/flow360/component/simulation/migration/extra_operating_condition.py b/flow360/component/simulation/migration/extra_operating_condition.py index a5f0ed549..d854cc1cc 100644 --- a/flow360/component/simulation/migration/extra_operating_condition.py +++ b/flow360/component/simulation/migration/extra_operating_condition.py @@ -46,8 +46,8 @@ def operating_condition_from_mach_muref( Freestream reference dynamic viscosity defined with mesh unit (must be positive). project_length_unit: LengthType.Positive Project length unit. - temperature : TemperatureType.Positive, optional - Freestream static temperature (must be a positive temperature value). Default is 288.15 Kelvin. + temperature : AbsoluteTemperatureType, optional + Freestream static temperature (must be above absolute zero, 0 K). Default is 288.15 Kelvin. alpha : AngleType, optional Angle of attack. Default is 0 degrees. beta : AngleType, optional diff --git a/flow360/component/simulation/models/solver_numerics.py b/flow360/component/simulation/models/solver_numerics.py index c21d3c9f7..28c99ebf1 100644 --- a/flow360/component/simulation/models/solver_numerics.py +++ b/flow360/component/simulation/models/solver_numerics.py @@ -17,10 +17,7 @@ from pydantic import NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt from typing_extensions import Self -from flow360.component.simulation.framework.base_model import ( - Conflicts, - Flow360BaseModel, -) +from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_base import EntityList from flow360.component.simulation.primitives import Box, CustomVolume, GenericVolume @@ -85,9 +82,13 @@ class LinearSolver(Flow360BaseModel): + "residual of the pseudo step is below this value.", ) - model_config = pd.ConfigDict( - conflicting_fields=[Conflicts(field1="absolute_tolerance", field2="relative_tolerance")] - ) + @pd.model_validator(mode="after") + def _check_tolerance_conflict(self) -> Self: + if self.absolute_tolerance is not None and self.relative_tolerance is not None: + raise ValueError( + "absolute_tolerance and relative_tolerance cannot be specified at the same time." + ) + return self class KrylovLinearSolver(LinearSolver): @@ -577,9 +578,13 @@ class TransitionModelSolver(GenericSolverSettings): ... ) """ - model_config = pd.ConfigDict( - conflicting_fields=[Conflicts(field1="N_crit", field2="turbulence_intensity_percent")] - ) + @pd.model_validator(mode="after") + def _check_n_crit_conflict(self) -> Self: + if self.N_crit is not None and self.turbulence_intensity_percent is not None: + raise ValueError( + "N_crit and turbulence_intensity_percent cannot be specified at the same time." + ) + return self type_name: Literal["AmplificationFactorTransport"] = pd.Field( "AmplificationFactorTransport", frozen=True diff --git a/flow360/component/simulation/models/volume_models.py b/flow360/component/simulation/models/volume_models.py index f854d1bbb..b8b43f770 100644 --- a/flow360/component/simulation/models/volume_models.py +++ b/flow360/component/simulation/models/volume_models.py @@ -7,6 +7,7 @@ from typing import Annotated, Dict, List, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.validation.context import DeserializationContext import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -989,7 +990,8 @@ def from_file(cls, filename: str, **kwargs) -> "BETDisk": raise Flow360ValueError(f"Invalid keyword arguments for {cls.__name__}: {invalid_keys}") model_dict.update(kwargs) - return cls(**model_dict) + with DeserializationContext(): + return cls.model_validate(model_dict) # pylint: disable=too-many-arguments, no-self-argument, not-callable @MultiConstructorBaseModel.model_constructor diff --git a/flow360/component/simulation/operating_condition/operating_condition.py b/flow360/component/simulation/operating_condition/operating_condition.py index 2c1f17e7c..493c4caf3 100644 --- a/flow360/component/simulation/operating_condition/operating_condition.py +++ b/flow360/component/simulation/operating_condition/operating_condition.py @@ -3,6 +3,15 @@ from typing import Literal, Optional, Tuple, Union import pydantic as pd +from flow360_schema.models.primitives import ( + AbsoluteTemperature, + Angle, + Density, + Length, + Pressure, + Velocity, + Viscosity, +) from typing_extensions import Self import flow360.component.simulation.units as u @@ -15,16 +24,7 @@ from flow360.component.simulation.operating_condition.atmosphere_model import ( StandardAtmosphereModel, ) -from flow360.component.simulation.unit_system import ( - AbsoluteTemperatureType, - AngleType, - DeltaTemperatureType, - DensityType, - LengthType, - PressureType, - VelocityType, - ViscosityType, -) +from flow360.component.simulation.unit_system import DeltaTemperatureType from flow360.component.simulation.user_code.core.types import ( Expression, ValueOrExpression, @@ -40,7 +40,7 @@ # pylint: disable=no-member VelocityVectorType = Union[ - Tuple[StringExpression, StringExpression, StringExpression], VelocityType.Vector + Tuple[StringExpression, StringExpression, StringExpression], Velocity.Vector3 ] @@ -48,7 +48,7 @@ class ThermalStateCache(Flow360BaseModel): """[INTERNAL] Cache for thermal state inputs""" # pylint: disable=no-member - altitude: Optional[LengthType] = None + altitude: Optional[Length.Float64] = None temperature_offset: Optional[DeltaTemperatureType] = None @@ -71,10 +71,10 @@ class ThermalState(MultiConstructorBaseModel): # pylint: disable=fixme # TODO: remove frozen and throw warning if temperature/density is modified after construction from atmospheric model type_name: Literal["ThermalState"] = pd.Field("ThermalState", frozen=True) - temperature: AbsoluteTemperatureType = pd.Field( + temperature: AbsoluteTemperature.Float64 = pd.Field( 288.15 * u.K, frozen=True, description="The temperature of the fluid." ) - density: DensityType.Positive = pd.Field( + density: Density.PositiveFloat64 = pd.Field( 1.225 * u.kg / u.m**3, frozen=True, description="The density of the fluid." ) material: Air = pd.Field(Air(), frozen=True, description="The material of the fluid.") @@ -88,7 +88,7 @@ class ThermalState(MultiConstructorBaseModel): @pd.validate_call def from_standard_atmosphere( cls, - altitude: LengthType = 0 * u.m, + altitude: Length.Float64 = 0 * u.m, temperature_offset: DeltaTemperatureType = 0 * u.K, ): """ @@ -96,7 +96,7 @@ def from_standard_atmosphere( Parameters ---------- - altitude : LengthType, optional + altitude : Length.Float64, optional The altitude at which the thermal state is calculated. Defaults to ``0 * u.m``. temperature_offset : DeltaTemperatureType, optional The temperature offset to be applied to the standard temperature at the given altitude. @@ -147,7 +147,7 @@ def from_standard_atmosphere( return state @property - def altitude(self) -> Optional[LengthType]: + def altitude(self) -> Optional[Length.Float64]: """Return user specified altitude.""" if not self.private_attribute_input_cache.altitude: log.warning("Altitude not provided from input") @@ -161,17 +161,17 @@ def temperature_offset(self) -> Optional[DeltaTemperatureType]: return self.private_attribute_input_cache.temperature_offset @property - def speed_of_sound(self) -> VelocityType.Positive: + def speed_of_sound(self) -> Velocity.PositiveFloat64: """Computes speed of sound.""" return self.material.get_speed_of_sound(self.temperature) @property - def pressure(self) -> PressureType.Positive: + def pressure(self) -> Pressure.PositiveFloat64: """Computes pressure.""" return self.material.get_pressure(self.density, self.temperature) @property - def dynamic_viscosity(self) -> ViscosityType.Positive: + def dynamic_viscosity(self) -> Viscosity.PositiveFloat64: """Computes dynamic viscosity.""" return self.material.get_dynamic_viscosity(self.temperature) @@ -207,7 +207,7 @@ class GenericReferenceCondition(MultiConstructorBaseModel): type_name: Literal["GenericReferenceCondition"] = pd.Field( "GenericReferenceCondition", frozen=True ) - velocity_magnitude: Optional[ValueOrExpression[VelocityType.Positive]] = ConditionalField( + velocity_magnitude: Optional[ValueOrExpression[Velocity.PositiveFloat64]] = ConditionalField( context=CASE, description="Freestream velocity magnitude. Used as reference velocity magnitude" + " when :py:attr:`reference_velocity_magnitude` is not specified. Cannot change once specified.", @@ -248,10 +248,10 @@ class AerospaceConditionCache(Flow360BaseModel): mach: Optional[pd.NonNegativeFloat] = None reynolds_mesh_unit: Optional[pd.PositiveFloat] = None - project_length_unit: Optional[LengthType.Positive] = None - alpha: Optional[AngleType] = None - beta: Optional[AngleType] = None - temperature: Optional[AbsoluteTemperatureType] = None + project_length_unit: Optional[Length.PositiveFloat64] = None + alpha: Optional[Angle.Float64] = None + beta: Optional[Angle.Float64] = None + temperature: Optional[AbsoluteTemperature.Float64] = None thermal_state: Optional[ThermalState] = pd.Field(None, alias="atmosphere") reference_mach: Optional[pd.PositiveFloat] = None @@ -281,9 +281,13 @@ class AerospaceCondition(MultiConstructorBaseModel): """ type_name: Literal["AerospaceCondition"] = pd.Field("AerospaceCondition", frozen=True) - alpha: AngleType = ConditionalField(0 * u.deg, description="The angle of attack.", context=CASE) - beta: AngleType = ConditionalField(0 * u.deg, description="The side slip angle.", context=CASE) - velocity_magnitude: Optional[ValueOrExpression[VelocityType.NonNegative]] = ConditionalField( + alpha: Angle.Float64 = ConditionalField( + 0 * u.deg, description="The angle of attack.", context=CASE + ) + beta: Angle.Float64 = ConditionalField( + 0 * u.deg, description="The side slip angle.", context=CASE + ) + velocity_magnitude: Optional[ValueOrExpression[Velocity.NonNegativeFloat64]] = ConditionalField( description="Freestream velocity magnitude. Used as reference velocity magnitude" + " when :py:attr:`reference_velocity_magnitude` is not specified.", context=CASE, @@ -294,7 +298,7 @@ class AerospaceCondition(MultiConstructorBaseModel): alias="atmosphere", description="Reference and freestream thermal state. Defaults to US standard atmosphere at sea level.", ) - reference_velocity_magnitude: Optional[VelocityType.Positive] = CaseField( + reference_velocity_magnitude: Optional[Velocity.PositiveFloat64] = CaseField( None, description="Reference velocity magnitude. Is required when :py:attr:`velocity_magnitude` is 0.", frozen=True, @@ -307,8 +311,8 @@ class AerospaceCondition(MultiConstructorBaseModel): def from_mach( cls, mach: pd.NonNegativeFloat, - alpha: AngleType = 0 * u.deg, - beta: AngleType = 0 * u.deg, + alpha: Angle.Float64 = 0 * u.deg, + beta: Angle.Float64 = 0 * u.deg, thermal_state: ThermalState = ThermalState(), reference_mach: Optional[pd.PositiveFloat] = None, ): @@ -320,9 +324,9 @@ def from_mach( mach : float Freestream Mach number (non-negative). Used as reference Mach number when ``reference_mach`` is not specified. - alpha : AngleType, optional + alpha : Angle.Float64, optional The angle of attack. Defaults to ``0 * u.deg``. - beta : AngleType, optional + beta : Angle.Float64, optional The side slip angle. Defaults to ``0 * u.deg``. thermal_state : ThermalState, optional Reference and freestream thermal state. Defaults to US standard atmosphere at sea level. @@ -383,10 +387,10 @@ def from_mach_reynolds( cls, mach: pd.PositiveFloat, reynolds_mesh_unit: pd.PositiveFloat, - project_length_unit: Optional[LengthType.Positive], - alpha: AngleType = 0 * u.deg, - beta: AngleType = 0 * u.deg, - temperature: AbsoluteTemperatureType = 288.15 * u.K, + project_length_unit: Optional[Length.PositiveFloat64], + alpha: Angle.Float64 = 0 * u.deg, + beta: Angle.Float64 = 0 * u.deg, + temperature: AbsoluteTemperature.Float64 = 288.15 * u.K, reference_mach: Optional[pd.PositiveFloat] = None, ): """ @@ -404,14 +408,14 @@ def from_mach_reynolds( Freestream Reynolds number scaled to mesh unit (must be positive). For example if the mesh unit is 1 mm, the reynolds_mesh_unit should be equal to a Reynolds number that has the characteristic length of 1 mm. - project_length_unit: LengthType.Positive + project_length_unit: Length.PositiveFloat64 Project length unit used to compute the density (must be positive). - alpha : AngleType, optional + alpha : Angle.Float64, optional Angle of attack. Default is 0 degrees. - beta : AngleType, optional + beta : Angle.Float64, optional Sideslip angle. Default is 0 degrees. - temperature : AbsoluteTemperatureType, optional - Freestream static temperature (must be a positive temperature value). Default is 288.15 Kelvin. + temperature : AbsoluteTemperature.Float64, optional + Freestream static temperature (must be above absolute zero, 0 K). Default is 288.15 Kelvin. reference_mach : PositiveFloat, optional Reference Mach number. Default is None. @@ -479,7 +483,7 @@ def from_mach_reynolds( ) @property - def _evaluated_velocity_magnitude(self) -> VelocityType.Positive: + def _evaluated_velocity_magnitude(self) -> Velocity.PositiveFloat64: if isinstance(self.velocity_magnitude, Expression): return self.velocity_magnitude.evaluate( raise_on_non_evaluable=True, force_evaluate=True @@ -515,7 +519,7 @@ def _update_input_cache(cls, value, info: pd.ValidationInfo): return value @pd.validate_call - def flow360_reynolds_number(self, length_unit: LengthType.Positive): + def flow360_reynolds_number(self, length_unit: Length.PositiveFloat64): """ Computes length_unit based Reynolds number. :math:`Re = \\rho_{\\infty} \\cdot U_{\\infty} \\cdot L_{grid}/\\mu_{\\infty}` where @@ -527,7 +531,7 @@ def flow360_reynolds_number(self, length_unit: LengthType.Positive): Parameters ---------- - length_unit : LengthType.Positive + length_unit : Length.PositiveFloat64 Physical length represented by unit length in the given mesh/geometry file. """ @@ -560,15 +564,19 @@ class LiquidOperatingCondition(Flow360BaseModel): type_name: Literal["LiquidOperatingCondition"] = pd.Field( "LiquidOperatingCondition", frozen=True ) - alpha: AngleType = ConditionalField(0 * u.deg, description="The angle of attack.", context=CASE) - beta: AngleType = ConditionalField(0 * u.deg, description="The side slip angle.", context=CASE) - velocity_magnitude: Optional[ValueOrExpression[VelocityType.NonNegative]] = ConditionalField( + alpha: Angle.Float64 = ConditionalField( + 0 * u.deg, description="The angle of attack.", context=CASE + ) + beta: Angle.Float64 = ConditionalField( + 0 * u.deg, description="The side slip angle.", context=CASE + ) + velocity_magnitude: Optional[ValueOrExpression[Velocity.NonNegativeFloat64]] = ConditionalField( context=CASE, description="Incoming flow velocity magnitude. Used as reference velocity magnitude" + " when :py:attr:`reference_velocity_magnitude` is not specified. Cannot change once specified.", frozen=True, ) - reference_velocity_magnitude: Optional[VelocityType.Positive] = CaseField( + reference_velocity_magnitude: Optional[Velocity.PositiveFloat64] = CaseField( None, description="Reference velocity magnitude. Is required when :py:attr:`velocity_magnitude` is 0." " Used as the velocity scale for nondimensionalization.", @@ -580,7 +588,7 @@ class LiquidOperatingCondition(Flow360BaseModel): ) @property - def _evaluated_velocity_magnitude(self) -> VelocityType.Positive: + def _evaluated_velocity_magnitude(self) -> Velocity.PositiveFloat64: if isinstance(self.velocity_magnitude, Expression): return self.velocity_magnitude.evaluate( raise_on_non_evaluable=True, force_evaluate=True diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 9a5699266..767c44238 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -81,7 +81,6 @@ validate_improper_surface_field_usage_for_imported_surface, ) from flow360.component.types import Axis -from flow360.log import log # Invalid characters for Linux filenames: / is path separator, \0 is null terminator _INVALID_FILENAME_CHARS_PATTERN = re.compile(r"[/\0]") @@ -367,53 +366,15 @@ def disable_frequency_settings_in_steady_simulation( return value -_OutputFormatOption = Literal["paraview", "tecplot", "vtkhdf", "ensight"] - -_LegacyOutputFormatStrings = Literal[ - "paraview", - "tecplot", - "both", -] - - class _AnimationAndFileFormatSettings(_AnimationSettings): """ Controls how frequently the output files are generated and the file format. """ - output_format: Union[List[_OutputFormatOption], _LegacyOutputFormatStrings] = pd.Field( - default=["paraview"], - min_length=1, - description="List of output formats, " - "Supported formats: :code:`paraview`, :code:`tecplot`, :code:`vtkhdf`, :code:`ensight`. " - "A single string is accepted for backward compatibility but deprecated.", + output_format: Literal["paraview", "tecplot", "both"] = pd.Field( + default="paraview", description=":code:`paraview`, :code:`tecplot` or :code:`both`." ) - @pd.field_validator("output_format", mode="before") - @classmethod - def _normalize_output_format(cls, value): - if isinstance(value, str): - if value == "both": - log.warning( - '`output_format="both"` is deprecated. ' - 'Use `output_format=["paraview", "tecplot"]` instead.' - ) - return ["paraview", "tecplot"] - if "," in value: - log.warning( - f"`output_format` comma-separated strings are deprecated. " - f'Use `output_format={sorted(set(v.strip() for v in value.split(",")))}` instead.' - ) - return sorted(set(v.strip() for v in value.split(","))) - log.warning( - f"Passing a string to `output_format` is deprecated. " - f'Use `output_format=["{value}"]` instead.' - ) - return [value] - if isinstance(value, list): - return sorted(set(value)) - return value - class SurfaceOutput(_AnimationAndFileFormatSettings, _OutputBase): """ @@ -462,10 +423,10 @@ class SurfaceOutput(_AnimationAndFileFormatSettings, _OutputBase): ) write_single_file: bool = pd.Field( default=False, - description="Enable writing all surface outputs into a single file instead of one file per surface. " - "Supported by Tecplot, Paraview, and VTK-HDF output formats. " - "Will choose the value of the last instance of this option of the same output type " - "(:class:`SurfaceOutput` or :class:`TimeAverageSurfaceOutput`) in the output list.", + description="Enable writing all surface outputs into a single file instead of one file per surface." + + "This option currently only supports Tecplot output format." + + "Will choose the value of the last instance of this option of the same output type " + + "(:class:`SurfaceOutput` or :class:`TimeAverageSurfaceOutput`) in the output list.", ) output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( description="List of output variables. Including :ref:`universal output variables`," @@ -1205,9 +1166,7 @@ class SurfaceSliceOutput(_AnimationAndFileFormatSettings, _OutputBase): description="List of :class:`Surface` entities on which the slice will cut through." ) - output_format: Union[List[Literal["paraview"]], Literal["paraview"]] = pd.Field( - default=["paraview"], min_length=1 - ) + output_format: Literal["paraview"] = pd.Field(default="paraview") output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( description="List of output variables. Including :ref:`universal output variables`," diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 44ebe501c..b1f96d078 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -9,6 +9,7 @@ import numpy as np import pydantic as pd +from flow360_schema.models.primitives import Length from pydantic import PositiveFloat from typing_extensions import Self @@ -559,12 +560,12 @@ class Cylinder(_VolumeEntityBase): private_attribute_entity_type_name: Literal["Cylinder"] = pd.Field("Cylinder", frozen=True) axis: Axis = pd.Field(description="The axis of the cylinder.") # pylint: disable=no-member - center: LengthType.Point = pd.Field(description="The center point of the cylinder.") - height: LengthType.Positive = pd.Field(description="The height of the cylinder.") - inner_radius: Optional[LengthType.NonNegative] = pd.Field( + center: Length.Vector3 = pd.Field(description="The center point of the cylinder.") + height: Length.PositiveFloat64 = pd.Field(description="The height of the cylinder.") + inner_radius: Optional[Length.NonNegativeFloat64] = pd.Field( 0 * u.m, description="The inner radius of the cylinder." ) - outer_radius: LengthType.Positive = pd.Field(description="The outer radius of the cylinder.") + outer_radius: Length.PositiveFloat64 = pd.Field(description="The outer radius of the cylinder.") private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) @pd.model_validator(mode="after") diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 371bca213..1e8dabc0c 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -18,6 +18,7 @@ ) import pydantic as pd +from flow360_schema.framework.validation.context import DeserializationContext from pydantic_core import ErrorDetails # Required for correct global scope initialization @@ -482,7 +483,10 @@ def handle_multi_constructor_model(params_as_dict: dict) -> dict: {"private_attribute_asset_cache": {"project_length_unit": project_length_unit_dict}}, [], ) - with ValidationContext(levels=validation_levels_to_use, info=parse_model_info): + with ( + ValidationContext(levels=validation_levels_to_use, info=parse_model_info), + DeserializationContext(), + ): # Multi-constructor model support updated_param_as_dict = parse_model_dict(params_as_dict, globals()) return updated_param_as_dict @@ -585,20 +589,16 @@ def dict_preprocessing(params_as_dict: dict) -> dict: info=validation_info, ) as context: validation_context = context - unit_system = updated_param_as_dict.get("unit_system") - with UnitSystem.from_dict( # pylint: disable=not-context-manager - verbose=False, **unit_system - ): - # Reuse pre-deserialized entity_info to avoid double deserialization - pre_deserialized_entity_info = validation_info.get_entity_info() - if pre_deserialized_entity_info is not None: - # Create shallow copy with entity_info substituted - updated_param_as_dict = {**updated_param_as_dict} - updated_param_as_dict["private_attribute_asset_cache"] = { - **updated_param_as_dict["private_attribute_asset_cache"], - "project_entity_info": pre_deserialized_entity_info, - } - + # Reuse pre-deserialized entity_info to avoid double deserialization + pre_deserialized_entity_info = validation_info.get_entity_info() + if pre_deserialized_entity_info is not None: + # Create shallow copy with entity_info substituted + updated_param_as_dict = {**updated_param_as_dict} + updated_param_as_dict["private_attribute_asset_cache"] = { + **updated_param_as_dict["private_attribute_asset_cache"], + "project_entity_info": pre_deserialized_entity_info, + } + with DeserializationContext(): validated_param = SimulationParams.model_validate(updated_param_as_dict) except pd.ValidationError as err: diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index bb81a950f..e2fba5cb2 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -9,6 +9,7 @@ import pydantic as pd import unyt as u +from flow360_schema.framework.validation.context import DeserializationContext from flow360.component.simulation.conversion import ( LIQUID_IMAGINARY_FREESTREAM_MACH, @@ -77,6 +78,7 @@ DimensionedTypes, LengthType, MassType, + SI_unit_system, TimeType, UnitSystem, UnitSystemType, @@ -134,10 +136,7 @@ _populate_validated_field_to_validation_context, ) from flow360.component.simulation.validation.validation_utils import has_mirroring_usage -from flow360.error_messages import ( - unit_system_inconsistent_msg, - use_unit_system_for_simulation_msg, -) +from flow360.error_messages import unit_system_inconsistent_msg from flow360.exceptions import Flow360ConfigurationError, Flow360RuntimeError from flow360.log import log from flow360.version import __version__ @@ -169,16 +168,18 @@ class _ParamModelBase(Flow360BaseModel): @classmethod def _init_check_unit_system(cls, **kwargs): """ - Check existence of unit system and raise an error if it is not set or inconsistent. + Resolve the unit system from kwargs / active context / SI default. + Raises if an explicit kwarg unit_system conflicts with the active context. + Returns (resolved_unit_system, remaining_kwargs). """ - if unit_system_manager.current is None: - raise Flow360RuntimeError(use_unit_system_for_simulation_msg) - # pylint: disable=duplicate-code kwarg_unit_system = kwargs.pop("unit_system", None) if kwarg_unit_system is not None: if not isinstance(kwarg_unit_system, UnitSystem): kwarg_unit_system = UnitSystem.from_dict(**kwarg_unit_system) - if kwarg_unit_system != unit_system_manager.current: + if ( + unit_system_manager.current is not None + and kwarg_unit_system != unit_system_manager.current + ): raise Flow360RuntimeError( unit_system_inconsistent_msg( kwarg_unit_system.system_repr(), @@ -186,7 +187,8 @@ def _init_check_unit_system(cls, **kwargs): ) ) - return kwargs + resolved = kwarg_unit_system or unit_system_manager.current or SI_unit_system + return resolved, kwargs @classmethod def _get_version_from_dict(cls, model_dict: dict) -> str: @@ -228,16 +230,19 @@ def _sanitize_params_dict(model_dict): """ return sanitize_params_dict(model_dict) + @classmethod + def from_file(cls, filename: str): + """Override to run sanitizer and version updater before validation.""" + model_dict = cls._handle_file(filename=filename) + model_dict = cls._sanitize_params_dict(model_dict) + model_dict, _ = cls._update_param_dict(model_dict) + with DeserializationContext(): + return cls.model_validate(model_dict) + def _init_no_unit_context(self, filename, file_content, **kwargs): """ - Initialize the simulation parameters without a unit context. + Initialize the simulation parameters from file or dict content. """ - if unit_system_manager.current is not None: - raise Flow360RuntimeError( - f"When loading params from file: {self.__class__.__name__}(filename), " - "unit context must not be used." - ) - if filename is not None: model_dict = self._handle_file(filename=filename, **kwargs) else: @@ -247,11 +252,7 @@ def _init_no_unit_context(self, filename, file_content, **kwargs): # When treating files/file like contents the updater will always be run. model_dict, _ = _ParamModelBase._update_param_dict(model_dict) - unit_system = model_dict.get("unit_system") - - with UnitSystem.from_dict( - **unit_system, verbose=False - ): # pylint: disable=not-context-manager + with DeserializationContext(): super().__init__(**model_dict) def _init_with_unit_context(self, **kwargs): @@ -260,9 +261,9 @@ def _init_with_unit_context(self, **kwargs): This is the entry when user construct Param with Python script. """ # When treating dicts the updater is skipped. - kwargs = _ParamModelBase._init_check_unit_system(**kwargs) + unit_system, kwargs = _ParamModelBase._init_check_unit_system(**kwargs) - super().__init__(unit_system=unit_system_manager.current, **kwargs) + super().__init__(unit_system=unit_system, **kwargs) # pylint: disable=super-init-not-called # pylint: disable=fixme diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index aa9ab39a5..d0f323f3f 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -154,7 +154,9 @@ def dump_dict(input_params, exclude_none=True): """Dumping param/model to dictionary.""" - result = input_params.model_dump(by_alias=True, exclude_none=exclude_none) + result = input_params.model_dump( + by_alias=True, exclude_none=exclude_none, context={"no_unit": True} + ) if result.pop("privateAttributeDict", None) is not None: result.update(input_params.private_attribute_dict) return result @@ -213,7 +215,9 @@ def init_output_base(obj_list, class_type: Type, is_average: bool): "output_format", ) assert output_format is not None - base["outputFormat"] = ",".join(sorted(output_format)) + if output_format == "both": + output_format = "paraview,tecplot" + base["outputFormat"] = output_format if is_average: base = init_average_output(base, obj_list, class_type) diff --git a/flow360/component/simulation/unit_system.py b/flow360/component/simulation/unit_system.py index 9ebf1983d..88f79572f 100644 --- a/flow360/component/simulation/unit_system.py +++ b/flow360/component/simulation/unit_system.py @@ -50,53 +50,9 @@ u.unit_systems.mks_unit_system["delta_temperature"] = u.Unit("K").expr u.unit_systems.cgs_unit_system["delta_temperature"] = u.Unit("K").expr - -class UnitSystemManager: - """ - :class: Class to manage global unit system context and switch currently used unit systems - """ - - __slots__ = ("_current", "_suspended") - - def __init__(self): - """ - Initialize the UnitSystemManager. - """ - self._current = None - self._suspended = None - - @property - def current(self) -> UnitSystem: - """ - Get the current UnitSystem. - :return: UnitSystem - """ - - return self._current - - def set_current(self, unit_system: UnitSystem): - """ - Set the current UnitSystem. - :param unit_system: - :return: - """ - self._current = unit_system - - def suspend(self): - """ - Suspend the current UnitSystem. - """ - self._suspended = self._current - self._current = None - - def resume(self): - """ - Resume the current UnitSystem. - """ - self._current = self._suspended - - -unit_system_manager = UnitSystemManager() +# Register with flow360-schema so new schema types respect unit system context +# pylint: disable=wrong-import-position,wrong-import-order +from flow360_schema.framework.validation.context import unit_system_manager def _encode_ndarray(x): @@ -1624,6 +1580,7 @@ class UnitSystem(pd.BaseModel): name: Literal["Custom"] = pd.Field("Custom") _verbose: bool = pd.PrivateAttr(True) + _context_token: Any = pd.PrivateAttr(None) @staticmethod def __get_unit(system, dim_name, unit): @@ -1738,15 +1695,42 @@ def system_repr(self): return str_repr + def _assert_no_active_unit_system(self): + active_unit_system = unit_system_manager.current + if active_unit_system is None: + return + active_name = ( + active_unit_system.system_repr() + if hasattr(active_unit_system, "system_repr") + else str(active_unit_system) + ) + raise RuntimeError( + "Nested unit system context is not allowed. " + f"Active unit system: {active_name}. " + f"Attempted: {self.system_repr()}. " + "Please remove the inner unit system context." + ) + def __enter__(self): _lock.acquire() - if self._verbose: - log.info(f"using: {self.system_repr()} unit system for unit inference.") - unit_system_manager.set_current(self) + try: + self._assert_no_active_unit_system() + if self._verbose: + log.info(f"using: {self.system_repr()} unit system for unit inference.") + self._context_token = unit_system_manager.set_current(self) + return self + except Exception: + _lock.release() + raise def __exit__(self, exc_type, exc_val, exc_tb): - _lock.release() - unit_system_manager.set_current(None) + try: + if self._context_token is None: + raise RuntimeError("Unit system context exit called without a matching enter.") + unit_system_manager.reset_current(self._context_token) + self._context_token = None + finally: + _lock.release() _SI_system = u.unit_systems.mks_unit_system diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index c03ad8d56..a4a238fc9 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -22,6 +22,7 @@ import numpy as np import pydantic as pd import unyt as u +from flow360_schema import StrictUnitContext from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag from pydantic_core import InitErrorDetails, core_schema from typing_extensions import Self @@ -1253,12 +1254,11 @@ def _internal_validator(value: Expression): "Run-time expression is not allowed in this field. " "Please ensure this field does not depend on any control or solver variables." ) - # Temporary suspend unit system to expose dimension problem - unit_system_manager.suspend() - pd.TypeAdapter(typevar_values).validate_python( - result, context={"allow_inf_nan": allow_run_time_expression} - ) - unit_system_manager.resume() + # Suspend unit system for legacy types; strict mode rejects bare numbers for new composed types + with unit_system_manager.suspended(), StrictUnitContext(): + pd.TypeAdapter(typevar_values).validate_python( + result, context={"allow_inf_nan": allow_run_time_expression} + ) return value expr_type = Annotated[Expression, pd.AfterValidator(_internal_validator)] diff --git a/flow360/component/simulation/validation/validation_context.py b/flow360/component/simulation/validation/validation_context.py index b57fee675..1a0a7a715 100644 --- a/flow360/component/simulation/validation/validation_context.py +++ b/flow360/component/simulation/validation/validation_context.py @@ -23,6 +23,9 @@ from typing import Any, Callable, List, Literal, Union import pydantic as pd +from flow360_schema.framework.validation.context import ( # noqa: F401 — re-used, not redefined + _validation_level_ctx, +) from pydantic import Field, TypeAdapter from flow360.component.simulation.unit_system import LengthType @@ -104,7 +107,6 @@ def __init__(self, param_as_dict: dict): self.bet_disk_count += 1 -_validation_level_ctx = contextvars.ContextVar("validation_levels", default=None) _validation_info_ctx = contextvars.ContextVar("validation_info", default=None) _validation_warnings_ctx = contextvars.ContextVar("validation_warnings", default=None) diff --git a/flow360/component/v1/updater.py b/flow360/component/v1/updater.py index 0918e8c72..6d1198a18 100644 --- a/flow360/component/v1/updater.py +++ b/flow360/component/v1/updater.py @@ -32,7 +32,6 @@ def _no_update(params_as_dict): ("25.6.*", "25.7.*", _no_update), ("25.7.*", "25.8.*", _no_update), ("25.8.*", "25.9.*", _no_update), - ("25.9.*", "25.10.*", _no_update), ] diff --git a/flow360/error_messages.py b/flow360/error_messages.py index 528141418..18374361a 100644 --- a/flow360/error_messages.py +++ b/flow360/error_messages.py @@ -62,14 +62,6 @@ def submit_warning(class_name): ) """ -use_unit_system_for_simulation_msg = """\ -SimulationParams must be created with a unit system context. For example: ->>> with SI_unit_system: ->>> params = SimulationParams( - ... - ) -""" - def unit_system_inconsistent_msg(kwarg_unit_system, context_unit_system): return f"""\ diff --git a/flow360/plugins/report/utils.py b/flow360/plugins/report/utils.py index e7b1b0f5e..012585ad8 100644 --- a/flow360/plugins/report/utils.py +++ b/flow360/plugins/report/utils.py @@ -25,10 +25,7 @@ from flow360.component.case import Case, CaseMetaV2 from flow360.component.results import base_results, case_results -from flow360.component.simulation.framework.base_model import ( - Conflicts, - Flow360BaseModel, -) +from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.volume_mesh import VolumeMeshDownloadable, VolumeMeshV2 from flow360.log import log @@ -561,17 +558,26 @@ class Average(GenericOperation): ) type_name: Literal["Average"] = pd.Field("Average", frozen=True) - model_config = pd.ConfigDict( - conflicting_fields=[ - Conflicts(field1="start_step", field2="start_time"), - Conflicts(field1="start_step", field2="fraction"), - Conflicts(field1="start_time", field2="fraction"), - Conflicts(field1="end_step", field2="end_time"), - Conflicts(field1="end_step", field2="fraction"), - Conflicts(field1="end_time", field2="fraction"), - ], - require_one_of=["start_step", "start_time", "fraction"], - ) + @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 def calculate( self, data, case, cases, variables, new_variable_name diff --git a/flow360/version.py b/flow360/version.py index 5c8af4243..6681f7ffe 100644 --- a/flow360/version.py +++ b/flow360/version.py @@ -2,5 +2,5 @@ version """ -__version__ = "25.10.0b1" +__version__ = "25.9.3b1" __solver_version__ = "release-25.8" diff --git a/plan_sort_json.markdown b/plan_sort_json.markdown new file mode 100644 index 000000000..d27a71ef4 --- /dev/null +++ b/plan_sort_json.markdown @@ -0,0 +1,22 @@ +# Plan: Reference JSON Key Sorting Script + +## 目标 +写一个 Python 脚本,递归排序 reference JSON 文件的 keys,方便 code review 时看 diff。 + +## 脚本功能 +- 路径: `scripts/sort_ref_json.py` +- 零外部依赖(只用标准库 `json`, `pathlib`, `sys`) +- 两种模式: + - **修复模式** (默认): `python scripts/sort_ref_json.py` — 原地重写所有未排序的 JSON 文件 + - **检查模式**: `python scripts/sort_ref_json.py --check` — 只检查,不修改,未排序则 exit 1(用于 CI) +- 扫描范围: `tests/` 目录下所有 `*.json` 文件 +- 递归排序所有嵌套 dict 的 keys(list 内的 dict 也排序 keys,但 list 元素顺序不变) +- 保持 4 空格缩进 + trailing newline(与现有格式一致) + +## 步骤 +1. 创建 `scripts/sort_ref_json.py` +2. (可选) 在 CI 配置里加 `python scripts/sort_ref_json.py --check` + +## 不做的事 +- 不引入 pre-commit framework +- 不修改非 tests/ 目录的 JSON 文件 diff --git a/poetry.lock b/poetry.lock index e1ab76c17..f6f269deb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1450,6 +1450,29 @@ files = [ {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, ] +[[package]] +name = "flow360-schema" +version = "0.1.12+feat.2ndstageunitmigrationfoll.5053b95" +description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" +optional = false +python-versions = ">=3.10,<4.0" +groups = ["main"] +files = [ + {file = "flow360_schema-0.1.12+feat.2ndstageunitmigrationfoll.5053b95-py3-none-any.whl", hash = "sha256:7fb3784689220885a2c418f4e5c3a99591d940da6d2a4c2f59814385ab3fe8e1"}, + {file = "flow360_schema-0.1.12+feat.2ndstageunitmigrationfoll.5053b95.tar.gz", hash = "sha256:33c0458f552e48114f9c41add8959c42c0b8d2f02537047d13a6534edd260af2"}, +] + +[package.dependencies] +pydantic = ">=2.8,<3.0" + +[package.extras] +unyt = ["unyt (>=2.9.0)"] + +[package.source] +type = "legacy" +url = "https://flexcompute-625554095313.d.codeartifact.us-east-1.amazonaws.com/pypi/pypi-releases/simple" +reference = "codeartifact" + [[package]] name = "fonttools" version = "4.62.1" @@ -6487,4 +6510,4 @@ docs = ["autodoc_pydantic", "cairosvg", "ipython", "jinja2", "jupyter", "myst-pa [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "789bc664b22914a4c4e4190068e73952827e861504c5224d84bc31c92fd985be" +content-hash = "0b9e8f9b56c45b31e77157cb628fcce597cf484b650ad8821a41a66642f232d6" diff --git a/pyproject.toml b/pyproject.toml index c9d6a9e43..d55e89483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,22 @@ [tool.poetry] name = "flow360" -version = "v25.10.0b1" +version = "v25.9.3b1" description = "Flow360 Python Client" authors = ["Flexcompute "] + +[[tool.poetry.source]] +name = "codeartifact" +url = "https://flexcompute-625554095313.d.codeartifact.us-east-1.amazonaws.com/pypi/pypi-releases/simple/" +priority = "explicit" + [tool.poetry.dependencies] python = ">=3.10,<3.14" pydantic = ">=2.8,<2.12" +# -- Local dev (editable install, schema changes take effect immediately): +# flow360-schema = { path = "../flow360-schema", develop = true } +# -- CI / release (install from CodeArtifact, swap comments before pushing): +flow360-schema = { version = "~0.1.1", source = "codeartifact" } pytest = "^7.1.2" click = "^8.1.3" toml = "^0.10.2" diff --git a/tests/ref/simulation/service_init_geometry.json b/tests/ref/simulation/service_init_geometry.json index 6e5b079d0..6c16ebaf3 100644 --- a/tests/ref/simulation/service_init_geometry.json +++ b/tests/ref/simulation/service_init_geometry.json @@ -198,29 +198,14 @@ } ], "operating_condition": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, + "alpha": 0.0, + "beta": 0.0, "private_attribute_constructor": "default", "private_attribute_input_cache": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, + "alpha": 0.0, + "beta": 0.0, "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, + "density": 1.225, "material": { "dynamic_viscosity": { "effective_temperature": { @@ -280,18 +265,12 @@ }, "private_attribute_constructor": "default", "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, + "temperature": 288.15, "type_name": "ThermalState" } }, "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, + "density": 1.225, "material": { "dynamic_viscosity": { "effective_temperature": { @@ -351,10 +330,7 @@ }, "private_attribute_constructor": "default", "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, + "temperature": 288.15, "type_name": "ThermalState" }, "type_name": "AerospaceCondition" @@ -381,9 +357,7 @@ "CfVec" ] }, - "output_format": [ - "paraview" - ], + "output_format": "paraview", "output_type": "SurfaceOutput", "write_single_file": false } diff --git a/tests/ref/simulation/service_init_surface_mesh.json b/tests/ref/simulation/service_init_surface_mesh.json index c48f8a747..bc77c40f3 100644 --- a/tests/ref/simulation/service_init_surface_mesh.json +++ b/tests/ref/simulation/service_init_surface_mesh.json @@ -198,29 +198,14 @@ } ], "operating_condition": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, + "alpha": 0.0, + "beta": 0.0, "private_attribute_constructor": "default", "private_attribute_input_cache": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, + "alpha": 0.0, + "beta": 0.0, "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, + "density": 1.225, "material": { "dynamic_viscosity": { "effective_temperature": { @@ -280,18 +265,12 @@ }, "private_attribute_constructor": "default", "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, + "temperature": 288.15, "type_name": "ThermalState" } }, "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, + "density": 1.225, "material": { "dynamic_viscosity": { "effective_temperature": { @@ -351,10 +330,7 @@ }, "private_attribute_constructor": "default", "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, + "temperature": 288.15, "type_name": "ThermalState" }, "type_name": "AerospaceCondition" @@ -381,9 +357,7 @@ "CfVec" ] }, - "output_format": [ - "paraview" - ], + "output_format": "paraview", "output_type": "SurfaceOutput", "write_single_file": false } diff --git a/tests/ref/simulation/service_init_volume_mesh.json b/tests/ref/simulation/service_init_volume_mesh.json index 358e2e191..c1dd0476e 100644 --- a/tests/ref/simulation/service_init_volume_mesh.json +++ b/tests/ref/simulation/service_init_volume_mesh.json @@ -153,29 +153,14 @@ } ], "operating_condition": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, + "alpha": 0.0, + "beta": 0.0, "private_attribute_constructor": "default", "private_attribute_input_cache": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, + "alpha": 0.0, + "beta": 0.0, "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, + "density": 1.225, "material": { "dynamic_viscosity": { "effective_temperature": { @@ -235,18 +220,12 @@ }, "private_attribute_constructor": "default", "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, + "temperature": 288.15, "type_name": "ThermalState" } }, "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, + "density": 1.225, "material": { "dynamic_viscosity": { "effective_temperature": { @@ -306,10 +285,7 @@ }, "private_attribute_constructor": "default", "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, + "temperature": 288.15, "type_name": "ThermalState" }, "type_name": "AerospaceCondition" @@ -336,9 +312,7 @@ "CfVec" ] }, - "output_format": [ - "paraview" - ], + "output_format": "paraview", "output_type": "SurfaceOutput", "write_single_file": false } diff --git a/tests/report/test_report_items.py b/tests/report/test_report_items.py index 4223410d0..68c933c16 100644 --- a/tests/report/test_report_items.py +++ b/tests/report/test_report_items.py @@ -1759,15 +1759,17 @@ def test_grouper_multi_level( "total_forces/averages/CD", ) + # beta values are stored in SI (radians): 0, 2, 4, 6 degrees + b0, b2, b4, b6 = np.radians([0, 2, 4, 6]) expected_x_data = [ - [0, 0, 2, 2, 4, 4, 6, 6], - [0, 2, 4, 6], - [0, 0, 2, 2, 4, 4, 6, 6], - [0, 2, 4, 6], - [0, 0, 2, 2, 4, 4, 6, 6], - [0, 2, 4, 6], - [0, 0, 2, 2, 4, 4, 6, 6], - [0, 2, 4, 6], + [b0, b0, b2, b2, b4, b4, b6, b6], + [b0, b2, b4, b6], + [b0, b0, b2, b2, b4, b4, b6, b6], + [b0, b2, b4, b6], + [b0, b0, b2, b2, b4, b4, b6, b6], + [b0, b2, b4, b6], + [b0, b0, b2, b2, b4, b4, b6, b6], + [b0, b2, b4, b6], ] for actual, expected in zip(x_data, expected_x_data): @@ -1807,15 +1809,17 @@ def test_chart2d_group_by_grouper(self, cases_beta_sweep, expected_y_data): plot_model = chart.get_data(cases_beta_sweep, context) + # beta values are stored in SI (radians): 0, 2, 4, 6 degrees + b0, b2, b4, b6 = np.radians([0, 2, 4, 6]) expected_x_data = [ - [0, 0, 2, 2, 4, 4, 6, 6], - [0, 2, 4, 6], - [0, 0, 2, 2, 4, 4, 6, 6], - [0, 2, 4, 6], - [0, 0, 2, 2, 4, 4, 6, 6], - [0, 2, 4, 6], - [0, 0, 2, 2, 4, 4, 6, 6], - [0, 2, 4, 6], + [b0, b0, b2, b2, b4, b4, b6, b6], + [b0, b2, b4, b6], + [b0, b0, b2, b2, b4, b4, b6, b6], + [b0, b2, b4, b6], + [b0, b0, b2, b2, b4, b4, b6, b6], + [b0, b2, b4, b6], + [b0, b0, b2, b2, b4, b4, b6, b6], + [b0, b2, b4, b6], ] for actual, expected in zip(plot_model.x_data, expected_x_data): diff --git a/tests/simulation/conftest.py b/tests/simulation/conftest.py index 670988d42..e2ba916f8 100644 --- a/tests/simulation/conftest.py +++ b/tests/simulation/conftest.py @@ -1,4 +1,7 @@ +import os +import tempfile from abc import ABCMeta +from numbers import Number import numpy as np import pytest @@ -9,6 +12,39 @@ from flow360.component.simulation.framework.entity_registry import EntityRegistry +def _approx_equal(a, b, rel_tol=1e-12): + """Recursively compare nested structures with float tolerance.""" + if isinstance(a, dict) and isinstance(b, dict): + if a.keys() != b.keys(): + return False + return all(_approx_equal(a[k], b[k], rel_tol) for k in a) + if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + if len(a) != len(b): + return False + return all(_approx_equal(ai, bi, rel_tol) for ai, bi in zip(a, b)) + if isinstance(a, bool) or isinstance(b, bool): + return isinstance(a, bool) and isinstance(b, bool) and a == b + if isinstance(a, Number) and isinstance(b, Number): + if a == b: + return True + return abs(a - b) <= rel_tol * max(abs(a), abs(b)) + return a == b + + +def to_file_from_file_test_approx(obj): + """v2 serialization round-trip test with float tolerance.""" + test_extentions = ["yaml", "json"] + factory = obj.__class__ + with tempfile.TemporaryDirectory() as tmpdir: + for ext in test_extentions: + obj_filename = os.path.join(tmpdir, f"obj.{ext}") + obj.to_file(obj_filename) + obj_read = factory.from_file(obj_filename) + assert _approx_equal(obj.model_dump(), obj_read.model_dump()) + obj_read = factory(filename=obj_filename) + assert _approx_equal(obj.model_dump(), obj_read.model_dump()) + + class AssetBase(metaclass=ABCMeta): internal_registry: EntityRegistry diff --git a/tests/simulation/converter/ref/ref_c81.json b/tests/simulation/converter/ref/ref_c81.json index 749476624..7465f2d0b 100644 --- a/tests/simulation/converter/ref/ref_c81.json +++ b/tests/simulation/converter/ref/ref_c81.json @@ -753,27 +753,15 @@ 0.0, 1.0 ], - "center": { - "units": "ft", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "height": { - "units": "ft", - "value": 15.0 - }, - "inner_radius": { - "units": "m", - "value": 0.0 - }, + "center": [ + 0.0, + 0.0, + 0.0 + ], + "height": 4.572, + "inner_radius": 0.0, "name": "BET_cylinder", - "outer_radius": { - "units": "ft", - "value": 150.0 - }, + "outer_radius": 45.72, "private_attribute_entity_type_name": "Cylinder", "private_attribute_full_name": null, "private_attribute_zone_boundary_names": { diff --git a/tests/simulation/converter/ref/ref_dfdc.json b/tests/simulation/converter/ref/ref_dfdc.json index 7ab783207..ade6b97fa 100644 --- a/tests/simulation/converter/ref/ref_dfdc.json +++ b/tests/simulation/converter/ref/ref_dfdc.json @@ -1050,27 +1050,15 @@ 0.0, 1.0 ], - "center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "height": { - "units": "m", - "value": 15.0 - }, - "inner_radius": { - "units": "m", - "value": 0.0 - }, + "center": [ + 0.0, + 0.0, + 0.0 + ], + "height": 15.0, + "inner_radius": 0.0, "name": "BET_cylinder", - "outer_radius": { - "units": "m", - "value": 3.81 - }, + "outer_radius": 3.81, "private_attribute_entity_type_name": "Cylinder", "private_attribute_full_name": null, "private_attribute_zone_boundary_names": { diff --git a/tests/simulation/converter/ref/ref_single_bet_disk.json b/tests/simulation/converter/ref/ref_single_bet_disk.json index 595681a97..5f2d49832 100644 --- a/tests/simulation/converter/ref/ref_single_bet_disk.json +++ b/tests/simulation/converter/ref/ref_single_bet_disk.json @@ -424,27 +424,15 @@ 0.0, 1.0 ], - "center": { - "units": "cm", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "height": { - "units": "cm", - "value": 15.0 - }, - "inner_radius": { - "units": "cm", - "value": 0.0 - }, + "center": [ + 0.0, + 0.0, + 0.0 + ], + "height": 0.15, + "inner_radius": 0.0, "name": "bet_cylinder_MyBETDisk", - "outer_radius": { - "units": "cm", - "value": 150.0 - }, + "outer_radius": 1.5, "private_attribute_entity_type_name": "Cylinder", "private_attribute_full_name": null, "private_attribute_zone_boundary_names": { @@ -481,27 +469,15 @@ 0.0, 1.0 ], - "center": { - "units": "cm", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "height": { - "units": "cm", - "value": 15.0 - }, - "inner_radius": { - "units": "cm", - "value": 0.0 - }, + "center": [ + 0.0, + 0.0, + 0.0 + ], + "height": 0.15, + "inner_radius": 0.0, "name": "bet_cylinder_MyBETDisk", - "outer_radius": { - "units": "cm", - "value": 150.0 - }, + "outer_radius": 1.5, "private_attribute_entity_type_name": "Cylinder", "private_attribute_full_name": null, "private_attribute_zone_boundary_names": { diff --git a/tests/simulation/converter/ref/ref_xfoil.json b/tests/simulation/converter/ref/ref_xfoil.json index 70176204f..d0c875b0c 100644 --- a/tests/simulation/converter/ref/ref_xfoil.json +++ b/tests/simulation/converter/ref/ref_xfoil.json @@ -754,27 +754,15 @@ 0.0, 1.0 ], - "center": { - "units": "ft", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "height": { - "units": "ft", - "value": 15.0 - }, - "inner_radius": { - "units": "m", - "value": 0.0 - }, + "center": [ + 0.0, + 0.0, + 0.0 + ], + "height": 4.572, + "inner_radius": 0.0, "name": "BET_cylinder", - "outer_radius": { - "units": "ft", - "value": 150.0 - }, + "outer_radius": 45.72, "private_attribute_entity_type_name": "Cylinder", "private_attribute_full_name": null, "private_attribute_zone_boundary_names": { diff --git a/tests/simulation/converter/ref/ref_xrotor.json b/tests/simulation/converter/ref/ref_xrotor.json index 3e2b8b128..949fe5091 100644 --- a/tests/simulation/converter/ref/ref_xrotor.json +++ b/tests/simulation/converter/ref/ref_xrotor.json @@ -1050,27 +1050,15 @@ 0.0, 1.0 ], - "center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "height": { - "units": "m", - "value": 15.0 - }, - "inner_radius": { - "units": "m", - "value": 0.0 - }, + "center": [ + 0.0, + 0.0, + 0.0 + ], + "height": 15.0, + "inner_radius": 0.0, "name": "BET_cylinder", - "outer_radius": { - "units": "m", - "value": 3.81 - }, + "outer_radius": 3.81, "private_attribute_entity_type_name": "Cylinder", "private_attribute_full_name": null, "private_attribute_zone_boundary_names": { diff --git a/tests/simulation/converter/test_bet_disk_flow360_converter.py b/tests/simulation/converter/test_bet_disk_flow360_converter.py index 3a15c5dab..c9a9e7904 100644 --- a/tests/simulation/converter/test_bet_disk_flow360_converter.py +++ b/tests/simulation/converter/test_bet_disk_flow360_converter.py @@ -4,6 +4,7 @@ from tempfile import NamedTemporaryFile import pytest +from flow360_schema.framework.validation.context import DeserializationContext import flow360.component.simulation.units as u from flow360.component.simulation.framework.updater_utils import compare_values @@ -146,7 +147,8 @@ def test_single_polar_flow360_bet_convert(): # Test that the disk can be serialized and validated successfully disk_dict = disk.model_dump() # Verify the model can be reconstructed - reconstructed_disk = BETDisk.model_validate(disk_dict) + with DeserializationContext(): + reconstructed_disk = BETDisk.model_validate(disk_dict) assert isinstance(reconstructed_disk, BETDisk) @@ -185,5 +187,6 @@ def test_xrotor_single_polar(): # Test serialization and validation disk_dict = bet_disk.model_dump() - reconstructed_disk = fl.BETDisk.model_validate(disk_dict) + with DeserializationContext(): + reconstructed_disk = fl.BETDisk.model_validate(disk_dict) assert isinstance(reconstructed_disk, fl.BETDisk) diff --git a/tests/simulation/framework/test_base_model_v2.py b/tests/simulation/framework/test_base_model_v2.py index da8686785..76f24e2c9 100644 --- a/tests/simulation/framework/test_base_model_v2.py +++ b/tests/simulation/framework/test_base_model_v2.py @@ -1,17 +1,13 @@ import json import os import tempfile -from typing import Optional import pydantic as pd import pytest import yaml import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import ( - Conflicts, - Flow360BaseModel, -) +from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.log import set_logging_level set_logging_level("DEBUG") @@ -35,19 +31,6 @@ def preprocess(self, **kwargs): return super().preprocess(**kwargs) -class BaseModelWithConflictFields(Flow360BaseModel): - some_value1: Optional[pd.StrictFloat] = pd.Field(None, alias="value1") - some_value2: Optional[pd.StrictFloat] = pd.Field(None, alias="value2") - - model_config = pd.ConfigDict( - conflicting_fields=[Conflicts(field1="some_value1", field2="some_value2")] - ) - - def preprocess(self, **kwargs): - self.some_value1 *= 2 - return super().preprocess(self, **kwargs) - - def test_help(): Flow360BaseModel().help() Flow360BaseModel().help(methods=True) @@ -61,20 +44,6 @@ def test_copy(): assert base_model_copy.some_value == 123 -def test_conflict(): - base_model = BaseModelWithConflictFields(some_value1=12.3) - with pytest.raises( - pd.ValidationError, - match="some_value1 and some_value2 cannot be specified at the same time.", - ): - base_model.some_value2 = 12.3 - with pytest.raises( - pd.ValidationError, - match="some_value1 and some_value2 cannot be specified at the same time.", - ): - base_model.value2 = 12.3 - - def test_from_file(): file_content = {"some_value": 321} diff --git a/tests/simulation/framework/test_multi_constructor_model.py b/tests/simulation/framework/test_multi_constructor_model.py index 1788c0475..04e85502c 100644 --- a/tests/simulation/framework/test_multi_constructor_model.py +++ b/tests/simulation/framework/test_multi_constructor_model.py @@ -3,6 +3,7 @@ import pydantic as pd import pytest +from flow360_schema.framework.validation.context import DeserializationContext import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -17,7 +18,6 @@ ) from flow360.component.simulation.primitives import Box, Cylinder from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.utils import model_attribute_unlock from tests.simulation.converter.test_bet_translator import generate_BET_param @@ -62,8 +62,9 @@ def get_aerospace_condition_using_from_mach_reynolds(): def compare_objects_from_dict(dict1: dict, dict2: dict, object_class: type[Flow360BaseModel]): - obj1 = object_class.model_validate(dict1) - obj2 = object_class.model_validate(dict2) + with DeserializationContext(): + obj1 = object_class.model_validate(dict1) + obj2 = object_class.model_validate(dict2) assert obj1.model_dump_json() == obj2.model_dump_json() diff --git a/tests/simulation/params/test_bet_disk_from_file.py b/tests/simulation/params/test_bet_disk_from_file.py index a73c1ccf6..b853cf990 100644 --- a/tests/simulation/params/test_bet_disk_from_file.py +++ b/tests/simulation/params/test_bet_disk_from_file.py @@ -1,7 +1,9 @@ import json import os +from unittest.mock import patch import pytest +from flow360_schema.framework.validation.context import DeserializationContext import flow360 as fl @@ -82,3 +84,65 @@ def test_bet_disk_updater_and_override(tmp_path): assert disk.name == "BET 57" assert disk.number_of_blades == 2 assert len(disk.entities.stored_entities) == 2 + + +def test_bet_disk_from_file_uses_deserialization_context(tmp_path): + """BETDisk.from_file must wrap model_validate in DeserializationContext.""" + filename = tmp_path / "bet.json" + data = { + "version": "25.7.5", + "name": "BET", + "type": "BETDisk", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Cylinder", + "private_attribute_registry_bucket_name": "DraftEntities", + "name": "cyl", + "axis": [0, 0, 1], + "center": {"value": [0, 0, 0], "units": "m"}, + "height": {"value": 1, "units": "m"}, + "outer_radius": {"value": 1, "units": "m"}, + } + ] + }, + "rotation_direction_rule": "leftHand", + "number_of_blades": 2, + "omega": {"value": 800, "units": "rpm"}, + "chord_ref": {"value": 0.14, "units": "m"}, + "n_loading_nodes": 20, + "blade_line_chord": {"value": 0.25, "units": "m"}, + "initial_blade_direction": [1, 0, 0], + "tip_gap": "inf", + "mach_numbers": [0], + "reynolds_numbers": [1000000], + "alphas": {"value": [-180, 180], "units": "degree"}, + "sectional_radiuses": {"value": [0.1, 1], "units": "m"}, + "sectional_polars": [ + {"lift_coeffs": [[[0.0, 0.0]]], "drag_coeffs": [[[0.0, 0.0]]]}, + {"lift_coeffs": [[[0.0, 0.0]]], "drag_coeffs": [[[0.0, 0.0]]]}, + ], + "twists": [ + {"radius": {"value": 0.1, "units": "m"}, "twist": {"value": 0, "units": "degree"}}, + {"radius": {"value": 1, "units": "m"}, "twist": {"value": 0, "units": "degree"}}, + ], + "chords": [ + {"radius": {"value": 0.1, "units": "m"}, "chord": {"value": 0.1, "units": "m"}}, + {"radius": {"value": 1, "units": "m"}, "chord": {"value": 0.1, "units": "m"}}, + ], + } + with open(filename, "w") as f: + json.dump(data, f) + + entered = False + original_enter = DeserializationContext.__enter__ + + def tracking_enter(self): + nonlocal entered + entered = True + return original_enter(self) + + with patch.object(DeserializationContext, "__enter__", tracking_enter): + fl.BETDisk.from_file(str(filename)) + + assert entered, "BETDisk.from_file must use DeserializationContext" diff --git a/tests/simulation/params/test_simulation_params.py b/tests/simulation/params/test_simulation_params.py index 934b58737..26a4336b2 100644 --- a/tests/simulation/params/test_simulation_params.py +++ b/tests/simulation/params/test_simulation_params.py @@ -73,7 +73,7 @@ UserDefinedDynamic, ) from flow360.component.simulation.utils import model_attribute_unlock -from tests.utils import to_file_from_file_test +from tests.simulation.conftest import to_file_from_file_test_approx assertions = unittest.TestCase("__init__") @@ -258,7 +258,7 @@ def get_param_with_list_of_lengths(): @pytest.mark.usefixtures("array_equality_override") def test_simulation_params_serialization(get_the_param): - to_file_from_file_test(get_the_param) + to_file_from_file_test_approx(get_the_param) @pytest.mark.usefixtures("array_equality_override") diff --git a/tests/simulation/params/test_validators_bet_disk.py b/tests/simulation/params/test_validators_bet_disk.py index e23d15695..9df0f4869 100644 --- a/tests/simulation/params/test_validators_bet_disk.py +++ b/tests/simulation/params/test_validators_bet_disk.py @@ -1,6 +1,7 @@ import unittest import pytest +from flow360_schema.framework.validation.context import DeserializationContext import flow360.component.simulation.units as u from flow360.component.simulation import services @@ -38,7 +39,8 @@ def test_bet_disk_blade_line_chord(create_steady_bet_disk): def test_bet_disk_initial_blade_direction(create_steady_bet_disk): bet_disk = create_steady_bet_disk - BETDisk.model_validate(bet_disk) + with DeserializationContext(): + BETDisk.model_validate(bet_disk) with pytest.raises( ValueError, @@ -67,7 +69,8 @@ def test_bet_disk_disorder_alphas(create_steady_bet_disk): tmp = bet_disk.alphas[0] bet_disk.alphas[0] = bet_disk.alphas[1] bet_disk.alphas[1] = tmp - BETDisk.model_validate(bet_disk.model_dump()) + with DeserializationContext(): + BETDisk.model_validate(bet_disk.model_dump()) def test_bet_disk_duplicate_chords(create_steady_bet_disk): @@ -78,7 +81,8 @@ def test_bet_disk_duplicate_chords(create_steady_bet_disk): ): bet_disk.name = "diskABC" bet_disk.chords.append(bet_disk.chords[-1]) - BETDisk.model_validate(bet_disk.model_dump()) + with DeserializationContext(): + BETDisk.model_validate(bet_disk.model_dump()) def test_bet_disk_duplicate_twists(create_steady_bet_disk): @@ -89,7 +93,8 @@ def test_bet_disk_duplicate_twists(create_steady_bet_disk): ): bet_disk.name = "diskABC" bet_disk.twists.append(bet_disk.twists[-1]) - BETDisk.model_validate(bet_disk.model_dump()) + with DeserializationContext(): + BETDisk.model_validate(bet_disk.model_dump()) def test_bet_disk_nonequal_sectional_radiuses_and_polars(create_steady_bet_disk): @@ -103,7 +108,8 @@ def test_bet_disk_nonequal_sectional_radiuses_and_polars(create_steady_bet_disk) bet_disk_dict["sectional_radiuses"]["value"] = bet_disk_dict["sectional_radiuses"][ "value" ] + (bet_disk.sectional_radiuses[-1],) - bet_disk_error = BETDisk(**bet_disk_dict) + with DeserializationContext(): + bet_disk_error = BETDisk.model_validate(bet_disk_dict) BETDisk.model_validate(bet_disk_error) @@ -115,7 +121,8 @@ def test_bet_disk_3d_coefficients_dimension_wrong_mach_numbers(create_steady_bet ): bet_disk.name = "diskABC" bet_disk.mach_numbers.append(bet_disk.mach_numbers[-1]) - BETDisk.model_validate(bet_disk) + with DeserializationContext(): + BETDisk.model_validate(bet_disk) def test_bet_disk_3d_coefficients_dimension_wrong_re_numbers(create_steady_bet_disk): @@ -138,5 +145,6 @@ def test_bet_disk_3d_coefficients_dimension_wrong_alpha_numbers(create_steady_be bet_disk.name = "diskABC" bet_disk_dict = bet_disk.model_dump() bet_disk_dict["alphas"]["value"] = bet_disk_dict["alphas"]["value"] + (bet_disk.alphas[-1],) - bet_disk_error = BETDisk(**bet_disk_dict) + with DeserializationContext(): + bet_disk_error = BETDisk.model_validate(bet_disk_dict) BETDisk.model_validate(bet_disk_error) diff --git a/tests/simulation/params/test_validators_output.py b/tests/simulation/params/test_validators_output.py index c40734e5d..eeac85a52 100644 --- a/tests/simulation/params/test_validators_output.py +++ b/tests/simulation/params/test_validators_output.py @@ -1690,45 +1690,3 @@ def test_surface_output_write_single_file_validator(): output_fields=["Cp"], output_format="both", ) - - -def test_output_format_list(): - # List format should be accepted and sorted - out = VolumeOutput(output_fields=["Mach"], output_format=["paraview"]) - assert out.output_format == ["paraview"] - - out = VolumeOutput(output_fields=["Mach"], output_format=["vtkhdf", "paraview"]) - assert out.output_format == ["paraview", "vtkhdf"] - - out = VolumeOutput(output_fields=["Mach"], output_format=["tecplot", "vtkhdf", "ensight"]) - assert out.output_format == ["ensight", "tecplot", "vtkhdf"] - - -def test_output_format_legacy_string_converted_to_list(): - # Legacy strings should be normalized to sorted lists - out = VolumeOutput(output_fields=["Mach"], output_format="paraview") - assert out.output_format == ["paraview"] - - out = VolumeOutput(output_fields=["Mach"], output_format="both") - assert out.output_format == ["paraview", "tecplot"] - - out = VolumeOutput(output_fields=["Mach"], output_format="tecplot") - assert out.output_format == ["tecplot"] - - -def test_output_format_deduplication(): - # Duplicate entries should be removed and result sorted - out = VolumeOutput(output_fields=["Mach"], output_format=["paraview", "paraview"]) - assert out.output_format == ["paraview"] - - out = VolumeOutput(output_fields=["Mach"], output_format=["vtkhdf", "paraview", "vtkhdf"]) - assert out.output_format == ["paraview", "vtkhdf"] - - -def test_output_format_both_not_allowed_in_list(): - # "both" is only valid as a legacy string, not as a list element - with pytest.raises(pydantic.ValidationError): - VolumeOutput(output_fields=["Mach"], output_format=["both"]) - - with pytest.raises(pydantic.ValidationError): - VolumeOutput(output_fields=["Mach"], output_format=["paraview", "both"]) diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index c34979740..c1337939b 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -4,6 +4,7 @@ import pydantic as pd import pytest +from flow360_schema.framework.validation.context import DeserializationContext import flow360.component.simulation.units as u from flow360.component.simulation.draft_context.coordinate_system_manager import ( @@ -53,6 +54,7 @@ DetachedEddySimulation, KOmegaSST, KOmegaSSTModelConstants, + LinearSolver, SpalartAllmaras, SpalartAllmarasModelConstants, TransitionModelSolver, @@ -648,6 +650,20 @@ def test_transition_model_solver_settings_validator(): assert params.models[0].transition_model_solver.turbulence_intensity_percent is None +def test_linear_solver_tolerance_conflict(): + with pytest.raises(pd.ValidationError, match="absolute_tolerance and relative_tolerance"): + LinearSolver(absolute_tolerance=1e-10, relative_tolerance=1e-6) + + # Only one is fine + ls = LinearSolver(absolute_tolerance=1e-10) + assert ls.absolute_tolerance == 1e-10 + assert ls.relative_tolerance is None + + ls = LinearSolver(relative_tolerance=1e-6) + assert ls.relative_tolerance == 1e-6 + assert ls.absolute_tolerance is None + + def test_BC_geometry(): """For a quasi 3D geometry test the check for the""" # --------------------------------------------------------# @@ -1804,12 +1820,14 @@ def test_wall_deserialization(): # Wall->velocity accept discriminated AND non-discriminated unions. # Need to check if all works when deserializing. dummy_boundary = Surface(name="chameleon") - simple_wall = Wall(**Wall(entities=dummy_boundary).model_dump(mode="json")) + with DeserializationContext(): + simple_wall = Wall(**Wall(entities=dummy_boundary).model_dump(mode="json")) assert simple_wall.velocity is None - const_vel_wall = Wall( - **Wall(entities=dummy_boundary, velocity=[1, 2, 3] * u.m / u.s).model_dump(mode="json") - ) + with DeserializationContext(): + const_vel_wall = Wall( + **Wall(entities=dummy_boundary, velocity=[1, 2, 3] * u.m / u.s).model_dump(mode="json") + ) assert all(const_vel_wall.velocity == [1, 2, 3] * u.m / u.s) slater_bleed_wall = Wall( diff --git a/tests/simulation/ref/simulation_with_project_variables.json b/tests/simulation/ref/simulation_with_project_variables.json index 96db0f96e..6ed03acb9 100644 --- a/tests/simulation/ref/simulation_with_project_variables.json +++ b/tests/simulation/ref/simulation_with_project_variables.json @@ -128,29 +128,14 @@ } ], "operating_condition": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, + "alpha": 0.0, + "beta": 0.0, "private_attribute_constructor": "default", "private_attribute_input_cache": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, + "alpha": 0.0, + "beta": 0.0, "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, + "density": 1.225, "material": { "dynamic_viscosity": { "effective_temperature": { @@ -210,22 +195,13 @@ }, "private_attribute_constructor": "default", "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, + "temperature": 288.15, "type_name": "ThermalState" } }, - "reference_velocity_magnitude": { - "units": "m/s", - "value": 10.0 - }, + "reference_velocity_magnitude": 10.0, "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, + "density": 1.225, "material": { "dynamic_viscosity": { "effective_temperature": { @@ -285,10 +261,7 @@ }, "private_attribute_constructor": "default", "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, + "temperature": 288.15, "type_name": "ThermalState" }, "type_name": "AerospaceCondition", @@ -318,9 +291,7 @@ } ] }, - "output_format": [ - "paraview" - ], + "output_format": "paraview", "output_type": "VolumeOutput", "private_attribute_id": "000" }, diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index d0c7fce03..3f77b3f3a 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -1,3 +1,4 @@ +import copy import json import re @@ -396,12 +397,9 @@ def _compare_validation_errors(err, exp_err): "private_attribute_input_cache", "thermal_state", "density", - "value", ), - "type": "greater_than", - "msg": "Input should be greater than 0", - "input": -2, - "ctx": {"gt": "0.0"}, + "type": "value_error", + "msg": "Value error, Value must be positive (>0), got -2.0", }, ] _compare_validation_errors(errors, expected_errors) @@ -493,12 +491,9 @@ def _compare_validation_errors(err, exp_err): "stored_entities", 0, "height", - "value", ), - "type": "greater_than", - "msg": "Input should be greater than 0", - "input": -15, - "ctx": {"gt": "0.0"}, + "type": "value_error", + "msg": "Value error, Value must be positive (>0), got -15.0", }, ] @@ -646,9 +641,9 @@ def _compare_validation_errors(err, exp_err): "private_attribute_input_cache", "altitude", ), - "msg": "Value error, arg '100.0 K' does not match (length) dimension.", - "input": None, - "ctx": {"error": "arg '100.0 K' does not match (length) dimension."}, + "msg": "Value error, Dimension mismatch: expected length (meter), got (temperature)", + "input": {"units": "K", "value": 100.0}, + "ctx": {"error": "Dimension mismatch: expected length (meter), got (temperature)"}, } ] _compare_validation_errors(errors, expected_errors) @@ -664,8 +659,7 @@ def remove_model_and_output_id_in_default_dict(data): data = services.get_default_params( unit_system_name="SI", length_unit="m", root_item_type="Geometry" ) - assert data["operating_condition"]["alpha"]["value"] == 0 - assert data["operating_condition"]["alpha"]["units"] == "degree" + assert data["operating_condition"]["alpha"] == 0 assert "velocity_magnitude" not in data["operating_condition"].keys() remove_model_and_output_id_in_default_dict(data) # to convert tuples to lists: @@ -1664,3 +1658,24 @@ def test_validate_error_location_with_selector(): ) # Verify key path components are present for tokenized selectors in used_selectors + + +@pytest.mark.parametrize("unit_system_name", ["SI", "Imperial", "CGS"]) +def test_validate_model_preserves_unit_system(unit_system_name): + """validate_model must not mutate the unit_system entry in the input dict.""" + with open("data/simulation.json", "r") as fp: + params_data = json.load(fp) + + # Convert to the target unit system so all values carry matching units + services.change_unit_system(data=params_data, target_unit_system=unit_system_name) + unit_system_before = copy.deepcopy(params_data["unit_system"]) + + validated_param, errors, _ = services.validate_model( + params_as_dict=params_data, + validated_by=services.ValidationCalledBy.LOCAL, + root_item_type="VolumeMesh", + ) + + assert params_data["unit_system"] == unit_system_before + if validated_param is not None: + assert validated_param.unit_system.name == unit_system_name diff --git a/tests/simulation/services/test_entity_processing_service.py b/tests/simulation/services/test_entity_processing_service.py index 0baca998e..45a502bf8 100644 --- a/tests/simulation/services/test_entity_processing_service.py +++ b/tests/simulation/services/test_entity_processing_service.py @@ -189,7 +189,10 @@ def test_validate_model_deduplicates_non_point_entities(): """ params = { "version": "25.7.6b0", - "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, + "operating_condition": { + "type_name": "AerospaceCondition", + "velocity_magnitude": {"units": "m/s", "value": 10}, + }, "outputs": [ { "output_type": "SurfaceOutput", @@ -376,7 +379,10 @@ def test_validate_model_does_not_deduplicate_point_entities(): """ params = { "version": "25.7.6b0", - "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, + "operating_condition": { + "type_name": "AerospaceCondition", + "velocity_magnitude": {"value": 10, "units": "m/s"}, + }, "outputs": [ { "output_type": "StreamlineOutput", @@ -425,7 +431,10 @@ def test_validate_model_shares_entity_instances_across_lists(): params = { "version": "25.7.6b0", "unit_system": {"name": "SI"}, - "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, + "operating_condition": { + "type_name": "AerospaceCondition", + "velocity_magnitude": {"value": 10, "units": "m/s"}, + }, "models": [ {"type": "Wall", "name": "Wall", "entities": {"stored_entities": [entity_dict]}} ], diff --git a/tests/simulation/test_conftest.py b/tests/simulation/test_conftest.py new file mode 100644 index 000000000..460261cd2 --- /dev/null +++ b/tests/simulation/test_conftest.py @@ -0,0 +1,8 @@ +from tests.simulation.conftest import _approx_equal + + +def test_approx_equal_distinguishes_bool_from_numeric_types(): + assert _approx_equal(True, True) + assert _approx_equal(False, False) + assert not _approx_equal(True, 1) + assert not _approx_equal(False, 0.0) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 23cd4554e..95dbf2779 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -78,7 +78,7 @@ save_user_variables, ) from flow360.component.simulation.user_code.variables import control, solution -from tests.utils import to_file_from_file_test +from tests.simulation.conftest import to_file_from_file_test_approx @pytest.fixture(autouse=True) @@ -811,7 +811,7 @@ def test_to_file_from_file_expression( ], ) - to_file_from_file_test(params) + to_file_from_file_test_approx(params) params.display_output_units() # Just to make sure not exception. @@ -954,15 +954,14 @@ def test_project_variables_serialization(): assert output_units_by_name["ddd"] == "m/s" assert output_units_by_name["eee"] == "dimensionless" - params_data = params.model_dump(mode="json", exclude_none=True) + paramsJson = params.model_dump_json(indent=4, exclude_none=True) + with open("ref/simulation_with_project_variables.json", "w") as f: + f.write(paramsJson) with open("ref/simulation_with_project_variables.json", "r") as fh: - ref_data = json.load(fh) + ref_data = fh.read() - # Compare ignoring version which changes between releases - params_data.pop("version", None) - ref_data.pop("version", None) - assert ref_data == params_data + assert ref_data == params.model_dump_json(indent=4, exclude_none=True) def test_project_variables_deserialization(): diff --git a/tests/simulation/test_krylov_solver.py b/tests/simulation/test_krylov_solver.py index 55d9782e5..b9f2d338f 100644 --- a/tests/simulation/test_krylov_solver.py +++ b/tests/simulation/test_krylov_solver.py @@ -206,12 +206,11 @@ def test_error_krylov_with_limit_pressure_density(self): ) def test_error_krylov_with_unsteady(self): - with SI_unit_system: - with pytest.raises(ValueError, match="Unsteady"): - _make_sim_params( - navier_stokes_solver=NavierStokesSolver(linear_solver=KrylovLinearSolver()), - time_stepping=Unsteady(steps=100, step_size=0.1), - ) + with pytest.raises(ValueError, match="Unsteady"): + _make_sim_params( + navier_stokes_solver=NavierStokesSolver(linear_solver=KrylovLinearSolver()), + time_stepping=Unsteady(steps=100, step_size=0.1), + ) def test_krylov_with_steady_is_ok(self): param = _make_sim_params( diff --git a/tests/simulation/test_updater.py b/tests/simulation/test_updater.py index 945bfab10..0fe3a2318 100644 --- a/tests/simulation/test_updater.py +++ b/tests/simulation/test_updater.py @@ -13,7 +13,6 @@ _to_25_9_0, _to_25_9_1, _to_25_9_2, - _to_25_10_0, updater, ) from flow360.component.simulation.framework.updater_utils import Flow360Version @@ -1931,87 +1930,6 @@ def test_updater_to_25_9_2_modular_zones_rotation_volume_sphere_to_rotation_sphe assert zone["spacing_circumferential"] == {"value": 0.7, "units": "m"} -def test_updater_to_25_10_0_output_format_to_list(): - """Test 25.10.0 updater converts string output_format to list form.""" - - params_as_dict = { - "version": "25.9.2", - "unit_system": {"name": "SI"}, - "outputs": [ - { - "output_type": "VolumeOutput", - "name": "Volume output", - "output_format": "paraview", - "output_fields": {"items": ["Mach"]}, - }, - { - "output_type": "SurfaceOutput", - "name": "Surface output both", - "output_format": "both", - "output_fields": {"items": ["Cp"]}, - "entities": {"stored_entities": []}, - }, - { - "output_type": "SliceOutput", - "name": "Slice output tecplot", - "output_format": "tecplot", - "output_fields": {"items": ["Mach"]}, - "entities": {"stored_entities": []}, - }, - { - "output_type": "VolumeOutput", - "name": "Volume output combo", - "output_format": "paraview,vtkhdf", - "output_fields": {"items": ["Mach"]}, - }, - ], - } - - params_new = updater( - version_from="25.9.2", - version_to="25.10.0", - params_as_dict=params_as_dict, - ) - - assert params_new["version"] == "25.10.0" - assert params_new["outputs"][0]["output_format"] == ["paraview"] - assert params_new["outputs"][1]["output_format"] == ["paraview", "tecplot"] - assert params_new["outputs"][2]["output_format"] == ["tecplot"] - assert params_new["outputs"][3]["output_format"] == ["paraview", "vtkhdf"] - - -def test_updater_to_25_10_0_output_format_already_list(): - """Test 25.10.0 updater is a no-op when output_format is already a list.""" - - params_as_dict = { - "version": "25.9.2", - "unit_system": {"name": "SI"}, - "outputs": [ - { - "output_type": "VolumeOutput", - "name": "Volume output", - "output_format": ["paraview", "vtkhdf"], - "output_fields": {"items": ["Mach"]}, - }, - ], - } - - params_new = _to_25_10_0(params_as_dict) - assert params_new["outputs"][0]["output_format"] == ["paraview", "vtkhdf"] - - -def test_updater_to_25_10_0_output_format_no_outputs(): - """Test 25.10.0 updater handles missing or empty outputs for output_format migration.""" - - params_no_outputs = {"version": "25.9.2", "unit_system": {"name": "SI"}} - params_new = _to_25_10_0(params_no_outputs) - assert "outputs" not in params_new - - params_empty = {"version": "25.9.2", "unit_system": {"name": "SI"}, "outputs": []} - params_new = _to_25_10_0(params_empty) - assert params_new["outputs"] == [] - - def test_updater_to_25_9_2_custom_volume_boundaries_to_bounding_entities(): """Test 25.9.2 updater renames boundaries -> bounding_entities on CustomVolume.""" params_as_dict = { diff --git a/tests/simulation/test_value_or_expression.py b/tests/simulation/test_value_or_expression.py index 918d839a7..da3070161 100644 --- a/tests/simulation/test_value_or_expression.py +++ b/tests/simulation/test_value_or_expression.py @@ -576,7 +576,7 @@ def test_integer_validation(): with pytest.raises( ValueError, - match=re.escape("Value error, arg '10' does not match (length)/(time) dimension."), + match=re.escape("Value does not have units matching 'velocity' dimension"), ): with SI_unit_system: AerospaceCondition(velocity_magnitude=Expression(expression="10")) diff --git a/tests/simulation/translator/test_surface_meshing_translator.py b/tests/simulation/translator/test_surface_meshing_translator.py index 4671b29ad..ea69aa846 100644 --- a/tests/simulation/translator/test_surface_meshing_translator.py +++ b/tests/simulation/translator/test_surface_meshing_translator.py @@ -1427,11 +1427,11 @@ def test_sliding_interface_tolerance_gai(): "stored_entities": [ { "axis": [0.0, 0.0, 1.0], - "center": {"units": "1.0*m", "value": [0.0, 0.0, 0.0]}, - "height": {"units": "1.0*m", "value": 10.0}, - "inner_radius": {"units": "1.0*m", "value": 0.0}, + "center": [0.0, 0.0, 0.0], + "height": 10.0, + "inner_radius": 0.0, "name": "cylinder", - "outer_radius": {"units": "1.0*m", "value": 5.0}, + "outer_radius": 5.0, "private_attribute_entity_type_name": "Cylinder", "private_attribute_id": "b8d08e11-e837-4cc7-95b3-f92e05e71a65", "private_attribute_zone_boundary_names": {"items": []}, diff --git a/tests/test_artifact_import.py b/tests/test_artifact_import.py new file mode 100644 index 000000000..84e2ff7cb --- /dev/null +++ b/tests/test_artifact_import.py @@ -0,0 +1,6 @@ +import importlib + + +def test_importable_artifact(): + module = importlib.import_module("flow360_schema") + assert module.__name__ == "flow360_schema" diff --git a/tests/test_current_flow360_version.py b/tests/test_current_flow360_version.py index db0081220..8783a5d98 100644 --- a/tests/test_current_flow360_version.py +++ b/tests/test_current_flow360_version.py @@ -2,4 +2,4 @@ def test_version(): - assert __version__ == "25.10.0b1" + assert __version__ == "25.9.3b1" diff --git a/tests/utils.py b/tests/utils.py index bbebbe46c..c95cab6f0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -3,7 +3,6 @@ import os import shutil import tempfile -from numbers import Number import numpy as np import pytest From b62e739f9d7a042d85f6a9245817caf5949df871 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Tue, 17 Mar 2026 09:50:56 -0400 Subject: [PATCH 02/25] Corrected the version --- flow360/component/v1/updater.py | 2 ++ flow360/version.py | 2 +- plan_sort_json.markdown | 22 ---------------------- tests/test_current_flow360_version.py | 2 +- 4 files changed, 4 insertions(+), 24 deletions(-) delete mode 100644 plan_sort_json.markdown diff --git a/flow360/component/v1/updater.py b/flow360/component/v1/updater.py index 6d1198a18..da25c66dd 100644 --- a/flow360/component/v1/updater.py +++ b/flow360/component/v1/updater.py @@ -32,6 +32,8 @@ def _no_update(params_as_dict): ("25.6.*", "25.7.*", _no_update), ("25.7.*", "25.8.*", _no_update), ("25.8.*", "25.9.*", _no_update), + ("25.9.*", "25.10.*", _no_update), + ("25.10.*", "25.11.*", _no_update), ] diff --git a/flow360/version.py b/flow360/version.py index 6681f7ffe..a32be0ae8 100644 --- a/flow360/version.py +++ b/flow360/version.py @@ -2,5 +2,5 @@ version """ -__version__ = "25.9.3b1" +__version__ = "25.11.0b1" __solver_version__ = "release-25.8" diff --git a/plan_sort_json.markdown b/plan_sort_json.markdown deleted file mode 100644 index d27a71ef4..000000000 --- a/plan_sort_json.markdown +++ /dev/null @@ -1,22 +0,0 @@ -# Plan: Reference JSON Key Sorting Script - -## 目标 -写一个 Python 脚本,递归排序 reference JSON 文件的 keys,方便 code review 时看 diff。 - -## 脚本功能 -- 路径: `scripts/sort_ref_json.py` -- 零外部依赖(只用标准库 `json`, `pathlib`, `sys`) -- 两种模式: - - **修复模式** (默认): `python scripts/sort_ref_json.py` — 原地重写所有未排序的 JSON 文件 - - **检查模式**: `python scripts/sort_ref_json.py --check` — 只检查,不修改,未排序则 exit 1(用于 CI) -- 扫描范围: `tests/` 目录下所有 `*.json` 文件 -- 递归排序所有嵌套 dict 的 keys(list 内的 dict 也排序 keys,但 list 元素顺序不变) -- 保持 4 空格缩进 + trailing newline(与现有格式一致) - -## 步骤 -1. 创建 `scripts/sort_ref_json.py` -2. (可选) 在 CI 配置里加 `python scripts/sort_ref_json.py --check` - -## 不做的事 -- 不引入 pre-commit framework -- 不修改非 tests/ 目录的 JSON 文件 diff --git a/tests/test_current_flow360_version.py b/tests/test_current_flow360_version.py index 8783a5d98..030f2635f 100644 --- a/tests/test_current_flow360_version.py +++ b/tests/test_current_flow360_version.py @@ -2,4 +2,4 @@ def test_version(): - assert __version__ == "25.9.3b1" + assert __version__ == "25.11.0b1" From bfbb7b47ca529e081628eecf6e7a3d76b4f20f88 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:45:47 -0400 Subject: [PATCH 03/25] Replace custom coverage script with py-cov-action (#1901) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/scripts/coverage_summary.py | 234 ---------------------------- .github/workflows/test.yml | 45 ++++-- pyproject.toml | 3 + 3 files changed, 35 insertions(+), 247 deletions(-) delete mode 100644 .github/scripts/coverage_summary.py diff --git a/.github/scripts/coverage_summary.py b/.github/scripts/coverage_summary.py deleted file mode 100644 index c67e21991..000000000 --- a/.github/scripts/coverage_summary.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a coverage summary from Cobertura XML, with optional diff coverage.""" - -import os -import re -import subprocess -import sys -import xml.etree.ElementTree as ET -from collections import defaultdict - - -def make_bar(pct, width=20): - pct = max(0.0, min(100.0, pct)) - filled = round(pct / 100 * width) - return "\u2593" * filled + "\u2591" * (width - filled) - - -def status_icon(pct): - if pct >= 80: - return "\U0001f7e2" - if pct >= 60: - return "\U0001f7e1" - return "\U0001f534" - - -def _get_repo_root(): - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() - - -_repo_root = None - - -def _normalize_filename(filename, source_roots): - """Normalize coverage XML filename to repo-relative path. - - Coverage XML records paths relative to roots, while git diff - produces paths relative to the repo root. This joins the two and strips - the repo root prefix so both sides use the same reference frame. - """ - global _repo_root - if _repo_root is None: - _repo_root = _get_repo_root() - repo_prefix = _repo_root + "/" - - for root in source_roots: - if os.path.isabs(filename): - if filename.startswith(repo_prefix): - return filename[len(repo_prefix) :] - else: - full = os.path.join(root, filename) - if full.startswith(repo_prefix): - return full[len(repo_prefix) :] - return filename - - -def parse_coverage_xml(xml_path, depth): - """Parse coverage.xml, return (groups_dict, file_line_coverage_dict).""" - tree = ET.parse(xml_path) - root = tree.getroot() - - source_roots = [s.text.rstrip("/") for s in root.findall(".//source") if s.text] - - groups = defaultdict(lambda: {"hits": 0, "lines": 0}) - file_coverage = defaultdict(dict) - - for pkg in root.findall(".//package"): - name = pkg.get("name", "") - parts = name.split(".") - key = ".".join(parts[:depth]) if len(parts) >= depth else name - - for cls in pkg.findall(".//class"): - filename = cls.get("filename", "") - filename = _normalize_filename(filename, source_roots) - for line in cls.findall(".//line"): - line_num = int(line.get("number", "0")) - hit = int(line.get("hits", "0")) > 0 - file_coverage[filename][line_num] = ( - file_coverage[filename].get(line_num, False) or hit - ) - groups[key]["lines"] += 1 - if hit: - groups[key]["hits"] += 1 - - return groups, file_coverage - - -def get_changed_lines(diff_branch): - """Run git diff and return {filepath: set_of_changed_line_numbers} for non-test .py files.""" - result = subprocess.run( - ["git", "diff", "--unified=0", diff_branch, "--", "*.py"], - capture_output=True, - text=True, - check=True, - ) - - changed = defaultdict(set) - current_file = None - hunk_re = re.compile(r"^@@ .+?\+(\d+)(?:,(\d+))? @@") - - for line in result.stdout.splitlines(): - if line.startswith("+++ b/"): - current_file = line[6:] - elif line.startswith("@@") and current_file: - m = hunk_re.match(line) - if m: - start = int(m.group(1)) - count = int(m.group(2)) if m.group(2) else 1 - if count > 0: - for i in range(start, start + count): - changed[current_file].add(i) - - return { - f: lines - for f, lines in changed.items() - if f.endswith(".py") and not f.startswith("tests/") and f.startswith("flow360/") - } - - -def build_diff_coverage_md(changed_lines, file_coverage): - """Build diff coverage markdown section.""" - if not changed_lines: - return "## Diff Coverage\n\nNo implementation files changed.\n" - - total_covered = 0 - total_changed = 0 - - file_stats = [] - for filepath, line_nums in sorted(changed_lines.items()): - cov_map = file_coverage.get(filepath, {}) - executable = {ln for ln in line_nums if ln in cov_map} - covered = {ln for ln in executable if cov_map[ln]} - missing = sorted(executable - covered) - - n_exec = len(executable) - n_cov = len(covered) - total_covered += n_cov - total_changed += n_exec - pct = (n_cov / n_exec * 100) if n_exec else -1 - file_stats.append((filepath, pct, n_cov, n_exec, missing)) - - file_stats.sort(key=lambda x: x[1]) - total_pct = (total_covered / total_changed * 100) if total_changed else 100 - - lines = [] - lines.append(f"## {status_icon(total_pct)} Diff Coverage — {total_pct:.0f}%") - lines.append("") - lines.append( - f"`{make_bar(total_pct, 30)}` **{total_pct:.1f}%** ({total_covered} / {total_changed} changed lines covered)" - ) - lines.append("") - lines.append("| File | Coverage | Lines | Missing |") - lines.append("|:-----|:--------:|:-----:|:--------|") - - for filepath, pct, n_cov, n_exec, missing in file_stats: - icon = status_icon(pct) if pct >= 0 else "\u26aa" - pct_str = f"{pct:.0f}%" if pct >= 0 else "N/A" - missing_str = ", ".join(f"L{ln}" for ln in missing[:20]) - if len(missing) > 20: - missing_str += f" \u2026 +{len(missing) - 20} more" - lines.append(f"| `{filepath}` | {icon} {pct_str} | {n_cov} / {n_exec} | {missing_str} |") - - lines.append(f"| **Total** | **{total_pct:.1f}%** | **{total_covered} / {total_changed}** | |") - lines.append("") - return "\n".join(lines) - - -def build_full_coverage_md(groups): - """Build full coverage markdown section (wrapped in
, collapsed by default).""" - total_lines = sum(g["lines"] for g in groups.values()) - total_hits = sum(g["hits"] for g in groups.values()) - total_pct = (total_hits / total_lines * 100) if total_lines else 0 - - sorted_groups = sorted( - groups.items(), - key=lambda x: (x[1]["hits"] / x[1]["lines"] * 100) if x[1]["lines"] else 0, - ) - - lines = [] - lines.append("
") - lines.append( - f"

{status_icon(total_pct)} Full Coverage Report — {total_pct:.0f}% ({total_hits} / {total_lines} lines)

" - ) - lines.append("") - lines.append( - f"`{make_bar(total_pct, 30)}` **{total_pct:.1f}%** ({total_hits} / {total_lines} lines)" - ) - lines.append("") - lines.append("| Package | Coverage | Progress | Lines |") - lines.append("|:--------|:--------:|:---------|------:|") - - for key, g in sorted_groups: - pct = (g["hits"] / g["lines"] * 100) if g["lines"] else 0 - icon = status_icon(pct) - lines.append( - f"| `{key}` | {icon} {pct:.1f}% | `{make_bar(pct)}` | {g['hits']} / {g['lines']} |" - ) - - lines.append(f"| **Total** | **{total_pct:.1f}%** | | **{total_hits} / {total_lines}** |") - lines.append("") - lines.append("
") - lines.append("") - return "\n".join(lines) - - -def main(): - xml_path = sys.argv[1] if len(sys.argv) > 1 else "coverage.xml" - output_path = sys.argv[2] if len(sys.argv) > 2 else "coverage-summary.md" - depth = int(sys.argv[3]) if len(sys.argv) > 3 else 2 - diff_branch = sys.argv[4] if len(sys.argv) > 4 else None - - groups, file_coverage = parse_coverage_xml(xml_path, depth) - - parts = [] - - if diff_branch: - changed_lines = get_changed_lines(diff_branch) - parts.append(build_diff_coverage_md(changed_lines, file_coverage)) - - parts.append(build_full_coverage_md(groups)) - - with open(output_path, "w") as f: - f.write("\n".join(parts)) - - print(f"Coverage summary written to {output_path}") - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 99fa1a1bd..b79ff051f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,18 +81,37 @@ jobs: - name: Run flow360_params tests with coverage if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' - run: poetry run pytest -rA --ignore tests/simulation -vv --cov=flow360 --cov-append --cov-report=term-missing:skip-covered --cov-report=xml:coverage.xml + run: poetry run pytest -rA --ignore tests/simulation -vv --cov=flow360 --cov-append --cov-report=term-missing:skip-covered - - name: Fetch base branch for diff coverage - if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' && github.event_name == 'pull_request' - run: git fetch origin ${{ github.base_ref }} --depth=1 - - - name: Generate coverage summary - if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' && github.event_name == 'pull_request' - run: python .github/scripts/coverage_summary.py coverage.xml coverage-summary.md 2 origin/${{ github.base_ref }} - - - name: Post coverage comment - if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' && github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v2 + - name: Upload coverage data + if: matrix.python-version == '3.10' && matrix.platform == 'ubuntu-latest' + uses: actions/upload-artifact@v4 with: - path: coverage-summary.md + name: coverage-data + path: .coverage + include-hidden-files: true + + coverage: + name: Post Coverage Comment + needs: testing + if: always() && !cancelled() && github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - name: Download coverage data + id: download-coverage + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: coverage-data + + - name: Post coverage comment + if: steps.download-coverage.outcome == 'success' + uses: py-cov-action/python-coverage-comment-action@7188638f871f721a365d644f505d1ff3df20d683 # v3.40 + with: + GITHUB_TOKEN: ${{ github.token }} + SUBPROJECT_ID: flow360 diff --git a/pyproject.toml b/pyproject.toml index d55e89483..72a2b5e3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,9 @@ pre-commit = ["autohooks.plugins.black", "autohooks.plugins.isort", "autohooks.p [tool.autohooks.plugins.pylint] arguments = ["--rcfile=.pylintrc"] +[tool.coverage.run] +relative_files = true + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" From b039d6042199e3f77caf92cd2b24b97f9befbbfb Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:31:08 -0400 Subject: [PATCH 04/25] Full unit physical dimension migration into Schema Side (#1888) Co-authored-by: Claude Opus 4.6 Co-authored-by: Stan-Flexcompute <168167683+Stan-He-5@users.noreply.github.com> --- .../migration_guide/example_bet_conversion.py | 3 +- .../example_monitor_conversion.py | 3 +- flow360/component/geometry.py | 6 +- flow360/component/project.py | 9 +- flow360/component/project_utils.py | 10 +- flow360/component/results/case_results.py | 132 +- flow360/component/simulation/conversion.py | 354 +-- .../simulation/draft_context/mirror.py | 4 +- flow360/component/simulation/entity_info.py | 9 +- .../component/simulation/entity_operation.py | 26 +- .../simulation/framework/base_model.py | 3 +- .../framework/entity_expansion_utils.py | 8 +- .../framework/entity_materializer.py | 2 +- .../simulation/framework/param_utils.py | 7 +- .../simulation/meshing_param/edge_params.py | 6 +- .../simulation/meshing_param/face_params.py | 14 +- .../simulation/meshing_param/meshing_specs.py | 24 +- .../snappy/snappy_mesh_refinements.py | 27 +- .../meshing_param/snappy/snappy_params.py | 3 +- .../meshing_param/snappy/snappy_specs.py | 20 +- .../simulation/meshing_param/volume_params.py | 60 +- .../component/simulation/migration/BETDisk.py | 22 +- .../migration/extra_operating_condition.py | 26 +- .../models/bet/bet_translator_interface.py | 40 +- .../component/simulation/models/material.py | 60 +- .../simulation/models/surface_models.py | 56 +- .../models/turbulence_quantities.py | 20 +- .../simulation/models/volume_models.py | 174 +- .../operating_condition.py | 12 +- .../simulation/outputs/output_entities.py | 22 +- .../simulation/outputs/output_fields.py | 3 +- .../component/simulation/outputs/outputs.py | 8 +- .../simulation/outputs/render_config.py | 24 +- flow360/component/simulation/primitives.py | 39 +- .../run_control/stopping_criterion.py | 3 +- flow360/component/simulation/services.py | 106 +- .../component/simulation/simulation_params.py | 139 +- .../simulation/time_stepping/time_stepping.py | 4 +- .../translator/solver_translator.py | 16 +- .../translator/surface_meshing_translator.py | 4 +- .../component/simulation/translator/utils.py | 7 +- flow360/component/simulation/unit_system.py | 1041 +------- flow360/component/simulation/units.py | 59 +- .../simulation/user_code/core/types.py | 4 +- .../validation/validation_context.py | 27 +- .../component/simulation/web/asset_base.py | 2 +- flow360/plugins/report/report_items.py | 22 +- plan_sort_json.markdown | 22 + poetry.lock | 174 +- pyproject.toml | 2 +- .../ref/simulation/service_init_geometry.json | 126 +- .../simulation/service_init_surface_mesh.json | 126 +- .../simulation/service_init_volume_mesh.json | 116 +- ...tion_json_with_multi_constructor_used.json | 2 +- tests/report/test_report_items.py | 3 +- tests/simulation/conftest.py | 45 +- tests/simulation/converter/ref/ref_c81.json | 1499 +++--------- tests/simulation/converter/ref/ref_dfdc.json | 2093 ++++++----------- .../simulation/converter/ref/ref_monitor.json | 80 +- .../converter/ref/ref_single_bet_disk.json | 878 ++++--- tests/simulation/converter/ref/ref_xfoil.json | 1581 ++++--------- .../simulation/converter/ref/ref_xrotor.json | 2093 ++++++----------- .../test_bet_disk_flow360_converter.py | 7 +- .../framework/test_entities_fast_register.py | 7 +- .../simulation/framework/test_entities_v2.py | 35 +- .../framework/test_entity_dict_database.py | 2 +- .../framework/test_entity_selector_token.py | 2 +- .../framework/test_unit_system_v2.py | 197 +- .../outputs/test_output_entities.py | 30 +- .../test_meshing_param_validation.py | 18 +- .../test_refinements_validation.py | 4 +- tests/simulation/params/test_actuator_disk.py | 3 +- .../params/test_automated_farfield.py | 4 +- tests/simulation/params/test_gravity.py | 9 +- .../params/test_simulation_params.py | 15 + .../params/test_unit_conversions.py | 89 +- .../params/test_validators_bet_disk.py | 36 +- .../params/test_validators_output.py | 2 +- .../params/test_validators_params.py | 53 +- .../simulation_with_project_variables.json | 90 +- .../test_bet_disk_coefficients.py | 9 +- .../result_merged_geometry_entity_info1.json | 5 +- .../result_merged_geometry_entity_info2.json | 5 +- .../service/test_apply_simulation_setting.py | 4 +- tests/simulation/service/test_project_util.py | 4 +- tests/simulation/service/test_services_v2.py | 71 +- .../service/test_translator_service.py | 7 +- tests/simulation/test_expression_math.py | 5 +- tests/simulation/test_krylov_solver.py | 2 +- tests/simulation/test_value_or_expression.py | 2 +- .../ref/Flow360_mirrored_surface_meshing.json | 118 +- ...opping_criterion_and_moving_statistic.json | 2 +- .../surface_meshing/gai_surface_mesher.json | 40 +- .../ref/surface_meshing/gai_windtunnel.json | 105 +- .../translator/test_output_translation.py | 18 +- .../translator/test_solver_translator.py | 2 +- .../test_surface_meshing_translator.py | 41 +- .../test_volume_meshing_translator.py | 34 +- .../TurbFlatPlate137x97_BoxTrip_generator.py | 2 +- .../test_compute_surface_integral_unit.py | 3 + tests/test_results.py | 63 +- .../_test_webui_workbench_integration.py | 4 +- 102 files changed, 3844 insertions(+), 8815 deletions(-) create mode 100644 plan_sort_json.markdown diff --git a/examples/migration_guide/example_bet_conversion.py b/examples/migration_guide/example_bet_conversion.py index e65ef9f85..e679b5a1a 100644 --- a/examples/migration_guide/example_bet_conversion.py +++ b/examples/migration_guide/example_bet_conversion.py @@ -1,8 +1,9 @@ import os +import unyt as u + import flow360 as fl from flow360.component.simulation.migration import BETDisk -from flow360.component.simulation.unit_system import u # Get the absolute path to the script file script_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/examples/migration_guide/example_monitor_conversion.py b/examples/migration_guide/example_monitor_conversion.py index dc1aff4eb..172300ae6 100644 --- a/examples/migration_guide/example_monitor_conversion.py +++ b/examples/migration_guide/example_monitor_conversion.py @@ -1,7 +1,8 @@ import os +import unyt as u + from flow360.component.simulation.migration import ProbeOutput -from flow360.component.simulation.unit_system import u # Get the absolute path to the script file script_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index b692f93fe..b7a98fffa 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -28,7 +28,6 @@ ) from flow360.component.simulation.folder import Folder from flow360.component.simulation.primitives import Edge, GeometryBodyGroup, Surface -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.web.asset_base import AssetBase from flow360.component.utils import ( @@ -430,7 +429,7 @@ def snappy_bodies(self): def get_dynamic_default_settings(self, simulation_dict: dict): """Get the default geometry settings from the simulation dict""" - def _get_default_geometry_accuracy(simulation_dict: dict) -> LengthType.Positive: + def _get_default_geometry_accuracy(simulation_dict: dict): """Get the default geometry accuracy from the simulation json""" if simulation_dict.get("meshing") is None: return None @@ -438,8 +437,7 @@ def _get_default_geometry_accuracy(simulation_dict: dict) -> LengthType.Positive return None if simulation_dict["meshing"]["defaults"].get("geometry_accuracy") is None: return None - # pylint: disable=no-member - return LengthType.validate(simulation_dict["meshing"]["defaults"]["geometry_accuracy"]) + return simulation_dict["meshing"]["defaults"]["geometry_accuracy"] self.default_settings["geometry_accuracy"] = ( self._entity_info.default_geometry_accuracy diff --git a/flow360/component/project.py b/flow360/component/project.py index 8fd77f437..041bf3982 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -11,6 +11,7 @@ import pydantic as pd import typing_extensions +from flow360_schema.framework.physical_dimensions import Length from pydantic import PositiveInt from flow360.cloud.flow360_requests import ( @@ -56,7 +57,7 @@ from flow360.component.simulation.folder import Folder from flow360.component.simulation.primitives import ImportedSurface from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import LengthType +from flow360.component.simulation.units import validate_length from flow360.component.simulation.web.asset_base import AssetBase from flow360.component.simulation.web.draft import Draft from flow360.component.simulation.web.project_records import ( @@ -638,13 +639,13 @@ def tags(self) -> List[str]: return self.metadata.tags @property - def length_unit(self) -> LengthType.Positive: + def length_unit(self) -> Length.PositiveFloat64: """ Returns the length unit of the project. Returns ------- - LengthType.Positive + Length.PositiveFloat64 The length unit. """ @@ -656,7 +657,7 @@ def length_unit(self) -> LengthType.Positive: if cache_key not in defaults or length_key not in defaults[cache_key]: raise Flow360ValueError("[Internal] Simulation params do not contain length unit info.") - return LengthType.validate(defaults[cache_key][length_key]) + return validate_length(defaults[cache_key][length_key]) @property def geometry(self) -> Geometry: diff --git a/flow360/component/project_utils.py b/flow360/component/project_utils.py index e4cc66e46..fa82f7991 100644 --- a/flow360/component/project_utils.py +++ b/flow360/component/project_utils.py @@ -4,6 +4,7 @@ from typing import Optional, Type, TypeVar, get_args +from flow360_schema.framework.physical_dimensions import Length from pydantic import ValidationError from flow360.component.simulation import services @@ -33,7 +34,6 @@ strip_selector_matches_and_broken_entities_inplace, ) from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.user_code.core.types import save_user_variables from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.web.asset_base import AssetBase @@ -145,7 +145,7 @@ def load_status_from_asset( return None try: - return status_class.model_validate(status_dict) + return status_class.deserialize(status_dict) except ValidationError as exc: # pragma: no cover - raises immediately status_name = cache_key.replace("_", " ") raise Flow360RuntimeError( @@ -161,7 +161,7 @@ def deep_copy_entity_info(entity_info: Flow360BaseModel) -> Flow360BaseModel: """ entity_info_dict = entity_info.model_dump(mode="json") - return type(entity_info).model_validate(entity_info_dict) + return type(entity_info).deserialize(entity_info_dict) def apply_and_inform_grouping_selections( @@ -477,7 +477,7 @@ def _set_up_default_geometry_accuracy( return params -def _set_up_default_reference_geometry(params: SimulationParams, length_unit: LengthType): +def _set_up_default_reference_geometry(params: SimulationParams, length_unit: Length.Float64): """ Setting up the default reference geometry if not provided in params. Ensure the simulation.json contains the default settings other than None. @@ -526,7 +526,7 @@ def _build_deduplicated_entity_registry_from_params(params: SimulationParams) -> def set_up_params_for_uploading( # pylint: disable=too-many-arguments root_asset, - length_unit: LengthType, + length_unit: Length.Float64, params: SimulationParams, use_beta_mesher: bool, use_geometry_AI: bool, # pylint: disable=invalid-name diff --git a/flow360/component/results/case_results.py b/flow360/component/results/case_results.py index a678ee333..d1a4a0571 100644 --- a/flow360/component/results/case_results.py +++ b/flow360/component/results/case_results.py @@ -9,6 +9,7 @@ import numpy as np import pydantic as pd +from flow360_schema.framework.physical_dimensions import Force, Moment, Power from flow360.component.results.base_results import ( _PHYSICAL_STEP, @@ -25,7 +26,6 @@ DiskCoefficientsComputation, PorousMediumCoefficientsComputation, ) -from flow360.component.simulation.conversion import unit_converter as unit_converter_v2 from flow360.component.simulation.outputs.output_fields import ( _CD_PER_STRIP, _CUMULATIVE_CD_CURVE, @@ -48,14 +48,6 @@ _CMz_PER_SPAN, ) from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import ( - Flow360UnitSystem, - ForceType, - MomentType, - PowerType, - is_flow360_unit, -) -from flow360.component.v1.conversions import unit_converter as unit_converter_v1 from flow360.component.v1.flow360_params import Flow360Params from flow360.exceptions import Flow360NotImplementedError, Flow360ValueError from flow360.log import log @@ -499,31 +491,35 @@ class _DimensionedCSVResultModel(pd.BaseModel): _name: str - def _in_base_component(self, base, component, component_name, params): - log.debug(f" -> need conversion for: {component_name} = {component}") + @staticmethod + def _build_flow360_unit_system(params): + """Build a schema UnitSystem from V1 or V2 params for unit inference context.""" + # pylint: disable=import-outside-toplevel + from flow360_schema.framework.unit_system import create_flow360_unit_system if isinstance(params, SimulationParams): - flow360_conv_system = unit_converter_v2( - component.units.dimensions, - params=params, - required_by=[self._name, component_name], + return create_flow360_unit_system( + length=params.base_length, + velocity=params.base_velocity, + density=params.base_density, + temperature=params.base_temperature, ) - elif isinstance(params, Flow360Params): - flow360_conv_system = unit_converter_v1( - component.units.dimensions, - params=params, - required_by=[self._name, component_name], - ) - else: - raise Flow360ValueError( - f"Unknown type of params: {type(params)=}, expected one of (Flow360Params, SimulationParams)" + if isinstance(params, Flow360Params): + fp = params.fluid_properties.to_fluid_properties() + return create_flow360_unit_system( + length=params.geometry.mesh_unit.to("m"), + velocity=params.fluid_properties.speed_of_sound().to("m/s"), + density=fp.density.to("kg/m**3"), + temperature=fp.temperature.to("K"), ) + raise Flow360ValueError( + f"Unknown type of params: {type(params)=}, " + "expected one of (Flow360Params, SimulationParams)" + ) - if is_flow360_unit(component): - converted = component.in_base(base, flow360_conv_system) - else: - component.units.registry = flow360_conv_system.registry # pylint:disable=no-member - converted = component.in_base(unit_system=base) + def _in_base_component(self, base, component, component_name): + log.debug(f" -> need conversion for: {component_name} = {component}") + converted = component.in_base(unit_system=base) log.debug(f" converted to: {converted}") return converted @@ -536,11 +532,11 @@ class _ActuatorDiskResults(_DimensionedCSVResultModel): Attributes ---------- - power : PowerType.Array + power : Power.Array Array of power values. - force : ForceType.Array + force : Force.Array Array of force values. - moment : MomentType.Array + moment : Moment.Array Array of moment values. Methods @@ -549,12 +545,12 @@ class _ActuatorDiskResults(_DimensionedCSVResultModel): Convert the results to the specified base system. """ - power: PowerType.Array = pd.Field() - force: ForceType.Array = pd.Field() - moment: MomentType.Array = pd.Field() + power: Power.Array = pd.Field() + force: Force.Array = pd.Field() + moment: Moment.Array = pd.Field() _name = "actuator_disks" - def to_base(self, base: str, params: Flow360Params): + def to_base(self, base: str): """ Convert the results to the specified base system. @@ -562,13 +558,11 @@ def to_base(self, base: str, params: Flow360Params): ---------- base : str The base system to convert the results to, for example SI. - params : Flow360Params - Case parameters for the conversion. """ - self.power = self._in_base_component(base, self.power, "power", params) - self.force = self._in_base_component(base, self.force, "force", params) - self.moment = self._in_base_component(base, self.moment, "moment", params) + self.power = self._in_base_component(base, self.power, "power") + self.force = self._in_base_component(base, self.force, "force") + self.moment = self._in_base_component(base, self.moment, "moment") class OptionallyDownloadableResultCSVModel(ResultCSVModel): @@ -636,7 +630,7 @@ class ActuatorDiskResultCSVModel(OptionallyDownloadableResultCSVModel): remote_file_name: str = pd.Field(CaseDownloadable.ACTUATOR_DISKS.value, frozen=True) _err_msg = "Case does not have any actuator disks." - def to_base(self, base: str, params: Flow360Params = None): + def to_base(self, base: str, params: Flow360Params | SimulationParams | None = None): """ Convert the results to the specified base system. @@ -644,7 +638,7 @@ def to_base(self, base: str, params: Flow360Params = None): ---------- base : str The base system to convert the results to. For example SI. - params : Flow360Params, optional + params : Flow360Params | SimulationParams, optional Case parameters for the conversion. """ @@ -653,14 +647,16 @@ def to_base(self, base: str, params: Flow360Params = None): disk_names = np.unique( [v.split("_")[0] for v in self.values.keys() if v.startswith("Disk")] ) - with Flow360UnitSystem(verbose=False): + with _ActuatorDiskResults._build_flow360_unit_system( # pylint:disable=protected-access + params + ): for disk_name in disk_names: disk = _ActuatorDiskResults( power=self.values[f"{disk_name}_Power"], force=self.values[f"{disk_name}_Force"], moment=self.values[f"{disk_name}_Moment"], ) - disk.to_base(base, params) + disk.to_base(base) self.values[f"{disk_name}_Power"] = disk.power self.values[f"{disk_name}_Force"] = disk.force self.values[f"{disk_name}_Moment"] = disk.moment @@ -733,17 +729,17 @@ class _BETDiskResults(_DimensionedCSVResultModel): Attributes ---------- - force_x : ForceType.Array + force_x : Force.Array Array of force values along the x-axis. - force_y : ForceType.Array + force_y : Force.Array Array of force values along the y-axis. - force_z : ForceType.Array + force_z : Force.Array Array of force values along the z-axis. - moment_x : MomentType.Array + moment_x : Moment.Array Array of moment values about the x-axis. - moment_y : MomentType.Array + moment_y : Moment.Array Array of moment values about the y-axis. - moment_z : MomentType.Array + moment_z : Moment.Array Array of moment values about the z-axis. _name : str Name of the BET forces result. @@ -754,16 +750,16 @@ class _BETDiskResults(_DimensionedCSVResultModel): Convert the results to the specified base system. """ - force_x: ForceType.Array = pd.Field() - force_y: ForceType.Array = pd.Field() - force_z: ForceType.Array = pd.Field() - moment_x: MomentType.Array = pd.Field() - moment_y: MomentType.Array = pd.Field() - moment_z: MomentType.Array = pd.Field() + force_x: Force.Array = pd.Field() + force_y: Force.Array = pd.Field() + force_z: Force.Array = pd.Field() + moment_x: Moment.Array = pd.Field() + moment_y: Moment.Array = pd.Field() + moment_z: Moment.Array = pd.Field() _name = "bet_forces" - def to_base(self, base: str, params: Flow360Params): + def to_base(self, base: str): """ Convert the results to the specified base system. @@ -771,16 +767,14 @@ def to_base(self, base: str, params: Flow360Params): ---------- base : str The base system to convert the results to, for example SI. - params : Flow360Params - Case parameters for the conversion. """ - self.force_x = self._in_base_component(base, self.force_x, "force_x", params) - self.force_y = self._in_base_component(base, self.force_y, "force_y", params) - self.force_z = self._in_base_component(base, self.force_z, "force_z", params) - self.moment_x = self._in_base_component(base, self.moment_x, "moment_x", params) - self.moment_y = self._in_base_component(base, self.moment_y, "moment_y", params) - self.moment_z = self._in_base_component(base, self.moment_z, "moment_z", params) + self.force_x = self._in_base_component(base, self.force_x, "force_x") + self.force_y = self._in_base_component(base, self.force_y, "force_y") + self.force_z = self._in_base_component(base, self.force_z, "force_z") + self.moment_x = self._in_base_component(base, self.moment_x, "moment_x") + self.moment_y = self._in_base_component(base, self.moment_y, "moment_y") + self.moment_z = self._in_base_component(base, self.moment_z, "moment_z") class BETForcesResultCSVModel(OptionallyDownloadableResultCSVModel): @@ -798,7 +792,7 @@ class BETForcesResultCSVModel(OptionallyDownloadableResultCSVModel): remote_file_name: str = pd.Field(CaseDownloadable.BET_FORCES.value, frozen=True) _err_msg = "Case does not have any BET disks." - def to_base(self, base: str, params: Flow360Params = None): + def to_base(self, base: str, params: Flow360Params | SimulationParams | None = None): """ Convert the results to the specified base system. @@ -815,7 +809,7 @@ def to_base(self, base: str, params: Flow360Params = None): disk_names = np.unique( [v.split("_")[0] for v in self.values.keys() if v.startswith("Disk")] ) - with Flow360UnitSystem(verbose=False): + with _BETDiskResults._build_flow360_unit_system(params): # pylint:disable=protected-access for disk_name in disk_names: bet = _BETDiskResults( force_x=self.values[f"{disk_name}_Force_x"], @@ -825,7 +819,7 @@ def to_base(self, base: str, params: Flow360Params = None): moment_y=self.values[f"{disk_name}_Moment_y"], moment_z=self.values[f"{disk_name}_Moment_z"], ) - bet.to_base(base, params) + bet.to_base(base) self.values[f"{disk_name}_Force_x"] = bet.force_x self.values[f"{disk_name}_Force_y"] = bet.force_y diff --git a/flow360/component/simulation/conversion.py b/flow360/component/simulation/conversion.py index 21a2c6797..157826cd5 100644 --- a/flow360/component/simulation/conversion.py +++ b/flow360/component/simulation/conversion.py @@ -6,19 +6,83 @@ import operator from functools import reduce -from typing import List -from flow360.component.simulation.unit_system import ( - flow360_conversion_unit_system, - is_flow360_unit, - u, -) +import unyt as u from ...exceptions import Flow360ConfigurationError LIQUID_IMAGINARY_FREESTREAM_MACH = 0.05 +class RestrictedUnitSystem(u.UnitSystem): + """UnitSystem that blocks conversions for unsupported base dimensions. + + Automatically derives supported dimensions from which unit arguments are + provided. Missing base units get placeholder values internally but are + masked so that conversion attempts raise ValueError. + + Examples:: + + # Meshing mode: only length defined, velocity/mass/temperature blocked + RestrictedUnitSystem("nondim", length_unit=0.5 * u.m) + + # Full mode: all units provided, no restrictions + RestrictedUnitSystem("nondim", length_unit=..., mass_unit=..., + time_unit=..., temperature_unit=...) + """ + + def __init__( # pylint: disable=too-many-arguments + self, + name, + length_unit, + mass_unit=None, + time_unit=None, + temperature_unit=None, + **kwargs, + ): + supported = {u.dimensions.length, u.dimensions.angle} + if mass_unit is not None: + supported.add(u.dimensions.mass) + if time_unit is not None: + supported.add(u.dimensions.time) + if temperature_unit is not None: + supported.add(u.dimensions.temperature) + + super().__init__( + name, + length_unit=length_unit, + mass_unit=mass_unit or 1 * u.kg, + time_unit=time_unit or 1 * u.s, + temperature_unit=temperature_unit or 1 * u.K, + **kwargs, + ) + + # All 5 dims provided (length, angle + mass, time, temperature) — no restrictions + if len(supported) == 5: + self._supported_dims = None + return + + # Mask unsupported base dimensions in units_map so that + # get_base_equivalent's fast path doesn't bypass our check + self._supported_dims = supported + for dim in list(self.units_map.keys()): + if not dim.free_symbols <= supported: + self.units_map[dim] = None + + def __getitem__(self, key): + if isinstance(key, str): + key = getattr(u.dimensions, key) + if self._supported_dims is not None: + unsupported = key.free_symbols - self._supported_dims + if unsupported: + names = ", ".join(str(s) for s in unsupported) + raise ValueError( + f"Cannot non-dimensionalize {key}: " + f"base units for {names} are not defined in this context." + ) + return super().__getitem__(key) + + def get_from_dict_by_key_list(key_list, data_dict): """ Get a value from a nested dictionary using a list of keys. @@ -51,12 +115,10 @@ def need_conversion(value): Returns ------- bool - True if conversion is needed, False otherwise. + True if conversion is needed (i.e. value carries physical units), False otherwise. """ - if hasattr(value, "units"): - return not is_flow360_unit(value) - return False + return hasattr(value, "units") def require(required_parameter, required_by, params): @@ -94,278 +156,6 @@ def require(required_parameter, required_by, params): dependency=required_parameter, ) from err - if hasattr(value, "units") and str(value.units).startswith("flow360"): - raise Flow360ConfigurationError( - f'{" -> ".join(required_parameter + ["units"])} must be in physical units ({required_msg}).', - field=required_by, - dependency=required_parameter + ["units"], - ) - - -# pylint: disable=too-many-locals, too-many-return-statements, too-many-statements, too-many-branches -def unit_converter(dimension, params, required_by: List[str] = None) -> u.UnitSystem: - """ - - Returns a flow360 conversion unit system for a given dimension. - - Parameters - ---------- - dimension : str - The dimension for which the conversion unit system is needed. e.g., length - length_unit : unyt_attribute - Externally provided mesh unit or geometry unit. - params : SimulationParams or dict - The parameters needed for unit conversion. - required_by : List[str], optional - List of keys specifying the path to the parameter that requires this unit conversion, by default []. - - Returns - ------- - flow360_conversion_unit_system - The conversion unit system for the specified dimension. This unit system allows for - .in_base(unit_system="flow360_v2") conversion. - - Raises - ------ - ValueError - The dimension is not recognized. - """ - - if required_by is None: - required_by = [] - - def get_base_length(): - require(["private_attribute_asset_cache", "project_length_unit"], required_by, params) - base_length = params.base_length.v.item() - return base_length - - def get_base_temperature(): - if params.operating_condition.type_name != "LiquidOperatingCondition": - # Temperature in Liquid condition has no effect because the thermal features will be disabled. - # Also the viscosity will be constant. - # pylint:disable = no-member - require(["operating_condition", "thermal_state", "temperature"], required_by, params) - base_temperature = params.base_temperature.v.item() - return base_temperature - - def get_base_velocity(): - if params.operating_condition.type_name != "LiquidOperatingCondition": - require(["operating_condition", "thermal_state", "temperature"], required_by, params) - base_velocity = params.base_velocity.v.item() - return base_velocity - - def get_base_time(): - base_time = params.base_time.v.item() - return base_time - - def get_base_mass(): - base_mass = params.base_mass.v.item() - return base_mass - - def get_base_angular_velocity(): - base_time = get_base_time() - base_angular_velocity = 1 / base_time - - return base_angular_velocity - - def get_base_density(): - if params.operating_condition.type_name != "LiquidOperatingCondition": - require(["operating_condition", "thermal_state", "density"], required_by, params) - base_density = params.base_density.v.item() - return base_density - - def get_base_viscosity(): - base_density = get_base_density() - base_length = get_base_length() - base_velocity = get_base_velocity() - base_viscosity = base_density * base_velocity * base_length - - return base_viscosity - - def get_base_kinematic_viscosity(): - base_length = get_base_length() - base_time = get_base_time() - base_kinematic_viscosity = base_length * base_length / base_time - - return base_kinematic_viscosity - - def get_base_force(): - base_length = get_base_length() - base_density = get_base_density() - base_velocity = get_base_velocity() - base_force = base_velocity**2 * base_density * base_length**2 - - return base_force - - def get_base_moment(): - base_length = get_base_length() - base_force = get_base_force() - base_moment = base_force * base_length - - return base_moment - - def get_base_power(): - base_length = get_base_length() - base_density = get_base_density() - base_velocity = get_base_velocity() - base_power = base_velocity**3 * base_density * base_length**2 - - return base_power - - def get_base_heat_flux(): - base_density = get_base_density() - base_velocity = get_base_velocity() - base_heat_flux = base_density * base_velocity**3 - - return base_heat_flux - - def get_base_heat_source(): - base_density = get_base_density() - base_velocity = get_base_velocity() - base_length = get_base_length() - - base_heat_source = base_density * base_velocity**3 / base_length - - return base_heat_source - - def get_base_specific_heat_capacity(): - base_velocity = get_base_velocity() - base_temperature = get_base_temperature() - - base_specific_heat_capacity = base_velocity**2 / base_temperature - - return base_specific_heat_capacity - - def get_base_thermal_conductivity(): - base_density = get_base_density() - base_velocity = get_base_velocity() - base_temperature = get_base_temperature() - base_length = get_base_length() - - base_thermal_conductivity = base_density * base_velocity**3 * base_length / base_temperature - - return base_thermal_conductivity - - if dimension == u.dimensions.length: - base_length = get_base_length() - flow360_conversion_unit_system.base_length = base_length - - elif dimension == u.dimensions.mass: - base_mass = get_base_mass() - flow360_conversion_unit_system.base_mass = base_mass - - elif dimension == u.dimensions.temperature: - base_temperature = get_base_temperature() - flow360_conversion_unit_system.base_temperature = base_temperature - # Flow360 uses absolute temperature for scaling. - # So the base_delta_temperature and base_temperature can have same scaling. - flow360_conversion_unit_system.base_delta_temperature = base_temperature - - elif dimension == u.dimensions.area: - base_length = get_base_length() - flow360_conversion_unit_system.base_area = base_length**2 - - elif dimension == u.dimensions.velocity: - base_velocity = get_base_velocity() - flow360_conversion_unit_system.base_velocity = base_velocity - - elif dimension == u.dimensions.acceleration: - base_velocity = get_base_velocity() - base_length = get_base_length() - flow360_conversion_unit_system.base_acceleration = base_velocity**2 / base_length - - elif dimension == u.dimensions.time: - base_time = get_base_time() - flow360_conversion_unit_system.base_time = base_time - - elif dimension == u.dimensions.angular_velocity: - base_angular_velocity = get_base_angular_velocity() - flow360_conversion_unit_system.base_angular_velocity = base_angular_velocity - - elif dimension == u.dimensions.density: - base_density = get_base_density() - flow360_conversion_unit_system.base_density = base_density - - elif dimension == u.dimensions.viscosity: - base_viscosity = get_base_viscosity() - flow360_conversion_unit_system.base_viscosity = base_viscosity - - elif dimension == u.dimensions.kinematic_viscosity: - base_kinematic_viscosity = get_base_kinematic_viscosity() - flow360_conversion_unit_system.base_kinematic_viscosity = base_kinematic_viscosity - - elif dimension == u.dimensions.force: - base_force = get_base_force() - flow360_conversion_unit_system.base_force = base_force - - elif dimension == u.dimensions.moment: - base_moment = get_base_moment() - flow360_conversion_unit_system.base_moment = base_moment - - elif dimension == u.dimensions.power: - base_power = get_base_power() - flow360_conversion_unit_system.base_power = base_power - - elif dimension == u.dimensions.heat_flux: - base_heat_flux = get_base_heat_flux() - flow360_conversion_unit_system.base_heat_flux = base_heat_flux - - elif dimension == u.dimensions.specific_heat_capacity: - base_specific_heat_capacity = get_base_specific_heat_capacity() - flow360_conversion_unit_system.base_specific_heat_capacity = base_specific_heat_capacity - - elif dimension == u.dimensions.thermal_conductivity: - base_thermal_conductivity = get_base_thermal_conductivity() - flow360_conversion_unit_system.base_thermal_conductivity = base_thermal_conductivity - - elif dimension == u.dimensions.inverse_area: - base_length = get_base_length() - flow360_conversion_unit_system.base_inverse_area = 1 / base_length**2 - - elif dimension == u.dimensions.inverse_length: - base_length = get_base_length() - flow360_conversion_unit_system.base_inverse_length = 1 / base_length - - elif dimension == u.dimensions.heat_source: - base_heat_source = get_base_heat_source() - flow360_conversion_unit_system.base_heat_source = base_heat_source - - elif dimension == u.dimensions.mass_flow_rate: - base_density = get_base_density() - base_length = get_base_length() - base_time = get_base_time() - - flow360_conversion_unit_system.base_mass_flow_rate = ( - base_density * base_length**3 / base_time - ) - - elif dimension == u.dimensions.specific_energy: - base_velocity = get_base_velocity() - - flow360_conversion_unit_system.base_specific_energy = base_velocity**2 - - elif dimension == u.dimensions.frequency: - base_time = get_base_time() - - flow360_conversion_unit_system.base_frequency = base_time ** (-1) - - elif dimension == u.dimensions.angle: - - # pylint: disable=no-member - flow360_conversion_unit_system.base_angle = 1 - - elif dimension == u.dimensions.pressure: - base_force = get_base_force() - base_length = get_base_length() - flow360_conversion_unit_system.base_pressure = base_force / (base_length**2) - - else: - raise ValueError( - f"Unit converter: not recognized dimension: {dimension}. Conversion for this dimension is not implemented." - ) - - return flow360_conversion_unit_system.conversion_system - def get_flow360_unit_system_liquid(params, to_flow360_unit: bool = False) -> u.UnitSystem: """ diff --git a/flow360/component/simulation/draft_context/mirror.py b/flow360/component/simulation/draft_context/mirror.py index 80028df9d..02dd440c2 100644 --- a/flow360/component/simulation/draft_context/mirror.py +++ b/flow360/component/simulation/draft_context/mirror.py @@ -4,6 +4,7 @@ import numpy as np import pydantic as pd +from flow360_schema.framework.physical_dimensions import Length from flow360.component.simulation.entity_operation import ( _transform_direction, @@ -22,7 +23,6 @@ MirroredSurface, Surface, ) -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.utils import is_exact_instance from flow360.component.types import Axis from flow360.exceptions import Flow360RuntimeError @@ -59,7 +59,7 @@ class MirrorPlane(EntityBase): name: str = pd.Field() normal: Axis = pd.Field(description="Normal direction of the plane.") # pylint: disable=no-member - center: LengthType.Point = pd.Field(description="Center point of the plane.") + center: Length.Vector3 = pd.Field(description="Center point of the plane.") private_attribute_entity_type_name: Literal["MirrorPlane"] = pd.Field( "MirrorPlane", frozen=True diff --git a/flow360/component/simulation/entity_info.py b/flow360/component/simulation/entity_info.py index f521ee39d..80ffaed93 100644 --- a/flow360/component/simulation/entity_info.py +++ b/flow360/component/simulation/entity_info.py @@ -1,10 +1,13 @@ """Deserializer for entity info retrieved from asset metadata pipeline.""" +# pylint: disable=no-member + from abc import ABCMeta, abstractmethod from collections import defaultdict from typing import Annotated, Any, Dict, List, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import Length from flow360_schema.framework.validation.context import DeserializationContext from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -33,7 +36,6 @@ Surface, WindTunnelGhostSurface, ) -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.utils import BoundingBoxType, model_attribute_unlock from flow360.component.utils import GeometryFiles from flow360.exceptions import Flow360ValueError @@ -173,8 +175,7 @@ class GeometryEntityInfo(EntityInfoModel): global_bounding_box: Optional[BoundingBoxType] = pd.Field(None) - # pylint: disable=no-member - default_geometry_accuracy: Optional[LengthType.Positive] = pd.Field( + default_geometry_accuracy: Optional[Length.PositiveFloat64] = pd.Field( None, description="The default value based on uploaded geometry for geometry_accuracy.", ) @@ -909,7 +910,7 @@ def apply_user_settings_to_entity( # Create a copy with updated user settings entity_data = entity.model_dump() entity_data.update(user_settings_map[attr_name][entity_id]) - return entity.__class__.model_validate(entity_data) + return entity.__class__.deserialize(entity_data) return entity diff --git a/flow360/component/simulation/entity_operation.py b/flow360/component/simulation/entity_operation.py index 41a6666cc..0531c024f 100644 --- a/flow360/component/simulation/entity_operation.py +++ b/flow360/component/simulation/entity_operation.py @@ -4,12 +4,12 @@ import numpy as np import pydantic as pd +from flow360_schema.framework.physical_dimensions import Angle, Length from pydantic import PositiveFloat import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_utils import generate_uuid -from flow360.component.simulation.unit_system import AngleType, LengthType from flow360.component.types import Axis from flow360.exceptions import Flow360ValueError @@ -34,11 +34,11 @@ def rotation_matrix_from_axis_and_angle(axis, angle): def _build_transformation_matrix( *, - origin: LengthType.Point, + origin: Length.Vector3, axis_of_rotation: Axis, - angle_of_rotation: AngleType, + angle_of_rotation: Angle.Float64, scale: Tuple[PositiveFloat, PositiveFloat, PositiveFloat], - translation: LengthType.Point, + translation: Length.Vector3, ) -> np.ndarray: """ Derive a 3(row) x 4(column) transformation matrix and store as row major. @@ -63,11 +63,11 @@ def _build_transformation_matrix( def _resolve_transformation_matrix( # pylint:disable=too-many-arguments *, # pylint: disable=no-member - origin: LengthType.Point, + origin: Length.Vector3, axis_of_rotation: Axis, - angle_of_rotation: AngleType, + angle_of_rotation: Angle.Float64, scale: Tuple[PositiveFloat, PositiveFloat, PositiveFloat], - translation: LengthType.Point, + translation: Length.Vector3, private_attribute_matrix: Optional[list[float]] = None, ) -> np.ndarray: """ @@ -281,18 +281,18 @@ class Transformation(Flow360BaseModel): type_name: Literal["BodyGroupTransformation"] = pd.Field("BodyGroupTransformation", frozen=True) - origin: LengthType.Point = pd.Field( # pylint:disable=no-member + origin: Length.Vector3 = pd.Field( # pylint:disable=no-member (0, 0, 0) * u.m, # pylint:disable=no-member description="The origin for geometry transformation in the order of scale," " rotation and translation.", ) axis_of_rotation: Axis = pd.Field((1, 0, 0)) - angle_of_rotation: AngleType = pd.Field(0 * u.deg) # pylint:disable=no-member + angle_of_rotation: Angle.Float64 = pd.Field(0 * u.deg) # pylint:disable=no-member scale: Tuple[PositiveFloat, PositiveFloat, PositiveFloat] = pd.Field((1, 1, 1)) - translation: LengthType.Point = pd.Field((0, 0, 0) * u.m) # pylint:disable=no-member + translation: Length.Vector3 = pd.Field((0, 0, 0) * u.m) # pylint:disable=no-member private_attribute_matrix: Optional[list[float]] = pd.Field(None) @@ -348,18 +348,18 @@ class CoordinateSystem(Flow360BaseModel): type_name: Literal["CoordinateSystem"] = pd.Field("CoordinateSystem", frozen=True) name: str = pd.Field(description="Name of the coordinate system.") - reference_point: LengthType.Point = pd.Field( # pylint:disable=no-member + reference_point: Length.Vector3 = pd.Field( # pylint:disable=no-member (0, 0, 0) * u.m, # pylint:disable=no-member description="Reference point about which scaling and rotation are performed. " "Translation is applied after scale and rotation.", ) axis_of_rotation: Axis = pd.Field((1, 0, 0)) - angle_of_rotation: AngleType = pd.Field(0 * u.deg) # pylint:disable=no-member + angle_of_rotation: Angle.Float64 = pd.Field(0 * u.deg) # pylint:disable=no-member scale: Tuple[PositiveFloat, PositiveFloat, PositiveFloat] = pd.Field((1, 1, 1)) - translation: LengthType.Point = pd.Field((0, 0, 0) * u.m) # pylint:disable=no-member + translation: Length.Vector3 = pd.Field((0, 0, 0) * u.m) # pylint:disable=no-member private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) def _get_local_matrix(self) -> np.ndarray: diff --git a/flow360/component/simulation/framework/base_model.py b/flow360/component/simulation/framework/base_model.py index 4986f36c3..7724e5077 100644 --- a/flow360/component/simulation/framework/base_model.py +++ b/flow360/component/simulation/framework/base_model.py @@ -123,8 +123,7 @@ def from_file(cls, filename: str) -> Flow360BaseModel: >>> params = Flow360BaseModel.from_file(filename='folder/sim.json') # doctest: +SKIP """ model_dict = cls._handle_file(filename=filename) - with DeserializationContext(): - return cls.model_validate(model_dict) + return cls.deserialize(model_dict) @classmethod def _dict_from_file(cls, filename: str) -> dict: diff --git a/flow360/component/simulation/framework/entity_expansion_utils.py b/flow360/component/simulation/framework/entity_expansion_utils.py index 51a62833c..0bf7590fa 100644 --- a/flow360/component/simulation/framework/entity_expansion_utils.py +++ b/flow360/component/simulation/framework/entity_expansion_utils.py @@ -35,7 +35,7 @@ def _register_mirror_entities_in_registry(registry: "EntityRegistry", mirror_sta # Dict path: deserialize to MirrorStatus if isinstance(mirror_status, dict): - mirror_status = MirrorStatus.model_validate(mirror_status) + mirror_status = MirrorStatus.deserialize(mirror_status) # Object path: MirrorStatus (or compatible) with is_empty() if hasattr(mirror_status, "is_empty") and mirror_status.is_empty(): @@ -109,10 +109,8 @@ def expand_entity_list_in_context( # This ensures consistency with the centralized filtering architecture if stored_entities: try: - # Use model_validate to trigger field validator which filters by type - validated_list = entity_list.__class__.model_validate( - {"stored_entities": stored_entities} - ) + # Use deserialize to trigger field validator which filters by type + validated_list = entity_list.__class__.deserialize({"stored_entities": stored_entities}) stored_entities = validated_list.stored_entities except pd.ValidationError as exc: raise Flow360ValueError( diff --git a/flow360/component/simulation/framework/entity_materializer.py b/flow360/component/simulation/framework/entity_materializer.py index cb0968623..b49c5e66e 100644 --- a/flow360/component/simulation/framework/entity_materializer.py +++ b/flow360/component/simulation/framework/entity_materializer.py @@ -309,7 +309,7 @@ def _materialize_selectors_list_in_node( # At local validation, `selector_lookup` is empty. # Since it is presubmit, no need to "materialize", "deserialize" is fine. try: - materialized_selectors.append(EntitySelector.model_validate(selector_item)) + materialized_selectors.append(EntitySelector.deserialize(selector_item)) except pd.ValidationError: # Keep the invalid dict as-is, let SimulationParams.model_validate handle the error. # This preserves the full error location path (e.g., "models.0.entities.selectors.0.children...") diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index 3944e50dd..f2035822c 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -1,8 +1,11 @@ """pre processing and post processing utilities for simulation parameters.""" +# pylint: disable=no-member + from typing import Annotated, List, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import Length from flow360.component.simulation.draft_context.coordinate_system_manager import ( CoordinateSystemStatus, @@ -23,7 +26,6 @@ _SurfaceEntityBase, _VolumeEntityBase, ) -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.user_code.core.types import ( VariableContextInfo, update_global_context, @@ -41,8 +43,7 @@ class AssetCache(Flow360BaseModel): Cached info from the project asset. """ - # pylint: disable=no-member - project_length_unit: Optional[LengthType.Positive] = pd.Field(None, frozen=True) + project_length_unit: Optional[Length.PositiveFloat64] = pd.Field(None, frozen=True) project_entity_info: Optional[ Union[GeometryEntityInfo, VolumeMeshEntityInfo, SurfaceMeshEntityInfo] ] = pd.Field(None, frozen=True, discriminator="type_name") diff --git a/flow360/component/simulation/meshing_param/edge_params.py b/flow360/component/simulation/meshing_param/edge_params.py index f1161ba6b..2ef0918df 100644 --- a/flow360/component/simulation/meshing_param/edge_params.py +++ b/flow360/component/simulation/meshing_param/edge_params.py @@ -3,11 +3,11 @@ from typing import Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import Angle, Length from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_base import EntityList from flow360.component.simulation.primitives import Edge -from flow360.component.simulation.unit_system import AngleType, LengthType from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, contextual_model_validator, @@ -27,7 +27,7 @@ class AngleBasedRefinement(Flow360BaseModel): """ type: Literal["angle"] = pd.Field("angle", frozen=True) - value: AngleType = pd.Field() + value: Angle.Float64 = pd.Field() class HeightBasedRefinement(Flow360BaseModel): @@ -44,7 +44,7 @@ class HeightBasedRefinement(Flow360BaseModel): type: Literal["height"] = pd.Field("height", frozen=True) # pylint: disable=no-member - value: LengthType.Positive = pd.Field() + value: Length.PositiveFloat64 = pd.Field() class AspectRatioBasedRefinement(Flow360BaseModel): diff --git a/flow360/component/simulation/meshing_param/face_params.py b/flow360/component/simulation/meshing_param/face_params.py index 6f7c77720..2197673e2 100644 --- a/flow360/component/simulation/meshing_param/face_params.py +++ b/flow360/component/simulation/meshing_param/face_params.py @@ -3,6 +3,7 @@ from typing import Literal, Optional import pydantic as pd +from flow360_schema.framework.physical_dimensions import Angle, Length from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_base import EntityList @@ -13,7 +14,6 @@ Surface, WindTunnelGhostSurface, ) -from flow360.component.simulation.unit_system import AngleType, LengthType from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, contextual_field_validator, @@ -47,11 +47,11 @@ class SurfaceRefinement(Flow360BaseModel): Surface, MirroredSurface, WindTunnelGhostSurface, GhostSurface, GhostCircularPlane ] = pd.Field(alias="faces") # pylint: disable=no-member - max_edge_length: Optional[LengthType.Positive] = pd.Field( + max_edge_length: Optional[Length.PositiveFloat64] = pd.Field( None, description="Maximum edge length of surface cells." ) - curvature_resolution_angle: Optional[AngleType.Positive] = pd.Field( + curvature_resolution_angle: Optional[Angle.PositiveFloat64] = pd.Field( None, description=( "Default maximum angular deviation in degrees. " @@ -129,7 +129,7 @@ class GeometryRefinement(Flow360BaseModel): entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field(alias="faces") # pylint: disable=no-member - geometry_accuracy: Optional[LengthType.Positive] = pd.Field( + geometry_accuracy: Optional[Length.PositiveFloat64] = pd.Field( None, description="The smallest length scale that will be resolved accurately by the surface meshing process. ", ) @@ -140,12 +140,12 @@ class GeometryRefinement(Flow360BaseModel): + "to geometry_accuracy should be resolved accurately during the surface meshing process.", ) - sealing_size: Optional[LengthType.NonNegative] = pd.Field( + sealing_size: Optional[Length.NonNegativeFloat64] = pd.Field( None, description="Threshold size below which all geometry gaps are automatically closed.", ) - min_passage_size: Optional[LengthType.Positive] = pd.Field( + min_passage_size: Optional[Length.PositiveFloat64] = pd.Field( None, description="Minimum passage size that hidden geometry removal can resolve for this face group. " "Internal regions connected by thin passages smaller than this size may not be detected. " @@ -225,7 +225,7 @@ class BoundaryLayer(Flow360BaseModel): refinement_type: Literal["BoundaryLayer"] = pd.Field("BoundaryLayer", frozen=True) entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field(alias="faces") # pylint: disable=no-member - first_layer_thickness: Optional[LengthType.Positive] = pd.Field( + first_layer_thickness: Optional[Length.PositiveFloat64] = pd.Field( None, description="First layer thickness for volumetric anisotropic layers grown from given `Surface` (s).", ) diff --git a/flow360/component/simulation/meshing_param/meshing_specs.py b/flow360/component/simulation/meshing_param/meshing_specs.py index 8af927731..dba666111 100644 --- a/flow360/component/simulation/meshing_param/meshing_specs.py +++ b/flow360/component/simulation/meshing_param/meshing_specs.py @@ -5,6 +5,7 @@ import numpy as np import pydantic as pd +from flow360_schema.framework.physical_dimensions import Angle, Length import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -12,7 +13,6 @@ DEFAULT_PLANAR_FACE_TOLERANCE, DEFAULT_SLIDING_INTERFACE_TOLERANCE, ) -from flow360.component.simulation.unit_system import AngleType, LengthType from flow360.component.simulation.validation.validation_context import ( SURFACE_MESH, VOLUME_MESH, @@ -34,7 +34,7 @@ class OctreeSpacing(Flow360BaseModel): """ # pylint: disable=no-member - base_spacing: LengthType.Positive + base_spacing: Length.PositiveFloat64 @pd.model_validator(mode="before") @classmethod @@ -52,7 +52,7 @@ def __getitem__(self, idx: int): # pylint: disable=no-member @pd.validate_call - def to_level(self, spacing: LengthType.Positive): + def to_level(self, spacing: Length.PositiveFloat64): """ Can be used to check in what refinement level would the given spacing result and if it is a direct match in the spacing series. @@ -65,7 +65,7 @@ def to_level(self, spacing: LengthType.Positive): # pylint: disable=no-member @pd.validate_call - def check_spacing(self, spacing: LengthType.Positive, location: str): + def check_spacing(self, spacing: Length.PositiveFloat64, location: str): """Warn if the given spacing does not align with the octree series.""" lvl, close = self.to_level(spacing) if not close: @@ -91,7 +91,7 @@ def set_default_octree_spacing(octree_spacing, param_info: ParamsValidationInfo) return octree_spacing # pylint: disable=no-member - project_length = 1 * LengthType.validate(param_info.project_length_unit) + project_length = 1 * param_info.project_length_unit return OctreeSpacing(base_spacing=project_length) @@ -114,7 +114,7 @@ class MeshingDefaults(Flow360BaseModel): """ # pylint: disable=no-member - geometry_accuracy: Optional[LengthType.Positive] = pd.Field( + geometry_accuracy: Optional[Length.PositiveFloat64] = pd.Field( None, description="The smallest length scale that will be resolved accurately by the surface meshing process. " "This parameter is only valid when using geometry AI." @@ -138,7 +138,7 @@ class MeshingDefaults(Flow360BaseModel): context=VOLUME_MESH, ) # pylint: disable=no-member - boundary_layer_first_layer_thickness: Optional[LengthType.Positive] = ConditionalField( + boundary_layer_first_layer_thickness: Optional[Length.PositiveFloat64] = ConditionalField( None, description="Default first layer thickness for volumetric anisotropic layers." " This can be overridden with :class:`~flow360.BoundaryLayer`.", @@ -174,7 +174,7 @@ class MeshingDefaults(Flow360BaseModel): ) ##:: Default surface layer settings - surface_max_edge_length: Optional[LengthType.Positive] = ConditionalField( + surface_max_edge_length: Optional[Length.PositiveFloat64] = ConditionalField( None, description="Default maximum edge length for surface cells." " This can be overridden with :class:`~flow360.SurfaceRefinement`.", @@ -202,7 +202,7 @@ class MeshingDefaults(Flow360BaseModel): context=SURFACE_MESH, ) - curvature_resolution_angle: AngleType.Positive = ContextField( + curvature_resolution_angle: Angle.PositiveFloat64 = ContextField( 12 * u.deg, description=( "Default maximum angular deviation in degrees. This value will restrict:" @@ -229,7 +229,7 @@ class MeshingDefaults(Flow360BaseModel): + "per face with :class:`~flow360.GeometryRefinement`.", ) - sealing_size: LengthType.NonNegative = pd.Field( + sealing_size: Length.NonNegativeFloat64 = pd.Field( 0.0 * u.m, description="Threshold size below which all geometry gaps are automatically closed. " + "This option is only supported when using geometry AI, and can be overridden " @@ -242,7 +242,7 @@ class MeshingDefaults(Flow360BaseModel): + "This option is only supported when using geometry AI.", ) - min_passage_size: Optional[LengthType.Positive] = pd.Field( + min_passage_size: Optional[Length.PositiveFloat64] = pd.Field( None, description="Minimum passage size that hidden geometry removal can resolve. " + "Internal regions connected by thin passages smaller than this size may not be detected. " @@ -378,7 +378,7 @@ class VolumeMeshingDefaults(Flow360BaseModel): ge=1, ) # pylint: disable=no-member - boundary_layer_first_layer_thickness: LengthType.Positive = pd.Field( + boundary_layer_first_layer_thickness: Length.PositiveFloat64 = pd.Field( description="Default first layer thickness for volumetric anisotropic layers." " This can be overridden with :class:`~flow360.BoundaryLayer`.", ) diff --git a/flow360/component/simulation/meshing_param/snappy/snappy_mesh_refinements.py b/flow360/component/simulation/meshing_param/snappy/snappy_mesh_refinements.py index 0beed13d3..fa4c53310 100644 --- a/flow360/component/simulation/meshing_param/snappy/snappy_mesh_refinements.py +++ b/flow360/component/simulation/meshing_param/snappy/snappy_mesh_refinements.py @@ -4,6 +4,7 @@ from typing import Annotated, List, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import Angle, Length from typing_extensions import Self import flow360.component.simulation.units as u @@ -11,7 +12,6 @@ from flow360.component.simulation.framework.entity_base import EntityList from flow360.component.simulation.meshing_param.volume_params import UniformRefinement from flow360.component.simulation.primitives import SnappyBody, Surface -from flow360.component.simulation.unit_system import AngleType, LengthType from flow360.log import log @@ -21,9 +21,9 @@ class SnappyEntityRefinement(Flow360BaseModel, metaclass=ABCMeta): """ # pylint: disable=no-member - min_spacing: Optional[LengthType.Positive] = pd.Field(None) - max_spacing: Optional[LengthType.Positive] = pd.Field(None) - proximity_spacing: Optional[LengthType.Positive] = pd.Field(None) + min_spacing: Optional[Length.PositiveFloat64] = pd.Field(None) + max_spacing: Optional[Length.PositiveFloat64] = pd.Field(None) + proximity_spacing: Optional[Length.PositiveFloat64] = pd.Field(None) @pd.model_validator(mode="after") def _check_spacing_order(self) -> Self: @@ -51,7 +51,7 @@ class BodyRefinement(SnappyEntityRefinement): # pylint: disable=no-member refinement_type: Literal["SnappyBodyRefinement"] = pd.Field("SnappyBodyRefinement", frozen=True) - gap_resolution: Optional[LengthType.NonNegative] = pd.Field(None) + gap_resolution: Optional[Length.NonNegativeFloat64] = pd.Field(None) entities: EntityList[SnappyBody] = pd.Field(alias="bodies") @pd.model_validator(mode="after") @@ -76,8 +76,8 @@ class RegionRefinement(SnappyEntityRefinement): """ # pylint: disable=no-member - min_spacing: LengthType.Positive = pd.Field() - max_spacing: LengthType.Positive = pd.Field() + min_spacing: Length.PositiveFloat64 = pd.Field() + max_spacing: Length.PositiveFloat64 = pd.Field() refinement_type: Literal["SnappySurfaceRefinement"] = pd.Field( "SnappySurfaceRefinement", frozen=True ) @@ -93,19 +93,19 @@ class SurfaceEdgeRefinement(Flow360BaseModel): refinement_type: Literal["SnappySurfaceEdgeRefinement"] = pd.Field( "SnappySurfaceEdgeRefinement", frozen=True ) - spacing: Optional[Union[LengthType.PositiveArray, LengthType.Positive]] = pd.Field( + spacing: Optional[Union[Length.PositiveArray, Length.PositiveFloat64]] = pd.Field( None, description="Spacing on and close to the edges. Defaults to default min_spacing." ) - distances: Optional[LengthType.PositiveArray] = pd.Field( + distances: Optional[Length.PositiveArray] = pd.Field( None, description="Distance from the edge where the spacing will be applied." ) min_elem: Optional[pd.NonNegativeInt] = pd.Field( None, description="Minimum number of elements on the edge to apply the edge refinement." ) - min_len: Optional[LengthType.NonNegative] = pd.Field( + min_len: Optional[Length.NonNegativeFloat64] = pd.Field( None, description="Minimum length of the edge to apply edge refinement." ) - included_angle: AngleType.Positive = pd.Field( + included_angle: Angle.PositiveFloat64 = pd.Field( 150 * u.deg, description="If the angle between two elements is less than this value, the edge is extracted as a feature.", ) @@ -156,7 +156,10 @@ def _check_spacings_increasing(cls, value): @pd.field_validator("spacing", "distances", mode="before") @classmethod def _convert_list_to_unyt_array(cls, value): - if isinstance(value, List): + # Only coalesce lists of unyt quantities (e.g., [4*u.mm, 5*u.mm]) into a + # single unyt_array. Bare numeric lists (e.g., [0.004]) must NOT be wrapped + # so that the schema type validator can attach the correct SI unit. + if isinstance(value, List) and all(isinstance(v, u.unyt.unyt_quantity) for v in value): return u.unyt.unyt_array(value) return value diff --git a/flow360/component/simulation/meshing_param/snappy/snappy_params.py b/flow360/component/simulation/meshing_param/snappy/snappy_params.py index 35b0b710e..037991475 100644 --- a/flow360/component/simulation/meshing_param/snappy/snappy_params.py +++ b/flow360/component/simulation/meshing_param/snappy/snappy_params.py @@ -24,7 +24,6 @@ SurfaceMeshingDefaults, ) from flow360.component.simulation.meshing_param.volume_params import UniformRefinement -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, contextual_field_validator, @@ -177,5 +176,5 @@ def _set_default_octree_spacing(cls, octree_spacing, param_info: ParamsValidatio return octree_spacing # pylint: disable=no-member - project_length = 1 * LengthType.validate(param_info.project_length_unit) + project_length = 1 * param_info.project_length_unit return OctreeSpacing(base_spacing=project_length) diff --git a/flow360/component/simulation/meshing_param/snappy/snappy_specs.py b/flow360/component/simulation/meshing_param/snappy/snappy_specs.py index 714a46942..80bb8a74f 100644 --- a/flow360/component/simulation/meshing_param/snappy/snappy_specs.py +++ b/flow360/component/simulation/meshing_param/snappy/snappy_specs.py @@ -3,11 +3,11 @@ from typing import Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import Angle, Area, Length from typing_extensions import Self import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.unit_system import AngleType, AreaType, LengthType class SurfaceMeshingDefaults(Flow360BaseModel): @@ -17,9 +17,9 @@ class SurfaceMeshingDefaults(Flow360BaseModel): """ # pylint: disable=no-member - min_spacing: LengthType.Positive = pd.Field() - max_spacing: LengthType.Positive = pd.Field() - gap_resolution: LengthType.Positive = pd.Field() + min_spacing: Length.PositiveFloat64 = pd.Field() + max_spacing: Length.PositiveFloat64 = pd.Field() + gap_resolution: Length.PositiveFloat64 = pd.Field() @pd.model_validator(mode="after") def _check_spacing_order(self) -> Self: @@ -35,21 +35,21 @@ class QualityMetrics(Flow360BaseModel): """ # pylint: disable=no-member - max_non_orthogonality: Union[AngleType.Positive, Literal[False]] = pd.Field( + max_non_orthogonality: Union[Angle.PositiveFloat64, Literal[False]] = pd.Field( default=85 * u.deg, alias="max_non_ortho", description="Maximum face non-orthogonality angle: the angle made by the vector between the two adjacent " "cell centres across the common face and the face normal. Set to False to disable this metric.", ) - max_boundary_skewness: Union[AngleType.Positive, Literal[False]] = pd.Field( + max_boundary_skewness: Union[Angle.PositiveFloat64, Literal[False]] = pd.Field( default=20 * u.deg, description="Maximum boundary skewness. Set to False to disable this metric.", ) - max_internal_skewness: Union[AngleType.Positive, Literal[False]] = pd.Field( + max_internal_skewness: Union[Angle.PositiveFloat64, Literal[False]] = pd.Field( default=50 * u.deg, description="Maximum internal face skewness. Set to False to disable this metric.", ) - max_concavity: Union[AngleType.Positive, Literal[False]] = pd.Field( + max_concavity: Union[Angle.PositiveFloat64, Literal[False]] = pd.Field( default=50 * u.deg, alias="max_concave", description="Maximum cell concavity. Set to False to disable this metric.", @@ -66,7 +66,7 @@ class QualityMetrics(Flow360BaseModel): alias="min_tet_quality", description="Minimum tetrahedron quality. Set to False to disable this metric (uses -1e30 internally).", ) - min_face_area: Optional[Union[AreaType.Positive, Literal[False]]] = pd.Field( + min_face_area: Optional[Union[Area.PositiveFloat64, Literal[False]]] = pd.Field( default=None, alias="min_area", description="Minimum face area. Set to False to disable. Defaults to 1e-12 of mesh unit.", @@ -145,7 +145,7 @@ class CastellatedMeshControls(Flow360BaseModel): """ # pylint: disable=no-member - resolve_feature_angle: AngleType.Positive = pd.Field( + resolve_feature_angle: Angle.PositiveFloat64 = pd.Field( default=25 * u.deg, description="This parameter controls the local curvature refinement. " "The higher the value, the less features it captures. " diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 607d9b834..b9680ddd3 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -7,6 +7,7 @@ from typing import Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import Length from typing_extensions import deprecated import flow360.component.simulation.units as u @@ -27,7 +28,6 @@ WindTunnelGhostSurface, compute_bbox_tolerance, ) -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, add_validation_warning, @@ -74,7 +74,7 @@ class UniformRefinement(Flow360BaseModel): + "and :class:`~flow360.Sphere` regions." ) # pylint: disable=no-member - spacing: LengthType.Positive = pd.Field(description="The required refinement spacing.") + spacing: Length.PositiveFloat64 = pd.Field(description="The required refinement spacing.") project_to_surface: Optional[bool] = pd.Field( None, description="Whether to include the refinement in the surface mesh. Defaults to True when using snappy.", @@ -165,13 +165,13 @@ class StructuredBoxRefinement(Flow360BaseModel): ) entities: EntityList[Box] = pd.Field() - spacing_axis1: LengthType.Positive = pd.Field( + spacing_axis1: Length.PositiveFloat64 = pd.Field( description="Spacing along the first axial direction." ) - spacing_axis2: LengthType.Positive = pd.Field( + spacing_axis2: Length.PositiveFloat64 = pd.Field( description="Spacing along the second axial direction." ) - spacing_normal: LengthType.Positive = pd.Field( + spacing_normal: Length.PositiveFloat64 = pd.Field( description="Spacing along the normal axial direction." ) @@ -214,11 +214,13 @@ class AxisymmetricRefinement(Flow360BaseModel): ) entities: EntityList[Cylinder] = pd.Field() # pylint: disable=no-member - spacing_axial: LengthType.Positive = pd.Field(description="Spacing along the axial direction.") - spacing_radial: LengthType.Positive = pd.Field( + spacing_axial: Length.PositiveFloat64 = pd.Field( + description="Spacing along the axial direction." + ) + spacing_radial: Length.PositiveFloat64 = pd.Field( description="Spacing along the radial direction." ) - spacing_circumferential: LengthType.Positive = pd.Field( + spacing_circumferential: Length.PositiveFloat64 = pd.Field( description="Spacing along the circumferential direction." ) @@ -438,11 +440,13 @@ class RotationVolume(_RotationVolumeBase): name: Optional[str] = pd.Field("Rotation Volume", description="Name to display in the GUI.") entities: EntityList[Cylinder, AxisymmetricBody] = pd.Field() # pylint: disable=no-member - spacing_axial: LengthType.Positive = pd.Field(description="Spacing along the axial direction.") - spacing_radial: LengthType.Positive = pd.Field( + spacing_axial: Length.PositiveFloat64 = pd.Field( + description="Spacing along the axial direction." + ) + spacing_radial: Length.PositiveFloat64 = pd.Field( description="Spacing along the radial direction." ) - spacing_circumferential: LengthType.Positive = pd.Field( + spacing_circumferential: Length.PositiveFloat64 = pd.Field( description="Spacing along the circumferential direction." ) @@ -486,7 +490,7 @@ class RotationSphere(_RotationVolumeBase): name: Optional[str] = pd.Field("Rotation Sphere", description="Name to display in the GUI.") entities: EntityList[Sphere] = pd.Field() # pylint: disable=no-member - spacing_circumferential: LengthType.Positive = pd.Field( + spacing_circumferential: Length.PositiveFloat64 = pd.Field( description="Uniform spacing on the spherical interface." ) @@ -817,10 +821,10 @@ class StaticFloor(Flow360BaseModel): type_name: Literal["StaticFloor"] = pd.Field( "StaticFloor", description="Static floor with friction patch.", frozen=True ) - friction_patch_x_range: LengthType.Range = pd.Field( + friction_patch_x_range: Length.StrictlyIncreasingVector2 = pd.Field( default=(-3, 6) * u.m, description="(Minimum, maximum) x of friction patch." ) - friction_patch_width: LengthType.Positive = pd.Field( + friction_patch_width: Length.PositiveFloat64 = pd.Field( default=2 * u.m, description="Width of friction patch." ) @@ -840,10 +844,10 @@ class CentralBelt(Flow360BaseModel): type_name: Literal["CentralBelt"] = pd.Field( "CentralBelt", description="Floor with central belt.", frozen=True ) - central_belt_x_range: LengthType.Range = pd.Field( + central_belt_x_range: Length.StrictlyIncreasingVector2 = pd.Field( default=(-2, 2) * u.m, description="(Minimum, maximum) x of central belt." ) - central_belt_width: LengthType.Positive = pd.Field( + central_belt_width: Length.PositiveFloat64 = pd.Field( default=1.2 * u.m, description="Width of central belt." ) @@ -857,16 +861,16 @@ class WheelBelts(CentralBelt): frozen=True, ) # No defaults for the below; user must specify - front_wheel_belt_x_range: LengthType.Range = pd.Field( + front_wheel_belt_x_range: Length.StrictlyIncreasingVector2 = pd.Field( description="(Minimum, maximum) x of front wheel belt." ) - front_wheel_belt_y_range: LengthType.PositiveRange = pd.Field( + front_wheel_belt_y_range: Length.PositiveStrictlyIncreasingVector2 = pd.Field( description="(Inner, outer) y of front wheel belt." ) - rear_wheel_belt_x_range: LengthType.Range = pd.Field( + rear_wheel_belt_x_range: Length.StrictlyIncreasingVector2 = pd.Field( description="(Minimum, maximum) x of rear wheel belt." ) - rear_wheel_belt_y_range: LengthType.PositiveRange = pd.Field( + rear_wheel_belt_y_range: Length.PositiveStrictlyIncreasingVector2 = pd.Field( description="(Inner, outer) y of rear wheel belt." ) @@ -932,17 +936,21 @@ class WindTunnelFarfield(_FarfieldAllowingEnclosedEntities): name: str = pd.Field("Wind Tunnel Farfield", description="Name of the wind tunnel farfield.") # Tunnel parameters - width: LengthType.Positive = pd.Field(default=10 * u.m, description="Width of the wind tunnel.") - height: LengthType.Positive = pd.Field( + width: Length.PositiveFloat64 = pd.Field( + default=10 * u.m, description="Width of the wind tunnel." + ) + height: Length.PositiveFloat64 = pd.Field( default=6 * u.m, description="Height of the wind tunnel." ) - inlet_x_position: LengthType = pd.Field( + inlet_x_position: Length.Float64 = pd.Field( default=-20 * u.m, description="X-position of the inlet." ) - outlet_x_position: LengthType = pd.Field( + outlet_x_position: Length.Float64 = pd.Field( default=40 * u.m, description="X-position of the outlet." ) - floor_z_position: LengthType = pd.Field(default=0 * u.m, description="Z-position of the floor.") + floor_z_position: Length.Float64 = pd.Field( + default=0 * u.m, description="Z-position of the floor." + ) floor_type: Union[ StaticFloor, @@ -1185,7 +1193,7 @@ class MeshSliceOutput(Flow360BaseModel): default=False, description="Generate crinkled slices in addition to flat slices.", ) - cutoff_radius: Optional[LengthType.Positive] = pd.Field( + cutoff_radius: Optional[Length.PositiveFloat64] = pd.Field( default=None, description="Cutoff radius of the slice output. If not specified, " "the slice extends to the boundaries of the volume mesh.", diff --git a/flow360/component/simulation/migration/BETDisk.py b/flow360/component/simulation/migration/BETDisk.py index 0753da313..4df56b119 100644 --- a/flow360/component/simulation/migration/BETDisk.py +++ b/flow360/component/simulation/migration/BETDisk.py @@ -6,13 +6,13 @@ import os from typing import Union +from flow360_schema.framework.physical_dimensions import AbsoluteTemperature, Length from numpy import sqrt from pydantic import validate_call import flow360.component.simulation.units as u from flow360.component.simulation.models.volume_models import BETDisk from flow360.component.simulation.primitives import Cylinder -from flow360.component.simulation.unit_system import AbsoluteTemperatureType, LengthType from flow360.log import log @@ -168,8 +168,8 @@ def _load_flow360_json(*, file_path: str) -> dict: @validate_call def read_single_v1_BETDisk( file_path: str, - mesh_unit: LengthType.NonNegative, # pylint: disable = no-member - freestream_temperature: AbsoluteTemperatureType, + mesh_unit: Length.NonNegativeFloat64, + freestream_temperature: AbsoluteTemperature.Float64, bet_disk_name: str = "Disk", ) -> BETDisk: """ @@ -179,9 +179,9 @@ def read_single_v1_BETDisk( ---------- file_path: str Path to Flow360 JSON file that contains a **single** BETDisk setting. - mesh_unit: LengthType.NonNegative - Length unit used for LengthType BETDisk parameters. - freestream_temperature: AbsoluteTemperatureType + mesh_unit: Length.NonNegativeFloat64 + Length unit used for BETDisk parameters. + freestream_temperature: AbsoluteTemperature.Float64 Freestream temperature. bet_disk_name: str The name for the BETDisk object. @@ -222,8 +222,8 @@ def read_single_v1_BETDisk( @validate_call def read_all_v1_BETDisks( file_path: str, - mesh_unit: LengthType.NonNegative, # pylint: disable = no-member - freestream_temperature: AbsoluteTemperatureType, + mesh_unit: Length.NonNegativeFloat64, + freestream_temperature: AbsoluteTemperature.Float64, bet_disk_name_prefix: str = "Disk", index_offest: int = 0, ) -> list[BETDisk]: @@ -234,9 +234,9 @@ def read_all_v1_BETDisks( ---------- file_path: str Path to the Flow360.json file. - mesh_unit: LengthType.NonNegative - Length unit used for LengthType BETDisk parameters. - freestream_temperature: AbsoluteTemperatureType + mesh_unit: Length.NonNegativeFloat64 + Length unit used for BETDisk parameters. + freestream_temperature: AbsoluteTemperature.Float64 Freestream temperature. bet_disk_name_prefix: str = "Disk", The prefix for the name of each BETDisk object. diff --git a/flow360/component/simulation/migration/extra_operating_condition.py b/flow360/component/simulation/migration/extra_operating_condition.py index d854cc1cc..aa49cc6b3 100644 --- a/flow360/component/simulation/migration/extra_operating_condition.py +++ b/flow360/component/simulation/migration/extra_operating_condition.py @@ -3,6 +3,11 @@ from typing import Optional import pydantic as pd +from flow360_schema.framework.physical_dimensions import ( + AbsoluteTemperature, + Angle, + Length, +) import flow360.component.simulation.units as u from flow360.component.simulation.operating_condition.operating_condition import ( @@ -10,11 +15,6 @@ Air, ThermalState, ) -from flow360.component.simulation.unit_system import ( - AbsoluteTemperatureType, - AngleType, - LengthType, -) from flow360.log import log @@ -23,12 +23,12 @@ def operating_condition_from_mach_muref( mach: pd.NonNegativeFloat, mu_ref: pd.PositiveFloat, - project_length_unit: LengthType.Positive = pd.Field( + project_length_unit: Length.PositiveFloat64 = pd.Field( description="The Length unit of the project." ), - temperature: AbsoluteTemperatureType = 288.15 * u.K, - alpha: Optional[AngleType] = 0 * u.deg, - beta: Optional[AngleType] = 0 * u.deg, + temperature: AbsoluteTemperature.Float64 = 288.15 * u.K, + alpha: Optional[Angle.Float64] = 0 * u.deg, + beta: Optional[Angle.Float64] = 0 * u.deg, reference_mach: Optional[pd.PositiveFloat] = None, ) -> AerospaceCondition: """ @@ -44,13 +44,13 @@ def operating_condition_from_mach_muref( Freestream Mach number (must be non-negative). muRef : PositiveFloat Freestream reference dynamic viscosity defined with mesh unit (must be positive). - project_length_unit: LengthType.Positive + project_length_unit: Length.PositiveFloat64 Project length unit. - temperature : AbsoluteTemperatureType, optional + temperature : AbsoluteTemperature.Float64, optional Freestream static temperature (must be above absolute zero, 0 K). Default is 288.15 Kelvin. - alpha : AngleType, optional + alpha : Angle.Float64, optional Angle of attack. Default is 0 degrees. - beta : AngleType, optional + beta : Angle.Float64, optional Sideslip angle. Default is 0 degrees. reference_mach : PositiveFloat, optional Reference Mach number. Default is None. diff --git a/flow360/component/simulation/models/bet/bet_translator_interface.py b/flow360/component/simulation/models/bet/bet_translator_interface.py index 43a5fd673..d1dcd3d98 100644 --- a/flow360/component/simulation/models/bet/bet_translator_interface.py +++ b/flow360/component/simulation/models/bet/bet_translator_interface.py @@ -5,9 +5,9 @@ from typing import Literal import numpy as np +from flow360_schema.framework.physical_dimensions import Angle, Length import flow360.component.simulation.units as u -from flow360.component.simulation.unit_system import AngleType, LengthType from flow360.exceptions import Flow360ValueError from flow360.log import log @@ -339,7 +339,7 @@ def read_in_c81_polar_csv(polar_file_content): return cl_alphas, cl_mach_nums, cl_values, cd_values -def read_in_xfoil_polars(bet_disk: dict, polar_file_content_list: list, angle_unit: AngleType): +def read_in_xfoil_polars(bet_disk: dict, polar_file_content_list: list, angle_unit: Angle.Float64): """ Read in the XFOIL polars and assigns the resulting values correctly into the BETDisk dictionary. @@ -347,7 +347,7 @@ def read_in_xfoil_polars(bet_disk: dict, polar_file_content_list: list, angle_un ---------- bet_disk: dictionary, contains required betdisk data polar_file_content_list: list of XFOIL polar file contents - angle_unit: AngleType, unit for angle of attack + angle_unit: Angle.Float64, unit for angle of attack Attributes ---------- @@ -396,7 +396,7 @@ def read_in_c81_polars( bet_disk: dict, c81_polar_file_contents: list, c81_polar_file_extensions: list, - angle_unit: AngleType, + angle_unit: Angle.Float64, ): """ Read in the C81 polars and assigns the resulting values correctly into the BETDisk dictionary. @@ -406,7 +406,7 @@ def read_in_c81_polars( bet_disk: dictionary, contains required betdisk data c81_polar_file_contents: list of C81 polar file contents c81_polar_file_extensions: list of C81 polar file contents - angle_unit: AngleType, unit for angle of attack + angle_unit: Angle.Float64, unit for angle of attack Attributes ---------- @@ -466,8 +466,8 @@ def generate_xfoil_bet_json( n_loading_nodes, entities, number_of_blades, - angle_unit: AngleType, - length_unit: LengthType, + angle_unit: Angle.Float64, + length_unit: Length.Float64, name, ): """ @@ -537,7 +537,7 @@ def generate_polar_file_name_list(geometry_file_content: str) -> list[list[str]] def parse_c81_xfoil_geometry_file( - geometry_file_content: str, length_unit: LengthType, angle_unit: AngleType + geometry_file_content: str, length_unit: Length.Float64, angle_unit: Angle.Float64 ) -> dict: """ Read in the geometry file. This file is a csv containing the filenames @@ -627,8 +627,8 @@ def parse_c81_xfoil_geometry_file( def translate_xfoil_c81_to_bet_dict( geometry_file_content: str, polar_file_contents_list: list, - length_unit: LengthType, - angle_unit: AngleType, + length_unit: Length.Float64, + angle_unit: Angle.Float64, file_format: Literal["xfoil", "c81"], polar_file_extensions=None, ) -> dict: @@ -665,8 +665,8 @@ def generate_c81_bet_json( chord_ref, n_loading_nodes, entities, - angle_unit: AngleType, - length_unit: LengthType, + angle_unit: Angle.Float64, + length_unit: Length.Float64, number_of_blades, name, ): @@ -1529,8 +1529,8 @@ def get_polar(xrotor_dict, alphas, machs, rR_station): def translate_xrotor_dfdc_to_bet_dict( geometry_file_content: str, - length_unit: LengthType, - angle_unit: AngleType, + length_unit: Length.Float64, + angle_unit: Angle.Float64, file_format: Literal["xrotor", "dfdc"], ) -> dict: """ @@ -1541,8 +1541,8 @@ def translate_xrotor_dfdc_to_bet_dict( ---------- geometry_file_content: string, path to the XROTOR/DFDC file bet_disk: dictionary, contains the BETDisk data - length_unit: LengthType, grid unit length with units - angle_unit: AngleType, unit for the angle of attack + length_unit: Length.Float64, grid unit length with units + angle_unit: Angle.Float64, unit for the angle of attack return: dictionary with BETDisk parameters from XROTOR/DFDC input file only """ @@ -1583,8 +1583,8 @@ def generate_xrotor_bet_json( chord_ref, n_loading_nodes, entities, - angle_unit: AngleType, - length_unit: LengthType, + angle_unit: Angle.Float64, + length_unit: Length.Float64, name, ) -> dict: """ @@ -1629,8 +1629,8 @@ def generate_dfdc_bet_json( chord_ref, n_loading_nodes, entities, - angle_unit: AngleType, - length_unit: LengthType, + angle_unit: Angle.Float64, + length_unit: Length.Float64, name, ) -> dict: """ diff --git a/flow360/component/simulation/models/material.py b/flow360/component/simulation/models/material.py index 8772655c9..3a22bf6c2 100644 --- a/flow360/component/simulation/models/material.py +++ b/flow360/component/simulation/models/material.py @@ -3,19 +3,19 @@ from typing import List, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import ( + AbsoluteTemperature, + Density, + Pressure, + SpecificHeatCapacity, + ThermalConductivity, + Velocity, + Viscosity, +) from numpy import sqrt import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.unit_system import ( - AbsoluteTemperatureType, - DensityType, - PressureType, - SpecificHeatCapacityType, - ThermalConductivityType, - VelocityType, - ViscosityType, -) # ============================================================================= # NASA 9-Coefficient Polynomial Utility Functions @@ -116,10 +116,10 @@ class NASA9CoefficientSet(Flow360BaseModel): """ type_name: Literal["NASA9CoefficientSet"] = pd.Field("NASA9CoefficientSet", frozen=True) - temperature_range_min: AbsoluteTemperatureType = pd.Field( + temperature_range_min: AbsoluteTemperature.Float64 = pd.Field( description="Minimum temperature for which this coefficient set is valid." ) - temperature_range_max: AbsoluteTemperatureType = pd.Field( + temperature_range_max: AbsoluteTemperature.Float64 = pd.Field( description="Maximum temperature for which this coefficient set is valid." ) coefficients: List[float] = pd.Field( @@ -382,20 +382,20 @@ class Sutherland(Flow360BaseModel): """ # pylint: disable=no-member - reference_viscosity: ViscosityType.NonNegative = pd.Field( + reference_viscosity: Viscosity.NonNegativeFloat64 = pd.Field( description="The reference dynamic viscosity at the reference temperature." ) - reference_temperature: AbsoluteTemperatureType = pd.Field( + reference_temperature: AbsoluteTemperature.Float64 = pd.Field( description="The reference temperature associated with the reference viscosity." ) - effective_temperature: AbsoluteTemperatureType = pd.Field( + effective_temperature: AbsoluteTemperature.Float64 = pd.Field( description="The effective temperature constant used in Sutherland's formula." ) @pd.validate_call def get_dynamic_viscosity( - self, temperature: AbsoluteTemperatureType - ) -> ViscosityType.NonNegative: + self, temperature: AbsoluteTemperature.Float64 + ) -> Viscosity.NonNegativeFloat64: """ Calculates the dynamic viscosity at a given temperature using Sutherland's law. @@ -478,7 +478,7 @@ class Air(MaterialBase): type: Literal["air"] = pd.Field("air", frozen=True) name: str = pd.Field("air") - dynamic_viscosity: Union[Sutherland, ViscosityType.NonNegative] = pd.Field( + dynamic_viscosity: Union[Sutherland, Viscosity.NonNegativeFloat64] = pd.Field( Sutherland( reference_viscosity=1.716e-5 * u.Pa * u.s, reference_temperature=273.15 * u.K, @@ -528,7 +528,7 @@ class Air(MaterialBase): description="Turbulent Prandtl number. Default is 0.9.", ) - def get_specific_heat_ratio(self, temperature: AbsoluteTemperatureType) -> pd.PositiveFloat: + def get_specific_heat_ratio(self, temperature: AbsoluteTemperature.Float64) -> pd.PositiveFloat: """ Computes the specific heat ratio (gamma) at a given temperature from NASA polynomial. @@ -575,7 +575,7 @@ def _get_coefficients_at_temperature(self, temp_k: float) -> list: return coeffs @property - def gas_constant(self) -> SpecificHeatCapacityType.Positive: + def gas_constant(self) -> SpecificHeatCapacity.PositiveFloat64: """ Returns the specific gas constant for air. @@ -589,8 +589,8 @@ def gas_constant(self) -> SpecificHeatCapacityType.Positive: @pd.validate_call def get_pressure( - self, density: DensityType.Positive, temperature: AbsoluteTemperatureType - ) -> PressureType.Positive: + self, density: Density.PositiveFloat64, temperature: AbsoluteTemperature.Float64 + ) -> Pressure.PositiveFloat64: """ Calculates the pressure of air using the ideal gas law. @@ -610,7 +610,9 @@ def get_pressure( return density * self.gas_constant * temperature @pd.validate_call - def get_speed_of_sound(self, temperature: AbsoluteTemperatureType) -> VelocityType.Positive: + def get_speed_of_sound( + self, temperature: AbsoluteTemperature.Float64 + ) -> Velocity.PositiveFloat64: """ Calculates the speed of sound in air at a given temperature. @@ -632,8 +634,8 @@ def get_speed_of_sound(self, temperature: AbsoluteTemperatureType) -> VelocityTy @pd.validate_call def get_dynamic_viscosity( - self, temperature: AbsoluteTemperatureType - ) -> ViscosityType.NonNegative: + self, temperature: AbsoluteTemperature.Float64 + ) -> Viscosity.NonNegativeFloat64: """ Calculates the dynamic viscosity of air at a given temperature. @@ -673,13 +675,13 @@ class SolidMaterial(MaterialBase): type: Literal["solid"] = pd.Field("solid", frozen=True) name: str = pd.Field(frozen=True, description="Name of the solid material.") - thermal_conductivity: ThermalConductivityType.Positive = pd.Field( + thermal_conductivity: ThermalConductivity.PositiveFloat64 = pd.Field( frozen=True, description="Thermal conductivity of the material." ) - density: Optional[DensityType.Positive] = pd.Field( + density: Optional[Density.PositiveFloat64] = pd.Field( None, frozen=True, description="Density of the material." ) - specific_heat_capacity: Optional[SpecificHeatCapacityType.Positive] = pd.Field( + specific_heat_capacity: Optional[SpecificHeatCapacity.PositiveFloat64] = pd.Field( None, frozen=True, description="Specific heat capacity of the material." ) @@ -710,10 +712,10 @@ class Water(MaterialBase): type: Literal["water"] = pd.Field("water", frozen=True) name: str = pd.Field(frozen=True, description="Custom name of the water with given property.") - density: Optional[DensityType.Positive] = pd.Field( + density: Optional[Density.PositiveFloat64] = pd.Field( 1000 * u.kg / u.m**3, frozen=True, description="Density of the water." ) - dynamic_viscosity: ViscosityType.NonNegative = pd.Field( + dynamic_viscosity: Viscosity.NonNegativeFloat64 = pd.Field( 0.001002 * u.kg / u.m / u.s, frozen=True, description="Dynamic viscosity of the water." ) diff --git a/flow360/component/simulation/models/surface_models.py b/flow360/component/simulation/models/surface_models.py index 04f1f8886..b75e67cd3 100644 --- a/flow360/component/simulation/models/surface_models.py +++ b/flow360/component/simulation/models/surface_models.py @@ -6,6 +6,18 @@ from typing import Annotated, Dict, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import ( + AbsoluteTemperature, + AngularVelocity, +) +from flow360_schema.framework.physical_dimensions import HeatFlux as HeatFluxDim +from flow360_schema.framework.physical_dimensions import ( + InverseArea, + InverseLength, + Length, +) +from flow360_schema.framework.physical_dimensions import MassFlowRate as MassFlowRateDim +from flow360_schema.framework.physical_dimensions import Pressure as PressureDim import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -32,16 +44,6 @@ SurfacePair, WindTunnelGhostSurface, ) -from flow360.component.simulation.unit_system import ( - AbsoluteTemperatureType, - AngularVelocityType, - HeatFluxType, - InverseAreaType, - InverseLengthType, - LengthType, - MassFlowRateType, - PressureType, -) from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, contextual_field_validator, @@ -100,7 +102,9 @@ class HeatFlux(SingleAttributeModel): """ type_name: Literal["HeatFlux"] = pd.Field("HeatFlux", frozen=True) - value: Union[StringExpression, HeatFluxType] = pd.Field(description="The heat flux value.") + value: Union[StringExpression, HeatFluxDim.Float64] = pd.Field( + description="The heat flux value." + ) class Temperature(SingleAttributeModel): @@ -119,7 +123,7 @@ class Temperature(SingleAttributeModel): type_name: Literal["Temperature"] = pd.Field("Temperature", frozen=True) # pylint: disable=no-member - value: Union[StringExpression, AbsoluteTemperatureType] = pd.Field( + value: Union[StringExpression, AbsoluteTemperature.Float64] = pd.Field( description="The temperature value." ) @@ -149,7 +153,7 @@ class TotalPressure(Flow360BaseModel): type_name: Literal["TotalPressure"] = pd.Field("TotalPressure", frozen=True) # pylint: disable=no-member - value: Union[StringExpression, PressureType.Positive] = pd.Field( + value: Union[StringExpression, PressureDim.PositiveFloat64] = pd.Field( description="The total pressure value. When a string expression is supplied the value" + " needs to nondimensionalized by the pressure defined in `operating_condition`." ) @@ -170,7 +174,7 @@ class Pressure(SingleAttributeModel): type_name: Literal["Pressure"] = pd.Field("Pressure", frozen=True) # pylint: disable=no-member - value: PressureType.Positive = pd.Field(description="The static pressure value.") + value: PressureDim.PositiveFloat64 = pd.Field(description="The static pressure value.") class SlaterPorousBleed(Flow360BaseModel): @@ -191,7 +195,9 @@ class SlaterPorousBleed(Flow360BaseModel): type_name: Literal["SlaterPorousBleed"] = pd.Field("SlaterPorousBleed", frozen=True) # pylint: disable=no-member - static_pressure: PressureType.Positive = pd.Field(description="The static pressure value.") + static_pressure: PressureDim.PositiveFloat64 = pd.Field( + description="The static pressure value." + ) porosity: float = pd.Field(gt=0, le=1, description="The porosity of the bleed region.") activation_step: Optional[pd.PositiveInt] = pd.Field( None, description="Pseudo step at which to start applying the SlaterPorousBleedModel." @@ -216,7 +222,7 @@ class MassFlowRate(Flow360BaseModel): type_name: Literal["MassFlowRate"] = pd.Field("MassFlowRate", frozen=True) # pylint: disable=no-member - value: MassFlowRateType.NonNegative = pd.Field(description="The mass flow rate.") + value: MassFlowRateDim.NonNegativeFloat64 = pd.Field(description="The mass flow rate.") ramp_steps: Optional[pd.PositiveInt] = pd.Field( None, description="Number of pseudo steps before reaching :py:attr:`MassFlowRate.value` within 1 physical step.", @@ -239,8 +245,8 @@ class Supersonic(Flow360BaseModel): type_name: Literal["Supersonic"] = pd.Field("Supersonic", frozen=True) # pylint: disable=no-member - total_pressure: PressureType.Positive = pd.Field(description="The total pressure.") - static_pressure: PressureType.Positive = pd.Field(description="The static pressure.") + total_pressure: PressureDim.PositiveFloat64 = pd.Field(description="The total pressure.") + static_pressure: PressureDim.PositiveFloat64 = pd.Field(description="The static pressure.") class Mach(SingleAttributeModel): @@ -306,9 +312,9 @@ class WallRotation(Flow360BaseModel): """ # pylint: disable=no-member - center: LengthType.Point = pd.Field(description="The center of rotation") + center: Length.Vector3 = pd.Field(description="The center of rotation") axis: Axis = pd.Field(description="The axis of rotation.") - angular_velocity: AngularVelocityType = pd.Field("The value of the angular velocity.") + angular_velocity: AngularVelocity.Float64 = pd.Field("The value of the angular velocity.") type_name: Literal["WallRotation"] = pd.Field("WallRotation", frozen=True) private_attribute_circle_mode: Optional[dict] = pd.Field(None) @@ -448,7 +454,7 @@ class Wall(BoundaryBase): discriminator="type_name", description="Specify the heat flux or temperature at the `Wall` boundary.", ) - roughness_height: LengthType.NonNegative = pd.Field( + roughness_height: Length.NonNegativeFloat64 = pd.Field( 0 * u.m, description="Equivalent sand grain roughness height. Available only to `Fluid` zone boundaries.", ) @@ -686,7 +692,7 @@ class Inflow(BoundaryBaseWithTurbulenceQuantities): name: Optional[str] = pd.Field("Inflow", description="Name of the `Inflow` boundary condition.") type: Literal["Inflow"] = pd.Field("Inflow", frozen=True) # pylint: disable=no-member - total_temperature: Union[StringExpression, AbsoluteTemperatureType] = pd.Field( + total_temperature: Union[StringExpression, AbsoluteTemperature.Float64] = pd.Field( description="Specify the total temperature at the `Inflow` boundary." + " When a string expression is supplied the value" + " needs to nondimensionalized by the temperature defined in `operating_condition`." @@ -871,16 +877,16 @@ class PorousJump(Flow360BaseModel): entity_pairs: UniqueItemList[SurfacePair] = pd.Field( alias="surface_pairs", description="List of matching pairs of :class:`~flow360.Surface`. " ) - darcy_coefficient: InverseAreaType = pd.Field( + darcy_coefficient: InverseArea.Float64 = pd.Field( description="Darcy coefficient of the porous media model which determines the scaling of the " + "viscous loss term. The value defines the coefficient for the axis normal " + "to the surface." ) - forchheimer_coefficient: InverseLengthType = pd.Field( + forchheimer_coefficient: InverseLength.Float64 = pd.Field( description="Forchheimer coefficient of the porous media model which determines " + "the scaling of the inertial loss term." ) - thickness: LengthType = pd.Field( + thickness: Length.Float64 = pd.Field( description="Thickness of the thin porous media on the surface" ) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) diff --git a/flow360/component/simulation/models/turbulence_quantities.py b/flow360/component/simulation/models/turbulence_quantities.py index 3f4b68352..47ac95cc7 100644 --- a/flow360/component/simulation/models/turbulence_quantities.py +++ b/flow360/component/simulation/models/turbulence_quantities.py @@ -8,14 +8,14 @@ from typing import Annotated, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import ( + Frequency, + KinematicViscosity, + Length, + SpecificEnergy, +) from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.unit_system import ( - FrequencyType, - KinematicViscosityType, - LengthType, - SpecificEnergyType, -) class TurbulentKineticEnergy(Flow360BaseModel): @@ -26,7 +26,7 @@ class TurbulentKineticEnergy(Flow360BaseModel): type_name: Literal["TurbulentKineticEnergy"] = pd.Field("TurbulentKineticEnergy", frozen=True) # pylint: disable=no-member - turbulent_kinetic_energy: SpecificEnergyType.NonNegative = pd.Field() + turbulent_kinetic_energy: SpecificEnergy.NonNegativeFloat64 = pd.Field() class TurbulentIntensity(Flow360BaseModel): @@ -50,7 +50,7 @@ class _SpecificDissipationRate(Flow360BaseModel, metaclass=ABCMeta): type_name: Literal["SpecificDissipationRate"] = pd.Field("SpecificDissipationRate", frozen=True) # pylint: disable=no-member - specific_dissipation_rate: FrequencyType.NonNegative = pd.Field() + specific_dissipation_rate: Frequency.NonNegativeFloat64 = pd.Field() class TurbulentViscosityRatio(Flow360BaseModel): @@ -75,7 +75,7 @@ class TurbulentLengthScale(Flow360BaseModel, metaclass=ABCMeta): type_name: Literal["TurbulentLengthScale"] = pd.Field("TurbulentLengthScale", frozen=True) # pylint: disable=no-member - turbulent_length_scale: LengthType.Positive = pd.Field() + turbulent_length_scale: Length.PositiveFloat64 = pd.Field() class ModifiedTurbulentViscosityRatio(Flow360BaseModel): @@ -101,7 +101,7 @@ class ModifiedTurbulentViscosity(Flow360BaseModel): "ModifiedTurbulentViscosity", frozen=True ) # pylint: disable=no-member - modified_turbulent_viscosity: Optional[KinematicViscosityType.Positive] = pd.Field() + modified_turbulent_viscosity: Optional[KinematicViscosity.PositiveFloat64] = pd.Field() # pylint: disable=missing-class-docstring diff --git a/flow360/component/simulation/models/volume_models.py b/flow360/component/simulation/models/volume_models.py index b8b43f770..c7bf0a85a 100644 --- a/flow360/component/simulation/models/volume_models.py +++ b/flow360/component/simulation/models/volume_models.py @@ -7,7 +7,18 @@ from typing import Annotated, Dict, List, Literal, Optional, Union import pydantic as pd -from flow360_schema.framework.validation.context import DeserializationContext +from flow360_schema.framework.physical_dimensions import Acceleration, Angle +from flow360_schema.framework.physical_dimensions import ( + AngularVelocity as AngularVelocityDim, +) +from flow360_schema.framework.physical_dimensions import ( + HeatSource, + InverseArea, + InverseLength, + Length, + Pressure, + Velocity, +) import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -63,18 +74,6 @@ GenericVolume, SeedpointVolume, ) -from flow360.component.simulation.unit_system import ( - AccelerationType, - AngleType, - AngularVelocityType, - HeatSourceType, - InverseAreaType, - InverseLengthType, - LengthType, - PressureType, - VelocityType, - u, -) from flow360.component.simulation.user_code.core.types import ValueOrExpression from flow360.component.simulation.utils import sanitize_params_dict from flow360.component.simulation.validation.validation_context import ( @@ -146,7 +145,7 @@ class AngularVelocity(SingleAttributeModel): """ type_name: Literal["AngularVelocity"] = pd.Field("AngularVelocity", frozen=True) - value: ValueOrExpression[AngularVelocityType] = pd.Field( + value: ValueOrExpression[AngularVelocityDim.Float64] = pd.Field( description="The value of the angular velocity." ) @@ -308,7 +307,7 @@ class Gravity(Flow360BaseModel): (0, 0, -1), description="The direction of the gravitational acceleration vector.", ) - magnitude: AccelerationType = pd.Field( + magnitude: Acceleration.Float64 = pd.Field( 9.81 * u.m / u.s**2, description="The magnitude of the gravitational acceleration. " + "For Earth's surface gravity, use 9.81 m/s².", @@ -432,7 +431,7 @@ class Solid(PDEModelBase): + ":class:`HeatEquationSolver` documentation.", ) # pylint: disable=no-member - volumetric_heat_source: Union[StringExpression, HeatSourceType] = pd.Field( + volumetric_heat_source: Union[StringExpression, HeatSource.Float64] = pd.Field( 0 * u.W / (u.m**3), description="The volumetric heat source." ) @@ -483,16 +482,16 @@ class ForcePerArea(Flow360BaseModel): """ # pylint: disable=no-member - radius: LengthType.NonNegativeArray = pd.Field( + radius: Length.NonNegativeArray = pd.Field( description="Radius of the sampled locations in grid unit." ) # pylint: disable=no-member - thrust: PressureType.Array = pd.Field( + thrust: Pressure.Array = pd.Field( description="Dimensional force per area in the axial direction, positive means the axial " + "force follows the same direction as the thrust axis. " ) # pylint: disable=no-member - circumferential: PressureType.Array = pd.Field( + circumferential: Pressure.Array = pd.Field( description="Dimensional force per area in the circumferential direction, positive means the " + "circumferential force follows the same direction as the thrust axis with the right hand rule. " ) @@ -553,7 +552,7 @@ class ActuatorDisk(Flow360BaseModel): description="The force per area input for the `ActuatorDisk` model. " + "See :class:`ForcePerArea` documentation." ) - reference_velocity: Optional[VelocityType.Vector] = pd.Field( # pylint: disable=no-member + reference_velocity: Optional[Velocity.Vector3] = pd.Field( # pylint: disable=no-member None, description="Reference velocity [Vx, Vy, Vz] for power calculation. " + "When provided, uses this velocity instead of local flow velocity " @@ -577,8 +576,8 @@ class BETDiskTwist(Flow360BaseModel): ==== """ - radius: LengthType.NonNegative = pd.Field(description="The radius of the radial location.") - twist: AngleType = pd.Field(description="The twist angle at this radial location.") + radius: Length.NonNegativeFloat64 = pd.Field(description="The radius of the radial location.") + twist: Angle.Float64 = pd.Field(description="The twist angle at this radial location.") # pylint: disable=no-member @@ -594,8 +593,8 @@ class BETDiskChord(Flow360BaseModel): ==== """ - radius: LengthType.NonNegative = pd.Field(description="The radius of the radial location.") - chord: LengthType.NonNegative = pd.Field( + radius: Length.NonNegativeFloat64 = pd.Field(description="The radius of the radial location.") + chord: Length.NonNegativeFloat64 = pd.Field( description="The blade chord at this radial location. " ) @@ -740,15 +739,15 @@ class BETDiskCache(Flow360BaseModel): name: Optional[str] = None file: Optional[BETFileTypes] = None rotation_direction_rule: Optional[Literal["leftHand", "rightHand"]] = None - omega: Optional[AngularVelocityType.NonNegative] = None - chord_ref: Optional[LengthType.Positive] = None + omega: Optional[AngularVelocityDim.NonNegativeFloat64] = None + chord_ref: Optional[Length.PositiveFloat64] = None n_loading_nodes: Optional[pd.StrictInt] = None entities: Optional[EntityList[Cylinder]] = None - angle_unit: Optional[AngleType] = None - length_unit: Optional[LengthType.NonNegative] = None + angle_unit: Optional[Angle.Float64] = None + length_unit: Optional[Length.NonNegativeFloat64] = None number_of_blades: Optional[pd.StrictInt] = None initial_blade_direction: Optional[Axis] = None - blade_line_chord: Optional[LengthType.NonNegative] = None + blade_line_chord: Optional[Length.NonNegativeFloat64] = None class BETDisk(MultiConstructorBaseModel): @@ -798,8 +797,8 @@ class BETDisk(MultiConstructorBaseModel): description='The rule for rotation direction and thrust direction, "rightHand" or "leftHand".', ) number_of_blades: pd.StrictInt = pd.Field(gt=0, le=10, description="Number of blades to model.") - omega: AngularVelocityType.NonNegative = pd.Field(description="Rotating speed.") - chord_ref: LengthType.Positive = pd.Field( + omega: AngularVelocityDim.NonNegativeFloat64 = pd.Field(description="Rotating speed.") + chord_ref: Length.PositiveFloat64 = pd.Field( description="Dimensional reference chord used to compute sectional blade loadings." ) n_loading_nodes: pd.StrictInt = pd.Field( @@ -808,7 +807,7 @@ class BETDisk(MultiConstructorBaseModel): description="Number of nodes used to compute the sectional thrust and " + "torque coefficients :math:`C_t` and :math:`C_q`, defined in :ref:`betDiskLoadingNote`.", ) - blade_line_chord: LengthType.NonNegative = pd.Field( + blade_line_chord: Length.NonNegativeFloat64 = pd.Field( 0 * u.m, description="Dimensional chord to use if performing an unsteady BET Line simulation. " + "Default of 0.0 is an indication to run a steady BET Disk simulation.", @@ -818,7 +817,7 @@ class BETDisk(MultiConstructorBaseModel): description="Orientation of the first blade in the BET model. " + "Must be specified if performing an unsteady BET Line simulation.", ) - tip_gap: Union[Literal["inf"], LengthType.NonNegative] = pd.Field( + tip_gap: Union[Literal["inf"], Length.NonNegativeFloat64] = pd.Field( "inf", description="Dimensional distance between blade tip and solid bodies to " + "define a :ref:`tip loss factor `.", @@ -834,7 +833,7 @@ class BETDisk(MultiConstructorBaseModel): + "provided in :class:`BETDiskSectionalPolar`.", frozen=True, ) - alphas: AngleType.Array = pd.Field( + alphas: Angle.Array = pd.Field( description="Alphas associated with airfoil polars provided in " + ":class:`BETDiskSectionalPolar`.", frozen=True, @@ -854,7 +853,7 @@ class BETDisk(MultiConstructorBaseModel): + ":py:attr:`sectional_radiuses`.", frozen=True, ) - sectional_radiuses: LengthType.NonNegativeArray = pd.Field( + sectional_radiuses: Length.NonNegativeArray = pd.Field( description="A list of the radial locations in grid units at which :math:`C_l` " + "and :math:`C_d` are specified in :class:`BETDiskSectionalPolar`.", frozen=True, @@ -990,8 +989,7 @@ def from_file(cls, filename: str, **kwargs) -> "BETDisk": raise Flow360ValueError(f"Invalid keyword arguments for {cls.__name__}: {invalid_keys}") model_dict.update(kwargs) - with DeserializationContext(): - return cls.model_validate(model_dict) + return cls.deserialize(model_dict) # pylint: disable=too-many-arguments, no-self-argument, not-callable @MultiConstructorBaseModel.model_constructor @@ -1000,15 +998,15 @@ def from_c81( cls, file: C81File, rotation_direction_rule: Literal["leftHand", "rightHand"], - omega: AngularVelocityType.NonNegative, - chord_ref: LengthType.Positive, + omega: AngularVelocityDim.NonNegativeFloat64, + chord_ref: Length.PositiveFloat64, n_loading_nodes: pd.StrictInt, entities: EntityList[Cylinder], number_of_blades: pd.StrictInt, - length_unit: LengthType.NonNegative, - angle_unit: AngleType, + length_unit: Length.NonNegativeFloat64, + angle_unit: Angle.Float64, initial_blade_direction: Optional[Axis] = None, - blade_line_chord: LengthType.NonNegative = 0 * u.m, + blade_line_chord: Length.NonNegativeFloat64 = 0 * u.m, name: str = "BET disk", ): """Constructs a :class: `BETDisk` instance from a given C81 file and additional inputs. @@ -1019,9 +1017,9 @@ def from_c81( C81File class instance containing information about the C81 file. rotation_direction_rule: str Rule for rotation direction and thrust direction. - omega: AngularVelocityType.NonNegative + omega: AngularVelocity.NonNegativeFloat64 Rotating speed of the propeller. - chord_ref: LengthType.Positive + chord_ref: Length.PositiveFloat64 Dimensional reference cord used to compute sectional blade loadings. n_loading_nodes: Int Number of nodes used to compute sectional thrust and torque coefficients. @@ -1029,14 +1027,14 @@ def from_c81( List of Cylinder entities used for defining the BET volumes. number_of_blades: Int Number of blades to model. - length_unit: LengthType.NonNegative + length_unit: Length.NonNegativeFloat64 Length unit of the geometry/mesh file. - angle_unit: AngleType - Angle unit used for AngleType BETDisk parameters. + angle_unit: Angle.Float64 + Angle unit used for Angle BETDisk parameters. initial_blade_direction: Axis, optional Orientation of the first blade in BET model. Must be specified for unsteady BET simulation. - blade_line_chord: LengthType.NonNegative + blade_line_chord: Length.NonNegativeFloat64 Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. @@ -1087,14 +1085,14 @@ def from_dfdc( cls, file: DFDCFile, rotation_direction_rule: Literal["leftHand", "rightHand"], - omega: AngularVelocityType.NonNegative, - chord_ref: LengthType.Positive, + omega: AngularVelocityDim.NonNegativeFloat64, + chord_ref: Length.PositiveFloat64, n_loading_nodes: pd.StrictInt, entities: EntityList[Cylinder], - length_unit: LengthType.NonNegative, - angle_unit: AngleType, + length_unit: Length.NonNegativeFloat64, + angle_unit: Angle.Float64, initial_blade_direction: Optional[Axis] = None, - blade_line_chord: LengthType.NonNegative = 0 * u.m, + blade_line_chord: Length.NonNegativeFloat64 = 0 * u.m, name: str = "BET disk", ): """Constructs a :class: `BETDisk` instance from a given DFDC file and additional inputs. @@ -1105,22 +1103,22 @@ def from_dfdc( DFDCFile class instance containing information about the DFDC file. rotation_direction_rule: str Rule for rotation direction and thrust direction. - omega: AngularVelocityType.NonNegative + omega: AngularVelocity.NonNegativeFloat64 Rotating speed of the propeller. - chord_ref: LengthType.Positive + chord_ref: Length.PositiveFloat64 Dimensional reference cord used to compute sectional blade loadings. n_loading_nodes: Int Number of nodes used to compute sectional thrust and torque coefficients. entities: EntityList[Cylinder] List of Cylinder entities used for defining the BET volumes. - length_unit: LengthType.NonNegative - Length unit used for LengthType BETDisk parameters. - angle_unit: AngleType - Angle unit used for AngleType BETDisk parameters. + length_unit: Length.NonNegativeFloat64 + Length unit used for BETDisk parameters. + angle_unit: Angle.Float64 + Angle unit used for Angle BETDisk parameters. initial_blade_direction: Axis, optional Orientation of the first blade in BET model. Must be specified for unsteady BET simulation. - blade_line_chord: LengthType.NonNegative + blade_line_chord: Length.NonNegativeFloat64 Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. @@ -1168,15 +1166,15 @@ def from_xfoil( cls, file: XFOILFile, rotation_direction_rule: Literal["leftHand", "rightHand"], - omega: AngularVelocityType.NonNegative, - chord_ref: LengthType.Positive, + omega: AngularVelocityDim.NonNegativeFloat64, + chord_ref: Length.PositiveFloat64, n_loading_nodes: pd.StrictInt, entities: EntityList[Cylinder], - length_unit: LengthType.NonNegative, - angle_unit: AngleType, + length_unit: Length.NonNegativeFloat64, + angle_unit: Angle.Float64, number_of_blades: pd.StrictInt, initial_blade_direction: Optional[Axis], - blade_line_chord: LengthType.NonNegative = 0 * u.m, + blade_line_chord: Length.NonNegativeFloat64 = 0 * u.m, name: str = "BET disk", ): """Constructs a :class: `BETDisk` instance from a given XROTOR file and additional inputs. @@ -1187,24 +1185,24 @@ def from_xfoil( XFOILFile class instance containing information about the XFOIL file. rotation_direction_rule: str Rule for rotation direction and thrust direction. - omega: AngularVelocityType.NonNegative + omega: AngularVelocity.NonNegativeFloat64 Rotating speed of the propeller. - chord_ref: LengthType.Positive + chord_ref: Length.PositiveFloat64 Dimensional reference cord used to compute sectional blade loadings. n_loading_nodes: Int Number of nodes used to compute sectional thrust and torque coefficients. entities: EntityList[Cylinder] List of Cylinder entities used for defining the BET volumes. - length_unit: LengthType.NonNegative - Length unit used for LengthType BETDisk parameters. - angle_unit: AngleType - Angle unit used for AngleType BETDisk parameters. + length_unit: Length.NonNegativeFloat64 + Length unit used for BETDisk parameters. + angle_unit: Angle.Float64 + Angle unit used for Angle BETDisk parameters. number_of_blades: Int Number of blades to model. initial_blade_direction: Axis, optional Orientation of the first blade in BET model. Must be specified for unsteady BET simulation. - blade_line_chord: LengthType.NonNegative + blade_line_chord: Length.NonNegativeFloat64 Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. @@ -1257,14 +1255,14 @@ def from_xrotor( cls, file: XROTORFile, rotation_direction_rule: Literal["leftHand", "rightHand"], - omega: AngularVelocityType.NonNegative, - chord_ref: LengthType.Positive, + omega: AngularVelocityDim.NonNegativeFloat64, + chord_ref: Length.PositiveFloat64, n_loading_nodes: pd.StrictInt, entities: EntityList[Cylinder], - length_unit: LengthType.NonNegative, - angle_unit: AngleType, + length_unit: Length.NonNegativeFloat64, + angle_unit: Angle.Float64, initial_blade_direction: Optional[Axis] = None, - blade_line_chord: LengthType.NonNegative = 0 * u.m, + blade_line_chord: Length.NonNegativeFloat64 = 0 * u.m, name: str = "BET disk", ): """Constructs a :class: `BETDisk` instance from a given XROTOR file and additional inputs. @@ -1275,22 +1273,22 @@ def from_xrotor( XROTORFile class instance containing information about the XROTOR file. rotation_direction_rule: str Rule for rotation direction and thrust direction. - omega: AngularVelocityType.NonNegative + omega: AngularVelocity.NonNegativeFloat64 Rotating speed of the propeller. - chord_ref: LengthType.Positive + chord_ref: Length.PositiveFloat64 Dimensional reference cord used to compute sectional blade loadings. n_loading_nodes: Int Number of nodes used to compute sectional thrust and torque coefficients. entities: EntityList[Cylinder] List of Cylinder entities used for defining the BET volumes. - length_unit: LengthType.NonNegative - Length unit used for LengthType BETDisk parameters. - angle_unit: AngleType - Angle unit used for AngleType BETDisk parameters. + length_unit: Length.NonNegativeFloat64 + Length unit used for BETDisk parameters. + angle_unit: Angle.Float64 + Angle unit used for Angle BETDisk parameters. initial_blade_direction: Axis, optional Orientation of the first blade in BET model. Must be specified for unsteady BET simulation. - blade_line_chord: LengthType.NonNegative + blade_line_chord: Length.NonNegativeFloat64 Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. @@ -1492,16 +1490,16 @@ class PorousMedium(Flow360BaseModel): + "porous medium material model.", ) - darcy_coefficient: InverseAreaType.Point = pd.Field( + darcy_coefficient: InverseArea.Vector3 = pd.Field( description="Darcy coefficient of the porous media model which determines the scaling of the " + "viscous loss term. The 3 values define the coefficient for each of the 3 axes defined by " + "the reference frame of the volume zone." ) - forchheimer_coefficient: InverseLengthType.Point = pd.Field( + forchheimer_coefficient: InverseLength.Vector3 = pd.Field( description="Forchheimer coefficient of the porous media model which determines " + "the scaling of the inertial loss term." ) - volumetric_heat_source: Optional[Union[StringExpression, HeatSourceType]] = pd.Field( + volumetric_heat_source: Optional[Union[StringExpression, HeatSource.Float64]] = pd.Field( None, description="The volumetric heat source." ) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) @@ -1524,7 +1522,7 @@ def _ensure_entities_have_sufficient_attributes( @classmethod def _validate_volumetric_heat_source_for_liquid( cls, - value: Optional[Union[StringExpression, HeatSourceType]], + value: Optional[Union[StringExpression, HeatSource.Float64]], param_info: ParamsValidationInfo, ): """Disable the volumetric_heat_source when liquid operating condition is used""" diff --git a/flow360/component/simulation/operating_condition/operating_condition.py b/flow360/component/simulation/operating_condition/operating_condition.py index 493c4caf3..591cb02ff 100644 --- a/flow360/component/simulation/operating_condition/operating_condition.py +++ b/flow360/component/simulation/operating_condition/operating_condition.py @@ -3,9 +3,10 @@ from typing import Literal, Optional, Tuple, Union import pydantic as pd -from flow360_schema.models.primitives import ( +from flow360_schema.framework.physical_dimensions import ( AbsoluteTemperature, Angle, + DeltaTemperature, Density, Length, Pressure, @@ -24,7 +25,6 @@ from flow360.component.simulation.operating_condition.atmosphere_model import ( StandardAtmosphereModel, ) -from flow360.component.simulation.unit_system import DeltaTemperatureType from flow360.component.simulation.user_code.core.types import ( Expression, ValueOrExpression, @@ -49,7 +49,7 @@ class ThermalStateCache(Flow360BaseModel): # pylint: disable=no-member altitude: Optional[Length.Float64] = None - temperature_offset: Optional[DeltaTemperatureType] = None + temperature_offset: Optional[DeltaTemperature.Float64] = None class ThermalState(MultiConstructorBaseModel): @@ -89,7 +89,7 @@ class ThermalState(MultiConstructorBaseModel): def from_standard_atmosphere( cls, altitude: Length.Float64 = 0 * u.m, - temperature_offset: DeltaTemperatureType = 0 * u.K, + temperature_offset: DeltaTemperature.Float64 = 0 * u.K, ): """ Constructs a :class:`ThermalState` instance from the standard atmosphere model. @@ -98,7 +98,7 @@ def from_standard_atmosphere( ---------- altitude : Length.Float64, optional The altitude at which the thermal state is calculated. Defaults to ``0 * u.m``. - temperature_offset : DeltaTemperatureType, optional + temperature_offset : DeltaTemperature.Float64, optional The temperature offset to be applied to the standard temperature at the given altitude. Defaults to ``0 * u.K``. @@ -154,7 +154,7 @@ def altitude(self) -> Optional[Length.Float64]: return self.private_attribute_input_cache.altitude @property - def temperature_offset(self) -> Optional[DeltaTemperatureType]: + def temperature_offset(self) -> Optional[DeltaTemperature.Float64]: """Return user specified temperature offset.""" if not self.private_attribute_input_cache.temperature_offset: log.warning("Temperature offset not provided from input") diff --git a/flow360/component/simulation/outputs/output_entities.py b/flow360/component/simulation/outputs/output_entities.py index 2d66a5137..fe1be653e 100644 --- a/flow360/component/simulation/outputs/output_entities.py +++ b/flow360/component/simulation/outputs/output_entities.py @@ -4,6 +4,7 @@ import numpy as np import pydantic as pd +from flow360_schema.framework.physical_dimensions import Length from flow360.component.simulation.entity_operation import ( _transform_direction, @@ -13,7 +14,6 @@ from flow360.component.simulation.framework.entity_base import EntityBase from flow360.component.simulation.framework.entity_utils import generate_uuid from flow360.component.simulation.outputs.output_fields import IsoSurfaceFieldNames -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.user_code.core.types import ( Expression, UnytQuantity, @@ -69,7 +69,7 @@ class Slice(EntityBase): private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) normal: Axis = pd.Field(description="Normal direction of the slice.") # pylint: disable=no-member - origin: LengthType.Point = pd.Field(description="A single point on the slice.") + origin: Length.Vector3 = pd.Field(description="A single point on the slice.") def _apply_transformation(self, matrix: np.ndarray) -> "Slice": """Apply 3x4 transformation matrix, returning new transformed instance.""" @@ -117,7 +117,7 @@ class Isosurface(_OutputItemBase): ) # pylint: disable=no-member - wall_distance_clip_threshold: Optional[LengthType.Positive] = pd.Field( + wall_distance_clip_threshold: Optional[Length.PositiveFloat64] = pd.Field( default=None, description="Optional parameter to remove the isosurface within a specified distance from walls.", ) @@ -232,7 +232,7 @@ class Point(EntityBase): private_attribute_entity_type_name: Literal["Point"] = pd.Field("Point", frozen=True) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) # pylint: disable=no-member - location: LengthType.Point = pd.Field(description="The coordinate of the point.") + location: Length.Vector3 = pd.Field(description="The coordinate of the point.") def _apply_transformation(self, matrix: np.ndarray) -> "Point": """Apply 3x4 transformation matrix, returning new transformed instance.""" @@ -265,8 +265,8 @@ class PointArray(EntityBase): private_attribute_entity_type_name: Literal["PointArray"] = pd.Field("PointArray", frozen=True) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) # pylint: disable=no-member - start: LengthType.Point = pd.Field(description="The starting point of the line.") - end: LengthType.Point = pd.Field(description="The end point of the line.") + start: Length.Vector3 = pd.Field(description="The starting point of the line.") + end: Length.Vector3 = pd.Field(description="The end point of the line.") number_of_points: int = pd.Field(ge=2, description="Number of points along the line.") def _apply_transformation(self, matrix: np.ndarray) -> "PointArray": @@ -315,9 +315,13 @@ class PointArray2D(EntityBase): ) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) # pylint: disable=no-member - origin: LengthType.Point = pd.Field(description="The corner of the parallelogram.") - u_axis_vector: LengthType.Axis = pd.Field(description="The scaled u-axis of the parallelogram.") - v_axis_vector: LengthType.Axis = pd.Field(description="The scaled v-axis of the parallelogram.") + origin: Length.Vector3 = pd.Field(description="The corner of the parallelogram.") + u_axis_vector: Length.NonNullVector3 = pd.Field( + description="The scaled u-axis of the parallelogram." + ) + v_axis_vector: Length.NonNullVector3 = pd.Field( + description="The scaled v-axis of the parallelogram." + ) u_number_of_points: int = pd.Field(ge=2, description="The number of points along the u axis.") v_number_of_points: int = pd.Field(ge=2, description="The number of points along the v axis.") diff --git a/flow360/component/simulation/outputs/output_fields.py b/flow360/component/simulation/outputs/output_fields.py index 1c87abb5e..0c6073e33 100644 --- a/flow360/component/simulation/outputs/output_fields.py +++ b/flow360/component/simulation/outputs/output_fields.py @@ -24,13 +24,14 @@ from typing import List, Literal, get_args, get_origin +import unyt as u + from flow360.component.simulation.conversion import ( compute_udf_dimensionalization_factor, ) from flow360.component.simulation.operating_condition.operating_condition import ( LiquidOperatingCondition, ) -from flow360.component.simulation.unit_system import u # pylint:disable=invalid-name _CD = "CD" diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 767c44238..8c47b3929 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -10,6 +10,7 @@ from typing import Annotated, ClassVar, List, Literal, Optional, Tuple, Union, get_args import pydantic as pd +from flow360_schema.framework.physical_dimensions import Length, Time from typing_extensions import deprecated import flow360.component.simulation.units as u @@ -59,7 +60,6 @@ Surface, WindTunnelGhostSurface, ) -from flow360.component.simulation.unit_system import LengthType, TimeType from flow360.component.simulation.user_code.core.types import ( Expression, UserVariable, @@ -1400,7 +1400,7 @@ class Observer(Flow360BaseModel): """ # pylint: disable=no-member - position: LengthType.Point = pd.Field( + position: Length.Vector3 = pd.Field( description="Position at which time history of acoustic pressure signal " + "is stored in aeroacoustic output file. The observer position can be outside the simulation domain, " + "but cannot be on or inside the solid surfaces of the simulation domain." @@ -1466,13 +1466,13 @@ class AeroAcousticOutput(Flow360BaseModel): description="Enable writing of aeroacoustic results on a per-surface basis, " + "in addition to results for all wall surfaces combined.", ) - observer_time_step_size: Optional[TimeType.Positive] = pd.Field( + observer_time_step_size: Optional[Time.PositiveFloat64] = pd.Field( None, description="Time step size for aeroacoustic output. " + "A valid value is smaller than or equal to the time step size of the CFD simulation. " + "Defaults to time step size of CFD.", ) - aeroacoustic_solver_start_time: TimeType.NonNegative = pd.Field( + aeroacoustic_solver_start_time: Time.NonNegativeFloat64 = pd.Field( 0 * u.s, description="Time to start the aeroacoustic solver. " + "Signals emitted after this start time at the source surfaces are included in the output.", diff --git a/flow360/component/simulation/outputs/render_config.py b/flow360/component/simulation/outputs/render_config.py index 069fd29ce..7d900e926 100644 --- a/flow360/component/simulation/outputs/render_config.py +++ b/flow360/component/simulation/outputs/render_config.py @@ -6,11 +6,11 @@ from typing import List, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import Angle, Length, Time import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.outputs.output_fields import CommonFieldNames -from flow360.component.simulation.unit_system import AngleType, LengthType, TimeType from flow360.component.simulation.user_code.core.types import ( Expression, UnytQuantity, @@ -43,9 +43,9 @@ class StaticView(Flow360BaseModel): type_name: Literal["StaticView"] = pd.Field("StaticView", frozen=True) # pylint: disable=no-member - position: LengthType.Point = pd.Field(description="Position of the camera in the scene") + position: Length.Vector3 = pd.Field(description="Position of the camera in the scene") # pylint: disable=no-member - target: LengthType.Point = pd.Field(description="Target point of the camera") + target: Length.Vector3 = pd.Field(description="Target point of the camera") up: Optional[Vector] = pd.Field( default=(0, 0, 1), description="Up vector, if not specified assume Z+" ) @@ -64,7 +64,7 @@ class Keyframe(Flow360BaseModel): """ type_name: Literal["Keyframe"] = pd.Field("Keyframe", frozen=True) - time: TimeType = pd.Field( + time: Time.Float64 = pd.Field( 0, ge=0, description="Timestamp at which the keyframe should be reached" ) view: StaticView = pd.Field(description="Camera parameters at this keyframe") @@ -119,11 +119,11 @@ class OrthographicProjection(Flow360BaseModel): """ type_name: Literal["OrthographicProjection"] = pd.Field("OrthographicProjection", frozen=True) - width: LengthType = pd.Field(description="Width of the camera frustum in world units") - near: LengthType = pd.Field( + width: Length.Float64 = pd.Field(description="Width of the camera frustum in world units") + near: Length.Float64 = pd.Field( description="Near clipping plane in world units, pixels closer to the camera than this value are culled" ) - far: LengthType = pd.Field( + far: Length.Float64 = pd.Field( description="Far clipping plane in world units, pixels further from the camera than this value are culled" ) @@ -142,11 +142,11 @@ class PerspectiveProjection(Flow360BaseModel): """ type_name: Literal["PerspectiveProjection"] = pd.Field("PerspectiveProjection", frozen=True) - fov: AngleType = pd.Field(description="Field of view of the camera (angle)") - near: LengthType = pd.Field( + fov: Angle.Float64 = pd.Field(description="Field of view of the camera (angle)") + near: Length.Float64 = pd.Field( description="Near clipping plane in world units, pixels closer to the camera than this value are culled" ) - far: LengthType = pd.Field( + far: Length.Float64 = pd.Field( description="Far clipping plane in world units, pixels further from the camera than this value are culled" ) @@ -778,11 +778,11 @@ class SceneTransform(Flow360BaseModel): type_name: Literal["SceneTransform"] = pd.Field("SceneTransform", frozen=True) # pylint: disable=no-member - translation: LengthType.Point = pd.Field( + translation: Length.Vector3 = pd.Field( (0, 0, 0) * u.m, description="Translation applied to all scene objects" ) # pylint: disable=no-member - rotation: AngleType.Vector = pd.Field( + rotation: Angle.Vector3 = pd.Field( (0, 0, 0) * u.deg, description="Rotation applied to all scene objects (Euler XYZ)" ) scale: Vector = pd.Field((1, 1, 1), description="Scaling applied to all scene objects") diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index b1f96d078..a99abeffe 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -9,7 +9,7 @@ import numpy as np import pydantic as pd -from flow360_schema.models.primitives import Length +from flow360_schema.framework.physical_dimensions import Angle, Area, Length from pydantic import PositiveFloat from typing_extensions import Self @@ -28,7 +28,6 @@ MultiConstructorBaseModel, ) from flow360.component.simulation.framework.unique_list import UniqueStringList -from flow360.component.simulation.unit_system import AngleType, AreaType, LengthType from flow360.component.simulation.user_code.core.types import ValueOrExpression from flow360.component.simulation.utils import BoundingBoxType, model_attribute_unlock from flow360.component.simulation.validation.validation_context import ( @@ -123,13 +122,13 @@ class ReferenceGeometry(Flow360BaseModel): """ # pylint: disable=no-member - moment_center: Optional[LengthType.Point] = pd.Field( + moment_center: Optional[Length.Vector3] = pd.Field( None, description="The x, y, z coordinate of moment center." ) - moment_length: Optional[Union[LengthType.Positive, LengthType.PositiveVector]] = pd.Field( + moment_length: Optional[Union[Length.PositiveFloat64, Length.PositiveVector3]] = pd.Field( None, description="The x, y, z component-wise moment reference lengths." ) - area: Optional[ValueOrExpression[AreaType.Positive]] = pd.Field( + area: Optional[ValueOrExpression[Area.PositiveFloat64]] = pd.Field( None, description="The reference area of the geometry." ) private_attribute_area_settings: Optional[dict] = pd.Field(None) @@ -319,7 +318,7 @@ class GenericVolume(_VolumeEntityBase): axes: Optional[OrthogonalAxes] = pd.Field(None, description="") # Porous media support axis: Optional[Axis] = pd.Field(None) # Rotation support # pylint: disable=no-member - center: Optional[LengthType.Point] = pd.Field(None, description="") # Rotation support + center: Optional[Length.Vector3] = pd.Field(None, description="") # Rotation support class BoxCache(Flow360BaseModel): @@ -328,8 +327,8 @@ class BoxCache(Flow360BaseModel): # `axes` will always exist as it needs to be used. So `axes` is more like a storage than input cache. axes: Optional[OrthogonalAxes] = pd.Field(None) # pylint: disable=no-member - center: Optional[LengthType.Point] = pd.Field(None) - size: Optional[LengthType.PositiveVector] = pd.Field(None) + center: Optional[Length.Vector3] = pd.Field(None) + size: Optional[Length.PositiveVector3] = pd.Field(None) name: Optional[str] = pd.Field(None) @@ -362,8 +361,8 @@ class Box(MultiConstructorBaseModel, _VolumeEntityBase): type_name: Literal["Box"] = pd.Field("Box", frozen=True) # pylint: disable=no-member - center: LengthType.Point = pd.Field(description="The coordinates of the center of the box.") - size: LengthType.PositiveVector = pd.Field( + center: Length.Vector3 = pd.Field(description="The coordinates of the center of the box.") + size: Length.PositiveVector3 = pd.Field( description="The dimensions of the box (length, width, height)." ) axis_of_rotation: Axis = pd.Field( @@ -371,7 +370,7 @@ class Box(MultiConstructorBaseModel, _VolumeEntityBase): description="The rotation axis. Cannot change once specified.", frozen=True, ) - angle_of_rotation: AngleType = pd.Field( + angle_of_rotation: Angle.Float64 = pd.Field( default=0 * u.degree, description="The rotation angle. Cannot change once specified.", frozen=True, @@ -386,8 +385,8 @@ class Box(MultiConstructorBaseModel, _VolumeEntityBase): def from_principal_axes( cls, name: str, - center: LengthType.Point, - size: LengthType.PositiveVector, + center: Length.Vector3, + size: Length.PositiveVector3, axes: OrthogonalAxes, ): """ @@ -508,8 +507,8 @@ class Sphere(_VolumeEntityBase): private_attribute_entity_type_name: Literal["Sphere"] = pd.Field("Sphere", frozen=True) # pylint: disable=no-member - center: LengthType.Point = pd.Field(description="The center point of the sphere.") - radius: LengthType.Positive = pd.Field(description="The radius of the sphere.") + center: Length.Vector3 = pd.Field(description="The center point of the sphere.") + radius: Length.PositiveFloat64 = pd.Field(description="The radius of the sphere.") axis: Axis = pd.Field( default=(0, 0, 1), description="The axis of rotation for the sphere (used in sliding interfaces).", @@ -629,8 +628,8 @@ class AxisymmetricBody(_VolumeEntityBase): ) axis: Axis = pd.Field(description="The axis of the body of revolution.") # pylint: disable=no-member - center: LengthType.Point = pd.Field(description="The center point of the body of revolution.") - profile_curve: List[LengthType.Pair] = pd.Field( + center: Length.Vector3 = pd.Field(description="The center point of the body of revolution.") + profile_curve: List[Length.Vector2] = pd.Field( description="The (Axial, Radial) profile of the body of revolution.", min_length=2, ) @@ -1038,14 +1037,14 @@ class SeedpointVolume(_VolumeEntityBase): "SeedpointVolume", frozen=True ) type: Literal["SeedpointVolume"] = pd.Field("SeedpointVolume", frozen=True) - point_in_mesh: LengthType.Point = pd.Field( + point_in_mesh: Length.Vector3 = pd.Field( description="Seedpoint for a main fluid zone in snappyHexMesh." ) axes: Optional[OrthogonalAxes] = pd.Field( None, description="Principal axes definition when using with PorousMedium" ) # Porous media support axis: Optional[Axis] = pd.Field(None) # Rotation support - center: Optional[LengthType.Point] = pd.Field(None, description="") # Rotation support + center: Optional[Length.Vector3] = pd.Field(None, description="") # Rotation support private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) def _per_entity_type_validation(self, param_info: ParamsValidationInfo): @@ -1108,7 +1107,7 @@ class CustomVolume(_VolumeEntityBase): axes: Optional[OrthogonalAxes] = pd.Field(None, description="") # Porous media support axis: Optional[Axis] = pd.Field(None) # Rotation support # pylint: disable=no-member - center: Optional[LengthType.Point] = pd.Field(None, description="") # Rotation support + center: Optional[Length.Vector3] = pd.Field(None, description="") # Rotation support @pd.model_validator(mode="before") @classmethod diff --git a/flow360/component/simulation/run_control/stopping_criterion.py b/flow360/component/simulation/run_control/stopping_criterion.py index 70d3f8462..8e3239108 100644 --- a/flow360/component/simulation/run_control/stopping_criterion.py +++ b/flow360/component/simulation/run_control/stopping_criterion.py @@ -3,8 +3,8 @@ from typing import List, Literal, Optional, Union import pydantic as pd +import unyt as u -import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.param_utils import serialize_model_obj_to_id from flow360.component.simulation.outputs.output_entities import Point @@ -15,7 +15,6 @@ SurfaceIntegralOutput, SurfaceProbeOutput, ) -from flow360.component.simulation.unit_system import u from flow360.component.simulation.user_code.core.types import ( SolverVariable, UnytQuantity, diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 1e8dabc0c..5603d2bca 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -18,7 +18,9 @@ ) import pydantic as pd +from flow360_schema.framework.physical_dimensions import Angle, Length from flow360_schema.framework.validation.context import DeserializationContext +from pydantic import TypeAdapter from pydantic_core import ErrorDetails # Required for correct global scope initialization @@ -72,17 +74,13 @@ get_volume_meshing_json, ) from flow360.component.simulation.unit_system import ( - AngleType, - CGS_unit_system, - LengthType, - SI_unit_system, + _UNIT_SYSTEMS, UnitSystem, _dimensioned_type_serializer, - flow360_unit_system, - imperial_unit_system, u, unit_system_manager, ) +from flow360.component.simulation.units import validate_length from flow360.component.simulation.user_code.core.types import ( UserVariable, get_referenced_expressions_and_user_variables, @@ -103,20 +101,12 @@ # Required for correct global scope initialization -unit_system_map = { - "SI": SI_unit_system, - "CGS": CGS_unit_system, - "Imperial": imperial_unit_system, - "Flow360": flow360_unit_system, -} - - def init_unit_system(unit_system_name) -> UnitSystem: """Returns UnitSystem object from string representation. Parameters ---------- - unit_system_name : ["SI", "CGS", "Imperial", "Flow360"] + unit_system_name : ["SI", "CGS", "Imperial"] Unit system string representation Returns @@ -132,11 +122,10 @@ def init_unit_system(unit_system_name) -> UnitSystem: If this function is run inside unit system context """ - unit_system = unit_system_map.get(unit_system_name, None) - if not isinstance(unit_system, UnitSystem): + unit_system = _UNIT_SYSTEMS.get(unit_system_name) + if unit_system is None: raise ValueError( - f"Incorrect unit system provided for {unit_system_name} unit " - f"system, got {unit_system=}, expected value of type UnitSystem" + f"Unknown unit system: {unit_system_name!r}. " f"Available: {list(_UNIT_SYSTEMS)}" ) if unit_system_manager.current is not None: @@ -157,7 +146,7 @@ def _store_project_length_unit(project_length_unit, params: SimulationParams): return params -def _get_default_reference_geometry(length_unit: LengthType): +def _get_default_reference_geometry(length_unit: Length.Float64): return ReferenceGeometry( area=1 * length_unit**2, moment_center=(0, 0, 0) * length_unit, @@ -191,7 +180,7 @@ def get_default_params( unit_system = init_unit_system(unit_system_name) dummy_value = 0.1 - project_length_unit = LengthType.validate(length_unit) # pylint: disable=no-member + project_length_unit = validate_length(length_unit) with unit_system: reference_geometry = _get_default_reference_geometry(project_length_unit) operating_condition = AerospaceCondition(velocity_magnitude=dummy_value) @@ -750,10 +739,39 @@ def validate_error_locations(errors: list, params: dict) -> list: if not valid: error["loc"] = tuple(loc for loc in error["loc"] if loc != field) + _normalize_union_branch_error_location(error, current) _populate_error_context(error) return errors +def _normalize_union_branch_error_location(error: dict, current) -> None: + """ + Hide internal tagged-union branch names from user-facing error locations. + + ValueOrExpression uses tagged union branches named ``number`` and ``expression``. + Pydantic includes the selected branch tag in ``loc``. When the original input is a + legacy ``{\"value\": ..., \"units\": ...}`` payload, restore the old ``value`` leaf. + Otherwise, collapse the synthetic branch name to the parent field. + """ + loc = error.get("loc") + if not isinstance(loc, tuple) or len(loc) == 0: + return + + branch = loc[-1] + if branch not in {"number", "expression"}: + return + + if isinstance(current, dict): + if branch == "number" and "value" in current: + error["loc"] = (*loc[:-1], "value") + return + if branch == "expression" and "expression" in current: + error["loc"] = (*loc[:-1], "expression") + return + + error["loc"] = loc[:-1] + + def _traverse_error_location(current, field): """ Traverse through the error location path within the parameters. @@ -956,7 +974,8 @@ def generate_process_json( """ params_as_dict = json.loads(simulation_json) - mesh_unit = _get_mesh_unit(params_as_dict) + # Pre-check that project_length_unit exists before validation + _get_mesh_unit(params_as_dict) # Note: There should not be any validation error for params_as_dict. Here is just a deserialization of the JSON params, errors, _ = validate_model( @@ -971,6 +990,10 @@ def generate_process_json( if errors is not None: raise ValueError(str(errors)) + # Extract the validated mesh_unit (a proper unyt quantity) from the params object, + # not from the raw dict which may be a bare number. + mesh_unit = params.private_attribute_asset_cache.project_length_unit + surface_mesh_res = _process_surface_mesh(params, root_item_type, mesh_unit) volume_mesh_res = _process_volume_mesh(params, root_item_type, mesh_unit, up_to) case_res = _process_case(params, mesh_unit, up_to) @@ -1164,15 +1187,32 @@ def _serialize_unit_in_dict(data): return data -def _validate_unit_string(unit_str: str, unit_type: Union[AngleType, LengthType]) -> bool: +_angle_adapter = TypeAdapter(Angle.Float64) +_length_adapter = TypeAdapter(Length.Float64) + +_UNIT_TYPE_ADAPTERS = { + "angle": _angle_adapter, + "length": _length_adapter, +} + + +def _validate_unit_string(unit_str: str, unit_kind: Literal["angle", "length"]): """ - Validate the unit string from request against the specified unit type. + Validate the unit string from request against the specified unit kind. + + Parameters + ---------- + unit_str : str + JSON-encoded or plain unit string. + unit_kind : str + One of "angle" or "length". """ + adapter = _UNIT_TYPE_ADAPTERS[unit_kind] try: unit_dict = json.loads(unit_str) - return unit_type.validate(unit_dict) + return adapter.validate_python(unit_dict) except json.JSONDecodeError: - return unit_type.validate(unit_str) + return adapter.validate_python(u.Unit(unit_str)) def translate_dfdc_xrotor_bet_disk( @@ -1190,8 +1230,8 @@ def translate_dfdc_xrotor_bet_disk( errors = [] bet_dict_list = [] try: - length_unit = _validate_unit_string(length_unit, LengthType) - angle_unit = _validate_unit_string(angle_unit, AngleType) + length_unit = _validate_unit_string(length_unit, "length") + angle_unit = _validate_unit_string(angle_unit, "angle") bet_disk_dict = translate_xrotor_dfdc_to_bet_dict( geometry_file_content=geometry_file_content, length_unit=length_unit, @@ -1221,8 +1261,8 @@ def translate_xfoil_c81_bet_disk( errors = [] bet_dict_list = [] try: - length_unit = _validate_unit_string(length_unit, LengthType) - angle_unit = _validate_unit_string(angle_unit, AngleType) + length_unit = _validate_unit_string(length_unit, "length") + angle_unit = _validate_unit_string(angle_unit, "angle") polar_file_name_list = generate_polar_file_name_list( geometry_file_content=geometry_file_content ) @@ -1295,7 +1335,7 @@ def merge_geometry_entity_info( if draft_param_entity_info_dict.get("type_name") != "GeometryEntityInfo": return draft_param_as_dict - current_entity_info = GeometryEntityInfo.model_validate(draft_param_entity_info_dict) + current_entity_info = GeometryEntityInfo.deserialize(draft_param_entity_info_dict) entity_info_components = [] for geometry_param_as_dict in geometry_dependencies_param_as_dict: @@ -1304,9 +1344,7 @@ def merge_geometry_entity_info( ).get("project_entity_info", {}) if dependency_entity_info_dict.get("type_name") != "GeometryEntityInfo": continue - entity_info_components.append( - GeometryEntityInfo.model_validate(dependency_entity_info_dict) - ) + entity_info_components.append(GeometryEntityInfo.deserialize(dependency_entity_info_dict)) merged_entity_info = merge_geometry_entity_info_obj( current_entity_info=current_entity_info, diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index e2fba5cb2..3ed34f445 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -9,11 +9,19 @@ import pydantic as pd import unyt as u +from flow360_schema.framework.physical_dimensions import ( + AbsoluteTemperature, + Density, + Length, + Mass, + Time, + Velocity, +) from flow360_schema.framework.validation.context import DeserializationContext from flow360.component.simulation.conversion import ( LIQUID_IMAGINARY_FREESTREAM_MACH, - unit_converter, + RestrictedUnitSystem, ) from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.boundary_split import ( @@ -73,20 +81,12 @@ from flow360.component.simulation.run_control.run_control import RunControl from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady from flow360.component.simulation.unit_system import ( - AbsoluteTemperatureType, - DensityType, DimensionedTypes, - LengthType, - MassType, - SI_unit_system, - TimeType, UnitSystem, - UnitSystemType, - VelocityType, - is_flow360_unit, + UnitSystemConfig, unit_system_manager, - unyt_quantity, ) +from flow360.component.simulation.units import validate_length from flow360.component.simulation.user_code.core.types import ( UserVariable, batch_get_user_variable_units, @@ -162,7 +162,7 @@ class _ParamModelBase(Flow360BaseModel): """ version: str = pd.Field(__version__, frozen=True) - unit_system: UnitSystemType = pd.Field(frozen=True, discriminator="name") + unit_system: UnitSystemConfig = pd.Field(frozen=True) model_config = pd.ConfigDict(include_hash=True) @classmethod @@ -172,22 +172,33 @@ def _init_check_unit_system(cls, **kwargs): Raises if an explicit kwarg unit_system conflicts with the active context. Returns (resolved_unit_system, remaining_kwargs). """ + if unit_system_manager.current is None: + raise Flow360RuntimeError( + "Please use a unit system context (e.g. `with SI_unit_system:`) " + "when constructing SimulationParams from Python." + ) + kwarg_unit_system = kwargs.pop("unit_system", None) if kwarg_unit_system is not None: - if not isinstance(kwarg_unit_system, UnitSystem): - kwarg_unit_system = UnitSystem.from_dict(**kwarg_unit_system) - if ( - unit_system_manager.current is not None - and kwarg_unit_system != unit_system_manager.current - ): + # Resolve to UnitSystem for comparison + if isinstance(kwarg_unit_system, UnitSystemConfig): + resolved = kwarg_unit_system.resolve() + elif isinstance(kwarg_unit_system, dict): + resolved = UnitSystemConfig.model_validate(kwarg_unit_system).resolve() + elif isinstance(kwarg_unit_system, UnitSystem): + resolved = kwarg_unit_system + else: + raise Flow360RuntimeError(f"Unexpected unit_system type: {type(kwarg_unit_system)}") + if resolved != unit_system_manager.current: raise Flow360RuntimeError( unit_system_inconsistent_msg( - kwarg_unit_system.system_repr(), + resolved.system_repr(), unit_system_manager.current.system_repr(), ) ) + else: + resolved = unit_system_manager.current - resolved = kwarg_unit_system or unit_system_manager.current or SI_unit_system return resolved, kwargs @classmethod @@ -236,8 +247,7 @@ def from_file(cls, filename: str): model_dict = cls._handle_file(filename=filename) model_dict = cls._sanitize_params_dict(model_dict) model_dict, _ = cls._update_param_dict(model_dict) - with DeserializationContext(): - return cls.model_validate(model_dict) + return cls.deserialize(model_dict) def _init_no_unit_context(self, filename, file_content, **kwargs): """ @@ -261,9 +271,10 @@ def _init_with_unit_context(self, **kwargs): This is the entry when user construct Param with Python script. """ # When treating dicts the updater is skipped. - unit_system, kwargs = _ParamModelBase._init_check_unit_system(**kwargs) + _, kwargs = _ParamModelBase._init_check_unit_system(**kwargs) - super().__init__(unit_system=unit_system, **kwargs) + current = unit_system_manager.current + super().__init__(unit_system=UnitSystemConfig(name=current.name), **kwargs) # pylint: disable=super-init-not-called # pylint: disable=fixme @@ -271,13 +282,22 @@ def _init_with_unit_context(self, **kwargs): def __init__(self, filename: str = None, file_content: dict = None, **kwargs): if filename is not None or file_content is not None: self._init_no_unit_context(filename, file_content, **kwargs) - else: + elif unit_system_manager.current is not None: self._init_with_unit_context(**kwargs) + elif "unit_system" in kwargs: + # Deserialization path (model_validate) — unit_system already in dict + with DeserializationContext(): + super().__init__(**kwargs) + else: + raise Flow360RuntimeError( + "Please use a unit system context (e.g. `with SI_unit_system:`) " + "when constructing SimulationParams from Python." + ) def copy(self, update=None, **kwargs) -> _ParamModelBase: if unit_system_manager.current is None: - # pylint: disable=not-context-manager - with self.unit_system: + # pylint: disable=not-context-manager,no-member + with self.unit_system.resolve(): return super().copy(update=update, **kwargs) return super().copy(update=update, **kwargs) @@ -359,10 +379,10 @@ def _preprocess(self, mesh_unit=None, exclude: list = None) -> SimulationParams: if mesh_unit is None: raise Flow360ConfigurationError("Mesh unit has not been supplied.") - self._private_set_length_unit(LengthType.validate(mesh_unit)) # pylint: disable=no-member + self._private_set_length_unit(validate_length(mesh_unit)) if unit_system_manager.current is None: - # pylint: disable=not-context-manager - with self.unit_system: + # pylint: disable=not-context-manager,no-member + with self.unit_system.resolve(): return super().preprocess( params=self, exclude=exclude, @@ -382,7 +402,7 @@ def convert_unit( self, value: DimensionedTypes, target_system: Literal["SI", "Imperial", "flow360"], - length_unit: Optional[LengthType] = None, + length_unit: Optional[Length.Float64] = None, ): """ Converts a given value to the specified unit system. @@ -397,7 +417,7 @@ def convert_unit( unit system. target_system : str The target unit system for conversion. Common values include "SI", "Imperial", "flow360". - length_unit : LengthType, optional + length_unit : Length.Float64, optional The length unit to use for conversion. If not provided, the method defaults to the project length unit stored in the `private_attribute_asset_cache`. @@ -425,23 +445,11 @@ def convert_unit( if length_unit is not None: # pylint: disable=no-member - self._private_set_length_unit(LengthType.validate(length_unit)) - - flow360_conv_system = unit_converter( - value.units.dimensions, - params=self, - required_by=[f"{self.__class__.__name__}.convert_unit(value=, target_system=)"], - ) + self._private_set_length_unit(validate_length(length_unit)) - if target_system == "flow360": - target_system = "flow360_v2" - - if is_flow360_unit(value) and not isinstance(value, unyt_quantity): - converted = value.in_base(target_system, flow360_conv_system) - else: - value.units.registry = flow360_conv_system.registry # pylint: disable=no-member - converted = value.in_base(unit_system=target_system) - return converted + if target_system in ("flow360", "flow360_v2"): + return value.in_base(unit_system=self.flow360_unit_system) + return value.in_base(unit_system=target_system) # pylint: disable=no-self-argument @pd.field_validator("models", mode="after") @@ -722,13 +730,13 @@ def _update_entity_private_attrs(self, registry: EntityRegistry) -> EntityRegist return registry @property - def base_length(self) -> LengthType: + def base_length(self) -> Length.Float64: """Get base length unit for non-dimensionalization""" # pylint:disable=no-member return self.private_attribute_asset_cache.project_length_unit.to("m") @property - def base_temperature(self) -> AbsoluteTemperatureType: + def base_temperature(self) -> AbsoluteTemperature.Float64: """Get base temperature unit for non-dimensionalization""" # pylint:disable=no-member if self.operating_condition.type_name == "LiquidOperatingCondition": @@ -739,7 +747,7 @@ def base_temperature(self) -> AbsoluteTemperatureType: return self.operating_condition.thermal_state.temperature.to("K") @property - def base_velocity(self) -> VelocityType: + def base_velocity(self) -> Velocity.Float64: """Get base velocity unit for non-dimensionalization""" # pylint:disable=no-member if self.operating_condition.type_name == "LiquidOperatingCondition": @@ -759,7 +767,7 @@ def base_velocity(self) -> VelocityType: return self.operating_condition.thermal_state.speed_of_sound.to("m/s") @property - def reference_velocity(self) -> VelocityType: + def reference_velocity(self) -> Velocity.Float64: """ This function returns the **reference velocity**. Note that the reference velocity is **NOT** the non-dimensionalization velocity scale @@ -784,7 +792,7 @@ def reference_velocity(self) -> VelocityType: return reference_velocity @property - def base_density(self) -> DensityType: + def base_density(self) -> Density.Float64: """Get base density unit for non-dimensionalization""" # pylint:disable=no-member if self.operating_condition.type_name == "LiquidOperatingCondition": @@ -792,30 +800,27 @@ def base_density(self) -> DensityType: return self.operating_condition.thermal_state.density.to("kg/m**3") @property - def base_mass(self) -> MassType: + def base_mass(self) -> Mass.Float64: """Get base mass unit for non-dimensionalization""" return self.base_density * self.base_length**3 @property - def base_time(self) -> TimeType: + def base_time(self) -> Time.Float64: """Get base time unit for non-dimensionalization""" return self.base_length / self.base_velocity @property def flow360_unit_system(self) -> u.UnitSystem: - """Get the unit system for non-dimensionalization""" - if self.operating_condition is None: - # Pure meshing mode - return u.UnitSystem( - name="flow360_nondim", - length_unit=self.base_length, - mass_unit=1 * u.kg, # pylint: disable=no-member - time_unit=1 * u.s, # pylint: disable=no-member - temperature_unit=1 * u.K, # pylint: disable=no-member - ) + """Get the unit system for non-dimensionalization. - return u.UnitSystem( - name="flow360_nondim", + In meshing-only mode (no operating_condition), returns a RestrictedUnitSystem + that only supports length conversions. Attempting to convert other dimensions + raises ValueError. + """ + if self.operating_condition is None: + return RestrictedUnitSystem("flow360_nondim", length_unit=self.base_length) + return RestrictedUnitSystem( + "flow360_nondim", length_unit=self.base_length, mass_unit=self.base_mass, time_unit=self.base_time, diff --git a/flow360/component/simulation/time_stepping/time_stepping.py b/flow360/component/simulation/time_stepping/time_stepping.py index d3ec5d1ad..7ebbe2b77 100644 --- a/flow360/component/simulation/time_stepping/time_stepping.py +++ b/flow360/component/simulation/time_stepping/time_stepping.py @@ -3,9 +3,9 @@ from typing import Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import Time from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.unit_system import TimeType from flow360.component.simulation.user_code.core.types import ValueOrExpression @@ -186,7 +186,7 @@ class Unsteady(Flow360BaseModel): ) steps: pd.PositiveInt = pd.Field(description="Number of physical steps.") # pylint: disable=no-member - step_size: ValueOrExpression[TimeType.Positive] = pd.Field( + step_size: ValueOrExpression[Time.PositiveFloat64] = pd.Field( description="Time step size in physical step marching," ) # pylint: disable=duplicate-code diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index d0f323f3f..912506dc2 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -8,6 +8,7 @@ import numpy as np import unyt as u +from flow360_schema.framework.physical_dimensions import Length from flow360.component.simulation.conversion import ( LIQUID_IMAGINARY_FREESTREAM_MACH, @@ -132,7 +133,6 @@ translate_setting_and_apply_to_all_entities, translate_value_or_expression_object, ) -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.user_code.core.types import ( Expression, UserVariable, @@ -1424,7 +1424,9 @@ def actuator_disk_translator(model: ActuatorDisk): } if model.reference_velocity is not None: ref_vel = remove_units_in_dict( - model.model_dump(by_alias=True, include={"reference_velocity"}) + model.model_dump( + by_alias=True, include={"reference_velocity"}, context={"no_unit": True} + ) ) result["referenceVelocity"] = convert_tuples_to_lists(ref_vel["referenceVelocity"]) return result @@ -1817,11 +1819,13 @@ def get_force_output_models(force_output: ForceOutput, params: SimulationParams) recursive_remove_key( model_dict, "privateAttributeId", "privateAttributeInputCache" ) - force_output_models_dict.append(json.dumps(model_dict)) + force_output_models_dict.append(json.dumps(model_dict, sort_keys=True)) json_string_list.extend(force_output_models_dict) json_string_list.extend(output.output_fields.items) if output.moving_statistic is not None: - json_string_list.append(json.dumps(dump_dict(output.moving_statistic))) + json_string_list.append( + json.dumps(dump_dict(output.moving_statistic), sort_keys=True) + ) combined_string = "".join(sorted(json_string_list)) hasher = hashlib.sha256() hasher.update(combined_string.encode("utf-8")) @@ -2132,7 +2136,7 @@ def translate_thermally_perfect_gas( # pylint: disable=too-many-locals def get_solver_json( input_params: SimulationParams, # pylint: disable=no-member,unused-argument - mesh_unit: LengthType.Positive, + mesh_unit: Length.PositiveFloat64, ): """ Get the solver json from the simulation parameters. @@ -2570,7 +2574,7 @@ def get_solver_json( def get_columnar_data_processor_json( input_params: SimulationParams, # pylint: disable=no-member,unused-argument - mesh_unit: LengthType.Positive, + mesh_unit: Length.PositiveFloat64, ): """ Get the columnar data processor json from the simulation parameters. diff --git a/flow360/component/simulation/translator/surface_meshing_translator.py b/flow360/component/simulation/translator/surface_meshing_translator.py index 5de67d54c..f9547f9e9 100644 --- a/flow360/component/simulation/translator/surface_meshing_translator.py +++ b/flow360/component/simulation/translator/surface_meshing_translator.py @@ -3,6 +3,7 @@ from copy import deepcopy from typing import List +from flow360_schema.framework.physical_dimensions import Length from unyt import unyt_array from flow360.component.simulation.entity_info import GeometryEntityInfo @@ -31,7 +32,6 @@ translate_setting_and_apply_to_all_entities, using_snappy, ) -from flow360.component.simulation.unit_system import LengthType from flow360.exceptions import Flow360TranslationError from flow360.log import log @@ -89,7 +89,7 @@ def SurfaceRefinement_to_faces( } -def remove_numerical_noise_from_spacing(spacing: LengthType, spacing_system: OctreeSpacing): +def remove_numerical_noise_from_spacing(spacing: Length.Float64, spacing_system: OctreeSpacing): """ If the spacing is in the proximity of 1e-8 to one of the octree series spacing casts that spacing onto the series. """ diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index df3456d6c..6944e302e 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -10,6 +10,7 @@ import numpy as np import pydantic as pd import unyt as u +from flow360_schema.framework.physical_dimensions import Length from flow360.component.simulation.draft_context.coordinate_system_manager import ( CoordinateSystemManager, @@ -27,7 +28,7 @@ _VolumeEntityBase, ) from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import LengthType +from flow360.component.simulation.units import validate_length from flow360.component.simulation.user_code.core.types import Expression, UserVariable from flow360.component.simulation.utils import is_exact_instance from flow360.exceptions import Flow360TranslationError @@ -172,7 +173,7 @@ def wrapper(input_params, mesh_unit, *args, **kwargs): ] else: preprocess_exclude = [] - validated_mesh_unit = LengthType.validate(mesh_unit) + validated_mesh_unit = validate_length(mesh_unit) processed_input = preprocess_param(input_params, validated_mesh_unit, preprocess_exclude) apply_coordinate_system_transformations(processed_input) @@ -189,7 +190,7 @@ def wrapper(input_params, mesh_unit, *args, **kwargs): def preprocess_param( input_params: SimulationParams | str | dict, - validated_mesh_unit: LengthType, + validated_mesh_unit: Length.Float64, preprocess_exclude: list[str], ): """ diff --git a/flow360/component/simulation/unit_system.py b/flow360/component/simulation/unit_system.py index 88f79572f..7ee88ee0b 100644 --- a/flow360/component/simulation/unit_system.py +++ b/flow360/component/simulation/unit_system.py @@ -6,54 +6,48 @@ from __future__ import annotations from abc import ABCMeta -from enum import Enum from numbers import Number -from operator import add, sub -from threading import RLock -from typing import Annotated, Any, Collection, List, Literal, Union +from typing import Annotated, List, Union import annotated_types import numpy as np import pydantic as pd import unyt as u import unyt.dimensions as udim + +# Importing unit_system triggers udim.* dimension registrations and +# unit_systems configuration on the schema side. Must happen before +# _DimensionedType subclass bodies reference udim.viscosity etc. +from flow360_schema.framework.unit_system import ( # pylint: disable=unused-import + _UNIT_SYSTEMS, + CGS_unit_system, + CGSUnitSystem, + ImperialUnitSystem, + SI_unit_system, + SIUnitSystem, + UnitSystem, + UnitSystemConfig, + create_flow360_unit_system, + imperial_unit_system, +) +from flow360_schema.framework.unit_system.base_system_type import ( # pylint: disable=unused-import + BaseSystemType, +) + +# pylint: disable=wrong-import-order +from flow360_schema.framework.validation.context import ( # pylint: disable=unused-import + unit_system_manager, +) from pydantic import PlainSerializer from pydantic_core import InitErrorDetails, core_schema -from sympy import Symbol # because unit_system.py is the only interface to our unit functions, you can import unit_quantity directly # "from unit_system import unyt_quantity" instead of knowing existence of unyt package. from unyt import unyt_quantity # pylint: disable=unused-import -from flow360.log import log +# pylint: enable=wrong-import-order from flow360.utils import classproperty -udim.viscosity = udim.pressure * udim.time -udim.kinematic_viscosity = udim.length * udim.length / udim.time -udim.angular_velocity = udim.angle / udim.time -udim.acceleration = udim.length / udim.time**2 -udim.heat_flux = udim.mass / udim.time**3 -udim.moment = udim.force * udim.length -udim.heat_source = udim.mass / udim.time**3 / udim.length -udim.specific_heat_capacity = udim.length**2 / udim.temperature / udim.time**2 -udim.thermal_conductivity = udim.mass / udim.time**3 * udim.length / udim.temperature -udim.inverse_area = 1 / udim.area -udim.inverse_length = 1 / udim.length -udim.mass_flow_rate = udim.mass / udim.time -udim.specific_energy = udim.length**2 * udim.time ** (-2) -udim.frequency = udim.time ** (-1) -udim.delta_temperature = Symbol("(delta temperature)", positive=True) - -# u.Unit("delta_degF") is parsed by unyt as 'ΔdegF and cannot find the unit. Had to use expr instead. -u.unit_systems.imperial_unit_system["temperature"] = u.Unit("degF").expr -u.unit_systems.imperial_unit_system["delta_temperature"] = u.Unit("delta_degF").expr -u.unit_systems.mks_unit_system["delta_temperature"] = u.Unit("K").expr -u.unit_systems.cgs_unit_system["delta_temperature"] = u.Unit("K").expr - -# Register with flow360-schema so new schema types respect unit system context -# pylint: disable=wrong-import-position,wrong-import-order -from flow360_schema.framework.validation.context import unit_system_manager - def _encode_ndarray(x): """ @@ -81,8 +75,6 @@ def _check_if_input_is_nested_collection(value, nest_level): def get_nesting_level(value): if isinstance(value, np.ndarray): return value.ndim - if isinstance(value, _Flow360BaseUnit): - return value.value.ndim if isinstance(value, (list, tuple)): return 1 + max(get_nesting_level(item) for item in value) return 0 @@ -203,18 +195,12 @@ def _unit_inference_validator(value, dim_name, is_array=False, is_matrix=False): if is_matrix: if all(all(isinstance(item, Number) for item in row) for row in value): float64_tuple = tuple(tuple(np.float64(row)) for row in value) - if isinstance(unit, _Flow360BaseUnit): - return float64_tuple * unit return float64_tuple * unit.units if is_array: if all(isinstance(item, Number) for item in value): float64_tuple = tuple(np.float64(item) for item in value) - if isinstance(unit, _Flow360BaseUnit): - return float64_tuple * unit return float64_tuple * unit.units if isinstance(value, Number): - if isinstance(unit, _Flow360BaseUnit): - return np.float64(value) * unit return np.float64(value) * unit.units return value @@ -267,7 +253,7 @@ def _enforce_float64(unyt_obj): """ This make sure all the values are float64 to minimize floating point errors """ - if isinstance(unyt_obj, (u.Unit, _Flow360BaseUnit)): + if isinstance(unyt_obj, u.Unit): return unyt_obj # Determine if the object is a scalar or an array and cast to float64 @@ -304,7 +290,7 @@ def validate(cls, value, *args, **kwargs): """ try: - value = _unit_object_parser(value, [u.unyt_quantity, _Flow360BaseUnit.factory]) + value = _unit_object_parser(value, [u.unyt_quantity]) value = _is_unit_validator(value) if cls.has_defaults: value = _unit_inference_validator(value, cls.dim_name) @@ -343,9 +329,9 @@ def __get_pydantic_json_schema__(cls, schema: pd.CoreSchema, handler: pd.GetJson units = [str(unit) for unit in ordered_complete_units[cls.dim_name]] else: units = [ - str(_SI_system[cls.dim_name]), - str(_CGS_system[cls.dim_name]), - str(_imperial_system[cls.dim_name]), + str(u.unit_systems.mks_unit_system[cls.dim_name]), + str(u.unit_systems.cgs_unit_system[cls.dim_name]), + str(u.unit_systems.imperial_unit_system[cls.dim_name]), ] units += [str(unit) for unit in extra_units[cls.dim_name]] units = list(dict.fromkeys(units)) @@ -494,7 +480,7 @@ def __get_pydantic_json_schema__( def validate(vec_cls, value, info, *args, **kwargs): """additional validator for value""" try: - value = _unit_object_parser(value, [u.unyt_array, _Flow360BaseUnit.factory]) + value = _unit_object_parser(value, [u.unyt_array]) value = _list_of_unyt_quantity_to_unyt_array(value) value = _is_unit_validator(value) @@ -597,7 +583,7 @@ def __get_pydantic_json_schema__( def validate(matrix_cls, value, *args, **kwargs): """additional validator for value""" try: - value = _unit_object_parser(value, [u.unyt_array, _Flow360BaseUnit.factory]) + value = _unit_object_parser(value, [u.unyt_array]) value = _is_unit_validator(value) is_nested_collection = _check_if_input_is_nested_collection( @@ -614,12 +600,7 @@ def validate(matrix_cls, value, *args, **kwargs): + f"with the 1st dimension as {shape[0]}." ) - if shape[1] and any( - len(item) != shape[1] - for item in ( - value if not isinstance(value, _Flow360BaseUnit) else value.val - ) - ): + if shape[1] and any(len(item) != shape[1] for item in value): raise TypeError( f"arg '{value}' needs to be a 2-dimensional collection of values " + f"with the 2nd dimension as {shape[1]}." @@ -1099,956 +1080,4 @@ class _FrequencyType(_DimensionedType): FrequencyType, ] - -def _iterable(obj): - try: - len(obj) - except TypeError: - return False - return True - - -class _Flow360BaseUnit(_DimensionedType): - dimension_type = None - unit_name = None - - @classproperty - def units(self): - """ - Retrieve units of a flow360 unit system value - """ - parent_self = self - - # pylint: disable=too-few-public-methods - # pylint: disable=invalid-name - class _Units: - dimensions = self.dimension_type.dim - - def __str__(self): - return f"{parent_self.unit_name}" - - def expr(self): - """alias for __str__ so the serializer can work""" - return str(self) - - return _Units() - - @property - def value(self): - """ - Retrieve value of a flow360 unit system value, use np.ndarray to keep interface consistent with unyt - """ - return np.asarray(self.val) - - # pylint: disable=invalid-name - @property - def v(self): - "alias for value" - return self.value - - def __init__(self, val=None) -> None: - self.val = val - - @classmethod - def factory(cls, value, unit_name, dtype=np.float64): - """Returns specialized class object based on unit name - - Parameters - ---------- - value : Numeric or Collection - Base value - unit_name : str - Unit name, e.g. flow360_length_unit - - Returns - ------- - Specialized _Flow360BaseUnit - Returns specialized _Flow360BaseUnit such as unit_name equals provided unit_name - - Raises - ------ - ValueError - If specialized class was not found based on provided unit_name - """ - for sub_classes in _Flow360BaseUnit.__subclasses__(): - if sub_classes.unit_name == unit_name: - return sub_classes(dtype(value)) - raise ValueError(f"No class found for unit_name: {unit_name}") - - def __eq__(self, other): - if isinstance(other, self.__class__): - return self.val == other.val - return False - - def __ne__(self, other): - if isinstance(other, self.__class__): - return self.val != other.val - return True - - def __lt__(self, other): - """ - This seems consistent with unyt in that for numbers only value is compared - e.g.: - >>> 1*u.mm >0.1 - array(True) - """ - if isinstance(other, self.__class__): - return self.val < other.val - if isinstance(other, Number): - return self.val < other - raise ValueError( - f"Invalid other value type for comparison, expected Number or Flow360BaseUnit but got {type(other)}" - ) - - def __gt__(self, other): - if isinstance(other, self.__class__): - return self.val > other.val - if isinstance(other, Number): - return self.val > other - raise ValueError( - f"Invalid other value type for comparison, expected Number or Flow360BaseUnit but got {type(other)}" - ) - - def __len__(self): - if self.val and isinstance(self.val, Collection): - return len(self.val) - return 1 - - @property - def size(self): - """implements numpy size interface""" - return len(self) - - def _unit_iter(self, iter_obj): - if not _iterable(iter_obj): - yield self.__class__(iter_obj) - else: - for value in iter(iter_obj): - yield self.__class__(value) - - def __iter__(self): - try: - return self._unit_iter(self.val) - except TypeError as exc: - raise TypeError(f"{self} is not iterable") from exc - - def __repr__(self): - if self.val: - return f"({self.val}, {self.units})" - return f"({self.units})" - - def __str__(self): - if self.val: - return f"{self.val} {self.units}" - return f"{self.units}" - - def _can_do_math_operations(self, other): - if self.val is None: - raise ValueError( - "Cannot perform math operations on units only. Multiply unit by numerical value first." - ) - if not isinstance(other, self.__class__): - raise TypeError(f"Operation not defined on {self} and {other}") - - def __rsub__(self, other): - self._can_do_math_operations(other) - return self.__class__(other.val - self.val) - - def __sub__(self, other): - self._can_do_math_operations(other) - if isinstance(self.val, Collection): - return self.__class__(list(map(sub, self.val, other.val))) - return self.__class__(self.val - other.val) - - def __radd__(self, other): - self._can_do_math_operations(other) - return self.__add__(other) - - def __add__(self, other): - self._can_do_math_operations(other) - if isinstance(self.val, Collection): - return self.__class__(list(map(add, self.val, other.val))) - return self.__class__(self.val + other.val) - - def __rmul__(self, unit): - return self.__mul__(unit) - - def __mul__(self, other): - if isinstance(other, Number): - if self.val: - return self.__class__(self.val * other) - return self.__class__(other) - if isinstance(other, Collection) and (not self.val or self.val == 1): - return self.__class__(other) - raise TypeError(f"Operation not defined on {self} and {other}") - - def in_base(self, base, flow360_conv_system): - """ - Convert unit to a specific base system - """ - value = self.value * flow360_conv_system[self.dimension_type.dim_name] - value.units.registry = flow360_conv_system.registry - converted = value.in_base(unit_system=base) - return converted - - -class Flow360LengthUnit(_Flow360BaseUnit): - """:class: Flow360LengthUnit""" - - dimension_type = LengthType - unit_name = "flow360_length_unit" - - -class Flow360AngleUnit(_Flow360BaseUnit): - """:class: Flow360AngleUnit""" - - dimension_type = AngleType - unit_name = "flow360_angle_unit" - - -class Flow360MassUnit(_Flow360BaseUnit): - """:class: Flow360MassUnit""" - - dimension_type = MassType - unit_name = "flow360_mass_unit" - - -class Flow360TimeUnit(_Flow360BaseUnit): - """:class: Flow360TimeUnit""" - - dimension_type = TimeType - unit_name = "flow360_time_unit" - - -class Flow360TemperatureUnit(_Flow360BaseUnit): - """ - :class: Flow360TemperatureUnit. - This is absolute temperature because temperature is scaled with Kelvin temperature. - """ - - dimension_type = AbsoluteTemperatureType - unit_name = "flow360_temperature_unit" - - -class Flow360DeltaTemperatureUnit(_Flow360BaseUnit): - """ - :class: Flow360DeltaTemperatureUnit. - """ - - dimension_type = DeltaTemperatureType - unit_name = "flow360_delta_temperature_unit" - - -class Flow360VelocityUnit(_Flow360BaseUnit): - """:class: Flow360VelocityUnit""" - - dimension_type = VelocityType - unit_name = "flow360_velocity_unit" - - -class Flow360AccelerationUnit(_Flow360BaseUnit): - """:class: Flow360AccelerationUnit""" - - dimension_type = AccelerationType - unit_name = "flow360_acceleration_unit" - - -class Flow360AreaUnit(_Flow360BaseUnit): - """:class: Flow360AreaUnit""" - - dimension_type = AreaType - unit_name = "flow360_area_unit" - - -class Flow360ForceUnit(_Flow360BaseUnit): - """:class: Flow360ForceUnit""" - - dimension_type = ForceType - unit_name = "flow360_force_unit" - - -class Flow360PressureUnit(_Flow360BaseUnit): - """:class: Flow360PressureUnit""" - - dimension_type = PressureType - unit_name = "flow360_pressure_unit" - - -class Flow360DensityUnit(_Flow360BaseUnit): - """:class: Flow360DensityUnit""" - - dimension_type = DensityType - unit_name = "flow360_density_unit" - - -class Flow360ViscosityUnit(_Flow360BaseUnit): - """:class: Flow360ViscosityUnit""" - - dimension_type = ViscosityType - unit_name = "flow360_viscosity_unit" - - -class Flow360KinematicViscosityUnit(_Flow360BaseUnit): - """:class: Flow360KinematicViscosityUnit""" - - dimension_type = KinematicViscosityType - unit_name = "flow360_kinematic_viscosity_unit" - - -class Flow360PowerUnit(_Flow360BaseUnit): - """:class: Flow360PowerUnit""" - - dimension_type = PowerType - unit_name = "flow360_power_unit" - - -class Flow360MomentUnit(_Flow360BaseUnit): - """:class: Flow360MomentUnit""" - - dimension_type = MomentType - unit_name = "flow360_moment_unit" - - -class Flow360AngularVelocityUnit(_Flow360BaseUnit): - """:class: Flow360AngularVelocityUnit""" - - dimension_type = AngularVelocityType - unit_name = "flow360_angular_velocity_unit" - - -class Flow360HeatFluxUnit(_Flow360BaseUnit): - """:class: Flow360HeatFluxUnit""" - - dimension_type = HeatFluxType - unit_name = "flow360_heat_flux_unit" - - -class Flow360HeatSourceUnit(_Flow360BaseUnit): - """:class: Flow360HeatSourceUnit""" - - dimension_type = HeatSourceType - unit_name = "flow360_heat_source_unit" - - -class Flow360SpecificHeatCapacityUnit(_Flow360BaseUnit): - """:class: Flow360SpecificHeatCapacityUnit""" - - dimension_type = SpecificHeatCapacityType - unit_name = "flow360_specific_heat_capacity_unit" - - -class Flow360ThermalConductivityUnit(_Flow360BaseUnit): - """:class: Flow360ThermalConductivityUnit""" - - dimension_type = ThermalConductivityType - unit_name = "flow360_thermal_conductivity_unit" - - -class Flow360InverseAreaUnit(_Flow360BaseUnit): - """:class: Flow360InverseAreaUnit""" - - dimension_type = InverseAreaType - unit_name = "flow360_inverse_area_unit" - - -class Flow360InverseLengthUnit(_Flow360BaseUnit): - """:class: Flow360InverseLengthUnit""" - - dimension_type = InverseLengthType - unit_name = "flow360_inverse_length_unit" - - -class Flow360MassFlowRateUnit(_Flow360BaseUnit): - """:class: Flow360MassFlowRateUnit""" - - dimension_type = MassFlowRateType - unit_name = "flow360_mass_flow_rate_unit" - - -class Flow360SpecificEnergyUnit(_Flow360BaseUnit): - """:class: Flow360SpecificEnergyUnit""" - - dimension_type = SpecificEnergyType - unit_name = "flow360_specific_energy_unit" - - -class Flow360FrequencyUnit(_Flow360BaseUnit): - """:class: Flow360FrequencyUnit""" - - dimension_type = FrequencyType - unit_name = "flow360_frequency_unit" - - -def is_flow360_unit(value): - """ - Check if the provided value represents a dimensioned quantity with units - that start with 'flow360'. - - Parameters: - - value: The value to be checked for units. - - Returns: - - bool: True if the value has units starting with 'flow360', False otherwise. - - Raises: - - ValueError: If the provided value does not have the 'units' attribute. - """ - - if hasattr(value, "units"): - return str(value.units).startswith("flow360") - raise ValueError(f"Expected a dimensioned value, but {value} provided.") - - -_lock = RLock() - - -# pylint: disable=too-few-public-methods -class BaseSystemType(Enum): - """ - :class: Type of the base unit system to use for unit inference (all units need to be specified if not provided) - """ - - SI = "SI" - CGS = "CGS" - IMPERIAL = "Imperial" - FLOW360 = "Flow360" - NONE = None - - -_dim_names = [ - "mass", - "length", - "angle", - "time", - "temperature", - "velocity", - "acceleration", - "area", - "force", - "pressure", - "density", - "viscosity", - "kinematic_viscosity", - "power", - "moment", - "angular_velocity", - "heat_flux", - "heat_source", - "specific_heat_capacity", - "thermal_conductivity", - "inverse_area", - "inverse_length", - "mass_flow_rate", - "specific_energy", - "frequency", - "delta_temperature", -] - - -class UnitSystem(pd.BaseModel): - """ - :class: Customizable unit system containing definitions for most atomic and complex dimensions. - """ - - mass: MassType = pd.Field() - length: LengthType = pd.Field() - angle: AngleType = pd.Field() - time: TimeType = pd.Field() - temperature: AbsoluteTemperatureType = pd.Field() - velocity: VelocityType = pd.Field() - acceleration: AccelerationType = pd.Field() - area: AreaType = pd.Field() - force: ForceType = pd.Field() - pressure: PressureType = pd.Field() - density: DensityType = pd.Field() - viscosity: ViscosityType = pd.Field() - kinematic_viscosity: KinematicViscosityType = pd.Field() - power: PowerType = pd.Field() - moment: MomentType = pd.Field() - angular_velocity: AngularVelocityType = pd.Field() - heat_flux: HeatFluxType = pd.Field() - heat_source: HeatSourceType = pd.Field() - specific_heat_capacity: SpecificHeatCapacityType = pd.Field() - thermal_conductivity: ThermalConductivityType = pd.Field() - inverse_area: InverseAreaType = pd.Field() - inverse_length: InverseLengthType = pd.Field() - mass_flow_rate: MassFlowRateType = pd.Field() - specific_energy: SpecificEnergyType = pd.Field() - frequency: FrequencyType = pd.Field() - delta_temperature: DeltaTemperatureType = pd.Field() - - name: Literal["Custom"] = pd.Field("Custom") - - _verbose: bool = pd.PrivateAttr(True) - _context_token: Any = pd.PrivateAttr(None) - - @staticmethod - def __get_unit(system, dim_name, unit): - if unit is not None: - return unit - if system is not None: - if system == BaseSystemType.SI: - return _SI_system[dim_name] - if system == BaseSystemType.CGS: - return _CGS_system[dim_name] - if system == BaseSystemType.IMPERIAL: - return _imperial_system[dim_name] - if system == BaseSystemType.FLOW360: - return _flow360_system[dim_name] - return None - - def __init__(self, verbose: bool = True, **kwargs): - base_system = kwargs.get("base_system") - base_system = BaseSystemType(base_system) - units = {} - - for dim in _dim_names: - unit = kwargs.get(dim) - units[dim] = UnitSystem.__get_unit(base_system, dim, unit) - - missing = set(_dim_names) - set(units.keys()) - - super().__init__(**units, base_system=base_system) - - if len(missing) > 0: - raise ValueError( - f"Tried defining incomplete unit system, missing definitions for {','.join(missing)}" - ) - - self._verbose = verbose - - def __eq__(self, other): - equal = [getattr(self, name) == getattr(other, name) for name in _dim_names] - return all(equal) - - @classmethod - def from_dict(cls, verbose: bool = True, **kwargs): - """ - Construct a unit system from the provided dictionary. - - Parameters - ---------- - verbose : bool, optional - If False, suppress the info logging when the unit system context - is entered. By default True. - kwargs : - Fields of the unit system dictionary. - """ - - class _TemporaryModel(pd.BaseModel): - unit_system: UnitSystemType = pd.Field(discriminator="name") - - params = {"unit_system": kwargs} - model = _TemporaryModel(**params) - - unit_system = model.unit_system - unit_system._verbose = verbose # pylint: disable=protected-access - - return unit_system - - def defaults(self): - """ - Get the default units for each dimension in the unit system. - - Returns - ------- - dict - A dictionary containing the default units for each dimension. The keys are dimension names, and the values - are strings representing the default unit expressions. - - Example - ------- - >>> unit_system = UnitSystem(base_system=BaseSystemType.SI, length=u.m, mass=u.kg, time=u.s) - >>> unit_system.defaults() - {'mass': 'kg', 'length': 'm', 'time': 's', 'temperature': 'K', 'velocity': 'm/s', - 'area': 'm**2', 'force': 'N', 'pressure': 'Pa', 'density': 'kg/m**3', - 'viscosity': 'Pa*s', kinematic_viscosity': 'm**2/s', 'power': 'W', 'angular_velocity': 'rad/s', - 'heat_flux': 'kg/s**3', 'specific_heat_capacity': 'm**2/(s**2*K)', 'thermal_conductivity': 'kg*m/(s**3*K)', - 'inverse_area': '1/m**2', 'inverse_length': '1/m', 'heat_source': 'kg/(m*s**3)'} - """ - - defaults = {} - for item in self._dim_names: - defaults[item] = str(self[item].units) - return defaults - - def __getitem__(self, item): - """to support [] access""" - try: - return getattr(self, item) - except TypeError: - # Allowing usage like [(mass)/(time)] - for attr_name, attr in vars(self).items(): - if not isinstance(attr, unyt_quantity): - continue - if attr.units.dimensions == item: - return getattr(self, attr_name) - raise AttributeError(f"'{item}' is not a valid attribute of {self.__class__.__name__}. ") - - def system_repr(self): - """(mass, length, time, temperature) string representation of the system""" - units = [ - str(unit.units if unit.v == 1.0 else unit) - for unit in [self.mass, self.length, self.time, self.temperature] - ] - str_repr = f"({', '.join(units)})" - - return str_repr - - def _assert_no_active_unit_system(self): - active_unit_system = unit_system_manager.current - if active_unit_system is None: - return - active_name = ( - active_unit_system.system_repr() - if hasattr(active_unit_system, "system_repr") - else str(active_unit_system) - ) - raise RuntimeError( - "Nested unit system context is not allowed. " - f"Active unit system: {active_name}. " - f"Attempted: {self.system_repr()}. " - "Please remove the inner unit system context." - ) - - def __enter__(self): - _lock.acquire() - try: - self._assert_no_active_unit_system() - if self._verbose: - log.info(f"using: {self.system_repr()} unit system for unit inference.") - self._context_token = unit_system_manager.set_current(self) - return self - except Exception: - _lock.release() - raise - - def __exit__(self, exc_type, exc_val, exc_tb): - try: - if self._context_token is None: - raise RuntimeError("Unit system context exit called without a matching enter.") - unit_system_manager.reset_current(self._context_token) - self._context_token = None - finally: - _lock.release() - - -_SI_system = u.unit_systems.mks_unit_system -_CGS_system = u.unit_systems.cgs_unit_system -_imperial_system = u.unit_systems.imperial_unit_system - -flow360_length_unit = Flow360LengthUnit() -flow360_angle_unit = Flow360AngleUnit() -flow360_mass_unit = Flow360MassUnit() -flow360_time_unit = Flow360TimeUnit() -flow360_temperature_unit = Flow360TemperatureUnit() -flow360_velocity_unit = Flow360VelocityUnit() -flow360_acceleration_unit = Flow360AccelerationUnit() -flow360_area_unit = Flow360AreaUnit() -flow360_force_unit = Flow360ForceUnit() -flow360_pressure_unit = Flow360PressureUnit() -flow360_density_unit = Flow360DensityUnit() -flow360_viscosity_unit = Flow360ViscosityUnit() -flow360_kinematic_viscosity_unit = Flow360KinematicViscosityUnit() -flow360_power_unit = Flow360PowerUnit() -flow360_moment_unit = Flow360MomentUnit() -flow360_angular_velocity_unit = Flow360AngularVelocityUnit() -flow360_heat_flux_unit = Flow360HeatFluxUnit() -flow360_heat_source_unit = Flow360HeatSourceUnit() -flow360_specific_heat_capacity_unit = Flow360SpecificHeatCapacityUnit() -flow360_thermal_conductivity_unit = Flow360ThermalConductivityUnit() -flow360_inverse_area_unit = Flow360InverseAreaUnit() -flow360_inverse_length_unit = Flow360InverseLengthUnit() -flow360_mass_flow_rate_unit = Flow360MassFlowRateUnit() -flow360_specific_energy_unit = Flow360SpecificEnergyUnit() -flow360_delta_temperature_unit = Flow360DeltaTemperatureUnit() -flow360_frequency_unit = Flow360FrequencyUnit() - -dimensions = [ - flow360_length_unit, - flow360_angle_unit, - flow360_mass_unit, - flow360_time_unit, - flow360_temperature_unit, - flow360_velocity_unit, - flow360_acceleration_unit, - flow360_area_unit, - flow360_force_unit, - flow360_pressure_unit, - flow360_density_unit, - flow360_viscosity_unit, - flow360_kinematic_viscosity_unit, - flow360_power_unit, - flow360_moment_unit, - flow360_angular_velocity_unit, - flow360_heat_flux_unit, - flow360_specific_heat_capacity_unit, - flow360_thermal_conductivity_unit, - flow360_inverse_area_unit, - flow360_inverse_length_unit, - flow360_mass_flow_rate_unit, - flow360_specific_energy_unit, - flow360_delta_temperature_unit, - flow360_frequency_unit, - flow360_heat_source_unit, -] - -_flow360_system = {u.dimension_type.dim_name: u for u in dimensions} - - -# pylint: disable=too-many-instance-attributes -class Flow360ConversionUnitSystem(pd.BaseModel): - """ - Flow360ConversionUnitSystem class for setting conversion rates for converting from dimensioned values into flow360 - values - """ - - base_length: float = pd.Field(np.inf, json_schema_extra={"target_dimension": Flow360LengthUnit}) - base_angle: float = pd.Field(np.inf, json_schema_extra={"target_dimension": Flow360AngleUnit}) - base_mass: float = pd.Field(np.inf, json_schema_extra={"target_dimension": Flow360MassUnit}) - base_time: float = pd.Field(np.inf, json_schema_extra={"target_dimension": Flow360TimeUnit}) - base_temperature: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360TemperatureUnit} - ) - base_velocity: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360VelocityUnit} - ) - base_acceleration: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360AccelerationUnit} - ) - base_area: float = pd.Field(np.inf, json_schema_extra={"target_dimension": Flow360AreaUnit}) - base_force: float = pd.Field(np.inf, json_schema_extra={"target_dimension": Flow360ForceUnit}) - base_density: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360DensityUnit} - ) - base_pressure: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360PressureUnit} - ) - base_viscosity: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360ViscosityUnit} - ) - base_kinematic_viscosity: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360KinematicViscosityUnit} - ) - base_power: float = pd.Field(np.inf, json_schema_extra={"target_dimension": Flow360PowerUnit}) - base_moment: float = pd.Field(np.inf, json_schema_extra={"target_dimension": Flow360MomentUnit}) - base_angular_velocity: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360AngularVelocityUnit} - ) - base_heat_flux: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360HeatFluxUnit} - ) - base_heat_source: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360HeatSourceUnit} - ) - base_specific_heat_capacity: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360SpecificHeatCapacityUnit} - ) - base_thermal_conductivity: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360ThermalConductivityUnit} - ) - base_inverse_area: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360InverseAreaUnit} - ) - base_inverse_length: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360InverseLengthUnit} - ) - base_mass_flow_rate: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360MassFlowRateUnit} - ) - base_specific_energy: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360SpecificEnergyUnit} - ) - base_frequency: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360FrequencyUnit} - ) - base_delta_temperature: float = pd.Field( - np.inf, json_schema_extra={"target_dimension": Flow360DeltaTemperatureUnit} - ) - - registry: Any = pd.Field(frozen=False) - conversion_system: Any = pd.Field(frozen=False) - - model_config = pd.ConfigDict(extra="forbid", validate_assignment=True, frozen=False) - - def __init__(self): - registry = u.UnitRegistry() - - for field in self.__class__.model_fields.values(): - if field.json_schema_extra is not None: - target_dimension = field.json_schema_extra.get("target_dimension", None) - if target_dimension is not None: - registry.add( - target_dimension.unit_name, - field.default, - target_dimension.dimension_type.dim, - ) - - conversion_system = u.UnitSystem( - "flow360_v2", - "flow360_length_unit", - "flow360_mass_unit", - "flow360_time_unit", - "flow360_temperature_unit", - "flow360_angle_unit", - registry=registry, - ) - - conversion_system["velocity"] = "flow360_velocity_unit" - conversion_system["acceleration"] = "flow360_acceleration_unit" - conversion_system["area"] = "flow360_area_unit" - conversion_system["force"] = "flow360_force_unit" - conversion_system["density"] = "flow360_density_unit" - conversion_system["pressure"] = "flow360_pressure_unit" - conversion_system["viscosity"] = "flow360_viscosity_unit" - conversion_system["kinematic_viscosity"] = "flow360_kinematic_viscosity_unit" - conversion_system["power"] = "flow360_power_unit" - conversion_system["moment"] = "flow360_moment_unit" - conversion_system["angular_velocity"] = "flow360_angular_velocity_unit" - conversion_system["heat_flux"] = "flow360_heat_flux_unit" - conversion_system["heat_source"] = "flow360_heat_source_unit" - conversion_system["specific_heat_capacity"] = "flow360_specific_heat_capacity_unit" - conversion_system["thermal_conductivity"] = "flow360_thermal_conductivity_unit" - conversion_system["inverse_area"] = "flow360_inverse_area_unit" - conversion_system["inverse_length"] = "flow360_inverse_length_unit" - conversion_system["mass_flow_rate"] = "flow360_mass_flow_rate_unit" - conversion_system["specific_energy"] = "flow360_specific_energy_unit" - conversion_system["delta_temperature"] = "flow360_delta_temperature_unit" - conversion_system["frequency"] = "flow360_frequency_unit" - conversion_system["angle"] = "flow360_angle_unit" - - super().__init__(registry=registry, conversion_system=conversion_system) - - # pylint: disable=no-self-argument - @pd.field_validator("*") - @classmethod - def assign_conversion_rate(cls, value, info: pd.ValidationInfo): - """ - Pydantic validator for assigning conversion rates to a specific unit in the registry. - """ - field = cls.model_fields.get(info.field_name) - if field.json_schema_extra is not None: - target_dimension = field.json_schema_extra.get("target_dimension", None) - if target_dimension is not None: - registry = info.data["registry"] - registry.modify(target_dimension.unit_name, value) - - return value - - -flow360_conversion_unit_system = Flow360ConversionUnitSystem() - - -class _PredefinedUnitSystem(UnitSystem): - mass: MassType = pd.Field(exclude=True) - length: LengthType = pd.Field(exclude=True) - angle: AngleType = pd.Field(exclude=True) - time: TimeType = pd.Field(exclude=True) - temperature: AbsoluteTemperatureType = pd.Field(exclude=True) - velocity: VelocityType = pd.Field(exclude=True) - acceleration: AccelerationType = pd.Field(exclude=True) - area: AreaType = pd.Field(exclude=True) - force: ForceType = pd.Field(exclude=True) - pressure: PressureType = pd.Field(exclude=True) - density: DensityType = pd.Field(exclude=True) - viscosity: ViscosityType = pd.Field(exclude=True) - kinematic_viscosity: KinematicViscosityType = pd.Field(exclude=True) - power: PowerType = pd.Field(exclude=True) - moment: MomentType = pd.Field(exclude=True) - angular_velocity: AngularVelocityType = pd.Field(exclude=True) - heat_flux: HeatFluxType = pd.Field(exclude=True) - heat_source: HeatSourceType = pd.Field(exclude=True) - specific_heat_capacity: SpecificHeatCapacityType = pd.Field(exclude=True) - thermal_conductivity: ThermalConductivityType = pd.Field(exclude=True) - inverse_area: InverseAreaType = pd.Field(exclude=True) - inverse_length: InverseLengthType = pd.Field(exclude=True) - mass_flow_rate: MassFlowRateType = pd.Field(exclude=True) - specific_energy: SpecificEnergyType = pd.Field(exclude=True) - delta_temperature: DeltaTemperatureType = pd.Field(exclude=True) - frequency: FrequencyType = pd.Field(exclude=True) - - # pylint: disable=missing-function-docstring - def system_repr(self): - return self.name - - -class SIUnitSystem(_PredefinedUnitSystem): - """:class: `SIUnitSystem` predefined SI system wrapper""" - - name: Literal["SI"] = pd.Field("SI", frozen=True) - - def __init__(self, verbose: bool = True, **kwargs): - super().__init__(base_system=BaseSystemType.SI, verbose=verbose, **kwargs) - - # pylint: disable=missing-function-docstring - @classmethod - def validate(cls, _): - return SIUnitSystem() - - @classmethod - def __get_validators__(cls): - yield cls.validate - - -class CGSUnitSystem(_PredefinedUnitSystem): - """:class: `CGSUnitSystem` predefined CGS system wrapper""" - - name: Literal["CGS"] = pd.Field("CGS", frozen=True) - - def __init__(self, **kwargs): - super().__init__(base_system=BaseSystemType.CGS, **kwargs) - - # pylint: disable=missing-function-docstring - @classmethod - def validate(cls, _): - return CGSUnitSystem() - - @classmethod - def __get_validators__(cls): - yield cls.validate - - -class ImperialUnitSystem(_PredefinedUnitSystem): - """:class: `ImperialUnitSystem` predefined imperial system wrapper""" - - name: Literal["Imperial"] = pd.Field("Imperial", frozen=True) - - def __init__(self, **kwargs): - super().__init__(base_system=BaseSystemType.IMPERIAL, **kwargs) - - # pylint: disable=missing-function-docstring - @classmethod - def validate(cls, _): - return ImperialUnitSystem() - - @classmethod - def __get_validators__(cls): - yield cls.validate - - -class Flow360UnitSystem(_PredefinedUnitSystem): - """:class: `Flow360UnitSystem` predefined flow360 system wrapper""" - - name: Literal["Flow360"] = pd.Field("Flow360", frozen=True) - - def __init__(self, verbose: bool = True): - super().__init__(base_system=BaseSystemType.FLOW360, verbose=verbose) - - # pylint: disable=missing-function-docstring - @classmethod - def validate(cls, _): - return Flow360UnitSystem() - - @classmethod - def __get_validators__(cls): - yield cls.validate - - -UnitSystemType = Union[ - SIUnitSystem, CGSUnitSystem, ImperialUnitSystem, Flow360UnitSystem, UnitSystem -] - -SI_unit_system = SIUnitSystem() -CGS_unit_system = CGSUnitSystem() -imperial_unit_system = ImperialUnitSystem() -flow360_unit_system = Flow360UnitSystem() +UnitSystemType = UnitSystem diff --git a/flow360/component/simulation/units.py b/flow360/component/simulation/units.py index d37397c8a..18ef9d48d 100644 --- a/flow360/component/simulation/units.py +++ b/flow360/component/simulation/units.py @@ -2,6 +2,8 @@ This module is for accessing units and unit systems including flow360 unit system. """ +import functools + import unyt from unyt import unit_symbols @@ -10,23 +12,6 @@ CGS_unit_system, SI_unit_system, UnitSystem, - flow360_angle_unit, - flow360_angular_velocity_unit, - flow360_area_unit, - flow360_density_unit, - flow360_force_unit, - flow360_frequency_unit, - flow360_kinematic_viscosity_unit, - flow360_length_unit, - flow360_mass_flow_rate_unit, - flow360_mass_unit, - flow360_pressure_unit, - flow360_specific_energy_unit, - flow360_temperature_unit, - flow360_time_unit, - flow360_unit_system, - flow360_velocity_unit, - flow360_viscosity_unit, imperial_unit_system, ) @@ -36,24 +21,7 @@ "CGS_unit_system", "SI_unit_system", "UnitSystem", - "flow360_angular_velocity_unit", - "flow360_area_unit", - "flow360_density_unit", - "flow360_force_unit", - "flow360_length_unit", - "flow360_angle_unit", - "flow360_mass_unit", - "flow360_pressure_unit", - "flow360_temperature_unit", - "flow360_time_unit", - "flow360_unit_system", - "flow360_velocity_unit", - "flow360_viscosity_unit", - "flow360_kinematic_viscosity_unit", "imperial_unit_system", - "flow360_mass_flow_rate_unit", - "flow360_specific_energy_unit", - "flow360_frequency_unit", ] @@ -66,3 +34,26 @@ def import_units(module, namespace): import_units(unit_symbols, globals()) del import_units + + +@functools.lru_cache(maxsize=1) +def _get_length_adapter(): + """Lazily build and cache TypeAdapter(Length.Float64) to avoid import-time cost.""" + from flow360_schema.framework.physical_dimensions import ( # pylint: disable=import-outside-toplevel + Length, + ) + from pydantic import TypeAdapter # pylint: disable=import-outside-toplevel + + return TypeAdapter(Length.Float64) + + +def validate_length(value): + """Validate a value as Length.Float64 using a cached TypeAdapter. + + Replacement for the old LengthType.validate() pattern. + Accepts unyt quantities, dicts, and bare numbers (interpreted as SI meters). + For backward compatibility, plain unit strings are interpreted as 1 * unit. + """ + if isinstance(value, str): + value = 1 * unyt.Unit(value) + return _get_length_adapter().validate_python(value) diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index a4a238fc9..65f3aa5c3 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -328,7 +328,7 @@ def value(self, value): @pd.model_validator(mode="before") @classmethod - def deserialize(cls, values): + def preprocess_variable_declaration(cls, values): """ Supporting syntax like `a = fl.Variable(name="a", value=1, description="some description")`. """ @@ -1666,6 +1666,6 @@ def compute_surface_integral_unit(variable: UserVariable, params) -> str: # Fallback if output_units is not set for expression or if it is a number base_unit = u.Unit("dimensionless") - area_unit = params.unit_system["area"].units + area_unit = params.unit_system.resolve()["area"].units result_unit = base_unit * area_unit return str(result_unit) diff --git a/flow360/component/simulation/validation/validation_context.py b/flow360/component/simulation/validation/validation_context.py index d3c1b638f..9c6965c12 100644 --- a/flow360/component/simulation/validation/validation_context.py +++ b/flow360/component/simulation/validation/validation_context.py @@ -23,12 +23,13 @@ from typing import Any, Callable, List, Literal, Union import pydantic as pd +from flow360_schema.framework.physical_dimensions import Length from flow360_schema.framework.validation.context import ( # noqa: F401 — re-used, not redefined + DeserializationContext, _validation_level_ctx, ) from pydantic import Field, TypeAdapter -from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.utils import BoundingBoxType SURFACE_MESH = "SurfaceMesh" @@ -158,7 +159,6 @@ class ParamsValidationInfo: # pylint:disable=too-few-public-methods,too-many-in @classmethod def _get_farfield_method_(cls, param_as_dict: dict): meshing = param_as_dict.get("meshing") - modular = False if meshing is None: # No meshing info. return None @@ -167,8 +167,8 @@ def _get_farfield_method_(cls, param_as_dict: dict): volume_zones = meshing.get("volume_zones") else: volume_zones = meshing.get("zones") - modular = True if volume_zones: + has_custom_zones = False for zone in volume_zones: if zone["type"] == "AutomatedFarfield": return zone["method"] @@ -176,15 +176,10 @@ def _get_farfield_method_(cls, param_as_dict: dict): return "user-defined" if zone["type"] == "WindTunnelFarfield": return "wind-tunnel" - if ( - zone["type"] - in [ - "CustomZones", - "SeedpointVolume", - ] - and modular - ): - return "user-defined" + if zone["type"] in ("CustomZones", "SeedpointVolume"): + has_custom_zones = True + if has_custom_zones: # CV + no FF => implicit UD + return "user-defined" return None @@ -268,8 +263,11 @@ def _get_project_length_unit_(cls, param_as_dict: dict): "project_length_unit" ] if project_length_unit_dict: - # pylint: disable=no-member - return LengthType.validate(project_length_unit_dict) + # Serialized value is a bare float (SI), use DeserializationContext + # so the schema validator interprets it as SI meters. + adapter = TypeAdapter(Length.PositiveFloat64) + with DeserializationContext(): + return adapter.validate_python(project_length_unit_dict) return None except KeyError: return None @@ -452,6 +450,7 @@ def _get_farfield_enclosed_entities(self, param_as_dict: dict) -> dict[str, str] for zone in volume_zones: if zone.get("type") not in ( "AutomatedFarfield", + "UserDefinedFarfield", "WindTunnelFarfield", ): continue diff --git a/flow360/component/simulation/web/asset_base.py b/flow360/component/simulation/web/asset_base.py index 34c5a7a94..be8e43c11 100644 --- a/flow360/component/simulation/web/asset_base.py +++ b/flow360/component/simulation/web/asset_base.py @@ -212,7 +212,7 @@ def info(self) -> AssetMetaBaseModelV2: @property def entity_info(self): """Return the entity info associated with the asset (copy to prevent unintentional overwrites)""" - return self._entity_info.model_validate(self._entity_info.model_dump()) + return self._entity_info.deserialize(self._entity_info.model_dump()) @property def params(self): diff --git a/flow360/plugins/report/report_items.py b/flow360/plugins/report/report_items.py index ebb3a05c7..8e9237363 100644 --- a/flow360/plugins/report/report_items.py +++ b/flow360/plugins/report/report_items.py @@ -38,11 +38,7 @@ ) from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.time_stepping.time_stepping import Unsteady -from flow360.component.simulation.unit_system import ( - DimensionedTypes, - is_flow360_unit, - unyt_quantity, -) +from flow360.component.simulation.unit_system import DimensionedTypes, unyt_quantity from flow360.exceptions import Flow360ValidationError from flow360.log import log from flow360.plugins.report.report_context import ReportContext @@ -1813,17 +1809,19 @@ def _get_limits(self, case: Case): ) else 1 ) - if isinstance(self.limits[0], unyt_quantity) or is_flow360_unit(self.limits[0]): + if isinstance(self.limits[0], unyt_quantity): target_system = "flow360" if unit_system is not None: target_system = unit_system - min_val = ( - params.convert_unit(self.limits[0], target_system=target_system) * liquid_factor - ) - max_val = ( - params.convert_unit(self.limits[1], target_system=target_system) * liquid_factor + min_converted = params.convert_unit(self.limits[0], target_system=target_system) + max_converted = params.convert_unit(self.limits[1], target_system=target_system) + # Extract numerical value before arithmetic to avoid unyt + # collapsing complex unit expressions (e.g. 340.29*m/s → m/s). + liquid_factor_float = float(liquid_factor) + return ( + float(min_converted.value) * liquid_factor_float, + float(max_converted.value) * liquid_factor_float, ) - return (float(min_val.value), float(max_val.value)) return self.limits diff --git a/plan_sort_json.markdown b/plan_sort_json.markdown new file mode 100644 index 000000000..d27a71ef4 --- /dev/null +++ b/plan_sort_json.markdown @@ -0,0 +1,22 @@ +# Plan: Reference JSON Key Sorting Script + +## 目标 +写一个 Python 脚本,递归排序 reference JSON 文件的 keys,方便 code review 时看 diff。 + +## 脚本功能 +- 路径: `scripts/sort_ref_json.py` +- 零外部依赖(只用标准库 `json`, `pathlib`, `sys`) +- 两种模式: + - **修复模式** (默认): `python scripts/sort_ref_json.py` — 原地重写所有未排序的 JSON 文件 + - **检查模式**: `python scripts/sort_ref_json.py --check` — 只检查,不修改,未排序则 exit 1(用于 CI) +- 扫描范围: `tests/` 目录下所有 `*.json` 文件 +- 递归排序所有嵌套 dict 的 keys(list 内的 dict 也排序 keys,但 list 元素顺序不变) +- 保持 4 空格缩进 + trailing newline(与现有格式一致) + +## 步骤 +1. 创建 `scripts/sort_ref_json.py` +2. (可选) 在 CI 配置里加 `python scripts/sort_ref_json.py --check` + +## 不做的事 +- 不引入 pre-commit framework +- 不修改非 tests/ 目录的 JSON 文件 diff --git a/poetry.lock b/poetry.lock index f6f269deb..5f0a63156 100644 --- a/poetry.lock +++ b/poetry.lock @@ -475,18 +475,18 @@ css = ["tinycss2 (>=1.1.0,<1.5)"] [[package]] name = "boto3" -version = "1.42.68" +version = "1.42.64" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "boto3-1.42.68-py3-none-any.whl", hash = "sha256:dbff353eb7dc93cbddd7926ed24793e0174c04adbe88860dfa639568442e4962"}, - {file = "boto3-1.42.68.tar.gz", hash = "sha256:3f349f967ab38c23425626d130962bcb363e75f042734fe856ea8c5a00eef03c"}, + {file = "boto3-1.42.64-py3-none-any.whl", hash = "sha256:2ca6b472937a54ba74af0b4bede582ba98c070408db1061fc26d5c3aa8e6e7e6"}, + {file = "boto3-1.42.64.tar.gz", hash = "sha256:58d47897a26adbc22f6390d133dab772fb606ba72695291a8c9e20cba1c7fd23"}, ] [package.dependencies] -botocore = ">=1.42.68,<1.43.0" +botocore = ">=1.42.64,<1.43.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.16.0,<0.17.0" @@ -495,14 +495,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.42.68" +version = "1.42.64" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "botocore-1.42.68-py3-none-any.whl", hash = "sha256:9df7da26374601f890e2f115bfa573d65bf15b25fe136bb3aac809f6145f52ab"}, - {file = "botocore-1.42.68.tar.gz", hash = "sha256:3951c69e12ac871dda245f48dac5c7dd88ea1bfdd74a8879ec356cf2874b806a"}, + {file = "botocore-1.42.64-py3-none-any.whl", hash = "sha256:f77c5cb76ed30576ed0bc73b591265d03dddffff02a9208d3ee0c790f43d3cd2"}, + {file = "botocore-1.42.64.tar.gz", hash = "sha256:4ee2aece227b9171ace8b749af694a77ab984fceab1639f2626bd0d6fb1aa69d"}, ] [package.dependencies] @@ -571,15 +571,15 @@ xcb = ["xcffib (>=1.4.0)"] [[package]] name = "cairosvg" -version = "2.9.0" +version = "2.8.2" description = "A Simple SVG Converter based on Cairo" optional = true -python-versions = ">=3.10" +python-versions = ">=3.9" groups = ["main"] markers = "extra == \"docs\"" files = [ - {file = "cairosvg-2.9.0-py3-none-any.whl", hash = "sha256:4b82d07d145377dffdfc19d9791bd5fb65539bb4da0adecf0bdbd9cd4ffd7c68"}, - {file = "cairosvg-2.9.0.tar.gz", hash = "sha256:1debb00cd2da11350d8b6f5ceb739f1b539196d71d5cf5eb7363dbd1bfbc8dc5"}, + {file = "cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5"}, + {file = "cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f"}, ] [package.dependencies] @@ -1439,27 +1439,27 @@ devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benc [[package]] name = "filelock" -version = "3.25.2" +version = "3.25.1" description = "A platform independent file lock." optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"docs\"" files = [ - {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, - {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, + {file = "filelock-3.25.1-py3-none-any.whl", hash = "sha256:18972df45473c4aa2c7921b609ee9ca4925910cc3a0fb226c96b92fc224ef7bf"}, + {file = "filelock-3.25.1.tar.gz", hash = "sha256:b9a2e977f794ef94d77cdf7d27129ac648a61f585bff3ca24630c1629f701aa9"}, ] [[package]] name = "flow360-schema" -version = "0.1.12+feat.2ndstageunitmigrationfoll.5053b95" +version = "0.1.12+feat.2ndstageunitmigrationfoll.ac7e8fd" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.12+feat.2ndstageunitmigrationfoll.5053b95-py3-none-any.whl", hash = "sha256:7fb3784689220885a2c418f4e5c3a99591d940da6d2a4c2f59814385ab3fe8e1"}, - {file = "flow360_schema-0.1.12+feat.2ndstageunitmigrationfoll.5053b95.tar.gz", hash = "sha256:33c0458f552e48114f9c41add8959c42c0b8d2f02537047d13a6534edd260af2"}, + {file = "flow360_schema-0.1.12+feat.2ndstageunitmigrationfoll.ac7e8fd-py3-none-any.whl", hash = "sha256:738b6dd9a185f0f131728149098a86e396b83c9ac76fe1e49683f649c34630b0"}, + {file = "flow360_schema-0.1.12+feat.2ndstageunitmigrationfoll.ac7e8fd.tar.gz", hash = "sha256:cf7578901c294f8913ce64b7aee13e121d6187c8f0bb247f4a39f0ca0726d185"}, ] [package.dependencies] @@ -1475,62 +1475,62 @@ reference = "codeartifact" [[package]] name = "fonttools" -version = "4.62.1" +version = "4.62.0" description = "Tools to manipulate font files" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"}, - {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"}, - {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3"}, - {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23"}, - {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d"}, - {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae"}, - {file = "fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed"}, - {file = "fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9"}, - {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7"}, - {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14"}, - {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7"}, - {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b"}, - {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1"}, - {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416"}, - {file = "fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53"}, - {file = "fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2"}, - {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974"}, - {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9"}, - {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936"}, - {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392"}, - {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04"}, - {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d"}, - {file = "fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c"}, - {file = "fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42"}, - {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79"}, - {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe"}, - {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68"}, - {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1"}, - {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069"}, - {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9"}, - {file = "fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24"}, - {file = "fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056"}, - {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca"}, - {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca"}, - {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782"}, - {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae"}, - {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7"}, - {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a"}, - {file = "fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800"}, - {file = "fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e"}, - {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82"}, - {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260"}, - {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4"}, - {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b"}, - {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87"}, - {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c"}, - {file = "fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a"}, - {file = "fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e"}, - {file = "fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd"}, - {file = "fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d"}, + {file = "fonttools-4.62.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:62b6a3d0028e458e9b59501cf7124a84cd69681c433570e4861aff4fb54a236c"}, + {file = "fonttools-4.62.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:966557078b55e697f65300b18025c54e872d7908d1899b7314d7c16e64868cb2"}, + {file = "fonttools-4.62.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf34861145b516cddd19b07ae6f4a61ea1c6326031b960ec9ddce8ee815e888"}, + {file = "fonttools-4.62.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e2ff573de2775508c8a366351fb901c4ced5dc6cf2d87dd15c973bedcdd5216"}, + {file = "fonttools-4.62.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:55b189a1b3033860a38e4e5bd0626c5aa25c7ce9caee7bc784a8caec7a675401"}, + {file = "fonttools-4.62.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:825f98cd14907c74a4d0a3f7db8570886ffce9c6369fed1385020febf919abf6"}, + {file = "fonttools-4.62.0-cp310-cp310-win32.whl", hash = "sha256:c858030560f92a054444c6e46745227bfd3bb4e55383c80d79462cd47289e4b5"}, + {file = "fonttools-4.62.0-cp310-cp310-win_amd64.whl", hash = "sha256:9bf75eb69330e34ad2a096fac67887102c8537991eb6cac1507fc835bbb70e0a"}, + {file = "fonttools-4.62.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:196cafef9aeec5258425bd31a4e9a414b2ee0d1557bca184d7923d3d3bcd90f9"}, + {file = "fonttools-4.62.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:153afc3012ff8761b1733e8fbe5d98623409774c44ffd88fbcb780e240c11d13"}, + {file = "fonttools-4.62.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13b663fb197334de84db790353d59da2a7288fd14e9be329f5debc63ec0500a5"}, + {file = "fonttools-4.62.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:591220d5333264b1df0d3285adbdfe2af4f6a45bbf9ca2b485f97c9f577c49ff"}, + {file = "fonttools-4.62.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:579f35c121528a50c96bf6fcb6a393e81e7f896d4326bf40e379f1c971603db9"}, + {file = "fonttools-4.62.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:44956b003151d5a289eba6c71fe590d63509267c37e26de1766ba15d9c589582"}, + {file = "fonttools-4.62.0-cp311-cp311-win32.whl", hash = "sha256:42c7848fa8836ab92c23b1617c407a905642521ff2d7897fe2bf8381530172f1"}, + {file = "fonttools-4.62.0-cp311-cp311-win_amd64.whl", hash = "sha256:4da779e8f342a32856075ddb193b2a024ad900bc04ecb744014c32409ae871ed"}, + {file = "fonttools-4.62.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22bde4dc12a9e09b5ced77f3b5053d96cf10c4976c6ac0dee293418ef289d221"}, + {file = "fonttools-4.62.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7199c73b326bad892f1cb53ffdd002128bfd58a89b8f662204fbf1daf8d62e85"}, + {file = "fonttools-4.62.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d732938633681d6e2324e601b79e93f7f72395ec8681f9cdae5a8c08bc167e72"}, + {file = "fonttools-4.62.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31a804c16d76038cc4e3826e07678efb0a02dc4f15396ea8e07088adbfb2578e"}, + {file = "fonttools-4.62.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:090e74ac86e68c20150e665ef8e7e0c20cb9f8b395302c9419fa2e4d332c3b51"}, + {file = "fonttools-4.62.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f086120e8be9e99ca1288aa5ce519833f93fe0ec6ebad2380c1dee18781f0b5"}, + {file = "fonttools-4.62.0-cp312-cp312-win32.whl", hash = "sha256:37a73e5e38fd05c637daede6ffed5f3496096be7df6e4a3198d32af038f87527"}, + {file = "fonttools-4.62.0-cp312-cp312-win_amd64.whl", hash = "sha256:658ab837c878c4d2a652fcbb319547ea41693890e6434cf619e66f79387af3b8"}, + {file = "fonttools-4.62.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:274c8b8a87e439faf565d3bcd3f9f9e31bca7740755776a4a90a4bfeaa722efa"}, + {file = "fonttools-4.62.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93e27131a5a0ae82aaadcffe309b1bae195f6711689722af026862bede05c07c"}, + {file = "fonttools-4.62.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83c6524c5b93bad9c2939d88e619fedc62e913c19e673f25d5ab74e7a5d074e5"}, + {file = "fonttools-4.62.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:106aec9226f9498fc5345125ff7200842c01eda273ae038f5049b0916907acee"}, + {file = "fonttools-4.62.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15d86b96c79013320f13bc1b15f94789edb376c0a2d22fb6088f33637e8dfcbc"}, + {file = "fonttools-4.62.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f16c07e5250d5d71d0f990a59460bc5620c3cc456121f2cfb5b60475699905f"}, + {file = "fonttools-4.62.0-cp313-cp313-win32.whl", hash = "sha256:d31558890f3fa00d4f937d12708f90c7c142c803c23eaeb395a71f987a77ebe3"}, + {file = "fonttools-4.62.0-cp313-cp313-win_amd64.whl", hash = "sha256:6826a5aa53fb6def8a66bf423939745f415546c4e92478a7c531b8b6282b6c3b"}, + {file = "fonttools-4.62.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4fa5a9c716e2f75ef34b5a5c2ca0ee4848d795daa7e6792bf30fd4abf8993449"}, + {file = "fonttools-4.62.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:625f5cbeb0b8f4e42343eaeb4bc2786718ddd84760a2f5e55fdd3db049047c00"}, + {file = "fonttools-4.62.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6247e58b96b982709cd569a91a2ba935d406dccf17b6aa615afaed37ac3856aa"}, + {file = "fonttools-4.62.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:840632ea9c1eab7b7f01c369e408c0721c287dfd7500ab937398430689852fd1"}, + {file = "fonttools-4.62.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:28a9ea2a7467a816d1bec22658b0cce4443ac60abac3e293bdee78beb74588f3"}, + {file = "fonttools-4.62.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ae611294f768d413949fd12693a8cba0e6332fbc1e07aba60121be35eac68d0"}, + {file = "fonttools-4.62.0-cp314-cp314-win32.whl", hash = "sha256:273acb61f316d07570a80ed5ff0a14a23700eedbec0ad968b949abaa4d3f6bb5"}, + {file = "fonttools-4.62.0-cp314-cp314-win_amd64.whl", hash = "sha256:a5f974006d14f735c6c878fc4b117ad031dc93638ddcc450ca69f8fd64d5e104"}, + {file = "fonttools-4.62.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0361a7d41d86937f1f752717c19f719d0fde064d3011038f9f19bdf5fc2f5c95"}, + {file = "fonttools-4.62.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4108c12773b3c97aa592311557c405d5b4fc03db2b969ed928fcf68e7b3c887"}, + {file = "fonttools-4.62.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b448075f32708e8fb377fe7687f769a5f51a027172c591ba9a58693631b077a8"}, + {file = "fonttools-4.62.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5f1fa8cc9f1a56a3e33ee6b954d6d9235e6b9d11eb7a6c9dfe2c2f829dc24db"}, + {file = "fonttools-4.62.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f8c8ea812f82db1e884b9cdb663080453e28f0f9a1f5027a5adb59c4cc8d38d1"}, + {file = "fonttools-4.62.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:03c6068adfdc67c565d217e92386b1cdd951abd4240d65180cec62fa74ba31b2"}, + {file = "fonttools-4.62.0-cp314-cp314t-win32.whl", hash = "sha256:d28d5baacb0017d384df14722a63abe6e0230d8ce642b1615a27d78ffe3bc983"}, + {file = "fonttools-4.62.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3f9e20c4618f1e04190c802acae6dc337cb6db9fa61e492fd97cd5c5a9ff6d07"}, + {file = "fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3"}, + {file = "fonttools-4.62.0.tar.gz", hash = "sha256:0dc477c12b8076b4eb9af2e440421b0433ffa9e1dcb39e0640a6c94665ed1098"}, ] [package.extras] @@ -2405,14 +2405,14 @@ test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (> [[package]] name = "jupyterlab" -version = "4.5.6" +version = "4.5.5" description = "JupyterLab computational environment" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "jupyterlab-4.5.6-py3-none-any.whl", hash = "sha256:d6b3dac883aa4d9993348e0f8e95b24624f75099aed64eab6a4351a9cdd1e580"}, - {file = "jupyterlab-4.5.6.tar.gz", hash = "sha256:642fe2cfe7f0f5922a8a558ba7a0d246c7bc133b708dfe43f7b3a826d163cf42"}, + {file = "jupyterlab-4.5.5-py3-none-any.whl", hash = "sha256:a35694a40a8e7f2e82f387472af24e61b22adcce87b5a8ab97a5d9c486202a6d"}, + {file = "jupyterlab-4.5.5.tar.gz", hash = "sha256:eac620698c59eb810e1729909be418d9373d18137cac66637141abba613b3fda"}, ] markers = {main = "extra == \"docs\""} @@ -3440,20 +3440,20 @@ markers = {main = "extra == \"docs\""} [[package]] name = "notebook" -version = "7.5.5" +version = "7.5.4" description = "Jupyter Notebook - A web-based notebook environment for interactive computing" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "notebook-7.5.5-py3-none-any.whl", hash = "sha256:a7c14dbeefa6592e87f72290ca982e0c10f5bbf3786be2a600fda9da2764a2b8"}, - {file = "notebook-7.5.5.tar.gz", hash = "sha256:dc0bfab0f2372c8278c457423d3256c34154ac2cc76bf20e9925260c461013c3"}, + {file = "notebook-7.5.4-py3-none-any.whl", hash = "sha256:860e31782b3d3a25ca0819ff039f5cf77845d1bf30c78ef9528b88b25e0a9850"}, + {file = "notebook-7.5.4.tar.gz", hash = "sha256:b928b2ba22cb63aa83df2e0e76fe3697950a0c1c4a41b84ebccf1972b1bb5771"}, ] markers = {main = "extra == \"docs\""} [package.dependencies] jupyter-server = ">=2.4.0,<3" -jupyterlab = ">=4.5.6,<4.6" +jupyterlab = ">=4.5.5,<4.6" jupyterlab-server = ">=2.28.0,<3" notebook-shim = ">=0.2,<0.3" tornado = ">=6.2.0" @@ -6236,22 +6236,24 @@ files = [ [[package]] name = "tornado" -version = "6.5.5" +version = "6.5.4" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"}, - {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"}, - {file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"}, - {file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"}, - {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"}, - {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"}, - {file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"}, - {file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"}, - {file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"}, - {file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"}, + {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"}, + {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"}, + {file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"}, + {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"}, + {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"}, + {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"}, + {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"}, + {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"}, + {file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"}, + {file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"}, + {file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"}, + {file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"}, ] markers = {main = "extra == \"docs\""} diff --git a/pyproject.toml b/pyproject.toml index 72a2b5e3d..33210ec1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "flow360" -version = "v25.9.3b1" +version = "v25.11.0b1" description = "Flow360 Python Client" authors = ["Flexcompute "] diff --git a/tests/ref/simulation/service_init_geometry.json b/tests/ref/simulation/service_init_geometry.json index 6c16ebaf3..866101ad1 100644 --- a/tests/ref/simulation/service_init_geometry.json +++ b/tests/ref/simulation/service_init_geometry.json @@ -2,18 +2,12 @@ "meshing": { "defaults": { "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": { - "units": "degree", - "value": 12.0 - }, + "curvature_resolution_angle": 0.20943951023931956, "planar_face_tolerance": 1e-06, "preserve_thin_geometry": false, "remove_hidden_geometry": false, "resolve_face_boundaries": false, - "sealing_size": { - "units": "m", - "value": 0.0 - }, + "sealing_size": 0.0, "sliding_interface_tolerance": 0.01, "surface_edge_growth_rate": 1.2, "surface_max_adaptation_iterations": 50, @@ -45,16 +39,10 @@ }, "heat_spec": { "type_name": "HeatFlux", - "value": { - "units": "W/m**2", - "value": 0.0 - } - }, - "name": "Wall", - "roughness_height": { - "units": "m", "value": 0.0 }, + "name": "Wall", + "roughness_height": 0.0, "type": "Wall" }, { @@ -82,18 +70,9 @@ "interface_interpolation_tolerance": 0.2, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -116,14 +95,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -208,18 +181,9 @@ "density": 1.225, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -242,14 +206,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -273,18 +231,9 @@ "density": 1.225, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -307,14 +256,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -363,10 +306,7 @@ } ], "private_attribute_asset_cache": { - "project_length_unit": { - "units": "m", - "value": 1.0 - }, + "project_length_unit": 1.0, "use_geometry_AI": false, "use_inhouse_mesher": false }, @@ -376,22 +316,16 @@ "units": "m**2", "value": 1.0 }, - "moment_center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "moment_length": { - "units": "m", - "value": [ - 1.0, - 1.0, - 1.0 - ] - } + "moment_center": [ + 0.0, + 0.0, + 0.0 + ], + "moment_length": [ + 1.0, + 1.0, + 1.0 + ] }, "time_stepping": { "CFL": { diff --git a/tests/ref/simulation/service_init_surface_mesh.json b/tests/ref/simulation/service_init_surface_mesh.json index bc77c40f3..780fc8e33 100644 --- a/tests/ref/simulation/service_init_surface_mesh.json +++ b/tests/ref/simulation/service_init_surface_mesh.json @@ -2,18 +2,12 @@ "meshing": { "defaults": { "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": { - "units": "degree", - "value": 12.0 - }, + "curvature_resolution_angle": 0.20943951023931956, "planar_face_tolerance": 1e-06, "preserve_thin_geometry": false, "remove_hidden_geometry": false, "resolve_face_boundaries": false, - "sealing_size": { - "units": "m", - "value": 0.0 - }, + "sealing_size": 0.0, "sliding_interface_tolerance": 0.01, "surface_edge_growth_rate": 1.2, "surface_max_adaptation_iterations": 50, @@ -45,16 +39,10 @@ }, "heat_spec": { "type_name": "HeatFlux", - "value": { - "units": "W/m**2", - "value": 0.0 - } - }, - "name": "Wall", - "roughness_height": { - "units": "cm", "value": 0.0 }, + "name": "Wall", + "roughness_height": 0.0, "type": "Wall" }, { @@ -82,18 +70,9 @@ "interface_interpolation_tolerance": 0.2, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -116,14 +95,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -208,18 +181,9 @@ "density": 1.225, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -242,14 +206,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -273,18 +231,9 @@ "density": 1.225, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -307,14 +256,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -363,10 +306,7 @@ } ], "private_attribute_asset_cache": { - "project_length_unit": { - "units": "cm", - "value": 1.0 - }, + "project_length_unit": 0.01, "use_geometry_AI": false, "use_inhouse_mesher": false }, @@ -376,22 +316,16 @@ "units": "cm**2", "value": 1.0 }, - "moment_center": { - "units": "cm", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "moment_length": { - "units": "cm", - "value": [ - 1.0, - 1.0, - 1.0 - ] - } + "moment_center": [ + 0.0, + 0.0, + 0.0 + ], + "moment_length": [ + 0.01, + 0.01, + 0.01 + ] }, "time_stepping": { "CFL": { diff --git a/tests/ref/simulation/service_init_volume_mesh.json b/tests/ref/simulation/service_init_volume_mesh.json index c1dd0476e..70308e46e 100644 --- a/tests/ref/simulation/service_init_volume_mesh.json +++ b/tests/ref/simulation/service_init_volume_mesh.json @@ -6,16 +6,10 @@ }, "heat_spec": { "type_name": "HeatFlux", - "value": { - "units": "W/m**2", - "value": 0.0 - } - }, - "name": "Wall", - "roughness_height": { - "units": "m", "value": 0.0 }, + "name": "Wall", + "roughness_height": 0.0, "type": "Wall" }, { @@ -37,18 +31,9 @@ "interface_interpolation_tolerance": 0.2, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -71,14 +56,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -163,18 +142,9 @@ "density": 1.225, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -197,14 +167,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -228,18 +192,9 @@ "density": 1.225, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -262,14 +217,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -318,10 +267,7 @@ } ], "private_attribute_asset_cache": { - "project_length_unit": { - "units": "m", - "value": 1.0 - }, + "project_length_unit": 1.0, "use_geometry_AI": false, "use_inhouse_mesher": false }, @@ -331,22 +277,16 @@ "units": "m**2", "value": 1.0 }, - "moment_center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "moment_length": { - "units": "m", - "value": [ - 1.0, - 1.0, - 1.0 - ] - } + "moment_center": [ + 0.0, + 0.0, + 0.0 + ], + "moment_length": [ + 1.0, + 1.0, + 1.0 + ] }, "time_stepping": { "CFL": { diff --git a/tests/ref/simulation/simulation_json_with_multi_constructor_used.json b/tests/ref/simulation/simulation_json_with_multi_constructor_used.json index e38dcbb4d..d1b845189 100644 --- a/tests/ref/simulation/simulation_json_with_multi_constructor_used.json +++ b/tests/ref/simulation/simulation_json_with_multi_constructor_used.json @@ -404,7 +404,7 @@ ], "type_name": "GeometryEntityInfo" }, - "project_length_unit": "m" + "project_length_unit": 1.0 }, "time_stepping": { "CFL": { diff --git a/tests/report/test_report_items.py b/tests/report/test_report_items.py index 68c933c16..5548b6ae3 100644 --- a/tests/report/test_report_items.py +++ b/tests/report/test_report_items.py @@ -569,7 +569,8 @@ def test_dimensioned_limits(cases): assert chart.limits == (0 * u.m / u.s, 100 * u.m / u.s) converted_limits = chart._get_limits(case) - assert converted_limits == (0, 0.2938635365101296) + assert converted_limits[0] == 0 + assert np.isclose(converted_limits[1], 0.2938635365101296, rtol=1e-12, atol=0) chart = Chart3D( field="velocity_m_per_s", diff --git a/tests/simulation/conftest.py b/tests/simulation/conftest.py index e2ba916f8..cb045f0c5 100644 --- a/tests/simulation/conftest.py +++ b/tests/simulation/conftest.py @@ -7,7 +7,6 @@ import pytest import unyt -from flow360.component.simulation import unit_system from flow360.component.simulation.framework.entity_base import EntityBase from flow360.component.simulation.framework.entity_registry import EntityRegistry @@ -68,67 +67,27 @@ def __getitem__(self, key: str) -> list[EntityBase]: @pytest.fixture() def array_equality_override(): - # Save original methods original_unyt_eq = unyt.unyt_array.__eq__ original_unyt_ne = unyt.unyt_array.__ne__ - original_flow360_eq = unit_system._Flow360BaseUnit.__eq__ - original_flow360_ne = unit_system._Flow360BaseUnit.__ne__ - # Overload equality for unyt arrays def unyt_array_eq(self: unyt.unyt_array, other: unyt.unyt_array): - if isinstance(other, unit_system._Flow360BaseUnit): - return flow360_unit_array_eq(other, self) if isinstance(self, unyt.unyt_quantity): return np.ndarray.__eq__(self, other) - elif self.size == other.size: + if self.size == other.size: return all(self[i] == other[i] for i in range(len(self))) return False def unyt_array_ne(self: unyt.unyt_array, other: unyt.unyt_array): - if isinstance(other, unit_system._Flow360BaseUnit): - return flow360_unit_array_ne(other, self) if isinstance(self, unyt.unyt_quantity): return np.ndarray.__ne__(self, other) - elif self.size == other.size: + if self.size == other.size: return any(self[i] != other[i] for i in range(len(self))) return True - def flow360_unit_array_eq( - self: unit_system._Flow360BaseUnit, other: unit_system._Flow360BaseUnit - ): - if isinstance(other, (unit_system._Flow360BaseUnit, unyt.unyt_array)): - if self.size == other.size: - if str(self.units) == str(other.units): - if self.size == 1: - return np.ndarray.__eq__(self.v, other.v) - if isinstance(other, unyt.unyt_array): - other = unit_system._Flow360BaseUnit.factory(other.v, str(other.units)) - return np.all(np.ndarray.__eq__(v.v, o.v) for v, o in zip(self, other)) - return False - - def flow360_unit_array_ne( - self: unit_system._Flow360BaseUnit, other: unit_system._Flow360BaseUnit - ): - if isinstance(other, (unit_system._Flow360BaseUnit, unyt.unyt_array)): - if self.size == other.size: - if str(self.units) == str(other.units): - if self.size == 1: - return np.ndarray.__ne__(self.v, other.v) - if isinstance(other, unyt.unyt_array): - other = unit_system._Flow360BaseUnit.factory(other.v, str(other.units)) - return np.any(np.ndarray.__ne__(v.v, o.v) for v, o in zip(self, other)) - return True - unyt.unyt_array.__eq__ = unyt_array_eq unyt.unyt_array.__ne__ = unyt_array_ne - unit_system._Flow360BaseUnit.__eq__ = flow360_unit_array_eq - unit_system._Flow360BaseUnit.__ne__ = flow360_unit_array_ne - # Yield control to the test yield - # Restore original methods unyt.unyt_array.__eq__ = original_unyt_eq unyt.unyt_array.__ne__ = original_unyt_ne - unit_system._Flow360BaseUnit.__eq__ = original_flow360_eq - unit_system._Flow360BaseUnit.__ne__ = original_flow360_ne diff --git a/tests/simulation/converter/ref/ref_c81.json b/tests/simulation/converter/ref/ref_c81.json index 7465f2d0b..d65524697 100644 --- a/tests/simulation/converter/ref/ref_c81.json +++ b/tests/simulation/converter/ref/ref_c81.json @@ -1,747 +1,354 @@ { - "alphas": { - "units": "degree", - "value": [ - -180.0, - -170.0, - -160.0, - -150.0, - -140.0, - -130.0, - -120.0, - -110.0, - -100.0, - -90.0, - -80.0, - -70.0, - -60.0, - -50.0, - -40.0, - -30.0, - -29.0, - -28.0, - -27.0, - -26.0, - -25.0, - -24.0, - -23.0, - -22.0, - -21.0, - -20.0, - -19.0, - -18.0, - -17.0, - -16.0, - -15.0, - -14.0, - -13.0, - -12.0, - -11.0, - -10.0, - -9.0, - -8.0, - -7.0, - -6.0, - -5.0, - -4.0, - -3.0, - -2.0, - -1.0, - 0.0, - 1.0, - 2.0, - 3.0, - 4.0, - 5.0, - 6.0, - 7.0, - 8.0, - 9.0, - 10.0, - 11.0, - 12.0, - 13.0, - 14.0, - 15.0, - 16.0, - 17.0, - 18.0, - 19.0, - 20.0, - 21.0, - 22.0, - 23.0, - 24.0, - 25.0, - 26.0, - 27.0, - 28.0, - 30.0, - 40.0, - 50.0, - 60.0, - 70.0, - 80.0, - 90.0, - 100.0, - 110.0, - 120.0, - 130.0, - 140.0, - 150.0, - 160.0, - 170.0, - 180.0 - ] - }, - "blade_line_chord": { - "units": "m", - "value": 0.0 - }, - "chord_ref": { - "units": "m", - "value": 14.0 - }, + "alphas": [ + -3.141592653589793, + -2.9670597283903604, + -2.792526803190927, + -2.6179938779914944, + -2.443460952792061, + -2.2689280275926285, + -2.0943951023931953, + -1.9198621771937625, + -1.7453292519943295, + -1.5707963267948966, + -1.3962634015954636, + -1.2217304763960306, + -1.0471975511965976, + -0.8726646259971648, + -0.6981317007977318, + -0.5235987755982988, + -0.5061454830783556, + -0.4886921905584123, + -0.47123889803846897, + -0.4537856055185257, + -0.4363323129985824, + -0.4188790204786391, + -0.4014257279586958, + -0.3839724354387525, + -0.3665191429188092, + -0.3490658503988659, + -0.33161255787892263, + -0.3141592653589793, + -0.29670597283903605, + -0.2792526803190927, + -0.2617993877991494, + -0.24434609527920614, + -0.22689280275926285, + -0.20943951023931956, + -0.19198621771937624, + -0.17453292519943295, + -0.15707963267948966, + -0.13962634015954636, + -0.12217304763960307, + -0.10471975511965978, + -0.08726646259971647, + -0.06981317007977318, + -0.05235987755982989, + -0.03490658503988659, + -0.017453292519943295, + 0.0, + 0.017453292519943295, + 0.03490658503988659, + 0.05235987755982989, + 0.06981317007977318, + 0.08726646259971647, + 0.10471975511965978, + 0.12217304763960307, + 0.13962634015954636, + 0.15707963267948966, + 0.17453292519943295, + 0.19198621771937624, + 0.20943951023931956, + 0.22689280275926285, + 0.24434609527920614, + 0.2617993877991494, + 0.2792526803190927, + 0.29670597283903605, + 0.3141592653589793, + 0.33161255787892263, + 0.3490658503988659, + 0.3665191429188092, + 0.3839724354387525, + 0.4014257279586958, + 0.4188790204786391, + 0.4363323129985824, + 0.4537856055185257, + 0.47123889803846897, + 0.4886921905584123, + 0.5235987755982988, + 0.6981317007977318, + 0.8726646259971648, + 1.0471975511965976, + 1.2217304763960306, + 1.3962634015954636, + 1.5707963267948966, + 1.7453292519943295, + 1.9198621771937625, + 2.0943951023931953, + 2.2689280275926285, + 2.443460952792061, + 2.6179938779914944, + 2.792526803190927, + 2.9670597283903604, + 3.141592653589793 + ], + "blade_line_chord": 0.0, + "chord_ref": 14.0, "chords": [ { - "chord": { - "units": "m", - "value": 0.0 - }, - "radius": { - "units": "m", - "value": 0.0 - } + "chord": 0.0, + "radius": 0.0 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 3.45059145 - } + "chord": 17.01426045, + "radius": 3.45059145 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 5.86743405 - } + "chord": 17.01426045, + "radius": 5.86743405 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 9.05761395 - } + "chord": 17.01426045, + "radius": 9.05761395 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 12.5377488 - } + "chord": 17.01426045, + "radius": 12.5377488 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 13.9536156 - } + "chord": 17.01426045, + "radius": 13.9536156 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 16.23802485 - } + "chord": 17.01426045, + "radius": 16.23802485 }, { - "chord": { - "units": "m", - "value": 16.7413494 - }, - "radius": { - "units": "m", - "value": 18.33595185 - } + "chord": 16.7413494, + "radius": 18.33595185 }, { - "chord": { - "units": "m", - "value": 16.4684385 - }, - "radius": { - "units": "m", - "value": 21.08656635 - } + "chord": 16.4684385, + "radius": 21.08656635 }, { - "chord": { - "units": "m", - "value": 16.19552745 - }, - "radius": { - "units": "m", - "value": 23.9304951 - } + "chord": 16.19552745, + "radius": 23.9304951 }, { - "chord": { - "units": "m", - "value": 15.9226164 - }, - "radius": { - "units": "m", - "value": 25.8886038 - } + "chord": 15.9226164, + "radius": 25.8886038 }, { - "chord": { - "units": "m", - "value": 15.6497055 - }, - "radius": { - "units": "m", - "value": 27.38050065 - } + "chord": 15.6497055, + "radius": 27.38050065 }, { - "chord": { - "units": "m", - "value": 15.37679445 - }, - "radius": { - "units": "m", - "value": 28.87241085 - } + "chord": 15.37679445, + "radius": 28.87241085 }, { - "chord": { - "units": "m", - "value": 15.1 - }, - "radius": { - "units": "m", - "value": 30.457542 - } + "chord": 15.1, + "radius": 30.457542 }, { - "chord": { - "units": "m", - "value": 14.8 - }, - "radius": { - "units": "m", - "value": 32.2291686 - } + "chord": 14.8, + "radius": 32.2291686 }, { - "chord": { - "units": "m", - "value": 14.6 - }, - "radius": { - "units": "m", - "value": 34.0940496 - } + "chord": 14.6, + "radius": 34.0940496 }, { - "chord": { - "units": "m", - "value": 14.3 - }, - "radius": { - "units": "m", - "value": 35.91228015 - } + "chord": 14.3, + "radius": 35.91228015 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 37.730544 - } + "chord": 14.0, + "radius": 37.730544 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 39.6886794 - } + "chord": 14.0, + "radius": 39.6886794 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 41.413722 - } + "chord": 14.0, + "radius": 41.413722 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 42.95224935 - } + "chord": 14.0, + "radius": 42.95224935 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 44.6772921 - } + "chord": 14.0, + "radius": 44.6772921 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 46.44887205 - } + "chord": 14.0, + "radius": 46.44887205 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 48.40696755 - } + "chord": 14.0, + "radius": 48.40696755 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 50.22518475 - } + "chord": 14.0, + "radius": 50.22518475 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 51.76369215 - } + "chord": 14.0, + "radius": 51.76369215 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 53.4887016 - } + "chord": 14.0, + "radius": 53.4887016 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 55.07383275 - } + "chord": 14.0, + "radius": 55.07383275 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 56.28603975 - } + "chord": 14.0, + "radius": 56.28603975 }, { - "chord": { - "units": "m", - "value": 14.0 - }, - "radius": { - "units": "m", - "value": 57.8245671 - } + "chord": 14.0, + "radius": 57.8245671 }, { - "chord": { - "units": "m", - "value": 14.01003 - }, - "radius": { - "units": "m", - "value": 59.45632875 - } + "chord": 14.01003, + "radius": 59.45632875 }, { - "chord": { - "units": "m", - "value": 14.00986005 - }, - "radius": { - "units": "m", - "value": 60.9948162 - } + "chord": 14.00986005, + "radius": 60.9948162 }, { - "chord": { - "units": "m", - "value": 14.00968995 - }, - "radius": { - "units": "m", - "value": 62.8595907 - } + "chord": 14.00968995, + "radius": 62.8595907 }, { - "chord": { - "units": "m", - "value": 14.00952 - }, - "radius": { - "units": "m", - "value": 65.4703071 - } + "chord": 14.00952, + "radius": 65.4703071 }, { - "chord": { - "units": "m", - "value": 14.00935005 - }, - "radius": { - "units": "m", - "value": 67.6148514 - } + "chord": 14.00935005, + "radius": 67.6148514 }, { - "chord": { - "units": "m", - "value": 14.00917995 - }, - "radius": { - "units": "m", - "value": 69.80601945 - } + "chord": 14.00917995, + "radius": 69.80601945 }, { - "chord": { - "units": "m", - "value": 14.00901 - }, - "radius": { - "units": "m", - "value": 71.53100235 - } + "chord": 14.00901, + "radius": 71.53100235 }, { - "chord": { - "units": "m", - "value": 14.00884005 - }, - "radius": { - "units": "m", - "value": 73.48907115 - } + "chord": 14.00884005, + "radius": 73.48907115 }, { - "chord": { - "units": "m", - "value": 14.00866995 - }, - "radius": { - "units": "m", - "value": 74.56137315 - } + "chord": 14.00866995, + "radius": 74.56137315 }, { - "chord": { - "units": "m", - "value": 14.0085 - }, - "radius": { - "units": "m", - "value": 76.7524548 - } + "chord": 14.0085, + "radius": 76.7524548 }, { - "chord": { - "units": "m", - "value": 14.00833005 - }, - "radius": { - "units": "m", - "value": 79.3631313 - } + "chord": 14.00833005, + "radius": 79.3631313 }, { - "chord": { - "units": "m", - "value": 14.00815995 - }, - "radius": { - "units": "m", - "value": 81.6941244 - } + "chord": 14.00815995, + "radius": 81.6941244 }, { - "chord": { - "units": "m", - "value": 14.00799 - }, - "radius": { - "units": "m", - "value": 83.65213995 - } + "chord": 14.00799, + "radius": 83.65213995 }, { - "chord": { - "units": "m", - "value": 14.00782005 - }, - "radius": { - "units": "m", - "value": 85.42368675 - } + "chord": 14.00782005, + "radius": 85.42368675 }, { - "chord": { - "units": "m", - "value": 14.00764995 - }, - "radius": { - "units": "m", - "value": 87.8479143 - } + "chord": 14.00764995, + "radius": 87.8479143 }, { - "chord": { - "units": "m", - "value": 14.00748 - }, - "radius": { - "units": "m", - "value": 89.52621345 - } + "chord": 14.00748, + "radius": 89.52621345 }, { - "chord": { - "units": "m", - "value": 14.00731005 - }, - "radius": { - "units": "m", - "value": 91.39097475 - } + "chord": 14.00731005, + "radius": 91.39097475 }, { - "chord": { - "units": "m", - "value": 14.00713995 - }, - "radius": { - "units": "m", - "value": 94.32797145 - } + "chord": 14.00713995, + "radius": 94.32797145 }, { - "chord": { - "units": "m", - "value": 14.00697 - }, - "radius": { - "units": "m", - "value": 99.73579305 - } + "chord": 14.00697, + "radius": 99.73579305 }, { - "chord": { - "units": "m", - "value": 14.00680005 - }, - "radius": { - "units": "m", - "value": 102.9524666 - } + "chord": 14.00680005, + "radius": 102.9524666 }, { - "chord": { - "units": "m", - "value": 14.00662995 - }, - "radius": { - "units": "m", - "value": 104.9104755 - } + "chord": 14.00662995, + "radius": 104.9104755 }, { - "chord": { - "units": "m", - "value": 14.00646 - }, - "radius": { - "units": "m", - "value": 106.6819956 - } + "chord": 14.00646, + "radius": 106.6819956 }, { - "chord": { - "units": "m", - "value": 14.00629005 - }, - "radius": { - "units": "m", - "value": 109.0129622 - } + "chord": 14.00629005, + "radius": 109.0129622 }, { - "chord": { - "units": "m", - "value": 14.00611995 - }, - "radius": { - "units": "m", - "value": 111.8101274 - } + "chord": 14.00611995, + "radius": 111.8101274 }, { - "chord": { - "units": "m", - "value": 14.00595 - }, - "radius": { - "units": "m", - "value": 114.4207971 - } + "chord": 14.00595, + "radius": 114.4207971 }, { - "chord": { - "units": "m", - "value": 14.00578005 - }, - "radius": { - "units": "m", - "value": 119.5955193 - } + "chord": 14.00578005, + "radius": 119.5955193 }, { - "chord": { - "units": "m", - "value": 14.00560995 - }, - "radius": { - "units": "m", - "value": 126.3086691 - } + "chord": 14.00560995, + "radius": 126.3086691 }, { - "chord": { - "units": "m", - "value": 14.00544 - }, - "radius": { - "units": "m", - "value": 131.5765793 - } + "chord": 14.00544, + "radius": 131.5765793 }, { - "chord": { - "units": "m", - "value": 14.00527005 - }, - "radius": { - "units": "m", - "value": 137.077602 - } + "chord": 14.00527005, + "radius": 137.077602 }, { - "chord": { - "units": "m", - "value": 14.00509995 - }, - "radius": { - "units": "m", - "value": 140.4341603 - } + "chord": 14.00509995, + "radius": 140.4341603 }, { - "chord": { - "units": "m", - "value": 14.00493 - }, - "radius": { - "units": "m", - "value": 143.6042165 - } + "chord": 14.00493, + "radius": 143.6042165 }, { - "chord": { - "units": "m", - "value": 14.00476005 - }, - "radius": { - "units": "m", - "value": 146.6810649 - } + "chord": 14.00476005, + "radius": 146.6810649 }, { - "chord": { - "units": "m", - "value": 14.00458995 - }, - "radius": { - "units": "m", - "value": 148.4525784 - } + "chord": 14.00458995, + "radius": 148.4525784 }, { - "chord": { - "units": "m", - "value": 14.00451 - }, - "radius": { - "units": "m", - "value": 150.0 - } + "chord": 14.00451, + "radius": 150.0 } ], "entities": { @@ -780,10 +387,7 @@ "n_loading_nodes": 20, "name": "BET disk", "number_of_blades": 3, - "omega": { - "units": "degree/s", - "value": 0.0046 - }, + "omega": 8.028514559173915e-05, "reynolds_numbers": [ 1.0 ], @@ -4580,657 +4184,270 @@ ] } ], - "sectional_radiuses": { - "units": "m", - "value": [ - 13.5, - 25.5, - 76.5, - 120.0, - 150.0 - ] - }, + "sectional_radiuses": [ + 13.5, + 25.5, + 76.5, + 120.0, + 150.0 + ], "tip_gap": "inf", "twists": [ { - "radius": { - "units": "m", - "value": 0.0 - }, - "twist": { - "units": "degree", - "value": 0.0 - } + "radius": 0.0, + "twist": 0.0 }, { - "radius": { - "units": "m", - "value": 3.45059145 - }, - "twist": { - "units": "degree", - "value": 33.27048712 - } + "radius": 3.45059145, + "twist": 0.5806795439863657 }, { - "radius": { - "units": "m", - "value": 5.86743405 - }, - "twist": { - "units": "degree", - "value": 32.37853609 - } + "radius": 5.86743405, + "twist": 0.565112061746311 }, { - "radius": { - "units": "m", - "value": 9.05761395 - }, - "twist": { - "units": "degree", - "value": 31.42712165 - } + "radius": 9.05761395, + "twist": 0.548506747217293 }, { - "radius": { - "units": "m", - "value": 12.5377488 - }, - "twist": { - "units": "degree", - "value": 30.65409742 - } + "radius": 12.5377488, + "twist": 0.535014929206099 }, { - "radius": { - "units": "m", - "value": 13.9536156 - }, - "twist": { - "units": "degree", - "value": 30.13214089 - } + "radius": 13.9536156, + "twist": 0.5259050692053145 }, { - "radius": { - "units": "m", - "value": 16.23802485 - }, - "twist": { - "units": "degree", - "value": 29.41523066 - } + "radius": 16.23802485, + "twist": 0.5133926252505847 }, { - "radius": { - "units": "m", - "value": 18.33595185 - }, - "twist": { - "units": "degree", - "value": 28.75567325 - } + "radius": 18.33595185, + "twist": 0.5018811768401585 }, { - "radius": { - "units": "m", - "value": 21.08656635 - }, - "twist": { - "units": "degree", - "value": 27.89538097 - } + "radius": 21.08656635, + "twist": 0.4868662440246696 }, { - "radius": { - "units": "m", - "value": 23.9304951 - }, - "twist": { - "units": "degree", - "value": 26.69097178 - } + "radius": 23.9304951, + "twist": 0.46584533811789164 }, { - "radius": { - "units": "m", - "value": 25.8886038 - }, - "twist": { - "units": "degree", - "value": 25.88803232 - } + "radius": 25.8886038, + "twist": 0.4518314008467063 }, { - "radius": { - "units": "m", - "value": 27.38050065 - }, - "twist": { - "units": "degree", - "value": 25.25715132 - } + "radius": 27.38050065, + "twist": 0.4408204502084319 }, { - "radius": { - "units": "m", - "value": 28.87241085 - }, - "twist": { - "units": "degree", - "value": 24.5689175 - } + "radius": 28.87241085, + "twist": 0.42880850402585396 }, { - "radius": { - "units": "m", - "value": 30.457542 - }, - "twist": { - "units": "degree", - "value": 23.93803649 - } + "radius": 30.457542, + "twist": 0.4177975532130466 }, { - "radius": { - "units": "m", - "value": 32.2291686 - }, - "twist": { - "units": "degree", - "value": 23.19244985 - } + "radius": 32.2291686, + "twist": 0.404784611486165 }, { - "radius": { - "units": "m", - "value": 34.0940496 - }, - "twist": { - "units": "degree", - "value": 22.36083398 - } + "radius": 34.0940496, + "twist": 0.39027017644282785 }, { - "radius": { - "units": "m", - "value": 35.91228015 - }, - "twist": { - "units": "degree", - "value": 21.67260016 - } + "radius": 35.91228015, + "twist": 0.37825823026024985 }, { - "radius": { - "units": "m", - "value": 37.730544 - }, - "twist": { - "units": "degree", - "value": 20.84098429 - } + "radius": 37.730544, + "twist": 0.36374379521691275 }, { - "radius": { - "units": "m", - "value": 39.6886794 - }, - "twist": { - "units": "degree", - "value": 19.92333919 - } + "radius": 39.6886794, + "twist": 0.3477278668571201 }, { - "radius": { - "units": "m", - "value": 41.413722 - }, - "twist": { - "units": "degree", - "value": 19.03437051 - } + "radius": 41.413722, + "twist": 0.3322124364440122 }, { - "radius": { - "units": "m", - "value": 42.95224935 - }, - "twist": { - "units": "degree", - "value": 18.34613668 - } + "radius": 42.95224935, + "twist": 0.3202004900869013 }, { - "radius": { - "units": "m", - "value": 44.6772921 - }, - "twist": { - "units": "degree", - "value": 17.457168 - } + "radius": 44.6772921, + "twist": 0.30468505967379345 }, { - "radius": { - "units": "m", - "value": 46.44887205 - }, - "twist": { - "units": "degree", - "value": 16.91231622 - } + "radius": 46.44887205, + "twist": 0.2951756021774417 }, { - "radius": { - "units": "m", - "value": 48.40696755 - }, - "twist": { - "units": "degree", - "value": 16.16672958 - } + "radius": 48.40696755, + "twist": 0.28216266045056 }, { - "radius": { - "units": "m", - "value": 50.22518475 - }, - "twist": { - "units": "degree", - "value": 15.53584858 - } + "radius": 50.22518475, + "twist": 0.27115170981228565 }, { - "radius": { - "units": "m", - "value": 51.76369215 - }, - "twist": { - "units": "degree", - "value": 14.93364398 - } + "radius": 51.76369215, + "twist": 0.2606412567716302 }, { - "radius": { - "units": "m", - "value": 53.4887016 - }, - "twist": { - "units": "degree", - "value": 14.18805734 - } + "radius": 53.4887016, + "twist": 0.24762831504474858 }, { - "radius": { - "units": "m", - "value": 55.07383275 - }, - "twist": { - "units": "degree", - "value": 13.55717634 - } + "radius": 55.07383275, + "twist": 0.2366173644064742 }, { - "radius": { - "units": "m", - "value": 56.28603975 - }, - "twist": { - "units": "degree", - "value": 12.86894251 - } + "radius": 56.28603975, + "twist": 0.2246054180493633 }, { - "radius": { - "units": "m", - "value": 57.8245671 - }, - "twist": { - "units": "degree", - "value": 12.18070869 - } + "radius": 57.8245671, + "twist": 0.2125934718667853 }, { - "radius": { - "units": "m", - "value": 59.45632875 - }, - "twist": { - "units": "degree", - "value": 11.49247487 - } + "radius": 59.45632875, + "twist": 0.2005815256842073 }, { - "radius": { - "units": "m", - "value": 60.9948162 - }, - "twist": { - "units": "degree", - "value": 10.9762995 - } + "radius": 60.9948162, + "twist": 0.19157256596000732 }, { - "radius": { - "units": "m", - "value": 62.8595907 - }, - "twist": { - "units": "degree", - "value": 10.60350618 - } + "radius": 62.8595907, + "twist": 0.18506609509656652 }, { - "radius": { - "units": "m", - "value": 65.4703071 - }, - "twist": { - "units": "degree", - "value": 9.94394877 - } + "radius": 65.4703071, + "twist": 0.17355464668614035 }, { - "radius": { - "units": "m", - "value": 67.6148514 - }, - "twist": { - "units": "degree", - "value": 9.28439136 - } + "radius": 67.6148514, + "twist": 0.16204319827571417 }, { - "radius": { - "units": "m", - "value": 69.80601945 - }, - "twist": { - "units": "degree", - "value": 8.59615753 - } + "radius": 69.80601945, + "twist": 0.15003125191860323 }, { - "radius": { - "units": "m", - "value": 71.53100235 - }, - "twist": { - "units": "degree", - "value": 7.96527653 - } + "radius": 71.53100235, + "twist": 0.13902030128032888 }, { - "radius": { - "units": "m", - "value": 73.48907115 - }, - "twist": { - "units": "degree", - "value": 7.33439553 - } + "radius": 73.48907115, + "twist": 0.12800935064205454 }, { - "radius": { - "units": "m", - "value": 74.56137315 - }, - "twist": { - "units": "degree", - "value": 6.87557298 - } + "radius": 74.56137315, + "twist": 0.12000138646215824 }, { - "radius": { - "units": "m", - "value": 76.7524548 - }, - "twist": { - "units": "degree", - "value": 6.56013248 - } + "radius": 76.7524548, + "twist": 0.11449591114302106 }, { - "radius": { - "units": "m", - "value": 79.3631313 - }, - "twist": { - "units": "degree", - "value": 6.07263352 - } + "radius": 79.3631313, + "twist": 0.10598744919097293 }, { - "radius": { - "units": "m", - "value": 81.6941244 - }, - "twist": { - "units": "degree", - "value": 5.49910533 - } + "radius": 81.6941244, + "twist": 0.0959774939224693 }, { - "radius": { - "units": "m", - "value": 83.65213995 - }, - "twist": { - "units": "degree", - "value": 5.0976356 - } + "radius": 83.65213995, + "twist": 0.08897052528687666 }, { - "radius": { - "units": "m", - "value": 85.42368675 - }, - "twist": { - "units": "degree", - "value": 4.69616587 - } + "radius": 85.42368675, + "twist": 0.08196355665128399 }, { - "radius": { - "units": "m", - "value": 87.8479143 - }, - "twist": { - "units": "degree", - "value": 4.12263769 - } + "radius": 87.8479143, + "twist": 0.07195360155731331 }, { - "radius": { - "units": "m", - "value": 89.52621345 - }, - "twist": { - "units": "degree", - "value": 3.77852078 - } + "radius": 89.52621345, + "twist": 0.06594762846602431 }, { - "radius": { - "units": "m", - "value": 91.39097475 - }, - "twist": { - "units": "degree", - "value": 3.46308028 - } + "radius": 91.39097475, + "twist": 0.06044215314688713 }, { - "radius": { - "units": "m", - "value": 94.32797145 - }, - "twist": { - "units": "degree", - "value": 2.97558132 - } + "radius": 94.32797145, + "twist": 0.051933691194838996 }, { - "radius": { - "units": "m", - "value": 99.73579305 - }, - "twist": { - "units": "degree", - "value": 2.0005834 - } + "radius": 99.73579305, + "twist": 0.03491676729074272 }, { - "radius": { - "units": "m", - "value": 102.9524666 - }, - "twist": { - "units": "degree", - "value": 1.62779008 - } + "radius": 102.9524666, + "twist": 0.0284102964273019 }, { - "radius": { - "units": "m", - "value": 104.9104755 - }, - "twist": { - "units": "degree", - "value": 1.25499676 - } + "radius": 104.9104755, + "twist": 0.02190382556386107 }, { - "radius": { - "units": "m", - "value": 106.6819956 - }, - "twist": { - "units": "degree", - "value": 0.96823267 - } + "radius": 106.6819956, + "twist": 0.016898848016875724 }, { - "radius": { - "units": "m", - "value": 109.0129622 - }, - "twist": { - "units": "degree", - "value": 0.50941012 - } + "radius": 109.0129622, + "twist": 0.008890883836979417 }, { - "radius": { - "units": "m", - "value": 111.8101274 - }, - "twist": { - "units": "degree", - "value": -0.064118065 - } + "radius": 111.8101274, + "twist": -0.0011190713442577381 }, { - "radius": { - "units": "m", - "value": 114.4207971 - }, - "twist": { - "units": "degree", - "value": -0.522940614 - } + "radius": 114.4207971, + "twist": -0.009127035506700755 }, { - "radius": { - "units": "m", - "value": 119.5955193 - }, - "twist": { - "units": "degree", - "value": -1.44058571 - } + "radius": 119.5955193, + "twist": -0.0251429637966802 }, { - "radius": { - "units": "m", - "value": 126.3086691 - }, - "twist": { - "units": "degree", - "value": -2.61631849 - } + "radius": 126.3086691, + "twist": -0.04566337193130633 }, { - "radius": { - "units": "m", - "value": 131.5765793 - }, - "twist": { - "units": "degree", - "value": -3.333228722 - } + "radius": 131.5765793, + "twist": -0.05817581592094275 }, { - "radius": { - "units": "m", - "value": 137.077602 - }, - "twist": { - "units": "degree", - "value": -4.164844591 - } + "radius": 137.077602, + "twist": -0.07269025094682659 }, { - "radius": { - "units": "m", - "value": 140.4341603 - }, - "twist": { - "units": "degree", - "value": -4.681019957 - } + "radius": 140.4341603, + "twist": -0.08169921060121339 }, { - "radius": { - "units": "m", - "value": 143.6042165 - }, - "twist": { - "units": "degree", - "value": -5.053813278 - } + "radius": 143.6042165, + "twist": -0.0882056814821075 }, { - "radius": { - "units": "m", - "value": 146.6810649 - }, - "twist": { - "units": "degree", - "value": -5.541312235 - } + "radius": 146.6810649, + "twist": -0.09671414338179578 }, { - "radius": { - "units": "m", - "value": 148.4525784 - }, - "twist": { - "units": "degree", - "value": -5.799399919 - } + "radius": 148.4525784, + "twist": -0.10121862322644246 }, { - "radius": { - "units": "m", - "value": 150.0 - }, - "twist": { - "units": "degree", - "value": -6.1 - } + "radius": 150.0, + "twist": -0.1064650843716541 } ], "type": "BETDisk" diff --git a/tests/simulation/converter/ref/ref_dfdc.json b/tests/simulation/converter/ref/ref_dfdc.json index ade6b97fa..9ad8389dc 100644 --- a/tests/simulation/converter/ref/ref_dfdc.json +++ b/tests/simulation/converter/ref/ref_dfdc.json @@ -1,1044 +1,651 @@ { - "alphas": { - "units": "degree", - "value": [ - -180.0, - -179.0, - -178.0, - -177.0, - -176.0, - -175.0, - -174.0, - -173.0, - -172.0, - -171.0, - -170.0, - -169.0, - -168.0, - -167.0, - -166.0, - -165.0, - -164.0, - -163.0, - -162.0, - -161.0, - -160.0, - -159.0, - -158.0, - -157.0, - -156.0, - -155.0, - -154.0, - -153.0, - -152.0, - -151.0, - -150.0, - -149.0, - -148.0, - -147.0, - -146.0, - -145.0, - -144.0, - -143.0, - -142.0, - -141.0, - -140.0, - -139.0, - -138.0, - -137.0, - -136.0, - -135.0, - -134.0, - -133.0, - -132.0, - -131.0, - -130.0, - -129.0, - -128.0, - -127.0, - -126.0, - -125.0, - -124.0, - -123.0, - -122.0, - -121.0, - -120.0, - -119.0, - -118.0, - -117.0, - -116.0, - -115.0, - -114.0, - -113.0, - -112.0, - -111.0, - -110.0, - -109.0, - -108.0, - -107.0, - -106.0, - -105.0, - -104.0, - -103.0, - -102.0, - -101.0, - -100.0, - -99.0, - -98.0, - -97.0, - -96.0, - -95.0, - -94.0, - -93.0, - -92.0, - -91.0, - -90.0, - -89.0, - -88.0, - -87.0, - -86.0, - -85.0, - -84.0, - -83.0, - -82.0, - -81.0, - -80.0, - -79.0, - -78.0, - -77.0, - -76.0, - -75.0, - -74.0, - -73.0, - -72.0, - -71.0, - -70.0, - -69.0, - -68.0, - -67.0, - -66.0, - -65.0, - -64.0, - -63.0, - -62.0, - -61.0, - -60.0, - -59.0, - -58.0, - -57.0, - -56.0, - -55.0, - -54.0, - -53.0, - -52.0, - -51.0, - -50.0, - -49.0, - -48.0, - -47.0, - -46.0, - -45.0, - -44.0, - -43.0, - -42.0, - -41.0, - -40.0, - -39.0, - -38.0, - -37.0, - -36.0, - -35.0, - -34.0, - -33.0, - -32.0, - -31.0, - -30.0, - -29.0, - -28.0, - -27.0, - -26.0, - -25.0, - -24.0, - -23.0, - -22.0, - -21.0, - -20.0, - -19.0, - -18.0, - -17.0, - -16.0, - -15.0, - -14.0, - -13.0, - -12.0, - -11.0, - -10.0, - -9.0, - -8.5, - -8.0, - -7.5, - -7.0, - -6.5, - -6.0, - -5.5, - -5.0, - -4.5, - -4.0, - -3.5, - -3.0, - -2.5, - -2.0, - -1.5, - -1.0, - -0.75, - -0.5, - -0.25, - 0.0, - 0.25, - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - 1.75, - 2.0, - 2.25, - 2.5, - 2.75, - 3.0, - 3.5, - 4.0, - 4.5, - 5.0, - 5.5, - 6.0, - 6.5, - 7.0, - 7.5, - 8.0, - 8.5, - 9.0, - 10.0, - 11.0, - 12.0, - 13.0, - 14.0, - 15.0, - 16.0, - 17.0, - 18.0, - 19.0, - 20.0, - 21.0, - 22.0, - 23.0, - 24.0, - 25.0, - 26.0, - 27.0, - 28.0, - 29.0, - 30.0, - 31.0, - 32.0, - 33.0, - 34.0, - 35.0, - 36.0, - 37.0, - 38.0, - 39.0, - 40.0, - 41.0, - 42.0, - 43.0, - 44.0, - 45.0, - 46.0, - 47.0, - 48.0, - 49.0, - 50.0, - 51.0, - 52.0, - 53.0, - 54.0, - 55.0, - 56.0, - 57.0, - 58.0, - 59.0, - 60.0, - 61.0, - 62.0, - 63.0, - 64.0, - 65.0, - 66.0, - 67.0, - 68.0, - 69.0, - 70.0, - 71.0, - 72.0, - 73.0, - 74.0, - 75.0, - 76.0, - 77.0, - 78.0, - 79.0, - 80.0, - 81.0, - 82.0, - 83.0, - 84.0, - 85.0, - 86.0, - 87.0, - 88.0, - 89.0, - 90.0, - 91.0, - 92.0, - 93.0, - 94.0, - 95.0, - 96.0, - 97.0, - 98.0, - 99.0, - 100.0, - 101.0, - 102.0, - 103.0, - 104.0, - 105.0, - 106.0, - 107.0, - 108.0, - 109.0, - 110.0, - 111.0, - 112.0, - 113.0, - 114.0, - 115.0, - 116.0, - 117.0, - 118.0, - 119.0, - 120.0, - 121.0, - 122.0, - 123.0, - 124.0, - 125.0, - 126.0, - 127.0, - 128.0, - 129.0, - 130.0, - 131.0, - 132.0, - 133.0, - 134.0, - 135.0, - 136.0, - 137.0, - 138.0, - 139.0, - 140.0, - 141.0, - 142.0, - 143.0, - 144.0, - 145.0, - 146.0, - 147.0, - 148.0, - 149.0, - 150.0, - 151.0, - 152.0, - 153.0, - 154.0, - 155.0, - 156.0, - 157.0, - 158.0, - 159.0, - 160.0, - 161.0, - 162.0, - 163.0, - 164.0, - 165.0, - 166.0, - 167.0, - 168.0, - 169.0, - 170.0, - 171.0, - 172.0, - 173.0, - 174.0, - 175.0, - 176.0, - 177.0, - 178.0, - 179.0, - 180.0 - ] - }, - "blade_line_chord": { - "units": "m", - "value": 0.0 - }, - "chord_ref": { - "units": "m", - "value": 14.0 - }, + "alphas": [ + -3.141592653589793, + -3.12413936106985, + -3.1066860685499065, + -3.0892327760299634, + -3.07177948351002, + -3.0543261909900767, + -3.036872898470133, + -3.01941960595019, + -3.001966313430247, + -2.9845130209103035, + -2.9670597283903604, + -2.949606435870417, + -2.9321531433504737, + -2.91469985083053, + -2.897246558310587, + -2.8797932657906435, + -2.8623399732707004, + -2.8448866807507573, + -2.827433388230814, + -2.8099800957108707, + -2.792526803190927, + -2.775073510670984, + -2.7576202181510405, + -2.7401669256310974, + -2.722713633111154, + -2.705260340591211, + -2.6878070480712677, + -2.670353755551324, + -2.652900463031381, + -2.6354471705114375, + -2.6179938779914944, + -2.600540585471551, + -2.5830872929516078, + -2.5656340004316642, + -2.548180707911721, + -2.530727415391778, + -2.5132741228718345, + -2.4958208303518914, + -2.478367537831948, + -2.4609142453120048, + -2.443460952792061, + -2.426007660272118, + -2.4085543677521746, + -2.3911010752322315, + -2.3736477827122884, + -2.356194490192345, + -2.3387411976724017, + -2.321287905152458, + -2.303834612632515, + -2.2863813201125716, + -2.2689280275926285, + -2.251474735072685, + -2.234021442552742, + -2.2165681500327987, + -2.199114857512855, + -2.181661564992912, + -2.1642082724729685, + -2.1467549799530254, + -2.129301687433082, + -2.111848394913139, + -2.0943951023931953, + -2.076941809873252, + -2.059488517353309, + -2.0420352248333655, + -2.0245819323134224, + -2.007128639793479, + -1.9896753472735358, + -1.9722220547535925, + -1.9547687622336491, + -1.9373154697137058, + -1.9198621771937625, + -1.9024088846738192, + -1.8849555921538759, + -1.8675022996339325, + -1.8500490071139892, + -1.8325957145940461, + -1.8151424220741028, + -1.7976891295541595, + -1.7802358370342162, + -1.7627825445142729, + -1.7453292519943295, + -1.7278759594743862, + -1.710422666954443, + -1.6929693744344996, + -1.6755160819145565, + -1.6580627893946132, + -1.6406094968746698, + -1.6231562043547265, + -1.6057029118347832, + -1.5882496193148399, + -1.5707963267948966, + -1.5533430342749532, + -1.53588974175501, + -1.5184364492350666, + -1.5009831567151235, + -1.4835298641951802, + -1.4660765716752369, + -1.4486232791552935, + -1.4311699866353502, + -1.413716694115407, + -1.3962634015954636, + -1.3788101090755203, + -1.361356816555577, + -1.3439035240356338, + -1.3264502315156905, + -1.3089969389957472, + -1.2915436464758039, + -1.2740903539558606, + -1.2566370614359172, + -1.239183768915974, + -1.2217304763960306, + -1.2042771838760873, + -1.1868238913561442, + -1.1693705988362009, + -1.1519173063162575, + -1.1344640137963142, + -1.117010721276371, + -1.0995574287564276, + -1.0821041362364843, + -1.064650843716541, + -1.0471975511965976, + -1.0297442586766545, + -1.0122909661567112, + -0.9948376736367679, + -0.9773843811168246, + -0.9599310885968813, + -0.9424777960769379, + -0.9250245035569946, + -0.9075712110370514, + -0.8901179185171081, + -0.8726646259971648, + -0.8552113334772214, + -0.8377580409572782, + -0.8203047484373349, + -0.8028514559173916, + -0.7853981633974483, + -0.767944870877505, + -0.7504915783575618, + -0.7330382858376184, + -0.7155849933176751, + -0.6981317007977318, + -0.6806784082777885, + -0.6632251157578453, + -0.6457718232379019, + -0.6283185307179586, + -0.6108652381980153, + -0.5934119456780721, + -0.5759586531581288, + -0.5585053606381855, + -0.5410520681182421, + -0.5235987755982988, + -0.5061454830783556, + -0.4886921905584123, + -0.47123889803846897, + -0.4537856055185257, + -0.4363323129985824, + -0.4188790204786391, + -0.4014257279586958, + -0.3839724354387525, + -0.3665191429188092, + -0.3490658503988659, + -0.33161255787892263, + -0.3141592653589793, + -0.29670597283903605, + -0.2792526803190927, + -0.2617993877991494, + -0.24434609527920614, + -0.22689280275926285, + -0.20943951023931956, + -0.19198621771937624, + -0.17453292519943295, + -0.15707963267948966, + -0.14835298641951802, + -0.13962634015954636, + -0.1308996938995747, + -0.12217304763960307, + -0.11344640137963143, + -0.10471975511965978, + -0.09599310885968812, + -0.08726646259971647, + -0.07853981633974483, + -0.06981317007977318, + -0.061086523819801536, + -0.05235987755982989, + -0.04363323129985824, + -0.03490658503988659, + -0.026179938779914945, + -0.017453292519943295, + -0.013089969389957472, + -0.008726646259971648, + -0.004363323129985824, + 0.0, + 0.004363323129985824, + 0.008726646259971648, + 0.013089969389957472, + 0.017453292519943295, + 0.02181661564992912, + 0.026179938779914945, + 0.030543261909900768, + 0.03490658503988659, + 0.039269908169872414, + 0.04363323129985824, + 0.04799655442984406, + 0.05235987755982989, + 0.061086523819801536, + 0.06981317007977318, + 0.07853981633974483, + 0.08726646259971647, + 0.09599310885968812, + 0.10471975511965978, + 0.11344640137963143, + 0.12217304763960307, + 0.1308996938995747, + 0.13962634015954636, + 0.14835298641951802, + 0.15707963267948966, + 0.17453292519943295, + 0.19198621771937624, + 0.20943951023931956, + 0.22689280275926285, + 0.24434609527920614, + 0.2617993877991494, + 0.2792526803190927, + 0.29670597283903605, + 0.3141592653589793, + 0.33161255787892263, + 0.3490658503988659, + 0.3665191429188092, + 0.3839724354387525, + 0.4014257279586958, + 0.4188790204786391, + 0.4363323129985824, + 0.4537856055185257, + 0.47123889803846897, + 0.4886921905584123, + 0.5061454830783556, + 0.5235987755982988, + 0.5410520681182421, + 0.5585053606381855, + 0.5759586531581288, + 0.5934119456780721, + 0.6108652381980153, + 0.6283185307179586, + 0.6457718232379019, + 0.6632251157578453, + 0.6806784082777885, + 0.6981317007977318, + 0.7155849933176751, + 0.7330382858376184, + 0.7504915783575618, + 0.767944870877505, + 0.7853981633974483, + 0.8028514559173916, + 0.8203047484373349, + 0.8377580409572782, + 0.8552113334772214, + 0.8726646259971648, + 0.8901179185171081, + 0.9075712110370514, + 0.9250245035569946, + 0.9424777960769379, + 0.9599310885968813, + 0.9773843811168246, + 0.9948376736367679, + 1.0122909661567112, + 1.0297442586766545, + 1.0471975511965976, + 1.064650843716541, + 1.0821041362364843, + 1.0995574287564276, + 1.117010721276371, + 1.1344640137963142, + 1.1519173063162575, + 1.1693705988362009, + 1.1868238913561442, + 1.2042771838760873, + 1.2217304763960306, + 1.239183768915974, + 1.2566370614359172, + 1.2740903539558606, + 1.2915436464758039, + 1.3089969389957472, + 1.3264502315156905, + 1.3439035240356338, + 1.361356816555577, + 1.3788101090755203, + 1.3962634015954636, + 1.413716694115407, + 1.4311699866353502, + 1.4486232791552935, + 1.4660765716752369, + 1.4835298641951802, + 1.5009831567151235, + 1.5184364492350666, + 1.53588974175501, + 1.5533430342749532, + 1.5707963267948966, + 1.5882496193148399, + 1.6057029118347832, + 1.6231562043547265, + 1.6406094968746698, + 1.6580627893946132, + 1.6755160819145565, + 1.6929693744344996, + 1.710422666954443, + 1.7278759594743862, + 1.7453292519943295, + 1.7627825445142729, + 1.7802358370342162, + 1.7976891295541595, + 1.8151424220741028, + 1.8325957145940461, + 1.8500490071139892, + 1.8675022996339325, + 1.8849555921538759, + 1.9024088846738192, + 1.9198621771937625, + 1.9373154697137058, + 1.9547687622336491, + 1.9722220547535925, + 1.9896753472735358, + 2.007128639793479, + 2.0245819323134224, + 2.0420352248333655, + 2.059488517353309, + 2.076941809873252, + 2.0943951023931953, + 2.111848394913139, + 2.129301687433082, + 2.1467549799530254, + 2.1642082724729685, + 2.181661564992912, + 2.199114857512855, + 2.2165681500327987, + 2.234021442552742, + 2.251474735072685, + 2.2689280275926285, + 2.2863813201125716, + 2.303834612632515, + 2.321287905152458, + 2.3387411976724017, + 2.356194490192345, + 2.3736477827122884, + 2.3911010752322315, + 2.4085543677521746, + 2.426007660272118, + 2.443460952792061, + 2.4609142453120048, + 2.478367537831948, + 2.4958208303518914, + 2.5132741228718345, + 2.530727415391778, + 2.548180707911721, + 2.5656340004316642, + 2.5830872929516078, + 2.600540585471551, + 2.6179938779914944, + 2.6354471705114375, + 2.652900463031381, + 2.670353755551324, + 2.6878070480712677, + 2.705260340591211, + 2.722713633111154, + 2.7401669256310974, + 2.7576202181510405, + 2.775073510670984, + 2.792526803190927, + 2.8099800957108707, + 2.827433388230814, + 2.8448866807507573, + 2.8623399732707004, + 2.8797932657906435, + 2.897246558310587, + 2.91469985083053, + 2.9321531433504737, + 2.949606435870417, + 2.9670597283903604, + 2.9845130209103035, + 3.001966313430247, + 3.01941960595019, + 3.036872898470133, + 3.0543261909900767, + 3.07177948351002, + 3.0892327760299634, + 3.1066860685499065, + 3.12413936106985, + 3.141592653589793 + ], + "blade_line_chord": 0.0, + "chord_ref": 14.0, "chords": [ { - "chord": { - "units": "m", - "value": 0.0 - }, - "radius": { - "units": "m", - "value": 0.0 - } + "chord": 0.0, + "radius": 0.0 }, { - "chord": { - "units": "m", - "value": 0.432162215 - }, - "radius": { - "units": "m", - "value": 0.087645023 - } + "chord": 0.432162215, + "radius": 0.087645023 }, { - "chord": { - "units": "m", - "value": 0.432162215 - }, - "radius": { - "units": "m", - "value": 0.149032825 - } + "chord": 0.432162215, + "radius": 0.149032825 }, { - "chord": { - "units": "m", - "value": 0.432162215 - }, - "radius": { - "units": "m", - "value": 0.230063394 - } + "chord": 0.432162215, + "radius": 0.230063394 }, { - "chord": { - "units": "m", - "value": 0.432162215 - }, - "radius": { - "units": "m", - "value": 0.31845882 - } + "chord": 0.432162215, + "radius": 0.31845882 }, { - "chord": { - "units": "m", - "value": 0.432162215 - }, - "radius": { - "units": "m", - "value": 0.354421836 - } + "chord": 0.432162215, + "radius": 0.354421836 }, { - "chord": { - "units": "m", - "value": 0.432162215 - }, - "radius": { - "units": "m", - "value": 0.412445831 - } + "chord": 0.432162215, + "radius": 0.412445831 }, { - "chord": { - "units": "m", - "value": 0.425230275 - }, - "radius": { - "units": "m", - "value": 0.465733177 - } + "chord": 0.425230275, + "radius": 0.465733177 }, { - "chord": { - "units": "m", - "value": 0.418298338 - }, - "radius": { - "units": "m", - "value": 0.535598785 - } + "chord": 0.418298338, + "radius": 0.535598785 }, { - "chord": { - "units": "m", - "value": 0.411366397 - }, - "radius": { - "units": "m", - "value": 0.607834576 - } + "chord": 0.411366397, + "radius": 0.607834576 }, { - "chord": { - "units": "m", - "value": 0.404434457 - }, - "radius": { - "units": "m", - "value": 0.657570537 - } + "chord": 0.404434457, + "radius": 0.657570537 }, { - "chord": { - "units": "m", - "value": 0.39750252 - }, - "radius": { - "units": "m", - "value": 0.695464717 - } + "chord": 0.39750252, + "radius": 0.695464717 }, { - "chord": { - "units": "m", - "value": 0.390570579 - }, - "radius": { - "units": "m", - "value": 0.733359236 - } + "chord": 0.390570579, + "radius": 0.733359236 }, { - "chord": { - "units": "m", - "value": 0.383638638 - }, - "radius": { - "units": "m", - "value": 0.773621567 - } + "chord": 0.383638638, + "radius": 0.773621567 }, { - "chord": { - "units": "m", - "value": 0.376706702 - }, - "radius": { - "units": "m", - "value": 0.818620882 - } + "chord": 0.376706702, + "radius": 0.818620882 }, { - "chord": { - "units": "m", - "value": 0.369774761 - }, - "radius": { - "units": "m", - "value": 0.86598886 - } + "chord": 0.369774761, + "radius": 0.86598886 }, { - "chord": { - "units": "m", - "value": 0.36284282 - }, - "radius": { - "units": "m", - "value": 0.912171916 - } + "chord": 0.36284282, + "radius": 0.912171916 }, { - "chord": { - "units": "m", - "value": 0.355910895 - }, - "radius": { - "units": "m", - "value": 0.958355818 - } + "chord": 0.355910895, + "radius": 0.958355818 }, { - "chord": { - "units": "m", - "value": 0.355906578 - }, - "radius": { - "units": "m", - "value": 1.008092457 - } + "chord": 0.355906578, + "radius": 1.008092457 }, { - "chord": { - "units": "m", - "value": 0.355902261 - }, - "radius": { - "units": "m", - "value": 1.051908539 - } + "chord": 0.355902261, + "radius": 1.051908539 }, { - "chord": { - "units": "m", - "value": 0.355897941 - }, - "radius": { - "units": "m", - "value": 1.090987133 - } + "chord": 0.355897941, + "radius": 1.090987133 }, { - "chord": { - "units": "m", - "value": 0.355893624 - }, - "radius": { - "units": "m", - "value": 1.134803219 - } + "chord": 0.355893624, + "radius": 1.134803219 }, { - "chord": { - "units": "m", - "value": 0.355889307 - }, - "radius": { - "units": "m", - "value": 1.17980135 - } + "chord": 0.355889307, + "radius": 1.17980135 }, { - "chord": { - "units": "m", - "value": 0.355884987 - }, - "radius": { - "units": "m", - "value": 1.229536976 - } + "chord": 0.355884987, + "radius": 1.229536976 }, { - "chord": { - "units": "m", - "value": 0.35588067 - }, - "radius": { - "units": "m", - "value": 1.275719693 - } + "chord": 0.35588067, + "radius": 1.275719693 }, { - "chord": { - "units": "m", - "value": 0.355876353 - }, - "radius": { - "units": "m", - "value": 1.314797781 - } + "chord": 0.355876353, + "radius": 1.314797781 }, { - "chord": { - "units": "m", - "value": 0.355872033 - }, - "radius": { - "units": "m", - "value": 1.358613021 - } + "chord": 0.355872033, + "radius": 1.358613021 }, { - "chord": { - "units": "m", - "value": 0.355867716 - }, - "radius": { - "units": "m", - "value": 1.398875352 - } + "chord": 0.355867716, + "radius": 1.398875352 }, { - "chord": { - "units": "m", - "value": 0.355863399 - }, - "radius": { - "units": "m", - "value": 1.42966541 - } + "chord": 0.355863399, + "radius": 1.42966541 }, { - "chord": { - "units": "m", - "value": 0.355859079 - }, - "radius": { - "units": "m", - "value": 1.468744004 - } + "chord": 0.355859079, + "radius": 1.468744004 }, { - "chord": { - "units": "m", - "value": 0.355854762 - }, - "radius": { - "units": "m", - "value": 1.51019075 - } + "chord": 0.355854762, + "radius": 1.51019075 }, { - "chord": { - "units": "m", - "value": 0.355850445 - }, - "radius": { - "units": "m", - "value": 1.549268331 - } + "chord": 0.355850445, + "radius": 1.549268331 }, { - "chord": { - "units": "m", - "value": 0.355846125 - }, - "radius": { - "units": "m", - "value": 1.596633604 - } + "chord": 0.355846125, + "radius": 1.596633604 }, { - "chord": { - "units": "m", - "value": 0.355841808 - }, - "radius": { - "units": "m", - "value": 1.6629458 - } + "chord": 0.355841808, + "radius": 1.6629458 }, { - "chord": { - "units": "m", - "value": 0.355837491 - }, - "radius": { - "units": "m", - "value": 1.717417226 - } + "chord": 0.355837491, + "radius": 1.717417226 }, { - "chord": { - "units": "m", - "value": 0.355833171 - }, - "radius": { - "units": "m", - "value": 1.773072894 - } + "chord": 0.355833171, + "radius": 1.773072894 }, { - "chord": { - "units": "m", - "value": 0.355828854 - }, - "radius": { - "units": "m", - "value": 1.81688746 - } + "chord": 0.355828854, + "radius": 1.81688746 }, { - "chord": { - "units": "m", - "value": 0.355824537 - }, - "radius": { - "units": "m", - "value": 1.866622407 - } + "chord": 0.355824537, + "radius": 1.866622407 }, { - "chord": { - "units": "m", - "value": 0.355820217 - }, - "radius": { - "units": "m", - "value": 1.893858878 - } + "chord": 0.355820217, + "radius": 1.893858878 }, { - "chord": { - "units": "m", - "value": 0.3558159 - }, - "radius": { - "units": "m", - "value": 1.949512352 - } + "chord": 0.3558159, + "radius": 1.949512352 }, { - "chord": { - "units": "m", - "value": 0.355811583 - }, - "radius": { - "units": "m", - "value": 2.015823535 - } + "chord": 0.355811583, + "radius": 2.015823535 }, { - "chord": { - "units": "m", - "value": 0.355807263 - }, - "radius": { - "units": "m", - "value": 2.07503076 - } + "chord": 0.355807263, + "radius": 2.07503076 }, { - "chord": { - "units": "m", - "value": 0.355802946 - }, - "radius": { - "units": "m", - "value": 2.124764355 - } + "chord": 0.355802946, + "radius": 2.124764355 }, { - "chord": { - "units": "m", - "value": 0.355798629 - }, - "radius": { - "units": "m", - "value": 2.169761643 - } + "chord": 0.355798629, + "radius": 2.169761643 }, { - "chord": { - "units": "m", - "value": 0.355794309 - }, - "radius": { - "units": "m", - "value": 2.231337023 - } + "chord": 0.355794309, + "radius": 2.231337023 }, { - "chord": { - "units": "m", - "value": 0.355789992 - }, - "radius": { - "units": "m", - "value": 2.273965822 - } + "chord": 0.355789992, + "radius": 2.273965822 }, { - "chord": { - "units": "m", - "value": 0.355785675 - }, - "radius": { - "units": "m", - "value": 2.321330759 - } + "chord": 0.355785675, + "radius": 2.321330759 }, { - "chord": { - "units": "m", - "value": 0.355781355 - }, - "radius": { - "units": "m", - "value": 2.395930475 - } + "chord": 0.355781355, + "radius": 2.395930475 }, { - "chord": { - "units": "m", - "value": 0.355777038 - }, - "radius": { - "units": "m", - "value": 2.533289143 - } + "chord": 0.355777038, + "radius": 2.533289143 }, { - "chord": { - "units": "m", - "value": 0.355772721 - }, - "radius": { - "units": "m", - "value": 2.61499265 - } + "chord": 0.355772721, + "radius": 2.61499265 }, { - "chord": { - "units": "m", - "value": 0.355768401 - }, - "radius": { - "units": "m", - "value": 2.664726078 - } + "chord": 0.355768401, + "radius": 2.664726078 }, { - "chord": { - "units": "m", - "value": 0.355764084 - }, - "radius": { - "units": "m", - "value": 2.709722688 - } + "chord": 0.355764084, + "radius": 2.709722688 }, { - "chord": { - "units": "m", - "value": 0.355759767 - }, - "radius": { - "units": "m", - "value": 2.768929239 - } + "chord": 0.355759767, + "radius": 2.768929239 }, { - "chord": { - "units": "m", - "value": 0.355755447 - }, - "radius": { - "units": "m", - "value": 2.839977235 - } + "chord": 0.355755447, + "radius": 2.839977235 }, { - "chord": { - "units": "m", - "value": 0.35575113 - }, - "radius": { - "units": "m", - "value": 2.906288246 - } + "chord": 0.35575113, + "radius": 2.906288246 }, { - "chord": { - "units": "m", - "value": 0.355746813 - }, - "radius": { - "units": "m", - "value": 3.03772619 - } + "chord": 0.355746813, + "radius": 3.03772619 }, { - "chord": { - "units": "m", - "value": 0.355742493 - }, - "radius": { - "units": "m", - "value": 3.208240195 - } + "chord": 0.355742493, + "radius": 3.208240195 }, { - "chord": { - "units": "m", - "value": 0.355738176 - }, - "radius": { - "units": "m", - "value": 3.342045113 - } + "chord": 0.355738176, + "radius": 3.342045113 }, { - "chord": { - "units": "m", - "value": 0.355733859 - }, - "radius": { - "units": "m", - "value": 3.481771091 - } + "chord": 0.355733859, + "radius": 3.481771091 }, { - "chord": { - "units": "m", - "value": 0.355729539 - }, - "radius": { - "units": "m", - "value": 3.56702767 - } + "chord": 0.355729539, + "radius": 3.56702767 }, { - "chord": { - "units": "m", - "value": 0.355725222 - }, - "radius": { - "units": "m", - "value": 3.647547098 - } + "chord": 0.355725222, + "radius": 3.647547098 }, { - "chord": { - "units": "m", - "value": 0.355720905 - }, - "radius": { - "units": "m", - "value": 3.725699048 - } + "chord": 0.355720905, + "radius": 3.725699048 }, { - "chord": { - "units": "m", - "value": 0.355716585 - }, - "radius": { - "units": "m", - "value": 3.770695491 - } + "chord": 0.355716585, + "radius": 3.770695491 }, { - "chord": { - "units": "m", - "value": 0.355714554 - }, - "radius": { - "units": "m", - "value": 3.81 - } + "chord": 0.355714554, + "radius": 3.81 } ], "entities": { @@ -1077,10 +684,7 @@ "n_loading_nodes": 20, "name": "BET disk", "number_of_blades": 3, - "omega": { - "units": "degree/s", - "value": 0.0046 - }, + "omega": 8.028514559173915e-05, "reynolds_numbers": [ 1.0 ], @@ -16757,657 +16361,270 @@ ] } ], - "sectional_radiuses": { - "units": "m", - "value": [ - 0.3429, - 0.6477, - 1.9431, - 3.048, - 3.81 - ] - }, + "sectional_radiuses": [ + 0.3429, + 0.6477, + 1.9431, + 3.048, + 3.81 + ], "tip_gap": "inf", "twists": [ { - "radius": { - "units": "m", - "value": 0.0 - }, - "twist": { - "units": "degree", - "value": 90.0 - } + "radius": 0.0, + "twist": 1.5707963267948966 }, { - "radius": { - "units": "m", - "value": 0.087645023 - }, - "twist": { - "units": "degree", - "value": 33.27048712 - } + "radius": 0.087645023, + "twist": 0.5806795439863657 }, { - "radius": { - "units": "m", - "value": 0.149032825 - }, - "twist": { - "units": "degree", - "value": 32.37853609 - } + "radius": 0.149032825, + "twist": 0.565112061746311 }, { - "radius": { - "units": "m", - "value": 0.230063394 - }, - "twist": { - "units": "degree", - "value": 31.42712165 - } + "radius": 0.230063394, + "twist": 0.548506747217293 }, { - "radius": { - "units": "m", - "value": 0.31845882 - }, - "twist": { - "units": "degree", - "value": 30.65409742 - } + "radius": 0.31845882, + "twist": 0.535014929206099 }, { - "radius": { - "units": "m", - "value": 0.354421836 - }, - "twist": { - "units": "degree", - "value": 30.13214089 - } + "radius": 0.354421836, + "twist": 0.5259050692053145 }, { - "radius": { - "units": "m", - "value": 0.412445831 - }, - "twist": { - "units": "degree", - "value": 29.41523066 - } + "radius": 0.412445831, + "twist": 0.5133926252505847 }, { - "radius": { - "units": "m", - "value": 0.465733177 - }, - "twist": { - "units": "degree", - "value": 28.75567325 - } + "radius": 0.465733177, + "twist": 0.5018811768401585 }, { - "radius": { - "units": "m", - "value": 0.535598785 - }, - "twist": { - "units": "degree", - "value": 27.89538097 - } + "radius": 0.535598785, + "twist": 0.4868662440246696 }, { - "radius": { - "units": "m", - "value": 0.607834576 - }, - "twist": { - "units": "degree", - "value": 26.69097178 - } + "radius": 0.607834576, + "twist": 0.46584533811789164 }, { - "radius": { - "units": "m", - "value": 0.657570537 - }, - "twist": { - "units": "degree", - "value": 25.88803232 - } + "radius": 0.657570537, + "twist": 0.4518314008467063 }, { - "radius": { - "units": "m", - "value": 0.695464717 - }, - "twist": { - "units": "degree", - "value": 25.25715132 - } + "radius": 0.695464717, + "twist": 0.4408204502084319 }, { - "radius": { - "units": "m", - "value": 0.733359236 - }, - "twist": { - "units": "degree", - "value": 24.5689175 - } + "radius": 0.733359236, + "twist": 0.42880850402585396 }, { - "radius": { - "units": "m", - "value": 0.773621567 - }, - "twist": { - "units": "degree", - "value": 23.93803649 - } + "radius": 0.773621567, + "twist": 0.4177975532130466 }, { - "radius": { - "units": "m", - "value": 0.818620882 - }, - "twist": { - "units": "degree", - "value": 23.19244985 - } + "radius": 0.818620882, + "twist": 0.404784611486165 }, { - "radius": { - "units": "m", - "value": 0.86598886 - }, - "twist": { - "units": "degree", - "value": 22.36083398 - } + "radius": 0.86598886, + "twist": 0.39027017644282785 }, { - "radius": { - "units": "m", - "value": 0.912171916 - }, - "twist": { - "units": "degree", - "value": 21.67260016 - } + "radius": 0.912171916, + "twist": 0.37825823026024985 }, { - "radius": { - "units": "m", - "value": 0.958355818 - }, - "twist": { - "units": "degree", - "value": 20.84098429 - } + "radius": 0.958355818, + "twist": 0.36374379521691275 }, { - "radius": { - "units": "m", - "value": 1.008092457 - }, - "twist": { - "units": "degree", - "value": 19.92333919 - } + "radius": 1.008092457, + "twist": 0.3477278668571201 }, { - "radius": { - "units": "m", - "value": 1.051908539 - }, - "twist": { - "units": "degree", - "value": 19.03437051 - } + "radius": 1.051908539, + "twist": 0.3322124364440122 }, { - "radius": { - "units": "m", - "value": 1.090987133 - }, - "twist": { - "units": "degree", - "value": 18.34613668 - } + "radius": 1.090987133, + "twist": 0.3202004900869013 }, { - "radius": { - "units": "m", - "value": 1.134803219 - }, - "twist": { - "units": "degree", - "value": 17.457168 - } + "radius": 1.134803219, + "twist": 0.30468505967379345 }, { - "radius": { - "units": "m", - "value": 1.17980135 - }, - "twist": { - "units": "degree", - "value": 16.91231622 - } + "radius": 1.17980135, + "twist": 0.2951756021774417 }, { - "radius": { - "units": "m", - "value": 1.229536976 - }, - "twist": { - "units": "degree", - "value": 16.16672958 - } + "radius": 1.229536976, + "twist": 0.28216266045056 }, { - "radius": { - "units": "m", - "value": 1.275719693 - }, - "twist": { - "units": "degree", - "value": 15.53584858 - } + "radius": 1.275719693, + "twist": 0.27115170981228565 }, { - "radius": { - "units": "m", - "value": 1.314797781 - }, - "twist": { - "units": "degree", - "value": 14.93364398 - } + "radius": 1.314797781, + "twist": 0.2606412567716302 }, { - "radius": { - "units": "m", - "value": 1.358613021 - }, - "twist": { - "units": "degree", - "value": 14.18805734 - } + "radius": 1.358613021, + "twist": 0.24762831504474858 }, { - "radius": { - "units": "m", - "value": 1.398875352 - }, - "twist": { - "units": "degree", - "value": 13.55717634 - } + "radius": 1.398875352, + "twist": 0.2366173644064742 }, { - "radius": { - "units": "m", - "value": 1.42966541 - }, - "twist": { - "units": "degree", - "value": 12.86894251 - } + "radius": 1.42966541, + "twist": 0.2246054180493633 }, { - "radius": { - "units": "m", - "value": 1.468744004 - }, - "twist": { - "units": "degree", - "value": 12.18070869 - } + "radius": 1.468744004, + "twist": 0.2125934718667853 }, { - "radius": { - "units": "m", - "value": 1.51019075 - }, - "twist": { - "units": "degree", - "value": 11.49247487 - } + "radius": 1.51019075, + "twist": 0.2005815256842073 }, { - "radius": { - "units": "m", - "value": 1.549268331 - }, - "twist": { - "units": "degree", - "value": 10.9762995 - } + "radius": 1.549268331, + "twist": 0.19157256596000732 }, { - "radius": { - "units": "m", - "value": 1.596633604 - }, - "twist": { - "units": "degree", - "value": 10.60350618 - } + "radius": 1.596633604, + "twist": 0.18506609509656652 }, { - "radius": { - "units": "m", - "value": 1.6629458 - }, - "twist": { - "units": "degree", - "value": 9.94394877 - } + "radius": 1.6629458, + "twist": 0.17355464668614035 }, { - "radius": { - "units": "m", - "value": 1.717417226 - }, - "twist": { - "units": "degree", - "value": 9.28439136 - } + "radius": 1.717417226, + "twist": 0.16204319827571417 }, { - "radius": { - "units": "m", - "value": 1.773072894 - }, - "twist": { - "units": "degree", - "value": 8.59615753 - } + "radius": 1.773072894, + "twist": 0.15003125191860323 }, { - "radius": { - "units": "m", - "value": 1.81688746 - }, - "twist": { - "units": "degree", - "value": 7.96527653 - } + "radius": 1.81688746, + "twist": 0.13902030128032888 }, { - "radius": { - "units": "m", - "value": 1.866622407 - }, - "twist": { - "units": "degree", - "value": 7.33439553 - } + "radius": 1.866622407, + "twist": 0.12800935064205454 }, { - "radius": { - "units": "m", - "value": 1.893858878 - }, - "twist": { - "units": "degree", - "value": 6.87557298 - } + "radius": 1.893858878, + "twist": 0.12000138646215824 }, { - "radius": { - "units": "m", - "value": 1.949512352 - }, - "twist": { - "units": "degree", - "value": 6.56013248 - } + "radius": 1.949512352, + "twist": 0.11449591114302106 }, { - "radius": { - "units": "m", - "value": 2.015823535 - }, - "twist": { - "units": "degree", - "value": 6.07263352 - } + "radius": 2.015823535, + "twist": 0.10598744919097293 }, { - "radius": { - "units": "m", - "value": 2.07503076 - }, - "twist": { - "units": "degree", - "value": 5.49910533 - } + "radius": 2.07503076, + "twist": 0.0959774939224693 }, { - "radius": { - "units": "m", - "value": 2.124764355 - }, - "twist": { - "units": "degree", - "value": 5.0976356 - } + "radius": 2.124764355, + "twist": 0.08897052528687666 }, { - "radius": { - "units": "m", - "value": 2.169761643 - }, - "twist": { - "units": "degree", - "value": 4.69616587 - } + "radius": 2.169761643, + "twist": 0.08196355665128399 }, { - "radius": { - "units": "m", - "value": 2.231337023 - }, - "twist": { - "units": "degree", - "value": 4.12263769 - } + "radius": 2.231337023, + "twist": 0.07195360155731331 }, { - "radius": { - "units": "m", - "value": 2.273965822 - }, - "twist": { - "units": "degree", - "value": 3.77852078 - } + "radius": 2.273965822, + "twist": 0.06594762846602431 }, { - "radius": { - "units": "m", - "value": 2.321330759 - }, - "twist": { - "units": "degree", - "value": 3.46308028 - } + "radius": 2.321330759, + "twist": 0.06044215314688713 }, { - "radius": { - "units": "m", - "value": 2.395930475 - }, - "twist": { - "units": "degree", - "value": 2.97558132 - } + "radius": 2.395930475, + "twist": 0.051933691194838996 }, { - "radius": { - "units": "m", - "value": 2.533289143 - }, - "twist": { - "units": "degree", - "value": 2.0005834 - } + "radius": 2.533289143, + "twist": 0.03491676729074272 }, { - "radius": { - "units": "m", - "value": 2.61499265 - }, - "twist": { - "units": "degree", - "value": 1.62779008 - } + "radius": 2.61499265, + "twist": 0.0284102964273019 }, { - "radius": { - "units": "m", - "value": 2.664726078 - }, - "twist": { - "units": "degree", - "value": 1.25499676 - } + "radius": 2.664726078, + "twist": 0.02190382556386107 }, { - "radius": { - "units": "m", - "value": 2.709722688 - }, - "twist": { - "units": "degree", - "value": 0.96823267 - } + "radius": 2.709722688, + "twist": 0.016898848016875724 }, { - "radius": { - "units": "m", - "value": 2.768929239 - }, - "twist": { - "units": "degree", - "value": 0.50941012 - } + "radius": 2.768929239, + "twist": 0.008890883836979417 }, { - "radius": { - "units": "m", - "value": 2.839977235 - }, - "twist": { - "units": "degree", - "value": -0.064118065 - } + "radius": 2.839977235, + "twist": -0.0011190713442577381 }, { - "radius": { - "units": "m", - "value": 2.906288246 - }, - "twist": { - "units": "degree", - "value": -0.522940614 - } + "radius": 2.906288246, + "twist": -0.009127035506700755 }, { - "radius": { - "units": "m", - "value": 3.03772619 - }, - "twist": { - "units": "degree", - "value": -1.44058571 - } + "radius": 3.03772619, + "twist": -0.0251429637966802 }, { - "radius": { - "units": "m", - "value": 3.208240195 - }, - "twist": { - "units": "degree", - "value": -2.61631849 - } + "radius": 3.208240195, + "twist": -0.04566337193130633 }, { - "radius": { - "units": "m", - "value": 3.342045113 - }, - "twist": { - "units": "degree", - "value": -3.333228722 - } + "radius": 3.342045113, + "twist": -0.05817581592094275 }, { - "radius": { - "units": "m", - "value": 3.481771091 - }, - "twist": { - "units": "degree", - "value": -4.164844591 - } + "radius": 3.481771091, + "twist": -0.07269025094682659 }, { - "radius": { - "units": "m", - "value": 3.56702767 - }, - "twist": { - "units": "degree", - "value": -4.681019957 - } + "radius": 3.56702767, + "twist": -0.08169921060121339 }, { - "radius": { - "units": "m", - "value": 3.647547098 - }, - "twist": { - "units": "degree", - "value": -5.053813278 - } + "radius": 3.647547098, + "twist": -0.0882056814821075 }, { - "radius": { - "units": "m", - "value": 3.725699048 - }, - "twist": { - "units": "degree", - "value": -5.541312235 - } + "radius": 3.725699048, + "twist": -0.09671414338179578 }, { - "radius": { - "units": "m", - "value": 3.770695491 - }, - "twist": { - "units": "degree", - "value": -5.799399919 - } + "radius": 3.770695491, + "twist": -0.10121862322644246 }, { - "radius": { - "units": "m", - "value": 3.81 - }, - "twist": { - "units": "degree", - "value": -6.1 - } + "radius": 3.81, + "twist": -0.1064650843716541 } ], "type": "BETDisk" diff --git a/tests/simulation/converter/ref/ref_monitor.json b/tests/simulation/converter/ref/ref_monitor.json index 3eaf1a580..c493399fd 100644 --- a/tests/simulation/converter/ref/ref_monitor.json +++ b/tests/simulation/converter/ref/ref_monitor.json @@ -12,18 +12,9 @@ "interface_interpolation_tolerance": 0.2, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -46,14 +37,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -132,14 +117,11 @@ "entities": { "stored_entities": [ { - "location": { - "units": "m", - "value": [ - 2.694298, - 0.0, - 1.0195910000000001 - ] - }, + "location": [ + 2.694298, + 0.0, + 1.0195910000000001 + ], "name": "Point-0", "private_attribute_entity_type_name": "Point" } @@ -157,38 +139,29 @@ "entities": { "stored_entities": [ { - "location": { - "units": "m", - "value": [ - 4.007, - 0.0, - -0.31760000000000005 - ] - }, + "location": [ + 4.007, + 0.0, + -0.31760000000000005 + ], "name": "Point-1", "private_attribute_entity_type_name": "Point" }, { - "location": { - "units": "m", - "value": [ - 4.007, - 0.0, - -0.29760000000000003 - ] - }, + "location": [ + 4.007, + 0.0, + -0.29760000000000003 + ], "name": "Point-2", "private_attribute_entity_type_name": "Point" }, { - "location": { - "units": "m", - "value": [ - 4.007, - 0.0, - -0.2776 - ] - }, + "location": [ + 4.007, + 0.0, + -0.2776 + ], "name": "Point-3", "private_attribute_entity_type_name": "Point" } @@ -221,5 +194,6 @@ "unit_system": { "name": "SI" }, - "user_defined_fields": [] + "user_defined_fields": [], + "version": "25.9.2b1" } diff --git a/tests/simulation/converter/ref/ref_single_bet_disk.json b/tests/simulation/converter/ref/ref_single_bet_disk.json index 5f2d49832..aa88e8e2c 100644 --- a/tests/simulation/converter/ref/ref_single_bet_disk.json +++ b/tests/simulation/converter/ref/ref_single_bet_disk.json @@ -1,418 +1,385 @@ { - "alphas": { - "units": "degree", - "value": [ - -180, - -179, - -178, - -177, - -176, - -175, - -174, - -173, - -172, - -171, - -170, - -169, - -168, - -167, - -166, - -165, - -164, - -163, - -162, - -161, - -160, - -159, - -158, - -157, - -156, - -155, - -154, - -153, - -152, - -151, - -150, - -149, - -148, - -147, - -146, - -145, - -144, - -143, - -142, - -141, - -140, - -139, - -138, - -137, - -136, - -135, - -134, - -133, - -132, - -131, - -130, - -129, - -128, - -127, - -126, - -125, - -124, - -123, - -122, - -121, - -120, - -119, - -118, - -117, - -116, - -115, - -114, - -113, - -112, - -111, - -110, - -109, - -108, - -107, - -106, - -105, - -104, - -103, - -102, - -101, - -100, - -99, - -98, - -97, - -96, - -95, - -94, - -93, - -92, - -91, - -90, - -89, - -88, - -87, - -86, - -85, - -84, - -83, - -82, - -81, - -80, - -79, - -78, - -77, - -76, - -75, - -74, - -73, - -72, - -71, - -70, - -69, - -68, - -67, - -66, - -65, - -64, - -63, - -62, - -61, - -60, - -59, - -58, - -57, - -56, - -55, - -54, - -53, - -52, - -51, - -50, - -49, - -48, - -47, - -46, - -45, - -44, - -43, - -42, - -41, - -40, - -39, - -38, - -37, - -36, - -35, - -34, - -33, - -32, - -31, - -30, - -29, - -28, - -27, - -26, - -25, - -24, - -23, - -22, - -21, - -20, - -19, - -18, - -17, - -16, - -15, - -14, - -13, - -12, - -11, - -10, - -9, - -8, - -7, - -6, - -5, - -4, - -3, - -2, - -1, - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 31, - 32, - 33, - 34, - 35, - 36, - 37, - 38, - 39, - 40, - 41, - 42, - 43, - 44, - 45, - 46, - 47, - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 55, - 56, - 57, - 58, - 59, - 60, - 61, - 62, - 63, - 64, - 65, - 66, - 67, - 68, - 69, - 70, - 71, - 72, - 73, - 74, - 75, - 76, - 77, - 78, - 79, - 80, - 81, - 82, - 83, - 84, - 85, - 86, - 87, - 88, - 89, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 106, - 107, - 108, - 109, - 110, - 111, - 112, - 113, - 114, - 115, - 116, - 117, - 118, - 119, - 120, - 121, - 122, - 123, - 124, - 125, - 126, - 127, - 128, - 129, - 130, - 131, - 132, - 133, - 134, - 135, - 136, - 137, - 138, - 139, - 140, - 141, - 142, - 143, - 144, - 145, - 146, - 147, - 148, - 149, - 150, - 151, - 152, - 153, - 154, - 155, - 156, - 157, - 158, - 159, - 160, - 161, - 162, - 163, - 164, - 165, - 166, - 167, - 168, - 169, - 170, - 171, - 172, - 173, - 174, - 175, - 176, - 177, - 178, - 179, - 180 - ] - }, - "blade_line_chord": { - "units": "m", - "value": 0.0 - }, - "chord_ref": { - "units": "cm", - "value": 14.0 - }, + "alphas": [ + -3.141592653589793, + -3.12413936106985, + -3.1066860685499065, + -3.0892327760299634, + -3.07177948351002, + -3.0543261909900767, + -3.036872898470133, + -3.01941960595019, + -3.001966313430247, + -2.9845130209103035, + -2.9670597283903604, + -2.949606435870417, + -2.9321531433504737, + -2.91469985083053, + -2.897246558310587, + -2.8797932657906435, + -2.8623399732707004, + -2.8448866807507573, + -2.827433388230814, + -2.8099800957108707, + -2.792526803190927, + -2.775073510670984, + -2.7576202181510405, + -2.7401669256310974, + -2.722713633111154, + -2.705260340591211, + -2.6878070480712677, + -2.670353755551324, + -2.652900463031381, + -2.6354471705114375, + -2.6179938779914944, + -2.600540585471551, + -2.5830872929516078, + -2.5656340004316642, + -2.548180707911721, + -2.530727415391778, + -2.5132741228718345, + -2.4958208303518914, + -2.478367537831948, + -2.4609142453120048, + -2.443460952792061, + -2.426007660272118, + -2.4085543677521746, + -2.3911010752322315, + -2.3736477827122884, + -2.356194490192345, + -2.3387411976724017, + -2.321287905152458, + -2.303834612632515, + -2.2863813201125716, + -2.2689280275926285, + -2.251474735072685, + -2.234021442552742, + -2.2165681500327987, + -2.199114857512855, + -2.181661564992912, + -2.1642082724729685, + -2.1467549799530254, + -2.129301687433082, + -2.111848394913139, + -2.0943951023931953, + -2.076941809873252, + -2.059488517353309, + -2.0420352248333655, + -2.0245819323134224, + -2.007128639793479, + -1.9896753472735358, + -1.9722220547535925, + -1.9547687622336491, + -1.9373154697137058, + -1.9198621771937625, + -1.9024088846738192, + -1.8849555921538759, + -1.8675022996339325, + -1.8500490071139892, + -1.8325957145940461, + -1.8151424220741028, + -1.7976891295541595, + -1.7802358370342162, + -1.7627825445142729, + -1.7453292519943295, + -1.7278759594743862, + -1.710422666954443, + -1.6929693744344996, + -1.6755160819145565, + -1.6580627893946132, + -1.6406094968746698, + -1.6231562043547265, + -1.6057029118347832, + -1.5882496193148399, + -1.5707963267948966, + -1.5533430342749532, + -1.53588974175501, + -1.5184364492350666, + -1.5009831567151235, + -1.4835298641951802, + -1.4660765716752369, + -1.4486232791552935, + -1.4311699866353502, + -1.413716694115407, + -1.3962634015954636, + -1.3788101090755203, + -1.361356816555577, + -1.3439035240356338, + -1.3264502315156905, + -1.3089969389957472, + -1.2915436464758039, + -1.2740903539558606, + -1.2566370614359172, + -1.239183768915974, + -1.2217304763960306, + -1.2042771838760873, + -1.1868238913561442, + -1.1693705988362009, + -1.1519173063162575, + -1.1344640137963142, + -1.117010721276371, + -1.0995574287564276, + -1.0821041362364843, + -1.064650843716541, + -1.0471975511965976, + -1.0297442586766545, + -1.0122909661567112, + -0.9948376736367679, + -0.9773843811168246, + -0.9599310885968813, + -0.9424777960769379, + -0.9250245035569946, + -0.9075712110370514, + -0.8901179185171081, + -0.8726646259971648, + -0.8552113334772214, + -0.8377580409572782, + -0.8203047484373349, + -0.8028514559173916, + -0.7853981633974483, + -0.767944870877505, + -0.7504915783575618, + -0.7330382858376184, + -0.7155849933176751, + -0.6981317007977318, + -0.6806784082777885, + -0.6632251157578453, + -0.6457718232379019, + -0.6283185307179586, + -0.6108652381980153, + -0.5934119456780721, + -0.5759586531581288, + -0.5585053606381855, + -0.5410520681182421, + -0.5235987755982988, + -0.5061454830783556, + -0.4886921905584123, + -0.47123889803846897, + -0.4537856055185257, + -0.4363323129985824, + -0.4188790204786391, + -0.4014257279586958, + -0.3839724354387525, + -0.3665191429188092, + -0.3490658503988659, + -0.33161255787892263, + -0.3141592653589793, + -0.29670597283903605, + -0.2792526803190927, + -0.2617993877991494, + -0.24434609527920614, + -0.22689280275926285, + -0.20943951023931956, + -0.19198621771937624, + -0.17453292519943295, + -0.15707963267948966, + -0.13962634015954636, + -0.12217304763960307, + -0.10471975511965978, + -0.08726646259971647, + -0.06981317007977318, + -0.05235987755982989, + -0.03490658503988659, + -0.017453292519943295, + 0.0, + 0.017453292519943295, + 0.03490658503988659, + 0.05235987755982989, + 0.06981317007977318, + 0.08726646259971647, + 0.10471975511965978, + 0.12217304763960307, + 0.13962634015954636, + 0.15707963267948966, + 0.17453292519943295, + 0.19198621771937624, + 0.20943951023931956, + 0.22689280275926285, + 0.24434609527920614, + 0.2617993877991494, + 0.2792526803190927, + 0.29670597283903605, + 0.3141592653589793, + 0.33161255787892263, + 0.3490658503988659, + 0.3665191429188092, + 0.3839724354387525, + 0.4014257279586958, + 0.4188790204786391, + 0.4363323129985824, + 0.4537856055185257, + 0.47123889803846897, + 0.4886921905584123, + 0.5061454830783556, + 0.5235987755982988, + 0.5410520681182421, + 0.5585053606381855, + 0.5759586531581288, + 0.5934119456780721, + 0.6108652381980153, + 0.6283185307179586, + 0.6457718232379019, + 0.6632251157578453, + 0.6806784082777885, + 0.6981317007977318, + 0.7155849933176751, + 0.7330382858376184, + 0.7504915783575618, + 0.767944870877505, + 0.7853981633974483, + 0.8028514559173916, + 0.8203047484373349, + 0.8377580409572782, + 0.8552113334772214, + 0.8726646259971648, + 0.8901179185171081, + 0.9075712110370514, + 0.9250245035569946, + 0.9424777960769379, + 0.9599310885968813, + 0.9773843811168246, + 0.9948376736367679, + 1.0122909661567112, + 1.0297442586766545, + 1.0471975511965976, + 1.064650843716541, + 1.0821041362364843, + 1.0995574287564276, + 1.117010721276371, + 1.1344640137963142, + 1.1519173063162575, + 1.1693705988362009, + 1.1868238913561442, + 1.2042771838760873, + 1.2217304763960306, + 1.239183768915974, + 1.2566370614359172, + 1.2740903539558606, + 1.2915436464758039, + 1.3089969389957472, + 1.3264502315156905, + 1.3439035240356338, + 1.361356816555577, + 1.3788101090755203, + 1.3962634015954636, + 1.413716694115407, + 1.4311699866353502, + 1.4486232791552935, + 1.4660765716752369, + 1.4835298641951802, + 1.5009831567151235, + 1.5184364492350666, + 1.53588974175501, + 1.5533430342749532, + 1.5707963267948966, + 1.5882496193148399, + 1.6057029118347832, + 1.6231562043547265, + 1.6406094968746698, + 1.6580627893946132, + 1.6755160819145565, + 1.6929693744344996, + 1.710422666954443, + 1.7278759594743862, + 1.7453292519943295, + 1.7627825445142729, + 1.7802358370342162, + 1.7976891295541595, + 1.8151424220741028, + 1.8325957145940461, + 1.8500490071139892, + 1.8675022996339325, + 1.8849555921538759, + 1.9024088846738192, + 1.9198621771937625, + 1.9373154697137058, + 1.9547687622336491, + 1.9722220547535925, + 1.9896753472735358, + 2.007128639793479, + 2.0245819323134224, + 2.0420352248333655, + 2.059488517353309, + 2.076941809873252, + 2.0943951023931953, + 2.111848394913139, + 2.129301687433082, + 2.1467549799530254, + 2.1642082724729685, + 2.181661564992912, + 2.199114857512855, + 2.2165681500327987, + 2.234021442552742, + 2.251474735072685, + 2.2689280275926285, + 2.2863813201125716, + 2.303834612632515, + 2.321287905152458, + 2.3387411976724017, + 2.356194490192345, + 2.3736477827122884, + 2.3911010752322315, + 2.4085543677521746, + 2.426007660272118, + 2.443460952792061, + 2.4609142453120048, + 2.478367537831948, + 2.4958208303518914, + 2.5132741228718345, + 2.530727415391778, + 2.548180707911721, + 2.5656340004316642, + 2.5830872929516078, + 2.600540585471551, + 2.6179938779914944, + 2.6354471705114375, + 2.652900463031381, + 2.670353755551324, + 2.6878070480712677, + 2.705260340591211, + 2.722713633111154, + 2.7401669256310974, + 2.7576202181510405, + 2.775073510670984, + 2.792526803190927, + 2.8099800957108707, + 2.827433388230814, + 2.8448866807507573, + 2.8623399732707004, + 2.8797932657906435, + 2.897246558310587, + 2.91469985083053, + 2.9321531433504737, + 2.949606435870417, + 2.9670597283903604, + 2.9845130209103035, + 3.001966313430247, + 3.01941960595019, + 3.036872898470133, + 3.0543261909900767, + 3.07177948351002, + 3.0892327760299634, + 3.1066860685499065, + 3.12413936106985, + 3.141592653589793 + ], + "blade_line_chord": 0.0, + "chord_ref": 0.14, "chords": [ { - "chord": { - "units": "cm", - "value": 0.0 - }, - "radius": { - "units": "cm", - "value": 13.4999999 - } + "chord": 0.0, + "radius": 0.134999999 }, { - "chord": { - "units": "cm", - "value": 17.69622361 - }, - "radius": { - "units": "cm", - "value": 13.5 - } + "chord": 0.1769622361, + "radius": 0.135 }, { - "chord": { - "units": "cm", - "value": 14.012241185039136 - }, - "radius": { - "units": "cm", - "value": 37.356855462705056 - } + "chord": 0.14012241185039137, + "radius": 0.3735685546270506 }, { - "chord": { - "units": "cm", - "value": 14.004512929656503 - }, - "radius": { - "units": "cm", - "value": 150.0348189415042 - } + "chord": 0.14004512929656504, + "radius": 1.500348189415042 } ], "entities": { @@ -448,18 +415,12 @@ "n_loading_nodes": 20, "name": "MyBETDisk", "number_of_blades": 3, - "omega": { - "units": "rad/s", - "value": 156.5352426717779 - }, + "omega": 156.5352426717779, "private_attribute_constructor": "default", "private_attribute_input_cache": { "angle_unit": null, "blade_line_chord": null, - "chord_ref": { - "units": "cm", - "value": 14.0 - }, + "chord_ref": 0.14, "entities": { "selectors": null, "stored_entities": [ @@ -492,10 +453,7 @@ "n_loading_nodes": 20, "name": "MyBETDisk", "number_of_blades": 3, - "omega": { - "units": "rad/s", - "value": 156.5352426717779 - }, + "omega": 156.5352426717779, "rotation_direction_rule": "leftHand" }, "reynolds_numbers": [ @@ -4920,81 +4878,39 @@ ] } ], - "sectional_radiuses": { - "units": "cm", - "value": [ - 13.5, - 25.5, - 37.5, - 76.5, - 120.0, - 150.0 - ] - }, - "tip_gap": { - "units": "cm", - "value": 0.02 - }, + "sectional_radiuses": [ + 0.135, + 0.255, + 0.375, + 0.765, + 1.2, + 1.5 + ], + "tip_gap": 0.0002, "twists": [ { - "radius": { - "units": "cm", - "value": 13.5 - }, - "twist": { - "units": "degree", - "value": 30.29936539609504 - } + "radius": 0.135, + "twist": 0.5288236874266943 }, { - "radius": { - "units": "cm", - "value": 25.5 - }, - "twist": { - "units": "degree", - "value": 26.047382700278234 - } + "radius": 0.255, + "twist": 0.4546125896468665 }, { - "radius": { - "units": "cm", - "value": 37.356855 - }, - "twist": { - "units": "degree", - "value": 21.01189770991256 - } + "radius": 0.37356855000000005, + "twist": 0.36672679713023054 }, { - "radius": { - "units": "cm", - "value": 76.5 - }, - "twist": { - "units": "degree", - "value": 6.596477306554306 - } + "radius": 0.765, + "twist": 0.11513024803245996 }, { - "radius": { - "units": "cm", - "value": 120.0 - }, - "twist": { - "units": "degree", - "value": -1.5114259546742872 - } + "radius": 1.2, + "twist": -0.02637935930916489 }, { - "radius": { - "units": "cm", - "value": 150.0 - }, - "twist": { - "units": "degree", - "value": -6.02484 - } + "radius": 1.5, + "twist": -0.10515329490585516 } ], "type": "BETDisk", diff --git a/tests/simulation/converter/ref/ref_xfoil.json b/tests/simulation/converter/ref/ref_xfoil.json index d0c875b0c..b075a6157 100644 --- a/tests/simulation/converter/ref/ref_xfoil.json +++ b/tests/simulation/converter/ref/ref_xfoil.json @@ -1,748 +1,355 @@ { - "alphas": { - "units": "degree", - "value": [ - -180.0, - -170.0, - -160.0, - -150.0, - -140.0, - -130.0, - -120.0, - -110.0, - -100.0, - -90.0, - -80.0, - -70.0, - -60.0, - -50.0, - -40.0, - -30.0, - -29.0, - -28.0, - -27.0, - -26.0, - -25.0, - -24.0, - -23.0, - -22.0, - -21.0, - -20.0, - -19.0, - -18.0, - -17.0, - -16.0, - -15.0, - -14.0, - -13.0, - -12.0, - -11.0, - -10.0, - -9.0, - -8.0, - -7.0, - -6.0, - -5.0, - -4.0, - -3.0, - -2.0, - -1.0, - 0.0, - 1.0, - 2.0, - 3.0, - 4.0, - 5.0, - 6.0, - 7.0, - 8.0, - 9.0, - 10.0, - 11.0, - 12.0, - 13.0, - 14.0, - 15.0, - 16.0, - 17.0, - 18.0, - 19.0, - 20.0, - 21.0, - 22.0, - 23.0, - 24.0, - 25.0, - 26.0, - 27.0, - 28.0, - 29.0, - 30.0, - 40.0, - 50.0, - 60.0, - 70.0, - 80.0, - 90.0, - 100.0, - 110.0, - 120.0, - 130.0, - 140.0, - 150.0, - 160.0, - 170.0, - 180.0 - ] - }, - "blade_line_chord": { - "units": "m", - "value": 1.0 - }, - "chord_ref": { - "units": "m", - "value": 14.0 - }, + "alphas": [ + -3.141592653589793, + -2.9670597283903604, + -2.792526803190927, + -2.6179938779914944, + -2.443460952792061, + -2.2689280275926285, + -2.0943951023931953, + -1.9198621771937625, + -1.7453292519943295, + -1.5707963267948966, + -1.3962634015954636, + -1.2217304763960306, + -1.0471975511965976, + -0.8726646259971648, + -0.6981317007977318, + -0.5235987755982988, + -0.5061454830783556, + -0.4886921905584123, + -0.47123889803846897, + -0.4537856055185257, + -0.4363323129985824, + -0.4188790204786391, + -0.4014257279586958, + -0.3839724354387525, + -0.3665191429188092, + -0.3490658503988659, + -0.33161255787892263, + -0.3141592653589793, + -0.29670597283903605, + -0.2792526803190927, + -0.2617993877991494, + -0.24434609527920614, + -0.22689280275926285, + -0.20943951023931956, + -0.19198621771937624, + -0.17453292519943295, + -0.15707963267948966, + -0.13962634015954636, + -0.12217304763960307, + -0.10471975511965978, + -0.08726646259971647, + -0.06981317007977318, + -0.05235987755982989, + -0.03490658503988659, + -0.017453292519943295, + 0.0, + 0.017453292519943295, + 0.03490658503988659, + 0.05235987755982989, + 0.06981317007977318, + 0.08726646259971647, + 0.10471975511965978, + 0.12217304763960307, + 0.13962634015954636, + 0.15707963267948966, + 0.17453292519943295, + 0.19198621771937624, + 0.20943951023931956, + 0.22689280275926285, + 0.24434609527920614, + 0.2617993877991494, + 0.2792526803190927, + 0.29670597283903605, + 0.3141592653589793, + 0.33161255787892263, + 0.3490658503988659, + 0.3665191429188092, + 0.3839724354387525, + 0.4014257279586958, + 0.4188790204786391, + 0.4363323129985824, + 0.4537856055185257, + 0.47123889803846897, + 0.4886921905584123, + 0.5061454830783556, + 0.5235987755982988, + 0.6981317007977318, + 0.8726646259971648, + 1.0471975511965976, + 1.2217304763960306, + 1.3962634015954636, + 1.5707963267948966, + 1.7453292519943295, + 1.9198621771937625, + 2.0943951023931953, + 2.2689280275926285, + 2.443460952792061, + 2.6179938779914944, + 2.792526803190927, + 2.9670597283903604, + 3.141592653589793 + ], + "blade_line_chord": 1.0, + "chord_ref": 14.0, "chords": [ { - "chord": { - "units": "m", - "value": 0.0 - }, - "radius": { - "units": "m", - "value": 0.0 - } + "chord": 0.0, + "radius": 0.0 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 3.4505914499999997 - } + "chord": 17.01426045, + "radius": 3.4505914499999997 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 5.867434050000001 - } + "chord": 17.01426045, + "radius": 5.867434050000001 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 9.05761395 - } + "chord": 17.01426045, + "radius": 9.05761395 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 12.5377488 - } + "chord": 17.01426045, + "radius": 12.5377488 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 13.9536156 - } + "chord": 17.01426045, + "radius": 13.9536156 }, { - "chord": { - "units": "m", - "value": 17.01426045 - }, - "radius": { - "units": "m", - "value": 16.238024850000002 - } + "chord": 17.01426045, + "radius": 16.238024850000002 }, { - "chord": { - "units": "m", - "value": 16.7413494 - }, - "radius": { - "units": "m", - "value": 18.33595185 - } + "chord": 16.7413494, + "radius": 18.33595185 }, { - "chord": { - "units": "m", - "value": 16.4684385 - }, - "radius": { - "units": "m", - "value": 21.086566350000002 - } + "chord": 16.4684385, + "radius": 21.086566350000002 }, { - "chord": { - "units": "m", - "value": 16.19552745 - }, - "radius": { - "units": "m", - "value": 23.9304951 - } + "chord": 16.19552745, + "radius": 23.9304951 }, { - "chord": { - "units": "m", - "value": 15.9226164 - }, - "radius": { - "units": "m", - "value": 25.8886038 - } + "chord": 15.9226164, + "radius": 25.8886038 }, { - "chord": { - "units": "m", - "value": 15.649705500000001 - }, - "radius": { - "units": "m", - "value": 27.380500650000002 - } + "chord": 15.649705500000001, + "radius": 27.380500650000002 }, { - "chord": { - "units": "m", - "value": 15.37679445 - }, - "radius": { - "units": "m", - "value": 28.87241085 - } + "chord": 15.37679445, + "radius": 28.87241085 }, { - "chord": { - "units": "m", - "value": 15.1038834 - }, - "radius": { - "units": "m", - "value": 30.457542 - } + "chord": 15.1038834, + "radius": 30.457542 }, { - "chord": { - "units": "m", - "value": 14.830972500000001 - }, - "radius": { - "units": "m", - "value": 32.2291686 - } + "chord": 14.830972500000001, + "radius": 32.2291686 }, { - "chord": { - "units": "m", - "value": 14.55806145 - }, - "radius": { - "units": "m", - "value": 34.0940496 - } + "chord": 14.55806145, + "radius": 34.0940496 }, { - "chord": { - "units": "m", - "value": 14.285150400000001 - }, - "radius": { - "units": "m", - "value": 35.91228015 - } + "chord": 14.285150400000001, + "radius": 35.91228015 }, { - "chord": { - "units": "m", - "value": 14.012239950000001 - }, - "radius": { - "units": "m", - "value": 37.730543999999995 - } + "chord": 14.012239950000001, + "radius": 37.730543999999995 }, { - "chord": { - "units": "m", - "value": 14.012070000000001 - }, - "radius": { - "units": "m", - "value": 39.6886794 - } + "chord": 14.012070000000001, + "radius": 39.6886794 }, { - "chord": { - "units": "m", - "value": 14.011900050000001 - }, - "radius": { - "units": "m", - "value": 41.413722 - } + "chord": 14.011900050000001, + "radius": 41.413722 }, { - "chord": { - "units": "m", - "value": 14.011729950000001 - }, - "radius": { - "units": "m", - "value": 42.95224935 - } + "chord": 14.011729950000001, + "radius": 42.95224935 }, { - "chord": { - "units": "m", - "value": 14.011560000000001 - }, - "radius": { - "units": "m", - "value": 44.6772921 - } + "chord": 14.011560000000001, + "radius": 44.6772921 }, { - "chord": { - "units": "m", - "value": 14.011390050000001 - }, - "radius": { - "units": "m", - "value": 46.448872050000006 - } + "chord": 14.011390050000001, + "radius": 46.448872050000006 }, { - "chord": { - "units": "m", - "value": 14.011219950000001 - }, - "radius": { - "units": "m", - "value": 48.40696755 - } + "chord": 14.011219950000001, + "radius": 48.40696755 }, { - "chord": { - "units": "m", - "value": 14.011050000000001 - }, - "radius": { - "units": "m", - "value": 50.22518475 - } + "chord": 14.011050000000001, + "radius": 50.22518475 }, { - "chord": { - "units": "m", - "value": 14.01088005 - }, - "radius": { - "units": "m", - "value": 51.763692150000004 - } + "chord": 14.01088005, + "radius": 51.763692150000004 }, { - "chord": { - "units": "m", - "value": 14.01070995 - }, - "radius": { - "units": "m", - "value": 53.4887016 - } + "chord": 14.01070995, + "radius": 53.4887016 }, { - "chord": { - "units": "m", - "value": 14.01054 - }, - "radius": { - "units": "m", - "value": 55.07383275 - } + "chord": 14.01054, + "radius": 55.07383275 }, { - "chord": { - "units": "m", - "value": 14.01037005 - }, - "radius": { - "units": "m", - "value": 56.28603975 - } + "chord": 14.01037005, + "radius": 56.28603975 }, { - "chord": { - "units": "m", - "value": 14.01019995 - }, - "radius": { - "units": "m", - "value": 57.824567099999996 - } + "chord": 14.01019995, + "radius": 57.824567099999996 }, { - "chord": { - "units": "m", - "value": 14.01003 - }, - "radius": { - "units": "m", - "value": 59.456328750000004 - } + "chord": 14.01003, + "radius": 59.456328750000004 }, { - "chord": { - "units": "m", - "value": 14.00986005 - }, - "radius": { - "units": "m", - "value": 60.994816199999995 - } + "chord": 14.00986005, + "radius": 60.994816199999995 }, { - "chord": { - "units": "m", - "value": 14.00968995 - }, - "radius": { - "units": "m", - "value": 62.859590700000005 - } + "chord": 14.00968995, + "radius": 62.859590700000005 }, { - "chord": { - "units": "m", - "value": 14.00952 - }, - "radius": { - "units": "m", - "value": 65.4703071 - } + "chord": 14.00952, + "radius": 65.4703071 }, { - "chord": { - "units": "m", - "value": 14.00935005 - }, - "radius": { - "units": "m", - "value": 67.6148514 - } + "chord": 14.00935005, + "radius": 67.6148514 }, { - "chord": { - "units": "m", - "value": 14.00917995 - }, - "radius": { - "units": "m", - "value": 69.80601945 - } + "chord": 14.00917995, + "radius": 69.80601945 }, { - "chord": { - "units": "m", - "value": 14.00901 - }, - "radius": { - "units": "m", - "value": 71.53100235 - } + "chord": 14.00901, + "radius": 71.53100235 }, { - "chord": { - "units": "m", - "value": 14.00884005 - }, - "radius": { - "units": "m", - "value": 73.48907115 - } + "chord": 14.00884005, + "radius": 73.48907115 }, { - "chord": { - "units": "m", - "value": 14.00866995 - }, - "radius": { - "units": "m", - "value": 74.56137315 - } + "chord": 14.00866995, + "radius": 74.56137315 }, { - "chord": { - "units": "m", - "value": 14.0085 - }, - "radius": { - "units": "m", - "value": 76.7524548 - } + "chord": 14.0085, + "radius": 76.7524548 }, { - "chord": { - "units": "m", - "value": 14.00833005 - }, - "radius": { - "units": "m", - "value": 79.36313129999999 - } + "chord": 14.00833005, + "radius": 79.36313129999999 }, { - "chord": { - "units": "m", - "value": 14.00815995 - }, - "radius": { - "units": "m", - "value": 81.6941244 - } + "chord": 14.00815995, + "radius": 81.6941244 }, { - "chord": { - "units": "m", - "value": 14.00799 - }, - "radius": { - "units": "m", - "value": 83.65213994999999 - } + "chord": 14.00799, + "radius": 83.65213994999999 }, { - "chord": { - "units": "m", - "value": 14.00782005 - }, - "radius": { - "units": "m", - "value": 85.42368675 - } + "chord": 14.00782005, + "radius": 85.42368675 }, { - "chord": { - "units": "m", - "value": 14.00764995 - }, - "radius": { - "units": "m", - "value": 87.84791430000001 - } + "chord": 14.00764995, + "radius": 87.84791430000001 }, { - "chord": { - "units": "m", - "value": 14.00748 - }, - "radius": { - "units": "m", - "value": 89.52621345 - } + "chord": 14.00748, + "radius": 89.52621345 }, { - "chord": { - "units": "m", - "value": 14.00731005 - }, - "radius": { - "units": "m", - "value": 91.39097475 - } + "chord": 14.00731005, + "radius": 91.39097475 }, { - "chord": { - "units": "m", - "value": 14.00713995 - }, - "radius": { - "units": "m", - "value": 94.32797144999999 - } + "chord": 14.00713995, + "radius": 94.32797144999999 }, { - "chord": { - "units": "m", - "value": 14.006969999999999 - }, - "radius": { - "units": "m", - "value": 99.73579305 - } + "chord": 14.006969999999999, + "radius": 99.73579305 }, { - "chord": { - "units": "m", - "value": 14.006800049999999 - }, - "radius": { - "units": "m", - "value": 102.95246655 - } + "chord": 14.006800049999999, + "radius": 102.95246655 }, { - "chord": { - "units": "m", - "value": 14.00662995 - }, - "radius": { - "units": "m", - "value": 104.91047549999999 - } + "chord": 14.00662995, + "radius": 104.91047549999999 }, { - "chord": { - "units": "m", - "value": 14.00646 - }, - "radius": { - "units": "m", - "value": 106.68199560000001 - } + "chord": 14.00646, + "radius": 106.68199560000001 }, { - "chord": { - "units": "m", - "value": 14.00629005 - }, - "radius": { - "units": "m", - "value": 109.01296215 - } + "chord": 14.00629005, + "radius": 109.01296215 }, { - "chord": { - "units": "m", - "value": 14.00611995 - }, - "radius": { - "units": "m", - "value": 111.81012735 - } + "chord": 14.00611995, + "radius": 111.81012735 }, { - "chord": { - "units": "m", - "value": 14.00595 - }, - "radius": { - "units": "m", - "value": 114.42079709999999 - } + "chord": 14.00595, + "radius": 114.42079709999999 }, { - "chord": { - "units": "m", - "value": 14.00578005 - }, - "radius": { - "units": "m", - "value": 119.5955193 - } + "chord": 14.00578005, + "radius": 119.5955193 }, { - "chord": { - "units": "m", - "value": 14.00560995 - }, - "radius": { - "units": "m", - "value": 126.3086691 - } + "chord": 14.00560995, + "radius": 126.3086691 }, { - "chord": { - "units": "m", - "value": 14.00544 - }, - "radius": { - "units": "m", - "value": 131.57657925 - } + "chord": 14.00544, + "radius": 131.57657925 }, { - "chord": { - "units": "m", - "value": 14.00527005 - }, - "radius": { - "units": "m", - "value": 137.077602 - } + "chord": 14.00527005, + "radius": 137.077602 }, { - "chord": { - "units": "m", - "value": 14.00509995 - }, - "radius": { - "units": "m", - "value": 140.43416025 - } + "chord": 14.00509995, + "radius": 140.43416025 }, { - "chord": { - "units": "m", - "value": 14.00493 - }, - "radius": { - "units": "m", - "value": 143.60421645 - } + "chord": 14.00493, + "radius": 143.60421645 }, { - "chord": { - "units": "m", - "value": 14.00476005 - }, - "radius": { - "units": "m", - "value": 146.6810649 - } + "chord": 14.00476005, + "radius": 146.6810649 }, { - "chord": { - "units": "m", - "value": 14.00458995 - }, - "radius": { - "units": "m", - "value": 148.4525784 - } + "chord": 14.00458995, + "radius": 148.4525784 }, { - "chord": { - "units": "m", - "value": 14.00451 - }, - "radius": { - "units": "m", - "value": 150.0 - } + "chord": 14.00451, + "radius": 150.0 } ], "entities": { @@ -785,10 +392,7 @@ "n_loading_nodes": 20, "name": "BET disk", "number_of_blades": 3, - "omega": { - "units": "degree/s", - "value": 0.0046 - }, + "omega": 8.028514559173915e-05, "reynolds_numbers": [ 1.0 ], @@ -1186,7 +790,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -1264,7 +868,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -1281,7 +885,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -1359,7 +963,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -1376,7 +980,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -1454,7 +1058,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -1471,7 +1075,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -1549,7 +1153,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -1952,7 +1556,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -2030,7 +1634,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -2047,7 +1651,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -2125,7 +1729,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -2142,7 +1746,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -2220,7 +1824,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -2237,7 +1841,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -2315,7 +1919,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -2718,7 +2322,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -2796,7 +2400,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -2813,7 +2417,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -2891,7 +2495,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -2908,7 +2512,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -2986,7 +2590,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -3003,7 +2607,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -3081,7 +2685,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -3484,7 +3088,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -3562,7 +3166,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -3579,7 +3183,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -3657,7 +3261,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -3674,7 +3278,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -3752,7 +3356,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -3769,7 +3373,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -3847,7 +3451,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -4250,7 +3854,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -4328,7 +3932,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -4345,7 +3949,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -4423,7 +4027,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -4440,7 +4044,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -4518,7 +4122,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -4535,7 +4139,7 @@ 0.8252273921394143, 0.7435900857357557, 0.6293481805726722, - 0.4917645958492829, + 0.4917645958492828, 0.33721764336183657, 0.17142399410016385, -6.047125210727891e-17, @@ -4613,7 +4217,7 @@ 6.047125210727891e-17, -0.17142399410016385, -0.33721764336183657, - -0.4917645958492829, + -0.4917645958492828, -0.6293481805726722, -0.7435900857357557, -0.8252273921394143, @@ -4625,657 +4229,270 @@ ] } ], - "sectional_radiuses": { - "units": "m", - "value": [ - 13.5, - 25.500000000000004, - 76.5, - 120.0, - 150.0 - ] - }, + "sectional_radiuses": [ + 13.5, + 25.500000000000004, + 76.5, + 120.0, + 150.0 + ], "tip_gap": "inf", "twists": [ { - "radius": { - "units": "m", - "value": 0.0 - }, - "twist": { - "units": "degree", - "value": 0.0 - } + "radius": 0.0, + "twist": 0.0 }, { - "radius": { - "units": "m", - "value": 3.4505914499999997 - }, - "twist": { - "units": "degree", - "value": 33.27048712 - } + "radius": 3.4505914499999997, + "twist": 0.5806795439863657 }, { - "radius": { - "units": "m", - "value": 5.867434050000001 - }, - "twist": { - "units": "degree", - "value": 32.37853609 - } + "radius": 5.867434050000001, + "twist": 0.565112061746311 }, { - "radius": { - "units": "m", - "value": 9.05761395 - }, - "twist": { - "units": "degree", - "value": 31.42712165 - } + "radius": 9.05761395, + "twist": 0.548506747217293 }, { - "radius": { - "units": "m", - "value": 12.5377488 - }, - "twist": { - "units": "degree", - "value": 30.65409742 - } + "radius": 12.5377488, + "twist": 0.535014929206099 }, { - "radius": { - "units": "m", - "value": 13.9536156 - }, - "twist": { - "units": "degree", - "value": 30.13214089 - } + "radius": 13.9536156, + "twist": 0.5259050692053145 }, { - "radius": { - "units": "m", - "value": 16.238024850000002 - }, - "twist": { - "units": "degree", - "value": 29.41523066 - } + "radius": 16.238024850000002, + "twist": 0.5133926252505847 }, { - "radius": { - "units": "m", - "value": 18.33595185 - }, - "twist": { - "units": "degree", - "value": 28.75567325 - } + "radius": 18.33595185, + "twist": 0.5018811768401585 }, { - "radius": { - "units": "m", - "value": 21.086566350000002 - }, - "twist": { - "units": "degree", - "value": 27.89538097 - } + "radius": 21.086566350000002, + "twist": 0.4868662440246696 }, { - "radius": { - "units": "m", - "value": 23.9304951 - }, - "twist": { - "units": "degree", - "value": 26.69097178 - } + "radius": 23.9304951, + "twist": 0.46584533811789164 }, { - "radius": { - "units": "m", - "value": 25.8886038 - }, - "twist": { - "units": "degree", - "value": 25.88803232 - } + "radius": 25.8886038, + "twist": 0.4518314008467063 }, { - "radius": { - "units": "m", - "value": 27.380500650000002 - }, - "twist": { - "units": "degree", - "value": 25.25715132 - } + "radius": 27.380500650000002, + "twist": 0.4408204502084319 }, { - "radius": { - "units": "m", - "value": 28.87241085 - }, - "twist": { - "units": "degree", - "value": 24.5689175 - } + "radius": 28.87241085, + "twist": 0.42880850402585396 }, { - "radius": { - "units": "m", - "value": 30.457542 - }, - "twist": { - "units": "degree", - "value": 23.93803649 - } + "radius": 30.457542, + "twist": 0.4177975532130466 }, { - "radius": { - "units": "m", - "value": 32.2291686 - }, - "twist": { - "units": "degree", - "value": 23.19244985 - } + "radius": 32.2291686, + "twist": 0.404784611486165 }, { - "radius": { - "units": "m", - "value": 34.0940496 - }, - "twist": { - "units": "degree", - "value": 22.36083398 - } + "radius": 34.0940496, + "twist": 0.39027017644282785 }, { - "radius": { - "units": "m", - "value": 35.91228015 - }, - "twist": { - "units": "degree", - "value": 21.67260016 - } + "radius": 35.91228015, + "twist": 0.37825823026024985 }, { - "radius": { - "units": "m", - "value": 37.730543999999995 - }, - "twist": { - "units": "degree", - "value": 20.84098429 - } + "radius": 37.730543999999995, + "twist": 0.36374379521691275 }, { - "radius": { - "units": "m", - "value": 39.6886794 - }, - "twist": { - "units": "degree", - "value": 19.92333919 - } + "radius": 39.6886794, + "twist": 0.3477278668571201 }, { - "radius": { - "units": "m", - "value": 41.413722 - }, - "twist": { - "units": "degree", - "value": 19.03437051 - } + "radius": 41.413722, + "twist": 0.3322124364440122 }, { - "radius": { - "units": "m", - "value": 42.95224935 - }, - "twist": { - "units": "degree", - "value": 18.34613668 - } + "radius": 42.95224935, + "twist": 0.3202004900869013 }, { - "radius": { - "units": "m", - "value": 44.6772921 - }, - "twist": { - "units": "degree", - "value": 17.457168 - } + "radius": 44.6772921, + "twist": 0.30468505967379345 }, { - "radius": { - "units": "m", - "value": 46.448872050000006 - }, - "twist": { - "units": "degree", - "value": 16.91231622 - } + "radius": 46.448872050000006, + "twist": 0.2951756021774417 }, { - "radius": { - "units": "m", - "value": 48.40696755 - }, - "twist": { - "units": "degree", - "value": 16.16672958 - } + "radius": 48.40696755, + "twist": 0.28216266045056 }, { - "radius": { - "units": "m", - "value": 50.22518475 - }, - "twist": { - "units": "degree", - "value": 15.53584858 - } + "radius": 50.22518475, + "twist": 0.27115170981228565 }, { - "radius": { - "units": "m", - "value": 51.763692150000004 - }, - "twist": { - "units": "degree", - "value": 14.93364398 - } + "radius": 51.763692150000004, + "twist": 0.2606412567716302 }, { - "radius": { - "units": "m", - "value": 53.4887016 - }, - "twist": { - "units": "degree", - "value": 14.18805734 - } + "radius": 53.4887016, + "twist": 0.24762831504474858 }, { - "radius": { - "units": "m", - "value": 55.07383275 - }, - "twist": { - "units": "degree", - "value": 13.55717634 - } + "radius": 55.07383275, + "twist": 0.2366173644064742 }, { - "radius": { - "units": "m", - "value": 56.28603975 - }, - "twist": { - "units": "degree", - "value": 12.86894251 - } + "radius": 56.28603975, + "twist": 0.2246054180493633 }, { - "radius": { - "units": "m", - "value": 57.824567099999996 - }, - "twist": { - "units": "degree", - "value": 12.18070869 - } + "radius": 57.824567099999996, + "twist": 0.2125934718667853 }, { - "radius": { - "units": "m", - "value": 59.456328750000004 - }, - "twist": { - "units": "degree", - "value": 11.49247487 - } + "radius": 59.456328750000004, + "twist": 0.2005815256842073 }, { - "radius": { - "units": "m", - "value": 60.994816199999995 - }, - "twist": { - "units": "degree", - "value": 10.9762995 - } + "radius": 60.994816199999995, + "twist": 0.19157256596000732 }, { - "radius": { - "units": "m", - "value": 62.859590700000005 - }, - "twist": { - "units": "degree", - "value": 10.60350618 - } + "radius": 62.859590700000005, + "twist": 0.18506609509656652 }, { - "radius": { - "units": "m", - "value": 65.4703071 - }, - "twist": { - "units": "degree", - "value": 9.94394877 - } + "radius": 65.4703071, + "twist": 0.17355464668614035 }, { - "radius": { - "units": "m", - "value": 67.6148514 - }, - "twist": { - "units": "degree", - "value": 9.28439136 - } + "radius": 67.6148514, + "twist": 0.16204319827571417 }, { - "radius": { - "units": "m", - "value": 69.80601945 - }, - "twist": { - "units": "degree", - "value": 8.59615753 - } + "radius": 69.80601945, + "twist": 0.15003125191860323 }, { - "radius": { - "units": "m", - "value": 71.53100235 - }, - "twist": { - "units": "degree", - "value": 7.96527653 - } + "radius": 71.53100235, + "twist": 0.13902030128032888 }, { - "radius": { - "units": "m", - "value": 73.48907115 - }, - "twist": { - "units": "degree", - "value": 7.33439553 - } + "radius": 73.48907115, + "twist": 0.12800935064205454 }, { - "radius": { - "units": "m", - "value": 74.56137315 - }, - "twist": { - "units": "degree", - "value": 6.87557298 - } + "radius": 74.56137315, + "twist": 0.12000138646215824 }, { - "radius": { - "units": "m", - "value": 76.7524548 - }, - "twist": { - "units": "degree", - "value": 6.56013248 - } + "radius": 76.7524548, + "twist": 0.11449591114302106 }, { - "radius": { - "units": "m", - "value": 79.36313129999999 - }, - "twist": { - "units": "degree", - "value": 6.07263352 - } + "radius": 79.36313129999999, + "twist": 0.10598744919097293 }, { - "radius": { - "units": "m", - "value": 81.6941244 - }, - "twist": { - "units": "degree", - "value": 5.49910533 - } + "radius": 81.6941244, + "twist": 0.0959774939224693 }, { - "radius": { - "units": "m", - "value": 83.65213994999999 - }, - "twist": { - "units": "degree", - "value": 5.0976356 - } + "radius": 83.65213994999999, + "twist": 0.08897052528687666 }, { - "radius": { - "units": "m", - "value": 85.42368675 - }, - "twist": { - "units": "degree", - "value": 4.69616587 - } + "radius": 85.42368675, + "twist": 0.08196355665128399 }, { - "radius": { - "units": "m", - "value": 87.84791430000001 - }, - "twist": { - "units": "degree", - "value": 4.12263769 - } + "radius": 87.84791430000001, + "twist": 0.07195360155731331 }, { - "radius": { - "units": "m", - "value": 89.52621345 - }, - "twist": { - "units": "degree", - "value": 3.77852078 - } + "radius": 89.52621345, + "twist": 0.06594762846602431 }, { - "radius": { - "units": "m", - "value": 91.39097475 - }, - "twist": { - "units": "degree", - "value": 3.46308028 - } + "radius": 91.39097475, + "twist": 0.06044215314688713 }, { - "radius": { - "units": "m", - "value": 94.32797144999999 - }, - "twist": { - "units": "degree", - "value": 2.97558132 - } + "radius": 94.32797144999999, + "twist": 0.051933691194838996 }, { - "radius": { - "units": "m", - "value": 99.73579305 - }, - "twist": { - "units": "degree", - "value": 2.0005834 - } + "radius": 99.73579305, + "twist": 0.03491676729074272 }, { - "radius": { - "units": "m", - "value": 102.95246655 - }, - "twist": { - "units": "degree", - "value": 1.62779008 - } + "radius": 102.95246655, + "twist": 0.0284102964273019 }, { - "radius": { - "units": "m", - "value": 104.91047549999999 - }, - "twist": { - "units": "degree", - "value": 1.25499676 - } + "radius": 104.91047549999999, + "twist": 0.02190382556386107 }, { - "radius": { - "units": "m", - "value": 106.68199560000001 - }, - "twist": { - "units": "degree", - "value": 0.96823267 - } + "radius": 106.68199560000001, + "twist": 0.016898848016875724 }, { - "radius": { - "units": "m", - "value": 109.01296215 - }, - "twist": { - "units": "degree", - "value": 0.50941012 - } + "radius": 109.01296215, + "twist": 0.008890883836979417 }, { - "radius": { - "units": "m", - "value": 111.81012735 - }, - "twist": { - "units": "degree", - "value": -0.064118065 - } + "radius": 111.81012735, + "twist": -0.0011190713442577381 }, { - "radius": { - "units": "m", - "value": 114.42079709999999 - }, - "twist": { - "units": "degree", - "value": -0.522940614 - } + "radius": 114.42079709999999, + "twist": -0.009127035506700755 }, { - "radius": { - "units": "m", - "value": 119.5955193 - }, - "twist": { - "units": "degree", - "value": -1.44058571 - } + "radius": 119.5955193, + "twist": -0.0251429637966802 }, { - "radius": { - "units": "m", - "value": 126.3086691 - }, - "twist": { - "units": "degree", - "value": -2.61631849 - } + "radius": 126.3086691, + "twist": -0.04566337193130633 }, { - "radius": { - "units": "m", - "value": 131.57657925 - }, - "twist": { - "units": "degree", - "value": -3.333228722 - } + "radius": 131.57657925, + "twist": -0.05817581592094275 }, { - "radius": { - "units": "m", - "value": 137.077602 - }, - "twist": { - "units": "degree", - "value": -4.164844591 - } + "radius": 137.077602, + "twist": -0.07269025094682659 }, { - "radius": { - "units": "m", - "value": 140.43416025 - }, - "twist": { - "units": "degree", - "value": -4.681019957 - } + "radius": 140.43416025, + "twist": -0.08169921060121339 }, { - "radius": { - "units": "m", - "value": 143.60421645 - }, - "twist": { - "units": "degree", - "value": -5.053813278 - } + "radius": 143.60421645, + "twist": -0.0882056814821075 }, { - "radius": { - "units": "m", - "value": 146.6810649 - }, - "twist": { - "units": "degree", - "value": -5.541312235 - } + "radius": 146.6810649, + "twist": -0.09671414338179578 }, { - "radius": { - "units": "m", - "value": 148.4525784 - }, - "twist": { - "units": "degree", - "value": -5.799399919 - } + "radius": 148.4525784, + "twist": -0.10121862322644246 }, { - "radius": { - "units": "m", - "value": 150.0 - }, - "twist": { - "units": "degree", - "value": -6.1 - } + "radius": 150.0, + "twist": -0.1064650843716541 } ], "type": "BETDisk" diff --git a/tests/simulation/converter/ref/ref_xrotor.json b/tests/simulation/converter/ref/ref_xrotor.json index 949fe5091..d916286ba 100644 --- a/tests/simulation/converter/ref/ref_xrotor.json +++ b/tests/simulation/converter/ref/ref_xrotor.json @@ -1,1044 +1,651 @@ { - "alphas": { - "units": "degree", - "value": [ - -180.0, - -179.0, - -178.0, - -177.0, - -176.0, - -175.0, - -174.0, - -173.0, - -172.0, - -171.0, - -170.0, - -169.0, - -168.0, - -167.0, - -166.0, - -165.0, - -164.0, - -163.0, - -162.0, - -161.0, - -160.0, - -159.0, - -158.0, - -157.0, - -156.0, - -155.0, - -154.0, - -153.0, - -152.0, - -151.0, - -150.0, - -149.0, - -148.0, - -147.0, - -146.0, - -145.0, - -144.0, - -143.0, - -142.0, - -141.0, - -140.0, - -139.0, - -138.0, - -137.0, - -136.0, - -135.0, - -134.0, - -133.0, - -132.0, - -131.0, - -130.0, - -129.0, - -128.0, - -127.0, - -126.0, - -125.0, - -124.0, - -123.0, - -122.0, - -121.0, - -120.0, - -119.0, - -118.0, - -117.0, - -116.0, - -115.0, - -114.0, - -113.0, - -112.0, - -111.0, - -110.0, - -109.0, - -108.0, - -107.0, - -106.0, - -105.0, - -104.0, - -103.0, - -102.0, - -101.0, - -100.0, - -99.0, - -98.0, - -97.0, - -96.0, - -95.0, - -94.0, - -93.0, - -92.0, - -91.0, - -90.0, - -89.0, - -88.0, - -87.0, - -86.0, - -85.0, - -84.0, - -83.0, - -82.0, - -81.0, - -80.0, - -79.0, - -78.0, - -77.0, - -76.0, - -75.0, - -74.0, - -73.0, - -72.0, - -71.0, - -70.0, - -69.0, - -68.0, - -67.0, - -66.0, - -65.0, - -64.0, - -63.0, - -62.0, - -61.0, - -60.0, - -59.0, - -58.0, - -57.0, - -56.0, - -55.0, - -54.0, - -53.0, - -52.0, - -51.0, - -50.0, - -49.0, - -48.0, - -47.0, - -46.0, - -45.0, - -44.0, - -43.0, - -42.0, - -41.0, - -40.0, - -39.0, - -38.0, - -37.0, - -36.0, - -35.0, - -34.0, - -33.0, - -32.0, - -31.0, - -30.0, - -29.0, - -28.0, - -27.0, - -26.0, - -25.0, - -24.0, - -23.0, - -22.0, - -21.0, - -20.0, - -19.0, - -18.0, - -17.0, - -16.0, - -15.0, - -14.0, - -13.0, - -12.0, - -11.0, - -10.0, - -9.0, - -8.5, - -8.0, - -7.5, - -7.0, - -6.5, - -6.0, - -5.5, - -5.0, - -4.5, - -4.0, - -3.5, - -3.0, - -2.5, - -2.0, - -1.5, - -1.0, - -0.75, - -0.5, - -0.25, - 0.0, - 0.25, - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - 1.75, - 2.0, - 2.25, - 2.5, - 2.75, - 3.0, - 3.5, - 4.0, - 4.5, - 5.0, - 5.5, - 6.0, - 6.5, - 7.0, - 7.5, - 8.0, - 8.5, - 9.0, - 10.0, - 11.0, - 12.0, - 13.0, - 14.0, - 15.0, - 16.0, - 17.0, - 18.0, - 19.0, - 20.0, - 21.0, - 22.0, - 23.0, - 24.0, - 25.0, - 26.0, - 27.0, - 28.0, - 29.0, - 30.0, - 31.0, - 32.0, - 33.0, - 34.0, - 35.0, - 36.0, - 37.0, - 38.0, - 39.0, - 40.0, - 41.0, - 42.0, - 43.0, - 44.0, - 45.0, - 46.0, - 47.0, - 48.0, - 49.0, - 50.0, - 51.0, - 52.0, - 53.0, - 54.0, - 55.0, - 56.0, - 57.0, - 58.0, - 59.0, - 60.0, - 61.0, - 62.0, - 63.0, - 64.0, - 65.0, - 66.0, - 67.0, - 68.0, - 69.0, - 70.0, - 71.0, - 72.0, - 73.0, - 74.0, - 75.0, - 76.0, - 77.0, - 78.0, - 79.0, - 80.0, - 81.0, - 82.0, - 83.0, - 84.0, - 85.0, - 86.0, - 87.0, - 88.0, - 89.0, - 90.0, - 91.0, - 92.0, - 93.0, - 94.0, - 95.0, - 96.0, - 97.0, - 98.0, - 99.0, - 100.0, - 101.0, - 102.0, - 103.0, - 104.0, - 105.0, - 106.0, - 107.0, - 108.0, - 109.0, - 110.0, - 111.0, - 112.0, - 113.0, - 114.0, - 115.0, - 116.0, - 117.0, - 118.0, - 119.0, - 120.0, - 121.0, - 122.0, - 123.0, - 124.0, - 125.0, - 126.0, - 127.0, - 128.0, - 129.0, - 130.0, - 131.0, - 132.0, - 133.0, - 134.0, - 135.0, - 136.0, - 137.0, - 138.0, - 139.0, - 140.0, - 141.0, - 142.0, - 143.0, - 144.0, - 145.0, - 146.0, - 147.0, - 148.0, - 149.0, - 150.0, - 151.0, - 152.0, - 153.0, - 154.0, - 155.0, - 156.0, - 157.0, - 158.0, - 159.0, - 160.0, - 161.0, - 162.0, - 163.0, - 164.0, - 165.0, - 166.0, - 167.0, - 168.0, - 169.0, - 170.0, - 171.0, - 172.0, - 173.0, - 174.0, - 175.0, - 176.0, - 177.0, - 178.0, - 179.0, - 180.0 - ] - }, - "blade_line_chord": { - "units": "m", - "value": 0.0 - }, - "chord_ref": { - "units": "m", - "value": 14.0 - }, + "alphas": [ + -3.141592653589793, + -3.12413936106985, + -3.1066860685499065, + -3.0892327760299634, + -3.07177948351002, + -3.0543261909900767, + -3.036872898470133, + -3.01941960595019, + -3.001966313430247, + -2.9845130209103035, + -2.9670597283903604, + -2.949606435870417, + -2.9321531433504737, + -2.91469985083053, + -2.897246558310587, + -2.8797932657906435, + -2.8623399732707004, + -2.8448866807507573, + -2.827433388230814, + -2.8099800957108707, + -2.792526803190927, + -2.775073510670984, + -2.7576202181510405, + -2.7401669256310974, + -2.722713633111154, + -2.705260340591211, + -2.6878070480712677, + -2.670353755551324, + -2.652900463031381, + -2.6354471705114375, + -2.6179938779914944, + -2.600540585471551, + -2.5830872929516078, + -2.5656340004316642, + -2.548180707911721, + -2.530727415391778, + -2.5132741228718345, + -2.4958208303518914, + -2.478367537831948, + -2.4609142453120048, + -2.443460952792061, + -2.426007660272118, + -2.4085543677521746, + -2.3911010752322315, + -2.3736477827122884, + -2.356194490192345, + -2.3387411976724017, + -2.321287905152458, + -2.303834612632515, + -2.2863813201125716, + -2.2689280275926285, + -2.251474735072685, + -2.234021442552742, + -2.2165681500327987, + -2.199114857512855, + -2.181661564992912, + -2.1642082724729685, + -2.1467549799530254, + -2.129301687433082, + -2.111848394913139, + -2.0943951023931953, + -2.076941809873252, + -2.059488517353309, + -2.0420352248333655, + -2.0245819323134224, + -2.007128639793479, + -1.9896753472735358, + -1.9722220547535925, + -1.9547687622336491, + -1.9373154697137058, + -1.9198621771937625, + -1.9024088846738192, + -1.8849555921538759, + -1.8675022996339325, + -1.8500490071139892, + -1.8325957145940461, + -1.8151424220741028, + -1.7976891295541595, + -1.7802358370342162, + -1.7627825445142729, + -1.7453292519943295, + -1.7278759594743862, + -1.710422666954443, + -1.6929693744344996, + -1.6755160819145565, + -1.6580627893946132, + -1.6406094968746698, + -1.6231562043547265, + -1.6057029118347832, + -1.5882496193148399, + -1.5707963267948966, + -1.5533430342749532, + -1.53588974175501, + -1.5184364492350666, + -1.5009831567151235, + -1.4835298641951802, + -1.4660765716752369, + -1.4486232791552935, + -1.4311699866353502, + -1.413716694115407, + -1.3962634015954636, + -1.3788101090755203, + -1.361356816555577, + -1.3439035240356338, + -1.3264502315156905, + -1.3089969389957472, + -1.2915436464758039, + -1.2740903539558606, + -1.2566370614359172, + -1.239183768915974, + -1.2217304763960306, + -1.2042771838760873, + -1.1868238913561442, + -1.1693705988362009, + -1.1519173063162575, + -1.1344640137963142, + -1.117010721276371, + -1.0995574287564276, + -1.0821041362364843, + -1.064650843716541, + -1.0471975511965976, + -1.0297442586766545, + -1.0122909661567112, + -0.9948376736367679, + -0.9773843811168246, + -0.9599310885968813, + -0.9424777960769379, + -0.9250245035569946, + -0.9075712110370514, + -0.8901179185171081, + -0.8726646259971648, + -0.8552113334772214, + -0.8377580409572782, + -0.8203047484373349, + -0.8028514559173916, + -0.7853981633974483, + -0.767944870877505, + -0.7504915783575618, + -0.7330382858376184, + -0.7155849933176751, + -0.6981317007977318, + -0.6806784082777885, + -0.6632251157578453, + -0.6457718232379019, + -0.6283185307179586, + -0.6108652381980153, + -0.5934119456780721, + -0.5759586531581288, + -0.5585053606381855, + -0.5410520681182421, + -0.5235987755982988, + -0.5061454830783556, + -0.4886921905584123, + -0.47123889803846897, + -0.4537856055185257, + -0.4363323129985824, + -0.4188790204786391, + -0.4014257279586958, + -0.3839724354387525, + -0.3665191429188092, + -0.3490658503988659, + -0.33161255787892263, + -0.3141592653589793, + -0.29670597283903605, + -0.2792526803190927, + -0.2617993877991494, + -0.24434609527920614, + -0.22689280275926285, + -0.20943951023931956, + -0.19198621771937624, + -0.17453292519943295, + -0.15707963267948966, + -0.14835298641951802, + -0.13962634015954636, + -0.1308996938995747, + -0.12217304763960307, + -0.11344640137963143, + -0.10471975511965978, + -0.09599310885968812, + -0.08726646259971647, + -0.07853981633974483, + -0.06981317007977318, + -0.061086523819801536, + -0.05235987755982989, + -0.04363323129985824, + -0.03490658503988659, + -0.026179938779914945, + -0.017453292519943295, + -0.013089969389957472, + -0.008726646259971648, + -0.004363323129985824, + 0.0, + 0.004363323129985824, + 0.008726646259971648, + 0.013089969389957472, + 0.017453292519943295, + 0.02181661564992912, + 0.026179938779914945, + 0.030543261909900768, + 0.03490658503988659, + 0.039269908169872414, + 0.04363323129985824, + 0.04799655442984406, + 0.05235987755982989, + 0.061086523819801536, + 0.06981317007977318, + 0.07853981633974483, + 0.08726646259971647, + 0.09599310885968812, + 0.10471975511965978, + 0.11344640137963143, + 0.12217304763960307, + 0.1308996938995747, + 0.13962634015954636, + 0.14835298641951802, + 0.15707963267948966, + 0.17453292519943295, + 0.19198621771937624, + 0.20943951023931956, + 0.22689280275926285, + 0.24434609527920614, + 0.2617993877991494, + 0.2792526803190927, + 0.29670597283903605, + 0.3141592653589793, + 0.33161255787892263, + 0.3490658503988659, + 0.3665191429188092, + 0.3839724354387525, + 0.4014257279586958, + 0.4188790204786391, + 0.4363323129985824, + 0.4537856055185257, + 0.47123889803846897, + 0.4886921905584123, + 0.5061454830783556, + 0.5235987755982988, + 0.5410520681182421, + 0.5585053606381855, + 0.5759586531581288, + 0.5934119456780721, + 0.6108652381980153, + 0.6283185307179586, + 0.6457718232379019, + 0.6632251157578453, + 0.6806784082777885, + 0.6981317007977318, + 0.7155849933176751, + 0.7330382858376184, + 0.7504915783575618, + 0.767944870877505, + 0.7853981633974483, + 0.8028514559173916, + 0.8203047484373349, + 0.8377580409572782, + 0.8552113334772214, + 0.8726646259971648, + 0.8901179185171081, + 0.9075712110370514, + 0.9250245035569946, + 0.9424777960769379, + 0.9599310885968813, + 0.9773843811168246, + 0.9948376736367679, + 1.0122909661567112, + 1.0297442586766545, + 1.0471975511965976, + 1.064650843716541, + 1.0821041362364843, + 1.0995574287564276, + 1.117010721276371, + 1.1344640137963142, + 1.1519173063162575, + 1.1693705988362009, + 1.1868238913561442, + 1.2042771838760873, + 1.2217304763960306, + 1.239183768915974, + 1.2566370614359172, + 1.2740903539558606, + 1.2915436464758039, + 1.3089969389957472, + 1.3264502315156905, + 1.3439035240356338, + 1.361356816555577, + 1.3788101090755203, + 1.3962634015954636, + 1.413716694115407, + 1.4311699866353502, + 1.4486232791552935, + 1.4660765716752369, + 1.4835298641951802, + 1.5009831567151235, + 1.5184364492350666, + 1.53588974175501, + 1.5533430342749532, + 1.5707963267948966, + 1.5882496193148399, + 1.6057029118347832, + 1.6231562043547265, + 1.6406094968746698, + 1.6580627893946132, + 1.6755160819145565, + 1.6929693744344996, + 1.710422666954443, + 1.7278759594743862, + 1.7453292519943295, + 1.7627825445142729, + 1.7802358370342162, + 1.7976891295541595, + 1.8151424220741028, + 1.8325957145940461, + 1.8500490071139892, + 1.8675022996339325, + 1.8849555921538759, + 1.9024088846738192, + 1.9198621771937625, + 1.9373154697137058, + 1.9547687622336491, + 1.9722220547535925, + 1.9896753472735358, + 2.007128639793479, + 2.0245819323134224, + 2.0420352248333655, + 2.059488517353309, + 2.076941809873252, + 2.0943951023931953, + 2.111848394913139, + 2.129301687433082, + 2.1467549799530254, + 2.1642082724729685, + 2.181661564992912, + 2.199114857512855, + 2.2165681500327987, + 2.234021442552742, + 2.251474735072685, + 2.2689280275926285, + 2.2863813201125716, + 2.303834612632515, + 2.321287905152458, + 2.3387411976724017, + 2.356194490192345, + 2.3736477827122884, + 2.3911010752322315, + 2.4085543677521746, + 2.426007660272118, + 2.443460952792061, + 2.4609142453120048, + 2.478367537831948, + 2.4958208303518914, + 2.5132741228718345, + 2.530727415391778, + 2.548180707911721, + 2.5656340004316642, + 2.5830872929516078, + 2.600540585471551, + 2.6179938779914944, + 2.6354471705114375, + 2.652900463031381, + 2.670353755551324, + 2.6878070480712677, + 2.705260340591211, + 2.722713633111154, + 2.7401669256310974, + 2.7576202181510405, + 2.775073510670984, + 2.792526803190927, + 2.8099800957108707, + 2.827433388230814, + 2.8448866807507573, + 2.8623399732707004, + 2.8797932657906435, + 2.897246558310587, + 2.91469985083053, + 2.9321531433504737, + 2.949606435870417, + 2.9670597283903604, + 2.9845130209103035, + 3.001966313430247, + 3.01941960595019, + 3.036872898470133, + 3.0543261909900767, + 3.07177948351002, + 3.0892327760299634, + 3.1066860685499065, + 3.12413936106985, + 3.141592653589793 + ], + "blade_line_chord": 0.0, + "chord_ref": 14.0, "chords": [ { - "chord": { - "units": "m", - "value": 0.0 - }, - "radius": { - "units": "m", - "value": 0.0 - } + "chord": 0.0, + "radius": 0.0 }, { - "chord": { - "units": "m", - "value": 0.43216221543 - }, - "radius": { - "units": "m", - "value": 0.08764502283 - } + "chord": 0.43216221543, + "radius": 0.08764502283 }, { - "chord": { - "units": "m", - "value": 0.43216221543 - }, - "radius": { - "units": "m", - "value": 0.14903282487000002 - } + "chord": 0.43216221543, + "radius": 0.14903282487000002 }, { - "chord": { - "units": "m", - "value": 0.43216221543 - }, - "radius": { - "units": "m", - "value": 0.23006339433 - } + "chord": 0.43216221543, + "radius": 0.23006339433 }, { - "chord": { - "units": "m", - "value": 0.43216221543 - }, - "radius": { - "units": "m", - "value": 0.31845881952 - } + "chord": 0.43216221543, + "radius": 0.31845881952 }, { - "chord": { - "units": "m", - "value": 0.43216221543 - }, - "radius": { - "units": "m", - "value": 0.35442183624 - } + "chord": 0.43216221543, + "radius": 0.35442183624 }, { - "chord": { - "units": "m", - "value": 0.43216221543 - }, - "radius": { - "units": "m", - "value": 0.41244583119 - } + "chord": 0.43216221543, + "radius": 0.41244583119 }, { - "chord": { - "units": "m", - "value": 0.42523027476 - }, - "radius": { - "units": "m", - "value": 0.46573317699 - } + "chord": 0.42523027476, + "radius": 0.46573317699 }, { - "chord": { - "units": "m", - "value": 0.41829833790000004 - }, - "radius": { - "units": "m", - "value": 0.53559878529 - } + "chord": 0.41829833790000004, + "radius": 0.53559878529 }, { - "chord": { - "units": "m", - "value": 0.41136639723 - }, - "radius": { - "units": "m", - "value": 0.6078345755400001 - } + "chord": 0.41136639723, + "radius": 0.6078345755400001 }, { - "chord": { - "units": "m", - "value": 0.40443445656 - }, - "radius": { - "units": "m", - "value": 0.65757053652 - } + "chord": 0.40443445656, + "radius": 0.65757053652 }, { - "chord": { - "units": "m", - "value": 0.39750251970000006 - }, - "radius": { - "units": "m", - "value": 0.6954647165100001 - } + "chord": 0.39750251970000006, + "radius": 0.6954647165100001 }, { - "chord": { - "units": "m", - "value": 0.39057057903 - }, - "radius": { - "units": "m", - "value": 0.73335923559 - } + "chord": 0.39057057903, + "radius": 0.73335923559 }, { - "chord": { - "units": "m", - "value": 0.38363863836 - }, - "radius": { - "units": "m", - "value": 0.7736215668 - } + "chord": 0.38363863836, + "radius": 0.7736215668 }, { - "chord": { - "units": "m", - "value": 0.3767067015 - }, - "radius": { - "units": "m", - "value": 0.81862088244 - } + "chord": 0.3767067015, + "radius": 0.81862088244 }, { - "chord": { - "units": "m", - "value": 0.36977476083 - }, - "radius": { - "units": "m", - "value": 0.8659888598400001 - } + "chord": 0.36977476083, + "radius": 0.8659888598400001 }, { - "chord": { - "units": "m", - "value": 0.36284282016 - }, - "radius": { - "units": "m", - "value": 0.91217191581 - } + "chord": 0.36284282016, + "radius": 0.91217191581 }, { - "chord": { - "units": "m", - "value": 0.35591089473000004 - }, - "radius": { - "units": "m", - "value": 0.9583558175999999 - } + "chord": 0.35591089473000004, + "radius": 0.9583558175999999 }, { - "chord": { - "units": "m", - "value": 0.35590657800000003 - }, - "radius": { - "units": "m", - "value": 1.0080924567599998 - } + "chord": 0.35590657800000003, + "radius": 1.0080924567599998 }, { - "chord": { - "units": "m", - "value": 0.35590226127 - }, - "radius": { - "units": "m", - "value": 1.0519085388 - } + "chord": 0.35590226127, + "radius": 1.0519085388 }, { - "chord": { - "units": "m", - "value": 0.35589794073000003 - }, - "radius": { - "units": "m", - "value": 1.09098713349 - } + "chord": 0.35589794073000003, + "radius": 1.09098713349 }, { - "chord": { - "units": "m", - "value": 0.355893624 - }, - "radius": { - "units": "m", - "value": 1.1348032193400002 - } + "chord": 0.355893624, + "radius": 1.1348032193400002 }, { - "chord": { - "units": "m", - "value": 0.35588930727 - }, - "radius": { - "units": "m", - "value": 1.1798013500700002 - } + "chord": 0.35588930727, + "radius": 1.1798013500700002 }, { - "chord": { - "units": "m", - "value": 0.35588498673 - }, - "radius": { - "units": "m", - "value": 1.22953697577 - } + "chord": 0.35588498673, + "radius": 1.22953697577 }, { - "chord": { - "units": "m", - "value": 0.35588067 - }, - "radius": { - "units": "m", - "value": 1.2757196926499998 - } + "chord": 0.35588067, + "radius": 1.2757196926499998 }, { - "chord": { - "units": "m", - "value": 0.35587635327 - }, - "radius": { - "units": "m", - "value": 1.3147977806100002 - } + "chord": 0.35587635327, + "radius": 1.3147977806100002 }, { - "chord": { - "units": "m", - "value": 0.35587203273 - }, - "radius": { - "units": "m", - "value": 1.35861302064 - } + "chord": 0.35587203273, + "radius": 1.35861302064 }, { - "chord": { - "units": "m", - "value": 0.355867716 - }, - "radius": { - "units": "m", - "value": 1.39887535185 - } + "chord": 0.355867716, + "radius": 1.39887535185 }, { - "chord": { - "units": "m", - "value": 0.35586339927 - }, - "radius": { - "units": "m", - "value": 1.42966540965 - } + "chord": 0.35586339927, + "radius": 1.42966540965 }, { - "chord": { - "units": "m", - "value": 0.35585907873 - }, - "radius": { - "units": "m", - "value": 1.46874400434 - } + "chord": 0.35585907873, + "radius": 1.46874400434 }, { - "chord": { - "units": "m", - "value": 0.355854762 - }, - "radius": { - "units": "m", - "value": 1.51019075025 - } + "chord": 0.355854762, + "radius": 1.51019075025 }, { - "chord": { - "units": "m", - "value": 0.35585044527000004 - }, - "radius": { - "units": "m", - "value": 1.54926833148 - } + "chord": 0.35585044527000004, + "radius": 1.54926833148 }, { - "chord": { - "units": "m", - "value": 0.35584612473 - }, - "radius": { - "units": "m", - "value": 1.5966336037800002 - } + "chord": 0.35584612473, + "radius": 1.5966336037800002 }, { - "chord": { - "units": "m", - "value": 0.35584180800000004 - }, - "radius": { - "units": "m", - "value": 1.66294580034 - } + "chord": 0.35584180800000004, + "radius": 1.66294580034 }, { - "chord": { - "units": "m", - "value": 0.35583749127000003 - }, - "radius": { - "units": "m", - "value": 1.71741722556 - } + "chord": 0.35583749127000003, + "radius": 1.71741722556 }, { - "chord": { - "units": "m", - "value": 0.35583317073000004 - }, - "radius": { - "units": "m", - "value": 1.77307289403 - } + "chord": 0.35583317073000004, + "radius": 1.77307289403 }, { - "chord": { - "units": "m", - "value": 0.355828854 - }, - "radius": { - "units": "m", - "value": 1.81688745969 - } + "chord": 0.355828854, + "radius": 1.81688745969 }, { - "chord": { - "units": "m", - "value": 0.35582453727 - }, - "radius": { - "units": "m", - "value": 1.8666224072100002 - } + "chord": 0.35582453727, + "radius": 1.8666224072100002 }, { - "chord": { - "units": "m", - "value": 0.35582021673 - }, - "radius": { - "units": "m", - "value": 1.8938588780099999 - } + "chord": 0.35582021673, + "radius": 1.8938588780099999 }, { - "chord": { - "units": "m", - "value": 0.3558159 - }, - "radius": { - "units": "m", - "value": 1.94951235192 - } + "chord": 0.3558159, + "radius": 1.94951235192 }, { - "chord": { - "units": "m", - "value": 0.35581158327 - }, - "radius": { - "units": "m", - "value": 2.01582353502 - } + "chord": 0.35581158327, + "radius": 2.01582353502 }, { - "chord": { - "units": "m", - "value": 0.35580726273 - }, - "radius": { - "units": "m", - "value": 2.07503075976 - } + "chord": 0.35580726273, + "radius": 2.07503075976 }, { - "chord": { - "units": "m", - "value": 0.355802946 - }, - "radius": { - "units": "m", - "value": 2.12476435473 - } + "chord": 0.355802946, + "radius": 2.12476435473 }, { - "chord": { - "units": "m", - "value": 0.35579862927 - }, - "radius": { - "units": "m", - "value": 2.16976164345 - } + "chord": 0.35579862927, + "radius": 2.16976164345 }, { - "chord": { - "units": "m", - "value": 0.35579430873 - }, - "radius": { - "units": "m", - "value": 2.23133702322 - } + "chord": 0.35579430873, + "radius": 2.23133702322 }, { - "chord": { - "units": "m", - "value": 0.355789992 - }, - "radius": { - "units": "m", - "value": 2.27396582163 - } + "chord": 0.355789992, + "radius": 2.27396582163 }, { - "chord": { - "units": "m", - "value": 0.35578567527 - }, - "radius": { - "units": "m", - "value": 2.32133075865 - } + "chord": 0.35578567527, + "radius": 2.32133075865 }, { - "chord": { - "units": "m", - "value": 0.35578135473 - }, - "radius": { - "units": "m", - "value": 2.3959304748299997 - } + "chord": 0.35578135473, + "radius": 2.3959304748299997 }, { - "chord": { - "units": "m", - "value": 0.355777038 - }, - "radius": { - "units": "m", - "value": 2.53328914347 - } + "chord": 0.355777038, + "radius": 2.53328914347 }, { - "chord": { - "units": "m", - "value": 0.35577272127 - }, - "radius": { - "units": "m", - "value": 2.61499265037 - } + "chord": 0.35577272127, + "radius": 2.61499265037 }, { - "chord": { - "units": "m", - "value": 0.35576840073 - }, - "radius": { - "units": "m", - "value": 2.6647260776999997 - } + "chord": 0.35576840073, + "radius": 2.6647260776999997 }, { - "chord": { - "units": "m", - "value": 0.355764084 - }, - "radius": { - "units": "m", - "value": 2.7097226882400003 - } + "chord": 0.355764084, + "radius": 2.7097226882400003 }, { - "chord": { - "units": "m", - "value": 0.35575976726999997 - }, - "radius": { - "units": "m", - "value": 2.76892923861 - } + "chord": 0.35575976726999997, + "radius": 2.76892923861 }, { - "chord": { - "units": "m", - "value": 0.35575544673 - }, - "radius": { - "units": "m", - "value": 2.83997723469 - } + "chord": 0.35575544673, + "radius": 2.83997723469 }, { - "chord": { - "units": "m", - "value": 0.35575112999999997 - }, - "radius": { - "units": "m", - "value": 2.90628824634 - } + "chord": 0.35575112999999997, + "radius": 2.90628824634 }, { - "chord": { - "units": "m", - "value": 0.35574681327 - }, - "radius": { - "units": "m", - "value": 3.03772619022 - } + "chord": 0.35574681327, + "radius": 3.03772619022 }, { - "chord": { - "units": "m", - "value": 0.35574249272999997 - }, - "radius": { - "units": "m", - "value": 3.20824019514 - } + "chord": 0.35574249272999997, + "radius": 3.20824019514 }, { - "chord": { - "units": "m", - "value": 0.355738176 - }, - "radius": { - "units": "m", - "value": 3.34204511295 - } + "chord": 0.355738176, + "radius": 3.34204511295 }, { - "chord": { - "units": "m", - "value": 0.35573385927 - }, - "radius": { - "units": "m", - "value": 3.4817710908 - } + "chord": 0.35573385927, + "radius": 3.4817710908 }, { - "chord": { - "units": "m", - "value": 0.35572953873 - }, - "radius": { - "units": "m", - "value": 3.56702767035 - } + "chord": 0.35572953873, + "radius": 3.56702767035 }, { - "chord": { - "units": "m", - "value": 0.355725222 - }, - "radius": { - "units": "m", - "value": 3.64754709783 - } + "chord": 0.355725222, + "radius": 3.64754709783 }, { - "chord": { - "units": "m", - "value": 0.35572090527 - }, - "radius": { - "units": "m", - "value": 3.72569904846 - } + "chord": 0.35572090527, + "radius": 3.72569904846 }, { - "chord": { - "units": "m", - "value": 0.35571658473 - }, - "radius": { - "units": "m", - "value": 3.77069549136 - } + "chord": 0.35571658473, + "radius": 3.77069549136 }, { - "chord": { - "units": "m", - "value": 0.355714554 - }, - "radius": { - "units": "m", - "value": 3.81 - } + "chord": 0.355714554, + "radius": 3.81 } ], "entities": { @@ -1077,10 +684,7 @@ "n_loading_nodes": 20, "name": "BET disk", "number_of_blades": 3, - "omega": { - "units": "degree/s", - "value": 0.0046 - }, + "omega": 8.028514559173915e-05, "reynolds_numbers": [ 1.0 ], @@ -16757,657 +16361,270 @@ ] } ], - "sectional_radiuses": { - "units": "m", - "value": [ - 0.3429, - 0.6477, - 1.9431, - 3.048, - 3.81 - ] - }, + "sectional_radiuses": [ + 0.3429, + 0.6477, + 1.9431, + 3.048, + 3.81 + ], "tip_gap": "inf", "twists": [ { - "radius": { - "units": "m", - "value": 0.0 - }, - "twist": { - "units": "degree", - "value": 90.0 - } + "radius": 0.0, + "twist": 1.5707963267948966 }, { - "radius": { - "units": "m", - "value": 0.08764502283 - }, - "twist": { - "units": "degree", - "value": 33.27048712 - } + "radius": 0.08764502283, + "twist": 0.5806795439863657 }, { - "radius": { - "units": "m", - "value": 0.14903282487000002 - }, - "twist": { - "units": "degree", - "value": 32.37853609 - } + "radius": 0.14903282487000002, + "twist": 0.565112061746311 }, { - "radius": { - "units": "m", - "value": 0.23006339433 - }, - "twist": { - "units": "degree", - "value": 31.42712165 - } + "radius": 0.23006339433, + "twist": 0.548506747217293 }, { - "radius": { - "units": "m", - "value": 0.31845881952 - }, - "twist": { - "units": "degree", - "value": 30.65409742 - } + "radius": 0.31845881952, + "twist": 0.535014929206099 }, { - "radius": { - "units": "m", - "value": 0.35442183624 - }, - "twist": { - "units": "degree", - "value": 30.13214089 - } + "radius": 0.35442183624, + "twist": 0.5259050692053145 }, { - "radius": { - "units": "m", - "value": 0.41244583119 - }, - "twist": { - "units": "degree", - "value": 29.41523066 - } + "radius": 0.41244583119, + "twist": 0.5133926252505847 }, { - "radius": { - "units": "m", - "value": 0.46573317699 - }, - "twist": { - "units": "degree", - "value": 28.75567325 - } + "radius": 0.46573317699, + "twist": 0.5018811768401585 }, { - "radius": { - "units": "m", - "value": 0.53559878529 - }, - "twist": { - "units": "degree", - "value": 27.89538097 - } + "radius": 0.53559878529, + "twist": 0.4868662440246696 }, { - "radius": { - "units": "m", - "value": 0.6078345755400001 - }, - "twist": { - "units": "degree", - "value": 26.69097178 - } + "radius": 0.6078345755400001, + "twist": 0.46584533811789164 }, { - "radius": { - "units": "m", - "value": 0.65757053652 - }, - "twist": { - "units": "degree", - "value": 25.88803232 - } + "radius": 0.65757053652, + "twist": 0.4518314008467063 }, { - "radius": { - "units": "m", - "value": 0.6954647165100001 - }, - "twist": { - "units": "degree", - "value": 25.25715132 - } + "radius": 0.6954647165100001, + "twist": 0.4408204502084319 }, { - "radius": { - "units": "m", - "value": 0.73335923559 - }, - "twist": { - "units": "degree", - "value": 24.5689175 - } + "radius": 0.73335923559, + "twist": 0.42880850402585396 }, { - "radius": { - "units": "m", - "value": 0.7736215668 - }, - "twist": { - "units": "degree", - "value": 23.93803649 - } + "radius": 0.7736215668, + "twist": 0.4177975532130466 }, { - "radius": { - "units": "m", - "value": 0.81862088244 - }, - "twist": { - "units": "degree", - "value": 23.19244985 - } + "radius": 0.81862088244, + "twist": 0.404784611486165 }, { - "radius": { - "units": "m", - "value": 0.8659888598400001 - }, - "twist": { - "units": "degree", - "value": 22.36083398 - } + "radius": 0.8659888598400001, + "twist": 0.39027017644282785 }, { - "radius": { - "units": "m", - "value": 0.91217191581 - }, - "twist": { - "units": "degree", - "value": 21.67260016 - } + "radius": 0.91217191581, + "twist": 0.37825823026024985 }, { - "radius": { - "units": "m", - "value": 0.9583558175999999 - }, - "twist": { - "units": "degree", - "value": 20.84098429 - } + "radius": 0.9583558175999999, + "twist": 0.36374379521691275 }, { - "radius": { - "units": "m", - "value": 1.0080924567599998 - }, - "twist": { - "units": "degree", - "value": 19.92333919 - } + "radius": 1.0080924567599998, + "twist": 0.3477278668571201 }, { - "radius": { - "units": "m", - "value": 1.0519085388 - }, - "twist": { - "units": "degree", - "value": 19.03437051 - } + "radius": 1.0519085388, + "twist": 0.3322124364440122 }, { - "radius": { - "units": "m", - "value": 1.09098713349 - }, - "twist": { - "units": "degree", - "value": 18.34613668 - } + "radius": 1.09098713349, + "twist": 0.3202004900869013 }, { - "radius": { - "units": "m", - "value": 1.1348032193400002 - }, - "twist": { - "units": "degree", - "value": 17.457168 - } + "radius": 1.1348032193400002, + "twist": 0.30468505967379345 }, { - "radius": { - "units": "m", - "value": 1.1798013500700002 - }, - "twist": { - "units": "degree", - "value": 16.91231622 - } + "radius": 1.1798013500700002, + "twist": 0.2951756021774417 }, { - "radius": { - "units": "m", - "value": 1.22953697577 - }, - "twist": { - "units": "degree", - "value": 16.16672958 - } + "radius": 1.22953697577, + "twist": 0.28216266045056 }, { - "radius": { - "units": "m", - "value": 1.2757196926499998 - }, - "twist": { - "units": "degree", - "value": 15.53584858 - } + "radius": 1.2757196926499998, + "twist": 0.27115170981228565 }, { - "radius": { - "units": "m", - "value": 1.3147977806100002 - }, - "twist": { - "units": "degree", - "value": 14.93364398 - } + "radius": 1.3147977806100002, + "twist": 0.2606412567716302 }, { - "radius": { - "units": "m", - "value": 1.35861302064 - }, - "twist": { - "units": "degree", - "value": 14.18805734 - } + "radius": 1.35861302064, + "twist": 0.24762831504474858 }, { - "radius": { - "units": "m", - "value": 1.39887535185 - }, - "twist": { - "units": "degree", - "value": 13.55717634 - } + "radius": 1.39887535185, + "twist": 0.2366173644064742 }, { - "radius": { - "units": "m", - "value": 1.42966540965 - }, - "twist": { - "units": "degree", - "value": 12.86894251 - } + "radius": 1.42966540965, + "twist": 0.2246054180493633 }, { - "radius": { - "units": "m", - "value": 1.46874400434 - }, - "twist": { - "units": "degree", - "value": 12.18070869 - } + "radius": 1.46874400434, + "twist": 0.2125934718667853 }, { - "radius": { - "units": "m", - "value": 1.51019075025 - }, - "twist": { - "units": "degree", - "value": 11.49247487 - } + "radius": 1.51019075025, + "twist": 0.2005815256842073 }, { - "radius": { - "units": "m", - "value": 1.54926833148 - }, - "twist": { - "units": "degree", - "value": 10.9762995 - } + "radius": 1.54926833148, + "twist": 0.19157256596000732 }, { - "radius": { - "units": "m", - "value": 1.5966336037800002 - }, - "twist": { - "units": "degree", - "value": 10.60350618 - } + "radius": 1.5966336037800002, + "twist": 0.18506609509656652 }, { - "radius": { - "units": "m", - "value": 1.66294580034 - }, - "twist": { - "units": "degree", - "value": 9.94394877 - } + "radius": 1.66294580034, + "twist": 0.17355464668614035 }, { - "radius": { - "units": "m", - "value": 1.71741722556 - }, - "twist": { - "units": "degree", - "value": 9.28439136 - } + "radius": 1.71741722556, + "twist": 0.16204319827571417 }, { - "radius": { - "units": "m", - "value": 1.77307289403 - }, - "twist": { - "units": "degree", - "value": 8.59615753 - } + "radius": 1.77307289403, + "twist": 0.15003125191860323 }, { - "radius": { - "units": "m", - "value": 1.81688745969 - }, - "twist": { - "units": "degree", - "value": 7.96527653 - } + "radius": 1.81688745969, + "twist": 0.13902030128032888 }, { - "radius": { - "units": "m", - "value": 1.8666224072100002 - }, - "twist": { - "units": "degree", - "value": 7.33439553 - } + "radius": 1.8666224072100002, + "twist": 0.12800935064205454 }, { - "radius": { - "units": "m", - "value": 1.8938588780099999 - }, - "twist": { - "units": "degree", - "value": 6.87557298 - } + "radius": 1.8938588780099999, + "twist": 0.12000138646215824 }, { - "radius": { - "units": "m", - "value": 1.94951235192 - }, - "twist": { - "units": "degree", - "value": 6.56013248 - } + "radius": 1.94951235192, + "twist": 0.11449591114302106 }, { - "radius": { - "units": "m", - "value": 2.01582353502 - }, - "twist": { - "units": "degree", - "value": 6.07263352 - } + "radius": 2.01582353502, + "twist": 0.10598744919097293 }, { - "radius": { - "units": "m", - "value": 2.07503075976 - }, - "twist": { - "units": "degree", - "value": 5.49910533 - } + "radius": 2.07503075976, + "twist": 0.0959774939224693 }, { - "radius": { - "units": "m", - "value": 2.12476435473 - }, - "twist": { - "units": "degree", - "value": 5.0976356 - } + "radius": 2.12476435473, + "twist": 0.08897052528687666 }, { - "radius": { - "units": "m", - "value": 2.16976164345 - }, - "twist": { - "units": "degree", - "value": 4.69616587 - } + "radius": 2.16976164345, + "twist": 0.08196355665128399 }, { - "radius": { - "units": "m", - "value": 2.23133702322 - }, - "twist": { - "units": "degree", - "value": 4.12263769 - } + "radius": 2.23133702322, + "twist": 0.07195360155731331 }, { - "radius": { - "units": "m", - "value": 2.27396582163 - }, - "twist": { - "units": "degree", - "value": 3.77852078 - } + "radius": 2.27396582163, + "twist": 0.06594762846602431 }, { - "radius": { - "units": "m", - "value": 2.32133075865 - }, - "twist": { - "units": "degree", - "value": 3.46308028 - } + "radius": 2.32133075865, + "twist": 0.06044215314688713 }, { - "radius": { - "units": "m", - "value": 2.3959304748299997 - }, - "twist": { - "units": "degree", - "value": 2.97558132 - } + "radius": 2.3959304748299997, + "twist": 0.051933691194838996 }, { - "radius": { - "units": "m", - "value": 2.53328914347 - }, - "twist": { - "units": "degree", - "value": 2.0005834 - } + "radius": 2.53328914347, + "twist": 0.03491676729074272 }, { - "radius": { - "units": "m", - "value": 2.61499265037 - }, - "twist": { - "units": "degree", - "value": 1.62779008 - } + "radius": 2.61499265037, + "twist": 0.0284102964273019 }, { - "radius": { - "units": "m", - "value": 2.6647260776999997 - }, - "twist": { - "units": "degree", - "value": 1.25499676 - } + "radius": 2.6647260776999997, + "twist": 0.02190382556386107 }, { - "radius": { - "units": "m", - "value": 2.7097226882400003 - }, - "twist": { - "units": "degree", - "value": 0.96823267 - } + "radius": 2.7097226882400003, + "twist": 0.016898848016875724 }, { - "radius": { - "units": "m", - "value": 2.76892923861 - }, - "twist": { - "units": "degree", - "value": 0.50941012 - } + "radius": 2.76892923861, + "twist": 0.008890883836979417 }, { - "radius": { - "units": "m", - "value": 2.83997723469 - }, - "twist": { - "units": "degree", - "value": -0.064118065 - } + "radius": 2.83997723469, + "twist": -0.0011190713442577381 }, { - "radius": { - "units": "m", - "value": 2.90628824634 - }, - "twist": { - "units": "degree", - "value": -0.522940614 - } + "radius": 2.90628824634, + "twist": -0.009127035506700755 }, { - "radius": { - "units": "m", - "value": 3.03772619022 - }, - "twist": { - "units": "degree", - "value": -1.44058571 - } + "radius": 3.03772619022, + "twist": -0.0251429637966802 }, { - "radius": { - "units": "m", - "value": 3.20824019514 - }, - "twist": { - "units": "degree", - "value": -2.61631849 - } + "radius": 3.20824019514, + "twist": -0.04566337193130633 }, { - "radius": { - "units": "m", - "value": 3.34204511295 - }, - "twist": { - "units": "degree", - "value": -3.333228722 - } + "radius": 3.34204511295, + "twist": -0.05817581592094275 }, { - "radius": { - "units": "m", - "value": 3.4817710908 - }, - "twist": { - "units": "degree", - "value": -4.164844591 - } + "radius": 3.4817710908, + "twist": -0.07269025094682659 }, { - "radius": { - "units": "m", - "value": 3.56702767035 - }, - "twist": { - "units": "degree", - "value": -4.681019957 - } + "radius": 3.56702767035, + "twist": -0.08169921060121339 }, { - "radius": { - "units": "m", - "value": 3.64754709783 - }, - "twist": { - "units": "degree", - "value": -5.053813278 - } + "radius": 3.64754709783, + "twist": -0.0882056814821075 }, { - "radius": { - "units": "m", - "value": 3.72569904846 - }, - "twist": { - "units": "degree", - "value": -5.541312235 - } + "radius": 3.72569904846, + "twist": -0.09671414338179578 }, { - "radius": { - "units": "m", - "value": 3.77069549136 - }, - "twist": { - "units": "degree", - "value": -5.799399919 - } + "radius": 3.77069549136, + "twist": -0.10121862322644246 }, { - "radius": { - "units": "m", - "value": 3.81 - }, - "twist": { - "units": "degree", - "value": -6.1 - } + "radius": 3.81, + "twist": -0.1064650843716541 } ], "type": "BETDisk" diff --git a/tests/simulation/converter/test_bet_disk_flow360_converter.py b/tests/simulation/converter/test_bet_disk_flow360_converter.py index c9a9e7904..5bc0701ac 100644 --- a/tests/simulation/converter/test_bet_disk_flow360_converter.py +++ b/tests/simulation/converter/test_bet_disk_flow360_converter.py @@ -4,7 +4,6 @@ from tempfile import NamedTemporaryFile import pytest -from flow360_schema.framework.validation.context import DeserializationContext import flow360.component.simulation.units as u from flow360.component.simulation.framework.updater_utils import compare_values @@ -147,8 +146,7 @@ def test_single_polar_flow360_bet_convert(): # Test that the disk can be serialized and validated successfully disk_dict = disk.model_dump() # Verify the model can be reconstructed - with DeserializationContext(): - reconstructed_disk = BETDisk.model_validate(disk_dict) + reconstructed_disk = BETDisk.deserialize(disk_dict) assert isinstance(reconstructed_disk, BETDisk) @@ -187,6 +185,5 @@ def test_xrotor_single_polar(): # Test serialization and validation disk_dict = bet_disk.model_dump() - with DeserializationContext(): - reconstructed_disk = fl.BETDisk.model_validate(disk_dict) + reconstructed_disk = fl.BETDisk.deserialize(disk_dict) assert isinstance(reconstructed_disk, fl.BETDisk) diff --git a/tests/simulation/framework/test_entities_fast_register.py b/tests/simulation/framework/test_entities_fast_register.py index 837d7d076..932dd004c 100644 --- a/tests/simulation/framework/test_entities_fast_register.py +++ b/tests/simulation/framework/test_entities_fast_register.py @@ -5,6 +5,7 @@ import numpy as np import pydantic as pd import pytest +from flow360_schema.framework.physical_dimensions import Length import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -28,7 +29,7 @@ SimulationParams, _ParamModelBase, ) -from flow360.component.simulation.unit_system import LengthType, SI_unit_system +from flow360.component.simulation.unit_system import SI_unit_system from flow360.component.simulation.utils import model_attribute_unlock from tests.simulation.conftest import AssetBase @@ -216,7 +217,7 @@ class TempSimulationParam(_ParamModelBase): private_attribute_asset_cache: AssetCache = pd.Field(AssetCache(), frozen=True) @property - def base_length(self) -> LengthType: + def base_length(self) -> Length.Float64: return self.private_attribute_asset_cache.project_length_unit.to("m") def preprocess(self): @@ -225,7 +226,7 @@ def preprocess(self): TempFluidDynamics etc so that the class can perform proper validation """ with model_attribute_unlock(self.private_attribute_asset_cache, "project_length_unit"): - self.private_attribute_asset_cache.project_length_unit = LengthType.validate(1 * u.m) + self.private_attribute_asset_cache.project_length_unit = 1 * u.m for model in self.models: model.entities.preprocess(params=self) diff --git a/tests/simulation/framework/test_entities_v2.py b/tests/simulation/framework/test_entities_v2.py index 15ee18474..5100f05ae 100644 --- a/tests/simulation/framework/test_entities_v2.py +++ b/tests/simulation/framework/test_entities_v2.py @@ -5,6 +5,7 @@ import numpy as np import pydantic as pd import pytest +from flow360_schema.framework.physical_dimensions import Length import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -29,7 +30,7 @@ SimulationParams, _ParamModelBase, ) -from flow360.component.simulation.unit_system import LengthType, SI_unit_system +from flow360.component.simulation.unit_system import SI_unit_system from flow360.component.simulation.utils import model_attribute_unlock from tests.simulation.conftest import AssetBase @@ -213,7 +214,7 @@ class TempSimulationParam(_ParamModelBase): private_attribute_asset_cache: AssetCache = pd.Field(AssetCache(), frozen=True) @property - def base_length(self) -> LengthType: + def base_length(self) -> Length.Float64: return self.private_attribute_asset_cache.project_length_unit.to("m") def preprocess(self): @@ -222,7 +223,7 @@ def preprocess(self): TempFluidDynamics etc so that the class can perform proper validation """ with model_attribute_unlock(self.private_attribute_asset_cache, "project_length_unit"): - self.private_attribute_asset_cache.project_length_unit = LengthType.validate(1 * u.m) + self.private_attribute_asset_cache.project_length_unit = 1 * u.m for model in self.models: model.entities.preprocess(params=self) @@ -692,27 +693,21 @@ def test_box_validation(): Box.from_principal_axes( name="box6", center=(0, 0, 0) * u.m, size=(1, 1, 1) * u.m, axes=((1, 0, 0), (1, 0, 0)) ) - - with pytest.raises(ValueError, match=re.escape("'[ 1 1 -10] m' cannot have negative value")): - Box( - name="box6", - center=(0, 0, 0) * u.m, - size=(1, 1, -10) * u.m, - axis_of_rotation=(1, 0, 0), - angle_of_rotation=10 * u.deg, - ) - with pytest.raises( - ValueError, match=re.escape("'(1, 1, -10) flow360_length_unit' cannot have negative value") + ValueError, + match=re.escape("All vector components must be positive (>0), got -10.0"), ): Box( name="box6", center=(0, 0, 0) * u.m, - size=(1, 1, -10) * u.flow360_length_unit, + size=(1, 1, -10) * u.m, axis_of_rotation=(1, 0, 0), angle_of_rotation=10 * u.deg, ) + # flow360_length_unit block removed — _Flow360BaseUnit deleted in Phase 4 + # Tracked in plans/removed_tests.markdown + def test_cylinder_validation(): with pytest.raises( @@ -740,16 +735,12 @@ def test_sphere_creation(): def test_sphere_validation(): """Test Sphere validation for negative radius.""" - with pytest.raises(ValueError, match="Input should be greater than 0"): + with pytest.raises(ValueError, match="|Value must be positive"): Sphere( name="sphere", center=(0, 0, 0) * u.m, radius=-5 * u.m, ) - with pytest.raises(ValueError, match="Input should be greater than 0"): - Sphere( - name="sphere", - center=(0, 0, 0) * u.m, - radius=-5 * u.flow360_length_unit, - ) + # flow360_length_unit block removed — _Flow360BaseUnit deleted in Phase 4 + # Tracked in plans/removed_tests.markdown diff --git a/tests/simulation/framework/test_entity_dict_database.py b/tests/simulation/framework/test_entity_dict_database.py index 26a187952..9de2997df 100644 --- a/tests/simulation/framework/test_entity_dict_database.py +++ b/tests/simulation/framework/test_entity_dict_database.py @@ -59,7 +59,7 @@ def __init__(self, params_dict: dict, entity_info_obj=None): # pylint: disable=import-outside-toplevel from flow360.component.simulation.draft_context.mirror import MirrorStatus - mirror_status = MirrorStatus.model_validate(mirror_status_dict) + mirror_status = MirrorStatus.deserialize(mirror_status_dict) self.private_attribute_asset_cache = _AssetCache( project_entity_info=entity_info_obj, selectors=selectors, mirror_status=mirror_status ) diff --git a/tests/simulation/framework/test_entity_selector_token.py b/tests/simulation/framework/test_entity_selector_token.py index 81f37ee80..605dd4fa3 100644 --- a/tests/simulation/framework/test_entity_selector_token.py +++ b/tests/simulation/framework/test_entity_selector_token.py @@ -113,7 +113,7 @@ class _ParamsWithAssetCache(Flow360BaseModel): # Materialize selector tokens before validation, matching validate_model() preprocessing behavior. materialize_entities_and_selectors_in_place(tokenized_params) - validated = _ParamsWithAssetCache.model_validate(tokenized_params) + validated = _ParamsWithAssetCache.deserialize(tokenized_params) cache = validated.private_attribute_asset_cache assert cache.used_selectors is not None diff --git a/tests/simulation/framework/test_unit_system_v2.py b/tests/simulation/framework/test_unit_system_v2.py index 174725a81..f9b0331f7 100644 --- a/tests/simulation/framework/test_unit_system_v2.py +++ b/tests/simulation/framework/test_unit_system_v2.py @@ -110,144 +110,15 @@ def test_unit_access(): def test_unit_systems_compare(): - # For some reason this fails but only when run with pytest -rA if we switch order - assert u.flow360_unit_system != u.SI_unit_system assert u.SI_unit_system != u.CGS_unit_system assert u.SI_unit_system == u.SI_unit_system - assert u.flow360_unit_system == u.flow360_unit_system - assert u.flow360_unit_system == u.UnitSystem(base_system="Flow360") assert u.SI_unit_system == u.UnitSystem(base_system="SI") -@pytest.mark.usefixtures("array_equality_override") -def test_flow360_unit_arithmetic(): - assert 1 * u.flow360_area_unit - assert u.flow360_area_unit * 1 - - assert u.flow360_area_unit == u.flow360_area_unit - assert u.flow360_area_unit != u.flow360_density_unit - assert 1 * u.flow360_area_unit == u.flow360_area_unit * 1 - assert 1 * u.flow360_area_unit != 1 * u.flow360_density_unit - assert 1 * u.flow360_area_unit != 1 - - assert ( - 6 * u.flow360_area_unit - == 1.0 * u.flow360_area_unit + 2.0 * u.flow360_area_unit + 3.0 * u.flow360_area_unit - ) - assert -3 * u.flow360_area_unit == 1.0 * u.flow360_area_unit - 4.0 * u.flow360_area_unit - assert -3 * u.flow360_area_unit == 1.0 * u.flow360_area_unit - 4.0 * u.flow360_area_unit - assert -3 * u.flow360_area_unit == -1.0 * u.flow360_area_unit - 2.0 * u.flow360_area_unit - assert 2.5 * u.flow360_mass_flow_rate_unit == (5 - 2.5) * u.flow360_mass_flow_rate_unit - assert 2 * 8 * u.flow360_specific_energy_unit == 2**4 * u.flow360_specific_energy_unit - assert (5 * 5) * u.flow360_frequency_unit == 5**2 * u.flow360_frequency_unit - - with pytest.raises(TypeError): - 1 * u.flow360_area_unit + 2 - - with pytest.raises(TypeError): - 1 * u.flow360_area_unit - 2 - - with pytest.raises(TypeError): - 2 + 1 * u.flow360_area_unit - - with pytest.raises(TypeError): - 2 - 1 * u.flow360_area_unit - - with pytest.raises(ValueError): - 2 - u.flow360_area_unit - - with pytest.raises(ValueError): - 2 + u.flow360_area_unit - - with pytest.raises(TypeError): - 1 * u.flow360_area_unit + 2 * u.flow360_density_unit - - with pytest.raises(TypeError): - 1 * u.flow360_area_unit - 2 * u.flow360_density_unit - - with pytest.raises(TypeError): - 1 * u.flow360_area_unit - 2 * u.m**2 - - with pytest.raises(TypeError): - 1 * u.flow360_area_unit * u.flow360_area_unit - - with pytest.raises(TypeError): - 1 * u.flow360_viscosity_unit + 1 * u.Pa * u.s - - with pytest.raises(TypeError): - 1 * u.flow360_angular_velocity_unit - 1 * u.rad / u.s - - assert (1, 1, 1) * u.flow360_area_unit - assert u.flow360_area_unit * (1, 1, 1) - assert (1, 1, 1) * u.flow360_mass_unit + (1, 1, 1) * u.flow360_mass_unit - assert (1, 1, 1) * u.flow360_mass_unit - (1, 1, 1) * u.flow360_mass_unit - - with pytest.raises(TypeError): - assert (1, 1, 1) * u.flow360_mass_unit * (1, 1, 1) * u.flow360_mass_unit - - with pytest.raises(TypeError): - assert (1, 1, 1) * u.flow360_mass_unit * u.flow360_mass_unit - - with pytest.raises(TypeError): - assert (1, 1, 1) * u.flow360_mass_unit + (1, 1, 1) * u.flow360_length_unit - - data = VectorDataWithUnits( - pt=(1, 1, 1) * u.flow360_length_unit, - vec=(1, 1, 1) * u.flow360_velocity_unit, - ax=(1, 1, 1) * u.flow360_length_unit, - omega=(1, 1, 1) * u.flow360_angular_velocity_unit, - lp=(1, 1, 1) * u.flow360_length_unit, - ) - - with u.flow360_unit_system: - data_flow360 = VectorDataWithUnits( - pt=(1, 1, 1), - vec=(1, 1, 1), - ax=(1, 1, 1), - omega=(1, 1, 1) * u.flow360_angular_velocity_unit, - lp=(1, 1, 1), - ) - assert data == data_flow360 - - with pytest.raises(TypeError): - data.pt + (1, 1, 1) * u.m - - with pytest.raises(TypeError): - data.vec + (1, 1, 1) * u.m / u.s - - data = ArrayDataWithUnits( - l_arr=[1, 1, 1, 1] * u.flow360_angle_unit, - l_arr_nonneg=[1, 0, 0, 0] * u.flow360_length_unit, - ) - - with u.flow360_unit_system: - data_flow360 = ArrayDataWithUnits( - l_arr=[1, 1, 1, 1] * u.flow360_angle_unit, l_arr_nonneg=[1, 0, 0, 0] - ) - assert data == data_flow360 - - with pytest.raises(TypeError): - data.l_arr + [1, 1, 1, 1] * u.rad - - with pytest.raises(TypeError): - data.l_arr_nonneg + [1, 1, 1, 1] * u.m - - data = MatrixDataWithUnits( - locations=[[1, 1, 1], [2, 3, 4]] * u.flow360_length_unit, - locationsT=[[1, 2], [1, 3], [1, 4]] * u.flow360_length_unit, - ) - - with u.flow360_unit_system: - data_flow360 = MatrixDataWithUnits( - locations=[[1, 1, 1], [2, 3, 4]], - locationsT=[[1, 2], [1, 3], [1, 4]], - ) - assert data == data_flow360 - - with pytest.raises(TypeError): - data.locations + [[1, 1, 1], [2, 2, 2]] * u.rad +# test_flow360_unit_arithmetic: REMOVED — tested _Flow360BaseUnit arithmetic (deleted in Phase 4) +# Tracked in plans/removed_tests.markdown for future migration to schema side. def _assert_exact_same_unyt(input, ref): @@ -368,26 +239,8 @@ def test_unit_system(): _assert_exact_same_unyt(data.v_sq, 123 * u.ft**2 / u.s**2) _assert_exact_same_unyt(data.fqc, 1111 / u.s) - # Flow360 - with u.flow360_unit_system: - data = DataWithUnits( - **input, a=1 * u.flow360_angle_unit, omega=1 * u.flow360_angular_velocity_unit - ) - - assert data.L == 1 * u.flow360_length_unit - assert data.m == 2 * u.flow360_mass_unit - assert data.t == 3 * u.flow360_time_unit - assert data.T == 300 * u.flow360_temperature_unit - assert data.v == 2 / 3 * u.flow360_velocity_unit - assert data.A == 6 * u.flow360_area_unit - assert data.F == 4 * u.flow360_force_unit - assert data.p == 5 * u.flow360_pressure_unit - assert data.r == 2 * u.flow360_density_unit - assert data.mu == 3 * u.flow360_viscosity_unit - assert data.nu == 4 * u.flow360_kinematic_viscosity_unit - assert data.m_dot == 11 * u.flow360_mass_flow_rate_unit - assert data.v_sq == 123 * u.flow360_specific_energy_unit - assert data.fqc == 1111 * u.flow360_frequency_unit + # Flow360 section removed — tested _Flow360BaseUnit inference (deleted in Phase 4) + # Tracked in plans/removed_tests.markdown correct_input = { "L": 1, @@ -573,9 +426,10 @@ def test_unit_system(): lp={"value": [1, 1, 1], "units": "m"}, ) + # Nested dict with wrong inner shape — rejected as not 3 values with pytest.raises( pd.ValidationError, - match=r"Value error, No class found for unit_name: N \[type=value_error, input_value={'value': {'value': \[1, 2... 'wrong'}, 'units': 'N'}, input_type=dict\]", + match=r"needs to be a collection of 3 values", ): data = VectorDataWithUnits( pt=None, @@ -585,6 +439,13 @@ def test_unit_system(): lp={"value": [1, 1, 1], "units": "m"}, ) + # Invalid unit name — rejected as dimension mismatch + with pytest.raises( + pd.ValidationError, + match=r"does not match \(length\) dimension", + ): + Flow360DataWithUnits(l={"value": 1.0, "units": "bogus_unit"}, lp=[1, 2, 3] * u.m, lc=u.m) + with pytest.raises( pd.ValidationError, match=r"NaN/Inf/None found in input array. Please ensure your input is complete.", @@ -784,36 +645,8 @@ def test_units_schema(): def test_unit_system_init(): - unit_system_dict = { - "mass": {"value": 1.0, "units": "kg"}, - "length": {"value": 1.0, "units": "m"}, - "angle": {"value": 1.0, "units": "rad"}, - "time": {"value": 1.0, "units": "s"}, - "temperature": {"value": 1.0, "units": "K"}, - "delta_temperature": {"value": 1.0, "units": "K"}, - "velocity": {"value": 1.0, "units": "m/s"}, - "acceleration": {"value": 1.0, "units": "m/s**2"}, - "area": {"value": 1.0, "units": "m**2"}, - "force": {"value": 1.0, "units": "N"}, - "pressure": {"value": 1.0, "units": "Pa"}, - "density": {"value": 1.0, "units": "kg/m**3"}, - "viscosity": {"value": 1.0, "units": "Pa*s"}, - "kinematic_viscosity": {"value": 1.0, "units": "m**2/s"}, - "power": {"value": 1.0, "units": "W"}, - "moment": {"value": 1.0, "units": "N*m"}, - "angular_velocity": {"value": 1.0, "units": "rad/s"}, - "heat_flux": {"value": 1.0, "units": "kg/s**3"}, - "heat_source": {"value": 1.0, "units": "kg/s**3/m"}, - "specific_heat_capacity": {"value": 1.0, "units": "m**2/s**2/K"}, - "thermal_conductivity": {"value": 1.0, "units": "kg/s**3*m/K"}, - "inverse_length": {"value": 1.0, "units": "m**(-1)"}, - "inverse_area": {"value": 1.0, "units": "m**(-2)"}, - "mass_flow_rate": {"value": 1.0, "units": "kg/s"}, - "specific_energy": {"value": 1.0, "units": "m**2/s**2"}, - "frequency": {"value": 1.0, "units": "s**(-1)"}, - "angle": {"value": 1.0, "units": "rad"}, - } - us = u.UnitSystem(**unit_system_dict) + # NEW UnitSystem only accepts base_system or 5 base dimensions + us = u.UnitSystem(base_system="SI") assert us == u.SI_unit_system diff --git a/tests/simulation/outputs/test_output_entities.py b/tests/simulation/outputs/test_output_entities.py index 92d72d2d6..eb99e5195 100644 --- a/tests/simulation/outputs/test_output_entities.py +++ b/tests/simulation/outputs/test_output_entities.py @@ -88,12 +88,7 @@ def test_isosurface_wall_distance_clip(): # Test that an Isosurface field must have length units with pytest.raises( pd.ValidationError, - match=re.escape( - "1 validation error for Isosurface\n" - "wall_distance_clip_threshold\n" - " Value error, arg '0.0 1/s' does not match (length) dimension." - " [type=value_error, input_value=None, input_type=NoneType]" - ), + match="wall_distance_clip_threshold", ): Isosurface( name="test_iso_vorticity_component", @@ -102,30 +97,11 @@ def test_isosurface_wall_distance_clip(): wall_distance_clip_threshold=0.0 / u.s, ) - with pytest.raises( - pd.ValidationError, - match=re.escape( - "1 validation error for Isosurface\n" - "wall_distance_clip_threshold\n" - " Value error, arg '0.0' does not match (length) dimension." - " [type=value_error, input_value=None, input_type=NoneType]" - ), - ): - Isosurface( - name="test_iso_vorticity_component", - field="T", - iso_value=0.5, - wall_distance_clip_threshold=0.0, - ) + # Bare numeric 0.0 is now accepted as SI (0 meters) — no longer a validation error. with pytest.raises( pd.ValidationError, - match=re.escape( - "1 validation error for Isosurface\n" - "wall_distance_clip_threshold.value\n" - " Input should be greater than 0" - " [type=greater_than, input_value=array(-0.1), input_type=ndarray]" - ), + match="wall_distance_clip_threshold", ): Isosurface( name="test_iso_vorticity_component", diff --git a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py index 9dafe82bc..9fe9f992a 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -46,11 +46,7 @@ ) from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import ( - CGS_unit_system, - LengthType, - SI_unit_system, -) +from flow360.component.simulation.unit_system import CGS_unit_system, SI_unit_system from flow360.component.simulation.utils import BoundingBox from flow360.component.simulation.validation.validation_context import ( SURFACE_MESH, @@ -67,7 +63,7 @@ beta_mesher_context = ParamsValidationInfo({}, []) beta_mesher_context.is_beta_mesher = True -beta_mesher_context.project_length_unit = "mm" +beta_mesher_context.project_length_unit = 1 * u.mm snappy_context = ParamsValidationInfo({}, []) snappy_context.use_snappy = True @@ -145,7 +141,7 @@ def test_disable_invalid_axisymmetric_body_construction(): with pytest.raises( pd.ValidationError, - match=re.escape("Value error, arg '(-1, 1, 3)' needs to be a collection of 2 values"), + match=re.escape("Vector must have exactly 2 components, got 3"), ): with CGS_unit_system: cylinder_1 = AxisymmetricBody( @@ -1833,14 +1829,14 @@ def test_wind_tunnel_invalid_dimensions(): # invalid floors with pytest.raises( pd.ValidationError, - match=r"is not strictly increasing", + match=r"strictly increasing", ): # invalid range _ = StaticFloor(friction_patch_x_range=(-100, -200), friction_patch_width=42) with pytest.raises( pd.ValidationError, - match=r"cannot have negative value", + match=r"All values must be positive", ): # invalid positive range _ = WheelBelts( @@ -2088,7 +2084,7 @@ def test_meshing_defaults_octree_spacing_auto_set_from_project_length_unit(): defaults = MeshingDefaults( boundary_layer_first_layer_thickness=0.001, ) - # beta_mesher_context has project_length_unit = "mm" + # beta_mesher_context has project_length_unit = 1 * u.mm assert defaults.octree_spacing is not None assert isinstance(defaults.octree_spacing, OctreeSpacing) assert defaults.octree_spacing.base_spacing == 1 * u.mm @@ -2675,7 +2671,7 @@ def test_geometry_accuracy_with_non_unit_project_length_scale(): """ gai_ctx = ParamsValidationInfo({}, []) gai_ctx.use_geometry_AI = True - gai_ctx.project_length_unit = LengthType.validate(1.2 * u.mm) + gai_ctx.project_length_unit = 1.2 * u.mm gai_ctx.global_bounding_box = BoundingBox([[-5e5, -5e5, -5e5], [5e5, 5e5, 5e5]]) expected_warning = ( diff --git a/tests/simulation/params/meshing_validation/test_refinements_validation.py b/tests/simulation/params/meshing_validation/test_refinements_validation.py index 16e745a27..a37363b6f 100644 --- a/tests/simulation/params/meshing_validation/test_refinements_validation.py +++ b/tests/simulation/params/meshing_validation/test_refinements_validation.py @@ -128,7 +128,7 @@ def test_snappy_refinements_validators(mock_validation_context): def test_snappy_edge_refinement_validators(): - message = "When using a distance spacing specification both spacing (2.0 mm) and distances ([5] mm) fields must be arrays and the same length." + message = "When using a distance spacing specification both spacing (2.0 mm) and distances ([5.] mm) fields must be arrays and the same length." with pytest.raises( ValueError, match=re.escape(message), @@ -149,7 +149,7 @@ def test_snappy_edge_refinement_validators(): spacing=2 * u.mm, distances=5 * u.mm, entities=[Surface(name="test")] ) - message = "When using a distance spacing specification both spacing ([2] mm) and distances (None) fields must be arrays and the same length." + message = "When using a distance spacing specification both spacing ([2.] mm) and distances (None) fields must be arrays and the same length." with pytest.raises( ValueError, match=re.escape(message), diff --git a/tests/simulation/params/test_actuator_disk.py b/tests/simulation/params/test_actuator_disk.py index a9a37e853..21f275880 100644 --- a/tests/simulation/params/test_actuator_disk.py +++ b/tests/simulation/params/test_actuator_disk.py @@ -1,6 +1,7 @@ import re import pytest +import unyt as u from flow360.component.simulation.models.volume_models import ActuatorDisk, ForcePerArea from flow360.component.simulation.primitives import Cylinder @@ -8,7 +9,7 @@ from flow360.component.simulation.translator.solver_translator import ( actuator_disk_translator, ) -from flow360.component.simulation.unit_system import SI_unit_system, u +from flow360.component.simulation.unit_system import SI_unit_system def test_actuator_disk(): diff --git a/tests/simulation/params/test_automated_farfield.py b/tests/simulation/params/test_automated_farfield.py index 665c2b827..43bb0bd3d 100644 --- a/tests/simulation/params/test_automated_farfield.py +++ b/tests/simulation/params/test_automated_farfield.py @@ -556,7 +556,7 @@ def test_domain_type_bounding_box_check(): dummy_boundary = Surface(name="dummy", private_attribute_id="test-dummy-surface-id") asset_cache_positive = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=True, project_entity_info=SurfaceMeshEntityInfo( @@ -639,7 +639,7 @@ def test_legacy_asset_missing_private_attributes(): missing_surface.private_attributes = None asset_cache = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=False, project_entity_info=SurfaceMeshEntityInfo( diff --git a/tests/simulation/params/test_gravity.py b/tests/simulation/params/test_gravity.py index 72cc7cd46..e48607e2b 100644 --- a/tests/simulation/params/test_gravity.py +++ b/tests/simulation/params/test_gravity.py @@ -7,10 +7,7 @@ from flow360.component.simulation.models.volume_models import Fluid, Gravity from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.translator.solver_translator import gravity_translator -from flow360.component.simulation.unit_system import ( - SI_unit_system, - flow360_acceleration_unit, -) +from flow360.component.simulation.unit_system import SI_unit_system # ============================================================================ # Gravity data class tests @@ -157,7 +154,7 @@ def test_gravity_translator_default_direction(): nondim_magnitude = 8.49e-5 gravity = Gravity( direction=(0, 0, -1), - magnitude=nondim_magnitude * flow360_acceleration_unit, + magnitude=nondim_magnitude * u.m / u.s**2, ) result = gravity_translator(gravity) @@ -174,7 +171,7 @@ def test_gravity_translator_custom_direction(): nondim_magnitude = 1e-3 gravity = Gravity( direction=(1, 0, 0), - magnitude=nondim_magnitude * flow360_acceleration_unit, + magnitude=nondim_magnitude * u.m / u.s**2, ) result = gravity_translator(gravity) diff --git a/tests/simulation/params/test_simulation_params.py b/tests/simulation/params/test_simulation_params.py index 26a4336b2..d7c0fb4ef 100644 --- a/tests/simulation/params/test_simulation_params.py +++ b/tests/simulation/params/test_simulation_params.py @@ -2,6 +2,7 @@ import unittest import numpy as np +import pydantic as pd import pytest import flow360.component.simulation.units as u @@ -409,6 +410,20 @@ def test_subsequent_param_with_different_unit_system(): assert param_CGS.meshing.defaults.boundary_layer_first_layer_thickness == 0.3 * u.cm +@pytest.mark.parametrize( + ("unit_system", "match"), + [ + ({}, "Field required"), + ({"name": "Bogus"}, "Input should be 'SI', 'CGS' or 'Imperial'"), + ], +) +def test_invalid_unit_system_dict_raises_validation_error(unit_system, match): + with SI_unit_system, pytest.raises(pd.ValidationError, match=match) as exc_info: + SimulationParams(unit_system=unit_system) + + assert not isinstance(exc_info.value, KeyError) + + def test_mach_reynolds_op_cond(): condition = AerospaceCondition.from_mach_reynolds( mach=0.2, diff --git a/tests/simulation/params/test_unit_conversions.py b/tests/simulation/params/test_unit_conversions.py index 50973fb97..d1e87d744 100644 --- a/tests/simulation/params/test_unit_conversions.py +++ b/tests/simulation/params/test_unit_conversions.py @@ -19,96 +19,17 @@ def change_test_dir(request, monkeypatch): monkeypatch.chdir(request.fspath.dirname) -def test_unit_conversions(): - - with SI_unit_system: - far_field_zone = fl.AutomatedFarfield() - params = fl.SimulationParams( - meshing=fl.MeshingParams( - defaults=fl.MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - surface_max_edge_length=1, - ), - volume_zones=[far_field_zone], - ), - reference_geometry=fl.ReferenceGeometry(), - operating_condition=fl.AerospaceCondition( - velocity_magnitude=100, - alpha=5 * u.deg, - ), - time_stepping=fl.Steady(max_steps=1000), - models=[ - fl.Wall( - surfaces=[Surface(name="surface")], - name="Wall", - ), - fl.Freestream( - surfaces=[far_field_zone.farfield], - name="Freestream", - ), - ], - ) - - mach = 0.2938635365101296 - velocity = 100 - converted = params.convert_unit(value=velocity * u.m / u.s, target_system="flow360") - assert float(converted.value) == mach - assert str(converted.units) == "flow360_velocity_unit" - - converted = params.convert_unit(value=converted, target_system="SI") - assert float(converted.value) == velocity - assert str(converted.units) == "m/s" - - converted = params.convert_unit(value=mach * u.flow360_velocity_unit, target_system="SI") - assert float(converted.value) == velocity - assert str(converted.units) == "m/s" - - converted = params.convert_unit(value=velocity * u.m / u.s, target_system="SI") - assert float(converted.value) == velocity - assert str(converted.units) == "m/s" - - converted = params.convert_unit(value=mach * u.flow360_velocity_unit, target_system="flow360") - assert float(converted.value) == mach - assert str(converted.units) == "flow360_velocity_unit" - - converted = params.convert_unit(value=velocity * u.m / u.s, target_system="Imperial") - assert float(converted.value) == 328.0839895013123 - assert str(converted.units) == "ft/s" - - converted = params.convert_unit(value=328.0839895013123 * u.ft / u.s, target_system="SI") - assert float(converted.value) == 100 - assert str(converted.units) == "m/s" - - pressure_flow360 = 1 / 1.4 - pressure = 101325.009090375 - converted = params.convert_unit( - value=pressure * u.Pa, target_system="flow360", length_unit=1 * u.m - ) - assert float(converted.value) == pressure_flow360 - assert str(converted.units) == "flow360_pressure_unit" - - converted = params.convert_unit(value=converted, target_system="SI") - assert float(converted.value) == pressure - assert str(converted.units) == "kg/(m*s**2)" - - converted = params.convert_unit( - value=pressure_flow360 * u.flow360_pressure_unit, target_system="SI" - ) - assert float(converted.value) == pressure - assert str(converted.units) == "kg/(m*s**2)" +# test_unit_conversions: REMOVED — tested convert_unit with flow360_*_unit (deleted in Phase 4) +# Tracked in plans/removed_tests.markdown for future migration to schema side. +def _removed_test_unit_conversions(): + pass def test_temperature_offset(): ThermalState.from_standard_atmosphere( altitude=10 * u.m, temperature_offset=11.11 * u.delta_degC ) - - with pytest.raises( - pd.ValidationError, - match=re.escape( - r"arg '11.11 °C' does not match unit representing difference in (temperature)." - ), - ): + with pytest.raises(ValueError, match="Use delta units"): ThermalState.from_standard_atmosphere(altitude=10 * u.m, temperature_offset=11.11 * u.degC) with fl.imperial_unit_system: diff --git a/tests/simulation/params/test_validators_bet_disk.py b/tests/simulation/params/test_validators_bet_disk.py index 9df0f4869..b6788c96d 100644 --- a/tests/simulation/params/test_validators_bet_disk.py +++ b/tests/simulation/params/test_validators_bet_disk.py @@ -1,7 +1,6 @@ import unittest import pytest -from flow360_schema.framework.validation.context import DeserializationContext import flow360.component.simulation.units as u from flow360.component.simulation import services @@ -39,8 +38,7 @@ def test_bet_disk_blade_line_chord(create_steady_bet_disk): def test_bet_disk_initial_blade_direction(create_steady_bet_disk): bet_disk = create_steady_bet_disk - with DeserializationContext(): - BETDisk.model_validate(bet_disk) + BETDisk.model_validate(bet_disk) with pytest.raises( ValueError, @@ -69,32 +67,29 @@ def test_bet_disk_disorder_alphas(create_steady_bet_disk): tmp = bet_disk.alphas[0] bet_disk.alphas[0] = bet_disk.alphas[1] bet_disk.alphas[1] = tmp - with DeserializationContext(): - BETDisk.model_validate(bet_disk.model_dump()) + BETDisk.deserialize(bet_disk.model_dump()) def test_bet_disk_duplicate_chords(create_steady_bet_disk): bet_disk = create_steady_bet_disk with pytest.raises( ValueError, - match="BETDisk with name 'diskABC': it has duplicated radius at 150.0348189415042 in chords.", + match=r"BETDisk with name 'diskABC': it has duplicated radius at .+ in chords\.", ): bet_disk.name = "diskABC" bet_disk.chords.append(bet_disk.chords[-1]) - with DeserializationContext(): - BETDisk.model_validate(bet_disk.model_dump()) + BETDisk.deserialize(bet_disk.model_dump()) def test_bet_disk_duplicate_twists(create_steady_bet_disk): bet_disk = create_steady_bet_disk with pytest.raises( ValueError, - match="BETDisk with name 'diskABC': it has duplicated radius at 150.0 in twists.", + match=r"BETDisk with name 'diskABC': it has duplicated radius at .+ in twists\.", ): bet_disk.name = "diskABC" bet_disk.twists.append(bet_disk.twists[-1]) - with DeserializationContext(): - BETDisk.model_validate(bet_disk.model_dump()) + BETDisk.deserialize(bet_disk.model_dump()) def test_bet_disk_nonequal_sectional_radiuses_and_polars(create_steady_bet_disk): @@ -105,12 +100,10 @@ def test_bet_disk_nonequal_sectional_radiuses_and_polars(create_steady_bet_disk) ): bet_disk.name = "diskABC" bet_disk_dict = bet_disk.model_dump() - bet_disk_dict["sectional_radiuses"]["value"] = bet_disk_dict["sectional_radiuses"][ - "value" - ] + (bet_disk.sectional_radiuses[-1],) - with DeserializationContext(): - bet_disk_error = BETDisk.model_validate(bet_disk_dict) - BETDisk.model_validate(bet_disk_error) + bet_disk_dict["sectional_radiuses"] = bet_disk_dict["sectional_radiuses"] + [ + bet_disk_dict["sectional_radiuses"][-1], + ] + BETDisk.deserialize(bet_disk_dict) def test_bet_disk_3d_coefficients_dimension_wrong_mach_numbers(create_steady_bet_disk): @@ -121,8 +114,7 @@ def test_bet_disk_3d_coefficients_dimension_wrong_mach_numbers(create_steady_bet ): bet_disk.name = "diskABC" bet_disk.mach_numbers.append(bet_disk.mach_numbers[-1]) - with DeserializationContext(): - BETDisk.model_validate(bet_disk) + BETDisk.model_validate(bet_disk) def test_bet_disk_3d_coefficients_dimension_wrong_re_numbers(create_steady_bet_disk): @@ -144,7 +136,5 @@ def test_bet_disk_3d_coefficients_dimension_wrong_alpha_numbers(create_steady_be ): bet_disk.name = "diskABC" bet_disk_dict = bet_disk.model_dump() - bet_disk_dict["alphas"]["value"] = bet_disk_dict["alphas"]["value"] + (bet_disk.alphas[-1],) - with DeserializationContext(): - bet_disk_error = BETDisk.model_validate(bet_disk_dict) - BETDisk.model_validate(bet_disk_error) + bet_disk_dict["alphas"] = bet_disk_dict["alphas"] + [bet_disk_dict["alphas"][-1]] + BETDisk.deserialize(bet_disk_dict) diff --git a/tests/simulation/params/test_validators_output.py b/tests/simulation/params/test_validators_output.py index eeac85a52..0a18056d3 100644 --- a/tests/simulation/params/test_validators_output.py +++ b/tests/simulation/params/test_validators_output.py @@ -1275,7 +1275,7 @@ def test_output_frequency_settings_in_steady_simulation(): "r", ) as fh: asset_cache_data = json.load(fh).pop("private_attribute_asset_cache") - asset_cache = AssetCache.model_validate(asset_cache_data) + asset_cache = AssetCache.deserialize(asset_cache_data) with imperial_unit_system: params = SimulationParams( models=[Wall(name="wall", entities=volume_mesh["*"])], diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 9b27e86a4..afc035cbf 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -846,7 +846,7 @@ def test_incomplete_BC_volume_mesh(): ) asset_cache = AssetCache( - project_length_unit="inch", + project_length_unit=1 * u.inch, project_entity_info=VolumeMeshEntityInfo( boundaries=[wall_1, periodic_1, periodic_2, i_exist, some_interface, no_bc] ), @@ -946,7 +946,7 @@ def test_incomplete_BC_surface_mesh(): auto_farfield = AutomatedFarfield(name="my_farfield") asset_cache = AssetCache( - project_length_unit="inch", + project_length_unit=1 * u.inch, project_entity_info=SurfaceMeshEntityInfo( boundaries=[wall_1, periodic_1, periodic_2, i_exist, no_bc, i_will_be_deleted], ghost_entities=[ @@ -1075,9 +1075,9 @@ def test_porousJump_entities_is_interface(mock_validation_context): with mock_validation_context, pytest.raises(ValueError, match=re.escape(error_message)): PorousJump( entity_pairs=[(surface_2_is_not_interface, surface_1_is_interface)], - darcy_coefficient=1e6, - forchheimer_coefficient=1e3, - thickness=0.01, + darcy_coefficient=1e6 / (u.m * u.m), + forchheimer_coefficient=1e3 / u.m, + thickness=0.01 * u.m, ) PorousJump( @@ -1652,7 +1652,7 @@ def test_rotation_parent_volumes(mock_case_validation_context): Wall(entities=[my_wall]), ], private_attribute_asset_cache=AssetCache( - project_length_unit="cm", + project_length_unit=1 * u.cm, project_entity_info=VolumeMeshEntityInfo(boundaries=[my_wall]), ), ) @@ -1720,7 +1720,7 @@ def test_rotating_reference_frame_model_flag(): ], time_stepping=timestepping_steady, private_attribute_asset_cache=AssetCache( - project_length_unit="cm", + project_length_unit=1 * u.cm, project_entity_info=VolumeMeshEntityInfo(boundaries=[my_wall]), ), ) @@ -1746,7 +1746,7 @@ def test_rotating_reference_frame_model_flag(): ], time_stepping=timestepping_unsteady, private_attribute_asset_cache=AssetCache( - project_length_unit="cm", + project_length_unit=1 * u.cm, project_entity_info=VolumeMeshEntityInfo(boundaries=[my_wall]), ), ) @@ -1830,12 +1830,13 @@ def test_wall_deserialization(): ) assert all(const_vel_wall.velocity == [1, 2, 3] * u.m / u.s) - slater_bleed_wall = Wall( - **Wall( - entities=dummy_boundary, - velocity=SlaterPorousBleed(porosity=0.2, static_pressure=0.1 * u.Pa), - ).model_dump(mode="json") - ) + with DeserializationContext(): + slater_bleed_wall = Wall( + **Wall( + entities=dummy_boundary, + velocity=SlaterPorousBleed(porosity=0.2, static_pressure=0.1 * u.Pa), + ).model_dump(mode="json") + ) assert slater_bleed_wall.velocity.porosity == 0.2 assert slater_bleed_wall.velocity.static_pressure == 0.1 * u.Pa @@ -3187,7 +3188,7 @@ def test_auto_farfield_full_body_surface_on_y0_not_marked_deleted(): ) asset_cache = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=True, project_entity_info=SurfaceMeshEntityInfo( @@ -3248,7 +3249,7 @@ def test_auto_farfield_half_body_surface_on_y0_marked_deleted(): ) asset_cache = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=True, project_entity_info=SurfaceMeshEntityInfo( @@ -3322,7 +3323,7 @@ def test_deleted_surfaces_domain_type(): ) asset_cache = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=True, project_entity_info=SurfaceMeshEntityInfo( @@ -3504,7 +3505,7 @@ def test_coordinate_system_requires_geometry_ai(): # Asset cache with GAI disabled but coordinate system used asset_cache_no_gai = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=False, coordinate_system_status=cs_status, @@ -3531,7 +3532,7 @@ def test_coordinate_system_requires_geometry_ai(): # Test with GAI enabled - should pass asset_cache_with_gai = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=True, coordinate_system_status=cs_status, @@ -3574,7 +3575,7 @@ def test_mirroring_requires_geometry_ai(): # Asset cache with GAI disabled but mirroring used asset_cache_no_gai = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=False, mirror_status=mirror_status, @@ -3597,7 +3598,7 @@ def test_mirroring_requires_geometry_ai(): # Test with GAI enabled - should pass asset_cache_with_gai = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=True, mirror_status=mirror_status, @@ -3637,7 +3638,7 @@ def test_mirror_missing_boundary_condition_downgraded_to_warning(): ) asset_cache = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=True, project_entity_info=VolumeMeshEntityInfo(boundaries=[front]), @@ -3683,7 +3684,7 @@ def test_mirror_unknown_boundary_still_raises_error(): ) asset_cache = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=True, project_entity_info=VolumeMeshEntityInfo(boundaries=[front]), @@ -3725,7 +3726,7 @@ def test_domain_type_bbox_mismatch_downgraded_to_warning_when_transformed(): # Global bbox fully on -Y side; choosing half_body_positive_y should normally raise. asset_cache = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, use_inhouse_mesher=True, use_geometry_AI=True, project_entity_info=SurfaceMeshEntityInfo( @@ -3772,7 +3773,7 @@ def test_incomplete_BC_with_geometry_AI(): ) asset_cache = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, project_entity_info=VolumeMeshEntityInfo(boundaries=[wall, no_bc]), use_geometry_AI=True, # Enable GAI ) @@ -3818,7 +3819,7 @@ def test_incomplete_BC_without_geometry_AI(): ) asset_cache = AssetCache( - project_length_unit="m", + project_length_unit=1 * u.m, project_entity_info=VolumeMeshEntityInfo(boundaries=[wall, no_bc]), use_geometry_AI=False, # Disable GAI ) diff --git a/tests/simulation/ref/simulation_with_project_variables.json b/tests/simulation/ref/simulation_with_project_variables.json index 3702dfb5f..c979612a1 100644 --- a/tests/simulation/ref/simulation_with_project_variables.json +++ b/tests/simulation/ref/simulation_with_project_variables.json @@ -12,18 +12,9 @@ "interface_interpolation_tolerance": 0.2, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -46,14 +37,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -138,18 +123,9 @@ "density": 1.225, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -172,14 +148,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -204,18 +174,9 @@ "density": 1.225, "material": { "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } + "effective_temperature": 110.4, + "reference_temperature": 273.15, + "reference_viscosity": 1.716e-05 }, "name": "air", "prandtl_number": 0.72, @@ -238,14 +199,8 @@ 0.0, 0.0 ], - "temperature_range_max": { - "units": "K", - "value": 6000.0 - }, - "temperature_range_min": { - "units": "K", - "value": 200.0 - }, + "temperature_range_max": 6000.0, + "temperature_range_min": 200.0, "type_name": "NASA9CoefficientSet" } ], @@ -299,14 +254,11 @@ "entities": { "stored_entities": [ { - "location": { - "units": "m", - "value": [ - 1.0, - 2.0, - 3.0 - ] - }, + "location": [ + 1.0, + 2.0, + 3.0 + ], "name": "pt1", "private_attribute_entity_type_name": "Point", "private_attribute_id": "111" @@ -391,5 +343,5 @@ "name": "SI" }, "user_defined_fields": [], - "version": "25.9.3b1" + "version": "25.11.0b1" } diff --git a/tests/simulation/results_processing/test_bet_disk_coefficients.py b/tests/simulation/results_processing/test_bet_disk_coefficients.py index cc92993a3..07613d17f 100644 --- a/tests/simulation/results_processing/test_bet_disk_coefficients.py +++ b/tests/simulation/results_processing/test_bet_disk_coefficients.py @@ -182,8 +182,13 @@ def test_bet_disk_real_case_coefficients(): mach_ref = params.operating_condition.mach coeff_env = _build_coeff_env(params) - assert coeff_env["dynamic_pressure"] == 0.5 * mach_ref * mach_ref - assert coeff_env["area"] == (params.reference_geometry.area / (1.0 * fl.u.cm**2)).value + assert np.isclose(coeff_env["dynamic_pressure"], 0.5 * mach_ref * mach_ref, rtol=1e-7, atol=0) + assert np.isclose( + coeff_env["area"], + (params.reference_geometry.area / (1.0 * fl.u.cm**2)).value, + rtol=1e-7, + atol=0, + ) assert np.allclose(coeff_env["moment_length_vec"], [140, 140, 140]) assert np.allclose(coeff_env["moment_center_global"], [0, 0, 0]) assert np.allclose(coeff_env["lift_dir"], [-0.25881905, 0.0, 0.96592583]) diff --git a/tests/simulation/service/data/result_merged_geometry_entity_info1.json b/tests/simulation/service/data/result_merged_geometry_entity_info1.json index 5e8230b6b..b70a06379 100644 --- a/tests/simulation/service/data/result_merged_geometry_entity_info1.json +++ b/tests/simulation/service/data/result_merged_geometry_entity_info1.json @@ -44,10 +44,7 @@ "body00001", "sphere1_body00001" ], - "default_geometry_accuracy": { - "units": "m", - "value": 0.0001 - }, + "default_geometry_accuracy": 0.0001, "draft_entities": [], "edge_attribute_names": [ "name", diff --git a/tests/simulation/service/data/result_merged_geometry_entity_info2.json b/tests/simulation/service/data/result_merged_geometry_entity_info2.json index 9081a0680..bcca5f01e 100644 --- a/tests/simulation/service/data/result_merged_geometry_entity_info2.json +++ b/tests/simulation/service/data/result_merged_geometry_entity_info2.json @@ -44,10 +44,7 @@ "body00001", "sphere2_body00001" ], - "default_geometry_accuracy": { - "units": "m", - "value": 0.0001 - }, + "default_geometry_accuracy": 0.0001, "draft_entities": [], "edge_attribute_names": [ "name", diff --git a/tests/simulation/service/test_apply_simulation_setting.py b/tests/simulation/service/test_apply_simulation_setting.py index 7c296ea3d..b3ab979e5 100644 --- a/tests/simulation/service/test_apply_simulation_setting.py +++ b/tests/simulation/service/test_apply_simulation_setting.py @@ -48,8 +48,8 @@ def _create_base_simulation_dict(entity_info_type="VolumeMeshEntityInfo", surfac "gap_treatment_strength": 0.2, "defaults": { "surface_edge_growth_rate": 1.5, - "boundary_layer_first_layer_thickness": "1*m", - "surface_max_edge_length": "1*m", + "boundary_layer_first_layer_thickness": {"value": 1, "units": "m"}, + "surface_max_edge_length": {"value": 1, "units": "m"}, }, "refinements": [], "volume_zones": [], diff --git a/tests/simulation/service/test_project_util.py b/tests/simulation/service/test_project_util.py index 26764da11..18eba7999 100644 --- a/tests/simulation/service/test_project_util.py +++ b/tests/simulation/service/test_project_util.py @@ -1,13 +1,13 @@ import numpy as np import pytest +import flow360.component.simulation.units as u from flow360.component.project_utils import ( _replace_ghost_surfaces, _set_up_default_reference_geometry, ) from flow360.component.simulation.primitives import GhostSphere from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import LengthType @pytest.fixture(autouse=True) @@ -26,7 +26,7 @@ def test_replace_ghost_surfaces(): def test_set_up_default_reference_geometry(): params = SimulationParams.from_file("./data/simulation_with_old_ghost_surface.json") - length_unit = LengthType.validate("cm") + length_unit = 1 * u.cm new_params = _set_up_default_reference_geometry(params, length_unit=length_unit) assert np.all(new_params.reference_geometry.area == 1.0 * length_unit**2) diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index 3f77b3f3a..3d9f009ce 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -1,6 +1,7 @@ import copy import json import re +from typing import get_args import pytest from unyt import Unit @@ -11,7 +12,7 @@ from flow360.component.simulation.exposed_units import supported_units_by_front_end from flow360.component.simulation.framework.updater_utils import compare_values from flow360.component.simulation.services_report import get_default_report_config -from flow360.component.simulation.unit_system import _PredefinedUnitSystem +from flow360.component.simulation.unit_system import DimensionedTypes from flow360.component.simulation.user_code.core.types import UserVariable from flow360.component.simulation.validation.validation_context import ( CASE, @@ -106,8 +107,8 @@ def test_validate_service(): params_data_from_geo = params_data_from_vm params_data_from_geo["meshing"]["defaults"] = { "surface_edge_growth_rate": 1.5, - "boundary_layer_first_layer_thickness": "1*m", - "surface_max_edge_length": "1*m", + "boundary_layer_first_layer_thickness": {"value": 1, "units": "m"}, + "surface_max_edge_length": {"value": 1, "units": "m"}, } params_data_from_geo["version"] = "24.11.0" @@ -252,8 +253,8 @@ def test_validate_multiple_errors(): "gap_treatment_strength": 0.2, "defaults": { "surface_edge_growth_rate": 1.5, - "boundary_layer_first_layer_thickness": "1*m", - "surface_max_edge_length": "1*s", + "boundary_layer_first_layer_thickness": {"value": 1, "units": "m"}, + "surface_max_edge_length": {"value": 1, "units": "s"}, }, "refinements": [], "volume_zones": [ @@ -303,7 +304,7 @@ def test_validate_multiple_errors(): }, { "loc": ("reference_geometry", "area", "value"), - "type": "greater_than", + "type": "value_error", "ctx": {"relevant_for": ["Case"]}, }, ] @@ -470,11 +471,9 @@ def _compare_validation_errors(err, exp_err): expected_errors = [ { - "loc": ("models", 0, "private_attribute_input_cache", "chord_ref", "value"), - "type": "greater_than", - "msg": "Input should be greater than 0", - "input": -14, - "ctx": {"gt": "0.0"}, + "loc": ("models", 0, "private_attribute_input_cache", "chord_ref"), + "type": "value_error", + "msg": "Value error, Value must be positive (>0), got -14.0", }, { "type": "missing_argument", @@ -560,9 +559,7 @@ def _compare_validation_errors(err, exp_err): "private_attribute_input_cache", "size", ), - "msg": "Value error, arg '[ 0.2 0.3 -2. ] m' cannot have negative value", - "input": {"units": "m", "value": [0.2, 0.3, -2.0]}, - "ctx": {"error": "arg '[ 0.2 0.3 -2. ] m' cannot have negative value"}, + "msg": "Value error, All vector components must be positive (>0), got -2.0", } ] _compare_validation_errors(errors, expected_errors) @@ -682,11 +679,13 @@ def remove_model_and_output_id_in_default_dict(data): unit_system_name="SI", length_unit="cm", root_item_type="SurfaceMesh" ) assert data["reference_geometry"]["area"]["units"] == "cm**2" - assert data["reference_geometry"]["moment_center"]["units"] == "cm" - assert data["reference_geometry"]["moment_length"]["units"] == "cm" - assert data["private_attribute_asset_cache"]["project_length_unit"]["units"] == "cm" + # New schema types serialize moment_center/moment_length as bare SI values + assert data["reference_geometry"]["moment_center"] == [0.0, 0.0, 0.0] + assert data["reference_geometry"]["moment_length"] == [0.01, 0.01, 0.01] + assert data["private_attribute_asset_cache"]["project_length_unit"] == 0.01 - assert data["models"][0]["roughness_height"]["units"] == "cm" + # roughness_height now serializes as bare SI float (new dimension types) + assert data["models"][0]["roughness_height"] == 0.0 remove_model_and_output_id_in_default_dict(data) # to convert tuples to lists: data = json.loads(json.dumps(data)) @@ -802,8 +801,8 @@ def test_front_end_JSON_with_multi_constructor(): params_data = { "meshing": { "defaults": { - "boundary_layer_first_layer_thickness": "1*m", - "surface_max_edge_length": "1*m", + "boundary_layer_first_layer_thickness": {"value": 1, "units": "m"}, + "surface_max_edge_length": {"value": 1, "units": "m"}, }, "refinement_factor": 1.45, "refinements": [ @@ -869,7 +868,7 @@ def test_front_end_JSON_with_multi_constructor(): "unit_system": {"name": "SI"}, "version": "24.2.0", "private_attribute_asset_cache": { - "project_length_unit": "m", + "project_length_unit": 1.0, "project_entity_info": { "type_name": "GeometryEntityInfo", "face_ids": ["face_x_1", "face_x_2", "face_x_3"], @@ -1002,7 +1001,7 @@ def test_generate_process_json(): } ], "private_attribute_asset_cache": { - "project_length_unit": "m", + "project_length_unit": 1.0, "project_entity_info": { "type_name": "GeometryEntityInfo", "face_ids": ["face_x_1", "face_x_2", "face_x_3"], @@ -1027,7 +1026,7 @@ def test_generate_process_json(): }, } - params_data["meshing"]["defaults"]["surface_max_edge_length"] = "1*m" + params_data["meshing"]["defaults"]["surface_max_edge_length"] = {"value": 1, "units": "m"} res1, res2, res3 = services.generate_process_json( simulation_json=json.dumps(params_data), root_item_type="Geometry", up_to="SurfaceMesh" ) @@ -1036,7 +1035,10 @@ def test_generate_process_json(): assert res2 is None assert res3 is None - params_data["meshing"]["defaults"]["boundary_layer_first_layer_thickness"] = "1*m" + params_data["meshing"]["defaults"]["boundary_layer_first_layer_thickness"] = { + "value": 1, + "units": "m", + } res1, res2, res3 = services.generate_process_json( simulation_json=json.dumps(params_data), root_item_type="Geometry", up_to="VolumeMesh" ) @@ -1328,14 +1330,13 @@ def _get_all_units(value): raise ValueError(f"Unit {unit} is not valid for dimension {dimension}") ##### 2. Ensure that all units supported have set their front-end approved units - for field_name, field_info in _PredefinedUnitSystem.model_fields.items(): - if field_name == "name": - continue - unit_system_dimension_string = str(field_info.annotation.dim) - # for unit_name in unit: + for dim_type in get_args(DimensionedTypes): + inner_type = get_args(dim_type)[0] # unwrap Annotated + unit_system_dimension_string = str(inner_type.dim) + dim_name = inner_type.dim_name if unit_system_dimension_string not in supported_units_by_front_end.keys(): raise ValueError( - f"Unit {unit_system_dimension_string} (A.K.A {field_name}) is not supported by the front-end.", + f"Unit {unit_system_dimension_string} (A.K.A {dim_name}) is not supported by the front-end.", "Please ensure front end team is aware of this new unit and add its support.", ) @@ -1421,19 +1422,19 @@ def check_setting_preserved( # Load test data with open("data/root_geometry_cube_simulation.json", "r") as f: root_cube_simulation_dict = json.load(f) - root_cube_entity_info = GeometryEntityInfo.model_validate( + root_cube_entity_info = GeometryEntityInfo.deserialize( root_cube_simulation_dict["private_attribute_asset_cache"]["project_entity_info"] ) with open("data/dependency_geometry_sphere1_simulation.json", "r") as f: dependency_sphere1_simulation_dict = json.load(f) - dependency_sphere1_entity_info = GeometryEntityInfo.model_validate( + dependency_sphere1_entity_info = GeometryEntityInfo.deserialize( dependency_sphere1_simulation_dict["private_attribute_asset_cache"][ "project_entity_info" ] ) with open("data/dependency_geometry_sphere2_simulation.json", "r") as f: dependency_sphere2_simulation_dict = json.load(f) - dependency_sphere2_entity_info = GeometryEntityInfo.model_validate( + dependency_sphere2_entity_info = GeometryEntityInfo.deserialize( dependency_sphere2_simulation_dict["private_attribute_asset_cache"][ "project_entity_info" ] @@ -1448,7 +1449,7 @@ def check_setting_preserved( dependency_sphere1_simulation_dict, ], ) - result_entity_info1 = GeometryEntityInfo.model_validate(result_entity_info_dict1) + result_entity_info1 = GeometryEntityInfo.deserialize(result_entity_info_dict1) # Load expected result for test 1 with open("data/result_merged_geometry_entity_info1.json", "r") as f: @@ -1505,7 +1506,7 @@ def check_setting_preserved( result_entity_info_dict2, expected_result2 ), "Test 2 failed: Merged entity info with replaced dependency does not match expected result" - result_entity_info2 = GeometryEntityInfo.model_validate(result_entity_info_dict2) + result_entity_info2 = GeometryEntityInfo.deserialize(result_entity_info_dict2) # Verify key properties are preserved using helper function check_setting_preserved( diff --git a/tests/simulation/service/test_translator_service.py b/tests/simulation/service/test_translator_service.py index 0ba834fef..c74b8e57b 100644 --- a/tests/simulation/service/test_translator_service.py +++ b/tests/simulation/service/test_translator_service.py @@ -2,6 +2,7 @@ from copy import deepcopy import pytest +import unyt as u from flow360.component.simulation.entity_info import GeometryEntityInfo from flow360.component.simulation.framework.entity_selector import SurfaceSelector @@ -29,7 +30,7 @@ validate_model, ) from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system, u +from flow360.component.simulation.unit_system import SI_unit_system from flow360.component.simulation.validation.validation_context import ( CASE, SURFACE_MESH, @@ -509,7 +510,7 @@ def test_simulation_to_case_json(): ], }, "private_attribute_asset_cache": { - "project_length_unit": "m", + "project_length_unit": 1.0, "project_entity_info": { "type_name": "GeometryEntityInfo", "face_ids": ["face_x_1", "face_x_2", "face_x_3"], @@ -782,7 +783,7 @@ def test_simulation_to_all_translation_2(): ], "unit_system": {"name": "SI"}, "private_attribute_asset_cache": { - "project_length_unit": "m", + "project_length_unit": 1.0, "project_entity_info": { "type_name": "GeometryEntityInfo", "face_ids": ["face_x_1", "face_x_2", "face_x_3"], diff --git a/tests/simulation/test_expression_math.py b/tests/simulation/test_expression_math.py index c9689b657..917a05d1d 100644 --- a/tests/simulation/test_expression_math.py +++ b/tests/simulation/test_expression_math.py @@ -4,6 +4,7 @@ import pydantic as pd import pytest import unyt as u +from flow360_schema.framework.physical_dimensions import Velocity import flow360.component.simulation.user_code.core.context as context from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -14,7 +15,7 @@ ) from flow360.component.simulation.services import clear_context from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system, VelocityType +from flow360.component.simulation.unit_system import SI_unit_system from flow360.component.simulation.user_code.core.types import ( Expression, UserVariable, @@ -54,7 +55,7 @@ def scaling_provider(): # ---------------------------# def test_cross_product(): class TestModel(Flow360BaseModel): - field: ValueOrExpression[VelocityType.Vector] = pd.Field() + field: ValueOrExpression[Velocity.Vector3] = pd.Field() x = UserVariable(name="x", value=[1, 2, 3]) diff --git a/tests/simulation/test_krylov_solver.py b/tests/simulation/test_krylov_solver.py index b9f2d338f..491005c0f 100644 --- a/tests/simulation/test_krylov_solver.py +++ b/tests/simulation/test_krylov_solver.py @@ -209,7 +209,7 @@ def test_error_krylov_with_unsteady(self): with pytest.raises(ValueError, match="Unsteady"): _make_sim_params( navier_stokes_solver=NavierStokesSolver(linear_solver=KrylovLinearSolver()), - time_stepping=Unsteady(steps=100, step_size=0.1), + time_stepping=Unsteady(steps=100, step_size=0.1 * u.s), ) def test_krylov_with_steady_is_ok(self): diff --git a/tests/simulation/test_value_or_expression.py b/tests/simulation/test_value_or_expression.py index da3070161..6e34a6b91 100644 --- a/tests/simulation/test_value_or_expression.py +++ b/tests/simulation/test_value_or_expression.py @@ -71,7 +71,7 @@ def asset_cache(): "r", ) as fh: asset_cache_data = json.load(fh).pop("private_attribute_asset_cache") - return AssetCache.model_validate(asset_cache_data) + return AssetCache.deserialize(asset_cache_data) def operating_condition_with_expression(): diff --git a/tests/simulation/translator/ref/Flow360_mirrored_surface_meshing.json b/tests/simulation/translator/ref/Flow360_mirrored_surface_meshing.json index d6abdc5d2..2da9ce6ce 100644 --- a/tests/simulation/translator/ref/Flow360_mirrored_surface_meshing.json +++ b/tests/simulation/translator/ref/Flow360_mirrored_surface_meshing.json @@ -1,97 +1,52 @@ { "meshing": { "defaults": { - "curvature_resolution_angle": { - "units": "rad", - "value": 0.2617993877991494 - }, - "geometry_accuracy": { - "units": "1.0*m", - "value": 0.01 - }, + "curvature_resolution_angle": 0.2617993877991494, + "geometry_accuracy": 0.01, "planar_face_tolerance": 1e-06, "preserve_thin_geometry": false, "remove_hidden_geometry": false, "resolve_face_boundaries": false, - "sealing_size": { - "units": "1.0*m", - "value": 0.0 - }, + "sealing_size": 0.0, "surface_edge_growth_rate": 1.2, "surface_max_adaptation_iterations": 50, "surface_max_aspect_ratio": 10.0, - "surface_max_edge_length": { - "units": "1.0*m", - "value": 0.2 - } + "surface_max_edge_length": 0.2 }, "refinements": [], "volume_zones": [ { "floor_type": { - "central_belt_width": { - "units": "1.0*m", - "value": 1.2 - }, - "central_belt_x_range": { - "units": "1.0*m", - "value": [ - -1.0, - 6.0 - ] - }, - "front_wheel_belt_x_range": { - "units": "1.0*m", - "value": [ - -0.3, - 0.5 - ] - }, - "front_wheel_belt_y_range": { - "units": "1.0*m", - "value": [ - 0.7, - 1.2 - ] - }, - "rear_wheel_belt_x_range": { - "units": "1.0*m", - "value": [ - 2.6, - 3.8 - ] - }, - "rear_wheel_belt_y_range": { - "units": "1.0*m", - "value": [ - 0.7, - 1.2 - ] - }, + "central_belt_width": 1.2, + "central_belt_x_range": [ + -1.0, + 6.0 + ], + "front_wheel_belt_x_range": [ + -0.3, + 0.5 + ], + "front_wheel_belt_y_range": [ + 0.7, + 1.2 + ], + "rear_wheel_belt_x_range": [ + 2.6, + 3.8 + ], + "rear_wheel_belt_y_range": [ + 0.7, + 1.2 + ], "type_name": "WheelBelts" }, - "floor_z_position": { - "units": "1.0*m", - "value": 0.0 - }, - "height": { - "units": "1.0*m", - "value": 10.0 - }, - "inlet_x_position": { - "units": "1.0*m", - "value": -5.0 - }, + "floor_z_position": 0.0, + "height": 10.0, + "inlet_x_position": -5.0, "name": "Wind Tunnel Farfield", - "outlet_x_position": { - "units": "1.0*m", - "value": 15.0 - }, + "outlet_x_position": 15.0, "type": "WindTunnelFarfield", - "width": { - "units": "1.0*m", - "value": 10.0 - } + "width": 10.0 } ] }, @@ -99,14 +54,11 @@ "mirror_status": { "mirror_planes": [ { - "center": { - "units": "1.0*m", - "value": [ - 1.6, - 0.0, - 0.0 - ] - }, + "center": [ + 1.6, + 0.0, + 0.0 + ], "name": "mirror_local", "normal": [ 1.0, diff --git a/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json b/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json index d2fdf3b3c..b3b524882 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json @@ -122,7 +122,7 @@ }, "runControl": { "externalProcessMonitorOutput": true, - "monitorProcessorHash": "513247f6ed57ddc4505dad7ea41e4ec34ad16eb98e5c07e930aebede14b0652d", + "monitorProcessorHash": "b0762ed89d5543c51766196b7f88ae921117428c337ef8ee9a35e75dbf0c8554", "stoppingCriteria": [ { "monitoredColumn": "point_legacy1_Point1_Helicity_mean", diff --git a/tests/simulation/translator/ref/surface_meshing/gai_surface_mesher.json b/tests/simulation/translator/ref/surface_meshing/gai_surface_mesher.json index fe7858e3b..e67441f67 100644 --- a/tests/simulation/translator/ref/surface_meshing/gai_surface_mesher.json +++ b/tests/simulation/translator/ref/surface_meshing/gai_surface_mesher.json @@ -1,36 +1,21 @@ { "meshing": { "defaults": { - "curvature_resolution_angle": { - "units": "rad", - "value": 0.20943951023931956 - }, - "geometry_accuracy": { - "units": "1.0*m", - "value": 0.05 - }, + "curvature_resolution_angle": 0.20943951023931956, + "geometry_accuracy": 0.05, "planar_face_tolerance": 1e-06, "preserve_thin_geometry": false, "remove_hidden_geometry": true, "resolve_face_boundaries": false, - "sealing_size": { - "units": "1.0*m", - "value": 0.0 - }, + "sealing_size": 0.0, "surface_edge_growth_rate": 1.2, "surface_max_adaptation_iterations": 19, "surface_max_aspect_ratio": 0.01, - "surface_max_edge_length": { - "units": "1.0*m", - "value": 0.2 - } + "surface_max_edge_length": 0.2 }, "refinements": [ { - "curvature_resolution_angle": { - "units": "rad", - "value": 0.08726646259971647 - }, + "curvature_resolution_angle": 0.08726646259971647, "entities": { "stored_entities": [ { @@ -152,10 +137,7 @@ } ] }, - "max_edge_length": { - "units": "1.0*m", - "value": 0.1 - }, + "max_edge_length": 0.1, "name": "renamed_surface", "refinement_type": "SurfaceRefinement", "resolve_face_boundaries": true @@ -174,14 +156,8 @@ } ] }, - "geometry_accuracy": { - "units": "1.0*m", - "value": 0.05 - }, - "min_passage_size": { - "units": "1.0*m", - "value": 0.1 - }, + "geometry_accuracy": 0.05, + "min_passage_size": 0.1, "name": "Local_override", "refinement_type": "GeometryRefinement" } diff --git a/tests/simulation/translator/ref/surface_meshing/gai_windtunnel.json b/tests/simulation/translator/ref/surface_meshing/gai_windtunnel.json index 9b3d58e28..6e277230b 100644 --- a/tests/simulation/translator/ref/surface_meshing/gai_windtunnel.json +++ b/tests/simulation/translator/ref/surface_meshing/gai_windtunnel.json @@ -1,97 +1,52 @@ { "meshing": { "defaults": { - "curvature_resolution_angle": { - "units": "rad", - "value": 0.2617993877991494 - }, - "geometry_accuracy": { - "units": "1.0*m", - "value": 0.01 - }, + "curvature_resolution_angle": 0.2617993877991494, + "geometry_accuracy": 0.01, "planar_face_tolerance": 0.001, "preserve_thin_geometry": false, "remove_hidden_geometry": false, "resolve_face_boundaries": false, - "sealing_size": { - "units": "1.0*m", - "value": 0.0 - }, + "sealing_size": 0.0, "surface_edge_growth_rate": 1.2, "surface_max_adaptation_iterations": 50, "surface_max_aspect_ratio": 10.0, - "surface_max_edge_length": { - "units": "1.0*m", - "value": 0.2 - } + "surface_max_edge_length": 0.2 }, "refinements": [], "volume_zones": [ { "floor_type": { - "central_belt_width": { - "units": "1.0*m", - "value": 1.2 - }, - "central_belt_x_range": { - "units": "1.0*m", - "value": [ - -1.0, - 6.0 - ] - }, - "front_wheel_belt_x_range": { - "units": "1.0*m", - "value": [ - -0.3, - 0.5 - ] - }, - "front_wheel_belt_y_range": { - "units": "1.0*m", - "value": [ - 0.7, - 1.2 - ] - }, - "rear_wheel_belt_x_range": { - "units": "1.0*m", - "value": [ - 2.6, - 3.8 - ] - }, - "rear_wheel_belt_y_range": { - "units": "1.0*m", - "value": [ - 0.7, - 1.2 - ] - }, + "central_belt_width": 1.2, + "central_belt_x_range": [ + -1.0, + 6.0 + ], + "front_wheel_belt_x_range": [ + -0.3, + 0.5 + ], + "front_wheel_belt_y_range": [ + 0.7, + 1.2 + ], + "rear_wheel_belt_x_range": [ + 2.6, + 3.8 + ], + "rear_wheel_belt_y_range": [ + 0.7, + 1.2 + ], "type_name": "WheelBelts" }, - "floor_z_position": { - "units": "1.0*m", - "value": 0.0 - }, - "height": { - "units": "1.0*m", - "value": 10.0 - }, - "inlet_x_position": { - "units": "1.0*m", - "value": -5.0 - }, + "floor_z_position": 0.0, + "height": 10.0, + "inlet_x_position": -5.0, "name": "Wind Tunnel Farfield", - "outlet_x_position": { - "units": "1.0*m", - "value": 15.0 - }, + "outlet_x_position": 15.0, "type": "WindTunnelFarfield", - "width": { - "units": "1.0*m", - "value": 10.0 - } + "width": 10.0 } ] }, diff --git a/tests/simulation/translator/test_output_translation.py b/tests/simulation/translator/test_output_translation.py index c1594c178..4593fc873 100644 --- a/tests/simulation/translator/test_output_translation.py +++ b/tests/simulation/translator/test_output_translation.py @@ -1326,6 +1326,7 @@ def test_force_distribution_output_with_wall_boundaries(): # Test: Without entities, should use all surfaces with Wall BC with SI_unit_system: param = SimulationParams( + operating_condition=AerospaceCondition(), models=[ Fluid(), Wall(entities=[wing_surface, fuselage_surface]), @@ -1356,6 +1357,7 @@ def test_force_distribution_output_with_wall_boundaries(): # Test: With entities, should use only specified surfaces with SI_unit_system: param = SimulationParams( + operating_condition=AerospaceCondition(), models=[ Fluid(), Wall(entities=[wing_surface, fuselage_surface]), @@ -1419,7 +1421,9 @@ def test_time_averaged_force_distribution_output(): with SI_unit_system: param = SimulationParams( - outputs=param_with_ref[0], time_stepping=Unsteady(steps=1, step_size=0.1) + operating_condition=AerospaceCondition(), + outputs=param_with_ref[0], + time_stepping=Unsteady(steps=1, step_size=0.1), ) param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"]) @@ -1455,7 +1459,9 @@ def test_time_averaged_force_distribution_output_with_entities_and_segments(): with SI_unit_system: param = SimulationParams( - outputs=param_with_entities[0], time_stepping=Unsteady(steps=100, step_size=0.1) + operating_condition=AerospaceCondition(), + outputs=param_with_entities[0], + time_stepping=Unsteady(steps=100, step_size=0.1), ) param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"]) @@ -1486,7 +1492,9 @@ def test_time_averaged_force_distribution_output_with_entities_and_segments(): with SI_unit_system: param = SimulationParams( - outputs=param_with_segments[0], time_stepping=Unsteady(steps=100, step_size=0.1) + operating_condition=AerospaceCondition(), + outputs=param_with_segments[0], + time_stepping=Unsteady(steps=100, step_size=0.1), ) param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"]) @@ -1522,7 +1530,9 @@ def test_time_averaged_force_distribution_output_with_entities_and_segments(): with SI_unit_system: param = SimulationParams( - outputs=param_with_both[0], time_stepping=Unsteady(steps=100, step_size=0.1) + operating_condition=AerospaceCondition(), + outputs=param_with_both[0], + time_stepping=Unsteady(steps=100, step_size=0.1), ) param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"]) diff --git a/tests/simulation/translator/test_solver_translator.py b/tests/simulation/translator/test_solver_translator.py index d91ea401b..9ea8797a9 100644 --- a/tests/simulation/translator/test_solver_translator.py +++ b/tests/simulation/translator/test_solver_translator.py @@ -216,7 +216,7 @@ def get_om6Wing_tutorial_param(): face_group_tag="default", grouped_faces=[[my_wall, my_symmetry_plane, my_freestream]], ) - asset_cache = AssetCache(project_entity_info=entity_info, project_length_unit="m") + asset_cache = AssetCache(project_entity_info=entity_info, project_length_unit=1 * u.m) with SI_unit_system: param = SimulationParams( diff --git a/tests/simulation/translator/test_surface_meshing_translator.py b/tests/simulation/translator/test_surface_meshing_translator.py index ea69aa846..8a84c7175 100644 --- a/tests/simulation/translator/test_surface_meshing_translator.py +++ b/tests/simulation/translator/test_surface_meshing_translator.py @@ -2,6 +2,7 @@ import os import pytest +from flow360_schema.framework.physical_dimensions import Length import flow360.component.simulation.units as u from flow360.component.geometry import Geometry, GeometryMeta @@ -68,10 +69,10 @@ get_surface_meshing_json, ) from flow360.component.simulation.unit_system import ( - LengthType, SI_unit_system, imperial_unit_system, ) +from flow360.component.simulation.units import validate_length from tests.simulation.conftest import AssetBase @@ -79,7 +80,7 @@ class TempGeometry(AssetBase): """Mimicing the final VolumeMesh class""" fname: str - mesh_unit: LengthType.Positive + mesh_unit: Length.PositiveFloat64 def _get_meta_data(self): if self.fname == "om6wing.csm": @@ -323,7 +324,7 @@ def _get_entity_info(self): raise ValueError("Invalid file name") def _populate_registry(self): - self.mesh_unit = LengthType.validate(self._get_meta_data()["mesh_unit"]) + self.mesh_unit = validate_length(self._get_meta_data()["mesh_unit"]) if self.snappy: self.internal_registry = self._get_entity_info()._group_entity_by_tag( "face", "faceId", self.internal_registry @@ -1336,9 +1337,7 @@ def test_gai_analytic_wind_tunnel_farfield(): ), "r", ) as fh: - asset_cache = AssetCache.model_validate( - json.load(fh).pop("private_attribute_asset_cache") - ) + asset_cache = AssetCache.deserialize(json.load(fh).pop("private_attribute_asset_cache")) params = SimulationParams( meshing=meshing_params, @@ -1379,7 +1378,7 @@ def test_sliding_interface_tolerance_gai(): ), "r", ) as fh: - asset_cache = AssetCache.model_validate(json.load(fh).pop("private_attribute_asset_cache")) + asset_cache = AssetCache.deserialize(json.load(fh).pop("private_attribute_asset_cache")) with SI_unit_system: farfield = AutomatedFarfield(domain_type="half_body_positive_y") @@ -1439,9 +1438,9 @@ def test_sliding_interface_tolerance_gai(): ] }, "name": "rotating_volume", - "spacing_axial": {"units": "1.0*m", "value": 0.1}, - "spacing_circumferential": {"units": "1.0*m", "value": 0.1}, - "spacing_radial": {"units": "1.0*m", "value": 0.1}, + "spacing_axial": 0.1, + "spacing_circumferential": 0.1, + "spacing_radial": 0.1, "type": "RotationVolume", } @@ -1515,7 +1514,7 @@ def test_gai_mirror_status_translation(): # Add mirror_status to asset_cache asset_cache_dict["mirror_status"] = mirror_status.model_dump(mode="json") - asset_cache = AssetCache.model_validate(asset_cache_dict) + asset_cache = AssetCache.deserialize(asset_cache_dict) with SI_unit_system: farfield = AutomatedFarfield(domain_type="half_body_positive_y") @@ -1555,17 +1554,11 @@ def test_gai_mirror_status_translation(): assert plane_json["name"] == "y_symmetry_plane" assert plane_json["normal"] == [0.0, 1.0, 0.0] - # KEY ASSERTION: Verify dimensional value (center) has proper units format + # KEY ASSERTION: Verify dimensional value (center) is serialized as SI values assert "center" in plane_json center = plane_json["center"] - assert isinstance(center, dict), "center should be a dict with value and units" - assert "value" in center, "center must have 'value' key" - assert "units" in center, "center must have 'units' key" - - # Verify the values are correct (converted to Flow360 units - meters) - assert center["value"] == [0.5, 0.0, 0.25], f"Expected [0.5, 0.0, 0.25], got {center['value']}" - # Units should be in meter format (could be "m" or "1.0*m" depending on serialization) - assert "m" in center["units"], f"Expected meter units, got {center['units']}" + # Schema-side serialization outputs bare SI values (meters) as a list/tuple + assert center == [0.5, 0.0, 0.25], f"Expected [0.5, 0.0, 0.25], got {center}" # Assert mirrored entities are present assert "mirrored_geometry_body_groups" in mirror_status_json @@ -1648,7 +1641,7 @@ def test_gai_mirror_status_translation_idempotency(): ) asset_cache_dict["mirror_status"] = mirror_status.model_dump(mode="json") - asset_cache = AssetCache.model_validate(asset_cache_dict) + asset_cache = AssetCache.deserialize(asset_cache_dict) with SI_unit_system: farfield = AutomatedFarfield(domain_type="half_body_positive_y") @@ -1778,7 +1771,7 @@ def test_gai_no_stationary_enclosed_entities(): params = SimulationParams( meshing=meshing, - private_attribute_asset_cache=AssetCache.model_validate( + private_attribute_asset_cache=AssetCache.deserialize( param_dict["private_attribute_asset_cache"] ), ) @@ -1811,7 +1804,7 @@ def test_gai_target_surface_node_count_set(): ), volume_zones=[AutomatedFarfield()], ), - private_attribute_asset_cache=AssetCache.model_validate( + private_attribute_asset_cache=AssetCache.deserialize( param_dict["private_attribute_asset_cache"] ), ) @@ -1842,7 +1835,7 @@ def test_gai_target_surface_node_count_absent(): ), volume_zones=[AutomatedFarfield()], ), - private_attribute_asset_cache=AssetCache.model_validate( + private_attribute_asset_cache=AssetCache.deserialize( param_dict["private_attribute_asset_cache"] ), ) diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index ec874cd83..d1ea80622 100644 --- a/tests/simulation/translator/test_volume_meshing_translator.py +++ b/tests/simulation/translator/test_volume_meshing_translator.py @@ -2,6 +2,7 @@ import os import pytest +from flow360_schema.framework.physical_dimensions import Length import flow360.component.simulation.units as u from flow360.component.project_utils import _replace_ghost_surfaces @@ -53,7 +54,8 @@ _translate_enclosed_entity_name, get_volume_meshing_json, ) -from flow360.component.simulation.unit_system import LengthType, SI_unit_system +from flow360.component.simulation.unit_system import SI_unit_system +from flow360.component.simulation.units import validate_length from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_context import VOLUME_MESH from tests.simulation.conftest import AssetBase @@ -63,7 +65,7 @@ class TempSurfaceMesh(AssetBase): """Mimicing the final SurfaceMesh class""" fname: str - mesh_unit: LengthType.Positive + mesh_unit: Length.PositiveFloat64 def _get_meta_data(self): if self.fname == "om6wing.cgns": @@ -77,7 +79,7 @@ def _get_meta_data(self): raise ValueError("Invalid file name") def _populate_registry(self): - self.mesh_unit = LengthType.validate(self._get_meta_data()["mesh_unit"]) + self.mesh_unit = validate_length(self._get_meta_data()["mesh_unit"]) for surface_name in self._get_meta_data()["surfaces"]: self.internal_registry.register(Surface(name=surface_name)) @@ -935,21 +937,21 @@ def test_user_defined_farfield_ghost_symmetry_passes_without_explicit_domain_typ }, ), ) - params_as_dict = params.model_dump(mode="json", exclude_none=True) - info = ParamsValidationInfo(param_as_dict=params_as_dict, referenced_expressions=[]) - with ValidationContext(levels=VOLUME_MESH, info=info): - PassiveSpacing(entities=[GhostCircularPlane(name="symmetric")], type="projected") + params_as_dict = params.model_dump(mode="json", exclude_none=True) + info = ParamsValidationInfo(param_as_dict=params_as_dict, referenced_expressions=[]) + with ValidationContext(levels=VOLUME_MESH, info=info): + PassiveSpacing(entities=[GhostCircularPlane(name="symmetric")], type="projected") - _replace_ghost_surfaces(params) # ensure that replacing ghost surfaces is successful - params_as_dict = params.model_dump(mode="json", exclude_none=True) + _replace_ghost_surfaces(params) # ensure that replacing ghost surfaces is successful + params_as_dict = params.model_dump(mode="json", exclude_none=True) - _, errors, _ = validate_model( - params_as_dict=params_as_dict, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level=VOLUME_MESH, - ) - assert errors is None + _, errors, _ = validate_model( + params_as_dict=params_as_dict, + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Geometry", + validation_level=VOLUME_MESH, + ) + assert errors is None def test_user_defined_farfield_ghost_symmetry_fails_without_explicit_domain_type_bad_bbox(): diff --git a/tests/simulation/translator/utils/TurbFlatPlate137x97_BoxTrip_generator.py b/tests/simulation/translator/utils/TurbFlatPlate137x97_BoxTrip_generator.py index fbe7bc098..62206d8a4 100644 --- a/tests/simulation/translator/utils/TurbFlatPlate137x97_BoxTrip_generator.py +++ b/tests/simulation/translator/utils/TurbFlatPlate137x97_BoxTrip_generator.py @@ -15,7 +15,7 @@ @pytest.fixture() def create_turb_flat_plate_box_trip_param(): - with units.flow360_unit_system: + with units.SI_unit_system: thermal_state = op_condition.ThermalState( temperature=300 * units.K, diff --git a/tests/simulation/user_code/core/test_compute_surface_integral_unit.py b/tests/simulation/user_code/core/test_compute_surface_integral_unit.py index 0a653b92e..e7b8c0dd1 100644 --- a/tests/simulation/user_code/core/test_compute_surface_integral_unit.py +++ b/tests/simulation/user_code/core/test_compute_surface_integral_unit.py @@ -22,6 +22,9 @@ def __init__(self, name, units): def __getitem__(self, key): return self.units[key] + def resolve(self): + return self + class MockParams: def __init__(self, unit_system_dict, unit_system_name="SI"): diff --git a/tests/test_results.py b/tests/test_results.py index 32dae30ce..a30db7de3 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -121,24 +121,49 @@ def test_actuator_disk_results(mock_id, mock_response, data_path): assert isinstance(results.actuator_disks.as_dict(), dict) assert isinstance(results.actuator_disks.as_numpy(), np.ndarray) - assert float(results.actuator_disks.values["Disk0_Power"][0].v) == 1451191686.9478528 + assert np.isclose( + float(results.actuator_disks.values["Disk0_Power"][0].v), + 1451191686.9478528, + rtol=1e-12, + atol=0, + ) assert str(results.actuator_disks.values["Disk0_Power"][0].units) == "kg*m**2/s**3" - assert float(results.actuator_disks.values["Disk0_Force"][0].v) == 106613080.32014923 + assert np.isclose( + float(results.actuator_disks.values["Disk0_Force"][0].v), + 106613080.32014923, + rtol=1e-12, + atol=0, + ) assert str(results.actuator_disks.values["Disk0_Force"][0].units) == "kg*m/s**2" - assert float(results.actuator_disks.values["Disk0_Moment"][0].v) == 1494767678.3286672 + assert np.isclose( + float(results.actuator_disks.values["Disk0_Moment"][0].v), + 1494767678.3286672, + rtol=1e-12, + atol=0, + ) assert str(results.actuator_disks.values["Disk0_Moment"][0].units) == "kg*m**2/s**2" # should be no change is calling again: results.actuator_disks.to_base("SI", params=params) - assert float(results.actuator_disks.values["Disk0_Power"][0].v) == 1451191686.9478528 + assert np.isclose( + float(results.actuator_disks.values["Disk0_Power"][0].v), + 1451191686.9478528, + rtol=1e-12, + atol=0, + ) assert str(results.actuator_disks.values["Disk0_Power"][0].units) == "kg*m**2/s**3" results.actuator_disks.to_base("Imperial", params=params) - assert float(results.actuator_disks.values["Disk0_Power"][0].v) == 34437301746.89787 + assert np.isclose( + float(results.actuator_disks.values["Disk0_Power"][0].v), + 34437301746.89787, + rtol=1e-12, + atol=0, + ) assert str(results.actuator_disks.values["Disk0_Power"][0].units) == "ft**2*lb/s**3" @@ -167,10 +192,20 @@ def test_bet_disk_results(mock_id, mock_response, data_path): assert isinstance(results.bet_forces.as_dict(), dict) assert isinstance(results.bet_forces.as_numpy(), np.ndarray) - assert float(results.bet_forces.values["Disk0_Force_x"][0].v) == -198185092.5822863 + assert np.isclose( + float(results.bet_forces.values["Disk0_Force_x"][0].v), + -198185092.5822863, + rtol=1e-12, + atol=0, + ) assert str(results.bet_forces.values["Disk0_Force_x"][0].units) == "kg*m/s**2" - assert float(results.bet_forces.values["Disk0_Moment_x"][0].v) == 23068914203.12496 + assert np.isclose( + float(results.bet_forces.values["Disk0_Moment_x"][0].v), + 23068914203.12496, + rtol=1e-12, + atol=0, + ) assert str(results.bet_forces.values["Disk0_Moment_x"][0].units) == "kg*m**2/s**2" @@ -194,10 +229,20 @@ def test_bet_disk_results_with_simulation_interface(mock_id, mock_response, data assert isinstance(results.bet_forces.as_dict(), dict) assert isinstance(results.bet_forces.as_numpy(), np.ndarray) - assert float(results.bet_forces.values["Disk0_Force_x"][0].v) == -198185092.5822863 + assert np.isclose( + float(results.bet_forces.values["Disk0_Force_x"][0].v), + -198185092.5822863, + rtol=1e-12, + atol=0, + ) assert str(results.bet_forces.values["Disk0_Force_x"][0].units) == "kg*m/s**2" - assert float(results.bet_forces.values["Disk0_Moment_x"][0].v) == 23068914203.12496 + assert np.isclose( + float(results.bet_forces.values["Disk0_Moment_x"][0].v), + 23068914203.12496, + rtol=1e-12, + atol=0, + ) assert str(results.bet_forces.values["Disk0_Moment_x"][0].units) == "kg*m**2/s**2" results.bet_forces_radial_distribution.load_from_local( diff --git a/tools/integrations/tests/_test_webui_workbench_integration.py b/tools/integrations/tests/_test_webui_workbench_integration.py index d8d383b2a..d34157aee 100644 --- a/tools/integrations/tests/_test_webui_workbench_integration.py +++ b/tools/integrations/tests/_test_webui_workbench_integration.py @@ -1,6 +1,8 @@ import json import os +import unyt as u + import flow360.v1 as fl from flow360.component.simulation.meshing_param.face_params import ( BoundaryLayer, @@ -23,7 +25,7 @@ SimulationParams, ) from flow360.component.simulation.time_stepping.time_stepping import Steady -from flow360.component.simulation.unit_system import SI_unit_system, u +from flow360.component.simulation.unit_system import SI_unit_system fl.UserConfig.set_profile("auto_test_1") fl.Env.dev.active() From cda388c51d26b50bc52b90713928ab98ff3ddbf8 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Mon, 23 Mar 2026 15:20:28 -0400 Subject: [PATCH 05/25] Updated lock --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5f0a63156..2d620f5af 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1452,14 +1452,14 @@ files = [ [[package]] name = "flow360-schema" -version = "0.1.12+feat.2ndstageunitmigrationfoll.ac7e8fd" +version = "0.1.13+feat.expression.migration.f178504" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.12+feat.2ndstageunitmigrationfoll.ac7e8fd-py3-none-any.whl", hash = "sha256:738b6dd9a185f0f131728149098a86e396b83c9ac76fe1e49683f649c34630b0"}, - {file = "flow360_schema-0.1.12+feat.2ndstageunitmigrationfoll.ac7e8fd.tar.gz", hash = "sha256:cf7578901c294f8913ce64b7aee13e121d6187c8f0bb247f4a39f0ca0726d185"}, + {file = "flow360_schema-0.1.13+feat.expression.migration.f178504-py3-none-any.whl", hash = "sha256:d65029673ebcd87d85247f541ef25a71de9e45cfbb1d32cad7351c1e7785df2b"}, + {file = "flow360_schema-0.1.13+feat.expression.migration.f178504.tar.gz", hash = "sha256:442148fc070d2ec2bf003eb0a6472003a07158b9d42afd315a4f25e17a73fd05"}, ] [package.dependencies] From d942eaaf396eab3b69bc92ae00056f238252f3e1 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:53:26 -0400 Subject: [PATCH 06/25] Expresssion migration (#1915) Co-authored-by: Claude Opus 4.6 (1M context) --- flow360/__init__.py | 17 +- flow360/component/results/results_utils.py | 2 +- .../simulation/blueprint/__init__.py | 18 - .../simulation/blueprint/core/__init__.py | 113 - .../simulation/blueprint/core/context.py | 295 --- .../blueprint/core/dependency_graph.py | 229 -- .../simulation/blueprint/core/expressions.py | 351 ---- .../simulation/blueprint/core/function.py | 38 - .../simulation/blueprint/core/generator.py | 347 --- .../simulation/blueprint/core/parser.py | 272 --- .../simulation/blueprint/core/resolver.py | 98 - .../simulation/blueprint/core/statements.py | 180 -- .../simulation/blueprint/core/types.py | 42 - .../simulation/blueprint/utils/__init__.py | 0 .../simulation/blueprint/utils/operators.py | 51 - .../simulation/framework/param_utils.py | 12 +- .../operating_condition.py | 6 +- .../simulation/outputs/output_entities.py | 16 +- .../component/simulation/outputs/outputs.py | 10 +- .../simulation/outputs/render_config.py | 16 +- .../run_control/stopping_criterion.py | 14 +- flow360/component/simulation/services.py | 119 +- .../component/simulation/simulation_params.py | 18 +- .../translator/solver_translator.py | 32 +- .../component/simulation/translator/utils.py | 4 +- .../simulation/user_code/core/context.py | 179 -- .../simulation/user_code/core/types.py | 1504 +------------ .../simulation/user_code/core/utils.py | 108 - .../user_code/functions/__init__.py | 0 .../simulation/user_code/functions/math.py | 414 ---- .../user_code/variables/__init__.py | 0 .../simulation/user_code/variables/control.py | 63 - .../user_code/variables/solution.py | 346 --- .../validation/validation_context.py | 9 +- .../validation/validation_output.py | 3 +- .../simulation/validation/validation_utils.py | 2 +- flow360/log.py | 21 + poetry.lock | 785 +++---- pyproject.toml | 4 +- .../outputs/test_output_entities.py | 6 +- .../test_output_at_final_pseudo_step_only.py | 2 +- .../params/test_validators_criterion.py | 6 +- .../params/test_validators_output.py | 7 +- .../params/test_validators_params.py | 6 +- tests/simulation/service/test_services_v2.py | 6 +- tests/simulation/test_expression_math.py | 1861 ----------------- tests/simulation/test_expressions.py | 1774 +--------------- tests/simulation/test_value_or_expression.py | 47 +- .../simulation/test_variable_context_skip.py | 5 +- .../translator/test_output_translation.py | 4 +- .../translator/test_solver_translator.py | 11 +- .../test_compute_surface_integral_unit.py | 72 +- 52 files changed, 667 insertions(+), 8878 deletions(-) delete mode 100644 flow360/component/simulation/blueprint/__init__.py delete mode 100644 flow360/component/simulation/blueprint/core/__init__.py delete mode 100644 flow360/component/simulation/blueprint/core/context.py delete mode 100644 flow360/component/simulation/blueprint/core/dependency_graph.py delete mode 100644 flow360/component/simulation/blueprint/core/expressions.py delete mode 100644 flow360/component/simulation/blueprint/core/function.py delete mode 100644 flow360/component/simulation/blueprint/core/generator.py delete mode 100644 flow360/component/simulation/blueprint/core/parser.py delete mode 100644 flow360/component/simulation/blueprint/core/resolver.py delete mode 100644 flow360/component/simulation/blueprint/core/statements.py delete mode 100644 flow360/component/simulation/blueprint/core/types.py delete mode 100644 flow360/component/simulation/blueprint/utils/__init__.py delete mode 100644 flow360/component/simulation/blueprint/utils/operators.py delete mode 100644 flow360/component/simulation/user_code/core/context.py delete mode 100644 flow360/component/simulation/user_code/core/utils.py delete mode 100644 flow360/component/simulation/user_code/functions/__init__.py delete mode 100644 flow360/component/simulation/user_code/functions/math.py delete mode 100644 flow360/component/simulation/user_code/variables/__init__.py delete mode 100644 flow360/component/simulation/user_code/variables/control.py delete mode 100644 flow360/component/simulation/user_code/variables/solution.py delete mode 100644 tests/simulation/test_expression_math.py diff --git a/flow360/__init__.py b/flow360/__init__.py index 550dff008..63b1e1ea7 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -2,6 +2,15 @@ This module is flow360 for simulation based models """ +from flow360_schema.framework.expression import ( + UserVariable, + get_user_variable, + remove_user_variable, + show_user_variables, +) +from flow360_schema.models.functions import math +from flow360_schema.models.variables import solution + from flow360.accounts_utils import Accounts from flow360.cli.api_set_func import configure_caller as configure from flow360.component.case import Case @@ -198,14 +207,6 @@ SI_unit_system, imperial_unit_system, ) -from flow360.component.simulation.user_code.core.types import ( - UserVariable, - get_user_variable, - remove_user_variable, - show_user_variables, -) -from flow360.component.simulation.user_code.functions import math -from flow360.component.simulation.user_code.variables import solution from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) diff --git a/flow360/component/results/results_utils.py b/flow360/component/results/results_utils.py index 47e6a832d..76d3bab7a 100644 --- a/flow360/component/results/results_utils.py +++ b/flow360/component/results/results_utils.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List, Tuple import numpy as np +from flow360_schema.framework.expression import Expression from flow360.component.results.base_results import ( _PHYSICAL_STEP, @@ -25,7 +26,6 @@ _CMz, ) from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.user_code.core.types import Expression from flow360.exceptions import Flow360ValueError from flow360.log import log diff --git a/flow360/component/simulation/blueprint/__init__.py b/flow360/component/simulation/blueprint/__init__.py deleted file mode 100644 index 30b3f33c0..000000000 --- a/flow360/component/simulation/blueprint/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Blueprint: Safe function serialization and visual programming integration.""" - -from flow360.component.simulation.blueprint.core.generator import model_to_function -from flow360.component.simulation.blueprint.core.parser import ( - expr_to_model, - function_to_model, -) - -from .core.function import FunctionNode -from .core.types import Evaluable - -__all__ = [ - "FunctionNode", - "Evaluable", - "function_to_model", - "model_to_function", - "expr_to_model", -] diff --git a/flow360/component/simulation/blueprint/core/__init__.py b/flow360/component/simulation/blueprint/core/__init__.py deleted file mode 100644 index 3ba06d6b9..000000000 --- a/flow360/component/simulation/blueprint/core/__init__.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Core blueprint functionality.""" - -from .context import EvaluationContext, ReturnValue -from .expressions import ( - BinOpNode, - CallModelNode, - ConstantNode, - ExpressionNode, - ExpressionNodeType, - ListCompNode, - ListNode, - NameNode, - RangeCallNode, - SubscriptNode, - TupleNode, - UnaryOpNode, -) -from .function import FunctionNode -from .generator import expr_to_code, model_to_function, stmt_to_code -from .parser import function_to_model -from .statements import ( - AssignNode, - AugAssignNode, - ForLoopNode, - IfElseNode, - ReturnNode, - StatementNode, - StatementNodeType, - TupleUnpackNode, -) -from .types import Evaluable - - -def _model_rebuild() -> None: - """Update forward references in the correct order.""" - namespace = { - # Expression types - "NameNode": NameNode, - "ConstantNode": ConstantNode, - "BinOpNode": BinOpNode, - "RangeCallNode": RangeCallNode, - "CallModelNode": CallModelNode, - "TupleNode": TupleNode, - "ListNode": ListNode, - "ListCompNode": ListCompNode, - "SubscriptNode": SubscriptNode, - "UnaryOpNode": UnaryOpNode, - "ExpressionNodeType": ExpressionNodeType, - # Statement types - "AssignNode": AssignNode, - "AugAssignNode": AugAssignNode, - "IfElseNode": IfElseNode, - "ForLoopNode": ForLoopNode, - "ReturnNode": ReturnNode, - "TupleUnpackNode": TupleUnpackNode, - "StatementNodeType": StatementNodeType, - # Function type - "FunctionNode": FunctionNode, - } - - # First update expression classes that only depend on ExpressionType - BinOpNode.model_rebuild(_types_namespace=namespace) - RangeCallNode.model_rebuild(_types_namespace=namespace) - CallModelNode.model_rebuild(_types_namespace=namespace) - TupleNode.model_rebuild(_types_namespace=namespace) - ListNode.model_rebuild(_types_namespace=namespace) - ListCompNode.model_rebuild(_types_namespace=namespace) - SubscriptNode.model_rebuild(_types_namespace=namespace) - - # Then update statement classes that depend on both types - AssignNode.model_rebuild(_types_namespace=namespace) - AugAssignNode.model_rebuild(_types_namespace=namespace) - IfElseNode.model_rebuild(_types_namespace=namespace) - ForLoopNode.model_rebuild(_types_namespace=namespace) - ReturnNode.model_rebuild(_types_namespace=namespace) - TupleUnpackNode.model_rebuild(_types_namespace=namespace) - - # Finally update Function class - FunctionNode.model_rebuild(_types_namespace=namespace) - - -# Update forward references -_model_rebuild() - - -__all__ = [ - "ExpressionNode", - "NameNode", - "ConstantNode", - "BinOpNode", - "RangeCallNode", - "CallModelNode", - "TupleNode", - "ListNode", - "ListCompNode", - "ExpressionNodeType", - "StatementNode", - "AssignNode", - "AugAssignNode", - "IfElseNode", - "ForLoopNode", - "ReturnNode", - "TupleUnpackNode", - "StatementNodeType", - "FunctionNode", - "EvaluationContext", - "ReturnValue", - "Evaluable", - "expr_to_code", - "stmt_to_code", - "model_to_function", - "function_to_model", -] diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py deleted file mode 100644 index d33430d0c..000000000 --- a/flow360/component/simulation/blueprint/core/context.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Evaluation context that contains references to known symbols""" - -import collections -from copy import deepcopy -from typing import Any, List, Optional, Tuple - -import pydantic as pd - -from flow360.component.simulation.blueprint.core.dependency_graph import DependencyGraph -from flow360.component.simulation.blueprint.core.resolver import CallableResolver -from flow360.log import log - - -class ReturnValue(Exception): - """ - Custom exception to signal a 'return' during the evaluation - of a function model. - """ - - def __init__(self, value: Any): - super().__init__("Function returned.") - self.value = value - - -def _levenshtein_distance(a: str, b: str) -> int: - """Compute the Levenshtein distance between two strings.""" - if len(a) < len(b): - return _levenshtein_distance(a=b, b=a) - if not b: - return len(a) - prev_row = list(range(len(b) + 1)) - for i, ca in enumerate(a, 1): - row = [i] - for j, cb in enumerate(b, 1): - insert = prev_row[j] + 1 - delete = row[j - 1] + 1 - replace = prev_row[j - 1] + (ca != cb) - row.append(min(insert, delete, replace)) - prev_row = row - return prev_row[-1] - - -def _find_closest_levenshtein(target: str, candidates: List[str]) -> Optional[Tuple[str, int]]: - """ - Return (best_match, distance) where `best_match` is the candidate - with the smallest edit distance to `target`. If `candidates` is empty, - returns None. - """ - if not candidates: - return None - - best = (None, float("inf")) - for candidate in candidates: - d = _levenshtein_distance(target, candidate) - if d < best[1]: - best = (candidate, d) - return best # type: ignore - - -class EvaluationContext: - """ - Manages variable scope and access during function evaluation. - - This class stores named values and optionally resolves names through a - `CallableResolver` when not already defined in the context. - """ - - __slots__ = ( - "_values", - "_data_models", - "_metadata", # Contains description of the variable - "_resolver", - "_aliases", - "_dependency_graph", - ) - - def __init__( - self, resolver: CallableResolver, initial_values: Optional[dict[str, Any]] = None - ) -> None: - """ - Initialize the evaluation context. - - Args: - resolver (CallableResolver): A resolver used to look up callable names - and constants if not explicitly defined. - initial_values (Optional[dict[str, Any]]): Initial variable values to populate - the context with. - """ - self._values = initial_values or {} - self._data_models = {} - self._metadata = {} - self._resolver = resolver - self._aliases: dict[str, str] = {} - self._dependency_graph = DependencyGraph() - - def get(self, name: str, resolve: bool = True) -> Any: - """ - Retrieve a value by name from the context. - - If the name is not explicitly defined and `resolve` is True, - attempt to resolve it using the resolver. - - Args: - name (str): The variable or callable name to retrieve. - resolve (bool): Whether to attempt to resolve the name if it's undefined. - - Returns: - Any: The corresponding value. - - Raises: - NameError: If the name is not found and cannot be resolved. - ValueError: If resolution is disabled and the name is undefined. - """ - if name not in self._values: - # Try loading from builtin callables/constants if possible - try: - if not resolve: - raise ValueError(f"{name} was not defined explicitly in the context") - val = self.resolve(name) - # If successful, store it so we don't need to import again - self._values[name] = val - except ValueError as err: - closest_name, distance = _find_closest_levenshtein( - name, list(self._values.keys()) + ["math.pi"] - ) - error_message = f"Name '{name}' is not defined." - if distance < 4: - # Else the recommended name might not make sense. - error_message += f" Did you mean: `{closest_name}`?" - raise NameError(error_message) from err - return self._values[name] - - def _get_all_variables_to_remove(self, start_name: str) -> set[str]: - # pylint: disable=protected-access - to_remove = {start_name} - queue = collections.deque([start_name]) - - while queue: - current_name = queue.popleft() - dependents_of_current = self._dependency_graph._graph.get(current_name, set()) - - for dep in dependents_of_current: - if dep not in to_remove: - to_remove.add(dep) - queue.append(dep) - return to_remove - - def remove(self, name: str, _is_recursive_call: bool = False) -> None: - """ - Remove the variable with the input name in the context. - The variables that depends on this variable are also removed recursively. - """ - # pylint: disable=protected-access - if name not in self._values: - raise NameError(f"There is no variable named '{name}'.") - if not _is_recursive_call: - all_affected_vars = self._get_all_variables_to_remove(name) - - log.info("--- Confirmation Required ---") - log.info("The following variables will be removed:") - for var in sorted(list(all_affected_vars)): # Sort for consistent display - log.info(f" - {var}") - - if len(all_affected_vars) > 1: - confirmation = ( - input( - f"Are you sure you want to remove '{name}' and " - f"its {len(all_affected_vars) - 1} dependent variable(s)? (yes/no): " - ) - .lower() - .strip() - ) - else: - confirmation = ( - input(f"Are you sure you want to remove '{name}'? (yes/no): ").lower().strip() - ) - - if confirmation not in ["yes", "y"]: - log.info("Operation cancelled. No variables were removed.") - return - log.info("--- Proceeding with removal ---") - - current_dependents = self._dependency_graph._graph.get(name, set()).copy() - - for dep in current_dependents: - self.remove(name=dep, _is_recursive_call=True) # Pass the flag - - self._dependency_graph.remove_variable(name=name) - self._values.pop(name) - self._metadata.pop(name, None) - log.info(f"Removed '{name}' from values.") - - def get_data_model(self, name: str) -> Optional[pd.BaseModel]: - """Get the Validation model for the given name.""" - if name not in self._data_models: - return None - return self._data_models[name] - - def set_alias(self, name, alias) -> None: - """ - Set alias used for code generation. - This is meant for non-user variables. - """ - self._aliases[name] = alias - - def get_alias(self, name) -> Optional[str]: - """ - Get alias used for code generation. - This is meant for non-user variables. - """ - return self._aliases.get(name) - - def set(self, name: str, value: Any, data_model: pd.BaseModel = None) -> None: - """ - Assign a value to a name in the context. - - Args: - name (str): The variable name to set. - value (Any): The value to assign. - data_model (BaseModel, optional): The type of the associate with this entry (for non-user variables) - """ - if name in self._values: - self._dependency_graph.update_expression(name, str(value)) - else: - self._dependency_graph.add_variable(name, str(value)) - - self._values[name] = value - - if data_model: - self._data_models[name] = data_model - - def resolve(self, name): - """ - Resolve a name using the provided resolver. - - Args: - name (str): The name to resolve. - - Returns: - Any: The resolved callable or constant. - - Raises: - ValueError: If the name cannot be resolved by the resolver. - """ - return self._resolver.get_allowed_callable(name) - - def can_evaluate(self, name) -> bool: - """ - Check if the name can be evaluated via the resolver. - - Args: - name (str): The name to check. - - Returns: - bool: True if the name is allowed and resolvable, False otherwise. - """ - return self._resolver.can_evaluate(name) - - def copy(self) -> "EvaluationContext": - """ - Create a copy of the current context. - - Returns: - EvaluationContext: A new context instance with the same resolver and a copy - of the current variable values. - """ - return deepcopy(self) - - def set_metadata(self, name: str, key: str, value: Any) -> None: - """ - Set the metadata for a variable. - """ - if name not in self._metadata: - self._metadata[name] = {} - - self._metadata[name][key] = value - - def get_metadata(self, name: str, key: str) -> Any: - """ - Get the metadata for a variable. - - Returns: - Any: The metadata value if it exists, otherwise None. - """ - return self._metadata.get(name, {}).get(key, None) - - @property - def user_variable_names(self): - """Get the set of user variables in the context.""" - return {name for name in self._values.keys() if "." not in name} - - @property - def registered_names(self): - """Show the registered names in the context.""" - return list(self._values.keys()) diff --git a/flow360/component/simulation/blueprint/core/dependency_graph.py b/flow360/component/simulation/blueprint/core/dependency_graph.py deleted file mode 100644 index 7b02b4163..000000000 --- a/flow360/component/simulation/blueprint/core/dependency_graph.py +++ /dev/null @@ -1,229 +0,0 @@ -""" -This module implements a dependency graph for user variables. -""" - -import ast -import copy -from collections import defaultdict, deque -from typing import Any, Dict, List, Optional, Set - -from pydantic import ValidationError -from pydantic_core import InitErrorDetails - - -class DependencyGraph: - """ - A dependency graph for variables. - """ - - __slots__ = ("_graph", "_deps") - - def __init__(self): - # adjacency list: key = variable u, value = set of variables v that depend on u - self._graph: Dict[str, Set[str]] = defaultdict(set) - # reverse dependency map: key = variable v, value = set of variables u that v depends on - self._deps: Dict[str, Set[str]] = defaultdict(set) - - @staticmethod - def _extract_deps(expression: str, all_names: Set[str]) -> Set[str]: - """ - Parse the expression into an AST and collect all Name nodes, - then filter them against the set of known variable names. - """ - # trailing semicolon breaks the AST parser - expression = expression.rstrip("; \n\t") - try: - tree = ast.parse(expression, mode="eval") - except SyntaxError: - return set() - found: Set[str] = set() - for node in ast.walk(tree): - if isinstance(node, ast.Name): - found.add(node.id) - return found & all_names - - def _check_for_cycle(self) -> None: - """ - Use Kahn's algorithm to detect cycles; if any remain unprocessed, - raise ValidationError with details on the cycle. - """ - indegree = {name: len(self._deps[name]) for name in self._deps} - for name in self._graph: - indegree.setdefault(name, 0) - - queue = deque([n for n, deg in indegree.items() if deg == 0]) - processed = set(queue) - - while queue: - u = queue.popleft() - for v in self._graph.get(u, ()): - indegree[v] -= 1 - if indegree[v] == 0: - processed.add(v) - queue.append(v) - - if len(processed) != len(indegree): - cycle_nodes = set(indegree) - processed - details = InitErrorDetails( - type="value_error", - ctx={"error": f"Circular dependency detected among: {sorted(cycle_nodes)}"}, - ) - raise ValidationError.from_exception_data("Variable value error", [details]) - - def load_from_list(self, vars_list: List[Dict[str, Any]]) -> None: - """ - Load variables from a list of dicts, clear existing graph, build dependencies, - and restore on error. - - Expected schema of the variable list: - ``` JSON - [ - { - "name": str, - "value": str - } - ] - ``` - """ - # backup state - old_graph = copy.deepcopy(self._graph) - old_deps = copy.deepcopy(self._deps) - try: - self._graph.clear() - self._deps.clear() - names = {item["name"] for item in vars_list} - for name in names: - # pylint: disable=pointless-statement - self._graph[name] - self._deps[name] - - for item in vars_list: - name = item["name"] - expr = item["value"] - deps = self._extract_deps(expr, names) - for dep in deps: - if dep not in names: - raise ValueError( - f"Expression for {name!r} references unknown variable {dep!r}" - ) - self._graph[dep].add(name) - self._deps[name].add(dep) - - self._check_for_cycle() - except Exception: - # restore old state on any error - self._graph = old_graph - self._deps = old_deps - raise - - def add_variable(self, name: str, expression: Optional[str] = None) -> None: - """ - Add or overwrite a variable. Restores previous state on cycle. - """ - # backup state - old_graph = copy.deepcopy(self._graph) - old_deps = copy.deepcopy(self._deps) - try: - # clear existing edges if overwrite - if name in self._graph: - for dep in self._deps[name]: - self._graph[dep].discard(name) - self._deps[name].clear() - for dependents in self._graph.values(): - dependents.discard(name) - else: - self._graph[name] = set() - self._deps[name] = set() - - if expression: - deps = self._extract_deps(expression, set(self._graph.keys())) - for dep in deps: - if dep not in self._graph: - raise ValueError( - f"Expression for {name!r} references unknown variable {dep!r}" - ) - self._graph[dep].add(name) - self._deps[name].add(dep) - - self._check_for_cycle() - except Exception: - # restore old state - self._graph = old_graph - self._deps = old_deps - raise - - def remove_variable(self, name: str) -> None: - """ - Remove a variable and all its edges. - """ - if name not in self._graph: - raise KeyError(f"Variable {name!r} does not exist") - - for dep in self._deps[name]: - self._graph[dep].discard(name) - del self._deps[name] - - del self._graph[name] - for dependents in self._graph.values(): - dependents.discard(name) - - def update_expression(self, name: str, expression: Optional[str]) -> None: - """ - Update expression for an existing variable; restores previous state on cycle. - """ - if name not in self._graph: - raise KeyError(f"Variable {name!r} does not exist") - - # backup state - old_graph = copy.deepcopy(self._graph) - old_deps = copy.deepcopy(self._deps) - try: - # clear old deps - for dep in self._deps[name]: - self._graph[dep].discard(name) - self._deps[name].clear() - - if expression: - deps = self._extract_deps(expression, set(self._graph.keys())) - for dep in deps: - if dep not in self._graph: - raise ValueError( - f"Expression for {name!r} references unknown variable {dep!r}" - ) - self._graph[dep].add(name) - self._deps[name].add(dep) - - self._check_for_cycle() - except Exception: - # restore on error - self._graph = old_graph - self._deps = old_deps - raise - - def topology_sort(self) -> List[str]: - """ - Return names in topological order; raises ValidationError on cycle. - """ - indegree = {n: len(self._deps[n]) for n in self._deps} - for n in self._graph: - indegree.setdefault(n, 0) - - queue = deque(n for n, d in indegree.items() if d == 0) - order: List[str] = [] - - while queue: - u = queue.popleft() - order.append(u) - for v in self._graph.get(u, ()): # type: ignore - indegree[v] -= 1 - if indegree[v] == 0: - queue.append(v) - - if len(order) != len(indegree): - cycle_nodes = set(indegree) - set(order) - details = InitErrorDetails( - type="value_error", - ctx={"error": f"Circular dependency detected among: {sorted(cycle_nodes)}"}, - ) - raise ValidationError.from_exception_data("Variable value error", [details]) - return order diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py deleted file mode 100644 index d20f25727..000000000 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ /dev/null @@ -1,351 +0,0 @@ -"""Data models and evaluator functions for rvalue expression elements""" - -import abc -from typing import Annotated, Any, Literal, Union - -import pydantic as pd - -from ..utils.operators import BINARY_OPERATORS, UNARY_OPERATORS -from .context import EvaluationContext -from .types import Evaluable - -ExpressionNodeType = Annotated[ - # pylint: disable=duplicate-code - Union[ - "NameNode", - "ConstantNode", - "BinOpNode", - "RangeCallNode", - "CallModelNode", - "TupleNode", - "ListNode", - "ListCompNode", - "SubscriptNode", - "UnaryOpNode", - ], - pd.Field(discriminator="type"), -] - - -class ExpressionNode(pd.BaseModel, Evaluable, metaclass=abc.ABCMeta): - """ - Base class for expressions (like `x > 3`, `range(n)`, etc.). - - Subclasses must implement the `evaluate` and `used_names` methods - to support context-based evaluation and variable usage introspection. - """ - - def used_names(self) -> set[str]: - """ - Return a set of variable names used by the expression. - - Returns: - set[str]: A set of strings representing variable names used in the expression. - """ - raise NotImplementedError - - -class NameNode(ExpressionNode): - """ - Expression representing a name qualifier - """ - - type: Literal["Name"] = "Name" - id: str - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> Any: - if raise_on_non_evaluable and not context.can_evaluate(self.id): - raise ValueError(f"Name '{self.id}' cannot be evaluated at client runtime") - if not force_evaluate and not context.can_evaluate(self.id): - data_model = context.get_data_model(self.id) - if data_model: - return data_model.model_validate({"name": self.id, "value": context.get(self.id)}) - raise ValueError("Partially evaluable symbols need to possess a type annotation.") - value = context.get(self.id) - # Recursively evaluate if the returned value is evaluable - if isinstance(value, Evaluable): - value = value.evaluate(context, raise_on_non_evaluable, force_evaluate) - return value - - def used_names(self) -> set[str]: - return {self.id} - - -class ConstantNode(ExpressionNode): - """ - Expression representing a constant numeric value - """ - - type: Literal["Constant"] = "Constant" - value: Any - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> Any: # noqa: ARG002 - return self.value - - def used_names(self) -> set[str]: - return set() - - -class UnaryOpNode(ExpressionNode): - """ - Expression representing a unary operation - """ - - type: Literal["UnaryOp"] = "UnaryOp" - op: str - operand: "ExpressionNodeType" - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> Any: - operand_val = self.operand.evaluate(context, raise_on_non_evaluable, force_evaluate) - - if self.op not in UNARY_OPERATORS: - raise ValueError(f"Unsupported operator: {self.op}") - - return UNARY_OPERATORS[self.op](operand_val) - - def used_names(self) -> set[str]: - return self.operand.used_names() - - -class BinOpNode(ExpressionNode): - """ - Expression representing a binary operation - """ - - type: Literal["BinOp"] = "BinOp" - left: "ExpressionNodeType" - op: str - right: "ExpressionNodeType" - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> Any: - left_val = self.left.evaluate(context, raise_on_non_evaluable, force_evaluate) - right_val = self.right.evaluate(context, raise_on_non_evaluable, force_evaluate) - - if self.op not in BINARY_OPERATORS: - raise ValueError(f"Unsupported operator: {self.op}") - - return BINARY_OPERATORS[self.op](left_val, right_val) - - def used_names(self) -> set[str]: - left = self.left.used_names() - right = self.right.used_names() - return left.union(right) - - -class SubscriptNode(ExpressionNode): - """ - Expression representing an iterable object subscript - """ - - type: Literal["Subscript"] = "Subscript" - value: "ExpressionNodeType" - slice: "ExpressionNodeType" # No proper slicing for now, only constants.. - ctx: str # Only load context - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> Any: - value = self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) - item = self.slice.evaluate(context, raise_on_non_evaluable, force_evaluate) - if self.ctx == "Load": - if isinstance(item, float): - item = int(item) - return value[item] - if self.ctx == "Store": - raise NotImplementedError("Subscripted writes are not supported yet") - - raise ValueError(f"Invalid subscript context {self.ctx}") - - def used_names(self) -> set[str]: - value = self.value.used_names() - item = self.slice.used_names() - return value.union(item) - - -class RangeCallNode(ExpressionNode): - """ - Model for something like range(). - """ - - type: Literal["RangeCall"] = "RangeCall" - arg: "ExpressionNodeType" - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> range: - return range(self.arg.evaluate(context, raise_on_non_evaluable, force_evaluate)) - - def used_names(self) -> set[str]: - return self.arg.used_names() - - -class CallModelNode(ExpressionNode): - """Represents a function or method call expression. - - This class handles both direct function calls and method calls through a fully qualified name. - For example: - - Simple function: "sum" - - Method call: "np.array" - - Nested attribute: "td.GridSpec.auto" - """ - - type: Literal["CallModel"] = "CallModel" - func_qualname: str - args: list["ExpressionNodeType"] = [] - kwargs: dict[str, "ExpressionNodeType"] = {} - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> Any: - try: - # Split into parts for attribute traversal - parts = self.func_qualname.split(".") - - if len(parts) == 1: - # Direct function call - func = context.resolve(parts[0]) - else: - # Method or nested attribute call - base = context.resolve(parts[0]) - - # Traverse the attribute chain - for part in parts[1:-1]: - base = getattr(base, part) - - # Get the final callable - func = getattr(base, parts[-1]) - - # Evaluate arguments - args = [ - arg.evaluate(context, raise_on_non_evaluable, force_evaluate) for arg in self.args - ] - kwargs = { - k: v.evaluate(context, raise_on_non_evaluable, force_evaluate) - for k, v in self.kwargs.items() - } - - return func(*args, **kwargs) - - except AttributeError as e: - raise ValueError( - f"Invalid attribute in call chain '{self.func_qualname}': {str(e)}" - ) from e - except Exception as e: - raise ValueError(f"Error evaluating call to '{self.func_qualname}': {str(e)}") from e - - def used_names(self) -> set[str]: - names = set() - - for arg in self.args: - names = names.union(arg.used_names()) - - for _, arg in self.kwargs.items(): - names = names.union(arg.used_names()) - - return names - - -class TupleNode(ExpressionNode): - """Model for tuple expressions.""" - - type: Literal["Tuple"] = "Tuple" - elements: list["ExpressionNodeType"] - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> tuple: - return tuple( - elem.evaluate(context, raise_on_non_evaluable, force_evaluate) for elem in self.elements - ) - - def used_names(self) -> set[str]: - return self.arg.used_names() - - -class ListNode(ExpressionNode): - """Model for list expressions.""" - - type: Literal["List"] = "List" - elements: list["ExpressionNodeType"] - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> list: - return [ - elem.evaluate(context, raise_on_non_evaluable, force_evaluate) for elem in self.elements - ] - - def used_names(self) -> set[str]: - names = set() - - for arg in self.elements: - names = names.union(arg.used_names()) - - return names - - -class ListCompNode(ExpressionNode): - """Model for list comprehension expressions.""" - - type: Literal["ListComp"] = "ListComp" - element: "ExpressionNodeType" # The expression to evaluate for each item - target: str # The loop variable name - iter: "ExpressionNodeType" # The iterable expression - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> list: - result = [] - iterable = self.iter.evaluate(context, raise_on_non_evaluable, force_evaluate) - for item in iterable: - # Create a new context for each iteration with the target variable - iter_context = context.copy() - iter_context.set(self.target, item) - result.append( - self.element.evaluate(iter_context, raise_on_non_evaluable, force_evaluate) - ) - return result - - def used_names(self) -> set[str]: - element = self.element.used_names() - iterable = self.iter.used_names() - - return element.union(iterable) diff --git a/flow360/component/simulation/blueprint/core/function.py b/flow360/component/simulation/blueprint/core/function.py deleted file mode 100644 index 560ba9873..000000000 --- a/flow360/component/simulation/blueprint/core/function.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Data models and evaluator functions for full Python function definitions""" - -from typing import Any - -import pydantic as pd - -from .context import EvaluationContext, ReturnValue -from .statements import StatementNodeType - - -class FunctionNode(pd.BaseModel): - """ - Represents an entire function: - def name(arg1, arg2, ...): - - """ - - name: str - args: list[str] - defaults: dict[str, Any] - body: list[StatementNodeType] - - def __call__(self, context: EvaluationContext, *call_args: Any) -> Any: - # Add default values - for arg_name, default_val in self.defaults.items(): - self.context.set(arg_name, default_val) - - # Add call arguments - for arg_name, arg_val in zip(self.args, call_args, strict=False): - self.context.set(arg_name, arg_val) - - try: - for stmt in self.body: - stmt.evaluate(self.context) - except ReturnValue as rv: - return rv.value - - return None diff --git a/flow360/component/simulation/blueprint/core/generator.py b/flow360/component/simulation/blueprint/core/generator.py deleted file mode 100644 index d26ec53cb..000000000 --- a/flow360/component/simulation/blueprint/core/generator.py +++ /dev/null @@ -1,347 +0,0 @@ -"""Code generator for the blueprint module, supports python and C++ syntax for now""" - -# pylint: disable=too-many-return-statements - -from typing import Any, Callable, Optional - -from flow360.component.simulation.blueprint.core.expressions import ( - BinOpNode, - CallModelNode, - ConstantNode, - ListCompNode, - ListNode, - NameNode, - RangeCallNode, - SubscriptNode, - TupleNode, - UnaryOpNode, -) -from flow360.component.simulation.blueprint.core.function import FunctionNode -from flow360.component.simulation.blueprint.core.statements import ( - AssignNode, - AugAssignNode, - ForLoopNode, - IfElseNode, - ReturnNode, - TupleUnpackNode, -) -from flow360.component.simulation.blueprint.core.types import TargetSyntax -from flow360.component.simulation.blueprint.utils.operators import ( - BINARY_OPERATORS, - UNARY_OPERATORS, -) - - -def _indent(code: str, level: int = 1) -> str: - """Add indentation to each line of code.""" - spaces = " " * level - return "\n".join(spaces + line if line else line for line in code.split("\n")) - - -def _empty(syntax: TargetSyntax) -> str: - if syntax == TargetSyntax.PYTHON: - return "None" - if syntax == TargetSyntax.CPP: - return "nullptr" - - raise ValueError( - f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" - ) - - -def _name(expr: NameNode, name_translator: Optional[Callable[[str], str]]) -> str: - if name_translator: - return name_translator(expr.id) - return expr.id - - -def _constant(expr: ConstantNode) -> str: - if isinstance(expr.value, str): - return f"'{expr.value}'" - return str(expr.value) - - -def _unary_op( - expr: UnaryOpNode, syntax: TargetSyntax, name_translator: Optional[Callable[[str], str]] -) -> str: - op_info = UNARY_OPERATORS[expr.op] - - arg = expr_to_code(expr.operand, syntax, name_translator) - - return f"{op_info.symbol}{arg}" - - -def _binary_op( - expr: BinOpNode, syntax: TargetSyntax, name_translator: Optional[Callable[[str], str]] -) -> str: - left = expr_to_code(expr.left, syntax, name_translator) - right = expr_to_code(expr.right, syntax, name_translator) - - if syntax == TargetSyntax.CPP: - # Special case handling for operators not directly supported in CPP syntax, requires #include - if expr.op == "FloorDiv": - return f"floor({left} / {right})" - if expr.op == "Pow": - return f"pow({left}, {right})" - if expr.op == "Is": - return f"&{left} == &{right}" - - op_info = BINARY_OPERATORS[expr.op] - return f"({left} {op_info.symbol} {right})" - - -def _range_call( - expr: RangeCallNode, syntax: TargetSyntax, name_translator: Optional[Callable[[str], str]] -) -> str: - if syntax == TargetSyntax.PYTHON: - arg = expr_to_code(expr.arg, syntax, name_translator) - return f"range({arg})" - - raise ValueError("Range calls are only supported for Python target syntax") - - -def _call_model( - expr: CallModelNode, syntax: TargetSyntax, name_translator: Optional[Callable[[str], str]] -) -> str: - if syntax == TargetSyntax.PYTHON: - args = [] - for arg in expr.args: - val_str = expr_to_code(arg, syntax, name_translator) - args.append(val_str) - args_str = ", ".join(args) - kwargs_parts = [] - for k, v in expr.kwargs.items(): - if v is None: - continue - val_str = expr_to_code(v, syntax, name_translator) - if not val_str or val_str.isspace(): - continue - kwargs_parts.append(f"{k}={val_str}") - - kwargs_str = ", ".join(kwargs_parts) - all_args = ", ".join(x for x in [args_str, kwargs_str] if x) - return f"{expr.func_qualname}({all_args})" - if syntax == TargetSyntax.CPP: - args = [] - for arg in expr.args: - val_str = expr_to_code(arg, syntax, name_translator) - args.append(val_str) - args_str = ", ".join(args) - if expr.kwargs: - raise ValueError("Named arguments are not supported in C++ syntax") - return f"{name_translator(expr.func_qualname)}({args_str})" - - raise ValueError( - f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" - ) - - -def _tuple( - expr: TupleNode, syntax: TargetSyntax, name_translator: Optional[Callable[[str], str]] -) -> str: - elements = [expr_to_code(e, syntax, name_translator) for e in expr.elements] - - if syntax == TargetSyntax.PYTHON: - if len(expr.elements) == 0: - return "()" - if len(expr.elements) == 1: - return f"({elements[0]},)" - return f"({', '.join(elements)})" - if syntax == TargetSyntax.CPP: - if len(expr.elements) == 0: - raise TypeError("Zero-length tuple is found in expression.") - return f"std::vector({{{', '.join(elements)}}})" - - raise ValueError( - f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" - ) - - -def _list( - expr: ListNode, syntax: TargetSyntax, name_translator: Optional[Callable[[str], str]] -) -> str: - elements = [expr_to_code(e, syntax, name_translator) for e in expr.elements] - - if syntax == TargetSyntax.PYTHON: - if len(expr.elements) == 0: - return "[]" - elements_str = ", ".join(elements) - return f"[{elements_str}]" - if syntax == TargetSyntax.CPP: - if len(expr.elements) == 0: - raise TypeError("Zero-length list is found in expression.") - - return f"std::vector({{{', '.join(elements)}}})" - - raise ValueError( - f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" - ) - - -def _list_comp( - expr: ListCompNode, syntax: TargetSyntax, name_translator: Optional[Callable[[str], str]] -) -> str: - if syntax == TargetSyntax.PYTHON: - element = expr_to_code(expr.element, syntax, name_translator) - target = expr_to_code(expr.target, syntax, name_translator) - iterator = expr_to_code(expr.iter, syntax, name_translator) - - return f"[{element} for {target} in {iterator}]" - - raise ValueError("List comprehensions are only supported for Python target syntax") - - -def _subscript( - expr: SubscriptNode, syntax: TargetSyntax, name_translator: Optional[Callable[[str], str]] -) -> str: # pylint:disable=unused-argument - # Generate code for the value and the index recursively. - base = expr_to_code(expr.value, syntax, name_translator) - index = expr_to_code(expr.slice, syntax, name_translator) - - # Push the subscript into the right-hand side of a multiplication - # to generate valid scalar*vector component access like: ((scalar * vector[index])). - if syntax == TargetSyntax.CPP and isinstance(expr.value, BinOpNode): - op = expr.value.op - if op == "Mult": - left = expr_to_code(expr.value.left, syntax, name_translator) - right = expr_to_code(expr.value.right, syntax, name_translator) - symbol = BINARY_OPERATORS[op].symbol - # Avoid redundant parentheses for simple names - if isinstance(expr.value.right, NameNode): - right_indexed = f"{right}[{index}]" - else: - right_indexed = f"({right})[{index}]" - return f"(({left} {symbol} {right_indexed}))" - - # Parenthesize non-trivial bases to preserve precedence, e.g., (a * b)[0]. - if not isinstance(expr.value, NameNode): - base = f"({base})" - - return f"{base}[{index}]" - - -def expr_to_code( - expr: Any, - syntax: TargetSyntax = TargetSyntax.PYTHON, - name_translator: Optional[Callable[[str], str]] = None, -) -> str: - """Convert an expression model back to source code.""" - if expr is None: - return _empty(syntax) - - # Names and constants are language-agnostic (apart from symbol remaps) - if isinstance(expr, NameNode): - return _name(expr, name_translator) - - if isinstance(expr, ConstantNode): - return _constant(expr) - - if isinstance(expr, UnaryOpNode): - return _unary_op(expr, syntax, name_translator) - - if isinstance(expr, BinOpNode): - return _binary_op(expr, syntax, name_translator) - - if isinstance(expr, RangeCallNode): - return _range_call(expr, syntax, name_translator) - - if isinstance(expr, CallModelNode): - return _call_model(expr, syntax, name_translator) - - if isinstance(expr, TupleNode): - return _tuple(expr, syntax, name_translator) - - if isinstance(expr, ListNode): - return _list(expr, syntax, name_translator) - - if isinstance(expr, ListCompNode): - return _list_comp(expr, syntax, name_translator) - - if isinstance(expr, SubscriptNode): - return _subscript(expr, syntax, name_translator) - - raise ValueError(f"Unsupported expression type: {type(expr)}") - - -def stmt_to_code( - stmt: Any, - syntax: TargetSyntax = TargetSyntax.PYTHON, - remap: Optional[dict[str, str]] = None, -) -> str: - """Convert a statement model back to source code.""" - if syntax == TargetSyntax.PYTHON: - if isinstance(stmt, AssignNode): - if stmt.target == "_": # Expression statement - return expr_to_code(stmt.value) - return f"{stmt.target} = {expr_to_code(stmt.value, syntax, remap)}" - - if isinstance(stmt, AugAssignNode): - op_map = { - "Add": "+=", - "Sub": "-=", - "Mult": "*=", - "Div": "/=", - } - op_str = op_map.get(stmt.op, f"{stmt.op}=") - return f"{stmt.target} {op_str} {expr_to_code(stmt.value, syntax, remap)}" - - if isinstance(stmt, IfElseNode): - code = [f"if {expr_to_code(stmt.condition)}:"] - code.append(_indent("\n".join(stmt_to_code(s, syntax, remap) for s in stmt.body))) - if stmt.orelse: - code.append("else:") - code.append(_indent("\n".join(stmt_to_code(s, syntax, remap) for s in stmt.orelse))) - return "\n".join(code) - - if isinstance(stmt, ForLoopNode): - code = [f"for {stmt.target} in {expr_to_code(stmt.iter)}:"] - code.append(_indent("\n".join(stmt_to_code(s, syntax, remap) for s in stmt.body))) - return "\n".join(code) - - if isinstance(stmt, ReturnNode): - return f"return {expr_to_code(stmt.value, syntax, remap)}" - - if isinstance(stmt, TupleUnpackNode): - targets = ", ".join(stmt.targets) - if len(stmt.values) == 1: - # Single expression that evaluates to a tuple - return f"{targets} = {expr_to_code(stmt.values[0], syntax, remap)}" - # Multiple expressions - values = ", ".join(expr_to_code(v, syntax, remap) for v in stmt.values) - return f"{targets} = {values}" - - raise ValueError(f"Unsupported statement type: {type(stmt)}") - - raise NotImplementedError("Statement translation is not available for other syntax types yet") - - -def model_to_function( - func: FunctionNode, - syntax: TargetSyntax = TargetSyntax.PYTHON, - remap: Optional[dict[str, str]] = None, -) -> str: - """Convert a Function model back to source code.""" - if syntax == TargetSyntax.PYTHON: - args_with_defaults = [] - for arg in func.args: - if arg in func.defaults: - default_val = func.defaults[arg] - if isinstance(default_val, (int, float, str, bool)): - args_with_defaults.append(f"{arg}={default_val}") - else: - args_with_defaults.append(f"{arg}={expr_to_code(default_val, syntax, remap)}") - else: - args_with_defaults.append(arg) - - signature = f"def {func.name}({', '.join(args_with_defaults)}):" - - # Convert the function body - body_lines = [] - for stmt in func.body: - line = stmt_to_code(stmt) - body_lines.append(line) - - body = "\n".join(body_lines) if body_lines else "pass" - return f"{signature}\n{_indent(body)}" - - raise NotImplementedError("Function translation is not available for other syntax types yet") diff --git a/flow360/component/simulation/blueprint/core/parser.py b/flow360/component/simulation/blueprint/core/parser.py deleted file mode 100644 index 61bb99fc3..000000000 --- a/flow360/component/simulation/blueprint/core/parser.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Python code parser using the AST module""" - -# pylint: disable=too-many-return-statements, too-many-branches - -import ast -import inspect -from collections.abc import Callable -from typing import Any, Union - -from flow360.component.simulation.blueprint.core.context import EvaluationContext -from flow360.component.simulation.blueprint.core.expressions import ( - BinOpNode, - CallModelNode, - ConstantNode, - ExpressionNode, - ListCompNode, -) -from flow360.component.simulation.blueprint.core.expressions import ListNode as ListExpr -from flow360.component.simulation.blueprint.core.expressions import ( - NameNode, - RangeCallNode, - SubscriptNode, - TupleNode, - UnaryOpNode, -) -from flow360.component.simulation.blueprint.core.function import FunctionNode -from flow360.component.simulation.blueprint.core.statements import ( - AssignNode, - AugAssignNode, - ForLoopNode, - IfElseNode, - ReturnNode, - TupleUnpackNode, -) - - -def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: - """Parse a Python AST expression into our intermediate representation.""" - if isinstance(node, ast.Name): - return NameNode(id=node.id) - - if isinstance(node, ast.Constant): - if hasattr(node, "value"): - return ConstantNode(value=node.value) - return ConstantNode(value=node.s) - - if isinstance(node, ast.Attribute): - # Handle attribute access (e.g., td.inf) - parts = [] - current = node - while isinstance(current, ast.Attribute): - parts.append(current.attr) - current = current.value - if isinstance(current, ast.Name): - parts.append(current.id) - # Create a Name node with the full qualified name - return NameNode(id=".".join(reversed(parts))) - raise ValueError(f"Unsupported attribute access: {ast.dump(node)}") - - if isinstance(node, ast.UnaryOp): - return UnaryOpNode(op=type(node.op).__name__, operand=parse_expr(node.operand, ctx)) - - if isinstance(node, ast.BinOp): - return BinOpNode( - op=type(node.op).__name__, - left=parse_expr(node.left, ctx), - right=parse_expr(node.right, ctx), - ) - - if isinstance(node, ast.Compare): - if len(node.ops) > 1 or len(node.comparators) > 1: - raise ValueError("Only single comparisons are supported") - return BinOpNode( - op=type(node.ops[0]).__name__, - left=parse_expr(node.left, ctx), - right=parse_expr(node.comparators[0], ctx), - ) - - if isinstance(node, ast.Subscript): - return SubscriptNode( - value=parse_expr(node.value, ctx), - slice=parse_expr(node.slice, ctx), - ctx=type(node.ctx).__name__, - ) - - if isinstance(node, ast.Call): - if isinstance(node.func, ast.Name) and node.func.id == "range" and len(node.args) == 1: - return RangeCallNode(arg=parse_expr(node.args[0], ctx)) - - # Build the full qualified name for the function - if isinstance(node.func, ast.Name): - func_name = node.func.id - elif isinstance(node.func, ast.Attribute): - # Handle nested attributes (e.g., td.GridSpec.auto) - parts = [] - current = node.func - while isinstance(current, ast.Attribute): - parts.append(current.attr) - current = current.value - if isinstance(current, ast.Name): - parts.append(current.id) - func_name = ".".join(reversed(parts)) - else: - raise ValueError(f"Unsupported function call: {ast.dump(node)}") - else: - raise ValueError(f"Unsupported function call: {ast.dump(node)}") - - # Parse arguments - args = [parse_expr(arg, ctx) for arg in node.args] - kwargs = { - kw.arg: parse_expr(kw.value, ctx) - for kw in node.keywords - if kw.arg is not None and kw.value is not None # Ensure value is not None - } - - return CallModelNode( - func_qualname=func_name, - args=args, - kwargs=kwargs, - ) - - if isinstance(node, ast.Tuple): - return TupleNode(elements=[parse_expr(elt, ctx) for elt in node.elts]) - - if isinstance(node, ast.List): - return ListExpr(elements=[parse_expr(elt, ctx) for elt in node.elts]) - - if isinstance(node, ast.ListComp): - if len(node.generators) != 1: - raise ValueError("Only single-generator list comprehensions are supported") - gen = node.generators[0] - if not isinstance(gen.target, ast.Name): - raise ValueError("Only simple targets in list comprehensions are supported") - if gen.ifs: - raise ValueError("If conditions in list comprehensions are not supported") - return ListCompNode( - element=parse_expr(node.elt, ctx), - target=gen.target.id, - iter=parse_expr(gen.iter, ctx), - ) - - raise ValueError(f"Unsupported expression type: {type(node)}") - - -def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: - """Parse a Python AST statement into our intermediate representation.""" - if isinstance(node, ast.Assign): - if len(node.targets) > 1: - raise ValueError("Multiple assignment targets not supported") - target = node.targets[0] - - if isinstance(target, ast.Name): - return AssignNode(target=target.id, value=parse_expr(node.value, ctx)) - if isinstance(target, ast.Tuple): - if not all(isinstance(elt, ast.Name) for elt in target.elts): - raise ValueError("Only simple names supported in tuple unpacking") - targets = [elt.id for elt in target.elts] - if isinstance(node.value, ast.Tuple): - values = [parse_expr(val, ctx) for val in node.value.elts] - return TupleUnpackNode(targets=targets, values=values) - return TupleUnpackNode(targets=targets, values=[parse_expr(node.value, ctx)]) - - raise ValueError(f"Unsupported assignment target: {type(target)}") - - if isinstance(node, ast.AugAssign): - if not isinstance(node.target, ast.Name): - raise ValueError("Only simple names supported in augmented assignment") - return AugAssignNode( - target=node.target.id, - op=type(node.op).__name__, - value=parse_expr(node.value, ctx), - ) - - if isinstance(node, ast.Expr): - # For expression statements, we use "_" as a dummy target - return AssignNode(target="_", value=parse_expr(node.value, ctx)) - - if isinstance(node, ast.If): - return IfElseNode( - condition=parse_expr(node.test, ctx), - body=[parse_stmt(stmt, ctx) for stmt in node.body], - orelse=[parse_stmt(stmt, ctx) for stmt in node.orelse] if node.orelse else [], - ) - - if isinstance(node, ast.For): - if not isinstance(node.target, ast.Name): - raise ValueError("Only simple names supported as loop targets") - return ForLoopNode( - target=node.target.id, - iter=parse_expr(node.iter, ctx), - body=[parse_stmt(stmt, ctx) for stmt in node.body], - ) - - if isinstance(node, ast.Return): - if node.value is None: - raise ValueError("Return statements must have a value") - return ReturnNode(value=parse_expr(node.value, ctx)) - - raise ValueError(f"Unsupported statement type: {type(node)}") - - -def function_to_model( - source: Union[str, Callable[..., Any]], - ctx: EvaluationContext, -) -> FunctionNode: - """Parse a Python function definition into our intermediate representation. - - Args: - source: Either a function object or a string containing the function definition - ctx: Evaluation context - """ - - # Convert function object to source string if needed - if callable(source) and not isinstance(source, str): - source = inspect.getsource(source) - - # Parse the source code into an AST - tree = ast.parse(source) - - # We expect a single function definition - if ( - not isinstance(tree, ast.Module) - or len(tree.body) != 1 - or not isinstance(tree.body[0], ast.FunctionDef) - ): - raise ValueError("Expected a single function definition") - - func_def = tree.body[0] - - # Extract function name and arguments - name = func_def.name - args = [arg.arg for arg in func_def.args.args] - defaults: dict[str, Any] = {} - - # Handle default values for arguments - default_offset = len(func_def.args.args) - len(func_def.args.defaults) - for i, default in enumerate(func_def.args.defaults): - arg_name = func_def.args.args[i + default_offset].arg - if isinstance(default, ast.Constant): - defaults[arg_name] = default.value - else: - defaults[arg_name] = parse_expr(default, ctx) - - # Parse the function body - body = [parse_stmt(stmt, ctx) for stmt in func_def.body] - - return FunctionNode(name=name, args=args, body=body, defaults=defaults) - - -def expr_to_model( - source: str, - ctx: EvaluationContext, -) -> ExpressionNode: - """Parse a Python rvalue expression - - Args: - source: a string containing the source - ctx: Optional evaluation context - """ - - # Parse the source code into an AST - tree = ast.parse(source) - - body = tree.body[0] - - # We expect a single line expression - if not isinstance(tree, ast.Module) or len(tree.body) != 1 or not isinstance(body, ast.Expr): - raise ValueError("Expected a single-line rvalue expression") - - expression = parse_expr(body.value, ctx) - - return expression diff --git a/flow360/component/simulation/blueprint/core/resolver.py b/flow360/component/simulation/blueprint/core/resolver.py deleted file mode 100644 index 8f2139fa3..000000000 --- a/flow360/component/simulation/blueprint/core/resolver.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Whitelisted functions and classes that can be called from blueprint functions.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - - -class CallableResolver: - """Manages resolution and validation of callable objects. - - Provides a unified interface for resolving function names, methods, and - attributes while enforcing whitelisting rules. - """ - - def __init__(self, callables, modules, imports, blacklist) -> None: - self._import_builtins = imports - self._callable_builtins = callables - self._module_builtins = modules - self._evaluation_blacklist = blacklist - - self._allowed_callables: dict[str, Callable[..., Any]] = {} - self._allowed_modules: dict[str, Any] = {} - - def register_callable(self, name: str, func: Callable[..., Any]) -> None: - """Register a callable for direct use.""" - self._allowed_callables[name] = func - - def register_module(self, name: str, module: Any) -> None: - """Register a module for attribute access.""" - self._allowed_modules[name] = module - - def can_evaluate(self, qualname: str) -> bool: - """Check if the name is not blacklisted for evaluation by the resolver""" - return qualname not in self._evaluation_blacklist - - def get_callable(self, qualname: str) -> Callable[..., Any]: - """Resolve a callable by its qualified name. - - Args: - qualname: Fully qualified name (e.g., "np.array" or "len") - context: Optional evaluation context for local lookups - - Returns: - The resolved callable object - - Raises: - ValueError: If the callable is not allowed or cannot be found - """ - # Check direct allowed callables - if qualname in self._allowed_callables: - return self._allowed_callables[qualname] - - # Handle module attributes - if "." in qualname: - module_name, *attr_parts = qualname.split(".") - if module_name in self._allowed_modules: - obj = self._allowed_modules[module_name] - for part in attr_parts: - obj = getattr(obj, part) - if qualname in self._callable_builtins: - return obj - # Try importing if it's a whitelisted callable - if qualname in self._callable_builtins: - for names, import_func in self._import_builtins.items(): - if module_name in names: - module = import_func(module_name) - self.register_module(module_name, module) - obj = module - for part in attr_parts: - obj = getattr(obj, part) - return obj - - raise ValueError(f"Callable '{qualname}' is not allowed") - - def get_allowed_callable(self, qualname: str) -> Callable[..., Any]: - """Get an allowed callable by name.""" - try: - return self.get_callable(qualname) - except ValueError as e: - # Check if it's a whitelisted callable before trying to import - if ( - qualname in self._callable_builtins - or qualname in self._module_builtins - or any( - qualname.startswith(f"{group['prefix']}{name}") - for group in self._callable_builtins.values() - if group is not None - for name in group["callables"] - ) - ): - # If found in resolver, try importing on demand - for names, import_func in self._import_builtins.items(): - if qualname in names or any(qualname.startswith(prefix) for prefix in names): - callable_obj = import_func(qualname) - self.register_callable(qualname, callable_obj) - return callable_obj - raise ValueError(f"Callable '{qualname}' is not allowed") from e diff --git a/flow360/component/simulation/blueprint/core/statements.py b/flow360/component/simulation/blueprint/core/statements.py deleted file mode 100644 index ecef64c46..000000000 --- a/flow360/component/simulation/blueprint/core/statements.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Data models and evaluator functions for single-line Python statements""" - -from typing import Annotated, Literal, Union - -import pydantic as pd - -from .context import EvaluationContext, ReturnValue -from .expressions import ExpressionNodeType -from .types import Evaluable - -# Forward declaration of type -StatementNodeType = Annotated[ - # pylint: disable=duplicate-code - Union[ - "AssignNode", - "AugAssignNode", - "IfElseNode", - "ForLoopNode", - "ReturnNode", - "TupleUnpackNode", - ], - pd.Field(discriminator="type"), -] - - -class StatementNode(pd.BaseModel, Evaluable): - """ - Base class for statements (like 'if', 'for', assignments, etc.). - """ - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> None: - raise NotImplementedError - - -class AssignNode(StatementNode): - """ - Represents something like 'result = '. - """ - - type: Literal["Assign"] = "Assign" - target: str - value: ExpressionNodeType - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> None: - context.set( - self.target, self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) - ) - - -class AugAssignNode(StatementNode): - """ - Represents something like 'result += '. - The 'op' is again the operator class name (e.g. 'Add', 'Mult', etc.). - """ - - type: Literal["AugAssign"] = "AugAssign" - target: str - op: str - value: ExpressionNodeType - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> None: - old_val = context.get(self.target) - increment = self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) - if self.op == "Add": - context.set(self.target, old_val + increment) - elif self.op == "Sub": - context.set(self.target, old_val - increment) - elif self.op == "Mult": - context.set(self.target, old_val * increment) - elif self.op == "Div": - context.set(self.target, old_val / increment) - else: - raise ValueError(f"Unsupported augmented assignment operator: {self.op}") - - -class IfElseNode(StatementNode): - """ - Represents an if/else block: - if condition: - - else: - - """ - - type: Literal["IfElse"] = "IfElse" - condition: ExpressionNodeType - body: list["StatementNodeType"] - orelse: list["StatementNodeType"] - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> None: - if self.condition.evaluate(context, raise_on_non_evaluable, force_evaluate): - for stmt in self.body: - stmt.evaluate(context, raise_on_non_evaluable, force_evaluate) - else: - for stmt in self.orelse: - stmt.evaluate(context, raise_on_non_evaluable) - - -class ForLoopNode(StatementNode): - """ - Represents a for loop: - for in : - - """ - - type: Literal["ForLoop"] = "ForLoop" - target: str - iter: ExpressionNodeType - body: list["StatementNodeType"] - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> None: - iterable = self.iter.evaluate(context, raise_on_non_evaluable, force_evaluate) - for item in iterable: - context.set(self.target, item) - for stmt in self.body: - stmt.evaluate(context, raise_on_non_evaluable, force_evaluate) - - -class ReturnNode(StatementNode): - """ - Represents a return statement: return . - We'll raise a custom exception to stop execution in the function. - """ - - type: Literal["Return"] = "Return" - value: ExpressionNodeType - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> None: - val = self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) - raise ReturnValue(val) - - -class TupleUnpackNode(StatementNode): - """Model for tuple unpacking assignments.""" - - type: Literal["TupleUnpack"] = "TupleUnpack" - targets: list[str] - values: list[ExpressionNodeType] - - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> None: - evaluated_values = [ - val.evaluate(context, raise_on_non_evaluable, force_evaluate) for val in self.values - ] - for target, value in zip(self.targets, evaluated_values): - context.set(target, value) diff --git a/flow360/component/simulation/blueprint/core/types.py b/flow360/component/simulation/blueprint/core/types.py deleted file mode 100644 index 6e5572ced..000000000 --- a/flow360/component/simulation/blueprint/core/types.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Shared type definitions for blueprint core submodules""" - -# pylint: disable=too-few-public-methods - -import abc -from enum import Enum -from typing import Any - -from .context import EvaluationContext - - -class Evaluable(metaclass=abc.ABCMeta): - """Base class for all classes that allow evaluation from their symbolic form""" - - @abc.abstractmethod - def evaluate( - self, - context: EvaluationContext, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> Any: - """ - Evaluate the expression using the given context. - - Args: - context (EvaluationContext): The context in which to evaluate the expression. - raise_on_non_evaluable (bool): If True, raise an error on non-evaluable symbols; - if False, allow graceful failure or fallback behavior. - force_evaluate (bool): If True, evaluate evaluable objects marked as - non-evaluable, instead of returning their identifier. - Returns: - Any: The evaluated value. - """ - raise NotImplementedError - - -class TargetSyntax(Enum): - """Target syntax enum, Python and""" - - PYTHON = ("python",) - CPP = ("cpp",) - # Possibly other languages in the future if needed... diff --git a/flow360/component/simulation/blueprint/utils/__init__.py b/flow360/component/simulation/blueprint/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/flow360/component/simulation/blueprint/utils/operators.py b/flow360/component/simulation/blueprint/utils/operators.py deleted file mode 100644 index 89bc9dcdf..000000000 --- a/flow360/component/simulation/blueprint/utils/operators.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Operator info for the parser module""" - -# pylint: disable=too-few-public-methods - -import operator -from collections.abc import Callable -from typing import Any, Union - - -class OpInfo: - """Class to hold operator information.""" - - def __init__( - self, func: Union[Callable[[Any], Any], Callable[[Any, Any], Any]], symbol: str - ) -> None: - self.func = func - self.symbol = symbol - - def __call__(self, *args: Any) -> Any: - return self.func(*args) - - -UNARY_OPERATORS = { - "UAdd": OpInfo(operator.pos, "+"), - "USub": OpInfo(operator.neg, "-"), -} - -BINARY_OPERATORS = { - # Arithmetic operators - "Add": OpInfo(operator.add, "+"), - "Sub": OpInfo(operator.sub, "-"), - "Mult": OpInfo(operator.mul, "*"), - "Div": OpInfo(operator.truediv, "/"), - "FloorDiv": OpInfo(operator.floordiv, "//"), - "Mod": OpInfo(operator.mod, "%"), - "Pow": OpInfo(operator.pow, "**"), - # Comparison operators - "Eq": OpInfo(operator.eq, "=="), - "NotEq": OpInfo(operator.ne, "!="), - "Lt": OpInfo(operator.lt, "<"), - "LtE": OpInfo(operator.le, "<="), - "Gt": OpInfo(operator.gt, ">"), - "GtE": OpInfo(operator.ge, ">="), - "Is": OpInfo(operator.is_, "is"), - # Bitwise operators - "BitAnd": OpInfo(operator.and_, "&"), - "BitOr": OpInfo(operator.or_, "|"), - "BitXor": OpInfo(operator.xor, "^"), - "LShift": OpInfo(operator.lshift, "<<"), - "RShift": OpInfo(operator.rshift, ">>"), -} diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index f2035822c..8b1ff9080 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -2,9 +2,10 @@ # pylint: disable=no-member -from typing import Annotated, List, Optional, Union +from typing import List, Optional, Union import pydantic as pd +from flow360_schema.framework.expression.variable import VariableContextList from flow360_schema.framework.physical_dimensions import Length from flow360.component.simulation.draft_context.coordinate_system_manager import ( @@ -26,17 +27,8 @@ _SurfaceEntityBase, _VolumeEntityBase, ) -from flow360.component.simulation.user_code.core.types import ( - VariableContextInfo, - update_global_context, -) from flow360.component.simulation.utils import model_attribute_unlock -VariableContextList = Annotated[ - List[VariableContextInfo], - pd.AfterValidator(update_global_context), -] - class AssetCache(Flow360BaseModel): """ diff --git a/flow360/component/simulation/operating_condition/operating_condition.py b/flow360/component/simulation/operating_condition/operating_condition.py index 591cb02ff..5ea867581 100644 --- a/flow360/component/simulation/operating_condition/operating_condition.py +++ b/flow360/component/simulation/operating_condition/operating_condition.py @@ -3,6 +3,7 @@ from typing import Literal, Optional, Tuple, Union import pydantic as pd +from flow360_schema.framework.expression import Expression from flow360_schema.framework.physical_dimensions import ( AbsoluteTemperature, Angle, @@ -25,10 +26,7 @@ from flow360.component.simulation.operating_condition.atmosphere_model import ( StandardAtmosphereModel, ) -from flow360.component.simulation.user_code.core.types import ( - Expression, - ValueOrExpression, -) +from flow360.component.simulation.user_code.core.types import ValueOrExpression from flow360.component.simulation.validation.validation_context import ( CASE, CaseField, diff --git a/flow360/component/simulation/outputs/output_entities.py b/flow360/component/simulation/outputs/output_entities.py index fe1be653e..1b80894f5 100644 --- a/flow360/component/simulation/outputs/output_entities.py +++ b/flow360/component/simulation/outputs/output_entities.py @@ -4,6 +4,15 @@ import numpy as np import pydantic as pd +from flow360_schema.framework.expression import ( + Expression, + UnytQuantity, + UserVariable, + get_input_value_dimensions, + get_input_value_length, + solver_variable_to_user_variable, +) +from flow360_schema.framework.expression.utils import is_runtime_expression from flow360_schema.framework.physical_dimensions import Length from flow360.component.simulation.entity_operation import ( @@ -15,17 +24,10 @@ from flow360.component.simulation.framework.entity_utils import generate_uuid from flow360.component.simulation.outputs.output_fields import IsoSurfaceFieldNames from flow360.component.simulation.user_code.core.types import ( - Expression, - UnytQuantity, - UserVariable, ValueOrExpression, - get_input_value_dimensions, - get_input_value_length, infer_units_by_unit_system, is_variable_with_unit_system_as_units, - solver_variable_to_user_variable, ) -from flow360.component.simulation.user_code.core.utils import is_runtime_expression from flow360.component.types import Axis diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 783d03011..b5332f1ac 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -10,6 +10,11 @@ from typing import Annotated, ClassVar, List, Literal, Optional, Tuple, Union, get_args import pydantic as pd +from flow360_schema.framework.expression import ( + Expression, + UserVariable, + solver_variable_to_user_variable, +) from flow360_schema.framework.physical_dimensions import Length, Time from typing_extensions import deprecated @@ -61,11 +66,6 @@ Surface, WindTunnelGhostSurface, ) -from flow360.component.simulation.user_code.core.types import ( - Expression, - UserVariable, - solver_variable_to_user_variable, -) from flow360.component.simulation.validation.validation_context import ( ALL, CASE, diff --git a/flow360/component/simulation/outputs/render_config.py b/flow360/component/simulation/outputs/render_config.py index 7d900e926..48390d412 100644 --- a/flow360/component/simulation/outputs/render_config.py +++ b/flow360/component/simulation/outputs/render_config.py @@ -6,23 +6,25 @@ from typing import List, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.expression import ( + Expression, + UnytQuantity, + UserVariable, + get_input_value_dimensions, + get_input_value_length, + solver_variable_to_user_variable, +) +from flow360_schema.framework.expression.utils import is_runtime_expression from flow360_schema.framework.physical_dimensions import Angle, Length, Time import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.outputs.output_fields import CommonFieldNames from flow360.component.simulation.user_code.core.types import ( - Expression, - UnytQuantity, - UserVariable, ValueOrExpression, - get_input_value_dimensions, - get_input_value_length, infer_units_by_unit_system, is_variable_with_unit_system_as_units, - solver_variable_to_user_variable, ) -from flow360.component.simulation.user_code.core.utils import is_runtime_expression from flow360.component.types import Axis, Color, Vector diff --git a/flow360/component/simulation/run_control/stopping_criterion.py b/flow360/component/simulation/run_control/stopping_criterion.py index 9c2e96e1f..642fbebbe 100644 --- a/flow360/component/simulation/run_control/stopping_criterion.py +++ b/flow360/component/simulation/run_control/stopping_criterion.py @@ -4,6 +4,14 @@ import pydantic as pd import unyt as u +from flow360_schema.framework.expression import ( + SolverVariable, + UnytQuantity, + UserVariable, + get_input_value_dimensions, + get_input_value_length, + solver_variable_to_user_variable, +) from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.param_utils import serialize_model_obj_to_id @@ -16,15 +24,9 @@ SurfaceProbeOutput, ) from flow360.component.simulation.user_code.core.types import ( - SolverVariable, - UnytQuantity, - UserVariable, ValueOrExpression, - get_input_value_dimensions, - get_input_value_length, infer_units_by_unit_system, is_variable_with_unit_system_as_units, - solver_variable_to_user_variable, ) from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 822fcfcec..04271183f 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -18,13 +18,21 @@ ) import pydantic as pd +from flow360_schema.framework.expression.registry import ( # pylint: disable=unused-import + clear_context, +) +from flow360_schema.framework.expression.variable import ( + RedeclaringVariableError, + get_referenced_expressions_and_user_variables, + restore_variable_space, +) + +# Required for correct global scope initialization from flow360_schema.framework.physical_dimensions import Angle, Length from flow360_schema.framework.validation.context import DeserializationContext from pydantic import TypeAdapter from pydantic_core import ErrorDetails -# Required for correct global scope initialization -from flow360.component.simulation.blueprint.core.dependency_graph import DependencyGraph from flow360.component.simulation.entity_info import GeometryEntityInfo from flow360.component.simulation.entity_info import ( merge_geometry_entity_info as merge_geometry_entity_info_obj, @@ -81,10 +89,6 @@ unit_system_manager, ) from flow360.component.simulation.units import validate_length -from flow360.component.simulation.user_code.core.types import ( - UserVariable, - get_referenced_expressions_and_user_variables, -) from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_context import ( ALL, @@ -354,7 +358,7 @@ def _insert_forward_compatibility_notice( def initialize_variable_space(param_as_dict: dict, use_clear_context: bool = False) -> dict: - """Load all user variables from private attributes when a simulation params object is initialized""" + """Load all user variables from private attributes when a simulation params object is initialized.""" if "private_attribute_asset_cache" not in param_as_dict.keys(): return param_as_dict asset_cache: dict = param_as_dict["private_attribute_asset_cache"] @@ -363,67 +367,30 @@ def initialize_variable_space(param_as_dict: dict, use_clear_context: bool = Fal if not isinstance(asset_cache["variable_context"], Iterable): return param_as_dict - if use_clear_context: - clear_context() - - # ==== Build dependency graph and sort variables ==== - dependency_graph = DependencyGraph() - # Pad the project variables into proper schema - variable_list = [] - for var in asset_cache["variable_context"]: - if "type_name" in var["value"] and var["value"]["type_name"] == "expression": - # Expression type - variable_list.append({"name": var["name"], "value": var["value"]["expression"]}) - else: - # Number type (#units ignored since it does not affect the dependency graph) - variable_list.append({"name": var["name"], "value": str(var["value"]["value"])}) - dependency_graph.load_from_list(variable_list) - sorted_variables = dependency_graph.topology_sort() - - pre_sort_name_to_index = { - var["name"]: idx for idx, var in enumerate(asset_cache["variable_context"]) - } - - for variable_name in sorted_variables: - variable_dict = next( - (var for var in asset_cache["variable_context"] if var["name"] == variable_name), - None, - ) - if variable_dict is None: - continue + variable_context = asset_cache["variable_context"] - value_or_expression = dict(variable_dict["value"].items()) - - try: - UserVariable( - name=variable_dict["name"], - value=value_or_expression, - description=variable_dict.get("description", None), - metadata=variable_dict.get("metadata", None), - ) - except pd.ValidationError as e: - # pylint:disable = raise-missing-from - if "Redeclaring user variable" in str(e): - raise ValueError( - f"Loading user variable '{variable_dict['name']}' from simulation.json which is " - "already defined in local context. Please change your local user variable definition." - ) - error_detail: dict = e.errors()[0] - raise pd.ValidationError.from_exception_data( - "Invalid user variable/expression", - line_errors=[ - ErrorDetails( - type=error_detail["type"], - loc=( - "private_attribute_asset_cache", - "variable_context", - pre_sort_name_to_index[variable_name], - ), - msg=error_detail.get("msg", "Unknown error"), - ctx=error_detail.get("ctx", {}), - ), - ], - ) + try: + restore_variable_space(variable_context, clear_first=use_clear_context) + except RedeclaringVariableError as e: + raise ValueError( + f"Loading user variable '{e.variable_name}' from simulation.json which is " + "already defined in local context. Please change your local user variable definition." + ) from e + except pd.ValidationError as e: + # Re-wrap with private_attribute_asset_cache prefix in loc + error_detail: dict = e.errors()[0] + loc = error_detail.get("loc", ()) + raise pd.ValidationError.from_exception_data( + "Invalid user variable/expression", + line_errors=[ + ErrorDetails( + type=error_detail["type"], + loc=("private_attribute_asset_cache",) + tuple(loc), + msg=error_detail.get("msg", "Unknown error"), + ctx=error_detail.get("ctx", {}), + ), + ], + ) from e return param_as_dict @@ -1139,24 +1106,6 @@ def update_simulation_json(*, params_as_dict: dict, target_python_api_version: s return updated_params_as_dict, errors -def clear_context(): - """ - Clear out `UserVariable` in the `context` and its dependency graph. - """ - - from flow360.component.simulation.user_code.core import ( # pylint: disable=import-outside-toplevel - context, - ) - - # pylint: disable=protected-access - for name in context.default_context._values.keys(): - if "." not in name: - context.default_context._dependency_graph.remove_variable(name) - context.default_context._values = { - name: value for name, value in context.default_context._values.items() if "." in name - } - - def _serialize_unit_in_dict(data): """ Recursively serialize unit type data in a dictionary or list. diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index 3ed34f445..0920c3463 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -9,6 +9,11 @@ import pydantic as pd import unyt as u +from flow360_schema.framework.expression import ( + UserVariable, + batch_get_user_variable_units, + compute_surface_integral_unit, +) from flow360_schema.framework.physical_dimensions import ( AbsoluteTemperature, Density, @@ -88,9 +93,6 @@ ) from flow360.component.simulation.units import validate_length from flow360.component.simulation.user_code.core.types import ( - UserVariable, - batch_get_user_variable_units, - compute_surface_integral_unit, get_post_processing_variables, ) from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( @@ -973,13 +975,19 @@ def display_output_units(self) -> None: # Sort for consistent behavior post_processing_variables = sorted(post_processing_variables) - name_units_pair = batch_get_user_variable_units(post_processing_variables, self) + name_units_pair = batch_get_user_variable_units( + post_processing_variables, self.unit_system.name # pylint: disable=no-member + ) for output in self.outputs: if isinstance(output, SurfaceIntegralOutput): for field in output.output_fields.items: if isinstance(field, UserVariable): - unit = compute_surface_integral_unit(field, self) + unit = compute_surface_integral_unit( + field, + unit_system_name=self.unit_system.name, # pylint: disable=no-member + unit_system=self.unit_system.resolve(), # pylint: disable=no-member + ) name_units_pair[f"{field.name} (Surface integral)"] = unit if not name_units_pair: diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index 85853fa20..e402b2762 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -8,7 +8,15 @@ import numpy as np import unyt as u +from flow360_schema.framework.expression import ( + Expression, + UserVariable, + compute_surface_integral_unit, +) +from flow360_schema.framework.expression.variable import _convert_numeric from flow360_schema.framework.physical_dimensions import Length +from flow360_schema.models.functions import math +from flow360_schema.models.variables import solution from flow360.component.simulation.conversion import ( LIQUID_IMAGINARY_FREESTREAM_MACH, @@ -133,14 +141,6 @@ translate_setting_and_apply_to_all_entities, translate_value_or_expression_object, ) -from flow360.component.simulation.user_code.core.types import ( - Expression, - UserVariable, - _convert_numeric, - compute_surface_integral_unit, -) -from flow360.component.simulation.user_code.functions import math -from flow360.component.simulation.user_code.variables import solution from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) @@ -934,7 +934,9 @@ def _prepare_prepending_code(expression: Expression): prepending_code = "".join(prepending_code) return prepending_code - requested_unit: Union[u.Unit, None] = expression.get_output_units(input_params=input_params) + requested_unit: Union[u.Unit, None] = expression.get_output_units( + unit_system_name=input_params.unit_system.name + ) if requested_unit is None: # Number constant output requested coefficient = 1 @@ -957,7 +959,7 @@ def _prepare_prepending_code(expression: Expression): if not isinstance(expression, Expression): # Enforce constant as Expression expression = Expression.model_validate(_convert_numeric(expression)) - expression = expression.to_solver_code(params=input_params) + expression = expression.to_solver_code(input_params.flow360_unit_system) return UserDefinedField( name=variable.name, expression=f"{prepending_code}{variable.name} = " + expression + ";" ) @@ -973,10 +975,10 @@ def _prepare_prepending_code(expression: Expression): expression = [item * coefficient for item in expression] for i, item in enumerate(expression): if isinstance(item, Expression): - expression[i] = item.to_solver_code(params=input_params) + expression[i] = item.to_solver_code(input_params.flow360_unit_system) else: expression[i] = Expression.model_validate(_convert_numeric(item)).to_solver_code( - params=input_params + input_params.flow360_unit_system ) expression = [f"{variable.name}[{i}] = " + item for i, item in enumerate(expression)] @@ -1099,7 +1101,11 @@ def process_output_field_for_integral(output_field, input_params): expression[i] * math.magnitude(solution.node_area_vector) for i in range(expression.length) ] - new_unit = compute_surface_integral_unit(output_field, input_params) + new_unit = compute_surface_integral_unit( + output_field, + unit_system_name=input_params.unit_system.name, + unit_system=input_params.unit_system.resolve(), + ) user_variable = UserVariable( name=output_field.name + "_integral", value=expression_processed, diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index 6944e302e..be336c33a 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -10,6 +10,7 @@ import numpy as np import pydantic as pd import unyt as u +from flow360_schema.framework.expression import Expression, UserVariable from flow360_schema.framework.physical_dimensions import Length from flow360.component.simulation.draft_context.coordinate_system_manager import ( @@ -29,7 +30,6 @@ ) from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.units import validate_length -from flow360.component.simulation.user_code.core.types import Expression, UserVariable from flow360.component.simulation.utils import is_exact_instance from flow360.exceptions import Flow360TranslationError @@ -295,7 +295,7 @@ def _is_unyt_or_unyt_like_obj(value): def get_units_from_field(field, input_params) -> u.Unit: """Get output units from a field, which can be either a UserVariable or a string.""" if isinstance(field, UserVariable): - return field.value.get_output_units(input_params=input_params) + return field.value.get_output_units(unit_system_name=input_params.unit_system.name) return u.dimensionless # pylint:disable=no-member diff --git a/flow360/component/simulation/user_code/core/context.py b/flow360/component/simulation/user_code/core/context.py deleted file mode 100644 index fc178fa86..000000000 --- a/flow360/component/simulation/user_code/core/context.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Context handler module""" - -from typing import Any - -from unyt import Unit, unit_symbols - -from flow360.component.simulation.blueprint.core import EvaluationContext -from flow360.component.simulation.blueprint.core.resolver import CallableResolver - - -def _unit_list(): - """Import a list of available unit symbols from the unyt module""" - unyt_symbol_dict = {} - for key, value in unit_symbols.__dict__.items(): - if isinstance(value, Unit): - dimension_str = str(value.dimensions) - u_expr_str = str(value.expr) - if ( - dimension_str.count("logarithmic") - or dimension_str.count("luminous") - or dimension_str.count("current") - or (dimension_str == "1" and u_expr_str != "dimensionless") - ): - continue - if u_expr_str.count("delta_degC") or u_expr_str.count("delta_degF"): - # Note: Disable the delta temperature units. - continue - if u_expr_str not in unyt_symbol_dict: - unyt_symbol_dict[u_expr_str] = { - "aliases": [key], - "dimensions": str(value.dimensions), - "SI_equivalent": str((1 * value).in_mks()), - } - else: - unyt_symbol_dict[str(value.expr)]["aliases"].append(key) - allowed_unyt_symbols = [] - for value in unyt_symbol_dict.values(): - allowed_unyt_symbols += value["aliases"] - return allowed_unyt_symbols - - -def _import_units(_) -> Any: - """Import and return allowed unit callables""" - # pylint:disable=import-outside-toplevel - from flow360.component.simulation import units as u - - return u - - -def _import_math(_) -> Any: - """Import and return allowed function callables""" - # pylint:disable=import-outside-toplevel, cyclic-import - from flow360.component.simulation.user_code.functions import math - - return math - - -def _import_control(_) -> Any: - """Import and return allowed control variable callables""" - # pylint:disable=import-outside-toplevel, cyclic-import - from flow360.component.simulation.user_code.variables import control - - return control - - -def _import_solution(_) -> Any: - """Import and return allowed solution variable callables""" - # pylint:disable=import-outside-toplevel, cyclic-import - from flow360.component.simulation.user_code.variables import solution - - return solution - - -WHITELISTED_CALLABLES = { - "flow360_math": {"prefix": "math.", "callables": ["pi"], "evaluate": True}, - "flow360.units": {"prefix": "u.", "callables": _unit_list(), "evaluate": True}, - "flow360.control": { - "prefix": "control.", - "callables": [ - "MachRef", - "Tref", - "t", - "physicalStep", - "pseudoStep", - "timeStepSize", - "alphaAngle", - "betaAngle", - "pressureFreestream", - "momentLengthX", - "momentLengthY", - "momentLengthZ", - "momentCenterX", - "momentCenterY", - "momentCenterZ", - "theta", - "omega", - "omegaDot", - ], - "evaluate": False, - }, - "flow360.solution": { - "prefix": "solution.", - "callables": [ - # pylint: disable=fixme - # TODO: Auto-populate this list from the solution module - "coordinate", - "Cp", - "Cpt", - "Cpt_auto", - "grad_density", - "grad_u", - "grad_v", - "grad_w", - "grad_pressure", - "Mach", - "mut", - "mut_ratio", - "nu_hat", - "turbulence_kinetic_energy", - "specific_rate_of_dissipation", - "amplification_factor", - "turbulence_intermittency", - "density", - "velocity", - "pressure", - "qcriterion", - "entropy", - "temperature", - "vorticity", - "wall_distance", - "CfVec", - "Cf", - "heat_flux", - "node_area_vector", - "node_unit_normal", - "node_forces_per_unit_area", - "y_plus", - "wall_shear_stress_magnitude", - "heat_transfer_coefficient_static_temperature", - "heat_transfer_coefficient_total_temperature", - ], - "evaluate": False, - }, -} - -# Define allowed modules -ALLOWED_MODULES = {"u", "math", "control", "solution"} - -ALLOWED_CALLABLES = { - **{ - f"{group['prefix']}{callable}": None - for group in WHITELISTED_CALLABLES.values() - for callable in group["callables"] - }, -} - -EVALUATION_BLACKLIST = { - **{ - f"{group['prefix']}{callable}": None - for group in WHITELISTED_CALLABLES.values() - for callable in group["callables"] - if not group["evaluate"] - }, -} - -# Note: Keys of IMPORT_FUNCTIONS needs to be consistent with ALLOWED_MODULES -IMPORT_FUNCTIONS = { - "u": _import_units, - "math": _import_math, - "control": _import_control, - "solution": _import_solution, -} - -default_context = EvaluationContext( - CallableResolver(ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST) -) - -user_variables: set[str] = set() -solver_variable_name_map: dict[str, str] = {} diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 65f3aa5c3..ac1bfc0c8 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -1,1225 +1,43 @@ -# pylint: disable=too-many-lines -"""This module allows users to write serializable, evaluable symbolic code for use in simulation params""" +"""Client-only adapters for the expression system. + +Core types (Variable, UserVariable, SolverVariable, Expression, VariableContextInfo) live in +flow360-schema. This file provides ValueOrExpression (client subclass) and adapter functions +that depend on client-specific state (unit_system_manager, deprecation_reminder, params). +""" from __future__ import annotations -import ast -import re -import textwrap from numbers import Number -from typing import ( - Annotated, - Any, - ClassVar, - Generic, - List, - Literal, - Optional, - TypeVar, - Union, -) +from typing import Annotated, Any, ClassVar, Generic, List, TypeVar, Union import numpy as np import pydantic as pd import unyt as u from flow360_schema import StrictUnitContext +from flow360_schema.framework.expression.utils import is_runtime_expression +from flow360_schema.framework.expression.value_or_expression import ( + SerializedValueOrExpression, + register_deprecation_check, +) +from flow360_schema.framework.expression.variable import ( + Expression, + UserVariable, + Variable, + _check_list_items_are_same_dimensions, +) from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag -from pydantic_core import InitErrorDetails, core_schema from typing_extensions import Self -from unyt import Unit, dimensions, unyt_array, unyt_quantity +from unyt import unyt_array, unyt_quantity -from flow360.component.simulation.blueprint import Evaluable, expr_to_model -from flow360.component.simulation.blueprint.core import EvaluationContext, expr_to_code -from flow360.component.simulation.blueprint.core.types import TargetSyntax -from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.updater_utils import deprecation_reminder from flow360.component.simulation.unit_system import unit_system_manager -from flow360.component.simulation.user_code.core.context import default_context -from flow360.component.simulation.user_code.core.utils import ( - SOLVER_INTERNAL_VARIABLES, - handle_syntax_error, - is_number_string, - is_runtime_expression, - split_keep_delimiters, -) -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - contextual_model_validator, -) -from flow360.log import log - -_solver_variables: dict[str, str] = {} - - -class VariableContextInfo(Flow360BaseModel): - """Variable context info for project variables.""" - - name: str - value: ValueOrExpression.configure(allow_run_time_expression=True)[AnyNumericType] # type: ignore - post_processing: bool = pd.Field() - description: Optional[str] = pd.Field(None) - # ** metadata is added to serve (hopefully) only front-end related purposes. - # ** All future new keys (even if used by Python client) should be added to this field to ensure compatibility. - metadata: Optional[dict] = pd.Field(None, description="Metadata used only by the frontend.") - - @pd.field_validator("value", mode="after") - @classmethod - def convert_number_to_expression(cls, value: AnyNumericType) -> ValueOrExpression: - """So that frontend can properly display the value of the variable.""" - if not isinstance(value, Expression): - return Expression.model_validate(_convert_numeric(value)) - return value - - -def update_global_context(value: List[VariableContextInfo]): - """Once the project variables are validated, update the global context.""" - - for item in value: - default_context.set(item.name, item.value) - return value - - -def get_user_variable(name: str): - """Get the user variable from the global context.""" - return UserVariable(name=name, value=default_context.get(name)) - - -def remove_user_variable(name: str): - """Remove the variable from the global context.""" - return default_context.remove(name) - - -def show_user_variables(): - """Show the user variables from the global context with name and value in two columns, wrapping long values.""" - # pylint: disable=too-many-locals - user_variables = { - name: default_context.get(name) for name in sorted(default_context.user_variable_names) - } - - if not user_variables.keys(): - log.info("No user variables are currently defined.") - return - - header_index = "Idx" - header_name = "Name" - header_value = "Value" - - max_name_width = max(max(len(name) for name in user_variables.keys()), len(header_name)) - - terminal_width = 100 - - value_col_width = max(terminal_width - (len(header_index) + 1 + max_name_width), 20) - - formatted_header = ( - f"{header_index:>{len(header_index)}}. " - f"{header_name:<{max_name_width}} " - f"{header_value}" - ) - separator = f"{'-'*(len(header_index)+1)} " f"{'-'*max_name_width} " f"{'-'*value_col_width}" - - output_lines = [formatted_header, separator] - - for idx, (name, value) in enumerate(user_variables.items()): - value_str = str(value) - - value_lines_raw = value_str.splitlines() - - wrapped_value_lines = [] - for line in value_lines_raw: - wrapped_line_parts = textwrap.wrap(line, width=value_col_width) - wrapped_value_lines.extend(wrapped_line_parts) - - first_value_line = wrapped_value_lines[0] if wrapped_value_lines else "" - - output_lines.append( - f"{idx+1:>{len(header_index)}}. {name:<{max_name_width}} {first_value_line}" - ) - - indent_for_wrapped_lines = " " * (len(header_index) + max_name_width + 2) - - for subsequent_line in wrapped_value_lines[1:]: - output_lines.append(f"{indent_for_wrapped_lines}{subsequent_line}") - - output_lines = "\n".join(output_lines) - - log.info(f"The current defined user variables are:\n{output_lines}") - - -def __soft_fail_add__(self, other): - if not isinstance(other, Expression) and not isinstance(other, Variable): - return np.ndarray.__add__(self, other) - return NotImplemented - - -def __soft_fail_sub__(self, other): - if not isinstance(other, Expression) and not isinstance(other, Variable): - return np.ndarray.__sub__(self, other) - return NotImplemented - - -def __soft_fail_mul__(self, other): - if not isinstance(other, Expression) and not isinstance(other, Variable): - return np.ndarray.__mul__(self, other) - return NotImplemented - - -def __soft_fail_truediv__(self, other): - if not isinstance(other, Expression) and not isinstance(other, Variable): - return np.ndarray.__truediv__(self, other) - return NotImplemented - - -def __soft_fail_pow__(self, other): - if not isinstance(other, Expression) and not isinstance(other, Variable): - return np.ndarray.__pow__(self, other) # pylint: disable=too-many-function-args - return NotImplemented - - -unyt_array.__add__ = __soft_fail_add__ -unyt_array.__sub__ = __soft_fail_sub__ -unyt_array.__mul__ = __soft_fail_mul__ -unyt_array.__truediv__ = __soft_fail_truediv__ -unyt_array.__pow__ = __soft_fail_pow__ - - -def _convert_numeric(value): - arg = None - unit_delimiters = ["+", "-", "*", "/", "(", ")"] - if isinstance(value, Number): - arg = str(value) - elif isinstance(value, Unit): - unit = str(value) - tokens = split_keep_delimiters(unit, unit_delimiters) - arg = "" - for token in tokens: - if token not in unit_delimiters and not is_number_string(token): - token = f"u.{token}" - arg += token - else: - arg += token - elif isinstance(value, unyt_array): - unit = str(value.units) - tokens = split_keep_delimiters(unit, unit_delimiters) - arg = f"{_convert_argument(value.value.tolist())[0]} * " - for token in tokens: - if token not in unit_delimiters and not is_number_string(token): - token = f"u.{token}" - arg += token - else: - arg += token - elif isinstance(value, list): - arg = f"[{','.join([_convert_argument(item)[0] for item in value])}]" - return arg - - -def _convert_argument(value): - parenthesize = False - arg = _convert_numeric(value) - if isinstance(value, Expression): - arg = value.expression - parenthesize = True - elif isinstance(value, Variable): - arg = value.name - if not arg: - raise ValueError(f"Incompatible argument of type {type(value)}") - return arg, parenthesize - - -class SerializedValueOrExpression(Flow360BaseModel): - """Serialized frontend-compatible format of an arbitrary value/expression field""" - - type_name: Literal["number", "expression"] = pd.Field() - value: Optional[Union[Number, list[Number]]] = pd.Field(None) - units: Optional[str] = pd.Field(None) - expression: Optional[str] = pd.Field(None) - output_units: Optional[str] = pd.Field(None, description="See definition in `Expression`.") - - -class UnytQuantity(unyt_quantity): - """UnytQuantity wrapper to enable pydantic compatibility""" - - # pylint: disable=unused-argument - @classmethod - def __get_pydantic_core_schema__(cls, source_type, handler): - return core_schema.no_info_plain_validator_function(cls.validate) - - @classmethod - def validate(cls, value: Any): - """Minimal validator for pydantic compatibility""" - if isinstance(value, unyt_quantity): - return value - if isinstance(value, unyt_array): - # When deserialized unyt_quantity() gives unyt_array - if value.shape == (): - return unyt_quantity(value.value, value.units) - raise ValueError("Input should be a valid unit quantity.") - - -# This is a wrapper to allow using unyt arrays with pydantic models -class UnytArray(unyt_array): - """UnytArray wrapper to enable pydantic compatibility""" - - def __repr__(self): - return f"UnytArray({str(self)})" - - # pylint: disable=unused-argument - @classmethod - def __get_pydantic_core_schema__(cls, source_type, handler): - return core_schema.no_info_plain_validator_function(cls.validate) - - @classmethod - def validate(cls, value: Any): - """Minimal validator for pydantic compatibility""" - if isinstance(value, unyt_array): - return value - raise ValueError(f"Cannot convert {type(value)} to UnytArray") - - -AnyNumericType = Union[float, UnytArray, list] - - -def _is_array(item): - if isinstance(item, unyt_array) and item.shape != (): - return True - if isinstance(item, list): - return True - return False - - -def check_vector_binary_arithmetic(func): - """Decorator to check if vector arithmetic is being attempted and raise an error if so.""" - - def wrapper(self, other): - if _is_array(self.value) or _is_array(other): - raise ValueError( - f"Vector operation ({func.__name__} between {self.name} and {other}) not " - "supported for variables. Please write expression for each component." - ) - return func(self, other) - - return wrapper - - -class Variable(Flow360BaseModel): - """Base class representing a symbolic variable""" - - name: str = pd.Field(frozen=True) - - model_config = pd.ConfigDict(validate_assignment=True) - - @property - def value(self): - """ - Get the value of the variable from the global context. - """ - return default_context.get(self.name) - - @value.setter - def value(self, value): - """ - Set the value of the variable in the global context. - In parallel to `deserialize` this supports syntax like `my_user_var.value = 10.0`. - """ - new_value = pd.TypeAdapter( - ValueOrExpression.configure(allow_run_time_expression=True)[AnyNumericType] - ).validate_python(value) - # Not checking overwrite here since it is user controlled explicit assignment operation - default_context.set(self.name, new_value) - - @pd.model_validator(mode="before") - @classmethod - def preprocess_variable_declaration(cls, values): - """ - Supporting syntax like `a = fl.Variable(name="a", value=1, description="some description")`. - """ - if values is None or "name" not in values: - raise ValueError("`name` is required for variable declaration.") - - if "value" in values: - new_value = pd.TypeAdapter( - ValueOrExpression.configure(allow_run_time_expression=True)[AnyNumericType] - ).validate_python(values.pop("value")) - - # Check redeclaration, skip for solver variables: - if values["name"] in default_context.user_variable_names: - registered_expression = VariableContextInfo.convert_number_to_expression( - default_context.get(values["name"]) - ) - registered_expression_stripped = registered_expression.expression.replace(" ", "") - - if isinstance(new_value, Expression): - new_value_stripped = new_value.expression.replace(" ", "") - else: - new_value_stripped = VariableContextInfo.convert_number_to_expression( - new_value - ).expression.replace(" ", "") - - if new_value_stripped != registered_expression_stripped: - raise ValueError( - f"Redeclaring user variable '{values['name']}' with new value: {new_value}. " - f"Previous value: {default_context.get(values['name'])}" - ) - else: - # No conflict, call the setter - default_context.set( - values["name"], - new_value, - ) - - if values.get("description") is not None: - if not isinstance(values["description"], str): - raise ValueError( - f"Description must be a string but got {type(values['description'])}." - ) - default_context.set_metadata(values["name"], "description", values["description"]) - values.pop("description", None) - - if values.get("metadata") is not None: - default_context.set_metadata(values["name"], "metadata", values["metadata"]) - values.pop("metadata", None) - return values - - @check_vector_binary_arithmetic - def __add__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{self.name} + {str_arg}") - - @check_vector_binary_arithmetic - def __sub__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{self.name} - {str_arg}") - - @check_vector_binary_arithmetic - def __mul__(self, other): - if isinstance(other, Number) and other == 0: - return Expression(expression="0") - - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{self.name} * {str_arg}") - - @check_vector_binary_arithmetic - def __truediv__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{self.name} / ({str_arg})") - - @check_vector_binary_arithmetic - def __floordiv__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{self.name} // ({str_arg})") - - @check_vector_binary_arithmetic - def __mod__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{self.name} % {str_arg}") - - def __pow__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - if _is_array(self.value): - components = [f"{self.name}[{i}] ** {str_arg}" for i in range(len(self.value))] - return Expression(expression=f"[{','.join(components)}]") - return Expression(expression=f"{self.name} ** {str_arg}") - - def __neg__(self): - if _is_array(self.value): - components = [f"-{self.name}[{i}]" for i in range(len(self.value))] - return Expression(expression=f"[{','.join(components)}]") - return Expression(expression=f"-{self.name}") - - def __pos__(self): - if _is_array(self.value): - components = [f"+{self.name}[{i}]" for i in range(len(self.value))] - return Expression(expression=f"[{','.join(components)}]") - return Expression(expression=f"+{self.name}") - - @check_vector_binary_arithmetic - def __radd__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} + {self.name}") - - @check_vector_binary_arithmetic - def __rsub__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} - {self.name}") - - @check_vector_binary_arithmetic - def __rmul__(self, other): - if isinstance(other, Number) and other == 0: - return Expression(expression="0") - - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} * {self.name}") - - @check_vector_binary_arithmetic - def __rtruediv__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} / {self.name}") - - @check_vector_binary_arithmetic - def __rfloordiv__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} // {self.name}") - - @check_vector_binary_arithmetic - def __rmod__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} % {self.name}") - - @check_vector_binary_arithmetic - def __rpow__(self, other): - (arg, _) = _convert_argument(other) - str_arg = f"({arg})" # Always parenthesize to ensure base is evaluated first - return Expression(expression=f"{str_arg} ** {self.name}") - - def __getitem__(self, item): - (arg, _) = _convert_argument(item) - return Expression(expression=f"{self.name}[{arg}]") - - def __str__(self): - # pylint:disable=invalid-str-returned - return self.name - - def __repr__(self): - return f"Variable({self.name} = {self.value})" - - def __hash__(self): - return hash(self.name) - - def __eq__(self, other): - # NaN-compatible equal operator for unit test support - if not isinstance(other, Variable): - return False - return self.model_dump_json() == other.model_dump_json() - - def __len__(self): - """The number of elements in self.value. 0 for scalar and anything else for vector.""" - if isinstance(self.value, Expression): - return len(self.value) - if isinstance(self.value, unyt_array): - # Can be either unyt_array or unyt_quantity - if self.value.shape == (): - return 0 - # No 2D arrays are supported - return self.value.shape[0] - if isinstance(self.value, list): - return len(self.value) - if isinstance(self.value, Number): - return 0 - raise ValueError(f"Cannot get length information for {self.value}") - - -class UserVariable(Variable): - """Class representing a user-defined symbolic variable""" - - name: str = pd.Field(frozen=True) - type_name: Literal["UserVariable"] = pd.Field("UserVariable", frozen=True) - - @pd.field_validator("name", mode="after") - @classmethod - @deprecation_reminder("26.2.0") - def check_value_is_not_legacy_variable(cls, v): - """Check that the value is not a legacy variable""" - # pylint:disable=import-outside-toplevel - from flow360.component.simulation.outputs.output_fields import AllFieldNames - - all_field_names = set(AllFieldNames.__args__) - if v in all_field_names: - raise ValueError( - f"'{v}' is a reserved (legacy) output field name. It cannot be used in expressions." - ) - return v - - @pd.field_validator("name", mode="after") - @classmethod - def check_valid_user_variable_name(cls, v): - """Validate a variable identifier (ASCII only).""" - # Partial list of keywords; extend as needed - RESERVED_SYNTAX_KEYWORDS = { # pylint:disable=invalid-name - "int", - "double", - "float", - "long", - "short", - "char", - "bool", - "void", - "class", - "for", - "while", - "if", - "else", - "return", - "namespace", - "template", - "typename", - "constexpr", - "virtual", - } - if not v: - raise ValueError("Identifier cannot be empty.") - - # 2) First character must be letter or underscore - if not re.match(r"^[A-Za-z_]", v): - raise ValueError("Identifier must start with a letter (A-Z/a-z) or underscore (_).") - - # 3) All characters must be letters, digits, or underscore - if re.search(r"[^A-Za-z0-9_]", v): - raise ValueError( - "Identifier can only contain letters, digits (0-9), or underscore (_)." - ) - - # 4) Not a C++ keyword - if v in RESERVED_SYNTAX_KEYWORDS: - raise ValueError(f"'{v}' is a reserved keyword.") - - # 5) existing variable name: - solver_side_names = { - item.split(".")[-1] for item in default_context.registered_names if "." in item - } - solver_side_names = solver_side_names.union(SOLVER_INTERNAL_VARIABLES) - if v in solver_side_names: - raise ValueError(f"'{v}' is a reserved solver side variable name.") - - return v - - def __hash__(self): - """ - Support for set and deduplicate. - """ - return hash(self.model_dump_json()) - - def in_units( - self, - new_unit: Union[ - str, Literal["SI_unit_system", "CGS_unit_system", "Imperial_unit_system"], Unit - ] = None, - ): - """Requesting the output of the variable to be in the given (new_unit) units.""" - if isinstance(new_unit, Unit): - new_unit = str(new_unit) - if not isinstance(self.value, Expression): - raise ValueError("Cannot set output units for non expression value.") - self.value.output_units = new_unit - return self - - -class SolverVariable(Variable): - """Class representing a pre-defined symbolic variable that cannot be evaluated at client runtime""" - - type_name: Literal["SolverVariable"] = pd.Field("SolverVariable", frozen=True) - solver_name: Optional[str] = pd.Field(None) - variable_type: Literal["Volume", "Surface", "Scalar"] = pd.Field() - - @pd.model_validator(mode="after") - def update_context(self): - """Auto updating context when new variable is declared""" - default_context.set(self.name, self.value, Variable) - _solver_variables.update({self.name: self.variable_type}) - if self.solver_name: - default_context.set_alias(self.name, self.solver_name) - return self - - def in_units( - self, - new_name: str, - new_unit: Union[ - str, Literal["SI_unit_system", "CGS_unit_system", "Imperial_unit_system"], Unit - ] = None, - ): - """ - Return a UserVariable that will generate results in the new_unit. - If new_unit is not specified then the unit will be determined by the unit system. - """ - if isinstance(new_unit, Unit): - new_unit = str(new_unit) - new_variable = UserVariable( - name=new_name, - value=Expression(expression=self.name), - ) - new_variable.value.output_units = new_unit # pylint:disable=assigning-non-slot - return new_variable - - -def get_input_value_length( - value: Union[Number, list[float], unyt_array, unyt_quantity, Expression, Variable], -): - """Get the length of the input value.""" - if isinstance(value, Expression): - value = value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - assert isinstance( - value, (unyt_array, unyt_quantity, list, Number, np.ndarray) - ), f"Unexpected evaluated result type: {type(value)}" - if isinstance(value, list): - return len(value) - if isinstance(value, np.ndarray): - return 0 if value.shape == () else value.shape[0] - return 0 if isinstance(value, (unyt_quantity, Number)) else value.shape[0] - - -_feature_requirement_map = { - "solution.nu_hat": ( - lambda x: x.feature_usage.turbulence_model_type == "SpalartAllmaras", - "Spalart-Allmaras turbulence solver is not used.", - ), - "solution.turbulence_kinetic_energy": ( - lambda x: x.feature_usage.turbulence_model_type == "kOmegaSST", - "k-omega turbulence solver is not used.", - ), - "solution.specific_rate_of_dissipation": ( - lambda x: x.feature_usage.turbulence_model_type == "kOmegaSST", - "k-omega turbulence solver is not used.", - ), - "solution.amplification_factor": ( - lambda x: x.feature_usage.transition_model_type == "AmplificationFactorTransport", - "Amplification factor transition model is not used.", - ), - "solution.turbulence_intermittency": ( - lambda x: x.feature_usage.transition_model_type == "AmplificationFactorTransport", - "Amplification factor transition model is not used.", - ), - "solution.density": ( - lambda x: x.using_liquid_as_material is False, - "Liquid operating condition is used.", - ), - "solution.temperature": ( - lambda x: x.using_liquid_as_material is False, - "Liquid operating condition is used.", - ), - "solution.Mach": ( - lambda x: x.using_liquid_as_material is False, - "Liquid operating condition is used.", - ), - "control.physicalStep": ( - lambda x: x.time_stepping == "Unsteady", - "Unsteady time stepping is not used.", - ), - "control.timeStepSize": ( - lambda x: x.time_stepping == "Unsteady", - "Unsteady time stepping is not used.", - ), - "control.theta": ( - lambda x: x.feature_usage.rotation_zone_count == 0, - "Rotation zone is not used.", - ), - "control.omega": ( - lambda x: x.feature_usage.rotation_zone_count == 0, - "Rotation zone is not used.", - ), - "control.omegaDot": ( - lambda x: x.feature_usage.rotation_zone_count == 0, - "Rotation zone is not used.", - ), -} - - -class Expression(Flow360BaseModel, Evaluable): - """ - A symbolic, validated representation of a mathematical expression. - - This model wraps a string-based expression, ensures its syntax and semantics - against the global evaluation context, and provides methods to: - - evaluate its numeric/unyt result (`evaluate`) - - list user-defined variables it references (`user_variables` / `user_variable_names`) - - emit C++ solver code (`to_solver_code`) - """ - - expression: str = pd.Field("") - output_units: Optional[str] = pd.Field( - None, - description="String representation of what the requested units the evaluated expression should be " - "when `self` is used as an output field. By default the output units will be inferred from the unit " - "system associated with SimulationParams", - ) - - model_config = pd.ConfigDict(validate_assignment=True) - - @pd.model_validator(mode="before") - @classmethod - def _validate_expression(cls, value) -> Self: - output_units = None - if isinstance(value, str): - expression = value - elif isinstance(value, dict) and "expression" in value.keys(): - expression = value["expression"] - output_units = value.get("output_units") - elif isinstance(value, Expression): - expression = str(value) - output_units = value.output_units - elif isinstance(value, Variable): - expression = str(value) - if isinstance(value.value, Expression): - output_units = value.value.output_units - elif isinstance(value, list): - expression = f"[{','.join([_convert_argument(item)[0] for item in value])}]" - elif isinstance(value, (Number, u.unyt_array, u.unyt_quantity)): - expression = _convert_numeric(value) - else: - details = InitErrorDetails( - type="value_error", ctx={"error": f"Invalid type {type(value)}"} - ) - raise pd.ValidationError.from_exception_data("Expression type error", [details]) - - try: - # To ensure the expression is valid (also checks for - expr_to_model(expression, default_context) - # To reduce unnecessary parentheses - expression = ast.unparse(ast.parse(expression)) - except SyntaxError as s_err: - handle_syntax_error(s_err, expression) - except ValueError as v_err: - details = InitErrorDetails(type="value_error", ctx={"error": v_err}) - raise pd.ValidationError.from_exception_data("Expression value error", [details]) - - return {"expression": expression, "output_units": output_units} - - @pd.field_validator("expression", mode="after") - @classmethod - def sanitize_expression(cls, value: str) -> str: - """Remove leading and trailing whitespace from the expression""" - return value.strip().rstrip("; \n\t") - - @pd.field_validator("expression", mode="after") - @classmethod - def disable_confusing_operators(cls, value: str) -> str: - """Disable confusing operators. This ideally should be warnings but we do not have warning system yet.""" - if "^" in value: - raise ValueError( - "^ operator is not allowed in expressions. For power operator, please use ** instead." - ) - # This has no possible usage yet. - if "&" in value: - raise ValueError( - "& operator is not allowed in expressions." # . For logical AND use 'and' instead." - ) - return value - - @pd.field_validator("expression", mode="after") - @classmethod - def disable_relative_temperature_scale(cls, value: str) -> str: - """Disable relative temperature scale usage""" - if "u.degF" in value or "u.degC" in value: - raise ValueError( - "Relative temperature scale usage is not allowed. Please use u.R or u.K instead." - ) - return value - - @pd.model_validator(mode="after") - def check_output_units_matches_dimensions(self) -> str: - """Check that the output units have the same dimensions as the expression""" - if not self.output_units: - return self - if self.output_units in ("SI_unit_system", "CGS_unit_system", "Imperial_unit_system"): - return self - output_units_dimensions = u.Unit(self.output_units).dimensions - expression_dimensions = self.dimensions - if output_units_dimensions != expression_dimensions: - raise ValueError( - f"Output units '{self.output_units}' have different dimensions " - f"{output_units_dimensions} than the expression {expression_dimensions}." - ) - - return self - - @pd.field_validator("output_units", mode="after") - @classmethod - def disable_relative_temperature_scale_in_output_units(cls, value: str) -> str: - """Disable relative temperature scale usage in output units""" - if not value: - return value - if "u.degF" in value or "u.degC" in value: - raise ValueError( - "Relative temperature scale usage is not allowed in output units. Please use u.R or u.K instead." - ) - return value - - @contextual_model_validator(mode="after") - def ensure_dependent_feature_enabled(self, param_info: ParamsValidationInfo) -> str: - """ - Ensure that all dependent features are enabled for all the solver variables. - Remaining checks: - 1. variable valid source check. - 2. variable location check. - - """ - if self.expression not in param_info.referenced_expressions: - return self - # Setting recursive to False to avoid recursive error message. - # All user variables will be checked anyways. - for solver_variable_name in self.solver_variable_names(recursive=False): - if solver_variable_name in _feature_requirement_map: - if not _feature_requirement_map[solver_variable_name][0](param_info): - raise ValueError( - f"`{solver_variable_name}` cannot be used " - f"because {_feature_requirement_map[solver_variable_name][1]}" - ) - return self - - def evaluate( - self, - context: EvaluationContext = None, - raise_on_non_evaluable: bool = True, - force_evaluate: bool = True, - ) -> Union[float, list[float], unyt_array, Expression]: - """Evaluate this expression against the given context.""" - if context is None: - context = default_context - expr = expr_to_model(self.expression, context) - result = expr.evaluate(context, raise_on_non_evaluable, force_evaluate) - - # Sometimes we may yield a list of expressions instead of - # an expression containing a list, so we check this here - # and convert if necessary - - if isinstance(result, list): - is_expression_list = False - - for item in result: - if isinstance(item, Expression): - is_expression_list = True - - if is_expression_list: - result = Expression.model_validate(result) - - return result - - def user_variables(self): - """Get list of user variables used in expression.""" - expr = expr_to_model(self.expression, default_context) - names = expr.used_names() - names = [name for name in names if name in default_context.user_variable_names] - - return [UserVariable(name=name, value=default_context.get(name)) for name in names] - - def user_variable_names(self): - """Get list of user variable names used in expression.""" - expr = expr_to_model(self.expression, default_context) - names = expr.used_names() - names = [name for name in names if name in default_context.user_variable_names] - - return names - - def solver_variable_names( - self, - recursive: bool, - variable_type: Literal["Volume", "Surface", "Scalar", "All"] = "All", - ): - """Get list of solver variable names used in expression, recursively checking user variables. - - Params: - ------- - - variable_type: The type of variable to get the names of. - - recursive: Whether to recursively check user variables for solver variables. - """ - - def _get_solver_variable_names_recursive( - expression: Expression, visited: set[str], recursive: bool - ) -> set[str]: - """Recursively get solver variable names from expression and its user variables.""" - solver_names = set() - - # Prevent infinite recursion by tracking visited expressions - expr_str = str(expression) - if expr_str in visited: - return solver_names - visited.add(expr_str) - - # Get solver variables directly from this expression - expr = expr_to_model(expression.expression, default_context) - names = expr.used_names() - direct_solver_names = [name for name in names if name in _solver_variables] - solver_names.update(direct_solver_names) - - if not recursive: - return solver_names - - # Get user variables from this expression and recursively check their values - user_vars = expression.user_variables() - for user_var in user_vars: - try: - if isinstance(user_var.value, Expression): - # Recursively check the user variable's expression - recursive_solver_names = _get_solver_variable_names_recursive( - user_var.value, visited, recursive - ) - solver_names.update(recursive_solver_names) - except (ValueError, AttributeError): - # Handle cases where user variable might not be properly defined - pass - - return solver_names - - # Start the recursive search - all_solver_names = _get_solver_variable_names_recursive(self, set(), recursive) - - # Filter by variable type if specified - if variable_type != "All": - all_solver_names = { - name for name in all_solver_names if _solver_variables[name] == variable_type - } - - return list(all_solver_names) - - def to_solver_code(self, params): - """Convert to solver readable code.""" - - def translate_symbol(name): - alias = default_context.get_alias(name) - - if alias: - return alias - - match = re.fullmatch("u\\.(.+)", name) - - if match: - unit_name = match.group(1) - unit = Unit(unit_name) - if unit == u.dimensionless: # pylint:disable=no-member - return "1.0" - conversion_factor = params.convert_unit(1.0 * unit, "flow360").v - return str(conversion_factor) - - # solver-time resolvable functions: - func_match = re.fullmatch(r"math\.(.+)", name) - if func_match: - func_name = func_match.group(1) - return func_name - - return name - - partial_result = self.evaluate( - default_context, raise_on_non_evaluable=False, force_evaluate=False - ) - - if isinstance(partial_result, Expression): - expr = expr_to_model(partial_result.expression, default_context) - else: - expr = expr_to_model(_convert_numeric(partial_result), default_context) - return expr_to_code(expr, TargetSyntax.CPP, translate_symbol) - - def __hash__(self): - return hash(self.expression) - - def __add__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{self.expression} + {str_arg}") - - def __sub__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{self.expression} - {str_arg}") - - def __mul__(self, other): - if isinstance(other, Number) and other == 0: - return Expression(expression="0") - - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"({self.expression}) * {str_arg}") - - def __truediv__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"({self.expression}) / ({str_arg})") - - def __floordiv__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"({self.expression}) // ({str_arg})") - - def __mod__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"({self.expression}) % {str_arg}") - - def __pow__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"({self.expression}) ** {str_arg}") - - def __neg__(self): - return Expression(expression=f"-({self.expression})") - - def __pos__(self): - return Expression(expression=f"+({self.expression})") - - def __abs__(self): - return Expression(expression=f"abs({self.expression})") - - def __radd__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} + {self.expression}") - - def __rsub__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} - {self.expression}") - - def __rmul__(self, other): - if isinstance(other, Number) and other == 0: - return Expression(expression="0") - - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} * ({self.expression})") - - def __rtruediv__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} / ({self.expression})") - - def __rfloordiv__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} // ({self.expression})") - - def __rmod__(self, other): - (arg, parenthesize) = _convert_argument(other) - str_arg = arg if not parenthesize else f"({arg})" - return Expression(expression=f"{str_arg} % ({self.expression})") - - def __rpow__(self, other): - (arg, _) = _convert_argument(other) - str_arg = f"({arg})" # Always parenthesize to ensure base is evaluated first - return Expression(expression=f"{str_arg} ** ({self.expression})") - - def __getitem__(self, index): - (arg, _) = _convert_argument(index) - tree = ast.parse(self.expression, mode="eval") - int_arg = None - try: - int_arg = int(arg) - except ValueError: - pass - if isinstance(tree.body, ast.List) and int_arg is not None: - # Expression string with list syntax, like "[aa,bb,cc]" - # and since the index is static we can reduce it - result = [ast.unparse(elt) for elt in tree.body.elts] - return Expression.model_validate(result[int_arg]) - return Expression(expression=f"({self.expression})[{arg}]") - - def __str__(self): - # pylint:disable=invalid-str-returned - return self.expression - - def __repr__(self): - return f"Expression({self.expression})" - - def __eq__(self, other): - if isinstance(other, Expression): - return self.expression == other.expression - return super().__eq__(other) - - @property - def dimensions(self): - """The physical dimensions of the expression.""" - value = self.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - if isinstance(value, (unyt_array, unyt_quantity)): - return value.units.dimensions - if isinstance(value, list): - _check_list_items_are_same_dimensions(value) - return value[0].units.dimensions - if isinstance(value, (Number, np.ndarray)): - # Plain numbers or numpy arrays without units are dimensionless - return u.Unit("dimensionless").dimensions - raise ValueError( - f"Cannot determine dimensions for expression with type {type(value).__name__}: {value}" - ) - - @property - def length(self): - """The number of elements in the expression. 0 for scalar and anything else for vector.""" - return get_input_value_length(self) - - def __len__(self): - return self.length - - def get_output_units(self, input_params=None): - """ - Get the output units of the expression. - - - If self.output_units is None, derive the default output unit based on the - value's dimensions and current unit system. - - - If self.output_units is valid u.Unit string, deserialize it and return it. - - - If self.output_units is valid unit system name, derive the default output - unit based on the value's dimensions and the **given** unit system. - - - If expression is a number constant, return None. - - - Else raise ValueError. - """ - - def get_unit_from_unit_system(expression: Expression, unit_system_name: str): - """Derive the default output unit based on the value's dimensions and current unit system""" - numerical_value = expression.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - if isinstance(numerical_value, list): - numerical_value = numerical_value[0] - if not isinstance(numerical_value, (u.unyt_array, u.unyt_quantity)): - # Pure dimensionless constant - return u.Unit("dimensionless") - if unit_system_name in ("SI", "SI_unit_system"): - return numerical_value.in_base("mks").units - if unit_system_name in ("Imperial", "Imperial_unit_system"): - return numerical_value.in_base("imperial").units - if unit_system_name in ("CGS", "CGS_unit_system"): - return numerical_value.in_base("cgs").units - raise ValueError(f"[Internal] Invalid unit system: {unit_system_name}") - - try: - return u.Unit(self.output_units) - except u.exceptions.UnitParseError as e: - if input_params is None: - raise ValueError( - "[Internal] input_params required when output_units is not valid u.Unit string" - ) from e - if not self.output_units: - unit_system_name: Literal["SI", "Imperial", "CGS"] = input_params.unit_system.name - else: - unit_system_name = self.output_units - # The unit system for inferring the units for input has different temperature unit - u.unit_systems.imperial_unit_system["temperature"] = u.Unit("R").expr - result = get_unit_from_unit_system(self, unit_system_name) - u.unit_systems.imperial_unit_system["temperature"] = u.Unit("degF").expr - return result - - -def _check_list_items_are_same_dimensions(value: list): - if all(isinstance(item, Expression) for item in value): - _check_list_items_are_same_dimensions( - [item.evaluate(raise_on_non_evaluable=False, force_evaluate=True) for item in value] - ) - return - if all(isinstance(item, unyt_quantity) for item in value): - # ensure all items have the same dimensions - if not all(item.units.dimensions == value[0].units.dimensions for item in value): - raise ValueError("All items in the list must have the same dimensions.") - return - # Also raise when some elements is Number and others are unyt_quantity - if any(isinstance(item, Number) for item in value) and any( - isinstance(item, unyt_quantity) for item in value - ): - raise ValueError("List must contain only all unyt_quantities or all numbers.") - return +register_deprecation_check(deprecation_reminder) T = TypeVar("T") +# TODO(migration): Migrate to schema once deprecation_reminder is migrated. class ValueOrExpression(Expression, Generic[T]): """Model accepting both value and expressions""" @@ -1341,6 +159,8 @@ def _serializer(value, info) -> dict: def _discriminator(v: Any) -> str: # Note: This is ran after deserializer + # Use schema base classes for isinstance checks so that both schema and client + # instances are recognized (client subclass instances also pass). if isinstance(v, SerializedValueOrExpression): return v.type_name if isinstance(v, dict): @@ -1364,6 +184,7 @@ def _discriminator(v: Any) -> str: return union_type +# TODO(migration): Migrate to schema once params.outputs structure is available in schema. def get_post_processing_variables(params) -> set[str]: """ Get all the post processing related variables from the simulation params. @@ -1382,237 +203,35 @@ def get_post_processing_variables(params) -> set[str]: return post_processing_variables +# TODO(migration): Migrate to schema once get_post_processing_variables and +# params.private_attribute_asset_cache are available in schema. def save_user_variables(params): - """ - Save user variables to the project variables. - """ - # pylint:disable=protected-access - post_processing_variables = get_post_processing_variables(params) - output_units_by_name = {} - if post_processing_variables: - # Derive output units for all post-processing variables. - output_units_by_name = batch_get_user_variable_units( - list(post_processing_variables), params - ) - - user_variable_names = default_context.user_variable_names - for name, value in list(default_context._values.items()): - if name not in user_variable_names: - continue - - output_unit = output_units_by_name.get(name) - if output_unit is not None: - output_unit_str = str(output_unit) - if isinstance(value, Expression): - value = value.model_copy() - value.output_units = output_unit_str - else: - value = VariableContextInfo.convert_number_to_expression(value) - value.output_units = output_unit_str - - if params.private_attribute_asset_cache.variable_context is None: - params.private_attribute_asset_cache.variable_context = [] - - # Check if variable with this name already exists - existing_index = None - for i, existing_var in enumerate(params.private_attribute_asset_cache.variable_context): - if existing_var.name == name: - existing_index = i - break - - new_variable = VariableContextInfo( - name=name, - value=value, - description=default_context.get_metadata(name, "description"), - post_processing=name in post_processing_variables, - metadata=default_context.get_metadata(name, "metadata"), - ) - - if existing_index is not None: - # Replace existing variable - params.private_attribute_asset_cache.variable_context[existing_index] = new_variable - else: - # Append new variable - params.private_attribute_asset_cache.variable_context.append(new_variable) - return params - - -def batch_get_user_variable_units(variable_names: list[str], params): - """ - Return output units for a list of user variable names. - - For each name, the value is pulled from `default_context` and converted to a unit: - - Expression: `Expression.get_output_units(params)` (respects explicit output_units or - infers from `params.unit_system`). - - unyt_array/unyt_quantity: their `units`. - - Number: "dimensionless". - - Returns a dict mapping variable name to a `unyt.Unit` (or the string "dimensionless"). - Raises `ValueError` if a name resolves to an unsupported type. - """ - result = {} - for name in variable_names: - value = default_context.get(name) - if isinstance(value, Expression): - result[name] = value.get_output_units(params) - elif isinstance(value, unyt_array): - result[name] = value.units - elif isinstance(value, Number): - result[name] = "dimensionless" - else: - raise ValueError(f"Unknown variable type: {value}") - return result - - -def get_input_value_dimensions( - value: Union[float, list[float], unyt_array, unyt_quantity, Expression, Variable], -): - """Get the dimensions of the input value.""" - if isinstance(value, list): - return get_input_value_dimensions(value=value[0]) if len(value) > 0 else None - if isinstance(value, Variable): - return get_input_value_dimensions(value=value.value) - if isinstance(value, Expression): - return value.dimensions - if isinstance(value, (unyt_array, unyt_quantity)): - return value.units.dimensions - if isinstance(value, Number): - return dimensions.dimensionless - raise ValueError( - "Cannot get input value's dimensions due to the unknown value type: ", - value, - value.__class__.__name__, + """Client adapter: extract data from params, delegate to schema.""" + # pylint:disable = import-outside-toplevel + from flow360_schema.framework.expression.variable import ( + batch_get_user_variable_units as _schema_batch_get_user_variable_units, ) - - -def solver_variable_to_user_variable(item): - """Convert the solver variable to a user variable using the current unit system.""" - if isinstance(item, SolverVariable): - if unit_system_manager.current is None: - raise ValueError(f"Solver variable {item.name} cannot be used without a unit system.") - unit_system_name = unit_system_manager.current.name - name = item.name.split(".")[-1] if "." in item.name else item.name - return UserVariable(name=f"{name}_{unit_system_name}", value=item) - return item - - -def get_referenced_expressions_and_user_variables(param_as_dict: dict): - """ - Get all the expressions that are mentioned/referenced in the params dict - (excluding the ones that are in the asset cache) - Two sources: - 1. Field is `Expression`. - 2. Field is `UserVariable` and `value` is an `Expression`. - `Expression` and `UserVariable` are both identified by their schema. - """ - - def _is_user_variable(field: dict) -> bool: - return "type_name" in field and field["type_name"] == "UserVariable" - - def _is_expression(field: dict) -> bool: - if "type_name" in field and field["type_name"] == "expression": - return True - if sorted(field.keys()) == ["expression", "output_units"] or sorted(field.keys()) == [ - "expression" - ]: - return True - return False - - def _get_dependent_expressions( - expression: Expression, - dependent_expressions: set[str], - ) -> list[str]: - """ - Get all the expressions that are dependent on the given expression. - """ - for var in expression.user_variables(): - try: - if "." not in var.name and isinstance(var.value, Expression): - dependent_expressions.add(str(var.value)) - _get_dependent_expressions(var.value, dependent_expressions) - except ValueError: - # An undefined variable is found. Validation will handle this. - pass - - def _collect_expressions_recursive( - data, - used_expressions: set, - current_path: tuple[str, ...] = (), - exclude_paths: set[tuple[str, ...]] = ( - ("private_attribute_asset_cache", "variable_context"), - ), - ): - # pylint: disable=too-many-branches - """ - Recursively collect expressions from nested data structures. - - current_path tracks the traversal keys from the root. If current_path matches - any tuple in exclude_paths, the sub-tree is skipped. seen_ids prevents revisiting - the same object multiple times when shared references exist. - """ - if data is None or isinstance(data, (int, float, str, bool)): - return - - if current_path in exclude_paths: - return - - if isinstance(data, dict): - # Check if this dict is a UserVariable - if _is_user_variable(data): - variable_name = data.get("name", {}) - if "." in variable_name: - return - try: - value = default_context.get(variable_name) - if isinstance(value, Expression): - used_expressions.add(str(value)) - except ValueError: - # An undefined variable is found. Validation will handle this. - pass - - # Check if this dict is an Expression - elif _is_expression(data): - used_expressions.add(data.get("expression")) - - # Recursively process all values in the dict - for key, value in data.items(): - _collect_expressions_recursive( - value, - used_expressions, - current_path + (key,), - exclude_paths, - ) - - elif isinstance(data, list): - # Recursively process all items in the list - for idx, item in enumerate(data): - _collect_expressions_recursive( - item, - used_expressions, - current_path + (str(idx),), - exclude_paths, - ) - - if ( - "private_attribute_asset_cache" not in param_as_dict - or "variable_context" not in param_as_dict["private_attribute_asset_cache"] - ): - return [], [] - - used_expressions: set[str] = set() - _collect_expressions_recursive( - param_as_dict, - used_expressions, - current_path=(), - exclude_paths={("private_attribute_asset_cache", "variable_context")}, + from flow360_schema.framework.expression.variable import ( + save_user_variables as _schema_save_user_variables, ) - dependent_expressions = set() - - for expr in used_expressions: - _get_dependent_expressions(Expression(expression=expr), dependent_expressions) + post_processing_variables = get_post_processing_variables(params) + output_units = {} + if post_processing_variables: + output_units = { + name: str(unit) + for name, unit in _schema_batch_get_user_variable_units( + list(post_processing_variables), params.unit_system.name + ).items() + } - return list(used_expressions.union(dependent_expressions)) + result = _schema_save_user_variables( + variable_context=params.private_attribute_asset_cache.variable_context, + post_processing_names=post_processing_variables, + output_units=output_units, + ) + params.private_attribute_asset_cache.variable_context = result + return params def is_variable_with_unit_system_as_units(value: dict) -> bool: @@ -1642,30 +261,3 @@ def infer_units_by_unit_system(value: dict, unit_system: str, value_dimensions): if unit_system == "CGS_unit_system": value["units"] = u.unit_systems.cgs_unit_system[value_dimensions] return value - - -def compute_surface_integral_unit(variable: UserVariable, params) -> str: - """ - Compute the unit of the surface integral of a UserVariable over a surface. - """ - base_unit = None - if isinstance(variable.value, Expression): - base_unit = variable.value.get_output_units(params) - else: - val = variable.value - if hasattr(val, "get_output_units"): - base_unit = val.get_output_units(params) - elif isinstance(val, (unyt_array, unyt_quantity)): - base_unit = val.units - elif isinstance(val, Number): - base_unit = u.Unit("dimensionless") - else: - base_unit = u.Unit("dimensionless") - - if base_unit is None: - # Fallback if output_units is not set for expression or if it is a number - base_unit = u.Unit("dimensionless") - - area_unit = params.unit_system.resolve()["area"].units - result_unit = base_unit * area_unit - return str(result_unit) diff --git a/flow360/component/simulation/user_code/core/utils.py b/flow360/component/simulation/user_code/core/utils.py deleted file mode 100644 index 85fb66433..000000000 --- a/flow360/component/simulation/user_code/core/utils.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Utility functions for the user code module""" - -import re -from numbers import Number - -import numpy as np -import pydantic as pd -from pydantic_core import InitErrorDetails -from unyt import unyt_array, unyt_quantity - - -def is_number_string(s: str) -> bool: - """Check if the string represents a single scalar number""" - try: - float(s) - return True - except ValueError: - return False - - -def split_keep_delimiters(value: str, delimiters: list) -> list: - """split string but keep the delimiters""" - escaped_delimiters = [re.escape(d) for d in delimiters] - pattern = f"({'|'.join(escaped_delimiters)})" - result = re.split(pattern, value) - return [part for part in result if part != ""] - - -def handle_syntax_error(se: SyntaxError, source: str): - """Handle expression syntax error.""" - caret = " " * (se.offset - 1) + "^" if se.text and se.offset else None - msg = f"{se.msg} at line {se.lineno}, column {se.offset}" - if caret: - msg += f"\n{se.text.rstrip()}\n{caret}" - - raise pd.ValidationError.from_exception_data( - "expression_syntax", - [ - InitErrorDetails( - type="value_error", - msg=se.msg, - input=source, - ctx={ - "line": se.lineno, - "column": se.offset, - "error": msg, - }, - ) - ], - ) - - -def is_runtime_expression(value): - """Check if the input value is a runtime expression.""" - if isinstance(value, unyt_quantity) and np.isnan(value.value): - return True - if isinstance(value, unyt_array) and np.isnan(value.value).any(): - return True - if isinstance(value, Number) and np.isnan(value): - return True - if isinstance(value, list) and any(np.isnan(item) for item in value): - return any(np.isnan(item) for item in value) - return False - - -SOLVER_INTERNAL_VARIABLES = { - "bet_omega", - "bet_torque", - "bet_thrust", - "coordinate", - "primitiveVars", - "gradPrimitive", - "wallShearStress", - "wallViscousStress", - "nodeNormals", - "t", - "mut", - "mu", - "primitiveNonInertial", - "gradPrimitiveNonInertial", - "wallDistance", - "solutionNavierStokes", - "residualNavierStokes", - "solutionTransition", - "residualTransition", - "solutionHeatSolver", - "residualHeatSolver", - "theta", - "omega", - "omegaDot", - "previousTheta", - "yPlus", - "heatFlux", - "CL", - "CD", - "momentX", - "momentY", - "momentZ", - "forceX", - "forceY", - "forceZ", - "wallFunctionMetric", - "massFlowRate", - "staticPressureRatio", - "totalPressureRatio", - "area", - "hasSupersonicFlow", -} diff --git a/flow360/component/simulation/user_code/functions/__init__.py b/flow360/component/simulation/user_code/functions/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py deleted file mode 100644 index 90f90b84e..000000000 --- a/flow360/component/simulation/user_code/functions/math.py +++ /dev/null @@ -1,414 +0,0 @@ -""" -Math.h for Flow360 Expression system -""" - -from numbers import Number -from typing import Any, Literal, Union - -import numpy as np -from unyt import dimensions, unyt_array, unyt_quantity - -from flow360.component.simulation.user_code.core.types import ( - Expression, - Variable, - _check_list_items_are_same_dimensions, - _convert_numeric, - get_input_value_dimensions, -) - - -def _handle_expression_list(value: list[Any]): - is_expression_list = False - - for item in value: - if isinstance(item, Expression): - is_expression_list = True - - if is_expression_list: - value = Expression.model_validate(value) - - return value - - -VectorInputType = Union[list[float], unyt_array, Expression, Variable] -ScalarInputType = Union[float, unyt_quantity, Expression, Variable] - - -def _get_input_array_length(value): - try: - return len(value) - except Exception as e: - raise ValueError( - f"Cannot get length information for {value} but array-like input is expected." - ) from e - - -def _check_same_length(left: VectorInputType, right: VectorInputType, operation_name: str): - """For vector arithmetic operations, we need to check that the vectors have the same length.""" - left_length = _get_input_array_length(left) - right_length = _get_input_array_length(right) - if left_length != right_length: - raise ValueError( - f"Vectors ({left} | {right}) must have the same length to perform {operation_name} operation." - ) - - -def _check_same_dimensions( - value1: Union[ScalarInputType, VectorInputType], - value2: Union[ScalarInputType, VectorInputType], - operation_name: str, -): - """ - For certain scalar/vector arithmetic operations, - we need to check that the scalars/vectors have the same dimensions. - """ - - def _check_list_same_dimensions(value): - if not isinstance(value, list) or len(value) <= 1: - return - try: - _check_list_items_are_same_dimensions(value=value) - except ValueError: - # pylint:disable = raise-missing-from - raise ValueError( - f"Each item in the input value ({value}) must have the same dimensions " - f"to perform {operation_name} operation." - ) - - _check_list_same_dimensions(value=value1) - _check_list_same_dimensions(value=value2) - value1_dimensions = get_input_value_dimensions(value=value1) - value2_dimensions = get_input_value_dimensions(value=value2) - if value1_dimensions != value2_dimensions: - raise ValueError( - f"Input values ({value1} | {value2}) must have the same dimensions to perform {operation_name} operation." - ) - - -def _compare_operation_dimensions(value: Union[ScalarInputType, VectorInputType], ref_dimensions): - """ - For certain scalar/vector arithmetic operations, - we need to check that the scalar/vector has the specify dimensions. - """ - value_dimensions = get_input_value_dimensions(value=value) - if value_dimensions: - return value_dimensions == ref_dimensions - return False - - -def _check_same_dimensions( - value1: Union[ScalarInputType, VectorInputType], - value2: Union[ScalarInputType, VectorInputType], - operation_name: str, -): - """ - For certain scalar/vector arithmetic operations, - we need to check that the scalars/vectors have the same dimensions. - """ - - def _check_list_same_dimensions(value): - if not isinstance(value, list) or len(value) <= 1: - return - value_0_dim = get_input_value_dimensions(value=value[0]) - if not all(get_input_value_dimensions(value=item) == value_0_dim for item in value): - raise ValueError( - f"Each item in the input value ({value}) must have the same dimensions " - f"to perform {operation_name} operation." - ) - - _check_list_same_dimensions(value=value1) - _check_list_same_dimensions(value=value2) - value1_dimensions = get_input_value_dimensions(value=value1) - value2_dimensions = get_input_value_dimensions(value=value2) - if value1_dimensions != value2_dimensions: - raise ValueError( - f"Input values ({value1} | {value2}) must have the same dimensions to perform {operation_name} operation." - ) - - -def _check_value_dimensions( - value: Union[ScalarInputType, VectorInputType], - ref_dimensions: list, - operation_name: str, -): - """ - For certain scalar/vector arithmetic operations, - we need to check that the scalar/vector satisfies the specific dimensions. - """ - - if len(ref_dimensions) == 1: - dimensions_err_msg = str(ref_dimensions[0]) - else: - dimensions_err_msg = ( - "one of (" + ", ".join([str(dimension) for dimension in ref_dimensions]) + ")" - ) - - if not any( - _compare_operation_dimensions(value=value, ref_dimensions=dimension) - for dimension in ref_dimensions - ): - raise ValueError( - f"The dimensions of the input value ({value}) " - f"must be {dimensions_err_msg} to perform {operation_name} operation." - ) - - -def _create_min_max_expression( - value1: ScalarInputType, value2: ScalarInputType, operation_name: Literal["min", "max"] -): - _check_same_dimensions(value1=value1, value2=value2, operation_name=operation_name) - if isinstance(value1, (unyt_quantity, Number)) and isinstance(value2, (unyt_quantity, Number)): - return np.maximum(value1, value2) if operation_name == "max" else np.minimum(value1, value2) - if isinstance(value1, (Expression, Variable)) and isinstance(value2, unyt_quantity): - return Expression(expression=f"math.{operation_name}({value1},{_convert_numeric(value2)})") - if isinstance(value2, (Expression, Variable)) and isinstance(value1, unyt_quantity): - return Expression(expression=f"math.{operation_name}({_convert_numeric(value1)},{value2})") - return Expression(expression=f"math.{operation_name}({value1},{value2})") - - -def cross(left: VectorInputType, right: VectorInputType): - """Customized Cross function to work with the `Expression` and Variables""" - # Taking advantage of unyt as much as possible: - if isinstance(left, unyt_array) and isinstance(right, unyt_array): - return np.cross(left, right) - - _check_same_length(left, right, "cross product") - - if len(left) == 3: - result = [ - left[1] * right[2] - left[2] * right[1], - left[2] * right[0] - left[0] * right[2], - left[0] * right[1] - left[1] * right[0], - ] - elif len(left) == 2: - result = left[0] * right[1] - left[1] * right[0] - else: - raise ValueError(f"Vector length must be 2 or 3, got {len(left)}.") - - return _handle_expression_list(result) - - -def dot(left: VectorInputType, right: VectorInputType): - """Customized Dot function to work with the `Expression` and Variables""" - # Taking advantage of unyt as much as possible: - if isinstance(left, unyt_array) and isinstance(right, unyt_array): - return np.dot(left, right) - - _check_same_length(left, right, "dot product") - - result = left[0] * right[0] - for i in range(1, len(left)): - result += left[i] * right[i] - - return result - - -def magnitude(value: VectorInputType): - """Customized Magnitude function to work with the `Expression` and Variables""" - # Taking advantage of unyt as much as possible: - - _get_input_array_length(value) - - if isinstance(value, unyt_array): - return np.linalg.norm(value) - - # For solution variables and expressions, return an Expression - if isinstance(value, Expression) or ( - isinstance(value, Variable) and hasattr(value, "solver_name") - ): - return Expression(expression=f"math.magnitude({value})") - - # For regular lists/arrays and UserVariables, compute the magnitude - result = value[0] ** 2 - for i in range(1, len(value)): - result += value[i] ** 2 - - return result**0.5 - - -def add(left: VectorInputType, right: VectorInputType): - """Customized Add function to work with the `Expression` and Variables""" - # Taking advantage of unyt as much as possible: - if isinstance(left, unyt_array) and isinstance(right, unyt_array): - return left + right - - _check_same_length(left, right, "add") - _check_same_dimensions(left, right, "add") - - result = [left[i] + right[i] for i in range(len(left))] - return _handle_expression_list(result) - - -def subtract(left: VectorInputType, right: VectorInputType): - """Customized Subtract function to work with the `Expression` and Variables""" - # Taking advantage of unyt as much as possible: - if isinstance(left, unyt_array) and isinstance(right, unyt_array): - return left - right - - _check_same_length(left, right, "subtract") - _check_same_dimensions(left, right, "subtract") - - result = [left[i] - right[i] for i in range(len(left))] - return _handle_expression_list(result) - - -########## Scalar functions ########## -def ensure_scalar_input(func): - """Decorator to check if the input is a scalar and raise an error if so.""" - - def wrapper(*args, **kwargs): - - def is_scalar(input_value): - if isinstance(input_value, list): - return False - if isinstance(input_value, Number): - return True - if isinstance(input_value, unyt_quantity): - return input_value.shape == () - - try: - return len(input_value) == 0 - except Exception: # pylint: disable=broad-exception-caught - return False - - for arg in args: - if not is_scalar(arg): - raise ValueError(f"Scalar function ({func.__name__}) on {arg} not supported.") - return func(*args, **kwargs) - - return wrapper - - -@ensure_scalar_input -def sqrt(value: ScalarInputType): - """Customized Sqrt function to work with the `Expression` and Variables""" - if isinstance(value, (unyt_quantity, Number)): - return np.sqrt(value) - return Expression(expression=f"math.sqrt({value})") - - -@ensure_scalar_input -def log(value: ScalarInputType): - """Customized Log function to work with the `Expression` and Variables""" - _check_value_dimensions( - value=value, - ref_dimensions=[dimensions.dimensionless], - operation_name="log", - ) - if isinstance(value, (unyt_quantity, Number)): - return np.log(value) - return Expression(expression=f"math.log({value})") - - -@ensure_scalar_input -def exp(value: ScalarInputType): - """Customized Exponential function to work with the `Expression` and Variables""" - _check_value_dimensions( - value=value, - ref_dimensions=[dimensions.dimensionless], - operation_name="exp", - ) - if isinstance(value, (unyt_quantity, Number)): - return np.exp(value) - return Expression(expression=f"math.exp({value})") - - -@ensure_scalar_input -def sin(value: ScalarInputType): - """Customized Sine function to work with the `Expression` and Variables""" - _check_value_dimensions( - value=value, - ref_dimensions=[dimensions.angle, dimensions.dimensionless], - operation_name="sin", - ) - if isinstance(value, (unyt_quantity, Number)): - return np.sin(value) - return Expression(expression=f"math.sin({value})") - - -@ensure_scalar_input -def cos(value: ScalarInputType): - """Customized Cosine function to work with the `Expression` and Variables""" - _check_value_dimensions( - value=value, - ref_dimensions=[dimensions.angle, dimensions.dimensionless], - operation_name="cos", - ) - if isinstance(value, (unyt_quantity, Number)): - return np.cos(value) - return Expression(expression=f"math.cos({value})") - - -@ensure_scalar_input -def tan(value: ScalarInputType): - """Customized Tangent function to work with the `Expression` and Variables""" - _check_value_dimensions( - value=value, - ref_dimensions=[dimensions.angle, dimensions.dimensionless], - operation_name="tan", - ) - if isinstance(value, (unyt_quantity, Number)): - return np.tan(value) - return Expression(expression=f"math.tan({value})") - - -@ensure_scalar_input -def asin(value: ScalarInputType): - """Customized ArcSine function to work with the `Expression` and Variables""" - _check_value_dimensions( - value=value, - ref_dimensions=[dimensions.dimensionless], - operation_name="asin", - ) - if isinstance(value, (unyt_quantity, Number)): - return np.arcsin(value) - return Expression(expression=f"math.asin({value})") - - -@ensure_scalar_input -def acos(value: ScalarInputType): - """Customized ArcCosine function to work with the `Expression` and Variables""" - _check_value_dimensions( - value=value, - ref_dimensions=[dimensions.dimensionless], - operation_name="acos", - ) - if isinstance(value, (unyt_quantity, Number)): - return np.arccos(value) - return Expression(expression=f"math.acos({value})") - - -@ensure_scalar_input -def atan(value: ScalarInputType): - """Customized ArcTangent function to work with the `Expression` and Variables""" - _check_value_dimensions( - value=value, - ref_dimensions=[dimensions.dimensionless], - operation_name="atan", - ) - if isinstance(value, (unyt_quantity, Number)): - return np.arctan(value) - return Expression(expression=f"math.atan({value})") - - -@ensure_scalar_input -def min(value1: ScalarInputType, value2: ScalarInputType): # pylint: disable=redefined-builtin - """Customized Min function to work with the `Expression` and Variables""" - return _create_min_max_expression(value1, value2, "min") - - -@ensure_scalar_input -def max(value1: ScalarInputType, value2: ScalarInputType): # pylint: disable=redefined-builtin - """Customized Max function to work with the `Expression` and Variables""" - return _create_min_max_expression(value1, value2, "max") - - -@ensure_scalar_input -def abs(value: ScalarInputType): # pylint: disable=redefined-builtin - """Customized Absolute function to work with the `Expression` and Variables""" - if isinstance(value, (unyt_quantity, Number)): - return np.abs(value) - return Expression(expression=f"math.abs({value})") - - -pi = np.pi diff --git a/flow360/component/simulation/user_code/variables/__init__.py b/flow360/component/simulation/user_code/variables/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/flow360/component/simulation/user_code/variables/control.py b/flow360/component/simulation/user_code/variables/control.py deleted file mode 100644 index cdafc6ad9..000000000 --- a/flow360/component/simulation/user_code/variables/control.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Control variables of Flow360""" - -from flow360.component.simulation import units as u -from flow360.component.simulation.user_code.core.types import SolverVariable - -# pylint:disable=no-member -MachRef = SolverVariable( - name="control.MachRef", - value=float("NaN"), - solver_name="machRef", - variable_type="Scalar", -) # Reference mach specified by the user -Tref = SolverVariable( - name="control.Tref", value=float("NaN") * u.K, variable_type="Scalar" -) # Temperature specified by the user -t = SolverVariable( - name="control.t", value=float("NaN") * u.s, variable_type="Scalar" -) # Physical time -physicalStep = SolverVariable( - name="control.physicalStep", value=float("NaN"), variable_type="Scalar" -) # Physical time step, starting from 0 -pseudoStep = SolverVariable( - name="control.pseudoStep", value=float("NaN"), variable_type="Scalar" -) # Pseudo time step within physical time step -timeStepSize = SolverVariable( - name="control.timeStepSize", value=float("NaN") * u.s, variable_type="Scalar" -) # Physical time step size -alphaAngle = SolverVariable( - name="control.alphaAngle", value=float("NaN") * u.rad, variable_type="Scalar" -) # Alpha angle specified in freestream -betaAngle = SolverVariable( - name="control.betaAngle", value=float("NaN") * u.rad, variable_type="Scalar" -) # Beta angle specified in freestream -pressureFreestream = SolverVariable( - name="control.pressureFreestream", value=float("NaN") * u.Pa, variable_type="Scalar" -) # Freestream reference pressure (1.0/1.4) -momentLengthX = SolverVariable( - name="control.momentLengthX", value=float("NaN") * u.m, variable_type="Scalar" -) # X component of momentLength -momentLengthY = SolverVariable( - name="control.momentLengthY", value=float("NaN") * u.m, variable_type="Scalar" -) # Y component of momentLength -momentLengthZ = SolverVariable( - name="control.momentLengthZ", value=float("NaN") * u.m, variable_type="Scalar" -) # Z component of momentLength -momentCenterX = SolverVariable( - name="control.momentCenterX", value=float("NaN") * u.m, variable_type="Scalar" -) # X component of momentCenter -momentCenterY = SolverVariable( - name="control.momentCenterY", value=float("NaN") * u.m, variable_type="Scalar" -) # Y component of momentCenter -momentCenterZ = SolverVariable( - name="control.momentCenterZ", value=float("NaN") * u.m, variable_type="Scalar" -) # Z component of momentCenter -theta = SolverVariable( - name="control.theta", value=float("NaN") * u.rad, variable_type="Scalar" -) # Rotation angle of volume zone -omega = SolverVariable( - name="control.omega", value=float("NaN") * u.rad, variable_type="Scalar" -) # Rotation speed of volume zone -omegaDot = SolverVariable( - name="control.omegaDot", value=float("NaN") * u.rad / u.s, variable_type="Scalar" -) # Rotation acceleration of volume zone diff --git a/flow360/component/simulation/user_code/variables/solution.py b/flow360/component/simulation/user_code/variables/solution.py deleted file mode 100644 index 78f6d3a3d..000000000 --- a/flow360/component/simulation/user_code/variables/solution.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Solution variables of Flow360""" - -import unyt as u - -from flow360.component.simulation.user_code.core.types import SolverVariable - -# pylint:disable = fixme -# TODO:Scalar type (needs further discussion on how to handle scalar values) -# bet_thrust = SolverVariable( -# name="solution.bet_thrust", value=float("NaN") -# ) # Thrust force for BET disk -# bet_torque = SolverVariable(name="solution.bet_torque", value=float("NaN")) # Torque for BET disk -# bet_omega = SolverVariable( -# name="solution.bet_omega", value=float("NaN") -# ) # Rotation speed for BET disk -# CD = SolverVariable(name="solution.CD", value=float("NaN")) # Drag coefficient on patch -# CL = SolverVariable(name="solution.CL", value=float("NaN")) # Lift coefficient on patch -# forceX = SolverVariable(name="solution.forceX", value=float("NaN")) # Total force in X direction -# forceY = SolverVariable(name="solution.forceY", value=float("NaN")) # Total force in Y direction -# forceZ = SolverVariable(name="solution.forceZ", value=float("NaN")) # Total force in Z direction -# momentX = SolverVariable(name="solution.momentX", value=float("NaN")) # Total moment in X direction -# momentY = SolverVariable(name="solution.momentY", value=float("NaN")) # Total moment in Y direction -# momentZ = SolverVariable(name="solution.momentZ", value=float("NaN")) # Total moment in Z direction - - -# pylint:disable=no-member -# Common -coordinate = SolverVariable( - name="solution.coordinate", - value=[float("NaN"), float("NaN"), float("NaN")] * u.m, - solver_name="coordinate", - variable_type="Volume", -) # Grid coordinates - -Cp = SolverVariable( - name="solution.Cp", - value=float("NaN"), - solver_name="___Cp", - variable_type="Volume", -) -Cpt = SolverVariable( - name="solution.Cpt", - value=float("NaN"), - solver_name="___Cpt", - variable_type="Volume", -) -Cpt_auto = SolverVariable( - name="solution.Cpt_auto", - value=float("NaN"), - solver_name="___Cpt_auto", - variable_type="Volume", -) -grad_density = SolverVariable( - name="solution.grad_density", - value=[float("NaN"), float("NaN"), float("NaN")] * u.kg / u.m**4, - solver_name="___grad_density", - variable_type="Volume", -) -grad_u = SolverVariable( - name="solution.grad_u", - value=[float("NaN"), float("NaN"), float("NaN")] / u.s, - solver_name="___grad_u", - variable_type="Volume", -) -grad_v = SolverVariable( - name="solution.grad_v", - value=[float("NaN"), float("NaN"), float("NaN")] / u.s, - solver_name="___grad_v", - variable_type="Volume", -) -grad_w = SolverVariable( - name="solution.grad_w", - value=[float("NaN"), float("NaN"), float("NaN")] / u.s, - solver_name="___grad_w", - variable_type="Volume", -) -grad_pressure = SolverVariable( - name="solution.grad_pressure", - value=[float("NaN"), float("NaN"), float("NaN")] * u.Pa / u.m, - solver_name="___grad_pressure", - variable_type="Volume", -) - -Mach = SolverVariable( - name="solution.Mach", - value=float("NaN"), - solver_name="___Mach", - variable_type="Volume", -) -mut = SolverVariable( - name="solution.mut", - value=float("NaN") * u.kg / u.m / u.s, - solver_name="___mut", - variable_type="Volume", -) # Turbulent viscosity -mut_ratio = SolverVariable( - name="solution.mut_ratio", - value=float("NaN"), - solver_name="___mut_ratio", - variable_type="Volume", -) -nu_hat = SolverVariable( - name="solution.nu_hat", - value=float("NaN") * u.m**2 / u.s, - solver_name="___nu_hat", - variable_type="Volume", -) -turbulence_kinetic_energy = SolverVariable( - name="solution.turbulence_kinetic_energy", - value=float("NaN") * u.J / u.kg, - solver_name="___turbulence_kinetic_energy", - variable_type="Volume", -) # k -specific_rate_of_dissipation = SolverVariable( - name="solution.specific_rate_of_dissipation", - value=float("NaN") / u.s, - solver_name="___specific_rate_of_dissipation", - variable_type="Volume", -) # Omega -amplification_factor = SolverVariable( - name="solution.amplification_factor", - value=float("NaN"), - solver_name="___amplification_factor", - variable_type="Volume", -) # transition model variable: n, non-dimensional -turbulence_intermittency = SolverVariable( - name="solution.turbulence_intermittency", - value=float("NaN"), - solver_name="___turbulence_intermittency", - variable_type="Volume", -) # transition model variable: gamma, non-dimensional - - -density = SolverVariable( - name="solution.density", - value=float("NaN") * u.kg / u.m**3, - solver_name="___density", - variable_type="Volume", -) -velocity = SolverVariable( - name="solution.velocity", - value=[float("NaN"), float("NaN"), float("NaN")] * u.m / u.s, - solver_name="___velocity", - variable_type="Volume", -) -pressure = SolverVariable( - name="solution.pressure", - value=float("NaN") * u.Pa, - solver_name="___pressure", - variable_type="Volume", -) - -qcriterion = SolverVariable( - name="solution.qcriterion", - value=float("NaN") / u.s**2, - solver_name="___qcriterion", - variable_type="Volume", -) -entropy = SolverVariable( - name="solution.entropy", - value=float("NaN") * u.J / u.K, - solver_name="___entropy", - variable_type="Volume", -) -temperature = SolverVariable( - name="solution.temperature", - value=float("NaN") * u.K, - solver_name="___temperature", - variable_type="Volume", -) -vorticity = SolverVariable( - name="solution.vorticity", - value=[float("NaN"), float("NaN"), float("NaN")] / u.s, - solver_name="___vorticity", - variable_type="Volume", -) -wall_distance = SolverVariable( - name="solution.wall_distance", - value=float("NaN") * u.m, - solver_name="wallDistance", - variable_type="Volume", -) - -# Surface -CfVec = SolverVariable( - name="solution.CfVec", - value=[float("NaN"), float("NaN"), float("NaN")], - solver_name="___CfVec", - variable_type="Surface", -) -Cf = SolverVariable( - name="solution.Cf", - value=float("NaN"), - solver_name="___Cf", - variable_type="Surface", -) -heat_flux = SolverVariable( - name="solution.heat_flux", - value=float("NaN") * u.W / u.m**2, - solver_name="heatFlux", - variable_type="Surface", -) -node_area_vector = SolverVariable( - name="solution.node_area_vector", - value=[float("NaN"), float("NaN"), float("NaN")] * u.m**2, - solver_name="nodeNormals", - variable_type="Surface", -) -node_unit_normal = SolverVariable( - name="solution.node_unit_normal", - value=[float("NaN"), float("NaN"), float("NaN")], - solver_name="___node_unit_normal", - variable_type="Surface", -) -node_forces_per_unit_area = SolverVariable( - name="solution.node_forces_per_unit_area", - value=[float("NaN"), float("NaN"), float("NaN")] * u.Pa, - solver_name="___node_forces_per_unit_area", - variable_type="Surface", -) -y_plus = SolverVariable( - name="solution.y_plus", value=float("NaN"), solver_name="yPlus", variable_type="Surface" -) -wall_shear_stress_magnitude = SolverVariable( - name="solution.wall_shear_stress_magnitude", - value=float("NaN") * u.Pa, - solver_name="___wall_shear_stress_magnitude", - variable_type="Surface", -) -heat_transfer_coefficient_static_temperature = SolverVariable( - name="solution.heat_transfer_coefficient_static_temperature", - value=float("NaN") * u.W / (u.m**2 * u.K), - solver_name="___heat_transfer_coefficient_static_temperature", - variable_type="Surface", -) -heat_transfer_coefficient_total_temperature = SolverVariable( - name="solution.heat_transfer_coefficient_total_temperature", - value=float("NaN") * u.W / (u.m**2 * u.K), - solver_name="___heat_transfer_coefficient_total_temperature", - variable_type="Surface", -) - -# TODO -# pylint:disable = fixme -# velocity_relative = SolverVariable( -# name="solution.velocity_relative", -# value=[float("NaN"), float("NaN"), float("NaN")] * u.m / u.s, -# solver_name="velocityRelative", -# prepending_code="double velocityRelative[3];for(int i=0;i<3;i++)" -# + "{velocityRelative[i]=velocity[i]-nodeVelocity[i];}", -# variable_type="Volume", -# ) -# wallFunctionMetric = SolverVariable( -# name="solution.wallFunctionMetric", value=float("NaN"), variable_type="Surface" -# ) -# bet_metrics_alpha_degree = SolverVariable( -# name="solution.bet_metrics_alpha_degree", value=float("NaN") * u.deg, variable_type="Volume" -# ) -# bet_metrics_Cf_axial = SolverVariable( -# name="solution.bet_metrics_Cf_axial", value=float("NaN"), variable_type="Volume" -# ) -# bet_metrics_Cf_circumferential = SolverVariable( -# name="solution.bet_metrics_Cf_circumferential", value=float("NaN"), variable_type="Volume" -# ) -# bet_metrics_local_solidity_integral_weight = SolverVariable( -# name="solution.bet_metrics_local_solidity_integral_weight", -# value=float("NaN"), -# variable_type="Volume", -# ) -# bet_metrics_tip_loss_factor = SolverVariable( -# name="solution.bet_metrics_tip_loss_factor", value=float("NaN"), variable_type="Volume" -# ) -# bet_metrics_velocity_relative = SolverVariable( -# name="solution.bet_metrics_velocity_relative", -# value=[float("NaN"), float("NaN"), float("NaN")] * u.m / u.s, -# variable_type="Volume", -# ) -# betMetricsPerDisk = SolverVariable( -# name="solution.betMetricsPerDisk", value=float("NaN"), variable_type="Volume" -# ) - - -# Abandoned (Possible) -# SpalartAllmaras_hybridModel = SolverVariable( -# name="solution.SpalartAllmaras_hybridModel", value=float("NaN"), variable_type="Volume" -# ) -# kOmegaSST_hybridModel = SolverVariable( -# name="solution.kOmegaSST_hybridModel", value=float("NaN"), variable_type="Volume" -# ) -# localCFL = SolverVariable(name="solution.localCFL", value=float("NaN"), variable_type="Volume") -# numericalDissipationFactor = SolverVariable( -# name="solution.numericalDissipationFactor", value=float("NaN"), variable_type="Volume" -# ) -# lowMachPreconditionerSensor = SolverVariable( -# name="solution.lowMachPreconditionerSensor", value=float("NaN"), variable_type="Volume" -# ) - -# Abandoned -# linearResidualNavierStokes = SolverVariable( -# name="solution.linearResidualNavierStokes", value=float("NaN"), variable_type="Volume" -# ) -# linearResidualTurbulence = SolverVariable( -# name="solution.linearResidualTurbulence", value=float("NaN"), variable_type="Volume" -# ) -# linearResidualTransition = SolverVariable( -# name="solution.linearResidualTransition", value=float("NaN"), variable_type="Volume" -# ) -# residualNavierStokes = SolverVariable( -# name="solution.residualNavierStokes", value=float("NaN"), variable_type="Volume" -# ) -# residualTransition = SolverVariable( -# name="solution.residualTransition", value=float("NaN"), variable_type="Volume" -# ) -# residualTurbulence = SolverVariable( -# name="solution.residualTurbulence", value=float("NaN"), variable_type="Volume" -# ) -# solutionNavierStokes = SolverVariable( -# name="solution.solutionNavierStokes", value=float("NaN"), variable_type="Volume" -# ) -# solutionTurbulence = SolverVariable( -# name="solution.solutionTurbulence", value=float("NaN"), variable_type="Volume" -# ) -# residualHeatSolver = SolverVariable( -# name="solution.residualHeatSolver", value=float("NaN"), variable_type="Volume" -# ) -# velocity_x = SolverVariable(name="solution.velocity_x", value=float("NaN"), variable_type="Volume") -# velocity_y = SolverVariable(name="solution.velocity_y", value=float("NaN"), variable_type="Volume") -# velocity_z = SolverVariable(name="solution.velocity_z", value=float("NaN"), variable_type="Volume") -# velocity_magnitude = SolverVariable( -# name="solution.velocity_magnitude", value=float("NaN"), variable_type="Volume" -# ) -# vorticityMagnitude = SolverVariable( -# name="solution.vorticityMagnitude", value=float("NaN"), variable_type="Volume" -# ) -# vorticity_x = SolverVariable( -# name="solution.vorticity_x", value=float("NaN"), variable_type="Volume" -# ) -# vorticity_y = SolverVariable( -# name="solution.vorticity_y", value=float("NaN"), variable_type="Volume" -# ) -# vorticity_z = SolverVariable( -# name="solution.vorticity_z", value=float("NaN"), variable_type="Volume" -# ) -# wall_shear_stress_magnitude_pa = SolverVariable( -# name="solution.wall_shear_stress_magnitude_pa", value=float("NaN"), variable_type="Surface" -# ) diff --git a/flow360/component/simulation/validation/validation_context.py b/flow360/component/simulation/validation/validation_context.py index aa93133f6..c34e13c80 100644 --- a/flow360/component/simulation/validation/validation_context.py +++ b/flow360/component/simulation/validation/validation_context.py @@ -15,7 +15,6 @@ information, enabling downstream processes to filter and interpret errors based on scenario-specific requirements. """ -import contextvars import inspect from enum import Enum from functools import wraps @@ -24,9 +23,11 @@ import pydantic as pd from flow360_schema.framework.physical_dimensions import Length -from flow360_schema.framework.validation.context import ( # noqa: F401 — re-used, not redefined +from flow360_schema.framework.validation.context import ( DeserializationContext, + _validation_info_ctx, _validation_level_ctx, + _validation_warnings_ctx, ) from pydantic import Field, TypeAdapter @@ -108,10 +109,6 @@ def __init__(self, param_as_dict: dict): self.bet_disk_count += 1 -_validation_info_ctx = contextvars.ContextVar("validation_info", default=None) -_validation_warnings_ctx = contextvars.ContextVar("validation_warnings", default=None) - - class ParamsValidationInfo: # pylint:disable=too-few-public-methods,too-many-instance-attributes """ Model that provides the information for each individual validator that is out of their scope. diff --git a/flow360/component/simulation/validation/validation_output.py b/flow360/component/simulation/validation/validation_output.py index 08e109f34..b95216a14 100644 --- a/flow360/component/simulation/validation/validation_output.py +++ b/flow360/component/simulation/validation/validation_output.py @@ -5,6 +5,8 @@ import math from typing import List, Literal, Union, get_args, get_origin +from flow360_schema.framework.expression import Expression + from flow360.component.simulation.models.volume_models import Fluid from flow360.component.simulation.outputs.outputs import ( AeroAcousticOutput, @@ -15,7 +17,6 @@ TimeAverageForceDistributionOutput, ) from flow360.component.simulation.time_stepping.time_stepping import Steady -from flow360.component.simulation.user_code.core.types import Expression from flow360.component.simulation.validation.validation_utils import ( customize_model_validator_error, ) diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index 0caf69f3b..cf0cb06b5 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -5,6 +5,7 @@ from functools import wraps from typing import Any, Tuple, Union, get_args +from flow360_schema.framework.expression import Expression, UserVariable from pydantic import ValidationError from pydantic_core import InitErrorDetails @@ -16,7 +17,6 @@ _SurfaceEntityBase, _VolumeEntityBase, ) -from flow360.component.simulation.user_code.core.types import Expression, UserVariable def _validator_append_instance_name(func): diff --git a/flow360/log.py b/flow360/log.py index 32ea50b2c..1f7b497e0 100644 --- a/flow360/log.py +++ b/flow360/log.py @@ -1,5 +1,6 @@ """Logging for Flow360.""" +import logging as _logging import os from datetime import datetime from typing import Union @@ -335,6 +336,26 @@ def toggle_rotation(rotate: bool): # Set default logging output set_logging_console() + +# Bridge stdlib logging from flow360_schema to Flow360's custom Logger. +# Schema package uses logging.getLogger(__name__); this handler routes +# those messages through our rich-based Logger so presentation is consistent. + + +class _BridgeHandler(_logging.Handler): + """Routes stdlib logging records to Flow360's custom Logger.""" + + def emit(self, record: _logging.LogRecord) -> None: + level_name = record.levelname.upper() + level = _get_level_int(level_name) + log._log(level, level_name, record.getMessage()) # pylint:disable = protected-access + + +_schema_logger = _logging.getLogger("flow360_schema") +_schema_logger.addHandler(_BridgeHandler()) +_schema_logger.setLevel(_logging.DEBUG) + + log_dir = flow360_dir + "logs" try: if not os.path.exists(log_dir): diff --git a/poetry.lock b/poetry.lock index 2d620f5af..7b55f6860 100644 --- a/poetry.lock +++ b/poetry.lock @@ -47,14 +47,14 @@ files = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, - {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, + {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, + {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, ] markers = {main = "extra == \"docs\""} @@ -64,7 +64,7 @@ idna = ">=2.8" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] +trio = ["trio (>=0.32.0)"] [[package]] name = "apeye" @@ -229,14 +229,14 @@ test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"] [[package]] name = "async-lru" -version = "2.2.0" +version = "2.3.0" description = "Simple LRU cache for asyncio" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "async_lru-2.2.0-py3-none-any.whl", hash = "sha256:e2c1cf731eba202b59c5feedaef14ffd9d02ad0037fcda64938699f2c380eafe"}, - {file = "async_lru-2.2.0.tar.gz", hash = "sha256:80abae2a237dbc6c60861d621619af39f0d920aea306de34cb992c879e01370c"}, + {file = "async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315"}, + {file = "async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6"}, ] markers = {main = "extra == \"docs\""} @@ -245,14 +245,14 @@ typing_extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, - {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] markers = {main = "extra == \"docs\""} @@ -475,18 +475,18 @@ css = ["tinycss2 (>=1.1.0,<1.5)"] [[package]] name = "boto3" -version = "1.42.64" +version = "1.42.76" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "boto3-1.42.64-py3-none-any.whl", hash = "sha256:2ca6b472937a54ba74af0b4bede582ba98c070408db1061fc26d5c3aa8e6e7e6"}, - {file = "boto3-1.42.64.tar.gz", hash = "sha256:58d47897a26adbc22f6390d133dab772fb606ba72695291a8c9e20cba1c7fd23"}, + {file = "boto3-1.42.76-py3-none-any.whl", hash = "sha256:63c6779c814847016b89ae1b72ed968f8a63d80e589ba337511aa6fc1b59585e"}, + {file = "boto3-1.42.76.tar.gz", hash = "sha256:aa2b1973eee8973a9475d24bb579b1dee7176595338d4e4f7880b5c6189b8814"}, ] [package.dependencies] -botocore = ">=1.42.64,<1.43.0" +botocore = ">=1.42.76,<1.43.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.16.0,<0.17.0" @@ -495,14 +495,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.42.64" +version = "1.42.76" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "botocore-1.42.64-py3-none-any.whl", hash = "sha256:f77c5cb76ed30576ed0bc73b591265d03dddffff02a9208d3ee0c790f43d3cd2"}, - {file = "botocore-1.42.64.tar.gz", hash = "sha256:4ee2aece227b9171ace8b749af694a77ab984fceab1639f2626bd0d6fb1aa69d"}, + {file = "botocore-1.42.76-py3-none-any.whl", hash = "sha256:151e714ae3c32f68ea0b4dc60751401e03f84a87c6cf864ea0ee64aa10eb4607"}, + {file = "botocore-1.42.76.tar.gz", hash = "sha256:c553fa0ae29e36a5c407f74da78b78404b81b74b15fb62bf640a3cd9385f0874"}, ] [package.dependencies] @@ -571,15 +571,15 @@ xcb = ["xcffib (>=1.4.0)"] [[package]] name = "cairosvg" -version = "2.8.2" +version = "2.9.0" description = "A Simple SVG Converter based on Cairo" optional = true -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "extra == \"docs\"" files = [ - {file = "cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5"}, - {file = "cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f"}, + {file = "cairosvg-2.9.0-py3-none-any.whl", hash = "sha256:4b82d07d145377dffdfc19d9791bd5fb65539bb4da0adecf0bdbd9cd4ffd7c68"}, + {file = "cairosvg-2.9.0.tar.gz", hash = "sha256:1debb00cd2da11350d8b6f5ceb739f1b539196d71d5cf5eb7363dbd1bfbc8dc5"}, ] [package.dependencies] @@ -705,125 +705,141 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.5" +version = "3.4.6" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05"}, - {file = "charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a"}, - {file = "charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636"}, - {file = "charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7"}, - {file = "charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa"}, - {file = "charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e22d1059b951e7ae7c20ef6b06afd10fb95e3c41bf3c4fbc874dba113321c193"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afca7f78067dd27c2b848f1b234623d26b87529296c6c5652168cc1954f2f3b2"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec56a2266f32bc06ed3c3e2a8f58417ce02f7e0356edc89786e52db13c593c98"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b970382e4a36bed897c19f310f31d7d13489c11b4f468ddfba42d41cddfb918"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:573ef5814c4b7c0d59a7710aa920eaaaef383bd71626aa420fba27b5cab92e8d"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:50bcbca6603c06a1dcc7b056ed45c37715fb5d2768feb3bcd37d2313c587a5b9"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f2da5cbb9becfcd607757a169e38fb82aa5fd86fae6653dea716e7b613fe2cf"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:fc1c64934b8faf7584924143eb9db4770bbdb16659626e1a1a4d9efbcb68d947"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ae8b03427410731469c4033934cf473426faff3e04b69d2dfb64a4281a3719f8"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:b3e71afc578b98512bfe7bdb822dd6bc57d4b0093b4b6e5487c1e96ad4ace242"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:4b8551b6e6531e156db71193771c93bda78ffc4d1e6372517fe58ad3b91e4659"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:65b3c403a5b6b8034b655e7385de4f72b7b244869a22b32d4030b99a60593eca"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8ce11cd4d62d11166f2b441e30ace226c19a3899a7cf0796f668fba49a9fb123"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-win32.whl", hash = "sha256:66dee73039277eb35380d1b82cccc69cc82b13a66f9f4a18da32d573acf02b7c"}, - {file = "charset_normalizer-3.4.5-cp38-cp38-win_amd64.whl", hash = "sha256:d29dd9c016f2078b43d0c357511e87eee5b05108f3dd603423cb389b89813969"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-win32.whl", hash = "sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc"}, - {file = "charset_normalizer-3.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4"}, - {file = "charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0"}, - {file = "charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"}, + {file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"}, + {file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"}, ] [[package]] @@ -1077,118 +1093,118 @@ test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist" [[package]] name = "coverage" -version = "7.13.4" +version = "7.13.5" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415"}, - {file = "coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def"}, - {file = "coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58"}, - {file = "coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9"}, - {file = "coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf"}, - {file = "coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95"}, - {file = "coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053"}, - {file = "coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef"}, - {file = "coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6"}, - {file = "coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9"}, - {file = "coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9"}, - {file = "coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f"}, - {file = "coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f"}, - {file = "coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459"}, - {file = "coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3"}, - {file = "coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985"}, - {file = "coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0"}, - {file = "coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246"}, - {file = "coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126"}, - {file = "coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d"}, - {file = "coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9"}, - {file = "coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242"}, - {file = "coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea"}, - {file = "coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a"}, - {file = "coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d"}, - {file = "coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd"}, - {file = "coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af"}, - {file = "coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d"}, - {file = "coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9"}, - {file = "coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0"}, - {file = "coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b"}, - {file = "coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9"}, - {file = "coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd"}, - {file = "coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997"}, - {file = "coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601"}, - {file = "coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a"}, - {file = "coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5"}, - {file = "coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0"}, - {file = "coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb"}, - {file = "coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505"}, - {file = "coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2"}, - {file = "coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056"}, - {file = "coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72"}, - {file = "coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39"}, - {file = "coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0"}, - {file = "coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea"}, - {file = "coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932"}, - {file = "coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b"}, - {file = "coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0"}, - {file = "coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, + {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, + {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, + {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, + {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, + {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, + {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, + {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, + {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, + {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, + {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, + {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, + {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, + {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, + {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, + {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, + {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, + {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, + {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, + {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, + {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, + {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, + {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, ] [package.dependencies] @@ -1439,27 +1455,27 @@ devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benc [[package]] name = "filelock" -version = "3.25.1" +version = "3.25.2" description = "A platform independent file lock." optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"docs\"" files = [ - {file = "filelock-3.25.1-py3-none-any.whl", hash = "sha256:18972df45473c4aa2c7921b609ee9ca4925910cc3a0fb226c96b92fc224ef7bf"}, - {file = "filelock-3.25.1.tar.gz", hash = "sha256:b9a2e977f794ef94d77cdf7d27129ac648a61f585bff3ca24630c1629f701aa9"}, + {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, + {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, ] [[package]] name = "flow360-schema" -version = "0.1.13+feat.expression.migration.f178504" +version = "0.1.16" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.13+feat.expression.migration.f178504-py3-none-any.whl", hash = "sha256:d65029673ebcd87d85247f541ef25a71de9e45cfbb1d32cad7351c1e7785df2b"}, - {file = "flow360_schema-0.1.13+feat.expression.migration.f178504.tar.gz", hash = "sha256:442148fc070d2ec2bf003eb0a6472003a07158b9d42afd315a4f25e17a73fd05"}, + {file = "flow360_schema-0.1.16-py3-none-any.whl", hash = "sha256:5080087b66b1ee7b4f4df02a64d05e3b67fcdae322b142f09c1f4ab5ee4f7c72"}, + {file = "flow360_schema-0.1.16.tar.gz", hash = "sha256:266e6fb766c13da9057d14ca72c554ed17f16b5d8906ed23447d1310c5595d78"}, ] [package.dependencies] @@ -1475,62 +1491,62 @@ reference = "codeartifact" [[package]] name = "fonttools" -version = "4.62.0" +version = "4.62.1" description = "Tools to manipulate font files" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fonttools-4.62.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:62b6a3d0028e458e9b59501cf7124a84cd69681c433570e4861aff4fb54a236c"}, - {file = "fonttools-4.62.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:966557078b55e697f65300b18025c54e872d7908d1899b7314d7c16e64868cb2"}, - {file = "fonttools-4.62.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf34861145b516cddd19b07ae6f4a61ea1c6326031b960ec9ddce8ee815e888"}, - {file = "fonttools-4.62.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e2ff573de2775508c8a366351fb901c4ced5dc6cf2d87dd15c973bedcdd5216"}, - {file = "fonttools-4.62.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:55b189a1b3033860a38e4e5bd0626c5aa25c7ce9caee7bc784a8caec7a675401"}, - {file = "fonttools-4.62.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:825f98cd14907c74a4d0a3f7db8570886ffce9c6369fed1385020febf919abf6"}, - {file = "fonttools-4.62.0-cp310-cp310-win32.whl", hash = "sha256:c858030560f92a054444c6e46745227bfd3bb4e55383c80d79462cd47289e4b5"}, - {file = "fonttools-4.62.0-cp310-cp310-win_amd64.whl", hash = "sha256:9bf75eb69330e34ad2a096fac67887102c8537991eb6cac1507fc835bbb70e0a"}, - {file = "fonttools-4.62.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:196cafef9aeec5258425bd31a4e9a414b2ee0d1557bca184d7923d3d3bcd90f9"}, - {file = "fonttools-4.62.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:153afc3012ff8761b1733e8fbe5d98623409774c44ffd88fbcb780e240c11d13"}, - {file = "fonttools-4.62.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13b663fb197334de84db790353d59da2a7288fd14e9be329f5debc63ec0500a5"}, - {file = "fonttools-4.62.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:591220d5333264b1df0d3285adbdfe2af4f6a45bbf9ca2b485f97c9f577c49ff"}, - {file = "fonttools-4.62.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:579f35c121528a50c96bf6fcb6a393e81e7f896d4326bf40e379f1c971603db9"}, - {file = "fonttools-4.62.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:44956b003151d5a289eba6c71fe590d63509267c37e26de1766ba15d9c589582"}, - {file = "fonttools-4.62.0-cp311-cp311-win32.whl", hash = "sha256:42c7848fa8836ab92c23b1617c407a905642521ff2d7897fe2bf8381530172f1"}, - {file = "fonttools-4.62.0-cp311-cp311-win_amd64.whl", hash = "sha256:4da779e8f342a32856075ddb193b2a024ad900bc04ecb744014c32409ae871ed"}, - {file = "fonttools-4.62.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22bde4dc12a9e09b5ced77f3b5053d96cf10c4976c6ac0dee293418ef289d221"}, - {file = "fonttools-4.62.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7199c73b326bad892f1cb53ffdd002128bfd58a89b8f662204fbf1daf8d62e85"}, - {file = "fonttools-4.62.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d732938633681d6e2324e601b79e93f7f72395ec8681f9cdae5a8c08bc167e72"}, - {file = "fonttools-4.62.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31a804c16d76038cc4e3826e07678efb0a02dc4f15396ea8e07088adbfb2578e"}, - {file = "fonttools-4.62.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:090e74ac86e68c20150e665ef8e7e0c20cb9f8b395302c9419fa2e4d332c3b51"}, - {file = "fonttools-4.62.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f086120e8be9e99ca1288aa5ce519833f93fe0ec6ebad2380c1dee18781f0b5"}, - {file = "fonttools-4.62.0-cp312-cp312-win32.whl", hash = "sha256:37a73e5e38fd05c637daede6ffed5f3496096be7df6e4a3198d32af038f87527"}, - {file = "fonttools-4.62.0-cp312-cp312-win_amd64.whl", hash = "sha256:658ab837c878c4d2a652fcbb319547ea41693890e6434cf619e66f79387af3b8"}, - {file = "fonttools-4.62.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:274c8b8a87e439faf565d3bcd3f9f9e31bca7740755776a4a90a4bfeaa722efa"}, - {file = "fonttools-4.62.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93e27131a5a0ae82aaadcffe309b1bae195f6711689722af026862bede05c07c"}, - {file = "fonttools-4.62.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83c6524c5b93bad9c2939d88e619fedc62e913c19e673f25d5ab74e7a5d074e5"}, - {file = "fonttools-4.62.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:106aec9226f9498fc5345125ff7200842c01eda273ae038f5049b0916907acee"}, - {file = "fonttools-4.62.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15d86b96c79013320f13bc1b15f94789edb376c0a2d22fb6088f33637e8dfcbc"}, - {file = "fonttools-4.62.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f16c07e5250d5d71d0f990a59460bc5620c3cc456121f2cfb5b60475699905f"}, - {file = "fonttools-4.62.0-cp313-cp313-win32.whl", hash = "sha256:d31558890f3fa00d4f937d12708f90c7c142c803c23eaeb395a71f987a77ebe3"}, - {file = "fonttools-4.62.0-cp313-cp313-win_amd64.whl", hash = "sha256:6826a5aa53fb6def8a66bf423939745f415546c4e92478a7c531b8b6282b6c3b"}, - {file = "fonttools-4.62.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4fa5a9c716e2f75ef34b5a5c2ca0ee4848d795daa7e6792bf30fd4abf8993449"}, - {file = "fonttools-4.62.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:625f5cbeb0b8f4e42343eaeb4bc2786718ddd84760a2f5e55fdd3db049047c00"}, - {file = "fonttools-4.62.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6247e58b96b982709cd569a91a2ba935d406dccf17b6aa615afaed37ac3856aa"}, - {file = "fonttools-4.62.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:840632ea9c1eab7b7f01c369e408c0721c287dfd7500ab937398430689852fd1"}, - {file = "fonttools-4.62.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:28a9ea2a7467a816d1bec22658b0cce4443ac60abac3e293bdee78beb74588f3"}, - {file = "fonttools-4.62.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ae611294f768d413949fd12693a8cba0e6332fbc1e07aba60121be35eac68d0"}, - {file = "fonttools-4.62.0-cp314-cp314-win32.whl", hash = "sha256:273acb61f316d07570a80ed5ff0a14a23700eedbec0ad968b949abaa4d3f6bb5"}, - {file = "fonttools-4.62.0-cp314-cp314-win_amd64.whl", hash = "sha256:a5f974006d14f735c6c878fc4b117ad031dc93638ddcc450ca69f8fd64d5e104"}, - {file = "fonttools-4.62.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0361a7d41d86937f1f752717c19f719d0fde064d3011038f9f19bdf5fc2f5c95"}, - {file = "fonttools-4.62.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4108c12773b3c97aa592311557c405d5b4fc03db2b969ed928fcf68e7b3c887"}, - {file = "fonttools-4.62.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b448075f32708e8fb377fe7687f769a5f51a027172c591ba9a58693631b077a8"}, - {file = "fonttools-4.62.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5f1fa8cc9f1a56a3e33ee6b954d6d9235e6b9d11eb7a6c9dfe2c2f829dc24db"}, - {file = "fonttools-4.62.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f8c8ea812f82db1e884b9cdb663080453e28f0f9a1f5027a5adb59c4cc8d38d1"}, - {file = "fonttools-4.62.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:03c6068adfdc67c565d217e92386b1cdd951abd4240d65180cec62fa74ba31b2"}, - {file = "fonttools-4.62.0-cp314-cp314t-win32.whl", hash = "sha256:d28d5baacb0017d384df14722a63abe6e0230d8ce642b1615a27d78ffe3bc983"}, - {file = "fonttools-4.62.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3f9e20c4618f1e04190c802acae6dc337cb6db9fa61e492fd97cd5c5a9ff6d07"}, - {file = "fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3"}, - {file = "fonttools-4.62.0.tar.gz", hash = "sha256:0dc477c12b8076b4eb9af2e440421b0433ffa9e1dcb39e0640a6c94665ed1098"}, + {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"}, + {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"}, + {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3"}, + {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23"}, + {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d"}, + {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae"}, + {file = "fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed"}, + {file = "fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9"}, + {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7"}, + {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14"}, + {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7"}, + {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b"}, + {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1"}, + {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416"}, + {file = "fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53"}, + {file = "fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2"}, + {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974"}, + {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9"}, + {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936"}, + {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392"}, + {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04"}, + {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d"}, + {file = "fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c"}, + {file = "fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42"}, + {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79"}, + {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe"}, + {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68"}, + {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1"}, + {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069"}, + {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9"}, + {file = "fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24"}, + {file = "fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056"}, + {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca"}, + {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca"}, + {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782"}, + {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae"}, + {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7"}, + {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a"}, + {file = "fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800"}, + {file = "fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e"}, + {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82"}, + {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260"}, + {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4"}, + {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b"}, + {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87"}, + {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c"}, + {file = "fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a"}, + {file = "fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e"}, + {file = "fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd"}, + {file = "fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d"}, ] [package.extras] @@ -2148,14 +2164,14 @@ markers = {main = "extra == \"docs\""} [[package]] name = "jsonpointer" -version = "3.0.0" -description = "Identify specific nodes in a JSON document (RFC 6901)" +version = "3.1.1" +description = "Identify specific nodes in a JSON document (RFC 6901) " optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, - {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, + {file = "jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca"}, + {file = "jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900"}, ] markers = {main = "extra == \"docs\""} @@ -2405,14 +2421,14 @@ test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (> [[package]] name = "jupyterlab" -version = "4.5.5" +version = "4.5.6" description = "JupyterLab computational environment" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "jupyterlab-4.5.5-py3-none-any.whl", hash = "sha256:a35694a40a8e7f2e82f387472af24e61b22adcce87b5a8ab97a5d9c486202a6d"}, - {file = "jupyterlab-4.5.5.tar.gz", hash = "sha256:eac620698c59eb810e1729909be418d9373d18137cac66637141abba613b3fda"}, + {file = "jupyterlab-4.5.6-py3-none-any.whl", hash = "sha256:d6b3dac883aa4d9993348e0f8e95b24624f75099aed64eab6a4351a9cdd1e580"}, + {file = "jupyterlab-4.5.6.tar.gz", hash = "sha256:642fe2cfe7f0f5922a8a558ba7a0d246c7bc133b708dfe43f7b3a826d163cf42"}, ] markers = {main = "extra == \"docs\""} @@ -3440,20 +3456,20 @@ markers = {main = "extra == \"docs\""} [[package]] name = "notebook" -version = "7.5.4" +version = "7.5.5" description = "Jupyter Notebook - A web-based notebook environment for interactive computing" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "notebook-7.5.4-py3-none-any.whl", hash = "sha256:860e31782b3d3a25ca0819ff039f5cf77845d1bf30c78ef9528b88b25e0a9850"}, - {file = "notebook-7.5.4.tar.gz", hash = "sha256:b928b2ba22cb63aa83df2e0e76fe3697950a0c1c4a41b84ebccf1972b1bb5771"}, + {file = "notebook-7.5.5-py3-none-any.whl", hash = "sha256:a7c14dbeefa6592e87f72290ca982e0c10f5bbf3786be2a600fda9da2764a2b8"}, + {file = "notebook-7.5.5.tar.gz", hash = "sha256:dc0bfab0f2372c8278c457423d3256c34154ac2cc76bf20e9925260c461013c3"}, ] markers = {main = "extra == \"docs\""} [package.dependencies] jupyter-server = ">=2.4.0,<3" -jupyterlab = ">=4.5.5,<4.6" +jupyterlab = ">=4.5.6,<4.6" jupyterlab-server = ">=2.28.0,<3" notebook-shim = ">=0.2,<0.3" tornado = ">=6.2.0" @@ -4060,14 +4076,14 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pontos" -version = "26.2.0" +version = "26.3.1" description = "Common utilities and tools maintained by Greenbone Networks" optional = false -python-versions = "<4.0,>=3.9" +python-versions = "<4.0,>=3.10" groups = ["dev"] files = [ - {file = "pontos-26.2.0-py3-none-any.whl", hash = "sha256:37d5dc30ea65cef9b5aa12e00997165ae906be8eef2b6c017c9eb41431bb1d71"}, - {file = "pontos-26.2.0.tar.gz", hash = "sha256:54ed2d1532879647d6f51b94d988c4295da419f7531910b5511a100f7ed81c8b"}, + {file = "pontos-26.3.1-py3-none-any.whl", hash = "sha256:735dcd13c69177405935c44e410d879623760529dd81a37a0f2b743e2af9842e"}, + {file = "pontos-26.3.1.tar.gz", hash = "sha256:de998a57494da64930a4570b5dd933dee1c401da445313cacc2026aebb8ae6c5"}, ] [package.dependencies] @@ -4923,25 +4939,26 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, + {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, + {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rfc3339-validator" @@ -6166,60 +6183,60 @@ files = [ [[package]] name = "tomli" -version = "2.4.0" +version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["main", "dev"] markers = "python_version == \"3.10\"" files = [ - {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, - {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, - {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, - {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, - {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, - {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, - {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, - {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, - {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, - {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, - {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, - {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, - {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, - {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, - {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, - {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, - {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, - {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, - {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, - {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, - {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, - {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, - {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, - {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, - {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, - {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, - {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, - {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, - {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, - {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, - {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, - {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, - {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] [[package]] @@ -6236,24 +6253,22 @@ files = [ [[package]] name = "tornado" -version = "6.5.4" +version = "6.5.5" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"}, - {file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"}, - {file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"}, - {file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"}, - {file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"}, - {file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"}, - {file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"}, - {file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"}, + {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"}, + {file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"}, + {file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"}, + {file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"}, + {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"}, + {file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"}, + {file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"}, + {file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"}, + {file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"}, + {file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"}, ] markers = {main = "extra == \"docs\""} @@ -6512,4 +6527,4 @@ docs = ["autodoc_pydantic", "cairosvg", "ipython", "jinja2", "jupyter", "myst-pa [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "0b9e8f9b56c45b31e77157cb628fcce597cf484b650ad8821a41a66642f232d6" +content-hash = "df6f455746ec9aec9adff6aaec7b71bfd8e402cc531069fdb237ba36b4b85d7b" diff --git a/pyproject.toml b/pyproject.toml index 33210ec1b..ef470518e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,9 +14,9 @@ priority = "explicit" python = ">=3.10,<3.14" pydantic = ">=2.8,<2.12" # -- Local dev (editable install, schema changes take effect immediately): -# flow360-schema = { path = "../flow360-schema", develop = true } +# flow360-schema = { path = "../flex/share/flow360-schema", develop = true } # -- CI / release (install from CodeArtifact, swap comments before pushing): -flow360-schema = { version = "~0.1.1", source = "codeartifact" } +flow360-schema = { version = "~0.1.14", source = "codeartifact" } pytest = "^7.1.2" click = "^8.1.3" toml = "^0.10.2" diff --git a/tests/simulation/outputs/test_output_entities.py b/tests/simulation/outputs/test_output_entities.py index eb99e5195..55e969b20 100644 --- a/tests/simulation/outputs/test_output_entities.py +++ b/tests/simulation/outputs/test_output_entities.py @@ -2,13 +2,13 @@ import pydantic as pd import pytest +from flow360_schema.framework.expression import Expression, UserVariable +from flow360_schema.models.functions import math +from flow360_schema.models.variables import solution from flow360 import SI_unit_system, u from flow360.component.simulation.outputs.output_entities import Isosurface from flow360.component.simulation.services import clear_context -from flow360.component.simulation.user_code.core.types import Expression, UserVariable -from flow360.component.simulation.user_code.functions import math -from flow360.component.simulation.user_code.variables import solution @pytest.fixture(autouse=True) diff --git a/tests/simulation/params/test_output_at_final_pseudo_step_only.py b/tests/simulation/params/test_output_at_final_pseudo_step_only.py index f8dea5f58..b94c6336c 100644 --- a/tests/simulation/params/test_output_at_final_pseudo_step_only.py +++ b/tests/simulation/params/test_output_at_final_pseudo_step_only.py @@ -4,6 +4,7 @@ import pydantic import pytest +from flow360_schema.models.variables import solution import flow360.component.simulation.units as u from flow360.component.simulation.outputs.output_entities import Point @@ -13,7 +14,6 @@ ) from flow360.component.simulation.unit_system import SI_unit_system from flow360.component.simulation.user_code.core.types import UserVariable -from flow360.component.simulation.user_code.variables import solution from flow360.component.simulation.validation.validation_context import TimeSteppingType diff --git a/tests/simulation/params/test_validators_criterion.py b/tests/simulation/params/test_validators_criterion.py index e5c9ad4a3..58a2f140f 100644 --- a/tests/simulation/params/test_validators_criterion.py +++ b/tests/simulation/params/test_validators_criterion.py @@ -4,6 +4,9 @@ import pydantic as pd import pytest +from flow360_schema.framework.expression import UserVariable +from flow360_schema.models.functions import math +from flow360_schema.models.variables import solution import flow360.component.simulation.units as u from flow360.component.simulation.models.volume_models import Fluid @@ -22,9 +25,6 @@ from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.user_code.core.types import UserVariable -from flow360.component.simulation.user_code.functions import math -from flow360.component.simulation.user_code.variables import solution assertions = unittest.TestCase("__init__") diff --git a/tests/simulation/params/test_validators_output.py b/tests/simulation/params/test_validators_output.py index 0a18056d3..34170ea19 100644 --- a/tests/simulation/params/test_validators_output.py +++ b/tests/simulation/params/test_validators_output.py @@ -28,6 +28,10 @@ def assert_validation_error_contains( assert matching_errors[0]["type"] == "value_error" +from flow360_schema.framework.expression import UserVariable +from flow360_schema.models.functions import math +from flow360_schema.models.variables import solution + from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.models.solver_numerics import ( KOmegaSST, @@ -66,9 +70,6 @@ def assert_validation_error_contains( SI_unit_system, imperial_unit_system, ) -from flow360.component.simulation.user_code.core.types import UserVariable -from flow360.component.simulation.user_code.functions import math -from flow360.component.simulation.user_code.variables import solution from flow360.component.simulation.validation.validation_context import ( CASE, ParamsValidationInfo, diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index bd3ae78f1..82ff48932 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -4,7 +4,10 @@ import pydantic as pd import pytest +from flow360_schema.framework.expression import UserVariable from flow360_schema.framework.validation.context import DeserializationContext +from flow360_schema.models.functions import math +from flow360_schema.models.variables import solution import flow360.component.simulation.units as u from flow360.component.simulation.draft_context.coordinate_system_manager import ( @@ -130,9 +133,6 @@ from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.user_code.core.types import UserVariable -from flow360.component.simulation.user_code.functions import math -from flow360.component.simulation.user_code.variables import solution from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index 9190df955..ab95072af 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -4,6 +4,7 @@ from typing import get_args import pytest +from flow360_schema.framework.expression import UserVariable from unyt import Unit import flow360.component.simulation.units as u @@ -13,7 +14,6 @@ from flow360.component.simulation.framework.updater_utils import compare_values from flow360.component.simulation.services_report import get_default_report_config from flow360.component.simulation.unit_system import DimensionedTypes -from flow360.component.simulation.user_code.core.types import UserVariable from flow360.component.simulation.validation.validation_context import ( CASE, SURFACE_MESH, @@ -1061,7 +1061,7 @@ def test_generate_process_json_skips_case_validation_for_meshing(): """velocity_magnitude=0 without reference_velocity should not fail when only generating mesh JSON.""" params_data = { "meshing": { - "defaults": {"surface_max_edge_length": 1}, + "defaults": {"surface_max_edge_length": 1.0}, "volume_zones": [ { "method": "auto", @@ -1107,7 +1107,7 @@ def test_generate_process_json_skips_case_validation_for_meshing(): } ], "private_attribute_asset_cache": { - "project_length_unit": 1, + "project_length_unit": 1.0, "project_entity_info": { "type_name": "GeometryEntityInfo", "face_ids": ["face_x_1", "face_x_2", "face_x_3"], diff --git a/tests/simulation/test_expression_math.py b/tests/simulation/test_expression_math.py deleted file mode 100644 index 917a05d1d..000000000 --- a/tests/simulation/test_expression_math.py +++ /dev/null @@ -1,1861 +0,0 @@ -import re - -import numpy as np -import pydantic as pd -import pytest -import unyt as u -from flow360_schema.framework.physical_dimensions import Velocity - -import flow360.component.simulation.user_code.core.context as context -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.models.material import Water -from flow360.component.simulation.operating_condition.operating_condition import ( - LiquidOperatingCondition, -) -from flow360.component.simulation.services import clear_context -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.user_code.core.types import ( - Expression, - UserVariable, - ValueOrExpression, -) -from flow360.component.simulation.user_code.functions import math -from flow360.component.simulation.user_code.variables import solution - - -@pytest.fixture(autouse=True) -def reset_context(): - """Clear user variables from the context.""" - clear_context() - - -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -@pytest.fixture() -def scaling_provider(): - with SI_unit_system: - params = SimulationParams( - operating_condition=LiquidOperatingCondition( - velocity_magnitude=50 * u.m / u.s, - reference_velocity_magnitude=100 * u.m / u.s, - material=Water(name="water"), - ), - private_attribute_asset_cache=AssetCache(project_length_unit=10 * u.m), - ) - return params - - -# ---------------------------# -# Cross product -# ---------------------------# -def test_cross_product(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[Velocity.Vector3] = pd.Field() - - x = UserVariable(name="x", value=[1, 2, 3]) - - model = TestModel(field=math.cross(x, [3, 2, 1]) * u.m / u.s) - assert ( - str(model.field) - == "[x[1] * 1 - x[2] * 2, x[2] * 3 - x[0] * 1, x[0] * 2 - x[1] * 3] * u.m / u.s" - ) - - assert (model.field.evaluate() == [-4, 8, -4] * u.m / u.s).all() - - model = TestModel(field="math.cross(x, [3, 2, 1]) * u.m / u.s") - assert str(model.field) == "math.cross(x, [3, 2, 1]) * u.m / u.s" - - result = model.field.evaluate() - assert (result == [-4, 8, -4] * u.m / u.s).all() - - some_var = UserVariable(name="some_var", value=[1, 2] * u.m) - a = UserVariable(name="a", value=math.cross(some_var, [3, 1]) * u.m / u.s) - assert str(a.value) == "(some_var[0] * 1 - some_var[1] * 3) * u.m / u.s" - result = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - assert result == -5 * u.m * u.m / u.s - - with pytest.raises( - ValueError, - match=re.escape( - "Vectors ([1, 2] | [3 1 2 3] m) must have the same length to perform cross product operation." - ), - ): - a.value = math.cross( - [ - 1, - 2, - ], - [3, 1, 2, 3] * u.m, - ) - - with pytest.raises(ValueError, match="Vector length must be 2 or 3, got 4."): - a.value = math.cross([1, 2, 2, 3], [3, 1, 2, 3] * u.m) - - -def test_cross_function_use_case(scaling_provider): - - print("\n1 Python mode\n") - a = UserVariable(name="a", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) - res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) - assert str(res) == ( - "[2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1], " - "1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2], " - "3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]]" - ) - assert ( - a.value.to_solver_code(scaling_provider) - == "std::vector({(((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])), (((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])), (((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0]))})" - ) - - print("\n1.1 Python mode but arg swapped\n") - a.value = math.cross(solution.coordinate, [3, 2, 1] * u.m) - res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) - assert str(res) == ( - "[solution.coordinate[1] * 1 * u.m - solution.coordinate[2] * 2 * u.m, " - "solution.coordinate[2] * 3 * u.m - solution.coordinate[0] * 1 * u.m, " - "solution.coordinate[0] * 2 * u.m - solution.coordinate[1] * 3 * u.m]" - ) - assert ( - a.value.to_solver_code(scaling_provider) - == "std::vector({(((coordinate[1] * 1) * 0.1) - ((coordinate[2] * 2) * 0.1)), (((coordinate[2] * 3) * 0.1) - ((coordinate[0] * 1) * 0.1)), (((coordinate[0] * 2) * 0.1) - ((coordinate[1] * 3) * 0.1))})" - ) - - print("\n2 Taking advantage of unyt as much as possible\n") - a.value = math.cross([3, 2, 1] * u.m, [2, 2, 1] * u.m) - assert all(a.value == [0, -1, 2] * u.m * u.m) - - print("\n3 (Units defined in components)\n") - a.value = math.cross([3 * u.m, 2 * u.m, 1 * u.m], [2 * u.m, 2 * u.m, 1 * u.m]) - assert all(a.value == [0, -1, 2] * u.m * u.m) - - print("\n4 Serialized version\n") - a.value = "math.cross([3, 2, 1] * u.m, solution.coordinate)" - res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) - assert str(res) == ( - "[2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1], " - "1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2], " - "3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]]" - ) - assert ( - a.value.to_solver_code(scaling_provider) - == "std::vector({(((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])), (((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])), (((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0]))})" - ) - - print("\n5 Recursive cross in Python mode\n") - a.value = math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m) - res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) - assert str(res) == ( - "[(1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 1 * u.m - (3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 2 * u.m, " - "(3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 3 * u.m - (2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 1 * u.m, " - "(2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 2 * u.m - (1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 3 * u.m]" - ) - assert ( - a.value.to_solver_code(scaling_provider) - == "std::vector({((((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 1) * 0.1) - (((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 2) * 0.1)), ((((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 3) * 0.1) - (((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 1) * 0.1)), ((((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 2) * 0.1) - (((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 3) * 0.1))})" - ) - - print("\n6 Recursive cross in String mode\n") - a.value = "math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m)" - res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) - assert ( - str(res) - == "[(1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 1 * u.m - (3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 2 * u.m, " - "(3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 3 * u.m - (2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 1 * u.m, " - "(2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 2 * u.m - (1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 3 * u.m]" - ) - assert ( - a.value.to_solver_code(scaling_provider) - == "std::vector({((((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 1) * 0.1) - (((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 2) * 0.1)), ((((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 3) * 0.1) - (((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 1) * 0.1)), ((((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 2) * 0.1) - (((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 3) * 0.1))})" - ) - - print("\n7 Using other variables in Python mode\n") - b = UserVariable(name="b", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) - a.value = math.cross(b, [3, 2, 1] * u.m) - res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) - assert str(res) == ( - "[(1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 1 * u.m - (3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 2 * u.m, " - "(3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 3 * u.m - (2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 1 * u.m, " - "(2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 2 * u.m - (1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 3 * u.m]" - ) - assert ( - a.value.to_solver_code(scaling_provider) - == "std::vector({((((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 1) * 0.1) - (((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 2) * 0.1)), ((((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 3) * 0.1) - (((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 1) * 0.1)), ((((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 2) * 0.1) - (((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 3) * 0.1))})" - ) - - print("\n8 Using other constant variables in Python mode\n") - b.value = [3, 2, 1] * u.m - a.value = math.cross(b, solution.coordinate) - res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) - assert str(res) == ( - "[2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1], " - "1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2], " - "3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]]" - ) - assert ( - a.value.to_solver_code(scaling_provider) - == "std::vector({(((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])), (((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])), (((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0]))})" - ) - - print("\n9 Using non-unyt_array\n") - b.value = [3 * u.m, 2 * u.m, 1 * u.m] - a.value = math.cross(b, solution.coordinate) - res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) - assert str(res) == ( - "[2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1], " - "1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2], " - "3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]]" - ) - assert ( - a.value.to_solver_code(scaling_provider) - == "std::vector({(((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])), (((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])), (((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0]))})" - ) - - -# ---------------------------# -# Dot product -# ---------------------------# -def test_dot_product(): - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - y = UserVariable(name="y", value=[1, 2, 3] * u.K) - - # Python mode - assert math.dot(x, y).evaluate() == 14 * u.m * u.K - assert math.dot(x, [1, 2, 3] * u.m).evaluate() == 14 * u.m * u.m - assert math.dot([1, 2, 3] * u.m, [1, 2, 3] * u.m) == 14 * u.m * u.m - assert math.dot([1, 2] * u.m, [1, 2] * u.m) == 5 * u.m * u.m - assert math.dot([1 * u.m, 2 * u.m], [1 * u.s, 3 * u.s]) == 7 * u.m * u.s - assert ( - str(math.dot(solution.coordinate, solution.velocity)) - == "solution.coordinate[0] * solution.velocity[0] + " - "solution.coordinate[1] * solution.velocity[1] + " - "solution.coordinate[2] * solution.velocity[2]" - ) - assert ( - str(math.dot(solution.coordinate, x)) - == "solution.coordinate[0] * x[0] + solution.coordinate[1] * x[1] + solution.coordinate[2] * x[2]" - ) - - # String mode - assert Expression(expression="math.dot(x, y)").evaluate() == 14 * u.m * u.K - assert Expression(expression="math.dot(x, [1, 2, 3] * u.m)").evaluate() == 14 * u.m * u.m - assert ( - Expression(expression="math.dot([1, 2, 3] * u.m, [1, 2, 3] * u.m)").evaluate() - == 14 * u.m * u.m - ) - assert Expression(expression="math.dot([1, 2] * u.m, [1, 2] * u.m)").evaluate() == 5 * u.m * u.m - assert ( - Expression(expression="math.dot([1* u.m, 2* u.m] , [1* u.s, 3* u.s])").evaluate() - == 7 * u.m * u.s - ) # We do not probably want to user to know this works though.... - - # Error handling - with pytest.raises( - ValueError, - match=re.escape( - "Vectors ([1 2] m | y) must have the same length to perform dot product operation." - ), - ): - math.dot([1, 2] * u.m, y) - - -# ---------------------------# -# Add and Subtract -# ---------------------------# -def test_add_vector(): - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - y = UserVariable(name="y", value=[4, 5, 6] * u.m) - - # Python mode - assert all(math.add(x, y).evaluate() == [5, 7, 9] * u.m) - assert all(math.add(x, [1, 2, 3] * u.m).evaluate() == [2, 4, 6] * u.m) - assert all(math.add([1, 2, 3] * u.m, [1, 2, 3] * u.m) == [2, 4, 6] * u.m) - assert all(math.add([1, 2] * u.m, [1, 2] * u.m) == [2, 4] * u.m) - assert all(math.add([1 * u.s, 2 * u.s], [1 * u.s, 3 * u.s]) == [2, 5] * u.s) - assert ( - str(math.add(solution.grad_u, solution.grad_w)) - == "[solution.grad_u[0] + solution.grad_w[0], " - "solution.grad_u[1] + solution.grad_w[1], " - "solution.grad_u[2] + solution.grad_w[2]]" - ) - assert ( - str(math.add(solution.coordinate, x)) - == "[solution.coordinate[0] + x[0], solution.coordinate[1] + x[1], solution.coordinate[2] + x[2]]" - ) - - # String mode - assert all(Expression(expression="math.add(x, y)").evaluate() == [5, 7, 9] * u.m) - assert all(Expression(expression="math.add(x, [1, 2, 3] * u.m)").evaluate() == [2, 4, 6] * u.m) - assert all( - Expression(expression="math.add([1, 2, 3] * u.m, [1, 2, 3] * u.m)").evaluate() - == [2, 4, 6] * u.m - ) - assert all( - Expression(expression="math.add([1, 2] * u.m, [1, 2] * u.m)").evaluate() == [2, 4] * u.m - ) - assert all( - Expression(expression="math.add([1 * u.s, 2 * u.s] , [1 * u.s, 3 * u.s])").evaluate() - == [2, 5] * u.s - ) - - # Error handling - with pytest.raises( - ValueError, - match=re.escape( - "Vectors ([1 2] m | y) must have the same length to perform add operation." - ), - ): - math.add([1, 2] * u.m, y) - - with pytest.raises( - ValueError, - match=re.escape( - "Input values ([1 2 3] s | y) must have the same dimensions to perform add operation." - ), - ): - math.add([1, 2, 3] * u.s, y) - - -def test_subtract_vector(): - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - y = UserVariable(name="y", value=[-4, -5, -6] * u.m) - - # Python mode - assert all(math.subtract(x, y).evaluate() == [5, 7, 9] * u.m) - assert all(math.subtract(x, [-1, -2, -3] * u.m).evaluate() == [2, 4, 6] * u.m) - assert all(math.subtract([-1, -2, -3] * u.m, [1, 2, 3] * u.m) == [-2, -4, -6] * u.m) - assert all(math.subtract([1, 2] * u.m, [-1, -2] * u.m) == [2, 4] * u.m) - assert all(math.subtract([1 * u.s, -2 * u.s], [-1 * u.s, 3 * u.s]) == [2, -5] * u.s) - assert ( - str(math.subtract(solution.grad_u, solution.grad_w)) - == "[solution.grad_u[0] - solution.grad_w[0], " - "solution.grad_u[1] - solution.grad_w[1], " - "solution.grad_u[2] - solution.grad_w[2]]" - ) - assert ( - str(math.subtract(solution.coordinate, x)) - == "[solution.coordinate[0] - x[0], solution.coordinate[1] - x[1], solution.coordinate[2] - x[2]]" - ) - - # String mode - assert all(Expression(expression="math.subtract(x, y)").evaluate() == [5, 7, 9] * u.m) - assert all( - Expression(expression="math.subtract(x, [-1, -2, -3] * u.m)").evaluate() == [2, 4, 6] * u.m - ) - assert all( - Expression(expression="math.subtract([-1, -2, -3] * u.m, [1, 2, 3] * u.m)").evaluate() - == [-2, -4, -6] * u.m - ) - assert all( - Expression(expression="math.subtract([1, 2] * u.m, [-1, -2] * u.m)").evaluate() - == [2, 4] * u.m - ) - assert all( - Expression(expression="math.subtract([1 * u.s, -2 * u.s] , [-1 * u.s, 3 * u.s])").evaluate() - == [2, -5] * u.s - ) - - # Error handling - with pytest.raises( - ValueError, - match=re.escape( - "Vectors ([1 2] m | y) must have the same length to perform subtract operation." - ), - ): - math.subtract([1, 2] * u.m, y) - - with pytest.raises( - ValueError, - match=re.escape( - "Input values ([1 2 3] s | y) must have the same dimensions to perform subtract operation." - ), - ): - math.subtract([1, 2, 3] * u.s, y) - - -# ---------------------------# -# Add and Subtract edge cases -# ---------------------------# -def test_add_subtract_edge_cases(): - """Test add and subtract functions with various edge cases.""" - - # Test with empty lists - assert math.add([], []) == [] - assert math.subtract([], []) == [] - - # Test with NaN/Inf values in unyt_arrays - assert np.array_equal( - math.add([np.nan, 1.0] * u.m, [2.0, np.inf] * u.m), [np.nan, np.inf] * u.m, equal_nan=True - ) - assert np.array_equal( - math.subtract([np.nan, 1.0] * u.m, [2.0, np.inf] * u.m), - [np.nan, -np.inf] * u.m, - equal_nan=True, - ) - assert np.array_equal( - math.add([np.inf, 1.0] * u.m, [2.0, -np.inf] * u.m), [np.inf, -np.inf] * u.m, equal_nan=True - ) - assert np.array_equal( - math.subtract([np.inf, 1.0] * u.m, [2.0, -np.inf] * u.m), - [np.inf, np.inf] * u.m, - equal_nan=True, - ) - - # Test with NaN/Inf values in plain lists (will be handled as numbers) - # Note: np.isnan on a list returns a boolean array, so we check np.all(np.isnan(...)) - add_result_nan = math.add([np.nan, 1.0], [2.0, 3.0]) - assert np.array_equal(add_result_nan, [np.nan, 4.0], equal_nan=True) - sub_result_nan = math.subtract([np.nan, 1.0], [2.0, 3.0]) - assert np.array_equal(sub_result_nan, [np.nan, -2.0], equal_nan=True) - - assert np.all(math.add([np.inf, 1.0], [2.0, 3.0]) == [np.inf, 4.0]) - assert np.all(math.subtract([np.inf, 1.0], [2.0, 3.0]) == [np.inf, -2.0]) - - # Test with mixed Expression/Variable and scalar/unyt_quantity within vector elements - x_var = UserVariable(name="x_var", value=10 * u.m) - y_unyt = 20 * u.m - expr_val = Expression(expression="x_var + 5 * u.m") # Evaluates to 15 - - vec1 = [x_var, y_unyt, expr_val] - vec2 = [5 * u.m, 10 * u.m, 3 * u.m] - - # Add - result_add = math.add(vec1, vec2) - assert isinstance(result_add, Expression) - assert str(result_add) == "[x_var + 5 * u.m, 30 * u.m, x_var + 5 * u.m + 3 * u.m]" - evaluated_add = result_add.evaluate() - assert isinstance(evaluated_add, list) - assert len(evaluated_add) == 3 - assert evaluated_add[0] == 15 * u.m - assert evaluated_add[1] == 30 * u.m - assert evaluated_add[2] == 18 * u.m - - # Subtract - result_sub = math.subtract(vec1, vec2) - assert isinstance(result_sub, Expression) - assert str(result_sub) == "[x_var - 5 * u.m, 10 * u.m, x_var + 5 * u.m - 3 * u.m]" - evaluated_sub = result_sub.evaluate() - assert isinstance(evaluated_sub, list) - assert len(evaluated_sub) == 3 - assert evaluated_sub[0] == 5 * u.m - assert evaluated_sub[1] == 10 * u.m - assert evaluated_sub[2] == 12 * u.m - - # Test with solution variables mixed with other types in vector elements - sol_vec = [solution.velocity[0], solution.velocity[1], solution.velocity[2]] - num_vec = [1 * u.m / u.s, 2 * u.m / u.s, 3 * u.m / u.s] - - result_add_sol = math.add(sol_vec, num_vec) - assert isinstance(result_add_sol, Expression) - assert ( - str(result_add_sol) - == "[solution.velocity[0] + 1 * u.m / u.s, solution.velocity[1] + 2 * u.m / u.s, solution.velocity[2] + 3 * u.m / u.s]" - ) - - result_sub_sol = math.subtract(sol_vec, num_vec) - assert isinstance(result_sub_sol, Expression) - assert ( - str(result_sub_sol) - == "[solution.velocity[0] - 1 * u.m / u.s, solution.velocity[1] - 2 * u.m / u.s, solution.velocity[2] - 3 * u.m / u.s]" - ) - - # Test with mixed dimensions within a list, but consistent across inputs (should pass _check_same_dimensions on first element) - list_mixed_units_1 = [1, 2 * u.m] - list_mixed_units_2 = [3, 4 * u.m] - with pytest.raises( - ValueError, - match=re.escape( - "Each item in the input value ([1, unyt_quantity(2, 'm')]) must have the same dimensions to perform add operation." - ), - ): - math.add(list_mixed_units_1, list_mixed_units_2) - with pytest.raises( - ValueError, - match=re.escape( - "Each item in the input value ([3, unyt_quantity(4, 'm')]) must have the same dimensions to perform subtract operation" - ), - ): - math.subtract(list_mixed_units_2, list_mixed_units_1) - - -# ---------------------------# -# Scalar functions with ensure_scalar_input wrapper -# ---------------------------# -def test_sqrt_scalar_input(): - """Test sqrt function with valid scalar inputs.""" - - # Test with regular numbers - assert math.sqrt(4) == 2.0 - assert math.sqrt(0) == 0.0 - assert math.sqrt(2) == pytest.approx(1.4142135623730951) - - with pytest.raises(RuntimeWarning, match="invalid value encountered in sqrt"): - math.sqrt(-1) - - # Test with unyt quantities - assert math.sqrt(4 * u.m * u.m) == 2 * u.m - assert math.sqrt(9 * u.K * u.K) == 3 * u.K - assert math.sqrt(16 * u.m * u.m / u.s / u.s) == 4 * u.m / u.s - - # Test with expressions - x = UserVariable(name="x", value=4 * u.m * u.m) - result = math.sqrt(x) - assert str(result) == "math.sqrt(x)" - assert result.evaluate() == 2 * u.m - - x.value = 9 * u.m - result = math.sqrt(x) - assert result.evaluate() == 3 * u.Unit("sqrt(m)") - - # Test with string expressions - expr = Expression(expression="math.sqrt(16)") - assert expr.evaluate() == 4.0 - - -def test_sqrt_non_scalar_input_errors(): - """Test sqrt function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises( - ValueError, match=re.escape("Scalar function (sqrt) on [1, 2, 3] not supported.") - ): - math.sqrt([1, 2, 3]) - - with pytest.raises( - ValueError, - match=re.escape("Scalar function (sqrt) on [unyt_quantity(1, 'm'), unyt_quantity(2, 'm')]"), - ): - math.sqrt([1 * u.m, 2 * u.m]) - - # Test with unyt arrays - with pytest.raises( - ValueError, match=re.escape("Scalar function (sqrt) on [1 2 3] m not supported.") - ): - math.sqrt(u.unyt_array([1, 2, 3], u.m)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - with pytest.raises(ValueError, match=re.escape("Scalar function (sqrt) on x not supported.")): - math.sqrt(x) - - # Test with solution variables (which are arrays) - with pytest.raises( - ValueError, match=re.escape("Scalar function (sqrt) on solution.coordinate not supported.") - ): - math.sqrt(solution.coordinate) - - with pytest.raises( - ValueError, match=re.escape("Scalar function (sqrt) on solution.velocity not supported.") - ): - math.sqrt(solution.velocity) - - -def test_sqrt_edge_cases(): - """Test sqrt function with edge cases.""" - - with pytest.raises(ValueError, match=re.escape("Scalar function (sqrt) on [] not supported.")): - math.sqrt([]) - - # Test with None (should raise TypeError from numpy) - with pytest.raises( - ValueError, match=re.escape("Scalar function (sqrt) on None not supported.") - ): - math.sqrt(None) - - # Test with string (should raise ValueError from numpy) - with pytest.raises( - ValueError, match=re.escape("Scalar function (sqrt) on not a number not supported.") - ): - math.sqrt("not a number") - - -def test_sqrt_with_expressions(scaling_provider): - """Test sqrt function with various expression types.""" - - # Test with UserVariable containing scalar - x = UserVariable(name="x", value=9 * u.m * u.m) - result = math.sqrt(x) - assert str(result) == "math.sqrt(x)" - assert result.evaluate() == 3 * u.m - - # Test with Expression containing scalar - expr = Expression(expression="math.sqrt(25)") - assert expr.evaluate() == 5.0 - - # Test with nested expressions - y = UserVariable(name="y", value=4 * u.m) - z = UserVariable(name="z", value=16 * u.m * u.m) - result = math.sqrt(y * math.sqrt(z)) - assert str(result) == "math.sqrt(y * math.sqrt(z))" - assert result.evaluate() == 4 * u.m - - # Test with solution variables - result = math.sqrt(solution.Cp * math.sqrt(solution.mut)) - assert str(result) == "math.sqrt(solution.Cp * math.sqrt(solution.mut))" - assert result.to_solver_code(scaling_provider) == "sqrt((___Cp * sqrt(___mut)))" - - -# ---------------------------# -# Magnitude function -# ---------------------------# -def test_magnitude(): - """Test magnitude function with various inputs.""" - - # Test with regular lists - assert math.magnitude([3, 4]) == 5.0 - assert math.magnitude([1, 1, 1]) == pytest.approx(1.7320508075688772) - assert math.magnitude([0, 0, 0]) == 0.0 - - # Test with unyt quantities - assert math.magnitude([3, 4] * u.m) == 5 * u.m - assert math.magnitude([1, 1, 1] * u.K).value == pytest.approx(1.7320508075688772) - - # Test with variables - x = UserVariable(name="x", value=[3, 4] * u.m) - result = math.magnitude(x) - assert str(result) == "(x[0] ** 2 + x[1] ** 2) ** 0.5" - assert result.evaluate() == 5 * u.m - - # Test with solution variables - result = math.magnitude(solution.coordinate) - assert str(result) == "math.magnitude(solution.coordinate)" - - # Test with string expressions - expr = Expression(expression="math.magnitude([3, 4])") - assert expr.evaluate() == 5.0 - - -def test_magnitude_errors(): - """Test magnitude function error handling.""" - - # Test with scalar input - with pytest.raises(ValueError, match="Cannot get length information"): - math.magnitude(5) - - with pytest.raises(ValueError, match="Cannot get length information"): - math.magnitude(5 * u.m) - - -# ---------------------------# -# Subtract function -# ---------------------------# -def test_subtract(): - """Test subtract function with various inputs.""" - - # Test with regular lists - assert math.subtract([5, 3], [2, 1]) == [3, 2] - assert math.subtract([1, 2, 3], [0, 1, 2]) == [1, 1, 1] - - # Test with unyt quantities - assert all(math.subtract([5, 3] * u.m, [2, 1] * u.m) == [3, 2] * u.m) - assert all(math.subtract([1, 2, 3] * u.K, [0, 1, 2] * u.K) == [1, 1, 1] * u.K) - - # Test with expressions - x = UserVariable(name="x", value=[5, 3] * u.m) - y = UserVariable(name="y", value=[2, 1] * u.m) - result = math.subtract(x, y) - assert str(result) == "[x[0] - y[0], x[1] - y[1]]" - assert all(result.evaluate() == [3, 2] * u.m) - - # Test with solution variables - result = math.subtract(solution.coordinate, [1, 0, 0] * u.m) - assert ( - str(result) - == "[solution.coordinate[0] - 1 * u.m, solution.coordinate[1] - 0 * u.m, solution.coordinate[2] - 0 * u.m]" - ) - - # Test with string expressions - expr = Expression(expression="math.subtract([5, 3], [2, 1])") - assert expr.evaluate() == [3, 2] - - -def test_subtract_errors(): - """Test subtract function error handling.""" - - # Test with different lengths - with pytest.raises(ValueError, match="must have the same length"): - math.subtract([1, 2], [1, 2, 3]) - - # Test with scalar inputs - with pytest.raises(ValueError, match="Cannot get length information"): - math.subtract(5, 3) - - -# ---------------------------# -# Power operator (**) -# ---------------------------# -def test_power_operator_scalar_input(): - """Test ** operator with valid scalar inputs.""" - - # Test with regular numbers - assert 2**3 == 8.0 - assert 4**0.5 == 2.0 - assert 2**0 == 1.0 - assert 0**2 == 0.0 - - # Test with unyt quantities - assert (2 * u.m) ** 2 == 4 * u.m * u.m - assert (4 * u.K) ** 0.5 == 2 * u.Unit("sqrt(K)") - - # Test with expressions - x = UserVariable(name="x", value=2 * u.m) - result = x**2 - assert str(result) == "x ** 2" - assert result.evaluate() == 4 * u.m * u.m - - # Test with string expressions - expr = Expression(expression="2 ** 3") - assert expr.evaluate() == 8.0 - - -def test_power_operator_non_scalar_input_errors(): - """Test ** operator raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(TypeError, match="unsupported operand type\\(s\\) for \\*\\* or pow\\(\\)"): - [1, 2, 3] ** 2 - - with pytest.raises(TypeError, match="unsupported operand type\\(s\\) for \\*\\* or pow\\(\\)"): - 2 ** [1, 2, 3] - - # Test with unyt arrays - with pytest.raises(ValueError, match="is not well defined."): - 2 ** ([1, 2, 3] * u.m) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - assert all((x**2).evaluate() == [1, 4, 9] * u.m * u.m) - - # Test with expressions - expr = Expression(expression="2 ** [1, 2, 3]") - with pytest.raises(TypeError, match="unsupported operand type\\(s\\) for \\*\\* or pow\\(\\)"): - expr.evaluate() - - -def test_power_operator_with_expressions(scaling_provider): - """Test ** operator with various expression types.""" - - # Test with UserVariable containing scalar - x = UserVariable(name="x", value=3 * u.m) - result = x**2 - assert str(result) == "x ** 2" - assert result.evaluate() == 9 * u.m * u.m - - # Test with Expression containing scalar - expr = Expression(expression="4 ** 2") - assert expr.evaluate() == 16.0 - - # Test with nested expressions - y = UserVariable(name="y", value=2 * u.m) - z = UserVariable(name="z", value=3) - result = y**z - assert str(result) == "y ** z" - assert result.evaluate() == 8 * u.m * u.m * u.m - - -def test_power_operator_exponent_validation(): - """Test ** operator exponent validation - exponents must be dimensionless scalars.""" - - # Test with unyt_quantity exponent (should raise error) - with pytest.raises(ValueError, match="is not well defined."): - (2 * u.m) ** (3 * u.m) - - with pytest.raises(ValueError, match="is not well defined."): - (2 * u.m) ** (3 * u.K) - - with pytest.raises(ValueError, match="is not well defined."): - 2 ** (3 * u.m) - - # Test with Variable containing unyt_quantity exponent (should raise error) - x = UserVariable(name="x", value=3 * u.m) - with pytest.raises(ValueError, match="is not well defined."): - ((2 * u.m) ** x).evaluate() - - # Test with Variable containing dimensionless scalar (should work) - y = UserVariable(name="y", value=3) # dimensionless scalar - result = (2 * u.m) ** y - assert str(result) == "(2 * u.m) ** y" - assert result.evaluate() == 8 * u.m * u.m * u.m - - # Test with Variable containing dimensionless unyt_quantity (should work) - z = UserVariable(name="z", value=3 * u.dimensionless) - result = (2 * u.m) ** z - assert str(result) == "(2 * u.m) ** z" - assert result.evaluate() == 8 * u.m * u.m * u.m - - -def test_power_operator_exponent_validation_edge_cases(): - """Test ** operator exponent validation with edge cases.""" - - # Test with zero exponent (should work) - result = (2 * u.m) ** 0 - assert result == 1.0 - - # Test with negative exponent (should work) - result = (2.0 * u.m) ** (-1) - assert result == 0.5 / u.m - - # Test with fractional exponent (should work) - result = (4 * u.m * u.m) ** 0.5 - assert result == 2 * u.m - - # Test with Variable containing zero (should work) - zero_var = UserVariable(name="zero_var", value=0) - result = (2 * u.m) ** zero_var - assert str(result) == "(2 * u.m) ** zero_var" - assert result.evaluate() == 1.0 - - # Test with Variable containing negative number (should work) - neg_var = UserVariable(name="neg_var", value=-1) - result = (2 * u.m) ** neg_var - assert str(result) == "(2 * u.m) ** neg_var" - assert result.evaluate() == 0.5 / u.m - - # Test with Expression containing zero (should work) - zero_expr = Expression(expression="0") - result = (2 * u.m) ** zero_expr - assert str(result) == "(2 * u.m) ** 0" - assert result.evaluate() == 1.0 - - -def test_power_operator_exponent_validation_complex_expressions(): - """Test ** operator exponent validation with complex expressions.""" - - # # Test with Variable containing complex dimensionless expression - # x = UserVariable(name="x", value=2) - # y = UserVariable(name="y", value=1) - # complex_var = UserVariable(name="complex_var", value=x + y) # 2 + 1 = 3 (dimensionless) - # result = (2 * u.m) ** complex_var - # assert str(result) == "(2 * u.m) ** complex_var" - # assert result.evaluate() == 8 * u.m * u.m * u.m - - # Test with Expression containing complex dimensionless expression - complex_expr = Expression(expression="2 + 1") # 3 (dimensionless) - result = (2 * u.m) ** complex_expr - assert str(result) == "(2 * u.m) ** (2 + 1)" - assert result.evaluate() == 8 * u.m * u.m * u.m - - # Test with Variable containing expression that has units (should raise error) - x_with_units = UserVariable(name="x_with_units", value=2 * u.m) - y_with_units = UserVariable(name="y_with_units", value=1 * u.m) - complex_var_with_units = UserVariable( - name="complex_var_with_units", value=x_with_units + y_with_units - ) - with pytest.raises(ValueError, match="not well defined."): - ((2 * u.m) ** complex_var_with_units).evaluate() - - # Test with Expression containing expression that has units (should raise error) - complex_expr_with_units = Expression(expression="2 * u.m + 1 * u.m") - with pytest.raises(ValueError, match="not well defined."): - ((2 * u.m) ** complex_var_with_units).evaluate() - - -# ---------------------------# -# Logarithm function -# ---------------------------# -def test_log_scalar_input(): - """Test log function with valid scalar inputs.""" - - # Test with regular numbers - assert math.log(1) == 0.0 - assert math.log(math.exp(1)) == pytest.approx(1.0) - assert math.log(10) == pytest.approx(2.302585092994046) - - # Test with unyt quantities - assert math.log(1 * u.m / u.m) == 0.0 - assert math.log(math.exp(1) * u.K / u.K) == pytest.approx(1.0) - - # Test with expressions - x = UserVariable(name="x", value=10 * u.m / u.m) - result = math.log(x) - assert str(result) == "math.log(x)" - assert result.evaluate() == pytest.approx(2.302585092994046) - - # Test with string expressions - expr = Expression(expression="math.log(10)") - assert expr.evaluate() == pytest.approx(2.302585092994046) - - -def test_log_non_scalar_input_errors(): - """Test log function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.log([1, 2, 3]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.log(u.unyt_array([1, 2, 3], u.m)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - with pytest.raises(ValueError, match="Scalar function"): - math.log(x) - - -def test_log_edge_cases(): - """Test log function with edge cases.""" - - # Test with zero (should raise error) - with pytest.raises(RuntimeWarning, match="divide by zero"): - math.log(0) - - # Test with negative number (should raise error) - with pytest.raises(RuntimeWarning, match="invalid value"): - math.log(-1) - - -def test_log_with_expressions(scaling_provider): - """Test log function with various expression types.""" - - # Test with UserVariable containing scalar - x = UserVariable(name="x", value=10 * u.m / u.m) - result = math.log(x) - assert str(result) == "math.log(x)" - assert result.evaluate() == pytest.approx(2.302585092994046) - - # Test with Expression containing scalar - expr = Expression(expression="math.log(10)") - assert expr.evaluate() == pytest.approx(2.302585092994046) - - # Test with nested expressions - y = UserVariable(name="y", value=2 * u.m / u.m) - result = math.log(y * math.log(math.exp(10))) - assert str(result) == "math.log(y * 10.0)" - - -# ---------------------------# -# Exponential function -# ---------------------------# -def test_exp_scalar_input(): - """Test exp function with valid scalar inputs.""" - - # Test with regular numbers - assert math.exp(0) == 1.0 - assert math.exp(1) == pytest.approx(2.718281828459045) - assert math.exp(-1) == pytest.approx(0.36787944117144233) - - # Test with unyt quantities - assert math.exp(0 * u.m / u.m) == 1.0 - assert math.exp(1 * u.K / u.K) == pytest.approx(2.718281828459045) - - # Test with expressions - x = UserVariable(name="x", value=1 * u.m / u.m) - result = math.exp(x) - assert str(result) == "math.exp(x)" - assert result.evaluate() == pytest.approx(2.718281828459045) - - # Test with string expressions - expr = Expression(expression="math.exp(1)") - assert expr.evaluate() == pytest.approx(2.718281828459045) - - -def test_exp_non_scalar_input_errors(): - """Test exp function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.exp([1, 2, 3]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.exp(u.unyt_array([1, 2, 3], u.m)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - with pytest.raises(ValueError, match="Scalar function"): - math.exp(x) - - -def test_exp_with_expressions(scaling_provider): - """Test exp function with various expression types.""" - - # Test with UserVariable containing scalar - x = UserVariable(name="x", value=1 * u.m / u.m) - result = math.exp(x) - assert str(result) == "math.exp(x)" - assert result.evaluate() == pytest.approx(2.718281828459045) - - # Test with Expression containing scalar - expr = Expression(expression="math.exp(1)") - assert expr.evaluate() == pytest.approx(2.718281828459045) - - # Test with nested expressions - y = UserVariable(name="y", value=2 * u.m / u.m) - result = math.exp(y * math.log(math.exp(2))) - assert str(result) == "math.exp(y * 2.0)" - - -def test_exp_edge_cases(): - """Test exp function with edge cases.""" - - # Test with empty list input (should raise ValueError from ensure_scalar_input) - with pytest.raises(ValueError, match=re.escape("Scalar function (exp) on [] not supported.")): - math.exp([]) - - # Test with None (should raise ValueError from ensure_scalar_input) - with pytest.raises(ValueError, match=re.escape("Scalar function (exp) on None not supported.")): - math.exp(None) - - # Test with string (should raise ValueError from ensure_scalar_input) - with pytest.raises( - ValueError, match=re.escape("Scalar function (exp) on not a number not supported.") - ): - math.exp("not a number") - - # Test with NaN/Inf inputs (should behave like numpy) - assert np.isnan(math.exp(np.nan)) - assert math.exp(np.inf) == np.inf - assert math.exp(-np.inf) == 0.0 - - assert np.isnan(math.exp(np.nan * u.dimensionless)) - assert math.exp(np.inf * u.dimensionless) == np.inf * u.dimensionless - assert math.exp(-np.inf * u.dimensionless) == 0.0 * u.dimensionless - - # Test with incorrect dimensions (should raise ValueError from _check_operation_dimensions) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (5 m) must be 1 to perform exp operation." - ), - ): - math.exp(5 * u.m) - - x_length = UserVariable(name="x_length", value=6 * u.m) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (x_length) must be 1 to perform exp operation." - ), - ): - math.exp(x_length) - - expr_length = Expression(expression="7 * u.m") - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (7 * u.m) must be 1 to perform exp operation." - ), - ): - math.exp(expr_length) - - # Test with solution variable that is not dimensionless - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (solution.velocity[0]) must be 1 to perform exp operation." - ), - ): - math.exp(solution.velocity[0]) - - -# ---------------------------# -# Trigonometric functions -# ---------------------------# -def test_sin_scalar_input(): - """Test sin function with valid scalar inputs.""" - - # Test with regular numbers - assert math.sin(0) == 0.0 - assert math.sin(np.pi / 2) == pytest.approx(1.0) - assert math.sin(np.pi) == pytest.approx(0.0) - - # Test with unyt quantities - assert math.sin(0 * u.rad) == 0.0 - assert math.sin(np.pi / 2 * u.rad) == pytest.approx(1.0) - - # Test with expressions - x = UserVariable(name="x", value=np.pi / 2 * u.rad) - result = math.sin(x) - assert str(result) == "math.sin(x)" - assert result.evaluate() == pytest.approx(1.0) - - # Test with string expressions - expr = Expression(expression="math.sin(0)") - assert expr.evaluate() == 0.0 - - -def test_sin_non_scalar_input_errors(): - """Test sin function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.sin([1, 2, 3]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.sin(u.unyt_array([1, 2, 3], u.rad)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.rad) - with pytest.raises(ValueError, match="Scalar function"): - math.sin(x) - - -def test_sin_dimensions_errors(): - """Test sin function raise errors for incorrect dimensions.""" - - # Test sin with incorrect dimensions - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (5 m) must be one of ((angle), 1) to perform sin operation." - ), - ): - math.sin(5 * u.m) - x_length = UserVariable(name="x_length", value=6 * u.m) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (x_length) must be one of ((angle), 1) to perform sin operation." - ), - ): - math.sin(x_length) - expr_length = Expression(expression="7 * u.m") - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (7 * u.m) must be one of ((angle), 1) to perform sin operation." - ), - ): - math.sin(expr_length) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (solution.Cp * math.sqrt(solution.mut)) must be one of ((angle), 1) to perform sin operation." - ), - ): - math.sin(solution.Cp * math.sqrt(solution.mut)) - - -def test_cos_scalar_input(): - """Test cos function with valid scalar inputs.""" - - # Test with regular numbers - assert math.cos(0) == 1.0 - assert math.cos(np.pi / 2) == pytest.approx(0.0) - assert math.cos(np.pi) == pytest.approx(-1.0) - - # Test with unyt quantities - assert math.cos(0 * u.rad) == 1.0 - assert math.cos(np.pi / 2 * u.rad) == pytest.approx(0.0) - - # Test with expressions - x = UserVariable(name="x", value=0 * u.rad) - result = math.cos(x) - assert str(result) == "math.cos(x)" - assert result.evaluate() == 1.0 - - # Test with string expressions - expr = Expression(expression="math.cos(0)") - assert expr.evaluate() == 1.0 - - -def test_cos_non_scalar_input_errors(): - """Test cos function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.cos([1, 2, 3]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.cos(u.unyt_array([1, 2, 3], u.rad)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.rad) - with pytest.raises(ValueError, match="Scalar function"): - math.cos(x) - - -def test_cos_dimensions_errors(): - """Test cos function raise errors for incorrect dimensions.""" - - # Test cos with incorrect dimensions - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (10 s) must be one of ((angle), 1) to perform cos operation." - ), - ): - math.cos(10 * u.s) - x_time = UserVariable(name="x_time", value=10 * u.s) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (x_time) must be one of ((angle), 1) to perform cos operation." - ), - ): - math.cos(x_time) - expr_time = Expression(expression="10 * u.s") - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (10 * u.s) must be one of ((angle), 1) to perform cos operation." - ), - ): - math.cos(expr_time) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (solution.Cf * math.sqrt(solution.velocity[0])) must be one of ((angle), 1) to perform cos operation." - ), - ): - math.cos(solution.Cf * math.sqrt(solution.velocity[0])) - - -def test_tan_scalar_input(): - """Test tan function with valid scalar inputs.""" - - # Test with regular numbers - assert math.tan(0) == 0.0 - assert math.tan(np.pi / 4) == pytest.approx(1.0) - - # Test with unyt quantities - assert math.tan(0 * u.rad) == 0.0 - assert math.tan(np.pi / 4 * u.rad) == pytest.approx(1.0) - - # Test with expressions - x = UserVariable(name="x", value=0 * u.rad) - result = math.tan(x) - assert str(result) == "math.tan(x)" - assert result.evaluate() == 0.0 - - # Test with string expressions - expr = Expression(expression="math.tan(0)") - assert expr.evaluate() == 0.0 - - -def test_tan_non_scalar_input_errors(): - """Test tan function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.tan([1, 2, 3]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.tan(u.unyt_array([1, 2, 3], u.rad)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.rad) - with pytest.raises(ValueError, match="Scalar function"): - math.tan(x) - - -def test_tan_dimensions_errors(): - """Test tan function raise errors for incorrect dimensions.""" - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (2 kg) must be one of ((angle), 1) to perform tan operation." - ), - ): - math.tan(2 * u.kg) - x_mass = UserVariable(name="x_mass", value=2 * u.kg) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (x_mass) must be one of ((angle), 1) to perform tan operation." - ), - ): - math.tan(x_mass) - expr_mass = Expression(expression="2 * u.kg") - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (2 * u.kg) must be one of ((angle), 1) to perform tan operation." - ), - ): - math.tan(expr_mass) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (solution.Cf * math.sqrt(solution.entropy)) must be one of ((angle), 1) to perform cos operation." - ), - ): - math.cos(solution.Cf * math.sqrt(solution.entropy)) - - -# ---------------------------# -# Inverse trigonometric functions -# ---------------------------# -def test_asin_scalar_input(): - """Test asin function with valid scalar inputs.""" - - # Test with regular numbers - assert math.asin(0) == 0.0 - assert math.asin(1) == pytest.approx(np.pi / 2) - assert math.asin(-1) == pytest.approx(-np.pi / 2) - - # Test with unyt quantities - assert math.asin(0 * u.dimensionless) == 0.0 - assert math.asin(1 * u.dimensionless) == pytest.approx(np.pi / 2) - - # Test with expressions - x = UserVariable(name="x", value=0 * u.dimensionless) - result = math.asin(x) - assert str(result) == "math.asin(x)" - assert result.evaluate() == 0.0 - - # Test with string expressions - expr = Expression(expression="math.asin(0)") - assert expr.evaluate() == 0.0 - - -def test_asin_non_scalar_input_errors(): - """Test asin function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.asin([1, 2, 3]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.asin(u.unyt_array([1, 2, 3], u.dimensionless)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.dimensionless) - with pytest.raises(ValueError, match="Scalar function"): - math.asin(x) - - -def test_asin_dimensions_errors(): - """Test asin function raise errors for incorrect dimensions.""" - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (2 K) must be 1 to perform asin operation." - ), - ): - math.asin(2 * u.K) - x_temperature = UserVariable(name="x_temperature", value=2 * u.K) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (x_temperature) must be 1 to perform asin operation." - ), - ): - math.asin(x_temperature) - expr_temperature = Expression(expression="2 * u.K") - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (2 * u.K) must be 1 to perform asin operation." - ), - ): - math.asin(expr_temperature) - - -def test_acos_scalar_input(): - """Test acos function with valid scalar inputs.""" - - # Test with regular numbers - assert math.acos(1) == 0.0 - assert math.acos(0) == pytest.approx(np.pi / 2) - assert math.acos(-1) == pytest.approx(np.pi) - - # Test with unyt quantities - assert math.acos(1 * u.dimensionless) == 0.0 - assert math.acos(0 * u.dimensionless) == pytest.approx(np.pi / 2) - - # Test with expressions - x = UserVariable(name="x", value=1 * u.dimensionless) - result = math.acos(x) - assert str(result) == "math.acos(x)" - assert result.evaluate() == 0.0 - - # Test with string expressions - expr = Expression(expression="math.acos(1)") - assert expr.evaluate() == 0.0 - - -def test_acos_non_scalar_input_errors(): - """Test acos function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.acos([1, 2, 3]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.acos(u.unyt_array([1, 2, 3], u.dimensionless)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.dimensionless) - with pytest.raises(ValueError, match="Scalar function"): - math.acos(x) - - -def test_acos_dimensions_errors(): - """Test tan function raise errors for incorrect dimensions.""" - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (2 K) must be 1 to perform acos operation." - ), - ): - math.acos(2 * u.K) - x_temperature = UserVariable(name="x_temperature", value=2 * u.K) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (x_temperature) must be 1 to perform acos operation." - ), - ): - math.acos(x_temperature) - expr_temperature = Expression(expression="2 * u.K") - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (2 * u.K) must be 1 to perform acos operation." - ), - ): - math.acos(expr_temperature) - - -def test_atan_scalar_input(): - """Test atan function with valid scalar inputs.""" - - # Test with regular numbers - assert math.atan(0) == 0.0 - assert math.atan(1) == pytest.approx(np.pi / 4) - assert math.atan(-1) == pytest.approx(-np.pi / 4) - - # Test with unyt quantities - assert math.atan(0 * u.dimensionless) == 0.0 - assert math.atan(1 * u.dimensionless) == pytest.approx(np.pi / 4) - - # Test with expressions - x = UserVariable(name="x", value=0 * u.dimensionless) - result = math.atan(x) - assert str(result) == "math.atan(x)" - assert result.evaluate() == 0.0 - - # Test with string expressions - expr = Expression(expression="math.atan(0)") - assert expr.evaluate() == 0.0 - - -def test_atan_non_scalar_input_errors(): - """Test atan function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.atan([1, 2, 3]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.atan(u.unyt_array([1, 2, 3], u.dimensionless)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.dimensionless) - with pytest.raises(ValueError, match="Scalar function"): - math.atan(x) - - -def test_atan_dimensions_errors(): - """Test tan function raise errors for incorrect dimensions.""" - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (2 K) must be 1 to perform atan operation." - ), - ): - math.atan(2 * u.K) - x_temperature = UserVariable(name="x_temperature", value=2 * u.K) - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (x_temperature) must be 1 to perform atan operation." - ), - ): - math.atan(x_temperature) - expr_temperature = Expression(expression="2 * u.K") - with pytest.raises( - ValueError, - match=re.escape( - "The dimensions of the input value (2 * u.K) must be 1 to perform atan operation." - ), - ): - math.atan(expr_temperature) - - -def test_trig_functions_edge_cases(): - """Test sin, cos, and tan functions with NaN/Inf inputs.""" - - # Test with NaN/Inf inputs (should behave like numpy) - assert np.isnan(math.sin(np.nan)) - assert np.isnan(math.cos(np.nan)) - assert np.isnan(math.tan(np.nan)) - - # For inf, numpy trig functions raise a warning and return nan - with pytest.warns(RuntimeWarning, match="invalid value encountered in sin"): - assert np.isnan(math.sin(np.inf)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in cos"): - assert np.isnan(math.cos(np.inf)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in tan"): - assert np.isnan(math.tan(np.inf)) - - with pytest.warns(RuntimeWarning, match="invalid value encountered in sin"): - assert np.isnan(math.sin(-np.inf)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in cos"): - assert np.isnan(math.cos(-np.inf)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in tan"): - assert np.isnan(math.tan(-np.inf)) - - # Test with unyt quantities - assert np.isnan(math.sin(np.nan * u.rad)) - assert np.isnan(math.cos(np.nan * u.rad)) - assert np.isnan(math.tan(np.nan * u.rad)) - - with pytest.warns(RuntimeWarning, match="invalid value encountered in sin"): - assert np.isnan(math.sin(np.inf * u.rad)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in cos"): - assert np.isnan(math.cos(np.inf * u.rad)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in tan"): - assert np.isnan(math.tan(np.inf * u.rad)) - - -def test_inverse_trig_functions_edge_cases(): - """Test asin, acos, and atan functions with NaN/Inf and out-of-domain inputs.""" - - # Test with NaN/Inf inputs (should behave like numpy) - assert np.isnan(math.asin(np.nan)) - assert np.isnan(math.acos(np.nan)) - assert np.isnan(math.atan(np.nan)) - - assert math.atan(np.inf) == pytest.approx(np.pi / 2) - assert math.atan(-np.inf) == pytest.approx(-np.pi / 2) - - # For asin/acos, inf is outside the domain [-1, 1] - with pytest.warns(RuntimeWarning, match="invalid value encountered in arcsin"): - assert np.isnan(math.asin(np.inf)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in arccos"): - assert np.isnan(math.acos(np.inf)) - - # Test with values outside the domain [-1, 1] for asin and acos - with pytest.warns(RuntimeWarning, match="invalid value encountered in arcsin"): - assert np.isnan(math.asin(2.0)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in arcsin"): - assert np.isnan(math.asin(-2.0)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in arccos"): - assert np.isnan(math.acos(2.0)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in arccos"): - assert np.isnan(math.acos(-2.0)) - - # Test with unyt quantities - assert np.isnan(math.asin(np.nan * u.dimensionless)) - assert np.isnan(math.acos(np.nan * u.dimensionless)) - assert np.isnan(math.atan(np.nan * u.dimensionless)) - - assert math.atan(np.inf * u.dimensionless) == pytest.approx(np.pi / 2) - assert math.atan(-np.inf * u.dimensionless) == pytest.approx(-np.pi / 2) - - with pytest.warns(RuntimeWarning, match="invalid value encountered in arcsin"): - assert np.isnan(math.asin(2.0 * u.dimensionless)) - with pytest.warns(RuntimeWarning, match="invalid value encountered in arccos"): - assert np.isnan(math.acos(2.0 * u.dimensionless)) - - -# ---------------------------# -# Min/Max functions -# ---------------------------# -def test_min_scalar_input(): - """Test min function with valid scalar inputs.""" - - # Test with regular numbers - assert math.min(5, 20) == 5.0 - assert math.min(-3, 5) == -3.0 - assert math.min(0, 19) == 0.0 - - # Test with unyt quantities - assert math.min(5 * u.m, 20 * u.m) == 5 * u.m - assert math.min(-3 * u.K, 5 * u.K) == -3 * u.K - - # Test with expressions - x = UserVariable(name="x", value=5 * u.m) - y = UserVariable(name="y", value=20 * u.m) - result = math.min(x, y) - assert str(result) == "math.min(x, y)" - assert result.evaluate() == 5 * u.m - - # Test with string expressions - expr = Expression(expression="math.min(5,20)") - assert expr.evaluate() == 5.0 - - -def test_min_non_scalar_input_errors(): - """Test min function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.min([1, 2, 3], [4, 5, 6]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.min(u.unyt_array([1, 2, 3], u.m), u.unyt_array([4, 5, 6], u.m)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - y = UserVariable(name="y", value=[4, 5, 6] * u.m) - with pytest.raises(ValueError, match="Scalar function"): - math.min(x, y) - - -def test_min_different_dimensions_errors(): - """Test min function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises( - ValueError, - match=re.escape( - "Input values (1 | 4 s) must have the same dimensions to perform min operation." - ), - ): - math.min(1, 4 * u.s) - - # Test with unyt quantity - with pytest.raises( - ValueError, - match=re.escape( - "Input values (1 m | 4 K) must have the same dimensions to perform min operation." - ), - ): - math.min(u.unyt_quantity(1, u.m), u.unyt_quantity(4, u.K)) - - # Test with variables - x = UserVariable(name="x", value=solution.mut) - y = UserVariable(name="y", value=solution.velocity[0]) - with pytest.raises( - ValueError, - match=re.escape( - "Input values (x | y) must have the same dimensions to perform min operation." - ), - ): - math.min(x, y) - - -def test_max_scalar_input(): - """Test max function with valid scalar inputs.""" - - # Test with regular numbers - assert math.max(5, -20) == 5.0 - assert math.max(-3, -5) == -3.0 - assert math.max(0, -19) == 0.0 - - # Test with unyt quantities - assert math.max(5 * u.m, -20 * u.m) == 5 * u.m - assert math.max(-3 * u.K, -5 * u.K) == -3 * u.K - - # Test with expressions - x = UserVariable(name="x", value=5 * u.m) - y = UserVariable(name="y", value=-20 * u.m) - result = math.max(x, y) - assert str(result) == "math.max(x, y)" - assert result.evaluate() == 5 * u.m - - # Test with string expressions - expr = Expression(expression="math.max(5,-20)") - assert expr.evaluate() == 5.0 - - -def test_max_non_scalar_input_errors(): - """Test max function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.max([1, 2, 3], [4, 5, 6]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.max(u.unyt_array([1, 2, 3], u.m), u.unyt_array([4, 5, 6], u.m)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - y = UserVariable(name="y", value=[4, 5, 6] * u.m) - with pytest.raises(ValueError, match="Scalar function"): - math.max(x, y) - - -def test_max_different_dimensions_errors(): - """Test max function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises( - ValueError, - match=re.escape( - "Input values (1 | 4 s) must have the same dimensions to perform max operation." - ), - ): - math.max(1, 4 * u.s) - - # Test with unyt quantity - with pytest.raises( - ValueError, - match=re.escape( - "Input values (1 m | 4 K) must have the same dimensions to perform max operation." - ), - ): - math.max(u.unyt_quantity(1, u.m), u.unyt_quantity(4, u.K)) - - # Test with variables - x = UserVariable(name="x", value=solution.mut) - y = UserVariable(name="y", value=solution.velocity[0]) - with pytest.raises( - ValueError, - match=re.escape( - "Input values (x | y) must have the same dimensions to perform max operation." - ), - ): - math.max(x, y) - - -# ---------------------------# -# Min/Max edge cases -# ---------------------------# -def test_min_max_edge_cases(): - """Test min and max functions with various edge cases.""" - - # Test with empty list input (should raise IndexError from _get_input_value_dimensions) - with pytest.raises(ValueError, match=re.escape("Scalar function (min) on [] not supported.")): - math.min([], []) - - with pytest.raises(ValueError, match=re.escape("Scalar function (max) on [] not supported.")): - math.max([], []) - - # Test with mixed scalar/expression/variable inputs - x = UserVariable(name="x", value=10) - y = UserVariable(name="y", value=5 * u.m) - - # Scalar and UserVariable - result = math.min(15, x) - assert isinstance(result, Expression) - assert str(result) == "math.min(15, x)" - assert result.evaluate() == 10 - - result = math.max(x, 15) - assert isinstance(result, Expression) - assert str(result) == "math.max(x, 15)" - assert result.evaluate() == 15 - - result = math.min(15 * u.m, y) - assert isinstance(result, Expression) - assert str(result) == "math.min(15 * u.m, y)" - assert result.evaluate() == 5 * u.m - - result = math.max(y, 15 * u.m) - assert isinstance(result, Expression) - assert str(result) == "math.max(y, 15 * u.m)" - assert result.evaluate() == 15 * u.m - - # Expression and scalar - result = math.min(Expression(expression="x + 5"), 12) - assert isinstance(result, Expression) - assert str(result) == "math.min(x + 5, 12)" - assert result.evaluate() == 12 - - result = math.max(Expression(expression="y * 2"), 8 * u.m) - assert isinstance(result, Expression) - assert str(result) == "math.max(y * 2, 8 * u.m)" - assert result.evaluate() == 10 * u.m - - # Test with NaN/Inf inputs (should behave like numpy) - assert np.isnan(math.min(np.nan, 5)) - assert np.isnan(math.max(np.nan, 5)) - assert math.min(np.inf, 5) == 5 - assert math.max(np.inf, 5) == np.inf - assert math.min(-np.inf, 5) == -np.inf - assert math.max(-np.inf, 5) == 5 - - assert np.isnan(math.min(np.nan * u.m, 5 * u.m)) - assert np.isnan(math.max(np.nan * u.m, 5 * u.m)) - assert math.min(np.inf * u.m, 5 * u.m) == 5 * u.m - assert math.max(np.inf * u.m, 5 * u.m) == np.inf * u.m - assert math.min(-np.inf * u.m, 5 * u.m) == -np.inf * u.m - assert math.max(-np.inf * u.m, 5 * u.m) == 5 * u.m - - -# ---------------------------# -# Absolute value function -# ---------------------------# -def test_abs_scalar_input(): - """Test abs function with valid scalar inputs.""" - - # Test with regular numbers - assert math.abs(5) == 5.0 - assert math.abs(-3) == 3.0 - assert math.abs(0) == 0.0 - - # Test with unyt quantities - assert math.abs(5 * u.m) == 5 * u.m - assert math.abs(-3 * u.K) == 3 * u.K - - # Test with expressions - x = UserVariable(name="x", value=-3 * u.m) - result = math.abs(x) - assert str(result) == "math.abs(x)" - assert result.evaluate() == 3 * u.m - - # Test with string expressions - expr = Expression(expression="math.abs(-3)") - assert expr.evaluate() == 3.0 - - -def test_abs_non_scalar_input_errors(): - """Test abs function raises errors for non-scalar inputs.""" - - # Test with lists/arrays - with pytest.raises(ValueError, match="Scalar function"): - math.abs([1, 2, 3]) - - # Test with unyt arrays - with pytest.raises(ValueError, match="Scalar function"): - math.abs(u.unyt_array([1, 2, 3], u.m)) - - # Test with variables containing arrays - x = UserVariable(name="x", value=[1, 2, 3] * u.m) - with pytest.raises(ValueError, match="Scalar function"): - math.abs(x) - - -# ---------------------------# -# Pi constant -# ---------------------------# -def test_pi_constant(): - """Test pi constant with various usage patterns.""" - - # Test direct access to pi - assert math.pi == np.pi - assert math.pi == pytest.approx(3.141592653589793) - - # Test pi in expressions - x = UserVariable(name="x", value=2 * u.m) - result = x * math.pi - assert str(result) == "x * 3.141592653589793" - assert result.evaluate() == 2 * np.pi * u.m - - # Test pi in trigonometric functions - assert math.sin(math.pi) == pytest.approx(0.0) - assert math.cos(math.pi) == pytest.approx(-1.0) - assert math.sin(math.pi / 2) == pytest.approx(1.0) - - # Test pi with unyt quantities - result = math.pi * u.rad - assert result == np.pi * u.rad - - # Test pi with solution variables - result = solution.coordinate[0] * math.pi - assert str(result) == "solution.coordinate[0] * 3.141592653589793" - - # Test pi in scaling context - with SI_unit_system: - result = math.pi * 10 * u.m - assert result == np.pi * 10 * u.m - - expr = Expression(expression="math.pi*x") - assert expr.evaluate() == 2 * np.pi * u.m diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 95dbf2779..8b9b689c5 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,12 +1,16 @@ +# NOTE: Schema-pure expression tests (DependencyGraph, Variable, Expression operators/validators, +# ValueOrExpression, registry, utils, etc.) have been migrated to: +# flex/share/flow360-schema/tests/framework/expression/ +# The tests below depend on SimulationParams/validate_model/translator and cannot be migrated +# until those components are also migrated to flow360-schema. + import json -import re -from typing import Annotated, Optional -import numpy as np -import pydantic as pd +import flow360_schema.framework.expression.registry as context import pytest +from flow360_schema.framework.expression import Expression, UserVariable +from flow360_schema.models.variables import control, solution -import flow360.component.simulation.user_code.core.context as context from flow360 import ( AerospaceCondition, HeatEquationInitialCondition, @@ -17,10 +21,8 @@ math, u, ) -from flow360.component.simulation.blueprint.core.dependency_graph import DependencyGraph from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.framework.updater_utils import compare_lists from flow360.component.simulation.models.material import Water, aluminum from flow360.component.simulation.outputs.output_entities import Point from flow360.component.simulation.outputs.outputs import ( @@ -41,43 +43,11 @@ from flow360.component.simulation.translator.solver_translator import ( user_variable_to_udf, ) -from flow360.component.simulation.unit_system import ( - AbsoluteTemperatureType, - AngleType, - AngularVelocityType, - AreaType, - DensityType, - ForceType, - FrequencyType, - HeatFluxType, - HeatSourceType, - InverseAreaType, - InverseLengthType, - LengthType, - MassFlowRateType, - MassType, - MomentType, - PowerType, - PressureType, - SI_unit_system, - SpecificEnergyType, - SpecificHeatCapacityType, - ThermalConductivityType, - TimeType, - VelocityType, - ViscosityType, -) -from flow360.component.simulation.user_code.core.context import WHITELISTED_CALLABLES +from flow360.component.simulation.unit_system import SI_unit_system from flow360.component.simulation.user_code.core.types import ( - Expression, - SolverVariable, - UserVariable, ValueOrExpression, - get_user_variable, - remove_user_variable, save_user_variables, ) -from flow360.component.simulation.user_code.variables import control, solution from tests.simulation.conftest import to_file_from_file_test_approx @@ -112,552 +82,6 @@ def constant_unyt_array(): return UserVariable(name="constant_unyt_array", value=[10, 20] * u.m) -@pytest.fixture() -def solution_variable(): - return UserVariable(name="solution_variable", value=solution.velocity) - - -def test_variable_init(): - # Variables can be initialized with a... - - # Value - a = UserVariable(name="a", value=1) - - # Dimensioned value - b = UserVariable(name="b", value=1 * u.m) - - # Expression (possibly with other variable) - c = UserVariable(name="c", value=b + 1 * u.m) - - with pytest.raises(ValueError, match="Empty list is not allowed."): - UserVariable(name="d", value=[]) - - -def test_expression_init(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[float] = pd.Field() - - # Declare a variable - x = UserVariable(name="x", value=1) - - # Initialize with value - model_1 = TestModel(field=1) - assert isinstance(model_1.field, float) - assert model_1.field == 1 - assert str(model_1.field) == "1.0" - - # Initialize with variable - model_2 = TestModel(field=x) - assert isinstance(model_2.field, Expression) - assert model_2.field.evaluate() == 1 - assert str(model_2.field) == "x" - - # Initialize with variable and value - model_3 = TestModel(field=x + 1) - assert isinstance(model_3.field, Expression) - assert model_3.field.evaluate() == 2 - assert str(model_3.field) == "x + 1" - - # Initialize with another expression - model_4 = TestModel(field=model_3.field + 1) - assert isinstance(model_4.field, Expression) - assert model_4.field.evaluate() == 3 - assert str(model_4.field) == "x + 1 + 1" - - -def test_variable_reassignment(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[float] = pd.Field() - - # Declare a variable - x = UserVariable(name="x", value=1) - - model = TestModel(field=x) - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 1 - assert str(model.field) == "x" - - # Change variable value - x.value = 2 - - assert model.field.evaluate() == 2 - - -def test_expression_operators(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[float] = pd.Field() - - # Declare two variables - x = UserVariable(name="x", value=3) - y = UserVariable(name="y", value=2) - - model = TestModel(field=x + y) - - # Addition - model.field = x + y - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 5 - assert str(model.field) == "x + y" - - # Subtraction - model.field = x - y - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 1 - assert str(model.field) == "x - y" - - # Multiplication - model.field = x * y - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 6 - assert str(model.field) == "x * y" - - # Division - model.field = x / y - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 1.5 - assert str(model.field) == "x / y" - - # Exponentiation - model.field = x**y - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 9 - assert str(model.field) == "x ** y" - - # Modulus - model.field = x % y - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 1 - assert str(model.field) == "x % y" - - # Negation - model.field = -x - assert isinstance(model.field, Expression) - assert model.field.evaluate() == -3 - assert str(model.field) == "-x" - - # Identity - model.field = +x - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 3 - assert str(model.field) == "+x" - - # Complex statement - model.field = ((x - 2 * x) + (x + y) / 2 - 2**x) % 4 - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 3.5 - assert str(model.field) == "(x - 2 * x + (x + y) / 2 - 2 ** x) % 4" - - -def test_dimensioned_expressions(): - class TestModel(Flow360BaseModel): - length: ValueOrExpression[LengthType] = pd.Field() - angle: ValueOrExpression[AngleType] = pd.Field() - mass: ValueOrExpression[MassType] = pd.Field() - time: ValueOrExpression[TimeType] = pd.Field() - absolute_temp: ValueOrExpression[AbsoluteTemperatureType] = pd.Field() - velocity: ValueOrExpression[VelocityType] = pd.Field() - area: ValueOrExpression[AreaType] = pd.Field() - force: ValueOrExpression[ForceType] = pd.Field() - pressure: ValueOrExpression[PressureType] = pd.Field() - density: ValueOrExpression[DensityType] = pd.Field() - viscosity: ValueOrExpression[ViscosityType] = pd.Field() - power: ValueOrExpression[PowerType] = pd.Field() - moment: ValueOrExpression[MomentType] = pd.Field() - angular_velocity: ValueOrExpression[AngularVelocityType] = pd.Field() - heat_flux: ValueOrExpression[HeatFluxType] = pd.Field() - heat_source: ValueOrExpression[HeatSourceType] = pd.Field() - specific_heat_capacity: ValueOrExpression[SpecificHeatCapacityType] = pd.Field() - thermal_conductivity: ValueOrExpression[ThermalConductivityType] = pd.Field() - inverse_area: ValueOrExpression[InverseAreaType] = pd.Field() - inverse_length: ValueOrExpression[InverseLengthType] = pd.Field() - mass_flow_rate: ValueOrExpression[MassFlowRateType] = pd.Field() - specific_energy: ValueOrExpression[SpecificEnergyType] = pd.Field() - frequency: ValueOrExpression[FrequencyType] = pd.Field() - - model_legacy = TestModel( - length=1 * u.m, - angle=1 * u.rad, - mass=1 * u.kg, - time=1 * u.s, - absolute_temp=1 * u.K, - velocity=1 * u.m / u.s, - area=1 * u.m**2, - force=1 * u.N, - pressure=1 * u.Pa, - density=1 * u.kg / u.m**3, - viscosity=1 * u.Pa * u.s, - power=1 * u.W, - moment=1 * u.N * u.m, - angular_velocity=1 * u.rad / u.s, - heat_flux=1 * u.W / u.m**2, - heat_source=1 * u.kg / u.m / u.s**3, - specific_heat_capacity=1 * u.J / u.kg / u.K, - thermal_conductivity=1 * u.W / u.m / u.K, - inverse_area=1 / u.m**2, - inverse_length=1 / u.m, - mass_flow_rate=1 * u.kg / u.s, - specific_energy=1 * u.J / u.kg, - frequency=1 * u.Hz, - ) - - assert model_legacy - - x = UserVariable(name="x", value=1) - - model_expression = TestModel( - length=x * u.m, - angle=x * u.rad, - mass=x * u.kg, - time=x * u.s, - absolute_temp=x * u.K, - velocity=x * u.m / u.s, - area=x * u.m**2, - force=x * u.N, - pressure=x * u.Pa, - density=x * u.kg / u.m**3, - viscosity=x * u.Pa * u.s, - power=x * u.W, - moment=x * u.N * u.m, - angular_velocity=x * u.rad / u.s, - heat_flux=x * u.W / u.m**2, - heat_source=x * u.kg / u.m / u.s**3, - specific_heat_capacity=x * u.J / u.kg / u.K, - thermal_conductivity=x * u.W / u.m / u.K, - inverse_area=x / u.m**2, - inverse_length=x / u.m, - mass_flow_rate=x * u.kg / u.s, - specific_energy=x * u.J / u.kg, - frequency=x * u.Hz, - ) - - assert model_expression - - -def test_variable_and_expression_overloaded_operations(): - sol_var = solution.pressure - const_dimensioned_var = 11 * u.N / u.m**2 - constant_var = 22 - constant_user_var_dimensioned = UserVariable( - name="constant_user_var_dimensioned", value=const_dimensioned_var - ) - - # Test all overloaded operators for Variables and Expressions - # Using dimensionally consistent operations - - # 1. __add__ (addition) - both Variable and Expression - assert ( - (sol_var + const_dimensioned_var) + const_dimensioned_var - ).expression == "solution.pressure + 11 * u.N / u.m ** 2 + 11 * u.N / u.m ** 2" - - # 2. __sub__ (subtraction) - both Variable and Expression - assert ( - (sol_var - const_dimensioned_var) - const_dimensioned_var - ).expression == "solution.pressure - 11 * u.N / u.m ** 2 - 11 * u.N / u.m ** 2" - - # 3. __mul__ (multiplication) - both Variable and Expression - assert ( - (sol_var * const_dimensioned_var) * constant_var - ).expression == "solution.pressure * 11 * u.N / u.m ** 2 * 22" - - # 4. __truediv__ (division) - both Variable and Expression - assert ( - (sol_var / const_dimensioned_var) / const_dimensioned_var - ).expression == "solution.pressure / (11 * u.N / u.m ** 2) / (11 * u.N / u.m ** 2)" - - # 5. __floordiv__ (floor division) - both Variable and Expression - assert ( - (sol_var // const_dimensioned_var) // const_dimensioned_var - ).expression == "solution.pressure // (11 * u.N / u.m ** 2) // (11 * u.N / u.m ** 2)" - - # 12. __rmul__ (reverse multiplication) - both Variable and Expression - assert ( - sol_var * (const_dimensioned_var * sol_var) - ).expression == "solution.pressure * (11 * u.N / u.m ** 2 * solution.pressure)" - - # 13. __rtruediv__ (reverse division) - both Variable and Expression - assert ( - sol_var / (const_dimensioned_var / sol_var) - ).expression == "solution.pressure / (11 * u.N / u.m ** 2 / solution.pressure)" - - # 14. __rfloordiv__ (reverse floor division) - both Variable and Expression - assert ( - (sol_var // (constant_user_var_dimensioned // sol_var)) - ).expression == "solution.pressure // (constant_user_var_dimensioned // solution.pressure)" - - -def test_constrained_scalar_type(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[Annotated[float, pd.Field(strict=True, ge=0)]] = pd.Field() - - x = UserVariable(name="x", value=1) - - model = TestModel(field=x) - - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 1 - assert str(model.field) == "x" - - with pytest.raises(pd.ValidationError): - model.field = -x - - -def test_disallow_run_time_expressions(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[float] = pd.Field() - - with pytest.raises(ValueError, match="Run-time expression is not allowed in this field."): - TestModel(field=solution.Cp) - - -def test_constrained_dimensioned_type(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[LengthType.Positive] = pd.Field() - - x = UserVariable(name="x", value=1) - - model = TestModel(field=x * u.m) - - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 1 * u.m - assert str(model.field) == "x * u.m" - - with pytest.raises(pd.ValidationError): - model.field = -x * u.m - - -def test_vector_types(): - class TestModel(Flow360BaseModel): - vector: ValueOrExpression[LengthType.Vector] = pd.Field() - axis: ValueOrExpression[LengthType.Axis] = pd.Field() - array: ValueOrExpression[LengthType.Array] = pd.Field() - direction: ValueOrExpression[LengthType.Direction] = pd.Field() - moment: ValueOrExpression[LengthType.Moment] = pd.Field() - - x = UserVariable(name="x", value=[1, 0, 0] * u.m) - y = UserVariable(name="y", value=[0, 0, 0] * u.m) - z = UserVariable(name="z", value=[1, 0, 0, 0] * u.m) - w = UserVariable(name="w", value=[1, 1, 1] * u.m) - - model = TestModel(vector=y, axis=x, array=z, direction=x, moment=w) - - assert isinstance(model.vector, Expression) - assert (model.vector.evaluate() == [0, 0, 0] * u.m).all() - assert str(model.vector) == "y" - - assert isinstance(model.axis, Expression) - assert (model.axis.evaluate() == [1, 0, 0] * u.m).all() - assert str(model.axis) == "x" - - assert isinstance(model.array, Expression) - assert (model.array.evaluate() == [1, 0, 0, 0] * u.m).all() - assert str(model.array) == "z" - - assert isinstance(model.direction, Expression) - assert (model.direction.evaluate() == [1, 0, 0] * u.m).all() - assert str(model.direction) == "x" - - assert isinstance(model.moment, Expression) - assert (model.moment.evaluate() == [1, 1, 1] * u.m).all() - assert str(model.moment) == "w" - - with pytest.raises(pd.ValidationError): - model.vector = z - - with pytest.raises(pd.ValidationError): - model.axis = y - - with pytest.raises(pd.ValidationError): - model.direction = y - - with pytest.raises(pd.ValidationError): - model.moment = x - - -def test_subscript_access(): - class ScalarModel(Flow360BaseModel): - scalar: ValueOrExpression[float] = pd.Field() - - x = UserVariable(name="x", value=[2, 3, 4]) - - model = ScalarModel(scalar=x[0] + x[1] + x[2] + 1) - - assert str(model.scalar) == "x[0] + x[1] + x[2] + 1" - - assert model.scalar.evaluate() == 10 - - model = ScalarModel(scalar="x[0] + x[1] + x[2] + 1") - - assert str(model.scalar) == "x[0] + x[1] + x[2] + 1" - - assert model.scalar.evaluate() == 10 - - -def test_subscript_on_binary_expression_codegen_cpp(): - from flow360.component.simulation.blueprint.core.generator import expr_to_code - from flow360.component.simulation.blueprint.core.parser import expr_to_model - from flow360.component.simulation.blueprint.core.types import TargetSyntax - from flow360.component.simulation.user_code.core.context import default_context - - # Ensure codegen supports subscript on a BinOp value, e.g., (a * b)[0] - expression_str = "(solution.pressure * solution.node_area_vector)[0]" - expr_model = expr_to_model(expression_str, default_context) - code = expr_to_code(expr_model, TargetSyntax.CPP) - - # In C++ we prefer pushing the subscript into the vector operand: - # ((solution.pressure * solution.node_area_vector[0])) - assert code == "((solution.pressure * solution.node_area_vector[0]))" - - -def test_subscript_on_binary_expression_velocity_cpp(): - from flow360.component.simulation.blueprint.core.generator import expr_to_code - from flow360.component.simulation.blueprint.core.parser import expr_to_model - from flow360.component.simulation.blueprint.core.types import TargetSyntax - from flow360.component.simulation.user_code.core.context import default_context - - expression_str = "(solution.pressure * solution.velocity)[1]" - expr_model = expr_to_model(expression_str, default_context) - code = expr_to_code(expr_model, TargetSyntax.CPP) - - assert code == "((solution.pressure * solution.velocity[1]))" - - -def test_subscript_on_binary_expression_constant_left_cpp(): - from flow360.component.simulation.blueprint.core.generator import expr_to_code - from flow360.component.simulation.blueprint.core.parser import expr_to_model - from flow360.component.simulation.blueprint.core.types import TargetSyntax - from flow360.component.simulation.user_code.core.context import default_context - - expression_str = "(2.0 * solution.node_area_vector)[2]" - expr_model = expr_to_model(expression_str, default_context) - code = expr_to_code(expr_model, TargetSyntax.CPP) - - assert code == "((2.0 * solution.node_area_vector[2]))" - - -def test_subscript_on_binary_expression_dynamic_index_cpp(): - from flow360.component.simulation.blueprint.core.generator import expr_to_code - from flow360.component.simulation.blueprint.core.parser import expr_to_model - from flow360.component.simulation.blueprint.core.types import TargetSyntax - from flow360.component.simulation.user_code.core.context import default_context - - expression_str = "(solution.pressure * solution.node_area_vector)[control.physicalStep]" - expr_model = expr_to_model(expression_str, default_context) - code = expr_to_code(expr_model, TargetSyntax.CPP) - - assert code == "((solution.pressure * solution.node_area_vector[control.physicalStep]))" - - -def test_subscript_on_binary_expression_with_left_parens_cpp(): - from flow360.component.simulation.blueprint.core.generator import expr_to_code - from flow360.component.simulation.blueprint.core.parser import expr_to_model - from flow360.component.simulation.blueprint.core.types import TargetSyntax - from flow360.component.simulation.user_code.core.context import default_context - - expression_str = "((solution.pressure + 1) * solution.node_area_vector)[2]" - expr_model = expr_to_model(expression_str, default_context) - code = expr_to_code(expr_model, TargetSyntax.CPP) - - assert code == "(((solution.pressure + 1) * solution.node_area_vector[2]))" - - -def test_error_message(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[VelocityType] = pd.Field() - - x = UserVariable(name="x", value=4) - - try: - TestModel(field="1 + nonexisting * 1") - except pd.ValidationError as err: - validation_errors = err.errors() - - assert len(validation_errors) >= 1 - assert validation_errors[0]["type"] == "value_error" - assert "Name 'nonexisting' is not defined" in validation_errors[0]["msg"] - - try: - TestModel(field="1 + x * 1") - except pd.ValidationError as err: - validation_errors = err.errors() - - assert len(validation_errors) >= 1 - assert validation_errors[0]["type"] == "value_error" - assert "does not match (length)/(time) dimension" in validation_errors[0]["msg"] - - try: - TestModel(field="1 * 1 +") - except pd.ValidationError as err: - validation_errors = err.errors() - - assert len(validation_errors) >= 1 - assert validation_errors[0]["type"] == "value_error" - assert "invalid syntax" in validation_errors[0]["msg"] - assert "1 * 1 +" in validation_errors[0]["msg"] - assert "line" in validation_errors[0]["ctx"] - assert "column" in validation_errors[0]["ctx"] - assert validation_errors[0]["ctx"]["column"] == 8 - - try: - TestModel(field="1 * 1 +* 2") - except pd.ValidationError as err: - validation_errors = err.errors() - - assert len(validation_errors) >= 1 - assert validation_errors[0]["type"] == "value_error" - assert "invalid syntax" in validation_errors[0]["msg"] - assert "1 * 1 +* 2" in validation_errors[0]["msg"] - assert "line" in validation_errors[0]["ctx"] - assert "column" in validation_errors[0]["ctx"] - assert validation_errors[0]["ctx"]["column"] == 8 - - try: - TestModel(field="1 * 1 + (2") - except pd.ValidationError as err: - validation_errors = err.errors() - - assert len(validation_errors) == 1 - assert validation_errors[0]["type"] == "value_error" - assert "line" in validation_errors[0]["ctx"] - assert "column" in validation_errors[0]["ctx"] - assert validation_errors[0]["ctx"]["column"] == 9 - - with pytest.raises( - ValueError, - match=re.escape( - "Vector operation (__add__ between solution.velocity and [1 2 3] cm/ms) not supported for variables. Please write expression for each component." - ), - ): - UserVariable(name="x", value=solution.velocity + [1, 2, 3] * u.cm / u.ms) - - with pytest.raises( - ValueError, - match=re.escape( - "Vector operation (__add__ between solution.velocity and [1 2 3] cm/ms) not supported for variables. Please write expression for each component." - ), - ): - UserVariable(name="xx", value="solution.velocity + [1, 2, 3] * u.cm / u.ms") - - -def test_temperature_units_usage(): - with pytest.raises( - ValueError, - match="Relative temperature scale usage is not allowed. Please use u.R or u.K instead.", - ): - Expression(expression="[1,2,3] * u.degF") - - with pytest.raises( - ValueError, - match="Relative temperature scale usage is not allowed in output units. Please use u.R or u.K instead.", - ): - UserVariable(name="x", value=solution.temperature + 123 * u.K).in_units(new_unit="u.degF") - - with pytest.raises( - ValueError, - match="Relative temperature scale usage is not allowed in output units. Please use u.R or u.K instead.", - ): - solution.temperature.in_units(new_name="my_temperature", new_unit="u.degF") - - def test_solver_translation(): timestepping_unsteady = Unsteady(steps=12, step_size=0.1 * u.s) solid_model = Solid( @@ -700,57 +124,18 @@ def test_solver_translation(): # 1. Units are converted to flow360 unit system using the provided params (1m**2 -> 0.25 because of length unit) # 2. User variables are inlined (for numeric value types) - assert expression.to_solver_code(params) == "(4.0 * pow(0.5, 2))" + assert expression.to_solver_code(params.flow360_unit_system) == "(4.0 * pow(0.5, 2))" # 3. User variables are inlined (for expression value types) expression = Expression.model_validate(y * u.m**2) - assert expression.to_solver_code(params) == "(5.0 * pow(0.5, 2))" + assert expression.to_solver_code(params.flow360_unit_system) == "(5.0 * pow(0.5, 2))" # 4. For solver variables, the units are stripped (assumed to be in solver units so factor == 1.0) expression = Expression.model_validate(y * u.m / u.s + control.MachRef) - assert expression.to_solver_code(params) == "(((5.0 * 0.5) / 500.0) + machRef)" - - -def test_cyclic_dependencies(): - x = UserVariable(name="x", value=4) - y = UserVariable(name="y", value=x) - - # If we try to create a cyclic dependency we throw a validation error - # The error contains info about the cyclic dependency, so here its x -> y -> x - with pytest.raises( - pd.ValidationError, match=re.escape("Circular dependency detected among: ['x', 'y']") - ): - x.value = y - - z = UserVariable(name="z", value=4) - - with pytest.raises(pd.ValidationError): - z.value = z - - -def test_auto_alias(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[VelocityType] = pd.Field() - - x = UserVariable(name="x", value=4) - - unaliased = { - "type_name": "expression", - "expression": "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)", - } - - aliased = { - "typeName": "expression", - "expression": "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)", - "evaluatedValue": 68.0, - "evaluatedUnits": "m/s", - } - - model_1 = TestModel(field=unaliased) - model_2 = TestModel(field=aliased) - - assert str(model_1.field) == "x * u.m / u.s + 4 * x ** 2 * u.m / u.s" - assert str(model_2.field) == "x * u.m / u.s + 4 * x ** 2 * u.m / u.s" + assert ( + expression.to_solver_code(params.flow360_unit_system) + == "(((5.0 * 0.5) / 500.0) + machRef)" + ) def test_variable_space_init(): @@ -768,28 +153,6 @@ def test_variable_space_init(): assert evaluated == 1.0 * u.m**2 -def test_expression_indexing(): - a = UserVariable(name="a", value=1) - b = UserVariable(name="b", value=[1, 2, 3]) - c = UserVariable(name="c", value=[3, 2, 1]) - - # Cannot simplify without non-statically evaluable index object (expression for example) - cross_result = math.cross(b, c) - expr = Expression.model_validate(cross_result[a]) - - assert ( - str(expr) - == "[b[1] * c[2] - b[2] * c[1], b[2] * c[0] - b[0] * c[2], b[0] * c[1] - b[1] * c[0]][a]" - ) - assert expr.evaluate() == 8 - - # Cannot simplify without non-statically evaluable index object (expression for example) - expr = Expression.model_validate(cross_result[1]) - - assert str(expr) == "b[2] * c[0] - b[0] * c[2]" - assert expr.evaluate() == 8 - - def test_to_file_from_file_expression( constant_variable, constant_array, constant_unyt_quantity, constant_unyt_array ): @@ -815,11 +178,6 @@ def test_to_file_from_file_expression( params.display_output_units() # Just to make sure not exception. -def assert_ignore_space(expected: str, actual: str): - """For expression comparison, ignore spaces""" - assert expected.replace(" ", "") == actual.replace(" ", "") - - def test_udf_generator(): with SI_unit_system: params = SimulationParams( @@ -842,13 +200,16 @@ def test_udf_generator(): vel_cross_vec = UserVariable( name="vel_cross_vec", value=math.cross(solution.velocity, [1, 2, 3] * u.cm) ).in_units(new_unit="CGS_unit_system") - assert vel_cross_vec.value.get_output_units(input_params=params) == u.cm**2 / u.s + assert ( + vel_cross_vec.value.get_output_units(unit_system_name=params.unit_system.name) + == u.cm**2 / u.s + ) # We disabled degC and degF on the interface and therefore the inferred units should be K or R. my_temp = UserVariable(name="my_temperature", value=solution.temperature).in_units( new_unit="Imperial_unit_system" ) - assert my_temp.value.get_output_units(input_params=params) == u.R + assert my_temp.value.get_output_units(unit_system_name=params.unit_system.name) == u.R # Test __pow__ on SolverVariable: vel_sq = UserVariable(name="vel_sq", value=solution.velocity**2) @@ -954,14 +315,18 @@ def test_project_variables_serialization(): assert output_units_by_name["ddd"] == "m/s" assert output_units_by_name["eee"] == "dimensionless" - paramsJson = params.model_dump_json(indent=4, exclude_none=True) + paramsJson = json.dumps( + json.loads(params.model_dump_json(exclude_none=True)), indent=4, sort_keys=True + ) with open("ref/simulation_with_project_variables.json", "w") as f: f.write(paramsJson) with open("ref/simulation_with_project_variables.json", "r") as fh: ref_data = fh.read() - assert ref_data == params.model_dump_json(indent=4, exclude_none=True) + assert ref_data == json.dumps( + json.loads(params.model_dump_json(exclude_none=True)), indent=4, sort_keys=True + ) def test_project_variables_deserialization(): @@ -1089,123 +454,6 @@ def test_project_variables_metadata(): ] -def test_overwriting_project_variables(): - a = UserVariable(name="a", value=1) - - with pytest.raises( - ValueError, - match="Redeclaring user variable 'a' with new value: 2.0. Previous value: 1.0", - ): - UserVariable(name="a", value=2) - - a.value = 2 - assert a.value == 2 - - -def test_unique_dimensions(): - with pytest.raises(ValueError, match="All items in the list must have the same dimensions."): - UserVariable(name="a", value=[1 * u.m, 1 * u.s]) - - with pytest.raises( - ValueError, match="List must contain only all unyt_quantities or all numbers." - ): - UserVariable(name="a", value=[1.0 * u.m, 1.0]) - - a = UserVariable(name="a", value=[1.0 * u.m, 1.0 * u.mm]) - assert all(a.value == [1.0, 0.001] * u.m) - - -@pytest.mark.parametrize( - "bad_name, expected_msg", - [ - ("", "Identifier cannot be empty."), - ("1stPlace", "Identifier must start with a letter (A-Z/a-z) or underscore (_)."), - ("bad-name", "Identifier can only contain letters, digits (0-9), or underscore (_)."), - ("has space", "Identifier can only contain letters, digits (0-9), or underscore (_)."), - (" leading", "Identifier must start with a letter (A-Z/a-z) or underscore (_)."), - ("trailing ", "Identifier can only contain letters, digits (0-9), or underscore (_)."), - ("tab\tname", "Identifier can only contain letters, digits (0-9), or underscore (_)."), - ("new\nline", "Identifier can only contain letters, digits (0-9), or underscore (_)."), - ("name$", "Identifier can only contain letters, digits (0-9), or underscore (_)."), - ("class", "'class' is a reserved keyword."), - ("namespace", "'namespace' is a reserved keyword."), - ("template", "'template' is a reserved keyword."), - ("temperature", "'temperature' is a reserved solver side variable name."), - ("area", "'area' is a reserved solver side variable name."), - ( - "velocity", - "'velocity' is a reserved (legacy) output field name. It cannot be used in expressions.", - ), - ( - "mut", - "'mut' is a reserved (legacy) output field name. It cannot be used in expressions.", - ), - ( - "pressure", - "'pressure' is a reserved (legacy) output field name. It cannot be used in expressions.", - ), - ], -) -def test_invalid_names_raise(bad_name, expected_msg): - with pytest.raises(ValueError, match=re.escape(expected_msg)): - UserVariable(name=bad_name, value=0) - - -def test_output_units_dimensions(): - with pytest.raises( - ValueError, - match=re.escape( - "Output units 'ms' have different dimensions (time) than the expression (length)." - ), - ): - a = UserVariable(name="a", value="1 * u.m") - a.in_units(new_unit="ms") - - -def test_dimensions_with_numpy_array_result(): - """Test that expressions returning numpy arrays can have their dimensions queried. - - This is a regression test for the case where math functions on solver variables - return plain numpy arrays instead of unyt arrays, which should be treated as - dimensionless. - """ - # Test 1: Direct solver variable access returns unyt_quantity - expr1 = Expression(expression="solution.CfVec[0]") - dims1 = expr1.dimensions - assert dims1 == u.dimensionless.dimensions - - # Test 2: Math function on solver variable returns numpy.ndarray - # This used to fail with AssertionError before the fix - expr2 = Expression(expression="math.exp(solution.CfVec[0])") - dims2 = expr2.dimensions - assert dims2 == u.dimensionless.dimensions - - # Test 3: Setting output_units should work for both cases - expr1_copy = expr1.model_copy() - expr1_copy.output_units = "dimensionless" - assert expr1_copy.output_units == "dimensionless" - - expr2_copy = expr2.model_copy() - expr2_copy.output_units = "dimensionless" - assert expr2_copy.output_units == "dimensionless" - - # Test 4: Plain numbers should also work - expr3 = Expression(expression="42") - dims3 = expr3.dimensions - assert dims3 == u.dimensionless.dimensions - - -def test_whitelisted_callables(): - def get_user_variable_names(module): - return [attr for attr in dir(module) if isinstance(getattr(module, attr), SolverVariable)] - - solution_vars = get_user_variable_names(solution) - control_vars = get_user_variable_names(control) - - assert compare_lists(solution_vars, WHITELISTED_CALLABLES["flow360.solution"]["callables"]) - assert compare_lists(control_vars, WHITELISTED_CALLABLES["flow360.control"]["callables"]) - - def test_deserialization_with_wrong_syntax(): with open("data/simulation_with_wrong_expr_syntax.json", "r") as fh: data = json.load(fh) @@ -1222,818 +470,6 @@ def test_deserialization_with_wrong_syntax(): assert errors[0]["loc"] == ("private_attribute_asset_cache", "variable_context", 0) -# DependencyGraph Tests -class TestDependencyGraph: - """Test suite for DependencyGraph class.""" - - def test_init(self): - """Test DependencyGraph initialization.""" - graph = DependencyGraph() - assert graph._graph == {} - assert graph._deps == {} - - def test_extract_deps_simple(self): - """Test dependency extraction from simple expressions.""" - graph = DependencyGraph() - all_names = {"x", "y", "z"} - - # Simple variable reference - deps = graph._extract_deps("x", all_names) - assert deps == {"x"} - - # Expression with multiple variables - deps = graph._extract_deps("x + y * z", all_names) - assert deps == {"x", "y", "z"} - - # Expression with unknown variables (should be filtered out) - deps = graph._extract_deps("x + unknown_var", all_names) - assert deps == {"x"} - - def test_extract_deps_complex_expressions(self): - """Test dependency extraction from complex expressions.""" - graph = DependencyGraph() - all_names = {"a", "b", "c", "d", "e"} - - # Complex mathematical expression - deps = graph._extract_deps("(a + b) * c / (d - e)", all_names) - assert deps == {"a", "b", "c", "d", "e"} - - # Function calls - deps = graph._extract_deps("abs(a) + max(b, c)", all_names) - assert deps == {"a", "b", "c"} - - # Nested expressions - deps = graph._extract_deps("a + (b * (c + d))", all_names) - assert deps == {"a", "b", "c", "d"} - - def test_extract_deps_syntax_error(self): - """Test dependency extraction with syntax errors.""" - graph = DependencyGraph() - all_names = {"x", "y"} - - # Invalid syntax should return empty set - deps = graph._extract_deps("x +", all_names) - assert deps == set() - - deps = graph._extract_deps("x + * y", all_names) - assert deps == set() - - def test_load_from_list_simple(self): - """Test loading variables from a simple list.""" - graph = DependencyGraph() - vars_list = [ - {"name": "x", "value": "1"}, - {"name": "y", "value": "x + 1"}, - ] - - graph.load_from_list(vars_list) - - # Check dependencies - assert "x" in graph._graph - assert "y" in graph._graph - assert "y" in graph._graph["x"] # y depends on x - assert "x" in graph._deps["y"] # y depends on x - - def test_load_from_list_complex_dependencies(self): - """Test loading variables with complex dependency relationships.""" - graph = DependencyGraph() - vars_list = [ - {"name": "a", "value": "1"}, - {"name": "b", "value": "a + 1"}, - {"name": "c", "value": "b * 2"}, - {"name": "d", "value": "a + c"}, - ] - - graph.load_from_list(vars_list) - - # Check dependency relationships - assert "b" in graph._graph["a"] # b depends on a - assert "c" in graph._graph["b"] # c depends on b - assert "d" in graph._graph["a"] # d depends on a - assert "d" in graph._graph["c"] # d depends on c - - assert "a" in graph._deps["b"] # b depends on a - assert "b" in graph._deps["c"] # c depends on b - assert "a" in graph._deps["d"] # d depends on a - assert "c" in graph._deps["d"] # d depends on c - - def test_load_from_list_unknown_variable_reference(self): - """Test loading with reference to unknown variable.""" - graph = DependencyGraph() - vars_list = [ - {"name": "x", "value": "y + 1"}, - ] - - # The DependencyGraph only creates dependencies for variables that exist in the graph - # Since 'y' is not in the vars_list, no dependency is created - graph.load_from_list(vars_list) - - # Check that x exists but has no dependencies - assert "x" in graph._graph - assert "x" in graph._deps - assert graph._graph["x"] == set() - assert graph._deps["x"] == set() - - def test_load_from_list_clear_existing(self): - """Test that loading clears existing graph.""" - graph = DependencyGraph() - - # Add some initial data - graph.add_variable("old_var", "1") - - # Load new data - vars_list = [ - {"name": "new_var", "value": "1"}, - ] - graph.load_from_list(vars_list) - - # Check old data is gone - assert "old_var" not in graph._graph - assert "new_var" in graph._graph - - def test_add_variable_simple(self): - """Test adding a simple variable.""" - graph = DependencyGraph() - graph.add_variable("x") - - assert "x" in graph._graph - assert "x" in graph._deps - assert graph._graph["x"] == set() - assert graph._deps["x"] == set() - - def test_add_variable_with_expression(self): - """Test adding a variable with an expression.""" - graph = DependencyGraph() - graph.add_variable("x") - graph.add_variable("y", "x + 1") - - assert "y" in graph._graph["x"] # y depends on x - assert "x" in graph._deps["y"] # y depends on x - - def test_add_variable_overwrite(self): - """Test overwriting an existing variable.""" - graph = DependencyGraph() - graph.add_variable("x") - graph.add_variable("y", "x + 1") - - # Overwrite y with new expression - graph.add_variable("y", "x * 2") - - assert "y" in graph._graph["x"] # y still depends on x - assert "x" in graph._deps["y"] # y depends on x - - # Overwrite y with no expression - graph.add_variable("y") - - assert "y" not in graph._graph["x"] # y no longer depends on x - assert "x" not in graph._deps["y"] # y no longer depends on x - - def test_add_variable_unknown_dependency(self): - """Test adding variable with unknown dependency.""" - graph = DependencyGraph() - - # The DependencyGraph only creates dependencies for variables that exist in the graph - # Since 'x' is not in the graph, no dependency is created - graph.add_variable("y", "x + 1") - - # Check that y exists but has no dependencies - assert "y" in graph._graph - assert "y" in graph._deps - assert graph._graph["y"] == set() - assert graph._deps["y"] == set() - - def test_remove_variable(self): - """Test removing a variable.""" - graph = DependencyGraph() - graph.add_variable("x") - graph.add_variable("y", "x + 1") - graph.add_variable("z", "y + 1") - - # Remove y - graph.remove_variable("y") - - assert "y" not in graph._graph - assert "y" not in graph._deps - assert "y" not in graph._graph["x"] # y's dependency on x is removed - assert "z" not in graph._graph["y"] # z's dependency on y is removed - - def test_remove_variable_nonexistent(self): - """Test removing a nonexistent variable.""" - graph = DependencyGraph() - - with pytest.raises(KeyError, match="Variable 'nonexistent' does not exist"): - graph.remove_variable("nonexistent") - - def test_update_expression(self): - """Test updating an existing variable's expression.""" - graph = DependencyGraph() - graph.add_variable("x") - graph.add_variable("y", "x + 1") - - # Update y's expression - graph.update_expression("y", "x * 2") - - assert "y" in graph._graph["x"] # y still depends on x - assert "x" in graph._deps["y"] # y depends on x - - # Remove expression - graph.update_expression("y", None) - - assert "y" not in graph._graph["x"] # y no longer depends on x - assert "x" not in graph._deps["y"] # y no longer depends on x - - def test_update_expression_nonexistent(self): - """Test updating expression for nonexistent variable.""" - graph = DependencyGraph() - - with pytest.raises(KeyError, match="Variable 'nonexistent' does not exist"): - graph.update_expression("nonexistent", "1 + 1") - - def test_update_expression_unknown_dependency(self): - """Test updating expression with unknown dependency.""" - graph = DependencyGraph() - graph.add_variable("x") - - # The DependencyGraph only creates dependencies for variables that exist in the graph - # Since 'y' is not in the graph, no dependency is created - graph.update_expression("x", "y + 1") - - # Check that x exists but has no dependencies - assert "x" in graph._graph - assert "x" in graph._deps - assert graph._graph["x"] == set() - assert graph._deps["x"] == set() - - def test_check_for_cycle_simple(self): - """Test cycle detection with simple cycle.""" - graph = DependencyGraph() - graph.add_variable("x") - graph.add_variable("y", "x + 1") - - # This should not raise an error (no cycle) - graph._check_for_cycle() - - # Create a cycle manually - graph._graph["y"].add("x") - graph._deps["x"].add("y") - - with pytest.raises( - pd.ValidationError, match="Circular dependency detected among: \\['x', 'y'\\]" - ): - graph._check_for_cycle() - - def test_check_for_cycle_complex(self): - """Test cycle detection with complex cycle.""" - graph = DependencyGraph() - graph.add_variable("a") - graph.add_variable("b", "a + 1") - graph.add_variable("c", "b + 1") - graph.add_variable("d", "c + 1") - - # Create a cycle: a -> b -> c -> d -> a - graph._graph["d"].add("a") - graph._deps["a"].add("d") - - with pytest.raises( - pd.ValidationError, match="Circular dependency detected among: \\['a', 'b', 'c', 'd'\\]" - ): - graph._check_for_cycle() - - def test_topology_sort_simple(self): - """Test topological sorting with simple dependencies.""" - graph = DependencyGraph() - graph.add_variable("a") - graph.add_variable("b", "a + 1") - graph.add_variable("c", "b + 1") - - order = graph.topology_sort() - - # Check that dependencies come before dependents - assert order.index("a") < order.index("b") - assert order.index("b") < order.index("c") - - def test_topology_sort_complex(self): - """Test topological sorting with complex dependencies.""" - graph = DependencyGraph() - graph.add_variable("a") - graph.add_variable("b", "a + 1") - graph.add_variable("c", "a + 1") - graph.add_variable("d", "b + c") - graph.add_variable("e", "d + 1") - - order = graph.topology_sort() - - # Check dependency order - assert order.index("a") < order.index("b") - assert order.index("a") < order.index("c") - assert order.index("b") < order.index("d") - assert order.index("c") < order.index("d") - assert order.index("d") < order.index("e") - - def test_topology_sort_with_cycle(self): - """Test topological sorting with cycle detection.""" - graph = DependencyGraph() - graph.add_variable("x") - graph.add_variable("y", "x + 1") - - # Create a cycle - graph._graph["y"].add("x") - graph._deps["x"].add("y") - - with pytest.raises( - pd.ValidationError, match="Circular dependency detected among: \\['x', 'y'\\]" - ): - graph.topology_sort() - - def test_load_from_list_restore_on_error(self): - """Test that graph state is restored on error during load.""" - graph = DependencyGraph() - - # Add initial state - graph.add_variable("initial", "1") - - # Try to load data that would create a cycle - vars_list = [ - {"name": "a", "value": "1"}, - {"name": "b", "value": "a + 1"}, - {"name": "c", "value": "b + 1"}, - { - "name": "a", - "value": "c + 1", - }, # This creates a cycle - ] - - with pytest.raises(pd.ValidationError, match="Circular dependency detected"): - graph.load_from_list(vars_list) - - # Check that initial state is preserved - assert "initial" in graph._graph - assert "a" not in graph._graph - assert "b" not in graph._graph - assert "c" not in graph._graph - - def test_add_variable_restore_on_error(self): - """Test that graph state is restored on error during add.""" - graph = DependencyGraph() - graph.add_variable("x") - graph.add_variable("y", "x + 1") - - # Try to add variable that creates a cycle - with pytest.raises(pd.ValidationError, match="Circular dependency detected"): - graph.add_variable("x", "y + 1") # This creates x -> y -> x cycle - - # Check that graph state is unchanged - assert "y" in graph._graph["x"] # y still depends on x - assert "x" in graph._deps["y"] # y depends on x - - def test_update_expression_restore_on_error(self): - """Test that graph state is restored on error during update.""" - graph = DependencyGraph() - graph.add_variable("x") - graph.add_variable("y", "x + 1") - - # Try to update with expression that creates a cycle - with pytest.raises(pd.ValidationError, match="Circular dependency detected"): - graph.update_expression("x", "y + 1") # This creates x -> y -> x cycle - - # Check that original dependency is preserved - assert "y" in graph._graph["x"] # y still depends on x - assert "x" in graph._deps["y"] # y depends on x - - def test_self_reference_cycle(self): - """Test cycle detection with self-reference.""" - graph = DependencyGraph() - graph.add_variable("x") - - # Try to create self-reference - with pytest.raises( - pd.ValidationError, match="Circular dependency detected among: \\['x'\\]" - ): - graph.update_expression("x", "x + 1") - - def test_empty_graph_operations(self): - """Test operations on empty graph.""" - graph = DependencyGraph() - - # Topological sort of empty graph - order = graph.topology_sort() - assert order == [] - - # Add variable to empty graph - graph.add_variable("x", "1") - assert "x" in graph._graph - assert "x" in graph._deps - - def test_isolated_variables(self): - """Test handling of variables with no dependencies.""" - graph = DependencyGraph() - graph.add_variable("a") - graph.add_variable("b") - graph.add_variable("c", "a + b") - - order = graph.topology_sort() - - # a and b can come in any order, but c must come after both - assert order.index("a") < order.index("c") - assert order.index("b") < order.index("c") - - def test_multiple_dependencies(self): - """Test variables with multiple dependencies.""" - graph = DependencyGraph() - graph.add_variable("a") - graph.add_variable("b") - graph.add_variable("c") - graph.add_variable("d", "a + b + c") - - order = graph.topology_sort() - - # d must come after all its dependencies - assert order.index("a") < order.index("d") - assert order.index("b") < order.index("d") - assert order.index("c") < order.index("d") - - def test_dependency_extraction_edge_cases(self): - """Test dependency extraction with edge cases.""" - graph = DependencyGraph() - all_names = {"x", "y", "z"} - - # Empty expression - deps = graph._extract_deps("", all_names) - assert deps == set() - - # Expression with no variables - deps = graph._extract_deps("1 + 2 * 3", all_names) - assert deps == set() - - # Expression with only unknown variables - deps = graph._extract_deps("unknown1 + unknown2", all_names) - assert deps == set() - - # Expression with mixed known and unknown variables - deps = graph._extract_deps("x + unknown + y", all_names) - assert deps == {"x", "y"} - - def test_trailing_semicolon(self): - graph = DependencyGraph() - input_variable_list = [ - {"name": "contains_unit_sudu", "value": "[math.ceil(math.sqrt(81)) + math.floor(5.5)]"}, - {"name": "post_processing_variables", "value": "1+1"}, - {"name": "gamma", "value": "1.4"}, - {"name": "pow1", "value": "gamma/(gamma-1)"}, - {"name": "pow2", "value": "(gamma-1) / 2"}, - {"name": "primitiveVar", "value": "[1,2,3,4,5]"}, - {"name": "v", "value": "90"}, - { - "name": "TotalPressureCoeff", - "value": "(gamma*primitiveVar[4]*(1+pow2*solution.Mach*solution.Mach)^pow1-(1+pow2*MachRefSq)^pow1)/(gamma/2*MachRefSq);", - }, - {"name": "MachRefSq", "value": "control.MachRef*control.MachRef"}, - ] - graph.load_from_list(input_variable_list) - order = graph.topology_sort() - assert order.index("gamma") < order.index("pow1") - assert order.index("gamma") < order.index("pow2") - assert order.index("gamma") < order.index("TotalPressureCoeff") - assert order.index("primitiveVar") < order.index("TotalPressureCoeff") - assert order.index("pow2") < order.index("TotalPressureCoeff") - assert order.index("pow1") < order.index("TotalPressureCoeff") - assert order.index("MachRefSq") < order.index("TotalPressureCoeff") - - -def test_remove_variable_with_yes_confirmation(monkeypatch, capsys): - # Simulate user typing 'yes' - monkeypatch.setattr("builtins.input", lambda _: "yes") - - var_a = UserVariable(name="var_a", value=solution.Cpt) - var_b = UserVariable(name="var_b", value=var_a**2) - var_c = UserVariable(name="var_c", value=var_a**3) - var_d = UserVariable(name="var_d", value=var_b**2 + var_a + var_c) - - remove_user_variable(name="var_b") - - captured = capsys.readouterr() - assert "--- Confirmation Required ---" in captured.out - assert "The following variables will be removed:" in captured.out - assert "- var_b" in captured.out - assert "- var_d" in captured.out - assert "--- Proceeding with removal ---" in captured.out - assert "Removed 'var_b' from values." in captured.out - assert "Removed 'var_d' from values." in captured.out - - assert var_a == get_user_variable("var_a") - with pytest.raises(NameError, match="Name 'var_b' is not defined"): - get_user_variable("var_b") - assert var_c == get_user_variable("var_c") - with pytest.raises(NameError, match="Name 'var_d' is not defined"): - get_user_variable("var_d") - - remove_user_variable(name="var_a") - captured = capsys.readouterr() - assert "--- Confirmation Required ---" in captured.out - assert "The following variables will be removed:" in captured.out - assert "- var_a" in captured.out - assert "- var_c" in captured.out - assert "--- Proceeding with removal ---" in captured.out - assert "Removed 'var_a' from values." in captured.out - assert "Removed 'var_c' from values." in captured.out - - with pytest.raises(NameError, match="Name 'var_a' is not defined"): - get_user_variable("var_a") - with pytest.raises(NameError, match="Name 'var_c' is not defined"): - get_user_variable("var_c") - - var_d = UserVariable(name="var_d", value=solution.Cp) - remove_user_variable(name="var_d") - with pytest.raises(NameError, match="Name 'var_d' is not defined"): - get_user_variable("var_d") - - -def test_remove_variable_with_no_confirmation(monkeypatch, capsys): - # Simulate user typing 'no' - monkeypatch.setattr("builtins.input", lambda _: "no") - - var_a = UserVariable(name="var_a", value=solution.Cpt) - var_b = UserVariable(name="var_b", value=var_a**2) - var_c = UserVariable(name="var_c", value=var_a**3) - var_d = UserVariable(name="var_d", value=var_b**2 + var_a + var_c) - - remove_user_variable(name="var_a") - - # Optionally, check the output messages (stdout) - captured = capsys.readouterr() - assert "--- Confirmation Required ---" in captured.out - assert ("The following variables will be removed:") in captured.out - assert "- var_a" in captured.out - assert "- var_b" in captured.out - assert "- var_c" in captured.out - assert "- var_d" in captured.out - assert "Operation cancelled. No variables were removed." in captured.out - - assert var_a == get_user_variable("var_a") - assert var_b == get_user_variable("var_b") - assert var_c == get_user_variable("var_c") - assert var_d == get_user_variable("var_d") - - -def test_remove_non_existent_variable(): - with pytest.raises(NameError, match="There is no variable named 'non_existent_var'."): - remove_user_variable("non_existent_var") - - -def test_sanitize_expression_validator(): - """Test the sanitize_expression validator that removes whitespace and trailing characters.""" - - # Test basic whitespace removal - assert Expression.sanitize_expression(" x + y ") == "x + y" - assert Expression.sanitize_expression("\t x + y \t") == "x + y" - assert Expression.sanitize_expression("\n x + y \n") == "x + y" - - # Test trailing semicolon removal - assert Expression.sanitize_expression("x + y;") == "x + y" - assert Expression.sanitize_expression("x + y ;") == "x + y" - assert Expression.sanitize_expression("x + y;\t") == "x + y" - assert Expression.sanitize_expression("x + y;\n") == "x + y" - - # Test multiple trailing characters - assert Expression.sanitize_expression("x + y; \n\t") == "x + y" - assert Expression.sanitize_expression("x + y \t\n;") == "x + y" - - # Test no changes needed - assert Expression.sanitize_expression("x + y") == "x + y" - assert Expression.sanitize_expression("") == "" - - # Test with complex expressions - assert Expression.sanitize_expression(" (a + b) * c / d; ") == "(a + b) * c / d" - assert ( - Expression.sanitize_expression("\t math.sqrt(x**2 + y**2); \n") == "math.sqrt(x**2 + y**2)" - ) - - # Test with units - assert Expression.sanitize_expression(" velocity * u.m / u.s; ") == "velocity * u.m / u.s" - - # Test with mixed whitespace characters - assert Expression.sanitize_expression(" \t\n x + y \n\t ; ") == "x + y" - - -def test_disable_confusing_operators_validator(): - """Test the disable_confusing_operators validator that prevents use of ^ and & operators.""" - - # Test valid expressions (should pass through unchanged) - assert Expression.disable_confusing_operators("x + y") == "x + y" - assert Expression.disable_confusing_operators("x ** y") == "x ** y" # Valid power operator - assert Expression.disable_confusing_operators("x and y") == "x and y" # Valid logical AND - assert Expression.disable_confusing_operators("math.sqrt(x)") == "math.sqrt(x)" - assert Expression.disable_confusing_operators("") == "" - - # Test ^ operator (should raise ValueError) - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators("x ^ y") - - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators("2 ^ 3") - - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators("x + y ^ z") - - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators("(x + y) ^ 2") - - # Test & operator (should raise ValueError) - with pytest.raises(ValueError, match="& operator is not allowed in expressions."): - Expression.disable_confusing_operators("x & y") - - with pytest.raises(ValueError, match="& operator is not allowed in expressions."): - Expression.disable_confusing_operators("a & b & c") - - with pytest.raises(ValueError, match="& operator is not allowed in expressions."): - Expression.disable_confusing_operators("x + y & z") - - # Test both operators in same expression (should catch first one) - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators("x ^ y & z") - - # Test operators in complex expressions - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators("math.sqrt(x ^ 2 + y ^ 2)") - - with pytest.raises(ValueError, match="& operator is not allowed in expressions."): - Expression.disable_confusing_operators("(a + b) & (c + d)") - - # Test operators in string literals (should still be caught) - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators("'x ^ y'") - - with pytest.raises(ValueError, match="& operator is not allowed in expressions."): - Expression.disable_confusing_operators("'x & y'") - - -def test_expression_validators_integration(): - """Test that the validators work together when processing expression strings.""" - - # Test sanitization and operator validation work together - # This should pass: sanitize removes whitespace and semicolon, no invalid operators - sanitized = Expression.sanitize_expression(" x + y; ") - assert sanitized == "x + y" - # Should pass operator validation - assert Expression.disable_confusing_operators(sanitized) == "x + y" - - # This should pass: sanitize removes whitespace, ** is valid power operator - sanitized = Expression.sanitize_expression(" x ** y; ") - assert sanitized == "x ** y" - # Should pass operator validation - assert Expression.disable_confusing_operators(sanitized) == "x ** y" - - # This should fail: sanitize removes whitespace but ^ is still invalid - sanitized = Expression.sanitize_expression(" x ^ y; ") - assert sanitized == "x ^ y" - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators(sanitized) - - # This should fail: sanitize removes whitespace but & is still invalid - sanitized = Expression.sanitize_expression(" x & y; ") - assert sanitized == "x & y" - with pytest.raises(ValueError, match="& operator is not allowed in expressions."): - Expression.disable_confusing_operators(sanitized) - - # Test with complex expressions - sanitized = Expression.sanitize_expression(" (a + b) ** 2; ") - assert sanitized == "(a + b) ** 2" - assert Expression.disable_confusing_operators(sanitized) == "(a + b) ** 2" - - # Test with units - sanitized = Expression.sanitize_expression(" velocity ** 2 * u.m / u.s; ") - assert sanitized == "velocity ** 2 * u.m / u.s" - assert Expression.disable_confusing_operators(sanitized) == "velocity ** 2 * u.m / u.s" - - # Test edge cases - sanitized = Expression.sanitize_expression(" ; ") # Just whitespace and semicolon - assert sanitized == "" - assert Expression.disable_confusing_operators(sanitized) == "" - - # Test with math functions - sanitized = Expression.sanitize_expression(" math.sqrt(x ** 2 + y ** 2); ") - assert sanitized == "math.sqrt(x ** 2 + y ** 2)" - assert Expression.disable_confusing_operators(sanitized) == "math.sqrt(x ** 2 + y ** 2)" - - -def test_expression_validators_with_user_variables(): - """Test validators work correctly with UserVariable expressions.""" - - # Create a user variable - x = UserVariable(name="x", value=5) - - # Test valid expressions - sanitized = Expression.sanitize_expression(" x + 1; ") - assert sanitized == "x + 1" - assert Expression.disable_confusing_operators(sanitized) == "x + 1" - - sanitized = Expression.sanitize_expression(" x ** 2; ") - assert sanitized == "x ** 2" - assert Expression.disable_confusing_operators(sanitized) == "x ** 2" - - # Test invalid expressions - sanitized = Expression.sanitize_expression(" x ^ 2; ") - assert sanitized == "x ^ 2" - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators(sanitized) - - sanitized = Expression.sanitize_expression(" x & 1; ") - assert sanitized == "x & 1" - with pytest.raises(ValueError, match="& operator is not allowed in expressions."): - Expression.disable_confusing_operators(sanitized) - - # Test with complex user variable expressions - y = UserVariable(name="y", value=x + 1) - sanitized = Expression.sanitize_expression(" y ** 2 + x; ") - assert sanitized == "y ** 2 + x" - assert Expression.disable_confusing_operators(sanitized) == "y ** 2 + x" - - -def test_expression_validators_edge_cases(): - """Test validators with edge cases and boundary conditions.""" - - # Test empty string - sanitized = Expression.sanitize_expression("") - assert sanitized == "" - assert Expression.disable_confusing_operators(sanitized) == "" - - # Test string with only whitespace and special characters - sanitized = Expression.sanitize_expression(" \t\n; ") - assert sanitized == "" - assert Expression.disable_confusing_operators(sanitized) == "" - - # Test string with only valid operators - sanitized = Expression.sanitize_expression(" + - * / ** // % ") - assert sanitized == "+ - * / ** // %" - assert Expression.disable_confusing_operators(sanitized) == "+ - * / ** // %" - - # Test string with mixed valid and invalid operators - sanitized = Expression.sanitize_expression("x + y ^ z") - assert sanitized == "x + y ^ z" - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators(sanitized) - - sanitized = Expression.sanitize_expression("x + y & z") - assert sanitized == "x + y & z" - with pytest.raises(ValueError, match="& operator is not allowed in expressions."): - Expression.disable_confusing_operators(sanitized) - - # Test with very long expressions - long_expr = " " + "x + " * 100 + "y;" + " " - expected = "x + " * 100 + "y" - sanitized = Expression.sanitize_expression(long_expr) - assert sanitized == expected - assert Expression.disable_confusing_operators(sanitized) == expected - - # Test with unicode characters (should still work) - sanitized = Expression.sanitize_expression(" α + β; ") - assert sanitized == "α + β" - assert Expression.disable_confusing_operators(sanitized) == "α + β" - - # Test with numbers and operators - sanitized = Expression.sanitize_expression(" 123 ** 456; ") - assert sanitized == "123 ** 456" - assert Expression.disable_confusing_operators(sanitized) == "123 ** 456" - - sanitized = Expression.sanitize_expression(" 123 ^ 456; ") - assert sanitized == "123 ^ 456" - with pytest.raises( - ValueError, - match="\\^ operator is not allowed in expressions. For power operator, please use \\*\\* instead.", - ): - Expression.disable_confusing_operators(sanitized) - - def test_correct_expression_error_location(): with open("data/simulation.json", "r") as fh: @@ -2051,159 +487,3 @@ def test_correct_expression_error_location(): "operator for unyt_arrays with units 'dimensionless' (dimensions '1') and 'm' (dimensions '(length)') is not well defined." in errors[0]["msg"] ) - - -def test_solver_variable_names_recursive(): - """Test the recursive solver_variable_names method with proper physical dimensions.""" - - # Test 1: Direct solver variable usage - expr1 = Expression(expression="solution.density + solution.pressure") - solver_vars = expr1.solver_variable_names(recursive=True) - assert set(solver_vars) == {"solution.density", "solution.pressure"} - - # Test 2: No solver variables - pure mathematical expression - expr2 = Expression(expression="1.0 + 2.0 * 3.0") - solver_vars = expr2.solver_variable_names(recursive=True) - assert solver_vars == [] - - # Test 3: Simple user variable with solver variable (dimensionally consistent) - user_var1 = UserVariable(name="my_density", value=solution.density) - expr3 = Expression(expression="my_density * 2.0") - solver_vars = expr3.solver_variable_names(recursive=True) - assert solver_vars == ["solution.density"] - - # Test 4: Nested user variables - velocity component access - user_var2 = UserVariable(name="vel_x_comp", value=solution.velocity[0]) - user_var3 = UserVariable(name="scaled_vel", value=user_var2 * 2.0) - expr4 = Expression(expression="scaled_vel") - solver_vars = expr4.solver_variable_names(recursive=True) - assert solver_vars == ["solution.velocity"] - - # Test 5: Multiple levels of nesting with dimensional consistency - user_var4 = UserVariable(name="rho_squared", value=user_var1 * user_var1) - user_var5 = UserVariable(name="momentum_like", value=user_var4 * user_var3) - expr5 = Expression(expression="momentum_like") - solver_vars = expr5.solver_variable_names(recursive=True) - assert set(solver_vars) == {"solution.density", "solution.velocity"} - - # Test 6: Mixed direct and indirect solver variables with proper dimensions - expr6 = Expression(expression="momentum_like + solution.pressure * solution.density") - solver_vars = expr6.solver_variable_names(recursive=True) - assert set(solver_vars) == {"solution.density", "solution.velocity", "solution.pressure"} - - # Test 7: User variable with dimensionless value - user_var6 = UserVariable(name="mach_number", value=0.3) - expr7 = Expression(expression="mach_number * solution.velocity[0]") - solver_vars = expr7.solver_variable_names(recursive=True) - assert solver_vars == ["solution.velocity"] - - # Test 8: Filter by variable type - Volume variables only - expr8 = Expression(expression="solution.density + solution.velocity[0] + control.MachRef") - volume_vars = expr8.solver_variable_names(variable_type="Volume", recursive=True) - assert set(volume_vars) == {"solution.density", "solution.velocity"} - - # Test 9: Filter by variable type - Scalar variables only - scalar_vars = expr8.solver_variable_names(variable_type="Scalar", recursive=True) - assert scalar_vars == ["control.MachRef"] - - # Test 10: Complex flow physics expression with proper dimensions - user_var7 = UserVariable( - name="dyn_press", value=0.5 * solution.density * solution.velocity[0] * solution.velocity[0] - ) - user_var8 = UserVariable(name="tot_press", value=solution.pressure + user_var7) - expr9 = Expression(expression="tot_press") - solver_vars = expr9.solver_variable_names(recursive=True) - assert set(solver_vars) == {"solution.density", "solution.velocity", "solution.pressure"} - - # Test 11: Temperature-based expressions (for compressible flow) - user_var9 = UserVariable( - name="temp_ratio", value=solution.temperature / 300.0 - ) # Reference temperature - user_var10 = UserVariable(name="scaled_density", value=solution.density * user_var9) - expr10 = Expression(expression="scaled_density") - solver_vars = expr10.solver_variable_names(recursive=True) - assert set(solver_vars) == {"solution.temperature", "solution.density"} - - # Test 12: Vector operations with proper indexing - user_var11 = UserVariable( - name="vel_mag_sq", - value=solution.velocity[0] * solution.velocity[0] - + solution.velocity[1] * solution.velocity[1] - + solution.velocity[2] * solution.velocity[2], - ) - expr11 = Expression(expression="vel_mag_sq") - solver_vars = expr11.solver_variable_names(recursive=True) - assert solver_vars == ["solution.velocity"] - - # Test 13: Deep nesting with multiple physics variables - user_var12 = UserVariable(name="ke_calc", value=0.5 * solution.density * user_var11) - user_var13 = UserVariable(name="te_calc", value=user_var12 + solution.pressure / (1.4 - 1.0)) - user_var14 = UserVariable(name="epv_calc", value=user_var13 / solution.temperature) - expr12 = Expression(expression="epv_calc") - solver_vars = expr12.solver_variable_names(recursive=True) - expected_vars = { - "solution.density", - "solution.velocity", - "solution.pressure", - "solution.temperature", - } - assert set(solver_vars) == expected_vars - - # Test 14: Mathematical functions with solver variables (using dimensionless ratios) - user_var15 = UserVariable( - name="rho_ratio", value=solution.density / solution.density - ) # Dimensionless ratio - user_var16 = UserVariable( - name="complex_func", value=Expression(expression="math.exp(rho_ratio)") - ) - expr13 = Expression(expression="complex_func") - solver_vars = expr13.solver_variable_names(recursive=True) - assert solver_vars == ["solution.density"] - - # Test 15: Control variables in time-dependent expressions - user_var17 = UserVariable( - name="time_scaled_vel", value=solution.velocity[0] * control.timeStepSize - ) - expr14 = Expression(expression="time_scaled_vel") # Just use the time-scaled velocity - solver_vars = expr14.solver_variable_names(recursive=True) - assert set(solver_vars) == {"solution.velocity", "control.timeStepSize"} - - # Test 16: Edge case - circular reference prevention - user_var18 = UserVariable(name="base_var", value=solution.pressure) - user_var19 = UserVariable(name="derived_var", value=user_var18 * 2.0) - user_var20 = UserVariable(name="twice_derived", value=user_var19 + user_var18) - expr15 = Expression(expression="twice_derived") - solver_vars = expr15.solver_variable_names(recursive=True) - assert solver_vars == ["solution.pressure"] - - # Test 17: Mixed dimensioned and dimensionless variables - user_var21 = UserVariable(name="re_num", value=1e6) # Dimensionless - user_var22 = UserVariable( - name="char_vel", value=user_var21 * solution.mut / (solution.density * 1.0) - ) # Length = 1.0 m - expr16 = Expression(expression="char_vel") - solver_vars = expr16.solver_variable_names(recursive=True) - assert set(solver_vars) == {"solution.mut", "solution.density"} - - # Test 18: Surface variables if available - try: - # Only test if surface variables exist - expr17 = Expression(expression="solution.Cp + solution.density") - solver_vars = expr17.solver_variable_names(variable_type="Surface", recursive=True) - # Check if surface variables are found - surface_vars = [var for var in solver_vars if "Cp" in var] - if surface_vars: - assert "solution.Cp" in solver_vars - except (AttributeError, ValueError): - # Skip if surface variables not available in test environment - pass - - # Test 19: All variable types combined - expr18 = Expression(expression="solution.density + solution.velocity[0] + control.MachRef") - all_vars = expr18.solver_variable_names(variable_type="All", recursive=True) - assert set(all_vars) == {"solution.density", "solution.velocity", "control.MachRef"} - - # Test 20: Simple expression with just a constant - expr19 = Expression(expression="42.0") - solver_vars = expr19.solver_variable_names(recursive=True) - assert solver_vars == [] diff --git a/tests/simulation/test_value_or_expression.py b/tests/simulation/test_value_or_expression.py index 6e34a6b91..8e0dd2886 100644 --- a/tests/simulation/test_value_or_expression.py +++ b/tests/simulation/test_value_or_expression.py @@ -4,8 +4,14 @@ import pytest import unyt as u +from flow360_schema.framework.expression import ( + Expression, + UserVariable, + get_referenced_expressions_and_user_variables, +) +from flow360_schema.models.functions import math +from flow360_schema.models.variables import control, solution -import flow360.component.simulation.user_code.core.context as context from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.framework.updater_utils import compare_values from flow360.component.simulation.models.solver_numerics import ( @@ -37,14 +43,7 @@ from flow360.component.simulation.time_stepping.time_stepping import Unsteady from flow360.component.simulation.translator.solver_translator import get_solver_json from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.user_code.core.types import ( - Expression, - UserVariable, - get_referenced_expressions_and_user_variables, - save_user_variables, -) -from flow360.component.simulation.user_code.functions import math -from flow360.component.simulation.user_code.variables import control, solution +from flow360.component.simulation.user_code.core.types import save_user_variables from flow360.component.volume_mesh import VolumeMeshV2 @@ -373,19 +372,15 @@ def param_with_steady_time_stepping_time_step_size(): return save_user_variables(params).model_dump(mode="json", exclude_none=True) -def param_with_rotation_zone_theta(): +def param_without_rotation_zone_theta(): + """No Rotation model → control.theta should be disallowed.""" reset_context() vm = volume_mesh() - vm["fluid"].axis = (0, 1, 0) - vm["fluid"].center = (1, 1, 2) * u.cm with SI_unit_system: params = SimulationParams( models=[ Fluid(turbulence_model_solver=SpalartAllmaras()), Wall(name="wall", entities=vm["*"]), - Rotation( - name="rotation", entities=vm["fluid"], spec=AngularVelocity(value=100 * u.rpm) - ), ], outputs=[VolumeOutput(name="output", output_fields=[control.theta])], private_attribute_asset_cache=asset_cache(), @@ -393,19 +388,15 @@ def param_with_rotation_zone_theta(): return save_user_variables(params).model_dump(mode="json", exclude_none=True) -def param_with_rotation_zone_omega(): +def param_without_rotation_zone_omega(): + """No Rotation model → control.omega should be disallowed.""" reset_context() vm = volume_mesh() - vm["fluid"].axis = (0, 1, 0) - vm["fluid"].center = (1, 1, 2) * u.cm with SI_unit_system: params = SimulationParams( models=[ Fluid(turbulence_model_solver=SpalartAllmaras()), Wall(name="wall", entities=vm["*"]), - Rotation( - name="rotation", entities=vm["fluid"], spec=AngularVelocity(value=100 * u.rpm) - ), ], outputs=[VolumeOutput(name="output", output_fields=[control.omega])], private_attribute_asset_cache=asset_cache(), @@ -413,19 +404,15 @@ def param_with_rotation_zone_omega(): return save_user_variables(params).model_dump(mode="json", exclude_none=True) -def param_with_rotation_zone_omega_dot(): +def param_without_rotation_zone_omega_dot(): + """No Rotation model → control.omegaDot should be disallowed.""" reset_context() vm = volume_mesh() - vm["fluid"].axis = (0, 1, 0) - vm["fluid"].center = (1, 1, 2) * u.cm with SI_unit_system: params = SimulationParams( models=[ Fluid(turbulence_model_solver=SpalartAllmaras()), Wall(name="wall", entities=vm["*"]), - Rotation( - name="rotation", entities=vm["fluid"], spec=AngularVelocity(value=100 * u.rpm) - ), ], outputs=[VolumeOutput(name="output", output_fields=[control.omegaDot])], private_attribute_asset_cache=asset_cache(), @@ -477,15 +464,15 @@ def param_with_rotation_zone_omega_dot(): "`control.timeStepSize` cannot be used because Unsteady time stepping is not used.", ), ( - param_with_rotation_zone_theta(), + param_without_rotation_zone_theta(), "`control.theta` cannot be used because Rotation zone is not used.", ), ( - param_with_rotation_zone_omega(), + param_without_rotation_zone_omega(), "`control.omega` cannot be used because Rotation zone is not used.", ), ( - param_with_rotation_zone_omega_dot(), + param_without_rotation_zone_omega_dot(), "`control.omegaDot` cannot be used because Rotation zone is not used.", ), ], diff --git a/tests/simulation/test_variable_context_skip.py b/tests/simulation/test_variable_context_skip.py index d3dcca760..432afbf0a 100644 --- a/tests/simulation/test_variable_context_skip.py +++ b/tests/simulation/test_variable_context_skip.py @@ -1,8 +1,9 @@ -from flow360.component.simulation.services import initialize_variable_space -from flow360.component.simulation.user_code.core.types import ( +from flow360_schema.framework.expression import ( get_referenced_expressions_and_user_variables, ) +from flow360.component.simulation.services import initialize_variable_space + def test_skip_variable_context_in_reference_collection(): # Only variable_context contains an expression; it should be skipped entirely diff --git a/tests/simulation/translator/test_output_translation.py b/tests/simulation/translator/test_output_translation.py index a4ca0e24f..ff1615252 100644 --- a/tests/simulation/translator/test_output_translation.py +++ b/tests/simulation/translator/test_output_translation.py @@ -2,6 +2,8 @@ import numpy as np import pytest +from flow360_schema.framework.expression import UserVariable +from flow360_schema.models.variables import solution import flow360.component.simulation.units as u from flow360.component.simulation.draft_context.coordinate_system_manager import ( @@ -54,8 +56,6 @@ translate_output, ) from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.user_code.core.types import UserVariable -from flow360.component.simulation.user_code.variables import solution @pytest.fixture() diff --git a/tests/simulation/translator/test_solver_translator.py b/tests/simulation/translator/test_solver_translator.py index f75070915..901f2e971 100644 --- a/tests/simulation/translator/test_solver_translator.py +++ b/tests/simulation/translator/test_solver_translator.py @@ -5,6 +5,9 @@ import numpy as np import pytest +from flow360_schema.framework.expression import UserVariable +from flow360_schema.models.functions import math +from flow360_schema.models.variables import solution import flow360.component.simulation.units as u from flow360.component.geometry import Geometry, GeometryMeta @@ -117,9 +120,6 @@ from flow360.component.simulation.time_stepping.time_stepping import RampCFL, Steady from flow360.component.simulation.translator.solver_translator import get_solver_json from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.user_code.core.types import UserVariable -from flow360.component.simulation.user_code.functions import math -from flow360.component.simulation.user_code.variables import solution from flow360.component.simulation.utils import model_attribute_unlock from tests.simulation.translator.utils.actuator_disk_param_generator import ( actuator_disk_create_param, @@ -184,7 +184,6 @@ assertions = unittest.TestCase("__init__") -import flow360.component.simulation.user_code.core.context as context from flow360.component.simulation.framework.entity_selector import SurfaceSelector from flow360.component.simulation.framework.updater_utils import compare_values from flow360.component.simulation.models.volume_models import ( @@ -1203,7 +1202,9 @@ def test_param_with_user_variables(): assert not errors, print(">>>", errors) translated = get_solver_json(params_validated, mesh_unit=1 * u.m) - units = iso_field_random_units.value.get_output_units(input_params=params_validated) + units = iso_field_random_units.value.get_output_units( + unit_system_name=params_validated.unit_system.name + ) assert units == u.kg * u.m / u.s assert ( translated["isoSurfaceOutput"]["isoSurfaces"]["iso_field_random_units"][ diff --git a/tests/simulation/user_code/core/test_compute_surface_integral_unit.py b/tests/simulation/user_code/core/test_compute_surface_integral_unit.py index e7b8c0dd1..bc41b94ba 100644 --- a/tests/simulation/user_code/core/test_compute_surface_integral_unit.py +++ b/tests/simulation/user_code/core/test_compute_surface_integral_unit.py @@ -1,17 +1,17 @@ +# NOTE: compute_surface_integral_unit tests have been migrated to: +# flex/share/flow360-schema/tests/framework/expression/test_compute_surface_integral_unit.py +# Only test_process_output_field_for_integral_vector remains here (depends on client translator). + from unittest import mock import pytest import unyt as u +from flow360_schema.framework.expression import Expression, UserVariable from flow360.component.simulation.services import clear_context from flow360.component.simulation.translator.solver_translator import ( process_output_field_for_integral, ) -from flow360.component.simulation.user_code.core.types import ( - Expression, - UserVariable, - compute_surface_integral_unit, -) class MockUnitSystem: @@ -28,8 +28,6 @@ def resolve(self): class MockParams: def __init__(self, unit_system_dict, unit_system_name="SI"): - # We populate both for compatibility during transition, - # but the test expects usage of unit_system eventually. self.flow360_unit_system = unit_system_dict self.unit_system = MockUnitSystem(unit_system_name, unit_system_dict) @@ -44,82 +42,22 @@ def reset_context(): @pytest.fixture def mock_params_si(): - # Simulate real behavior where UnitSystem returns unyt_quantity (1.0 * unit) return MockParams({"area": 1.0 * u.m**2}, unit_system_name="SI") -@pytest.fixture -def mock_params_imperial(): - return MockParams({"area": 1.0 * u.ft**2}, unit_system_name="Imperial") - - -def test_compute_surface_integral_unit_scalar_si(mock_params_si): - # Variable with explicit units - var = UserVariable(name="var", value=10 * u.Pa) - unit = compute_surface_integral_unit(var, mock_params_si) - # Pa * m^2 -> N - assert u.Unit(unit) == u.N - - -def test_compute_surface_integral_unit_expression_si(mock_params_si): - # Variable defined by expression - var = UserVariable(name="var", value=Expression(expression="10 * u.Pa")) - unit = compute_surface_integral_unit(var, mock_params_si) - assert u.Unit(unit) == u.N - - -def test_compute_surface_integral_unit_dimensionless(mock_params_si): - var = UserVariable(name="var", value=10) - unit = compute_surface_integral_unit(var, mock_params_si) - # dimensionless * m^2 -> m^2 - assert u.Unit(unit) == u.m**2 - - -def test_compute_surface_integral_unit_imperial(mock_params_imperial): - var = UserVariable(name="var", value=10 * u.lbf / u.ft**2) # psf - unit = compute_surface_integral_unit(var, mock_params_imperial) - # (lbf/ft^2) * ft^2 -> lbf - assert u.Unit(unit) == u.lbf - - -def test_compute_surface_integral_unit_with_output_units(mock_params_si): - # Variable with specific output units set - # Must use Expression to use in_units - var = UserVariable(name="var", value=Expression(expression="10 * u.Pa")).in_units( - new_unit="kPa" - ) - unit = compute_surface_integral_unit(var, mock_params_si) - # kPa * m^2 -> kN (or equivalent) - assert u.Unit(unit) == u.kN - - -def test_compute_surface_integral_unit_fallback(mock_params_si): - # Case where value is a simple number inside an expression - var = UserVariable(name="var", value=Expression(expression="10")) - unit = compute_surface_integral_unit(var, mock_params_si) - assert u.Unit(unit) == u.m**2 - - def test_process_output_field_for_integral_vector(mock_params_si): # Test vector variable integration logic - # Mock node_area_vector for this test as it's used in process_output_field_for_integral with mock.patch( "flow360.component.simulation.translator.solver_translator.solution" ) as mock_sol: - # Mock magnitude call with mock.patch( "flow360.component.simulation.translator.solver_translator.math" ) as mock_math: mock_math.magnitude.return_value = Expression(expression="1.0 * u.m**2") - # Create a vector variable - # UserVariable automatically wraps list in Expression var = UserVariable(name="vec", value=[10 * u.Pa, 20 * u.Pa, 30 * u.Pa]) - # This should not raise AttributeError now processed_var = process_output_field_for_integral(var, mock_params_si) - # Check if the processed variable value is an Expression assert isinstance(processed_var.value, Expression) - # Ensure output_units is set on the Expression assert u.Unit(processed_var.value.output_units) == u.N From 8ae42211b098fdf265072ea4724211dc060804fe Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:58:35 -0400 Subject: [PATCH 07/25] refactor(flow360): use unified Flow360BaseModel from schema package (#1943) Co-authored-by: Claude Opus 4.6 (1M context) --- .../simulation/framework/base_model.py | 397 +----------------- poetry.lock | 8 +- pyproject.toml | 2 +- tests/simulation/conftest.py | 2 +- .../framework/test_base_model_v2.py | 34 -- 5 files changed, 12 insertions(+), 431 deletions(-) diff --git a/flow360/component/simulation/framework/base_model.py b/flow360/component/simulation/framework/base_model.py index 7724e5077..fca66155b 100644 --- a/flow360/component/simulation/framework/base_model.py +++ b/flow360/component/simulation/framework/base_model.py @@ -1,393 +1,8 @@ -"""Flow360BaseModel class definition.""" +"""Flow360BaseModel — re-exports from flow360-schema. -from __future__ import annotations +All SDK methods (copy, preprocess, file I/O, help, hash) now live in +flow360_schema.framework.base_model.Flow360BaseModel. +""" -import hashlib -import json -from itertools import chain -from typing import List - -import pydantic as pd -import rich -import unyt as u -import yaml -from flow360_schema.framework.base_model import Flow360BaseModel as _SchemaBaseModel -from flow360_schema.framework.validation.context import ( - DeserializationContext, - unit_system_manager, -) - -from flow360.component.simulation.conversion import need_conversion -from flow360.error_messages import do_not_modify_file_manually_msg -from flow360.exceptions import Flow360FileError -from flow360.log import log - - -def _preprocess_nested_list(value, required_by, params, exclude, flow360_unit_system): - new_list = [] - for i, item in enumerate(value): - # Extend the 'required_by' path with the current index. - new_required_by = required_by + [f"{i}"] - if isinstance(item, list): - # Recursively process nested lists. - new_list.append( - _preprocess_nested_list(item, new_required_by, params, exclude, flow360_unit_system) - ) - elif isinstance(item, Flow360BaseModel): - # Process Flow360BaseModel instances. - new_list.append( - item.preprocess( - params=params, - required_by=new_required_by, - exclude=exclude, - flow360_unit_system=flow360_unit_system, - ) - ) - elif need_conversion(item): - # Convert nested dimensioned values to base unit system - new_list.append(item.in_base(flow360_unit_system)) - else: - # Return item unchanged if it doesn't need processing. - new_list.append(item) - return new_list - - -class Flow360BaseModel(_SchemaBaseModel): - """Base pydantic (V2) model that all Flow360 components inherit from. - Extends the schema-layer Flow360BaseModel with SDK features: - file I/O, hash tracking, unit conversion (preprocess), and rich help output. - """ - - # -- SDK-only methods: dict / file handling -- - - @classmethod - def _handle_dict(cls, **kwargs): - """Handle dictionary input for the model.""" - model_dict = kwargs - model_dict = cls._handle_dict_with_hash(model_dict) - return model_dict - - @classmethod - def _handle_file(cls, filename: str = None, **kwargs): - """Handle file input for the model. - - Parameters - ---------- - filename : str - Full path to the .json or .yaml file to load the :class:`Flow360BaseModel` from. - **kwargs - Keyword arguments to be passed to the model.""" - if filename is not None: - return cls._dict_from_file(filename=filename) - return kwargs - - def copy(self, update=None, **kwargs) -> Flow360BaseModel: - """Copy a Flow360BaseModel. With ``deep=True`` as default.""" - if "deep" in kwargs and kwargs["deep"] is False: - raise ValueError("Can't do shallow copy of component, set `deep=True` in copy().") - new_copy = pd.BaseModel.model_copy(self, update=update, deep=True, **kwargs) - data = new_copy.model_dump(exclude={"private_attribute_id"}) - with unit_system_manager.suspended(), DeserializationContext(): - return self.model_validate(data) - - def help(self, methods: bool = False) -> None: - """Prints message describing the fields and methods of a :class:`Flow360BaseModel`. - - Parameters - ---------- - methods : bool = False - Whether to also print out information about object's methods. - - Example - ------- - >>> params.help(methods=True) # doctest: +SKIP - """ - rich.inspect(self, methods=methods) - - @classmethod - def from_file(cls, filename: str) -> Flow360BaseModel: - """Loads a :class:`Flow360BaseModel` from .json, or .yaml file. - - Parameters - ---------- - filename : str - Full path to the .yaml or .json file to load the :class:`Flow360BaseModel` from. - - Returns - ------- - :class:`Flow360BaseModel` - An instance of the component class calling `load`. - - Example - ------- - >>> params = Flow360BaseModel.from_file(filename='folder/sim.json') # doctest: +SKIP - """ - model_dict = cls._handle_file(filename=filename) - return cls.deserialize(model_dict) - - @classmethod - def _dict_from_file(cls, filename: str) -> dict: - """Loads a dictionary containing the model from a .json or .yaml file. - - Parameters - ---------- - filename : str - Full path to the .yaml or .json file to load the :class:`Flow360BaseModel` from. - - Returns - ------- - dict - A dictionary containing the model. - - Example - ------- - >>> params = Flow360BaseModel.from_file(filename='folder/flow360.json') # doctest: +SKIP - """ - - if ".json" in filename: - model_dict = cls._dict_from_json(filename=filename) - elif ".yaml" in filename: - model_dict = cls._dict_from_yaml(filename=filename) - else: - raise Flow360FileError(f"File must be *.json or *.yaml type, given {filename}") - - model_dict = cls._handle_dict_with_hash(model_dict) - return model_dict - - def to_file(self, filename: str, **kwargs) -> None: - """Exports :class:`Flow360BaseModel` instance to .json or .yaml file - - Parameters - ---------- - filename : str - Full path to the .json or .yaml or file to save the :class:`Flow360BaseModel` to. - - Example - ------- - >>> params.to_file(filename='folder/flow360.json') # doctest: +SKIP - """ - - if ".json" in filename: - return self._to_json(filename=filename, **kwargs) - if ".yaml" in filename: - return self._to_yaml(filename=filename, **kwargs) - - raise Flow360FileError(f"File must be .json, or .yaml, type, given {filename}") - - @classmethod - def _dict_from_json(cls, filename: str) -> dict: - """Load dictionary of the model from a .json file. - - Parameters - ---------- - filename : str - Full path to the .json file to load the :class:`Flow360BaseModel` from. - - Returns - ------- - dict - A dictionary containing the model. - - Example - ------- - >>> params_dict = Flow360BaseModel.dict_from_json(filename='folder/flow360.json') # doctest: +SKIP - """ - with open(filename, "r", encoding="utf-8") as json_fhandle: - model_dict = json.load(json_fhandle) - return model_dict - - def _to_json(self, filename: str, **kwargs) -> None: - """Exports :class:`Flow360BaseModel` instance to .json file - - Parameters - ---------- - filename : str - Full path to the .json file to save the :class:`Flow360BaseModel` to. - - Example - ------- - >>> params._to_json(filename='folder/flow360.json') # doctest: +SKIP - """ - json_string = self.model_dump_json(exclude_none=True, **kwargs) - model_dict = json.loads(json_string) - if self.model_config["include_hash"] is True: - model_dict["hash"] = self._calculate_hash(model_dict) - with open(filename, "w+", encoding="utf-8") as file_handle: - json.dump(model_dict, file_handle, indent=4, sort_keys=True) - - @classmethod - def _dict_from_yaml(cls, filename: str) -> dict: - """Load dictionary of the model from a .yaml file. - - Parameters - ---------- - filename : str - Full path to the .yaml file to load the :class:`Flow360BaseModel` from. - - Returns - ------- - dict - A dictionary containing the model. - - Example - ------- - >>> params_dict = Flow360BaseModel.dict_from_yaml(filename='folder/flow360.yaml') # doctest: +SKIP - """ - with open(filename, "r", encoding="utf-8") as yaml_in: - model_dict = yaml.safe_load(yaml_in) - return model_dict - - def _to_yaml(self, filename: str, **kwargs) -> None: - """Exports :class:`Flow360BaseModel` instance to .yaml file. - - Parameters - ---------- - filename : str - Full path to the .yaml file to save the :class:`Flow360BaseModel` to. - - Example - ------- - >>> params._to_yaml(filename='folder/flow360.yaml') # doctest: +SKIP - """ - json_string = self.model_dump_json(exclude_none=True, **kwargs) - model_dict = json.loads(json_string) - if self.model_config["include_hash"]: - model_dict["hash"] = self._calculate_hash(model_dict) - with open(filename, "w+", encoding="utf-8") as file_handle: - yaml.dump(model_dict, file_handle, indent=4, sort_keys=True) - - @classmethod - def _handle_dict_with_hash(cls, model_dict): - """ - Handle dictionary input for the model. - 1. Pop the hash. - 2. Check file manipulation. - """ - hash_from_input = model_dict.pop("hash", None) - if hash_from_input is not None: - if hash_from_input != cls._calculate_hash(model_dict): - log.warning(do_not_modify_file_manually_msg) - return model_dict - - @classmethod - def _calculate_hash(cls, model_dict): - def remove_private_attribute_id(obj): - """ - Recursively remove all 'private_attribute_id' keys from the data structure. - This ensures hash consistency when private_attribute_id contains UUID4 values - that change between runs. - """ - if isinstance(obj, dict): - # Create new dict excluding 'private_attribute_id' keys - return { - key: remove_private_attribute_id(value) - for key, value in obj.items() - if key != "private_attribute_id" - } - if isinstance(obj, list): - # Recursively process list elements - return [remove_private_attribute_id(item) for item in obj] - # Return other types as-is (maintains reference for immutable objects) - return obj - - # Remove private_attribute_id before calculating hash - cleaned_dict = remove_private_attribute_id(model_dict) - hasher = hashlib.sha256() - json_string = json.dumps(cleaned_dict, sort_keys=True) - hasher.update(json_string.encode("utf-8")) - return hasher.hexdigest() - - # pylint: disable=too-many-arguments, too-many-locals, too-many-branches - def _nondimensionalization( - self, - *, - exclude: List[str] = None, - required_by: List[str] = None, - flow360_unit_system: u.UnitSystem = None, - ) -> dict: - solver_values = {} - self_dict = self.__dict__ - - if exclude is None: - exclude = [] - - if required_by is None: - required_by = [] - - additional_fields = {} - - for property_name, value in chain(self_dict.items(), additional_fields.items()): - if need_conversion(value) and property_name not in exclude: - solver_values[property_name] = value.in_base(flow360_unit_system) - else: - solver_values[property_name] = value - - return solver_values - - def preprocess( - self, - *, - params=None, - exclude: List[str] = None, - required_by: List[str] = None, - flow360_unit_system: u.UnitSystem = None, - ) -> Flow360BaseModel: - """ - Loops through all fields, for Flow360BaseModel runs .preprocess() recursively. For dimensioned value performs - - unit conversion to flow360_base system. - - Parameters - ---------- - params : SimulationParams - Full config definition as Flow360Params. - - mesh_unit: LengthType.Positive - The length represented by 1 unit length in the mesh. - - exclude: List[str] (optional) - List of fields to not convert to solver dimensions. - - required_by: List[str] (optional) - Path to property which requires conversion. - - - - Returns - ------- - caller class - returns caller class with units all in flow360 base unit system - """ - - if exclude is None: - exclude = [] - - if required_by is None: - required_by = [] - - solver_values = self._nondimensionalization( - exclude=exclude, - required_by=required_by, - flow360_unit_system=flow360_unit_system, - ) - for property_name, value in self.__dict__.items(): - if property_name in exclude: - continue - loc_name = property_name - field = self.__class__.model_fields.get(property_name) - if field is not None and field.alias is not None: - loc_name = field.alias - if isinstance(value, Flow360BaseModel): - solver_values[property_name] = value.preprocess( - params=params, - required_by=[*required_by, loc_name], - exclude=exclude, - flow360_unit_system=flow360_unit_system, - ) - elif isinstance(value, list): - # Use the helper to handle nested lists. - solver_values[property_name] = _preprocess_nested_list( - value, [loc_name], params, exclude, flow360_unit_system - ) - - return self.__class__(**solver_values) +# pylint: disable=unused-import +from flow360_schema.framework.base_model import Flow360BaseModel # noqa: F401 diff --git a/poetry.lock b/poetry.lock index 7b55f6860..1f20e29e2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1468,14 +1468,14 @@ files = [ [[package]] name = "flow360-schema" -version = "0.1.16" +version = "0.1.17" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.16-py3-none-any.whl", hash = "sha256:5080087b66b1ee7b4f4df02a64d05e3b67fcdae322b142f09c1f4ab5ee4f7c72"}, - {file = "flow360_schema-0.1.16.tar.gz", hash = "sha256:266e6fb766c13da9057d14ca72c554ed17f16b5d8906ed23447d1310c5595d78"}, + {file = "flow360_schema-0.1.17-py3-none-any.whl", hash = "sha256:ffdff16f651f4a40c7cf653af200ac3b93719c3cc826ecc1cc7b7e7554c6ce52"}, + {file = "flow360_schema-0.1.17.tar.gz", hash = "sha256:42054361a82cb67391fc1cc12fa6d103b58d836601f906796ca6634e6f5721b6"}, ] [package.dependencies] @@ -6527,4 +6527,4 @@ docs = ["autodoc_pydantic", "cairosvg", "ipython", "jinja2", "jupyter", "myst-pa [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "df6f455746ec9aec9adff6aaec7b71bfd8e402cc531069fdb237ba36b4b85d7b" +content-hash = "d6456dbd7d96755a313d46a1ccba9d108923ce844d4199729df3974836efc12d" diff --git a/pyproject.toml b/pyproject.toml index ef470518e..a082efe6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ pydantic = ">=2.8,<2.12" # -- Local dev (editable install, schema changes take effect immediately): # flow360-schema = { path = "../flex/share/flow360-schema", develop = true } # -- CI / release (install from CodeArtifact, swap comments before pushing): -flow360-schema = { version = "~0.1.14", source = "codeartifact" } +flow360-schema = { version = "~0.1.17", source = "codeartifact" } pytest = "^7.1.2" click = "^8.1.3" toml = "^0.10.2" diff --git a/tests/simulation/conftest.py b/tests/simulation/conftest.py index cb045f0c5..1d1317331 100644 --- a/tests/simulation/conftest.py +++ b/tests/simulation/conftest.py @@ -32,7 +32,7 @@ def _approx_equal(a, b, rel_tol=1e-12): def to_file_from_file_test_approx(obj): """v2 serialization round-trip test with float tolerance.""" - test_extentions = ["yaml", "json"] + test_extentions = ["json"] factory = obj.__class__ with tempfile.TemporaryDirectory() as tmpdir: for ext in test_extentions: diff --git a/tests/simulation/framework/test_base_model_v2.py b/tests/simulation/framework/test_base_model_v2.py index 76f24e2c9..6798387ad 100644 --- a/tests/simulation/framework/test_base_model_v2.py +++ b/tests/simulation/framework/test_base_model_v2.py @@ -4,7 +4,6 @@ import pydantic as pd import pytest -import yaml import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -58,17 +57,6 @@ def test_from_file(): finally: os.remove(temp_file_name) - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as temp_file: - yaml.dump(file_content, temp_file) - temp_file.flush() - temp_file_name = temp_file.name - - try: - base_model = BaseModelTestModel.from_file(temp_file_name) - assert base_model.some_value == 321 - finally: - os.remove(temp_file_name) - def test_dict_from_file(): file_content = { @@ -87,17 +75,6 @@ def test_dict_from_file(): finally: os.remove(temp_file_name) - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as temp_file: - yaml.dump(file_content, temp_file) - temp_file.flush() - temp_file_name = temp_file.name - - try: - base_model_dict = BaseModelTestModel._dict_from_file(temp_file_name) - assert base_model_dict["some_value"] == 3210 - finally: - os.remove(temp_file_name) - def test_to_file(): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as temp_file: @@ -113,17 +90,6 @@ def test_to_file(): finally: os.remove(temp_file_name) - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as temp_file: - temp_file_name = temp_file.name - - try: - base_model.to_file(temp_file_name) - with open(temp_file_name) as fp: - base_model_dict = yaml.load(fp, Loader=yaml.Loader) - assert base_model_dict["some_value"] == 1230 - finally: - os.remove(temp_file_name) - def test_preprocess(): value = 123 From f60f935dd0545f852afc50b444a4fe168dd9c129 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:01:43 -0400 Subject: [PATCH 08/25] =?UTF-8?q?refactor(flow360):=20entity=20system=20?= =?UTF-8?q?=E2=80=94=20client=20files=20become=20re-import=20relays=20(#19?= =?UTF-8?q?45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.6 (1M context) --- flow360/component/geometry.py | 4 +- flow360/component/project_utils.py | 60 +- flow360/component/simulation/entity_info.py | 1013 +------------ .../component/simulation/entity_operation.py | 398 +---- .../simulation/framework/boundary_split.py | 6 +- .../simulation/framework/entity_base.py | 458 +----- .../framework/entity_expansion_utils.py | 79 +- .../entity_materialization_context.py | 91 +- .../framework/entity_materializer.py | 341 +---- .../simulation/framework/entity_registry.py | 575 +------- .../simulation/framework/entity_selector.py | 662 +-------- .../simulation/framework/entity_utils.py | 229 +-- .../framework/multi_constructor_model_base.py | 251 +--- .../simulation/framework/param_utils.py | 14 +- .../simulation/framework/unique_list.py | 112 +- .../simulation/outputs/output_entities.py | 361 +---- flow360/component/simulation/primitives.py | 1288 +---------------- flow360/component/simulation/services.py | 12 +- .../component/simulation/simulation_params.py | 12 +- flow360/component/simulation/utils.py | 149 +- flow360/component/types.py | 102 +- flow360/exceptions.py | 25 +- poetry.lock | 8 +- pyproject.toml | 2 +- .../framework/test_entities_fast_register.py | 4 +- .../simulation/framework/test_entities_v2.py | 4 +- .../framework/test_entity_expansion_impl.py | 31 + .../simulation/framework/test_entity_list.py | 17 +- .../test_entity_selector_fluent_api.py | 30 + .../framework/test_entity_selector_token.py | 28 + .../params/test_simulation_params.py | 4 +- .../simulation_with_project_variables.json | 2 +- tests/simulation/test_project.py | 4 +- .../translator/test_solver_translator.py | 4 +- .../test_surface_meshing_translator_ghost.py | 4 +- .../test_volume_meshing_translator.py | 1 - tests/test_results.py | 4 +- 37 files changed, 374 insertions(+), 6015 deletions(-) diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 63e754686..a40c925f6 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -31,7 +31,6 @@ ) from flow360.component.simulation.folder import Folder from flow360.component.simulation.primitives import Edge, GeometryBodyGroup, Surface -from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.web.asset_base import AssetBase from flow360.component.utils import ( GeometryFiles, @@ -724,8 +723,7 @@ def _rename_entity( raise Flow360ValueError( f"Renaming failed: An entity with the new name: {new_name} already exists." ) - with model_attribute_unlock(entity, "name"): - entity.name = new_name + entity._force_set_attr("name", new_name) # pylint:disable=protected-access def rename_edges(self, current_name_pattern: str, new_name_prefix: str): """ diff --git a/flow360/component/project_utils.py b/flow360/component/project_utils.py index fa82f7991..f8d9a70a2 100644 --- a/flow360/component/project_utils.py +++ b/flow360/component/project_utils.py @@ -35,7 +35,6 @@ ) from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.user_code.core.types import save_user_variables -from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.web.asset_base import AssetBase from flow360.exceptions import ( Flow360ConfigurationError, @@ -320,8 +319,9 @@ def _set_up_params_imported_surfaces(params: SimulationParams): if isinstance(surface, ImportedSurface) and surface.name not in imported_surfaces: imported_surfaces[surface.name] = surface - with model_attribute_unlock(params.private_attribute_asset_cache, "imported_surfaces"): - params.private_attribute_asset_cache.imported_surfaces = list(imported_surfaces.values()) + params.private_attribute_asset_cache._force_set_attr( # pylint:disable=protected-access + "imported_surfaces", list(imported_surfaces.values()) + ) return params @@ -444,8 +444,7 @@ def _get_used_tags(model: Flow360BaseModel, target_entity_type, used_tags: set): f" and SimulationParams ({used_tags[0]}). " "Ignoring the geometry object and using the one in the SimulationParams." ) - with model_attribute_unlock(entity_info, entity_grouping_tags): - setattr(entity_info, entity_grouping_tags, used_tags[0]) + entity_info._force_set_attr(entity_grouping_tags, used_tags[0]) if len(used_tags) > 1: raise Flow360ConfigurationError( @@ -542,18 +541,19 @@ def set_up_params_for_uploading( # pylint: disable=too-many-arguments use_geometry_AI: Whether to use Geometry AI. """ - with model_attribute_unlock(params.private_attribute_asset_cache, "project_length_unit"): - params.private_attribute_asset_cache.project_length_unit = length_unit + params.private_attribute_asset_cache._force_set_attr( # pylint:disable=protected-access + "project_length_unit", length_unit + ) - with model_attribute_unlock(params.private_attribute_asset_cache, "use_inhouse_mesher"): - params.private_attribute_asset_cache.use_inhouse_mesher = ( - use_beta_mesher if use_beta_mesher else False - ) + params.private_attribute_asset_cache._force_set_attr( # pylint:disable=protected-access + "use_inhouse_mesher", + use_beta_mesher if use_beta_mesher else False, + ) - with model_attribute_unlock(params.private_attribute_asset_cache, "use_geometry_AI"): - params.private_attribute_asset_cache.use_geometry_AI = ( - use_geometry_AI if use_geometry_AI else False - ) + params.private_attribute_asset_cache._force_set_attr( # pylint:disable=protected-access + "use_geometry_AI", + use_geometry_AI if use_geometry_AI else False, + ) active_draft = get_active_draft() @@ -567,18 +567,15 @@ def set_up_params_for_uploading( # pylint: disable=too-many-arguments # (back compatibility, since the grouping should already have been captured in the draft_entity_info) entity_info = _update_entity_grouping_tags(entity_info, params) - with model_attribute_unlock(params.private_attribute_asset_cache, "mirror_status"): - mirror_status = active_draft.mirror._mirror_status - if not mirror_status.is_empty(): - params.private_attribute_asset_cache.mirror_status = mirror_status - else: - params.private_attribute_asset_cache.mirror_status = None - with model_attribute_unlock( - params.private_attribute_asset_cache, "coordinate_system_status" - ): - params.private_attribute_asset_cache.coordinate_system_status = ( - active_draft.coordinate_systems._to_status() - ) + mirror_status = active_draft.mirror._mirror_status + if not mirror_status.is_empty(): + params.private_attribute_asset_cache._force_set_attr("mirror_status", mirror_status) + else: + params.private_attribute_asset_cache._force_set_attr("mirror_status", None) + params.private_attribute_asset_cache._force_set_attr( + "coordinate_system_status", + active_draft.coordinate_systems._to_status(), + ) else: # Legacy workflow (without DraftContext): use root_asset.entity_info # User may have made modifications to the entities which is recorded in asset's entity registry @@ -595,10 +592,11 @@ def set_up_params_for_uploading( # pylint: disable=too-many-arguments # we need to update the entity grouping tags to the ones in the SimulationParams. entity_info = _update_entity_grouping_tags(entity_info, params) - with model_attribute_unlock(params.private_attribute_asset_cache, "project_entity_info"): - # At this point the draft entity info has replaced the SimulationParams's entity info. - # So the validation afterwards does not require the access to the draft entity info anymore. - params.private_attribute_asset_cache.project_entity_info = entity_info + # At this point the draft entity info has replaced the SimulationParams's entity info. + # So the validation afterwards does not require the access to the draft entity info anymore. + params.private_attribute_asset_cache._force_set_attr( # pylint:disable=protected-access + "project_entity_info", entity_info + ) # Replace the ghost surfaces in the SimulationParams by the real ghost ones from asset metadata. # This has to be done after `project_entity_info` is properly set. params = _replace_ghost_surfaces(params) diff --git a/flow360/component/simulation/entity_info.py b/flow360/component/simulation/entity_info.py index 80ffaed93..2671a87e6 100644 --- a/flow360/component/simulation/entity_info.py +++ b/flow360/component/simulation/entity_info.py @@ -1,1000 +1,15 @@ -"""Deserializer for entity info retrieved from asset metadata pipeline.""" - -# pylint: disable=no-member - -from abc import ABCMeta, abstractmethod -from collections import defaultdict -from typing import Annotated, Any, Dict, List, Literal, Optional, Union - -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Length -from flow360_schema.framework.validation.context import DeserializationContext - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_registry import ( - EntityRegistry, - SnappyBodyRegistry, -) -from flow360.component.simulation.outputs.output_entities import ( - Point, - PointArray, - PointArray2D, - Slice, +"""Re-import relay: entity info models now live in flow360_schema.models.entity_info.""" + +# pylint: disable=unused-import +# ruff: noqa: F401 +from flow360_schema.models.entity_info import ( + BodyComponentInfo, + DraftEntityTypes, + EntityInfoModel, + EntityInfoUnion, + GeometryEntityInfo, + SurfaceMeshEntityInfo, + VolumeMeshEntityInfo, + merge_geometry_entity_info, + parse_entity_info_model, ) -from flow360.component.simulation.primitives import ( - AxisymmetricBody, - Box, - CustomVolume, - Cylinder, - Edge, - GenericVolume, - GeometryBodyGroup, - GhostCircularPlane, - GhostSphere, - SnappyBody, - Sphere, - Surface, - WindTunnelGhostSurface, -) -from flow360.component.simulation.utils import BoundingBoxType, model_attribute_unlock -from flow360.component.utils import GeometryFiles -from flow360.exceptions import Flow360ValueError -from flow360.log import log - -DraftEntityTypes = Annotated[ - Union[ - AxisymmetricBody, - Box, - Cylinder, - Sphere, - Point, - PointArray, - PointArray2D, - Slice, - CustomVolume, - ], - pd.Field(discriminator="private_attribute_entity_type_name"), -] - - -class EntityInfoModel(Flow360BaseModel, metaclass=ABCMeta): - """Base model for asset entity info JSON""" - - # entities that appear in simulation JSON but did not appear in EntityInfo - draft_entities: List[DraftEntityTypes] = pd.Field([]) - ghost_entities: List[ - Annotated[ - Union[GhostSphere, GhostCircularPlane, WindTunnelGhostSurface], - pd.Field(discriminator="private_attribute_entity_type_name"), - ] - ] = pd.Field([]) - - @abstractmethod - def get_boundaries(self, attribute_name: str = None) -> list[Surface]: - """ - Helper function. - Get the full list of boundary. - If it is geometry then use supplied attribute name to get the list. - """ - - @abstractmethod - def update_persistent_entities(self, *, asset_entity_registry: EntityRegistry) -> None: - """ - Update self persistent entities with param ones by simple id/name matching. - """ - - @abstractmethod - def get_persistent_entity_registry(self, internal_registry, **kwargs): - """ - Ensure that `internal_registry` exists and if not, initialize `internal_registry`. - """ - - -class BodyComponentInfo(Flow360BaseModel): - """Data model for body component info.""" - - face_ids: list[str] = pd.Field( - description="A full list of face IDs that appear in the body.", - ) - edge_ids: Optional[list[str]] = pd.Field( - None, - description="A full list of edge IDs that appear in the body. Optional for surface mesh geometry.", - ) - - -class GeometryEntityInfo(EntityInfoModel): - """Data model for geometry entityInfo.json""" - - type_name: Literal["GeometryEntityInfo"] = pd.Field("GeometryEntityInfo", frozen=True) - - bodies_face_edge_ids: Optional[Dict[str, BodyComponentInfo]] = pd.Field( - None, - description="Mapping from body ID to the face and edge IDs of the body.", - ) - # bodies_face_edge_ids: Mostly just used by front end. On python side this - # is less useful as users do not operate on face/body/edge IDs directly. - # But at least this can replace `face_ids`, `body_ids`, and `edge_ids` since these contains less info. - - body_ids: list[str] = pd.Field( - [], - description="A full list of body IDs that appear in the geometry.", - alias="bodyIDs", - ) - body_attribute_names: List[str] = pd.Field( - [], - description="A full list of attribute names that the user can" - "select to achieve grouping of bodies. It has same length as `grouped_bodies`", - alias="bodyAttributeNames", - ) - grouped_bodies: List[List[GeometryBodyGroup]] = pd.Field( - [[]], - description="The resulting list " - "of `GeometryBodyGroup` entities after grouping using the attribute name.", - alias="groupedBodies", - ) - - face_ids: list[str] = pd.Field( - [], - description="A full list of faceIDs/model IDs that appear in the geometry.", - alias="faceIDs", - ) - face_attribute_names: List[str] = pd.Field( - [], - description="A full list of attribute names that the user can" - "select to achieve grouping of faces. It has same length as `grouped_faces`", - alias="faceAttributeNames", - ) - grouped_faces: List[List[Surface]] = pd.Field( - [[]], - description="The resulting list " - "of `Surface` entities after grouping using the attribute name.", - alias="groupedFaces", - ) - - edge_ids: list[str] = pd.Field( - [], - description="A full list of edgeIDs/model IDs that appear in the geometry.", - alias="edgeIDs", - ) - edge_attribute_names: List[str] = pd.Field( - [], - description="A full list of attribute names that the user can" - "select to achieve grouping of edges. It has same length as `grouped_edges`", - alias="edgeAttributeNames", - ) - grouped_edges: List[List[Edge]] = pd.Field( - [[]], - description="The resulting list " - "of `Edge` entities after grouping using the attribute name.", - alias="groupedEdges", - ) - - body_group_tag: Optional[str] = pd.Field(None, frozen=True) - face_group_tag: Optional[str] = pd.Field(None, frozen=True) - edge_group_tag: Optional[str] = pd.Field(None, frozen=True) - - global_bounding_box: Optional[BoundingBoxType] = pd.Field(None) - - default_geometry_accuracy: Optional[Length.PositiveFloat64] = pd.Field( - None, - description="The default value based on uploaded geometry for geometry_accuracy.", - ) - - @property - def all_face_ids(self) -> list[str]: - """ - Returns a full list of face IDs that appear in the geometry. - Use `bodies_face_edge_ids` if available, otherwise fall back to use `face_ids`. - """ - if self.bodies_face_edge_ids is not None: - return [ - face_id - for body_component_info in self.bodies_face_edge_ids.values() - for face_id in body_component_info.face_ids - ] - return self.face_ids - - @property - def all_edge_ids(self) -> list[str]: - """ - Returns a full list of edge IDs that appear in the geometry. - Use `bodies_face_edge_ids` if available, otherwise fall back to use `edge_ids`. - """ - if self.bodies_face_edge_ids is not None: - return [ - edge_id - for body_component_info in self.bodies_face_edge_ids.values() - # edge_ids can be None for surface-only geometry; treat it as an empty list. - for edge_id in (body_component_info.edge_ids or []) - ] - return self.edge_ids - - @property - def all_body_ids(self) -> list[str]: - """ - Returns a full list of body IDs that appear in the geometry. - Use `bodies_face_edge_ids` if available, otherwise fall back to use `body_ids`. - """ - if self.bodies_face_edge_ids is not None: - return list(self.bodies_face_edge_ids.keys()) - return self.body_ids - - def group_in_registry( - self, - entity_type_name: Literal["face", "edge", "body", "snappy_body"], - attribute_name: str, - registry: EntityRegistry, - ) -> EntityRegistry: - """ - Group items with given attribute_name. - """ - entity_list = self._get_list_of_entities(attribute_name, entity_type_name) - known_frozen_hashes = set() - for item in entity_list: - known_frozen_hashes = registry.fast_register(item, known_frozen_hashes) - return registry - - def _get_snappy_bodies(self) -> List[SnappyBody]: - - snappy_body_mapping = {} - for patch in self.grouped_faces[self.face_attribute_names.index("faceId")]: - name_components = patch.name.split("::") - body_name = name_components[0] - if body_name not in snappy_body_mapping: - snappy_body_mapping[body_name] = [] - if patch not in snappy_body_mapping[body_name]: - snappy_body_mapping[body_name].append(patch) - - return [ - SnappyBody(name=snappy_body, surfaces=body_entities) - for snappy_body, body_entities in snappy_body_mapping.items() - ] - - def _get_list_of_entities( - self, - attribute_name: Union[str, None] = None, - entity_type_name: Literal["face", "edge", "body", "snappy_body"] = None, - ) -> Union[List[Surface], List[Edge], List[GeometryBodyGroup], List[SnappyBody]]: - # Validations - if entity_type_name is None: - raise ValueError("Entity type name is required.") - if entity_type_name not in ["face", "edge", "body", "snappy_body"]: - raise ValueError( - f"Invalid entity type name, expected 'body, 'face' or 'edge' but got {entity_type_name}." - ) - if entity_type_name == "face": - entity_attribute_names = self.face_attribute_names - entity_full_list = self.grouped_faces - specified_attribute_name = self.face_group_tag - elif entity_type_name == "edge": - entity_attribute_names = self.edge_attribute_names - entity_full_list = self.grouped_edges - specified_attribute_name = self.edge_group_tag - elif entity_type_name == "body": - entity_attribute_names = self.body_attribute_names - entity_full_list = self.grouped_bodies - specified_attribute_name = self.body_group_tag - else: - return self._get_snappy_bodies() - - # Use the supplied one if not None - if attribute_name is not None: - specified_attribute_name = attribute_name - - # pylint: disable=unsupported-membership-test - if specified_attribute_name in entity_attribute_names: - # pylint: disable=no-member, unsubscriptable-object - return entity_full_list[entity_attribute_names.index(specified_attribute_name)] - - raise ValueError( - f"The given attribute_name `{attribute_name}` is not found" - f" in geometry metadata. Available: {entity_attribute_names}" - ) - - def get_boundaries(self, attribute_name: str = None) -> list[Surface]: - """ - Get the full list of boundaries. - If attribute_name is supplied then ignore stored face_group_tag and use supplied one. - """ - return self._get_list_of_entities(attribute_name, "face") - - def update_persistent_entities(self, *, asset_entity_registry: EntityRegistry) -> None: - """ - Update the persistent entities stored inside `self` according to `asset_entity_registry` - """ - - def _search_and_replace(grouped_entities, entity_registry: EntityRegistry): - for i_group, _ in enumerate(grouped_entities): - for i_entity, _ in enumerate(grouped_entities[i_group]): - assigned_entity = entity_registry.find_by_asset_id( - entity_id=grouped_entities[i_group][i_entity].id, - entity_class=grouped_entities[i_group][i_entity].__class__, - ) - if assigned_entity is not None: - grouped_entities[i_group][i_entity] = assigned_entity - - _search_and_replace(self.grouped_faces, asset_entity_registry) # May changed entity name - _search_and_replace(self.grouped_edges, asset_entity_registry) - _search_and_replace(self.grouped_bodies, asset_entity_registry) # May changed entity name - - def _get_processed_file_list(self): - """ - Return the list of files that are uploaded by geometryConversionPipeline. - - This function examines the files mentioned under `grouped_bodies->groupByFile` - and append folder prefix if necessary. - """ - body_groups_grouped_by_file = self._get_list_of_entities("groupByFile", "body") - unprocessed_file_names = [item.private_attribute_id for item in body_groups_grouped_by_file] - processed_geometry_file_names = [] - surface_mesh_file_names = [] - for unprocessed_file_name in unprocessed_file_names: - # All geometry source file gets lumped into a single file - if GeometryFiles.check_is_valid_geometry_file_format(file_name=unprocessed_file_name): - # This is a geometry file - processed_geometry_file_names.append(f"{unprocessed_file_name}.egads") - else: - # Not a geometry file. Maybe a surface mesh file. No special treatment needed. - surface_mesh_file_names.append(unprocessed_file_name) - return processed_geometry_file_names, surface_mesh_file_names - - def _get_id_to_file_map( - self, *, entity_type_name: Literal["face", "edge", "body"] - ) -> dict[str, str]: - """Returns faceId/edgeId/bodyId to file name mapping.""" - - if entity_type_name not in ("face", "edge", "body"): - raise ValueError( - f"Invalid entity_type_name given:{entity_type_name}. Valid options are 'face', 'edge', 'body'" - ) - - if entity_type_name in ("face", "edge"): - # No direct/consistent way of getting this info compared to bodies - # Also need to figure out what mesher team needs exactly. - raise NotImplementedError() - - id_to_file_name = {} - - body_groups_grouped_by_file = self._get_list_of_entities("groupByFile", "body") - for item in body_groups_grouped_by_file: - if GeometryFiles.check_is_valid_geometry_file_format( - file_name=item.private_attribute_id - ): - file_name = f"{item.private_attribute_id}.egads" - else: - file_name = item.private_attribute_id - for sub_component_id in item.private_attribute_sub_components: - id_to_file_name[sub_component_id] = file_name - - return id_to_file_name - - def _get_default_grouping_tag(self, entity_type_name: Literal["face", "edge", "body"]) -> str: - """ - Returns the default grouping tag for the given entity type. - The selection logic is intended to mimic the webUI behavior. - """ - - def _get_the_first_non_id_tag( - attribute_names: list[str], entity_type_name: Literal["face", "edge", "body"] - ): - if not attribute_names: - raise ValueError( - f"[Internal] No valid tag available for grouping {entity_type_name}." - ) - id_tag = f"{entity_type_name}Id" - for item in attribute_names: - if item != id_tag: - return item - return id_tag - - if entity_type_name == "body": - return _get_the_first_non_id_tag(self.body_attribute_names, entity_type_name) - - if entity_type_name == "face": - return _get_the_first_non_id_tag(self.face_attribute_names, entity_type_name) - - if entity_type_name == "edge": - return _get_the_first_non_id_tag(self.edge_attribute_names, entity_type_name) - - raise ValueError(f"[Internal] Invalid entity type name: {entity_type_name}.") - - def _group_entity_by_tag( - self, - entity_type_name: Literal["face", "edge", "body"], - tag_name: str, - registry: EntityRegistry = None, - ) -> EntityRegistry: - - if entity_type_name not in ["face", "edge", "body"]: - raise ValueError( - f"[Internal] Unknown entity type: `{entity_type_name}`, allowed entity: 'face', 'edge', 'body'." - ) - - if registry is None: - registry = EntityRegistry() - - existing_tag = None - if entity_type_name == "face" and self.face_group_tag is not None: - existing_tag = self.face_group_tag - - elif entity_type_name == "edge" and self.edge_group_tag is not None: - existing_tag = self.edge_group_tag - - elif entity_type_name == "body" and self.body_group_tag is not None: - existing_tag = self.body_group_tag - - if existing_tag: - if existing_tag != tag_name: - log.info( - f"Regrouping {entity_type_name} entities under `{tag_name}` tag (previous `{existing_tag}`)." - ) - registry = self._reset_grouping(entity_type_name=entity_type_name, registry=registry) - - registry = self.group_in_registry( - entity_type_name, attribute_name=tag_name, registry=registry - ) - if entity_type_name == "face": - with model_attribute_unlock(self, "face_group_tag"): - self.face_group_tag = tag_name - elif entity_type_name == "edge": - with model_attribute_unlock(self, "edge_group_tag"): - self.edge_group_tag = tag_name - else: - with model_attribute_unlock(self, "body_group_tag"): - self.body_group_tag = tag_name - - return registry - - def _group_faces_by_snappy_format(self): - registry = SnappyBodyRegistry() - - registry = self.group_in_registry("snappy_body", attribute_name="faceId", registry=registry) - - return registry - - @pd.validate_call - def _reset_grouping( - self, entity_type_name: Literal["face", "edge", "body"], registry: EntityRegistry - ) -> EntityRegistry: - if entity_type_name == "face": - registry.clear(Surface) - registry.clear(SnappyBody) - with model_attribute_unlock(self, "face_group_tag"): - self.face_group_tag = None - elif entity_type_name == "edge": - registry.clear(Edge) - with model_attribute_unlock(self, "edge_group_tag"): - self.edge_group_tag = None - else: - registry.clear(GeometryBodyGroup) - with model_attribute_unlock(self, "body_group_tag"): - self.body_group_tag = None - return registry - - def get_persistent_entity_registry(self, internal_registry, **_) -> EntityRegistry: - if internal_registry is None: - internal_registry = EntityRegistry() - if self.face_group_tag is None: - face_group_tag = self._get_default_grouping_tag("face") - log.info(f"Using `{face_group_tag}` as default grouping for faces.") - else: - face_group_tag = self.face_group_tag - - internal_registry = self._group_entity_by_tag( - "face", face_group_tag, registry=internal_registry - ) - - if len(self.all_edge_ids) > 0: - if self.edge_group_tag is None: - edge_group_tag = self._get_default_grouping_tag("edge") - log.info(f"Using `{edge_group_tag}` as default grouping for edges.") - else: - edge_group_tag = self.edge_group_tag - - internal_registry = self._group_entity_by_tag( - "edge", edge_group_tag, registry=internal_registry - ) - - if self.body_attribute_names: - # Post-25.5 geometry asset. For Pre 25.5 we just skip body grouping. - if self.body_group_tag is None: - body_group_tag = self._get_default_grouping_tag("body") - log.info(f"Using `{body_group_tag}` as default grouping for bodies.") - else: - body_group_tag = self.body_group_tag - - internal_registry = self._group_entity_by_tag( - "body", body_group_tag, registry=internal_registry - ) - return internal_registry - - def get_body_group_to_surface_mapping(self) -> dict[str, list[str]]: - """ - Return body group's (id, name) to Surfaces' (face groups') (id, name) mapping - """ - - # pylint: disable=too-many-locals - def create_group_to_sub_component_mapping(group): - return { - item.private_attribute_id: (item.name, item.private_attribute_sub_components) - for item in group - } - - # body_group_id to (body_group_name, body_ids) of the current body group - body_group_to_body = create_group_to_sub_component_mapping( - self._get_list_of_entities(entity_type_name="body", attribute_name=self.body_group_tag) - ) - # surface_id to (surface_name, face_ids) of the current face group - surface_to_face = create_group_to_sub_component_mapping( - self._get_list_of_entities(entity_type_name="face", attribute_name=self.face_group_tag) - ) - - # Create body id to face ids mapping: - if self.bodies_face_edge_ids: - # With bodies_face_edge_ids - body_id_to_face_ids = { - body_id: body_component_info.face_ids - for body_id, body_component_info in self.bodies_face_edge_ids.items() - } - else: - # Fallback: With the face group:"groupByBodyId" where face_group_name is body_id - if "groupByBodyId" not in self.face_attribute_names: - # This likely means the geometry asset is pre-25.5. - raise Flow360ValueError( - "Geometry cloud resource is too old." - " Please consider re-uploading the geometry with newer solver version (>25.5)." - ) - body_id_to_face_ids = { - face_group.name: face_group.private_attribute_sub_components - for face_group in self._get_list_of_entities( - entity_type_name="face", attribute_name="groupByBodyId" - ) - } - - # body_group_id to (body_group_name, face_ids) of the current body group - body_group_to_face = {} - for body_group_id, (body_group_name, body_ids) in body_group_to_body.items(): - face_ids = [] - for body_id in body_ids: - face_ids.extend(body_id_to_face_ids[body_id]) - body_group_to_face[body_group_id] = (body_group_name, face_ids) - - # face_id to (body_group_id, body_group_name) of the current body group - face_to_body_group = {} - for body_group_id, (body_group_name, face_ids) in body_group_to_face.items(): - for face_id in face_ids: - face_to_body_group[face_id] = (body_group_id, body_group_name) - - # body_group (id, name) to surface (id, name) - body_group_to_surface = {} - for surface_id, (surface_name, face_ids) in surface_to_face.items(): - body_group_in_this_face_group = set() - for face_id in face_ids: - owning_body = face_to_body_group.get(face_id) - if owning_body is None: - raise Flow360ValueError( - f"Face ID '{face_id}' found in face group '{surface_name}' " - "but not found in any body group." - ) - body_group_in_this_face_group.add(owning_body) - if len(body_group_in_this_face_group) > 1: - raise Flow360ValueError( - f"Face group '{surface_name}' contains faces belonging to multiple body groups: " - f"{list(sorted(body_group_in_this_face_group))}. " - "The mapping between body and face groups cannot be created." - ) - - owning_body = list(body_group_in_this_face_group)[0] - if owning_body not in body_group_to_surface: - body_group_to_surface[owning_body] = [] - body_group_to_surface[owning_body].append((surface_id, surface_name)) - - return body_group_to_surface - - def get_body_group_to_face_group_name_map(self) -> dict[str, list[str]]: - """ - Returns body group name to face group (Surface) name mapping. - """ - - body_group_to_surface = self.get_body_group_to_surface_mapping() - body_group_to_surface_name = defaultdict(list) - - for (_, body_group_name), boundaries in body_group_to_surface.items(): - body_group_to_surface_name[body_group_name].extend( - [surface_name for (_, surface_name) in boundaries] - ) - - return body_group_to_surface_name - - def get_face_group_to_body_group_id_map(self) -> dict[str, str]: - """ - Returns a mapping from face group (Surface) name to the owning body group ID. - - This is the inverse of :meth:`get_body_group_to_surface_mapping` and uses the - same underlying assumptions and validations about the grouping tags. - """ - - body_group_to_surface = self.get_body_group_to_surface_mapping() - face_group_to_body_group: dict[str, str] = {} - for (body_group_id, _), surfaces in body_group_to_surface.items(): - for _, surface_name in surfaces: - existing_owner = face_group_to_body_group.get(surface_name) - if existing_owner is not None and existing_owner != body_group_id: - raise ValueError( - f"[Internal] Face group '{surface_name}' is mapped to multiple body groups: " - f"{existing_owner}, {body_group_id}. Data is likely corrupted." - ) - face_group_to_body_group[surface_name] = body_group_id - - return face_group_to_body_group - - -class VolumeMeshEntityInfo(EntityInfoModel): - """Data model for volume mesh entityInfo.json""" - - type_name: Literal["VolumeMeshEntityInfo"] = pd.Field("VolumeMeshEntityInfo", frozen=True) - zones: list[GenericVolume] = pd.Field([]) - boundaries: list[Surface] = pd.Field([]) - - @pd.field_validator("boundaries", mode="after") - @classmethod - def check_all_surface_has_interface_indicator(cls, value): - """private_attribute_is_interface should have been set coming from volume mesh.""" - for item in value: - if item.private_attribute_is_interface is None: - raise ValueError( - "[INTERNAL] {item.name} is missing private_attribute_is_interface attribute!." - ) - return value - - # pylint: disable=arguments-differ - def get_boundaries(self) -> list: - """ - Get the full list of boundary. - """ - # pylint: disable=not-an-iterable - return [item for item in self.boundaries if item.private_attribute_is_interface is False] - - def update_persistent_entities(self, *, asset_entity_registry: EntityRegistry) -> None: - """ - 1. Changed GenericVolume axis and center etc - """ - - for i_zone, _ in enumerate(self.zones): - # pylint:disable = unsubscriptable-object - assigned_zone = asset_entity_registry.find_by_asset_id( - entity_id=self.zones[i_zone].id, entity_class=self.zones[i_zone].__class__ - ) - if assigned_zone is not None: - # pylint:disable = unsupported-assignment-operation - self.zones[i_zone] = assigned_zone - - def get_persistent_entity_registry(self, internal_registry, **_) -> EntityRegistry: - if internal_registry is None: - # Initialize the local registry - internal_registry = EntityRegistry() - - # Populate boundaries - known_frozen_hashes = set() - # pylint: disable=not-an-iterable - for boundary in self.boundaries: - known_frozen_hashes = internal_registry.fast_register(boundary, known_frozen_hashes) - - # Populate zones - # pylint: disable=not-an-iterable - known_frozen_hashes = set() - for zone in self.zones: - known_frozen_hashes = internal_registry.fast_register(zone, known_frozen_hashes) - - return internal_registry - - -class SurfaceMeshEntityInfo(EntityInfoModel): - """Data model for surface mesh entityInfo.json""" - - type_name: Literal["SurfaceMeshEntityInfo"] = pd.Field("SurfaceMeshEntityInfo", frozen=True) - boundaries: list[Surface] = pd.Field([]) - global_bounding_box: Optional[BoundingBoxType] = pd.Field(None) - - # pylint: disable=arguments-differ - def get_boundaries(self) -> list: - """ - Get the full list of boundary. - """ - return self.boundaries - - def update_persistent_entities(self, *, asset_entity_registry: EntityRegistry) -> None: - """ - Nothing related to SurfaceMeshEntityInfo for now. - """ - return - - def get_persistent_entity_registry(self, internal_registry, **_) -> EntityRegistry: - if internal_registry is None: - # Initialize the local registry - internal_registry = EntityRegistry() - known_frozen_hashes = set() - # Populate boundaries - # pylint: disable=not-an-iterable - for boundary in self.boundaries: - known_frozen_hashes = internal_registry.fast_register(boundary, known_frozen_hashes) - return internal_registry - return internal_registry - - -EntityInfoUnion = Annotated[ - Union[GeometryEntityInfo, VolumeMeshEntityInfo, SurfaceMeshEntityInfo], - pd.Field(discriminator="type_name"), -] - - -def parse_entity_info_model(data: dict) -> EntityInfoUnion: - """ - parse entity info data and return one of [GeometryEntityInfo, VolumeMeshEntityInfo, SurfaceMeshEntityInfo] - - # TODO: Add a fast mode by popping entities that are not needed due to wrong grouping tags before deserialization. - """ - with DeserializationContext(): - return pd.TypeAdapter(EntityInfoUnion).validate_python(data) - - -def merge_geometry_entity_info( - current_entity_info: GeometryEntityInfo, - entity_info_components: List[GeometryEntityInfo], -) -> GeometryEntityInfo: - """ - Update a GeometryEntityInfo by including/merging data from a list of other GeometryEntityInfo objects. - - Args: - current_entity_info: Used as reference to preserve user settings such as group tags, - mesh_exterior, attribute name order. - entity_info_components: List of GeometryEntityInfo objects that contain all data for the new entity info - - Returns: - A new GeometryEntityInfo with merged data from entity_info_components, preserving user settings from current - - The merge logic: - 1. IDs: Union of body_ids, face_ids, edge_ids from entity_info_components - 2. Attribute names: Intersection of attribute_names from entity_info_components - 3. Group tags: Use tags from current_entity_info - 4. Bounding box: Merge global bounding boxes from entity_info_components - 5. Grouped entities: Merge from entity_info_components, - preserving mesh_exterior from current_entity_info for grouped_bodies - 6. Draft and Ghost entities: Preserve from current_entity_info. - """ - # pylint: disable=too-many-locals, too-many-statements - - if not entity_info_components: - raise ValueError("entity_info_components cannot be empty") - - # 1. Compute union of IDs from entity_info_components - all_body_ids = set() - all_face_ids = set() - all_edge_ids = set() - all_bodies_face_edge_ids = {} - - for entity_info in entity_info_components: - all_body_ids.update(entity_info.body_ids) - all_face_ids.update(entity_info.face_ids) - all_edge_ids.update(entity_info.edge_ids) - all_bodies_face_edge_ids.update(entity_info.bodies_face_edge_ids or {}) - - # 2. Compute intersection of attribute names from entity_info_components - body_attr_sets = [set(ei.body_attribute_names) for ei in entity_info_components] - face_attr_sets = [set(ei.face_attribute_names) for ei in entity_info_components] - # Ignore the Geometry resource created from surface mesh that does not have any edge group - edge_attr_sets = [ - set(ei.edge_attribute_names) for ei in entity_info_components if ei.edge_attribute_names - ] - - body_attr_intersection = set.intersection(*body_attr_sets) if body_attr_sets else set() - face_attr_intersection = set.intersection(*face_attr_sets) if face_attr_sets else set() - edge_attr_intersection = set.intersection(*edge_attr_sets) if edge_attr_sets else set() - - # Preserve order from current_entity_info, but include all attributes from intersection - def ordered_intersection(reference_list: List[str], intersection_set: set) -> List[str]: - """Return all attributes from intersection_set, preserving order from reference_list where possible.""" - # First, add attributes that exist in reference_list (in order) - result = [attr for attr in reference_list if attr in intersection_set] - # Then, add remaining attributes from intersection_set that weren't in reference_list (sorted) - remaining = sorted(intersection_set - set(result)) - return result + remaining - - result_body_attribute_names = ordered_intersection( - current_entity_info.body_attribute_names, body_attr_intersection - ) - result_face_attribute_names = ordered_intersection( - current_entity_info.face_attribute_names, face_attr_intersection - ) - result_edge_attribute_names = ordered_intersection( - current_entity_info.edge_attribute_names, edge_attr_intersection - ) - - # 3. Update group tags: preserve from current if exists in intersection, otherwise use first - def select_tag( - current_tag: Optional[str], result_attrs: List[str], entity_type: str - ) -> Optional[str]: - if entity_type != "edge" and not result_attrs: - raise ValueError(f"No attribute names available to select {entity_type} group tag.") - log.info(f"Preserving {entity_type} group tag: {current_tag}") - return current_tag - - result_body_group_tag = select_tag( - current_entity_info.body_group_tag, result_body_attribute_names, "body" - ) - result_face_group_tag = select_tag( - current_entity_info.face_group_tag, result_face_attribute_names, "face" - ) - result_edge_group_tag = select_tag( - current_entity_info.edge_group_tag, result_edge_attribute_names, "edge" - ) - - # 4. Merge global bounding boxes from entity_info_components - result_bounding_box = None - for entity_info in entity_info_components: - if entity_info.global_bounding_box is not None: - if result_bounding_box is None: - result_bounding_box = entity_info.global_bounding_box - else: - result_bounding_box = result_bounding_box.expand(entity_info.global_bounding_box) - - # 5. Get current user settings from body group and face - def get_current_user_settings_map( - entity_info: GeometryEntityInfo, - entity_type: Literal["body", "face"], - ) -> Dict[str, Dict[str, Dict[str, Any]]]: - """ - Extract user settings (like mesh_exterior, name) from entity_info. - - Args: - entity_info: The GeometryEntityInfo to extract settings from - entity_type: Either "body" or "face" - - Returns: - A nested dictionary: {attribute_name: {entity_id: {setting_key: setting_value}}} - """ - user_settings_map = {} - - if entity_type == "body": - attribute_names = entity_info.body_attribute_names - grouped_entities = entity_info.grouped_bodies - settings_keys = ["mesh_exterior", "name"] - elif entity_type == "face": - attribute_names = entity_info.face_attribute_names - grouped_entities = entity_info.grouped_faces - settings_keys = ["name"] - else: - raise ValueError(f"Invalid entity_type: {entity_type}. Must be 'body' or 'face'.") - - for group_idx, group_name in enumerate(attribute_names): - user_settings_map[group_name] = {} - for entity in grouped_entities[group_idx]: - entity_id = entity.private_attribute_id - user_settings_map[group_name][entity_id] = { - key: getattr(entity, key) for key in settings_keys - } - - return user_settings_map - - current_body_user_settings_map = get_current_user_settings_map( - current_entity_info, entity_type="body" - ) - current_face_user_settings_map = get_current_user_settings_map( - current_entity_info, entity_type="face" - ) - - # 6. Merge grouped entities from entity_info_components - def apply_user_settings_to_entity( - entity: Union[GeometryBodyGroup, Surface, Edge], - attr_name: str, - user_settings_map: Optional[Dict[str, Dict[str, Dict[str, Any]]]], - ) -> Union[GeometryBodyGroup, Surface, Edge]: - """ - Apply user settings to an entity if available in the user_settings_map. - - Args: - entity: The entity to apply settings to - attr_name: The attribute name (group name) for this entity - user_settings_map: The user settings map from get_current_user_settings_map() - - Returns: - The entity with user settings applied, or the original entity if no settings found - """ - if user_settings_map is None: - return entity - - entity_id = entity.private_attribute_id - - # Check if we have user settings for this entity - if attr_name in user_settings_map and entity_id in user_settings_map[attr_name]: - # Create a copy with updated user settings - entity_data = entity.model_dump() - entity_data.update(user_settings_map[attr_name][entity_id]) - return entity.__class__.deserialize(entity_data) - - return entity - - def merge_grouped_entities( - entity_type: Literal["body", "face", "edge"], - result_attr_names: List[str], - ): - """Helper to merge grouped entities (bodies, faces, or edges) from entity_info_components""" - - # Determine which attributes to access based on entity type - def get_attrs(entity_info): - if entity_type == "body": - return entity_info.body_attribute_names - if entity_type == "face": - return entity_info.face_attribute_names - return entity_info.edge_attribute_names - - def get_groups(entity_info): - if entity_type == "body": - return entity_info.grouped_bodies - if entity_type == "face": - return entity_info.grouped_faces - return entity_info.grouped_edges - - result_grouped = [] - - # For each attribute name in the result intersection - for attr_name in result_attr_names: - # Dictionary to accumulate entities by their unique ID - entity_map = {} - - # Process all include entity infos - for entity_info in entity_info_components: - entity_attrs = get_attrs(entity_info) - if attr_name not in entity_attrs: - continue - idx = entity_attrs.index(attr_name) - entity_groups = get_groups(entity_info) - for entity in entity_groups[idx]: - # Use private_attribute_id as the unique identifier - entity_id = entity.private_attribute_id - if entity_id in entity_map: - continue - # Apply user settings if available - user_settings_map = ( - current_body_user_settings_map - if entity_type == "body" - else current_face_user_settings_map if entity_type == "face" else None - ) - entity_map[entity_id] = apply_user_settings_to_entity( - entity, attr_name, user_settings_map - ) - - # Convert map to list, maintaining a stable order (sorted by entity ID) - result_grouped.append(sorted(entity_map.values(), key=lambda e: e.private_attribute_id)) - - return result_grouped - - result_grouped_bodies = merge_grouped_entities("body", result_body_attribute_names) - result_grouped_faces = merge_grouped_entities("face", result_face_attribute_names) - result_grouped_edges = merge_grouped_entities("edge", result_edge_attribute_names) - - # Use default_geometry_accuracy from first include_entity_info - result_default_geometry_accuracy = entity_info_components[0].default_geometry_accuracy - - # Create the result GeometryEntityInfo - result = GeometryEntityInfo( - bodies_face_edge_ids=all_bodies_face_edge_ids if all_bodies_face_edge_ids else None, - body_ids=sorted(all_body_ids), - body_attribute_names=result_body_attribute_names, - grouped_bodies=result_grouped_bodies, - face_ids=sorted(all_face_ids), - face_attribute_names=result_face_attribute_names, - grouped_faces=result_grouped_faces, - edge_ids=sorted(all_edge_ids), - edge_attribute_names=result_edge_attribute_names, - grouped_edges=result_grouped_edges, - body_group_tag=result_body_group_tag, - face_group_tag=result_face_group_tag, - edge_group_tag=result_edge_group_tag, - global_bounding_box=result_bounding_box, - default_geometry_accuracy=result_default_geometry_accuracy, - draft_entities=current_entity_info.draft_entities, - ghost_entities=current_entity_info.ghost_entities, - ) - - return result diff --git a/flow360/component/simulation/entity_operation.py b/flow360/component/simulation/entity_operation.py index 0531c024f..ac4c587bc 100644 --- a/flow360/component/simulation/entity_operation.py +++ b/flow360/component/simulation/entity_operation.py @@ -1,380 +1,18 @@ -"""Operations that can be performed on entities.""" - -from typing import Literal, Optional, Tuple - -import numpy as np -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Angle, Length -from pydantic import PositiveFloat - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_utils import generate_uuid -from flow360.component.types import Axis -from flow360.exceptions import Flow360ValueError - - -def rotation_matrix_from_axis_and_angle(axis, angle): - """get rotation matrix from axis and angle of rotation""" - # Compute the components of the rotation matrix using Rodrigues' formula - cos_theta = np.cos(angle) - sin_theta = np.sin(angle) - one_minus_cos = 1 - cos_theta - - n_x, n_y, n_z = axis - - # Compute the skew-symmetric cross-product matrix of axis - cross_n = np.array([[0, -n_z, n_y], [n_z, 0, -n_x], [-n_y, n_x, 0]]) - - # Compute the rotation matrix - rotation_matrix = np.eye(3) + sin_theta * cross_n + one_minus_cos * np.dot(cross_n, cross_n) - - return rotation_matrix - - -def _build_transformation_matrix( - *, - origin: Length.Vector3, - axis_of_rotation: Axis, - angle_of_rotation: Angle.Float64, - scale: Tuple[PositiveFloat, PositiveFloat, PositiveFloat], - translation: Length.Vector3, -) -> np.ndarray: - """ - Derive a 3(row) x 4(column) transformation matrix and store as row major. - Applies to vector of [x, y, z, 1] in project length unit. - """ - - # pylint:disable=no-member - origin_array = np.asarray(origin.value) - translation_array = np.asarray(translation.value) - - axis = np.asarray(axis_of_rotation, dtype=np.float64) - angle = angle_of_rotation.to("rad").v.item() - - axis = axis / np.linalg.norm(axis) - - rotation_scale_matrix = rotation_matrix_from_axis_and_angle(axis, angle) * np.array(scale) - final_translation = -rotation_scale_matrix @ origin_array + origin_array + translation_array - - return np.hstack([rotation_scale_matrix, final_translation[:, np.newaxis]]) - - -def _resolve_transformation_matrix( # pylint:disable=too-many-arguments - *, - # pylint: disable=no-member - origin: Length.Vector3, - axis_of_rotation: Axis, - angle_of_rotation: Angle.Float64, - scale: Tuple[PositiveFloat, PositiveFloat, PositiveFloat], - translation: Length.Vector3, - private_attribute_matrix: Optional[list[float]] = None, -) -> np.ndarray: - """ - Return the local transformation matrix, honoring a precomputed matrix if provided. - """ - if private_attribute_matrix is not None: - matrix = np.asarray(private_attribute_matrix, dtype=np.float64) - matrix = matrix.reshape(3, 4) - return matrix - return _build_transformation_matrix( - origin=origin, - axis_of_rotation=axis_of_rotation, - angle_of_rotation=angle_of_rotation, - scale=scale, - translation=translation, - ) - - -def _compose_transformation_matrices(parent: np.ndarray, child: np.ndarray) -> np.ndarray: - """ - Compose two 3x4 transformation matrices (parent ∘ child). - """ - parent_rotation = parent[:, :3] - parent_translation = parent[:, 3] - - child_rotation = child[:, :3] - child_translation = child[:, 3] - - combined_rotation = parent_rotation @ child_rotation - combined_translation = parent_rotation @ child_translation + parent_translation - - return np.hstack([combined_rotation, combined_translation[:, np.newaxis]]) - - -def _transform_point(point: np.ndarray, matrix: np.ndarray) -> np.ndarray: - """ - Transform a 3D point using a 3x4 transformation matrix. - - Args: - point: 3D point as numpy array [x, y, z] - matrix: 3x4 transformation matrix - - Returns: - Transformed point as numpy array [x', y', z'] - """ - rotation_scale = matrix[:, :3] - translation = matrix[:, 3] - return rotation_scale @ point + translation - - -def _transform_direction(vector: np.ndarray, matrix: np.ndarray) -> np.ndarray: - """ - Transform a direction vector using only the rotation part of a transformation matrix. - Does not apply translation (directions are independent of position). - - Args: - vector: 3D direction vector as numpy array [x, y, z] - matrix: 3x4 transformation matrix - - Returns: - Transformed direction vector as numpy array [x', y', z'] - """ - rotation_scale = matrix[:, :3] - return rotation_scale @ vector - - -def _extract_scale_from_matrix(matrix: np.ndarray) -> np.ndarray: - """ - Extract scale factors from a 3x4 transformation matrix. - - Args: - matrix: 3x4 transformation matrix - - Returns: - Scale factors as numpy array [sx, sy, sz] - """ - rotation_scale = matrix[:, :3] - # Scale factors are the norms of the column vectors - return np.linalg.norm(rotation_scale, axis=0) - - -def _is_uniform_scale(matrix: np.ndarray, rtol: float = 1e-5) -> bool: - """ - Check if a transformation matrix represents uniform scaling. - - Args: - matrix: 3x4 transformation matrix - rtol: Relative tolerance for comparison - - Returns: - True if the matrix has uniform scaling (sx = sy = sz), False otherwise - """ - scale_factors = _extract_scale_from_matrix(matrix) - return np.allclose(scale_factors, scale_factors[0], rtol=rtol) - - -def _validate_uniform_scale_and_transform_center( - matrix: np.ndarray, center, entity_name: str -) -> tuple: - """ - Common transformation logic for volume primitives that require uniform scaling. - - Validates that the transformation matrix has uniform scaling, extracts the scale factor, - and transforms the center point. - - Args: - matrix: 3x4 transformation matrix - center: The center point (LengthType.Point) to transform - entity_name: Name of the entity type (e.g., "Sphere", "Cylinder") for error messages - - Returns: - Tuple of (new_center, uniform_scale) where: - - new_center: Transformed center point with same type and units as input - - uniform_scale: The uniform scale factor extracted from the matrix - - Raises: - Flow360ValueError: If the matrix has non-uniform scaling - """ - # Validate uniform scaling - if not _is_uniform_scale(matrix): - scale_factors = _extract_scale_from_matrix(matrix) - raise Flow360ValueError( - f"{entity_name} only supports uniform scaling. " - f"Detected scale factors: {scale_factors}" - ) - - # Extract uniform scale factor - uniform_scale = _extract_scale_from_matrix(matrix)[0] - - # Transform center - center_array = np.asarray(center.value) - new_center_array = _transform_point(center_array, matrix) - new_center = type(center)(new_center_array, center.units) - - return new_center, uniform_scale - - -def _extract_rotation_matrix(matrix: np.ndarray) -> np.ndarray: - """ - Extract the pure rotation matrix from a 3x4 transformation matrix, - removing any scaling component. - - Args: - matrix: 3x4 transformation matrix - - Returns: - Pure 3x3 rotation matrix (orthonormal) - """ - rotation_scale = matrix[:, :3] - scale_factors = _extract_scale_from_matrix(matrix) - - # Divide each column by its scale factor to remove scaling - rotation_matrix = rotation_scale / scale_factors - return rotation_matrix - - -def _rotation_matrix_to_axis_angle(rotation_matrix: np.ndarray) -> Tuple[np.ndarray, float]: - """ - Extract axis-angle representation from a 3x3 rotation matrix. - This is the inverse operation of rotation_matrix_from_axis_and_angle. - - Args: - rotation_matrix: 3x3 rotation matrix - - Returns: - Tuple of (axis, angle) where: - - axis: 3D unit vector as numpy array [x, y, z] - - angle: rotation angle in radians - """ - # Check for identity matrix (no rotation) - if np.allclose(rotation_matrix, np.eye(3)): - return np.array([1.0, 0.0, 0.0]), 0.0 - - # Compute the rotation angle from the trace - trace = np.trace(rotation_matrix) - angle = np.arccos(np.clip((trace - 1) / 2, -1.0, 1.0)) - - # Check for 180-degree rotation (special case) - if np.abs(angle - np.pi) < 1e-10: - # For 180-degree rotation, the axis is the eigenvector with eigenvalue 1 - # Find the column with the largest diagonal element - diag = np.diag(rotation_matrix) - k = np.argmax(diag) - - # Extract axis from the matrix - axis = np.zeros(3) - axis[k] = np.sqrt((rotation_matrix[k, k] + 1) / 2) - - for i in range(3): - if i != k: - axis[i] = rotation_matrix[k, i] / (2 * axis[k]) - - axis = axis / np.linalg.norm(axis) - return axis, angle - - # General case: extract axis from skew-symmetric part - axis = np.array( - [ - rotation_matrix[2, 1] - rotation_matrix[1, 2], - rotation_matrix[0, 2] - rotation_matrix[2, 0], - rotation_matrix[1, 0] - rotation_matrix[0, 1], - ] - ) - - axis = axis / np.linalg.norm(axis) - return axis, angle - - -class Transformation(Flow360BaseModel): - """[Deprecating] Transformation that will be applied to a body group.""" - - type_name: Literal["BodyGroupTransformation"] = pd.Field("BodyGroupTransformation", frozen=True) - - origin: Length.Vector3 = pd.Field( # pylint:disable=no-member - (0, 0, 0) * u.m, # pylint:disable=no-member - description="The origin for geometry transformation in the order of scale," - " rotation and translation.", - ) - - axis_of_rotation: Axis = pd.Field((1, 0, 0)) - angle_of_rotation: Angle.Float64 = pd.Field(0 * u.deg) # pylint:disable=no-member - - scale: Tuple[PositiveFloat, PositiveFloat, PositiveFloat] = pd.Field((1, 1, 1)) - - translation: Length.Vector3 = pd.Field((0, 0, 0) * u.m) # pylint:disable=no-member - - private_attribute_matrix: Optional[list[float]] = pd.Field(None) - - def get_transformation_matrix(self) -> np.ndarray: - """ - Find 3(row)x4(column) transformation matrix and store as row major. - Applies to vector of [x, y, z, 1] in project length unit. - """ - - return _resolve_transformation_matrix( - origin=self.origin, - axis_of_rotation=self.axis_of_rotation, - angle_of_rotation=self.angle_of_rotation, - scale=self.scale, - translation=self.translation, - ) - - -class CoordinateSystem(Flow360BaseModel): - """ - Coordinate system using geometric transformation primitives. - - The transformation is applied in the following order: - - 1. **Scale**: Apply scaling factors (sx, sy, sz) about the reference_point - 2. **Rotate**: Rotate by angle_of_rotation about axis_of_rotation through the reference_point - 3. **Translate**: Apply translation vector to the result - - Mathematically, for a point P, the transformation is: - P' = R * S * (P - reference_point) + reference_point + translation - - where: - - S is the scaling matrix with diagonal (sx, sy, sz) - - R is the rotation matrix derived from axis_of_rotation and angle_of_rotation - - reference_point is the origin for scale and rotation operations - - translation is the final displacement vector - - Examples - -------- - Create a coordinate system that scales by 2x, rotates 90° about Z-axis, then translates: - - >>> import flow360 as fl - >>> cs = fl.CoordinateSystem( - ... name="my_frame", - ... reference_point=(0, 0, 0) * fl.u.m, - ... axis_of_rotation=(0, 0, 1), - ... angle_of_rotation=90 * fl.u.deg, - ... scale=(2, 2, 2), - ... translation=(1, 0, 0) * fl.u.m - ... ) - """ - - type_name: Literal["CoordinateSystem"] = pd.Field("CoordinateSystem", frozen=True) - - name: str = pd.Field(description="Name of the coordinate system.") - reference_point: Length.Vector3 = pd.Field( # pylint:disable=no-member - (0, 0, 0) * u.m, # pylint:disable=no-member - description="Reference point about which scaling and rotation are performed. " - "Translation is applied after scale and rotation.", - ) - - axis_of_rotation: Axis = pd.Field((1, 0, 0)) - angle_of_rotation: Angle.Float64 = pd.Field(0 * u.deg) # pylint:disable=no-member - - scale: Tuple[PositiveFloat, PositiveFloat, PositiveFloat] = pd.Field((1, 1, 1)) - - translation: Length.Vector3 = pd.Field((0, 0, 0) * u.m) # pylint:disable=no-member - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - def _get_local_matrix(self) -> np.ndarray: - """Local transformation without applying inheritance.""" - return _resolve_transformation_matrix( - origin=self.reference_point, - axis_of_rotation=self.axis_of_rotation, - angle_of_rotation=self.angle_of_rotation, - scale=self.scale, - translation=self.translation, - ) - - def get_transformation_matrix(self) -> np.ndarray: - """ - Find 3(row)x4(column) transformation matrix and store as row major. - Applies to vector of [x, y, z, 1] in project length unit. - """ - return self._get_local_matrix() +"""Re-import relay: entity operations now live in flow360-schema.""" + +# pylint: disable=unused-import +from flow360_schema.framework.entity.entity_operation import ( # noqa: F401 + CoordinateSystem, + Transformation, + _build_transformation_matrix, + _compose_transformation_matrices, + _extract_rotation_matrix, + _extract_scale_from_matrix, + _is_uniform_scale, + _resolve_transformation_matrix, + _rotation_matrix_to_axis_angle, + _transform_direction, + _transform_point, + _validate_uniform_scale_and_transform_center, + rotation_matrix_from_axis_and_angle, +) diff --git a/flow360/component/simulation/framework/boundary_split.py b/flow360/component/simulation/framework/boundary_split.py index 17d1b0a44..189953bc6 100644 --- a/flow360/component/simulation/framework/boundary_split.py +++ b/flow360/component/simulation/framework/boundary_split.py @@ -36,7 +36,6 @@ Surface, _SurfaceEntityBase, ) -from flow360.component.simulation.utils import model_attribute_unlock from flow360.log import log if TYPE_CHECKING: @@ -443,8 +442,9 @@ def _replace_with_actual_entities( def _set_entity_full_name(entity: _SurfaceEntityBase, full_name: str) -> None: """Set the full_name on an entity.""" - with model_attribute_unlock(entity, "private_attribute_full_name"): - entity.private_attribute_full_name = full_name + entity._force_set_attr( # pylint:disable=protected-access + "private_attribute_full_name", full_name + ) def _create_entity_parts( original: _SurfaceEntityBase, diff --git a/flow360/component/simulation/framework/entity_base.py b/flow360/component/simulation/framework/entity_base.py index d087abca3..0f3cfa2e6 100644 --- a/flow360/component/simulation/framework/entity_base.py +++ b/flow360/component/simulation/framework/entity_base.py @@ -1,450 +1,10 @@ -"""Base classes for entity types.""" - -from __future__ import annotations - -import hashlib -from abc import ABCMeta -from typing import Annotated, Any, List, Optional, Union, get_args, get_origin - -import pydantic as pd - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_selector import EntitySelector -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - contextual_model_validator, +"""Re-import relay: entity base classes now live in flow360-schema.""" + +# pylint: disable=unused-import +# Re-import relay: all entity classes now live in flow360-schema +from flow360_schema.framework.entity.entity_base import EntityBase # noqa: F401 +from flow360_schema.framework.entity.entity_list import ( # noqa: F401 + EntityList, + _CombinedMeta, + _EntityListMeta, ) -from flow360.log import log - - -class EntityBase(Flow360BaseModel, metaclass=ABCMeta): - """ - Base class for dynamic entity types. - - Attributes: - private_attribute_entity_type_name (str): - A string representing the specific type of the entity. - This should be set in subclasses to differentiate between entity types. - - name (str): - The name of the entity instance, used for identification and retrieval. - """ - - private_attribute_entity_type_name: str = "Invalid" - private_attribute_id: Optional[str] = pd.Field( - # pylint: disable=fixme - # TODO: This should not have default value. Everyone is supposed to set it. - None, - frozen=True, - description="Unique identifier for the entity. Used by front end to track entities and enable auto update etc.", - ) - - name: str = pd.Field(frozen=True) - - # Whether the entity is dirty and needs to be re-hashed - _dirty: bool = pd.PrivateAttr(True) - # Cached hash of the entity - _hash_cache: str = pd.PrivateAttr(None) - - def __init_subclass__(cls, **kwargs): # type: ignore[override] - """Validate required class-level attributes at subclass creation time. - - This avoids per-instance checks and catches misconfigured subclasses early. - - Rules: - - If a subclass explicitly defines `private_attribute_entity_type_name` in its own - class body, it must also be non-"Invalid". Intermediate abstract bases that do not - set an entity type are allowed. - """ - super().__init_subclass__(**kwargs) - if cls is EntityBase: - return - - # Only enforce entity type when the subclass explicitly sets it. - if "private_attribute_entity_type_name" in cls.__dict__: - # entity_type remains a Pydantic field - def _resolve_field_default(field_name: str): - for base in cls.__mro__: - if field_name in getattr(base, "__dict__", {}): - raw_value = base.__dict__[field_name] - return getattr(raw_value, "default", raw_value) - model_fields = getattr(cls, "model_fields", None) - if isinstance(model_fields, dict) and field_name in model_fields: - field_info = model_fields[field_name] - return getattr(field_info, "default", None) - return None - - type_value = _resolve_field_default("private_attribute_entity_type_name") - if type_value is None or type_value == "Invalid": - raise NotImplementedError( - f"private_attribute_entity_type_name is not defined in the entity class: {cls.__name__}." - ) - - def copy(self, update: dict, **kwargs) -> EntityBase: # pylint:disable=signature-differs - """ - Creates a copy of the entity with compulsory updates. - - Parameters: - update: A dictionary containing the updated attributes to apply to the copied entity. - **kwargs: Additional arguments to pass to the copy constructor. - - Returns: - A copy of the entity with the specified updates. - """ - if "name" not in update or update["name"] == self.name: - raise ValueError( - "Copying an entity requires a new name to be specified. " - "Please provide a new name in the update dictionary." - ) - return super().copy(update=update, **kwargs) - - def __eq__(self, other): - """Defines the equality comparison for entities to support usage in UniqueItemList.""" - if isinstance(other, EntityBase): - return (self.name + "-" + self.__class__.__name__) == ( - other.name + "-" + other.__class__.__name__ - ) - return False - - @property - def entity_type(self) -> str: - """returns the entity class name.""" - return self.private_attribute_entity_type_name - - @entity_type.setter - def entity_type(self, value: str): - raise AttributeError("Cannot modify the name of entity class.") - - def __str__(self) -> str: - return "\n".join([f" {attr}: {value}" for attr, value in self.__dict__.items()]) - - def _recompute_hash(self): - new_hash = hashlib.sha256(self.model_dump_json().encode("utf-8")).hexdigest() - # Can further speed up 10% by using `object.__setattr__` - self._hash_cache = new_hash - self._dirty = False - return new_hash - - def _get_hash(self): - """hash generator to identify if two entities are the same""" - # Can further speed up 10% by using `object.__getattribute__` - dirty = self._dirty - cache = self._hash_cache - if dirty or cache is None: - return self._recompute_hash() - return cache - - def __setattr__(self, name, value): - """ - [Large model performance] - Wrapping the __setattr__ to mark the entity as dirty when the attribute is not private - This enables caching the hash of the entity to avoid re-calculating the hash when the entity is not changed. - """ - - super().__setattr__(name, value) - if not name.startswith("_") and not self._dirty: - # Not using self to avoid invoking - # Can further speed up 10% by using `object.__setattr__` - self._dirty = True - - @property - def id(self) -> str: - """Returns private_attribute_id of the entity.""" - return self.private_attribute_id - - def _manual_assignment_validation(self, _: ParamsValidationInfo) -> EntityBase: - """ - Pre-expansion contextual validation for the entity. - This handles validation for the entity manually assigned. - """ - return self - - def _per_entity_type_validation(self, _: ParamsValidationInfo) -> EntityBase: - """Contextual validation with validation logic bond with the specific entity type.""" - return self - - -class _CombinedMeta(type(Flow360BaseModel), type): - pass - - -class _EntityListMeta(_CombinedMeta): - def __getitem__(cls, entity_types): - """ - Creates a new class with the specified entity types as a list of stored entities. - """ - if not isinstance(entity_types, tuple): - entity_types = (entity_types,) - union_type = Annotated[ - Union[entity_types], pd.Field(discriminator="private_attribute_entity_type_name") - ] - annotations = { - "stored_entities": List[union_type] - } # Make sure it is somewhat consistent with the EntityList class - new_cls = type( - f"{cls.__name__}[{','.join([t.__name__ for t in entity_types])}]", - (cls,), - {"__annotations__": annotations}, - ) - # Note: - # Printing the stored_entities's discriminator will be None but - # that FieldInfo->discriminator seems to be just for show. - # It seems Pydantic use the discriminator inside the annotation - # instead so the above should trigger the discrimination during deserialization. - return new_cls - - -class EntityList(Flow360BaseModel, metaclass=_EntityListMeta): - """ - The type accepting a list of entities or selectors. - - Attributes: - stored_entities (List[Union[EntityBase, Tuple[str, registry]]]): List of stored entities, which can be - instances of `Box`, `Cylinder`, or strings representing naming patterns. - """ - - stored_entities: List = pd.Field( - description="List of manually picked entities in addition to the ones selected by selectors." - ) - selectors: Optional[List[EntitySelector]] = pd.Field( - None, description="Selectors on persistent entities for rule-based selection." - ) - - @pd.field_validator("stored_entities", mode="before") - @classmethod - def _filter_entities_by_valid_types(cls, value): - """ - Centralized entity type filtering. - - This validator runs before discriminator validation and filters out entities - whose types are not in the EntityList's valid types. This centralizes filtering - logic that was previously split between the deserializer and selector expansion. - """ - if not value: - return value - - # Extract valid entity type names from class annotation - try: - valid_types = cls._get_valid_entity_types() - valid_type_names = set() - for valid_type in valid_types: - field = valid_type.model_fields.get("private_attribute_entity_type_name") - if field and field.default: - valid_type_names.add(field.default) - except (TypeError, AttributeError, KeyError): - # If we can't extract valid types, skip filtering - return value - - if not valid_type_names: - return value - - # Filter entities to only include valid types - filtered_entities = [] - entity_count = 0 - for entity in value: - if not isinstance(entity, EntityBase): - # Not an entity object, keep it (might be dict for deserialization) - filtered_entities.append(entity) - continue - - entity_count += 1 - entity_type_name = getattr(entity, "private_attribute_entity_type_name", None) - if entity_type_name in valid_type_names: - filtered_entities.append(entity) - else: - log.debug( - "Entity '%s' (type=%s) filtered out: not in EntityList valid types %s", - getattr(entity, "name", ""), - entity_type_name, - valid_type_names, - ) - - # If all entity objects were filtered out, raise an error - if entity_count > 0 and not any(isinstance(e, EntityBase) for e in filtered_entities): - valid_type_name_list = [vt.__name__ for vt in valid_types] - raise ValueError( - f"Can not find any valid entity of type {valid_type_name_list} from the input." - ) - - return filtered_entities - - @contextual_model_validator(mode="after") - def _ensure_entities_after_expansion(self, param_info: ParamsValidationInfo): - """ - Ensure entity selections yielded at least one entity once selectors are expanded. - - With delayed selector expansion, stored_entities may be empty if only selectors - are defined. - """ - is_empty = True - # If stored_entities already has entities (user manual assignment), validation passes - manual_assignments: List[EntityBase] = self.stored_entities - # pylint: disable=protected-access - if manual_assignments: - filtered_assignments = [ - item - for item in manual_assignments - if item._manual_assignment_validation(param_info) is not None - ] - # Use object.__setattr__ to bypass validate_on_assignment and avoid recursion - object.__setattr__( - self, - "stored_entities", - filtered_assignments, - ) - - for item in filtered_assignments: - item._per_entity_type_validation(param_info) - - if filtered_assignments: - is_empty = False - - # No stored_entities - check if selectors will produce any entities - if self.selectors: - expanded: List[EntityBase] = param_info.expand_entity_list(self) - if expanded: - for item in expanded: - item._per_entity_type_validation(param_info) - # Known non-empty - return self - - # Neither stored_entities nor selectors produced any entities - if is_empty: - raise ValueError("No entities were selected.") - return self - - @classmethod - def _get_valid_entity_types(cls): - entity_field_type = cls.__annotations__.get("stored_entities") - - if entity_field_type is None: - raise TypeError("Internal error, the metaclass for EntityList is not properly set.") - - # Handle List[...] - if get_origin(entity_field_type) in (list, List): - inner_type = get_args(entity_field_type)[0] # Extract the inner type - else: - # Not a List, handle other cases or raise an error - raise TypeError("Expected 'stored_entities' to be a List.") - - # Handle Annotated[...] - if get_origin(inner_type) is Annotated: - annotated_args = get_args(inner_type) - if len(annotated_args) > 0: - actual_type = annotated_args[0] # The actual type inside Annotated - else: - raise TypeError("Annotated type has no arguments.") - else: - actual_type = inner_type - - # Handle Union[...] - if get_origin(actual_type) is Union: - valid_types = [arg for arg in get_args(actual_type) if isinstance(arg, type)] - return valid_types - if isinstance(actual_type, type): - return [actual_type] - raise TypeError("Cannot extract valid entity types.") - - @classmethod - def _validate_selector(cls, selector: EntitySelector, valid_type_names: List[str]) -> dict: - """Process and validate an EntitySelector object.""" - if selector.target_class not in valid_type_names: - raise ValueError( - f"Selector target_class ({selector.target_class}) is incompatible " - f"with EntityList types {valid_type_names}." - ) - return selector - - @classmethod - def _validate_entity(cls, entity: Union[EntityBase, Any]) -> EntityBase: - """Process and validate an entity object.""" - if isinstance(entity, EntityBase): - return entity - - raise ValueError( - f"Type({type(entity)}) of input to `entities` ({entity}) is not valid. " - "Expected entity instance." - ) - - @classmethod - def _build_result( - cls, entities_to_store: List[EntityBase], entity_selectors_to_store: List[dict] - ) -> dict: - """Build the final result dictionary.""" - return { - "stored_entities": entities_to_store, - "selectors": entity_selectors_to_store if entity_selectors_to_store else None, - } - - @classmethod - # pylint: disable=too-many-arguments - def _process_single_item( - cls, - item: Union[EntityBase, EntitySelector], - valid_type_names: List[str], - entities_to_store: List[EntityBase], - entity_selectors_to_store: List[dict], - ) -> None: - """Process a single item (entity or selector) and add to appropriate storage lists.""" - if isinstance(item, EntitySelector): - entity_selectors_to_store.append(cls._validate_selector(item, valid_type_names)) - else: - processed_entity = cls._validate_entity(item) - entities_to_store.append(processed_entity) - - @pd.model_validator(mode="before") - @classmethod - def deserializer(cls, input_data: Union[dict, list, EntityBase, EntitySelector]): - """ - Flatten List[EntityBase] and put into stored_entities. - - Note: Type filtering is now handled by the _filter_entities_by_valid_types - field validator, which runs after deserialization but before discriminator validation. - """ - entities_to_store = [] - entity_selectors_to_store = [] - valid_types = tuple(cls._get_valid_entity_types()) - valid_type_names = [t.__name__ for t in valid_types] - - if isinstance(input_data, list): - # -- User input mode. -- - # List content might be entity Python objects or selector Python objects - if input_data == []: - raise ValueError("Invalid input type to `entities`, list is empty.") - for item in input_data: - if isinstance(item, list): # Nested list comes from assets __getitem__ - # Process all entities without filtering - processed_entities = [cls._validate_entity(individual) for individual in item] - entities_to_store.extend(processed_entities) - else: - # Single entity or selector - cls._process_single_item( - item, - valid_type_names, - entities_to_store, - entity_selectors_to_store, - ) - elif isinstance(input_data, dict): # Deserialization - # With delayed selector expansion, stored_entities may be absent if only selectors are defined. - # We allow empty stored_entities + empty/None selectors here - the model_validator - # (_ensure_entities_after_expansion) will raise a proper error if no entities are selected. - stored_entities = input_data.get("stored_entities", []) - selectors = input_data.get("selectors", []) - return cls._build_result(stored_entities, selectors) - else: # Single entity or selector - if input_data is None: - return cls._build_result(None, []) - cls._process_single_item( - input_data, - valid_type_names, - entities_to_store, - entity_selectors_to_store, - ) - - if not entities_to_store and not entity_selectors_to_store: - raise ValueError( - f"Can not find any valid entity of type {[valid_type.__name__ for valid_type in valid_types]}" - f" from the input." - ) - - return cls._build_result(entities_to_store, entity_selectors_to_store) diff --git a/flow360/component/simulation/framework/entity_expansion_utils.py b/flow360/component/simulation/framework/entity_expansion_utils.py index 0bf7590fa..34d3675b8 100644 --- a/flow360/component/simulation/framework/entity_expansion_utils.py +++ b/flow360/component/simulation/framework/entity_expansion_utils.py @@ -1,10 +1,15 @@ """Entity list expansion helpers shared across results and user utilities.""" +# pylint: disable=unused-import from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union import pydantic as pd +from flow360_schema.framework.entity.entity_expansion_utils import ( # noqa: F401 + _register_mirror_entities_in_registry, + get_entity_info_and_registry_from_dict, +) from flow360.component.simulation.framework.entity_materializer import ( materialize_entities_and_selectors_in_place, @@ -18,38 +23,6 @@ from flow360.component.simulation.framework.entity_registry import EntityRegistry -def _register_mirror_entities_in_registry(registry: "EntityRegistry", mirror_status: Any) -> None: - """Register mirror-related entities (planes + derived mirrored entities) into registry. - - This helper is shared by both dict-based and params-based registry builders to ensure - consistent selector expansion coverage. - """ - if not mirror_status: - return - - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.draft_context.mirror import ( - MirrorPlane, - MirrorStatus, - ) - - # Dict path: deserialize to MirrorStatus - if isinstance(mirror_status, dict): - mirror_status = MirrorStatus.deserialize(mirror_status) - - # Object path: MirrorStatus (or compatible) with is_empty() - if hasattr(mirror_status, "is_empty") and mirror_status.is_empty(): - return - - for plane in getattr(mirror_status, "mirror_planes", []) or []: - if isinstance(plane, MirrorPlane): - registry.register(plane) - for mirrored_group in getattr(mirror_status, "mirrored_geometry_body_groups", []) or []: - registry.register(mirrored_group) - for mirrored_surface in getattr(mirror_status, "mirrored_surfaces", []) or []: - registry.register(mirrored_surface) - - def expand_entity_list_in_context( entity_list, params, @@ -207,45 +180,3 @@ def _process_entity_list(obj): return True # Continue traversing other objects walk_object_tree_with_cycle_detection(params, _process_entity_list, check_dict=True) - - -def get_entity_info_and_registry_from_dict(params_as_dict: dict) -> tuple: - """ - Create EntityInfo and EntityRegistry from simulation params dictionary. - - The EntityInfo owns the entities, and EntityRegistry holds references to them. - Callers must keep entity_info alive as long as registry is used. - - Parameters - ---------- - params_as_dict : dict - Simulation parameters as dictionary containing private_attribute_asset_cache. - - Returns - ------- - tuple[EntityInfo, EntityRegistry] - (entity_info, registry) where entity_info owns entities and registry references them. - """ - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.framework.entity_registry import EntityRegistry - - asset_cache = params_as_dict.get("private_attribute_asset_cache") - if asset_cache is None: - raise ValueError("[Internal] private_attribute_asset_cache not found in params_as_dict.") - - entity_info_dict = asset_cache.get("project_entity_info") - if entity_info_dict is None: - raise ValueError("[Internal] project_entity_info not found in asset cache.") - - # Deserialize entity_info dict to the appropriate EntityInfo class - from flow360.component.simulation.entity_info import parse_entity_info_model - - entity_info = parse_entity_info_model(entity_info_dict) - registry = EntityRegistry.from_entity_info(entity_info) - - # Register mirror entities from mirror_status so selector expansion can include mirrored types - # (e.g. SurfaceSelector can expand to include MirroredSurface) during validation. - mirror_status_dict = asset_cache.get("mirror_status") - _register_mirror_entities_in_registry(registry, mirror_status_dict) - - return entity_info, registry diff --git a/flow360/component/simulation/framework/entity_materialization_context.py b/flow360/component/simulation/framework/entity_materialization_context.py index 0a632ae7a..bfb9107fc 100644 --- a/flow360/component/simulation/framework/entity_materialization_context.py +++ b/flow360/component/simulation/framework/entity_materialization_context.py @@ -1,84 +1,9 @@ -"""Scoped context for entity materialization and reuse. - -This module provides a context-managed cache and an injectable builder -for converting entity dictionaries to model instances, avoiding global -state and enabling high-performance reuse during validation. -""" - -from __future__ import annotations - -import contextvars -from typing import TYPE_CHECKING, Any, Callable, Optional - -if TYPE_CHECKING: - from flow360.component.simulation.framework.entity_registry import EntityRegistry - -_entity_cache_ctx: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar( - "entity_cache", default=None -) -_entity_builder_ctx: contextvars.ContextVar[Optional[Callable[[dict], Any]]] = ( - contextvars.ContextVar("entity_builder", default=None) +"""Re-import relay: entity_materialization_context moved to flow360_schema.""" + +# pylint: disable=unused-import +from flow360_schema.framework.entity.entity_materialization_context import ( # noqa: F401 + EntityMaterializationContext, + get_entity_builder, + get_entity_cache, + get_entity_registry, ) -_entity_registry_ctx: contextvars.ContextVar[Optional[EntityRegistry]] = contextvars.ContextVar( - "entity_registry", default=None -) - - -class EntityMaterializationContext: - """Context manager providing a per-validation scoped cache and builder. - - Use this to avoid global state when materializing entity dictionaries - into model instances while reusing objects across the validation pass. - - Parameters - ---------- - builder : Callable[[dict], Any] - Function to convert entity dict to instance when not found in cache. - entity_registry : Optional[EntityRegistry] - Pre-existing EntityRegistry containing canonical entity instances. - When provided, entities are looked up by (type_name, private_attribute_id) - and must exist in the registry (errors if not found). - """ - - def __init__( - self, - *, - builder: Callable[[dict], Any], - entity_registry: Optional[EntityRegistry] = None, - ): - self._token_cache = None - self._token_builder = None - self._supplied_entity_registry = None - self._builder = builder - self._entity_registry = entity_registry - - def __enter__(self): - # Set up context variables - initial_cache = {} - self._token_cache = _entity_cache_ctx.set(initial_cache) - self._token_builder = _entity_builder_ctx.set(self._builder) - self._supplied_entity_registry = _entity_registry_ctx.set(self._entity_registry) - return self - - def __exit__(self, exc_type, exc, tb): - _entity_cache_ctx.reset(self._token_cache) - _entity_builder_ctx.reset(self._token_builder) - _entity_registry_ctx.reset(self._supplied_entity_registry) - - -def get_entity_cache() -> Optional[dict]: - """Return the current cache dict for entity reuse, or None if not active.""" - - return _entity_cache_ctx.get() - - -def get_entity_builder() -> Optional[Callable[[dict], Any]]: - """Return the current dict->entity builder, or None if not active.""" - - return _entity_builder_ctx.get() - - -def get_entity_registry() -> Optional[EntityRegistry]: - """Return the current EntityRegistry for entity lookup, or None if not active.""" - - return _entity_registry_ctx.get() diff --git a/flow360/component/simulation/framework/entity_materializer.py b/flow360/component/simulation/framework/entity_materializer.py index b49c5e66e..5bb35e846 100644 --- a/flow360/component/simulation/framework/entity_materializer.py +++ b/flow360/component/simulation/framework/entity_materializer.py @@ -1,30 +1,23 @@ -"""Entity materialization utilities. +"""Re-import relay: entity_materializer moved to flow360_schema. -Provides mapping from entity type names to classes, stable keys, and an -in-place materialization routine to convert entity dictionaries to shared -Pydantic model instances and perform per-list deduplication. +This module retains ENTITY_TYPE_MAP and _build_entity_instance because they +depend on concrete entity classes that live in the client package. +materialize_entities_and_selectors_in_place is re-exported with a default +entity_builder so that existing callers need no changes. """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Optional import pydantic as pd +from flow360_schema.framework.entity.entity_materializer import ( + materialize_entities_and_selectors_in_place as _materialize_entities_and_selectors_in_place, +) +from flow360_schema.framework.entity.entity_utils import DEFAULT_NOT_MERGED_TYPES from flow360_schema.framework.validation.context import DeserializationContext from flow360.component.simulation.draft_context.mirror import MirrorPlane -from flow360.component.simulation.framework.entity_materialization_context import ( - EntityMaterializationContext, - get_entity_builder, - get_entity_cache, - get_entity_registry, -) -from flow360.component.simulation.framework.entity_selector import EntitySelector -from flow360.component.simulation.framework.entity_utils import ( - DEFAULT_NOT_MERGED_TYPES, - deduplicate_entities, - get_entity_key, -) from flow360.component.simulation.outputs.output_entities import ( Point, PointArray, @@ -92,316 +85,16 @@ def _build_entity_instance(entity_dict: dict): return pd.TypeAdapter(cls).validate_python(entity_dict) -def _build_registry_index(registry: EntityRegistry) -> dict[tuple[str, str], Any]: - """Build O(1) lookup index from EntityRegistry. - - Pre-builds a dict mapping (type_name, entity_id) -> entity for fast lookups. - This converts O(n) registry.find_by_asset_id() to O(1) dict lookup. - - Parameters - ---------- - registry : EntityRegistry - Registry to index. - - Returns - ------- - dict[tuple[str, str], Any] - Index mapping (type_name, entity_id) to entity instances. - """ - index = {} - for entity_type, entities in registry.internal_registry.items(): - type_name = entity_type.__name__ - for entity in entities: - entity_id = getattr(entity, "private_attribute_id", None) - if entity_id: - index[(type_name, entity_id)] = entity - return index - - -def _lookup_entity_from_registry_cache(entity_dict: dict, cache: dict) -> Any: - """Lookup entity from registry cache (Mode 2). - - Assumes cache has been pre-populated with registry index. - - Parameters - ---------- - entity_dict : dict - Entity dictionary with type_name and private_attribute_id. - cache : dict - Pre-populated cache with registry entities. - - Returns - ------- - Any - Entity instance from cache. - - Raises - ------ - ValueError - If entity missing ID, unknown type, or not found in cache. - """ - type_name = entity_dict.get("private_attribute_entity_type_name") - entity_id = entity_dict.get("private_attribute_id") - - if not entity_id: - raise ValueError( - f"[EntityMaterializer] Entity missing 'private_attribute_id' " - f"when EntityRegistry is provided. Entity type: {type_name}, " - f"name: {entity_dict.get('name', '')}" - ) - - # O(1) lookup from pre-populated cache - key = (type_name, entity_id) - obj = cache.get(key) - - if obj is None: - raise ValueError( - f"[EntityMaterializer] Entity not found in EntityRegistry. " - f"Type: {type_name}, ID: {entity_id}, " - f"name: {entity_dict.get('name', '')}" - ) - - return obj - - -def _build_entity_from_dict(entity_dict: dict, cache: Optional[dict], builder: Callable) -> Any: - """Build entity from dict with caching (Mode 1). - - Parameters - ---------- - entity_dict : dict - Entity dictionary to build from. - cache : Optional[dict] - Cache for reuse within params. - builder : Callable - Function to build entity instance. - - Returns - ------- - Any - Entity instance. - """ - key = get_entity_key(entity_dict) - obj = cache.get(key) if (cache and key in cache) else builder(entity_dict) - if cache is not None and key not in cache: - cache[key] = obj - return obj - - -def _convert_entity_dict_to_object( - item: dict, - is_registry_mode: bool, - cache: Optional[dict], - builder: Optional[Callable], -) -> tuple[Any, tuple]: - """Convert entity dict to object instance. - - Parameters - ---------- - item : dict - Entity dictionary. - is_registry_mode : bool - True if using EntityRegistry mode (Mode 2). - cache : Optional[dict] - Cache for performance (pre-populated in Mode 2). Else a progressive built cache. - builder : Optional[Callable] - Builder for Mode 1. - - Returns - ------- - tuple[Any, tuple] - (entity_object, key) tuple. - """ - if is_registry_mode: - # Mode 2: Lookup from pre-populated cache (O(1)) - obj = _lookup_entity_from_registry_cache(item, cache) - type_name = item.get("private_attribute_entity_type_name") - entity_id = item.get("private_attribute_id") - key = (type_name, entity_id) - else: - # Mode 1: Create and cache within params - obj = _build_entity_from_dict(item, cache, builder) - key = get_entity_key(item) - - return obj, key - - -def _deserialize_used_selectors_and_build_lookup(params_as_dict: dict) -> Dict[str, EntitySelector]: - """Deserialize asset_cache.used_selectors in-place and build selector_id -> selector lookup.""" - asset_cache = params_as_dict.get("private_attribute_asset_cache") - if not isinstance(asset_cache, dict): - return {} - - raw_used_selectors = asset_cache.get("used_selectors") - if not isinstance(raw_used_selectors, list) or not raw_used_selectors: - return {} - - try: - selector_list = pd.TypeAdapter(List[EntitySelector]).validate_python(raw_used_selectors) - except pd.ValidationError as e: - # Prepend the correct path to error locations so they match SimulationParams structure - errors_with_path = [] - for err in e.errors(): - new_loc = ("private_attribute_asset_cache", "used_selectors") + tuple(err["loc"]) - errors_with_path.append({**err, "loc": new_loc}) - raise pd.ValidationError.from_exception_data( - title=e.title, - line_errors=errors_with_path, - ) from None - - selector_lookup = {selector.selector_id: selector for selector in selector_list} - - # Keep used_selectors as a list, but ensure it contains deserialized EntitySelector instances. - asset_cache["used_selectors"] = selector_list - return selector_lookup - - -def _materialize_stored_entities_list_in_node( - node: dict, - *, - is_registry_mode: bool, - cache: Optional[dict], - builder: Optional[Callable], - not_merged_types: set[str], -) -> None: - """Materialize node['stored_entities'] in-place if present.""" - stored_entities = node.get("stored_entities") - if not isinstance(stored_entities, list): - return - - def processor(item): - if isinstance(item, dict): - return _convert_entity_dict_to_object(item, is_registry_mode, cache, builder) - # Already materialized - return item, get_entity_key(item) - - node["stored_entities"] = deduplicate_entities( - stored_entities, - processor=processor, - not_merged_types=not_merged_types, - ) - - -def _materialize_selectors_list_in_node( - node: dict, selector_lookup: Dict[str, EntitySelector] -) -> None: - """Replace selector tokens in node['selectors'] with shared EntitySelector instances.""" - selectors = node.get("selectors") - if not isinstance(selectors, list) or not selectors: - return - - materialized_selectors: List[EntitySelector] = [] - for selector_item in selectors: - if isinstance(selector_item, str): - # ==== Selector token (str) ==== - selector_object = selector_lookup.get(selector_item) - if selector_object is None: - raise ValueError( - "[Internal] Selector token not found in " - "private_attribute_asset_cache.used_selectors: " - f"{selector_item}" - ) - materialized_selectors.append(selector_object) - elif isinstance(selector_item, dict): - # ==== Inline selector definition (dict, pre-submit JSON) ==== - # Cloud/Production JSON data will only contain selector tokens (str). - # Local pre-upload JSON (from model_dump) will contain inline selector definitions (dict). - # At local validation, `selector_lookup` is empty. - # Since it is presubmit, no need to "materialize", "deserialize" is fine. - try: - materialized_selectors.append(EntitySelector.deserialize(selector_item)) - except pd.ValidationError: - # Keep the invalid dict as-is, let SimulationParams.model_validate handle the error. - # This preserves the full error location path (e.g., "models.0.entities.selectors.0.children...") - # instead of a truncated path (e.g., "children..."). - materialized_selectors.append(selector_item) - elif isinstance(selector_item, EntitySelector): - # ==== Already materialized EntitySelector ==== - # When materialize_entities_and_selectors_in_place is called multiple times - # on the same params dict (e.g., repeated validation or upload after preprocessing), - # selectors may already be EntitySelector objects. Pass through unchanged. - materialized_selectors.append(selector_item) - else: - raise TypeError( - "[Internal] Unsupported selector item type in selectors list. " - "Expected selector tokens (str/dict) or EntitySelector instances. Got: " - f"{type(selector_item)}" - ) - node["selectors"] = materialized_selectors - - def materialize_entities_and_selectors_in_place( params_as_dict: dict, *, not_merged_types: set[str] = DEFAULT_NOT_MERGED_TYPES, entity_registry: Optional[EntityRegistry] = None, ) -> dict: - """ - From raw dict simulation params: - 1. Materialize `stored_entities` dicts to shared instances and dedupe per list in-place. - 2. Materialize `selectors` list to shared EntitySelector instances. - - Two operation modes: - - Mode 1 (entity_registry=None): Intra-params deduplication - - Converts dict entries to instances using a scoped cache for reuse - - Entities appearing multiple times within params share the same object - - Deduplicates within each stored_entities list - - Mode 2 (entity_registry provided): Registry reference mode - - ALL entities MUST already exist in the EntityRegistry - - Replaces entity dicts with references to registry instances - - Errors if an entity is not found in the registry - - No new entity instances are created - - When called by validate_model(), the entity_registry can be provided by ParamsValidationInfo. - BLOCKED: This require all entities to have private_attribute_id set - Which due to legacy reasons is not the case for all entities. - - Parameters - ---------- - params_as_dict : dict - The simulation params dictionary to materialize in-place. - not_merged_types : set[str] - Entity types to skip deduplication (e.g., Point). - entity_registry : Optional[EntityRegistry] - EntityRegistry containing canonical entity instances. - When provided, all entities must exist in registry (Mode 2). - """ - - selector_lookup = _deserialize_used_selectors_and_build_lookup(params_as_dict) - - with EntityMaterializationContext( - builder=_build_entity_instance, entity_registry=entity_registry - ): - # Pre-build registry index for O(1) lookups (Mode 2 only) - registry = get_entity_registry() - cache = get_entity_cache() - builder = get_entity_builder() - - is_registry_mode = registry is not None - if is_registry_mode: - # Mode 2: Pre-populate cache with registry index for O(1) lookups - registry_index = _build_registry_index(registry) - cache.update(registry_index) - - def visit(node): - if isinstance(node, dict): - _materialize_stored_entities_list_in_node( - node, - is_registry_mode=is_registry_mode, - cache=cache, - builder=builder, - not_merged_types=not_merged_types, - ) - _materialize_selectors_list_in_node(node, selector_lookup) - - for v in node.values(): - visit(v) - elif isinstance(node, list): - for it in node: - visit(it) - - visit(params_as_dict) - - return params_as_dict + """Wrapper that injects the default entity builder for the client package.""" + return _materialize_entities_and_selectors_in_place( + params_as_dict, + entity_builder=_build_entity_instance, + not_merged_types=not_merged_types, + entity_registry=entity_registry, + ) diff --git a/flow360/component/simulation/framework/entity_registry.py b/flow360/component/simulation/framework/entity_registry.py index 6a2d79018..4f18296be 100644 --- a/flow360/component/simulation/framework/entity_registry.py +++ b/flow360/component/simulation/framework/entity_registry.py @@ -1,565 +1,10 @@ -"""Registry for managing and storing instances of various entity types.""" - -from typing import Any, Dict, List, Optional, Union - -import pydantic as pd - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityBase -from flow360.component.utils import _naming_pattern_handler -from flow360.exceptions import Flow360ValueError - - -class StringIndexableList(list): - """ - An extension of a list that allows accessing elements inside it through a string key. - """ - - def __getitem__(self, key: Union[str, slice, int]): - if isinstance(key, str): - returned_items = [] - for item in self: - try: - item_ret_value = item[key] - except KeyError: - item_ret_value = [] - except Exception as e: - raise ValueError( - f"Trying to access something in {item} through string indexing, which is not allowed." - ) from e - if isinstance(item_ret_value, list): - returned_items += item_ret_value - else: - returned_items.append(item_ret_value) - if not returned_items: - raise ValueError( - "No entity found in registry for parent entities: " - + f"{', '.join([f'{entity.name}' for entity in self])} with given name/naming pattern: '{key}'." - ) - return returned_items - return super().__getitem__(key) - - -class EntityRegistryView: - """ - Type-filtered view over EntityRegistry with glob pattern support. - - Provides a simplified interface for accessing entities of **only** a specific type, - supporting both direct name lookup and glob pattern matching. - """ - - def __init__(self, registry: "EntityRegistry", entity_type: type[EntityBase]) -> None: - self._registry = registry - self._entity_type = entity_type - - def __iter__(self): - """Iterate over all entities of this type.""" - return iter(self._entities) - - def __len__(self): - """Return the number of entities of this type.""" - return len(self._entities) - - @property - def _entities(self) -> list[EntityBase]: - """Get all entities of the target type (exact type match only).""" - # Direct lookup in internal_registry for exact type - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.utils import is_exact_instance - - entities_of_type = self._registry.internal_registry.get(self._entity_type, []) - # Filter to ensure exact type match (not subclasses) - return [item for item in entities_of_type if is_exact_instance(item, self._entity_type)] - - def __getitem__(self, key: str) -> Union[EntityBase, list[EntityBase]]: - """ - Support syntax like `registry.view(Surface)['wing']` and glob patterns `registry.view(Surface)['wing*']`. - - Parameters: - key (str): Entity name or glob pattern (e.g., 'wing', 'wing*', '*tail'). - - Returns: - EntityBase or list[EntityBase]: Single entity if exactly one match, otherwise list of matches. - - Raises: - Flow360ValueError: If key is not a string. - ValueError: If no entities match the pattern. - """ - if not isinstance(key, str): - raise Flow360ValueError(f"Entity naming pattern: {key} is not a string.") - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.framework.entity_utils import ( - compile_glob_cached, - ) - - matcher = compile_glob_cached(pattern=key) - matched = [entity for entity in self._entities if matcher.match(entity.name)] - - if not matched: - raise ValueError( - f"No entity found in registry with given name/naming pattern: '{key}'." - ) - if len(matched) == 1: - return matched[0] - return matched - - -class EntityRegistry(Flow360BaseModel): - """ - A registry for managing references to instances of various entity types. - - This class provides methods to register entities, retrieve entities by their type, - and find entities by name patterns using regular expressions. - - Attributes: - internal_registry (Dict[type[EntityBase], List[EntityBase]]): A dictionary that maps entity - types to lists of instances. - - #Known Issues: - frozen=True do not stop the user from changing the internal_registry - """ - - internal_registry: Dict[type[EntityBase], list[Any]] = pd.Field({}) - - def fast_register(self, entity: EntityBase, known_frozen_hashes: set[str]) -> set[str]: - """ - Registers an entity in the registry under its type. Suitable for registering a large number of entities. - - Parameters: - entity (EntityBase): The entity instance to register. - known_frozen_hashes (Optional[set[str]]): A set of hashes of frozen entities. - This is used to speed up checking if the has is already in the registry by avoiding O(N^2) complexity. - This can be provided when registering a large number of entities. - - Returns: - known_frozen_hashes (set[str]) - """ - entity_type = type(entity) - if entity_type not in self.internal_registry: - # pylint: disable=unsupported-assignment-operation - self.internal_registry[entity_type] = [] - - # pylint: disable=protected-access - if entity._get_hash() in known_frozen_hashes: - return known_frozen_hashes - known_frozen_hashes.add(entity._get_hash()) - - # pylint: disable=unsubscriptable-object - self.internal_registry[entity_type].append(entity) - return known_frozen_hashes - - def register(self, entity: EntityBase): - """ - Registers an entity in the registry under its type. - - Parameters: - entity (EntityBase): The entity instance to register. - """ - entity_type = type(entity) - # pylint: disable=unsupported-membership-test - if entity_type not in self.internal_registry: - # pylint: disable=unsupported-assignment-operation - self.internal_registry[entity_type] = [] - - # pylint: disable=unsubscriptable-object - for existing_entity in self.internal_registry[entity_type]: - # pylint: disable=protected-access - if existing_entity._get_hash() == entity._get_hash(): - # Identical entities. Just ignore - return - - # pylint: disable=unsubscriptable-object - self.internal_registry[entity_type].append(entity) - - def remove(self, entity: EntityBase) -> None: - """Remove an entity from the registry.""" - entity_type = type(entity) - if ( - entity_type not in self.internal_registry - or entity not in self.internal_registry[entity_type] - ): - return - self.internal_registry[entity_type].remove(entity) - - def view(self, entity_type: type[EntityBase]) -> EntityRegistryView: - """ - Create a filtered view for a specific entity type with glob pattern support. - - Parameters: - entity_type (type[EntityBase]): The entity type to filter by (exact type match). - - Returns: - EntityRegistryView: A view providing filtered access to entities of the specified type. - - Example: - >>> surfaces = registry.view(Surface) - >>> wing = surfaces['wing'] - >>> tails = surfaces['*tail'] - """ - return EntityRegistryView(registry=self, entity_type=entity_type) - - def view_subclasses(self, parent_type: type[EntityBase]) -> list[EntityRegistryView]: - """ - Create views for all subclasses of a parent entity type. - - Parameters: - parent_type (type[EntityBase]): The parent entity type. - - Returns: - list[EntityRegistryView]: A list of views, one for each subclass found in the registry. - - Example: - >>> # Get views for all Surface subclasses - >>> surface_views = registry.view_subclasses(Surface) - >>> for view in surface_views: - >>> print(f"Found {len(view)} entities") - """ - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.utils import get_combined_subclasses - - subclasses = get_combined_subclasses(parent_type) - views = [] - for subclass in subclasses: - if subclass in self.internal_registry: - views.append(EntityRegistryView(registry=self, entity_type=subclass)) - return views - - def find_by_naming_pattern( - self, pattern: str, enforce_output_as_list: bool = True, error_when_no_match: bool = False - ) -> list[EntityBase]: - """ - Finds all registered entities whose names match a given pattern. - - Parameters: - pattern (str): A naming pattern, which can include '*' as a wildcard. - - Returns: - List[EntityBase]: A list of entities whose names match the pattern. - """ - matched_entities = [] - regex = _naming_pattern_handler(pattern=pattern) - # pylint: disable=no-member - for entity_list in self.internal_registry.values(): - matched_entities.extend(filter(lambda x: regex.match(x.name), entity_list)) - - if not matched_entities and error_when_no_match is True: - raise ValueError( - f"No entity found in registry with given name/naming pattern: '{pattern}'." - ) - if enforce_output_as_list is False and len(matched_entities) == 1: - return matched_entities[0] - - return matched_entities - - def __str__(self): - """ - Returns a string representation of all registered entities, grouped by type. - """ - index = 0 - result = "---- Content of the registry ----\n" - # pylint: disable=no-member - for entity_type, entities in self.internal_registry.items(): - result += f"\n Entities of type '{entity_type.__name__}':\n" - for entity in entities: - result += f" - [{index:05d}]\n{entity}\n" - index += 1 - result += "---- End of content ----" - return result - - def clear(self, entity_type: Union[None, type[EntityBase]] = None): - """ - Clears all entities from the registry. - """ - # pylint: disable=no-member - if entity_type is not None: - if entity_type in self.internal_registry.keys(): - # pylint: disable=unsubscriptable-object - self.internal_registry[entity_type].clear() - else: - self.internal_registry.clear() - - def contains(self, entity: EntityBase) -> bool: - """ - Returns True if the registry contains any entities, False otherwise. - """ - entity_type = type(entity) - # pylint: disable=unsupported-membership-test - if entity_type in self.internal_registry: - # pylint: disable=unsubscriptable-object - if entity in self.internal_registry[entity_type]: - return True - return False - - def entity_count(self) -> int: - """Return total number of entities in the registry.""" - count = 0 - # pylint: disable=no-member - for list_of_entities in self.internal_registry.values(): - count += len(list_of_entities) - return count - - def replace_existing_with(self, new_entity: EntityBase): - """ - Replaces an entity in the registry with a new entity (searched by name across all types). - - This searches for an entity with the same name across all registered types. - If found, removes the old entity and registers the new one. - If not found, simply registers the new entity. - - Parameters: - new_entity (EntityBase): The new entity to replace the existing entity with. - """ - # Search by name across all types to find entity to replace - # pylint: disable=no-member - for entity_type, entity_list in self.internal_registry.items(): - for i, entity in enumerate(entity_list): - if entity.name == new_entity.name: - # Found entity with matching name - remove it - self.internal_registry[entity_type].pop(i) - # Register new entity under its own type - self.register(new_entity) - return - - # No matching entity found, just register the new one - self.register(new_entity) - - def find_by_asset_id(self, *, entity_id: str, entity_class: type[EntityBase]): - """ - Find the entity with matching asset id and the same type as the input entity class. - Return None if no such entity is found. - """ - # Get entities of the specific type (including subclasses) - entities = self.view(entity_class)._entities # pylint: disable=protected-access - matched_entities = [item for item in entities if item.private_attribute_id == entity_id] - - if len(matched_entities) > 1: - raise ValueError( - f"[INTERNAL] Multiple entities with the same asset id ({entity_id}) found." - " Data is likely corrupted." - ) - if len(matched_entities) == 0: - return None - return matched_entities[0] - - @property - def is_empty(self): - """Return True if the registry is empty, False otherwise.""" - return not self.internal_registry - - def find_by_type(self, entity_class: type[EntityBase]) -> list[EntityBase]: - """Find all registered entities of a given type (including subclasses). - - Parameters: - entity_class (type[EntityBase]): The entity class to search for. - - Returns: - list[EntityBase]: All entities that are instances of the given class. - """ - matched_entities = [] - # pylint: disable=no-member - for entity_type, entity_list in self.internal_registry.items(): - # Check if entity_type is the target class or a subclass - if issubclass(entity_type, entity_class): - matched_entities.extend(entity_list) - return matched_entities - - def find_by_type_name(self, type_name: Union[str, List[str]]) -> list[EntityBase]: - """Find all registered entities with matching private_attribute_entity_type_name. - - This is useful for matching entities by their serialized type name (e.g., "Surface", "Edge"). - Supports both single type name and multiple type names for efficient batch lookup. - - Parameters: - type_name: Single type name string or list of type name strings to search for. - - Returns: - list[EntityBase]: All entities with matching type names. - - Examples: - >>> registry.find_by_type_name("Surface") - >>> registry.find_by_type_name(["Surface", "MirroredSurface"]) - """ - # Normalize to list for consistent handling - type_names_to_find = [type_name] if isinstance(type_name, str) else type_name - type_name_set = set(type_names_to_find) # O(1) lookup optimization - - matched_entities = [] - # pylint: disable=no-member - for entity_list in self.internal_registry.values(): - for entity in entity_list: - if entity.private_attribute_entity_type_name in type_name_set: - matched_entities.append(entity) - return matched_entities - - def find_by_type_name_and_id(self, *, entity_type: str, entity_id: str) -> Optional[EntityBase]: - """Find entity by serialized type name and asset id. - - Parameters - ---------- - entity_type : str - Serialized type name (EntityBase.private_attribute_entity_type_name), e.g. "Surface". - entity_id : str - Asset id (EntityBase.private_attribute_id). - - Returns - ------- - Optional[EntityBase] - Matched entity if found, otherwise None. - """ - if not isinstance(entity_type, str): - raise Flow360ValueError( - f"[Internal] entity_type must be a string. Received: {type(entity_type).__name__}." - ) - if not isinstance(entity_id, str): - raise Flow360ValueError( - f"[Internal] entity_id must be a string. Received: {type(entity_id).__name__}." - ) - - matched_entities: list[EntityBase] = [] - # pylint: disable=no-member - for entity_list in self.internal_registry.values(): - for entity in entity_list: - if not isinstance(entity, EntityBase): - continue - if ( - entity.private_attribute_entity_type_name == entity_type - and entity.private_attribute_id == entity_id - ): - matched_entities.append(entity) - - if len(matched_entities) > 1: - raise ValueError( - f"[INTERNAL] Multiple entities with the same type/id ({entity_type}:{entity_id}) found." - " Data is likely corrupted." - ) - if not matched_entities: - return None - return matched_entities[0] - - @classmethod - def from_entity_info(cls, entity_info) -> "EntityRegistry": - """Build registry by referencing entities from entity_info. - - This is for the DraftContext workflow only. Legacy asset code - continues to use entity_info.get_persistent_entity_registry(). - - Parameters: - entity_info: One of GeometryEntityInfo, VolumeMeshEntityInfo, or SurfaceMeshEntityInfo. - Must be a deserialized object instance, not a dictionary. - - Returns: - EntityRegistry: A registry populated with references to entities from entity_info. - """ - registry = cls() - registry._register_from_entity_info(entity_info) - return registry - - def _register_from_entity_info(self, entity_info): # pylint: disable=too-many-branches - """Populate internal_registry with references to entity_info entities. - - This method extracts all entities from the given entity_info and registers - them in this registry. It handles all three entity info types: - - GeometryEntityInfo: grouped_faces, grouped_edges, grouped_bodies - - VolumeMeshEntityInfo: boundaries, zones - - SurfaceMeshEntityInfo: boundaries - - All entity info types also have draft_entities and ghost_entities which are - registered as well. - - Note: - self is supposed to be an empty registry. - """ - known_frozen_hashes = set() - - def _register_selected_grouping(group_tag, attribute_names, grouped_items): - """Helper to register entities from the selected grouping.""" - if group_tag and group_tag in attribute_names: - idx = attribute_names.index(group_tag) - if idx < len(grouped_items): - for entity in grouped_items[idx]: - nonlocal known_frozen_hashes - known_frozen_hashes = self.fast_register(entity, known_frozen_hashes) - else: - raise Flow360ValueError( - f"Group tag {group_tag} not found in attribute names {attribute_names}." - ) - - if entity_info.type_name == "GeometryEntityInfo": - # Register only entities from the selected groupings - _register_selected_grouping( - entity_info.face_group_tag, - entity_info.face_attribute_names, - entity_info.grouped_faces, - ) - _register_selected_grouping( - entity_info.edge_group_tag, - entity_info.edge_attribute_names, - entity_info.grouped_edges, - ) - _register_selected_grouping( - entity_info.body_group_tag, - entity_info.body_attribute_names, - entity_info.grouped_bodies, - ) - - elif entity_info.type_name == "VolumeMeshEntityInfo": - for boundary in entity_info.boundaries: - known_frozen_hashes = self.fast_register(boundary, known_frozen_hashes) - for zone in entity_info.zones: - known_frozen_hashes = self.fast_register(zone, known_frozen_hashes) - - elif entity_info.type_name == "SurfaceMeshEntityInfo": - for boundary in entity_info.boundaries: - known_frozen_hashes = self.fast_register(boundary, known_frozen_hashes) - - # Common to all: draft_entities and ghost_entities - for entity in entity_info.draft_entities: - known_frozen_hashes = self.fast_register(entity, known_frozen_hashes) - for entity in entity_info.ghost_entities: - known_frozen_hashes = self.fast_register(entity, known_frozen_hashes) - - -class SnappyBodyRegistry(EntityRegistry): - """ - Extension of :class:`EntityRegistry` to be used with :class:`SnappyBody`, allows double indexing - for accessing the boundaries under certain :class:`SnappyBody`. - """ - - def find_by_naming_pattern( - self, pattern: str, enforce_output_as_list: bool = True, error_when_no_match: bool = False - ) -> StringIndexableList[EntityBase]: - """ - Finds all registered entities whose names match a given pattern. - - Parameters: - pattern (str): A naming pattern, which can include '*' as a wildcard. - - Returns: - List[EntityBase]: A list of entities whose names match the pattern. - """ - matched_entities = StringIndexableList() - regex = _naming_pattern_handler(pattern=pattern) - # pylint: disable=no-member - for entity_list in self.internal_registry.values(): - matched_entities.extend(filter(lambda x: regex.match(x.name), entity_list)) - - if not matched_entities and error_when_no_match is True: - raise ValueError( - f"No entity found in registry with given name/naming pattern: '{pattern}'." - ) - if enforce_output_as_list is False and len(matched_entities) == 1: - return matched_entities[0] - - return matched_entities - - def __getitem__(self, key): - """ - Get the entity by name. - `key` is the name of the entity or the naming pattern if wildcard is used. - """ - if isinstance(key, str) is False: - raise Flow360ValueError(f"Entity naming pattern: {key} is not a string.") - - return self.find_by_naming_pattern( - key, enforce_output_as_list=False, error_when_no_match=True - ) +"""Re-import relay: entity registry classes now live in flow360-schema.""" + +# pylint: disable=unused-import +# Re-import relay: all entity registry classes now live in flow360-schema +from flow360_schema.framework.entity.entity_registry import ( # noqa: F401 + EntityRegistry, + EntityRegistryView, + SnappyBodyRegistry, + StringIndexableList, +) diff --git a/flow360/component/simulation/framework/entity_selector.py b/flow360/component/simulation/framework/entity_selector.py index 802946681..963faf840 100644 --- a/flow360/component/simulation/framework/entity_selector.py +++ b/flow360/component/simulation/framework/entity_selector.py @@ -1,649 +1,27 @@ """ -Entity selector models +Entity selector models — re-import relay. -Defines a minimal, stable schema for selecting entities by rules. +All definitions have been migrated to flow360_schema.framework.entity.entity_selector. +This module re-exports them for backward compatibility. """ -import re -from collections import deque -from functools import lru_cache -from typing import Any, Dict, List, Literal, Optional, Union - -import pydantic as pd -from typing_extensions import Self +# pylint: disable=unused-import +from flow360_schema.framework.entity.entity_selector import ( # noqa: F401 + BodyGroupSelector, + EdgeSelector, + EntityNode, + EntitySelector, + Predicate, + SurfaceSelector, + TargetClass, + VolumeSelector, + _process_selectors, + collect_and_tokenize_selectors_in_place, + expand_entity_list_selectors, + expand_entity_list_selectors_in_place, +) -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_utils import ( - DEFAULT_NOT_MERGED_TYPES, +# Re-export transitive imports that external code depends on via this module +from flow360_schema.framework.entity.entity_utils import ( # noqa: F401 compile_glob_cached, - deduplicate_entities, - generate_uuid, ) -from flow360.log import log - -# These corresponds to the private_attribute_entity_type_name of supported entity types. -TargetClass = Literal["Surface", "Edge", "GenericVolume", "GeometryBodyGroup"] - -EntityNode = Union[Any, Dict[str, Any]] # Union[EntityBase, Dict[str, Any]] - - -class Predicate(Flow360BaseModel): - """ - Single predicate in a selector. - """ - - # For now only name matching is supported - attribute: Literal["name"] = pd.Field("name", description="The attribute to match/filter on.") - operator: Literal[ - "any_of", - "not_any_of", - "matches", - "not_matches", - ] = pd.Field() - value: Union[str, List[str]] = pd.Field() - # Applies only to matches/notMatches; default to glob if not specified explicitly. - non_glob_syntax: Optional[Literal["regex"]] = pd.Field( - None, - description="If specified, the pattern (`value`) will be treated " - "as a non-glob pattern with the specified syntax.", - ) - - -class EntitySelector(Flow360BaseModel): - """Entity selector for an EntityList. - - - target_class chooses the entity pool - - logic combines child predicates (AND = intersection, OR = union) - """ - - target_class: TargetClass = pd.Field() - description: Optional[str] = pd.Field( - None, description="Customizable description of the selector." - ) - selector_id: str = pd.Field( - default_factory=generate_uuid, - description="[Internal] Unique identifier for the selector.", - frozen=True, - ) - # Unique name for global reuse - name: str = pd.Field(description="Unique name for this selector.") - logic: Literal["AND", "OR"] = pd.Field("AND") - children: List[Predicate] = pd.Field() - - @pd.validate_call - def match( - self, - pattern: str, - *, - attribute: Literal["name"] = "name", - ) -> Self: - """Append a matches predicate (glob pattern) and return self for chaining.""" - # pylint: disable=no-member - self.children.append( - Predicate( - attribute=attribute, - operator="matches", - value=pattern, - ) - ) - return self - - @pd.validate_call - def not_match( - self, - pattern: str, - *, - attribute: Literal["name"] = "name", - ) -> Self: - """Append a not-matches predicate (glob pattern) and return self for chaining.""" - # pylint: disable=no-member - self.children.append( - Predicate( - attribute=attribute, - operator="not_matches", - value=pattern, - ) - ) - return self - - @pd.validate_call - def any_of(self, values: List[str], *, attribute: Literal["name"] = "name") -> Self: - """Append an in predicate and return self for chaining.""" - # pylint: disable=no-member - self.children.append(Predicate(attribute=attribute, operator="any_of", value=values)) - return self - - @pd.validate_call - def not_any_of(self, values: List[str], *, attribute: Literal["name"] = "name") -> Self: - """Append a notIn predicate and return self for chaining.""" - # pylint: disable=no-member - self.children.append(Predicate(attribute=attribute, operator="not_any_of", value=values)) - return self - - -########## SELECTOR CLASSES ########## - - -class SurfaceSelector(EntitySelector): - """ - Pattern-based selector for Surface entities. Stores matching rules, - enabling reusable SimulationParams templates. - - Example - ------- - >>> # Simple: match surfaces by glob pattern - >>> fl.SurfaceSelector(name="wing_surfaces").match("wing*") - >>> # With OR logic: match surfaces matching either pattern - >>> sel = fl.SurfaceSelector( - ... name="wing_and_tail", logic="OR", - ... description="Wing and tail surfaces" - ... ).match("wing*").match("tail*") - - ==== - """ - - target_class: Literal["Surface"] = pd.Field("Surface", frozen=True) - children: List[Predicate] = pd.Field(default_factory=list) - - -class EdgeSelector(EntitySelector): - """ - Pattern-based selector for Edge entities. Stores matching rules, - enabling reusable SimulationParams templates. - - Example - ------- - >>> # Simple: match edges by glob pattern - >>> fl.EdgeSelector(name="leading_edges").match("leading_edge*") - >>> # With OR logic: match edges by name list - >>> sel = fl.EdgeSelector( - ... name="selected_edges", logic="OR", - ... description="Edges for refinement" - ... ).any_of(["edge1", "edge2"]) - - ==== - """ - - target_class: Literal["Edge"] = pd.Field("Edge", frozen=True) - children: List[Predicate] = pd.Field(default_factory=list) - - -class VolumeSelector(EntitySelector): - """ - Pattern-based selector for volume zone entities. Stores matching rules, - enabling reusable SimulationParams templates. - - Example - ------- - >>> # Simple: match volumes by glob pattern - >>> fl.VolumeSelector(name="fluid_zones").match("fluid*") - >>> # With OR logic: match specific zones by name - >>> sel = fl.VolumeSelector( - ... name="porous_zones", logic="OR", - ... description="Zones for porous medium" - ... ).any_of(["zone1", "zone2"]) - - ==== - """ - - target_class: Literal["GenericVolume"] = pd.Field("GenericVolume", frozen=True) - children: List[Predicate] = pd.Field(default_factory=list) - - -class BodyGroupSelector(EntitySelector): - """ - Pattern-based selector for body group entities. Stores matching rules, - enabling reusable SimulationParams templates. - - Example - ------- - >>> # Simple: match body groups by glob pattern - >>> fl.BodyGroupSelector(name="rotor_bodies").match("rotor*") - >>> # With OR logic: match specific bodies by name - >>> sel = fl.BodyGroupSelector( - ... name="rotating_bodies", logic="OR", - ... description="Bodies for rotation" - ... ).any_of(["body1", "body2"]) - - ==== - """ - - target_class: Literal["GeometryBodyGroup"] = pd.Field("GeometryBodyGroup", frozen=True) - children: List[Predicate] = pd.Field(default_factory=list) - - -########## EXPANSION IMPLEMENTATION ########## - - -@lru_cache(maxsize=2048) -def _compile_regex_cached(pattern: str) -> re.Pattern: - return re.compile(pattern) - - -def _get_node_attribute(entity: Any, attribute: str): - """Return attribute value from either dicts or entity objects.""" - if isinstance(entity, dict): - return entity.get(attribute) - return getattr(entity, attribute, None) - - -def _get_attribute_value(entity: Any, attribute: str) -> Optional[str]: - """Return the scalar string value of an attribute, or None if absent/unsupported. - - Only scalar string attributes are supported by this matcher layer for now. - """ - val = _get_node_attribute(entity, attribute) - if isinstance(val, str): - return val - return None - - -def _build_value_matcher(predicate: Predicate): - """ - Build a fast predicate(value: Optional[str])->bool matcher. - - Precompiles regex/glob and converts membership lists to sets for speed. - """ - operator = predicate.operator - value = predicate.value - non_glob_syntax = predicate.non_glob_syntax - - negate = False - if operator in ("not_any_of", "not_matches"): - negate = True - base_operator = { - "not_any_of": "any_of", - "not_matches": "matches", - }.get(operator) - else: - base_operator = operator - - if base_operator == "any_of": - values = set(value or []) - - def base_match(val: Optional[str]) -> bool: - return val in values - - elif base_operator == "matches": - if non_glob_syntax == "regex": - pattern = _compile_regex_cached(value) - else: - pattern = compile_glob_cached(value) - - def base_match(val: Optional[str]) -> bool: - return isinstance(val, str) and (pattern.fullmatch(val) is not None) - - else: - - def base_match(_val: Optional[str]) -> bool: - return False - - if negate: - return lambda val: not base_match(val) - return base_match - - -def _build_index(pool: list[EntityNode], attribute: str) -> dict[str, list[int]]: - """Build an index for in lookups on a given attribute.""" - value_to_indices: dict[str, list[int]] = {} - for idx, item in enumerate(pool): - val = _get_node_attribute(item, attribute) - if isinstance(val, str): - value_to_indices.setdefault(val, []).append(idx) - return value_to_indices - - -def _apply_or_selector( - pool: list[EntityNode], - ordered_children: list[Predicate], -) -> list[Any]: - indices: set[int] = set() - for predicate in ordered_children: - attribute = predicate.attribute - matcher = _build_value_matcher(predicate) - for i, item in enumerate(pool): - if i in indices: - continue - if matcher(_get_attribute_value(item, attribute)): - indices.add(i) - if len(indices) >= len(pool): - break - if len(indices) * 4 < len(pool): - return [pool[i] for i in sorted(indices)] - return [pool[i] for i in range(len(pool)) if i in indices] - - -def _apply_and_selector( - pool: list[EntityNode], - ordered_children: list[Predicate], - indices_by_attribute: dict[str, dict[str, list[int]]], -) -> list[Any]: - candidate_indices: Optional[set[int]] = None - - def _matched_indices_for_predicate( - predicate: Predicate, current_candidates: Optional[set[int]] - ) -> set[int]: - operator = predicate.operator - attribute = predicate.attribute - if operator == "any_of": - idx_map = indices_by_attribute.get(attribute) - if idx_map is not None: - result: set[int] = set() - for v in predicate.value or []: - result.update(idx_map.get(v, [])) - return result - matcher = _build_value_matcher(predicate) - matched: set[int] = set() - if current_candidates is None: - for i, item in enumerate(pool): - if matcher(_get_attribute_value(item, attribute)): - matched.add(i) - return matched - for i in current_candidates: - if matcher(_get_attribute_value(pool[i], attribute)): - matched.add(i) - return matched - - for predicate in ordered_children: - matched = _matched_indices_for_predicate(predicate, candidate_indices) - candidate_indices = ( - matched if candidate_indices is None else candidate_indices.intersection(matched) - ) - if not candidate_indices: - return [] - - assert candidate_indices is not None - if len(candidate_indices) * 4 < len(pool): - return [pool[i] for i in sorted(candidate_indices)] - return [pool[i] for i in range(len(pool)) if i in candidate_indices] - - -def _apply_single_selector(pool: list[EntityNode], selector: EntitySelector) -> list[EntityNode]: - """Apply one selector over a pool of entities (dicts or objects). - - Implementation notes for future readers: - - We assume selector_dict conforms to the EntitySelector schema (no validation here for speed). - - We respect the default of logic="AND" when absent. - - For performance: - * Reorder predicates under AND so that cheap/selective operations run first. - * Build a name->indices index to accelerate equals/in where beneficial. - * Precompile regex/glob matchers once per predicate. - * Short-circuit when the candidate set becomes empty. - - Result ordering is stable (by original pool index) to keep the operation idempotent. - """ - logic = selector.logic - children = selector.children - - # Fast path: empty predicates -> return nothing. Empty children is actually misuse. - if not children: - return [] - - # Predicate ordering (AND only): cheap/selective first - def _cost(predicate: Predicate) -> int: - op = predicate.operator - order = { - "any_of": 0, - "matches": 1, - "not_any_of": 2, - "not_matches": 3, - } - return order.get(op, 10) - - ordered_children = children if logic == "OR" else sorted(children, key=_cost) - - # Optional per-attribute indices for in - attributes_needing_index = {p.attribute for p in ordered_children if p.operator == "any_of"} - indices_by_attribute: dict[str, dict[str, list[int]]] = ( - {attr: _build_index(pool, attr) for attr in attributes_needing_index} - if attributes_needing_index - else {} - ) - - if logic == "OR": - # Favor a full scan for OR to preserve predictable union behavior - # and avoid over-indexing that could complicate ordering. - result = _apply_or_selector(pool, ordered_children) - else: - result = _apply_and_selector(pool, ordered_children, indices_by_attribute) - - if not result: - name = selector.name - target_class = selector.target_class - log.warning( - "Entity selector '%s' (target_class=%s) matched 0 entities. " - "Please check if the entity name or pattern is correct.", - name, - target_class, - ) - - return result - - -def _get_selector_cache_key(selector: EntitySelector) -> tuple: - """ - Return the cache key for a selector: requires unique name. - - We mandate a unique identifier per selector; use ("name", target_class, name) - for stable global reuse. If neither `name` is provided, fall back to a - structural key so different unnamed selectors won't collide. - """ - # selector_id is always present and unique by schema. - return ("selector_id", selector.selector_id) - - -def _process_selectors( - registry, - selectors_list: list, - selector_cache: dict, - *, - expansion_map: Optional[Dict[str, List[str]]] = None, -) -> tuple[dict[str, list[EntityNode]], list[str]]: - """Process selectors and return additions grouped by class. - - This function iterates over a list of materialized selectors (EntitySelector-like objects - or plain dicts) and applies them over the entity registry. - Results are cached in `selector_cache` to avoid re-computation for the same selector. - - Parameters: - registry: EntityRegistry instance containing entities. - selectors_list: List of selector definitions (materialized; no string tokens). - selector_cache: Cache for selector results. - expansion_map: Optional type expansion mapping. If None, uses DEFAULT_TARGET_CLASS_EXPANSION_MAP. - - Returns: - Tuple of (additions_by_class dict, ordered_target_classes list). - """ - # Set default expansion map - if expansion_map is None: - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.framework.entity_expansion_config import ( - DEFAULT_TARGET_CLASS_EXPANSION_MAP, - ) - - expansion_map = DEFAULT_TARGET_CLASS_EXPANSION_MAP - - additions_by_class: dict[str, list[EntityNode]] = {} - ordered_target_classes: list[str] = [] - - for item in selectors_list: - if not isinstance(item, EntitySelector): - raise TypeError( - f"[Internal] selectors_list must contain EntitySelector objects. Got: {type(item)}" - ) - selector: EntitySelector = item - target_class = selector.target_class - - # Use expansion map to get multiple type names - expanded_type_names = expansion_map.get(target_class, [target_class]) - entities = registry.find_by_type_name(expanded_type_names) - if not entities: - continue - cache_key = _get_selector_cache_key(selector) - additions = selector_cache.get(cache_key) - if additions is None: - additions = _apply_single_selector(entities, selector) - selector_cache[cache_key] = additions - if target_class not in additions_by_class: - additions_by_class[target_class] = [] - ordered_target_classes.append(target_class) - additions_by_class[target_class].extend(additions) - - return additions_by_class, ordered_target_classes - - -def _merge_entities( - existing: list[EntityNode], - additions_by_class: dict[str, list[EntityNode]], - ordered_target_classes: list[str], - merge_mode: Literal["merge", "replace"], - not_merged_types: set[str] = DEFAULT_NOT_MERGED_TYPES, -) -> list[Any]: - """Merge existing entities with selector additions based on merge mode. - - Note: Type filtering is now handled by EntityList's _filter_entities_by_valid_types - field validator, which runs after this function returns entities to the list. - """ - candidates: list[EntityNode] = [] - - if merge_mode == "merge": # explicit first, then selector additions - candidates.extend(existing) - for target_class in ordered_target_classes: - candidates.extend(additions_by_class.get(target_class, [])) - - else: # replace: drop explicit items of targeted classes - classes_to_update = set(ordered_target_classes) - for item in existing: - entity_type = _get_node_attribute(item, "private_attribute_entity_type_name") - if entity_type not in classes_to_update: - candidates.append(item) - for target_class in ordered_target_classes: - candidates.extend(additions_by_class.get(target_class, [])) - - # Deduplication logic (same as materialize_entities_and_selectors_in_place) - return deduplicate_entities( - candidates, - not_merged_types=not_merged_types, - ) - - -def expand_entity_list_selectors( - registry, - entity_list, - *, - selector_cache: dict = None, - merge_mode: Literal["merge", "replace"] = "merge", - expansion_map: Optional[Dict[str, List[str]]] = None, -) -> list[EntityNode]: - """ - Expand selectors in a single EntityList within an EntityRegistry context. - - Parameters - ---------- - expansion_map : Optional type expansion mapping for selectors. - - Notes - ----- - - This function does NOT modify the input EntityList. - - selector_cache can be shared across multiple calls to reuse selector results. - - Type filtering is now handled by EntityList's field validator. - """ - stored_entities = list(getattr(entity_list, "stored_entities", []) or []) - raw_selectors = list(getattr(entity_list, "selectors", []) or []) - - if selector_cache is None: - selector_cache = {} - - if not raw_selectors: - return stored_entities - - additions_by_class, ordered_target_classes = _process_selectors( - registry, - raw_selectors, - selector_cache, - expansion_map=expansion_map, - ) - return _merge_entities( - stored_entities, - additions_by_class, - ordered_target_classes, - merge_mode, - ) - - -def expand_entity_list_selectors_in_place( - registry, - entity_list, - *, - selector_cache: dict = None, - merge_mode: Literal["merge", "replace"] = "merge", - expansion_map: Optional[Dict[str, List[str]]] = None, -) -> None: - """ - Expand selectors in an EntityList and write results into stored_entities in-place. - - This is intended for translation-time expansion where mutating the params object is safe. - """ - expanded = expand_entity_list_selectors( - registry, - entity_list, - selector_cache=selector_cache, - merge_mode=merge_mode, - expansion_map=expansion_map, - ) - entity_list.stored_entities = expanded - - -def collect_and_tokenize_selectors_in_place( # pylint: disable=too-many-branches - params_as_dict: dict, -) -> dict: - """ - Collect all matched/defined selectors into AssetCache and replace them with tokens (`selector_id`). - - This optimization reduces the size of the JSON and allows for efficient re-use of - selector definitions. - 1. It traverses the `params_as_dict` to find all `EntitySelector` definitions (dicts with "name"). - 2. It moves these definitions into `private_attribute_asset_cache["selectors"]`. - 3. It replaces the original dictionary definition in the `selectors` list with just the `selector_id` (token). - """ - known_selectors = {} - - # Pre-populate from existing AssetCache if any - asset_cache = params_as_dict.setdefault("private_attribute_asset_cache", {}) - if isinstance(asset_cache, dict): - cached_selectors = asset_cache.get("used_selectors") - if isinstance(cached_selectors, list): - for s in cached_selectors: - selector_id = s.get("selector_id") - if selector_id is None: - selector_id = generate_uuid() - s["selector_id"] = selector_id - known_selectors[selector_id] = s - - queue = deque([params_as_dict]) - while queue: - node = queue.popleft() - if isinstance(node, dict): - selectors = node.get("selectors") - new_selectors = [] - for item in selectors or (): - selector_id = item.get("selector_id") - known_selectors[selector_id] = item - new_selectors.append(selector_id) - if selectors is not None: - node["selectors"] = new_selectors - - # Recurse - for value in node.values(): - if isinstance(value, (dict, list)): - queue.append(value) - - elif isinstance(node, list): - for item in node: - if isinstance(item, (dict, list)): - queue.append(item) - - # Update AssetCache - if isinstance(asset_cache, dict): - asset_cache.pop("used_selectors", None) - asset_cache["used_selectors"] = list(known_selectors.values()) - return params_as_dict diff --git a/flow360/component/simulation/framework/entity_utils.py b/flow360/component/simulation/framework/entity_utils.py index 8a626c829..97f3a4bc6 100644 --- a/flow360/component/simulation/framework/entity_utils.py +++ b/flow360/component/simulation/framework/entity_utils.py @@ -1,217 +1,12 @@ -"""Shared utilities for entity operations.""" - -import hashlib -import json -import re -import uuid -from functools import lru_cache -from typing import Any, Callable, Iterable, List, Optional, Set, Tuple - -# Define a default set of types that should not be merged/deduplicated. -DEFAULT_NOT_MERGED_TYPES = frozenset({"Point"}) - - -def generate_uuid(): - """generate a unique identifier for non-persistent entities. Required by front end.""" - return str(uuid.uuid4()) - - -def get_entity_type(item: Any) -> Optional[str]: - """Get entity type name from dict or object.""" - if isinstance(item, dict): - return item.get("private_attribute_entity_type_name") - return getattr(item, "private_attribute_entity_type_name", type(item).__name__) - - -def get_entity_key(item: Any) -> tuple: - """Return a stable deduplication key for an entity (dict or object). - - Strategy: - 1. (type, private_attribute_id) if ID exists. - 2. For dicts without ID: (type, hash(json_dump(content))). - 3. For objects without ID: (type, id(object)). - """ - t = get_entity_type(item) - - # Try getting ID - if isinstance(item, dict): - pid = item.get("private_attribute_id") - else: - pid = getattr(item, "private_attribute_id", None) - - if pid: - return (t, pid) - - # Fallback - if isinstance(item, dict): - # Hash content for dicts without ID - # Exclude volatile fields - data = {k: v for k, v in item.items() if k not in ("private_attribute_input_cache",)} - return (t, hashlib.sha256(json.dumps(data, sort_keys=True).encode("utf-8")).hexdigest()) - - # Object identity for objects without ID - return (t, id(item)) - - -def deduplicate_entities( - entities: Iterable[Any], - *, - processor: Optional[Callable[[Any], Tuple[Any, tuple]]] = None, - not_merged_types: Set[str] = DEFAULT_NOT_MERGED_TYPES, -) -> List[Any]: - """ - Process and deduplicate a list of entities. - - Parameters - ---------- - entities : Iterable[Any] - Input list of entities (dicts or objects). - processor : Callable[[Any], Tuple[Any, tuple]], optional - Function that processes an item and returns (processed_item, key). - If None, the item is used as is, and key is derived via get_entity_key. - not_merged_types : Set[str] - Set of entity type names to skip deduplication (always keep). - - Returns - ------- - List[Any] - New list of processed and deduplicated entities. - """ - new_list = [] - seen = set() - - for item in entities: - if processor: - obj, key = processor(item) - else: - obj = item - key = get_entity_key(obj) - - # Check if we should skip deduplication for this type - t = get_entity_type(obj) - if t in not_merged_types: - new_list.append(obj) - continue - - if key in seen: - continue - - seen.add(key) - new_list.append(obj) - - return new_list - - -def walk_object_tree_with_cycle_detection( - obj: Any, - visitor: Callable[[Any], bool], - *, - check_dict: bool = True, -) -> None: - """ - Walk an object tree using depth-first traversal with cycle detection. - - This utility provides a reusable pattern for traversing nested object structures - (lists, tuples, dicts, and objects with __dict__) while avoiding infinite loops - caused by circular references. - - Parameters - ---------- - obj : Any - The root object to start traversal from - visitor : Callable[[Any], bool] - Function called on each object. Should return True to continue traversal - into this object's children, or False to skip traversal of children. - The visitor handles all type-specific logic. - check_dict : bool, default True - Whether to traverse dict objects. Set to False if you only want to traverse - list/tuple/object-with-__dict__. - - Notes - ----- - - Uses id() to track visited objects, preventing revisiting in cycles - - Traverses list, tuple, dict (if check_dict=True), and objects with __dict__ - - The visitor function controls what happens at each node and whether to recurse - - Examples - -------- - >>> def print_entity_lists(obj): - ... if isinstance(obj, EntityList): - ... print(f"Found EntityList: {obj}") - ... return False # Don't traverse into EntityList internals - ... return True # Continue traversing - >>> walk_object_tree_with_cycle_detection(params, print_entity_lists) - """ - visited: set[int] = set() - - def _should_traverse(item): - """Check if an item should be traversed.""" - return ( - isinstance(item, (list, tuple)) - or hasattr(item, "__dict__") - or (check_dict and isinstance(item, dict)) - ) - - def _walk(current_obj): - obj_id = id(current_obj) - if obj_id in visited: - return - visited.add(obj_id) - - # Call visitor and check if we should continue traversing - should_continue = visitor(current_obj) - if not should_continue: - return - - # Get children to traverse based on object type - children = [] - if isinstance(current_obj, (list, tuple)): - children = current_obj - elif check_dict and isinstance(current_obj, dict): - children = current_obj.values() - elif hasattr(current_obj, "__dict__"): - children = current_obj.__dict__.values() - - # Traverse children - for child in children: - if _should_traverse(child): - _walk(child) - - _walk(obj) - - -@lru_cache(maxsize=2048) -def compile_glob_cached(pattern: str) -> re.Pattern: - """Compile an extended-glob pattern via wcmatch to a fullmatch-ready regex. - - We enable extended glob features including brace expansion, extglob groups, - and globstar. We intentionally avoid PATHNAME semantics because entity - names are not paths in this context, and we keep case-sensitive matching to - remain predictable across platforms. - """ - # Strong requirement: wcmatch must be present to support full glob features. - # pylint: disable=import-outside-toplevel - from wcmatch import fnmatch as wfnmatch - - # Enforce case-sensitive matching across platforms (Windows defaults to case-insensitive). - wc_flags = wfnmatch.BRACE | wfnmatch.EXTMATCH | wfnmatch.DOTMATCH | wfnmatch.CASE - translated = wfnmatch.translate(pattern, flags=wc_flags) - # wcmatch.translate may return a tuple: (list_of_regex_strings, list_of_flags) - if isinstance(translated, tuple): - regex_parts, _flags = translated - if isinstance(regex_parts, list) and len(regex_parts) > 1: - - def _strip_anchors(expr: str) -> str: - if expr.startswith("^"): - expr = expr[1:] - if expr.endswith("$"): - expr = expr[:-1] - return expr - - stripped = [_strip_anchors(s) for s in regex_parts] - combined = "^(?:" + ")|(?:".join(stripped) + ")$" - return re.compile(combined) - if isinstance(regex_parts, list) and len(regex_parts) == 1: - return re.compile(regex_parts[0]) - # Otherwise, assume it's a single regex string - return re.compile(translated) +"""Re-import relay: entity utilities now live in flow360-schema.""" + +# pylint: disable=unused-import +from flow360_schema.framework.entity.entity_utils import ( # noqa: F401 + DEFAULT_NOT_MERGED_TYPES, + compile_glob_cached, + deduplicate_entities, + generate_uuid, + get_entity_key, + get_entity_type, + walk_object_tree_with_cycle_detection, +) diff --git a/flow360/component/simulation/framework/multi_constructor_model_base.py b/flow360/component/simulation/framework/multi_constructor_model_base.py index 2d09b762c..522d24977 100644 --- a/flow360/component/simulation/framework/multi_constructor_model_base.py +++ b/flow360/component/simulation/framework/multi_constructor_model_base.py @@ -1,241 +1,10 @@ -"""MultiConstructorModelBase class for Pydantic models with multiple constructors.""" - -# requirements for data models with custom constructors: -# 1. data model can be saved to JSON and read back to pydantic model without problems -# 2. data model can return data provided to custom constructor -# 3. data model can be created from JSON that contains only custom constructor inputs - incomplete JSON -# 4. incomplete JSON is not in a conflict with complete JSON (from point 1), such that there is no need for 2 parsers -import abc -import inspect -from functools import wraps -from typing import Any, Callable, Literal, Optional, Union, get_args, get_origin - -import pydantic as pd -from flow360_schema.framework.validation.context import DeserializationContext - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.utils import model_attribute_unlock - - -class MultiConstructorBaseModel(Flow360BaseModel, metaclass=abc.ABCMeta): - """ - [INTERNAL] - - Base class for models with multiple constructors. - - This class provides a mechanism to create models with multiple constructors, each having its own set - of parameters and default values. It stores the constructor name and input cache so the class instance - can be constructed from front end input. - """ - - type_name: Literal["MultiConstructorBaseModel"] = pd.Field( - "MultiConstructorBaseModel", frozen=True - ) - private_attribute_constructor: str = pd.Field("default", frozen=True) - private_attribute_input_cache: Optional[Any] = pd.Field(None, frozen=True) - - @classmethod - def model_constructor(cls, func: Callable) -> Callable: - """ - [AI-Generated] Decorator for model constructor functions. - - This method wraps a constructor function to manage default argument values and cache the inputs. - - Args: - func (Callable): The constructor function to wrap. - - Returns: - Callable: The wrapped constructor function. - """ - - @classmethod - @wraps(func) - def wrapper(cls, **kwargs): - obj = func(cls, **kwargs) - sig = inspect.signature(func) - function_arg_defaults = { - k: v.default - for k, v in sig.parameters.items() - if v.default is not inspect.Parameter.empty - } - # XXCache should not include private_attribute_id as it is not **User** input - kwargs.pop("private_attribute_id", None) - - with model_attribute_unlock(obj, "private_attribute_input_cache"): - obj.private_attribute_input_cache = obj.private_attribute_input_cache.__class__( - # Note: obj.private_attribute_input_cache should not be included here - # Note: Because it carries over the previous cached inputs. Whatever the user choose not to specify - # Note: should be using default values rather than the previous cached inputs. - **{ - **function_arg_defaults, # Defaults as the base (needed when load in UI) - **kwargs, # User specified inputs (overwrites defaults) - } - ) - with model_attribute_unlock(obj, "private_attribute_constructor"): - obj.private_attribute_constructor = func.__name__ - return obj - - return wrapper - - -##:: Utility functions for multi-constructor models -def get_class_method(cls, method_name): - """ - Retrieve a class method by its name. - - Parameters - ---------- - cls : type - The class containing the method. - method_name : str - The name of the method as a string. - - Returns - ------- - method : callable - The class method corresponding to the method name. - - Raises - ------ - AttributeError - If the method_name is not a callable method of the class. - - Examples - -------- - >>> class MyClass: - ... @classmethod - ... def my_class_method(cls): - ... return "Hello from class method!" - ... - >>> method = get_class_method(MyClass, "my_class_method") - >>> method() - 'Hello from class method!' - """ - method = getattr(cls, method_name) - if not callable(method): - raise AttributeError(f"{method_name} is not a callable method of {cls.__name__}") - return method - - -def get_class_by_name(class_name, global_vars): - """ - Retrieve a class by its name from the global scope. - - Parameters - ---------- - class_name : str - The name of the class as a string. - - Returns - ------- - cls : type - The class corresponding to the class name. - - Raises - ------ - NameError - If the class_name is not found in the global scope. - TypeError - If the found object is not a class. - - Examples - -------- - >>> class MyClass: - ... pass - ... - >>> cls = get_class_by_name("MyClass") - >>> cls - - """ - cls = global_vars.get(class_name) - if cls is None: - raise NameError(f"Class '{class_name}' not found in the global scope.") - if not isinstance(cls, type): - raise TypeError(f"'{class_name}' found in global scope, but it is not a class.") - return cls - - -def _add_parent_location_to_validation_error( - validation_error: pd.ValidationError, parent_loc=None -) -> pd.ValidationError: - """Convert the validation error by appending the parent location""" - if parent_loc is None: - return validation_error - updated_errors = [] - for error in validation_error.errors(): - error["loc"] = (parent_loc,) + error["loc"] - updated_errors.append(error) - return pd.ValidationError.from_exception_data( - title=validation_error.title, - line_errors=updated_errors, - ) - - -def model_custom_constructor_parser(model_as_dict, global_vars): - """Parse the dictionary, construct the object and return obj dict.""" - constructor_name = model_as_dict.get("private_attribute_constructor", None) - if constructor_name is not None and constructor_name != "default": - - def is_optional_argument(annotation) -> bool: - # Ensure the annotation has been parsed as the typing object - # Avoid the unnecessary use of from __future__ import annotations - assert not isinstance( - arg_obj.annotation, str - ), "[Internal] Invalid string type annotation. Please check importing future." - if get_origin(annotation) is Union and type(None) in get_args(annotation): - return True - return False - - model_cls = get_class_by_name(model_as_dict.get("type_name"), global_vars) - input_kwargs = model_as_dict.get("private_attribute_input_cache") - - constructor = get_class_method(model_cls, constructor_name) - constructor_args = inspect.signature(constructor).parameters - # Filter the input_kwargs using constructor signatures - # If the argument is not found in input_kwargs: - # 1. Error out if the argument is required - # 2. Use default value if available, else use None if `Optional` - input_kwargs_filtered = {} - for arg_name, arg_obj in constructor_args.items(): - if arg_name in input_kwargs.keys(): - input_kwargs_filtered[arg_name] = input_kwargs[arg_name] - elif ( - is_optional_argument(arg_obj.annotation) - and arg_obj.default is inspect.Parameter.empty - ): - input_kwargs_filtered[arg_name] = None - try: - with DeserializationContext(): - model_dict = constructor(**input_kwargs_filtered).model_dump( - mode="json", exclude_none=True - ) - # Make sure we do not generate a new ID. - if "private_attribute_id" in model_as_dict: - model_dict["private_attribute_id"] = model_as_dict["private_attribute_id"] - return model_dict - except pd.ValidationError as err: - # pylint:disable = raise-missing-from - raise _add_parent_location_to_validation_error( - validation_error=err, parent_loc="private_attribute_input_cache" - ) - - return model_as_dict - - -def parse_model_dict(model_as_dict, global_vars, parent_loc: Union[str, int] = None) -> dict: - """Recursively parses the model dictionary and attempts to construct the multi-constructor object.""" - try: - if isinstance(model_as_dict, dict): - for key, value in model_as_dict.items(): - model_as_dict[key] = parse_model_dict(value, global_vars, key) - - model_as_dict = model_custom_constructor_parser(model_as_dict, global_vars) - elif isinstance(model_as_dict, list): - model_as_dict = [ - parse_model_dict(item, global_vars, idx) for idx, item in enumerate(model_as_dict) - ] - except pd.ValidationError as err: - # pylint:disable = raise-missing-from - raise _add_parent_location_to_validation_error(validation_error=err, parent_loc=parent_loc) - - return model_as_dict +"""Re-import relay: multi-constructor model base now lives in flow360-schema.""" + +# pylint: disable=unused-import +from flow360_schema.framework.multi_constructor_model_base import ( # noqa: F401 + MultiConstructorBaseModel, + get_class_by_name, + get_class_method, + model_custom_constructor_parser, + parse_model_dict, +) diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index 8b1ff9080..666a5dba0 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -27,7 +27,6 @@ _SurfaceEntityBase, _VolumeEntityBase, ) -from flow360.component.simulation.utils import model_attribute_unlock class AssetCache(Flow360BaseModel): @@ -248,10 +247,12 @@ def _update_zone_boundaries_with_metadata( for entity in view._entities ]: if volume_entity.name in volume_mesh_meta_data["zones"]: - with model_attribute_unlock(volume_entity, "private_attribute_zone_boundary_names"): - volume_entity.private_attribute_zone_boundary_names = UniqueStringList( + volume_entity._force_set_attr( # pylint:disable=protected-access + "private_attribute_zone_boundary_names", + UniqueStringList( items=volume_mesh_meta_data["zones"][volume_entity.name]["boundaryNames"] - ) + ), + ) def _set_boundary_full_name_with_zone_name( @@ -268,8 +269,9 @@ def _set_boundary_full_name_with_zone_name( # Note: We need to figure out how to handle this. Otherwise this may result in wrong # Note: zone name getting prepended. continue - with model_attribute_unlock(surface, "private_attribute_full_name"): - surface.private_attribute_full_name = f"{give_zone_name}/{surface.name}" + surface._force_set_attr( # pylint:disable=protected-access + "private_attribute_full_name", f"{give_zone_name}/{surface.name}" + ) def serialize_model_obj_to_id(model_obj: Flow360BaseModel) -> str: diff --git a/flow360/component/simulation/framework/unique_list.py b/flow360/component/simulation/framework/unique_list.py index 6e43a3c5e..9516e009d 100644 --- a/flow360/component/simulation/framework/unique_list.py +++ b/flow360/component/simulation/framework/unique_list.py @@ -1,107 +1,7 @@ -"""Unique list classes for Simulation framework.""" +"""Unique list classes for Simulation framework. Re-import relay from flow360-schema.""" -from collections import OrderedDict -from copy import deepcopy -from typing import Annotated, Any, List, Union - -import pydantic as pd - -from flow360.component.simulation.framework.base_model import Flow360BaseModel - - -class _CombinedMeta(type(Flow360BaseModel), type): - pass - - -class _UniqueListMeta(_CombinedMeta): - def __getitem__(cls, item_types): - """ - Creates a new class with the specified item types as a list. - """ - if not isinstance(item_types, tuple): - item_types = (item_types,) - union_type = Union[item_types] - annotations = {"items": List[union_type]} - new_cls = type( - f"{cls.__name__}[{','.join([t.__name__ if hasattr(t, '__name__') else repr(t) for t in item_types])}]", - (cls,), - {"__annotations__": annotations}, - ) - return new_cls - - -def _remove_duplicates(v: List) -> List: - seen = set() - seen_add = seen.add - return [x for x in v if not (x in seen or seen_add(x))] - - -class UniqueItemList(Flow360BaseModel, metaclass=_UniqueListMeta): - """ - A list of general type items that must be unique - (uniqueness is determined by the item's __eq__ and __hash__ method). - - Duplicates present in the list will be removed. - """ - - items: Annotated[List, {"uniqueItems": True}] - - @pd.field_validator("items", mode="after") - @classmethod - def check_unique(cls, v): - """Check if the items are unique after type checking""" - return _remove_duplicates(v) - - @pd.model_validator(mode="before") - @classmethod - def deserializer(cls, input_data: Union[dict, list, Any]): - """Deserializer handling both JSON dict input as well as Python object input""" - if isinstance(input_data, list): - return {"items": input_data} - if isinstance(input_data, dict): - if "items" not in input_data: - raise KeyError( - f"Invalid input to `entities` [UniqueItemList], dict {input_data} is missing the key 'items'." - ) - return {"items": input_data["items"]} - # Single reference to an entity - return {"items": [input_data]} - - def append(self, obj): - """Append an item to `UniqueItemList`.""" - items_copy = deepcopy(self.items) - items_copy.append(obj) - self.items = items_copy # To trigger validation - - -class UniqueStringList(Flow360BaseModel): - """ - A list of string that must be unique by original name or by aliased name. - Expect string only and we will remove the duplicate ones. - """ - - items: List[str] = pd.Field([]) - - @pd.model_validator(mode="before") - @classmethod - def deserializer(cls, input_data: Union[dict, list, str]): - """Deserializer handling both JSON dict input as well as Python object input""" - if isinstance(input_data, list): - return {"items": input_data} - if isinstance(input_data, dict): - if input_data == {}: - return {"items": []} - return {"items": input_data["items"]} - return {"items": [input_data]} - - @pd.field_validator("items", mode="after") - @classmethod - def ensure_unique(cls, v): - """Deduplicate the list""" - return list(OrderedDict.fromkeys(v)) - - def append(self, obj): - """Append an item to `UniqueStringList`.""" - items_copy = deepcopy(self.items) - items_copy.append(obj) - self.items = items_copy # To trigger validation +# pylint: disable=unused-import +from flow360_schema.framework.unique_list import ( # noqa: F401 + UniqueItemList, + UniqueStringList, +) diff --git a/flow360/component/simulation/outputs/output_entities.py b/flow360/component/simulation/outputs/output_entities.py index 1b80894f5..f31f6b5b6 100644 --- a/flow360/component/simulation/outputs/output_entities.py +++ b/flow360/component/simulation/outputs/output_entities.py @@ -1,349 +1,14 @@ -"""Output for simulation.""" - -from typing import Literal, Optional, Union - -import numpy as np -import pydantic as pd -from flow360_schema.framework.expression import ( - Expression, - UnytQuantity, - UserVariable, - get_input_value_dimensions, - get_input_value_length, - solver_variable_to_user_variable, -) -from flow360_schema.framework.expression.utils import is_runtime_expression -from flow360_schema.framework.physical_dimensions import Length - -from flow360.component.simulation.entity_operation import ( - _transform_direction, - _transform_point, -) -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityBase -from flow360.component.simulation.framework.entity_utils import generate_uuid -from flow360.component.simulation.outputs.output_fields import IsoSurfaceFieldNames -from flow360.component.simulation.user_code.core.types import ( - ValueOrExpression, - infer_units_by_unit_system, - is_variable_with_unit_system_as_units, +"""Output for simulation — re-import relay. + +All definitions migrated to flow360_schema.models.entities.output_entities. +""" + +# pylint: disable=unused-import +from flow360_schema.models.entities.output_entities import ( # noqa: F401 + Isosurface, + Point, + PointArray, + PointArray2D, + Slice, + _OutputItemBase, ) -from flow360.component.types import Axis - - -class _OutputItemBase(Flow360BaseModel): - name: str = pd.Field() - - def __hash__(self): - return hash(self.name + "-" + self.__class__.__name__) - - def __eq__(self, other): - if isinstance(other, _OutputItemBase): - return (self.name + "-" + self.__class__.__name__) == ( - other.name + "-" + other.__class__.__name__ - ) - return False - - def __str__(self): - return f"{self.__class__.__name__} with name: {self.name}" - - -class Slice(EntityBase): - """ - - :class:`Slice` class for defining a slice for :class:`~flow360.SliceOutput`. - - Example - ------- - - Define a :class:`Slice` along (0,1,0) direction with the origin of (0,2,0) fl.u.m. - - >>> fl.Slice( - ... name="Slice", - ... normal=(0, 1, 0), - ... origin=(0, 2, 0)*fl.u.m - ... ) - - ==== - """ - - private_attribute_entity_type_name: Literal["Slice"] = pd.Field("Slice", frozen=True) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - normal: Axis = pd.Field(description="Normal direction of the slice.") - # pylint: disable=no-member - origin: Length.Vector3 = pd.Field(description="A single point on the slice.") - - def _apply_transformation(self, matrix: np.ndarray) -> "Slice": - """Apply 3x4 transformation matrix, returning new transformed instance.""" - # Transform the origin point - origin_array = np.asarray(self.origin.value) - new_origin_array = _transform_point(origin_array, matrix) - new_origin = type(self.origin)(new_origin_array, self.origin.units) - - # Transform and normalize the normal direction - normal_array = np.asarray(self.normal) - transformed_normal = _transform_direction(normal_array, matrix) - new_normal = tuple(transformed_normal / np.linalg.norm(transformed_normal)) - - return self.model_copy(update={"origin": new_origin, "normal": new_normal}) - - -class Isosurface(_OutputItemBase): - """ - - :class:`Isosurface` class for defining an isosurface for :class:`~flow360.IsosurfaceOutput`. - - Example - ------- - - Define a :class:`Isosurface` of temperature equal to 1.5 non-dimensional temperature. - - >>> fl.Isosurface( - ... name="Isosurface_T_1.5", - ... iso_value=1.5, - ... field="T", - ... wallDistanceClipThreshold=0.005 * fl.u.m, (optional) - ... ) - - ==== - """ - - field: Union[IsoSurfaceFieldNames, str, UserVariable] = pd.Field( - description="Isosurface field variable. One of :code:`p`, :code:`rho`, " - ":code:`Mach`, :code:`qcriterion`, :code:`s`, :code:`T`, :code:`Cp`, :code:`mut`," - " :code:`nuHat` or one of scalar field defined in :class:`UserDefinedField`." - ) - # pylint: disable=fixme - iso_value: ValueOrExpression[Union[UnytQuantity, float]] = pd.Field( - description="Expect non-dimensional value.", - ) - - # pylint: disable=no-member - wall_distance_clip_threshold: Optional[Length.PositiveFloat64] = pd.Field( - default=None, - description="Optional parameter to remove the isosurface within a specified distance from walls.", - ) - - @pd.field_validator("field", mode="before") - @classmethod - def _preprocess_expression_and_solver_variable(cls, value): - if isinstance(value, Expression): - raise ValueError( - f"Expression ({value}) cannot be directly used as isosurface field, " - "please define a UserVariable first." - ) - return solver_variable_to_user_variable(value) - - @pd.field_validator("iso_value", mode="before") - @classmethod - def _preprocess_field_with_unit_system(cls, value, info: pd.ValidationInfo): - if is_variable_with_unit_system_as_units(value): - return value - if info.data.get("field") is None: - # `field` validation failed. - raise ValueError( - "The isosurface field is invalid and therefore unit inference is not possible." - ) - units = value["units"] - field = info.data["field"] - field_dimensions = get_input_value_dimensions(value=field) - value = infer_units_by_unit_system( - value=value, value_dimensions=field_dimensions, unit_system=units - ) - return value - - @pd.field_validator("field", mode="after") - @classmethod - def check_expression_length(cls, v): - """Ensure the isofield is a scalar.""" - if isinstance(v, UserVariable) and len(v) != 0: - raise ValueError(f"The isosurface field ({v}) must be defined with a scalar variable.") - return v - - @pd.field_validator("field", mode="after") - @classmethod - def check_runtime_expression(cls, v): - """Ensure the isofield is a runtime expression but not a constant value.""" - if isinstance(v, UserVariable): - if not isinstance(v.value, Expression): - raise ValueError(f"The isosurface field ({v}) cannot be a constant value.") - try: - result = v.value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - except Exception as err: - raise ValueError(f"expression evaluation failed for the isofield: {err}") from err - if not is_runtime_expression(result): - raise ValueError(f"The isosurface field ({v}) cannot be a constant value.") - return v - - @pd.field_validator("iso_value", mode="after") - @classmethod - def check_single_iso_value(cls, v): - """Ensure the iso_value is a single value.""" - if get_input_value_length(v) == 0: - return v - raise ValueError(f"The iso_value ({v}) must be a scalar.") - - @pd.field_validator("iso_value", mode="after") - @classmethod - def check_iso_value_dimensions(cls, v, info: pd.ValidationInfo): - """Ensure the iso_value has the same dimensions as the field.""" - - field = info.data.get("field", None) - if not isinstance(field, UserVariable): - return v - value_dimensions = get_input_value_dimensions(value=v) - if value_dimensions is None: - return v - field_dimensions = get_input_value_dimensions(value=field) - if field_dimensions != value_dimensions: - raise ValueError( - f"The iso_value ({v}, dimensions:{value_dimensions}) should have the same dimensions as " - f"the isosurface field ({field}, dimensions: {field_dimensions})." - ) - return v - - @pd.field_validator("iso_value", mode="after") - @classmethod - def check_iso_value_for_string_field(cls, v, info: pd.ValidationInfo): - """Ensure the iso_value is float when string field is used.""" - - field = info.data.get("field", None) - if isinstance(field, str) and not isinstance(v, float): - raise ValueError( - f"The isosurface field ({field}) specified by string " - "can only be used with a nondimensional iso_value." - ) - return v - - -class Point(EntityBase): - """ - :class:`Point` class for defining a single point used in various outputs. - - Example - ------- - - >>> fl.Point( - ... name="Point", - ... location=(1.0, 2.0, 3.0) * fl.u.m, - ... ) - - ==== - """ - - private_attribute_entity_type_name: Literal["Point"] = pd.Field("Point", frozen=True) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - # pylint: disable=no-member - location: Length.Vector3 = pd.Field(description="The coordinate of the point.") - - def _apply_transformation(self, matrix: np.ndarray) -> "Point": - """Apply 3x4 transformation matrix, returning new transformed instance.""" - location_array = np.asarray(self.location.value) - new_location_array = _transform_point(location_array, matrix) - new_location = type(self.location)(new_location_array, self.location.units) - return self.model_copy(update={"location": new_location}) - - -class PointArray(EntityBase): - """ - :class:`PointArray` class for defining multiple equally spaced monitor points along a line used in various outputs. - - Example - ------- - Define :class:`PointArray` with 6 equally spaced points along a line starting from - (0,0,0) * fl.u.m to (1,2,3) * fl.u.m. - Both the starting and end points are included in the :class:`PointArray`. - - >>> fl.PointArray( - ... name="Line_1", - ... start=(0.0, 0.0, 0.0) * fl.u.m, - ... end=(1.0, 2.0, 3.0) * fl.u.m, - ... number_of_points=6, - ... ) - - ==== - """ - - private_attribute_entity_type_name: Literal["PointArray"] = pd.Field("PointArray", frozen=True) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - # pylint: disable=no-member - start: Length.Vector3 = pd.Field(description="The starting point of the line.") - end: Length.Vector3 = pd.Field(description="The end point of the line.") - number_of_points: int = pd.Field(ge=2, description="Number of points along the line.") - - def _apply_transformation(self, matrix: np.ndarray) -> "PointArray": - """Apply 3x4 transformation matrix, returning new transformed instance.""" - start_array = np.asarray(self.start.value) - end_array = np.asarray(self.end.value) - - new_start_array = _transform_point(start_array, matrix) - new_end_array = _transform_point(end_array, matrix) - - new_start = type(self.start)(new_start_array, self.start.units) - new_end = type(self.end)(new_end_array, self.end.units) - - return self.model_copy(update={"start": new_start, "end": new_end}) - - -class PointArray2D(EntityBase): - """ - :class:`PointArray2D` class for defining multiple equally spaced points along the u and - v axes of a parallelogram. - - - Example - ------- - Define :class:`PointArray2D` with points equally distributed on a parallelogram with - origin (1.0, 0.0, 0.0) * fl.u.m. There are 7 equally spaced points along the parallelogram's u-axis - of (0.5, 1.0, 0.2) * fl.u.m and 10 equally spaced points along the its v-axis of - (0.1, 0, 1) * fl.u.m. - - Both the starting and end points are included in the :class:`PointArray`. - - >>> fl.PointArray2D( - ... name="Parallelogram_1", - ... origin=(1.0, 0.0, 0.0) * fl.u.m, - ... u_axis_vector=(0.5, 1.0, 0.2) * fl.u.m, - ... v_axis_vector=(0.1, 0, 1) * fl.u.m, - ... u_number_of_points=7, - ... v_number_of_points=10 - ... ) - - ==== - """ - - private_attribute_entity_type_name: Literal["PointArray2D"] = pd.Field( - "PointArray2D", frozen=True - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - # pylint: disable=no-member - origin: Length.Vector3 = pd.Field(description="The corner of the parallelogram.") - u_axis_vector: Length.NonNullVector3 = pd.Field( - description="The scaled u-axis of the parallelogram." - ) - v_axis_vector: Length.NonNullVector3 = pd.Field( - description="The scaled v-axis of the parallelogram." - ) - u_number_of_points: int = pd.Field(ge=2, description="The number of points along the u axis.") - v_number_of_points: int = pd.Field(ge=2, description="The number of points along the v axis.") - - def _apply_transformation(self, matrix: np.ndarray) -> "PointArray2D": - """Apply 3x4 transformation matrix, returning new transformed instance.""" - # Transform the origin point - origin_array = np.asarray(self.origin.value) - new_origin_array = _transform_point(origin_array, matrix) - new_origin = type(self.origin)(new_origin_array, self.origin.units) - - # Transform the u and v axis vectors (these are scaled directions, not unit vectors) - u_axis_array = np.asarray(self.u_axis_vector.value) - v_axis_array = np.asarray(self.v_axis_vector.value) - - new_u_axis_array = _transform_direction(u_axis_array, matrix) - new_v_axis_array = _transform_direction(v_axis_array, matrix) - - new_u_axis = type(self.u_axis_vector)(new_u_axis_array, self.u_axis_vector.units) - new_v_axis = type(self.v_axis_vector)(new_v_axis_array, self.v_axis_vector.units) - - return self.model_copy( - update={"origin": new_origin, "u_axis_vector": new_u_axis, "v_axis_vector": new_v_axis} - ) diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 6b6866243..a9fd27987 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -1,104 +1,54 @@ -# pylint: disable=too-many-lines """ Primitive type definitions for simulation entities. + +Re-import relay: all entity classes are defined in flow360_schema.models.entities. +Only ReferenceGeometry and VolumeEntityTypes remain client-owned. """ -import re -from abc import ABCMeta, abstractmethod -from typing import Annotated, ClassVar, List, Literal, Optional, Tuple, Union, final +# pylint: disable=unused-import +from typing import Optional, Union -import numpy as np import pydantic as pd -from flow360_schema.framework.physical_dimensions import Angle, Area, Length -from pydantic import PositiveFloat -from typing_extensions import Self - -import flow360.component.simulation.units as u -from flow360.component.simulation.entity_operation import ( - _extract_rotation_matrix, - _rotation_matrix_to_axis_angle, - _transform_direction, - _validate_uniform_scale_and_transform_center, - rotation_matrix_from_axis_and_angle, +from flow360_schema.framework.physical_dimensions import Area, Length +from flow360_schema.models.entities import ( + BOUNDARY_FULL_NAME_WHEN_NOT_FOUND, + AxisymmetricBody, + Box, + BoxCache, + CustomVolume, + Cylinder, + Edge, + GenericVolume, + GeometryBodyGroup, + GhostCircularPlane, + GhostSphere, + GhostSurface, + GhostSurfacePair, + ImportedSurface, + MirroredGeometryBodyGroup, + MirroredSurface, + OrthogonalAxes, + SeedpointVolume, + SnappyBody, + Sphere, + Surface, + SurfacePair, + SurfacePairBase, + SurfacePrivateAttributes, + WindTunnelGhostSurface, + _auto_symmetric_plane_exists_from_bbox, + _check_axis_is_orthogonal, + _get_generated_boundary_names, + _MirroredEntityBase, + _SurfaceEntityBase, + _VolumeEntityBase, + compute_bbox_tolerance, ) + from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityBase, EntityList -from flow360.component.simulation.framework.entity_utils import generate_uuid -from flow360.component.simulation.framework.multi_constructor_model_base import ( - MultiConstructorBaseModel, -) -from flow360.component.simulation.framework.unique_list import UniqueStringList from flow360.component.simulation.user_code.core.types import ValueOrExpression -from flow360.component.simulation.utils import BoundingBoxType, model_attribute_unlock -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - contextual_field_validator, - contextual_model_validator, -) -from flow360.component.types import Axis -from flow360.component.utils import _naming_pattern_handler -from flow360.exceptions import Flow360DeprecationError - -BOUNDARY_FULL_NAME_WHEN_NOT_FOUND = "This boundary does not exist!!!" - - -def _get_generated_boundary_names(surface_name: str, volume_mesh_meta: dict[str, dict]) -> list: - """ - - Returns all the boundaries that are eventually generated by name matching in the volume mesh metadata. - - May return multiple boundaries when the original one is split into multiple boundaries. - - """ - full_boundary_names = [] - - for zone_name, zone_meta in volume_mesh_meta["zones"].items(): - for existing_boundary_name in zone_meta["boundaryNames"]: - pattern = re.escape(zone_name) + r"/(.*)" - match = re.search(pattern, existing_boundary_name) - if ( - match is not None and match.group(1) == surface_name - ) or existing_boundary_name == surface_name: - full_boundary_names.append(existing_boundary_name) - - # Not found - if not full_boundary_names: - return [BOUNDARY_FULL_NAME_WHEN_NOT_FOUND] - - return full_boundary_names - - -def _check_axis_is_orthogonal(axis_pair: Tuple[Axis, Axis]) -> Tuple[Axis, Axis]: - axis_1, axis_2 = np.array(axis_pair[0]), np.array(axis_pair[1]) - dot_product = np.dot(axis_1, axis_2) - if not np.isclose(dot_product, 0, atol=1e-3): - raise ValueError(f"The two axes are not orthogonal, dot product is {dot_product}.") - axis_2 -= dot_product * axis_1 - axis_2 /= np.linalg.norm(axis_2) - return (tuple(axis_1), tuple(axis_2)) - - -def _auto_symmetric_plane_exists_from_bbox( - *, - global_bounding_box: BoundingBoxType, - planar_face_tolerance: float, -) -> bool: - """ - Determine whether automated farfield logic will generate a `symmetric` plane - from global bounding box extents and planar-face tolerance. - """ - - y_min = global_bounding_box[0][1] - y_max = global_bounding_box[1][1] - tolerance = global_bounding_box.largest_dimension * planar_face_tolerance - positive_half = abs(y_min) < tolerance < y_max - negative_half = abs(y_max) < tolerance and y_min < -tolerance - - return positive_half or negative_half - - -OrthogonalAxes = Annotated[Tuple[Axis, Axis], pd.AfterValidator(_check_axis_is_orthogonal)] +VolumeEntityTypes = Union[GenericVolume, Cylinder, Sphere, Box, str] class ReferenceGeometry(Flow360BaseModel): @@ -142,11 +92,6 @@ def fill_defaults(cls, ref, params): # type: ignore[override] - moment_center: (0,0,0) * base_length - moment_length: (1,1,1) * base_length """ - # Note: - # This helper avoids scattering default logic; consumers can always call this - # to obtain a fully-specified reference geometry in solver units. - # `params.base_length` provides the length unit for the project. - # Determine base length unit from params base_length_unit = params.base_length # LengthType quantity @@ -168,1154 +113,3 @@ def fill_defaults(cls, ref, params): # type: ignore[override] moment_length = (1.0, 1.0, 1.0) * base_length_unit return cls(area=area, moment_center=moment_center, moment_length=moment_length) - - -class GeometryBodyGroup(EntityBase): - """ - :class:`GeometryBodyGroup` represents a collection of bodies that are grouped for meshing and - coordinate-system-based transformation. - """ - - private_attribute_tag_key: str = pd.Field( - description="The tag/attribute string used to group bodies.", - ) - private_attribute_entity_type_name: Literal["GeometryBodyGroup"] = pd.Field( - "GeometryBodyGroup", frozen=True - ) - private_attribute_sub_components: List[str] = pd.Field( - description="A list of body IDs which constitutes the current body group" - ) - private_attribute_color: Optional[str] = pd.Field( - None, description="Color used for visualization" - ) - mesh_exterior: bool = pd.Field( - True, - description="Option to define whether to mesh exterior or interior of body group in geometry AI." - "Note that this is a beta feature and the interface might change in future releases.", - ) - - @property - def transformation(self): - """Deprecated property.""" - raise Flow360DeprecationError( - "GeometryBodyGroup.transformation is deprecated and has been removed. " - "Please use CoordinateSystem for transformations instead." - ) - - @transformation.setter - def transformation(self, value): - """Deprecated property setter.""" - raise Flow360DeprecationError( - "GeometryBodyGroup.transformation is deprecated and has been removed. " - "Please use CoordinateSystem for transformations instead." - ) - - -class _VolumeEntityBase(EntityBase, metaclass=ABCMeta): - """All volumetric entities should inherit from this class.""" - - private_attribute_zone_boundary_names: UniqueStringList = pd.Field( - UniqueStringList(), - frozen=True, - description="Boundary names of the zone WITH the prepending zone name.", - ) - private_attribute_full_name: Optional[str] = pd.Field(None, frozen=True) - - def _is_volume_zone(self) -> bool: - """This is not a zone if zone boundaries are not defined. For validation usage.""" - return self.private_attribute_zone_boundary_names is not None - - def _update_entity_info_with_metadata(self, volume_mesh_meta_data: dict[str, dict]) -> None: - """ - Update the full name of zones once the volume mesh is done. - e.g. rotating_cylinder --> rotatingBlock-rotating_cylinder - """ - entity_name = self.name - for zone_full_name, zone_meta in volume_mesh_meta_data["zones"].items(): - pattern = r"rotatingBlock-" + re.escape(entity_name) - if entity_name == "__farfield_zone_name_not_properly_set_yet": - # We have hardcoded name for farfield zone. - pattern = r"stationaryBlock|fluid" - match = re.search(pattern, zone_full_name) - if match is not None or entity_name == zone_full_name: - with model_attribute_unlock(self, "private_attribute_full_name"): - self.private_attribute_full_name = zone_full_name - with model_attribute_unlock(self, "private_attribute_zone_boundary_names"): - self.private_attribute_zone_boundary_names = UniqueStringList( - items=zone_meta["boundaryNames"] - ) - break - - @property - def full_name(self): - """Gets the full name which includes the zone name""" - if self.private_attribute_full_name is None: - return self.name - return self.private_attribute_full_name - - -class _SurfaceEntityBase(EntityBase, metaclass=ABCMeta): - private_attribute_full_name: Optional[str] = pd.Field(None, frozen=True) - - def _update_entity_info_with_metadata(self, volume_mesh_meta_data: dict) -> None: - """ - Update parent zone name once the volume mesh is done. - """ - updated_boundary_names = _get_generated_boundary_names(self.name, volume_mesh_meta_data) - - with model_attribute_unlock(self, "private_attribute_full_name"): - self.private_attribute_full_name = updated_boundary_names.pop(0) - - multiplication_result = [] - for new_boundary_name in updated_boundary_names: - multiplication_result.append( - self.copy( - update={ - "name": new_boundary_name, - "private_attribute_full_name": new_boundary_name, - } - ) - ) - - return multiplication_result if multiplication_result else None - - @property - def full_name(self): - """Gets the full name which includes the zone name""" - if self.private_attribute_full_name is None: - return self.name - return self.private_attribute_full_name - - -@final -class Edge(EntityBase): - """ - Edge which contains a set of grouped edges from geometry. - """ - - private_attribute_entity_type_name: Literal["Edge"] = pd.Field("Edge", frozen=True) - private_attribute_tag_key: Optional[str] = pd.Field( - None, - description="The tag/attribute string used to group geometry edges to form this `Edge`.", - ) - private_attribute_sub_components: Optional[List[str]] = pd.Field( - [], description="The edge ids in geometry that composed into this `Edge`." - ) - - -@final -class GenericVolume(_VolumeEntityBase): - """ - Do not expose. - This type of entity will get auto-constructed by assets when loading metadata. - By design these GenericVolume entities should only contain basic connectivity/mesh information. - These can only come from uploaded volume mesh. - """ - - private_attribute_entity_type_name: Literal["GenericVolume"] = pd.Field( - "GenericVolume", frozen=True - ) - axes: Optional[OrthogonalAxes] = pd.Field(None, description="") # Porous media support - axis: Optional[Axis] = pd.Field(None) # Rotation support - # pylint: disable=no-member - center: Optional[Length.Vector3] = pd.Field(None, description="") # Rotation support - - -class BoxCache(Flow360BaseModel): - """BoxCache""" - - # `axes` will always exist as it needs to be used. So `axes` is more like a storage than input cache. - axes: Optional[OrthogonalAxes] = pd.Field(None) - # pylint: disable=no-member - center: Optional[Length.Vector3] = pd.Field(None) - size: Optional[Length.PositiveVector3] = pd.Field(None) - name: Optional[str] = pd.Field(None) - - -@final -class Box(MultiConstructorBaseModel, _VolumeEntityBase): - """ - :class:`Box` class represents a box in three-dimensional space. - - Example - ------- - >>> fl.Box( - ... name="box", - ... axis_of_rotation = (1, 0, 0), - ... angle_of_rotation = 45 * fl.u.deg, - ... center = (1, 1, 1) * fl.u.m, - ... size=(0.2, 0.3, 2) * fl.u.m, - ... ) - - Define a box using principal axes: - - >>> fl.Box.from_principal_axes( - ... name="box", - ... axes=[(0, 1, 0), (0, 0, 1)], - ... center=(0, 0, 0) * fl.u.m, - ... size=(0.2, 0.3, 2) * fl.u.m, - ... ) - - ==== - """ - - type_name: Literal["Box"] = pd.Field("Box", frozen=True) - # pylint: disable=no-member - center: Length.Vector3 = pd.Field(description="The coordinates of the center of the box.") - size: Length.PositiveVector3 = pd.Field( - description="The dimensions of the box (length, width, height)." - ) - axis_of_rotation: Axis = pd.Field( - default=(0, 0, 1), - description="The rotation axis. Cannot change once specified.", - frozen=True, - ) - angle_of_rotation: Angle.Float64 = pd.Field( - default=0 * u.degree, - description="The rotation angle. Cannot change once specified.", - frozen=True, - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - private_attribute_input_cache: BoxCache = pd.Field(BoxCache(), frozen=True) - private_attribute_entity_type_name: Literal["Box"] = pd.Field("Box", frozen=True) - - # pylint: disable=no-self-argument - @MultiConstructorBaseModel.model_constructor - @pd.validate_call - def from_principal_axes( - cls, - name: str, - center: Length.Vector3, - size: Length.PositiveVector3, - axes: OrthogonalAxes, - ): - """ - Construct box from principal axes - """ - - # validate - x_axis, y_axis = np.array(axes[0]), np.array(axes[1]) - z_axis = np.cross(x_axis, y_axis) - - rotation_matrix = np.transpose(np.asarray([x_axis, y_axis, z_axis], dtype=np.float64)) - - # Calculate the rotation axis n using numpy instead of scipy - eigvals, eigvecs = np.linalg.eig(rotation_matrix) - axis = np.real(eigvecs[:, np.where(np.isreal(eigvals))]) - if axis.shape[2] > 1: # in case of 0 rotation angle - axis = axis[:, :, 0] - axis = np.ndarray.flatten(axis) - - angle = np.sum(abs(np.angle(eigvals))) / 2 - - # Find correct angle - matrix_test = rotation_matrix_from_axis_and_angle(axis, angle) - angle *= -1 if np.isclose(rotation_matrix[0, :] @ matrix_test[:, 0], 1) else 1 - - # pylint: disable=not-callable - return cls( - name=name, - center=center, - size=size, - axis_of_rotation=tuple(axis), - angle_of_rotation=angle * u.rad, - ) - - @pd.model_validator(mode="after") - def _convert_axis_and_angle_to_coordinate_axes(self) -> Self: - """ - Converts the Box object's axis and angle orientation information to a - coordinate axes representation. - """ - # Ensure the axis is a numpy array - if not self.private_attribute_input_cache.axes: - axis = np.asarray(self.axis_of_rotation, dtype=np.float64) - angle = self.angle_of_rotation.to("rad").v.item() - - # Normalize the axis vector - axis = axis / np.linalg.norm(axis) - - rotation_matrix = rotation_matrix_from_axis_and_angle(axis, angle) - - # pylint: disable=assigning-non-slot - self.private_attribute_input_cache.axes = np.transpose(rotation_matrix[:, :2]).tolist() - - return self - - @property - def axes(self): - """Return the axes that the box is aligned with.""" - return self.private_attribute_input_cache.axes - - @pd.field_validator("center", "size", mode="after") - @classmethod - def _update_input_cache(cls, value, info: pd.ValidationInfo): - setattr(info.data["private_attribute_input_cache"], info.field_name, value) - return value - - def _apply_transformation(self, matrix: np.ndarray) -> "Box": - """Apply 3x4 transformation matrix with uniform scale validation and rotation composition.""" - new_center, uniform_scale = _validate_uniform_scale_and_transform_center( - matrix, self.center, "Box" - ) - - # Combine rotations: existing rotation + transformation rotation - # Step 1: Get existing rotation matrix from axis-angle - existing_axis = np.asarray(self.axis_of_rotation, dtype=np.float64) - existing_axis = existing_axis / np.linalg.norm(existing_axis) - existing_angle = self.angle_of_rotation.to("rad").v.item() - rot_existing = rotation_matrix_from_axis_and_angle(existing_axis, existing_angle) - - # Step 2: Extract pure rotation from transformation matrix - rot_transform = _extract_rotation_matrix(matrix) - - # Step 3: Combine rotations (apply transformation rotation to existing) - rot_combined = rot_transform @ rot_existing - - # Step 4: Extract new axis-angle from combined rotation - new_axis, new_angle = _rotation_matrix_to_axis_angle(rot_combined) - - # Scale size uniformly - new_size = self.size * uniform_scale - - return self.model_copy( - update={ - "center": new_center, - "axis_of_rotation": tuple(new_axis), - "angle_of_rotation": new_angle * u.rad, - "size": new_size, - } - ) - - -@final -class Sphere(_VolumeEntityBase): - """ - :class:`Sphere` class represents a sphere in three-dimensional space. - - Example - ------- - >>> fl.Sphere( - ... name="sphere_zone", - ... center=(0, 0, 0) * fl.u.m, - ... radius=1.5 * fl.u.m, - ... axis=(0, 0, 1), - ... ) - - ==== - """ - - private_attribute_entity_type_name: Literal["Sphere"] = pd.Field("Sphere", frozen=True) - # pylint: disable=no-member - center: Length.Vector3 = pd.Field(description="The center point of the sphere.") - radius: Length.PositiveFloat64 = pd.Field(description="The radius of the sphere.") - axis: Axis = pd.Field( - default=(0, 0, 1), - description="The axis of rotation for the sphere (used in sliding interfaces).", - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - def _apply_transformation(self, matrix: np.ndarray) -> "Sphere": - """Apply 3x4 transformation matrix with uniform scale validation.""" - new_center, uniform_scale = _validate_uniform_scale_and_transform_center( - matrix, self.center, "Sphere" - ) - - # Rotate axis - axis_array = np.asarray(self.axis) - transformed_axis = _transform_direction(axis_array, matrix) - new_axis = tuple(transformed_axis / np.linalg.norm(transformed_axis)) - - # Scale radius uniformly - new_radius = self.radius * uniform_scale - - return self.model_copy( - update={ - "center": new_center, - "axis": new_axis, - "radius": new_radius, - } - ) - - -@final -class Cylinder(_VolumeEntityBase): - """ - :class:`Cylinder` class represents a cylinder in three-dimensional space. - - Example - ------- - >>> fl.Cylinder( - ... name="bet_disk_volume", - ... center=(0, 0, 0) * fl.u.inch, - ... axis=(0, 0, 1), - ... outer_radius=150 * fl.u.inch, - ... height=15 * fl.u.inch, - ... ) - - ==== - """ - - private_attribute_entity_type_name: Literal["Cylinder"] = pd.Field("Cylinder", frozen=True) - axis: Axis = pd.Field(description="The axis of the cylinder.") - # pylint: disable=no-member - center: Length.Vector3 = pd.Field(description="The center point of the cylinder.") - height: Length.PositiveFloat64 = pd.Field(description="The height of the cylinder.") - inner_radius: Optional[Length.NonNegativeFloat64] = pd.Field( - 0 * u.m, description="The inner radius of the cylinder." - ) - outer_radius: Length.PositiveFloat64 = pd.Field(description="The outer radius of the cylinder.") - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @pd.model_validator(mode="after") - def _check_inner_radius_is_less_than_outer_radius(self) -> Self: - if self.inner_radius is not None and self.inner_radius >= self.outer_radius: - raise ValueError( - f"Cylinder inner radius ({self.inner_radius}) must be less than outer radius ({self.outer_radius})." - ) - return self - - def _apply_transformation(self, matrix: np.ndarray) -> "Cylinder": - """Apply 3x4 transformation matrix with uniform scale validation.""" - new_center, uniform_scale = _validate_uniform_scale_and_transform_center( - matrix, self.center, "Cylinder" - ) - - # Rotate axis - axis_array = np.asarray(self.axis) - transformed_axis = _transform_direction(axis_array, matrix) - new_axis = tuple(transformed_axis / np.linalg.norm(transformed_axis)) - - # Scale dimensions uniformly - new_height = self.height * uniform_scale - new_outer_radius = self.outer_radius * uniform_scale - new_inner_radius = ( - self.inner_radius * uniform_scale if self.inner_radius is not None else None - ) - - return self.model_copy( - update={ - "center": new_center, - "axis": new_axis, - "height": new_height, - "outer_radius": new_outer_radius, - "inner_radius": new_inner_radius, - } - ) - - -@final -class AxisymmetricBody(_VolumeEntityBase): - """ - :class:`AxisymmetricBody` class represents a generic body of revolution in three-dimensional space, - represented as a list[(Axial Position, Radial Extent)] profile polyline with arbitrary center and axial direction. - Expect first and last profile samples to connect to axis, i.e., have radius = 0. - - Example - ------- - >>> fl.AxisymmetricBody( - ... name="cone_frustum_body", - ... center=(0, 0, 0) * fl.u.inch, - ... axis=(0, 0, 1), - ... profile_curve = [(-1, 0) * fl.u.inch, (-1, 1) * fl.u.inch, (1, 2) * fl.u.inch, (1, 0) * fl.u.inch] - ... ) - - ==== - """ - - private_attribute_entity_type_name: Literal["AxisymmetricBody"] = pd.Field( - "AxisymmetricBody", frozen=True - ) - axis: Axis = pd.Field(description="The axis of the body of revolution.") - # pylint: disable=no-member - center: Length.Vector3 = pd.Field(description="The center point of the body of revolution.") - profile_curve: List[Length.Vector2] = pd.Field( - description="The (Axial, Radial) profile of the body of revolution.", - min_length=2, - ) - - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @pd.field_validator("profile_curve", mode="after") - @classmethod - def _check_radial_profile_is_positive(cls, curve): - first_point = curve[0] - if first_point[1] != 0: - raise ValueError( - f"Expect first profile sample to be (Axial, 0.0). Found invalid point: {str(first_point)}." - ) - - last_point = curve[-1] - if last_point[1] != 0: - raise ValueError( - f"Expect last profile sample to be (Axial, 0.0). Found invalid point: {str(last_point)}." - ) - - for profile_point in curve[1:-1]: - if profile_point[1] < 0: - raise ValueError( - f"Expect profile samples to be (Axial, Radial) samples with positive Radial." - f" Found invalid point: {str(profile_point)}." - ) - - return curve - - @pd.field_validator("profile_curve", mode="after") - @classmethod - def _check_profile_curve_has_no_duplicates(cls, curve): - for i in range(len(curve) - 1): - p1, p2 = curve[i], curve[i + 1] - if p1[0] == p2[0] and p1[1] == p2[1]: - raise ValueError( - f"Profile curve has duplicate consecutive points at indices {i} and {i + 1}: {str(p1)}." - ) - - return curve - - def _apply_transformation(self, matrix: np.ndarray) -> "AxisymmetricBody": - """Apply 3x4 transformation matrix with uniform scale validation.""" - new_center, uniform_scale = _validate_uniform_scale_and_transform_center( - matrix, self.center, "AxisymmetricBody" - ) - - # Rotate axis - axis_array = np.asarray(self.axis) - transformed_axis = _transform_direction(axis_array, matrix) - new_axis = tuple(transformed_axis / np.linalg.norm(transformed_axis)) - - # Scale profile curve uniformly - new_profile_curve = [] - for point in self.profile_curve: - point_array = np.asarray(point.value) - scaled_point_array = point_array * uniform_scale - new_profile_curve.append(type(point)(scaled_point_array, point.units)) - - return self.model_copy( - update={ - "center": new_center, - "axis": new_axis, - "profile_curve": new_profile_curve, - } - ) - - -class SurfacePrivateAttributes(Flow360BaseModel): - """ - Private attributes for Surface. - TODO: With the amount of private_attribute prefixes we have - TODO: Maybe it makes more sense to lump them together to save storage space? - """ - - type_name: Literal["SurfacePrivateAttributes"] = pd.Field( - "SurfacePrivateAttributes", frozen=True - ) - bounding_box: BoundingBoxType = pd.Field(description="Bounding box of the surface.") - - -@final -class Surface(_SurfaceEntityBase): - """ - :class:`Surface` represents a boundary surface in three-dimensional space. - """ - - private_attribute_entity_type_name: Literal["Surface"] = pd.Field("Surface", frozen=True) - private_attribute_is_interface: Optional[bool] = pd.Field( - None, - frozen=True, - description="This is required when generated from volume mesh " - + "but not required when from surface mesh meta.", - ) - private_attribute_tag_key: Optional[str] = pd.Field( - None, - description="The tag/attribute string used to group geometry faces to form this `Surface`.", - ) - private_attribute_sub_components: Optional[List[str]] = pd.Field( - [], description="The face ids in geometry that composed into this `Surface`." - ) - private_attribute_color: Optional[str] = pd.Field( - None, description="Color used for visualization" - ) - private_attributes: Optional[SurfacePrivateAttributes] = pd.Field(None) - - # Note: private_attribute_id should not be `Optional` anymore. - # B.C. Updater and geometry pipeline will populate it. - - def _overlaps(self, ghost_surface_center_y: Optional[float], length_tolerance: float) -> bool: - if self.private_attributes is None: - # Legacy cloud asset. - return False - # pylint: disable=no-member - my_bounding_box = self.private_attributes.bounding_box - if abs(my_bounding_box.ymax - ghost_surface_center_y) > length_tolerance: - return False - if abs(my_bounding_box.ymin - ghost_surface_center_y) > length_tolerance: - return False - return True - - def _will_be_deleted_by_mesher( - # pylint: disable=too-many-arguments, too-many-return-statements, too-many-branches - self, - entity_transformation_detected: bool, - farfield_method: Optional[ - Literal["auto", "quasi-3d", "quasi-3d-periodic", "user-defined", "wind-tunnel"] - ], - global_bounding_box: Optional[BoundingBoxType], - planar_face_tolerance: Optional[float], - half_model_symmetry_plane_center_y: Optional[float], - quasi_3d_symmetry_planes_center_y: Optional[tuple[float]], - farfield_domain_type: Optional[str] = None, - gai_and_beta_mesher: Optional[bool] = False, - ) -> bool: - """ - Check against the automated farfield method and - determine if the current `Surface` will be deleted by the mesher. - """ - if entity_transformation_detected: - # If transformed then the following check will no longer be accurate - # since we do not know the final bounding box for each surface and global model. - return False - - if global_bounding_box is None or planar_face_tolerance is None or farfield_method is None: - # VolumeMesh or Geometry/SurfaceMesh with legacy schema. - return False - - length_tolerance = global_bounding_box.largest_dimension * planar_face_tolerance - - if farfield_domain_type in ("half_body_positive_y", "half_body_negative_y"): - if self.private_attributes is not None: - # pylint: disable=no-member - y_min = self.private_attributes.bounding_box.ymin - y_max = self.private_attributes.bounding_box.ymax - - if farfield_domain_type == "half_body_positive_y" and y_max < -length_tolerance: - return True - - if farfield_domain_type == "half_body_negative_y" and y_min > length_tolerance: - return True - - if farfield_method == "wind-tunnel": - # Not applicable to wind tunnel farfield - return False - - if farfield_method in ("auto", "user-defined"): - if half_model_symmetry_plane_center_y is None: - # Legacy schema. - return False - if farfield_method == "user-defined" and not gai_and_beta_mesher: - return False - if ( - farfield_method == "auto" - and farfield_domain_type not in ("half_body_positive_y", "half_body_negative_y") - and ( - not _auto_symmetric_plane_exists_from_bbox( - global_bounding_box=global_bounding_box, - planar_face_tolerance=planar_face_tolerance, - ) - ) - ): - return False - return self._overlaps(half_model_symmetry_plane_center_y, length_tolerance) - - if farfield_method in ("quasi-3d", "quasi-3d-periodic"): - if quasi_3d_symmetry_planes_center_y is None: - # Legacy schema. - return False - for plane_center_y in quasi_3d_symmetry_planes_center_y: - if self._overlaps(plane_center_y, length_tolerance): - return True - return False - - raise ValueError(f"Unknown auto farfield generation method: {farfield_method}.") - - -@final -class ImportedSurface(EntityBase): - """ImportedSurface for post-processing""" - - private_attribute_entity_type_name: Literal["ImportedSurface"] = pd.Field( - "ImportedSurface", frozen=True - ) - - private_attribute_sub_components: Optional[List[str]] = pd.Field( - None, description="A list of sub components" - ) - file_name: Optional[str] = None - surface_mesh_id: Optional[str] = None - - @pd.model_validator(mode="after") - def _populate_id_from_name(self) -> "ImportedSurface": - """Ensure a deterministic private_attribute_id exists. - - CoordinateSystemManager and MirrorManager use private_attribute_id as - a dict key for entity tracking. A deterministic id derived from name - guarantees the same ImportedSurface always resolves to the same id, - even when reconstructed from cloud metadata across sessions. - """ - if self.private_attribute_id is None: - object.__setattr__(self, "private_attribute_id", f"{self.name}_defaultBody") - return self - - -class GhostSurface(_SurfaceEntityBase): - """ - Represents a boundary surface that may or may not be generated therefore may or may not exist. - It depends on the submitted geometry/Surface mesh. E.g. the symmetry plane in `AutomatedFarfield`. - - This is a token/place-holder used only on the Python API side. - All `GhostSurface` entities will be replaced with exact entity instances before simulation.json submission. - """ - - name: str = pd.Field(frozen=True) - - private_attribute_entity_type_name: Literal["GhostSurface"] = pd.Field( - "GhostSurface", frozen=True - ) - - -class WindTunnelGhostSurface(GhostSurface): - """Wind tunnel boundary patches.""" - - private_attribute_entity_type_name: Literal["WindTunnelGhostSurface"] = pd.Field( - "WindTunnelGhostSurface", frozen=True - ) - # For frontend: list of floor types that use this boundary patch, or ["all"] - used_by: List[ - Literal["StaticFloor", "FullyMovingFloor", "CentralBelt", "WheelBelts", "all"] - ] = pd.Field(default_factory=lambda: ["all"], frozen=True) - - def exists(self, _) -> bool: - """Currently, .exists() is only called on automated farfield""" - raise ValueError(".exists should not be called on wind tunnel farfield") - - -# pylint: disable=missing-class-docstring -@final -class GhostSphere(_SurfaceEntityBase): - private_attribute_entity_type_name: Literal["GhostSphere"] = pd.Field( - "GhostSphere", frozen=True - ) - # Note: Making following optional since front end will not carry these over to assigned entities. - center: Optional[List] = pd.Field(None, alias="center") - max_radius: Optional[PositiveFloat] = pd.Field(None, alias="maxRadius") - - def exists(self, _) -> bool: - """Ghost farfield always exists.""" - return True - - -def compute_bbox_tolerance(global_bounding_box, planar_face_tolerance): - """Compute the largest bounding-box dimension and the derived planar-face tolerance.""" - largest_dimension = 0.0 - for dim in range(3): - largest_dimension = max( - largest_dimension, global_bounding_box[1][dim] - global_bounding_box[0][dim] - ) - return largest_dimension, largest_dimension * planar_face_tolerance - - -# pylint: disable=missing-class-docstring -@final -class GhostCircularPlane(_SurfaceEntityBase): - private_attribute_entity_type_name: Literal["GhostCircularPlane"] = pd.Field( - "GhostCircularPlane", frozen=True - ) - # Note: Making following optional since front end will not carry these over to assigned entities. - center: Optional[List] = pd.Field(None, alias="center") - max_radius: Optional[PositiveFloat] = pd.Field(None, alias="maxRadius") - normal_axis: Optional[List] = pd.Field(None, alias="normalAxis") - - def _get_existence_dependency(self, validation_info): - y_max = validation_info.global_bounding_box[1][1] - y_min = validation_info.global_bounding_box[0][1] - largest_dimension, tolerance = compute_bbox_tolerance( - validation_info.global_bounding_box, validation_info.planar_face_tolerance - ) - return y_min, y_max, tolerance, largest_dimension - - def exists(self, validation_info) -> bool: - """For automated farfield, check mesher logic for symmetric plane existence.""" - - if self.name != "symmetric": - # Quasi-3D mode, no need to check existence. - return True - - if validation_info is None: - raise ValueError("Validation info is required for GhostCircularPlane existence check.") - - if validation_info.global_bounding_box is None: - # This likely means the user try to use mesher on old cloud resources. - # We cannot validate if symmetric exists so will let it pass. Pipeline will error out anyway. - return True - - if validation_info.will_generate_forced_symmetry_plane(): - return True - - return _auto_symmetric_plane_exists_from_bbox( - global_bounding_box=validation_info.global_bounding_box, - planar_face_tolerance=validation_info.planar_face_tolerance, - ) - - def _per_entity_type_validation(self, param_info: ParamsValidationInfo): - """Validate ghost surface existence and configuration.""" - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.validation.validation_utils import ( - check_symmetric_boundary_existence, - check_user_defined_farfield_symmetry_existence, - ) - - # These functions expect a list, so wrap self - check_user_defined_farfield_symmetry_existence([self], param_info) - check_symmetric_boundary_existence([self], param_info) - return self - - -class SurfacePairBase(Flow360BaseModel): - """ - Base class for surface pair objects. - Subclasses must define a `pair` attribute with the appropriate surface type. - """ - - pair: Tuple[_SurfaceEntityBase, _SurfaceEntityBase] - - @pd.field_validator("pair", mode="after") - @classmethod - def check_unique(cls, v): - """Check if pairing with self.""" - if v[0].name == v[1].name: - raise ValueError("A surface cannot be paired with itself.") - return v - - @pd.model_validator(mode="before") - @classmethod - def _format_input(cls, input_data: Union[dict, list, tuple]): - if isinstance(input_data, (list, tuple)): - return {"pair": input_data} - if isinstance(input_data, dict): - return {"pair": input_data["pair"]} - raise ValueError("Invalid input data.") - - def __hash__(self): - return hash(tuple(sorted([self.pair[0].name, self.pair[1].name]))) - - def __eq__(self, other): - if isinstance(other, self.__class__): - return tuple(sorted([self.pair[0].name, self.pair[1].name])) == tuple( - sorted([other.pair[0].name, other.pair[1].name]) - ) - return False - - def __str__(self): - return ",".join(sorted([self.pair[0].name, self.pair[1].name])) - - -class SnappyBody(EntityBase): - """ - Represents a group of faces forming a body for snappyHexMesh. - Bodies and their regions are defined in the ASCII STL file by using the solid -> endsolid" - keywords with a body::region naming scheme. - """ - - private_attribute_entity_type_name: Literal["SnappyBody"] = pd.Field("SnappyBody", frozen=True) - private_attribute_id: str = pd.Field( - default_factory=generate_uuid, - frozen=True, - description="Unique identifier for the entity. Used by front end to track entities and enable auto update etc.", - ) - - surfaces: List[Surface] = pd.Field() - - def __getitem__(self, key: str): - if len(self.surfaces) == 1 and ("::" not in self.surfaces[0].name): - regex = _naming_pattern_handler(pattern=key) - else: - regex = _naming_pattern_handler(pattern=f"{self.name}::{key}") - - matched_surfaces = [entity for entity in self.surfaces if regex.match(entity.name)] - if not matched_surfaces: - raise KeyError( - f"No entity found in registry for parent entity: {self.name} with given name/naming pattern: '{key}'." - ) - return matched_surfaces - - -@final -class SeedpointVolume(_VolumeEntityBase): - """ - Represents a separate zone in the mesh, defined by a point inside it. - To be used only with snappyHexMesh. - """ - - # pylint: disable=no-member - private_attribute_entity_type_name: Literal["SeedpointVolume"] = pd.Field( - "SeedpointVolume", frozen=True - ) - type: Literal["SeedpointVolume"] = pd.Field("SeedpointVolume", frozen=True) - point_in_mesh: Length.Vector3 = pd.Field( - description="Seedpoint for a main fluid zone in snappyHexMesh." - ) - axes: Optional[OrthogonalAxes] = pd.Field( - None, description="Principal axes definition when using with PorousMedium" - ) # Porous media support - axis: Optional[Axis] = pd.Field(None) # Rotation support - center: Optional[Length.Vector3] = pd.Field(None, description="") # Rotation support - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - def _per_entity_type_validation(self, param_info: ParamsValidationInfo): - """Validate that SeedpointVolume is listed in meshing->volume_zones.""" - if self.name not in param_info.to_be_generated_custom_volumes: - raise ValueError( - f"SeedpointVolume {self.name} is not listed under meshing->volume_zones(or zones)" - "->CustomZones." - ) - return self - - -VolumeEntityTypes = Union[GenericVolume, Cylinder, Sphere, Box, str] - - -class SurfacePair(SurfacePairBase): - """ - Represents a pair of surfaces. - - Attributes: - pair (Tuple[Surface, Surface]): A tuple containing two Surface objects representing the pair. - """ - - pair: Tuple[Surface, Surface] - - -class GhostSurfacePair(SurfacePairBase): - """ - Represents a pair of ghost surfaces. - - Attributes: - pair (Tuple[GhostSurfaceType, GhostSurfaceType]): - A tuple containing two GhostSurfaceType objects representing the pair. - GhostSurface is for Python API, GhostCircularPlane is for Web UI. - """ - - GhostSurfaceType: ClassVar[type] = Annotated[ - Union[GhostSurface, GhostCircularPlane], - pd.Field(discriminator="private_attribute_entity_type_name"), - ] - - pair: Tuple[GhostSurfaceType, GhostSurfaceType] - - -@final -class CustomVolume(_VolumeEntityBase): - """ - CustomVolume is a volume zone defined by its bounding entities. - It will be generated by the volume mesher. - """ - - private_attribute_entity_type_name: Literal["CustomVolume"] = pd.Field( - "CustomVolume", frozen=True - ) - bounding_entities: EntityList[Surface, Cylinder, AxisymmetricBody, Sphere] = pd.Field( - description="The entities that define the boundaries of the custom volume." - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - axes: Optional[OrthogonalAxes] = pd.Field(None, description="") # Porous media support - axis: Optional[Axis] = pd.Field(None) # Rotation support - # pylint: disable=no-member - center: Optional[Length.Vector3] = pd.Field(None, description="") # Rotation support - - @pd.model_validator(mode="before") - @classmethod - def _rename_boundaries_to_bounding_entities(cls, value): - """Accept the legacy ``boundaries`` key and migrate to ``bounding_entities``.""" - if not isinstance(value, dict): - return value - - if "boundaries" in value and "bounding_entities" not in value: - value["bounding_entities"] = value.pop("boundaries") - from flow360.component.simulation.validation.validation_context import ( # pylint: disable=import-outside-toplevel - add_validation_warning, - ) - - add_validation_warning( - "`CustomVolume.boundaries` has been renamed to `bounding_entities`. " - "Please update your code to use `bounding_entities`." - ) - - return value - - @contextual_field_validator("bounding_entities", mode="after") - @classmethod - def ensure_unique_boundary_names(cls, v, param_info: ParamsValidationInfo): - """Check if the bounding entities have different names within a CustomVolume.""" - expanded_surfaces = param_info.expand_entity_list(v) - if len(expanded_surfaces) != len({entity.name for entity in expanded_surfaces}): - raise ValueError("The bounding entities of a CustomVolume must have different names.") - return v - - @contextual_field_validator("bounding_entities", mode="after") - @classmethod - def _validate_bounding_entity_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher.""" - # Lazy import to avoid circular dependency - from flow360.component.simulation.validation.validation_utils import ( # pylint: disable=import-outside-toplevel - validate_entity_list_surface_existence, - ) - - return validate_entity_list_surface_existence(value, param_info) - - @contextual_model_validator(mode="after") - def ensure_beta_mesher_and_compatible_farfield(self, param_info: ParamsValidationInfo): - """Check if the beta mesher is enabled and that the user is using a compatible farfield.""" - if param_info.is_beta_mesher and param_info.farfield_method in ( - "user-defined", - "wind-tunnel", - "auto", - ): - return self - raise ValueError( - "CustomVolume is supported only when the beta mesher is enabled " - "and an automated, user-defined, or wind tunnel farfield is enabled." - ) - - def _apply_transformation(self, matrix: np.ndarray) -> "CustomVolume": - """Apply rotation from transformation matrix to axes only (no translation or scaling).""" - if self.axes is None: - # No axes to transform - return self - - # Extract pure rotation matrix (ignore translation and scaling) - rotation_matrix = _extract_rotation_matrix(matrix) - - # Rotate both axes - x_axis_array = np.asarray(self.axes[0]) - y_axis_array = np.asarray(self.axes[1]) - - new_x_axis = rotation_matrix @ x_axis_array - new_y_axis = rotation_matrix @ y_axis_array - - new_axes = (tuple(new_x_axis), tuple(new_y_axis)) - - return self.model_copy(update={"axes": new_axes}) - - def _per_entity_type_validation(self, param_info: ParamsValidationInfo): - """Validate that CustomVolume is listed in meshing->volume_zones.""" - if self.name not in param_info.to_be_generated_custom_volumes: - raise ValueError( - f"CustomVolume {self.name} is not listed under meshing->volume_zones(or zones)" - "->CustomZones." - ) - return self - - -class _MirroredEntityBase(EntityBase, metaclass=ABCMeta): - """ - Base class for mirrored entities (MirroredSurface, MirroredGeometryBodyGroup). - Provides common validation logic for checking source entity and mirror plane existence. - """ - - mirror_plane_id: str = pd.Field(description="ID of the mirror plane.") - - @property - @abstractmethod - def source_entity_id_field_name(self) -> str: - """Return the name of the field containing the source entity ID.""" - - @property - @abstractmethod - def source_entity_type_name(self) -> str: - """Return the entity type name of the source entity.""" - - def _manual_assignment_validation(self, param_info: ParamsValidationInfo): - """Validate that source entity and mirror plane still exist.""" - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.validation.validation_context import ( - add_validation_warning, - ) - - registry = param_info.get_entity_registry() - if registry is None: - return self - - # Get source entity ID using the field name from subclass - source_entity_id = getattr(self, self.source_entity_id_field_name) - - # Check if source entity exists - source_entity = registry.find_by_type_name_and_id( - entity_type=self.source_entity_type_name, entity_id=source_entity_id - ) - if source_entity is None: - add_validation_warning( - f"{self.__class__.__name__} '{self.name}' references non-existent source " - f"{self.source_entity_type_name.lower()} (id={source_entity_id}). " - "This entity will be removed." - ) - return None - - # Check if mirror plane exists - mirror_plane = registry.find_by_type_name_and_id( - entity_type="MirrorPlane", entity_id=self.mirror_plane_id - ) - if mirror_plane is None: - add_validation_warning( - f"{self.__class__.__name__} '{self.name}' references non-existent mirror plane " - f"(id={self.mirror_plane_id}). This entity will be removed." - ) - return None - - return self - - -class MirroredSurface(_SurfaceEntityBase, _MirroredEntityBase): - """ - :class:`MirroredSurface` class for representing a mirrored surface. - """ - - name: str = pd.Field() - surface_id: str = pd.Field( - description="ID of the original surface being mirrored.", frozen=True - ) - mirror_plane_id: str = pd.Field(description="ID of the mirror plane to mirror the surface.") - - private_attribute_entity_type_name: Literal["MirroredSurface"] = pd.Field( - "MirroredSurface", frozen=True - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - # Private attribute used for draft-only bookkeeping. This must NOT affect schema or serialization. - _geometry_body_group_id: Optional[str] = pd.PrivateAttr(default=None) - - @property - def source_entity_id_field_name(self) -> str: - """Return the name of the field containing the source entity ID.""" - return "surface_id" - - @property - def source_entity_type_name(self) -> str: - """Return the entity type name of the source entity.""" - return "Surface" - - -class MirroredGeometryBodyGroup(_MirroredEntityBase): - """ - :class:`MirroredGeometryBodyGroup` class for representing a mirrored geometry body group. - """ - - name: str = pd.Field() - geometry_body_group_id: str = pd.Field(description="ID of the geometry body group to mirror.") - mirror_plane_id: str = pd.Field( - description="ID of the mirror plane to mirror the geometry body group." - ) - - private_attribute_entity_type_name: Literal["MirroredGeometryBodyGroup"] = pd.Field( - "MirroredGeometryBodyGroup", frozen=True - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @property - def source_entity_id_field_name(self) -> str: - """Return the name of the field containing the source entity ID.""" - return "geometry_body_group_id" - - @property - def source_entity_type_name(self) -> str: - """Return the entity type name of the source entity.""" - return "GeometryBodyGroup" diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 04271183f..1dd337041 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -89,7 +89,6 @@ unit_system_manager, ) from flow360.component.simulation.units import validate_length -from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_context import ( ALL, ParamsValidationInfo, @@ -144,9 +143,10 @@ def _store_project_length_unit(project_length_unit, params: SimulationParams): # Store the length unit so downstream services/pipelines can use it # pylint: disable=fixme # TODO: client does not call this. We need to start using new webAPI for that - with model_attribute_unlock(params.private_attribute_asset_cache, "project_length_unit"): - # pylint: disable=assigning-non-slot,no-member - params.private_attribute_asset_cache.project_length_unit = project_length_unit + # pylint: disable=assigning-non-slot,no-member + params.private_attribute_asset_cache._force_set_attr( # pylint:disable=protected-access + "project_length_unit", project_length_unit + ) return params @@ -1311,6 +1311,7 @@ def merge_geometry_entity_info( def _get_draft_entity_type_names() -> set: """Extract entity type names from DraftEntityTypes in entity_info.py.""" # pylint: disable=import-outside-toplevel + import types from typing import get_args, get_origin from flow360.component.simulation.entity_info import EntityInfoModel @@ -1327,7 +1328,8 @@ def _get_draft_entity_type_names() -> set: union_args = get_args(inner_type) # Get Annotated args if union_args: actual_union = union_args[0] # First arg is the Union - if get_origin(actual_union) is Union: + # Support both typing.Union and types.UnionType (X | Y syntax in Python 3.10+) + if get_origin(actual_union) is Union or isinstance(actual_union, types.UnionType): for cls in get_args(actual_union): type_names.add(cls.__name__) diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index 0920c3463..c8bf9d87e 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -98,10 +98,7 @@ from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) -from flow360.component.simulation.utils import ( - model_attribute_unlock, - sanitize_params_dict, -) +from flow360.component.simulation.utils import sanitize_params_dict from flow360.component.simulation.validation.validation_output import ( _check_aero_acoustics_observer_time_step_size, _check_local_cfl_output, @@ -395,9 +392,10 @@ def _preprocess(self, mesh_unit=None, exclude: list = None) -> SimulationParams: ) def _private_set_length_unit(self, validated_mesh_unit): - with model_attribute_unlock(self.private_attribute_asset_cache, "project_length_unit"): - # pylint: disable=assigning-non-slot - self.private_attribute_asset_cache.project_length_unit = validated_mesh_unit + # pylint: disable=assigning-non-slot, no-member + self.private_attribute_asset_cache._force_set_attr( # pylint:disable=protected-access + "project_length_unit", validated_mesh_unit + ) @pd.validate_call def convert_unit( diff --git a/flow360/component/simulation/utils.py b/flow360/component/simulation/utils.py index 54f937214..93e07676e 100644 --- a/flow360/component/simulation/utils.py +++ b/flow360/component/simulation/utils.py @@ -1,10 +1,9 @@ """Utility functions for the simulation component.""" -from contextlib import contextmanager +# pylint: disable=unused-import from typing import Annotated, Union, get_args, get_origin -import pydantic as pd -from pydantic_core import core_schema +from flow360_schema.framework.bounding_box import BoundingBox, BoundingBoxType from flow360.component.simulation.framework.updater_utils import recursive_remove_key @@ -22,29 +21,6 @@ def sanitize_params_dict(model_dict): return model_dict -@contextmanager -def model_attribute_unlock(model, attr: str): - """ - Helper function to: - 1. Set frozen fields of a pydantic model from internal systems - 2. Temporarily disable validation on assignment to avoid infinite recursion. - """ - # Save original state - original_validate_assignment = model.model_config.get("validate_assignment", True) - original_frozen = model.__class__.model_fields[attr].frozen - - try: - # validate_assignment is set to False to allow for the attribute to be modified - # Otherwise, the attribute will STILL be frozen and cannot be modified - model.model_config["validate_assignment"] = False - model.__class__.model_fields[attr].frozen = False - yield - finally: - # Restore original state - model.model_config["validate_assignment"] = original_validate_assignment - model.__class__.model_fields[attr].frozen = original_frozen - - def get_combined_subclasses(cls): """get subclasses of cls""" if isinstance(cls, tuple): @@ -82,124 +58,3 @@ def is_instance_of_type_in_union(obj, typ) -> bool: # Otherwise, do a normal isinstance check. return isinstance(obj, typ) - - -class BoundingBox(list[list[float]]): - """Bounding box.""" - - # --- Properties for min/max coordinates --- - @property - def xmin(self) -> float: - """Return the minimum x coordinate.""" - return self[0][0] - - @property - def ymin(self) -> float: - """Return the minimum y coordinate.""" - return self[0][1] - - @property - def zmin(self) -> float: - """Return the minimum z coordinate.""" - return self[0][2] - - @property - def xmax(self) -> float: - """Return the maximum x coordinate.""" - return self[1][0] - - @property - def ymax(self) -> float: - """Return the maximum y coordinate.""" - return self[1][1] - - @property - def zmax(self) -> float: - """Return the maximum z coordinate.""" - return self[1][2] - - @classmethod - def get_default_bounding_box(cls) -> "BoundingBox": - """Return the default bounding box with infinite values.""" - return BoundingBox( - [ - [float("inf"), float("inf"), float("inf")], - [float("-inf"), float("-inf"), float("-inf")], - ] - ) - - # --- Pydantic v2 schema integration --- - @classmethod - # pylint: disable=unused-argument - def __get_pydantic_core_schema__(cls, source, handler: pd.GetCoreSchemaHandler): - # Inner row = 3 floats - inner_row = core_schema.list_schema( - core_schema.float_schema(), - min_length=3, - max_length=3, - ) - # Outer list = 2 rows - outer = core_schema.list_schema(inner_row, min_length=2, max_length=2) - return core_schema.no_info_after_validator_function(cls._coerce, outer) - - @classmethod - def _coerce(cls, v): - # Convert input list into BoundingBox - return v if isinstance(v, cls) else cls(v) - - # --- Additional geometry helpers --- - @property - def size(self): - """Return the size (dx, dy, dz).""" - return self.xmax - self.xmin, self.ymax - self.ymin, self.zmax - self.zmin - - @property - def center(self): - """Return the center point of the bounding box.""" - return ( - (self.xmin + self.xmax) / 2.0, - (self.ymin + self.ymax) / 2.0, - (self.zmin + self.zmax) / 2.0, - ) - - @property - def largest_dimension(self): - """Return the largest dimension of the bounding box.""" - return max(self.size) - - @property - def diagonal(self): - """Return the diagonal length of the bounding box.""" - dx, dy, dz = self.size - return (dx**2 + dy**2 + dz**2) ** 0.5 - - def expand(self, other: "BoundingBox") -> "BoundingBox": - """Return a new bounding box expanded by a given bounding box.""" - (sx0, sy0, sz0), (sx1, sy1, sz1) = self - (ox0, oy0, oz0), (ox1, oy1, oz1) = other - - # Disabled since if implementation is much faster than using max builtin - # pylint: disable=consider-using-max-builtin, consider-using-min-builtin - if ox0 < sx0: - sx0 = ox0 - if oy0 < sy0: - sy0 = oy0 - if oz0 < sz0: - sz0 = oz0 - if ox1 > sx1: - sx1 = ox1 - if oy1 > sy1: - sy1 = oy1 - if oz1 > sz1: - sz1 = oz1 - - self[0][0], self[0][1], self[0][2] = sx0, sy0, sz0 - self[1][0], self[1][1], self[1][2] = sx1, sy1, sz1 - return self - - -# Annotated alias for documentation -BoundingBoxType = Annotated[ - BoundingBox, - pd.Field(description="[[xmin, ymin, zmin], [xmax, ymax, zmax]]"), -] diff --git a/flow360/component/types.py b/flow360/component/types.py index c054657bd..e8701f6a3 100644 --- a/flow360/component/types.py +++ b/flow360/component/types.py @@ -1,102 +1,26 @@ """Defines 'types' that various fields can be""" -from typing import Annotated, List, Optional, Tuple +from typing import Annotated, List, Tuple -import numpy as np -import pydantic.v1 as pd -from pydantic import Field, GetJsonSchemaHandler -from pydantic_core import CoreSchema, core_schema +from flow360_schema.framework.entity.geometric_types import Axis, Coordinate, Vector +from pydantic import Field # type tag default name TYPE_TAG_STR = "_type" COMMENTS = "comments" List2D = List[List[float]] -# we use tuple for fixed length lists, beacause List is a mutable, variable length structure -Coordinate = Tuple[float, float, float] Int8 = Annotated[int, Field(ge=0, le=255)] Color = Tuple[Int8, Int8, Int8] - -class Vector(Coordinate): - """:class: Vector - - Example - ------- - >>> v = Vector((2, 1, 1)) # doctest: +SKIP - """ - - @classmethod - def __get_validators__(cls): - yield cls.validate - - # pylint: disable=unused-argument - @classmethod - def __get_pydantic_core_schema__(cls, *args, **kwargs) -> CoreSchema: - return core_schema.no_info_plain_validator_function(cls.validate) - - # pylint: disable=unused-argument - @classmethod - def __get_pydantic_json_schema__(cls, schema: CoreSchema, handler: GetJsonSchemaHandler): - schema = {"properties": {"value": {"type": "array"}}} - schema["properties"]["value"]["items"] = {"type": "number"} - schema["properties"]["value"]["strictType"] = {"type": "vector3"} - - return schema - - @classmethod - def validate(cls, vector): - """validator for vector""" - - class _PydanticValidate(pd.BaseModel): - c: Optional[Coordinate] - - if isinstance(vector, set): - raise TypeError(f"set provided {vector}, but tuple or array expected.") - _ = _PydanticValidate(c=vector) - if not isinstance(vector, cls): - vector = cls(vector) - if vector == (0, 0, 0): - raise ValueError(f"{cls.__name__} cannot be (0, 0, 0)") - - return vector - - # pylint: disable=unused-argument - @classmethod - def __modify_schema__(cls, field_schema, field): - new_schema = { - "type": "array", - "minItems": 3, - "maxItems": 3, - "items": [{"type": "number"}, {"type": "number"}, {"type": "number"}], - } - - field_schema.update(new_schema) - - -class Axis(Vector): - """:class: Axis (unit vector) - - Example - ------- - >>> v = Axis((0, 0, 1)) # doctest: +SKIP - """ - - @classmethod - def __get_validators__(cls): - yield cls.validate - - @classmethod - def __get_pydantic_core_schema__(cls, *args, **kwargs) -> CoreSchema: - return core_schema.no_info_plain_validator_function(cls.validate) - - @classmethod - def validate(cls, vector): - """validator for Axis""" - if vector is None: - return None - vector = super().validate(vector) - vector_norm = np.linalg.norm(vector) - normalized_vector = tuple(e / vector_norm for e in vector) - return Axis(normalized_vector) +__all__ = [ + "Axis", + "Coordinate", + "Vector", + "TYPE_TAG_STR", + "COMMENTS", + "List2D", + "Int8", + "Color", +] diff --git a/flow360/exceptions.py b/flow360/exceptions.py index 460f34b25..9d5f1e634 100644 --- a/flow360/exceptions.py +++ b/flow360/exceptions.py @@ -1,30 +1,17 @@ """Custom Flow360 exceptions""" +# pylint: disable=unused-import from typing import Any, List -from flow360.version import __version__ +from flow360_schema.exceptions import ( + Flow360DeprecationError, + Flow360Error, + Flow360ValueError, +) from .log import log -class Flow360Error(Exception): - """Any error in flow360""" - - def __init__(self, message: str = None): - """Log just the error message and then raise the Exception.""" - super().__init__(message) - log.error(message + " [Flow360 client version: " + __version__ + "]") - - -class Flow360DeprecationError(Flow360Error): - """Error when a deprecated feature is used.""" - - -# pylint: disable=redefined-builtin -class Flow360ValueError(Flow360Error): - """Error with value.""" - - class Flow360TypeError(Flow360Error): """Error with type.""" diff --git a/poetry.lock b/poetry.lock index 1f20e29e2..57645106b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1468,14 +1468,14 @@ files = [ [[package]] name = "flow360-schema" -version = "0.1.17" +version = "0.1.18" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.17-py3-none-any.whl", hash = "sha256:ffdff16f651f4a40c7cf653af200ac3b93719c3cc826ecc1cc7b7e7554c6ce52"}, - {file = "flow360_schema-0.1.17.tar.gz", hash = "sha256:42054361a82cb67391fc1cc12fa6d103b58d836601f906796ca6634e6f5721b6"}, + {file = "flow360_schema-0.1.18-py3-none-any.whl", hash = "sha256:739b7c9fef756c820d1e7476a84b726cd8b396b6a0178a5a2afad977c20498fa"}, + {file = "flow360_schema-0.1.18.tar.gz", hash = "sha256:d4256c3aedd6f599ab0d4970d46d3e4b882b09e4ee43e3b82977811c84add932"}, ] [package.dependencies] @@ -6527,4 +6527,4 @@ docs = ["autodoc_pydantic", "cairosvg", "ipython", "jinja2", "jupyter", "myst-pa [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "d6456dbd7d96755a313d46a1ccba9d108923ce844d4199729df3974836efc12d" +content-hash = "e11c2aaf5aac714ffa242b26ef22404b4da6689ec4983198cf022ae989675068" diff --git a/pyproject.toml b/pyproject.toml index 818a0ffc3..22d1e0ea2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ pydantic = ">=2.8,<2.12" # -- Local dev (editable install, schema changes take effect immediately): # flow360-schema = { path = "../flex/share/flow360-schema", develop = true } # -- CI / release (install from CodeArtifact, swap comments before pushing): -flow360-schema = { version = "~0.1.17", source = "codeartifact" } +flow360-schema = { version = "~0.1.18", source = "codeartifact" } pytest = "^7.1.2" click = "^8.1.3" toml = "^0.10.2" diff --git a/tests/simulation/framework/test_entities_fast_register.py b/tests/simulation/framework/test_entities_fast_register.py index 932dd004c..a4e512c08 100644 --- a/tests/simulation/framework/test_entities_fast_register.py +++ b/tests/simulation/framework/test_entities_fast_register.py @@ -30,7 +30,6 @@ _ParamModelBase, ) from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.utils import model_attribute_unlock from tests.simulation.conftest import AssetBase @@ -225,8 +224,7 @@ def preprocess(self): Supply self._supplementary_registry to the construction of TempFluidDynamics etc so that the class can perform proper validation """ - with model_attribute_unlock(self.private_attribute_asset_cache, "project_length_unit"): - self.private_attribute_asset_cache.project_length_unit = 1 * u.m + self.private_attribute_asset_cache._force_set_attr("project_length_unit", 1 * u.m) for model in self.models: model.entities.preprocess(params=self) diff --git a/tests/simulation/framework/test_entities_v2.py b/tests/simulation/framework/test_entities_v2.py index 5100f05ae..7c5be88c8 100644 --- a/tests/simulation/framework/test_entities_v2.py +++ b/tests/simulation/framework/test_entities_v2.py @@ -31,7 +31,6 @@ _ParamModelBase, ) from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.utils import model_attribute_unlock from tests.simulation.conftest import AssetBase @@ -222,8 +221,7 @@ def preprocess(self): Supply self._supplementary_registry to the construction of TempFluidDynamics etc so that the class can perform proper validation """ - with model_attribute_unlock(self.private_attribute_asset_cache, "project_length_unit"): - self.private_attribute_asset_cache.project_length_unit = 1 * u.m + self.private_attribute_asset_cache._force_set_attr("project_length_unit", 1 * u.m) for model in self.models: model.entities.preprocess(params=self) diff --git a/tests/simulation/framework/test_entity_expansion_impl.py b/tests/simulation/framework/test_entity_expansion_impl.py index 15758fad5..e116bf846 100644 --- a/tests/simulation/framework/test_entity_expansion_impl.py +++ b/tests/simulation/framework/test_entity_expansion_impl.py @@ -2,6 +2,8 @@ import json import os +import pytest + from flow360.component.simulation.framework.entity_expansion_utils import ( expand_all_entity_lists_in_place, ) @@ -229,3 +231,32 @@ def match(pattern: str) -> list[str]: assert match(r"literal\*star") == ["literal*star"] assert match(r"foo\.bar") == ["foo.bar"] assert match("foo[.]bar") == ["foo.bar"] + + +def test_compile_glob_cached_combined_patterns_do_not_partial_match(): + regex = compile_glob_cached("{a,b}") + + assert regex.match("a") is not None + assert regex.match("b") is not None + assert regex.match("ab") is None + assert regex.match("xb") is None + + +def test_entity_registry_view_glob_uses_full_string_matching(): + registry = _make_registry(surfaces=_mk_pool(["a", "ab", "b", "xb"], "Surface")) + + matched = registry.view(Surface)["{a,b}"] + + assert [entity.name for entity in matched] == ["a", "b"] + + +def test_compile_glob_cached_rejects_empty_regex_parts(monkeypatch): + from wcmatch import fnmatch as wfnmatch + + compile_glob_cached.cache_clear() + monkeypatch.setattr(wfnmatch, "translate", lambda pattern, flags: ([], flags)) + + with pytest.raises(ValueError, match="returned no regex parts"): + compile_glob_cached("wing*") + + compile_glob_cached.cache_clear() diff --git a/tests/simulation/framework/test_entity_list.py b/tests/simulation/framework/test_entity_list.py index 9901ca9e4..00ca041cf 100644 --- a/tests/simulation/framework/test_entity_list.py +++ b/tests/simulation/framework/test_entity_list.py @@ -1,5 +1,5 @@ import re -from typing import ClassVar, Literal +from typing import Literal import pydantic as pd import pytest @@ -157,7 +157,7 @@ def test_entity_list_invalid_inputs(): # 3. Test None input with pytest.raises( pd.ValidationError, - match="Input should be a valid list", + match=re.escape("None is not a valid input to `entities`."), ): EntityList[Surface].model_validate(None) @@ -170,6 +170,19 @@ def test_entity_list_invalid_inputs(): EntityList[Surface].model_validate([GenericVolume(name="a_volume")]) +def test_force_set_attr_marks_entity_dirty_and_updates_hash(): + entity = GenericVolume(name="zone") + original_hash = entity._get_hash() + + entity._force_set_attr("private_attribute_full_name", "fluid/zone") + + assert entity.model_dump(exclude_unset=True)["private_attribute_full_name"] == "fluid/zone" + assert entity._dirty is True + assert entity._get_hash() != original_hash + assert entity._dirty is False + assert "_dirty" not in entity.__dict__ + + def test_preview_selection_returns_names_by_default(): _, draft = _build_preview_context(["tail", "wing_leading", "wing_trailing"]) selector = SurfaceSelector(name="wing_surfaces").match("wing*") diff --git a/tests/simulation/framework/test_entity_selector_fluent_api.py b/tests/simulation/framework/test_entity_selector_fluent_api.py index 42f4f3429..5ebd3614c 100644 --- a/tests/simulation/framework/test_entity_selector_fluent_api.py +++ b/tests/simulation/framework/test_entity_selector_fluent_api.py @@ -165,6 +165,36 @@ def test_in_and_not_any_of_chain(): assert names == ["a", "c"] +def test_any_of_string_value_from_json_matches_full_name(): + """ + Test: JSON-deserialized any_of predicate accepts a single string value. + + Purpose: + - Verify that the schema-side `Predicate.value: str | list[str]` contract is honored. + - Verify that a single string is treated as one candidate value, not split into characters. + - Verify that selector expansion remains correct for JSON input paths. + """ + registry = _make_registry(surfaces=_mk_pool(["wing", "w", "i", "n", "g"], "Surface")) + + selector = EntitySelector.model_validate( + { + "name": "t_any_of_string", + "target_class": "Surface", + "logic": "AND", + "children": [ + { + "attribute": "name", + "operator": "any_of", + "value": "wing", + } + ], + } + ) + + names = _expand_and_get_names(registry, selector) + assert names == ["wing"] + + def test_edge_class_basic_match(): """ Test: EntitySelector with Edge entity type (non-Surface). diff --git a/tests/simulation/framework/test_entity_selector_token.py b/tests/simulation/framework/test_entity_selector_token.py index 605dd4fa3..501ca4a1f 100644 --- a/tests/simulation/framework/test_entity_selector_token.py +++ b/tests/simulation/framework/test_entity_selector_token.py @@ -144,3 +144,31 @@ def test_entity_selector_unknown_token_raises_error(): with pytest.raises(ValueError, match=r"Selector token not found.*unknown-selector-id"): materialize_entities_and_selectors_in_place(params) + + +def test_entity_selector_token_generates_missing_selector_id(): + params_as_dict = { + "private_attribute_asset_cache": {}, + "models": [ + { + "name": "m1", + "selectors": [ + { + "target_class": "Surface", + "name": "sel1", + "children": [ + {"attribute": "name", "operator": "matches", "value": "wing*"} + ], + } + ], + } + ], + } + + tokenized_params = collect_and_tokenize_selectors_in_place(copy.deepcopy(params_as_dict)) + + generated_token = tokenized_params["models"][0]["selectors"][0] + used_selector = tokenized_params["private_attribute_asset_cache"]["used_selectors"][0] + + assert generated_token is not None + assert used_selector["selector_id"] == generated_token diff --git a/tests/simulation/params/test_simulation_params.py b/tests/simulation/params/test_simulation_params.py index d7c0fb4ef..bef695c5b 100644 --- a/tests/simulation/params/test_simulation_params.py +++ b/tests/simulation/params/test_simulation_params.py @@ -73,7 +73,6 @@ from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) -from flow360.component.simulation.utils import model_attribute_unlock from tests.simulation.conftest import to_file_from_file_test_approx assertions = unittest.TestCase("__init__") @@ -669,8 +668,7 @@ def _get_selected_grouped_bodies(entity_info_dict: dict) -> list[dict]: candidate_body_group_tag = tag break if candidate_body_group_tag is not None: - with model_attribute_unlock(entity_info, "body_group_tag"): - entity_info.body_group_tag = candidate_body_group_tag + entity_info._force_set_attr("body_group_tag", candidate_body_group_tag) with create_draft(new_run_from=mock_geometry) as draft: body_groups = list(draft.body_groups) diff --git a/tests/simulation/ref/simulation_with_project_variables.json b/tests/simulation/ref/simulation_with_project_variables.json index d084749ea..32d500aa1 100644 --- a/tests/simulation/ref/simulation_with_project_variables.json +++ b/tests/simulation/ref/simulation_with_project_variables.json @@ -345,4 +345,4 @@ }, "user_defined_fields": [], "version": "25.11.0b1" -} \ No newline at end of file +} diff --git a/tests/simulation/test_project.py b/tests/simulation/test_project.py index 7fa8a9c39..08b424ea4 100644 --- a/tests/simulation/test_project.py +++ b/tests/simulation/test_project.py @@ -14,7 +14,6 @@ from flow360.component.resource_base import local_metadata_builder from flow360.component.simulation.primitives import ImportedSurface from flow360.component.simulation.services import ValidationCalledBy, validate_model -from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.volume_mesh import VolumeMeshV2 from flow360.exceptions import Flow360ConfigurationError, Flow360ValueError @@ -356,8 +355,7 @@ def _build_params_with_imported_surfaces(imported_surfaces): """Build a minimal SimulationParams with imported_surfaces set in asset cache.""" with fl.SI_unit_system: params = fl.SimulationParams() - with model_attribute_unlock(params.private_attribute_asset_cache, "imported_surfaces"): - params.private_attribute_asset_cache.imported_surfaces = imported_surfaces + params.private_attribute_asset_cache._force_set_attr("imported_surfaces", imported_surfaces) return params diff --git a/tests/simulation/translator/test_solver_translator.py b/tests/simulation/translator/test_solver_translator.py index 901f2e971..faef5fbd6 100644 --- a/tests/simulation/translator/test_solver_translator.py +++ b/tests/simulation/translator/test_solver_translator.py @@ -120,7 +120,6 @@ from flow360.component.simulation.time_stepping.time_stepping import RampCFL, Steady from flow360.component.simulation.translator.solver_translator import get_solver_json from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.utils import model_attribute_unlock from tests.simulation.translator.utils.actuator_disk_param_generator import ( actuator_disk_create_param, actuator_disk_with_reference_velocity_param, @@ -1004,8 +1003,7 @@ def test_liquid_simulation_translation(): param, mesh_unit=1 * u.m, ref_json_file="Flow360_liquid_rotation_dd.json", debug=False ) - with model_attribute_unlock(param.operating_condition, "reference_velocity_magnitude"): - param.operating_condition.reference_velocity_magnitude = 20 * u.m / u.s + param.operating_condition._force_set_attr("reference_velocity_magnitude", 20 * u.m / u.s) translate_and_compare( param, mesh_unit=1 * u.m, diff --git a/tests/simulation/translator/test_surface_meshing_translator_ghost.py b/tests/simulation/translator/test_surface_meshing_translator_ghost.py index c2fa46772..a034ed9d6 100644 --- a/tests/simulation/translator/test_surface_meshing_translator_ghost.py +++ b/tests/simulation/translator/test_surface_meshing_translator_ghost.py @@ -11,7 +11,6 @@ get_surface_meshing_json, ) from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.utils import model_attribute_unlock def _minimal_geometry_entity_info(): @@ -35,8 +34,7 @@ def _minimal_geometry_entity_info(): edge_attribute_names=[], grouped_edges=[[]], ) - with model_attribute_unlock(info, "body_group_tag"): - info.body_group_tag = "groupByFile" + info._force_set_attr("body_group_tag", "groupByFile") return info diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index d1ea80622..6edf2dac8 100644 --- a/tests/simulation/translator/test_volume_meshing_translator.py +++ b/tests/simulation/translator/test_volume_meshing_translator.py @@ -56,7 +56,6 @@ ) from flow360.component.simulation.unit_system import SI_unit_system from flow360.component.simulation.units import validate_length -from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_context import VOLUME_MESH from tests.simulation.conftest import AssetBase diff --git a/tests/test_results.py b/tests/test_results.py index a30db7de3..3ceca5874 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -34,7 +34,6 @@ AerospaceCondition, ) from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.utils import model_attribute_unlock # log.set_logging_level("DEBUG") @@ -214,8 +213,7 @@ def test_bet_disk_results_with_simulation_interface(mock_id, mock_response, data with u2.SI_unit_system: params = SimulationParams(operating_condition=AerospaceCondition(velocity_magnitude=286)) - with model_attribute_unlock(params.private_attribute_asset_cache, "project_length_unit"): - params.private_attribute_asset_cache.project_length_unit = 1 * u2.m + params.private_attribute_asset_cache._force_set_attr("project_length_unit", 1 * u2.m) results = case.results results.bet_forces.load_from_local(os.path.join(data_path, "results", "bet_forces_v2.csv")) From 4a7d3d99ae6e286e4dc82dbbe3c68b0230f0124b Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:40:48 -0400 Subject: [PATCH 09/25] Use schema-owned asset cache models (#1950) --- flow360/component/project.py | 5 +- flow360/component/project_utils.py | 2 +- .../simulation/draft_context/context.py | 8 +- .../coordinate_system_manager.py | 88 +--- .../simulation/draft_context/mirror.py | 120 +---- .../component/simulation/entity_operation.py | 4 +- .../simulation/framework/boundary_split.py | 2 +- .../framework/entity_materializer.py | 2 +- .../simulation/framework/param_utils.py | 101 +--- .../component/simulation/simulation_params.py | 2 +- .../component/simulation/translator/utils.py | 2 +- poetry.lock | 10 +- .../test_coordinate_system_assignment.py | 35 -- .../test_entity_transformation.py | 459 ------------------ .../draft_context/test_mirror_action.py | 200 +------- .../test_pre_deserialized_entity_info.py | 83 +--- 16 files changed, 32 insertions(+), 1091 deletions(-) delete mode 100644 tests/simulation/draft_context/test_entity_transformation.py diff --git a/flow360/component/project.py b/flow360/component/project.py index 56a814a0f..07284e09b 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -12,6 +12,7 @@ import pydantic as pd import typing_extensions from flow360_schema.framework.physical_dimensions import Length +from flow360_schema.models.asset_cache import CoordinateSystemStatus, MirrorStatus from pydantic import PositiveInt from flow360.cloud.file_cache import get_shared_cloud_file_cache @@ -47,10 +48,6 @@ DraftContext, get_active_draft, ) -from flow360.component.simulation.draft_context.coordinate_system_manager import ( - CoordinateSystemStatus, -) -from flow360.component.simulation.draft_context.mirror import MirrorStatus from flow360.component.simulation.draft_context.obb.tessellation_loader import ( TessellationFileLoader, ) diff --git a/flow360/component/project_utils.py b/flow360/component/project_utils.py index f8d9a70a2..230de2171 100644 --- a/flow360/component/project_utils.py +++ b/flow360/component/project_utils.py @@ -5,6 +5,7 @@ from typing import Optional, Type, TypeVar, get_args from flow360_schema.framework.physical_dimensions import Length +from flow360_schema.models.asset_cache import AssetCache from pydantic import ValidationError from flow360.component.simulation import services @@ -17,7 +18,6 @@ from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_base import EntityList from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.outputs.outputs import ( SurfaceIntegralOutput, SurfaceOutput, diff --git a/flow360/component/simulation/draft_context/context.py b/flow360/component/simulation/draft_context/context.py index ea44019f8..54fe0869e 100644 --- a/flow360/component/simulation/draft_context/context.py +++ b/flow360/component/simulation/draft_context/context.py @@ -7,14 +7,12 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, List, Optional, Union, get_args +from flow360_schema.models.asset_cache import CoordinateSystemStatus, MirrorStatus + from flow360.component.simulation.draft_context.coordinate_system_manager import ( CoordinateSystemManager, - CoordinateSystemStatus, -) -from flow360.component.simulation.draft_context.mirror import ( - MirrorManager, - MirrorStatus, ) +from flow360.component.simulation.draft_context.mirror import MirrorManager from flow360.component.simulation.entity_info import ( DraftEntityTypes, EntityInfoModel, diff --git a/flow360/component/simulation/draft_context/coordinate_system_manager.py b/flow360/component/simulation/draft_context/coordinate_system_manager.py index 487676046..91cb882a4 100644 --- a/flow360/component/simulation/draft_context/coordinate_system_manager.py +++ b/flow360/component/simulation/draft_context/coordinate_system_manager.py @@ -3,16 +3,20 @@ from __future__ import annotations import collections -from typing import Dict, List, Literal, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import numpy as np -import pydantic as pd +from flow360_schema.models.asset_cache import ( + CoordinateSystemAssignmentGroup, + CoordinateSystemEntityRef, + CoordinateSystemParent, + CoordinateSystemStatus, +) from flow360.component.simulation.entity_operation import ( CoordinateSystem, _compose_transformation_matrices, ) -from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_base import EntityBase from flow360.component.simulation.framework.entity_registry import EntityRegistry from flow360.component.simulation.utils import is_exact_instance @@ -20,84 +24,6 @@ from flow360.log import log -class CoordinateSystemParent(Flow360BaseModel): - """ - Parent relationship for a coordinate system. - - This is a lightweight, serializable representation of a coordinate system hierarchy edge - used by `CoordinateSystemStatus`. - """ - - type_name: Literal["CoordinateSystemParent"] = pd.Field("CoordinateSystemParent", frozen=True) - coordinate_system_id: str - parent_id: Optional[str] = pd.Field(None) - - -class CoordinateSystemEntityRef(Flow360BaseModel): - """ - Entity reference used in assignment serialization. - - Notes - ----- - This stores an `(entity_type, entity_id)` pair instead of a direct entity instance so that - the status can be serialized and later restored against a draft's entity registry. - """ - - type_name: Literal["CoordinateSystemEntityRef"] = pd.Field( - "CoordinateSystemEntityRef", frozen=True - ) - entity_type: str - entity_id: str - - -class CoordinateSystemAssignmentGroup(Flow360BaseModel): - """ - Grouped entity assignments for a coordinate system. - - A single coordinate system can be assigned to multiple entities. This model groups the - entity references to keep the status payload compact and easy to validate. - """ - - type_name: Literal["CoordinateSystemAssignmentGroup"] = pd.Field( - "CoordinateSystemAssignmentGroup", frozen=True - ) - coordinate_system_id: str - entities: List[CoordinateSystemEntityRef] - - -class CoordinateSystemStatus(Flow360BaseModel): - """ - Serializable snapshot for front end/asset cache. - - This status is stored in an asset's private cache and restored into a `DraftContext` so - that coordinate system definitions and assignments can persist across sessions. - """ - - type_name: Literal["CoordinateSystemStatus"] = pd.Field("CoordinateSystemStatus", frozen=True) - coordinate_systems: List[CoordinateSystem] - parents: List[CoordinateSystemParent] - assignments: List[CoordinateSystemAssignmentGroup] - - @pd.model_validator(mode="after") - def _validate_unique_coordinate_system_ids_and_names(self): - """Validate that all coordinate system IDs and names are unique.""" - seen_ids = set() - seen_names = set() - for cs in self.coordinate_systems: - # Check IDs first to match the order of validation in _from_status - if cs.private_attribute_id in seen_ids: - raise ValueError( - f"[Internal] Duplicate coordinate system id '{cs.private_attribute_id}' in status." - ) - if cs.name in seen_names: - raise ValueError( - f"[Internal] Duplicate coordinate system name '{cs.name}' in status." - ) - seen_ids.add(cs.private_attribute_id) - seen_names.add(cs.name) - return self - - class CoordinateSystemManager: """ Manage coordinate systems, hierarchy, and entity assignments inside a `DraftContext`. diff --git a/flow360/component/simulation/draft_context/mirror.py b/flow360/component/simulation/draft_context/mirror.py index 1a0064edb..31b76ceb0 100644 --- a/flow360/component/simulation/draft_context/mirror.py +++ b/flow360/component/simulation/draft_context/mirror.py @@ -1,22 +1,14 @@ """Mirror plane, mirrored entities and helpers.""" -from typing import Dict, List, Literal, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union -import numpy as np -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Length +from flow360_schema.models.asset_cache import MirrorStatus +from flow360_schema.models.entities import MirrorPlane -from flow360.component.simulation.entity_operation import ( - _transform_direction, - _transform_point, -) -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityBase from flow360.component.simulation.framework.entity_registry import ( EntityRegistry, EntityRegistryView, ) -from flow360.component.simulation.framework.entity_utils import generate_uuid from flow360.component.simulation.primitives import ( GeometryBodyGroup, MirroredGeometryBodyGroup, @@ -24,115 +16,9 @@ Surface, ) from flow360.component.simulation.utils import is_exact_instance -from flow360.component.types import Axis from flow360.exceptions import Flow360RuntimeError from flow360.log import log - -class MirrorPlane(EntityBase): - """ - Define a mirror plane used by `MirrorManager` to create mirrored draft entities. - - A `MirrorPlane` is a draft entity representing an infinite plane defined by a center point - and a normal direction. Mirror operations use this plane to derive mirrored entities. - - Parameters - ---------- - name : str - Mirror plane name. Must be unique within the draft. - normal : Axis - Normal direction of the mirror plane. - center : LengthType.Point - Center point of the mirror plane. - - Example - ------- - - >>> import flow360 as fl - >>> plane = fl.MirrorPlane( - ... name="MirrorPlane", - ... normal=(0, 1, 0), - ... center=(0, 0, 0) * fl.u.m, - ... ) - """ - - name: str = pd.Field() - normal: Axis = pd.Field(description="Normal direction of the plane.") - # pylint: disable=no-member - center: Length.Vector3 = pd.Field(description="Center point of the plane.") - - private_attribute_entity_type_name: Literal["MirrorPlane"] = pd.Field( - "MirrorPlane", frozen=True - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - def _apply_transformation(self, matrix: np.ndarray) -> "MirrorPlane": - """Apply 3x4 transformation matrix, returning new transformed instance.""" - # Transform the center point - center_array = np.asarray(self.center.value) - new_center_array = _transform_point(center_array, matrix) - new_center = type(self.center)(new_center_array, self.center.units) - - # Transform and normalize the normal direction - normal_array = np.asarray(self.normal) - transformed_normal = _transform_direction(normal_array, matrix) - new_normal = tuple(transformed_normal / np.linalg.norm(transformed_normal)) - - return self.model_copy(update={"center": new_center, "normal": new_normal}) - - -# region -----------------------------Internal Model Below------------------------------------- -class MirrorStatus(Flow360BaseModel): - """ - Serializable snapshot of mirror state stored in the asset cache. - - Notes - ----- - This status stores both: - - User-authored inputs: `mirror_planes` - - Derived draft-only entities: `mirrored_geometry_body_groups` and `mirrored_surfaces` - - The derived entities are generated from mirror actions and are registered into the draft's - entity registry when a draft is created/restored. - """ - - # Note: We can do similar thing as entityList to support mirroring with EntitySelectors. - mirror_planes: List[MirrorPlane] = pd.Field(description="List of mirror planes to mirror.") - mirrored_geometry_body_groups: List[MirroredGeometryBodyGroup] = pd.Field( - description="List of mirrored geometry body groups." - ) - mirrored_surfaces: List[MirroredSurface] = pd.Field(description="List of mirrored surfaces.") - - @pd.model_validator(mode="after") - def _validate_unique_mirror_plane_names(self): - """Validate that all mirror plane names are unique.""" - seen_names = set() - for plane in self.mirror_planes: - if plane.name in seen_names: - raise ValueError( - f"Duplicate mirror plane name '{plane.name}' found in mirror status." - ) - seen_names.add(plane.name) - return self - - def is_empty(self) -> bool: - """ - Return True if no mirror planes or mirrored entities exist in this status. - - Returns - ------- - bool - True when no mirroring is configured. - """ - return ( - not self.mirror_planes - and not self.mirrored_geometry_body_groups - and not self.mirrored_surfaces - ) - - -# endregion ------------------------------------------------------------------------------------- - MIRROR_SUFFIX = "_" # region -----------------------------Internal Functions Below------------------------------------- diff --git a/flow360/component/simulation/entity_operation.py b/flow360/component/simulation/entity_operation.py index ac4c587bc..e8310a03a 100644 --- a/flow360/component/simulation/entity_operation.py +++ b/flow360/component/simulation/entity_operation.py @@ -3,7 +3,6 @@ # pylint: disable=unused-import from flow360_schema.framework.entity.entity_operation import ( # noqa: F401 CoordinateSystem, - Transformation, _build_transformation_matrix, _compose_transformation_matrices, _extract_rotation_matrix, @@ -16,3 +15,6 @@ _validate_uniform_scale_and_transform_center, rotation_matrix_from_axis_and_angle, ) +from flow360_schema.framework.entity.legacy_transformation import ( # noqa: F401 + Transformation, +) diff --git a/flow360/component/simulation/framework/boundary_split.py b/flow360/component/simulation/framework/boundary_split.py index 189953bc6..903792cd0 100644 --- a/flow360/component/simulation/framework/boundary_split.py +++ b/flow360/component/simulation/framework/boundary_split.py @@ -398,7 +398,7 @@ def update_entities_in_model( # pylint: disable=too-many-branches - Lists/tuples containing entities or models """ # pylint: disable=import-outside-toplevel - from flow360.component.simulation.framework.param_utils import AssetCache + from flow360_schema.models.asset_cache import AssetCache for field in model.__dict__.values(): if isinstance(field, AssetCache): diff --git a/flow360/component/simulation/framework/entity_materializer.py b/flow360/component/simulation/framework/entity_materializer.py index 5bb35e846..2970a8a3f 100644 --- a/flow360/component/simulation/framework/entity_materializer.py +++ b/flow360/component/simulation/framework/entity_materializer.py @@ -16,8 +16,8 @@ ) from flow360_schema.framework.entity.entity_utils import DEFAULT_NOT_MERGED_TYPES from flow360_schema.framework.validation.context import DeserializationContext +from flow360_schema.models.entities import MirrorPlane -from flow360.component.simulation.draft_context.mirror import MirrorPlane from flow360.component.simulation.outputs.output_entities import ( Point, PointArray, diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index 666a5dba0..e2f0fd221 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -2,117 +2,20 @@ # pylint: disable=no-member -from typing import List, Optional, Union +from typing import Union -import pydantic as pd -from flow360_schema.framework.expression.variable import VariableContextList -from flow360_schema.framework.physical_dimensions import Length +from flow360_schema.models.asset_cache import AssetCache -from flow360.component.simulation.draft_context.coordinate_system_manager import ( - CoordinateSystemStatus, -) -from flow360.component.simulation.draft_context.mirror import MirrorStatus -from flow360.component.simulation.entity_info import ( - GeometryEntityInfo, - SurfaceMeshEntityInfo, - VolumeMeshEntityInfo, -) from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_base import EntityBase, EntityList from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.entity_selector import EntitySelector from flow360.component.simulation.framework.unique_list import UniqueStringList from flow360.component.simulation.primitives import ( - ImportedSurface, _SurfaceEntityBase, _VolumeEntityBase, ) -class AssetCache(Flow360BaseModel): - """ - Cached info from the project asset. - """ - - project_length_unit: Optional[Length.PositiveFloat64] = pd.Field(None, frozen=True) - project_entity_info: Optional[ - Union[GeometryEntityInfo, VolumeMeshEntityInfo, SurfaceMeshEntityInfo] - ] = pd.Field(None, frozen=True, discriminator="type_name") - use_inhouse_mesher: bool = pd.Field( - False, - description="Flag whether user requested the use of inhouse surface and volume mesher.", - ) - use_geometry_AI: bool = pd.Field( - False, description="Flag whether user requested the use of GAI." - ) - variable_context: Optional[VariableContextList] = pd.Field( - None, - description="List of user variables that are used in all the `Expression` instances.", - ) - used_selectors: Optional[List[EntitySelector]] = pd.Field( - None, - description="Collected entity selectors for token reference.", - ) - imported_surfaces: Optional[List[ImportedSurface]] = pd.Field( - None, description="List of imported surface meshes for post-processing." - ) - mirror_status: Optional[MirrorStatus] = pd.Field( - None, description="Status of mirroring operations that are used in the simulation." - ) - coordinate_system_status: Optional[CoordinateSystemStatus] = pd.Field( - None, description="Status of coordinate systems used in the simulation." - ) - - @property - def boundaries(self): - """ - Get all boundaries (not just names) from the cached entity info. - """ - if self.project_entity_info is None: - return None - return self.project_entity_info.get_boundaries() - - @pd.model_validator(mode="after") - def _validate_mirror_status_compatible_with_geometry(self): - """Raise if mirror_status has mirroring but geometry doesn't support face-to-body-group mapping.""" - if self.mirror_status is None: - return self - if not self.mirror_status.mirrored_geometry_body_groups: - return self - if not isinstance(self.project_entity_info, GeometryEntityInfo): - return self - - try: - self.project_entity_info.get_face_group_to_body_group_id_map() - except ValueError as exc: - raise ValueError( - "Mirroring is requested but the geometry's face groupings span across body groups. " - f"Mirroring cannot be performed: {exc}" - ) from exc - return self - - def preprocess( - self, - *, - params=None, - exclude: List[str] = None, - required_by: List[str] = None, - flow360_unit_system=None, - ) -> Flow360BaseModel: - # Exclude variable_context and selectors from preprocessing. - # NOTE: coordinate_system_status is NOT excluded, which means it will be - # recursively preprocessed. This is CRITICAL because CoordinateSystem objects - # contain LengthType fields (origin, translation) that must be nondimensionalized - # before transformation matrices are computed in the translator. - exclude_asset_cache = exclude + ["variable_context", "selectors"] - return super().preprocess( - params=params, - exclude=exclude_asset_cache, - required_by=required_by, - flow360_unit_system=flow360_unit_system, - ) - - def find_instances(obj, target_type): """Recursively find items of target_type within a python object""" stack = [obj] diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index c8bf9d87e..d960e53b2 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -23,6 +23,7 @@ Velocity, ) from flow360_schema.framework.validation.context import DeserializationContext +from flow360_schema.models.asset_cache import AssetCache from flow360.component.simulation.conversion import ( LIQUID_IMAGINARY_FREESTREAM_MACH, @@ -37,7 +38,6 @@ ) from flow360.component.simulation.framework.entity_registry import EntityRegistry from flow360.component.simulation.framework.param_utils import ( - AssetCache, _set_boundary_full_name_with_zone_name, _update_entity_full_name, _update_zone_boundaries_with_metadata, diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index be336c33a..034e44c1e 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -12,6 +12,7 @@ import unyt as u from flow360_schema.framework.expression import Expression, UserVariable from flow360_schema.framework.physical_dimensions import Length +from flow360_schema.models.asset_cache import AssetCache from flow360.component.simulation.draft_context.coordinate_system_manager import ( CoordinateSystemManager, @@ -19,7 +20,6 @@ from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.base_model_config import snake_to_camel from flow360.component.simulation.framework.entity_base import EntityBase, EntityList -from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.framework.unique_list import UniqueItemList from flow360.component.simulation.meshing_param import snappy from flow360.component.simulation.meshing_param.params import ModularMeshingWorkflow diff --git a/poetry.lock b/poetry.lock index 57645106b..8583ddad7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1468,21 +1468,19 @@ files = [ [[package]] name = "flow360-schema" -version = "0.1.18" +version = "0.1.19" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.18-py3-none-any.whl", hash = "sha256:739b7c9fef756c820d1e7476a84b726cd8b396b6a0178a5a2afad977c20498fa"}, - {file = "flow360_schema-0.1.18.tar.gz", hash = "sha256:d4256c3aedd6f599ab0d4970d46d3e4b882b09e4ee43e3b82977811c84add932"}, + {file = "flow360_schema-0.1.19-py3-none-any.whl", hash = "sha256:5cf3750337a3b58a672314bd3b0ab63ba8e5e43963f4a0d4b87e6976f72a52eb"}, + {file = "flow360_schema-0.1.19.tar.gz", hash = "sha256:34a40ac032555b9af317e99d877288f3564fbaea0c39340c8a6b811d64482aac"}, ] [package.dependencies] pydantic = ">=2.8,<3.0" - -[package.extras] -unyt = ["unyt (>=2.9.0)"] +unyt = ">=2.9.0" [package.source] type = "legacy" diff --git a/tests/simulation/draft_context/test_coordinate_system_assignment.py b/tests/simulation/draft_context/test_coordinate_system_assignment.py index 574501349..fbb3d9cae 100644 --- a/tests/simulation/draft_context/test_coordinate_system_assignment.py +++ b/tests/simulation/draft_context/test_coordinate_system_assignment.py @@ -281,41 +281,6 @@ def test_from_status_validation_errors(mock_geometry): ) -def test_from_status_rejects_duplicate_cs_id(mock_geometry): - """Test that CoordinateSystemStatus rejects duplicate coordinate system IDs via Pydantic validation.""" - from pydantic import ValidationError - - with create_draft(new_run_from=mock_geometry) as draft: - cs = CoordinateSystem(name="cs") - with pytest.raises( - ValidationError, - match="Duplicate coordinate system id", - ): - CoordinateSystemStatus( - coordinate_systems=[cs, cs], - parents=[], - assignments=[], - ) - - -def test_from_status_rejects_duplicate_cs_name(mock_geometry): - """Test that CoordinateSystemStatus rejects duplicate coordinate system names via Pydantic validation.""" - from pydantic import ValidationError - - with create_draft(new_run_from=mock_geometry) as draft: - cs1 = CoordinateSystem(name="duplicate") - cs2 = CoordinateSystem(name="duplicate") - with pytest.raises( - ValidationError, - match="Duplicate coordinate system name 'duplicate'", - ): - CoordinateSystemStatus( - coordinate_systems=[cs1, cs2], - parents=[], - assignments=[], - ) - - def test_from_status_rejects_assignment_unknown_cs(mock_geometry): with create_draft(new_run_from=mock_geometry) as draft: status = CoordinateSystemStatus( diff --git a/tests/simulation/draft_context/test_entity_transformation.py b/tests/simulation/draft_context/test_entity_transformation.py deleted file mode 100644 index 4128b6cc9..000000000 --- a/tests/simulation/draft_context/test_entity_transformation.py +++ /dev/null @@ -1,459 +0,0 @@ -"""Tests for entity transformation methods (_apply_transformation). - -This test module verifies that all entities with coordinate system support -correctly apply 3x4 transformation matrices (rotation + translation + scale). -""" - -import numpy as np -import pytest - -import flow360.component.simulation.units as u -from flow360.component.simulation.draft_context.mirror import MirrorPlane -from flow360.component.simulation.entity_operation import CoordinateSystem -from flow360.component.simulation.outputs.output_entities import ( - Point, - PointArray, - PointArray2D, - Slice, -) -from flow360.component.simulation.primitives import Box, Cylinder, Sphere -from flow360.component.simulation.unit_system import SI_unit_system -from flow360.exceptions import Flow360ValueError - - -# Simple transformation matrices for testing -def identity_matrix(): - """Identity transformation (no change).""" - return np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]], dtype=np.float64) - - -def translation_matrix(tx, ty, tz): - """Pure translation.""" - return np.array([[1, 0, 0, tx], [0, 1, 0, ty], [0, 0, 1, tz]], dtype=np.float64) - - -def rotation_z_90(): - """90 degree rotation around Z axis.""" - return np.array([[0, -1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0]], dtype=np.float64) - - -def uniform_scale_matrix(scale, tx=0, ty=0, tz=0): - """Uniform scaling with optional translation.""" - return np.array([[scale, 0, 0, tx], [0, scale, 0, ty], [0, 0, scale, tz]], dtype=np.float64) - - -def non_uniform_scale_matrix(): - """Non-uniform scaling (different scale on each axis).""" - return np.array([[2, 0, 0, 0], [0, 3, 0, 0], [0, 0, 4, 0]], dtype=np.float64) - - -# ============================================================================== -# Point Tests -# ============================================================================== - - -def test_point_identity_transformation(): - """Point with identity matrix should remain unchanged.""" - with SI_unit_system: - point = Point(name="test_point", location=(1, 2, 3) * u.m) - transformed = point._apply_transformation(identity_matrix()) - - assert transformed.name == "test_point" - np.testing.assert_allclose(transformed.location.value, [1, 2, 3], atol=1e-10) - - -def test_point_translation(): - """Point should translate correctly.""" - with SI_unit_system: - point = Point(name="test_point", location=(1, 2, 3) * u.m) - matrix = translation_matrix(10, 20, 30) - transformed = point._apply_transformation(matrix) - - # Expected: (1, 2, 3) + (10, 20, 30) = (11, 22, 33) - np.testing.assert_allclose(transformed.location.value, [11, 22, 33], atol=1e-10) - - -def test_point_rotation(): - """Point should rotate correctly.""" - with SI_unit_system: - point = Point(name="test_point", location=(1, 0, 0) * u.m) - matrix = rotation_z_90() - transformed = point._apply_transformation(matrix) - - # Expected: 90° rotation of (1, 0, 0) around Z = (0, 1, 0) - np.testing.assert_allclose(transformed.location.value, [0, 1, 0], atol=1e-10) - - -def test_point_uniform_scale(): - """Point should scale correctly.""" - with SI_unit_system: - point = Point(name="test_point", location=(1, 2, 3) * u.m) - matrix = uniform_scale_matrix(2.0) - transformed = point._apply_transformation(matrix) - - # Expected: (1, 2, 3) * 2 = (2, 4, 6) - np.testing.assert_allclose(transformed.location.value, [2, 4, 6], atol=1e-10) - - -# ============================================================================== -# PointArray Tests -# ============================================================================== - - -def test_point_array_translation(): - """PointArray should translate both start and end points.""" - with SI_unit_system: - point_array = PointArray( - name="test_array", start=(0, 0, 0) * u.m, end=(10, 0, 0) * u.m, number_of_points=5 - ) - matrix = translation_matrix(5, 10, 15) - transformed = point_array._apply_transformation(matrix) - - # Expected: start (0,0,0) + (5,10,15) = (5,10,15) - # end (10,0,0) + (5,10,15) = (15,10,15) - np.testing.assert_allclose(transformed.start.value, [5, 10, 15], atol=1e-10) - np.testing.assert_allclose(transformed.end.value, [15, 10, 15], atol=1e-10) - assert transformed.number_of_points == 5 - - -def test_point_array_rotation(): - """PointArray should rotate correctly.""" - with SI_unit_system: - point_array = PointArray( - name="test_array", start=(1, 0, 0) * u.m, end=(2, 0, 0) * u.m, number_of_points=3 - ) - matrix = rotation_z_90() - transformed = point_array._apply_transformation(matrix) - - # Expected: 90° rotation around Z - # start (1,0,0) -> (0,1,0) - # end (2,0,0) -> (0,2,0) - np.testing.assert_allclose(transformed.start.value, [0, 1, 0], atol=1e-10) - np.testing.assert_allclose(transformed.end.value, [0, 2, 0], atol=1e-10) - - -# ============================================================================== -# PointArray2D Tests -# ============================================================================== - - -def test_point_array_2d_translation(): - """PointArray2D should translate origin.""" - with SI_unit_system: - array_2d = PointArray2D( - name="test_2d", - origin=(0, 0, 0) * u.m, - u_axis_vector=(1, 0, 0) * u.m, - v_axis_vector=(0, 1, 0) * u.m, - u_number_of_points=3, - v_number_of_points=3, - ) - matrix = translation_matrix(10, 20, 30) - transformed = array_2d._apply_transformation(matrix) - - np.testing.assert_allclose(transformed.origin.value, [10, 20, 30], atol=1e-10) - # Axis vectors should remain unchanged (pure translation) - np.testing.assert_allclose(transformed.u_axis_vector.value, [1, 0, 0], atol=1e-10) - np.testing.assert_allclose(transformed.v_axis_vector.value, [0, 1, 0], atol=1e-10) - - -def test_point_array_2d_rotation(): - """PointArray2D should rotate origin and axis vectors.""" - with SI_unit_system: - array_2d = PointArray2D( - name="test_2d", - origin=(1, 0, 0) * u.m, - u_axis_vector=(1, 0, 0) * u.m, - v_axis_vector=(0, 1, 0) * u.m, - u_number_of_points=2, - v_number_of_points=2, - ) - matrix = rotation_z_90() - transformed = array_2d._apply_transformation(matrix) - - # Origin (1,0,0) rotated 90° around Z = (0,1,0) - np.testing.assert_allclose(transformed.origin.value, [0, 1, 0], atol=1e-10) - # u_axis (1,0,0) rotated = (0,1,0) - np.testing.assert_allclose(transformed.u_axis_vector.value, [0, 1, 0], atol=1e-10) - # v_axis (0,1,0) rotated = (-1,0,0) - np.testing.assert_allclose(transformed.v_axis_vector.value, [-1, 0, 0], atol=1e-10) - - -# ============================================================================== -# Slice Tests -# ============================================================================== - - -def test_slice_translation(): - """Slice should translate origin.""" - with SI_unit_system: - slice_obj = Slice(name="test_slice", origin=(0, 0, 0) * u.m, normal=(0, 0, 1)) - matrix = translation_matrix(5, 10, 15) - transformed = slice_obj._apply_transformation(matrix) - - np.testing.assert_allclose(transformed.origin.value, [5, 10, 15], atol=1e-10) - # Normal should remain unchanged (pure translation) - np.testing.assert_allclose(transformed.normal, [0, 0, 1], atol=1e-10) - - -def test_slice_rotation(): - """Slice should rotate normal vector.""" - with SI_unit_system: - slice_obj = Slice(name="test_slice", origin=(0, 0, 0) * u.m, normal=(1, 0, 0)) - matrix = rotation_z_90() - transformed = slice_obj._apply_transformation(matrix) - - # Origin unchanged - np.testing.assert_allclose(transformed.origin.value, [0, 0, 0], atol=1e-10) - # Normal (1,0,0) rotated 90° around Z = (0,1,0) - np.testing.assert_allclose(transformed.normal, [0, 1, 0], atol=1e-10) - - -# ============================================================================== -# MirrorPlane Tests -# ============================================================================== - - -def test_mirror_plane_translation(): - """MirrorPlane should translate center.""" - with SI_unit_system: - mirror = MirrorPlane(name="test_mirror", center=(0, 0, 0) * u.m, normal=(0, 1, 0)) - matrix = translation_matrix(10, 20, 30) - transformed = mirror._apply_transformation(matrix) - - np.testing.assert_allclose(transformed.center.value, [10, 20, 30], atol=1e-10) - np.testing.assert_allclose(transformed.normal, [0, 1, 0], atol=1e-10) - - -def test_mirror_plane_rotation(): - """MirrorPlane should rotate normal vector.""" - with SI_unit_system: - mirror = MirrorPlane(name="test_mirror", center=(1, 0, 0) * u.m, normal=(1, 0, 0)) - matrix = rotation_z_90() - transformed = mirror._apply_transformation(matrix) - - # Center (1,0,0) rotated = (0,1,0) - np.testing.assert_allclose(transformed.center.value, [0, 1, 0], atol=1e-10) - # Normal (1,0,0) rotated = (0,1,0) - np.testing.assert_allclose(transformed.normal, [0, 1, 0], atol=1e-10) - - -# ============================================================================== -# Box Tests (with uniform scaling validation) -# ============================================================================== - - -def test_box_identity(): - """Box with identity matrix should remain unchanged.""" - with SI_unit_system: - box = Box(name="test_box", center=(0, 0, 0) * u.m, size=(2, 2, 2) * u.m) - transformed = box._apply_transformation(identity_matrix()) - - np.testing.assert_allclose(transformed.center.value, [0, 0, 0], atol=1e-10) - np.testing.assert_allclose(transformed.size.value, [2, 2, 2], atol=1e-10) - - -def test_box_translation(): - """Box should translate center.""" - with SI_unit_system: - box = Box(name="test_box", center=(1, 2, 3) * u.m, size=(2, 4, 6) * u.m) - matrix = translation_matrix(10, 20, 30) - transformed = box._apply_transformation(matrix) - - np.testing.assert_allclose(transformed.center.value, [11, 22, 33], atol=1e-10) - # Size unchanged by translation - np.testing.assert_allclose(transformed.size.value, [2, 4, 6], atol=1e-10) - - -def test_box_uniform_scale(): - """Box should scale size uniformly.""" - with SI_unit_system: - box = Box(name="test_box", center=(1, 0, 0) * u.m, size=(2, 4, 6) * u.m) - matrix = uniform_scale_matrix(2.0) - transformed = box._apply_transformation(matrix) - - # Center scaled: (1,0,0) * 2 = (2,0,0) - np.testing.assert_allclose(transformed.center.value, [2, 0, 0], atol=1e-10) - # Size scaled: (2,4,6) * 2 = (4,8,12) - np.testing.assert_allclose(transformed.size.value, [4, 8, 12], atol=1e-10) - - -def test_box_rotation(): - """Box should rotate axis_of_rotation.""" - with SI_unit_system: - box = Box( - name="test_box", - center=(0, 0, 0) * u.m, - size=(2, 2, 2) * u.m, - axis_of_rotation=(1, 0, 0), - angle_of_rotation=45 * u.deg, - ) - matrix = rotation_z_90() - transformed = box._apply_transformation(matrix) - - # Axis (1,0,0) rotated 90° around Z = (0,1,0) - # The combined rotation should be applied - # This is a complex test - just verify it doesn't crash - assert transformed.center.value is not None - - -def test_box_non_uniform_scale_raises_error(): - """Box should reject non-uniform scaling.""" - with SI_unit_system: - box = Box(name="test_box", center=(0, 0, 0) * u.m, size=(2, 2, 2) * u.m) - matrix = non_uniform_scale_matrix() - - with pytest.raises(Flow360ValueError, match="only supports uniform scaling"): - box._apply_transformation(matrix) - - -# ============================================================================== -# Sphere Tests (with uniform scaling validation) -# ============================================================================== - - -def test_sphere_identity(): - """Sphere with identity matrix should remain unchanged.""" - with SI_unit_system: - sphere = Sphere(name="test_sphere", center=(0, 0, 0) * u.m, radius=5 * u.m) - transformed = sphere._apply_transformation(identity_matrix()) - - np.testing.assert_allclose(transformed.center.value, [0, 0, 0], atol=1e-10) - np.testing.assert_allclose(transformed.radius.value, 5, atol=1e-10) - - -def test_sphere_translation(): - """Sphere should translate center.""" - with SI_unit_system: - sphere = Sphere(name="test_sphere", center=(1, 2, 3) * u.m, radius=5 * u.m) - matrix = translation_matrix(10, 20, 30) - transformed = sphere._apply_transformation(matrix) - - np.testing.assert_allclose(transformed.center.value, [11, 22, 33], atol=1e-10) - # Radius unchanged by translation - np.testing.assert_allclose(transformed.radius.value, 5, atol=1e-10) - - -def test_sphere_uniform_scale(): - """Sphere should scale radius uniformly.""" - with SI_unit_system: - sphere = Sphere(name="test_sphere", center=(1, 0, 0) * u.m, radius=5 * u.m) - matrix = uniform_scale_matrix(2.0) - transformed = sphere._apply_transformation(matrix) - - # Center scaled: (1,0,0) * 2 = (2,0,0) - np.testing.assert_allclose(transformed.center.value, [2, 0, 0], atol=1e-10) - # Radius scaled: 5 * 2 = 10 - np.testing.assert_allclose(transformed.radius.value, 10, atol=1e-10) - - -def test_sphere_rotation(): - """Sphere center and axis should rotate (radius unchanged).""" - with SI_unit_system: - sphere = Sphere(name="test_sphere", center=(1, 0, 0) * u.m, radius=5 * u.m, axis=(1, 0, 0)) - matrix = rotation_z_90() - transformed = sphere._apply_transformation(matrix) - - # Center (1,0,0) rotated 90° around Z = (0,1,0) - np.testing.assert_allclose(transformed.center.value, [0, 1, 0], atol=1e-10) - # Axis (1,0,0) rotated 90° around Z = (0,1,0) - np.testing.assert_allclose(transformed.axis, (0, 1, 0), atol=1e-10) - # Radius unchanged by rotation - np.testing.assert_allclose(transformed.radius.value, 5, atol=1e-10) - - -def test_sphere_non_uniform_scale_raises_error(): - """Sphere should reject non-uniform scaling.""" - with SI_unit_system: - sphere = Sphere(name="test_sphere", center=(0, 0, 0) * u.m, radius=5 * u.m) - matrix = non_uniform_scale_matrix() - - with pytest.raises(Flow360ValueError, match="only supports uniform scaling"): - sphere._apply_transformation(matrix) - - -# ============================================================================== -# Cylinder Tests (with uniform scaling validation) -# ============================================================================== - - -def test_cylinder_translation(): - """Cylinder should translate center.""" - with SI_unit_system: - cylinder = Cylinder( - name="test_cyl", - center=(0, 0, 0) * u.m, - axis=(0, 0, 1), - height=10 * u.m, - outer_radius=2 * u.m, - ) - matrix = translation_matrix(5, 10, 15) - transformed = cylinder._apply_transformation(matrix) - - np.testing.assert_allclose(transformed.center.value, [5, 10, 15], atol=1e-10) - # Axis unchanged - np.testing.assert_allclose(transformed.axis, [0, 0, 1], atol=1e-10) - - -def test_cylinder_rotation(): - """Cylinder should rotate axis.""" - with SI_unit_system: - cylinder = Cylinder( - name="test_cyl", - center=(0, 0, 0) * u.m, - axis=(1, 0, 0), - height=10 * u.m, - outer_radius=2 * u.m, - ) - matrix = rotation_z_90() - transformed = cylinder._apply_transformation(matrix) - - # Axis (1,0,0) rotated 90° around Z = (0,1,0) - np.testing.assert_allclose(transformed.axis, [0, 1, 0], atol=1e-10) - - -def test_cylinder_uniform_scale(): - """Cylinder should scale uniformly.""" - with SI_unit_system: - cylinder = Cylinder( - name="test_cyl", - center=(1, 0, 0) * u.m, - axis=(0, 0, 1), - height=10 * u.m, - outer_radius=2 * u.m, - inner_radius=1 * u.m, - ) - matrix = uniform_scale_matrix(2.0) - transformed = cylinder._apply_transformation(matrix) - - # Center scaled - np.testing.assert_allclose(transformed.center.value, [2, 0, 0], atol=1e-10) - # Dimensions scaled - np.testing.assert_allclose(transformed.height.value, 20, atol=1e-10) - np.testing.assert_allclose(transformed.outer_radius.value, 4, atol=1e-10) - np.testing.assert_allclose(transformed.inner_radius.value, 2, atol=1e-10) - - -def test_cylinder_non_uniform_scale_raises_error(): - """Cylinder should reject non-uniform scaling.""" - with SI_unit_system: - cylinder = Cylinder( - name="test_cyl", - center=(0, 0, 0) * u.m, - axis=(0, 0, 1), - height=10 * u.m, - outer_radius=2 * u.m, - ) - matrix = non_uniform_scale_matrix() - - with pytest.raises(Flow360ValueError, match="only supports uniform scaling"): - cylinder._apply_transformation(matrix) - - -# ============================================================================== -# AxisymmetricBody Tests (with uniform scaling validation) -# ============================================================================== - -# Note: AxisymmetricBody and CustomVolume tests skipped for now -# They require complex construction with specific boundary conditions -# The transformation logic has been implemented and tested via other entities diff --git a/tests/simulation/draft_context/test_mirror_action.py b/tests/simulation/draft_context/test_mirror_action.py index 3838b883d..e50475e5f 100644 --- a/tests/simulation/draft_context/test_mirror_action.py +++ b/tests/simulation/draft_context/test_mirror_action.py @@ -1,6 +1,5 @@ import json import os -from unittest.mock import MagicMock import pytest from pydantic import ValidationError @@ -17,12 +16,7 @@ MirrorStatus, _derive_mirrored_entities_from_actions, ) -from flow360.component.simulation.entity_info import GeometryEntityInfo -from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.primitives import ( - GeometryBodyGroup, - MirroredGeometryBodyGroup, -) +from flow360.component.simulation.primitives import GeometryBodyGroup from flow360.component.simulation.simulation_params import SimulationParams from flow360.exceptions import Flow360RuntimeError @@ -289,33 +283,6 @@ def test_mirror_create_rejects_duplicate_plane_name(mock_geometry): draft.mirror.create_mirror_of(entities=body_groups[0], mirror_plane=mirror_plane2) -def test_mirror_from_status_rejects_duplicate_plane_names(mock_geometry): - """Test that MirrorStatus rejects duplicate mirror plane names via Pydantic validation.""" - with create_draft(new_run_from=mock_geometry) as draft: - body_groups = list(draft.body_groups) - assert body_groups, "Test requires at least one body group." - - # Create a status with duplicate mirror plane names. - plane1 = MirrorPlane(name="duplicate", normal=(1, 0, 0), center=(0, 0, 0) * u.m) - plane2 = MirrorPlane(name="duplicate", normal=(0, 1, 0), center=(0, 0, 0) * u.m) - - # Build a MirrorStatus with duplicate plane names - should fail during construction. - from flow360.component.simulation.primitives import MirroredGeometryBodyGroup - - with pytest.raises(ValidationError, match="Duplicate mirror plane name 'duplicate'"): - MirrorStatus( - mirror_planes=[plane1, plane2], - mirrored_geometry_body_groups=[ - MirroredGeometryBodyGroup( - name=f"{body_groups[0].name}_", - geometry_body_group_id=body_groups[0].private_attribute_id, - mirror_plane_id=plane1.private_attribute_id, - ) - ], - mirrored_surfaces=[], - ) - - def test_remove_mirror_of_removes_mirror_assignment(mock_geometry): """Test that remove_mirror_of successfully removes mirror assignments.""" with create_draft(new_run_from=mock_geometry) as draft: @@ -573,171 +540,6 @@ def test_mirror_create_raises_when_face_group_to_body_group_is_none(mock_geometr mirror_manager.create_mirror_of(entities=body_group, mirror_plane=mirror_plane) -def test_asset_cache_validator_raises_when_mirror_status_conflicts_with_geometry(): - """Test that AssetCache validator raises when mirror_status has mirrorings but geometry doesn't support it.""" - # Create a mock GeometryEntityInfo that raises ValueError when get_face_group_to_body_group_id_map() is called - mock_entity_info = MagicMock(spec=GeometryEntityInfo) - mock_entity_info.get_face_group_to_body_group_id_map.side_effect = ValueError( - "Face group 'test_face' contains faces belonging to multiple body groups" - ) - - # Create a MirrorStatus with meaningful mirrorings - mirror_plane = MirrorPlane( - name="mirrorX", - normal=(1, 0, 0), - center=(0, 0, 0) * u.m, - ) - mirrored_body_group = MirroredGeometryBodyGroup( - name="test_body_group_", - geometry_body_group_id="test-body-group-id", - mirror_plane_id=mirror_plane.private_attribute_id, - ) - mirror_status = MirrorStatus( - mirror_planes=[mirror_plane], - mirrored_geometry_body_groups=[mirrored_body_group], - mirrored_surfaces=[], - ) - - # Use model_construct to bypass field validation and directly set values - # Then call the validator method directly - asset_cache = AssetCache.model_construct( - project_entity_info=mock_entity_info, - mirror_status=mirror_status, - ) - - # Call the validator directly - it should raise ValueError - with pytest.raises( - ValueError, - match="Mirroring is requested but the geometry's face groupings span across body groups", - ): - asset_cache._validate_mirror_status_compatible_with_geometry() - - -def test_asset_cache_validator_passes_when_no_mirror_status(): - """Test that AssetCache validator passes when mirror_status is None.""" - # Create a mock GeometryEntityInfo that would raise if called - mock_entity_info = MagicMock(spec=GeometryEntityInfo) - mock_entity_info.get_face_group_to_body_group_id_map.side_effect = ValueError( - "This should not be called" - ) - - # Use model_construct to bypass field validation - asset_cache = AssetCache.model_construct( - project_entity_info=mock_entity_info, - mirror_status=None, - ) - - # Validator should pass and return self - result = asset_cache._validate_mirror_status_compatible_with_geometry() - assert result is asset_cache - # Verify the method was never called - mock_entity_info.get_face_group_to_body_group_id_map.assert_not_called() - - -def test_asset_cache_validator_passes_when_empty_mirrored_body_groups(): - """Test that AssetCache validator passes when mirror_status has no mirrored body groups.""" - mock_entity_info = MagicMock(spec=GeometryEntityInfo) - mock_entity_info.get_face_group_to_body_group_id_map.side_effect = ValueError( - "This should not be called" - ) - - # MirrorStatus with empty mirrored_geometry_body_groups should not trigger validation - mirror_status = MirrorStatus( - mirror_planes=[], - mirrored_geometry_body_groups=[], - mirrored_surfaces=[], - ) - - # Use model_construct to bypass field validation - asset_cache = AssetCache.model_construct( - project_entity_info=mock_entity_info, - mirror_status=mirror_status, - ) - - # Validator should pass and return self - result = asset_cache._validate_mirror_status_compatible_with_geometry() - assert result is asset_cache - # Verify the method was never called - mock_entity_info.get_face_group_to_body_group_id_map.assert_not_called() - - -def test_asset_cache_validator_passes_when_geometry_supports_mirroring(): - """Test that AssetCache validator passes when geometry supports mirroring.""" - # Create a mock GeometryEntityInfo that returns valid mapping - mock_entity_info = MagicMock(spec=GeometryEntityInfo) - mock_entity_info.get_face_group_to_body_group_id_map.return_value = { - "surface1": "body_group_1", - "surface2": "body_group_1", - } - - # Create a MirrorStatus with meaningful mirrorings - mirror_plane = MirrorPlane( - name="mirrorX", - normal=(1, 0, 0), - center=(0, 0, 0) * u.m, - ) - mirrored_body_group = MirroredGeometryBodyGroup( - name="test_body_group_", - geometry_body_group_id="test-body-group-id", - mirror_plane_id=mirror_plane.private_attribute_id, - ) - mirror_status = MirrorStatus( - mirror_planes=[mirror_plane], - mirrored_geometry_body_groups=[mirrored_body_group], - mirrored_surfaces=[], - ) - - # Use model_construct to bypass field validation - asset_cache = AssetCache.model_construct( - project_entity_info=mock_entity_info, - mirror_status=mirror_status, - ) - - # Validator should pass and return self - result = asset_cache._validate_mirror_status_compatible_with_geometry() - assert result is asset_cache - # Verify the method was called - mock_entity_info.get_face_group_to_body_group_id_map.assert_called_once() - - -def test_asset_cache_validator_skips_non_geometry_entity_info(): - """Test that AssetCache validator skips validation when entity_info is not GeometryEntityInfo.""" - # Use a mock that doesn't have isinstance(x, GeometryEntityInfo) returning True - mock_entity_info = MagicMock() # Not spec=GeometryEntityInfo - mock_entity_info.get_face_group_to_body_group_id_map.side_effect = ValueError( - "This should not be called" - ) - - # Create a MirrorStatus with meaningful mirrorings - mirror_plane = MirrorPlane( - name="mirrorX", - normal=(1, 0, 0), - center=(0, 0, 0) * u.m, - ) - mirrored_body_group = MirroredGeometryBodyGroup( - name="test_body_group_", - geometry_body_group_id="test-body-group-id", - mirror_plane_id=mirror_plane.private_attribute_id, - ) - mirror_status = MirrorStatus( - mirror_planes=[mirror_plane], - mirrored_geometry_body_groups=[mirrored_body_group], - mirrored_surfaces=[], - ) - - # Use model_construct to bypass field validation - asset_cache = AssetCache.model_construct( - project_entity_info=mock_entity_info, - mirror_status=mirror_status, - ) - - # Validator should pass because entity_info is not GeometryEntityInfo - result = asset_cache._validate_mirror_status_compatible_with_geometry() - assert result is asset_cache - # Verify the method was never called because isinstance check should fail - mock_entity_info.get_face_group_to_body_group_id_map.assert_not_called() - - # -------------------------------------------------------------------------------------- # Tests for mirror status when face groupings changes # -------------------------------------------------------------------------------------- diff --git a/tests/simulation/framework/test_pre_deserialized_entity_info.py b/tests/simulation/framework/test_pre_deserialized_entity_info.py index fe205a1d6..97725d305 100644 --- a/tests/simulation/framework/test_pre_deserialized_entity_info.py +++ b/tests/simulation/framework/test_pre_deserialized_entity_info.py @@ -1,9 +1,8 @@ -"""Tests for pre-deserialized entity_info optimization in validate_model().""" +"""Tests for validate_model() dict substitution optimization.""" import pytest from flow360.component.simulation.entity_info import VolumeMeshEntityInfo -from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.primitives import Surface @@ -17,66 +16,8 @@ def volume_mesh_entity_info(): ) -@pytest.fixture -def volume_mesh_entity_info_dict(volume_mesh_entity_info): - """Sample VolumeMeshEntityInfo as dict.""" - return volume_mesh_entity_info.model_dump(mode="json") - - -class TestAssetCacheWithPreDeserializedEntityInfo: - """Tests for AssetCache accepting pre-deserialized entity_info directly.""" - - def test_asset_cache_accepts_entity_info_dict(self, volume_mesh_entity_info_dict): - """AssetCache correctly deserializes entity_info from dict.""" - asset_cache = AssetCache(project_entity_info=volume_mesh_entity_info_dict) - - assert asset_cache.project_entity_info is not None - assert isinstance(asset_cache.project_entity_info, VolumeMeshEntityInfo) - assert len(asset_cache.project_entity_info.boundaries) == 1 - assert asset_cache.project_entity_info.boundaries[0].name == "wall" - - def test_asset_cache_accepts_entity_info_object_directly(self, volume_mesh_entity_info): - """AssetCache accepts pre-deserialized entity_info object directly.""" - # This is the key optimization: pass the object directly instead of dict - asset_cache = AssetCache(project_entity_info=volume_mesh_entity_info) - - # Should be the exact same object (identity preserved) - assert asset_cache.project_entity_info is volume_mesh_entity_info - - def test_object_identity_preserved_with_marker(self, volume_mesh_entity_info): - """Verify object identity is preserved by checking a marker attribute.""" - # Add a marker attribute to verify identity - object.__setattr__(volume_mesh_entity_info, "_test_marker", "unique_marker_12345") - - asset_cache = AssetCache(project_entity_info=volume_mesh_entity_info) - - # The marker should still be present (same object) - assert hasattr(asset_cache.project_entity_info, "_test_marker") - assert asset_cache.project_entity_info._test_marker == "unique_marker_12345" - - def test_none_entity_info_stays_none(self): - """None entity_info should remain None.""" - asset_cache = AssetCache(project_entity_info=None) - assert asset_cache.project_entity_info is None - - def test_full_asset_cache_with_pre_deserialized(self, volume_mesh_entity_info): - """Full AssetCache with multiple fields and pre-deserialized entity_info.""" - asset_cache = AssetCache( - project_length_unit={"value": 1.0, "units": "m"}, - project_entity_info=volume_mesh_entity_info, - use_inhouse_mesher=True, - use_geometry_AI=False, - ) - - # Verify all fields are correct - assert asset_cache.project_entity_info is volume_mesh_entity_info - assert asset_cache.use_inhouse_mesher is True - assert asset_cache.use_geometry_AI is False - assert asset_cache.project_length_unit is not None - - -class TestDictSubstitutionOptimization: - """Tests verifying the dict substitution approach works correctly.""" +class TestValidateModelDictSubstitutionOptimization: + """Tests verifying validate_model() dict substitution works correctly.""" def test_shallow_copy_does_not_affect_original(self, volume_mesh_entity_info): """Shallow copy of dict with substituted entity_info doesn't affect original.""" @@ -110,21 +51,3 @@ def test_shallow_copy_does_not_affect_original(self, volume_mesh_entity_info): ) # Other fields are shared (shallow copy) assert new_dict["other_field"] is original_dict["other_field"] - - def test_different_deserializations_create_distinct_objects(self, volume_mesh_entity_info_dict): - """Without optimization, each deserialization creates distinct objects.""" - asset_cache1 = AssetCache(project_entity_info=volume_mesh_entity_info_dict) - asset_cache2 = AssetCache(project_entity_info=volume_mesh_entity_info_dict) - - # Without optimization, each call creates a new entity_info object - assert asset_cache1.project_entity_info is not asset_cache2.project_entity_info - - def test_same_object_reused_when_passed_directly(self, volume_mesh_entity_info): - """When same object is passed directly, identity is preserved.""" - asset_cache1 = AssetCache(project_entity_info=volume_mesh_entity_info) - asset_cache2 = AssetCache(project_entity_info=volume_mesh_entity_info) - - # Both use the same object - assert asset_cache1.project_entity_info is volume_mesh_entity_info - assert asset_cache2.project_entity_info is volume_mesh_entity_info - assert asset_cache1.project_entity_info is asset_cache2.project_entity_info From 6565610497fff6c74444689ebcf0c331f5c64ff7 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:15:43 -0400 Subject: [PATCH 10/25] schema: remove duplicated client entity tests (#1954) --- .../framework/test_entities_fast_register.py | 204 +------- .../simulation/framework/test_entities_v2.py | 495 +----------------- .../framework/test_entity_dict_database.py | 283 +--------- .../framework/test_entity_expansion_impl.py | 262 --------- .../simulation/framework/test_entity_list.py | 170 +----- .../framework/test_entity_materializer.py | 472 ----------------- .../test_entity_materializer_coverage.py | 56 -- .../test_entity_selector_fluent_api.py | 231 -------- .../framework/test_entity_selector_token.py | 174 ------ ..._entity_type_filtering_during_expansion.py | 179 +------ .../test_geometry_entity_info_all_ids.py | 52 -- .../framework/test_multi_constructor_model.py | 84 +-- .../test_selector_merge_vs_replace.py | 79 --- 13 files changed, 23 insertions(+), 2718 deletions(-) delete mode 100644 tests/simulation/framework/test_entity_expansion_impl.py delete mode 100644 tests/simulation/framework/test_entity_materializer.py delete mode 100644 tests/simulation/framework/test_entity_materializer_coverage.py delete mode 100644 tests/simulation/framework/test_entity_selector_fluent_api.py delete mode 100644 tests/simulation/framework/test_entity_selector_token.py delete mode 100644 tests/simulation/framework/test_geometry_entity_info_all_ids.py delete mode 100644 tests/simulation/framework/test_selector_merge_vs_replace.py diff --git a/tests/simulation/framework/test_entities_fast_register.py b/tests/simulation/framework/test_entities_fast_register.py index a4e512c08..8a62065b6 100644 --- a/tests/simulation/framework/test_entities_fast_register.py +++ b/tests/simulation/framework/test_entities_fast_register.py @@ -1,48 +1,28 @@ -import re -from copy import deepcopy -from typing import List, Literal, Optional, Union +from typing import List, Literal -import numpy as np import pydantic as pd import pytest from flow360_schema.framework.physical_dimensions import Length import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityBase, EntityList +from flow360.component.simulation.framework.entity_base import EntityList from flow360.component.simulation.framework.entity_registry import EntityRegistry from flow360.component.simulation.framework.param_utils import ( AssetCache, register_entity_list, ) -from flow360.component.simulation.outputs.output_entities import PointArray2D -from flow360.component.simulation.outputs.outputs import StreamlineOutput from flow360.component.simulation.primitives import ( Box, Cylinder, - Edge, GenericVolume, Surface, - _SurfaceEntityBase, -) -from flow360.component.simulation.simulation_params import ( - SimulationParams, - _ParamModelBase, ) +from flow360.component.simulation.simulation_params import _ParamModelBase from flow360.component.simulation.unit_system import SI_unit_system from tests.simulation.conftest import AssetBase -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -@pytest.fixture -def array_equality_override(): - pass # No-op fixture to override the original one - - class TempVolumeMesh(AssetBase): """Mimicking the final VolumeMesh class""" @@ -180,39 +160,19 @@ def __init__(self, file_name: str): self._populate_registry() -class TempSurface(_SurfaceEntityBase): - private_attribute_entity_type_name: Literal["TempSurface"] = pd.Field( - "TempSurface", frozen=True - ) - - class TempFluidDynamics(Flow360BaseModel): entities: EntityList[GenericVolume, Box, Cylinder] = pd.Field(alias="volumes", default=None) class TempWallBC(Flow360BaseModel): - entities: EntityList[Surface, TempSurface] = pd.Field(alias="surfaces", default=[]) - - -class TempRotation(Flow360BaseModel): - entities: EntityList[GenericVolume, Cylinder] = pd.Field(alias="volumes") - parent_volume: Optional[Union[GenericVolume]] = pd.Field(None) - - -class TempUserDefinedDynamic(Flow360BaseModel): - name: str = pd.Field() - input_boundary_patches: Optional[EntityList[Surface]] = pd.Field(None) - output_target: Optional[Cylinder] = pd.Field( - None - ) # Limited to `Cylinder` for now as we have only tested using UDD to control rotation. + entities: EntityList[Surface] = pd.Field(alias="surfaces", default=[]) class TempSimulationParam(_ParamModelBase): far_field_type: Literal["auto", "user-defined"] = pd.Field() - models: List[Union[TempFluidDynamics, TempWallBC, TempRotation]] = pd.Field() - udd: Optional[TempUserDefinedDynamic] = pd.Field(None) + models: List[TempFluidDynamics | TempWallBC] = pd.Field() private_attribute_asset_cache: AssetCache = pd.Field(AssetCache(), frozen=True) @property @@ -251,18 +211,6 @@ def my_cylinder1(): ) -@pytest.fixture -def my_cylinder2(): - return Cylinder( - name="zone/Cylinder2", - height=12 * u.nm, - axis=(1, 0, 0), - inner_radius=1 * u.nm, - outer_radius=2 * u.nm, - center=(1, 2, 3) * u.nm, - ) - - @pytest.fixture def my_box_zone1(): return Box.from_principal_axes( @@ -283,11 +231,6 @@ def my_box_zone2(): ) -@pytest.fixture -def my_surface1(): - return TempSurface(name="MySurface1") - - @pytest.fixture def my_volume_mesh1(): return TempVolumeMesh(file_name="volMesh-1.cgns") @@ -298,85 +241,6 @@ def my_volume_mesh2(): return TempVolumeMesh(file_name="volMesh-2.cgns") -@pytest.fixture -def my_volume_mesh_with_interface(): - return TempVolumeMesh(file_name="volMesh-with_interface.cgns") - - -##:: ---------------- Entity tests ---------------- - - -def unset_entity_type(): - def IncompleteEntity(EntityBase): - pass - - with pytest.raises( - NotImplementedError, - match=re.escape( - "private_attribute_registry_bucket_name is not defined in the entity class." - ), - ): - IncompleteEntity(name="IncompleteEntity") - - -##:: ---------------- EntityList/Registry tests ---------------- - - -def test_by_reference_registry(my_cylinder2): - """Test that the entity registry contains reference not deepcopy of the entities.""" - my_fd = TempFluidDynamics(entities=[my_cylinder2]) - - registry = EntityRegistry() - registry.fast_register(my_cylinder2, set()) - entities = list(registry.view(Cylinder)) # get the entities now before change - # [Registry] External changes --> Internal - my_cylinder2.height = 131 * u.m - for entity in entities: - if isinstance(entity, Cylinder) and entity.name == "zone/Cylinder2": - assert entity.height == 131 * u.m - - # [Registry] Internal changes --> External - my_cylinder2_ref = registry.find_by_naming_pattern( - pattern="zone/Cylinder2", enforce_output_as_list=False - ) - my_cylinder2_ref.height = 132 * u.m - assert my_cylinder2.height == 132 * u.m - - assert my_fd.entities.stored_entities[0].height == 132 * u.m - - -def test_entity_registry_item_retrieval( - my_cylinder1, - my_cylinder2, - my_box_zone1, - my_box_zone2, -): - known_frozen_hashes = set() - registry = EntityRegistry() - known_frozen_hashes = registry.fast_register(my_cylinder1, known_frozen_hashes) - known_frozen_hashes = registry.fast_register(my_cylinder2, known_frozen_hashes) - known_frozen_hashes = registry.fast_register(my_box_zone1, known_frozen_hashes) - known_frozen_hashes = registry.fast_register(my_box_zone2, known_frozen_hashes) - all_box_entities = list(registry.view(Box)) - # Note: After switching to type-based storage, Box and Cylinder are separate types - assert len(all_box_entities) == 2 - assert my_box_zone1 in all_box_entities - assert my_box_zone2 in all_box_entities - assert my_cylinder1 not in all_box_entities - assert my_cylinder2 not in all_box_entities - - known_frozen_hashes = set() - registry = EntityRegistry() - known_frozen_hashes = registry.fast_register( - Surface(name="AA_ground_close"), known_frozen_hashes - ) - known_frozen_hashes = registry.fast_register(Surface(name="BB"), known_frozen_hashes) - known_frozen_hashes = registry.fast_register(Surface(name="CC_ground"), known_frozen_hashes) - items = registry.find_by_naming_pattern("*ground", enforce_output_as_list=True) - assert len(items) == 1 - assert items[0].name == "CC_ground" - - def test_multiple_param_creation_and_asset_registry( my_cylinder1, my_box_zone2, my_box_zone1, my_volume_mesh1, my_volume_mesh2 ): # Make sure that no entities from the first param are present in the second param @@ -477,61 +341,3 @@ def test_entities_change_reflection_in_param_registry(my_cylinder1, my_volume_me pattern="zone/Cylinder1", enforce_output_as_list=False ) assert all(my_cylinder1_ref.center == [3, 2, 1] * u.m) - - -def test_entity_registry_find_by_id(): - registry = EntityRegistry() - - genericVolume_entity = GenericVolume(name="123", private_attribute_id="original_zone_name") - surface_entity1 = Surface(name="123", private_attribute_id="original_surface_name") - surface_entity2 = Surface(name="1234", private_attribute_id="original_surface_name2") - edge_entity = Edge(name="123", private_attribute_id="original_edge_name") - with SI_unit_system: - box_entity = Box( - name="123bOx", - center=(0, 0, 0) * u.m, - size=(1, 1, 1) * u.m, - axis_of_rotation=(1, 1, 0), - angle_of_rotation=np.pi * u.rad, - private_attribute_id="original_box_name", - ) - - known_frozen_hashes = set() - known_frozen_hashes = registry.fast_register(genericVolume_entity, known_frozen_hashes) - known_frozen_hashes = registry.fast_register(surface_entity1, known_frozen_hashes) - known_frozen_hashes = registry.fast_register(surface_entity2, known_frozen_hashes) - known_frozen_hashes = registry.fast_register(edge_entity, known_frozen_hashes) - known_frozen_hashes = registry.fast_register(box_entity, known_frozen_hashes) - - modified_genericVolume_entity = GenericVolume( - name="999", private_attribute_id="original_zone_name" - ) - modified_surface_entity1 = Surface(name="999", private_attribute_id="original_surface_name") - modified_surface_entity2 = Surface(name="9992", private_attribute_id="original_surface_name2") - modified_edge_entity = Edge(name="999", private_attribute_id="original_edge_name") - with SI_unit_system: - modified_box_entity = Box( - name="999", - center=(0, 0, 0) * u.m, - size=(1, 1, 1) * u.m, - axis_of_rotation=(1, 1, 0), - angle_of_rotation=np.pi * u.rad, - private_attribute_id="original_box_name", - ) - - for modified_item, original_item in zip( - [ - modified_genericVolume_entity, - modified_surface_entity1, - modified_surface_entity2, - modified_edge_entity, - modified_box_entity, - ], - [genericVolume_entity, surface_entity1, surface_entity2, edge_entity, box_entity], - ): - assert ( - registry.find_by_asset_id( - entity_id=modified_item.id, entity_class=modified_item.__class__ - ) - == original_item - ) diff --git a/tests/simulation/framework/test_entities_v2.py b/tests/simulation/framework/test_entities_v2.py index 7c5be88c8..fb57ec367 100644 --- a/tests/simulation/framework/test_entities_v2.py +++ b/tests/simulation/framework/test_entities_v2.py @@ -1,49 +1,23 @@ -import re -from copy import deepcopy -from typing import List, Literal, Optional, Union +from typing import List, Literal, Optional -import numpy as np import pydantic as pd import pytest from flow360_schema.framework.physical_dimensions import Length import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityBase, EntityList +from flow360.component.simulation.framework.entity_base import EntityList from flow360.component.simulation.framework.entity_registry import EntityRegistry from flow360.component.simulation.framework.param_utils import ( AssetCache, register_entity_list, ) -from flow360.component.simulation.outputs.output_entities import PointArray2D -from flow360.component.simulation.outputs.outputs import StreamlineOutput -from flow360.component.simulation.primitives import ( - Box, - Cylinder, - Edge, - GenericVolume, - Sphere, - Surface, - _SurfaceEntityBase, -) -from flow360.component.simulation.simulation_params import ( - SimulationParams, - _ParamModelBase, -) +from flow360.component.simulation.primitives import Cylinder, GenericVolume, Surface +from flow360.component.simulation.simulation_params import _ParamModelBase from flow360.component.simulation.unit_system import SI_unit_system from tests.simulation.conftest import AssetBase -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -@pytest.fixture -def array_equality_override(): - pass # No-op fixture to override the original one - - class TempVolumeMesh(AssetBase): """Mimicking the final VolumeMesh class""" @@ -177,23 +151,9 @@ def __init__(self, file_name: str): self._populate_registry() -class TempSurface(_SurfaceEntityBase): - private_attribute_entity_type_name: Literal["TempSurface"] = pd.Field( - "TempSurface", frozen=True - ) - - -class TempFluidDynamics(Flow360BaseModel): - entities: EntityList[GenericVolume, Box, Cylinder] = pd.Field(alias="volumes", default=None) - - -class TempWallBC(Flow360BaseModel): - entities: EntityList[Surface, TempSurface] = pd.Field(alias="surfaces", default=[]) - - class TempRotation(Flow360BaseModel): entities: EntityList[GenericVolume, Cylinder] = pd.Field(alias="volumes") - parent_volume: Optional[Union[GenericVolume]] = pd.Field(None) + parent_volume: Optional[GenericVolume] = pd.Field(None) class TempUserDefinedDynamic(Flow360BaseModel): @@ -208,7 +168,7 @@ class TempSimulationParam(_ParamModelBase): far_field_type: Literal["auto", "user-defined"] = pd.Field() - models: List[Union[TempFluidDynamics, TempWallBC, TempRotation]] = pd.Field() + models: List[TempRotation] = pd.Field() udd: Optional[TempUserDefinedDynamic] = pd.Field(None) private_attribute_asset_cache: AssetCache = pd.Field(AssetCache(), frozen=True) @@ -248,234 +208,30 @@ def my_cylinder1(): ) -@pytest.fixture -def my_cylinder2(): - return Cylinder( - name="zone/Cylinder2", - height=12 * u.nm, - axis=(1, 0, 0), - inner_radius=1 * u.nm, - outer_radius=2 * u.nm, - center=(1, 2, 3) * u.nm, - ) - - -@pytest.fixture -def my_box_zone1(): - return Box.from_principal_axes( - name="zone/Box1", - axes=((-1, 0, 0), (0, 1, 0)), - center=(1, 2, 3) * u.mm, - size=(0.1, 0.01, 0.001) * u.mm, - ) - - -@pytest.fixture -def my_box_zone2(): - return Box.from_principal_axes( - name="zone/Box2", - axes=((0, 0, 1), (1, 1, 0)), - center=(3, 2, 3) * u.um, - size=(0.1, 0.01, 0.001) * u.um, - ) - - -@pytest.fixture -def my_surface1(): - return TempSurface(name="MySurface1") - - @pytest.fixture def my_volume_mesh1(): return TempVolumeMesh(file_name="volMesh-1.cgns") -@pytest.fixture -def my_volume_mesh2(): - return TempVolumeMesh(file_name="volMesh-2.cgns") - - @pytest.fixture def my_volume_mesh_with_interface(): return TempVolumeMesh(file_name="volMesh-with_interface.cgns") -##:: ---------------- Entity tests ---------------- - - -def unset_entity_type(): - def IncompleteEntity(EntityBase): - pass - - with pytest.raises( - NotImplementedError, - match=re.escape( - "private_attribute_registry_bucket_name is not defined in the entity class." - ), - ): - IncompleteEntity(name="IncompleteEntity") - - -def test_copying_entity(my_cylinder1): - with pytest.raises( - ValueError, - match=re.escape( - "Copying an entity requires a new name to be specified. Please provide a new name in the update dictionary." - ), - ): - my_cylinder1.copy(update={"height": 1.0234}) - - my_cylinder3_2 = my_cylinder1.copy(update={"height": 8119 * u.m, "name": "zone/Cylinder3-2"}) - assert my_cylinder3_2.height == 8119 * u.m - - -##:: ---------------- EntityList/Registry tests ---------------- - - -def test_by_reference_registry(my_cylinder2): - """Test that the entity registry contains reference not deepcopy of the entities.""" - my_fd = TempFluidDynamics(entities=[my_cylinder2]) - - registry = EntityRegistry() - registry.register(my_cylinder2) - entities = list(registry.view(Cylinder)) # get the entities now before change - # [Registry] External changes --> Internal - my_cylinder2.height = 131 * u.m - for entity in entities: - if isinstance(entity, Cylinder) and entity.name == "zone/Cylinder2": - assert entity.height == 131 * u.m - - # [Registry] Internal changes --> External - my_cylinder2_ref = registry.find_by_naming_pattern( - pattern="zone/Cylinder2", enforce_output_as_list=False - ) - my_cylinder2_ref.height = 132 * u.m - assert my_cylinder2.height == 132 * u.m - - assert my_fd.entities.stored_entities[0].height == 132 * u.m - - -def test_entity_registry_item_retrieval( - my_cylinder1, - my_cylinder2, - my_box_zone1, - my_box_zone2, -): - registry = EntityRegistry() - registry.register(my_cylinder1) - registry.register(my_cylinder2) - registry.register(my_box_zone1) - registry.register(my_box_zone2) - # Test find_by_type includes all _VolumeEntityBase entities (Box and Cylinder) - all_box_entities = list(registry.view(Box)) - assert len(all_box_entities) == 2 - assert my_box_zone1 in all_box_entities - assert my_box_zone2 in all_box_entities - assert my_cylinder1 not in all_box_entities - assert my_cylinder2 not in all_box_entities - - registry = EntityRegistry() - registry.register(Surface(name="AA_ground_close")) - registry.register(Surface(name="BB")) - registry.register(Surface(name="CC_ground")) - items = registry.find_by_naming_pattern("*ground", enforce_output_as_list=True) - assert len(items) == 1 - assert items[0].name == "CC_ground" - - def test_asset_getitem(my_volume_mesh1): """Test the __getitem__ interface of asset objects.""" # 1. Using reference of single asset entity expanded_entities = my_volume_mesh1["zone*"] assert len(expanded_entities) == 3 - # 2. test typo/non-existing entities. - with pytest.raises( - ValueError, - match=re.escape("Failed to find any matching entity with asdf. Please check your input."), - ): + try: my_volume_mesh1["asdf"] - - -def test_multiple_param_creation_and_asset_registry( - my_cylinder1, my_box_zone2, my_box_zone1, my_volume_mesh1, my_volume_mesh2 -): # Make sure that no entities from the first param are present in the second param - with SI_unit_system: - my_param1 = TempSimulationParam( - far_field_type="auto", - models=[ - TempFluidDynamics( - entities=[ - my_cylinder1, - my_cylinder1, - my_cylinder1, - my_volume_mesh1["*"], - ] - ), - TempWallBC(surfaces=[my_volume_mesh1["*"]]), - ], - ) - - ref_registry = EntityRegistry() - ref_registry.register(my_cylinder1) - ref_registry.register(my_volume_mesh1["zone_1"]) - ref_registry.register(my_volume_mesh1["zone_2"]) - ref_registry.register(my_volume_mesh1["zone_3"]) - ref_registry.register(my_volume_mesh1["surface_1"]) - ref_registry.register(my_volume_mesh1["surface_2"]) - ref_registry.register(my_volume_mesh1["surface_3"]) - - assert my_param1.get_used_entity_registry() == ref_registry - - TempFluidDynamics(entities=[my_box_zone2]) # This should not be added to the registry - - with SI_unit_system: - my_param2 = TempSimulationParam( - far_field_type="auto", - models=[ - TempFluidDynamics( - entities=[ - my_box_zone1, - my_box_zone1, - my_volume_mesh2["*"], - ] - ), - TempWallBC(surfaces=[my_volume_mesh2["*"]]), - ], - ) - - ref_registry = EntityRegistry() - ref_registry.register(my_box_zone1) - ref_registry.register(my_volume_mesh2["zone_4"]) - ref_registry.register(my_volume_mesh2["zone_5"]) - ref_registry.register(my_volume_mesh2["zone_6"]) - ref_registry.register(my_volume_mesh2["zone_1"]) - ref_registry.register(my_volume_mesh2["surface_4"]) - ref_registry.register(my_volume_mesh2["surface_5"]) - ref_registry.register(my_volume_mesh2["surface_6"]) - ref_registry.register(my_volume_mesh2["surface_1"]) - assert my_param2.get_used_entity_registry() == ref_registry - - -def test_registry_replacing_existing_entity(my_volume_mesh_with_interface): - user_override_cylinder = Cylinder( - name="innerZone", - height=12 * u.m, - axis=(1, 0, 0), - inner_radius=1 * u.m, - outer_radius=2 * u.m, - center=(1, 2, 3) * u.m, - ) - backup = deepcopy( - my_volume_mesh_with_interface.internal_registry.find_by_naming_pattern( - pattern="innerZone", enforce_output_as_list=False + except ValueError as error: + assert ( + str(error) == "Failed to find any matching entity with asdf. Please check your input." ) - ) - assert my_volume_mesh_with_interface.internal_registry.contains(backup) - - my_volume_mesh_with_interface.internal_registry.replace_existing_with(user_override_cylinder) - - assert my_volume_mesh_with_interface.internal_registry.contains(user_override_cylinder) + else: + raise AssertionError("Expected __getitem__ lookup failure for missing entity pattern.") def test_corner_cases_for_entity_registry_thoroughness(my_cylinder1, my_volume_mesh_with_interface): @@ -515,230 +271,3 @@ def test_corner_cases_for_entity_registry_thoroughness(my_cylinder1, my_volume_m # entities assert my_reg.contains(my_volume_mesh_with_interface["innerZone"]) assert my_reg.entity_count() == 11 - - -def compare_boxes(box1, box2): - return ( - np.isclose( - np.linalg.norm(np.cross(box1.axis_of_rotation, box2.axis_of_rotation)), 0, atol=1e-5 - ) - and np.isclose( - np.mod(box1.angle_of_rotation.value, 2 * np.pi), - np.mod(box2.angle_of_rotation.value, 2 * np.pi), - atol=1e-4, - ) - and np.all(np.isclose(box1.center.value, box2.center.value)) - and np.all(np.isclose(box1.size.value, box2.size.value)) - and np.all( - np.isclose( - np.asarray(box1.private_attribute_input_cache.axes, dtype=float), - np.asarray(box2.private_attribute_input_cache.axes, dtype=float), - atol=1e-3, - ), - ) - ) - - -def test_box_multi_constructor(): - box1 = Box( - name="box1", - center=(0, 0, 0) * u.m, - size=(1, 1, 1) * u.m, - axis_of_rotation=(1, 1, 0), - angle_of_rotation=np.pi * u.rad, - ) - box2 = Box.from_principal_axes( - name="box2", center=(0, 0, 0) * u.m, size=(1, 1, 1) * u.m, axes=((0, 1, 0), (1, 0, 0)) - ) - assert compare_boxes(box1, box2) - - box3 = Box( - name="box3", - center=(0, 0, 0) * u.m, - size=(1, 1, 1) * u.m, - axis_of_rotation=(0.1, 0.5, 0.2), - angle_of_rotation=np.pi / 6 * u.rad, - ) - box4 = Box.from_principal_axes( - name="box4", - center=(0, 0, 0) * u.m, - size=(1, 1, 1) * u.m, - axes=( - (0.8704912236582907, 0.20490328520431558, -0.4475038248399343), - (-0.16024508646579513, 0.9776709006307398, 0.13594529165604813), - ), - ) - assert compare_boxes(box3, box4) - - box5 = Box.from_principal_axes( - name="box5", center=(0, 0, 0) * u.m, size=(1, 1, 1) * u.m, axes=((1, 0, 0), (0, 1, 0)) - ) - assert np.isclose(box5.angle_of_rotation.value, 0) - - box6 = Box.from_principal_axes( - name="box6", - center=(0, 0, 0) * u.m, - size=(1, 1, 1) * u.m, - axes=( - (0.565, -0.019, 0.825), - (0.258, -0.945, -0.198), - ), - ) - - box7 = Box.from_principal_axes( - name="box7", - center=(0, 0, 0) * u.m, - size=(1, 1, 1) * u.m, - axes=( - (0.564999958187871847307527, -0.018999846726114406421873, 0.825000032164884796159215), - (0.257788, -0.944993, -0.198309), - ), - ) - assert compare_boxes(box6, box7) - - -def test_entity_registry_find_by_id(): - registry = EntityRegistry() - - genericVolume_entity = GenericVolume(name="123", private_attribute_id="original_zone_name") - surface_entity1 = Surface(name="123", private_attribute_id="original_surface_name") - surface_entity2 = Surface(name="1234", private_attribute_id="original_surface_name2") - edge_entity = Edge(name="123", private_attribute_id="original_edge_name") - with SI_unit_system: - box_entity = Box( - name="123bOx", - center=(0, 0, 0) * u.m, - size=(1, 1, 1) * u.m, - axis_of_rotation=(1, 1, 0), - angle_of_rotation=np.pi * u.rad, - private_attribute_id="original_box_name", - ) - - registry.register(genericVolume_entity) - registry.register(surface_entity1) - registry.register(surface_entity2) - registry.register(edge_entity) - registry.register(box_entity) - - modified_genericVolume_entity = GenericVolume( - name="999", private_attribute_id="original_zone_name" - ) - modified_surface_entity1 = Surface(name="999", private_attribute_id="original_surface_name") - modified_surface_entity2 = Surface(name="9992", private_attribute_id="original_surface_name2") - modified_edge_entity = Edge(name="999", private_attribute_id="original_edge_name") - with SI_unit_system: - modified_box_entity = Box( - name="999", - center=(0, 0, 0) * u.m, - size=(1, 1, 1) * u.m, - axis_of_rotation=(1, 1, 0), - angle_of_rotation=np.pi * u.rad, - private_attribute_id="original_box_name", - ) - - for modified_item, original_item in zip( - [ - modified_genericVolume_entity, - modified_surface_entity1, - modified_surface_entity2, - modified_edge_entity, - modified_box_entity, - ], - [genericVolume_entity, surface_entity1, surface_entity2, edge_entity, box_entity], - ): - assert ( - registry.find_by_asset_id( - entity_id=modified_item.id, entity_class=modified_item.__class__ - ) - == original_item - ) - - -def test_same_name_and_type_entities_in_entity_registry(): - with u.SI_unit_system: - point_array_2d_1 = PointArray2D( - name="Parallelogram_streamline", - origin=(1.0, 0.0, 0.0) * u.m, - u_axis_vector=(0, 2.0, 2.0) * u.m, - v_axis_vector=(0, 1.0, 0) * u.m, - u_number_of_points=11, - v_number_of_points=20, - ) - point_array_2d_2 = PointArray2D( - name="Parallelogram_streamline", - origin=(1.0, 0.0, 0.0) * u.m, - u_axis_vector=(0, 2.0, 2.0) * u.m, - v_axis_vector=(0, 1.0, 0) * u.m, - u_number_of_points=3, - v_number_of_points=4, - ) - params = SimulationParams( - outputs=[ - StreamlineOutput(entities=[point_array_2d_1, point_array_2d_2, point_array_2d_2]) - ] - ) - used_entity_registry = params.used_entity_registry - assert len(used_entity_registry.find_by_naming_pattern("*")) == 2 - - -##:: ---------------- Entity specific validations ---------------- - - -def test_box_validation(): - with pytest.raises( - ValueError, match=re.escape("The two axes are not orthogonal, dot product is 1.") - ): - Box.from_principal_axes( - name="box6", center=(0, 0, 0) * u.m, size=(1, 1, 1) * u.m, axes=((1, 0, 0), (1, 0, 0)) - ) - with pytest.raises( - ValueError, - match=re.escape("All vector components must be positive (>0), got -10.0"), - ): - Box( - name="box6", - center=(0, 0, 0) * u.m, - size=(1, 1, -10) * u.m, - axis_of_rotation=(1, 0, 0), - angle_of_rotation=10 * u.deg, - ) - - # flow360_length_unit block removed — _Flow360BaseUnit deleted in Phase 4 - # Tracked in plans/removed_tests.markdown - - -def test_cylinder_validation(): - with pytest.raises( - ValueError, - match=re.escape("Cylinder inner radius (1000.0 m) must be less than outer radius (2.0 m)"), - ): - Cylinder( - name="cyl", - center=(0, 0, 0) * u.m, - height=2 * u.m, - axis=(1, 0, 0), - inner_radius=1000 * u.m, - outer_radius=2 * u.m, - ) - - -def test_sphere_creation(): - """Test basic Sphere creation.""" - with SI_unit_system: - sphere = Sphere(name="test_sphere", center=(1, 2, 3) * u.m, radius=5 * u.m) - assert sphere.name == "test_sphere" - np.testing.assert_allclose(sphere.center.value, [1, 2, 3]) - np.testing.assert_allclose(sphere.radius.value, 5) - - -def test_sphere_validation(): - """Test Sphere validation for negative radius.""" - with pytest.raises(ValueError, match="|Value must be positive"): - Sphere( - name="sphere", - center=(0, 0, 0) * u.m, - radius=-5 * u.m, - ) - - # flow360_length_unit block removed — _Flow360BaseUnit deleted in Phase 4 - # Tracked in plans/removed_tests.markdown diff --git a/tests/simulation/framework/test_entity_dict_database.py b/tests/simulation/framework/test_entity_dict_database.py index 9de2997df..745bf39dd 100644 --- a/tests/simulation/framework/test_entity_dict_database.py +++ b/tests/simulation/framework/test_entity_dict_database.py @@ -1,12 +1,6 @@ -""" -Tests for entity selector and get_entity_info_and_registry_from_dict function. -""" +"""Client-side parity tests for get_registry_from_params().""" import copy -import json -import os - -import pytest from flow360.component.simulation.framework.entity_expansion_utils import ( get_entity_info_and_registry_from_dict, @@ -19,15 +13,6 @@ GeometryBodyGroup, Surface, ) -from flow360.component.simulation.simulation_params import SimulationParams - - -def _load_simulation_json(relative_path: str) -> dict: - """Helper function to load simulation JSON files.""" - test_dir = os.path.dirname(os.path.abspath(__file__)) - json_path = os.path.join(test_dir, "..", relative_path) - with open(json_path, "r", encoding="utf-8") as f: - return json.load(f) class _AssetCache: @@ -147,181 +132,6 @@ def test_get_registry_from_params_matches_dict_with_mirror_status(): assert _entity_names(dict_planes) == _entity_names(instance_planes) -def test_get_registry_for_geometry_entity_info(): - """ - Test get_entity_info_and_registry_from_dict with GeometryEntityInfo. - Uses geometry_grouped_by_file/simulation.json as test data. - """ - params_as_dict = _load_simulation_json("data/geometry_grouped_by_file/simulation.json") - params_as_dict, _ = SimulationParams._update_param_dict(params_as_dict) - entity_info = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] - _, registry = get_entity_info_and_registry_from_dict(params_as_dict) - - # Get expected counts from entity_info based on grouping tags - face_group_tag = entity_info.get("face_group_tag") - if face_group_tag: - face_idx = entity_info["face_attribute_names"].index(face_group_tag) - expected_surfaces_count = len(entity_info["grouped_faces"][face_idx]) - else: - expected_surfaces_count = 0 - - edge_group_tag = entity_info.get("edge_group_tag") - if edge_group_tag and entity_info.get("edge_ids"): - edge_idx = entity_info["edge_attribute_names"].index(edge_group_tag) - expected_edges_count = len(entity_info["grouped_edges"][edge_idx]) - else: - expected_edges_count = 0 - - body_group_tag = entity_info.get("body_group_tag") - if body_group_tag and entity_info.get("body_attribute_names"): - body_idx = entity_info["body_attribute_names"].index(body_group_tag) - expected_bodies_count = len(entity_info["grouped_bodies"][body_idx]) - else: - expected_bodies_count = 0 - - assert isinstance(registry, EntityRegistry) - surfaces = registry.find_by_type(Surface) - edges = registry.find_by_type(Edge) - body_groups = registry.find_by_type(GeometryBodyGroup) - generic_volumes = registry.find_by_type(GenericVolume) - - assert len(surfaces) == expected_surfaces_count - assert len(edges) == expected_edges_count - assert len(body_groups) == expected_bodies_count - assert len(generic_volumes) == 0 - - # Verify entity type names if entities exist - if surfaces: - assert surfaces[0].private_attribute_entity_type_name == "Surface" - if edges: - assert edges[0].private_attribute_entity_type_name == "Edge" - if body_groups: - assert body_groups[0].private_attribute_entity_type_name == "GeometryBodyGroup" - - -def test_get_registry_for_volume_mesh_entity_info(): - """ - Test get_entity_info_and_registry_from_dict with VolumeMeshEntityInfo. - Uses vm_entity_provider/simulation.json as test data. - """ - params_as_dict = _load_simulation_json("data/vm_entity_provider/simulation.json") - entity_info = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] - _, registry = get_entity_info_and_registry_from_dict(params_as_dict) - - # Get expected counts from entity_info - expected_boundaries_count = len(entity_info.get("boundaries", [])) - expected_zones_count = len(entity_info.get("zones", [])) - - assert isinstance(registry, EntityRegistry) - surfaces = registry.find_by_type(Surface) - generic_volumes = registry.find_by_type(GenericVolume) - edges = registry.find_by_type(Edge) - body_groups = registry.find_by_type(GeometryBodyGroup) - - assert len(surfaces) == expected_boundaries_count - assert len(generic_volumes) == expected_zones_count - assert len(edges) == 0 - assert len(body_groups) == 0 - - # Verify entity type names if entities exist - if surfaces: - assert surfaces[0].private_attribute_entity_type_name == "Surface" - if generic_volumes: - assert generic_volumes[0].private_attribute_entity_type_name == "GenericVolume" - - -def test_get_registry_for_surface_mesh_entity_info(): - """ - Test get_entity_info_and_registry_from_dict with SurfaceMeshEntityInfo. - Uses params/data/surface_mesh/simulation.json as test data. - """ - params_as_dict = _load_simulation_json("params/data/surface_mesh/simulation.json") - params_as_dict, _ = SimulationParams._update_param_dict(params_as_dict) - entity_info = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] - _, registry = get_entity_info_and_registry_from_dict(params_as_dict) - - # Get expected count from entity_info - expected_boundaries_count = len(entity_info.get("boundaries", [])) - - assert isinstance(registry, EntityRegistry) - surfaces = registry.find_by_type(Surface) - edges = registry.find_by_type(Edge) - body_groups = registry.find_by_type(GeometryBodyGroup) - generic_volumes = registry.find_by_type(GenericVolume) - - assert len(surfaces) == expected_boundaries_count - assert len(edges) == 0 - assert len(body_groups) == 0 - assert len(generic_volumes) == 0 - - # Verify entity type name if entities exist - if surfaces: - assert surfaces[0].private_attribute_entity_type_name == "Surface" - - -def test_get_registry_missing_asset_cache(): - """ - Test that the function raises ValueError when private_attribute_asset_cache is missing. - """ - params_as_dict = {} - - with pytest.raises(ValueError, match="private_attribute_asset_cache not found"): - get_entity_info_and_registry_from_dict(params_as_dict) - - -def test_get_registry_missing_entity_info(): - """ - Test that the function raises ValueError when project_entity_info is missing. - """ - params_as_dict = {"private_attribute_asset_cache": {}} - - with pytest.raises(ValueError, match="project_entity_info not found"): - get_entity_info_and_registry_from_dict(params_as_dict) - - -def test_geometry_entity_info_respects_grouping_tags(): - """ - Test that GeometryEntityInfo uses the correct grouping tags to extract entities. - Verifies the function extracts entities based on the set grouping tag. - """ - params_as_dict = _load_simulation_json("data/geometry_grouped_by_file/simulation.json") - params_as_dict, _ = SimulationParams._update_param_dict(params_as_dict) - entity_info = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] - _, registry = get_entity_info_and_registry_from_dict(params_as_dict) - - # Verify face grouping - face_group_tag = entity_info.get("face_group_tag") - assert face_group_tag is not None, "Test data should have face_group_tag set" - - face_attribute_names = entity_info.get("face_attribute_names", []) - grouped_faces = entity_info.get("grouped_faces", []) - index = face_attribute_names.index(face_group_tag) - expected_faces = grouped_faces[index] - - surfaces = registry.find_by_type(Surface) - assert len(surfaces) == len(expected_faces) - - # Verify edge grouping - if entity_info.get("edge_group_tag"): - edge_group_tag = entity_info["edge_group_tag"] - edge_attribute_names = entity_info.get("edge_attribute_names", []) - grouped_edges = entity_info.get("grouped_edges", []) - index = edge_attribute_names.index(edge_group_tag) - expected_edges = grouped_edges[index] - edges = registry.find_by_type(Edge) - assert len(edges) == len(expected_edges) - - # Verify body grouping - if entity_info.get("body_group_tag"): - body_group_tag = entity_info["body_group_tag"] - body_attribute_names = entity_info.get("body_attribute_names", []) - grouped_bodies = entity_info.get("grouped_bodies", []) - index = body_attribute_names.index(body_group_tag) - expected_bodies = grouped_bodies[index] - body_groups = registry.find_by_type(GeometryBodyGroup) - assert len(body_groups) == len(expected_bodies) - - def test_get_registry_from_params_matches_dict(): params_as_dict = _build_simple_params_dict() dummy_params = _DummyParams(params_as_dict) @@ -346,94 +156,3 @@ def test_get_registry_from_params_matches_dict(): dict_volumes = dict_registry.find_by_type(GenericVolume) instance_volumes = instance_registry.find_by_type(GenericVolume) assert _entity_names(dict_volumes) == _entity_names(instance_volumes) - - -def test_entity_registry_respects_grouping_selection(): - """ - Test that EntityRegistry.from_entity_info() only registers entities from the selected grouping. - - When GeometryEntityInfo has multiple groupings (e.g., by face, by body, all-in-one), - the registry should only include entities from the grouping specified by face_group_tag, - edge_group_tag, and body_group_tag. - """ - params_as_dict = _load_simulation_json("data/geometry_grouped_by_file/simulation.json") - params_as_dict, _ = SimulationParams._update_param_dict(params_as_dict) - entity_info = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] - - # Get the actual entity_info object by deserializing - _, registry = get_entity_info_and_registry_from_dict(params_as_dict) - - # Verify we're testing GeometryEntityInfo with multiple groupings - assert entity_info["type_name"] == "GeometryEntityInfo" - assert ( - len(entity_info.get("face_attribute_names", [])) > 1 - ), "Test requires multiple face groupings" - - # Get the selected grouping index and expected entities - face_group_tag = entity_info.get("face_group_tag") - assert face_group_tag is not None, "Test requires face_group_tag to be set" - - face_attribute_names = entity_info["face_attribute_names"] - grouped_faces = entity_info["grouped_faces"] - - selected_face_index = face_attribute_names.index(face_group_tag) - expected_surface_names = [face["name"] for face in grouped_faces[selected_face_index]] - - # Get surfaces from registry - registered_surfaces = registry.find_by_type(Surface) - registered_surface_names = [surface.name for surface in registered_surfaces] - - # Verify ONLY surfaces from the selected grouping are registered - assert set(registered_surface_names) == set(expected_surface_names), ( - f"Registry should only contain surfaces from grouping '{face_group_tag}' " - f"(index {selected_face_index}), but got different entities" - ) - - # Verify surfaces from OTHER groupings are NOT registered - for i, grouping_name in enumerate(face_attribute_names): - if i != selected_face_index: - other_grouping_names = [face["name"] for face in grouped_faces[i]] - # Check that none of these names appear in registered surfaces - overlap = set(other_grouping_names) & set(registered_surface_names) - # Allow overlap only if the same surface name appears in multiple groupings - # (which can happen in geometry files) - for name in overlap: - # Verify this is the same surface from the selected grouping - assert name in expected_surface_names, ( - f"Surface '{name}' from non-selected grouping '{grouping_name}' " - f"should not be registered when grouping tag is '{face_group_tag}'" - ) - - # Test edge grouping if available - edge_group_tag = entity_info.get("edge_group_tag") - if edge_group_tag and entity_info.get("edge_attribute_names"): - edge_attribute_names = entity_info["edge_attribute_names"] - grouped_edges = entity_info["grouped_edges"] - - if edge_group_tag in edge_attribute_names: - selected_edge_index = edge_attribute_names.index(edge_group_tag) - expected_edge_names = [edge["name"] for edge in grouped_edges[selected_edge_index]] - - registered_edges = registry.find_by_type(Edge) - registered_edge_names = [edge.name for edge in registered_edges] - - assert set(registered_edge_names) == set( - expected_edge_names - ), f"Registry should only contain edges from grouping '{edge_group_tag}'" - - # Test body grouping if available - body_group_tag = entity_info.get("body_group_tag") - if body_group_tag and entity_info.get("body_attribute_names"): - body_attribute_names = entity_info["body_attribute_names"] - grouped_bodies = entity_info["grouped_bodies"] - - if body_group_tag in body_attribute_names: - selected_body_index = body_attribute_names.index(body_group_tag) - expected_body_names = [body["name"] for body in grouped_bodies[selected_body_index]] - - registered_bodies = registry.find_by_type(GeometryBodyGroup) - registered_body_names = [body.name for body in registered_bodies] - - assert set(registered_body_names) == set( - expected_body_names - ), f"Registry should only contain bodies from grouping '{body_group_tag}'" diff --git a/tests/simulation/framework/test_entity_expansion_impl.py b/tests/simulation/framework/test_entity_expansion_impl.py deleted file mode 100644 index e116bf846..000000000 --- a/tests/simulation/framework/test_entity_expansion_impl.py +++ /dev/null @@ -1,262 +0,0 @@ -import copy -import json -import os - -import pytest - -from flow360.component.simulation.framework.entity_expansion_utils import ( - expand_all_entity_lists_in_place, -) -from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.entity_selector import ( - EntitySelector, - SurfaceSelector, - compile_glob_cached, - expand_entity_list_selectors_in_place, -) -from flow360.component.simulation.framework.updater_utils import compare_values -from flow360.component.simulation.primitives import Edge, GenericVolume, Surface -from flow360.component.simulation.simulation_params import SimulationParams - - -class _EntityListStub: - """Minimal stub for selector expansion tests (avoids EntityList metaclass constraints).""" - - def __init__(self, *, stored_entities=None, selectors=None): - self.stored_entities = stored_entities or [] - self.selectors = selectors - - -def _mk_pool(names, entity_type): - # Build list of entity dicts with given names and type - return [{"name": n, "private_attribute_entity_type_name": entity_type} for n in names] - - -def _make_registry(surfaces=None, edges=None, generic_volumes=None, geometry_body_groups=None): - """Create an EntityRegistry from entity dictionaries.""" - registry = EntityRegistry() - for entity_dict in surfaces or []: - registry.register(Surface(name=entity_dict["name"])) - for entity_dict in edges or []: - registry.register(Edge(name=entity_dict["name"])) - for entity_dict in generic_volumes or []: - registry.register(GenericVolume(name=entity_dict["name"])) - # GeometryBodyGroup is handled separately if needed - return registry - - -def test_operator_and_syntax_coverage(): - # Prepare database with diverse names (not just quantity) - pool_names = [ - "wing", - "wingtip", - "wing-root", - "wind", - "tail", - "tailplane", - "fuselage", - "body", - "leading-wing", - "my_wing", - "hinge", - ] - registry = _make_registry(surfaces=_mk_pool(pool_names, "Surface")) - - # Build selectors that cover operators and patterns - entity_list = _EntityListStub( - stored_entities=[], - selectors=[ - # any_of(["tail"]) -> ["tail"] - SurfaceSelector(name="sel_any_tail").any_of(["tail"]), - # not_any_of(["wing"]) -> ["wingtip","wing-root","wind","tail","tailplane","fuselage","body","leading-wing","my_wing","hinge"] - SurfaceSelector(name="sel_not_any_wing").not_any_of(["wing"]), - # any_of(["wing","fuselage"]) -> ["wing","fuselage"] - SurfaceSelector(name="sel_any_wing_fuselage").any_of(["wing", "fuselage"]), - # not_any_of(["tail","hinge"]) -> ["wing","wingtip","wing-root","wind","tailplane","fuselage","body","leading-wing","my_wing"] - SurfaceSelector(name="sel_not_any_tail_hinge").not_any_of(["tail", "hinge"]), - # matches("wing*") -> ["wing","wingtip","wing-root"] - SurfaceSelector(name="sel_match_wing_glob").match("wing*"), - # not_matches("^wing$", regex) -> ["wingtip","wing-root","wind","tail","tailplane","fuselage","body","leading-wing","my_wing","hinge"] - # Use model_validate for regex since it's not exposed in fluent API - EntitySelector.model_validate( - { - "name": "sel_not_match_exact_wing_regex", - "target_class": "Surface", - "logic": "AND", - "children": [ - { - "attribute": "name", - "operator": "not_matches", - "value": "^wing$", - "non_glob_syntax": "regex", - } - ], - } - ), - ], - ) - - expand_entity_list_selectors_in_place(registry, entity_list, merge_mode="merge") - stored = entity_list.stored_entities - - # Build expected union by concatenating each selector's expected results (order matters) - expected = [] - expected += ["tail"] - expected += [ - "wingtip", - "wing-root", - "wind", - "tail", - "tailplane", - "fuselage", - "body", - "leading-wing", - "my_wing", - "hinge", - ] - expected += ["wing", "fuselage"] - expected += [ - "wing", - "wingtip", - "wing-root", - "wind", - "tailplane", - "fuselage", - "body", - "leading-wing", - "my_wing", - ] - expected += ["wing", "wingtip", "wing-root"] - expected += [ - "wingtip", - "wing-root", - "wind", - "tail", - "tailplane", - "fuselage", - "body", - "leading-wing", - "my_wing", - "hinge", - ] - # Note: final_names has been deduplicated by merging. - final_names = [e.name for e in stored if e.private_attribute_entity_type_name == "Surface"] - assert sorted(final_names) == sorted(list(set(expected))) - - -def test_combined_predicates_and_or(): - registry = _make_registry( - surfaces=_mk_pool(["s1", "s2", "wing", "wing-root", "tail"], "Surface") - ) - - entity_list = _EntityListStub( - stored_entities=[], - selectors=[ - SurfaceSelector(name="sel_and_wing_not_wing", logic="AND") - .match("wing*") - .not_any_of(["wing"]), - SurfaceSelector(name="sel_or_s1_tail", logic="OR").any_of(["s1"]).any_of(["tail"]), - SurfaceSelector(name="sel_any_wing_or_root").any_of(["wing", "wing-root"]), - ], - ) - - expand_entity_list_selectors_in_place(registry, entity_list, merge_mode="merge") - stored = entity_list.stored_entities - - # Union across three selectors (concatenated in selector order, no dedup): - # 1) AND wing* & notIn ["wing"] -> ["wing-root"] - # 2) OR in ["s1"] or in ["tail"] -> ["s1", "tail"] - # 3) default AND with in {wing, wing-root} -> ["wing", "wing-root"] - # Final list -> ["wing-root", "s1", "tail", "wing", "wing-root"] - # Note: final_names has been deduplicated by merging. - final_names = [e.name for e in stored if e.private_attribute_entity_type_name == "Surface"] - assert sorted(final_names) == sorted(list(set(["wing-root", "s1", "tail", "wing"]))) - - -def test_compile_glob_cached_extended_syntax_support(): - # Comments in English for maintainers - # Ensure extended glob features supported by wcmatch translation are honored. - candidates = [ - "a", - "b", - "ab", - "abc", - "file", - "file1", - "file2", - "file10", - "file.txt", - "File.TXT", - "data_01", - "data-xyz", - "[star]", - "literal*star", - "foo.bar", - ".hidden", - "1", - "2", - "3", - ] - - def match(pattern: str) -> list[str]: - regex = compile_glob_cached(pattern) - return [n for n in candidates if regex.fullmatch(n) is not None] - - # Basic glob - assert match("file*") == ["file", "file1", "file2", "file10", "file.txt"] - assert match("file[0-9]") == ["file1", "file2"] - - # Brace expansion - assert match("{a,b}") == ["a", "b"] - assert match("file{1,2}") == ["file1", "file2"] - assert match("{1..3}") == ["1", "2", "3"] - assert match("file{01..10}") == ["file10"] - - # Extglob - # In extglob, @(file|data) means exactly 'file' or 'data'. To match 'data_*', use data*. - assert match("@(file|data*)") == ["file", "data_01", "data-xyz"] - expected_not_file = [n for n in candidates if n != "file"] - assert match("!(file)") == expected_not_file - assert match("?(file)") == ["file"] - assert match("+(file)") == ["file"] - assert match("*(file)") == ["file"] - - # POSIX character classes - assert match("[[:digit:]]*") == ["1", "2", "3"] - assert match("file[[:digit:]]") == ["file1", "file2"] - assert match("[[:upper:]]*.[[:alpha:]]*") == ["File.TXT"] - - # Escaping and literals - assert match("literal[*]star") == ["literal*star"] - assert match(r"literal\*star") == ["literal*star"] - assert match(r"foo\.bar") == ["foo.bar"] - assert match("foo[.]bar") == ["foo.bar"] - - -def test_compile_glob_cached_combined_patterns_do_not_partial_match(): - regex = compile_glob_cached("{a,b}") - - assert regex.match("a") is not None - assert regex.match("b") is not None - assert regex.match("ab") is None - assert regex.match("xb") is None - - -def test_entity_registry_view_glob_uses_full_string_matching(): - registry = _make_registry(surfaces=_mk_pool(["a", "ab", "b", "xb"], "Surface")) - - matched = registry.view(Surface)["{a,b}"] - - assert [entity.name for entity in matched] == ["a", "b"] - - -def test_compile_glob_cached_rejects_empty_regex_parts(monkeypatch): - from wcmatch import fnmatch as wfnmatch - - compile_glob_cached.cache_clear() - monkeypatch.setattr(wfnmatch, "translate", lambda pattern, flags: ([], flags)) - - with pytest.raises(ValueError, match="returned no regex parts"): - compile_glob_cached("wing*") - - compile_glob_cached.cache_clear() diff --git a/tests/simulation/framework/test_entity_list.py b/tests/simulation/framework/test_entity_list.py index 00ca041cf..a92ab39a3 100644 --- a/tests/simulation/framework/test_entity_list.py +++ b/tests/simulation/framework/test_entity_list.py @@ -1,30 +1,8 @@ -import re -from typing import Literal - -import pydantic as pd -import pytest - import flow360 as fl from flow360.component.simulation.draft_context import DraftContext from flow360.component.simulation.entity_info import SurfaceMeshEntityInfo -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityBase, EntityList from flow360.component.simulation.framework.entity_selector import SurfaceSelector -from flow360.component.simulation.primitives import GenericVolume, Surface - - -class _SurfaceEntityBase(EntityBase): - """Base class for surface-like entities (CAD or mesh).""" - - private_attribute_entity_type_name: Literal["_SurfaceEntityBase"] = pd.Field( - "_SurfaceEntityBase", frozen=True - ) - - -class TempSurface(_SurfaceEntityBase): - private_attribute_entity_type_name: Literal["TempSurface"] = pd.Field( - "TempSurface", frozen=True - ) +from flow360.component.simulation.primitives import Surface def _build_preview_context(boundary_names: list[str]): @@ -37,152 +15,6 @@ def _build_preview_context(boundary_names: list[str]): return boundaries, draft -def test_entity_list_deserializer_handles_mixed_types_and_selectors(): - """ - Test: EntityList deserializer correctly processes a mixed list of entities and selectors. - - Verifies that EntityList can accept a list containing both entity instances and selectors. - - Verifies that entity objects are placed in `stored_entities`. - - Verifies that EntitySelector objects are placed in `selectors`. - - Verifies that the types are validated against the EntityList's generic parameters. - """ - with fl.SI_unit_system: - selector = SurfaceSelector(name="all_surfaces").match("*") - surface_entity = Surface(name="my_surface") - temp_surface_entity = TempSurface(name="my_temp_surface") - # This entity should be filtered out as it's not a valid type for this list - volume_entity = GenericVolume(name="my_volume") - - # Use model_validate to correctly trigger the "before" mode validator - entity_list = EntityList[Surface, TempSurface].model_validate( - [selector, surface_entity, temp_surface_entity, volume_entity] - ) - - assert len(entity_list.stored_entities) == 2 - assert entity_list.stored_entities[0] == surface_entity - assert entity_list.stored_entities[1] == temp_surface_entity - - assert len(entity_list.selectors) == 1 - # Selector is deserialized as EntitySelector (base class), check attributes instead - assert entity_list.selectors[0].name == selector.name - assert entity_list.selectors[0].target_class == selector.target_class - assert entity_list.selectors[0].logic == selector.logic - - -def test_entity_list_discrimination(): - """ - Test: EntityList correctly uses the discriminator field for Pydantic model validation. - """ - - class ConfusingEntity1(EntityBase): - some_value: int = pd.Field(1, gt=1) - private_attribute_entity_type_name: Literal["ConfusingEntity1"] = pd.Field( - "ConfusingEntity1", frozen=True - ) - - class ConfusingEntity2(EntityBase): - some_value: int = pd.Field(1, gt=2) - private_attribute_entity_type_name: Literal["ConfusingEntity2"] = pd.Field( - "ConfusingEntity2", frozen=True - ) - - class MyModel(Flow360BaseModel): - entities: EntityList[ConfusingEntity1, ConfusingEntity2] - - # Ensure EntityList requires the discriminator - with pytest.raises( - ValueError, - match=re.escape( - "Unable to extract tag using discriminator 'private_attribute_entity_type_name'" - ), - ): - MyModel( - entities={ - "stored_entities": [ - { - "name": "discriminator_is_missing", - "some_value": 3, - } - ], - } - ) - - # Ensure EntityList validates against the correct model based on the discriminator - with pytest.raises(pd.ValidationError) as err: - MyModel( - entities={ - "stored_entities": [ - { - "name": "should_be_confusing_entity_1", - "private_attribute_entity_type_name": "ConfusingEntity1", - "some_value": 1, # This violates the gt=1 constraint of ConfusingEntity1 - } - ], - } - ) - - validation_errors = err.value.errors() - # Pydantic should only try to validate against ConfusingEntity1, resulting in one error. - # Without discrimination, it would have failed checks for both models. - assert len(validation_errors) == 1 - assert validation_errors[0]["msg"] == "Input should be greater than 1" - assert validation_errors[0]["loc"] == ( - "entities", - "stored_entities", - 0, - "ConfusingEntity1", - "some_value", - ) - - -def test_entity_list_invalid_inputs(): - """ - Test: EntityList deserializer handles various invalid inputs gracefully. - """ - # 1. Test invalid entity type in list (e.g., int) - with pytest.raises( - ValueError, - match=re.escape( - "Type() of input to `entities` (1) is not valid. Expected entity instance." - ), - ): - EntityList[Surface].model_validate([1]) - - # 2. Test empty list - with pytest.raises( - ValueError, - match=re.escape("Invalid input type to `entities`, list is empty."), - ): - EntityList[Surface].model_validate([]) - - # 3. Test None input - with pytest.raises( - pd.ValidationError, - match=re.escape("None is not a valid input to `entities`."), - ): - EntityList[Surface].model_validate(None) - - # 4. Test list containing only invalid types - with pytest.raises( - ValueError, - match=re.escape("Can not find any valid entity of type ['Surface'] from the input."), - ): - with fl.SI_unit_system: - EntityList[Surface].model_validate([GenericVolume(name="a_volume")]) - - -def test_force_set_attr_marks_entity_dirty_and_updates_hash(): - entity = GenericVolume(name="zone") - original_hash = entity._get_hash() - - entity._force_set_attr("private_attribute_full_name", "fluid/zone") - - assert entity.model_dump(exclude_unset=True)["private_attribute_full_name"] == "fluid/zone" - assert entity._dirty is True - assert entity._get_hash() != original_hash - assert entity._dirty is False - assert "_dirty" not in entity.__dict__ - - def test_preview_selection_returns_names_by_default(): _, draft = _build_preview_context(["tail", "wing_leading", "wing_trailing"]) selector = SurfaceSelector(name="wing_surfaces").match("wing*") diff --git a/tests/simulation/framework/test_entity_materializer.py b/tests/simulation/framework/test_entity_materializer.py deleted file mode 100644 index 11eb9e7c7..000000000 --- a/tests/simulation/framework/test_entity_materializer.py +++ /dev/null @@ -1,472 +0,0 @@ -import copy -from typing import Optional - -import pydantic as pd -import pytest - -from flow360.component.simulation.framework.entity_materializer import ( - materialize_entities_and_selectors_in_place, -) -from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.entity_selector import EntitySelector -from flow360.component.simulation.outputs.output_entities import Point -from flow360.component.simulation.primitives import Surface - - -def _mk_entity(name: str, entity_type: str, eid: Optional[str] = None) -> dict: - d = {"name": name, "private_attribute_entity_type_name": entity_type} - if eid is not None: - d["private_attribute_id"] = eid - return d - - -def _mk_surface_dict(name: str, eid: str): - return { - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": eid, - "name": name, - } - - -def _mk_point_dict(name: str, eid: str, coords=(0.0, 0.0, 0.0)): - return { - "private_attribute_entity_type_name": "Point", - "private_attribute_id": eid, - "name": name, - "location": {"units": "m", "value": list(coords)}, - } - - -def test_materializes_dicts_and_shares_instances_across_lists(): - """ - Test: Entity materializer converts dicts to Pydantic instances and shares them globally. - - Purpose: - - Verify that materialize_entities_and_selectors_in_place() converts entity dicts to model instances - - Verify that entities with same (type, id) are the same Python object (by identity) - - Verify that instance sharing works across different nodes in the params tree - - Verify that materialization is idempotent with respect to instance identity - - Expected behavior: - - Input: Entity dicts with same IDs in different locations (nodes a and b) - - Process: Materialization uses global cache keyed by (type, id) - - Output: Same instances appear in both locations (a_list[0] is b_list[1]) - - This enables memory efficiency and supports identity-based entity comparison. - """ - params = { - "a": { - "stored_entities": [ - _mk_entity("wing", "Surface", eid="s-1"), - _mk_entity("tail", "Surface", eid="s-2"), - ] - }, - "b": { - "stored_entities": [ - # same ids as in node a - _mk_entity("tail", "Surface", eid="s-2"), - _mk_entity("wing", "Surface", eid="s-1"), - ] - }, - } - - out = materialize_entities_and_selectors_in_place(copy.deepcopy(params)) - a_list = out["a"]["stored_entities"] - b_list = out["b"]["stored_entities"] - - # Objects with same (type, id) across different lists should be the same instance - assert a_list[0] is b_list[1] - assert a_list[1] is b_list[0] - - -def test_per_list_dedup_for_non_point(): - """ - Test: Materializer deduplicates non-Point entities within each list. - - Purpose: - - Verify that materialize_entities_and_selectors_in_place() removes duplicate entities - - Verify that deduplication is based on stable key (type, id) tuple - - Verify that order is preserved (first occurrence kept) - - Verify that this applies to all non-Point entity types - - Expected behavior: - - Input: List with duplicate Surface entities (same id "s-1") - - Process: Deduplication removes second occurrence - - Output: Single "wing" and one "tail" entity remain - - Note: Point entities are exempt from deduplication (tested separately). - """ - params = { - "node": { - "stored_entities": [ - _mk_entity("wing", "Surface", eid="s-1"), - _mk_entity("wing", "Surface", eid="s-1"), # duplicate - _mk_entity("tail", "Surface", eid="s-2"), - ] - } - } - - out = materialize_entities_and_selectors_in_place(copy.deepcopy(params)) - items = out["node"]["stored_entities"] - # Dedup preserves order and removes duplicates for non-Point types - assert [e.name for e in items] == ["wing", "tail"] - - -def test_skip_dedup_for_point(): - """ - Test: Point entities are exempt from deduplication during materialization. - - Purpose: - - Verify that Point entity type is explicitly excluded from deduplication - - Verify that duplicate Point entities with identical data are preserved - - Verify that this exception only applies to Point (not PointArray, etc.) - - Expected behavior: - - Input: Two Point entities with same name and location - - Process: Materialization skips deduplication for Point type - - Output: Both Point entities remain in the list - - Rationale: Point entities may intentionally be duplicated for different - use cases (e.g., multiple probes or streamline seeds at same location). - """ - params = { - "node": { - "stored_entities": [ - { - "name": "p1", - "private_attribute_entity_type_name": "Point", - "location": {"units": "m", "value": [0.0, 0.0, 0.0]}, - "private_attribute_id": "p1ahgdszhf", - }, - { - "name": "p1", - "private_attribute_entity_type_name": "Point", - "location": {"units": "m", "value": [0.0, 0.0, 0.0]}, - "private_attribute_id": "p2aaaaaa", - }, # duplicate Point remains - { - "name": "p2", - "private_attribute_entity_type_name": "Point", - "location": {"units": "m", "value": [1.0, 0.0, 0.0]}, - "private_attribute_id": "p3dszahg", - }, - ] - } - } - - out = materialize_entities_and_selectors_in_place(copy.deepcopy(params)) - items = out["node"]["stored_entities"] - assert [e.name for e in items] == ["p1", "p1", "p2"] - - -def test_reentrant_safe_and_idempotent(): - """ - Test: Materializer is reentrant-safe and idempotent. - - Purpose: - - Verify that materialize_entities_and_selectors_in_place() can be called multiple times safely - - Verify that subsequent calls on already-materialized data are no-ops - - Verify that object identity is maintained across re-entrant calls - - Verify that deduplication results are stable - - Expected behavior: - - First call: Converts dicts to objects, deduplicates - - Second call: Recognizes already-materialized objects, preserves identity - - Output: Same results, same object identities (items1[0] is items2[0]) - - This property is important for pipeline robustness and allows the - materializer to be called at multiple stages without side effects. - """ - params = { - "node": { - "stored_entities": [ - _mk_entity("wing", "Surface", eid="s-1"), - _mk_entity("wing", "Surface", eid="s-1"), # duplicate - _mk_entity("tail", "Surface", eid="s-2"), - ] - } - } - - out1 = materialize_entities_and_selectors_in_place(copy.deepcopy(params)) - # Re-entrant call on already materialized objects - out2 = materialize_entities_and_selectors_in_place(out1) - items1 = out1["node"]["stored_entities"] - items2 = out2["node"]["stored_entities"] - - assert [e.name for e in items1] == ["wing", "tail"] - assert [e.name for e in items2] == ["wing", "tail"] - # Identity maintained across re-entrant call - assert items1[0] is items2[0] - assert items1[1] is items2[1] - - -def test_materialize_dedup_and_point_passthrough(): - params = { - "models": [ - { - "entities": { - "stored_entities": [ - _mk_surface_dict("wing", "s1"), - _mk_surface_dict("wing", "s1"), # duplicate by id - _mk_point_dict("p1", "p1", (0.0, 0.0, 0.0)), - ] - } - } - ] - } - - out = materialize_entities_and_selectors_in_place(params) - items = out["models"][0]["entities"]["stored_entities"] - - # 1) Surfaces are deduped per list - assert sum(isinstance(x, Surface) for x in items) == 1 - # 2) Points are not deduped by policy (pass-through in not_merged_types) - assert sum(isinstance(x, Point) for x in items) == 1 - - # 3) Idempotency: re-run should keep the same shape and types - out2 = materialize_entities_and_selectors_in_place(out) - items2 = out2["models"][0]["entities"]["stored_entities"] - assert len(items2) == len(items) - assert sum(isinstance(x, Surface) for x in items2) == 1 - assert sum(isinstance(x, Point) for x in items2) == 1 - - -def test_materialize_passthrough_on_reentrant_call(): - # Re-entrant call should pass through object instances and remain stable - explicit = pd.TypeAdapter(Surface).validate_python( - { - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "s1", - "name": "wing", - } - ) - params = { - "models": [ - { - "entities": { - "stored_entities": [ - explicit, - ] - } - } - ] - } - out = materialize_entities_and_selectors_in_place(params) - items = out["models"][0]["entities"]["stored_entities"] - assert len([x for x in items if isinstance(x, Surface)]) == 1 - - -def test_materialize_reuses_cached_instance_across_nodes(): - # Same entity appears in multiple lists -> expect the same Python object reused - sdict = _mk_surface_dict("wing", "s1") - params = { - "models": [ - {"entities": {"stored_entities": [sdict]}}, - {"entities": {"stored_entities": [sdict]}}, - ] - } - - out = materialize_entities_and_selectors_in_place(params) - items1 = out["models"][0]["entities"]["stored_entities"] - items2 = out["models"][1]["entities"]["stored_entities"] - - obj1 = next(x for x in items1 if isinstance(x, Surface)) - obj2 = next(x for x in items2 if isinstance(x, Surface)) - # identity check: cached instance is reused across nodes - assert obj1 is obj2 - - -def test_materialize_with_entity_registry_mode(): - """ - Test: Mode 2 - materialize_entities_and_selectors_in_place with EntityRegistry. - - Purpose: - - Verify that when EntityRegistry is provided, entities are looked up from registry - - Verify that params references point to the same instances as in the registry - - Verify that this enables reference identity between entity_info and params - - Expected behavior: - - Input: Entity dicts with IDs + pre-populated EntityRegistry - - Process: Lookup entities by (type, id) from registry - - Output: Params contain references to registry instances (identity check) - """ - # Create registry with canonical entity instances - registry = EntityRegistry() - wing = Surface(name="wing", private_attribute_id="s-1") - tail = Surface(name="tail", private_attribute_id="s-2") - registry.register(wing) - registry.register(tail) - - # Params with entity dicts referencing the registry entities - params = { - "a": {"stored_entities": [_mk_surface_dict("wing", "s-1")]}, - "b": { - "stored_entities": [ - _mk_surface_dict("tail", "s-2"), - _mk_surface_dict("wing", "s-1"), - ] - }, - } - - # Materialize with registry - should use registry instances - out = materialize_entities_and_selectors_in_place( - copy.deepcopy(params), entity_registry=registry - ) - - # Verify that params now reference the exact registry instances - a_wing = out["a"]["stored_entities"][0] - b_tail = out["b"]["stored_entities"][0] - b_wing = out["b"]["stored_entities"][1] - - assert a_wing is wing # Same object from registry - assert b_tail is tail # Same object from registry - assert b_wing is wing # Same object from registry - assert a_wing is b_wing # Same object across different lists - - -def test_materialize_with_entity_registry_missing_entity_raises(): - """ - Test: Mode 2 - Error when entity not found in registry. - - Purpose: - - Verify that materialize_entities_and_selectors_in_place raises clear error - - Verify that error includes entity type, ID, and name for debugging - - Expected behavior: - - Input: Entity dict with ID not in registry - - Process: Lookup fails in registry - - Output: Raise ValueError with descriptive message - """ - registry = EntityRegistry() - wing = pd.TypeAdapter(Surface).validate_python({"name": "wing", "private_attribute_id": "s-1"}) - registry.register(wing) - - # Reference to non-existent entity - params = {"a": {"stored_entities": [_mk_surface_dict("fuselage", "s-999")]}} - - with pytest.raises(ValueError, match=r"Entity not found in EntityRegistry.*s-999.*fuselage"): - materialize_entities_and_selectors_in_place(copy.deepcopy(params), entity_registry=registry) - - -def test_materialize_with_entity_registry_missing_id_raises(): - """ - Test: Mode 2 - Error when entity dict missing private_attribute_id. - - Purpose: - - Verify that all entities must have IDs in registry mode - - Verify that error message is clear about missing ID requirement - - Expected behavior: - - Input: Entity dict without private_attribute_id + registry provided - - Process: Validation detects missing ID - - Output: Raise ValueError indicating ID is required in registry mode - """ - registry = EntityRegistry() - - # Entity dict without ID - params = { - "a": { - "stored_entities": [{"name": "wing", "private_attribute_entity_type_name": "Surface"}] - } - } - - with pytest.raises(ValueError, match=r"Entity missing 'private_attribute_id'.*EntityRegistry"): - materialize_entities_and_selectors_in_place(copy.deepcopy(params), entity_registry=registry) - - -def test_materialize_deserializes_used_selectors_and_materializes_selector_tokens(): - selector_dict = { - "target_class": "Surface", - "name": "my-selector", - "selector_id": "sel-1", - "logic": "AND", - "children": [ - { - "attribute": "name", - "operator": "matches", - "value": "wing*", - "non_glob_syntax": None, - } - ], - } - params = { - "private_attribute_asset_cache": {"used_selectors": [selector_dict]}, - "node": {"selectors": ["sel-1"]}, - } - - out = materialize_entities_and_selectors_in_place(copy.deepcopy(params)) - used_selectors = out["private_attribute_asset_cache"]["used_selectors"] - assert isinstance(used_selectors[0], EntitySelector) - - node_selectors = out["node"]["selectors"] - assert isinstance(node_selectors[0], EntitySelector) - # Ensure the token was replaced with the same shared instance as in used_selectors - assert node_selectors[0] is used_selectors[0] - - -def test_materialize_selector_token_missing_definition_raises(): - params = { - "private_attribute_asset_cache": {"used_selectors": []}, - "node": {"selectors": ["sel-missing"]}, - } - - with pytest.raises(ValueError, match=r"Selector token not found.*sel-missing"): - materialize_entities_and_selectors_in_place(copy.deepcopy(params)) - - -def test_materialize_idempotent_with_already_materialized_selectors(): - """Test that calling materialize_entities_and_selectors_in_place twice is idempotent.""" - selector_dict = { - "target_class": "Surface", - "name": "test-selector", - "selector_id": "sel-123", - "logic": "AND", - "children": [ - { - "attribute": "name", - "operator": "matches", - "value": "surface*", - "non_glob_syntax": None, - } - ], - } - params = { - "private_attribute_asset_cache": {"used_selectors": [selector_dict]}, - "node1": {"selectors": ["sel-123"]}, - "node2": {"selectors": ["sel-123"]}, - } - - # First materialization - out1 = materialize_entities_and_selectors_in_place(params) - - # Verify selectors are materialized - used_selectors_1 = out1["private_attribute_asset_cache"]["used_selectors"] - assert isinstance(used_selectors_1[0], EntitySelector) - node1_selectors_1 = out1["node1"]["selectors"] - assert isinstance(node1_selectors_1[0], EntitySelector) - node2_selectors_1 = out1["node2"]["selectors"] - assert isinstance(node2_selectors_1[0], EntitySelector) - - # Store object ids for identity checks - selector_obj_id = id(node1_selectors_1[0]) - - # Second materialization on the same params dict (already materialized) - # This should NOT raise TypeError and should be a no-op - out2 = materialize_entities_and_selectors_in_place(out1) - - # Verify selectors remain EntitySelector objects - used_selectors_2 = out2["private_attribute_asset_cache"]["used_selectors"] - assert isinstance(used_selectors_2[0], EntitySelector) - node1_selectors_2 = out2["node1"]["selectors"] - assert isinstance(node1_selectors_2[0], EntitySelector) - node2_selectors_2 = out2["node2"]["selectors"] - assert isinstance(node2_selectors_2[0], EntitySelector) - - # Verify object identity is preserved (no unnecessary re-instantiation) - assert id(node1_selectors_2[0]) == selector_obj_id - assert node1_selectors_2[0] is node2_selectors_2[0] - - # Verify the selector still has the expected attributes - assert node1_selectors_2[0].selector_id == "sel-123" - assert node1_selectors_2[0].name == "test-selector" - assert node1_selectors_2[0].target_class == "Surface" diff --git a/tests/simulation/framework/test_entity_materializer_coverage.py b/tests/simulation/framework/test_entity_materializer_coverage.py deleted file mode 100644 index 577a5e584..000000000 --- a/tests/simulation/framework/test_entity_materializer_coverage.py +++ /dev/null @@ -1,56 +0,0 @@ -import inspect - -import flow360.component.simulation.draft_context.mirror as mirror -import flow360.component.simulation.outputs.output_entities as output_entities -import flow360.component.simulation.primitives as primitives -from flow360.component.simulation.framework.entity_materializer import ENTITY_TYPE_MAP - - -def test_entity_type_map_completeness(): - """ - Ensure all classes in primitives and output_entities that define - 'private_attribute_entity_type_name' are registered in ENTITY_TYPE_MAP. - """ - - modules_to_check = [primitives, output_entities, mirror] - - missing_entities = [] - - for module in modules_to_check: - for name, obj in inspect.getmembers(module): - if inspect.isclass(obj): - # Check if the class has 'private_attribute_entity_type_name' - # We need to check if it's a pydantic model field or a class attribute - - # Skip private classes and EntityBase - if name.startswith("_") or name == "EntityBase": - continue - - if inspect.isabstract(obj): - continue - - has_entity_type = False - - # Check Pydantic fields - if ( - hasattr(obj, "model_fields") - and "private_attribute_entity_type_name" in obj.model_fields - ): - has_entity_type = True - - # Check class attributes (if defined as a simple attribute, though unlikely for Pydantic models) - elif hasattr(obj, "private_attribute_entity_type_name"): - has_entity_type = True - - if has_entity_type: - # Get the expected type name. - # Usually it's the class name or the default value of the field. - # We check if the class name itself is in the map or if the value is in the map. - - # For safety, we check if the class itself is in ENTITY_TYPE_MAP.values() - if obj not in ENTITY_TYPE_MAP.values(): - missing_entities.append(name) - - assert ( - not missing_entities - ), f"The following entities are missing from ENTITY_TYPE_MAP: {missing_entities}" diff --git a/tests/simulation/framework/test_entity_selector_fluent_api.py b/tests/simulation/framework/test_entity_selector_fluent_api.py deleted file mode 100644 index 5ebd3614c..000000000 --- a/tests/simulation/framework/test_entity_selector_fluent_api.py +++ /dev/null @@ -1,231 +0,0 @@ -import json - -import pytest - -from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.entity_selector import ( - EdgeSelector, - EntitySelector, - SurfaceSelector, - expand_entity_list_selectors_in_place, -) -from flow360.component.simulation.primitives import Edge, Surface - - -class _EntityListStub: - """Minimal stub for selector expansion tests (avoids EntityList metaclass constraints).""" - - def __init__(self, *, stored_entities=None, selectors=None): - self.stored_entities = stored_entities or [] - self.selectors = selectors - - -def _mk_pool(names, entity_type): - # Build list of entity dicts with given names and type - return [{"name": n, "private_attribute_entity_type_name": entity_type} for n in names] - - -def _make_registry(surfaces=None, edges=None): - """Create an EntityRegistry from entity dictionaries.""" - registry = EntityRegistry() - for entity_dict in surfaces or []: - registry.register(Surface(name=entity_dict["name"])) - for entity_dict in edges or []: - registry.register(Edge(name=entity_dict["name"])) - return registry - - -def _expand_and_get_names(registry: EntityRegistry, selector_model) -> list[str]: - entity_list = _EntityListStub(stored_entities=[], selectors=[selector_model]) - expand_entity_list_selectors_in_place(registry, entity_list, merge_mode="merge") - stored = entity_list.stored_entities - return [ - e.name - for e in stored - if e.private_attribute_entity_type_name == selector_model.target_class - ] - - -def test_surface_class_match_and_chain_and(): - """ - Test: EntitySelector fluent API with AND logic (default) and predicate chaining. - - Purpose: - - Verify that SurfaceSelector().match() creates a selector with glob pattern matching - - Verify that chaining .not_any_of() adds an exclusion predicate - - Verify that AND logic correctly computes intersection of predicates - - Verify that the selector expands correctly against an entity database - - Expected behavior: - - match("wing*") selects: ["wing", "wing-root", "wingtip"] - - not_any_of(["wing"]) excludes: ["wing"] - - AND logic result: ["wing-root", "wingtip"] - """ - # Prepare a pool of Surface entities - registry = _make_registry( - surfaces=_mk_pool(["wing", "wing-root", "wingtip", "tail"], "Surface") - ) - - # AND logic by default; expect intersection of predicates - selector = SurfaceSelector(name="t_and").match("wing*").not_any_of(["wing"]) - names = _expand_and_get_names(registry, selector) - assert names == ["wing-root", "wingtip"] - - -def test_surface_class_match_or_union(): - """ - Test: EntitySelector with OR logic for union of predicates. - - Purpose: - - Verify that logic="OR" parameter works correctly - - Verify that OR logic computes union of all matching predicates - - Verify that result order is stable (preserved from original pool) - - Verify that any_of() predicate works in OR mode - - Expected behavior: - - match("s1") selects: ["s1"] - - any_of(["tail"]) selects: ["tail"] - - OR logic result: ["s1", "tail"] (in pool order) - """ - registry = _make_registry(surfaces=_mk_pool(["s1", "s2", "tail", "wing"], "Surface")) - - # OR logic: union of predicates - selector = SurfaceSelector(name="t_or", logic="OR").match("s1").any_of(["tail"]) - names = _expand_and_get_names(registry, selector) - # Order preserved by pool scan under OR - assert names == ["s1", "tail"] - - -def test_surface_regex_and_not_match(): - """ - Test: EntitySelector with mixed syntax (regex and glob) for pattern matching. - - Purpose: - - Verify that non_glob_syntax="regex" enables regex pattern matching (fullmatch) - - Verify that glob pattern matching works (default) - - Verify that match and not_match predicates can be combined - - Verify that different syntax modes can be used in the same selector - - Note: regex syntax is not exposed via the fluent API (UI doesn't support it), - so we use EntitySelector.model_validate() to test this internal capability. - - Expected behavior: - - match(r"^wing$", non_glob_syntax="regex") selects: ["wing"] (exact match) - - not_match("*-root") excludes: ["wing-root"] - - Result: ["wing"] (passed both predicates) - """ - registry = _make_registry(surfaces=_mk_pool(["wing", "wing-root", "tail"], "Surface")) - - # Use model_validate to construct selector with regex predicate (internal API) - selector = EntitySelector.model_validate( - { - "name": "t_regex", - "target_class": "Surface", - "logic": "AND", - "children": [ - { - "attribute": "name", - "operator": "matches", - "value": r"^wing$", - "non_glob_syntax": "regex", - }, - { - "attribute": "name", - "operator": "not_matches", - "value": "*-root", - }, - ], - } - ) - names = _expand_and_get_names(registry, selector) - assert names == ["wing"] - - -def test_in_and_not_any_of_chain(): - """ - Test: EntitySelector with any_of() and not_any_of() membership predicates. - - Purpose: - - Verify that any_of() (inclusion) predicate works correctly - - Verify that not_any_of() (exclusion) predicate works correctly - - Verify that membership predicates can be combined with pattern matching - - Verify that AND logic correctly applies set operations in sequence - - Expected behavior: - - match("*") selects all: ["a", "b", "c", "d"] - - any_of(["a", "b", "c"]) filters to: ["a", "b", "c"] - - not_any_of(["b"]) excludes: ["b"] - - Final result: ["a", "c"] - """ - registry = _make_registry(surfaces=_mk_pool(["a", "b", "c", "d"], "Surface")) - - # AND semantics: in {a,b,c} and not_in {b} - selector = SurfaceSelector(name="t_in").match("*").any_of(["a", "b", "c"]).not_any_of(["b"]) - names = _expand_and_get_names(registry, selector) - assert names == ["a", "c"] - - -def test_any_of_string_value_from_json_matches_full_name(): - """ - Test: JSON-deserialized any_of predicate accepts a single string value. - - Purpose: - - Verify that the schema-side `Predicate.value: str | list[str]` contract is honored. - - Verify that a single string is treated as one candidate value, not split into characters. - - Verify that selector expansion remains correct for JSON input paths. - """ - registry = _make_registry(surfaces=_mk_pool(["wing", "w", "i", "n", "g"], "Surface")) - - selector = EntitySelector.model_validate( - { - "name": "t_any_of_string", - "target_class": "Surface", - "logic": "AND", - "children": [ - { - "attribute": "name", - "operator": "any_of", - "value": "wing", - } - ], - } - ) - - names = _expand_and_get_names(registry, selector) - assert names == ["wing"] - - -def test_edge_class_basic_match(): - """ - Test: EntitySelector with Edge entity type (non-Surface). - - Purpose: - - Verify that entity selector works with different entity types (Edge vs Surface) - - Verify that EdgeSelector().match() creates a selector targeting Edge entities - - Verify that the entity database correctly routes to the edges pool - - Verify that simple exact name matching works - - Expected behavior: - - EdgeSelector().match("edgeA") selects only edgeA from the edges pool - - Edge entities are correctly filtered by target_class - """ - registry = _make_registry(edges=_mk_pool(["edgeA", "edgeB"], "Edge")) - - selector = EdgeSelector(name="edge_basic").match("edgeA") - entity_list = _EntityListStub(stored_entities=[], selectors=[selector]) - expand_entity_list_selectors_in_place(registry, entity_list, merge_mode="merge") - stored = entity_list.stored_entities - assert [e.name for e in stored if e.private_attribute_entity_type_name == "Edge"] == ["edgeA"] - - -def test_selector_factory_propagates_description(): - """ - Test: Selector factory functions propagate description into EntitySelector instances. - - Expected behavior: - - Passing description to SurfaceSelector() stores it on the resulting selector. - - model_dump() contains the provided description for serialization/round-trip. - """ - selector = SurfaceSelector(name="desc_selector", description="Select all surfaces").match("*") - assert selector.description == "Select all surfaces" - assert selector.model_dump()["description"] == "Select all surfaces" diff --git a/tests/simulation/framework/test_entity_selector_token.py b/tests/simulation/framework/test_entity_selector_token.py deleted file mode 100644 index 501ca4a1f..000000000 --- a/tests/simulation/framework/test_entity_selector_token.py +++ /dev/null @@ -1,174 +0,0 @@ -import copy - -import pytest - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_materializer import ( - materialize_entities_and_selectors_in_place, -) -from flow360.component.simulation.framework.entity_selector import ( - EntitySelector, - collect_and_tokenize_selectors_in_place, -) -from flow360.component.simulation.framework.param_utils import AssetCache - - -def test_entity_selector_token_flow(): - # 1. Setup input dictionary with repeated selectors (same definition repeated in two places) - selector_dict = { - "selector_id": "sel1-token", - "target_class": "Surface", - "name": "sel1", - "logic": "AND", - "children": [{"attribute": "name", "operator": "matches", "value": "wing*"}], - } - params_as_dict = { - "private_attribute_asset_cache": AssetCache().model_dump(mode="json", exclude_none=True), - "models": [ - {"name": "m1", "selectors": [copy.deepcopy(selector_dict)]}, - {"name": "m2", "selectors": [copy.deepcopy(selector_dict)]}, - ], - } - - # 2. Run tokenization (dict -> tokens + used_selectors list) - tokenized_params = collect_and_tokenize_selectors_in_place(copy.deepcopy(params_as_dict)) - - # 3. Verify AssetCache and tokens - asset_cache = tokenized_params["private_attribute_asset_cache"] - assert "used_selectors" in asset_cache - assert len(asset_cache["used_selectors"]) == 1 - assert asset_cache["used_selectors"][0]["selector_id"] == selector_dict["selector_id"] - assert asset_cache["used_selectors"][0]["name"] == selector_dict["name"] - - # Check that selectors are replaced by tokens - assert tokenized_params["models"][0]["selectors"] == [selector_dict["selector_id"]] - assert tokenized_params["models"][1]["selectors"] == [selector_dict["selector_id"]] - - # 4. Resolve selector tokens back to shared EntitySelector objects - resolved_params = materialize_entities_and_selectors_in_place(tokenized_params) - - # 5. Verify selectors are resolved from tokens to EntitySelector objects (not strings) - sel1 = resolved_params["models"][0]["selectors"] - sel2 = resolved_params["models"][1]["selectors"] - - assert len(sel1) == 1 - assert isinstance( - sel1[0], EntitySelector - ), "Selector token should be materialized to EntitySelector" - assert sel1[0].selector_id == selector_dict["selector_id"] - assert sel1[0].name == selector_dict["name"] - - assert len(sel2) == 1 - assert isinstance( - sel2[0], EntitySelector - ), "Selector token should be materialized to EntitySelector" - - # 6. Verify shared instance linkage across references - assert sel1[0] is sel2[0] - assert resolved_params["private_attribute_asset_cache"]["used_selectors"][0] is sel1[0] - - -def test_entity_selector_token_round_trip_validation(): - params_as_dict = { - "private_attribute_asset_cache": {}, - "models": [ - { - "name": "m1", - "selectors": [ - { - "selector_id": "sel1-token", - "target_class": "Surface", - "name": "sel1", - "children": [ - {"attribute": "name", "operator": "matches", "value": "wing*"} - ], - } - ], - }, - { - "name": "m2", - "selectors": [ - { - "selector_id": "sel1-token", - "target_class": "Surface", - "name": "sel1", - "children": [ - {"attribute": "name", "operator": "matches", "value": "wing*"} - ], - } - ], - }, - ], - } - - tokenized_params = collect_and_tokenize_selectors_in_place(copy.deepcopy(params_as_dict)) - - class _ModelWithSelectors(Flow360BaseModel): - name: str - selectors: list[EntitySelector] - - class _ParamsWithAssetCache(Flow360BaseModel): - private_attribute_asset_cache: AssetCache - models: list[_ModelWithSelectors] - - # Materialize selector tokens before validation, matching validate_model() preprocessing behavior. - materialize_entities_and_selectors_in_place(tokenized_params) - validated = _ParamsWithAssetCache.deserialize(tokenized_params) - - cache = validated.private_attribute_asset_cache - assert cache.used_selectors is not None - assert cache.used_selectors[0].selector_id == "sel1-token" - assert validated.models[0].selectors[0].selector_id == "sel1-token" - assert validated.models[1].selectors[0].selector_id == "sel1-token" - - -def test_entity_selector_unknown_token_raises_error(): - """Test that referencing an unknown selector token raises a ValueError.""" - params = { - "private_attribute_asset_cache": { - "used_selectors": [ - { - "selector_id": "known-selector-id", - "target_class": "Surface", - "name": "known_selector", - "children": [{"attribute": "name", "operator": "matches", "value": "wing*"}], - } - ] - }, - "model": { - "selectors": [ - "unknown-selector-id", # This token does not exist in used_selectors - ] - }, - } - - with pytest.raises(ValueError, match=r"Selector token not found.*unknown-selector-id"): - materialize_entities_and_selectors_in_place(params) - - -def test_entity_selector_token_generates_missing_selector_id(): - params_as_dict = { - "private_attribute_asset_cache": {}, - "models": [ - { - "name": "m1", - "selectors": [ - { - "target_class": "Surface", - "name": "sel1", - "children": [ - {"attribute": "name", "operator": "matches", "value": "wing*"} - ], - } - ], - } - ], - } - - tokenized_params = collect_and_tokenize_selectors_in_place(copy.deepcopy(params_as_dict)) - - generated_token = tokenized_params["models"][0]["selectors"][0] - used_selector = tokenized_params["private_attribute_asset_cache"]["used_selectors"][0] - - assert generated_token is not None - assert used_selector["selector_id"] == generated_token diff --git a/tests/simulation/framework/test_entity_type_filtering_during_expansion.py b/tests/simulation/framework/test_entity_type_filtering_during_expansion.py index df40ebbc8..bd0ecc5a3 100644 --- a/tests/simulation/framework/test_entity_type_filtering_during_expansion.py +++ b/tests/simulation/framework/test_entity_type_filtering_during_expansion.py @@ -1,111 +1,16 @@ -"""Test that entity type filtering works correctly during selector expansion. - -This test verifies the key behavior of the centralized field validator: -- Selectors can match entity types beyond what the EntityList accepts -- Field validator silently filters out invalid types during expansion -- No error is raised when expanded entities include invalid types -""" - -import json -import os - -import pytest - from flow360.component.simulation.draft_context import DraftContext from flow360.component.simulation.entity_info import SurfaceMeshEntityInfo -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.entity_selector import ( - SurfaceSelector, - expand_entity_list_selectors_in_place, -) -from flow360.component.simulation.primitives import ( - GenericVolume, - GhostSphere, - MirroredSurface, - Surface, -) - - -def test_selector_expansion_filters_invalid_types_silently(): - """ - Test that selector expansion with type expansion map works correctly. - - Scenario: - - EntityList[Surface, MirroredSurface] only accepts Surface and MirroredSurface - - SurfaceSelector with expansion map matches Surface, MirroredSurface, AND GhostSphere - - Field validator should silently filter out GhostSphere - - No error should be raised - """ - # Create registry with mixed surface types - registry = EntityRegistry() - surface1 = Surface(name="wing") - surface2 = MirroredSurface( - name="wing_mirrored", surface_id="surface1", mirror_plane_id="plane1" - ) - # GhostSphere is in the expansion map for Surface but not in EntityList[Surface, MirroredSurface] - ghost_sphere = GhostSphere(name="ghost_sphere") - - registry.register(surface1) - registry.register(surface2) - registry.register(ghost_sphere) - - print("\nRegistry contains:") - print(f" - Surface: {surface1.name}") - print(f" - MirroredSurface: {surface2.name}") - print( - f" - GhostSphere: {ghost_sphere.name} (in expansion map but not in EntityList valid types)" - ) - - # Create EntityList[Surface, MirroredSurface] with selector matching all - selector = SurfaceSelector(name="all_surfaces").match("*") - entity_list = EntityList[Surface, MirroredSurface]( - stored_entities=[], - selectors=[selector], - ) - - print(f"\nEntityList valid types: Surface, MirroredSurface") - print(f"Selector will match: Surface, MirroredSurface, GhostSphere (via expansion map)") - - # Expand selectors - should NOT raise error - print(f"\nExpanding selector...") - expand_entity_list_selectors_in_place(registry, entity_list) - - print(f"\nAfter expansion:") - print(f" stored_entities: {[e.name for e in entity_list.stored_entities]}") - print(f" Types: {[type(e).__name__ for e in entity_list.stored_entities]}") - - # Verify results - entity_names = {e.name for e in entity_list.stored_entities} - entity_types = {type(e).__name__ for e in entity_list.stored_entities} - - # Should include valid types - assert "wing" in entity_names, "Surface should be included" - assert "wing_mirrored" in entity_names, "MirroredSurface should be included" - - # Should NOT include invalid type - assert "ghost_sphere" not in entity_names, "GhostSphere should be filtered out" - - # Verify only valid types present - assert entity_types == { - "Surface", - "MirroredSurface", - }, f"Only Surface and MirroredSurface should be present, got {entity_types}" - - print(f"\n✓ Test passed: Invalid type (GhostSphere) was silently filtered out") +from flow360.component.simulation.framework.entity_selector import SurfaceSelector +from flow360.component.simulation.primitives import MirroredSurface, Surface def test_preview_selector_matches_mirrored_entities(): - """Test that DraftContext.preview_selector can match mirrored entities.""" - registry = EntityRegistry() + """DraftContext.preview_selector should surface mirrored entities from the draft registry.""" surface = Surface(name="wing") mirrored_surface = MirroredSurface( name="wing_mirrored", surface_id="surface1", mirror_plane_id="plane1" ) - registry.register(surface) - registry.register(mirrored_surface) - # Build a draft context that has a populated entity_info, then inject mirrored entities # into the draft registry so the selector expansion can see them. draft = DraftContext(entity_info=SurfaceMeshEntityInfo(boundaries=[surface])) @@ -115,81 +20,3 @@ def test_preview_selector_matches_mirrored_entities(): SurfaceSelector(name="mirrored").match("*mirrored"), return_names=True ) assert previewed_names == ["wing_mirrored"] - - -def test_selector_expansion_with_all_invalid_types_raises_error(): - """ - Test that expansion raises error when ALL matched entities are invalid types. - - This ensures we catch configuration errors where selectors match nothing valid. - """ - # Create registry with only invalid types for EntityList[Surface] - registry = EntityRegistry() - ghost_sphere = GhostSphere(name="ghost_sphere") - volume = GenericVolume(name="fluid") # Not a surface type at all - mirrored_surface = MirroredSurface( - name="Some random mirrored surface", surface_id="surface1", mirror_plane_id="plane1" - ) # Not a surface type at all - - registry.register(ghost_sphere) - registry.register(volume) - registry.register(mirrored_surface) - - # Create EntityList[Surface] with selector matching all - # Selector will match mirrored_surface via expansion map - # but EntityList[Surface] only accepts Surface - selector = SurfaceSelector(name="all_surfaces").match("*") - - with pytest.raises(ValueError, match="Can not find any valid entity of type.*Surface"): - entity_list = EntityList[Surface]( - stored_entities=[], - selectors=[selector], - ) - expand_entity_list_selectors_in_place(registry, entity_list) - - print("\n✓ Test passed: Error raised when all matched entities are invalid types") - - -def test_selector_expansion_with_mixed_explicit_and_selector_entities(): - """ - Test that explicit entities and selector-matched entities both get filtered. - - This verifies that the field validator runs consistently regardless of entity source. - """ - # Create registry - registry = EntityRegistry() - surface1 = Surface(name="wing") - surface2 = MirroredSurface( - name="wing_mirrored", surface_id="surface1", mirror_plane_id="plane1" - ) - ghost_sphere = GhostSphere(name="ghost_sphere") - - registry.register(surface1) - registry.register(surface2) - registry.register(ghost_sphere) - - # Create EntityList with explicit Surface and selector matching all - selector = SurfaceSelector(name="mirrored_surfaces").match("*mirrored") - entity_list = EntityList[Surface, MirroredSurface]( - stored_entities=[surface1], # Explicit Surface - selectors=[selector], # Will match MirroredSurface - ) - - print("\nBefore expansion:") - print(f" Explicit: {[e.name for e in entity_list.stored_entities]}") - - expand_entity_list_selectors_in_place(registry, entity_list) - - print("\nAfter expansion:") - print(f" All entities: {[e.name for e in entity_list.stored_entities]}") - - entity_names = {e.name for e in entity_list.stored_entities} - - # Should have both explicit and selector-matched valid entities - assert "wing" in entity_names, "Explicit Surface should be present" - assert "wing_mirrored" in entity_names, "Selector-matched MirroredSurface should be present" - - # Should not have invalid types - assert "ghost_sphere" not in entity_names, "Invalid type should be filtered" - - print("\n✓ Test passed: Mixed explicit and selector entities filtered correctly") diff --git a/tests/simulation/framework/test_geometry_entity_info_all_ids.py b/tests/simulation/framework/test_geometry_entity_info_all_ids.py deleted file mode 100644 index 5a38ad135..000000000 --- a/tests/simulation/framework/test_geometry_entity_info_all_ids.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -Tests for GeometryEntityInfo all_*_ids properties. - -These tests ensure that the prioritized path (using `bodies_face_edge_ids`) produces the -same outputs as the fallback path (using `face_ids` / `edge_ids` / `body_ids`). -""" - -import copy -import json -import os - -from flow360.component.simulation.entity_info import ( - GeometryEntityInfo, - parse_entity_info_model, -) - - -def _load_simulation_json(relative_path: str) -> dict: - """Load a simulation JSON file relative to this test folder.""" - test_dir = os.path.dirname(os.path.abspath(__file__)) - json_path = os.path.join(test_dir, "..", relative_path) - with open(json_path, "r", encoding="utf-8") as file_handle: - return json.load(file_handle) - - -def test_geometry_entity_info_all_ids_match_fallback_when_bodies_face_edge_ids_removed(): - """ - `all_face_ids`, `all_edge_ids`, and `all_body_ids` should be consistent between: - - prioritized mode: `bodies_face_edge_ids` is present - - fallback mode: `bodies_face_edge_ids` removed so the legacy lists are used - """ - params_as_dict = _load_simulation_json("data/geometry_airplane/simulation.json") - entity_info_dict = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] - - entity_info_prioritized = parse_entity_info_model(entity_info_dict) - assert isinstance(entity_info_prioritized, GeometryEntityInfo) - assert entity_info_prioritized.bodies_face_edge_ids is not None - - prioritized_faces = entity_info_prioritized.all_face_ids - prioritized_edges = entity_info_prioritized.all_edge_ids - prioritized_bodies = entity_info_prioritized.all_body_ids - - entity_info_dict_fallback = copy.deepcopy(entity_info_dict) - entity_info_dict_fallback.pop("bodies_face_edge_ids", None) - - entity_info_fallback = parse_entity_info_model(entity_info_dict_fallback) - assert isinstance(entity_info_fallback, GeometryEntityInfo) - assert entity_info_fallback.bodies_face_edge_ids is None - - assert entity_info_fallback.all_face_ids == prioritized_faces - assert entity_info_fallback.all_edge_ids == prioritized_edges - assert entity_info_fallback.all_body_ids == prioritized_bodies diff --git a/tests/simulation/framework/test_multi_constructor_model.py b/tests/simulation/framework/test_multi_constructor_model.py index 04e85502c..dd5964953 100644 --- a/tests/simulation/framework/test_multi_constructor_model.py +++ b/tests/simulation/framework/test_multi_constructor_model.py @@ -1,13 +1,11 @@ import os from copy import deepcopy -import pydantic as pd import pytest from flow360_schema.framework.validation.context import DeserializationContext import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList from flow360.component.simulation.framework.multi_constructor_model_base import ( parse_model_dict, ) @@ -16,8 +14,6 @@ AerospaceCondition, ThermalState, ) -from flow360.component.simulation.primitives import Box, Cylinder -from flow360.component.simulation.unit_system import SI_unit_system from tests.simulation.converter.test_bet_translator import generate_BET_param @@ -159,85 +155,7 @@ def test_recursive_incomplete_model(get_aerospace_condition_using_from_mach): compare_objects_from_dict(full_data, data_parsed, AerospaceCondition) -def test_entity_with_multi_constructor(): - - class ModelWithEntityList(Flow360BaseModel): - entities: EntityList[Box, Cylinder] = pd.Field() - - with SI_unit_system: - model = ModelWithEntityList( - entities=[ - Box( - name="my_box_default", - center=(1, 2, 3), - size=(2, 2, 3), - angle_of_rotation=20 * u.deg, - axis_of_rotation=(1, 0, 0), - ), - Box.from_principal_axes( - name="my_box_from", - center=(7, 1, 2), - size=(2, 2, 3), - axes=((3 / 5, 4 / 5, 0), (4 / 5, -3 / 5, 0)), - ), - Cylinder( - name="my_cylinder_default", - axis=(0, 1, 0), - center=(1, 2, 3), - outer_radius=2, - height=3, - ), - ] - ) - full_data = model.model_dump(exclude_none=False) - incomplete_data = {"entities": {"stored_entities": []}} - # For default constructed entity we do not do anything - incomplete_data["entities"]["stored_entities"].append( - full_data["entities"]["stored_entities"][0] - ) - incomplete_data["entities"]["stored_entities"][0]["private_attribute_input_cache"] = {} - entity_dict = full_data["entities"]["stored_entities"][1] - incomplete_entity = {} - for key, value in entity_dict.items(): - if key in [ - "type_name", - "private_attribute_constructor", - "private_attribute_input_cache", - "private_attribute_id", - ]: - incomplete_entity[key] = value - incomplete_data["entities"]["stored_entities"].append(incomplete_entity) - incomplete_data["entities"]["stored_entities"].append( - full_data["entities"]["stored_entities"][2] - ) - - data_parsed = parse_model_dict(incomplete_data, globals()) - compare_objects_from_dict(full_data, data_parsed, ModelWithEntityList) - - -def test_entity_modification(get_aerospace_condition_using_from_mach): - - my_box = Box.from_principal_axes( - name="box", - axes=[(0, 1, 0), (0, 0, 1)], - center=(0, 0, 0) * u.m, - size=(0.2, 0.3, 2) * u.m, - ) - - my_box.center = (1, 2, 3) * u.m - assert all(my_box.private_attribute_input_cache.center == (1, 2, 3) * u.m) - - my_box = Box( - name="box2", - axis_of_rotation=(1, 0, 0), - angle_of_rotation=45 * u.deg, - center=(1, 1, 1) * u.m, - size=(0.2, 0.3, 2) * u.m, - ) - - my_box.size = (1, 2, 32) * u.m - assert all(my_box.private_attribute_input_cache.size == (1, 2, 32) * u.m) - +def test_non_entity_modification_updates_input_cache(get_aerospace_condition_using_from_mach): my_op = get_aerospace_condition_using_from_mach my_op.alpha = -12 * u.rad assert my_op.private_attribute_input_cache.alpha == -12 * u.rad diff --git a/tests/simulation/framework/test_selector_merge_vs_replace.py b/tests/simulation/framework/test_selector_merge_vs_replace.py deleted file mode 100644 index fe4ca5445..000000000 --- a/tests/simulation/framework/test_selector_merge_vs_replace.py +++ /dev/null @@ -1,79 +0,0 @@ -from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.entity_selector import ( - EntitySelector, - expand_entity_list_selectors_in_place, -) -from flow360.component.simulation.primitives import Edge, Surface - - -class _EntityListStub: - """Minimal stub for selector expansion tests (avoids EntityList metaclass constraints).""" - - def __init__(self, *, stored_entities=None, selectors=None): - self.stored_entities = stored_entities or [] - self.selectors = selectors - - -def _mk_pool(names, entity_type): - return [{"name": n, "private_attribute_entity_type_name": entity_type} for n in names] - - -def _make_registry(surfaces=None, edges=None): - """Create an EntityRegistry from entity dictionaries.""" - registry = EntityRegistry() - for entity_dict in surfaces or []: - registry.register(Surface(name=entity_dict["name"])) - for entity_dict in edges or []: - registry.register(Edge(name=entity_dict["name"])) - return registry - - -def test_merge_mode_preserves_explicit_then_appends_selector_results(): - registry = _make_registry(surfaces=_mk_pool(["wing", "tail", "body"], "Surface")) - entity_list = _EntityListStub( - stored_entities=[Surface(name="tail")], - selectors=[ - EntitySelector.model_validate( - { - "name": "sel_any_wing", - "target_class": "Surface", - "children": [{"attribute": "name", "operator": "any_of", "value": ["wing"]}], - } - ) - ], - ) - expand_entity_list_selectors_in_place(registry, entity_list, merge_mode="merge") - items = entity_list.stored_entities - assert [e.name for e in items if e.private_attribute_entity_type_name == "Surface"] == [ - "tail", - "wing", - ] - assert entity_list.selectors is not None - - -def test_replace_mode_overrides_target_class_only(): - registry = _make_registry( - surfaces=_mk_pool(["wing", "tail"], "Surface"), - edges=_mk_pool(["e1"], "Edge"), - ) - entity_list = _EntityListStub( - stored_entities=[ - Surface(name="tail"), - Edge(name="e1"), - ], - selectors=[ - EntitySelector.model_validate( - { - "name": "sel_any_wing", - "target_class": "Surface", - "children": [{"attribute": "name", "operator": "any_of", "value": ["wing"]}], - } - ) - ], - ) - expand_entity_list_selectors_in_place(registry, entity_list, merge_mode="replace") - items = entity_list.stored_entities - # Surface entries replaced by selector result; Edge preserved - assert [e.name for e in items if e.private_attribute_entity_type_name == "Surface"] == ["wing"] - assert [e.name for e in items if e.private_attribute_entity_type_name == "Edge"] == ["e1"] - assert entity_list.selectors is not None From 97438624598d09ae16a89df65119f8d842ed58cd Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:37:50 -0400 Subject: [PATCH 11/25] refactor(flow360): relay simulation infra helpers to schema (#1963) --- flow360/component/project_utils.py | 79 ++++--- .../simulation/framework/base_model_config.py | 47 +--- .../framework/entity_expansion_config.py | 83 ++----- .../framework/entity_expansion_utils.py | 126 +++-------- .../framework/entity_materializer.py | 100 +-------- .../simulation/framework/expressions.py | 122 +--------- .../simulation/framework/param_utils.py | 201 ++--------------- .../framework/single_attribute_base.py | 24 +- .../component/simulation/services_utils.py | 4 +- .../simulation/user_code/core/types.py | 212 +++--------------- poetry.lock | 7 +- 11 files changed, 165 insertions(+), 840 deletions(-) diff --git a/flow360/component/project_utils.py b/flow360/component/project_utils.py index 230de2171..73a8b7d41 100644 --- a/flow360/component/project_utils.py +++ b/flow360/component/project_utils.py @@ -233,43 +233,58 @@ def _replace_the_ghost_surface(*, ghost_surface, ghost_entities_from_metadata): " Please double check the use of ghost surfaces." ) - def _find_ghost_surfaces(*, model, ghost_entities_from_metadata): - for field in model.__dict__.values(): - if isinstance(field, GhostSurface): - # pylint: disable=protected-access - field = _replace_the_ghost_surface( - ghost_surface=field, + def _replace_in_value(*, value, ghost_entities_from_metadata): + if isinstance(value, GhostSurface): + return _replace_the_ghost_surface( + ghost_surface=value, + ghost_entities_from_metadata=ghost_entities_from_metadata, + ) + + if isinstance(value, EntityList): + if value.stored_entities: + for entity_index, entity in enumerate(value.stored_entities): + value.stored_entities[entity_index] = _replace_in_value( + value=entity, + ghost_entities_from_metadata=ghost_entities_from_metadata, + ) + return value + + if isinstance(value, list): + for item_index, item in enumerate(value): + value[item_index] = _replace_in_value( + value=item, ghost_entities_from_metadata=ghost_entities_from_metadata, ) + return value - if isinstance(field, EntityList): - if field.stored_entities: - for entity_index, _ in enumerate(field.stored_entities): - if isinstance(field.stored_entities[entity_index], GhostSurface): - field.stored_entities[entity_index] = _replace_the_ghost_surface( - ghost_surface=field.stored_entities[entity_index], - ghost_entities_from_metadata=ghost_entities_from_metadata, - ) - - elif isinstance(field, (list, tuple)): - for item in field: - if isinstance(item, GhostSurface): - # pylint: disable=protected-access - item = _replace_the_ghost_surface( - ghost_surface=item, - ghost_entities_from_metadata=ghost_entities_from_metadata, - ) - elif isinstance(item, Flow360BaseModel): - _find_ghost_surfaces( - model=item, - ghost_entities_from_metadata=ghost_entities_from_metadata, - ) - - elif isinstance(field, Flow360BaseModel): - _find_ghost_surfaces( - model=field, + if isinstance(value, tuple): + return tuple( + _replace_in_value( + value=item, ghost_entities_from_metadata=ghost_entities_from_metadata, ) + for item in value + ) + + if isinstance(value, Flow360BaseModel): + _find_ghost_surfaces( + model=value, + ghost_entities_from_metadata=ghost_entities_from_metadata, + ) + return value + + return value + + def _find_ghost_surfaces(*, model, ghost_entities_from_metadata): + for field_name, field in model.__dict__.items(): + updated_field = _replace_in_value( + value=field, + ghost_entities_from_metadata=ghost_entities_from_metadata, + ) + if updated_field is field: + continue + # pylint: disable=protected-access + model._force_set_attr(field_name, updated_field) ghost_entities_from_metadata = ( params.private_attribute_asset_cache.project_entity_info.ghost_entities diff --git a/flow360/component/simulation/framework/base_model_config.py b/flow360/component/simulation/framework/base_model_config.py index 6f73b1682..367233fa9 100644 --- a/flow360/component/simulation/framework/base_model_config.py +++ b/flow360/component/simulation/framework/base_model_config.py @@ -1,46 +1,5 @@ -""" -Sharing the config for base models to reduce unnecessary inheritance from Flow360BaseModel. -""" +"""Base model config now reuses flow360-schema definitions.""" -import pydantic as pd +from flow360_schema.framework.base_model_config import base_model_config, snake_to_camel - -def snake_to_camel(string: str) -> str: - """ - Convert a snake_case string to camelCase. - - This function takes a snake_case string as input and converts it to camelCase. - It splits the input string by underscores, capitalizes the first letter of - each subsequent component (after the first one), and joins them together. - - Parameters: - string (str): The input string in snake_case format. - - Returns: - str: The converted string in camelCase format. - - Example: - >>> snake_to_camel("example_snake_case") - 'exampleSnakeCase' - """ - components = string.split("_") - - camel_case_string = components[0] - - for component in components[1:]: - camel_case_string += component[0].upper() + component[1:] - - return camel_case_string - - -base_model_config = pd.ConfigDict( - arbitrary_types_allowed=True, - extra="forbid", - frozen=False, - populate_by_name=True, - validate_assignment=True, - validate_default=True, - alias_generator=pd.AliasGenerator( - serialization_alias=snake_to_camel, - ), -) +__all__ = ["base_model_config", "snake_to_camel"] diff --git a/flow360/component/simulation/framework/entity_expansion_config.py b/flow360/component/simulation/framework/entity_expansion_config.py index f622e17dc..badafa0f1 100644 --- a/flow360/component/simulation/framework/entity_expansion_config.py +++ b/flow360/component/simulation/framework/entity_expansion_config.py @@ -1,68 +1,15 @@ -"""Entity type expansion configuration for selectors. - -This module defines mappings from selector target classes to the actual entity type names -they should match when expanding selectors. This allows a single SurfaceSelector to match -multiple surface-related entity types (Surface, MirroredSurface, GhostSurface, etc.). -""" - -from typing import Dict, List - -# Type alias for expansion map -TargetClassExpansionMap = Dict[str, List[str]] - -# Default expansion mapping for normal simulation context -DEFAULT_TARGET_CLASS_EXPANSION_MAP: TargetClassExpansionMap = { - "Surface": [ - "Surface", - "MirroredSurface", - # * The following types are commented out because it is easier to add more types (which does not - # * even trigger compatibility issue since they overwrite the stored_entities anyway) than add then remove. - # * We can add with front end (webUI) later if requested. - # "GhostSurface", - # "WindTunnelGhostSurface", - # "GhostSphere", - # "GhostCircularPlane", - # Note: ImportedSurface is excluded - it's only used for post-processing - ], - "Edge": ["Edge"], - "GenericVolume": ["GenericVolume"], - "GeometryBodyGroup": [ - "GeometryBodyGroup", - "MirroredGeometryBodyGroup", - ], -} - -# Mirror context expansion mapping - excludes already-mirrored entity types -# to prevent circular references when performing mirror operations -MIRROR_CONTEXT_EXPANSION_MAP: TargetClassExpansionMap = { - "Surface": [ - "Surface", - "GhostSurface", - "WindTunnelGhostSurface", - "GhostSphere", - "GhostCircularPlane", - # Note: MirroredSurface is excluded to prevent mirroring already-mirrored entities - ], - "Edge": ["Edge"], - "GenericVolume": ["GenericVolume"], - "GeometryBodyGroup": [ - "GeometryBodyGroup", - # Note: MirroredGeometryBodyGroup is excluded - ], -} - - -def get_expansion_map(context: str = "default") -> TargetClassExpansionMap: - """Get the appropriate expansion map for the specified context. - - Parameters: - context: The context for expansion. Options: - - "default": Normal simulation context (DEFAULT_TARGET_CLASS_EXPANSION_MAP) - - "mirror": Mirror operation context (MIRROR_CONTEXT_EXPANSION_MAP) - - Returns: - The expansion map for the specified context. - """ - if context == "mirror": - return MIRROR_CONTEXT_EXPANSION_MAP - return DEFAULT_TARGET_CLASS_EXPANSION_MAP +"""Compatibility relay for selector entity expansion configuration.""" + +from flow360_schema.framework.entity.entity_expansion_config import ( + DEFAULT_TARGET_CLASS_EXPANSION_MAP, + MIRROR_CONTEXT_EXPANSION_MAP, + TargetClassExpansionMap, + get_expansion_map, +) + +__all__ = [ + "DEFAULT_TARGET_CLASS_EXPANSION_MAP", + "MIRROR_CONTEXT_EXPANSION_MAP", + "TargetClassExpansionMap", + "get_expansion_map", +] diff --git a/flow360/component/simulation/framework/entity_expansion_utils.py b/flow360/component/simulation/framework/entity_expansion_utils.py index 34d3675b8..cd3fa0bf7 100644 --- a/flow360/component/simulation/framework/entity_expansion_utils.py +++ b/flow360/component/simulation/framework/entity_expansion_utils.py @@ -5,18 +5,15 @@ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union -import pydantic as pd from flow360_schema.framework.entity.entity_expansion_utils import ( # noqa: F401 _register_mirror_entities_in_registry, + expand_all_entity_lists_with_registry_in_place, + expand_entity_list_with_registry, + get_entity_info_and_registry_from_asset_cache, get_entity_info_and_registry_from_dict, + get_registry_from_asset_cache, ) -from flow360.component.simulation.framework.entity_materializer import ( - materialize_entities_and_selectors_in_place, -) -from flow360.component.simulation.framework.entity_utils import ( - walk_object_tree_with_cycle_detection, -) from flow360.exceptions import Flow360ValueError if TYPE_CHECKING: @@ -47,54 +44,17 @@ def expand_entity_list_in_context( List of EntityBase objects or their names depending on `return_names`. """ - stored_entities = list(getattr(entity_list, "stored_entities", []) or []) + asset_cache = getattr(params, "private_attribute_asset_cache", None) selectors = list(getattr(entity_list, "selectors", []) or []) - - if selectors: - asset_cache = getattr(params, "private_attribute_asset_cache", None) - if asset_cache is None: - raise Flow360ValueError( - "The given `params` does not contain any info on usable entities." - ) - - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.framework.entity_selector import ( - expand_entity_list_selectors, - ) - - registry = get_registry_from_params(params) - stored_entities = expand_entity_list_selectors( - registry, - entity_list, - selector_cache={}, - merge_mode="merge", - ) - - if not stored_entities: - return [] - - if not all(hasattr(entity, "name") for entity in stored_entities): - wrapper = {"stored_entities": stored_entities} - materialize_entities_and_selectors_in_place(wrapper) - stored_entities = wrapper.get("stored_entities", []) - - # Trigger field validator to filter invalid entity types - # This ensures consistency with the centralized filtering architecture - if stored_entities: - try: - # Use deserialize to trigger field validator which filters by type - validated_list = entity_list.__class__.deserialize({"stored_entities": stored_entities}) - stored_entities = validated_list.stored_entities - except pd.ValidationError as exc: - raise Flow360ValueError( - "Failed to find any valid entities in the input. " - "Has the simulationParams been manually edited since loading from the cloud " - "or have you changed the cloud resource for which the SimulationParams is being used?" - ) from exc - - if return_names: - return [entity.name for entity in stored_entities] - return stored_entities + if selectors and asset_cache is None: + raise Flow360ValueError("The given `params` does not contain any info on usable entities.") + + registry = get_registry_from_asset_cache(asset_cache) if selectors else None + return expand_entity_list_with_registry( + entity_list, + registry, + return_names=return_names, + ) def get_registry_from_params(params) -> EntityRegistry: @@ -111,9 +71,6 @@ def get_registry_from_params(params) -> EntityRegistry: EntityRegistry Registry containing all entities from the params. """ - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.framework.entity_registry import EntityRegistry - if params is None: raise ValueError("[Internal] SimulationParams is required to build entity registry.") @@ -123,18 +80,7 @@ def get_registry_from_params(params) -> EntityRegistry: "[Internal] SimulationParams.private_attribute_asset_cache is required to build entity registry." ) - entity_info = getattr(asset_cache, "project_entity_info", None) - if entity_info is None: - raise ValueError("[Internal] SimulationParams is missing project_entity_info.") - - registry = EntityRegistry.from_entity_info(entity_info) - - # Register mirror entities from mirror_status so selector expansion can include mirrored types - # (e.g. SurfaceSelector can expand to include MirroredSurface). - mirror_status = getattr(asset_cache, "mirror_status", None) - _register_mirror_entities_in_registry(registry, mirror_status) - - return registry + return get_registry_from_asset_cache(asset_cache) def expand_all_entity_lists_in_place( @@ -151,32 +97,28 @@ def expand_all_entity_lists_in_place( Parameters: expansion_map: Optional type expansion mapping for selectors. """ - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.framework.entity_base import EntityList - from flow360.component.simulation.framework.entity_selector import ( - expand_entity_list_selectors_in_place, - ) - asset_cache = getattr(params, "private_attribute_asset_cache", None) entity_info = getattr(asset_cache, "project_entity_info", None) if asset_cache is None or entity_info is None: # Unit tests may not provide entity_info; in that case selector expansion is not possible. return - registry = get_registry_from_params(params) - selector_cache: dict = {} - - def _process_entity_list(obj): - """Process EntityList objects by expanding their selectors.""" - if isinstance(obj, EntityList): - expand_entity_list_selectors_in_place( - registry, - obj, - selector_cache=selector_cache, - merge_mode=merge_mode, - expansion_map=expansion_map, - ) - return False # Don't traverse into EntityList internals - return True # Continue traversing other objects - - walk_object_tree_with_cycle_detection(params, _process_entity_list, check_dict=True) + expand_all_entity_lists_with_registry_in_place( + params, + registry=get_registry_from_asset_cache(asset_cache), + merge_mode=merge_mode, + expansion_map=expansion_map, + ) + + +__all__ = [ + "_register_mirror_entities_in_registry", + "expand_all_entity_lists_in_place", + "expand_all_entity_lists_with_registry_in_place", + "expand_entity_list_in_context", + "expand_entity_list_with_registry", + "get_entity_info_and_registry_from_asset_cache", + "get_entity_info_and_registry_from_dict", + "get_registry_from_asset_cache", + "get_registry_from_params", +] diff --git a/flow360/component/simulation/framework/entity_materializer.py b/flow360/component/simulation/framework/entity_materializer.py index 2970a8a3f..c14fc9695 100644 --- a/flow360/component/simulation/framework/entity_materializer.py +++ b/flow360/component/simulation/framework/entity_materializer.py @@ -1,100 +1,8 @@ -"""Re-import relay: entity_materializer moved to flow360_schema. +"""Compatibility relay for entity materialization utilities.""" -This module retains ENTITY_TYPE_MAP and _build_entity_instance because they -depend on concrete entity classes that live in the client package. -materialize_entities_and_selectors_in_place is re-exported with a default -entity_builder so that existing callers need no changes. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Optional - -import pydantic as pd from flow360_schema.framework.entity.entity_materializer import ( - materialize_entities_and_selectors_in_place as _materialize_entities_and_selectors_in_place, -) -from flow360_schema.framework.entity.entity_utils import DEFAULT_NOT_MERGED_TYPES -from flow360_schema.framework.validation.context import DeserializationContext -from flow360_schema.models.entities import MirrorPlane - -from flow360.component.simulation.outputs.output_entities import ( - Point, - PointArray, - PointArray2D, - Slice, -) -from flow360.component.simulation.primitives import ( - AxisymmetricBody, - Box, - CustomVolume, - Cylinder, - Edge, - GenericVolume, - GeometryBodyGroup, - GhostCircularPlane, - GhostSphere, - GhostSurface, - ImportedSurface, - MirroredGeometryBodyGroup, - MirroredSurface, - SeedpointVolume, - SnappyBody, - Sphere, - Surface, - WindTunnelGhostSurface, + ENTITY_TYPE_MAP, + materialize_entities_and_selectors_in_place, ) -if TYPE_CHECKING: - from flow360.component.simulation.framework.entity_registry import EntityRegistry - -ENTITY_TYPE_MAP = { - "Surface": Surface, - "Edge": Edge, - "GenericVolume": GenericVolume, - "GeometryBodyGroup": GeometryBodyGroup, - "CustomVolume": CustomVolume, - "AxisymmetricBody": AxisymmetricBody, - "Box": Box, - "Cylinder": Cylinder, - "Sphere": Sphere, - "ImportedSurface": ImportedSurface, - "GhostSurface": GhostSurface, - "GhostSphere": GhostSphere, - "GhostCircularPlane": GhostCircularPlane, - "Point": Point, - "PointArray": PointArray, - "PointArray2D": PointArray2D, - "Slice": Slice, - "SeedpointVolume": SeedpointVolume, - "SnappyBody": SnappyBody, - "WindTunnelGhostSurface": WindTunnelGhostSurface, - "MirroredSurface": MirroredSurface, - "MirroredGeometryBodyGroup": MirroredGeometryBodyGroup, - "MirrorPlane": MirrorPlane, -} - - -def _build_entity_instance(entity_dict: dict): - """Construct a concrete entity instance from a dictionary via TypeAdapter.""" - type_name = entity_dict.get("private_attribute_entity_type_name") - cls = ENTITY_TYPE_MAP.get(type_name) - if cls is None: - raise ValueError(f"[Internal] Unknown entity type: {type_name}") - with DeserializationContext(): - return pd.TypeAdapter(cls).validate_python(entity_dict) - - -def materialize_entities_and_selectors_in_place( - params_as_dict: dict, - *, - not_merged_types: set[str] = DEFAULT_NOT_MERGED_TYPES, - entity_registry: Optional[EntityRegistry] = None, -) -> dict: - """Wrapper that injects the default entity builder for the client package.""" - return _materialize_entities_and_selectors_in_place( - params_as_dict, - entity_builder=_build_entity_instance, - not_merged_types=not_merged_types, - entity_registry=entity_registry, - ) +__all__ = ["ENTITY_TYPE_MAP", "materialize_entities_and_selectors_in_place"] diff --git a/flow360/component/simulation/framework/expressions.py b/flow360/component/simulation/framework/expressions.py index a86859503..458a4685f 100644 --- a/flow360/component/simulation/framework/expressions.py +++ b/flow360/component/simulation/framework/expressions.py @@ -1,117 +1,7 @@ -"""String expression type for simulation framework.""" +"""Expression helpers now live in flow360-schema.""" -import ast - -from pydantic.functional_validators import AfterValidator -from typing_extensions import Annotated - -from flow360.component.utils import process_expressions - -# pylint: disable=fixme -# TODO: Add units to expression? -# TODO: Add variable existence check? -StringExpression = Annotated[str, AfterValidator(process_expressions)] - - -def validate_angle_expression_of_t_seconds(expr: str): - """ - [TEMPORARY SOLUTION TO ENABLE DIMENSIONED EXPRESSION FOR ROTATION] - Validate that: - 1. The only allowed names in the expression are those in ALLOWED_NAMES. - 2. Every occurrence of 't_seconds' is used only in a multiplicative context. - Allowed forms: - - as part of a multiplication (e.g. 0.58*t_seconds), - - or as a standalone with a unary minus (i.e. -t_seconds) provided that - this unary minus is not immediately part of an addition/subtraction. - This check ensure that the later _preprocess() results in correct non-dimensioned expression - - E.g. - "2*sin(1*t_seconds+2)" - = 2rad*sin(1/s*t_seconds+2rad) - = 2*sin(1/s*(t*seconds_per_flow360_time)+2rad) (After calling _preprocess()) - (v) = "2*sin(1*(t*seconds_per_flow360_time)+2)" (seen by solver) - - whereas - - "2*sin(1*(t_seconds+2))" - = 2rad*sin(1/s*(t_seconds+2second)) - != 2*sin(1/s*((t*seconds_per_flow360_time)+2second)) (After calling _preprocess()) - (x) = "2*sin(1*((t*seconds_per_flow360_time)+2))" (seen by solver) - - Returns a list of error messages (empty if valid). - """ - ALLOWED_NAMES = { # pylint:disable=invalid-name - "t_seconds", - "t", - "pi", - "sin", - "cos", - "tan", - "atan", - "min", - "max", - "pow", - "powf", - "log", - "exp", - "sqrt", - "abs", - "ceil", - "floor", - } - - errors = [] - - try: - tree = ast.parse(expr, mode="eval") - except SyntaxError as e: - return [f"Syntax error in expression `{e.text}`: {e.msg}."] - - def visit(node, ancestors=None): - if ancestors is None: - ancestors = [] - # Check for Name nodes: allow only ALLOWED_NAMES. - if isinstance(node, ast.Name): - if node.id not in ALLOWED_NAMES: - errors.append(f"Unexpected variable `{node.id}` found.") - # If the name is t_seconds, check its context. - if node.id == "t_seconds": - if not is_valid_t_seconds_usage(ancestors): - errors.append( - "t_seconds must be used as a multiplicative factor, " - "not directly added/subtracted with a number." - ) - # Recurse into children while keeping track of ancestors. - for child in ast.iter_child_nodes(node): - visit(child, ancestors + [node]) - - def is_valid_t_seconds_usage(ancestors): - """ - Check the usage of t_seconds based on its ancestors. - We consider the immediate parent (and grandparent if needed) to decide. - - Allowed if: - - Immediate parent is a BinOp with operator Mult. - - Immediate parent is a UnaryOp with USub and its own parent is not a BinOp with Add or Sub. - (This allows a standalone -t_seconds or as the argument of a function.) - """ - if not ancestors: - # t_seconds is at the top-level; not allowed because it isn't scaled by multiplication. - return False - parent = ancestors[-1] - if isinstance(parent, ast.BinOp) and isinstance(parent.op, ast.Mult): - return True - if isinstance(parent, ast.UnaryOp) and isinstance(parent.op, ast.USub): - # Check the grandparent if it exists. - if len(ancestors) >= 2: - grandparent = ancestors[-2] - if isinstance(grandparent, ast.BinOp) and isinstance( - grandparent.op, (ast.Add, ast.Sub) - ): - return False - # Otherwise, standalone -t_seconds or inside a function call is acceptable. - return True - return False - - visit(tree) - return errors +# pylint: disable=unused-import +from flow360_schema.framework.expression import ( # noqa: F401 + StringExpression, + validate_angle_expression_of_t_seconds, +) diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index e2f0fd221..c557ea3b4 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -1,184 +1,21 @@ -"""pre processing and post processing utilities for simulation parameters.""" - -# pylint: disable=no-member - -from typing import Union - -from flow360_schema.models.asset_cache import AssetCache - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityBase, EntityList -from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.unique_list import UniqueStringList -from flow360.component.simulation.primitives import ( - _SurfaceEntityBase, - _VolumeEntityBase, +"""Compatibility relay for simulation parameter utilities.""" + +from flow360_schema.framework.param_utils import ( + AssetCache, + _set_boundary_full_name_with_zone_name, + _update_entity_full_name, + _update_zone_boundaries_with_metadata, + find_instances, + register_entity_list, + serialize_model_obj_to_id, ) - -def find_instances(obj, target_type): - """Recursively find items of target_type within a python object""" - stack = [obj] - seen_ids = set() - results = set() - - while stack: - current = stack.pop() - - obj_id = id(current) - if obj_id in seen_ids: - continue - seen_ids.add(obj_id) - - if isinstance(current, target_type): - results.add(current) - - if isinstance(current, dict): - stack.extend(current.keys()) - stack.extend(current.values()) - - elif isinstance(current, (list, tuple, set, frozenset)): - stack.extend(current) - - elif hasattr(current, "__dict__"): - stack.extend(vars(current).values()) - - elif hasattr(current, "__iter__") and not isinstance(current, (str, bytes)): - try: - stack.extend(iter(current)) - except Exception: # pylint: disable=broad-exception-caught - pass # skip problematic iterables - - return list(results) - - -def register_entity_list(model: Flow360BaseModel, registry: EntityRegistry) -> None: - """ - Registers entities used/occurred in a Flow360BaseModel instance to an EntityRegistry. - - This function iterates through the attributes of the given model. If an attribute is an - EntityList, it retrieves the expanded entities and registers each entity in the registry. - If an attribute is a list and contains instances of Flow360BaseModel, it recursively - registers the entities within those instances. - - Args: - model (Flow360BaseModel): The model containing entities to be registered. - registry (EntityRegistry): The registry where entities will be registered. - - Returns: - None - """ - known_frozen_hashes = set() - for field in model.__dict__.values(): - if isinstance(field, EntityBase): - known_frozen_hashes = registry.fast_register(field, known_frozen_hashes) - - if isinstance(field, EntityList): - for entity in field.stored_entities if field.stored_entities else []: - known_frozen_hashes = registry.fast_register(entity, known_frozen_hashes) - - elif isinstance(field, (list, tuple)): - for item in field: - if isinstance(item, Flow360BaseModel): - register_entity_list(item, registry) - - elif isinstance(field, Flow360BaseModel): - register_entity_list(field, registry) - - -# pylint: disable=too-many-branches -def _update_entity_full_name( - model: Flow360BaseModel, - target_entity_type: Union[type[_SurfaceEntityBase], type[_VolumeEntityBase]], - volume_mesh_meta_data: dict, -): - """ - Update Surface/Boundary with zone name from volume mesh metadata. - """ - for field in model.__dict__.values(): - # Skip the AssetCache since updating there makes no difference - if isinstance(field, AssetCache): - continue - - if isinstance(field, target_entity_type): - # pylint: disable=protected-access - field._update_entity_info_with_metadata(volume_mesh_meta_data) - - if isinstance(field, EntityList): - added_entities = [] - for entity in field.stored_entities: - if isinstance(entity, target_entity_type): - # pylint: disable=protected-access - partial_additions = entity._update_entity_info_with_metadata( - volume_mesh_meta_data - ) - if partial_additions is not None: - added_entities.extend(partial_additions) - field.stored_entities.extend(added_entities) - - elif isinstance(field, (list, tuple)): - added_entities = [] - for item in field: - if isinstance(item, target_entity_type): - partial_additions = ( - item._update_entity_info_with_metadata( # pylint: disable=protected-access - volume_mesh_meta_data - ) - ) - if partial_additions is not None: - added_entities.extend(partial_additions) - elif isinstance(item, Flow360BaseModel): - _update_entity_full_name(item, target_entity_type, volume_mesh_meta_data) - - if isinstance(field, list): - field.extend(added_entities) - if isinstance(field, tuple): - field += tuple(added_entities) - - elif isinstance(field, Flow360BaseModel): - _update_entity_full_name(field, target_entity_type, volume_mesh_meta_data) - - -def _update_zone_boundaries_with_metadata( - registry: EntityRegistry, volume_mesh_meta_data: dict -) -> None: - """Update zone boundaries with volume mesh metadata.""" - for volume_entity in [ - # pylint: disable=protected-access - entity - for view in registry.view_subclasses(_VolumeEntityBase) - for entity in view._entities - ]: - if volume_entity.name in volume_mesh_meta_data["zones"]: - volume_entity._force_set_attr( # pylint:disable=protected-access - "private_attribute_zone_boundary_names", - UniqueStringList( - items=volume_mesh_meta_data["zones"][volume_entity.name]["boundaryNames"] - ), - ) - - -def _set_boundary_full_name_with_zone_name( - registry: EntityRegistry, naming_pattern: str, give_zone_name: str -) -> None: - """Set the full name of surfaces that does not have full name specified.""" - if registry.find_by_naming_pattern(naming_pattern): - for surface in registry.find_by_naming_pattern(naming_pattern): - if surface.private_attribute_full_name is not None: - # This indicates that full name has been set by mesh metadata because that and this are the - # only two places we set the full name. - # mesh meta data takes precedence as it is the most reliable source. - # Note: Currently automated farfield assumes zone name to be "fluid" but the other mesher has "1". - # Note: We need to figure out how to handle this. Otherwise this may result in wrong - # Note: zone name getting prepended. - continue - surface._force_set_attr( # pylint:disable=protected-access - "private_attribute_full_name", f"{give_zone_name}/{surface.name}" - ) - - -def serialize_model_obj_to_id(model_obj: Flow360BaseModel) -> str: - """Serialize a model object to its id.""" - if hasattr(model_obj, "private_attribute_id"): - return model_obj.private_attribute_id - raise ValueError(f"The model object {model_obj} cannot be serialized to id.") +__all__ = [ + "AssetCache", + "_set_boundary_full_name_with_zone_name", + "_update_entity_full_name", + "_update_zone_boundaries_with_metadata", + "find_instances", + "register_entity_list", + "serialize_model_obj_to_id", +] diff --git a/flow360/component/simulation/framework/single_attribute_base.py b/flow360/component/simulation/framework/single_attribute_base.py index e9670a9d3..fc2118b76 100644 --- a/flow360/component/simulation/framework/single_attribute_base.py +++ b/flow360/component/simulation/framework/single_attribute_base.py @@ -1,20 +1,6 @@ -"""Single attribute base model.""" +"""SingleAttributeModel now lives in flow360-schema.""" -import abc -from typing import Any - -import pydantic as pd - -from flow360.component.simulation.framework.base_model import Flow360BaseModel - - -class SingleAttributeModel(Flow360BaseModel, metaclass=abc.ABCMeta): - """Base class for single attribute models.""" - - value: Any = pd.Field() - - # pylint: disable=unused-argument - def __init__(self, value: Any = None, type_name=None): - if value is None: - raise ValueError(f"Value must be provided for {self.__class__.__name__}.") - super().__init__(value=value) +# pylint: disable=unused-import +from flow360_schema.framework.single_attribute_base import ( # noqa: F401 + SingleAttributeModel, +) diff --git a/flow360/component/simulation/services_utils.py b/flow360/component/simulation/services_utils.py index f5816f975..a53ecacea 100644 --- a/flow360/component/simulation/services_utils.py +++ b/flow360/component/simulation/services_utils.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any from flow360.component.simulation.framework.entity_expansion_utils import ( - get_registry_from_params, + get_registry_from_asset_cache, ) from flow360.component.simulation.framework.entity_selector import _process_selectors from flow360.component.simulation.framework.entity_utils import ( @@ -74,7 +74,7 @@ def strip_selector_matches_and_broken_entities_inplace(params) -> Any: return params selector_cache: dict = {} - registry = get_registry_from_params(params) + registry = get_registry_from_asset_cache(asset_cache) valid_mirrored_registry_keys = { (entity.private_attribute_entity_type_name, entity.private_attribute_id) diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index ac1bfc0c8..b8232e2fe 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -1,190 +1,29 @@ -"""Client-only adapters for the expression system. - -Core types (Variable, UserVariable, SolverVariable, Expression, VariableContextInfo) live in -flow360-schema. This file provides ValueOrExpression (client subclass) and adapter functions -that depend on client-specific state (unit_system_manager, deprecation_reminder, params). -""" +"""Client adapters for expression types and params-dependent helpers.""" from __future__ import annotations -from numbers import Number -from typing import Annotated, Any, ClassVar, Generic, List, TypeVar, Union - -import numpy as np -import pydantic as pd import unyt as u -from flow360_schema import StrictUnitContext -from flow360_schema.framework.expression.utils import is_runtime_expression -from flow360_schema.framework.expression.value_or_expression import ( +from flow360_schema.framework.expression import ( SerializedValueOrExpression, + UnytQuantity, + UserVariable, + ValueOrExpression, +) +from flow360_schema.framework.expression.value_or_expression import ( register_deprecation_check, ) from flow360_schema.framework.expression.variable import ( - Expression, - UserVariable, - Variable, - _check_list_items_are_same_dimensions, + batch_get_user_variable_units as _schema_batch_get_user_variable_units, +) +from flow360_schema.framework.expression.variable import ( + save_user_variables as _schema_save_user_variables, ) -from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag -from typing_extensions import Self -from unyt import unyt_array, unyt_quantity from flow360.component.simulation.framework.updater_utils import deprecation_reminder -from flow360.component.simulation.unit_system import unit_system_manager register_deprecation_check(deprecation_reminder) -T = TypeVar("T") - - -# TODO(migration): Migrate to schema once deprecation_reminder is migrated. -class ValueOrExpression(Expression, Generic[T]): - """Model accepting both value and expressions""" - - _cfg: ClassVar[dict] = {} - - @classmethod - def configure(cls, **flags): - """ - Create a new subclass with the given flags. - """ - name = f"{cls.__name__}[{','.join(f'{k}={v}' for k,v in flags.items())}]" - return type(name, (cls,), {"_cfg": {**cls._cfg, **flags}}) - - def __class_getitem__(cls, typevar_values): # pylint:disable=too-many-statements - cfg = cls._cfg - # By default all value or expression should be able to be evaluated at compile-time - allow_run_time_expression = bool(cfg.get("allow_run_time_expression", False)) - - def _internal_validator(value: Expression): - try: - # Symbolically validate - value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) - # Numerically validate - result = value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - except Exception as err: - raise ValueError(f"expression evaluation failed: {err}") from err - - # Detect run-time expressions - if allow_run_time_expression is False: - if is_runtime_expression(result): - raise ValueError( - "Run-time expression is not allowed in this field. " - "Please ensure this field does not depend on any control or solver variables." - ) - # Suspend unit system for legacy types; strict mode rejects bare numbers for new composed types - with unit_system_manager.suspended(), StrictUnitContext(): - pd.TypeAdapter(typevar_values).validate_python( - result, context={"allow_inf_nan": allow_run_time_expression} - ) - return value - - expr_type = Annotated[Expression, pd.AfterValidator(_internal_validator)] - - def _deserialize(value) -> Self: - # Try to see if the value is already a SerializedValueOrExpression - try: - value = SerializedValueOrExpression.model_validate(value) - except Exception: # pylint:disable=broad-exception-caught - pass - if isinstance(value, SerializedValueOrExpression): - if value.type_name == "number": - if value.units is not None: - # unyt objects - return unyt_array(value.value, value.units, dtype=np.float64) - return value.value - if value.type_name == "expression": - if value.expression is None: - raise ValueError("No expression found in the input") - # Validate via Pydantic so that Expression validators and AfterValidator both run - return pd.TypeAdapter(expr_type).validate_python( - {"expression": value.expression, "output_units": value.output_units} - ) - @deprecation_reminder("26.2.0") - def _handle_legacy_unyt_values(value): - """Handle {"units":..., "value":...} from legacy input. This is much easier than writing the updater.""" - if isinstance(value, dict) and "units" in value and "value" in value: - return unyt_array(value["value"], value["units"], dtype=np.float64), True - return value, False - - value, is_legacy_unyt_value = _handle_legacy_unyt_values(value) - if is_legacy_unyt_value: - return value - - # Handle list of unyt_quantities: - if isinstance(value, list): - # Only checking when list[unyt_quantity] - if len(value) == 0: - raise ValueError("Empty list is not allowed.") - _check_list_items_are_same_dimensions(value) - if all(isinstance(item, (unyt_quantity, Number)) for item in value): - # try limiting the number of types we need to handle - return unyt_array(value, dtype=np.float64) - return value - - def _serializer(value, info) -> dict: - if isinstance(value, Expression): - serialized = SerializedValueOrExpression( - type_name="expression", - output_units=value.output_units, - ) - - serialized.expression = value.expression - - evaluated = value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - - if isinstance(evaluated, list): - # May result from Expression which is actually a list of expressions - try: - evaluated = u.unyt_array(evaluated, dtype=np.float64) - except u.exceptions.IterableUnitCoercionError: - # Inconsistent units for components of list - pass - else: - serialized = SerializedValueOrExpression(type_name="number") - # Note: NaN handling should be unnecessary since it would - # have end up being expression first so not reaching here. - if isinstance(value, (Number, List)): - serialized.value = value - elif isinstance(value, unyt_array): - if value.size == 1: - serialized.value = float(value.value) - else: - serialized.value = tuple(value.value.tolist()) - - serialized.units = str(value.units.expr) - - return serialized.model_dump(**info.__dict__) - - def _discriminator(v: Any) -> str: - # Note: This is ran after deserializer - # Use schema base classes for isinstance checks so that both schema and client - # instances are recognized (client subclass instances also pass). - if isinstance(v, SerializedValueOrExpression): - return v.type_name - if isinstance(v, dict): - return v.get("typeName") if v.get("typeName") else v.get("type_name") - if isinstance(v, (Expression, Variable, str)): - return "expression" - if isinstance(v, list) and all(isinstance(item, Expression) for item in v): - return "expression" - if isinstance(v, (Number, unyt_array, list)): - return "number" - raise KeyError("Unknown expression input type: ", v, v.__class__.__name__) - - union_type = Annotated[ - Union[ - Annotated[expr_type, Tag("expression")], Annotated[typevar_values, Tag("number")] - ], - pd.Field(discriminator=Discriminator(_discriminator)), - BeforeValidator(_deserialize), - PlainSerializer(_serializer), - ] - return union_type - - -# TODO(migration): Migrate to schema once params.outputs structure is available in schema. def get_post_processing_variables(params) -> set[str]: """ Get all the post processing related variables from the simulation params. @@ -195,26 +34,16 @@ def get_post_processing_variables(params) -> set[str]: for isosurface in item.entities.items: if isinstance(isosurface.field, UserVariable): post_processing_variables.add(isosurface.field.name) - if not "output_fields" in item.__class__.model_fields: + if "output_fields" not in item.__class__.model_fields: continue - for item in item.output_fields.items: - if isinstance(item, UserVariable): - post_processing_variables.add(item.name) + for output_field in item.output_fields.items: + if isinstance(output_field, UserVariable): + post_processing_variables.add(output_field.name) return post_processing_variables -# TODO(migration): Migrate to schema once get_post_processing_variables and -# params.private_attribute_asset_cache are available in schema. def save_user_variables(params): """Client adapter: extract data from params, delegate to schema.""" - # pylint:disable = import-outside-toplevel - from flow360_schema.framework.expression.variable import ( - batch_get_user_variable_units as _schema_batch_get_user_variable_units, - ) - from flow360_schema.framework.expression.variable import ( - save_user_variables as _schema_save_user_variables, - ) - post_processing_variables = get_post_processing_variables(params) output_units = {} if post_processing_variables: @@ -261,3 +90,14 @@ def infer_units_by_unit_system(value: dict, unit_system: str, value_dimensions): if unit_system == "CGS_unit_system": value["units"] = u.unit_systems.cgs_unit_system[value_dimensions] return value + + +__all__ = [ + "SerializedValueOrExpression", + "UnytQuantity", + "ValueOrExpression", + "get_post_processing_variables", + "infer_units_by_unit_system", + "is_variable_with_unit_system_as_units", + "save_user_variables", +] diff --git a/poetry.lock b/poetry.lock index 8583ddad7..93bb6ffdf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1468,19 +1468,20 @@ files = [ [[package]] name = "flow360-schema" -version = "0.1.19" +version = "0.1.22" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.19-py3-none-any.whl", hash = "sha256:5cf3750337a3b58a672314bd3b0ab63ba8e5e43963f4a0d4b87e6976f72a52eb"}, - {file = "flow360_schema-0.1.19.tar.gz", hash = "sha256:34a40ac032555b9af317e99d877288f3564fbaea0c39340c8a6b811d64482aac"}, + {file = "flow360_schema-0.1.22-py3-none-any.whl", hash = "sha256:b3f84e445a0aa9b973d682f9b0547f6b6e706dacb2bed12efe003c726ec645b5"}, + {file = "flow360_schema-0.1.22.tar.gz", hash = "sha256:73d55b2cfc99c673612decb6f1de5f2b46b8d46a0cf55c32619550cccc858bde"}, ] [package.dependencies] pydantic = ">=2.8,<3.0" unyt = ">=2.9.0" +wcmatch = ">=10,<11" [package.source] type = "legacy" From 04e00af30ef8e076c55ded19b161d55331d6bdc5 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:24:25 -0400 Subject: [PATCH 12/25] schema(): Split draft context managers from schema state (#1983) --- .../coordinate_system_manager.py | 426 ++++-------- .../simulation/draft_context/mirror.py | 652 +++--------------- poetry.lock | 8 +- pyproject.toml | 2 +- .../test_coordinate_system_assignment.py | 28 +- .../draft_context/test_mirror_action.py | 24 +- 6 files changed, 241 insertions(+), 899 deletions(-) diff --git a/flow360/component/simulation/draft_context/coordinate_system_manager.py b/flow360/component/simulation/draft_context/coordinate_system_manager.py index 91cb882a4..2c41a8472 100644 --- a/flow360/component/simulation/draft_context/coordinate_system_manager.py +++ b/flow360/component/simulation/draft_context/coordinate_system_manager.py @@ -2,10 +2,11 @@ from __future__ import annotations -import collections -from typing import Dict, List, Optional, Tuple, Union +from typing import List, Optional, Union -import numpy as np +from flow360_schema.framework.entity.coordinate_system_state import ( + CoordinateSystemState, +) from flow360_schema.models.asset_cache import ( CoordinateSystemAssignmentGroup, CoordinateSystemEntityRef, @@ -13,16 +14,24 @@ CoordinateSystemStatus, ) -from flow360.component.simulation.entity_operation import ( - CoordinateSystem, - _compose_transformation_matrices, -) +from flow360.component.simulation.entity_operation import CoordinateSystem from flow360.component.simulation.framework.entity_base import EntityBase from flow360.component.simulation.framework.entity_registry import EntityRegistry from flow360.component.simulation.utils import is_exact_instance -from flow360.exceptions import Flow360RuntimeError +from flow360.exceptions import Flow360ValueError from flow360.log import log +# pylint: disable=protected-access,unused-import + + +__all__ = [ + "CoordinateSystemAssignmentGroup", + "CoordinateSystemEntityRef", + "CoordinateSystemManager", + "CoordinateSystemParent", + "CoordinateSystemStatus", +] + class CoordinateSystemManager: """ @@ -40,39 +49,47 @@ class CoordinateSystemManager: asset cache. """ - __slots__ = ( - "_coordinate_systems", - "_coordinate_system_parents", - "_entity_key_to_coordinate_system_id", - ) + # Keep the manager as a thin client-facing facade. We intentionally use composition + # instead of inheriting from CoordinateSystemState so the client does not implicitly + # expose every schema-core helper as part of its API surface. + __slots__ = ("_state",) - def __init__(self) -> None: - self._coordinate_systems: list[CoordinateSystem] = [] - self._coordinate_system_parents: dict[str, Optional[str]] = {} - self._entity_key_to_coordinate_system_id: dict[Tuple[str, str], str] = {} + def __init__(self, *, state: Optional[CoordinateSystemState] = None) -> None: + self._state = CoordinateSystemState() if state is None else state @property - def _known_ids(self) -> set[str]: - """Return set of registered coordinate system IDs for O(1) lookups.""" - return {cs.private_attribute_id for cs in self._coordinate_systems} + def _coordinate_systems(self) -> list[CoordinateSystem]: + """Read-only compatibility surface for existing tests and draft internals.""" + return self._state._coordinate_systems - def _contains(self, coordinate_system: CoordinateSystem) -> bool: - return coordinate_system.private_attribute_id in self._known_ids + @property + def _coordinate_system_parents(self) -> dict[str, Optional[str]]: + """Read-only compatibility surface for existing tests and draft internals.""" + return self._state._coordinate_system_parents - def _register_coordinate_system( - self, *, coordinate_system: CoordinateSystem, parent_id: Optional[str] - ) -> None: - """Internal helper to register a coordinate system without graph validation.""" - if self._contains(coordinate_system): - return # Already registered, skip - if any(existing.name == coordinate_system.name for existing in self._coordinate_systems): - raise Flow360RuntimeError( - f"Coordinate system name '{coordinate_system.name}' already registered." - ) - self._coordinate_systems.append(coordinate_system) - self._coordinate_system_parents[coordinate_system.private_attribute_id] = parent_id + @property + def _entity_key_to_coordinate_system_id(self) -> dict[tuple[str, str], str]: + """Read-only compatibility surface for existing tests and draft internals.""" + return self._state._entity_key_to_coordinate_system_id + + def _get_coordinate_system_by_id(self, coordinate_system_id: str) -> Optional[CoordinateSystem]: + return self._state._get_coordinate_system_by_id(coordinate_system_id) + + def _get_coordinate_system_matrix(self, *, coordinate_system: CoordinateSystem): + return self._state._get_coordinate_system_matrix(coordinate_system=coordinate_system) + + def _get_coordinate_system_for_entity(self, *, entity: EntityBase): + return self._state._get_coordinate_system_for_entity(entity=entity) + + def _get_matrix_for_entity(self, *, entity: EntityBase): + return self._state._get_matrix_for_entity(entity=entity) + + def _get_matrix_for_entity_key(self, *, entity_type: str, entity_id: str): + return self._state._get_matrix_for_entity_key(entity_type=entity_type, entity_id=entity_id) + + def _to_status(self) -> CoordinateSystemStatus: + return self._state._to_status() - # region Registration and hierarchy ------------------------------------------------- def add( self, coordinate_system: CoordinateSystem, *, parent: Optional[CoordinateSystem] = None ) -> CoordinateSystem: @@ -94,32 +111,31 @@ def add( Raises ------ - Flow360RuntimeError + Flow360ValueError If `coordinate_system` is not an exact `CoordinateSystem` instance, if the id or name is already registered, or if the resulting parent graph is invalid (e.g. cycle). """ if not is_exact_instance(coordinate_system, CoordinateSystem): - raise Flow360RuntimeError( + raise Flow360ValueError( f"coordinate_system must be a CoordinateSystem. Received: {type(coordinate_system).__name__}." ) cs = coordinate_system - # Auto-register parent as root if not already registered if parent is not None: - self._register_coordinate_system(coordinate_system=parent, parent_id=None) + self._state._register_coordinate_system(coordinate_system=parent, parent_id=None) - if self._contains(cs): - raise Flow360RuntimeError( + if self._state._contains(cs): + raise Flow360ValueError( f"Coordinate system id '{cs.private_attribute_id}' already registered." ) - if any(existing.name == cs.name for existing in self._coordinate_systems): - raise Flow360RuntimeError(f"Coordinate system name '{cs.name}' already registered.") + if any(existing.name == cs.name for existing in self._state._coordinate_systems): + raise Flow360ValueError(f"Coordinate system name '{cs.name}' already registered.") - self._coordinate_systems.append(cs) - self._coordinate_system_parents[cs.private_attribute_id] = ( + self._state._coordinate_systems.append(cs) + self._state._coordinate_system_parents[cs.private_attribute_id] = ( parent.private_attribute_id if parent is not None else None ) - self._validate_coordinate_system_graph() + self._state._validate_coordinate_system_graph() return cs def update_parent( @@ -138,24 +154,26 @@ def update_parent( Raises ------ - Flow360RuntimeError + Flow360ValueError If `coordinate_system` is not registered, or if updating the parent would make the parent graph invalid (e.g. introduce a cycle). In that case, the change is rolled back. """ - if not self._contains(coordinate_system): - raise Flow360RuntimeError("Coordinate system must be part of the draft to be updated.") - # Auto-register parent as root if not already registered + if not self._state._contains(coordinate_system): + raise Flow360ValueError("Coordinate system must be part of the draft to be updated.") + if parent is not None: - self._register_coordinate_system(coordinate_system=parent, parent_id=None) + self._state._register_coordinate_system(coordinate_system=parent, parent_id=None) - cs_id = coordinate_system.private_attribute_id - original_parent = self._coordinate_system_parents.get(cs_id) - self._coordinate_system_parents[cs_id] = parent.private_attribute_id if parent else None + coordinate_system_id = coordinate_system.private_attribute_id + original_parent = self._state._coordinate_system_parents.get(coordinate_system_id) + self._state._coordinate_system_parents[coordinate_system_id] = ( + parent.private_attribute_id if parent else None + ) try: - self._validate_coordinate_system_graph() + self._state._validate_coordinate_system_graph() except Exception: - self._coordinate_system_parents[cs_id] = original_parent + self._state._coordinate_system_parents[coordinate_system_id] = original_parent raise def remove(self, coordinate_system: CoordinateSystem) -> None: @@ -171,143 +189,41 @@ def remove(self, coordinate_system: CoordinateSystem) -> None: Raises ------ - Flow360RuntimeError + Flow360ValueError If the coordinate system is not registered, or if other registered coordinate systems depend on it (i.e. it is a parent). """ - if not self._contains(coordinate_system): - raise Flow360RuntimeError("Coordinate system is not registered in this draft.") + if not self._state._contains(coordinate_system): + raise Flow360ValueError("Coordinate system is not registered in this draft.") - cs_id = coordinate_system.private_attribute_id + coordinate_system_id = coordinate_system.private_attribute_id dependents = [ child_id - for child_id, parent_id in self._coordinate_system_parents.items() - if parent_id == cs_id + for child_id, parent_id in self._state._coordinate_system_parents.items() + if parent_id == coordinate_system_id ] if dependents: names = ", ".join( - cs.name for cs in self._coordinate_systems if cs.private_attribute_id in dependents + existing.name + for existing in self._state._coordinate_systems + if existing.private_attribute_id in dependents ) - raise Flow360RuntimeError( + raise Flow360ValueError( f"Cannot remove coordinate system '{coordinate_system.name}' because dependents exist: {names}" ) - self._coordinate_systems = [ - cs - for cs in self._coordinate_systems - if cs.private_attribute_id != coordinate_system.private_attribute_id + self._state._coordinate_systems = [ + existing + for existing in self._state._coordinate_systems + if existing.private_attribute_id != coordinate_system.private_attribute_id ] - self._coordinate_system_parents.pop(cs_id, None) - # Drop assignments referencing this coordinate system. - self._entity_key_to_coordinate_system_id = { + self._state._coordinate_system_parents.pop(coordinate_system_id, None) + self._state._entity_key_to_coordinate_system_id = { entity_id: assigned_id - for entity_id, assigned_id in self._entity_key_to_coordinate_system_id.items() - if assigned_id != cs_id + for entity_id, assigned_id in self._state._entity_key_to_coordinate_system_id.items() + if assigned_id != coordinate_system_id } - # endregion ------------------------------------------------------------------------------------ - - def _validate_coordinate_system_graph(self) -> None: - """Validate parent references exist and detect cycles using Kahn's algorithm.""" - id_to_cs: Dict[str, CoordinateSystem] = {} - for cs in self._coordinate_systems: - if cs.private_attribute_id in id_to_cs: - raise Flow360RuntimeError( - f"Duplicate coordinate system id '{cs.private_attribute_id}' detected." - ) - if cs.name in (existing.name for existing in id_to_cs.values()): - raise Flow360RuntimeError(f"Coordinate system name '{cs.name}' already registered.") - id_to_cs[cs.private_attribute_id] = cs - - # Validate all parent references exist - for cs_id, cs in id_to_cs.items(): - parent_id = self._coordinate_system_parents.get(cs_id) - if parent_id is not None and parent_id not in id_to_cs: - raise Flow360RuntimeError( - f"Parent coordinate system '{parent_id}' not found for '{cs.name}'." - ) - - # Kahn's algorithm for cycle detection - in_degree = {cs_id: 0 for cs_id in id_to_cs} - for cs_id in id_to_cs: - parent_id = self._coordinate_system_parents.get(cs_id) - if parent_id is not None: - in_degree[cs_id] += 1 - - queue = collections.deque([cs_id for cs_id, degree in in_degree.items() if degree == 0]) - processed = 0 - - while queue: - current = queue.popleft() - processed += 1 - for cs_id, parent_id in self._coordinate_system_parents.items(): - if parent_id == current: - in_degree[cs_id] -= 1 - if in_degree[cs_id] == 0: - queue.append(cs_id) - - if processed != len(id_to_cs): - cycle_nodes = [cs_id for cs_id, degree in in_degree.items() if degree > 0] - raise Flow360RuntimeError( - f"Cycle detected in coordinate system inheritance among: {sorted(cycle_nodes)}" - ) - - def _get_coordinate_system_matrix(self, *, coordinate_system: CoordinateSystem) -> np.ndarray: - """Return the composed matrix for a registered coordinate system (parents applied).""" - if not self._contains(coordinate_system): - raise Flow360RuntimeError("Coordinate system must be registered to compute its matrix.") - - cs_id = coordinate_system.private_attribute_id - combined_matrix = coordinate_system._get_local_matrix() # pylint:disable=protected-access - - # Graph is validated, parent guaranteed to exist and no cycles - parent_id = self._coordinate_system_parents.get(cs_id) - while parent_id is not None: - parent = self._get_coordinate_system_by_id(parent_id) - combined_matrix = _compose_transformation_matrices( - parent=parent._get_local_matrix(), # pylint:disable=protected-access - child=combined_matrix, - ) - parent_id = self._coordinate_system_parents.get(parent_id) - - return combined_matrix - - # -------------------------------------------------------------------- - def get_by_name(self, name: str) -> CoordinateSystem: - """ - Retrieve a registered coordinate system by name. - - Parameters - ---------- - name : str - Coordinate system name. - - Returns - ------- - CoordinateSystem - The matching coordinate system. - - Raises - ------ - Flow360RuntimeError - If no coordinate system with the given name exists in this draft. - """ - for cs in self._coordinate_systems: - if cs.name == name: - return cs - raise Flow360RuntimeError(f"Coordinate system '{name}' not found in the draft.") - - def _get_coordinate_system_by_id(self, cs_id: str) -> Optional[CoordinateSystem]: - for cs in self._coordinate_systems: - if cs.private_attribute_id == cs_id: - return cs - return None - - @staticmethod - def _entity_key(entity: EntityBase) -> tuple[str, Optional[str]]: - return (entity.private_attribute_entity_type_name, entity.private_attribute_id) - - # Assignment ---------------------------------------------------------------- def assign( self, *, @@ -327,7 +243,7 @@ def assign( Raises ------ - Flow360RuntimeError + Flow360ValueError If `coordinate_system` is not an exact `CoordinateSystem` instance or if any item in `entities` is not an entity instance. @@ -337,11 +253,10 @@ def assign( overwritten and a warning is logged. """ if not is_exact_instance(coordinate_system, CoordinateSystem): - raise Flow360RuntimeError( + raise Flow360ValueError( f"`coordinate_system` must be a CoordinateSystem. Received: {type(coordinate_system).__name__}." ) - # Normalize to list for uniform validation. if isinstance(entities, list): normalized_entities = entities else: @@ -349,20 +264,20 @@ def assign( for entity in normalized_entities: if not isinstance(entity, EntityBase): - raise Flow360RuntimeError( + raise Flow360ValueError( f"Only entities can be assigned a coordinate system. Received: {type(entity).__name__}." ) if entity.private_attribute_id is None: - raise Flow360RuntimeError( + raise Flow360ValueError( f"Entity '{entity.name}' ({type(entity).__name__}) is not supported " f"for coordinate system assignment." ) - self._register_coordinate_system(coordinate_system=coordinate_system, parent_id=None) + self._state._register_coordinate_system(coordinate_system=coordinate_system, parent_id=None) for entity in normalized_entities: - entity_key = self._entity_key(entity) - previous = self._entity_key_to_coordinate_system_id.get(entity_key) + entity_key = self._state._entity_key(entity) + previous = self._state._entity_key_to_coordinate_system_id.get(entity_key) if previous is not None and previous != coordinate_system.private_attribute_id: log.warning( "Entity '%s' already had a coordinate system '%s'; overwriting to '%s'.", @@ -370,7 +285,7 @@ def assign( previous, coordinate_system.private_attribute_id, ) - self._entity_key_to_coordinate_system_id[entity_key] = ( + self._state._entity_key_to_coordinate_system_id[entity_key] = ( coordinate_system.private_attribute_id ) @@ -383,72 +298,28 @@ def clear_assignment(self, *, entity: EntityBase) -> None: entity : EntityBase The entity whose coordinate system assignment should be cleared. """ - self._entity_key_to_coordinate_system_id.pop(self._entity_key(entity), None) - - def _get_coordinate_system_for_entity( - self, *, entity: EntityBase - ) -> Optional[CoordinateSystem]: - """Return the coordinate system assigned to the entity, if any.""" - cs_id = self._entity_key_to_coordinate_system_id.get(self._entity_key(entity)) - if cs_id is None: - return None - cs = self._get_coordinate_system_by_id(cs_id) - if cs is None: - raise Flow360RuntimeError( - f"Coordinate system id '{cs_id}' assigned to entity '{entity.name}' is not registered." - ) - return cs + self._state._entity_key_to_coordinate_system_id.pop(self._state._entity_key(entity), None) - def _get_matrix_for_entity(self, *, entity: EntityBase) -> Optional[np.ndarray]: - """Return the composed 3x4 transformation matrix for an entity, if assigned.""" - cs = self._get_coordinate_system_for_entity(entity=entity) - if cs is None: - return None - return self._get_coordinate_system_matrix(coordinate_system=cs) - - def _get_matrix_for_entity_key( - self, *, entity_type: str, entity_id: str - ) -> Optional[np.ndarray]: - """Return the composed 3x4 matrix for an entity reference, if assigned.""" - cs_id = self._entity_key_to_coordinate_system_id.get((entity_type, entity_id)) - if cs_id is None: - return None - cs = self._get_coordinate_system_by_id(cs_id) - if cs is None: - raise Flow360RuntimeError( - f"Coordinate system id '{cs_id}' assigned to entity '{entity_type}:{entity_id}' is not registered." - ) - return self._get_coordinate_system_matrix(coordinate_system=cs) + def get_by_name(self, name: str) -> CoordinateSystem: + """ + Retrieve a registered coordinate system by name. - # Serialization ---------------------------------------------------------------- - def _to_status(self) -> CoordinateSystemStatus: - """Build a serializable status snapshot. + Parameters + ---------- + name : str + Coordinate system name. Returns ------- - CoordinateSystemStatus - The serialized status. - """ - parents = [ - CoordinateSystemParent(coordinate_system_id=cs_id, parent_id=parent_id) - for cs_id, parent_id in self._coordinate_system_parents.items() - ] - - grouped: Dict[str, List[CoordinateSystemEntityRef]] = {} - for (entity_type, entity_id), cs_id in self._entity_key_to_coordinate_system_id.items(): - grouped.setdefault(cs_id, []).append( - CoordinateSystemEntityRef(entity_type=entity_type, entity_id=entity_id) - ) + CoordinateSystem + The matching coordinate system. - assignments = [ - CoordinateSystemAssignmentGroup(coordinate_system_id=cs_id, entities=entities) - for cs_id, entities in grouped.items() - ] - return CoordinateSystemStatus( - coordinate_systems=self._coordinate_systems, - parents=parents, - assignments=assignments, - ) + Raises + ------ + Flow360ValueError + If no coordinate system with the given name exists in this draft. + """ + return self._state.get_by_name(name) @classmethod def _from_status( @@ -457,7 +328,8 @@ def _from_status( status: Optional[CoordinateSystemStatus], entity_registry: Optional[EntityRegistry] = None, ) -> CoordinateSystemManager: - """Restore manager from a status snapshot. + """ + Restore manager from a status snapshot. Parameters ---------- @@ -470,59 +342,9 @@ def _from_status( CoordinateSystemManager The restored manager. """ - mgr = cls() - if status is None: - return mgr - - # Build set of IDs for validation of parent/assignment references. - existing_ids: set[str] = set() - for cs in status.coordinate_systems: - mgr._coordinate_systems.append(cs) - existing_ids.add(cs.private_attribute_id) - - for parent in status.parents: - if parent.coordinate_system_id not in existing_ids: - raise Flow360RuntimeError( - f"Parent record references unknown coordinate system '{parent.coordinate_system_id}'." - ) - if parent.parent_id is not None and parent.parent_id not in existing_ids: - raise Flow360RuntimeError( - f"Parent coordinate system '{parent.parent_id}' not found for '{parent.coordinate_system_id}'." - ) - mgr._coordinate_system_parents[parent.coordinate_system_id] = parent.parent_id - - seen_entity_keys: set[Tuple[str, str]] = set() - for assignment in status.assignments: - if assignment.coordinate_system_id not in existing_ids: - raise Flow360RuntimeError( - f"Assignment references unknown coordinate system '{assignment.coordinate_system_id}'." - ) - for entity in assignment.entities: - key = (entity.entity_type, entity.entity_id) - # Sanitize invalid assignments due to entity not being in scope anymore. - if ( - entity_registry # Fast lane: entity_registry None indicates that no validation is needed - and ( - entity_registry.find_by_type_name_and_id( - entity_type=entity.entity_type, entity_id=entity.entity_id - ) - is None - ) - ): - log.warning( - "Entity '%s:%s' assigned to coordinate system '%s' is not in the draft registry; " - "skipping this coordinate system assignment.", - entity.entity_type, - entity.entity_id, - assignment.coordinate_system_id, - ) - continue - if key in seen_entity_keys: - raise Flow360RuntimeError( - f"Duplicate entity assignment for entity '{entity.entity_type}:{entity.entity_id}'." - ) - seen_entity_keys.add(key) - mgr._entity_key_to_coordinate_system_id[key] = assignment.coordinate_system_id - - mgr._validate_coordinate_system_graph() - return mgr + return cls( + state=CoordinateSystemState._from_status( + status=status, + entity_registry=entity_registry, + ) + ) diff --git a/flow360/component/simulation/draft_context/mirror.py b/flow360/component/simulation/draft_context/mirror.py index 31b76ceb0..8c2f6a936 100644 --- a/flow360/component/simulation/draft_context/mirror.py +++ b/flow360/component/simulation/draft_context/mirror.py @@ -1,9 +1,14 @@ """Mirror plane, mirrored entities and helpers.""" -from typing import Dict, List, Optional, Tuple, Union +from __future__ import annotations +from flow360_schema.framework.entity.mirror_state import ( + MIRROR_SUFFIX, + MirrorState, + _derive_mirrored_entities_from_actions, +) from flow360_schema.models.asset_cache import MirrorStatus -from flow360_schema.models.entities import MirrorPlane +from flow360_schema.models.entities.geometry_entities import MirrorPlane from flow360.component.simulation.framework.entity_registry import ( EntityRegistry, @@ -13,238 +18,20 @@ GeometryBodyGroup, MirroredGeometryBodyGroup, MirroredSurface, - Surface, ) from flow360.component.simulation.utils import is_exact_instance -from flow360.exceptions import Flow360RuntimeError -from flow360.log import log - -MIRROR_SUFFIX = "_" - -# region -----------------------------Internal Functions Below------------------------------------- - - -def _build_mirrored_geometry_groups( - *, - body_group_id_to_mirror_id: Dict[str, str], - body_groups_by_id: Dict[str, GeometryBodyGroup], - mirror_planes_by_id: Dict[str, MirrorPlane], -) -> List[MirroredGeometryBodyGroup]: - """Create mirrored geometry body groups for valid mirror actions.""" - - mirrored_groups: List[MirroredGeometryBodyGroup] = [] - - for body_group_id, mirror_plane_id in body_group_id_to_mirror_id.items(): - body_group = body_groups_by_id.get(body_group_id) - if body_group is None: - log.warning( - "Mirror action references unknown GeometryBodyGroup id '%s'; skipping.", - body_group_id, - ) - continue - - mirror_plane = mirror_planes_by_id.get(mirror_plane_id) - if mirror_plane is None: - log.warning( - "Mirror action references unknown MirrorPlane id '%s'; skipping.", - mirror_plane_id, - ) - continue - - mirrored_groups.append( - MirroredGeometryBodyGroup( - name=f"{body_group.name}{MIRROR_SUFFIX}", - geometry_body_group_id=body_group_id, - mirror_plane_id=mirror_plane_id, - ) - ) - - return mirrored_groups - - -def _build_mirrored_surfaces( - *, - body_group_id_to_mirror_id: Dict[str, str], - face_group_to_body_group: Optional[Dict[str, str]], - surfaces: List[Surface], - mirror_planes_by_id: Dict[str, MirrorPlane], -) -> List[MirroredSurface]: - """Create mirrored surfaces for the requested body groups.""" - - if not body_group_id_to_mirror_id or face_group_to_body_group is None: - return [] - - surfaces_by_name: Dict[str, Surface] = {surface.name: surface for surface in surfaces} - requested_body_group_ids = set(body_group_id_to_mirror_id.keys()) - mirrored_surfaces: List[MirroredSurface] = [] - - for surface_name, owning_body_group_id in face_group_to_body_group.items(): - if owning_body_group_id not in requested_body_group_ids: - continue - - surface = surfaces_by_name.get(surface_name) - if surface is None: - log.warning( - "Surface '%s' referenced in GeometryEntityInfo was not found in draft registry; " - "skipping mirroring for this surface.", - surface_name, - ) - continue - - mirror_plane_id = body_group_id_to_mirror_id.get(owning_body_group_id) - mirror_plane = mirror_planes_by_id.get(mirror_plane_id) - if mirror_plane is None: - log.warning( - "Mirror action references unknown MirrorPlane id '%s' for body group '%s'; " - "skipping mirroring for surface '%s'.", - mirror_plane_id, - owning_body_group_id, - surface_name, - ) - continue - - mirrored_surface = MirroredSurface( - name=f"{surface.name}{MIRROR_SUFFIX}", - surface_id=surface.private_attribute_id, - mirror_plane_id=mirror_plane_id, - ) - # Draft-only bookkeeping: record which body group generated this mirrored surface. - mirrored_surface._geometry_body_group_id = ( # pylint: disable=protected-access - owning_body_group_id - ) - mirrored_surfaces.append(mirrored_surface) - - return mirrored_surfaces - - -def _derive_mirrored_entities_from_actions( - *, - body_group_id_to_mirror_id: Dict[str, str], - face_group_to_body_group: Optional[Dict[str, str]], - entity_registry: EntityRegistry, - mirror_planes: List[MirrorPlane], -) -> Tuple[List[MirroredGeometryBodyGroup], List[MirroredSurface]]: - """ - Derive mirrored entities (MirroredGeometryBodyGroup + MirroredSurface) - based on the given ``body_group_id_to_mirror_id`` mapping. - - The ``body_group_id_to_mirror_id`` schema is:: - - {geometry_body_group_id: mirror_plane_id} - - Parameters - ---------- - body_group_id_to_mirror_id : Dict[str, str] - Mapping from geometry body group ID to mirror plane ID. - face_group_to_body_group : Optional[Dict[str, str]] - Mapping from surface name to owning body group ID. If None, no surfaces will be mirrored. - entity_registry : EntityRegistry - Entity registry containing body groups and surfaces. - mirror_planes : List[MirrorPlane] - List of all mirror planes. - - Returns - ------- - Tuple[List[MirroredGeometryBodyGroup], List[MirroredSurface]] - Mirrored body groups and surfaces. - - This helper is intended to be reusable both from within the draft context - (for incremental updates) and before submission (for generating the full list - of mirrored entities from the stored mirror status). - """ - - if not body_group_id_to_mirror_id: - return [], [] - - # Extract body groups and surfaces from the entity registry. - body_groups = entity_registry.view( # pylint: disable=protected-access - GeometryBodyGroup - )._entities - surfaces = entity_registry.view(Surface)._entities # pylint: disable=protected-access +from flow360.exceptions import Flow360ValueError - # Lookup tables for body groups and mirror planes. - body_groups_by_id: Dict[str, GeometryBodyGroup] = { - body_group.private_attribute_id: body_group for body_group in body_groups - } - mirror_planes_by_id: Dict[str, MirrorPlane] = { - plane.private_attribute_id: plane for plane in mirror_planes - } +# pylint: disable=protected-access - mirrored_geometry_groups = _build_mirrored_geometry_groups( - body_group_id_to_mirror_id=body_group_id_to_mirror_id, - body_groups_by_id=body_groups_by_id, - mirror_planes_by_id=mirror_planes_by_id, - ) - mirrored_surfaces = _build_mirrored_surfaces( - body_group_id_to_mirror_id=body_group_id_to_mirror_id, - face_group_to_body_group=face_group_to_body_group, - surfaces=surfaces, - mirror_planes_by_id=mirror_planes_by_id, - ) - return mirrored_geometry_groups, mirrored_surfaces - - -def _extract_body_group_id_to_mirror_id_from_status( - *, - mirror_status: Optional[MirrorStatus], - valid_body_group_ids: Optional[set[str]], -) -> Dict[str, str]: - """ - Deserialize mirror actions from a :class:`MirrorStatus` instance. - - Parameters - ---------- - mirror_status : MirrorStatus - The mirror status to deserialize. - valid_body_group_ids : Optional[set[str]] - Set of valid body group IDs. If provided, any mirror actions referencing - body groups not in this set will be skipped. - - Returns - ------- - Dict[str, str] - - ``body_group_id_to_mirror_id``: mapping from geometry body group ID to mirror plane ID. - """ - - if mirror_status is None: - # No mirror feature used in the asset. - log.debug("Mirror status not provided; no mirroring actions to restore.") - return {} - - mirror_planes_by_id: Dict[str, MirrorPlane] = { - plane.private_attribute_id: plane for plane in mirror_status.mirror_planes - } - - body_group_id_to_mirror_id: Dict[str, str] = {} - for mirrored_group in mirror_status.mirrored_geometry_body_groups: - body_group_id = mirrored_group.geometry_body_group_id - mirror_plane_id = mirrored_group.mirror_plane_id - - if valid_body_group_ids is not None and body_group_id not in valid_body_group_ids: - # Skip body groups that no longer exist. - log.debug( - "Ignoring mirroring of GeometryBodyGroup (ID:'%s') because it no longer exists.", - body_group_id, - ) - continue - - if mirror_plane_id not in mirror_planes_by_id: - # Skip if the referenced mirror plane is no longer present. - log.debug( - "Ignoring mirroring of GeometryBodyGroup (ID:'%s') because the referenced" - " mirror plane (ID:'%s') no longer exists.", - body_group_id, - mirror_plane_id, - ) - continue - - body_group_id_to_mirror_id[body_group_id] = mirror_plane_id - - return body_group_id_to_mirror_id - - -# endregion ------------------------------------------------------------------------------------- +__all__ = [ + "MIRROR_SUFFIX", + "MirrorManager", + "MirrorPlane", + "MirrorStatus", + "_derive_mirrored_entities_from_actions", +] class MirrorManager: @@ -265,32 +52,29 @@ class MirrorManager: is disabled and mirror operations will raise. """ - __slots__ = ( - # MirrorManager owns the single mirror status instance. This is always validate and up to date. - "_mirror_status", - "_body_group_id_to_mirror_id", - "_face_group_to_body_group", - "_entity_registry", # A link to the full picture. - ) - _mirror_status: MirrorStatus - _body_group_id_to_mirror_id: Dict[str, str] - _face_group_to_body_group: Optional[Dict[str, str]] - _entity_registry: EntityRegistry + # Keep the manager as a thin client-facing facade. We intentionally use composition + # instead of inheriting from MirrorState so the client does not implicitly expose + # every schema-core helper as part of its API surface. + __slots__ = ("_state",) def __init__( self, *, - face_group_to_body_group: Optional[Dict[str, str]], - entity_registry: EntityRegistry, + face_group_to_body_group: dict[str, str] | None = None, + entity_registry: EntityRegistry | None = None, + state: MirrorState | None = None, ) -> None: - self._body_group_id_to_mirror_id = {} - self._face_group_to_body_group = face_group_to_body_group - self._entity_registry = entity_registry - self._mirror_status = MirrorStatus( - mirror_planes=[], mirrored_geometry_body_groups=[], mirrored_surfaces=[] - ) + if state is None: + if entity_registry is None: + raise Flow360ValueError( + "[Internal] MirrorManager requires `entity_registry` when `state` is not provided." + ) + state = MirrorState( + face_group_to_body_group=face_group_to_body_group, + entity_registry=entity_registry, + ) + self._state = state - # region Public API ------------------------------------------------- @property def mirror_planes(self) -> EntityRegistryView: """ @@ -301,14 +85,29 @@ def mirror_planes(self) -> EntityRegistryView: EntityRegistryView A registry view of `MirrorPlane` entities available in this draft. """ - return self._entity_registry.view(MirrorPlane) + return self._state.mirror_planes + + @property + def _mirror_status(self) -> MirrorStatus: + """Read-only compatibility surface for existing draft plumbing and tests.""" + return self._state._mirror_status + + @property + def _body_group_id_to_mirror_id(self) -> dict[str, str]: + """Read-only compatibility surface for existing draft plumbing and tests.""" + return self._state._body_group_id_to_mirror_id + + @property + def _mirror_planes(self) -> list[MirrorPlane]: + """Read-only compatibility surface for existing draft plumbing and tests.""" + return self._state._mirror_planes def create_mirror_of( self, *, - entities: Union[List[GeometryBodyGroup], GeometryBodyGroup], + entities: list[GeometryBodyGroup] | GeometryBodyGroup, mirror_plane: MirrorPlane, - ) -> tuple[List[MirroredGeometryBodyGroup], List[MirroredSurface]]: + ) -> tuple[list[MirroredGeometryBodyGroup], list[MirroredSurface]]: """ Create mirrored entities for one or more geometry body groups. @@ -323,331 +122,67 @@ def create_mirror_of( Parameters ---------- - entities : Union[List[GeometryBodyGroup], GeometryBodyGroup] + entities : list[GeometryBodyGroup] | GeometryBodyGroup One or more geometry body groups to mirror. mirror_plane : MirrorPlane The mirror plane to use for mirroring. Returns ------- - tuple[List[MirroredGeometryBodyGroup], List[MirroredSurface]] + tuple[list[MirroredGeometryBodyGroup], list[MirroredSurface]] Mirrored geometry body groups and surfaces. - - Raises - ------ - Flow360RuntimeError - If inputs are of incorrect types, if the mirror plane name conflicts with an existing - plane in the draft, or if mirroring is unavailable due to missing surface ownership - mapping. - - Notes - ----- - If a body group was previously mirrored, its existing derived mirrored entities are removed - and replaced with the latest mirror plane request (a warning is logged). """ - normalized_entities = self._validate_and_normalize_create_inputs( - entities=entities, mirror_plane=mirror_plane - ) - self._prepare_for_mirror_update(entities=normalized_entities) - self._ensure_mirror_plane_registered(mirror_plane=mirror_plane) - mirrored_geometry_groups, mirrored_surfaces = self._apply_actions_and_generate_entities( - entities=normalized_entities, mirror_plane=mirror_plane - ) - return mirrored_geometry_groups, mirrored_surfaces - - # region Internal helpers ------------------------------------------------- - def _validate_and_normalize_create_inputs( - self, - *, - entities: Union[List[GeometryBodyGroup], GeometryBodyGroup], - mirror_plane: MirrorPlane, - ) -> List[GeometryBodyGroup]: - """Validate inputs for create_mirror_of and normalize entities to a list.""" if isinstance(entities, GeometryBodyGroup): normalized_entities = [entities] elif isinstance(entities, list): normalized_entities = entities else: - raise Flow360RuntimeError( + raise Flow360ValueError( f"`entities` accepts a single entity or a list of entities. Received type: {type(entities).__name__}." ) - for entity in normalized_entities: if not is_exact_instance(entity, GeometryBodyGroup): - raise Flow360RuntimeError( + raise Flow360ValueError( "Only GeometryBodyGroup entities are supported by `create()` currently. " f"Received: {type(entity).__name__}." ) if entity.private_attribute_id is None: - raise Flow360RuntimeError( - f"Entity '{entity.name}' ({type(entity).__name__}) is not supported " - f"for mirror operations." - ) - - if not is_exact_instance(mirror_plane, MirrorPlane): - raise Flow360RuntimeError( - f"`mirror_plane` must be a MirrorPlane entity. Instead received: {type(mirror_plane).__name__}." - ) - - if self._face_group_to_body_group is None: - raise Flow360RuntimeError( - "Mirroring is not available because the surface-to-body-group mapping could not be derived. " - "This typically happens when face groupings span across multiple body groups." - ) - - return normalized_entities - - def _prepare_for_mirror_update(self, *, entities: List[GeometryBodyGroup]) -> None: - """Warn on overwrites and remove previously-derived mirrored entities for these body groups.""" - body_group_ids_to_update = set() - for body_group in entities: - body_group_id = body_group.private_attribute_id - body_group_ids_to_update.add(body_group_id) - if body_group_id in self._body_group_id_to_mirror_id: - log.warning( - "GeometryBodyGroup `%s` was already mirrored; resetting to the latest mirror plane request.", - body_group.name, + raise Flow360ValueError( + f"Entity '{entity.name}' ({type(entity).__name__}) is not supported for mirror operations." ) - existing_mirrored_groups = [ - mirrored_group - for mirrored_group in list(self._mirror_status.mirrored_geometry_body_groups) - if mirrored_group.geometry_body_group_id in body_group_ids_to_update - ] - for mirrored_group in existing_mirrored_groups: - self._remove(mirrored_group) - - def _ensure_mirror_plane_registered(self, *, mirror_plane: MirrorPlane) -> None: - """Validate mirror plane name uniqueness and register the plane if needed.""" - for existing_plane in self._mirror_planes: - if ( - existing_plane.name == mirror_plane.name - and existing_plane.private_attribute_id != mirror_plane.private_attribute_id - ): - raise Flow360RuntimeError( - f"Mirror plane name '{mirror_plane.name}' already exists in the draft." - ) - - if any( - plane.private_attribute_id == mirror_plane.private_attribute_id - for plane in self._mirror_planes - ): - return - self._add(mirror_plane) - - def _apply_actions_and_generate_entities( - self, - *, - entities: List[GeometryBodyGroup], - mirror_plane: MirrorPlane, - ) -> Tuple[List[MirroredGeometryBodyGroup], List[MirroredSurface]]: - """Update actions for the given entities and generate/register derived mirrored entities.""" - mirror_plane_id = mirror_plane.private_attribute_id - body_group_id_to_mirror_id_update: Dict[str, str] = {} - for body_group in entities: - body_group_id = body_group.private_attribute_id - body_group_id_to_mirror_id_update[body_group_id] = mirror_plane_id - self._body_group_id_to_mirror_id[body_group_id] = mirror_plane_id - - mirrored_geometry_groups, mirrored_surfaces = _derive_mirrored_entities_from_actions( - body_group_id_to_mirror_id=body_group_id_to_mirror_id_update, - face_group_to_body_group=self._face_group_to_body_group, - entity_registry=self._entity_registry, - mirror_planes=self._mirror_status.mirror_planes, + return self._state.create_mirror_of( + entities=normalized_entities, + mirror_plane=mirror_plane, ) - for mirrored_geometry_group in mirrored_geometry_groups: - self._add(mirrored_geometry_group) - for mirrored_surface in mirrored_surfaces: - self._add(mirrored_surface) - - return mirrored_geometry_groups, mirrored_surfaces - - # endregion -------------------------------------------------------------- - - def remove_mirror_of( - self, *, entities: Union[List[GeometryBodyGroup], GeometryBodyGroup] - ) -> None: + def remove_mirror_of(self, *, entities: list[GeometryBodyGroup] | GeometryBodyGroup) -> None: """ Remove the mirror of the given entities. Parameters ---------- - entities : Union[List[GeometryBodyGroup], GeometryBodyGroup] + entities : list[GeometryBodyGroup] | GeometryBodyGroup One or more geometry body groups to remove mirroring from. - - Raises - ------ - Flow360RuntimeError - If `entities` is not a `GeometryBodyGroup` or a list of `GeometryBodyGroup` instances. """ - # 1. [Validation] Ensure `entities` are GeometryBodyGroup entities. - normalized_entities: List[GeometryBodyGroup] - if isinstance(entities, GeometryBodyGroup): - normalized_entities = [entities] - elif isinstance(entities, list): - normalized_entities = entities - else: - raise Flow360RuntimeError( - f"`entities` accepts a single entity or a list of entities. Received type: {type(entities).__name__}." - ) - - for entity in normalized_entities: - if not is_exact_instance(entity, GeometryBodyGroup): - raise Flow360RuntimeError( - "Only GeometryBodyGroup entities are supported by `remove_mirror_of()`. " - f"Received: {type(entity).__name__}." - ) - - # 2. Remove mirror assignments for the given entities. - for body_group in normalized_entities: - body_group_id = body_group.private_attribute_id - self._body_group_id_to_mirror_id.pop(body_group_id, None) - mirrored_groups_to_remove = [ - mirrored_group - for mirrored_group in list(self._mirror_status.mirrored_geometry_body_groups) - if mirrored_group.geometry_body_group_id == body_group_id - ] - for mirrored_group in mirrored_groups_to_remove: - self._remove(mirrored_group) - - # endregion ------------------------------------------------------------------------------------ - @property - def _mirror_planes(self) -> List[MirrorPlane]: - """Return the list of mirror planes.""" - return self._mirror_status.mirror_planes - - @_mirror_planes.setter - def _mirror_planes(self, *args, **kwargs): - """Set the list of mirror planes.""" - raise NotImplementedError( - "Mirror planes are managed by the mirror manager -> _mirror_status and cannot be assigned directly." - ) - - def _add(self, entity: Union[MirrorPlane, MirroredGeometryBodyGroup, MirroredSurface]) -> None: - """Add an entity to the mirror status.""" - if self._entity_registry.contains(entity): - return - - # pylint: disable=no-member - if is_exact_instance(entity, MirrorPlane): - self._mirror_status.mirror_planes.append(entity) - self._entity_registry.register(entity) - elif is_exact_instance(entity, MirroredGeometryBodyGroup): - self._mirror_status.mirrored_geometry_body_groups.append(entity) - self._entity_registry.register(entity) - elif is_exact_instance(entity, MirroredSurface): - self._mirror_status.mirrored_surfaces.append(entity) - self._entity_registry.register(entity) - else: - raise Flow360RuntimeError( - f"[Internal] Unsupported entity type: {type(entity).__name__}." - ) - - def _remove(self, entity: MirroredGeometryBodyGroup) -> None: - """Remove an MirroredGeometryBodyGroup from the mirror status.""" - # pylint: disable=no-member - - if entity in self._mirror_status.mirrored_geometry_body_groups: - self._mirror_status.mirrored_geometry_body_groups.remove(entity) - self._entity_registry.remove(entity) - - # Now remove the mirrored surfaces that are associated with this body group. - body_group_id = entity.geometry_body_group_id - - mirrored_surfaces_to_remove = [ - mirrored_surface - for mirrored_surface in list(self._mirror_status.mirrored_surfaces) - if getattr(mirrored_surface, "_geometry_body_group_id", None) == body_group_id - ] - for mirrored_surface in mirrored_surfaces_to_remove: - if mirrored_surface in self._mirror_status.mirrored_surfaces: - self._mirror_status.mirrored_surfaces.remove(mirrored_surface) - self._entity_registry.remove(mirrored_surface) - - @staticmethod - def _generate_mirror_status( - entity_registry, body_group_id_to_mirror_id, face_group_to_body_group, mirror_planes - ) -> MirrorStatus: - """Build a serializable status snapshot. - - Parameters - ---------- - entity_registry : EntityRegistry - The entity registry to validate entity references against. - - Returns - ------- - Optional[MirrorStatus] - The serialized mirror status, or None if no valid mirror actions exist. - """ - - # Build a set of existing GeometryBodyGroup IDs in the registry for validation. - existing_body_group_ids = set() - for entity in entity_registry.find_by_type(GeometryBodyGroup): - if is_exact_instance(entity, GeometryBodyGroup): - existing_body_group_ids.add(entity.private_attribute_id) - - # Filter out actions that refer to body groups that no longer exist in the registry. - filtered_actions: Dict[str, str] = {} - for body_group_id, mirror_plane_id in body_group_id_to_mirror_id.items(): - if body_group_id not in existing_body_group_ids: - log.warning( - "GeometryBodyGroup '%s' assigned to mirror plane '%s' is not in the draft registry; " - "skipping this mirror action.", - body_group_id, - mirror_plane_id, - ) - continue - filtered_actions[body_group_id] = mirror_plane_id - - if not filtered_actions: - # No valid mirror actions – nothing to serialize. - return MirrorStatus( - mirror_planes=[], mirrored_geometry_body_groups=[], mirrored_surfaces=[] - ) - - mirrored_geometry_groups, mirrored_surfaces = _derive_mirrored_entities_from_actions( - body_group_id_to_mirror_id=filtered_actions, - face_group_to_body_group=face_group_to_body_group, - entity_registry=entity_registry, - mirror_planes=mirror_planes, - ) - - # Only keep mirror planes that are actually referenced by the filtered actions. - mirror_planes_by_id: Dict[str, MirrorPlane] = { - plane.private_attribute_id: plane for plane in mirror_planes - } - used_plane_ids = { - mirror_plane_id - for mirror_plane_id in filtered_actions.values() - if mirror_plane_id in mirror_planes_by_id - } - mirror_planes_for_status: List[MirrorPlane] = [ - plane for plane in mirror_planes if plane.private_attribute_id in used_plane_ids - ] - - return MirrorStatus( - mirror_planes=mirror_planes_for_status, - mirrored_geometry_body_groups=mirrored_geometry_groups, - mirrored_surfaces=mirrored_surfaces, - ) + self._state.remove_mirror_of(entities=entities) @classmethod def _from_status( cls, *, - status: Optional[MirrorStatus], - face_group_to_body_group: Optional[Dict[str, str]], + status: MirrorStatus | None, + face_group_to_body_group: dict[str, str] | None, entity_registry: EntityRegistry, ) -> "MirrorManager": - """Restore manager from a status snapshot. + """ + Restore manager from a status snapshot. Parameters ---------- - status : Optional[MirrorStatus] + status : MirrorStatus | None The mirror status to restore from. - face_group_to_body_group : Optional[Dict[str, str]] + face_group_to_body_group : dict[str, str] | None Mapping from surface name to owning body group ID. entity_registry : EntityRegistry Entity registry containing body groups and surfaces. @@ -657,43 +192,16 @@ def _from_status( MirrorManager Restored mirror manager. """ - mgr = cls( - face_group_to_body_group=face_group_to_body_group, - entity_registry=entity_registry, - ) - - body_groups = entity_registry.view( # pylint: disable=protected-access - GeometryBodyGroup - )._entities - valid_body_group_ids = {body_group.private_attribute_id for body_group in body_groups} - - body_group_id_to_mirror_id = _extract_body_group_id_to_mirror_id_from_status( - mirror_status=status, - valid_body_group_ids=valid_body_group_ids, - ) - - mgr._body_group_id_to_mirror_id = body_group_id_to_mirror_id - - # Initialize with external mirror planes. - # These are not tightly coupled with the persistent entities therefore can be initialized separately. - mgr._mirror_status.mirror_planes = status.mirror_planes if status is not None else [] + if status is None or status.is_empty(): + return cls( + face_group_to_body_group=face_group_to_body_group, + entity_registry=entity_registry, + ) - mgr._mirror_status = cls._generate_mirror_status( - entity_registry=entity_registry, - body_group_id_to_mirror_id=mgr._body_group_id_to_mirror_id, - face_group_to_body_group=mgr._face_group_to_body_group, - mirror_planes=mgr._mirror_status.mirror_planes, + return cls( + state=MirrorState._from_status( + status=status, + face_group_to_body_group=face_group_to_body_group, + entity_registry=entity_registry, + ) ) - - # Register restored entities in the entity registry without mutating the same - # MirrorStatus lists while iterating. - for mirrored_group in list(mgr._mirror_status.mirrored_geometry_body_groups): - mgr._entity_registry.register(mirrored_group) - for mirrored_surface in list(mgr._mirror_status.mirrored_surfaces): - mgr._entity_registry.register(mirrored_surface) - for mirror_plane in list(mgr._mirror_status.mirror_planes): - mgr._entity_registry.register(mirror_plane) - - return mgr - - # endregion ------------------------------------------------------------------------------------ diff --git a/poetry.lock b/poetry.lock index 93bb6ffdf..c40b5b1b2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1468,14 +1468,14 @@ files = [ [[package]] name = "flow360-schema" -version = "0.1.22" +version = "0.1.24" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.22-py3-none-any.whl", hash = "sha256:b3f84e445a0aa9b973d682f9b0547f6b6e706dacb2bed12efe003c726ec645b5"}, - {file = "flow360_schema-0.1.22.tar.gz", hash = "sha256:73d55b2cfc99c673612decb6f1de5f2b46b8d46a0cf55c32619550cccc858bde"}, + {file = "flow360_schema-0.1.24-py3-none-any.whl", hash = "sha256:ce44c694d88ffb42316f262603b3b1c4ea02e46fd8416564c8fa649fc5b825ab"}, + {file = "flow360_schema-0.1.24.tar.gz", hash = "sha256:d5ad3ecc5046976c9791d9530ec13d025298e906f73be26ed2f676f556c25e7f"}, ] [package.dependencies] @@ -6526,4 +6526,4 @@ docs = ["autodoc_pydantic", "cairosvg", "ipython", "jinja2", "jupyter", "myst-pa [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "e11c2aaf5aac714ffa242b26ef22404b4da6689ec4983198cf022ae989675068" +content-hash = "3aaf995480733e28ca8f33d2197489f017c762d94cfa35c34cefe2a685608727" diff --git a/pyproject.toml b/pyproject.toml index 22d1e0ea2..8b3daa83a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ pydantic = ">=2.8,<2.12" # -- Local dev (editable install, schema changes take effect immediately): # flow360-schema = { path = "../flex/share/flow360-schema", develop = true } # -- CI / release (install from CodeArtifact, swap comments before pushing): -flow360-schema = { version = "~0.1.18", source = "codeartifact" } +flow360-schema = { version = "~0.1.24", source = "codeartifact" } pytest = "^7.1.2" click = "^8.1.3" toml = "^0.10.2" diff --git a/tests/simulation/draft_context/test_coordinate_system_assignment.py b/tests/simulation/draft_context/test_coordinate_system_assignment.py index fbb3d9cae..5671d1fbe 100644 --- a/tests/simulation/draft_context/test_coordinate_system_assignment.py +++ b/tests/simulation/draft_context/test_coordinate_system_assignment.py @@ -14,7 +14,7 @@ from flow360.component.simulation.entity_operation import CoordinateSystem from flow360.component.simulation.primitives import Edge, ImportedSurface from flow360.component.simulation.simulation_params import SimulationParams -from flow360.exceptions import Flow360RuntimeError +from flow360.exceptions import Flow360ValueError def _compose(parent: np.ndarray, child: np.ndarray) -> np.ndarray: @@ -106,7 +106,7 @@ def test_assign_coordinate_system_rejects_cycle(mock_geometry): ) with pytest.raises( - Flow360RuntimeError, match="Cycle detected in coordinate system inheritance" + Flow360ValueError, match="Cycle detected in coordinate system inheritance" ): draft.coordinate_systems.update_parent(coordinate_system=cs_root, parent=cs_child) @@ -117,7 +117,7 @@ def test_assign_coordinate_system_rejects_duplicate_ids(mock_geometry): coordinate_system=CoordinateSystem(name="first", private_attribute_id="dup-id") ) with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Coordinate system id 'dup-id' already registered.", ): draft.coordinate_systems.add( @@ -129,7 +129,7 @@ def test_assign_coordinate_system_rejects_duplicate_names(mock_geometry): with create_draft(new_run_from=mock_geometry) as draft: draft.coordinate_systems.add(coordinate_system=CoordinateSystem(name="dup-name")) with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Coordinate system name 'dup-name' already registered.", ): draft.coordinate_systems.add(coordinate_system=CoordinateSystem(name="dup-name")) @@ -142,7 +142,7 @@ def test_get_coordinate_system_by_name(mock_geometry): assert fetched.private_attribute_id == cs.private_attribute_id with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Coordinate system 'missing' not found in the draft.", ): draft.coordinate_systems.get_by_name("missing") @@ -153,7 +153,7 @@ def test_update_parent_requires_registered_coordinate_system(mock_geometry): with create_draft(new_run_from=mock_geometry) as draft: cs = CoordinateSystem(name="standalone") with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Coordinate system must be part of the draft to be updated.", ): draft.coordinate_systems.update_parent(coordinate_system=cs, parent=None) @@ -191,7 +191,7 @@ def test_remove_coordinate_system_errors(mock_geometry): with create_draft(new_run_from=mock_geometry) as draft: cs = CoordinateSystem(name="not-registered") with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Coordinate system is not registered in this draft.", ): draft.coordinate_systems.remove(coordinate_system=cs) @@ -201,7 +201,7 @@ def test_remove_coordinate_system_errors(mock_geometry): coordinate_system=CoordinateSystem(name="child"), parent=parent ) with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Cannot remove coordinate system 'parent' because dependents exist: child", ): draft.coordinate_systems.remove(coordinate_system=parent) @@ -221,7 +221,7 @@ def test_assign_requires_registered_entity(mock_geometry): cs = draft.coordinate_systems.add(coordinate_system=CoordinateSystem(name="cs")) rogue_entity = CoordinateSystem(name="not-an-entity") # wrong type with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Only entities can be assigned a coordinate system. Received: CoordinateSystem", ): draft.coordinate_systems.assign(entities=rogue_entity, coordinate_system=cs) @@ -231,7 +231,7 @@ def test_get_coordinate_system_matrix_requires_registration(mock_geometry): with create_draft(new_run_from=mock_geometry) as draft: cs = CoordinateSystem(name="unregistered") with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Coordinate system must be registered to compute its matrix.", ): draft.coordinate_systems._get_coordinate_system_matrix(coordinate_system=cs) @@ -272,7 +272,7 @@ def test_from_status_validation_errors(mock_geometry): ], ) with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Parent record references unknown coordinate system 'missing'", ): CoordinateSystemManager._from_status( @@ -294,7 +294,7 @@ def test_from_status_rejects_assignment_unknown_cs(mock_geometry): ], ) with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Assignment references unknown coordinate system 'missing'", ): CoordinateSystemManager._from_status( @@ -327,7 +327,7 @@ def test_from_status_rejects_duplicate_entity_assignment(mock_geometry): ], ) with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match=f"Duplicate entity assignment for entity '{entity_type_name}:{entity_id}'", ): CoordinateSystemManager._from_status( @@ -439,7 +439,7 @@ def test_assign_entity_without_id_raises(mock_geometry): entity_without_id = Edge(name="orphan_edge") assert entity_without_id.private_attribute_id is None - with pytest.raises(Flow360RuntimeError, match="is not supported for coordinate system"): + with pytest.raises(Flow360ValueError, match="is not supported for coordinate system"): draft.coordinate_systems.assign(entities=entity_without_id, coordinate_system=cs) diff --git a/tests/simulation/draft_context/test_mirror_action.py b/tests/simulation/draft_context/test_mirror_action.py index e50475e5f..2afd96912 100644 --- a/tests/simulation/draft_context/test_mirror_action.py +++ b/tests/simulation/draft_context/test_mirror_action.py @@ -18,7 +18,7 @@ ) from flow360.component.simulation.primitives import GeometryBodyGroup from flow360.component.simulation.simulation_params import SimulationParams -from flow360.exceptions import Flow360RuntimeError +from flow360.exceptions import Flow360ValueError def test_mirror_single_call_returns_expected_entities(mock_geometry): @@ -277,7 +277,7 @@ def test_mirror_create_rejects_duplicate_plane_name(mock_geometry): ) with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Mirror plane name 'mirror' already exists in the draft", ): draft.mirror.create_mirror_of(entities=body_groups[0], mirror_plane=mirror_plane2) @@ -320,12 +320,24 @@ def test_remove_mirror_of_rejects_invalid_input_type(mock_geometry): with create_draft(new_run_from=mock_geometry) as draft: # Try to pass something that's neither a GeometryBodyGroup nor a list with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="`entities` accepts a single entity or a list of entities", ): draft.mirror.remove_mirror_of(entities="invalid_string") +def test_create_mirror_of_rejects_invalid_input_type(mock_geometry): + """Test that create_mirror_of rejects invalid input types.""" + with create_draft(new_run_from=mock_geometry) as draft: + mirror_plane = MirrorPlane(name="mirror", normal=(1, 0, 0), center=(0, 0, 0) * u.m) + + with pytest.raises( + Flow360ValueError, + match="`entities` accepts a single entity or a list of entities", + ): + draft.mirror.create_mirror_of(entities="invalid_string", mirror_plane=mirror_plane) + + def test_remove_mirror_of_rejects_invalid_entity_type(mock_geometry): """Test that remove_mirror_of rejects invalid entity types.""" with create_draft(new_run_from=mock_geometry) as draft: @@ -333,7 +345,7 @@ def test_remove_mirror_of_rejects_invalid_entity_type(mock_geometry): invalid_entity = MirrorPlane(name="invalid", normal=(1, 0, 0), center=(0, 0, 0) * u.m) with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Only GeometryBodyGroup entities are supported by `remove_mirror_of\\(\\)`", ): draft.mirror.remove_mirror_of(entities=[invalid_entity]) @@ -534,7 +546,7 @@ def test_mirror_create_raises_when_face_group_to_body_group_is_none(mock_geometr ) with pytest.raises( - Flow360RuntimeError, + Flow360ValueError, match="Mirroring is not available because the surface-to-body-group mapping could not be derived", ): mirror_manager.create_mirror_of(entities=body_group, mirror_plane=mirror_plane) @@ -796,5 +808,5 @@ def test_mirror_entity_without_id_raises(mock_geometry): center=(0, 0, 0) * u.m, ) - with pytest.raises(Flow360RuntimeError, match="is not supported for mirror"): + with pytest.raises(Flow360ValueError, match="is not supported for mirror"): draft.mirror.create_mirror_of(entities=[entity_without_id], mirror_plane=mirror_plane) From b7a6a9a8f06cbfd9b7d79b6a7cb8b31631667d3e Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:56:27 -0400 Subject: [PATCH 13/25] Migrate SimulationParams models to flow360-schema (#1989) --- .../simulation/framework/boundary_split.py | 642 +----- .../simulation/meshing_param/edge_params.py | 122 +- .../simulation/meshing_param/face_params.py | 267 +-- .../simulation/meshing_param/meshing_specs.py | 415 +--- .../meshing_param/meshing_validators.py | 39 +- .../simulation/meshing_param/params.py | 878 +------- .../snappy/snappy_mesh_refinements.py | 177 +- .../meshing_param/snappy/snappy_params.py | 181 +- .../meshing_param/snappy/snappy_specs.py | 244 +-- .../simulation/meshing_param/volume_params.py | 1248 +---------- .../component/simulation/models/material.py | 742 +------ .../simulation/models/solver_numerics.py | 686 +----- .../simulation/models/surface_models.py | 973 +-------- .../models/turbulence_quantities.py | 443 +--- .../models/validation/validation_bet_disk.py | 130 +- .../simulation/models/volume_models.py | 1600 +------------- .../operating_condition/atmosphere_model.py | 105 +- .../operating_condition.py | 633 +----- .../simulation/outputs/output_fields.py | 679 +----- .../component/simulation/outputs/outputs.py | 1877 +---------------- .../simulation/outputs/render_config.py | 815 +------ flow360/component/simulation/primitives.py | 121 +- .../simulation/run_control/run_control.py | 34 +- .../run_control/stopping_criterion.py | 243 +-- .../component/simulation/simulation_params.py | 1029 +-------- .../simulation/time_stepping/time_stepping.py | 218 +- .../translator/solver_translator.py | 4 +- .../component/simulation/translator/utils.py | 18 +- flow360/component/simulation/units.py | 60 +- .../user_defined_dynamics.py | 162 +- .../validation/validation_context.py | 1128 +--------- .../validation/validation_output.py | 368 +--- .../validation_simulation_params.py | 1013 +-------- .../simulation/validation/validation_utils.py | 551 +---- poetry.lock | 6 +- .../ref/simulation/service_init_geometry.json | 4 +- .../simulation/service_init_surface_mesh.json | 4 +- .../simulation/service_init_volume_mesh.json | 4 +- .../simulation_with_project_variables.json | 4 +- 39 files changed, 549 insertions(+), 17318 deletions(-) diff --git a/flow360/component/simulation/framework/boundary_split.py b/flow360/component/simulation/framework/boundary_split.py index e6b5fb57c..43d4ce700 100644 --- a/flow360/component/simulation/framework/boundary_split.py +++ b/flow360/component/simulation/framework/boundary_split.py @@ -1,640 +1,4 @@ -""" -Boundary split infrastructure for handling surface/volume name mapping after meshing. +"""Relay boundary split helpers from schema.""" -This module provides a unified framework for handling how boundaries (surfaces/volumes) -get renamed or split during the meshing process. - -Naming convention: -- base_name: The name specified by user (e.g., "wing", "blade") -- full_name: The actual name in mesh (e.g., "fluid/wing", "rotatingBlock/blade__rotating_rotatingBlock") - -Split scenarios: -- Zone prefix: "wing" -> "fluid/wing" -- Multi-zone split: "wing" -> "zone1/wing", "zone2/wing" -- RotationVolume: "blade" -> "rotatingBlock/blade__rotating_rotatingBlock" -""" - -import re -from dataclasses import dataclass -from enum import Enum -from typing import ( - TYPE_CHECKING, - Dict, - List, - Optional, - Protocol, - Set, - Union, - runtime_checkable, -) - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.primitives import ( - BOUNDARY_FULL_NAME_WHEN_NOT_FOUND, - MirroredSurface, - Surface, - _SurfaceEntityBase, -) -from flow360.log import log - -if TYPE_CHECKING: - from flow360.component.simulation.meshing_param.volume_params import ( - RotationSphere, - RotationVolume, - ) - from flow360.component.simulation.models.surface_models import Wall - from flow360.component.simulation.simulation_params import SimulationParams - - -class SplitType(str, Enum): - """Types of boundary splits that can occur during meshing.""" - - ZONE_PREFIX = "zone_prefix" # Normal zone/boundary prefix - MULTI_ZONE = "multi_zone" # Surface appears in multiple zones - ROTATION_ENCLOSED = "rotation_enclosed" # RotationVolume __rotating patch (enclosed) - ROTATION_STATIONARY = "rotation_stationary" # RotationVolume __rotating patch (stationary) - - -@dataclass -class BoundarySplitInfo: - """Describes one variant of an original boundary after being split by the mesher.""" - - full_name: str - split_type: SplitType - source_zone: Optional[str] = None - - -@runtime_checkable -class SplitProvider(Protocol): - """ - Protocol for classes that can provide split information. - - Any class implementing this protocol can contribute split mappings - to the BoundaryNameLookupTable. - """ - - def get_split_mappings(self, volume_mesh_meta_data: dict) -> Dict[str, List[BoundarySplitInfo]]: - """Return split mappings for this provider.""" - - def handled_by_provider(self, full_name: str) -> bool: - """Check if boundary should be skipped by base lookup (handled by this provider).""" - - -# --- RotationVolume Split Provider --- -# Defined before BoundaryNameLookupTable since from_params() references it - - -class RotationVolumeSplitProvider: - """ - SplitProvider implementation for RotationVolume __rotating patches. - - When a RotationVolume is defined, enclosed surfaces get renamed with __rotating suffix - by the mesher. This provider detects those patterns and adds them to the lookup table. - """ - - def __init__(self, params: "SimulationParams"): - self._params = params - self._generated_full_names: Set[str] = set() - - # region ====private methods==== - - def _get_rotation_volumes(self) -> List[Union["RotationVolume", "RotationSphere"]]: - """Extract rotation sliding-interface zones from params.meshing.""" - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.meshing_param.params import ( - MeshingParams, - ModularMeshingWorkflow, - ) - from flow360.component.simulation.meshing_param.volume_params import ( - RotationSphere, - RotationVolume, - ) - - if self._params.meshing is None: - return [] - - volume_zones = None - if isinstance(self._params.meshing, MeshingParams): - volume_zones = self._params.meshing.volume_zones - elif isinstance(self._params.meshing, ModularMeshingWorkflow): - volume_zones = self._params.meshing.zones - - if volume_zones is None: - return [] - - return [ - zone - for zone in volume_zones - if isinstance( - zone, - ( - RotationVolume, - RotationSphere, - ), - ) - ] - - def has_active_volumes(self) -> bool: - """Check if there are any rotation volumes with enclosed entities.""" - rotation_volumes = self._get_rotation_volumes() - for rotation_volume in rotation_volumes: - if rotation_volume.enclosed_entities: - return True - return False - - @staticmethod - def _find_zone_name( - rotation_volume: Union["RotationVolume", "RotationSphere"], zones: dict - ) -> Optional[str]: - """Find the zone name for a rotation zone by matching entity name.""" - if not rotation_volume.entities or not rotation_volume.entities.stored_entities: - return None - - entity_name = rotation_volume.entities.stored_entities[0].name - - if entity_name in zones: - return entity_name - - matching_zone_names = [name for name in zones.keys() if entity_name in name] - - if len(matching_zone_names) == 1: - return matching_zone_names[0] - - log.warning( - f"[Internal] Ambiguous or no match found for rotation volume {rotation_volume.name}." - ) - return None - - @staticmethod - def _get_stationary_base_names( - rotation_volume: Union["RotationVolume", "RotationSphere"] - ) -> Set[str]: - """Collect base names of stationary enclosed entities.""" - stationary_base_names: Set[str] = set() - if rotation_volume.stationary_enclosed_entities: - for entity in rotation_volume.stationary_enclosed_entities.stored_entities: - if isinstance(entity, (Surface, MirroredSurface)): - stationary_base_names.add(entity.name) - return stationary_base_names - - def _add_enclosed_mappings( # pylint: disable=too-many-arguments - self, - rotation_volume: Union["RotationVolume", "RotationSphere"], - zone_name: str, - boundary_full_names: List[str], - stationary_base_names: Set[str], - mappings: Dict[str, List[BoundarySplitInfo]], - ) -> None: - """Add mappings for enclosed entities with __rotating patches.""" - if not rotation_volume.enclosed_entities: - return - - for entity in rotation_volume.enclosed_entities.stored_entities: - if not isinstance(entity, (Surface, MirroredSurface)): - continue - - base_name = entity.name - rotating_full_name = self._find_rotating_full_name( - zone_name, base_name, boundary_full_names - ) - if rotating_full_name is None: - continue - - split_type = ( - SplitType.ROTATION_STATIONARY - if base_name in stationary_base_names - else SplitType.ROTATION_ENCLOSED - ) - - info = BoundarySplitInfo( - full_name=rotating_full_name, - split_type=split_type, - source_zone=zone_name, - ) - mappings.setdefault(base_name, []).append(info) - - @staticmethod - def _find_rotating_full_name( - zone_name: str, base_name: str, boundary_full_names: List[str] - ) -> Optional[str]: - """Find the __rotating boundary full_name matching the pattern.""" - rotating_pattern = ( - re.escape(zone_name) - + r"/" - + re.escape(base_name) - + r"__rotating_" - + re.escape(zone_name) - ) - for full_name in boundary_full_names: - if re.fullmatch(rotating_pattern, full_name): - return full_name - return None - - # endregion - - def get_split_mappings(self, volume_mesh_meta_data: dict) -> Dict[str, List[BoundarySplitInfo]]: - """Return split mappings for RotationVolume __rotating patches.""" - mappings: Dict[str, List[BoundarySplitInfo]] = {} - rotation_volumes = self._get_rotation_volumes() - if not rotation_volumes: - return mappings - - zones = volume_mesh_meta_data.get("zones", {}) - - for rotation_volume in rotation_volumes: - zone_name = self._find_zone_name(rotation_volume, zones) - if zone_name is None: - continue - - zone_meta = zones.get(zone_name) - if zone_meta is None: - continue - - boundary_full_names = zone_meta.get("boundaryNames", []) - stationary_base_names = self._get_stationary_base_names(rotation_volume) - self._add_enclosed_mappings( - rotation_volume, zone_name, boundary_full_names, stationary_base_names, mappings - ) - - # Cache generated full names for handled_by_provider check - for infos in mappings.values(): - for info in infos: - self._generated_full_names.add(info.full_name) - - return mappings - - def handled_by_provider(self, full_name: str) -> bool: - """ - Check if boundary is a __rotating patch (handled by this provider). - Only returns True if the boundary was actually generated by this provider. - """ - return full_name in self._generated_full_names - - -# --- Lookup Table --- - - -class BoundaryNameLookupTable: - """ - Lookup table mapping base boundary names to their full names after meshing. - - This class is generic and extensible. Split logic is provided by: - 1. Built-in zone/boundary matching from metadata - 2. External SplitProvider implementations (e.g., RotationVolumeSplitProvider) - - Usage: - # With SimulationParams (auto-creates RotationVolumeSplitProvider) - lookup_table = BoundaryNameLookupTable.from_params(volume_mesh_meta_data, params) - - # With custom providers - providers = [CustomSplitProvider()] - lookup_table = BoundaryNameLookupTable(volume_mesh_meta_data, providers) - """ - - def __init__( - self, - volume_mesh_meta_data: dict, - split_providers: Optional[List[SplitProvider]] = None, - ): - self._mapping: Dict[str, List[BoundarySplitInfo]] = {} - self._providers = split_providers or [] - self._build_mapping(volume_mesh_meta_data) - - @classmethod - def from_params( - cls, - volume_mesh_meta_data: dict, - params: "SimulationParams", - ) -> "BoundaryNameLookupTable": - """ - Create a BoundaryNameLookupTable with default providers for SimulationParams. - - This factory method automatically creates the RotationVolumeSplitProvider - based on the params. - """ - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.entity_info import VolumeMeshEntityInfo - - providers = [] - entity_info = params.private_attribute_asset_cache.project_entity_info - - # Skip if using existing volume mesh (split logic already handled in mesh) - # Also skip if no active rotation volumes defined - if not isinstance(entity_info, VolumeMeshEntityInfo): - rotation_provider = RotationVolumeSplitProvider(params) - if rotation_provider.has_active_volumes(): - providers.append(rotation_provider) - - return cls(volume_mesh_meta_data, providers) - - def _build_mapping(self, volume_mesh_meta_data: dict) -> None: - """Build the complete mapping from base_name to split info.""" - self._add_zone_boundary_matches(volume_mesh_meta_data) - - for provider in self._providers: - provider_mappings = provider.get_split_mappings(volume_mesh_meta_data) - for base_name, infos in provider_mappings.items(): - for info in infos: - self._mapping.setdefault(base_name, []).append(info) - - def _add_zone_boundary_matches(self, volume_mesh_meta_data: dict) -> None: - """Add mappings for standard zone/boundary name prefixing.""" - zones = volume_mesh_meta_data.get("zones", {}) or {} - - for zone_name, zone_meta in zones.items(): - boundary_names = zone_meta.get("boundaryNames", []) or [] - for full_name in boundary_names: - # Check if any provider claims this boundary as special - if any(p.handled_by_provider(full_name) for p in self._providers): - continue - - # Extract base_name from full_name - if "/" in full_name: - parts = full_name.split("/", 1) - base_name = parts[1] if parts[0] == zone_name else full_name - else: - base_name = full_name - - # Determine split type and add entry - split_type = ( - SplitType.MULTI_ZONE if base_name in self._mapping else SplitType.ZONE_PREFIX - ) - info = BoundarySplitInfo( - full_name=full_name, - split_type=split_type, - source_zone=zone_name, - ) - self._mapping.setdefault(base_name, []).append(info) - - # Also add full_name as key for passthrough lookup - # (when entity.name is already a full_name, e.g., from volume mesh) - if full_name != base_name: - self._mapping.setdefault(full_name, []).append(info) - - def get_split_info(self, base_name: str) -> List[BoundarySplitInfo]: - """Get all split info for a base boundary name.""" - split_infos = self._mapping.get(base_name, []) - if split_infos: - return split_infos - - normalized_name = base_name.strip() - if normalized_name != base_name: - return self._mapping.get(normalized_name, []) - - return [] - - -# --- Entity update functions (external entry point) --- - - -def update_entities_in_model( # pylint: disable=too-many-branches - model: Flow360BaseModel, - lookup_table: BoundaryNameLookupTable, - target_type: type = _SurfaceEntityBase, -) -> None: - """ - Recursively (walk the params): - 1. update entity full_names in a model - 2. replace assignment with all the split entities - using the lookup table. - - Handles: - - Direct entity fields - - EntityList fields - - Nested Flow360BaseModel fields - - Lists/tuples containing entities or models - """ - # pylint: disable=import-outside-toplevel - from flow360_schema.models.asset_cache import AssetCache - - for field in model.__dict__.values(): - if isinstance(field, AssetCache): - continue - - if isinstance(field, target_type): - _replace_with_actual_entities(field, lookup_table) - - elif isinstance(field, EntityList): - added = [] - for entity in field.stored_entities: - if isinstance(entity, target_type): - added.extend(_replace_with_actual_entities(entity, lookup_table)) - field.stored_entities.extend(added) - - elif isinstance(field, (list, tuple)): - added = [] - for item in field: - if isinstance(item, target_type): - added.extend(_replace_with_actual_entities(item, lookup_table)) - elif isinstance(item, Flow360BaseModel): - update_entities_in_model(item, lookup_table, target_type) - if isinstance(field, list): - field.extend(added) - elif isinstance(field, tuple) and added: - field += tuple(added) - - elif isinstance(field, Flow360BaseModel): - update_entities_in_model(field, lookup_table, target_type) - - -def _replace_with_actual_entities( - entity: _SurfaceEntityBase, - lookup_table: BoundaryNameLookupTable, -) -> List[_SurfaceEntityBase]: - """ - Update a single entity's full_name using the lookup table. - - Returns additional entities if the entity is split into multiple boundaries. - """ - - def _set_entity_full_name(entity: _SurfaceEntityBase, full_name: str) -> None: - """Set the full_name on an entity.""" - entity._force_set_attr( # pylint:disable=protected-access - "private_attribute_full_name", full_name - ) - - def _create_entity_parts( - original: _SurfaceEntityBase, - split_infos: List[BoundarySplitInfo], - ) -> List[_SurfaceEntityBase]: - """Create entity copies for additional split results.""" - parts = [] - for info in split_infos: - # Extract base name from full name (e.g., "zone/boundary" -> "boundary") - parts.append( - original.copy( - update={ - "name": info.full_name, - "private_attribute_full_name": info.full_name, - } - ) - ) - return parts - - split_infos = lookup_table.get_split_info(entity.name) - - if not split_infos: - _set_entity_full_name(entity, BOUNDARY_FULL_NAME_WHEN_NOT_FOUND) - return [] - - _set_entity_full_name(entity, split_infos[0].full_name) - return _create_entity_parts(entity, split_infos[1:]) - - -# region --- Post-processing functions --- - - -def post_process_rotation_volume_entities( - params: "SimulationParams", - lookup_table: BoundaryNameLookupTable, -) -> None: - """ - Filter RotationVolume enclosed_entities to only keep __rotating patches. - - After generic traversal, enclosed_entities may have been expanded to include - both original and __rotating versions. This function filters to only keep - the __rotating version for entities that have one. - """ - provider = RotationVolumeSplitProvider(params) - rotation_volumes = provider._get_rotation_volumes() # pylint: disable=protected-access - if not rotation_volumes: - return - - both_types = (SplitType.ROTATION_ENCLOSED, SplitType.ROTATION_STATIONARY) - stationary_only = (SplitType.ROTATION_STATIONARY,) - - for rotation_volume in rotation_volumes: - _filter_entity_list_to_rotating(rotation_volume.enclosed_entities, lookup_table, both_types) - _filter_entity_list_to_rotating( - rotation_volume.stationary_enclosed_entities, lookup_table, stationary_only - ) - - -def _filter_entity_list_to_rotating( - entity_list: Optional[EntityList], - lookup_table: BoundaryNameLookupTable, - target_split_types: tuple, -) -> None: - """Filter an EntityList to only keep __rotating patches matching target split types.""" - if not entity_list or not entity_list.stored_entities: - return - - # Collect matching __rotating full_names from the lookup table - rotating_full_names = set() - for split_infos in lookup_table._mapping.values(): # pylint: disable=protected-access - for info in split_infos: - if info.split_type in target_split_types: - rotating_full_names.add(info.full_name) - - filtered_entities = [] - for entity in entity_list.stored_entities: - if not isinstance(entity, (Surface, MirroredSurface)): - filtered_entities.append(entity) - continue - - # Keep if this entity is a __rotating version (by full_name match) - if entity.full_name in rotating_full_names: - filtered_entities.append(entity) - continue - - # Skip if this entity has a __rotating counterpart (we'll keep the rotating one) - split_infos = lookup_table.get_split_info(entity.name) - has_rotating = any(info.split_type in target_split_types for info in split_infos) - if not has_rotating: - filtered_entities.append(entity) - - entity_list.stored_entities[:] = filtered_entities - - -def post_process_wall_models_for_rotating( - params: "SimulationParams", - lookup_table: BoundaryNameLookupTable, -) -> None: - """ - Create Wall models for __rotating patches. - - For each Wall model that references a surface with a __rotating patch, - create a new Wall model for the __rotating boundary. For stationary - boundaries, set velocity=(0,0,0). - """ - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.models.surface_models import Wall - - if params.models is None: - return - - # Build mapping of base_name -> (rotating_full_name, is_stationary) - rotating_mappings: Dict[str, tuple] = {} - for base_name, split_infos in lookup_table._mapping.items(): # pylint: disable=protected-access - for info in split_infos: - if info.split_type == SplitType.ROTATION_ENCLOSED: - rotating_mappings[base_name] = (info.full_name, False) - elif info.split_type == SplitType.ROTATION_STATIONARY: - rotating_mappings[base_name] = (info.full_name, True) - - if not rotating_mappings: - return - - models_to_add = [] - for model in params.models: - if not isinstance(model, Wall): - continue - - new_models = _create_rotating_wall_models(model, rotating_mappings) - models_to_add.extend(new_models) - - if models_to_add: - params.models.extend(models_to_add) - - -def _create_rotating_wall_models( - wall_model: "Wall", - rotating_mappings: Dict[str, tuple], -) -> List["Wall"]: - """Create new Wall models for __rotating patches.""" - if not wall_model.entities or not wall_model.entities.stored_entities: - return [] - - stationary_surfaces = [] - non_stationary_surfaces = [] - - for entity in wall_model.entities.stored_entities: - if not isinstance(entity, (Surface, MirroredSurface)): - continue - - base_name = entity.name - if base_name not in rotating_mappings: - continue - - rotating_full_name, is_stationary = rotating_mappings[base_name] - rotating_base_name = ( - rotating_full_name.split("/")[-1] if "/" in rotating_full_name else rotating_full_name - ) - rotating_entity = entity.copy( - update={ - "name": rotating_base_name, - "private_attribute_full_name": rotating_full_name, - } - ) - - if is_stationary: - stationary_surfaces.append(rotating_entity) - else: - non_stationary_surfaces.append(rotating_entity) - - models = [] - if stationary_surfaces: - new_model = wall_model.copy(update={"velocity": ("0", "0", "0")}) - new_model.entities = stationary_surfaces - models.append(new_model) - - if non_stationary_surfaces: - new_model = wall_model.copy() - new_model.entities = non_stationary_surfaces - models.append(new_model) - - return models - - -# endregion +# pylint: disable=wildcard-import,unused-wildcard-import +from flow360_schema.models.simulation.framework.boundary_split import * diff --git a/flow360/component/simulation/meshing_param/edge_params.py b/flow360/component/simulation/meshing_param/edge_params.py index 2ef0918df..bffc70453 100644 --- a/flow360/component/simulation/meshing_param/edge_params.py +++ b/flow360/component/simulation/meshing_param/edge_params.py @@ -1,117 +1,11 @@ -"""Edge based meshing parameters for meshing.""" +"""Relay import for meshing edge refinement models.""" -from typing import Literal, Optional, Union +# pylint: disable=unused-import -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Angle, Length - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.primitives import Edge -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - contextual_model_validator, +from flow360_schema.models.simulation.meshing_param.edge_params import ( + AngleBasedRefinement, + AspectRatioBasedRefinement, + HeightBasedRefinement, + ProjectAnisoSpacing, + SurfaceEdgeRefinement, ) - - -class AngleBasedRefinement(Flow360BaseModel): - """ - Surface edge refinement by specifying curvature resolution angle. - - Example - ------- - - >>> fl.AngleBasedRefinement(value=8*fl.u.deg) - - ==== - """ - - type: Literal["angle"] = pd.Field("angle", frozen=True) - value: Angle.Float64 = pd.Field() - - -class HeightBasedRefinement(Flow360BaseModel): - """ - Surface edge refinement by specifying first layer height of the anisotropic layers. - - Example - ------- - - >>> fl.HeightBasedRefinement(value=1e-4*fl.u.m) - - ==== - """ - - type: Literal["height"] = pd.Field("height", frozen=True) - # pylint: disable=no-member - value: Length.PositiveFloat64 = pd.Field() - - -class AspectRatioBasedRefinement(Flow360BaseModel): - """ - Surface edge refinement by specifying maximum aspect ratio of the anisotropic cells. - - Example - ------- - - >>> fl.AspectRatioBasedRefinement(value=10) - - ==== - """ - - type: Literal["aspectRatio"] = pd.Field("aspectRatio", frozen=True) - value: pd.PositiveFloat = pd.Field() - - -class ProjectAnisoSpacing(Flow360BaseModel): - """ - Project the anisotropic spacing from neighboring faces to the edge. - - Example - ------- - - >>> fl.ProjectAnisoSpacing() - - ==== - """ - - type: Literal["projectAnisoSpacing"] = pd.Field("projectAnisoSpacing", frozen=True) - - -class SurfaceEdgeRefinement(Flow360BaseModel): - """ - Setting for growing anisotropic layers orthogonal to the specified `Edge` (s). - - Example - ------- - - >>> fl.SurfaceEdgeRefinement( - ... edges=[geometry["edge1"], geometry["edge2"]], - ... method=fl.HeightBasedRefinement(value=1e-4) - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Surface edge refinement") - refinement_type: Literal["SurfaceEdgeRefinement"] = pd.Field( - "SurfaceEdgeRefinement", frozen=True - ) - entities: EntityList[Edge] = pd.Field(alias="edges") - method: Union[ - AngleBasedRefinement, - HeightBasedRefinement, - AspectRatioBasedRefinement, - ProjectAnisoSpacing, - ] = pd.Field( - discriminator="type", - description="Method for determining the spacing. See :class:`AngleBasedRefinement`," - " :class:`HeightBasedRefinement`, :class:`AspectRatioBasedRefinement`, :class:`ProjectAnisoSpacing`", - ) - - @contextual_model_validator(mode="after") - def ensure_not_geometry_ai(self, param_info: ParamsValidationInfo): - """Ensure that geometry AI is disabled when using this feature.""" - if param_info.use_geometry_AI: - raise ValueError("SurfaceEdgeRefinement is not currently supported with geometry AI.") - return self diff --git a/flow360/component/simulation/meshing_param/face_params.py b/flow360/component/simulation/meshing_param/face_params.py index 2197673e2..749998dde 100644 --- a/flow360/component/simulation/meshing_param/face_params.py +++ b/flow360/component/simulation/meshing_param/face_params.py @@ -1,263 +1,10 @@ -"""Face based meshing parameters for meshing.""" +"""Relay import for meshing face refinement models.""" -from typing import Literal, Optional +# pylint: disable=unused-import -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Angle, Length - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.primitives import ( - GhostCircularPlane, - GhostSurface, - MirroredSurface, - Surface, - WindTunnelGhostSurface, -) -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - contextual_field_validator, - contextual_model_validator, -) -from flow360.component.simulation.validation.validation_utils import ( - check_deleted_surface_in_entity_list, - check_geometry_ai_features, - check_ghost_surface_usage_policy_for_face_refinements, +from flow360_schema.models.simulation.meshing_param.face_params import ( + BoundaryLayer, + GeometryRefinement, + PassiveSpacing, + SurfaceRefinement, ) - - -class SurfaceRefinement(Flow360BaseModel): - """ - Setting for refining surface elements for given `Surface`. - - Example - ------- - - >>> fl.SurfaceRefinement( - ... faces=[geometry["face1"], geometry["face2"]], - ... max_edge_length=0.001*fl.u.m - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Surface refinement") - refinement_type: Literal["SurfaceRefinement"] = pd.Field("SurfaceRefinement", frozen=True) - entities: EntityList[ - Surface, MirroredSurface, WindTunnelGhostSurface, GhostSurface, GhostCircularPlane - ] = pd.Field(alias="faces") - # pylint: disable=no-member - max_edge_length: Optional[Length.PositiveFloat64] = pd.Field( - None, description="Maximum edge length of surface cells." - ) - - curvature_resolution_angle: Optional[Angle.PositiveFloat64] = pd.Field( - None, - description=( - "Default maximum angular deviation in degrees. " - "This value will restrict the angle between a cell’s normal and its underlying surface normal." - ), - ) - - resolve_face_boundaries: Optional[bool] = pd.Field( - None, - description="Flag to specify whether boundaries between adjacent faces should be resolved " - + "accurately during the surface meshing process using anisotropic mesh refinement.", - ) - - @contextual_field_validator("entities", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - expanded = param_info.expand_entity_list(value) - check_ghost_surface_usage_policy_for_face_refinements( - expanded, feature_name="SurfaceRefinement", param_info=param_info - ) - check_deleted_surface_in_entity_list(expanded, param_info) - return value - - @contextual_field_validator("curvature_resolution_angle", mode="after") - @classmethod - def ensure_geometry_ai_or_beta_mesher(cls, value, param_info: ParamsValidationInfo): - """Ensure curvature resolution angle is specified only when beta mesher or geometry AI is used""" - if value is not None and not (param_info.is_beta_mesher or param_info.use_geometry_AI): - raise ValueError( - "curvature_resolution_angle is only supported by the beta mesher or when geometry AI is enabled" - ) - return value - - @contextual_field_validator("resolve_face_boundaries", mode="after") - @classmethod - def ensure_geometry_ai_features(cls, value, info, param_info: ParamsValidationInfo): - """Validate that the feature is only used when Geometry AI is enabled.""" - return check_geometry_ai_features(cls, value, info, param_info) - - @pd.model_validator(mode="after") - def require_at_least_one_setting(self): - """Ensure that at least one of max_edge_length, curvature_resolution_angle, - or resolve_face_boundaries is specified for SurfaceRefinement. - """ - if ( - self.max_edge_length is None - and self.curvature_resolution_angle is None - and self.resolve_face_boundaries is None - ): - raise ValueError( - "SurfaceRefinement requires at least one of 'max_edge_length', " - "'curvature_resolution_angle', or 'resolve_face_boundaries' to be specified." - ) - return self - - -class GeometryRefinement(Flow360BaseModel): - """ - Setting for refining surface elements for given `Surface`. - - Example - ------- - - >>> fl.GeometryRefinement( - ... faces=[geometry["face1"], geometry["face2"]], - ... geometry_accuracy=0.001*fl.u.m - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Geometry refinement") - refinement_type: Literal["GeometryRefinement"] = pd.Field("GeometryRefinement", frozen=True) - entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field(alias="faces") - # pylint: disable=no-member - - geometry_accuracy: Optional[Length.PositiveFloat64] = pd.Field( - None, - description="The smallest length scale that will be resolved accurately by the surface meshing process. ", - ) - - preserve_thin_geometry: Optional[bool] = pd.Field( - None, - description="Flag to specify whether thin geometry features with thickness roughly equal " - + "to geometry_accuracy should be resolved accurately during the surface meshing process.", - ) - - sealing_size: Optional[Length.NonNegativeFloat64] = pd.Field( - None, - description="Threshold size below which all geometry gaps are automatically closed.", - ) - - min_passage_size: Optional[Length.PositiveFloat64] = pd.Field( - None, - description="Minimum passage size that hidden geometry removal can resolve for this face group. " - "Internal regions connected by thin passages smaller than this size may not be detected. " - "If not specified, the value is derived from geometry_accuracy and sealing_size.", - ) - - # Note: No checking on deleted surfaces since geometry accuracy on deleted surface does impact the volume mesh. - - @contextual_model_validator(mode="after") - def ensure_geometry_ai(self, param_info: ParamsValidationInfo): - """Ensure feature is only activated with geometry AI enabled.""" - if not param_info.use_geometry_AI: - raise ValueError("GeometryRefinement is only supported by geometry AI.") - return self - - -class PassiveSpacing(Flow360BaseModel): - """ - Passively control the mesh spacing either through adjacent `Surface`'s meshing - setting or doing nothing to change existing surface mesh at all. - - Example - ------- - - >>> fl.PassiveSpacing( - ... faces=[geometry["face1"], geometry["face2"]], - ... type="projected" - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Passive spacing") - type: Literal["projected", "unchanged"] = pd.Field( - description=""" - 1. When set to *projected*, turn off anisotropic layers growing for this `Surface`. - Project the anisotropic spacing from the neighboring volumes to this face. - - 2. When set to *unchanged*, turn off anisotropic layers growing for this `Surface`. - The surface mesh will remain unaltered when populating the volume mesh. - """ - ) - refinement_type: Literal["PassiveSpacing"] = pd.Field("PassiveSpacing", frozen=True) - entities: EntityList[ - Surface, MirroredSurface, WindTunnelGhostSurface, GhostSurface, GhostCircularPlane - ] = pd.Field(alias="faces") - - @contextual_field_validator("entities", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - expanded = param_info.expand_entity_list(value) - check_ghost_surface_usage_policy_for_face_refinements( - expanded, feature_name="PassiveSpacing", param_info=param_info - ) - check_deleted_surface_in_entity_list(expanded, param_info) - return value - - -class BoundaryLayer(Flow360BaseModel): - """ - Setting for growing anisotropic layers orthogonal to the specified `Surface` (s). - - Example - ------- - - >>> fl.BoundaryLayer( - ... faces=[geometry["face1"], geometry["face2"]], - ... first_layer_thickness=1e-5, - ... growth_rate=1.15 - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Boundary layer refinement") - refinement_type: Literal["BoundaryLayer"] = pd.Field("BoundaryLayer", frozen=True) - entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field(alias="faces") - # pylint: disable=no-member - first_layer_thickness: Optional[Length.PositiveFloat64] = pd.Field( - None, - description="First layer thickness for volumetric anisotropic layers grown from given `Surface` (s).", - ) - - growth_rate: Optional[float] = pd.Field( - None, - ge=1, - description="Growth rate for volume prism layers for given `Surface` (s)." - " Supported only by the beta mesher.", - ) - - @contextual_field_validator("entities", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - expanded = param_info.expand_entity_list(value) - check_deleted_surface_in_entity_list(expanded, param_info) - return value - - @contextual_field_validator("growth_rate", mode="after") - @classmethod - def invalid_growth_rate(cls, value, param_info: ParamsValidationInfo): - """Ensure growth rate per face is not specified""" - - if value is not None and not param_info.is_beta_mesher: - raise ValueError("Growth rate per face is only supported by the beta mesher.") - return value - - @contextual_field_validator("first_layer_thickness", mode="after") - @classmethod - def require_first_layer_thickness(cls, value, param_info: ParamsValidationInfo): - """Verify first layer thickness is specified""" - if value is None and not param_info.is_beta_mesher: - raise ValueError("First layer thickness is required.") - return value diff --git a/flow360/component/simulation/meshing_param/meshing_specs.py b/flow360/component/simulation/meshing_param/meshing_specs.py index 79315dc1d..64aa52b68 100644 --- a/flow360/component/simulation/meshing_param/meshing_specs.py +++ b/flow360/component/simulation/meshing_param/meshing_specs.py @@ -1,412 +1,9 @@ -"""Default settings for meshing using different meshing algorithms""" +"""Relay import for meshing default setting models.""" -from math import log2 -from typing import Optional +# pylint: disable=unused-import -import numpy as np -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Angle, Length - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.updater import ( - DEFAULT_PLANAR_FACE_TOLERANCE, - DEFAULT_SLIDING_INTERFACE_TOLERANCE, -) -from flow360.component.simulation.validation.validation_context import ( - SURFACE_MESH, - VOLUME_MESH, - ConditionalField, - ContextField, - ParamsValidationInfo, - add_validation_warning, - contextual_field_validator, +from flow360_schema.models.simulation.meshing_param.meshing_specs import ( + MeshingDefaults, + OctreeSpacing, + VolumeMeshingDefaults, ) -from flow360.component.simulation.validation.validation_utils import ( - check_geometry_ai_features, -) -from flow360.log import log - - -class OctreeSpacing(Flow360BaseModel): - """ - Helper class for octree-based meshers. Holds the base for the octree spacing and lows calculation of levels. - """ - - # pylint: disable=no-member - base_spacing: Length.PositiveFloat64 - - @pd.model_validator(mode="before") - @classmethod - def _reject_plain_value(cls, input_data): - if isinstance(input_data, u.unyt.unyt_quantity): - raise ValueError( - "Passing a plain dimensional value to OctreeSpacing is not supported. " - "Use OctreeSpacing(base_spacing=) instead." - ) - return input_data - - @pd.validate_call - def __getitem__(self, idx: int): - return self.base_spacing * (2 ** (-idx)) - - # pylint: disable=no-member - @pd.validate_call - def to_level(self, spacing: Length.PositiveFloat64): - """ - Can be used to check in what refinement level would the given spacing result - and if it is a direct match in the spacing series. - """ - level = -log2(spacing / self.base_spacing) - - direct_spacing = np.isclose(level, np.round(level), atol=1e-8) - returned_level = np.round(level) if direct_spacing else np.ceil(level) - return returned_level, direct_spacing - - # pylint: disable=no-member - @pd.validate_call - def check_spacing(self, spacing: Length.PositiveFloat64, location: str): - """Warn if the given spacing does not align with the octree series.""" - lvl, close = self.to_level(spacing) - if not close: - spacing_unit = spacing.units - closest_spacing = self[lvl] - msg = ( - f"The spacing of {spacing:.4g} specified in {location} will be cast " - f"to the first lower refinement in the octree series " - f"({closest_spacing.to(spacing_unit):.4g})." - ) - log.warning(msg) - - -def set_default_octree_spacing(octree_spacing, param_info: ParamsValidationInfo): - """Shared logic for defaulting octree_spacing to 1 * project_length_unit.""" - if octree_spacing is not None: - return octree_spacing - if param_info.project_length_unit is None: - add_validation_warning( - "No project length unit found; `octree_spacing` will not be set automatically. " - "Octree spacing validation will be skipped." - ) - return octree_spacing - - # pylint: disable=no-member - project_length = 1 * param_info.project_length_unit - return OctreeSpacing(base_spacing=project_length) - - -class MeshingDefaults(Flow360BaseModel): - """ - Default/global settings for meshing parameters. - - Example - ------- - - >>> fl.MeshingDefaults( - ... surface_max_edge_length=1*fl.u.m, - ... surface_edge_growth_rate=1.2, - ... curvature_resolution_angle=12*fl.u.deg, - ... boundary_layer_growth_rate=1.1, - ... boundary_layer_first_layer_thickness=1e-5*fl.u.m - ... ) - - ==== - """ - - # pylint: disable=no-member - geometry_accuracy: Optional[Length.PositiveFloat64] = pd.Field( - None, - description="The smallest length scale that will be resolved accurately by the surface meshing process. " - "This parameter is only valid when using geometry AI." - "It can be overridden with class: ~flow360.GeometryRefinement.", - ) - - ##:: Default surface edge settings - surface_edge_growth_rate: float = ContextField( - 1.2, - ge=1, - description="Growth rate of the anisotropic layers grown from the edges." - "This can not be overridden per edge.", - context=SURFACE_MESH, - ) - - ##:: Default boundary layer settings - boundary_layer_growth_rate: float = ContextField( - 1.2, - description="Default growth rate for volume prism layers.", - ge=1, - context=VOLUME_MESH, - ) - # pylint: disable=no-member - boundary_layer_first_layer_thickness: Optional[Length.PositiveFloat64] = ConditionalField( - None, - description="Default first layer thickness for volumetric anisotropic layers." - " This can be overridden with :class:`~flow360.BoundaryLayer`.", - context=VOLUME_MESH, - ) # Truly optional if all BL faces already have first_layer_thickness - - number_of_boundary_layers: Optional[pd.NonNegativeInt] = pd.Field( - None, - description="Default number of volumetric anisotropic layers." - " The volume mesher will automatically calculate the required" - " no. of layers to grow the boundary layer elements to isotropic size if not specified." - " This is only supported by the beta mesher and can not be overridden per face.", - ) - - planar_face_tolerance: pd.NonNegativeFloat = pd.Field( - DEFAULT_PLANAR_FACE_TOLERANCE, - strict=True, - description="Tolerance used for detecting planar faces in the input surface mesh / geometry" - " that need to be remeshed, such as symmetry planes." - " This tolerance is non-dimensional, and represents a distance" - " relative to the largest dimension of the bounding box of the input surface mesh / geometry." - " This can not be overridden per face.", - ) - # pylint: disable=duplicate-code - sliding_interface_tolerance: pd.NonNegativeFloat = ConditionalField( - DEFAULT_SLIDING_INTERFACE_TOLERANCE, - strict=True, - description="Tolerance used for detecting / creating curves in the input surface mesh / geometry lying on" - " sliding interfaces. This tolerance is non-dimensional, and represents a distance" - " relative to the smallest radius of all sliding interfaces specified in meshing parameters." - " This cannot be overridden per sliding interface.", - context=VOLUME_MESH, - ) - - ##:: Default surface layer settings - surface_max_edge_length: Optional[Length.PositiveFloat64] = ConditionalField( - None, - description="Default maximum edge length for surface cells." - " This can be overridden with :class:`~flow360.SurfaceRefinement`.", - context=SURFACE_MESH, - ) - - surface_max_aspect_ratio: pd.PositiveFloat = ConditionalField( - 10.0, - description="Maximum aspect ratio for surface cells for the GAI surface mesher." - " This cannot be overridden per face", - context=SURFACE_MESH, - ) - - surface_max_adaptation_iterations: pd.NonNegativeInt = ConditionalField( - 50, - description="Maximum adaptation iterations for the GAI surface mesher.", - context=SURFACE_MESH, - ) - - target_surface_node_count: Optional[pd.PositiveInt] = ContextField( - None, - description="Target number of surface mesh nodes. When specified, the surface mesher " - "will rescale the meshing parameters to achieve approximately this number of nodes. " - "This option is only supported by the beta surface mesher or when using geometry AI, " - "and can not be overridden per face.", - context=SURFACE_MESH, - ) - - curvature_resolution_angle: Angle.PositiveFloat64 = ContextField( - 12 * u.deg, - description=( - "Default maximum angular deviation in degrees. This value will restrict:" - " 1. The angle between a cell's normal and its underlying surface normal." - " 2. The angle between a line segment's normal and its underlying curve normal." - " This can be overridden per face only when using geometry AI." - ), - context=SURFACE_MESH, - ) - - resolve_face_boundaries: bool = pd.Field( - False, - description="Flag to specify whether boundaries between adjacent faces should be resolved " - + "accurately during the surface meshing process using anisotropic mesh refinement. " - + "This option is only supported when using geometry AI, and can be overridden " - + "per face with :class:`~flow360.SurfaceRefinement`.", - ) - - preserve_thin_geometry: bool = pd.Field( - False, - description="Flag to specify whether thin geometry features with thickness roughly equal " - + "to geometry_accuracy should be resolved accurately during the surface meshing process. " - + "This option is only supported when using geometry AI, and can be overridden " - + "per face with :class:`~flow360.GeometryRefinement`.", - ) - - sealing_size: Length.NonNegativeFloat64 = pd.Field( - 0.0 * u.m, - description="Threshold size below which all geometry gaps are automatically closed. " - + "This option is only supported when using geometry AI, and can be overridden " - + "per face with :class:`~flow360.GeometryRefinement`.", - ) - - remove_hidden_geometry: bool = pd.Field( - False, - description="Flag to remove hidden geometry that is not visible to flow. " - + "This option is only supported when using geometry AI.", - ) - - min_passage_size: Optional[Length.PositiveFloat64] = pd.Field( - None, - description="Minimum passage size that hidden geometry removal can resolve. " - + "Internal regions connected by thin passages smaller than this size may not be detected. " - + "If not specified, the value is derived from geometry_accuracy and sealing_size. " - + "This option is only supported when using geometry AI.", - ) - - edge_split_layers: int = pd.Field( - 1, - ge=0, - # Skip default-value validation so warnings are emitted only when users explicitly set this field. - validate_default=False, - description="The number of layers that are considered for edge splitting in the boundary layer mesh." - + "This only affects beta mesher.", - ) - - octree_spacing: Optional[OctreeSpacing] = pd.Field( - None, - description="Octree spacing configuration for volume meshing. " - "If specified, this will be used to control the base spacing for octree-based meshers.", - ) - - @pd.model_validator(mode="before") - @classmethod - def remove_deprecated_arguments(cls, value): - """ - Detect when invoking the constructor of the MeshingDefaults() - (Warning: contrary to deserializing data, which is supposed to be handled by the updater.py) - If the user added the remove_non_manifold_faces in the argument, pop the argument and give warning - that this is no longer supported. - """ - if not isinstance(value, dict): - return value - - if "remove_non_manifold_faces" in value: - value.pop("remove_non_manifold_faces", None) - message = ( - "`meshing.defaults.remove_non_manifold_faces` is no longer supported and has been " - + "ignored. Set `meshing.defaults.remove_hidden_geometry` instead." - ) - add_validation_warning(message) - - return value - - @contextual_field_validator("number_of_boundary_layers", mode="after") - @classmethod - def invalid_number_of_boundary_layers(cls, value, param_info: ParamsValidationInfo): - """Ensure number of boundary layers is not specified""" - if value is not None and not param_info.is_beta_mesher: - raise ValueError("Number of boundary layers is only supported by the beta mesher.") - return value - - @contextual_field_validator("edge_split_layers", mode="after") - @classmethod - def invalid_edge_split_layers(cls, value, param_info: ParamsValidationInfo): - """Ensure edge split layers is only configured for beta mesher.""" - if value > 0 and not param_info.is_beta_mesher: - add_validation_warning( - "`edge_split_layers` is only supported by the beta mesher; " - "this setting will be ignored." - ) - return value - - @contextual_field_validator("geometry_accuracy", mode="after") - @classmethod - def invalid_geometry_accuracy(cls, value, param_info: ParamsValidationInfo): - """Ensure geometry accuracy is not specified when GAI is not used""" - if value is not None and not param_info.use_geometry_AI: - raise ValueError("Geometry accuracy is only supported when geometry AI is used.") - - if value is None and param_info.use_geometry_AI: - raise ValueError("Geometry accuracy is required when geometry AI is used.") - - if ( - value is not None - and param_info.global_bounding_box is not None - and param_info.project_length_unit is not None - ): - relative_bounding_box_limit = 1e-6 - bbox_diag = param_info.global_bounding_box.diagonal * param_info.project_length_unit - ga_value = value - lower_limit = relative_bounding_box_limit * bbox_diag - if ga_value < lower_limit: - add_validation_warning( - f"geometry_accuracy ({ga_value}) is below the recommended value " - f"of {relative_bounding_box_limit} * bounding box diagonal ({lower_limit:.2e}). " - f"Please increase geometry_accuracy." - ) - - return value - - @contextual_field_validator( - "surface_max_aspect_ratio", - "surface_max_adaptation_iterations", - "resolve_face_boundaries", - "preserve_thin_geometry", - "sealing_size", - "remove_hidden_geometry", - "min_passage_size", - mode="after", - ) - @classmethod - def ensure_geometry_ai_features(cls, value, info, param_info: ParamsValidationInfo): - """Validate that the feature is only used when Geometry AI is enabled.""" - return check_geometry_ai_features(cls, value, info, param_info) - - @contextual_field_validator("target_surface_node_count", mode="after") - @classmethod - def ensure_target_surface_node_count_mesher(cls, value, param_info: ParamsValidationInfo): - """Validate that target_surface_node_count is only used with geometry AI or beta mesher.""" - if value is not None and not (param_info.use_geometry_AI or param_info.is_beta_mesher): - raise ValueError("target_surface_node_count is not supported by the legacy mesher.") - return value - - @contextual_field_validator("octree_spacing", mode="after") - @classmethod - def _set_default_octree_spacing(cls, octree_spacing, param_info: ParamsValidationInfo): - """Set default octree_spacing to 1 * project_length_unit when not specified.""" - return set_default_octree_spacing(octree_spacing, param_info) - - @pd.model_validator(mode="after") - def validate_min_passage_size_requires_remove_hidden_geometry(self): - """Ensure min_passage_size is only specified when remove_hidden_geometry is True.""" - if self.min_passage_size is not None and not self.remove_hidden_geometry: - raise ValueError( - "'min_passage_size' can only be specified when 'remove_hidden_geometry' is True." - ) - return self - - -class VolumeMeshingDefaults(Flow360BaseModel): - """ - Default/global settings for volume meshing parameters. To be used with class:`ModularMeshingWorkflow`. - """ - - ##:: Default boundary layer settings - boundary_layer_growth_rate: float = pd.Field( - 1.2, - description="Default growth rate for volume prism layers.", - ge=1, - ) - # pylint: disable=no-member - boundary_layer_first_layer_thickness: Length.PositiveFloat64 = pd.Field( - description="Default first layer thickness for volumetric anisotropic layers." - " This can be overridden with :class:`~flow360.BoundaryLayer`.", - ) - - number_of_boundary_layers: Optional[pd.NonNegativeInt] = pd.Field( - None, - description="Default number of volumetric anisotropic layers." - " The volume mesher will automatically calculate the required" - " no. of layers to grow the boundary layer elements to isotropic size if not specified." - " This is only supported by the beta mesher and can not be overridden per face.", - ) - - octree_spacing: Optional[OctreeSpacing] = pd.Field( - None, - description="Octree spacing configuration for volume meshing. " - "If specified, this will be used to control the base spacing for octree-based meshers.", - ) - - @contextual_field_validator("octree_spacing", mode="after") - @classmethod - def _set_default_octree_spacing(cls, octree_spacing, param_info: ParamsValidationInfo): - """Set default octree_spacing to 1 * project_length_unit when not specified.""" - return set_default_octree_spacing(octree_spacing, param_info) diff --git a/flow360/component/simulation/meshing_param/meshing_validators.py b/flow360/component/simulation/meshing_param/meshing_validators.py index 44c84352f..ed8ffdcc9 100644 --- a/flow360/component/simulation/meshing_param/meshing_validators.py +++ b/flow360/component/simulation/meshing_param/meshing_validators.py @@ -1,30 +1,17 @@ -"""Shared validation helpers for meshing parameters.""" +"""Meshing validators — re-import relay.""" -import flow360.component.simulation.units as u -from flow360.component.simulation.meshing_param.volume_params import UniformRefinement -from flow360.component.simulation.primitives import Box, Cylinder +from importlib import import_module +_EXPORTED_NAMES = {"validate_snappy_uniform_refinement_entities"} -def validate_snappy_uniform_refinement_entities(refinement: UniformRefinement): - """Validate that a UniformRefinement's entities are compatible with snappyHexMesh. - Raises ValueError if any Box has a non-axis-aligned rotation or any Cylinder is hollow. - """ - # pylint: disable=no-member - for entity in refinement.entities.stored_entities: - if ( - isinstance(entity, Box) - and entity.angle_of_rotation.to("deg") % (360 * u.deg) != 0 * u.deg - ): - raise ValueError( - "UniformRefinement for snappy accepts only Boxes with axes aligned" - + " with the global coordinate system (angle_of_rotation=0)." - ) - if ( - isinstance(entity, Cylinder) - and entity.inner_radius is not None - and entity.inner_radius.to("m") != 0 * u.m - ): - raise ValueError( - "UniformRefinement for snappy accepts only full cylinders (where inner_radius = 0)." - ) +def __getattr__(name): + if name not in _EXPORTED_NAMES: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + schema_module = import_module( + "flow360_schema.models.simulation.meshing_param.meshing_validators" + ) + value = getattr(schema_module, name) + globals()[name] = value + return value diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index 49de5c730..eb19dbf3c 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -1,871 +1,17 @@ -"""Meshing related parameters for volume and surface mesher.""" +"""Relay import for simulation meshing parameter models.""" -from typing import Annotated, List, Literal, Optional, Union +# pylint: disable=unused-import -import pydantic as pd -from typing_extensions import Self - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.updater import ( - DEFAULT_PLANAR_FACE_TOLERANCE, - DEFAULT_SLIDING_INTERFACE_TOLERANCE, -) -from flow360.component.simulation.meshing_param import snappy -from flow360.component.simulation.meshing_param.edge_params import SurfaceEdgeRefinement -from flow360.component.simulation.meshing_param.face_params import ( - BoundaryLayer, - GeometryRefinement, - PassiveSpacing, - SurfaceRefinement, -) -from flow360.component.simulation.meshing_param.meshing_specs import ( +from flow360_schema.models.simulation.meshing_param import snappy +from flow360_schema.models.simulation.meshing_param.params import ( MeshingDefaults, + MeshingParams, + ModularMeshingWorkflow, + RefinementTypes, + SurfaceMeshingParams, VolumeMeshingDefaults, + VolumeMeshingParams, + VolumeRefinementTypes, + VolumeZonesTypes, + ZoneTypesModular, ) -from flow360.component.simulation.meshing_param.meshing_validators import ( - validate_snappy_uniform_refinement_entities, -) -from flow360.component.simulation.meshing_param.volume_params import ( - AutomatedFarfield, - AxisymmetricRefinement, - CustomVolume, - CustomZones, - MeshSliceOutput, - RotationCylinder, - RotationSphere, - RotationVolume, - StructuredBoxRefinement, - UniformRefinement, - UserDefinedFarfield, - WindTunnelFarfield, - _FarfieldAllowingEnclosedEntities, -) -from flow360.component.simulation.primitives import ( - AxisymmetricBody, - Cylinder, - SeedpointVolume, - Sphere, -) -from flow360.component.simulation.validation.validation_context import ( - SURFACE_MESH, - VOLUME_MESH, - ContextField, - ParamsValidationInfo, - add_validation_warning, - contextual_field_validator, - contextual_model_validator, -) -from flow360.component.simulation.validation.validation_utils import EntityUsageMap -from flow360.log import log - -RefinementTypes = Annotated[ - Union[ - SurfaceEdgeRefinement, - SurfaceRefinement, - GeometryRefinement, - BoundaryLayer, - PassiveSpacing, - UniformRefinement, - StructuredBoxRefinement, - AxisymmetricRefinement, - ], - pd.Field(discriminator="refinement_type"), -] - -VolumeZonesTypes = Annotated[ - Union[ - RotationVolume, - RotationCylinder, - RotationSphere, - AutomatedFarfield, - UserDefinedFarfield, - CustomZones, - WindTunnelFarfield, - ], - pd.Field(discriminator="type"), -] - -ZoneTypesModular = Annotated[ - Union[ - RotationVolume, - RotationSphere, - AutomatedFarfield, - UserDefinedFarfield, - CustomZones, - ], - pd.Field(discriminator="type"), -] - -VolumeRefinementTypes = Annotated[ - Union[ - UniformRefinement, - AxisymmetricRefinement, - BoundaryLayer, - PassiveSpacing, - StructuredBoxRefinement, - ], - pd.Field(discriminator="refinement_type"), -] - - -def _collect_rotation_entity_names(zones, param_info, zone_types): - """Collect entity names associated with RotationVolume/RotationCylinder/RotationSphere zones.""" - names: set[str] = set() - for zone in zones: - if isinstance(zone, zone_types): - for entity in param_info.expand_entity_list(zone.entities): - names.add(entity.name) - return names - - -def _validate_farfield_enclosed_entities( - zones, rotation_entity_names, has_custom_volumes, param_info -): - """Validate farfield enclosed_entities: require CustomVolumes and rotation-volume association. - Only applies to farfield types that support enclosed_entities (Automated, WindTunnel). - """ - for zone in zones: - if not isinstance(zone, _FarfieldAllowingEnclosedEntities): - continue - - if zone.enclosed_entities is None: - if has_custom_volumes: - raise ValueError( - "`enclosed_entities` for farfield must be specified when " - "`CustomVolume` entities are present in volume zones." - ) - continue - - if not has_custom_volumes: - raise ValueError( - "`enclosed_entities` for farfield is only allowed when " - "`CustomVolume` entities are present in volume zones." - ) - - for entity in param_info.expand_entity_list(zone.enclosed_entities): - if ( - isinstance(entity, (Cylinder, AxisymmetricBody, Sphere)) - and entity.name not in rotation_entity_names - ): - raise ValueError( - f"`{type(entity).__name__}` entity `{entity.name}` in " - f"`enclosed_entities` must be associated with a `RotationVolume` or `RotationSphere`." - ) - - -def _collect_all_custom_volumes(zones): - """Collect all CustomVolume instances from CustomZones.""" - custom_volumes: list[CustomVolume] = [] - for zone in zones: - if isinstance(zone, CustomZones): - for cv in zone.entities.stored_entities: - if isinstance(cv, CustomVolume): - custom_volumes.append(cv) - return custom_volumes - - -def _validate_custom_volume_rotation_association(custom_volumes, rotation_entity_names, param_info): - """Validate that Cylinder/AxisymmetricBody/Sphere in CustomVolume.bounding_entities - are associated with a RotationVolume or RotationSphere.""" - for cv in custom_volumes: - for entity in param_info.expand_entity_list(cv.bounding_entities): - if ( - isinstance(entity, (Cylinder, AxisymmetricBody, Sphere)) - and entity.name not in rotation_entity_names - ): - raise ValueError( - f"`{type(entity).__name__}` entity `{entity.name}` in " - f"`CustomVolume` `{cv.name}` `bounding_entities` must be " - f"associated with a `RotationVolume` or `RotationSphere`." - ) - - -class MeshingParams(Flow360BaseModel): - """ - Meshing parameters for volume and/or surface mesher. This contains all the meshing related settings. - - Example - ------- - - >>> fl.MeshingParams( - ... refinement_factor=1.0, - ... gap_treatment_strength=0.5, - ... defaults=fl.MeshingDefaults( - ... surface_max_edge_length=1*fl.u.m, - ... boundary_layer_first_layer_thickness=1e-5*fl.u.m - ... ), - ... volume_zones=[farfield], - ... refinements=[ - ... fl.SurfaceEdgeRefinement( - ... edges=[geometry["edge1"], geometry["edge2"]], - ... method=fl.AngleBasedRefinement(value=8*fl.u.deg) - ... ), - ... fl.SurfaceRefinement( - ... faces=[geometry["face1"], geometry["face2"]], - ... max_edge_length=0.001*fl.u.m - ... ), - ... fl.UniformRefinement( - ... entities=[cylinder, box], - ... spacing=1*fl.u.cm - ... ) - ... ] - ... ) - - ==== - """ - - type_name: Literal["MeshingParams"] = pd.Field("MeshingParams", frozen=True) - refinement_factor: Optional[pd.PositiveFloat] = pd.Field( - default=1, - description="All spacings in refinement regions" - + "and first layer thickness will be adjusted to generate `r`-times" - + " finer mesh where r is the refinement_factor value.", - ) - - # pylint: disable=duplicate-code - gap_treatment_strength: Optional[float] = ContextField( - default=None, - ge=0, - le=1, - description="Narrow gap treatment strength used when two surfaces are in close proximity." - " Use a value between 0 and 1, where 0 is no treatment and 1 is the most conservative treatment." - " This parameter has a global impact where the anisotropic transition into the isotropic mesh." - " However the impact on regions without close proximity is negligible." - " The beta mesher uses a conservative default value of 1.0.", - context=VOLUME_MESH, - ) - - defaults: MeshingDefaults = pd.Field( - MeshingDefaults(), - description="Default settings for meshing." - " In other words the settings specified here will be applied" - " as a default setting for all `Surface` (s) and `Edge` (s).", - ) - - refinements: List[RefinementTypes] = pd.Field( - default=[], - description="Additional fine-tunning for refinements on top of :py:attr:`defaults`", - ) - # Will add more to the Union - volume_zones: Optional[List[VolumeZonesTypes]] = pd.Field( - default=None, description="Creation of new volume zones." - ) - - # Meshing outputs (for now, volume mesh slices) - outputs: List[MeshSliceOutput] = pd.Field( - default=[], - description="Mesh output settings.", - ) - - @pd.field_validator("volume_zones", mode="after") - @classmethod - def _check_volume_zones_has_farfield(cls, v): - if v is None: - # User did not put anything in volume_zones so may not want to use volume meshing - return v - - total_farfield = sum( - isinstance( - volume_zone, - (AutomatedFarfield, WindTunnelFarfield, UserDefinedFarfield), - ) - for volume_zone in v - ) - if total_farfield == 0: - raise ValueError("Farfield zone is required in `volume_zones`.") - - if total_farfield > 1: - raise ValueError("Only one farfield zone is allowed in `volume_zones`.") - - return v - - @contextual_field_validator("volume_zones", mode="after") - @classmethod - def _check_automated_farfield_custom_volumes(cls, v, param_info): - if v is None: - return v - - automated_farfield = next((zone for zone in v if isinstance(zone, AutomatedFarfield)), None) - if automated_farfield is not None: - custom_volumes = _collect_all_custom_volumes(v) - if any(cv.name == "farfield" for cv in custom_volumes): - raise ValueError( - "CustomVolume name 'farfield' is reserved when using AutomatedFarfield. " - "The 'farfield' zone will be automatically generated using `AutomatedFarfield.enclosed_entities`. " - "Please choose a different name." - ) - - enclosed_entities = ( - param_info.expand_entity_list(automated_farfield.enclosed_entities) - if automated_farfield.enclosed_entities is not None - else [] - ) - - if custom_volumes and not enclosed_entities: - raise ValueError( - "When using AutomatedFarfield with CustomVolumes, `enclosed_entities` must be " - "specified on the AutomatedFarfield to define the exterior farfield zone boundary." - ) - if enclosed_entities and not custom_volumes: - raise ValueError( - "`enclosed_entities` on AutomatedFarfield is only allowed when CustomVolume entities are used. " - "Without custom volumes, the farfield zone will be automatically detected." - ) - if any(s.name == "farfield" for s in enclosed_entities): - raise ValueError( - "Surface name 'farfield' in `enclosed_entities` will conflict with the automatically " - "generated farfield boundary. Please choose a different surface." - ) - - return v - - @contextual_field_validator("volume_zones", mode="after") - @classmethod - def _check_enclosed_entities_rotation_volume_association( - cls, v, param_info: ParamsValidationInfo - ): - """ - Ensure that: - - enclosed_entities on any farfield requires at least one CustomZone - - Cylinder, AxisymmetricBody, and Sphere entities in enclosed_entities - are associated with a RotationVolume or RotationSphere - """ - if v is None: - return v - - rotation_entity_names = _collect_rotation_entity_names( - v, param_info, (RotationVolume, RotationCylinder, RotationSphere) - ) - custom_volumes = _collect_all_custom_volumes(v) - has_custom_volumes = len(custom_volumes) > 0 - _validate_farfield_enclosed_entities( - v, rotation_entity_names, has_custom_volumes, param_info - ) - _validate_custom_volume_rotation_association( - custom_volumes, rotation_entity_names, param_info - ) - - return v - - @contextual_field_validator("volume_zones", mode="after") - @classmethod - def _check_volume_zones_have_unique_names(cls, v): - """Ensure there won't be duplicated volume zone names.""" - - if v is None: - return v - - to_be_generated_volume_zone_names = set() - for volume_zone in v: - if not isinstance(volume_zone, CustomZones): - continue - # Extract CustomVolume from CustomZones - for custom_volume in volume_zone.entities.stored_entities: - if custom_volume.name in to_be_generated_volume_zone_names: - raise ValueError( - f"Multiple CustomVolume with the same name `{custom_volume.name}` are not allowed." - ) - to_be_generated_volume_zone_names.add(custom_volume.name) - - return v - - @contextual_model_validator(mode="after") - def _check_no_reused_volume_entities(self) -> Self: - """ - Meshing entities reuse check. - +------------------------+------------------------+------------------------+------------------------+ - | | RotationCylinder | AxisymmetricRefinement | UniformRefinement | - +------------------------+------------------------+------------------------+------------------------+ - | RotationCylinder | NO | -- | -- | - +------------------------+------------------------+------------------------+------------------------+ - | AxisymmetricRefinement | NO | NO | -- | - +------------------------+------------------------+------------------------+------------------------+ - | UniformRefinement | YES | NO | NO | - +------------------------+------------------------+------------------------+------------------------+ - - +------------------------+------------------------+------------------------+ - | |StructuredBoxRefinement | UniformRefinement | - +------------------------+------------------------+------------------------+ - |StructuredBoxRefinement | NO | -- | - +------------------------+------------------------+------------------------+ - | UniformRefinement | NO | NO | - +------------------------+------------------------+------------------------+ - - """ - - usage = EntityUsageMap() - - for volume_zone in self.volume_zones if self.volume_zones is not None else []: - if isinstance(volume_zone, (RotationVolume, RotationCylinder, RotationSphere)): - # pylint: disable=protected-access - _ = [ - usage.add_entity_usage(item, volume_zone.type) - for item in volume_zone.entities.stored_entities - ] - - for refinement in self.refinements if self.refinements is not None else []: - if isinstance( - refinement, - (UniformRefinement, AxisymmetricRefinement, StructuredBoxRefinement), - ): - # pylint: disable=protected-access - _ = [ - usage.add_entity_usage(item, refinement.refinement_type) - for item in refinement.entities.stored_entities - ] - - error_msg = "" - for entity_type, entity_model_map in usage.dict_entity.items(): - for entity_info in entity_model_map.values(): - if len(entity_info["model_list"]) == 1 or sorted(entity_info["model_list"]) in [ - sorted(["RotationCylinder", "UniformRefinement"]), - sorted(["RotationVolume", "UniformRefinement"]), - sorted(["RotationSphere", "UniformRefinement"]), - ]: - continue - - model_set = set(entity_info["model_list"]) - if len(model_set) == 1: - error_msg += ( - f"{entity_type} entity `{entity_info['entity_name']}` " - + f"is used multiple times in `{model_set.pop()}`." - ) - else: - model_string = ", ".join(f"`{x}`" for x in sorted(model_set)) - error_msg += ( - f"Using {entity_type} entity `{entity_info['entity_name']}` " - + f"in {model_string} at the same time is not allowed." - ) - - if error_msg: - raise ValueError(error_msg) - - return self - - @contextual_model_validator(mode="after") - def _check_sizing_against_octree_series(self, param_info: ParamsValidationInfo): - """Validate that UniformRefinement spacings align with the octree series.""" - if not param_info.is_beta_mesher: - return self - if self.defaults.octree_spacing is None: # pylint: disable=no-member - log.warning( - "No `octree_spacing` configured in `%s`; " - "octree spacing validation for UniformRefinement will be skipped.", - type(self.defaults).__name__, - ) - return self - - if self.refinements is not None: - for refinement in self.refinements: # pylint: disable=not-an-iterable - if isinstance(refinement, UniformRefinement): - self.defaults.octree_spacing.check_spacing( # pylint: disable=no-member - refinement.spacing, type(refinement).__name__ - ) - return self - - @contextual_model_validator(mode="after") - def _warn_min_passage_size_without_remove_hidden_geometry(self) -> Self: - """Warn when GeometryRefinement specifies min_passage_size but remove_hidden_geometry is disabled.""" - if self.defaults.remove_hidden_geometry: # pylint: disable=no-member - return self - for refinement in self.refinements or []: - if ( - isinstance(refinement, GeometryRefinement) - and refinement.min_passage_size is not None - ): - add_validation_warning( - f"GeometryRefinement '{refinement.name}' specifies 'min_passage_size' but " - "'remove_hidden_geometry' is not enabled in meshing defaults. " - "The per-face 'min_passage_size' will be ignored." - ) - return self - - @contextual_model_validator(mode="after") - def _warn_multi_zone_remove_hidden_geometry(self) -> Self: - """Warn when remove_hidden_geometry is enabled with multiple farfield/custom volume zones.""" - if not self.defaults.remove_hidden_geometry: # pylint: disable=no-member - return self - if self.volume_zones is None: - return self - # AF and WTF each generate their own farfield zone but UDF does not, - # so it doesn't contribute to the zone count - has_non_udf_farfield = any( - isinstance(zone, (AutomatedFarfield, WindTunnelFarfield)) - for zone in self.volume_zones # pylint: disable=not-an-iterable - ) - count = len(_collect_all_custom_volumes(self.volume_zones)) + ( - 1 if has_non_udf_farfield else 0 - ) - if count > 1: - add_validation_warning( - "Multiple farfield/custom volume zones detected. Removal of hidden geometry " - "for multi-zone cases is not fully supported and may not work as intended." - ) - return self - - @property - def farfield_method(self): - """Returns the farfield method used.""" - if self.volume_zones: - for zone in self.volume_zones: # pylint: disable=not-an-iterable - if isinstance(zone, AutomatedFarfield): - return zone.method - if isinstance(zone, WindTunnelFarfield): - return "wind-tunnel" - if isinstance(zone, UserDefinedFarfield): - return "user-defined" - return None - - -class VolumeMeshingParams(Flow360BaseModel): - """ - Volume meshing parameters. - """ - - type_name: Literal["VolumeMeshingParams"] = pd.Field("VolumeMeshingParams", frozen=True) - defaults: VolumeMeshingDefaults = pd.Field() - refinement_factor: Optional[pd.PositiveFloat] = pd.Field( - default=1, - description="All spacings in refinement regions" - + "and first layer thickness will be adjusted to generate `r`-times" - + " finer mesh where r is the refinement_factor value.", - ) - - refinements: List[VolumeRefinementTypes] = pd.Field( - default=[], - description="Additional fine-tunning for refinements on top of the global settings", - ) - - planar_face_tolerance: pd.NonNegativeFloat = pd.Field( - DEFAULT_PLANAR_FACE_TOLERANCE, - description="Tolerance used for detecting planar faces in the input surface mesh" - " that need to be remeshed, such as symmetry planes." - " This tolerance is non-dimensional, and represents a distance" - " relative to the largest dimension of the bounding box of the input surface mesh." - " This is only supported by the beta mesher and can not be overridden per face.", - ) - - gap_treatment_strength: Optional[float] = pd.Field( - default=None, - ge=0, - le=1, - description="Narrow gap treatment strength used when two surfaces are in close proximity." - " Use a value between 0 and 1, where 0 is no treatment and 1 is the most conservative treatment." - " This parameter has a global impact where the anisotropic transition into the isotropic mesh." - " However the impact on regions without close proximity is negligible." - " The beta mesher uses a conservative default value of 1.0.", - ) - - sliding_interface_tolerance: pd.NonNegativeFloat = pd.Field( - DEFAULT_SLIDING_INTERFACE_TOLERANCE, - strict=True, - description="Tolerance used for detecting / creating curves in the input surface mesh / geometry lying on" - " sliding interfaces. This tolerance is non-dimensional, and represents a distance" - " relative to the smallest radius of all sliding interfaces specified in meshing parameters." - " This cannot be overridden per sliding interface.", - ) - - @contextual_model_validator(mode="after") - def _check_sizing_against_octree_series(self, param_info: ParamsValidationInfo): - """Validate that UniformRefinement spacings align with the octree series.""" - if not param_info.is_beta_mesher: - return self - if self.defaults.octree_spacing is None: # pylint: disable=no-member - log.warning( - "No `octree_spacing` configured in `%s`; " - "octree spacing validation for UniformRefinement will be skipped.", - type(self.defaults).__name__, - ) - return self - - if self.refinements is not None: - for refinement in self.refinements: # pylint: disable=not-an-iterable - if isinstance(refinement, UniformRefinement): - self.defaults.octree_spacing.check_spacing( # pylint: disable=no-member - refinement.spacing, type(refinement).__name__ - ) - - return self - - @contextual_model_validator(mode="after") - def _check_snappy_uniform_refinement_entities(self, param_info: ParamsValidationInfo): - """Validate projected UniformRefinement entities are compatible with snappyHexMesh.""" - if not param_info.use_snappy: - return self - for refinement in self.refinements: # pylint: disable=not-an-iterable - if ( - isinstance(refinement, UniformRefinement) - and refinement.project_to_surface is not False - ): - validate_snappy_uniform_refinement_entities(refinement) - return self - - -SurfaceMeshingParams = Annotated[ - Union[snappy.SurfaceMeshingParams], pd.Field(discriminator="type_name") -] - - -class ModularMeshingWorkflow(Flow360BaseModel): - """ - Structure consolidating surface and volume meshing parameters. - """ - - type_name: Literal["ModularMeshingWorkflow"] = pd.Field("ModularMeshingWorkflow", frozen=True) - surface_meshing: Optional[SurfaceMeshingParams] = ContextField( - default=None, context=SURFACE_MESH - ) - volume_meshing: Optional[VolumeMeshingParams] = ContextField(default=None, context=VOLUME_MESH) - zones: List[ZoneTypesModular] - - # Meshing outputs (for now, volume mesh slices) - outputs: List[MeshSliceOutput] = pd.Field( - default=[], - description="Mesh output settings.", - ) - - @pd.field_validator("zones", mode="after") - @classmethod - def _check_volume_zones_has_farfield(cls, v): - total_automated_farfield = sum( - isinstance(volume_zone, AutomatedFarfield) for volume_zone in v - ) - total_user_defined_farfield = sum( - isinstance(volume_zone, UserDefinedFarfield) for volume_zone in v - ) - total_custom_zones = sum(isinstance(volume_zone, CustomZones) for volume_zone in v) - - if total_custom_zones and total_user_defined_farfield: - raise ValueError("When using `CustomZones` the `UserDefinedFarfield` will be ignored.") - - if total_automated_farfield > 1: - raise ValueError("Only one `AutomatedFarfield` zone is allowed in `zones`.") - - if total_user_defined_farfield > 1: - raise ValueError("Only one `UserDefinedFarfield` zone is allowed in `zones`.") - - if total_automated_farfield + total_user_defined_farfield > 1: - raise ValueError( - "Cannot use `AutomatedFarfield` and `UserDefinedFarfield` simultaneously." - ) - - if (total_user_defined_farfield + total_automated_farfield + total_custom_zones) == 0: - raise ValueError("At least one zone defining the farfield is required.") - - if total_automated_farfield and total_custom_zones: - raise ValueError("`CustomZones` cannot be used with `AutomatedFarfield`.") - - return v - - @pd.field_validator("zones", mode="after") - @classmethod - def _check_volume_zones_have_unique_names(cls, v): - """Ensure there won't be duplicated volume zone names.""" - - if v is None: - return v - to_be_generated_volume_zone_names = set() - for volume_zone in v: - if isinstance(volume_zone, CustomZones): - for custom_volume in volume_zone.entities.stored_entities: - if custom_volume.name in to_be_generated_volume_zone_names: - raise ValueError( - f"Multiple `CustomVolume` with the same name `{custom_volume.name}` are not allowed." - ) - to_be_generated_volume_zone_names.add(custom_volume.name) - - return v - - @contextual_field_validator("zones", mode="after") - @classmethod - def _check_enclosed_entities_rotation_volume_association( - cls, v, param_info: ParamsValidationInfo - ): - """ - Ensure that: - - enclosed_entities on any farfield requires at least one CustomZone - - Cylinder, AxisymmetricBody, and Sphere entities in enclosed_entities - are associated with a RotationVolume or RotationSphere - """ - if v is None: - return v - - has_custom_zones = any(isinstance(zone, CustomZones) for zone in v) - rotation_entity_names = _collect_rotation_entity_names( - v, param_info, (RotationVolume, RotationSphere) - ) - _validate_farfield_enclosed_entities(v, rotation_entity_names, has_custom_zones, param_info) - custom_volumes = _collect_all_custom_volumes(v) - _validate_custom_volume_rotation_association( - custom_volumes, rotation_entity_names, param_info - ) - - return v - - @pd.model_validator(mode="after") - def _check_snappy_zones(self) -> Self: - total_custom_volumes = 0 - total_seedpoint_volumes = 0 - for zone in self.zones: # pylint: disable=not-an-iterable - if isinstance(zone, CustomZones): - for custom_volume in zone.entities.stored_entities: - if isinstance(custom_volume, CustomVolume): - total_custom_volumes += 1 - if isinstance(custom_volume, SeedpointVolume): - total_seedpoint_volumes += 1 - - if isinstance(self.surface_meshing, snappy.SurfaceMeshingParams): - if total_seedpoint_volumes and total_custom_volumes: - raise ValueError( - "Volume zones with snappyHexMeshing are defined using `SeedpointVolume`, not `CustomZones`." - ) - - if self.farfield_method != "auto" and not total_seedpoint_volumes: - raise ValueError( - "snappyHexMeshing requires at least one `SeedpointVolume` when not using `AutomatedFarfield`." - ) - - else: - if total_seedpoint_volumes: - raise ValueError("`SeedpointVolume` is applicable only with snappyHexMeshing.") - - return self - - @contextual_model_validator(mode="after") - def _check_uniform_refinement_names_not_in_snappy_bodies( # pylint: disable=too-many-branches - self, param_info: ParamsValidationInfo - ) -> Self: - """Ensure no UniformRefinement entity shares a name with a SnappyBody in the geometry.""" - - if not isinstance(self.surface_meshing, snappy.SurfaceMeshingParams): - return self - - entity_info = param_info.get_entity_info() - if entity_info is None or getattr(entity_info, "type_name", None) != "GeometryEntityInfo": - return self - - # pylint: disable=protected-access - try: - snappy_body_names = {body.name for body in entity_info._get_snappy_bodies()} - except (ValueError, IndexError, AttributeError): - return self - - if not snappy_body_names: - return self - - conflicting: list[str] = [] - - # Surface meshing: all UniformRefinement entities - # pylint: disable=no-member - if self.surface_meshing is not None and self.surface_meshing.refinements is not None: - for refinement in self.surface_meshing.refinements: - if isinstance(refinement, UniformRefinement): - for entity in refinement.entities.stored_entities: - if entity.name in snappy_body_names: - conflicting.append(entity.name) - - # Volume meshing: UniformRefinement entities that project to surface - # (project_to_surface defaults to True for snappy, so None counts as True) - # pylint: disable=no-member - if self.volume_meshing is not None and self.volume_meshing.refinements is not None: - for refinement in self.volume_meshing.refinements: - if isinstance(refinement, UniformRefinement) and ( - refinement.project_to_surface is not False - ): - for entity in refinement.entities.stored_entities: - if entity.name in snappy_body_names: - conflicting.append(entity.name) - - if conflicting: - names_str = ", ".join(f"`{name}`" for name in dict.fromkeys(conflicting)) - raise ValueError( - f"UniformRefinement entity name(s) {names_str} conflict with SnappyBody name(s)" - " in the geometry. Please use different names for the UniformRefinement entities." - ) - - return self - - @contextual_model_validator(mode="after") - def _check_no_reused_volume_entities(self) -> Self: - """ - Meshing entities reuse check. - +------------------------+------------------------+------------------------+------------------------+ - | | RotationCylinder | AxisymmetricRefinement | UniformRefinement | - +------------------------+------------------------+------------------------+------------------------+ - | RotationCylinder | NO | -- | -- | - +------------------------+------------------------+------------------------+------------------------+ - | AxisymmetricRefinement | NO | NO | -- | - +------------------------+------------------------+------------------------+------------------------+ - | UniformRefinement | YES | NO | NO | - +------------------------+------------------------+------------------------+------------------------+ - - +------------------------+------------------------+------------------------+ - | |StructuredBoxRefinement | UniformRefinement | - +------------------------+------------------------+------------------------+ - |StructuredBoxRefinement | NO | -- | - +------------------------+------------------------+------------------------+ - | UniformRefinement | NO | NO | - +------------------------+------------------------+------------------------+ - - """ - - usage = EntityUsageMap() - - for volume_zone in self.zones if self.zones is not None else []: - if isinstance(volume_zone, (RotationVolume, RotationSphere)): - _ = [ - usage.add_entity_usage(item, volume_zone.type) - for item in volume_zone.entities.stored_entities - ] - # pylint: disable=no-member - for refinement in ( - self.volume_meshing.refinements - if (self.volume_meshing is not None and self.volume_meshing.refinements is not None) - else [] - ): - if isinstance( - refinement, - (UniformRefinement, AxisymmetricRefinement, StructuredBoxRefinement), - ): - _ = [ - usage.add_entity_usage(item, refinement.refinement_type) - for item in refinement.entities.stored_entities - ] - - error_msg = "" - for entity_type, entity_model_map in usage.dict_entity.items(): - for entity_info in entity_model_map.values(): - if len(entity_info["model_list"]) == 1 or sorted(entity_info["model_list"]) in [ - sorted(["RotationCylinder", "UniformRefinement"]), - sorted(["RotationVolume", "UniformRefinement"]), - sorted(["RotationSphere", "UniformRefinement"]), - ]: - continue - - model_set = set(entity_info["model_list"]) - if len(model_set) == 1: - error_msg += ( - f"{entity_type} entity `{entity_info['entity_name']}` " - + f"is used multiple times in `{model_set.pop()}`." - ) - else: - model_string = ", ".join(f"`{x}`" for x in sorted(model_set)) - error_msg += ( - f"Using {entity_type} entity `{entity_info['entity_name']}` " - + f"in {model_string} at the same time is not allowed." - ) - - if error_msg: - raise ValueError(error_msg) - - return self - - @property - def farfield_method(self): - """Returns the farfield method used.""" - if self.zones: - for zone in self.zones: # pylint: disable=not-an-iterable - if isinstance(zone, AutomatedFarfield): - return zone.method - return "user-defined" - return None diff --git a/flow360/component/simulation/meshing_param/snappy/snappy_mesh_refinements.py b/flow360/component/simulation/meshing_param/snappy/snappy_mesh_refinements.py index fa4c53310..32e629030 100644 --- a/flow360/component/simulation/meshing_param/snappy/snappy_mesh_refinements.py +++ b/flow360/component/simulation/meshing_param/snappy/snappy_mesh_refinements.py @@ -1,170 +1,11 @@ -"""Reinements for surface meshing""" +"""Relay import for snappy refinement models.""" -from abc import ABCMeta -from typing import Annotated, List, Literal, Optional, Union +# pylint: disable=unused-import -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Angle, Length -from typing_extensions import Self - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.meshing_param.volume_params import UniformRefinement -from flow360.component.simulation.primitives import SnappyBody, Surface -from flow360.log import log - - -class SnappyEntityRefinement(Flow360BaseModel, metaclass=ABCMeta): - """ - Base refinement for snappyHexMesh. - """ - - # pylint: disable=no-member - min_spacing: Optional[Length.PositiveFloat64] = pd.Field(None) - max_spacing: Optional[Length.PositiveFloat64] = pd.Field(None) - proximity_spacing: Optional[Length.PositiveFloat64] = pd.Field(None) - - @pd.model_validator(mode="after") - def _check_spacing_order(self) -> Self: - if self.min_spacing and self.max_spacing: - if self.min_spacing > self.max_spacing: - raise ValueError("Minimum spacing must be lower than maximum spacing.") - return self - - @pd.model_validator(mode="after") - def _check_proximity_spacing(self) -> Self: - if self.min_spacing and self.proximity_spacing: - if self.proximity_spacing > self.min_spacing: - log.warning( - f"Proximity spacing ({self.proximity_spacing}) was set higher than the minimal spacing" - + f"({self.min_spacing}), setting proximity spacing to minimal spacing." - ) - self.proximity_spacing = self.min_spacing - return self - - -class BodyRefinement(SnappyEntityRefinement): - """ - Refinement for snappyHexMesh body (searchableSurfaceWithGaps). - """ - - # pylint: disable=no-member - refinement_type: Literal["SnappyBodyRefinement"] = pd.Field("SnappyBodyRefinement", frozen=True) - gap_resolution: Optional[Length.NonNegativeFloat64] = pd.Field(None) - entities: EntityList[SnappyBody] = pd.Field(alias="bodies") - - @pd.model_validator(mode="after") - def _check_parameters_given(self) -> Self: - if ( - self.gap_resolution is None - and self.min_spacing is None - and self.max_spacing is None - and self.proximity_spacing is None - ): - raise ValueError( - "No refinement (gap_resolution, min_spacing, max_spacing, proximity_spacing)" - " specified in `BodyRefinement`." - ) - - return self - - -class RegionRefinement(SnappyEntityRefinement): - """ - Refinement for the body region in snappyHexMesh. - """ - - # pylint: disable=no-member - min_spacing: Length.PositiveFloat64 = pd.Field() - max_spacing: Length.PositiveFloat64 = pd.Field() - refinement_type: Literal["SnappySurfaceRefinement"] = pd.Field( - "SnappySurfaceRefinement", frozen=True - ) - entities: EntityList[Surface] = pd.Field(alias="regions") - - -class SurfaceEdgeRefinement(Flow360BaseModel): - """ - Edge refinement for bodies and regions in snappyHexMesh. - """ - - # pylint: disable=no-member - refinement_type: Literal["SnappySurfaceEdgeRefinement"] = pd.Field( - "SnappySurfaceEdgeRefinement", frozen=True - ) - spacing: Optional[Union[Length.PositiveArray, Length.PositiveFloat64]] = pd.Field( - None, description="Spacing on and close to the edges. Defaults to default min_spacing." - ) - distances: Optional[Length.PositiveArray] = pd.Field( - None, description="Distance from the edge where the spacing will be applied." - ) - min_elem: Optional[pd.NonNegativeInt] = pd.Field( - None, description="Minimum number of elements on the edge to apply the edge refinement." - ) - min_len: Optional[Length.NonNegativeFloat64] = pd.Field( - None, description="Minimum length of the edge to apply edge refinement." - ) - included_angle: Angle.PositiveFloat64 = pd.Field( - 150 * u.deg, - description="If the angle between two elements is less than this value, the edge is extracted as a feature.", - ) - entities: EntityList[SnappyBody, Surface] = pd.Field(None) - retain_on_smoothing: bool = pd.Field( - True, description="Maintain the edge when smoothing is applied." - ) - geometric_test_only: bool = pd.Field( - False, - description="If enabled, only geometric tests are performed on the edge (region edge will be ignored).", - ) - - @pd.model_validator(mode="after") - def _check_spacing_format(self) -> Self: - distances_state = None - spacing_state = None - if self.distances is not None: - distances_state = (True, len(self.distances)) - else: - distances_state = (False, 0) - - # pylint: disable=fixme - # TODO: Note to self: - # TODO: Maybe we can come up with a more efficient and elegant way of differentiating Array from scalar - try: - spacing_state = (True, len(self.spacing)) - except TypeError: - # spacing is a scalar - spacing_state = (False, 1) - - if ( - distances_state[0] and spacing_state[0] and (spacing_state[1] != distances_state[1]) - ) or (distances_state[0] is not spacing_state[0]): - raise ValueError( - f"When using a distance spacing specification both spacing ({self.spacing}) and distances " - + f"({self.distances}) fields must be arrays and the same length." - ) - return self - - @pd.field_validator("spacing", "distances", mode="after") - @classmethod - def _check_spacings_increasing(cls, value): - if value is not None: - if isinstance(value.tolist(), List) and (sorted(value.tolist()) != value.tolist()): - raise ValueError("Spacings and distances must be increasing arrays.") - return value - - @pd.field_validator("spacing", "distances", mode="before") - @classmethod - def _convert_list_to_unyt_array(cls, value): - # Only coalesce lists of unyt quantities (e.g., [4*u.mm, 5*u.mm]) into a - # single unyt_array. Bare numeric lists (e.g., [0.004]) must NOT be wrapped - # so that the schema type validator can attach the correct SI unit. - if isinstance(value, List) and all(isinstance(v, u.unyt.unyt_quantity) for v in value): - return u.unyt.unyt_array(value) - return value - - -SnappySurfaceRefinementTypes = Annotated[ - Union[BodyRefinement, SurfaceEdgeRefinement, RegionRefinement, UniformRefinement], - pd.Field(discriminator="refinement_type"), -] +from flow360_schema.models.simulation.meshing_param.snappy.snappy_mesh_refinements import ( + BodyRefinement, + RegionRefinement, + SnappyEntityRefinement, + SnappySurfaceRefinementTypes, + SurfaceEdgeRefinement, +) diff --git a/flow360/component/simulation/meshing_param/snappy/snappy_params.py b/flow360/component/simulation/meshing_param/snappy/snappy_params.py index 037991475..5890aa0c1 100644 --- a/flow360/component/simulation/meshing_param/snappy/snappy_params.py +++ b/flow360/component/simulation/meshing_param/snappy/snappy_params.py @@ -1,180 +1,7 @@ -"""surface meshing parameters to use with snappyHexMesh""" +"""Relay import for snappy meshing parameter models.""" -from typing import List, Literal, Optional, Union +# pylint: disable=unused-import -import pydantic as pd - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.meshing_param.meshing_specs import OctreeSpacing -from flow360.component.simulation.meshing_param.meshing_validators import ( - validate_snappy_uniform_refinement_entities, -) -from flow360.component.simulation.meshing_param.snappy.snappy_mesh_refinements import ( - BodyRefinement, - RegionRefinement, - SnappyEntityRefinement, - SnappySurfaceRefinementTypes, - SurfaceEdgeRefinement, -) -from flow360.component.simulation.meshing_param.snappy.snappy_specs import ( - CastellatedMeshControls, - QualityMetrics, - SmoothControls, - SnapControls, - SurfaceMeshingDefaults, +from flow360_schema.models.simulation.meshing_param.snappy.snappy_params import ( + SurfaceMeshingParams, ) -from flow360.component.simulation.meshing_param.volume_params import UniformRefinement -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - contextual_field_validator, - contextual_model_validator, -) -from flow360.log import log - - -class SurfaceMeshingParams(Flow360BaseModel): - """ - Parameters for snappyHexMesh surface meshing. - """ - - type_name: Literal["SnappySurfaceMeshingParams"] = pd.Field( - "SnappySurfaceMeshingParams", frozen=True - ) - defaults: SurfaceMeshingDefaults = pd.Field() - quality_metrics: QualityMetrics = pd.Field(QualityMetrics()) - snap_controls: SnapControls = pd.Field(SnapControls()) - castellated_mesh_controls: CastellatedMeshControls = pd.Field(CastellatedMeshControls()) - smooth_controls: Union[SmoothControls, Literal[False]] = pd.Field(SmoothControls()) - refinements: Optional[List[SnappySurfaceRefinementTypes]] = pd.Field(None) - octree_spacing: Optional[OctreeSpacing] = pd.Field(None, validation_alias="base_spacing") - - @pd.model_validator(mode="before") - @classmethod - def _warn_base_spacing_deprecated(cls, data): - if isinstance(data, dict) and "base_spacing" in data: - log.warning( - "`base_spacing` has been renamed to `octree_spacing`. " - "Please update your code. `base_spacing` will be removed in a future release." - ) - return data - - @pd.model_validator(mode="after") - def _check_body_refinements_w_defaults(self): - # set body refinements - # pylint: disable=no-member - if self.refinements is None: - return self - for refinement in self.refinements: - if isinstance(refinement, BodyRefinement): - if refinement.min_spacing is None and refinement.max_spacing is None: - continue - if refinement.min_spacing is None and self.defaults.min_spacing.to( - "m" - ) > refinement.max_spacing.to("m"): - raise ValueError( - f"Default minimum spacing ({self.defaults.min_spacing}) is higher than " - + f"refinement maximum spacing ({refinement.max_spacing}) " - + "and minimum spacing is not provided for BodyRefinement." - ) - if refinement.max_spacing is None and self.defaults.max_spacing.to( - "m" - ) < refinement.min_spacing.to("m"): - raise ValueError( - f"Default maximum spacing ({self.defaults.max_spacing}) is lower than " - + f"refinement minimum spacing ({refinement.min_spacing}) " - + "and maximum spacing is not provided for BodyRefinement." - ) - return self - - @pd.field_validator("refinements", mode="after") - @classmethod - def _check_duplicate_refinements_per_entity(cls, refinements): - """Raise if the same refinement type is applied more than once to the same entity.""" - if refinements is None: - return refinements - - entity_refinement_map: dict[tuple[str, str], dict[str, int]] = {} - refinement_types_with_entities = (BodyRefinement, RegionRefinement, SurfaceEdgeRefinement) - - for refinement in refinements: - if not isinstance(refinement, refinement_types_with_entities): - continue - if refinement.entities is None: - continue - refinement_type_name = type(refinement).__name__ - for entity in refinement.entities.stored_entities: - entity_key = (type(entity).__name__, entity.name) - counts = entity_refinement_map.setdefault(entity_key, {}) - counts[refinement_type_name] = counts.get(refinement_type_name, 0) + 1 - - for entity_key, type_counts in entity_refinement_map.items(): - for refinement_type_name, count in type_counts.items(): - if count > 1: - raise ValueError( - f"`{refinement_type_name}` is applied {count} times " - f"to entity `{entity_key[1]}`. Each refinement type " - f"can only be applied once per entity." - ) - return refinements - - @contextual_model_validator(mode="after") - def _check_uniform_refinement_entities(self): - # pylint: disable=no-member - if self.refinements is None: - return self - for refinement in self.refinements: - if isinstance(refinement, UniformRefinement): - validate_snappy_uniform_refinement_entities(refinement) - - return self - - @pd.model_validator(mode="after") - def _check_sizing_against_octree_series(self): - - if self.octree_spacing is None: - return self - - def check_spacing(spacing, location): - # pylint: disable=no-member - lvl, close = self.octree_spacing.to_level(spacing) - spacing_unit = spacing.units - if not close: - closest_spacing = self.octree_spacing[lvl] - msg = f"The spacing of {spacing:.4g} specified in {location} will be cast to the first lower refinement" - msg += f" in the octree series ({closest_spacing.to(spacing_unit):.4g})." - log.warning(msg) - - # pylint: disable=no-member - check_spacing(self.defaults.min_spacing, "defaults") - check_spacing(self.defaults.max_spacing, "defaults") - - if self.refinements is not None: - # pylint: disable=not-an-iterable - for refinement in self.refinements: - if isinstance(refinement, SnappyEntityRefinement): - if refinement.min_spacing is not None: - check_spacing(refinement.min_spacing, type(refinement).__name__) - if refinement.max_spacing is not None: - check_spacing(refinement.max_spacing, type(refinement).__name__) - if refinement.proximity_spacing is not None: - check_spacing(refinement.proximity_spacing, type(refinement).__name__) - if isinstance(refinement, SurfaceEdgeRefinement): - if refinement.distances is not None: - for spacing in refinement.spacing: - check_spacing(spacing, type(refinement).__name__) - else: - check_spacing(refinement.spacing, type(refinement).__name__) - if isinstance(refinement, UniformRefinement): - check_spacing(refinement.spacing, type(refinement).__name__) - - return self - - @contextual_field_validator("octree_spacing", mode="after") - @classmethod - def _set_default_octree_spacing(cls, octree_spacing, param_info: ParamsValidationInfo): - if (octree_spacing is not None) or (param_info.project_length_unit is None): - return octree_spacing - - # pylint: disable=no-member - project_length = 1 * param_info.project_length_unit - return OctreeSpacing(base_spacing=project_length) diff --git a/flow360/component/simulation/meshing_param/snappy/snappy_specs.py b/flow360/component/simulation/meshing_param/snappy/snappy_specs.py index 80bb8a74f..84d1629f5 100644 --- a/flow360/component/simulation/meshing_param/snappy/snappy_specs.py +++ b/flow360/component/simulation/meshing_param/snappy/snappy_specs.py @@ -1,237 +1,11 @@ -"""Setting groups for meshing using snappy""" +"""Relay import for snappy meshing setting groups.""" -from typing import Literal, Optional, Union +# pylint: disable=unused-import -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Angle, Area, Length -from typing_extensions import Self - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel - - -class SurfaceMeshingDefaults(Flow360BaseModel): - """ - Default/global settings for snappyHexMesh surface meshing parameters. - To be used with class:`ModularMeshingWorkflow`. - """ - - # pylint: disable=no-member - min_spacing: Length.PositiveFloat64 = pd.Field() - max_spacing: Length.PositiveFloat64 = pd.Field() - gap_resolution: Length.PositiveFloat64 = pd.Field() - - @pd.model_validator(mode="after") - def _check_spacing_order(self) -> Self: - if self.min_spacing and self.max_spacing: - if self.min_spacing > self.max_spacing: - raise ValueError("Minimum spacing must be lower than or equal to maximum spacing.") - return self - - -class QualityMetrics(Flow360BaseModel): - """ - Mesh quality control parameters for snappyHexMesh meshing process. - """ - - # pylint: disable=no-member - max_non_orthogonality: Union[Angle.PositiveFloat64, Literal[False]] = pd.Field( - default=85 * u.deg, - alias="max_non_ortho", - description="Maximum face non-orthogonality angle: the angle made by the vector between the two adjacent " - "cell centres across the common face and the face normal. Set to False to disable this metric.", - ) - max_boundary_skewness: Union[Angle.PositiveFloat64, Literal[False]] = pd.Field( - default=20 * u.deg, - description="Maximum boundary skewness. Set to False to disable this metric.", - ) - max_internal_skewness: Union[Angle.PositiveFloat64, Literal[False]] = pd.Field( - default=50 * u.deg, - description="Maximum internal face skewness. Set to False to disable this metric.", - ) - max_concavity: Union[Angle.PositiveFloat64, Literal[False]] = pd.Field( - default=50 * u.deg, - alias="max_concave", - description="Maximum cell concavity. Set to False to disable this metric.", - ) - min_pyramid_cell_volume: Optional[Union[float, Literal[False]]] = pd.Field( - default=None, - alias="min_vol", - description="Minimum cell pyramid volume [mesh_unit³]. " - + "Set to False to disable this metric (uses -1e30 internally). " - + "Defaults to (effective_min_spacing³) * 1e-10 when not specified.", - ) - min_tetrahedron_quality: Union[float, Literal[False]] = pd.Field( - default=1e-9, - alias="min_tet_quality", - description="Minimum tetrahedron quality. Set to False to disable this metric (uses -1e30 internally).", - ) - min_face_area: Optional[Union[Area.PositiveFloat64, Literal[False]]] = pd.Field( - default=None, - alias="min_area", - description="Minimum face area. Set to False to disable. Defaults to 1e-12 of mesh unit.", - ) - min_twist: Union[float, Literal[False]] = pd.Field( - default=False, - description="Minimum twist. Controls the twist quality of faces. Set to False to disable this metric.", - ) - min_cell_determinant: Union[float, Literal[False]] = pd.Field( - default=False, - alias="min_determinant", - description="Minimum cell determinant. Set to False to disable this metric (uses -1e5 internally).", - ) - min_volume_ratio: Union[pd.NonNegativeFloat, Literal[False]] = pd.Field( - default=0, alias="min_vol_ratio", description="Minimum volume ratio between adjacent cells." - ) - min_face_weight: Union[pd.NonNegativeFloat, Literal[False]] = pd.Field( - default=0, - description="Minimum face interpolation weight. Controls the quality of face interpolation.", - ) - min_triangle_twist: Union[float, Literal[False]] = pd.Field( - default=False, description="Minimum triangle twist. Set to False to disable this metric." - ) - n_smooth_scale: pd.NonNegativeInt = pd.Field( - default=4, - description="Number of smoothing iterations. Used in combination with error_reduction.", - ) - error_reduction: float = pd.Field( - default=0.75, - ge=0, - le=1, - description="Error reduction factor. Used in combination with n_smooth_scale. Must be between 0 and 1.", - ) - zmetric_threshold: Union[pd.NonNegativeFloat, Literal[False]] = pd.Field( - alias="zfactor_threshold", - default=0.8, - le=2, - description=( - "Threshold for z-factor limiting during mesh quality checks and smoothing. " - "Set to False to disable this metric." - ), - ) - feature_edge_deduplication_tolerance: Union[pd.NonNegativeFloat, Literal[False]] = pd.Field( - default=0.2, - description=( - "Relative tolerance to deduplicate feature edges when snapping and smoothing. " - "Set to False to disable this metric." - ), - ) - min_volume_collapse_ratio: Union[float, Literal[False]] = pd.Field( - default=0, - alias="min_vol_collapse_ratio", - description="Minimum volume collapse ratio. If > 0: preserves single cells with all pointson the surface " - "if the resulting volume after snapping is larger than min_vol_collapse_ratio " - "times the old volume (i.e., not collapsed to flat cell). If < 0: always deletes such cells.", - ) - - @pd.field_validator( - "max_non_orthogonality", - "max_concavity", - "max_boundary_skewness", - "max_internal_skewness", - mode="after", - ) - @classmethod - def disable_angle_metrics_w_defaults(cls, value): - """Maximum value with units for angle metrics.""" - if value and value > 180 * u.deg: - raise ValueError("Value must be less than or equal to 180 degrees.") - return value - - -class CastellatedMeshControls(Flow360BaseModel): - """ - snappyHexMesh castellation controls. - """ - - # pylint: disable=no-member - resolve_feature_angle: Angle.PositiveFloat64 = pd.Field( - default=25 * u.deg, - description="This parameter controls the local curvature refinement. " - "The higher the value, the less features it captures. " - "Applies maximum level of refinement to cells that can see intersections whose angle exceeds this value.", - ) - n_cells_between_levels: pd.NonNegativeInt = pd.Field( - 1, - description="This parameter controls the transition between cell refinement levels. " - "Number of buffer layers of cells between different levels of refinement.", - ) - min_refinement_cells: pd.NonNegativeInt = pd.Field( - 10, - description="The refinement along the surfaces may spend many iterations on refinement of only few cells. " - "Whenever the number of cells to be refined is less than or equal to this value, the refinement will stop. " - "Unless the parameter is set to zero, at least one refining iteration will be performed.", - ) - - @pd.field_validator("resolve_feature_angle", mode="after") - @classmethod - def angle_limits(cls, value): - """Limit angular values.""" - if value is None: - return value - if value > 180 * u.deg: - raise ValueError("resolve_feature_angle must be between 0 and 180 degrees.") - return value - - -class SnapControls(Flow360BaseModel): - """ - snappyHexMesh snap controls. - """ - - # pylint: disable=no-member - n_smooth_patch: pd.NonNegativeInt = pd.Field( - 3, - description="Number of patch smoothing iterations before finding correspondence to surface.", - ) - tolerance: pd.PositiveFloat = pd.Field( - 2, - description="Ratio of distance for points to be attracted by surface feature point or edge, " - "to local maximum edge length.", - ) - n_solve_iterations: pd.NonNegativeInt = pd.Field( - 30, alias="n_solve_iter", description="Number of mesh displacement relaxation iterations." - ) - n_relax_iterations: pd.NonNegativeInt = pd.Field( - 5, - alias="n_relax_iter", - description="Number of relaxation iterations during the snapping. " - "If the mesh does not conform the geometry and all the iterations are spend, " - "user may try to increase the number of iterations.", - ) - n_feature_snap_iterations: pd.NonNegativeInt = pd.Field( - 15, - alias="n_feature_snap_iter", - description="Number of relaxation iterations used for snapping onto the features." - " If not specified, feature snapping will be disabled.", - ) - multi_region_feature_snap: bool = pd.Field( - True, - description="When using explicitFeatureSnap and this switch is on, " - "features between multiple surfaces will be captured. " - "This is useful for multi-region meshing where the internal mesh " - "must conform the region geometrical boundaries.", - ) - strict_region_snap: bool = pd.Field( - False, - description="Attract points only to the surface they originate from. " - "This can improve snapping of intersecting surfaces.", - ) - - -class SmoothControls(Flow360BaseModel): - """ - Mesh smoothing controls. - """ - - # pylint: disable=no-member - lambda_factor: pd.NonNegativeFloat = pd.Field( - 0.7, le=1, description="Controls the strength of smoothing in a single iteration." - ) - mu_factor: pd.NonNegativeFloat = pd.Field( - 0.71, - le=1, - description="Controls the strength of geometry inflation during a single iteration. " - "It is reccomended to set mu to be a little higher than lambda.", - ) - iterations: pd.NonNegativeInt = pd.Field(5, description="Number of smoothing iterations.") +from flow360_schema.models.simulation.meshing_param.snappy.snappy_specs import ( + CastellatedMeshControls, + QualityMetrics, + SmoothControls, + SnapControls, + SurfaceMeshingDefaults, +) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 9d1690839..4c4a5ddfd 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -1,1227 +1,23 @@ -""" -Meshing settings that applies to volumes. -""" - -# pylint: disable=too-many-lines - -from typing import Literal, Optional, Union - -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Length -from typing_extensions import deprecated - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.outputs.output_entities import Slice -from flow360.component.simulation.primitives import ( - AxisymmetricBody, - Box, - CustomVolume, - Cylinder, - GenericVolume, - GhostSurface, - MirroredSurface, - SeedpointVolume, - Sphere, - Surface, - WindTunnelGhostSurface, - compute_bbox_tolerance, -) -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - add_validation_warning, - contextual_field_validator, - contextual_model_validator, - get_validation_info, -) -from flow360.component.simulation.validation.validation_utils import ( - validate_entity_list_surface_existence, +"""Relay import for meshing volume zone models.""" + +# pylint: disable=unused-import + +from flow360_schema.models.entities.volume_entities import CustomVolume +from flow360_schema.models.simulation.meshing_param.volume_params import ( + AutomatedFarfield, + AxisymmetricRefinement, + CentralBelt, + CustomZones, + FullyMovingFloor, + MeshSliceOutput, + RotationCylinder, + RotationSphere, + RotationVolume, + StaticFloor, + StructuredBoxRefinement, + UniformRefinement, + UserDefinedFarfield, + WheelBelts, + WindTunnelFarfield, + _FarfieldAllowingEnclosedEntities, ) -from flow360.exceptions import Flow360ValueError - - -class classproperty: # pylint: disable=invalid-name,too-few-public-methods - """Descriptor to create class-level properties that can be accessed from the class itself.""" - - def __init__(self, func): - self.func = func - - def __get__(self, obj, owner): - return self.func(owner) - - -class UniformRefinement(Flow360BaseModel): - """ - Uniform spacing refinement inside specified region of mesh. - - Example - ------- - - >>> fl.UniformRefinement( - ... entities=[cylinder, box, axisymmetric_body, sphere], - ... spacing=1*fl.u.cm - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Uniform refinement") - refinement_type: Literal["UniformRefinement"] = pd.Field("UniformRefinement", frozen=True) - entities: EntityList[Box, Cylinder, AxisymmetricBody, Sphere] = pd.Field( - description=":class:`UniformRefinement` can be applied to :class:`~flow360.Box`, " - + ":class:`~flow360.Cylinder`, :class:`~flow360.AxisymmetricBody`, " - + "and :class:`~flow360.Sphere` regions." - ) - # pylint: disable=no-member - spacing: Length.PositiveFloat64 = pd.Field(description="The required refinement spacing.") - project_to_surface: Optional[bool] = pd.Field( - None, - description="Whether to include the refinement in the surface mesh. Defaults to True when using snappy.", - ) - - @contextual_field_validator("entities", mode="after") - @classmethod - def check_entities_used_with_beta_mesher(cls, values, param_info: ParamsValidationInfo): - """Check that AxisymmetricBody and Sphere are used with beta mesher.""" - - if values is None: - return values - if param_info.is_beta_mesher: - return values - - expanded = param_info.expand_entity_list(values) - for entity in expanded: - if isinstance(entity, AxisymmetricBody): - raise ValueError( - "`AxisymmetricBody` entity for `UniformRefinement` is supported only with beta mesher." - ) - if isinstance(entity, Sphere): - raise ValueError( - "`Sphere` entity for `UniformRefinement` is supported only with beta mesher." - ) - - return values - - @contextual_field_validator("entities", mode="after") - @classmethod - def check_entities_used_with_snappy(cls, values, param_info: ParamsValidationInfo): - """Check that only Box, Cylinder, and Sphere entities are used with snappyHexMesh.""" - - if values is None: - return values - if not param_info.use_snappy: - return values - - expanded = param_info.expand_entity_list(values) - for entity in expanded: - if not isinstance(entity, (Box, Cylinder, Sphere)): - raise ValueError( - f"`{type(entity).__name__}` entity for `UniformRefinement` is not supported " - "with snappyHexMesh. Only `Box`, `Cylinder`, and `Sphere` are allowed." - ) - - return values - - @contextual_model_validator(mode="after") - def check_project_to_surface_with_snappy(self, param_info: ParamsValidationInfo): - """Check that project_to_surface is used only with snappy.""" - if not param_info.use_snappy and self.project_to_surface is not None: - raise ValueError("project_to_surface is supported only for snappyHexMesh.") - - return self - - -class StructuredBoxRefinement(Flow360BaseModel): - """ - - The mesh inside the :class:`StructuredBoxRefinement` is semi-structured. - - The :class:`StructuredBoxRefinement` cannot enclose/intersect with other objects. - - The spacings along the three box axes can be adjusted independently. - - Example - ------- - - >>> StructuredBoxRefinement( - ... entities=[ - ... Box.from_principal_axes( - ... name="boxRefinement", - ... center=(0, 1, 1) * fl.u.cm, - ... size=(1, 2, 1) * fl.u.cm, - ... axes=((2, 2, 0), (-2, 2, 0)), - ... ) - ... ], - ... spacing_axis1=7.5*u.cm, - ... spacing_axis2=10*u.cm, - ... spacing_normal=15*u.cm, - ... ) - ==== - """ - - # pylint: disable=no-member - # pylint: disable=too-few-public-methods - name: Optional[str] = pd.Field("StructuredBoxRefinement") - refinement_type: Literal["StructuredBoxRefinement"] = pd.Field( - "StructuredBoxRefinement", frozen=True - ) - entities: EntityList[Box] = pd.Field() - - spacing_axis1: Length.PositiveFloat64 = pd.Field( - description="Spacing along the first axial direction." - ) - spacing_axis2: Length.PositiveFloat64 = pd.Field( - description="Spacing along the second axial direction." - ) - spacing_normal: Length.PositiveFloat64 = pd.Field( - description="Spacing along the normal axial direction." - ) - - @contextual_model_validator(mode="after") - def _validate_only_in_beta_mesher(self, param_info: ParamsValidationInfo): - """ - Ensure that StructuredBoxRefinement objects are only processed with the beta mesher. - """ - if param_info.is_beta_mesher: - return self - - raise ValueError("`StructuredBoxRefinement` is only supported with the beta mesher.") - - -class AxisymmetricRefinement(Flow360BaseModel): - """ - - The mesh inside the :class:`AxisymmetricRefinement` is semi-structured. - - The :class:`AxisymmetricRefinement` cannot enclose/intersect with other objects. - - Users could create a donut-shape :class:`AxisymmetricRefinement` and place their hub/centerbody in the middle. - - :class:`AxisymmetricRefinement` can be used for resolving the strong flow gradient - along the axial direction for the actuator or BET disks. - - The spacings along the axial, radial and circumferential directions can be adjusted independently. - - Example - ------- - - >>> fl.AxisymmetricRefinement( - ... entities=[cylinder], - ... spacing_axial=1e-4, - ... spacing_radial=0.3*fl.u.cm, - ... spacing_circumferential=5*fl.u.mm - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Axisymmetric refinement") - refinement_type: Literal["AxisymmetricRefinement"] = pd.Field( - "AxisymmetricRefinement", frozen=True - ) - entities: EntityList[Cylinder] = pd.Field() - # pylint: disable=no-member - spacing_axial: Length.PositiveFloat64 = pd.Field( - description="Spacing along the axial direction." - ) - spacing_radial: Length.PositiveFloat64 = pd.Field( - description="Spacing along the radial direction." - ) - spacing_circumferential: Length.PositiveFloat64 = pd.Field( - description="Spacing along the circumferential direction." - ) - - -class _RotationVolumeBase(Flow360BaseModel): - """ - Shared base class for rotation volume zones. - - - The mesh on :class:`RotationVolume` is guaranteed to be concentric. - - The :class:`RotationVolume` is designed to enclose other objects, but it can't intersect with other objects. - - Users can create a donut-shaped :class:`RotationVolume` and put their stationary centerbody in the middle. - - This type of volume zone can be used to generate volume zones compatible with :class:`~flow360.Rotation` model. - """ - - # Note: Please refer to - # Note: https://www.notion.so/flexcompute/Python-model-design-document- - # Note: 78d442233fa944e6af8eed4de9541bb1?pvs=4#c2de0b822b844a12aa2c00349d1f68a3 - - name: Optional[str] = pd.Field(None, description="Name to display in the GUI.") - enclosed_entities: Optional[ - EntityList[Cylinder, Surface, MirroredSurface, AxisymmetricBody, Box, Sphere] - ] = pd.Field( - None, - description=( - "Entities enclosed by :class:`RotationVolume`. " - "Can be :class:`~flow360.Surface` and/or other :class:`~flow360.Cylinder`" - "and/or other :class:`~flow360.AxisymmetricBody`" - "and/or other :class:`~flow360.Box`" - "and/or other :class:`~flow360.Sphere`" - ), - ) - stationary_enclosed_entities: Optional[EntityList[Surface, MirroredSurface]] = pd.Field( - None, - description=( - "Surface entities included in `enclosed_entities` which should remain stationary " - "(excluded from rotation)." - ), - ) - - @pd.model_validator(mode="before") - @classmethod - def _prevent_direct_instantiation(cls, data): - if cls is _RotationVolumeBase: - raise TypeError( - "`_RotationVolumeBase` is an abstract base model and cannot be instantiated directly." - ) - return data - - @contextual_field_validator("entities", mode="after", check_fields=False) - @classmethod - def _validate_single_instance_in_entity_list(cls, values, param_info: ParamsValidationInfo): - """ - [CAPABILITY-LIMITATION] Only single instance is allowed in entities. - """ - # Note: Should be fine without expansion since we only allow Draft entities here. - # But using expand_entity_list for consistency and future-proofing. - expanded_entities = param_info.expand_entity_list(values) - if len(expanded_entities) > 1: - raise ValueError( - f"Only single instance is allowed in entities for each `{cls.__name__}`." - ) - return values - - @contextual_field_validator("entities", mode="after", check_fields=False) - @classmethod - def _validate_cylinder_name_length(cls, values, param_info: ParamsValidationInfo): - """ - Check the name length for the cylinder entities due to the 32-character - limitation of all data structure names and labels in CGNS format. - The current prefix is 'rotatingBlock-' with 14 characters. - """ - if param_info.is_beta_mesher: - return values - - expanded_entities = param_info.expand_entity_list(values) - cgns_max_zone_name_length = 32 - max_cylinder_name_length = cgns_max_zone_name_length - len("rotatingBlock-") - for entity in expanded_entities: - if isinstance(entity, Cylinder) and len(entity.name) > max_cylinder_name_length: - raise ValueError( - f"The name ({entity.name}) of `Cylinder` entity in `{cls.__name__}` " - + f"exceeds {max_cylinder_name_length} characters limit." - ) - return values - - @contextual_field_validator("enclosed_entities", mode="after") - @classmethod - def _validate_enclosed_entities_beta_mesher_only(cls, values, param_info: ParamsValidationInfo): - """ - Ensure that Box and Sphere entities in enclosed_entities are only used with the beta mesher. - """ - if values is None: - return values - if param_info.is_beta_mesher: - return values - - expanded = param_info.expand_entity_list(values) # Can Have `Surface` - for entity in expanded: - if isinstance(entity, Box): - raise ValueError( - f"`Box` entity in `{cls.__name__}.enclosed_entities` is only supported with the beta mesher." - ) - if isinstance(entity, Sphere): - raise ValueError( - f"`Sphere` entity in `{cls.__name__}.enclosed_entities` is only supported with the beta mesher." - ) - - return values - - @contextual_field_validator("enclosed_entities", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher.""" - return validate_entity_list_surface_existence(value, param_info) - - @contextual_field_validator("stationary_enclosed_entities", mode="after") - @classmethod - def _validate_stationary_enclosed_entities_only_in_beta_mesher( - cls, values, param_info: ParamsValidationInfo - ): - """ - Ensure that stationary_enclosed_entities is only used with the beta mesher. - """ - if values is None: - return values - if not param_info.is_beta_mesher: - raise ValueError( - f"`stationary_enclosed_entities` in `{cls.__name__}` is only supported with the beta mesher." - ) - return values - - @contextual_model_validator(mode="after") - def _validate_stationary_enclosed_entities_subset(self, param_info: ParamsValidationInfo): - """ - Ensure that stationary_enclosed_entities is a subset of enclosed_entities. - """ - if self.stationary_enclosed_entities is None: - return self - - if self.enclosed_entities is None: - raise ValueError( - "`stationary_enclosed_entities` cannot be specified when `enclosed_entities` is None." - ) - - # Get sets of entity names for comparison - # pylint: disable=no-member - expanded_enclosed_entities = param_info.expand_entity_list(self.enclosed_entities) - enclosed_names = {entity.name for entity in expanded_enclosed_entities} - expanded_stationary_enclosed_entities = param_info.expand_entity_list( - self.stationary_enclosed_entities - ) - stationary_names = {entity.name for entity in expanded_stationary_enclosed_entities} - - # Check if all stationary entities are in enclosed entities - if not stationary_names.issubset(enclosed_names): - missing_entities = stationary_names - enclosed_names - raise ValueError( - f"All entities in `stationary_enclosed_entities` must be present in `enclosed_entities`. " - f"Missing entities: {', '.join(missing_entities)}" - ) - - return self - - -class RotationVolume(_RotationVolumeBase): - """ - Creates a rotation volume mesh using cylindrical or axisymmetric body entities. - - - The mesh on :class:`RotationVolume` is guaranteed to be concentric. - - The :class:`RotationVolume` is designed to enclose other objects, but it can't intersect with other objects. - - Users can create a donut-shaped :class:`RotationVolume` and put their stationary centerbody in the middle. - - This type of volume zone can be used to generate volume zones compatible with :class:`~flow360.Rotation` model. - - Supports :class:`Cylinder` and :class:`AxisymmetricBody` entities for defining the rotation volume geometry. - - .. note:: - For spherical sliding interfaces, use :class:`RotationSphere`. - - .. note:: - The deprecated :class:`RotationCylinder` class is maintained for backward compatibility - but only accepts :class:`Cylinder` entities. New code should use :class:`RotationVolume`. - - Example - ------- - Using a Cylinder entity: - - >>> fl.RotationVolume( - ... name="RotationCylinder", - ... spacing_axial=0.5*fl.u.m, - ... spacing_circumferential=0.3*fl.u.m, - ... spacing_radial=1.5*fl.u.m, - ... entities=cylinder - ... ) - - Using an AxisymmetricBody entity: - - >>> fl.RotationVolume( - ... name="RotationConeFrustum", - ... spacing_axial=0.5*fl.u.m, - ... spacing_circumferential=0.3*fl.u.m, - ... spacing_radial=1.5*fl.u.m, - ... entities=axisymmetric_body - ... ) - - With enclosed entities: - - >>> fl.RotationVolume( - ... name="RotationVolume", - ... spacing_axial=0.5*fl.u.m, - ... spacing_circumferential=0.3*fl.u.m, - ... spacing_radial=1.5*fl.u.m, - ... entities=outer_cylinder, - ... enclosed_entities=[inner_cylinder, surface] - ... ) - """ - - type: Literal["RotationVolume"] = pd.Field("RotationVolume", frozen=True) - name: Optional[str] = pd.Field("Rotation Volume", description="Name to display in the GUI.") - entities: EntityList[Cylinder, AxisymmetricBody] = pd.Field() - # pylint: disable=no-member - spacing_axial: Length.PositiveFloat64 = pd.Field( - description="Spacing along the axial direction." - ) - spacing_radial: Length.PositiveFloat64 = pd.Field( - description="Spacing along the radial direction." - ) - spacing_circumferential: Length.PositiveFloat64 = pd.Field( - description="Spacing along the circumferential direction." - ) - - @contextual_field_validator("entities", mode="after") - @classmethod - def _validate_entities_beta_mesher_only(cls, values, param_info: ParamsValidationInfo): - """ - Ensure that AxisymmetricBody entities are only used with the beta mesher. - """ - if param_info.is_beta_mesher: - return values - - expanded_entities = param_info.expand_entity_list(values) - for entity in expanded_entities: - if isinstance(entity, AxisymmetricBody): - raise ValueError( - "`AxisymmetricBody` entity for `RotationVolume` is only supported with the beta mesher." - ) - return values - - -class RotationSphere(_RotationVolumeBase): - """ - Creates a spherical sliding interface using :class:`Sphere` entities. - - - The mesh on :class:`RotationSphere` is guaranteed to be concentric. - - The :class:`RotationSphere` is designed to enclose other objects, but it can't intersect with other objects. - - This type of volume zone can be used to generate volume zones compatible with :class:`~flow360.Rotation` model. - - Example - ------- - - >>> fl.RotationSphere( - ... name="RotationSphere", - ... spacing_circumferential=0.3*fl.u.m, - ... entities=sphere - ... ) - """ - - type: Literal["RotationSphere"] = pd.Field("RotationSphere", frozen=True) - name: Optional[str] = pd.Field("Rotation Sphere", description="Name to display in the GUI.") - entities: EntityList[Sphere] = pd.Field() - # pylint: disable=no-member - spacing_circumferential: Length.PositiveFloat64 = pd.Field( - description="Uniform spacing on the spherical interface." - ) - - @contextual_field_validator("entities", mode="after") - @classmethod - def _validate_entities_beta_mesher_only(cls, values, param_info: ParamsValidationInfo): - """ - Ensure that Sphere entities are only used with the beta mesher. - """ - if param_info.is_beta_mesher: - return values - - expanded_entities = param_info.expand_entity_list(values) - if expanded_entities: - raise ValueError( - "`Sphere` entity for `RotationSphere` is only supported with the beta mesher." - ) - return values - - -@deprecated( - "The `RotationCylinder` class is deprecated! Use `RotationVolume` for non-sphere " - "entities and `RotationSphere` for sphere entities instead." -) -class RotationCylinder(RotationVolume): - """ - .. deprecated:: - Use :class:`RotationVolume` instead. This class is maintained for backward - compatibility but will be removed in a future version. - - RotationCylinder creates a rotation volume mesh using cylindrical entities. - - - The mesh on :class:`RotationCylinder` is guaranteed to be concentric. - - The :class:`RotationCylinder` is designed to enclose other objects, but it can't intersect with other objects. - - Users could create a donut-shape :class:`RotationCylinder` and put their stationary centerbody in the middle. - - This type of volume zone can be used to generate volume zone compatible with :class:`~flow360.Rotation` model. - - .. note:: - :class:`RotationVolume` supports :class:`Cylinder` and :class:`AxisymmetricBody` entities. - :class:`RotationSphere` supports :class:`Sphere` entities. - Please migrate to using :class:`RotationVolume` / :class:`RotationSphere` directly. - - Example - ------- - >>> fl.RotationCylinder( - ... name="RotationCylinder", - ... spacing_axial=0.5*fl.u.m, - ... spacing_circumferential=0.3*fl.u.m, - ... spacing_radial=1.5*fl.u.m, - ... entities=cylinder - ... ) - """ - - type: Literal["RotationCylinder"] = pd.Field("RotationCylinder", frozen=True) - entities: EntityList[Cylinder] = pd.Field() - - -### BEGIN FARFIELDS ### - - -class _FarfieldBase(Flow360BaseModel): - """Base class for farfield parameters.""" - - domain_type: Optional[Literal["half_body_positive_y", "half_body_negative_y", "full_body"]] = ( - pd.Field( # In the future, we will support more flexible half model types and full model via Union. - None, - description=""" - - half_body_positive_y: Trim to a half-model by slicing with the global Y=0 plane; keep the '+y' side for meshing and simulation. - - half_body_negative_y: Trim to a half-model by slicing with the global Y=0 plane; keep the '-y' side for meshing and simulation. - - full_body: Keep the full body for meshing and simulation without attempting to add symmetry planes. - - Warning: When using AutomatedFarfield or UserDefinedFarfield, setting `domain_type` overrides automatic symmetry plane detection. - """, - ) - ) - - @contextual_field_validator("domain_type", mode="after") - @classmethod - def _validate_only_in_beta_mesher(cls, value, param_info: ParamsValidationInfo): - """ - Ensure that domain_type is only used with the beta mesher and GAI. - """ - if not value or (param_info.use_geometry_AI is True and param_info.is_beta_mesher is True): - return value - raise ValueError( - "`domain_type` is only supported when using both GAI surface mesher and beta volume mesher." - ) - - @pd.field_validator("domain_type", mode="after") - @classmethod - def _validate_domain_type_bbox(cls, value): - """ - Ensure that when domain_type is used, the model actually spans across Y=0. - """ - validation_info = get_validation_info() - if validation_info is None: - return value - - if ( - value not in ("half_body_positive_y", "half_body_negative_y") - or validation_info.global_bounding_box is None - or validation_info.planar_face_tolerance is None - ): - return value - - y_min = validation_info.global_bounding_box[0][1] - y_max = validation_info.global_bounding_box[1][1] - - _, tolerance = compute_bbox_tolerance( - validation_info.global_bounding_box, validation_info.planar_face_tolerance - ) - - # Check if model crosses Y=0 - crossing = y_min < -tolerance and y_max > tolerance - if crossing: - return value - - # If not crossing, check if it matches the requested domain - if value == "half_body_positive_y": - # Should be on positive side (y > 0) - if y_min >= -tolerance: - return value - - if value == "half_body_negative_y": - # Should be on negative side (y < 0) - if y_max <= tolerance: - return value - - message = ( - f"The model does not cross the symmetry plane (Y=0) with tolerance {tolerance:.2g}. " - f"Model Y range: [{y_min:.2g}, {y_max:.2g}]. " - "Please check if `domain_type` is set correctly." - ) - if getattr(validation_info, "entity_transformation_detected", False): - add_validation_warning(message) - return value - raise ValueError(message) - - -class _FarfieldAllowingEnclosedEntities(_FarfieldBase): - """Intermediate class for farfield types that support enclosed_entities (Automated, WindTunnel).""" - - enclosed_entities: Optional[ - EntityList[Surface, Cylinder, AxisymmetricBody, Sphere, CustomVolume] - ] = pd.Field( - None, - description=""" - The surfaces/surface groups that are the interior boundaries of the `farfield` zone when defining custom volumes. - - Only allowed when using one or more ``CustomZone(s)`` to define volume zone(s) in meshing parameters - - Cylinder, AxisymmetricBody, Sphere entities must be associated with ``RotationVolume(s)`` - """, - ) - - @contextual_field_validator("enclosed_entities", mode="after") - @classmethod - def _validate_enclosed_entities_no_intersection(cls, value, param_info: ParamsValidationInfo): - """Check that no CustomVolume's bounding_entities overlap with sibling entities.""" - if value is None: - return value - expanded = param_info.expand_entity_list(value) - - custom_volumes_in_list = [e for e in expanded if isinstance(e, CustomVolume)] - if not custom_volumes_in_list: - return value - - non_cv_names = {e.name for e in expanded if not isinstance(e, CustomVolume)} - - for cv in custom_volumes_in_list: - cv_child_names = {e.name for e in param_info.expand_entity_list(cv.bounding_entities)} - overlap = cv_child_names & non_cv_names - if overlap: - raise ValueError( - f"`CustomVolume` `{cv.name}` shares bounding entities " - f"({sorted(overlap)}) with sibling `CustomVolume`. " - f"A `CustomVolume`'s bounding entities must be disjoint from its siblings." - ) - - return value - - @contextual_field_validator("enclosed_entities", mode="after") - @classmethod - def _validate_enclosed_entities_beta_mesher_only(cls, value, param_info: ParamsValidationInfo): - """Ensure enclosed_entities is only used with the beta mesher.""" - if value is None: - return value - if param_info.is_beta_mesher: - return value - - raise ValueError("`enclosed_entities` is only supported with the beta mesher.") - - @contextual_field_validator("enclosed_entities", mode="after") - @classmethod - def _validate_enclosed_entity_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher.""" - return validate_entity_list_surface_existence(value, param_info) - - -class AutomatedFarfield(_FarfieldAllowingEnclosedEntities): - """ - Settings for automatic farfield volume zone generation. - - Example - ------- - - >>> fl.AutomatedFarfield(name="Farfield", method="auto") - - ==== - """ - - type: Literal["AutomatedFarfield"] = pd.Field("AutomatedFarfield", frozen=True) - name: Optional[str] = pd.Field("Automated Farfield") # Kept optional for backward compatibility - method: Literal["auto", "quasi-3d", "quasi-3d-periodic"] = pd.Field( - default="auto", - frozen=True, - description=""" - - auto: The mesher will Sphere or semi-sphere will be generated based on the bounding box of the geometry. - - Full sphere if min{Y} < 0 and max{Y} > 0. - - +Y semi sphere if min{Y} = 0 and max{Y} > 0. - - -Y semi sphere if min{Y} < 0 and max{Y} = 0. - - quasi-3d: Thin disk will be generated for quasi 3D cases. - Both sides of the farfield disk will be treated as "symmetric plane" - - quasi-3d-periodic: The two sides of the quasi-3d disk will be conformal - - Note: For quasi-3d, please do not group patches from both sides of the farfield disk into a single surface. - """, - ) - private_attribute_entity: GenericVolume = pd.Field( - GenericVolume( - name="__farfield_zone_name_not_properly_set_yet", - private_attribute_id="farfield_zone_name_not_properly_set_yet", - ), - frozen=True, - exclude=True, - ) - relative_size: pd.PositiveFloat = pd.Field( - default=50.0, - description="Radius of the far-field (semi)sphere/cylinder relative to " - "the max dimension of the geometry bounding box.", - ) - - @property - def farfield(self): - """Returns the farfield boundary surface.""" - # Make sure the naming is the same here and what the geometry/surface mesh pipeline generates. - return GhostSurface(name="farfield", private_attribute_id="farfield") - - @property - def symmetry_plane(self) -> GhostSurface: - """ - Returns the symmetry plane boundary surface. - """ - if self.method == "auto": - return GhostSurface(name="symmetric", private_attribute_id="symmetric") - raise Flow360ValueError( - "Unavailable for quasi-3d farfield methods. Please use `symmetry_planes` property instead." - ) - - @property - def symmetry_planes(self): - """Returns the symmetry plane boundary surface(s).""" - # Make sure the naming is the same here and what the geometry/surface mesh pipeline generates. - if self.method == "auto": - return GhostSurface(name="symmetric", private_attribute_id="symmetric") - if self.method in ("quasi-3d", "quasi-3d-periodic"): - return [ - GhostSurface(name="symmetric-1", private_attribute_id="symmetric-1"), - GhostSurface(name="symmetric-2", private_attribute_id="symmetric-2"), - ] - raise Flow360ValueError(f"Unsupported method: {self.method}") - - @contextual_field_validator("method", mode="after") - @classmethod - def _validate_quasi_3d_periodic_only_in_legacy_mesher( - cls, values, param_info: ParamsValidationInfo - ): - """ - Check mesher and AutomatedFarfield method compatibility - """ - if param_info.is_beta_mesher and values == "quasi-3d-periodic": - raise ValueError("Only legacy mesher can support quasi-3d-periodic") - return values - - -class UserDefinedFarfield(_FarfieldBase): - """ - Setting for user defined farfield zone generation. - This means the "farfield" boundaries are coming from the supplied geometry file - and meshing will take place inside this "geometry". - - **Important:** By default, the volume mesher will grow boundary layers on :class:`~flow360.UserDefinedFarfield`. - Use :class:`~flow360.PassiveSpacing` to project or disable boundary layer growth. - - Example - ------- - - >>> fl.UserDefinedFarfield(name="InnerChannel") - - ==== - """ - - type: Literal["UserDefinedFarfield"] = pd.Field("UserDefinedFarfield", frozen=True) - name: Optional[str] = pd.Field(None) - - @property - def symmetry_plane(self) -> GhostSurface: - """ - Returns the symmetry plane boundary surface. - - Warning: This should only be used when using GAI and beta mesher. - """ - if self.domain_type not in ( - None, - "half_body_positive_y", - "half_body_negative_y", - ): - # We allow None here to allow auto detection of domain type from bounding box. - raise Flow360ValueError( - "Symmetry plane of user defined farfield is only supported when domain_type " - "is `half_body_positive_y`, `half_body_negative_y`, or None (auto detection)." - ) - return GhostSurface(name="symmetric", private_attribute_id="symmetric") - - -# pylint: disable=no-member -class StaticFloor(Flow360BaseModel): - """Class for static wind tunnel floor with friction patch.""" - - type_name: Literal["StaticFloor"] = pd.Field( - "StaticFloor", description="Static floor with friction patch.", frozen=True - ) - friction_patch_x_range: Length.StrictlyIncreasingVector2 = pd.Field( - default=(-3, 6) * u.m, description="(Minimum, maximum) x of friction patch." - ) - friction_patch_width: Length.PositiveFloat64 = pd.Field( - default=2 * u.m, description="Width of friction patch." - ) - - -class FullyMovingFloor(Flow360BaseModel): - """Class for fully moving wind tunnel floor with friction patch.""" - - type_name: Literal["FullyMovingFloor"] = pd.Field( - "FullyMovingFloor", description="Fully moving floor.", frozen=True - ) - - -# pylint: disable=no-member -class CentralBelt(Flow360BaseModel): - """Class for wind tunnel floor with one central belt.""" - - type_name: Literal["CentralBelt"] = pd.Field( - "CentralBelt", description="Floor with central belt.", frozen=True - ) - central_belt_x_range: Length.StrictlyIncreasingVector2 = pd.Field( - default=(-2, 2) * u.m, description="(Minimum, maximum) x of central belt." - ) - central_belt_width: Length.PositiveFloat64 = pd.Field( - default=1.2 * u.m, description="Width of central belt." - ) - - -class WheelBelts(CentralBelt): - """Class for wind tunnel floor with one central belt and four wheel belts.""" - - type_name: Literal["WheelBelts"] = pd.Field( - "WheelBelts", - description="Floor with central belt and four wheel belts.", - frozen=True, - ) - # No defaults for the below; user must specify - front_wheel_belt_x_range: Length.StrictlyIncreasingVector2 = pd.Field( - description="(Minimum, maximum) x of front wheel belt." - ) - front_wheel_belt_y_range: Length.PositiveStrictlyIncreasingVector2 = pd.Field( - description="(Inner, outer) y of front wheel belt." - ) - rear_wheel_belt_x_range: Length.StrictlyIncreasingVector2 = pd.Field( - description="(Minimum, maximum) x of rear wheel belt." - ) - rear_wheel_belt_y_range: Length.PositiveStrictlyIncreasingVector2 = pd.Field( - description="(Inner, outer) y of rear wheel belt." - ) - - @pd.model_validator(mode="after") - def _validate_wheel_belt_ranges(self): - if self.front_wheel_belt_x_range[1] >= self.rear_wheel_belt_x_range[0]: - raise ValueError( - f"Front wheel belt maximum x ({self.front_wheel_belt_x_range[1]}) " - f"must be less than rear wheel belt minimum x ({self.rear_wheel_belt_x_range[0]})." - ) - - # Central belt is centered at y=0 and extends from -width/2 to +width/2 - # It must fit within the inner edges of the wheel belts - front_wheel_inner_edge = self.front_wheel_belt_y_range[0] - rear_wheel_inner_edge = self.rear_wheel_belt_y_range[0] - - # Validate central belt width against front wheel belt inner edge - if self.central_belt_width > 2 * front_wheel_inner_edge: - raise ValueError( - f"Central belt width ({self.central_belt_width}) " - f"must be less than or equal to twice the front wheel belt inner edge " - f"(2 × {front_wheel_inner_edge} = {2 * front_wheel_inner_edge})." - ) - - # Validate central belt width against rear wheel belt inner edge - if self.central_belt_width > 2 * rear_wheel_inner_edge: - raise ValueError( - f"Central belt width ({self.central_belt_width}) " - f"must be less than or equal to twice the rear wheel belt inner edge " - f"(2 × {rear_wheel_inner_edge} = {2 * rear_wheel_inner_edge})." - ) - - return self - - -# pylint: disable=no-member -class WindTunnelFarfield(_FarfieldAllowingEnclosedEntities): - """ - Settings for analytic wind tunnel farfield generation. - The user only needs to provide tunnel dimensions and floor type and dimensions, rather than a geometry. - - **Important:** By default, the volume mesher will grow boundary layers on :class:`~flow360.WindTunnelFarfield`. - Use :class:`~flow360.PassiveSpacing` to project or disable boundary layer growth. - - Example - ------- - >>> fl.WindTunnelFarfield( - width = 10 * fl.u.m, - height = 5 * fl.u.m, - inlet_x_position = -10 * fl.u.m, - outlet_x_position = 20 * fl.u.m, - floor_z_position = 0 * fl.u.m, - floor_type = fl.CentralBelt( - central_belt_x_range = (-1, 4) * fl.u.m, - central_belt_width = 1.2 * fl.u.m - ) - ) - """ - - model_config = pd.ConfigDict(ignored_types=(classproperty,)) - - type: Literal["WindTunnelFarfield"] = pd.Field("WindTunnelFarfield", frozen=True) - name: str = pd.Field("Wind Tunnel Farfield", description="Name of the wind tunnel farfield.") - - # Tunnel parameters - width: Length.PositiveFloat64 = pd.Field( - default=10 * u.m, description="Width of the wind tunnel." - ) - height: Length.PositiveFloat64 = pd.Field( - default=6 * u.m, description="Height of the wind tunnel." - ) - inlet_x_position: Length.Float64 = pd.Field( - default=-20 * u.m, description="X-position of the inlet." - ) - outlet_x_position: Length.Float64 = pd.Field( - default=40 * u.m, description="X-position of the outlet." - ) - floor_z_position: Length.Float64 = pd.Field( - default=0 * u.m, description="Z-position of the floor." - ) - - floor_type: Union[ - StaticFloor, - FullyMovingFloor, - CentralBelt, - WheelBelts, - ] = pd.Field( - default_factory=StaticFloor, - description="Floor type of the wind tunnel.", - discriminator="type_name", - ) - - # up direction not yet supported; assume +Z - - @property - def symmetry_plane(self) -> GhostSurface: - """ - Returns the symmetry plane boundary surface for half body domains. - """ - if self.domain_type not in ("half_body_positive_y", "half_body_negative_y"): - raise Flow360ValueError( - "Symmetry plane for wind tunnel farfield is only supported when domain_type " - "is `half_body_positive_y` or `half_body_negative_y`." - ) - return GhostSurface(name="symmetric", private_attribute_id="symmetric") - - # pylint: disable=no-self-argument - @classproperty - def left(cls): - """Return the ghost surface representing the tunnel's left wall.""" - return WindTunnelGhostSurface(name="windTunnelLeft", private_attribute_id="windTunnelLeft") - - @classproperty - def right(cls): - """Return the ghost surface representing the tunnel's right wall.""" - return WindTunnelGhostSurface( - name="windTunnelRight", private_attribute_id="windTunnelRight" - ) - - @classproperty - def inlet(cls): - """Return the ghost surface corresponding to the wind tunnel inlet.""" - return WindTunnelGhostSurface( - name="windTunnelInlet", private_attribute_id="windTunnelInlet" - ) - - @classproperty - def outlet(cls): - """Return the ghost surface corresponding to the wind tunnel outlet.""" - return WindTunnelGhostSurface( - name="windTunnelOutlet", private_attribute_id="windTunnelOutlet" - ) - - @classproperty - def ceiling(cls): - """Return the ghost surface for the tunnel ceiling.""" - return WindTunnelGhostSurface( - name="windTunnelCeiling", private_attribute_id="windTunnelCeiling" - ) - - @classproperty - def floor(cls): - """Return the ghost surface for the tunnel floor.""" - return WindTunnelGhostSurface( - name="windTunnelFloor", private_attribute_id="windTunnelFloor" - ) - - @classproperty - def friction_patch(cls): - """Return the ghost surface for the floor friction patch used by static floors.""" - return WindTunnelGhostSurface( - name="windTunnelFrictionPatch", - used_by=["StaticFloor"], - private_attribute_id="windTunnelFrictionPatch", - ) - - @classproperty - def central_belt(cls): - """Return the ghost surface used by central and wheel belt floor types.""" - return WindTunnelGhostSurface( - name="windTunnelCentralBelt", - used_by=["CentralBelt", "WheelBelts"], - private_attribute_id="windTunnelCentralBelt", - ) - - @classproperty - def front_wheel_belts(cls): - """Return the ghost surface for the front wheel belt region.""" - return WindTunnelGhostSurface( - name="windTunnelFrontWheelBelt", - used_by=["WheelBelts"], - private_attribute_id="windTunnelFrontWheelBelt", - ) - - @classproperty - def rear_wheel_belts(cls): - """Return the ghost surface for the rear wheel belt region.""" - return WindTunnelGhostSurface( - name="windTunnelRearWheelBelt", - used_by=["WheelBelts"], - private_attribute_id="windTunnelRearWheelBelt", - ) - - # pylint: enable=no-self-argument - - @staticmethod - def _get_valid_ghost_surfaces( - floor_string: Optional[str] = "all", domain_string: Optional[str] = None - ) -> list[WindTunnelGhostSurface]: - """ - Returns a list of valid ghost surfaces given a floor type as a string - or ``all``, and the domain type as a string. - """ - common_ghost_surfaces = [ - WindTunnelFarfield.inlet, - WindTunnelFarfield.outlet, - WindTunnelFarfield.ceiling, - WindTunnelFarfield.floor, - ] - if domain_string != "half_body_negative_y": - common_ghost_surfaces += [WindTunnelFarfield.right] - if domain_string != "half_body_positive_y": - common_ghost_surfaces += [WindTunnelFarfield.left] - for ghost_surface_type in [ - WindTunnelFarfield.friction_patch, - WindTunnelFarfield.central_belt, - WindTunnelFarfield.front_wheel_belts, - WindTunnelFarfield.rear_wheel_belts, - ]: - if floor_string == "all" or floor_string in ghost_surface_type.used_by: - common_ghost_surfaces += [ghost_surface_type] - return common_ghost_surfaces - - @pd.model_validator(mode="after") - def _validate_inlet_is_less_than_outlet(self): - if self.inlet_x_position >= self.outlet_x_position: - raise ValueError( - f"Inlet x position ({self.inlet_x_position}) " - f"must be less than outlet x position ({self.outlet_x_position})." - ) - return self - - @pd.model_validator(mode="after") - def _validate_central_belt_ranges(self): - # friction patch - if isinstance(self.floor_type, StaticFloor): - if self.floor_type.friction_patch_width >= self.width: - raise ValueError( - f"Friction patch width ({self.floor_type.friction_patch_width}) " - f"must be less than wind tunnel width ({self.width})" - ) - if self.floor_type.friction_patch_x_range[0] <= self.inlet_x_position: - raise ValueError( - f"Friction patch minimum x ({self.floor_type.friction_patch_x_range[0]}) " - f"must be greater than inlet x ({self.inlet_x_position})" - ) - if self.floor_type.friction_patch_x_range[1] >= self.outlet_x_position: - raise ValueError( - f"Friction patch maximum x ({self.floor_type.friction_patch_x_range[1]}) " - f"must be less than outlet x ({self.outlet_x_position})" - ) - # central belt - elif isinstance(self.floor_type, CentralBelt): - if self.floor_type.central_belt_width >= self.width: - raise ValueError( - f"Central belt width ({self.floor_type.central_belt_width}) " - f"must be less than wind tunnel width ({self.width})" - ) - if self.floor_type.central_belt_x_range[0] <= self.inlet_x_position: - raise ValueError( - f"Central belt minimum x ({self.floor_type.central_belt_x_range[0]}) " - f"must be greater than inlet x ({self.inlet_x_position})" - ) - if self.floor_type.central_belt_x_range[1] >= self.outlet_x_position: - raise ValueError( - f"Central belt maximum x ({self.floor_type.central_belt_x_range[1]}) " - f"must be less than outlet x ({self.outlet_x_position})" - ) - return self - - @pd.model_validator(mode="after") - def _validate_wheel_belts_ranges(self): - if isinstance(self.floor_type, WheelBelts): - if self.floor_type.front_wheel_belt_y_range[1] >= self.width * 0.5: - raise ValueError( - f"Front wheel outer y ({self.floor_type.front_wheel_belt_y_range[1]}) " - f"must be less than half of wind tunnel width ({self.width * 0.5})" - ) - if self.floor_type.rear_wheel_belt_y_range[1] >= self.width * 0.5: - raise ValueError( - f"Rear wheel outer y ({self.floor_type.rear_wheel_belt_y_range[1]}) " - f"must be less than half of wind tunnel width ({self.width * 0.5})" - ) - if self.floor_type.front_wheel_belt_x_range[0] <= self.inlet_x_position: - raise ValueError( - f"Front wheel minimum x ({self.floor_type.front_wheel_belt_x_range[0]}) " - f"must be greater than inlet x ({self.inlet_x_position})" - ) - if self.floor_type.rear_wheel_belt_x_range[1] >= self.outlet_x_position: - raise ValueError( - f"Rear wheel maximum x ({self.floor_type.rear_wheel_belt_x_range[1]}) " - f"must be less than outlet x ({self.outlet_x_position})" - ) - return self - - @contextual_model_validator(mode="after") - def _validate_requires_geometry_ai(self, param_info: ParamsValidationInfo): - """Ensure WindTunnelFarfield is only used when GeometryAI is enabled.""" - if not param_info.use_geometry_AI: - raise ValueError("WindTunnelFarfield is only supported when Geometry AI is enabled.") - return self - - -class MeshSliceOutput(Flow360BaseModel): - """ - :class:`MeshSliceOutput` class for mesh slice output settings. - - Example - ------- - - >>> fl.MeshSliceOutput( - ... slices=[ - ... fl.Slice( - ... name="Slice_1", - ... normal=(0, 1, 0), - ... origin=(0, 0.56, 0)*fl.u.m - ... ), - ... ], - ... ) - - ==== - """ - - name: str = pd.Field("Mesh slice output", description="Name of the `MeshSliceOutput`.") - entities: EntityList[Slice] = pd.Field( - alias="slices", - description="List of output :class:`~flow360.Slice` entities.", - ) - include_crinkled_slices: bool = pd.Field( - default=False, - description="Generate crinkled slices in addition to flat slices.", - ) - cutoff_radius: Optional[Length.PositiveFloat64] = pd.Field( - default=None, - description="Cutoff radius of the slice output. If not specified, " - "the slice extends to the boundaries of the volume mesh.", - ) - output_type: Literal["MeshSliceOutput"] = pd.Field("MeshSliceOutput", frozen=True) - - -class CustomZones(Flow360BaseModel): - """ - :class:`CustomZones` class for creating volume zones from custom volumes or seedpoint volumes. - Names of the generated volume zones will be the names of the custom volumes. - - Example - ------- - - >>> fl.CustomZones(name="Custom zones", entities=[custom_volume1, custom_volume2], ) - - ==== - """ - - type: Literal["CustomZones"] = pd.Field("CustomZones", frozen=True) - name: str = pd.Field("Custom zones", description="Name of the `CustomZones` meshing setting.") - entities: EntityList[CustomVolume, SeedpointVolume] = pd.Field( - description="The custom volume zones to be generated." - ) - element_type: Literal["mixed", "tetrahedra"] = pd.Field( - default="mixed", - description="The element type to be used for the generated volume zones." - + " - mixed: Mesher will automatically choose the element types used." - + " - tetrahedra: Only tetrahedra element type will be used for the generated volume zones.", - ) diff --git a/flow360/component/simulation/models/material.py b/flow360/component/simulation/models/material.py index ab4f3af16..128e22113 100644 --- a/flow360/component/simulation/models/material.py +++ b/flow360/component/simulation/models/material.py @@ -1,724 +1,20 @@ -"""Material classes for the simulation framework.""" - -from typing import List, Literal, Optional, Union - -import pydantic as pd -from flow360_schema.framework.physical_dimensions import ( - AbsoluteTemperature, - Density, - Pressure, - SpecificHeatCapacity, - ThermalConductivity, - Velocity, - Viscosity, -) -from numpy import sqrt - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel - -# ============================================================================= -# NASA 9-Coefficient Polynomial Utility Functions -# ============================================================================= - - -def compute_cp_over_r(coeffs, temperature): - """ - Compute cp/R from NASA 9-coefficient polynomial. - - cp/R = a0*T^-2 + a1*T^-1 + a2 + a3*T + a4*T^2 + a5*T^3 + a6*T^4 - - Parameters - ---------- - coeffs : list - NASA 9 coefficients [a0, a1, a2, a3, a4, a5, a6, a7, a8] - temperature : float - Temperature in Kelvin - - Returns - ------- - float - Specific heat at constant pressure divided by gas constant (cp/R) - """ - temp = temperature - return ( - coeffs[0] * temp ** (-2) - + coeffs[1] * temp ** (-1) - + coeffs[2] - + coeffs[3] * temp - + coeffs[4] * temp**2 - + coeffs[5] * temp**3 - + coeffs[6] * temp**4 - ) - - -def compute_gamma_from_coefficients(coeffs, temperature): - """ - Compute specific heat ratio (gamma) from NASA 9-coefficient polynomial. - - gamma = cp/cv = (cp/R) / (cp/R - 1) - - Parameters - ---------- - coeffs : list - NASA 9 coefficients [a0, a1, a2, a3, a4, a5, a6, a7, a8] - temperature : float - Temperature in Kelvin - - Returns - ------- - float - Specific heat ratio (gamma) - """ - cp_r = compute_cp_over_r(coeffs, temperature) - cv_r = cp_r - 1 # cv/R = cp/R - 1 for ideal gas - return cp_r / cv_r - - -class MaterialBase(Flow360BaseModel): - """ - Basic properties required to define a material. - For example: young's modulus, viscosity as an expression of temperature, etc. - """ - - type: str = pd.Field() - name: str = pd.Field() - - -class NASA9CoefficientSet(Flow360BaseModel): - """ - Represents a set of 9 NASA polynomial coefficients for a specific temperature range. - - The NASA 9-coefficient polynomial (McBride et al., 2002) computes thermodynamic - properties as: - - cp/R = a0*T^-2 + a1*T^-1 + a2 + a3*T + a4*T^2 + a5*T^3 + a6*T^4 - - h/RT = -a0*T^-2 + a1*ln(T)/T + a2 + (a3/2)*T + (a4/3)*T^2 + (a5/4)*T^3 + (a6/5)*T^4 + a7/T - - s/R = -(a0/2)*T^-2 - a1*T^-1 + a2*ln(T) + a3*T + (a4/2)*T^2 + (a5/3)*T^3 + (a6/4)*T^4 + a8 - - Coefficients: - - a0-a6: cp polynomial coefficients - - a7: enthalpy integration constant - - a8: entropy integration constant - - Example - ------- - - >>> fl.NASA9CoefficientSet( - ... temperature_range_min=200.0 * fl.u.K, - ... temperature_range_max=1000.0 * fl.u.K, - ... coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - ... ) - - ==== - """ - - type_name: Literal["NASA9CoefficientSet"] = pd.Field("NASA9CoefficientSet", frozen=True) - temperature_range_min: AbsoluteTemperature.Float64 = pd.Field( - description="Minimum temperature for which this coefficient set is valid." - ) - temperature_range_max: AbsoluteTemperature.Float64 = pd.Field( - description="Maximum temperature for which this coefficient set is valid." - ) - coefficients: List[float] = pd.Field( - description="Nine NASA polynomial coefficients [a0, a1, a2, a3, a4, a5, a6, a7, a8]. " - "a0-a6 are cp/R polynomial coefficients, a7 is the enthalpy integration constant, " - "and a8 is the entropy integration constant." - ) - - @pd.field_validator("coefficients", mode="after") - @classmethod - def validate_coefficients(cls, v): - """Validate that exactly 9 coefficients are provided.""" - if len(v) != 9: - raise ValueError( - f"NASA 9-coefficient polynomial requires exactly 9 coefficients, " f"got {len(v)}" - ) - return v - - @pd.field_validator("temperature_range_max", mode="after") - @classmethod - def validate_temperature_range_order(cls, v, info): - """Validate that temperature_range_min < temperature_range_max.""" - t_min = info.data.get("temperature_range_min") - if t_min is not None: - t_min_k = t_min.to("K").v.item() - t_max_k = v.to("K").v.item() - if t_min_k >= t_max_k: - raise ValueError( - f"temperature_range_min ({t_min}) must be less than " - f"temperature_range_max ({v})" - ) - return v - - -class NASA9Coefficients(Flow360BaseModel): - """ - NASA 9-coefficient polynomial coefficients for computing temperature-dependent thermodynamic properties. - - Supports 1-5 temperature ranges with continuous boundaries. Defaults to a single temperature range. - - Example - ------- - - Single temperature range (default): - - >>> fl.NASA9Coefficients( - ... temperature_ranges=[ - ... fl.NASA9CoefficientSet( - ... temperature_range_min=200.0 * fl.u.K, - ... temperature_range_max=6000.0 * fl.u.K, - ... coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - ... ) - ... ] - ... ) - - Multiple temperature ranges: - - >>> fl.NASA9Coefficients( - ... temperature_ranges=[ - ... fl.NASA9CoefficientSet( - ... temperature_range_min=200.0 * fl.u.K, - ... temperature_range_max=1000.0 * fl.u.K, - ... coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - ... ), - ... fl.NASA9CoefficientSet( - ... temperature_range_min=1000.0 * fl.u.K, - ... temperature_range_max=6000.0 * fl.u.K, - ... coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - ... ) - ... ] - ... ) - - ==== - """ - - type_name: Literal["NASA9Coefficients"] = pd.Field("NASA9Coefficients", frozen=True) - temperature_ranges: List[NASA9CoefficientSet] = pd.Field( - min_length=1, - max_length=5, - description="List of NASA 9-coefficient sets for different temperature ranges. " - "Must be ordered by increasing temperature and be continuous. Maximum 5 ranges supported.", - ) - - @pd.model_validator(mode="after") - def validate_temperature_continuity(self): - """Validate that temperature ranges are continuous and non-overlapping.""" - for i in range(len(self.temperature_ranges) - 1): - current_max = self.temperature_ranges[i].temperature_range_max - next_min = self.temperature_ranges[i + 1].temperature_range_min - if current_max != next_min: - raise ValueError( - f"Temperature ranges must be continuous: range {i} max " - f"({current_max}) must equal range {i+1} min ({next_min})" - ) - return self - - @pd.validate_call - def get_coefficients_at_temperature(self, temp_k: float) -> list: - """ - Get the NASA 9 coefficients for a given temperature. - - Finds the temperature range that contains the specified temperature - and returns the corresponding coefficients. - - Parameters - ---------- - temp_k : float - Temperature in Kelvin - - Returns - ------- - list - NASA 9 coefficients [a0, a1, a2, a3, a4, a5, a6, a7, a8] - """ - for coeff_set in self.temperature_ranges: - t_min = coeff_set.temperature_range_min.to("K").v.item() - t_max = coeff_set.temperature_range_max.to("K").v.item() - if t_min <= temp_k <= t_max: - return list(coeff_set.coefficients) - - # Fallback to first range if temp_k is out of bounds - return list(self.temperature_ranges[0].coefficients) - - -class FrozenSpecies(Flow360BaseModel): - """ - Represents a single gas species with NASA 9-coefficient thermodynamic properties. - - Used within :class:`ThermallyPerfectGas` to define multi-species gas mixtures - where each species contributes to the mixture properties weighted by mass fraction. - The term "frozen" indicates fixed mass fractions (non-reacting flow). - - Example - ------- - - >>> fl.FrozenSpecies( - ... name="N2", - ... nasa_9_coefficients=fl.NASA9Coefficients( - ... temperature_ranges=[ - ... fl.NASA9CoefficientSet( - ... temperature_range_min=200.0 * fl.u.K, - ... temperature_range_max=6000.0 * fl.u.K, - ... coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - ... ) - ... ] - ... ), - ... mass_fraction=0.7555 - ... ) - - ==== - """ - - type_name: Literal["FrozenSpecies"] = pd.Field("FrozenSpecies", frozen=True) - name: str = pd.Field(description="Species name (e.g., 'N2', 'O2', 'Ar')") - nasa_9_coefficients: NASA9Coefficients = pd.Field( - description="NASA 9-coefficient polynomial for this species" - ) - mass_fraction: pd.PositiveFloat = pd.Field( - description="Mass fraction of this species (must sum to 1 across all species in mixture)" - ) - - -class ThermallyPerfectGas(Flow360BaseModel): - """ - Multi-species thermally perfect gas model. - - Combines NASA 9-coefficient polynomials from multiple species weighted by mass fraction. - All species must use the same temperature range boundaries. The mixture properties - are computed as mass-fraction-weighted averages of individual species properties. - - This model supports temperature-dependent specific heats (cp) while maintaining - fixed mass fractions (non-reacting flow). - - Example - ------- - - >>> fl.ThermallyPerfectGas( - ... species=[ - ... fl.FrozenSpecies(name="N2", nasa_9_coefficients=..., mass_fraction=0.7555), - ... fl.FrozenSpecies(name="O2", nasa_9_coefficients=..., mass_fraction=0.2316), - ... fl.FrozenSpecies(name="Ar", nasa_9_coefficients=..., mass_fraction=0.0129), - ... ] - ... ) - - ==== - """ - - type_name: Literal["ThermallyPerfectGas"] = pd.Field("ThermallyPerfectGas", frozen=True) - species: List[FrozenSpecies] = pd.Field( - min_length=1, - description="List of species with their NASA 9 coefficients and mass fractions. " - "Mass fractions must sum to 1.0.", - ) - - @pd.model_validator(mode="after") - def validate_mass_fractions_sum_to_one(self): - """Validate that mass fractions sum to 1 and re-normalize if within tolerance.""" - total = sum(s.mass_fraction for s in self.species) - tolerance = 1.0e-3 - if abs(total - 1.0) > tolerance: - raise ValueError(f"Mass fractions must sum to 1.0, got {total}") - # Re-normalize to ensure exact sum of 1.0 - if total != 1.0: - for species in self.species: - species.mass_fraction = species.mass_fraction / total - return self - - @pd.model_validator(mode="after") - def validate_unique_species_names(self): - """Validate that all species have unique names.""" - names = [s.name for s in self.species] - if len(names) != len(set(names)): - duplicates = [name for name in names if names.count(name) > 1] - raise ValueError(f"Species names must be unique. Duplicates found: {set(duplicates)}") - return self - - @pd.model_validator(mode="after") - def validate_temperature_ranges_match(self): - """Validate all species have matching temperature range boundaries.""" - if len(self.species) < 2: - return self - ref_ranges = self.species[0].nasa_9_coefficients.temperature_ranges - for species in self.species[1:]: - ranges = species.nasa_9_coefficients.temperature_ranges - if len(ranges) != len(ref_ranges): - raise ValueError( - f"Species '{species.name}' has {len(ranges)} temperature ranges, " - f"but '{self.species[0].name}' has {len(ref_ranges)}. " - "All species must have the same number of temperature ranges." - ) - for i, (r1, r2) in enumerate(zip(ref_ranges, ranges)): - if ( - r1.temperature_range_min != r2.temperature_range_min - or r1.temperature_range_max != r2.temperature_range_max - ): - raise ValueError( - f"Temperature range {i} boundaries mismatch between species " - f"'{self.species[0].name}' and '{species.name}'. " - "All species must use the same temperature range boundaries." - ) - return self - - -class Sutherland(Flow360BaseModel): - """ - Represents Sutherland's law for calculating dynamic viscosity. - This class implements Sutherland's formula to compute the dynamic viscosity of a gas - as a function of temperature. - - Example - ------- - - >>> fl.Sutherland( - ... reference_viscosity=1.70138e-5 * fl.u.Pa * fl.u.s, - ... reference_temperature=300.0 * fl.u.K, - ... effective_temperature=110.4 * fl.u.K, - ... ) - - ==== - """ - - # pylint: disable=no-member - reference_viscosity: Viscosity.NonNegativeFloat64 = pd.Field( - description="The reference dynamic viscosity at the reference temperature." - ) - reference_temperature: AbsoluteTemperature.Float64 = pd.Field( - description="The reference temperature associated with the reference viscosity." - ) - effective_temperature: AbsoluteTemperature.Float64 = pd.Field( - description="The effective temperature constant used in Sutherland's formula." - ) - - @pd.validate_call - def get_dynamic_viscosity( - self, temperature: AbsoluteTemperature.Float64 - ) -> Viscosity.NonNegativeFloat64: - """ - Calculates the dynamic viscosity at a given temperature using Sutherland's law. - - Parameters - ---------- - temperature : AbsoluteTemperatureType - The temperature at which to calculate the dynamic viscosity. - - Returns - ------- - ViscosityType.NonNegative - The calculated dynamic viscosity at the specified temperature. - """ - return self.reference_viscosity * float( - pow(temperature / self.reference_temperature, 1.5) - * (self.reference_temperature + self.effective_temperature) - / (temperature + self.effective_temperature) - ) - - -# pylint: disable=no-member, missing-function-docstring -class Air(MaterialBase): - """ - Represents the material properties for air. - This sets specific material properties for air, - including dynamic viscosity, specific heat ratio, gas constant, and Prandtl number. - - The thermodynamic properties are specified using NASA 9-coefficient polynomials - for temperature-dependent specific heats via the `thermally_perfect_gas` parameter. - By default, coefficients are set to reproduce a constant gamma=1.4 (calorically perfect gas). - - Example - ------- - - >>> fl.Air( - ... dynamic_viscosity=1.063e-05 * fl.u.Pa * fl.u.s - ... ) - - With custom NASA 9-coefficient polynomial for single species: - - >>> fl.Air( - ... thermally_perfect_gas=fl.ThermallyPerfectGas( - ... species=[ - ... fl.FrozenSpecies( - ... name="Air", - ... nasa_9_coefficients=fl.NASA9Coefficients( - ... temperature_ranges=[ - ... fl.NASA9CoefficientSet( - ... temperature_range_min=200.0 * fl.u.K, - ... temperature_range_max=1000.0 * fl.u.K, - ... coefficients=[...], - ... ), - ... fl.NASA9CoefficientSet( - ... temperature_range_min=1000.0 * fl.u.K, - ... temperature_range_max=6000.0 * fl.u.K, - ... coefficients=[...], - ... ), - ... ] - ... ), - ... mass_fraction=1.0, - ... ) - ... ] - ... ) - ... ) - - With multi-species thermally perfect gas: - - >>> fl.Air( - ... thermally_perfect_gas=fl.ThermallyPerfectGas( - ... species=[ - ... fl.FrozenSpecies(name="N2", nasa_9_coefficients=..., mass_fraction=0.7555), - ... fl.FrozenSpecies(name="O2", nasa_9_coefficients=..., mass_fraction=0.2316), - ... fl.FrozenSpecies(name="Ar", nasa_9_coefficients=..., mass_fraction=0.0129), - ... ] - ... ) - ... ) - - ==== - """ - - type: Literal["air"] = pd.Field("air", frozen=True) - name: str = pd.Field("air") - dynamic_viscosity: Union[Sutherland, Viscosity.NonNegativeFloat64] = pd.Field( - Sutherland( - reference_viscosity=1.716e-5 * u.Pa * u.s, - reference_temperature=273.15 * u.K, - # pylint: disable=fixme - # TODO: validation error for effective_temperature not equal 110.4 K - effective_temperature=110.4 * u.K, - ), - description=( - "The dynamic viscosity model or value for air. Defaults to a `Sutherland` " - "model with standard atmospheric conditions." - ), - ) - thermally_perfect_gas: ThermallyPerfectGas = pd.Field( - default_factory=lambda: ThermallyPerfectGas( - species=[ - FrozenSpecies( - name="Air", - nasa_9_coefficients=NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * u.K, - temperature_range_max=6000.0 * u.K, - # For constant gamma=1.4: cp/R = gamma/(gamma-1) = 1.4/0.4 = 3.5 - # In NASA9 format, constant cp/R is the a2 coefficient (index 2) - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - ] - ), - mass_fraction=1.0, - ) - ] - ), - description=( - "Thermally perfect gas model with NASA 9-coefficient polynomials for " - "temperature-dependent thermodynamic properties. Defaults to a single-species " - "'Air' with coefficients that reproduce constant gamma=1.4 (calorically perfect gas). " - "For multi-species gas mixtures, specify multiple FrozenSpecies with their " - "respective mass fractions." - ), - ) - prandtl_number: pd.PositiveFloat = pd.Field( - 0.72, - description="Laminar Prandtl number. Default is 0.72 for air.", - ) - turbulent_prandtl_number: pd.PositiveFloat = pd.Field( - 0.9, - description="Turbulent Prandtl number. Default is 0.9.", - ) - - def get_specific_heat_ratio(self, temperature: AbsoluteTemperature.Float64) -> pd.PositiveFloat: - """ - Computes the specific heat ratio (gamma) at a given temperature from NASA polynomial. - - For thermally perfect gas, gamma = cp/cv = (cp/R) / (cp/R - 1) varies with temperature. - The cp/R is computed from the NASA 9-coefficient polynomial: - cp/R = a0*T^-2 + a1*T^-1 + a2 + a3*T + a4*T^2 + a5*T^3 + a6*T^4 - - Parameters - ---------- - temperature : AbsoluteTemperatureType - The temperature at which to compute gamma. - - Returns - ------- - pd.PositiveFloat - The specific heat ratio at the given temperature. - """ - temp_k = temperature.to("K").v.item() - coeffs = self._get_coefficients_at_temperature(temp_k) - return compute_gamma_from_coefficients(coeffs, temp_k) - - def _get_coefficients_at_temperature(self, temp_k: float) -> list: - """ - Get the NASA 9 coefficients at a given temperature. - - Coefficients are mass-fraction weighted across all species. - - Parameters - ---------- - temp_k : float - Temperature in Kelvin - - Returns - ------- - list - NASA 9 coefficients [a0, a1, a2, a3, a4, a5, a6, a7, a8] - """ - # Combine coefficients by mass fraction across species - coeffs = [0.0] * 9 - for species in self.thermally_perfect_gas.species: - species_coeffs = species.nasa_9_coefficients.get_coefficients_at_temperature(temp_k) - for i in range(9): - coeffs[i] += species.mass_fraction * species_coeffs[i] - return coeffs - - @property - def gas_constant(self) -> SpecificHeatCapacity.PositiveFloat64: - """ - Returns the specific gas constant for air. - - Returns - ------- - SpecificHeatCapacityType.Positive - The specific gas constant for air. - """ - - return 287.0529 * u.m**2 / u.s**2 / u.K - - @pd.validate_call - def get_pressure( - self, density: Density.PositiveFloat64, temperature: AbsoluteTemperature.Float64 - ) -> Pressure.PositiveFloat64: - """ - Calculates the pressure of air using the ideal gas law. - - Parameters - ---------- - density : DensityType.Positive - The density of the air. - temperature : AbsoluteTemperatureType - The temperature of the air. - - Returns - ------- - PressureType.Positive - The calculated pressure. - """ - temperature = temperature.to("K") - return density * self.gas_constant * temperature - - @pd.validate_call - def get_speed_of_sound( - self, temperature: AbsoluteTemperature.Float64 - ) -> Velocity.PositiveFloat64: - """ - Calculates the speed of sound in air at a given temperature. - - For thermally perfect gas, uses the temperature-dependent gamma from the NASA polynomial. - - Parameters - ---------- - temperature : AbsoluteTemperatureType - The temperature at which to calculate the speed of sound. - - Returns - ------- - VelocityType.Positive - The speed of sound at the specified temperature. - """ - temperature = temperature.to("K") - gamma = self.get_specific_heat_ratio(temperature) - return sqrt(gamma * self.gas_constant * temperature) - - @pd.validate_call - def get_dynamic_viscosity( - self, temperature: AbsoluteTemperature.Float64 - ) -> Viscosity.NonNegativeFloat64: - """ - Calculates the dynamic viscosity of air at a given temperature. - - Parameters - ---------- - temperature : AbsoluteTemperatureType - The temperature at which to calculate the dynamic viscosity. - - Returns - ------- - ViscosityType.NonNegative - The dynamic viscosity at the specified temperature. - """ - if temperature.units is u.degC or temperature.units is u.degF: - temperature = temperature.to("K") - if isinstance(self.dynamic_viscosity, Sutherland): - return self.dynamic_viscosity.get_dynamic_viscosity(temperature) - return self.dynamic_viscosity - - -class SolidMaterial(MaterialBase): - """ - Represents the solid material properties for heat transfer volume. - - Example - ------- - - >>> fl.SolidMaterial( - ... name="aluminum", - ... thermal_conductivity=235 * fl.u.kg / fl.u.s**3 * fl.u.m / fl.u.K, - ... density=2710 * fl.u.kg / fl.u.m**3, - ... specific_heat_capacity=903 * fl.u.m**2 / fl.u.s**2 / fl.u.K, - ... ) - - ==== - """ - - type: Literal["solid"] = pd.Field("solid", frozen=True) - name: str = pd.Field(frozen=True, description="Name of the solid material.") - thermal_conductivity: ThermalConductivity.PositiveFloat64 = pd.Field( - frozen=True, description="Thermal conductivity of the material." - ) - density: Optional[Density.PositiveFloat64] = pd.Field( - None, frozen=True, description="Density of the material." - ) - specific_heat_capacity: Optional[SpecificHeatCapacity.PositiveFloat64] = pd.Field( - None, frozen=True, description="Specific heat capacity of the material." - ) - - -aluminum = SolidMaterial( - name="aluminum", - thermal_conductivity=235 * u.kg / u.s**3 * u.m / u.K, - density=2710 * u.kg / u.m**3, - specific_heat_capacity=903 * u.m**2 / u.s**2 / u.K, +"""Material classes for the simulation framework — re-import relay.""" + +# pylint: disable=unused-import + +from flow360_schema.models.simulation.models.material import ( + Air, + FluidMaterialTypes, + FrozenSpecies, + MaterialBase, + NASA9Coefficients, + NASA9CoefficientSet, + SolidMaterial, + SolidMaterialTypes, + Sutherland, + ThermallyPerfectGas, + Water, + aluminum, + compute_cp_over_r, + compute_gamma_from_coefficients, ) - - -class Water(MaterialBase): - """ - Water material used for :class:`LiquidOperatingCondition` - - Example - ------- - - >>> fl.Water( - ... name="Water", - ... density=1000 * fl.u.kg / fl.u.m**3, - ... dynamic_viscosity=0.001002 * fl.u.kg / fl.u.m / fl.u.s, - ... ) - - ==== - """ - - type: Literal["water"] = pd.Field("water", frozen=True) - name: str = pd.Field(frozen=True, description="Custom name of the water with given property.") - density: Optional[Density.PositiveFloat64] = pd.Field( - 1000 * u.kg / u.m**3, frozen=True, description="Density of the water." - ) - dynamic_viscosity: Viscosity.NonNegativeFloat64 = pd.Field( - 0.001002 * u.kg / u.m / u.s, frozen=True, description="Dynamic viscosity of the water." - ) - - -SolidMaterialTypes = SolidMaterial -FluidMaterialTypes = Union[Air, Water] diff --git a/flow360/component/simulation/models/solver_numerics.py b/flow360/component/simulation/models/solver_numerics.py index 28c99ebf1..89565da79 100644 --- a/flow360/component/simulation/models/solver_numerics.py +++ b/flow360/component/simulation/models/solver_numerics.py @@ -1,663 +1,23 @@ -""" -Contains basic components(solvers) that composes the `volume` type models. -Each volume model represents a physical phenomena that require a combination of solver features to model. - -E.g. -NavierStokes, turbulence and transition composes FluidDynamics `volume` type - -""" - -from __future__ import annotations - -from abc import ABCMeta -from typing import Annotated, Dict, List, Literal, Optional, Union - -import numpy as np -import pydantic as pd -from pydantic import NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt -from typing_extensions import Self - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.primitives import Box, CustomVolume, GenericVolume - -# from .time_stepping import UnsteadyTimeStepping - -HEAT_EQUATION_EVAL_MAX_PER_PSEUDOSTEP_UNSTEADY = 40 -HEAT_EQUATION_EVALUATION_FREQUENCY_STEADY = 10 - - -class LineSearch(Flow360BaseModel): - """:class:`LineSearch` class for configuring line search parameters used with - the Krylov solver. - - Example - ------- - >>> fl.LineSearch( - ... residual_growth_threshold=0.85, - ... max_residual_growth=1.1, - ... activation_step=100, - ... ) - """ - - residual_growth_threshold: pd.confloat(ge=0.5, le=1) = pd.Field( - 0.85, - description="Pseudotime nonlinear residual norm convergence ratio above which " - "residual norm increase is allowed.", - ) - max_residual_growth: pd.confloat(ge=1.0) = pd.Field( - 1.1, - description="Hard cap on the residual norm ratio — never allow the residual norm " - "to grow beyond this factor over a single pseudotime step.", - ) - activation_step: PositiveInt = pd.Field( - 100, - description="Pseudotime step threshold before the max_residual_growth limit is activated.", - ) - - -class LinearSolver(Flow360BaseModel): - """:class:`LinearSolver` class for setting up the linear solver. - - Example - ------- - >>> fl.LinearSolver( - ... max_iterations=50, - ... absoluteTolerance=1e-10 - ... ) - """ - - type_name: Literal["LinearSolver"] = pd.Field("LinearSolver", frozen=True) - max_iterations: PositiveInt = pd.Field( - 30, description="Maximum number of linear solver iterations." - ) - absolute_tolerance: Optional[PositiveFloat] = pd.Field( - None, - description="The linear solver converges when the final residual of the pseudo steps below this value." - + "Either absolute tolerance or relative tolerance can be used to determine convergence.", - ) - relative_tolerance: Optional[PositiveFloat] = pd.Field( - None, - description="The linear solver converges when the ratio of the final residual and the initial " - + "residual of the pseudo step is below this value.", - ) - - @pd.model_validator(mode="after") - def _check_tolerance_conflict(self) -> Self: - if self.absolute_tolerance is not None and self.relative_tolerance is not None: - raise ValueError( - "absolute_tolerance and relative_tolerance cannot be specified at the same time." - ) - return self - - -class KrylovLinearSolver(LinearSolver): - """:class:`KrylovLinearSolver` class for setting up the Krylov linear solver. - - When used as the ``linear_solver`` on :class:`NavierStokesSolver`, - ``max_iterations`` is interpreted as the Krylov iterations. - - Example - ------- - >>> fl.KrylovLinearSolver( - ... max_iterations=15, - ... max_preconditioner_iterations=25, - ... relative_tolerance=0.05, - ... ) - """ - - type_name: Literal["KrylovLinearSolver"] = pd.Field("KrylovLinearSolver", frozen=True) - max_iterations: pd.conint(gt=0, le=50) = pd.Field( - 15, description="Number of Krylov iterations." - ) - max_preconditioner_iterations: PositiveInt = pd.Field( - 25, description="Number of preconditioner sweeps per Krylov iteration." - ) - relative_tolerance: PositiveFloat = pd.Field( - 0.05, description="Relative tolerance for the Krylov linear solver convergence." - ) - - -class GenericSolverSettings(Flow360BaseModel, metaclass=ABCMeta): - """:class:`GenericSolverSettings` class""" - - absolute_tolerance: PositiveFloat = pd.Field(1.0e-10) - relative_tolerance: NonNegativeFloat = pd.Field( - 0, - description="Tolerance to the relative residual, below which the solver goes to the next physical step. " - + "Relative residual is defined as the ratio of the current pseudoStep's residual to the maximum " - + "residual present in the first 10 pseudoSteps within the current physicalStep. " - + "NOTE: relativeTolerance is ignored in steady simulations and only absoluteTolerance is " - + "used as the convergence criterion.", - ) - order_of_accuracy: Literal[1, 2] = pd.Field(2, description="Order of accuracy in space.") - equation_evaluation_frequency: PositiveInt = pd.Field( - 1, description="Frequency at which to solve the equation." - ) - linear_solver: LinearSolver = pd.Field(LinearSolver()) - private_attribute_dict: Optional[Dict] = pd.Field(None) - - -class NavierStokesSolver(GenericSolverSettings): - """:class:`NavierStokesSolver` class for setting up the compressible Navier-Stokes solver. - For more information on setting up the numerical parameters for the Navier-Stokes solver, - refer to :ref:`Navier-Stokes solver knowledge base `. - - Example - ------- - >>> fl.NavierStokesSolver( - ... absolute_tolerance=1e-10, - ... numerical_dissipation_factor=0.01, - ... linear_solver=LinearSolver(max_iterations=50), - ... low_mach_preconditioner=True, - ... ) - """ - - absolute_tolerance: PositiveFloat = pd.Field( - 1.0e-10, - description="Tolerance for the NS residual, below which the solver goes to the next physical step.", - ) - - CFL_multiplier: PositiveFloat = pd.Field( - 1.0, - description="Factor to the CFL definitions defined in the " - + ":ref:`Time Stepping ` section.", - ) - kappa_MUSCL: pd.confloat(ge=-1, le=1) = pd.Field( - -1, - description="Kappa for the MUSCL scheme, range from [-1, 1], with 1 being unstable. " - + "The default value of -1 leads to a 2nd order upwind scheme and is the most stable. " - + "A value of 0.33 leads to a blended upwind/central scheme and is recommended for low " - + "subsonic flows leading to reduced dissipation.", - ) - - numerical_dissipation_factor: pd.confloat(ge=0.01, le=1) = pd.Field( - 1, - description="A factor in the range [0.01, 1.0] which exponentially reduces the " - + "dissipation of the numerical flux. The recommended starting value for most " - + "low-dissipation runs is 0.2.", - ) - limit_velocity: bool = pd.Field(False, description="Limiter for velocity") - limit_pressure_density: bool = pd.Field(False, description="Limiter for pressure and density.") - - type_name: Literal["Compressible", "CompressibleIsentropic"] = pd.Field( - "Compressible", - description="The type of Navier-Stokes equations to solve. " - + "The default is the compressible conservation laws. " - + "CompressibleIsentropic is recommended for low mach number applications to speed up the solver. " - + "It will apply mass and momentum conservation along with the isentropic assumption for low-speed flow." - + "CompressibleIsentropic is applied automatically for LiquidOperatingCondition " - + "regardless of the value of this field.", - ) - - low_mach_preconditioner: bool = pd.Field( - False, description="Use preconditioning for accelerating low Mach number flows." - ) - low_mach_preconditioner_threshold: Optional[NonNegativeFloat] = pd.Field( - None, - description="For flow regions with Mach numbers smaller than threshold, the input " - + "Mach number to the preconditioner is assumed to be the threshold value if it is " - + "smaller than the threshold. The default value for the threshold is the freestream " - + "Mach number.", - ) - - linear_solver: Union[LinearSolver, KrylovLinearSolver] = pd.Field( - default_factory=LinearSolver, - description="Linear solver configuration. Use KrylovLinearSolver for Newton-Krylov.", - discriminator="type_name", - ) - line_search: Optional[LineSearch] = pd.Field( - None, - description="Line search parameters for the Newton-Krylov solver. " - "Only valid when linear_solver is a KrylovLinearSolver.", - ) - - update_jacobian_frequency: PositiveInt = pd.Field( - 4, description="Frequency at which the jacobian is updated." - ) - max_force_jac_update_physical_steps: NonNegativeInt = pd.Field( - 0, - description="When physical step is less than this value, the jacobian matrix is " - + "updated every pseudo step.", - ) - - @pd.model_validator(mode="after") - def _validate_line_search(self) -> Self: - if self.line_search is not None and not isinstance(self.linear_solver, KrylovLinearSolver): - raise ValueError( - "line_search can only be set when linear_solver is a KrylovLinearSolver." - ) - return self - - -class SpalartAllmarasModelConstants(Flow360BaseModel): - """ - :class:`SpalartAllmarasModelConstants` class specifies the constants of the Spalart-Allmaras model. - - Example - ------- - >>> fl.SpalartAllmaras( - ... modeling_constants = SpalartAllmarasModelConstants(C_w2=2.718) - ... ) - """ - - type_name: Literal["SpalartAllmarasConsts"] = pd.Field("SpalartAllmarasConsts", frozen=True) - C_DES: NonNegativeFloat = pd.Field(0.72) - C_d: NonNegativeFloat = pd.Field(8.0) - C_cb1: NonNegativeFloat = pd.Field(0.1355) - C_cb2: NonNegativeFloat = pd.Field(0.622) - C_sigma: NonNegativeFloat = pd.Field(2.0 / 3.0) - C_v1: NonNegativeFloat = pd.Field(7.1) - C_vonKarman: NonNegativeFloat = pd.Field(0.41) - C_w2: float = pd.Field(0.3) - C_w4: float = pd.Field(0.21) - C_w5: float = pd.Field(1.5) - C_t3: NonNegativeFloat = pd.Field(1.2) - C_t4: NonNegativeFloat = pd.Field(0.5) - C_min_rd: NonNegativeFloat = pd.Field(10.0) - - -class KOmegaSSTModelConstants(Flow360BaseModel): - """ - :class:`KOmegaSSTModelConstants` class specifies the constants of the SST k-omega model. - - Example - ------- - >>> fl.KOmegaSST( - ... modeling_constants = KOmegaSSTModelConstants(C_sigma_omega1=2.718) - ... ) - """ - - type_name: Literal["kOmegaSSTConsts"] = pd.Field("kOmegaSSTConsts", frozen=True) - C_DES1: NonNegativeFloat = pd.Field(0.78) - C_DES2: NonNegativeFloat = pd.Field(0.61) - C_d1: NonNegativeFloat = pd.Field(20.0) - C_d2: NonNegativeFloat = pd.Field(3.0) - C_sigma_k1: NonNegativeFloat = pd.Field(0.85) - C_sigma_k2: NonNegativeFloat = pd.Field(1.0) - C_sigma_omega1: NonNegativeFloat = pd.Field(0.5) - C_sigma_omega2: NonNegativeFloat = pd.Field(0.856) - C_alpha1: NonNegativeFloat = pd.Field(0.31) - C_beta1: NonNegativeFloat = pd.Field(0.075) - C_beta2: NonNegativeFloat = pd.Field(0.0828) - C_beta_star: NonNegativeFloat = pd.Field(0.09) - - -TurbulenceModelConstants = Annotated[ - Union[SpalartAllmarasModelConstants, KOmegaSSTModelConstants], - pd.Field(discriminator="type_name"), -] - - -class TurbulenceModelControls(Flow360BaseModel): - """ - :class:`TurbulenceModelControls` class specifies modeling constants and enforces turbulence model - behavior on a zonal basis, as defined by mesh entities or boxes in space. These controls - supersede the global turbulence model solver settings. - - Example - _______ - >>> fl.TurbulenceModelControls( - ... modeling_constants=fl.SpalartAllmarasConstants(C_w2=2.718), - ... enforcement="RANS", - ... entities=[ - ... volume_mesh["block-1"], - ... fl.Box.from_principal_axes( - ... name="box", - ... axes=[(0, 1, 0), (0, 0, 1)], - ... center=(0, 0, 0) * fl.u.m, - ... size=(0.2, 0.3, 2) * fl.u.m, - ... ), - ... ], - ... ) - """ - - modeling_constants: Optional[TurbulenceModelConstants] = pd.Field( - None, - description="A class of `SpalartAllmarasModelConstants` or `KOmegaSSTModelConstants` used to " - + "specify constants in specific regions of the domain.", - ) - - enforcement: Optional[Literal["RANS", "LES"]] = pd.Field( - None, description="Force RANS or LES mode in a specific control region." - ) - - entities: EntityList[GenericVolume, CustomVolume, Box] = pd.Field( - alias="volumes", - description="The entity in which to apply the `TurbulenceMOdelControls``. " - + "The entity should be defined by :class:`Box` or zones from the geometry/volume mesh." - + "The axes of entity must be specified to serve as the the principle axes of the " - + "`TurbulenceModelControls` region.", - ) - - -class DetachedEddySimulation(Flow360BaseModel): - """ - :class:`DetachedEddySimulation` class is used for running hybrid RANS-LES simulations - It is supported for both SpalartAllmaras and kOmegaSST turbulence models, with and - without AmplificationFactorTransport transition model enabled." - - Example - ------- - >>> fl.SpalartAllmaras( - ... hybrid_model = DetachedEddySimulation(shielding_function = 'ZDES', grid_size_for_LES = 'maxEdgeLength') - ... ) - """ - - shielding_function: Literal["DDES", "ZDES"] = pd.Field( - "DDES", - description="Specifies the type of shielding used for the detached eddy simulation. The allowed inputs are " - "``DDES`` (Delayed Detached Eddy Simulation proposed by Spalart 2006) and ``ZDES`` " - "(proposed by Deck and Renard 2020).", - ) - grid_size_for_LES: Literal["maxEdgeLength", "meanEdgeLength", "shearLayerAdapted"] = pd.Field( - "maxEdgeLength", - description="Specifies the length used for the computation of LES length scale. " - + "The allowed inputs are :code:`maxEdgeLength`, :code:`meanEdgeLength` and :code:`shearLayerAdapted`.", - ) - - -class TurbulenceModelSolver(GenericSolverSettings, metaclass=ABCMeta): - """:class:`TurbulenceModelSolver` class for setting up turbulence model solver. - For more information on setting up the numerical parameters for the turbulence model solver, - refer to :ref:`the turbulence model solver knowledge base `. - - Example - ------- - >>> fl.TurbulenceModelSolver(absolute_tolerance=1e-10) - """ - - CFL_multiplier: PositiveFloat = pd.Field( - 2.0, - description="Factor to the CFL definitions defined in the " - + ":ref:`Time Stepping ` section.", - ) - type_name: str = pd.Field( - description=":code:`SpalartAllmaras`, :code:`kOmegaSST`, or :code:`None`." - ) - absolute_tolerance: PositiveFloat = pd.Field( - 1e-8, - description="Tolerance for the turbulence model residual, below which the solver progresses to the " - + "next physical step (unsteady) or completes the simulation (steady).", - ) - equation_evaluation_frequency: PositiveInt = pd.Field( - 4, description="Frequency at which to update the turbulence equation." - ) - reconstruction_gradient_limiter: pd.confloat(ge=0, le=2) = pd.Field( - 1.0, - description="The strength of gradient limiter used in reconstruction of solution " - + "variables at the faces (specified in the range [0.0, 2.0]). 0.0 corresponds to " - + "setting the gradient equal to zero, and 2.0 means no limiting.", - ) - quadratic_constitutive_relation: bool = pd.Field( - False, - description="Use quadratic constitutive relation for turbulence shear stress tensor " - + "instead of Boussinesq Approximation.", - ) - modeling_constants: Optional[TurbulenceModelConstants] = pd.Field( - discriminator="type_name", - description=" A :class:`TurbulenceModelConstants` object containing the DDES coefficients " - + "in the solver: **SpalartAllmaras**: :code:`C_DES` (= 0.72), :code:`C_d` (= 8.0)," - + '**kOmegaSST**: :code:`"C_DES1"` (= 0.78), ' - + ":code:`C_DES2` (= 0.61), :code:`C_d1` (= 20.0), :code:`C_d2` (= 3.0), " - + "*(values shown in the parentheses are the default values used in Flow360).*", - ) - update_jacobian_frequency: PositiveInt = pd.Field( - 4, description="Frequency at which the jacobian is updated." - ) - max_force_jac_update_physical_steps: NonNegativeInt = pd.Field( - 0, - description="For physical steps less than the input value, the jacobian matrix is " - + "updated every pseudo step overriding the :py:attr:`update_jacobian_frequency` value.", - ) - - linear_solver: LinearSolver = pd.Field( - LinearSolver(max_iterations=20), - description="Linear solver settings, see :class:`LinearSolver` documentation.", - ) - - hybrid_model: Optional[DetachedEddySimulation] = pd.Field( - None, description="Model used for running hybrid RANS-LES simulations" - ) - - rotation_correction: bool = pd.Field( - False, description="Rotation correction for the turbulence model." - ) - - controls: Optional[List[TurbulenceModelControls]] = pd.Field( - None, - strict=True, # Note: To ensure propoer err msg when none-list is fed. - description="List of control zones to enforce specific turbulence model constants " - + "and behavior.", - ) - - @pd.model_validator(mode="after") - def _check_zonal_modeling_constants_consistency(self) -> Self: - if self.controls is None: - return self - - for index, control in enumerate(self.controls): - if control.modeling_constants is None: - continue - if not isinstance( - control.modeling_constants, SpalartAllmarasModelConstants - ) and isinstance(self, SpalartAllmaras): - raise ValueError( - "Turbulence model is SpalartAllmaras, but controls.modeling" - "_constants is of a conflicting class " - f"in control region {index}." - ) - if not isinstance(control.modeling_constants, KOmegaSSTModelConstants) and isinstance( - self, KOmegaSST - ): - raise ValueError( - "Turbulence model is KOmegaSST, but controls.modeling_constants" - f" is of a conflicting class in control region {index}." - ) - return self - - -class KOmegaSST(TurbulenceModelSolver): - """ - :class:`KOmegaSST` class for setting up the turbulence solver based on the SST k-omega model. - - Example - ------- - >>> fl.KOmegaSST( - ... absolute_tolerance=1e-10, - ... linear_solver=LinearSolver(max_iterations=25), - ... update_jacobian_frequency=2, - ... equation_evaluation_frequency=1, - ... ) - """ - - type_name: Literal["kOmegaSST"] = pd.Field("kOmegaSST", frozen=True) - modeling_constants: KOmegaSSTModelConstants = pd.Field( - KOmegaSSTModelConstants(), - description="A :class:`KOmegaSSTModelConstants` object containing the coefficients " - + "used in the SST k-omega model. For the default values used in Flow360, " - + "please refer to :class:`KOmegaSSTModelConstants`.", - ) - - -class SpalartAllmaras(TurbulenceModelSolver): - """ - :class:`SpalartAllmaras` class for setting up the turbulence solver based on the Spalart-Allmaras model. - - Example - ------- - >>> fl.SpalartAllmaras( - ... absolute_tolerance=1e-10, - ... linear_solver=LinearSolver(max_iterations=25), - ... update_jacobian_frequency=2, - ... equation_evaluation_frequency=1, - ... ) - """ - - type_name: Literal["SpalartAllmaras"] = pd.Field("SpalartAllmaras", frozen=True) - - modeling_constants: Optional[SpalartAllmarasModelConstants] = pd.Field( - SpalartAllmarasModelConstants(), - description="A :class:`SpalartAllmarasModelConstants` object containing the coefficients " - + "used in the Spalart-Allmaras model. For the default values used in Flow360, " - + "please refer to :class:`SpalartAllmarasModelConstants`.", - ) - reconstruction_gradient_limiter: Optional[pd.confloat(ge=0, le=2)] = pd.Field( - 0.5, - description="The strength of gradient limiter used in reconstruction of solution " - + "variables at the faces (specified in the range [0.0, 2.0]). 0.0 corresponds to " - + "setting the gradient equal to zero, and 2.0 means no limiting.", - ) - low_reynolds_correction: Optional[bool] = pd.Field( - False, - description="Use low Reynolds number correction for Spalart-Allmaras turbulence model", - ) - - -class NoneSolver(Flow360BaseModel): - """:class:`NoneSolver` class for disabling the turbulence solver.""" - - type_name: Literal["None"] = pd.Field("None", frozen=True) - - -TurbulenceModelSolverType = Annotated[ - Union[NoneSolver, SpalartAllmaras, KOmegaSST], pd.Field(discriminator="type_name") -] - - -class HeatEquationSolver(GenericSolverSettings): - """:class:`HeatEquationSolver` class for setting up heat equation solver. - - Example - ------- - >>> fl.HeatEquationSolver( - ... equation_evaluation_frequency=10, - ... linear_solver_config=LinearSolver( - ... max_iterations=50, - ... absoluteTolerance=1e-10 - ... ) - ... ) - """ - - type_name: Literal["HeatEquation"] = pd.Field("HeatEquation", frozen=True) - absolute_tolerance: PositiveFloat = pd.Field( - 1e-9, - description="Absolute residual tolerance that determines the convergence of the heat equation in " - + "conjugate heat transfer. This value should be the same or higher than the absolute tolerance " - + "for the linear solver by a small margin.", - ) - equation_evaluation_frequency: PositiveInt = pd.Field( - 10, - description="Frequency at which to solve the heat equation in conjugate heat transfer simulations.", - ) - order_of_accuracy: Literal[2] = pd.Field(2, description="Order of accuracy in space.") - - linear_solver: LinearSolver = pd.Field( - LinearSolver(max_iterations=50, absolute_tolerance=1e-10), - description="Linear solver settings, see :class:`LinearSolver` documentation.", - ) - - -class TransitionModelSolver(GenericSolverSettings): - """:class:`TransitionModelSolver` class for setting up transition model solver. - For more information on setting up the numerical parameters for the transition model solver, - refer to :ref:`the transition model solver knowledge base `. - - Warning - ------- - :py:attr:`N_crit` and :py:attr:`turbulence_intensity_percent` cannot be specified at the same time. - - Example - ------- - >>> fl.TransitionModelSolver( - ... linear_solver=fl.LinearSolver(max_iterations=50), - ... absolute_tolerance=1e-8, - ... update_jacobian_frequency=1, - ... equation_evaluation_frequency=1, - ... turbulence_intensity_percent=0.04, - ... ) - """ - - @pd.model_validator(mode="after") - def _check_n_crit_conflict(self) -> Self: - if self.N_crit is not None and self.turbulence_intensity_percent is not None: - raise ValueError( - "N_crit and turbulence_intensity_percent cannot be specified at the same time." - ) - return self - - type_name: Literal["AmplificationFactorTransport"] = pd.Field( - "AmplificationFactorTransport", frozen=True - ) - CFL_multiplier: PositiveFloat = pd.Field( - 2.0, - description="Factor to the CFL definitions defined in the " - + ":ref:`Time Stepping ` section.", - ) - absolute_tolerance: PositiveFloat = pd.Field( - 1e-7, - description="Tolerance for the transition model residual, below which the solver progresses to " - + "the next physical step (unsteady) or completes the simulation (steady).", - ) - equation_evaluation_frequency: PositiveInt = pd.Field( - 4, description="Frequency at which to update the transition equation." - ) - turbulence_intensity_percent: Optional[pd.confloat(ge=0.03, le=2.5)] = pd.Field( - None, - description=":ref:`Turbulence Intensity `, Range from [0.03-2.5]. " - + "Only valid when :py:attr:`N_crit` is not specified.", - ) - # pylint: disable=invalid-name - N_crit: Optional[pd.confloat(ge=1.0, le=11.0)] = pd.Field( - None, - description=":ref:`Critical Amplification Factor `, Range from [1-11]. " - + "Only valid when :py:attr:`turbulence_intensity_percent` is not specified.", - ) - update_jacobian_frequency: PositiveInt = pd.Field( - 4, description="Frequency at which the jacobian is updated." - ) - max_force_jac_update_physical_steps: NonNegativeInt = pd.Field( - 0, - description="For physical steps less than the input value, the jacobian matrix " - + "is updated every pseudo step overriding the :py:attr:`update_jacobian_frequency` value.", - ) - - reconstruction_gradient_limiter: Optional[pd.confloat(ge=0.0, le=2.0)] = pd.Field( - 1.0, - description="The strength of gradient limiter used in reconstruction of solution " - + "variables at the faces (specified in the range [0.0, 2.0]). 0.0 corresponds to " - + "setting the gradient equal to zero, and 2.0 means no limiting.", - ) - - trip_regions: Optional[EntityList[Box]] = pd.Field( - None, description="A list of :class:`~flow360.Box` entities defining the trip zones." - ) - - linear_solver: LinearSolver = pd.Field( - LinearSolver(max_iterations=20), - description="Linear solver settings, see :class:`LinearSolver` documentation.", - ) - - @pd.model_validator(mode="after") - def _set_aft_ncrit(self) -> Self: - """ - Compute the critical amplification factor for AFT transition solver based on - input turbulence intensity and input Ncrit. Computing Ncrit from turbulence - intensity takes priority if both are specified. - """ - - if self.turbulence_intensity_percent is not None: - ncrit_converted = -8.43 - 2.4 * np.log( - 0.025 * np.tanh(self.turbulence_intensity_percent / 2.5) - ) - self.turbulence_intensity_percent = None - self.N_crit = ncrit_converted - elif self.N_crit is None: - self.N_crit = 8.15 - - return self - - -TransitionModelSolverType = Annotated[ - Union[NoneSolver, TransitionModelSolver], pd.Field(discriminator="type_name") -] +"""Relay import for solver numerics models.""" + +# pylint: disable=unused-import + +from flow360_schema.models.simulation.models.solver_numerics import ( + DetachedEddySimulation, + GenericSolverSettings, + HeatEquationSolver, + KOmegaSST, + KOmegaSSTModelConstants, + KrylovLinearSolver, + LinearSolver, + LineSearch, + NavierStokesSolver, + NoneSolver, + SpalartAllmaras, + SpalartAllmarasModelConstants, + TransitionModelSolver, + TransitionModelSolverType, + TurbulenceModelControls, + TurbulenceModelSolver, + TurbulenceModelSolverType, +) diff --git a/flow360/component/simulation/models/surface_models.py b/flow360/component/simulation/models/surface_models.py index 1ab5b1c17..42a97179a 100644 --- a/flow360/component/simulation/models/surface_models.py +++ b/flow360/component/simulation/models/surface_models.py @@ -1,955 +1,30 @@ -""" -Contains basically only boundary conditons for now. In future we can add new models like 2D equations. -""" +"""Relay import for surface boundary condition models.""" -from abc import ABCMeta -from typing import Annotated, Dict, Literal, Optional, Union +# pylint: disable=unused-import -import pydantic as pd -from flow360_schema.framework.physical_dimensions import ( - AbsoluteTemperature, - AngularVelocity, -) -from flow360_schema.framework.physical_dimensions import HeatFlux as HeatFluxDim -from flow360_schema.framework.physical_dimensions import ( - InverseArea, - InverseLength, - Length, -) -from flow360_schema.framework.physical_dimensions import MassFlowRate as MassFlowRateDim -from flow360_schema.framework.physical_dimensions import Pressure as PressureDim - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.framework.entity_utils import generate_uuid -from flow360.component.simulation.framework.expressions import StringExpression -from flow360.component.simulation.framework.single_attribute_base import ( - SingleAttributeModel, -) -from flow360.component.simulation.framework.unique_list import UniqueItemList -from flow360.component.simulation.models.turbulence_quantities import ( - TurbulenceQuantitiesType, -) -from flow360.component.simulation.operating_condition.operating_condition import ( - VelocityVectorType, -) -from flow360.component.simulation.primitives import ( - GhostCircularPlane, - GhostSphere, - GhostSurface, - GhostSurfacePair, - MirroredSurface, - Surface, - SurfacePair, - WindTunnelGhostSurface, -) -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - contextual_field_validator, -) -from flow360.component.simulation.validation.validation_utils import ( - check_deleted_surface_pair, - validate_entity_list_surface_existence, -) - -# pylint: disable=fixme -# TODO: Warning: Pydantic V1 import -from flow360.component.types import Axis -from flow360.log import log - - -class BoundaryBase(Flow360BaseModel, metaclass=ABCMeta): - """Boundary base""" - - type: str = pd.Field() - entities: EntityList[Surface, MirroredSurface] = pd.Field( - alias="surfaces", - description="List of boundaries with boundary condition imposed.", - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @contextual_field_validator("entities", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - # pylint: disable=fixme - # TODO: This should have been moved to EntityListAllowingGhost? - return validate_entity_list_surface_existence(value, param_info) - - -class BoundaryBaseWithTurbulenceQuantities(BoundaryBase, metaclass=ABCMeta): - """Boundary base with turbulence quantities""" - - turbulence_quantities: Optional[TurbulenceQuantitiesType] = pd.Field( - None, - description="The turbulence related quantities definition." - + "See :func:`TurbulenceQuantities` documentation.", - ) - - -class HeatFlux(SingleAttributeModel): - """ - :class:`HeatFlux` class to specify the heat flux for `Wall` boundary condition - via :py:attr:`Wall.heat_spec`. - - Example - ------- - - >>> fl.HeatFlux(value = 1.0 * fl.u.W/fl.u.m**2) - - ==== - """ - - type_name: Literal["HeatFlux"] = pd.Field("HeatFlux", frozen=True) - value: Union[StringExpression, HeatFluxDim.Float64] = pd.Field( - description="The heat flux value." - ) - - -class Temperature(SingleAttributeModel): - """ - :class:`Temperature` class to specify the temperature for `Wall` or `Inflow` - boundary condition via :py:attr:`Wall.heat_spec`/ - :py:attr:`Inflow.spec`. - - Example - ------- - - >>> fl.Temperature(value = 350 * fl.u.K) - - ==== - """ - - type_name: Literal["Temperature"] = pd.Field("Temperature", frozen=True) - # pylint: disable=no-member - value: Union[StringExpression, AbsoluteTemperature.Float64] = pd.Field( - description="The temperature value." - ) - - -class TotalPressure(Flow360BaseModel): - """ - :class:`TotalPressure` class to specify the total pressure for `Inflow` - boundary condition via :py:attr:`Inflow.spec`. - - Example - ------- - - - Using a constant value: - - >>> fl.TotalPressure( - ... value = 1.04e6 * fl.u.Pa, - ... ) - - - Using an expression (nondimensionalized by Flow360 pressure unit, rho * a^2): - - >>> fl.TotalPressure( - ... value = "pow(1.0+0.2*pow(0.1*(1.0-y*y),2.0),1.4/0.4) / 1.4", - ... ) - - ==== - """ - - type_name: Literal["TotalPressure"] = pd.Field("TotalPressure", frozen=True) - # pylint: disable=no-member - value: Union[StringExpression, PressureDim.PositiveFloat64] = pd.Field( - description="The total pressure value. When a string expression is supplied the value" - + " needs to be nondimensionalized by the Flow360 pressure unit (rho_inf * a_inf^2)." - ) - - -class Pressure(SingleAttributeModel): - """ - :class:`Pressure` class to specify the static pressure for `Outflow` - boundary condition via :py:attr:`Outflow.spec`. - - Example - ------- - - >>> fl.Pressure(value = 1.01e6 * fl.u.Pa) - - ==== - """ - - type_name: Literal["Pressure"] = pd.Field("Pressure", frozen=True) - # pylint: disable=no-member - value: PressureDim.PositiveFloat64 = pd.Field(description="The static pressure value.") - - -class SlaterPorousBleed(Flow360BaseModel): - """ - :class:`SlaterPorousBleed` is a no-slip wall model which prescribes a normal - velocity at the surface as a function of the surface pressure and density according - to the model of John Slater. - - Example - ------- - - Specify a static pressure of 1.01e6 Pascals at the slater bleed boundary, and - set the porosity of the surface to 0.4 (40%). - - >>> fl.SlaterPorousBleed(static_pressure=1.01e6 * fl.u.Pa, porosity=0.4, activation_step=200) - - ==== - """ - - type_name: Literal["SlaterPorousBleed"] = pd.Field("SlaterPorousBleed", frozen=True) - # pylint: disable=no-member - static_pressure: PressureDim.PositiveFloat64 = pd.Field( - description="The static pressure value." - ) - porosity: float = pd.Field(gt=0, le=1, description="The porosity of the bleed region.") - activation_step: Optional[pd.PositiveInt] = pd.Field( - None, description="Pseudo step at which to start applying the SlaterPorousBleedModel." - ) - - -class MassFlowRate(Flow360BaseModel): - """ - :class:`MassFlowRate` class to specify the mass flow rate for `Inflow` or `Outflow` - boundary condition via :py:attr:`Inflow.spec`/:py:attr:`Outflow.spec`. - - Example - ------- - - >>> fl.MassFlowRate( - ... value = 123 * fl.u.lb / fl.u.s, - ... ramp_steps = 100, - ... ) - - ==== - """ - - type_name: Literal["MassFlowRate"] = pd.Field("MassFlowRate", frozen=True) - # pylint: disable=no-member - value: MassFlowRateDim.NonNegativeFloat64 = pd.Field(description="The mass flow rate.") - ramp_steps: Optional[pd.PositiveInt] = pd.Field( - None, - description="Number of pseudo steps before reaching :py:attr:`MassFlowRate.value` within 1 physical step.", - ) - - -class Supersonic(Flow360BaseModel): - """ - :class:`Supersonic` class to specify the supersonic conditions for `Inflow`. - - Example - ------- - - >>> fl.Supersonic( - ... total_pressure = 7.90e6 * fl.u.Pa, - ... static_pressure = 1.01e6 * fl.u.Pa, - ... ) - - """ - - type_name: Literal["Supersonic"] = pd.Field("Supersonic", frozen=True) - # pylint: disable=no-member - total_pressure: PressureDim.PositiveFloat64 = pd.Field(description="The total pressure.") - static_pressure: PressureDim.PositiveFloat64 = pd.Field(description="The static pressure.") - - -class Mach(SingleAttributeModel): - """ - :class:`Mach` class to specify Mach number for the `Inflow` - boundary condition via :py:attr:`Inflow.spec`. - - Example - ------- - - >>> fl.Mach(value = 0.5) - - ==== - """ - - type_name: Literal["Mach"] = pd.Field("Mach", frozen=True) - value: pd.NonNegativeFloat = pd.Field(description="The Mach number.") - - -class Translational(Flow360BaseModel): - """ - :class:`Translational` class to specify translational periodic - boundary condition via :py:attr:`Periodic.spec`. - - """ - - type_name: Literal["Translational"] = pd.Field("Translational", frozen=True) - - -class Rotational(Flow360BaseModel): - """ - :class:`Rotational` class to specify rotational periodic - boundary condition via :py:attr:`Periodic.spec`. - """ - - type_name: Literal["Rotational"] = pd.Field("Rotational", frozen=True) - # pylint: disable=fixme - # TODO: Maybe we need more precision when serializeing this one? - axis_of_rotation: Optional[Axis] = pd.Field(None) - - -class WallRotation(Flow360BaseModel): - """ - :class:`WallRotation` class to specify the rotational velocity model for the `Wall` boundary condition. - - The wall rotation model prescribes a rotational motion at the wall by defining a center of rotation, - an axis about which the wall rotates, and an angular velocity. This model can be used to simulate - rotating components or surfaces in a flow simulation. - - Example - ------- - >>> fl.Wall( - ... entities=volume_mesh["fluid/wall"], - ... velocity=fl.WallRotation( - ... axis=(0, 0, 1), - ... center=(1, 2, 3) * u.m, - ... angular_velocity=100 * u.rpm - ... ), - ... use_wall_function=True, - ... ) - - ==== - """ - - # pylint: disable=no-member - center: Length.Vector3 = pd.Field(description="The center of rotation") - axis: Axis = pd.Field(description="The axis of rotation.") - angular_velocity: AngularVelocity.Float64 = pd.Field("The value of the angular velocity.") - type_name: Literal["WallRotation"] = pd.Field("WallRotation", frozen=True) - private_attribute_circle_mode: Optional[dict] = pd.Field(None) - - -########################################## -############# Surface models ############# -########################################## - - -WallVelocityModelTypes = Annotated[ - Union[SlaterPorousBleed, WallRotation], pd.Field(discriminator="type_name") -] - - -class WallFunction(Flow360BaseModel): - """ - :class:`WallFunction` specifies the wall function model to use on a :class:`Wall` boundary. - - Example - ------- - - - Default boundary-layer wall function: - - >>> fl.Wall( - ... entities=volume_mesh["fluid/wall"], - ... use_wall_function=fl.WallFunction(), - ... ) - - - Inner-layer wall model: - - >>> fl.Wall( - ... entities=volume_mesh["fluid/wall"], - ... use_wall_function=fl.WallFunction(wall_function_type="InnerLayer"), - ... ) - - ==== - """ - - wall_function_type: Literal["BoundaryLayer", "InnerLayer"] = pd.Field( - "BoundaryLayer", - description="Type of wall function model. " - + "'BoundaryLayer' uses integral flat plate boundary layer theory to predict wall shear stress. " - + "It performs well across all y+ ranges. " - + "'InnerLayer' uses the inner layer behavior of the turbulent boundary layer, " - + "offering better accuracy for y+ values in the log layer and below.", - ) - - -class Wall(BoundaryBase): - """ - :class:`Wall` class defines the wall boundary condition based on the inputs. - - Example - ------- - - - :code:`Wall` with default wall function (BoundaryLayer) and prescribed velocity: - - >>> fl.Wall( - ... entities=geometry["wall_function"], - ... velocity = ["min(0.2, 0.2 + 0.2*y/0.5)", "0", "0.1*y/0.5"], - ... use_wall_function=fl.WallFunction(), - ... ) - - - :code:`Wall` with inner-layer wall function: - - >>> fl.Wall( - ... entities=volume_mesh["8"], - ... use_wall_function=fl.WallFunction(wall_function_type="InnerLayer"), - ... ) - - - :code:`Wall` with wall function and wall rotation: - - >>> fl.Wall( - ... entities=volume_mesh["8"], - ... velocity=WallRotation( - ... axis=(0, 0, 1), - ... center=(1, 2, 3) * u.m, - ... angular_velocity=100 * u.rpm - ... ), - ... use_wall_function=fl.WallFunction(), - ... ) - - - Define isothermal wall boundary condition on entities - with the naming pattern :code:`"fluid/isothermal-*"`: - - >>> fl.Wall( - ... entities=volume_mesh["fluid/isothermal-*"], - ... heat_spec=fl.Temperature(350 * fl.u.K), - ... ) - - - Define isoflux wall boundary condition on entities - with the naming pattern :code:`"solid/isoflux-*"`: - - >>> fl.Wall( - ... entities=volume_mesh["solid/isoflux-*"], - ... heat_spec=fl.HeatFlux(1.0 * fl.u.W/fl.u.m**2), - ... ) - - - Define Slater no-slip bleed model on entities - with the naming pattern :code:`"fluid/SlaterBoundary-*"`: - - >>> fl.Wall( - ... entities=volume_mesh["fluid/SlaterBoundary-*"], - ... velocity=fl.SlaterPorousBleed( - ... static_pressure=1.01e6 * fl.u.Pa, porosity=0.4, activation_step=200 - ... ), - ... ) - - - Define roughness height on entities - with the naming pattern :code:`"fluid/Roughness-*"`: - - >>> fl.Wall( - ... entities=volume_mesh["fluid/Roughness-*"], - ... roughness_height=0.1 * fl.u.mm, - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Wall", description="Name of the `Wall` boundary condition.") - type: Literal["Wall"] = pd.Field("Wall", frozen=True) - use_wall_function: Optional[WallFunction] = pd.Field( - None, - description="Wall function configuration. Set to :class:`WallFunction` to enable " - + "wall functions. The default wall function type is ``'BoundaryLayer'``. " - + "Set to ``None`` to disable wall functions (no-slip wall).", - ) - - velocity: Optional[Union[WallVelocityModelTypes, VelocityVectorType]] = pd.Field( - None, description="Prescribe a velocity or the velocity model on the wall." - ) - - # pylint: disable=no-member - heat_spec: Union[HeatFlux, Temperature] = pd.Field( - HeatFlux(0 * u.W / u.m**2), - discriminator="type_name", - description="Specify the heat flux or temperature at the `Wall` boundary.", - ) - roughness_height: Length.NonNegativeFloat64 = pd.Field( - 0 * u.m, - description="Equivalent sand grain roughness height. Available only to `Fluid` zone boundaries.", - ) - private_attribute_dict: Optional[Dict] = pd.Field(None) - - entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field( - alias="surfaces", - description="List of boundaries with the `Wall` boundary condition imposed.", - ) - - @pd.field_validator("use_wall_function", mode="before") - @classmethod - def _normalize_wall_function(cls, value): - """Handle backward-compatible bool inputs for use_wall_function.""" - if value is True: - log.warning( - "Passing a bool to `use_wall_function` is deprecated. " - "Use `use_wall_function=WallFunction()` instead of `True`." - ) - return WallFunction() - if value is False: - log.warning( - "Passing a bool to `use_wall_function` is deprecated. " - "Use `use_wall_function=None` instead of `False`." - ) - return None - return value - - @pd.model_validator(mode="after") - def check_wall_function_conflict(self): - """Check no setting is conflicting with the usage of wall function""" - if self.use_wall_function is None: - return self - if isinstance(self.velocity, SlaterPorousBleed): - raise ValueError( - f"Using `{type(self.velocity).__name__}` with wall function is not supported currently." - ) - return self - - @contextual_field_validator("heat_spec", mode="after") - @classmethod - def _ensure_adiabatic_wall_for_liquid(cls, value, param_info: ParamsValidationInfo): - """Allow only adiabatic wall when liquid operating condition is used""" - if param_info.using_liquid_as_material is False: - return value - if isinstance(value, HeatFlux) and value.value == 0 * u.W / u.m**2: - return value - raise ValueError("Only adiabatic wall is allowed when using liquid as simulation material.") - - @contextual_field_validator("velocity", mode="after") - @classmethod - def _disable_expression_for_liquid(cls, value, param_info: ParamsValidationInfo): - if param_info.using_liquid_as_material is False: - return value - - if isinstance(value, tuple): - if ( - isinstance(value[0], str) - and isinstance(value[1], str) - and isinstance(value[2], str) - ): - raise ValueError( - "Expression cannot be used when using liquid as simulation material." - ) - return value - - -class Freestream(BoundaryBaseWithTurbulenceQuantities): - """ - :class:`Freestream` defines the freestream boundary condition. - - Example - ------- - - - Define freestream boundary condition with velocity expression and boundaries from the volume mesh: - - >>> fl.Freestream( - ... surfaces=[volume_mesh["blk-1/freestream-part1"], - ... volume_mesh["blk-1/freestream-part2"]], - ... velocity = ["min(0.2, 0.2 + 0.2*y/0.5)", "0", "0.1*y/0.5"] - ... ) - - - Define freestream boundary condition with turbulence quantities and automated farfield: - - >>> auto_farfield = fl.AutomatedFarfield() - ... fl.Freestream( - ... entities=[auto_farfield.farfield], - ... turbulence_quantities= fl.TurbulenceQuantities( - ... modified_viscosity_ratio=10, - ... ) - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Freestream", description="Name of the `Freestream` boundary condition." - ) - type: Literal["Freestream"] = pd.Field("Freestream", frozen=True) - velocity: Optional[VelocityVectorType] = pd.Field( - None, - description="The default values are set according to the " - + ":py:attr:`AerospaceCondition.alpha` and :py:attr:`AerospaceCondition.beta` angles. " - + "Optionally, an expression for each of the velocity components can be specified.", - ) - entities: EntityList[ - Surface, - MirroredSurface, - GhostSurface, - WindTunnelGhostSurface, - GhostSphere, - GhostCircularPlane, - ] = pd.Field( # pylint: disable=duplicate-code - alias="surfaces", - description="List of boundaries with the `Freestream` boundary condition imposed.", - ) - - @contextual_field_validator("velocity", mode="after") - @classmethod - def _disable_expression_for_liquid(cls, value, param_info: ParamsValidationInfo): - if param_info.using_liquid_as_material is False: - return value - - if isinstance(value, tuple): - if ( - isinstance(value[0], str) - and isinstance(value[1], str) - and isinstance(value[2], str) - ): - raise ValueError( - "Expression cannot be used when using liquid as simulation material." - ) - return value - - -class Outflow(BoundaryBase): - """ - :class:`Outflow` defines the outflow boundary condition based on the input :py:attr:`spec`. - - Example - ------- - - Define outflow boundary condition with pressure: - - >>> fl.Outflow( - ... surfaces=volume_mesh["fluid/outlet"], - ... spec=fl.Pressure(value = 0.99e6 * fl.u.Pa) - ... ) - - - Define outflow boundary condition with Mach number: - - >>> fl.Outflow( - ... surfaces=volume_mesh["fluid/outlet"], - ... spec=fl.Mach(value = 0.2) - ... ) - - - Define outflow boundary condition with mass flow rate: - - >>> fl.Outflow( - ... surfaces=volume_mesh["fluid/outlet"], - ... spec=fl.MassFlowRate(value = 123 * fl.u.lb / fl.u.s) - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Outflow", description="Name of the `Outflow` boundary condition." - ) - type: Literal["Outflow"] = pd.Field("Outflow", frozen=True) - spec: Union[Pressure, MassFlowRate, Mach] = pd.Field( - discriminator="type_name", - description="Specify the static pressure, mass flow rate, or Mach number parameters at" - + " the `Outflow` boundary.", - ) - entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field( - alias="surfaces", - description="List of boundaries with the `Outflow` boundary condition imposed.", - ) - - -class Inflow(BoundaryBaseWithTurbulenceQuantities): - """ - :class:`Inflow` defines the inflow boundary condition based on the input :py:attr:`spec`. - - Example - ------- - - - Define inflow boundary condition with pressure: - - >>> fl.Inflow( - ... entities=[geometry["inflow"]], - ... total_temperature=300 * fl.u.K, - ... spec=fl.TotalPressure( - ... value = 1.028e6 * fl.u.Pa, - ... ), - ... velocity_direction = (1, 0, 0), - ... ) - - - Define inflow boundary condition with mass flow rate: - - >>> fl.Inflow( - ... entities=[volume_mesh["fluid/inflow"]], - ... total_temperature=300 * fl.u.K, - ... spec=fl.MassFlowRate( - ... value = 123 * fl.u.lb / fl.u.s, - ... ramp_steps = 10, - ... ), - ... velocity_direction = (1, 0, 0), - ... ) - - - Define inflow boundary condition with turbulence quantities: - - >>> fl.Inflow( - ... entities=[volume_mesh["fluid/inflow"]], - ... turbulence_quantities=fl.TurbulenceQuantities( - ... turbulent_kinetic_energy=2.312e-3 * fl.u.m **2 / fl.u.s**2, - ... specific_dissipation_rate= 1020 / fl.u.s, - ... ) - ... ) - - - Define inflow boundary condition with expressions for spatially varying total temperature and total pressure: - - >>> fl.Inflow( - ... entities=[volumeMesh["fluid/inflow"]], - ... total_temperature="1.0+0.2*pow(0.1*(1.0-y*y),2.0)", - ... velocity_direction=(1.0, 0.0, 0.0), - ... spec=fl.TotalPressure( - ... value="pow(1.0+0.2*pow(0.1*(1.0-y*y),2.0),1.4/0.4)", - ... ), - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Inflow", description="Name of the `Inflow` boundary condition.") - type: Literal["Inflow"] = pd.Field("Inflow", frozen=True) - # pylint: disable=no-member - total_temperature: Union[StringExpression, AbsoluteTemperature.Float64] = pd.Field( - description="Specify the total temperature at the `Inflow` boundary." - + " When a string expression is supplied the value" - + " needs to nondimensionalized by the temperature defined in `operating_condition`." - ) - spec: Union[TotalPressure, MassFlowRate, Supersonic] = pd.Field( - discriminator="type_name", - description="Specify additional conditions at the `Inflow` boundary.", - ) - velocity_direction: Optional[Axis] = pd.Field( - None, - description="Direction of the incoming flow. Must be a unit vector pointing " - + "into the volume. If unspecified, the direction will be normal to the surface.", - ) - entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field( - alias="surfaces", - description="List of boundaries with the `Inflow` boundary condition imposed.", - ) - - -class SlipWall(BoundaryBase): - """:class:`SlipWall` class defines the :code:`SlipWall` boundary condition. - - Example - ------- - - Define :code:`SlipWall` boundary condition for entities with the naming pattern: - - :code:`"*/slipWall"` in the volume mesh. - - >>> fl.SlipWall(entities=volume_mesh["*/slipWall"] - - - Define :code:`SlipWall` boundary condition with automated farfield symmetry plane boundaries: - - >>> auto_farfield = fl.AutomatedFarfield() - >>> fl.SlipWall( - ... entities=[auto_farfield.symmetry_planes], - ... turbulence_quantities= fl.TurbulenceQuantities( - ... modified_viscosity_ratio=10, - ... ) - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Slip wall", description="Name of the `SlipWall` boundary condition." - ) - type: Literal["SlipWall"] = pd.Field("SlipWall", frozen=True) - entities: EntityList[ - Surface, MirroredSurface, GhostSurface, WindTunnelGhostSurface, GhostCircularPlane - ] = pd.Field( - alias="surfaces", - description="List of boundaries with the :code:`SlipWall` boundary condition imposed.", - ) - - -class SymmetryPlane(BoundaryBase): - """ - :class:`SymmetryPlane` defines the symmetric boundary condition. - It is similar to :class:`SlipWall`, but the normal gradient of scalar quantities - are forced to be zero on the symmetry plane. **Only planar surfaces are supported.** - - Example - ------- - - >>> fl.SymmetryPlane(entities=volume_mesh["fluid/symmetry"]) - - - Define `SymmetryPlane` boundary condition with automated farfield symmetry plane boundaries: - - >>> auto_farfield = fl.AutomatedFarfield() - >>> fl.SymmetryPlane( - ... entities=[auto_farfield.symmetry_planes], - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Symmetry", description="Name of the `SymmetryPlane` boundary condition." - ) - type: Literal["SymmetryPlane"] = pd.Field("SymmetryPlane", frozen=True) - entities: EntityList[Surface, MirroredSurface, GhostSurface, GhostCircularPlane] = pd.Field( - alias="surfaces", - description="List of boundaries with the `SymmetryPlane` boundary condition imposed.", - ) - - -class Periodic(Flow360BaseModel): - """ - :class:`Periodic` defines the translational or rotational periodic boundary condition. - - Example - ------- - - - Define a translationally periodic boundary condition using :class:`Translational`: - - >>> fl.Periodic( - ... surface_pairs=[ - ... (volume_mesh["VOLUME/BOTTOM"], volume_mesh["VOLUME/TOP"]), - ... (volume_mesh["VOLUME/RIGHT"], volume_mesh["VOLUME/LEFT"]), - ... ], - ... spec=fl.Translational(), - ... ) - - - Define a rotationally periodic boundary condition using :class:`Rotational`: - - >>> fl.Periodic( - ... surface_pairs=[(volume_mesh["VOLUME/PERIODIC-1"], - ... volume_mesh["VOLUME/PERIODIC-2"])], - ... spec=fl.Rotational() - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Periodic", description="Name of the `Periodic` boundary condition." - ) - type: Literal["Periodic"] = pd.Field("Periodic", frozen=True) - entity_pairs: UniqueItemList[Union[SurfacePair, GhostSurfacePair]] = pd.Field( - alias="surface_pairs", - description="List of matching pairs of :class:`~flow360.Surface` or `~flow360.GhostSurface`. ", - ) - spec: Union[Translational, Rotational] = pd.Field( - discriminator="type_name", - description="Define the type of periodic boundary condition (translational/rotational) " - + "via :class:`Translational`/:class:`Rotational`.", - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @contextual_field_validator("entity_pairs", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - for surface_pair in value.items: - check_deleted_surface_pair(surface_pair, param_info) - return value - - @contextual_field_validator("entity_pairs", mode="after") - @classmethod - def _ensure_quasi_3d_periodic_when_using_ghost_surface( - cls, value, param_info: ParamsValidationInfo - ): - """ - When using ghost surface pairs, ensure the farfield type is quasi-3d-periodic. - """ - for surface_pair in value.items: - if isinstance(surface_pair, GhostSurfacePair): - if param_info.farfield_method != "quasi-3d-periodic": - raise ValueError( - "Farfield type must be 'quasi-3d-periodic' when using GhostSurfacePair." - ) - return value - - -class PorousJump(Flow360BaseModel): - """ - :class:`PorousJump` defines the Porous Jump boundary condition. - - Example - ------- - - Define a porous jump condition: - - >>> fl.PorousJump( - ... surface_pairs=[ - ... (volume_mesh["blk-1/Interface-blk-2"], volume_mesh["blk-2/Interface-blk-1"]), - ... (volume_mesh["blk-1/Interface-blk-3"], volume_mesh["blk-3/Interface-blk-1"]), - ... ], - ... darcy_coefficient = 1e6 / fl.u.m **2, - ... forchheimer_coefficient = 1 / fl.u.m, - ... thickness = 1 * fl.u.m, - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "PorousJump", description="Name of the `PorousJump` boundary condition." - ) - type: Literal["PorousJump"] = pd.Field("PorousJump", frozen=True) - entity_pairs: UniqueItemList[SurfacePair] = pd.Field( - alias="surface_pairs", description="List of matching pairs of :class:`~flow360.Surface`. " - ) - darcy_coefficient: InverseArea.Float64 = pd.Field( - description="Darcy coefficient of the porous media model which determines the scaling of the " - + "viscous loss term. The value defines the coefficient for the axis normal " - + "to the surface." - ) - forchheimer_coefficient: InverseLength.Float64 = pd.Field( - description="Forchheimer coefficient of the porous media model which determines " - + "the scaling of the inertial loss term." - ) - thickness: Length.Float64 = pd.Field( - description="Thickness of the thin porous media on the surface" - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @contextual_field_validator("entity_pairs", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher and all entities are surfaces""" - - def _is_cross_custom_volume_interface(surface1, surface2) -> bool: - """Check if two surfaces belong to different CustomVolumes' bounding_entities.""" - surface1_id = surface1.private_attribute_id - surface2_id = surface2.private_attribute_id - - cv_names_for_surface1 = set() - cv_names_for_surface2 = set() - - for cv_name, cv_info in param_info.to_be_generated_custom_volumes.items(): - boundary_ids = cv_info.get("boundary_surface_ids", set()) - if surface1_id in boundary_ids: - cv_names_for_surface1.add(cv_name) - if surface2_id in boundary_ids: - cv_names_for_surface2.add(cv_name) - - # Both surfaces must belong to at least one CustomVolume, - # and they must not share any common CustomVolume - return ( - bool(cv_names_for_surface1) - and bool(cv_names_for_surface2) - and cv_names_for_surface1.isdisjoint(cv_names_for_surface2) - ) - - def _is_farfield_custom_volume_interface(surface1, surface2) -> bool: - """Check if both surfaces are dual-belonging (farfield enclosed ∩ CustomVolume bounding_entities).""" - dual = param_info.farfield_cv_dual_belonging_ids - return surface1.private_attribute_id in dual and surface2.private_attribute_id in dual - - for surface_pair in value.items: - check_deleted_surface_pair(surface_pair, param_info) - - surface1, surface2 = surface_pair.pair - - # Skip interface check for cross-CustomVolume bounding_entities (will become interface after meshing) - if _is_cross_custom_volume_interface(surface1, surface2): - continue - - # Skip interface check for cross-farfield-CustomVolume bounding_entities - if _is_farfield_custom_volume_interface(surface1, surface2): - continue - - for surface in surface_pair.pair: - if not surface.private_attribute_is_interface: - raise ValueError(f"Boundary `{surface.name}` is not an interface") - - return value - - -SurfaceModelTypes = Union[ - Wall, - SlipWall, +from flow360_schema.models.simulation.models.surface_models import ( + BoundaryBase, + BoundaryBaseWithTurbulenceQuantities, Freestream, - Outflow, + HeatFlux, Inflow, + Mach, + MassFlowRate, + Outflow, Periodic, - SymmetryPlane, PorousJump, -] + Pressure, + Rotational, + SlaterPorousBleed, + SlipWall, + Supersonic, + SurfaceModelTypes, + SymmetryPlane, + Temperature, + TotalPressure, + Translational, + Wall, + WallFunction, + WallRotation, + WallVelocityModelTypes, +) diff --git a/flow360/component/simulation/models/turbulence_quantities.py b/flow360/component/simulation/models/turbulence_quantities.py index 47ac95cc7..2ee118303 100644 --- a/flow360/component/simulation/models/turbulence_quantities.py +++ b/flow360/component/simulation/models/turbulence_quantities.py @@ -1,428 +1,23 @@ -""" -Turbulence quantities parameters -""" +"""Turbulence quantities parameters — re-import relay.""" # pylint: disable=unused-import -from abc import ABCMeta -from functools import wraps -from typing import Annotated, Literal, Optional, Union -import pydantic as pd -from flow360_schema.framework.physical_dimensions import ( - Frequency, - KinematicViscosity, - Length, - SpecificEnergy, +from flow360_schema.models.simulation.models.turbulence_quantities import ( + ModifiedTurbulentViscosity, + ModifiedTurbulentViscosityRatio, + SpecificDissipationRateAndTurbulentKineticEnergy, + SpecificDissipationRateAndTurbulentLengthScale, + SpecificDissipationRateAndTurbulentViscosityRatio, + TurbulenceQuantities, + TurbulenceQuantitiesType, + TurbulentIntensity, + TurbulentIntensityAndSpecificDissipationRate, + TurbulentIntensityAndTurbulentLengthScale, + TurbulentIntensityAndTurbulentViscosityRatio, + TurbulentKineticEnergy, + TurbulentLengthScale, + TurbulentLengthScaleAndTurbulentKineticEnergy, + TurbulentViscosityRatio, + TurbulentViscosityRatioAndTurbulentKineticEnergy, + TurbulentViscosityRatioAndTurbulentLengthScale, ) - -from flow360.component.simulation.framework.base_model import Flow360BaseModel - - -class TurbulentKineticEnergy(Flow360BaseModel): - """ - turbulentKineticEnergy : SpecificEnergyType [energy / mass] - Turbulent kinetic energy. Applicable only when using SST model. - """ - - type_name: Literal["TurbulentKineticEnergy"] = pd.Field("TurbulentKineticEnergy", frozen=True) - # pylint: disable=no-member - turbulent_kinetic_energy: SpecificEnergy.NonNegativeFloat64 = pd.Field() - - -class TurbulentIntensity(Flow360BaseModel): - """ - turbulentIntensity : non-dimensional [`-`] - Turbulent intensity. Applicable only when using SST model. - This is related to turbulent kinetic energy as: - `turbulentKineticEnergy = 1.5*pow(U_ref * turbulentIntensity, 2)`. - Note the use of the freestream velocity U_ref instead of C_inf. - """ - - type_name: Literal["TurbulentIntensity"] = pd.Field("TurbulentIntensity", frozen=True) - turbulent_intensity: pd.NonNegativeFloat = pd.Field() - - -class _SpecificDissipationRate(Flow360BaseModel, metaclass=ABCMeta): - """ - specificDissipationRate : FrequencyType [1 / time] - Turbulent specific dissipation rate. Applicable only when using SST model. - """ - - type_name: Literal["SpecificDissipationRate"] = pd.Field("SpecificDissipationRate", frozen=True) - # pylint: disable=no-member - specific_dissipation_rate: Frequency.NonNegativeFloat64 = pd.Field() - - -class TurbulentViscosityRatio(Flow360BaseModel): - """ - turbulentViscosityRatio : non-dimensional [`-`] - The ratio of turbulent eddy viscosity over the freestream viscosity. Applicable for both SA and SST model. - """ - - type_name: Literal["TurbulentViscosityRatio"] = pd.Field("TurbulentViscosityRatio", frozen=True) - turbulent_viscosity_ratio: pd.PositiveFloat = pd.Field() - - -class TurbulentLengthScale(Flow360BaseModel, metaclass=ABCMeta): - """ - turbulentLengthScale : LengthType [length] - The turbulent length scale is an estimation of the size of the eddies that are modeled/not resolved. - Applicable only when using SST model. This is related to the turbulent kinetic energy and turbulent - specific dissipation rate as: `L_T = sqrt(k)/(pow(beta_0^*, 0.25)*w)` where `L_T` is turbulent length scale, - `k` is turbulent kinetic energy, `beta_0^*` is 0.09 and `w` is turbulent specific dissipation rate. - Applicable only when using SST model. - """ - - type_name: Literal["TurbulentLengthScale"] = pd.Field("TurbulentLengthScale", frozen=True) - # pylint: disable=no-member - turbulent_length_scale: Length.PositiveFloat64 = pd.Field() - - -class ModifiedTurbulentViscosityRatio(Flow360BaseModel): - """ - modifiedTurbulentViscosityRatio : non-dimensional [`-`] - The ratio of modified turbulent eddy viscosity (SA) over the freestream viscosity. - Applicable only when using SA model. - """ - - type_name: Literal["ModifiedTurbulentViscosityRatio"] = pd.Field( - "ModifiedTurbulentViscosityRatio", frozen=True - ) - modified_turbulent_viscosity_ratio: pd.PositiveFloat = pd.Field() - - -class ModifiedTurbulentViscosity(Flow360BaseModel): - """ - modifiedTurbulentViscosity : KinematicViscosityType [length**2 / time] - The modified turbulent eddy viscosity (SA). Applicable only when using SA model. - """ - - type_name: Literal["ModifiedTurbulentViscosity"] = pd.Field( - "ModifiedTurbulentViscosity", frozen=True - ) - # pylint: disable=no-member - modified_turbulent_viscosity: Optional[KinematicViscosity.PositiveFloat64] = pd.Field() - - -# pylint: disable=missing-class-docstring -class SpecificDissipationRateAndTurbulentKineticEnergy( - _SpecificDissipationRate, TurbulentKineticEnergy -): - type_name: Literal["SpecificDissipationRateAndTurbulentKineticEnergy"] = pd.Field( - "SpecificDissipationRateAndTurbulentKineticEnergy", frozen=True - ) - - -class TurbulentViscosityRatioAndTurbulentKineticEnergy( - TurbulentViscosityRatio, TurbulentKineticEnergy -): - type_name: Literal["TurbulentViscosityRatioAndTurbulentKineticEnergy"] = pd.Field( - "TurbulentViscosityRatioAndTurbulentKineticEnergy", frozen=True - ) - - -class TurbulentLengthScaleAndTurbulentKineticEnergy(TurbulentLengthScale, TurbulentKineticEnergy): - type_name: Literal["TurbulentLengthScaleAndTurbulentKineticEnergy"] = pd.Field( - "TurbulentLengthScaleAndTurbulentKineticEnergy", frozen=True - ) - - -class TurbulentIntensityAndSpecificDissipationRate(TurbulentIntensity, _SpecificDissipationRate): - type_name: Literal["TurbulentIntensityAndSpecificDissipationRate"] = pd.Field( - "TurbulentIntensityAndSpecificDissipationRate", frozen=True - ) - - -class TurbulentIntensityAndTurbulentViscosityRatio(TurbulentIntensity, TurbulentViscosityRatio): - type_name: Literal["TurbulentIntensityAndTurbulentViscosityRatio"] = pd.Field( - "TurbulentIntensityAndTurbulentViscosityRatio", frozen=True - ) - - -class TurbulentIntensityAndTurbulentLengthScale(TurbulentIntensity, TurbulentLengthScale): - type_name: Literal["TurbulentIntensityAndTurbulentLengthScale"] = pd.Field( - "TurbulentIntensityAndTurbulentLengthScale", frozen=True - ) - - -class SpecificDissipationRateAndTurbulentViscosityRatio( - _SpecificDissipationRate, TurbulentViscosityRatio -): - type_name: Literal["SpecificDissipationRateAndTurbulentViscosityRatio"] = pd.Field( - "SpecificDissipationRateAndTurbulentViscosityRatio", frozen=True - ) - - -class SpecificDissipationRateAndTurbulentLengthScale( - _SpecificDissipationRate, TurbulentLengthScale -): - type_name: Literal["SpecificDissipationRateAndTurbulentLengthScale"] = pd.Field( - "SpecificDissipationRateAndTurbulentLengthScale", frozen=True - ) - - -class TurbulentViscosityRatioAndTurbulentLengthScale(TurbulentViscosityRatio, TurbulentLengthScale): - type_name: Literal["TurbulentViscosityRatioAndTurbulentLengthScale"] = pd.Field( - "TurbulentViscosityRatioAndTurbulentLengthScale", frozen=True - ) - - -# pylint: enable=missing-class-docstring -# pylint: disable=duplicate-code - -TurbulenceQuantitiesType = Annotated[ - Union[ - TurbulentViscosityRatio, - TurbulentKineticEnergy, - TurbulentIntensity, - TurbulentLengthScale, - ModifiedTurbulentViscosityRatio, - ModifiedTurbulentViscosity, - SpecificDissipationRateAndTurbulentKineticEnergy, - TurbulentViscosityRatioAndTurbulentKineticEnergy, - TurbulentLengthScaleAndTurbulentKineticEnergy, - TurbulentIntensityAndSpecificDissipationRate, - TurbulentIntensityAndTurbulentViscosityRatio, - TurbulentIntensityAndTurbulentLengthScale, - SpecificDissipationRateAndTurbulentViscosityRatio, - SpecificDissipationRateAndTurbulentLengthScale, - TurbulentViscosityRatioAndTurbulentLengthScale, - ], - pd.Field(discriminator="type_name"), -] - - -# pylint: disable=too-many-arguments, too-many-return-statements, too-many-branches, invalid-name -# using class naming convetion here -def TurbulenceQuantities( - viscosity_ratio=None, - modified_viscosity_ratio=None, - modified_viscosity=None, - specific_dissipation_rate=None, - turbulent_kinetic_energy=None, - turbulent_length_scale=None, - turbulent_intensity=None, -) -> TurbulenceQuantitiesType: - r""" - - :func:`TurbulenceQuantities` function specifies turbulence conditions - for the :class:`~flow360.Inflow` or :class:`~flow360.Freestream` - at boundaries. The turbulence properties that can be - specified are listed below. All values are dimensional. - For valid specifications as well as the default values, - please see the `Notes` section below. - - Parameters - ---------- - viscosity_ratio : >= 0 - The ratio between the turbulent viscosity and freestream laminar - viscosity. Applicable to both :class:`~flow360.KOmegaSST` and - :class:`~flow360.SpalartAllmaras`. Its value will be converted to - :py:attr:`modifiedTurbulentViscosityRatio` when using - SpalartAllmaras model. - modified_viscosity_ratio : >= 0 - The ratio between the modified turbulent viscosity (in SA model) and - freestream laminar viscosity. - Applicable to :class:`~flow360.SpalartAllmaras`. - modified_viscosity : >=0 - The modified turbulent viscosity, aka nuHat. - Applicable to :class:`~flow360.SpalartAllmaras`. - specific_dissipation_rate : >= 0 - The turbulent specific dissipation rate. Applicable to :class:`~flow360.KOmegaSST`. - turbulent_kinetic_energy : >=0 - The turbulent kinetic energy. Applicable to :class:`~flow360.KOmegaSST`. - turbulent_length_scale : > 0 - The turbulent length scale is an estimation of the size of - the eddies that are modeled/not resolved. - Applicable to :class:`~flow360.KOmegaSST`. - turbulent_intensity : >= 0 - The turbulent intensity is related to the turbulent kinetic energy by - :math:`k = 1.5(U_{ref} * I)^2` where :math:`k` is the dimensional - turbulent kinetic energy, :math:`U_{ref}` is the reference velocity - and :math:`I` is the turbulent intensity. The value represents the - actual magnitude of intensity instead of percentage. Applicable to - :class:`~flow360.KOmegaSST`. - - Returns - ------- - A matching tubulence specification object. - - Raises - ------- - ValueError - If the TurbulenceQuantities inputs do not represent a valid specification. - - Notes - ----- - - **Default Behavior** - The default turbulence depends on the turbulence model. - For SA model *without transition model* this is equivalent to set - :code:`modified_viscosity_ratio = 3.0` (or effectively :code:`viscosity_ratio = 0.210438`). - For SA model *with transition model*, :code:`modified_viscosity_ratio = 0.1` - (or effectively :code:`viscosity_ratio = 2.794e-7`). For SST model the default turbulence is - :code:`viscosity_ratio = 0.01` with default :code:`specific_dissipation_rate` = :math:`MachRef/L_{box}` - where :math:`L_{box} \triangleq exp\left(\displaystyle\sum_{i=1}^{3}log(x_{i,max}-x_{i,min}\right)`. - :math:`x_{i,max},x_{i,min}` is the bounding box dimension for wall boundaries. - - **Valid Parameter Combinations** - - The following table shows which parameter combinations are valid for each turbulence model: - - .. list-table:: Turbulence Quantity Compatibility - :header-rows: 1 - :widths: 50 25 25 - - * - Parameter Combination - - SA Model - - SST Model - * - (default - no parameters) - - ✓ (default) - - ✓ (default) - * - :code:`viscosity_ratio` alone - - ✓ (converted to :code:`modified_viscosity_ratio`) - - ✓ (overrides default, keeps default :code:`specific_dissipation_rate`) - * - :code:`modified_viscosity` alone - - ✓ - - ✗ - * - :code:`modified_viscosity_ratio` alone - - ✓ - - ✗ - * - :code:`turbulent_kinetic_energy` alone - - ✗ - - ✓ (:code:`specific_dissipation_rate` set to default) - * - :code:`turbulent_intensity` alone - - ✗ - - ✓ (:code:`specific_dissipation_rate` set to default) - * - :code:`turbulent_length_scale` alone - - ✗ - - ✓ (:code:`specific_dissipation_rate` set to default) - * - :code:`turbulent_kinetic_energy` + :code:`specific_dissipation_rate` - - ✗ - - ✓ - * - :code:`turbulent_intensity` + :code:`specific_dissipation_rate` - - ✗ - - ✓ - * - :code:`turbulent_kinetic_energy` + :code:`viscosity_ratio` - - ✗ - - ✓ - * - :code:`turbulent_intensity` + :code:`viscosity_ratio` - - ✗ - - ✓ - * - :code:`turbulent_kinetic_energy` + :code:`turbulent_length_scale` - - ✗ - - ✓ - * - :code:`turbulent_intensity` + :code:`turbulent_length_scale` - - ✗ - - ✓ - * - :code:`specific_dissipation_rate` + :code:`viscosity_ratio` - - ✗ - - ✓ - * - :code:`specific_dissipation_rate` + :code:`turbulent_length_scale` - - ✗ - - ✓ - * - :code:`viscosity_ratio` + :code:`turbulent_length_scale` - - ✗ - - ✓ - - Example - ------- - Apply modified turbulent viscosity ratio for SA model. - - >>> fl.TurbulenceQuantities(modified_viscosity_ratio=10) - - Apply turbulent kinetic energy and specific dissipation rate for SST model. - - >>> fl.TurbulenceQuantities( - turbulent_kinetic_energy=0.2 * fl.u.m**2 / fl.u.s**2, - specific_dissipation_rate=100 / fl.u.s) - - Apply specific dissipation rate and turbulent viscosity ratio for SST model. - - >>> fl.TurbulenceQuantities(specific_dissipation_rate=150 / fl.u.s, viscosity_ratio=1000) - - """ - non_none_arg_count = sum(arg is not None for arg in locals().values()) - if non_none_arg_count == 0: - return None - - if non_none_arg_count > 2: - raise ValueError( - "Provided number of inputs exceeds the limit for any of the listed specifications. " - + "Please recheck TurbulenceQuantities inputs and make sure they represent a valid specification." - ) - - if viscosity_ratio is not None: - if non_none_arg_count == 1: - return TurbulentViscosityRatio(turbulent_viscosity_ratio=viscosity_ratio) - if turbulent_kinetic_energy is not None: - return TurbulentViscosityRatioAndTurbulentKineticEnergy( - turbulent_viscosity_ratio=viscosity_ratio, - turbulent_kinetic_energy=turbulent_kinetic_energy, - ) - if turbulent_intensity is not None: - return TurbulentIntensityAndTurbulentViscosityRatio( - turbulent_viscosity_ratio=viscosity_ratio, - turbulent_intensity=turbulent_intensity, - ) - if specific_dissipation_rate is not None: - return SpecificDissipationRateAndTurbulentViscosityRatio( - turbulent_viscosity_ratio=viscosity_ratio, - specific_dissipation_rate=specific_dissipation_rate, - ) - if turbulent_length_scale is not None: - return TurbulentViscosityRatioAndTurbulentLengthScale( - turbulent_viscosity_ratio=viscosity_ratio, - turbulent_length_scale=turbulent_length_scale, - ) - - if modified_viscosity_ratio is not None and non_none_arg_count == 1: - return ModifiedTurbulentViscosityRatio( - modified_turbulent_viscosity_ratio=modified_viscosity_ratio - ) - - if modified_viscosity is not None and non_none_arg_count == 1: - return ModifiedTurbulentViscosity(modified_turbulent_viscosity=modified_viscosity) - - if turbulent_intensity is not None: - if non_none_arg_count == 1: - return TurbulentIntensity(turbulent_intensity=turbulent_intensity) - if specific_dissipation_rate is not None: - return TurbulentIntensityAndSpecificDissipationRate( - turbulent_intensity=turbulent_intensity, - specific_dissipation_rate=specific_dissipation_rate, - ) - if turbulent_length_scale is not None: - return TurbulentIntensityAndTurbulentLengthScale( - turbulent_intensity=turbulent_intensity, - turbulent_length_scale=turbulent_length_scale, - ) - - if turbulent_kinetic_energy is not None: - if non_none_arg_count == 1: - return TurbulentKineticEnergy(turbulent_kinetic_energy=turbulent_kinetic_energy) - if specific_dissipation_rate is not None: - return SpecificDissipationRateAndTurbulentKineticEnergy( - turbulent_kinetic_energy=turbulent_kinetic_energy, - specific_dissipation_rate=specific_dissipation_rate, - ) - if turbulent_length_scale is not None: - return TurbulentLengthScaleAndTurbulentKineticEnergy( - turbulent_kinetic_energy=turbulent_kinetic_energy, - turbulent_length_scale=turbulent_length_scale, - ) - - if turbulent_length_scale is not None and non_none_arg_count == 1: - return TurbulentLengthScale(turbulent_length_scale=turbulent_length_scale) - - if specific_dissipation_rate is not None: - if turbulent_length_scale is not None: - return SpecificDissipationRateAndTurbulentLengthScale( - specific_dissipation_rate=specific_dissipation_rate, - turbulent_length_scale=turbulent_length_scale, - ) - - raise ValueError( - "Provided inputs do not create a valid specification. " - + "Please recheck TurbulenceQuantities inputs and make sure they represent a valid specification." - ) diff --git a/flow360/component/simulation/models/validation/validation_bet_disk.py b/flow360/component/simulation/models/validation/validation_bet_disk.py index 7ae6829fb..1559718ab 100644 --- a/flow360/component/simulation/models/validation/validation_bet_disk.py +++ b/flow360/component/simulation/models/validation/validation_bet_disk.py @@ -1,123 +1,19 @@ -""" -validation BETDisk -""" +"""BET disk validation helpers — re-import relay.""" -from pydantic import ValidationInfo +from importlib import import_module -def _check_bet_disk_initial_blade_direction_and_blade_line_chord(bet_disk): - if bet_disk.blade_line_chord > 0 and bet_disk.initial_blade_direction is None: - raise ValueError( - "the initial_blade_direction" - " is required to specify since its blade_line_chord is non-zero." - ) - if bet_disk.initial_blade_direction is not None and bet_disk.blade_line_chord == 0: - raise ValueError( - "the blade_line_chord has to be positive" - " since its initial_blade_direction is specified." - ) - return bet_disk - - -# pylint: disable=unused-argument -# This is to enable getting name from the info. -def _check_bet_disk_alphas_in_order(value, info: ValidationInfo): - if any(value != sorted(value)): - raise ValueError("the alphas are not in increasing order.") - return value - - -def _check_has_duplicate_in_one_radial_list(radial_list): - existing_radius = set() - for item in radial_list: - radius = item.radius.value.item() - if radius not in existing_radius: - existing_radius.add(radius) - else: - return True, radius - return False, None - - -def _check_bet_disk_duplicate_chords(value, info: ValidationInfo): - has_duplicate, duplicated_radius = _check_has_duplicate_in_one_radial_list(value) - if has_duplicate: - raise ValueError(f"it has duplicated radius at {duplicated_radius} in chords.") +def __getattr__(name): + schema_module = import_module( + "flow360_schema.models.simulation.models.validation.validation_bet_disk" + ) + value = getattr(schema_module, name) + globals()[name] = value return value -def _check_bet_disk_duplicate_twists(value, info: ValidationInfo): - has_duplicate, duplicated_radius = _check_has_duplicate_in_one_radial_list(value) - if has_duplicate: - raise ValueError(f"it has duplicated radius at {duplicated_radius} in twists.") - return value - - -def _check_bet_disk_sectional_radius_and_polars(bet_disk): - radiuses = bet_disk.sectional_radiuses - polars = bet_disk.sectional_polars - if len(radiuses) != len(polars): - raise ValueError( - f"the length of sectional_radiuses ({len(radiuses)})" - f" is not the same as that of sectional_polars ({len(polars)})." - ) - return bet_disk - - -# pylint: disable=invalid-name -# pylint: disable=too-many-arguments -def _check_3d_coeffs_in_BET_polars( - coeffs_3d, num_Mach, num_Re, num_alphas, section_index, coeffs_name -): - if len(coeffs_3d) != num_Mach: - raise ValueError( - f"(cross section: {section_index}): number of mach_numbers =" - f" {num_Mach}, but the first dimension of {coeffs_name} is {len(coeffs_3d)}." - ) - for index_Mach, coeffs_2d in enumerate(coeffs_3d): - if len(coeffs_2d) != num_Re: - raise ValueError( - f"(cross section: {section_index}) (Mach index (0-based)" - f" {index_Mach}): number of Reynolds = {num_Re}, " - f"but the second dimension of {coeffs_name} is {len(coeffs_2d)}." - ) - for index_Re, coeffs_1d in enumerate(coeffs_2d): - if len(coeffs_1d) != num_alphas: - raise ValueError( - f"(cross section: {section_index}) " - f"(Mach index (0-based) {index_Mach}, Reynolds index (0-based)" - f" {index_Re}): number of Alphas = {num_alphas}, " - f"but the third dimension of {coeffs_name} is {len(coeffs_1d)}." - ) - - -def _check_bet_disk_3d_coefficients_in_polars(bet_disk): - mach_numbers = bet_disk.mach_numbers - reynolds_numbers = bet_disk.reynolds_numbers - alphas = bet_disk.alphas - num_Mach = len(mach_numbers) - num_Re = len(reynolds_numbers) - num_alphas = len(alphas) - polars_all_sections = bet_disk.sectional_polars - - for section_index, polars_one_section in enumerate(polars_all_sections): - lift_coeffs = polars_one_section.lift_coeffs - drag_coeffs = polars_one_section.drag_coeffs - if lift_coeffs is not None: - _check_3d_coeffs_in_BET_polars( - lift_coeffs, - num_Mach, - num_Re, - num_alphas, - section_index, - "lift_coeffs", - ) - if drag_coeffs is not None: - _check_3d_coeffs_in_BET_polars( - drag_coeffs, - num_Mach, - num_Re, - num_alphas, - section_index, - "drag_coeffs", - ) - return bet_disk +def __dir__(): + schema_module = import_module( + "flow360_schema.models.simulation.models.validation.validation_bet_disk" + ) + return sorted(set(globals()) | set(vars(schema_module))) diff --git a/flow360/component/simulation/models/volume_models.py b/flow360/component/simulation/models/volume_models.py index b4f0977de..80efb2521 100644 --- a/flow360/component/simulation/models/volume_models.py +++ b/flow360/component/simulation/models/volume_models.py @@ -1,1575 +1,35 @@ -"""Volume models for the simulation framework.""" +"""Relay import for simulation volume models.""" -# pylint: disable=too-many-lines -import os -import re -from abc import ABCMeta -from typing import Annotated, Dict, List, Literal, Optional, Union +# pylint: disable=unused-import -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Acceleration, Angle -from flow360_schema.framework.physical_dimensions import ( - AngularVelocity as AngularVelocityDim, -) -from flow360_schema.framework.physical_dimensions import ( - HeatSource, - InverseArea, - InverseLength, - Length, - Pressure, - Velocity, -) - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.framework.entity_utils import generate_uuid -from flow360.component.simulation.framework.expressions import ( - StringExpression, - validate_angle_expression_of_t_seconds, -) -from flow360.component.simulation.framework.multi_constructor_model_base import ( - MultiConstructorBaseModel, -) -from flow360.component.simulation.framework.single_attribute_base import ( - SingleAttributeModel, -) -from flow360.component.simulation.framework.updater import updater -from flow360.component.simulation.framework.updater_utils import Flow360Version -from flow360.component.simulation.models.bet.bet_translator_interface import ( - generate_c81_bet_json, - generate_dfdc_bet_json, - generate_polar_file_name_list, - generate_xfoil_bet_json, - generate_xrotor_bet_json, - get_file_content, -) -from flow360.component.simulation.models.material import ( - Air, - FluidMaterialTypes, - MaterialBase, - SolidMaterialTypes, -) -from flow360.component.simulation.models.solver_numerics import ( - HeatEquationSolver, - NavierStokesSolver, - NoneSolver, - SpalartAllmaras, - TransitionModelSolverType, - TurbulenceModelSolverType, -) -from flow360.component.simulation.models.validation.validation_bet_disk import ( - _check_bet_disk_3d_coefficients_in_polars, - _check_bet_disk_alphas_in_order, - _check_bet_disk_duplicate_chords, - _check_bet_disk_duplicate_twists, - _check_bet_disk_initial_blade_direction_and_blade_line_chord, - _check_bet_disk_sectional_radius_and_polars, -) -from flow360.component.simulation.primitives import ( - AxisymmetricBody, - Box, - CustomVolume, - Cylinder, - GenericVolume, - SeedpointVolume, -) -from flow360.component.simulation.user_code.core.types import ValueOrExpression -from flow360.component.simulation.utils import sanitize_params_dict -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - contextual_field_validator, -) -from flow360.component.simulation.validation.validation_utils import ( - _validator_append_instance_name, -) - -# pylint: disable=fixme -# TODO: Warning: Pydantic V1 import -from flow360.component.types import Axis -from flow360.exceptions import Flow360FileError, Flow360ValueError -from flow360.version import __version__ - - -class AngleExpression(SingleAttributeModel): - """ - :class:`AngleExpression` class for define the angle expression for :py:attr:`Rotation.spec`. - The result of the expression is assumed to be in radians. - - Example - ------- - - >>> fl.AngleExpression("0.1*sin(t)") - - ==== - """ - - type_name: Literal["AngleExpression"] = pd.Field("AngleExpression", frozen=True) - value: StringExpression = pd.Field( - description="The expression defining the rotation angle as a function of time." - ) - - @pd.field_validator("value", mode="after") - @classmethod - def _validate_angle_expression(cls, value): - errors = validate_angle_expression_of_t_seconds(value) - if errors: - raise ValueError(" | ".join(errors)) - return value - - def preprocess(self, **kwargs): - # locate t_seconds and convert it to (t*flow360_time_to_seconds) - params = kwargs.get("params") - one_sec_to_flow360_time = params.convert_unit( - value=1 * u.s, # pylint:disable=no-member - target_system="flow360", - ) - flow360_time_to_seconds_expression = f"({1.0 / one_sec_to_flow360_time.value} * t)" - self.value = re.sub(r"\bt_seconds\b", flow360_time_to_seconds_expression, self.value) - - return super().preprocess(**kwargs) - - -class AngularVelocity(SingleAttributeModel): - """ - :class:`AngularVelocity` class to define the angular velocity for :py:attr:`Rotation.spec`. - - Example - ------- - - >>> fl.AngularVelocity(812.31 * fl.u.rpm) - - >>> fl.AngularVelocity(85.06 * fl.u.rad / fl.u.s) - - ==== - """ - - type_name: Literal["AngularVelocity"] = pd.Field("AngularVelocity", frozen=True) - value: ValueOrExpression[AngularVelocityDim.Float64] = pd.Field( - description="The value of the angular velocity." - ) - - -class FromUserDefinedDynamics(Flow360BaseModel): - """ - :class:`FromUserDefinedDynamics` class to define the rotation - controlled by user defined dynamics for :py:attr:`Rotation.spec`. - - Example - ------- - - >>> params=fl.SimulationParams(...) - >>> params.user_defined_dynamics=fl.UserDefinedDynamic(...) - >>> params.models.append( - ... fl.Rotation( - ... spec=fl.FromUserDefinedDynamics(), - ... entities=[rotation_entity] - ... ) - ... ) - - ==== - """ - - type_name: Literal["FromUserDefinedDynamics"] = pd.Field("FromUserDefinedDynamics", frozen=True) - - -class ExpressionInitialConditionBase(Flow360BaseModel): - """ - :class:`ExpressionInitialCondition` class for specifying the initial conditions of - :py:attr:`Fluid.initial_condition`. - """ - - type_name: Literal["expression"] = pd.Field("expression", frozen=True) - constants: Optional[Dict[str, StringExpression]] = pd.Field( - None, description="The expression for the initial condition." - ) - - -# pylint: disable=missing-class-docstring -class NavierStokesInitialCondition(ExpressionInitialConditionBase): - """ - :class:`NavierStokesInitialCondition` class for specifying the - :py:attr:`Fluid.initial_condition`. - - Note - ---- - The result of the expressions will be treated as non-dimensional values. - Please refer to the :ref:`Units Introduction` for more details. - - Example - ------- - - >>> fl.NavierStokesInitialCondition( - ... rho = "(x <= 0) ? (1.0) : (0.125)", - ... u = "0", - ... v = "0", - ... w = "0", - ... p = "(x <= 0) ? (1 / 1.4) : (0.1 / 1.4)" - ... ) - - ==== - """ - - type_name: Literal["NavierStokesInitialCondition"] = pd.Field( - "NavierStokesInitialCondition", frozen=True - ) - rho: StringExpression = pd.Field("rho", description="Density") - u: StringExpression = pd.Field("u", description="X-direction velocity") - v: StringExpression = pd.Field("v", description="Y-direction velocity") - w: StringExpression = pd.Field("w", description="Z-direction velocity") - p: StringExpression = pd.Field("p", description="Pressure") - - @contextual_field_validator("rho", "u", "v", "w", "p", mode="after") - @classmethod - def _disable_expression_for_liquid( - cls, - value, - info: pd.ValidationInfo, - param_info: ParamsValidationInfo, - ): - if param_info.using_liquid_as_material is False: - return value - - # pylint:disable = unsubscriptable-object - if value != cls.model_fields[info.field_name].get_default(): - raise ValueError("Expression cannot be used when using liquid as simulation material.") - return value - - -class NavierStokesModifiedRestartSolution(NavierStokesInitialCondition): - type_name: Literal["NavierStokesModifiedRestartSolution"] = pd.Field( - "NavierStokesModifiedRestartSolution", frozen=True - ) - - -class HeatEquationInitialCondition(ExpressionInitialConditionBase): - """ - :class:`HeatEquationInitialCondition` class for specifying the - :py:attr:`Solid.initial_condition`. - - Note - ---- - The result of the expressions will be treated as non-dimensional values. - Please refer to the :ref:`Units Introduction` for more details. - - Example - ------- - - >>> fl.HeatEquationInitialCondition(temperature="1.0") - - ==== - """ - - type_name: Literal["HeatEquationInitialCondition"] = pd.Field( - "HeatEquationInitialCondition", frozen=True - ) - temperature: StringExpression = pd.Field() - - -class PDEModelBase(Flow360BaseModel): - """ - Base class for equation models - - """ - - material: MaterialBase = pd.Field() - initial_condition: Optional[dict] = pd.Field(None) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - -class Gravity(Flow360BaseModel): - """ - :class:`Gravity` class for specifying gravitational body force. - - The gravity model applies a body force ρg to the momentum equations and ρ(g·u) to the - energy equation, enabling simulation of buoyancy-driven flows. Gravity is applied - globally to all fluid zones in the simulation. - - Example - ------- - - Define gravity with Earth's default values (direction=(0,0,-1), magnitude=9.81 m/s²): - - >>> fl.Gravity() - - Define gravity with custom direction and magnitude: - - >>> fl.Gravity( - ... direction=(1, 0, 0), - ... magnitude=5.0 * fl.u.m / fl.u.s**2, - ... ) - - ==== - """ - - # pylint: disable=no-member - direction: Axis = pd.Field( - (0, 0, -1), - description="The direction of the gravitational acceleration vector.", - ) - magnitude: Acceleration.Float64 = pd.Field( - 9.81 * u.m / u.s**2, - description="The magnitude of the gravitational acceleration. " - + "For Earth's surface gravity, use 9.81 m/s².", - ) - - -class Fluid(PDEModelBase): - """ - :class:`Fluid` class for setting up the volume model that contains - all the common fields every fluid dynamics zone should have. - - Example - ------- - - >>> fl.Fluid( - ... navier_stokes_solver=fl.NavierStokesSolver( - ... absolute_tolerance=1e-10, - ... linear_solver=fl.LinearSolver(max_iterations=35), - ... low_mach_preconditioner=True, - ... ), - ... turbulence_model_solver=fl.SpalartAllmaras( - ... absolute_tolerance=1e-10, - ... linear_solver=fl.LinearSolver(max_iterations=25) - ... ), - ... transition_model_solver=fl.NoneSolver(), - ... ) - - ==== - """ - - type: Literal["Fluid"] = pd.Field("Fluid", frozen=True) - navier_stokes_solver: NavierStokesSolver = pd.Field( - NavierStokesSolver(), - description="Navier-Stokes solver settings, see " - + ":class:`NavierStokesSolver` documentation.", - ) - turbulence_model_solver: TurbulenceModelSolverType = pd.Field( - SpalartAllmaras(), - description="Turbulence model solver settings, see :class:`SpalartAllmaras`, " - + ":class:`KOmegaSST` and :class:`NoneSolver` documentation.", - ) - transition_model_solver: TransitionModelSolverType = pd.Field( - NoneSolver(), - description="Transition solver settings, see " - + ":class:`TransitionModelSolver` documentation.", - ) - - material: FluidMaterialTypes = pd.Field(Air(), description="The material property of fluid.") - - initial_condition: Union[NavierStokesModifiedRestartSolution, NavierStokesInitialCondition] = ( - pd.Field( - NavierStokesInitialCondition(), - discriminator="type_name", - description="The initial condition of the fluid solver.", - ) - ) - - gravity: Optional[Gravity] = pd.Field( - None, - description="Gravitational body force settings. When specified, gravity is applied " - "globally to all fluid zones. See :class:`Gravity` documentation.", - ) - - interface_interpolation_tolerance: pd.PositiveFloat = pd.Field( - 0.2, - description="Interpolation will fail if the distance between an interpolation " - "point and the closest triangle is greater than `relative_interpolation_tolerance` " - "multiplied by the maximum edge length of the patch containing the interpolation point.", - ) - # pylint: disable=fixme - # fixme: Add support for other initial conditions - - -class Solid(PDEModelBase): - """ - :class:`Solid` class for setting up the conjugate heat transfer volume model that - contains all the common fields every heat transfer zone should have. - - Example - ------- - - Define :class:`Solid` model for volumes with the name pattern :code:`"solid-*"`. - - >>> fl.Solid( - ... entities=[volume_mesh["solid-*"]], - ... heat_equation_solver=fl.HeatEquationSolver( - ... equation_evaluation_frequency=2, - ... linear_solver=fl.LinearSolver( - ... absolute_tolerance=1e-10, - ... max_iterations=50 - ... ), - ... relative_tolerance=0.001, - ... ), - ... initial_condition=fl.HeatEquationInitialCondition(temperature="1.0"), - ... material=fl.SolidMaterial( - ... name="aluminum", - ... thermal_conductivity=235 * fl.u.kg / fl.u.s**3 * fl.u.m / fl.u.K, - ... density=2710 * fl.u.kg / fl.u.m**3, - ... specific_heat_capacity=903 * fl.u.m**2 / fl.u.s**2 / fl.u.K, - ... ), - ... volumetric_heat_source=1.0 * fl.u.W / fl.u.m**3, - ... ) - - ==== - """ - - name: Optional[str] = pd.Field(None, description="Name of the `Solid` model.") - type: Literal["Solid"] = pd.Field("Solid", frozen=True) - entities: EntityList[GenericVolume, CustomVolume, SeedpointVolume] = pd.Field( - alias="volumes", - description="The list of :class:`GenericVolume` or :class:`CustomVolume` or :class:`SeedpointVolume` " - + "entities on which the heat transfer equation is solved. " - "The assigned volumes must have only tetrahedral elements.", - ) - - material: SolidMaterialTypes = pd.Field(description="The material property of solid.") - - heat_equation_solver: HeatEquationSolver = pd.Field( - HeatEquationSolver(), - description="Heat equation solver settings, see " - + ":class:`HeatEquationSolver` documentation.", - ) - # pylint: disable=no-member - volumetric_heat_source: Union[StringExpression, HeatSource.Float64] = pd.Field( - 0 * u.W / (u.m**3), description="The volumetric heat source." - ) - - initial_condition: Optional[HeatEquationInitialCondition] = pd.Field( - None, description="The initial condition of the heat equation solver." - ) - - @contextual_field_validator("entities", mode="after") - @classmethod - def ensure_custom_volume_has_tets_only(cls, v, param_info: ParamsValidationInfo): - """ - Check if the CustomVolume object was meshed with tetrahedra-only elements. - """ - expanded = param_info.expand_entity_list(v) - for entity in expanded: - if not isinstance(entity, (SeedpointVolume, CustomVolume)): - continue - - enforce_map = getattr(param_info, "to_be_generated_custom_volumes", {}) - if not isinstance(enforce_map, dict): - continue - - cv_info = enforce_map.get(entity.name, {}) - if cv_info.get("enforce_tetrahedra") is False: - raise ValueError( - f"{type(entity).__name__} '" - + entity.name - + "' must be meshed with tetrahedra-only elements. Please adjust setting in `CustomZones`." - ) - - return v - - -# pylint: disable=duplicate-code -class ForcePerArea(Flow360BaseModel): - """:class:`ForcePerArea` class for setting up force per area for Actuator Disk. - - Example - ------- - - >>> fl.ForcePerArea( - ... radius=[0, 1] * fl.u.mm, - ... thrust=[4.1, 5.5] * fl.u.Pa, - ... circumferential=[4.1, 5.5] * fl.u.Pa, - ... ) - - ==== - """ - - # pylint: disable=no-member - radius: Length.NonNegativeArray = pd.Field( - description="Radius of the sampled locations in grid unit." - ) - # pylint: disable=no-member - thrust: Pressure.Array = pd.Field( - description="Dimensional force per area in the axial direction, positive means the axial " - + "force follows the same direction as the thrust axis. " - ) - # pylint: disable=no-member - circumferential: Pressure.Array = pd.Field( - description="Dimensional force per area in the circumferential direction, positive means the " - + "circumferential force follows the same direction as the thrust axis with the right hand rule. " - ) - - # pylint: disable=no-self-argument, missing-function-docstring - @pd.model_validator(mode="before") - @classmethod - def validate_consistent_array_length(cls, values): - radius, thrust, circumferential = ( - values.get("radius"), - values.get("thrust"), - values.get("circumferential"), - ) - if len(radius) != len(thrust) or len(radius) != len(circumferential): - raise ValueError( - "length of radius, thrust, circumferential must be the same, but got: " - + f"len(radius)={len(radius)}, len(thrust)={len(thrust)}, len(circumferential)={len(circumferential)}" - ) - - return values - - -class ActuatorDisk(Flow360BaseModel): - """:class:`ActuatorDisk` class for setting up the inputs for an Actuator Disk. - Please refer to the :ref:`actuator disk knowledge base ` for further information. - - Note - ---- - :py:attr:`Cylinder.center`, :py:attr:`Cylinder.axis` and :py:attr:`Cylinder.height` are taken as the - center, thrust axis, and thickness of the Actuator Disk, respectively. - - Example - ------- - - >>> fl.ActuatorDisk( - ... entities = fl.Cylinder( - ... name="actuator_disk", - ... center=(0,0,0)*fl.u.mm, - ... axis=(-1,0,0), - ... height = 30 * fl.u.mm, - ... outer_radius=5.0 * fl.u.mm, - ... ), - ... force_per_area = fl.ForcePerArea( - ... radius=[0, 1] * fl.u.mm, - ... thrust=[4.1, 5.5] * fl.u.Pa, - ... circumferential=[4.1, 5.5] * fl.u.Pa, - ... ) - ... ) - - ==== - """ - - entities: EntityList[Cylinder] = pd.Field( - alias="volumes", - description="The list of :class:`Cylinder` entities for the `ActuatorDisk` model", - ) - force_per_area: ForcePerArea = pd.Field( - description="The force per area input for the `ActuatorDisk` model. " - + "See :class:`ForcePerArea` documentation." - ) - reference_velocity: Optional[Velocity.Vector3] = pd.Field( # pylint: disable=no-member - None, - description="Reference velocity [Vx, Vy, Vz] for power calculation. " - + "When provided, uses this velocity instead of local flow velocity " - + "for the actuator disk power output.", - ) - name: Optional[str] = pd.Field("Actuator disk", description="Name of the `ActuatorDisk` model.") - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - type: Literal["ActuatorDisk"] = pd.Field("ActuatorDisk", frozen=True) - - -# pylint: disable=no-member -class BETDiskTwist(Flow360BaseModel): - """ - :class:`BETDiskTwist` class for setting up the :py:attr:`BETDisk.twists`. - - Example - ------- - - >>> fl.BETDiskTwist(radius=2 * fl.u.inch, twist=26 * fl.u.deg) - - ==== - """ - - radius: Length.NonNegativeFloat64 = pd.Field(description="The radius of the radial location.") - twist: Angle.Float64 = pd.Field(description="The twist angle at this radial location.") - - -# pylint: disable=no-member -class BETDiskChord(Flow360BaseModel): - """ - :class:`BETDiskChord` class for setting up the :py:attr:`BETDisk.chords`. - - Example - ------- - - >>> fl.BETDiskChord(radius=2 * fl.u.inch, chord=18 * fl.u.inch) - - ==== - """ - - radius: Length.NonNegativeFloat64 = pd.Field(description="The radius of the radial location.") - chord: Length.NonNegativeFloat64 = pd.Field( - description="The blade chord at this radial location. " - ) - - -class BETDiskSectionalPolar(Flow360BaseModel): - """:class:`BETDiskSectionalPolar` class for setting up :py:attr:`BETDisk.sectional_polars` - for :class:`BETDisk`. There are two variables, “lift_coeffs” and “drag_coeffs”, - need to be set up as 3D arrays (implemented as nested lists). - The first index of the array corresponds to the :py:attr:`BETDisk.mach_numbers` - of the specified polar data. - The second index of the array corresponds to the :py:attr:`BETDisk.reynolds_numbers` - of the polar data. - The third index corresponds to the :py:attr:`BETDisk.alphas`. - The value specifies the lift or drag coefficient, respectively. - - Example - ------- - - Define :class:`BETDiskSectionalPolar` at one single radial location. - :code:`lift_coeffs` and :code:`drag_coeffs` are lists with the dimension of 3 x 2 x 2, corresponding to - 3 :py:attr:`BETDisk.mach_numbers` by 2 :py:attr:`BETDisk.reynolds_numbers` by 2 :py:attr:`BETDisk.alphas`. - - >>> lift_coeffs = [[[0.1, 0.2], [0.3, 0.4]], [[0.5, 0.6], [0.7, 0.8]], [[0.9, 1.0], [1.1, 1.2]]] - >>> drag_coeffs = [[[0.01, 0.02], [0.03, 0.04]], [[0.05, 0.06], [0.07, 0.08]], [[0.09, 0.1], [0.11, 0.12]]] - >>> fl.BETDiskSectionalPolar( - ... lift_coeffs=lift_coeffs, - ... drag_coeffs=drag_coeffs - ... ) - - ==== - """ - - lift_coeffs: List[List[List[float]]] = pd.Field( - description="The 3D arrays specifying the list coefficient." - ) - drag_coeffs: List[List[List[float]]] = pd.Field( - description="The 3D arrays specifying the drag coefficient." - ) - - -class BETSingleInputFileBaseModel(Flow360BaseModel, metaclass=ABCMeta): - file_path: str = pd.Field( - frozen=True, - description="Path to the BET configuration file. It cannot be changed once initialized.", - ) - content: str = pd.Field( - frozen=True, - description="File content of the BET configuration file. It will be automatically loaded.", - ) - - @pd.model_validator(mode="before") - @classmethod - def _extract_content(cls, input_data): - """ - Read the file content and store it as string. - """ - if "file_path" not in input_data: - raise ValueError("file_path is require but is not found in input.") - if "content" in input_data and input_data.get("content"): - return input_data - - file_content = get_file_content(input_data["file_path"]) - - return { - "file_path": os.path.basename(input_data["file_path"]), - "content": file_content, - } - - -class AuxiliaryPolarFile(BETSingleInputFileBaseModel): - """Auxiliary polar file for XFoil""" - - type_name: Literal["AuxiliaryPolarFile"] = pd.Field("AuxiliaryPolarFile", frozen=True) - - -class BETSingleMultiFileBaseModel(Flow360BaseModel, metaclass=ABCMeta): - file_path: str = pd.Field( - frozen=True, - description="Path to the BET configuration file. It cannot be changed once initialized.", - ) - content: str = pd.Field( - frozen=True, - description="File content of the BET configuration file. It will be automatically loaded.", - ) - polar_files: list[list[AuxiliaryPolarFile]] = pd.Field() - - @pd.model_validator(mode="before") - @classmethod - def _extract_content(cls, input_data): - """ - Read the file content and store it as string. - """ - if "file_path" not in input_data: - raise ValueError("file_path is require but is not found in input.") - if "content" in input_data and input_data.get("content"): - return input_data - if "polar_files" in input_data and input_data.get("polar_files"): - return input_data - - file_path = input_data["file_path"] - file_content = get_file_content(file_path=file_path) - - # Now read the polar files - polar_file_obj_list = [] - file_dir = os.path.dirname(file_path) - for file_name_list in generate_polar_file_name_list(geometry_file_content=file_content): - polar_file_obj_list.append( - [{"file_path": os.path.join(file_dir, file_name)} for file_name in file_name_list] - ) - return { - "file_path": os.path.basename(file_path), - "content": file_content, - "polar_files": polar_file_obj_list, - } - - -class XROTORFile(BETSingleInputFileBaseModel): - type_name: Literal["XRotorFile"] = pd.Field("XRotorFile", frozen=True) - - -class DFDCFile(BETSingleInputFileBaseModel): - type_name: Literal["DFDCFile"] = pd.Field("DFDCFile", frozen=True) - - -class C81File(BETSingleMultiFileBaseModel): - type_name: Literal["C81File"] = pd.Field("C81File", frozen=True) - - -class XFOILFile(BETSingleMultiFileBaseModel): - type_name: Literal["XFoilFile"] = pd.Field("XFoilFile", frozen=True) - - -BETFileTypes = Annotated[ - Union[XROTORFile, DFDCFile, XFOILFile, C81File], - pd.Field(discriminator="type_name"), -] - - -class BETDiskCache(Flow360BaseModel): - """[INTERNAL] Cache for BETDisk inputs""" - - name: Optional[str] = None - file: Optional[BETFileTypes] = None - rotation_direction_rule: Optional[Literal["leftHand", "rightHand"]] = None - omega: Optional[AngularVelocityDim.NonNegativeFloat64] = None - chord_ref: Optional[Length.PositiveFloat64] = None - n_loading_nodes: Optional[pd.StrictInt] = None - entities: Optional[EntityList[Cylinder]] = None - angle_unit: Optional[Angle.Float64] = None - length_unit: Optional[Length.NonNegativeFloat64] = None - number_of_blades: Optional[pd.StrictInt] = None - initial_blade_direction: Optional[Axis] = None - blade_line_chord: Optional[Length.NonNegativeFloat64] = None - collective_pitch: Optional[Angle.Float64] = None - - -class BETDisk(MultiConstructorBaseModel): - """:class:`BETDisk` class for defining the Blade Element Theory (BET) model inputs. - For detailed information on the parameters, please refer to the :ref:`BET knowledge Base `. - To generate the sectional polars the BET translators can be used which are - outlined :ref:`here `. - A validation study of the XV-15 rotor using the steady BET Disk method is available - in :ref:`Validation Studies `. - Because a transient BET Line simulation is simply a time-accurate version of a steady-state - BET Disk simulation, most of the parameters below are applicable to both methods. - - Note - ---- - :py:attr:`Cylinder.center`, :py:attr:`Cylinder.axis`, :py:attr:`Cylinder.outer_radius`, - and :py:attr:`Cylinder.height` are taken as the rotation center, - rotation axis, radius, and thickness of the BETDisk, respectively. - - Example - ------- - >>> fl.BETDisk( - ... entities=[fl.Cylinder(...)], - ... rotation_direction_rule="leftHand", - ... number_of_blades=3, - ... omega=rpm * fl.u.rpm, - ... chord_ref=14 * fl.u.inch, - ... n_loading_nodes=20, - ... mach_numbers=[0], - ... reynolds_numbers=[1000000], - ... twists=[fl.BETDiskTwist(...), ...], - ... chords=[fl.BETDiskChord(...), ...], - ... alphas=[-2,0,2] * fl.u.deg, - ... sectional_radiuses=[13.5, 25.5] * fl.u.inch, - ... sectional_polars=[fl.BETDiskSectionalPolar(...), ...] - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("BET disk", description="Name of the `BETDisk` model.") - type: Literal["BETDisk"] = pd.Field("BETDisk", frozen=True) - type_name: Literal["BETDisk"] = pd.Field("BETDisk", frozen=True) - entities: EntityList[Cylinder] = pd.Field(alias="volumes") - - rotation_direction_rule: Literal["leftHand", "rightHand"] = pd.Field( - "rightHand", - description='The rule for rotation direction and thrust direction, "rightHand" or "leftHand".', - ) - number_of_blades: pd.StrictInt = pd.Field(gt=0, le=10, description="Number of blades to model.") - omega: AngularVelocityDim.NonNegativeFloat64 = pd.Field(description="Rotating speed.") - chord_ref: Length.PositiveFloat64 = pd.Field( - description="Dimensional reference chord used to compute sectional blade loadings." - ) - n_loading_nodes: pd.StrictInt = pd.Field( - gt=0, - le=1000, - description="Number of nodes used to compute the sectional thrust and " - + "torque coefficients :math:`C_t` and :math:`C_q`, defined in :ref:`betDiskLoadingNote`.", - ) - blade_line_chord: Length.NonNegativeFloat64 = pd.Field( - 0 * u.m, - description="Dimensional chord to use if performing an unsteady BET Line simulation. " - + "Default of 0.0 is an indication to run a steady BET Disk simulation.", - ) - initial_blade_direction: Optional[Axis] = pd.Field( - None, - description="Direction of the first blade at the initial time, in the global mesh coordinate " - + "system (same frame as the BET cylinder center and axis), not a local BET/disk-fixed frame. " - + "Must be orthogonal to the rotation axis (Cylinder.axis). Only the direction is used—the " - + "vector need not be unit length. Must be specified for unsteady BET Line (blade_line_chord > 0).", - ) - collective_pitch: Optional[Angle.Float64] = pd.Field( - None, - description="Collective pitch angle applied as a uniform offset to all blade twist values. " - + "Positive value increases the angle of attack at every radial station.", - ) - tip_gap: Union[Literal["inf"], Length.NonNegativeFloat64] = pd.Field( - "inf", - description="Dimensional distance between blade tip and solid bodies to " - + "define a :ref:`tip loss factor `.", - frozen=True, - ) - mach_numbers: List[pd.NonNegativeFloat] = pd.Field( - description="Mach numbers associated with airfoil polars provided " - + "in :class:`BETDiskSectionalPolar`.", - frozen=True, - ) - reynolds_numbers: List[pd.PositiveFloat] = pd.Field( - description="Reynolds numbers associated with the airfoil polars " - + "provided in :class:`BETDiskSectionalPolar`.", - frozen=True, - ) - alphas: Angle.Array = pd.Field( - description="Alphas associated with airfoil polars provided in " - + ":class:`BETDiskSectionalPolar`.", - frozen=True, - ) - twists: List[BETDiskTwist] = pd.Field( - description="A list of :class:`BETDiskTwist` objects specifying the twist in degrees as a " - + "function of radial location.", - frozen=True, - ) - chords: List[BETDiskChord] = pd.Field( - description="A list of :class:`BETDiskChord` objects specifying the blade chord as a function " - + "of the radial location. ", - frozen=True, - ) - sectional_polars: List[BETDiskSectionalPolar] = pd.Field( - description="A list of :class:`BETDiskSectionalPolar` objects for every radial location specified in " - + ":py:attr:`sectional_radiuses`.", - frozen=True, - ) - sectional_radiuses: Length.NonNegativeArray = pd.Field( - description="A list of the radial locations in grid units at which :math:`C_l` " - + "and :math:`C_d` are specified in :class:`BETDiskSectionalPolar`.", - frozen=True, - ) - - private_attribute_input_cache: BETDiskCache = BETDiskCache() - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @pd.model_validator(mode="after") - @_validator_append_instance_name - def check_bet_disk_initial_blade_direction_and_blade_line_chord(self): - """validate initial blade direction and blade line chord in BET disks""" - return _check_bet_disk_initial_blade_direction_and_blade_line_chord(self) - - @pd.field_validator("alphas", mode="after") - @classmethod - @_validator_append_instance_name - def check_bet_disk_alphas_in_order(cls, value, info: pd.ValidationInfo): - """validate order of alphas in BET disks""" - return _check_bet_disk_alphas_in_order(value, info) - - @pd.field_validator("chords", mode="after") - @classmethod - @_validator_append_instance_name - def check_bet_disk_duplicate_chords(cls, value, info: pd.ValidationInfo): - """validate duplicates in chords in BET disks""" - return _check_bet_disk_duplicate_chords(value, info) - - @pd.field_validator("twists", mode="after") - @classmethod - @_validator_append_instance_name - def check_bet_disk_duplicate_twists(cls, value, info: pd.ValidationInfo): - """validate duplicates in twists in BET disks""" - return _check_bet_disk_duplicate_twists(value, info) - - @pd.model_validator(mode="after") - @_validator_append_instance_name - def check_bet_disk_sectional_radius_and_polars(self): - """validate duplicates in chords and twists in BET disks""" - return _check_bet_disk_sectional_radius_and_polars(self) - - @pd.model_validator(mode="after") - @_validator_append_instance_name - def check_bet_disk_3d_coefficients_in_polars(self): - """validate dimension of 3d coefficients in polars""" - return _check_bet_disk_3d_coefficients_in_polars(self) - - @pd.field_validator( - "name", - "rotation_direction_rule", - "omega", - "chord_ref", - "n_loading_nodes", - "number_of_blades", - "entities", - "initial_blade_direction", - "collective_pitch", - mode="after", - ) - @classmethod - def _update_input_cache(cls, value, info: pd.ValidationInfo): - # BETDisk input cache does not currently support EntityList with selectors. - setattr( - info.data["private_attribute_input_cache"], - info.field_name, - value if info.field_name != "entities" else value.stored_entities, - ) - return value - - @classmethod - def from_file(cls, filename: str, **kwargs) -> "BETDisk": - """Loads a :class:`BETDisk` from exported .json file, with optional overrides. - - Parameters - ---------- - filename : str - Full path to the .yaml or .json file to load the :class:`BETDisk` from. - **kwargs - Keyword arguments to be passed to the model to override or complete the file content. - - Returns - ------- - :class:`BETDisk` - An instance of the BETDisk component. - - Example - ------- - >>> params = BETDisk.from_file( - ... filename='folder/bet_disk.json', - ... entities=[cylinder_FL_CCW, cylinder_FR_CW, ...], - ... omega=1000 * fl.u.rpm, - ... name="my_disk" - ... ) - """ - model_dict = cls._dict_from_file(filename=filename) - - # Handle version migration if needed - model_dict = sanitize_params_dict(model_dict) - - version_from = model_dict.pop("version", None) - if version_from is not None: - if Flow360Version(version_from) < Flow360Version(__version__): - # Wrap in a simulation-params-like structure for the updater - # The updater expects a full simulation params dict structure - wrapped_dict = {"version": version_from, "models": [model_dict]} - wrapped_dict = updater( - version_from=version_from, version_to=__version__, params_as_dict=wrapped_dict - ) - # Unwrap the updated model - model_dict = wrapped_dict["models"][0] - - # Clean any extra fields that might remain from the file load (like internal IDs) - # We only keep fields that are part of the model definition or valid aliases - valid_fields = set(cls.model_fields.keys()) - valid_aliases = set() - for _, field in cls.model_fields.items(): - if field.alias: - valid_aliases.add(field.alias) - - # Check for unknown fields in model_dict and raise error if found, - # unless they are internal fields (starting with '_') or specific ignored fields - unknown_fields = [ - k for k in model_dict.keys() if k not in valid_fields and k not in valid_aliases - ] - - if unknown_fields: - raise Flow360FileError( - f"Unknown fields found in input file for {cls.__name__}: {unknown_fields}" - ) - - # Validate kwargs keys - invalid_keys = [k for k in kwargs if k not in valid_fields and k not in valid_aliases] - if invalid_keys: - raise Flow360ValueError(f"Invalid keyword arguments for {cls.__name__}: {invalid_keys}") - - model_dict.update(kwargs) - return cls.deserialize(model_dict) - - # pylint: disable=too-many-arguments, no-self-argument, not-callable - @MultiConstructorBaseModel.model_constructor - @pd.validate_call - def from_c81( - cls, - file: C81File, - rotation_direction_rule: Literal["leftHand", "rightHand"], - omega: AngularVelocityDim.NonNegativeFloat64, - chord_ref: Length.PositiveFloat64, - n_loading_nodes: pd.StrictInt, - entities: EntityList[Cylinder], - number_of_blades: pd.StrictInt, - length_unit: Length.NonNegativeFloat64, - angle_unit: Angle.Float64, - initial_blade_direction: Optional[Axis] = None, - blade_line_chord: Length.NonNegativeFloat64 = 0 * u.m, - collective_pitch: Optional[Angle.Float64] = None, - name: str = "BET disk", - ): - """Constructs a :class: `BETDisk` instance from a given C81 file and additional inputs. - - Parameters - ---------- - file: C81File - C81File class instance containing information about the C81 file. - rotation_direction_rule: str - Rule for rotation direction and thrust direction. - omega: AngularVelocity.NonNegativeFloat64 - Rotating speed of the propeller. - chord_ref: Length.PositiveFloat64 - Dimensional reference cord used to compute sectional blade loadings. - n_loading_nodes: Int - Number of nodes used to compute sectional thrust and torque coefficients. - entities: EntityList[Cylinder] - List of Cylinder entities used for defining the BET volumes. - number_of_blades: Int - Number of blades to model. - length_unit: Length.NonNegativeFloat64 - Length unit of the geometry/mesh file. - angle_unit: Angle.Float64 - Angle unit used for Angle BETDisk parameters. - initial_blade_direction: Axis, optional - Direction of the first blade in global mesh coordinates; orthogonal to the rotation axis. - Only direction matters (need not be a unit vector). Required for unsteady BET Line. - blade_line_chord: Length.NonNegativeFloat64 - Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. - collective_pitch: AngleType, optional - Collective pitch angle applied as a uniform offset to all blade twist values. - - - Returns - ------- - BETDisk - An instance of :class:`BETDisk` completed with given inputs. - - Examples - -------- - Create a BET disk with an C81 file. - - >>> param = fl.BETDisk.from_c81( - ... file=fl.C81File(file_path="c81_xv15.csv")), - ... rotation_direction_rule="leftHand", - ... omega=0.0046 * fl.u.deg / fl.u.s, - ... chord_ref=14 * fl.u.m, - ... n_loading_nodes=20, - ... entities=bet_cylinder, - ... angle_unit=fl.u.deg, - ... number_of_blades=3, - ... length_unit=fl.u.m, - ... ) - """ - - params = generate_c81_bet_json( - geometry_file_content=file.content, - c81_polar_file_list=file.polar_files, - rotation_direction_rule=rotation_direction_rule, - initial_blade_direction=initial_blade_direction, - blade_line_chord=blade_line_chord, - omega=omega, - chord_ref=chord_ref, - n_loading_nodes=n_loading_nodes, - entities=entities, - angle_unit=angle_unit, - length_unit=length_unit, - number_of_blades=number_of_blades, - name=name, - ) - if collective_pitch is not None: - params["collective_pitch"] = collective_pitch - - return cls(**params) - - # pylint: disable=too-many-arguments, no-self-argument, not-callable - @MultiConstructorBaseModel.model_constructor - @pd.validate_call - def from_dfdc( - cls, - file: DFDCFile, - rotation_direction_rule: Literal["leftHand", "rightHand"], - omega: AngularVelocityDim.NonNegativeFloat64, - chord_ref: Length.PositiveFloat64, - n_loading_nodes: pd.StrictInt, - entities: EntityList[Cylinder], - length_unit: Length.NonNegativeFloat64, - angle_unit: Angle.Float64, - initial_blade_direction: Optional[Axis] = None, - blade_line_chord: Length.NonNegativeFloat64 = 0 * u.m, - collective_pitch: Optional[Angle.Float64] = None, - name: str = "BET disk", - ): - """Constructs a :class: `BETDisk` instance from a given DFDC file and additional inputs. - - Parameters - ---------- - file: DFDCFile - DFDCFile class instance containing information about the DFDC file. - rotation_direction_rule: str - Rule for rotation direction and thrust direction. - omega: AngularVelocity.NonNegativeFloat64 - Rotating speed of the propeller. - chord_ref: Length.PositiveFloat64 - Dimensional reference cord used to compute sectional blade loadings. - n_loading_nodes: Int - Number of nodes used to compute sectional thrust and torque coefficients. - entities: EntityList[Cylinder] - List of Cylinder entities used for defining the BET volumes. - length_unit: Length.NonNegativeFloat64 - Length unit used for BETDisk parameters. - angle_unit: Angle.Float64 - Angle unit used for Angle BETDisk parameters. - initial_blade_direction: Axis, optional - Direction of the first blade in global mesh coordinates; orthogonal to the rotation axis. - Only direction matters (need not be a unit vector). Required for unsteady BET Line. - blade_line_chord: Length.NonNegativeFloat64 - Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. - collective_pitch: AngleType, optional - Collective pitch angle applied as a uniform offset to all blade twist values. - - - Returns - ------- - BETDisk - An instance of :class:`BETDisk` completed with given inputs. - - Examples - -------- - Create a BET disk with a DFDC file. - - >>> param = fl.BETDisk.from_dfdc( - ... file=fl.DFDCFile(file_path="dfdc_xv15.case")), - ... rotation_direction_rule="leftHand", - ... omega=0.0046 * fl.u.deg / fl.u.s, - ... chord_ref=14 * fl.u.m, - ... n_loading_nodes=20, - ... entities=bet_cylinder, - ... length_unit=fl.u.m, - ... angle_unit=fl.u.deg, - ... ) - """ - - params = generate_dfdc_bet_json( - dfdc_file_content=file.content, - rotation_direction_rule=rotation_direction_rule, - initial_blade_direction=initial_blade_direction, - blade_line_chord=blade_line_chord, - omega=omega, - chord_ref=chord_ref, - n_loading_nodes=n_loading_nodes, - entities=entities, - angle_unit=angle_unit, - length_unit=length_unit, - name=name, - ) - if collective_pitch is not None: - params["collective_pitch"] = collective_pitch - - return cls(**params) - - # pylint: disable=too-many-arguments, no-self-argument, not-callable - @MultiConstructorBaseModel.model_constructor - @pd.validate_call - def from_xfoil( - cls, - file: XFOILFile, - rotation_direction_rule: Literal["leftHand", "rightHand"], - omega: AngularVelocityDim.NonNegativeFloat64, - chord_ref: Length.PositiveFloat64, - n_loading_nodes: pd.StrictInt, - entities: EntityList[Cylinder], - length_unit: Length.NonNegativeFloat64, - angle_unit: Angle.Float64, - number_of_blades: pd.StrictInt, - initial_blade_direction: Optional[Axis], - blade_line_chord: Length.NonNegativeFloat64 = 0 * u.m, - collective_pitch: Optional[Angle.Float64] = None, - name: str = "BET disk", - ): - """Constructs a :class: `BETDisk` instance from a given XROTOR file and additional inputs. - - Parameters - ---------- - file: XFOILFile - XFOILFile class instance containing information about the XFOIL file. - rotation_direction_rule: str - Rule for rotation direction and thrust direction. - omega: AngularVelocity.NonNegativeFloat64 - Rotating speed of the propeller. - chord_ref: Length.PositiveFloat64 - Dimensional reference cord used to compute sectional blade loadings. - n_loading_nodes: Int - Number of nodes used to compute sectional thrust and torque coefficients. - entities: EntityList[Cylinder] - List of Cylinder entities used for defining the BET volumes. - length_unit: Length.NonNegativeFloat64 - Length unit used for BETDisk parameters. - angle_unit: Angle.Float64 - Angle unit used for Angle BETDisk parameters. - number_of_blades: Int - Number of blades to model. - initial_blade_direction: Axis, optional - Direction of the first blade in global mesh coordinates; orthogonal to the rotation axis. - Only direction matters (need not be a unit vector). Required for unsteady BET Line. - blade_line_chord: Length.NonNegativeFloat64 - Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. - collective_pitch: AngleType, optional - Collective pitch angle applied as a uniform offset to all blade twist values. - - - Returns - ------- - BETDisk - An instance of :class:`BETDisk` completed with given inputs. - - Examples - -------- - Create a BET disk with an XFOIL file. - - >>> param = fl.BETDisk.from_xfoil( - ... file=fl.XFOILFile(file_path=("xfoil_xv15.csv")), - ... rotation_direction_rule="leftHand", - ... initial_blade_direction=[1, 0, 0], - ... blade_line_chord=1 * fl.u.m, - ... omega=0.0046 * fl.u.deg / fl.u.s, - ... chord_ref=14 * fl.u.m, - ... n_loading_nodes=20, - ... entities=bet_cylinder_imperial, - ... length_unit=fl.u.m, - ... angle_unit=fl.u.deg, - ... number_of_blades=3, - ) - """ - - params = generate_xfoil_bet_json( - geometry_file_content=file.content, - xfoil_polar_file_list=file.polar_files, - rotation_direction_rule=rotation_direction_rule, - initial_blade_direction=initial_blade_direction, - blade_line_chord=blade_line_chord, - omega=omega, - chord_ref=chord_ref, - n_loading_nodes=n_loading_nodes, - entities=entities, - angle_unit=angle_unit, - length_unit=length_unit, - number_of_blades=number_of_blades, - name=name, - ) - if collective_pitch is not None: - params["collective_pitch"] = collective_pitch - - return cls(**params) - - # pylint: disable=too-many-arguments, no-self-argument, not-callable - @MultiConstructorBaseModel.model_constructor - @pd.validate_call - def from_xrotor( - cls, - file: XROTORFile, - rotation_direction_rule: Literal["leftHand", "rightHand"], - omega: AngularVelocityDim.NonNegativeFloat64, - chord_ref: Length.PositiveFloat64, - n_loading_nodes: pd.StrictInt, - entities: EntityList[Cylinder], - length_unit: Length.NonNegativeFloat64, - angle_unit: Angle.Float64, - initial_blade_direction: Optional[Axis] = None, - blade_line_chord: Length.NonNegativeFloat64 = 0 * u.m, - collective_pitch: Optional[Angle.Float64] = None, - name: str = "BET disk", - ): - """Constructs a :class: `BETDisk` instance from a given XROTOR file and additional inputs. - - Parameters - ---------- - file: XROTORFile - XROTORFile class instance containing information about the XROTOR file. - rotation_direction_rule: str - Rule for rotation direction and thrust direction. - omega: AngularVelocity.NonNegativeFloat64 - Rotating speed of the propeller. - chord_ref: Length.PositiveFloat64 - Dimensional reference cord used to compute sectional blade loadings. - n_loading_nodes: Int - Number of nodes used to compute sectional thrust and torque coefficients. - entities: EntityList[Cylinder] - List of Cylinder entities used for defining the BET volumes. - length_unit: Length.NonNegativeFloat64 - Length unit used for BETDisk parameters. - angle_unit: Angle.Float64 - Angle unit used for Angle BETDisk parameters. - initial_blade_direction: Axis, optional - Direction of the first blade in global mesh coordinates; orthogonal to the rotation axis. - Only direction matters (need not be a unit vector). Required for unsteady BET Line. - blade_line_chord: Length.NonNegativeFloat64 - Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. - collective_pitch: AngleType, optional - Collective pitch angle applied as a uniform offset to all blade twist values. - - - Returns - ------- - BETDisk - An instance of :class:`BETDisk` completed with given inputs. - - Examples - -------- - Create a BET disk with an XROTOR file. - - >>> param = fl.BETDisk.from_xrotor( - ... file=fl.XROTORFile(file_path="xrotor_xv15.xrotor")), - ... rotation_direction_rule="leftHand", - ... omega=0.0046 * fl.u.deg / fl.u.s, - ... chord_ref=14 * fl.u.m, - ... n_loading_nodes=20, - ... entities=bet_cylinder, - ... angle_unit=fl.u.deg, - ... length_unit=fl.u.m, - ... ) - """ - - params = generate_xrotor_bet_json( - xrotor_file_content=file.content, - rotation_direction_rule=rotation_direction_rule, - initial_blade_direction=initial_blade_direction, - blade_line_chord=blade_line_chord, - omega=omega, - chord_ref=chord_ref, - n_loading_nodes=n_loading_nodes, - entities=entities, - angle_unit=angle_unit, - length_unit=length_unit, - name=name, - ) - if collective_pitch is not None: - params["collective_pitch"] = collective_pitch - - return cls(**params) - - -class Rotation(Flow360BaseModel): - """ - :class:`Rotation` class for specifying rotation settings. - - Example - ------- - - Define a rotation model :code:`outer_rotation` for the :code:`volume_mesh["outer"]` volume. - The rotation center and axis are defined via the rotation entity's property: - - >>> outer_rotation_volume = volume_mesh["outer"] - >>> outer_rotation_volume.center = (-1, 0, 0) * fl.u.m - >>> outer_rotation_volume.axis = (0, 1, 0) - >>> outer_rotation = fl.Rotation( - ... name="outerRotation", - ... volumes=[outer_rotation_volume], - ... spec= fl.AngleExpression("sin(t)"), - ... ) - - Define another rotation model :code:`inner_rotation` for the :code:`volume_mesh["inner"]` volume. - :code:`inner_rotation` is nested in :code:`outer_rotation` by setting :code:`volume_mesh["outer"]` - as the :py:attr:`Rotation.parent_volume`: - - >>> inner_rotation_volume = volume_mesh["inner"] - >>> inner_rotation_volume.center = (0, 0, 0) * fl.u.m - >>> inner_rotation_volume.axis = (0, 1, 0) - >>> inner_rotation = fl.Rotation( - ... name="innerRotation", - ... volumes=inner_rotation_volume, - ... spec= fl.AngleExpression("-2*sin(t)"), - ... parent_volume=outer_rotation_volume # inner rotation is nested in the outer rotation. - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Rotation", description="Name of the `Rotation` model.") - type: Literal["Rotation"] = pd.Field("Rotation", frozen=True) - entities: EntityList[ - GenericVolume, Cylinder, CustomVolume, SeedpointVolume, AxisymmetricBody - ] = pd.Field( - alias="volumes", - description="The entity list for the `Rotation` model. " - + "The entity should be :class:`Cylinder` or :class:`AxisymmetricBody` or :class:`GenericVolume` type.", - ) - - # TODO: Add test for each of the spec specification. - spec: Union[AngleExpression, FromUserDefinedDynamics, AngularVelocity] = pd.Field( - discriminator="type_name", - description="The angular velocity or rotation angle as a function of time.", - ) - parent_volume: Optional[ - Annotated[ - Union[GenericVolume, Cylinder, CustomVolume, SeedpointVolume, AxisymmetricBody], - pd.Field(discriminator="private_attribute_entity_type_name"), - ] - ] = pd.Field( - None, - description="The parent rotating entity in a nested rotation case." - + "The entity should be :class:`Cylinder` or :class:`AxisymmetricBody` or :class:`GenericVolume` type.", - ) - rotating_reference_frame_model: Optional[bool] = pd.Field( - None, - description="Flag to specify whether the non-inertial reference frame model is " - + "to be used for the rotation model. Steady state simulation requires this flag " - + "to be True for all rotation models.", - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @contextual_field_validator("entities", mode="after") - @classmethod - def _ensure_entities_have_sufficient_attributes( - cls, value: EntityList, param_info: ParamsValidationInfo - ): - """Ensure entities have sufficient attributes.""" - expanded = param_info.expand_entity_list(value) - for entity in expanded: - if entity.axis is None: - raise ValueError( - f"Entity '{entity.name}' must specify `axis` to be used under `Rotation`." - ) - if entity.center is None: - raise ValueError( - f"Entity '{entity.name}' must specify `center` to be used under `Rotation`" - ) - return value - - @contextual_field_validator("parent_volume", mode="after") - @classmethod - def _ensure_custom_volume_is_valid( - cls, - value: Optional[Union[GenericVolume, Cylinder, CustomVolume, SeedpointVolume]], - param_info: ParamsValidationInfo, - ): - """Ensure parent volume is a custom volume.""" - if value is None: - return value - if not isinstance(value, (CustomVolume, SeedpointVolume)): - return value - if value.name not in param_info.to_be_generated_custom_volumes: - raise ValueError( - f"Parent {type(value).__name__} {value.name} is not listed under meshing->volume_zones(or zones)" - + "->CustomZones." - ) - return value - - -class PorousMedium(Flow360BaseModel): - """ - :class:`PorousMedium` class for specifying porous media settings. - For further information please refer to the :ref:`porous media knowledge base `. - - Example - ------- - - Define a porous medium model :code:`porous_zone` with the :py:class:`Box` entity. - The center and size of the `porous_zone` box are (0, 0, 0) * fl.u.m and (0.2, 0.3, 2) * fl.u.m, respectively. - The axes of the :code:`porous_zone` are set as (0, 1, 0) and (0, 0, 1). - - >>> fl.PorousMedium( - ... entities=[ - ... fl.Box.from_principal_axes( - ... name="porous_zone", - ... axes=[(0, 1, 0), (0, 0, 1)], - ... center=(0, 0, 0) * fl.u.m, - ... size=(0.2, 0.3, 2) * fl.u.m, - ... ) - ... ], - ... darcy_coefficient=(1e6, 0, 0) / fl.u.m **2, - ... forchheimer_coefficient=(1, 0, 0) / fl.u.m, - ... volumetric_heat_source=1.0 * fl.u.W/ fl.u.m **3, - ... ) - - Define a porous medium model :code:`porous_zone` with the :code:`volume_mesh["porous_zone"]` volume. - The axes of entity must be specified to serve as the the principle axes of the porous medium - material model, and we set the axes of the :code:`porous_zone` as (1, 0, 0) and (0, 1, 0). - - >>> porous_zone = volume_mesh["porous_zone"] - >>> porous_zone.axes = [(1, 0, 0), (0, 1, 0)] - >>> porous_medium_model = fl.PorousMedium( - ... entities=[porous_zone], - ... darcy_coefficient=(1e6, 0, 0) / fl.u.m **2, - ... forchheimer_coefficient=(1, 0, 0) / fl.u.m, - ... volumetric_heat_source=1.0 * fl.u.W/ fl.u.m **3, - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Porous medium", description="Name of the `PorousMedium` model.") - type: Literal["PorousMedium"] = pd.Field("PorousMedium", frozen=True) - entities: EntityList[GenericVolume, Box, CustomVolume, SeedpointVolume] = pd.Field( - alias="volumes", - description="The entity list for the `PorousMedium` model. " - + "The entity should be defined by :class:`Box`, zones from the geometry/volume mesh or" - + "by :class:`SeedpointVolume` when using snappyHexMeshing." - + "The axes of entity must be specified to serve as the the principle axes of the " - + "porous medium material model.", - ) - - darcy_coefficient: InverseArea.Vector3 = pd.Field( - description="Darcy coefficient of the porous media model which determines the scaling of the " - + "viscous loss term. The 3 values define the coefficient for each of the 3 axes defined by " - + "the reference frame of the volume zone." - ) - forchheimer_coefficient: InverseLength.Vector3 = pd.Field( - description="Forchheimer coefficient of the porous media model which determines " - + "the scaling of the inertial loss term." - ) - volumetric_heat_source: Optional[Union[StringExpression, HeatSource.Float64]] = pd.Field( - None, description="The volumetric heat source." - ) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @contextual_field_validator("entities", mode="after") - @classmethod - def _ensure_entities_have_sufficient_attributes( - cls, value: EntityList, param_info: ParamsValidationInfo - ): - """Ensure entities have sufficient attributes.""" - expanded = param_info.expand_entity_list(value) - for entity in expanded: - if entity.axes is None: - raise ValueError( - f"Entity '{entity.name}' must specify `axes` to be used under `PorousMedium`." - ) - return value - - @contextual_field_validator("volumetric_heat_source", mode="after") - @classmethod - def _validate_volumetric_heat_source_for_liquid( - cls, - value: Optional[Union[StringExpression, HeatSource.Float64]], - param_info: ParamsValidationInfo, - ): - """Disable the volumetric_heat_source when liquid operating condition is used""" - if param_info.using_liquid_as_material is False: - return value - if value is not None: - raise ValueError( - "`volumetric_heat_source` cannot be setup under `PorousMedium` when using " - "liquid as simulation material." - ) - return value - - -VolumeModelTypes = Union[ - Fluid, - Solid, +from flow360_schema.models.simulation.models.volume_models import ( ActuatorDisk, + AngleExpression, + AngularVelocity, + AuxiliaryPolarFile, BETDisk, - Rotation, + BETDiskCache, + BETDiskChord, + BETDiskSectionalPolar, + BETDiskTwist, + BETFileTypes, + BETSingleInputFileBaseModel, + BETSingleMultiFileBaseModel, + C81File, + DFDCFile, + ExpressionInitialConditionBase, + Fluid, + ForcePerArea, + FromUserDefinedDynamics, + Gravity, + HeatEquationInitialCondition, + NavierStokesInitialCondition, + NavierStokesModifiedRestartSolution, + PDEModelBase, PorousMedium, -] + Rotation, + Solid, + VolumeModelTypes, + XFOILFile, + XROTORFile, +) diff --git a/flow360/component/simulation/operating_condition/atmosphere_model.py b/flow360/component/simulation/operating_condition/atmosphere_model.py index 4b60f7ca3..d9d3e53bb 100644 --- a/flow360/component/simulation/operating_condition/atmosphere_model.py +++ b/flow360/component/simulation/operating_condition/atmosphere_model.py @@ -1,98 +1,17 @@ -"""U.S. STANDARD ATMOSPHERE 1976 -# https://www.ngdc.noaa.gov/stp/space-weather/online-publications/miscellaneous/us-standard-atmosphere-1976/us-standard-atmosphere_st76-1562_noaa.pdf - Source: design 360 -""" +"""Operating condition atmosphere model — re-import relay.""" -from math import exp +from importlib import import_module +_EXPORTED_NAMES = {"StandardAtmosphereModel"} -class StandardAtmosphereModel: - """Standard atmosphere model for the Earth.""" - g0_prim = 9.80665 - r0 = 6356766 # earth radius in m - R_star = 8.31432e3 # universal gas constant N*m/kmol/K - M0 = 28.9644 # kg/kmol - R = 287.0529 +def __getattr__(name): + if name not in _EXPORTED_NAMES: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - H = [0, 11, 20, 32, 47, 51, 71, 84.8520] - L_M = [-6.5, 0, 1, 2.8, 0, -2.8, -2.0] - T_M = [288.15, 216.65, 216.65, 228.65, 270.65, 270.65, 214.65, 186.946] - P_b = [ - 101325, - 22632.06397346292, - 5474.888669677776, - 868.0186847552283, - 110.90630555496591, - 66.93887311868727, - 3.956420428040724, - 0.3733835899762152, - ] - - def __init__(self, altitude_in_meters, temperature_offset_in_kelvin=0): - if altitude_in_meters > 86000 or altitude_in_meters < -5000: - raise ValueError( - "The altitude should be between -5000 and 86000 meters. The input value is " - + str(altitude_in_meters) - + " meters." - ) - self.altitude_in_meters = altitude_in_meters - self.temperature_offset_in_kelvin = temperature_offset_in_kelvin - - self._temperature = self._calculate_temperature( - self.altitude_in_meters, self.temperature_offset_in_kelvin - ) - self._pressure = self._calculate_pressure(self.altitude_in_meters) - self._density = self._calculate_density(self.temperature, self.pressure) - - @classmethod - def b_index(cls, h): - """Return the index of the layer in which the altitude is located.""" - for i in range(len(cls.H) - 2): - if h < cls.H[i + 1]: - return i - return len(cls.H) - 2 - - @classmethod - def _calculate_geopotential_altitude(cls, altitude_in_meters): - geopotential_altitude = cls.r0 * altitude_in_meters / (cls.r0 + altitude_in_meters) - return geopotential_altitude - - @classmethod - def _calculate_temperature(cls, altitude_in_meters, temperature_offset_in_kelvin): - h = cls._calculate_geopotential_altitude(altitude_in_meters) / 1000 - b = cls.b_index(h) - temperature = cls.T_M[b] + cls.L_M[b] * (h - cls.H[b]) - return temperature + temperature_offset_in_kelvin - - @classmethod - def _calculate_pressure(cls, altitude_in_meters): - h = cls._calculate_geopotential_altitude(altitude_in_meters) / 1000 - b = cls.b_index(h) - if cls.L_M[b] == 0: - factor = exp(-cls.g0_prim * cls.M0 / cls.R_star * 1000 * (h - cls.H[b]) / cls.T_M[b]) - else: - factor = pow( - (cls.T_M[b] / (cls.T_M[b] + cls.L_M[b] * (h - cls.H[b]))), - (cls.g0_prim * cls.M0 / cls.R_star / cls.L_M[b] * 1000), - ) - return cls.P_b[b] * factor - - @classmethod - def _calculate_density(cls, temperature, pressure): - return pressure / temperature * cls.M0 / cls.R_star - - @property - def pressure(self) -> float: - """Return pressure in Pa.""" - return self._pressure - - @property - def density(self) -> float: - """Return density in kg/m^3.""" - return self._density - - @property - def temperature(self) -> float: - """Return temperature in K.""" - return self._temperature + schema_module = import_module( + "flow360_schema.models.simulation.operating_condition.atmosphere_model" + ) + value = getattr(schema_module, name) + globals()[name] = value + return value diff --git a/flow360/component/simulation/operating_condition/operating_condition.py b/flow360/component/simulation/operating_condition/operating_condition.py index 5ea867581..1bca741d5 100644 --- a/flow360/component/simulation/operating_condition/operating_condition.py +++ b/flow360/component/simulation/operating_condition/operating_condition.py @@ -1,618 +1,17 @@ -"""Operating conditions for the simulation framework.""" - -from typing import Literal, Optional, Tuple, Union - -import pydantic as pd -from flow360_schema.framework.expression import Expression -from flow360_schema.framework.physical_dimensions import ( - AbsoluteTemperature, - Angle, - DeltaTemperature, - Density, - Length, - Pressure, - Velocity, - Viscosity, -) -from typing_extensions import Self - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.expressions import StringExpression -from flow360.component.simulation.framework.multi_constructor_model_base import ( - MultiConstructorBaseModel, +"""Relay import for simulation operating condition models.""" + +# pylint: disable=unused-import + +from flow360_schema.models.simulation.operating_condition.operating_condition import ( + AerospaceCondition, + AerospaceConditionCache, + Air, + GenericReferenceCondition, + GenericReferenceConditionCache, + LiquidOperatingCondition, + OperatingConditionTypes, + ThermalState, + ThermalStateCache, + VelocityVectorType, + Water, ) -from flow360.component.simulation.models.material import Air, Water -from flow360.component.simulation.operating_condition.atmosphere_model import ( - StandardAtmosphereModel, -) -from flow360.component.simulation.user_code.core.types import ValueOrExpression -from flow360.component.simulation.validation.validation_context import ( - CASE, - CaseField, - ConditionalField, - context_validator, - get_validation_info, -) -from flow360.log import log - -# pylint: disable=no-member -VelocityVectorType = Union[ - Tuple[StringExpression, StringExpression, StringExpression], Velocity.Vector3 -] - - -class ThermalStateCache(Flow360BaseModel): - """[INTERNAL] Cache for thermal state inputs""" - - # pylint: disable=no-member - altitude: Optional[Length.Float64] = None - temperature_offset: Optional[DeltaTemperature.Float64] = None - - -class ThermalState(MultiConstructorBaseModel): - """ - Represents the thermal state of a fluid with specific properties. - - Example - ------- - - >>> fl.ThermalState( - ... temperature=300 * fl.u.K, - ... density=1.225 * fl.u.kg / fl.u.m**3, - ... material=fl.Air() - ... ) - - ==== - """ - - # pylint: disable=fixme - # TODO: remove frozen and throw warning if temperature/density is modified after construction from atmospheric model - type_name: Literal["ThermalState"] = pd.Field("ThermalState", frozen=True) - temperature: AbsoluteTemperature.Float64 = pd.Field( - 288.15 * u.K, frozen=True, description="The temperature of the fluid." - ) - density: Density.PositiveFloat64 = pd.Field( - 1.225 * u.kg / u.m**3, frozen=True, description="The density of the fluid." - ) - material: Air = pd.Field(Air(), frozen=True, description="The material of the fluid.") - private_attribute_input_cache: ThermalStateCache = ThermalStateCache() - private_attribute_constructor: Literal["from_standard_atmosphere", "default"] = pd.Field( - default="default", frozen=True - ) - - # pylint: disable=no-self-argument, not-callable, unused-argument - @MultiConstructorBaseModel.model_constructor - @pd.validate_call - def from_standard_atmosphere( - cls, - altitude: Length.Float64 = 0 * u.m, - temperature_offset: DeltaTemperature.Float64 = 0 * u.K, - ): - """ - Constructs a :class:`ThermalState` instance from the standard atmosphere model. - - Parameters - ---------- - altitude : Length.Float64, optional - The altitude at which the thermal state is calculated. Defaults to ``0 * u.m``. - temperature_offset : DeltaTemperature.Float64, optional - The temperature offset to be applied to the standard temperature at the given altitude. - Defaults to ``0 * u.K``. - - Returns - ------- - ThermalState - A thermal state representing the atmospheric conditions at the specified altitude and temperature offset. - - Notes - ----- - - This method uses the :class:`StandardAtmosphereModel` to compute the standard atmospheric - conditions based on the given altitude. - - The ``temperature_offset`` allows for adjustments to the standard temperature, simulating - non-standard atmospheric conditions. - - Examples - -------- - Create a thermal state at an altitude of 10,000 meters: - - >>> thermal_state = ThermalState.from_standard_atmosphere(altitude=10000 * u.m) - >>> thermal_state.temperature - - >>> thermal_state.density - - - Apply a temperature offset of -5 Fahrenheit at 5,000 meters: - - >>> thermal_state = ThermalState.from_standard_atmosphere( - ... altitude=5000 * u.m, - ... temperature_offset=-5 * u.delta_degF - ... ) - >>> thermal_state.temperature - - >>> thermal_state.density - - """ - standard_atmosphere_model = StandardAtmosphereModel( - altitude.in_units(u.m).value, temperature_offset.in_units(u.K).value - ) - # Construct and return the thermal state - state = cls( - density=standard_atmosphere_model.density * u.kg / u.m**3, - temperature=standard_atmosphere_model.temperature * u.K, - material=Air(), - ) - return state - - @property - def altitude(self) -> Optional[Length.Float64]: - """Return user specified altitude.""" - if not self.private_attribute_input_cache.altitude: - log.warning("Altitude not provided from input") - return self.private_attribute_input_cache.altitude - - @property - def temperature_offset(self) -> Optional[DeltaTemperature.Float64]: - """Return user specified temperature offset.""" - if not self.private_attribute_input_cache.temperature_offset: - log.warning("Temperature offset not provided from input") - return self.private_attribute_input_cache.temperature_offset - - @property - def speed_of_sound(self) -> Velocity.PositiveFloat64: - """Computes speed of sound.""" - return self.material.get_speed_of_sound(self.temperature) - - @property - def pressure(self) -> Pressure.PositiveFloat64: - """Computes pressure.""" - return self.material.get_pressure(self.density, self.temperature) - - @property - def dynamic_viscosity(self) -> Viscosity.PositiveFloat64: - """Computes dynamic viscosity.""" - return self.material.get_dynamic_viscosity(self.temperature) - - -class GenericReferenceConditionCache(Flow360BaseModel): - """[INTERNAL] Cache for GenericReferenceCondition inputs""" - - thermal_state: Optional[ThermalState] = None - mach: Optional[pd.PositiveFloat] = None - - -class GenericReferenceCondition(MultiConstructorBaseModel): - """ - Operating condition defines the physical (non-geometrical) reference values for the problem. - - Example - ------- - - - Define :class:`GenericReferenceCondition` with :py:meth:`from_mach`: - - >>> fl.GenericReferenceCondition.from_mach( - ... mach=0.2, - ... thermal_state=ThermalState(), - ... ) - - - Define :class:`GenericReferenceCondition` with :py:attr:`velocity_magnitude`: - - >>> fl.GenericReferenceCondition(velocity_magnitude=40 * fl.u.m / fl.u.s) - - ==== - """ - - type_name: Literal["GenericReferenceCondition"] = pd.Field( - "GenericReferenceCondition", frozen=True - ) - velocity_magnitude: Optional[ValueOrExpression[Velocity.PositiveFloat64]] = ConditionalField( - context=CASE, - description="Freestream velocity magnitude. Used as reference velocity magnitude" - + " when :py:attr:`reference_velocity_magnitude` is not specified. Cannot change once specified.", - frozen=True, - ) - thermal_state: ThermalState = pd.Field( - ThermalState(), - description="Reference and freestream thermal state. Defaults to US standard atmosphere at sea level.", - ) - private_attribute_input_cache: GenericReferenceConditionCache = GenericReferenceConditionCache() - - # pylint: disable=no-self-argument, not-callable - @MultiConstructorBaseModel.model_constructor - @pd.validate_call - def from_mach( - cls, - mach: pd.PositiveFloat, - thermal_state: ThermalState = ThermalState(), - ): - """Constructs a reference condition from Mach number and thermal state.""" - velocity_magnitude = mach * thermal_state.speed_of_sound - return cls(velocity_magnitude=velocity_magnitude, thermal_state=thermal_state) - - @property - def mach(self) -> pd.PositiveFloat: - """Computes Mach number.""" - return (self.velocity_magnitude / self.thermal_state.speed_of_sound).value - - @pd.field_validator("thermal_state", mode="after") - @classmethod - def _update_input_cache(cls, value, info: pd.ValidationInfo): - setattr(info.data["private_attribute_input_cache"], info.field_name, value) - return value - - -class AerospaceConditionCache(Flow360BaseModel): - """[INTERNAL] Cache for AerospaceCondition inputs""" - - mach: Optional[pd.NonNegativeFloat] = None - reynolds_mesh_unit: Optional[pd.PositiveFloat] = None - project_length_unit: Optional[Length.PositiveFloat64] = None - alpha: Optional[Angle.Float64] = None - beta: Optional[Angle.Float64] = None - temperature: Optional[AbsoluteTemperature.Float64] = None - thermal_state: Optional[ThermalState] = pd.Field(None, alias="atmosphere") - reference_mach: Optional[pd.PositiveFloat] = None - - -class AerospaceCondition(MultiConstructorBaseModel): - """ - Operating condition for aerospace applications. Defines both reference parameters used to compute nondimensional - coefficients in postprocessing and the default :class:`Freestream` boundary condition for the simulation. - - Example - ------- - - - Define :class:`AerospaceCondition` with :py:meth:`from_mach`: - - >>> fl.AerospaceCondition.from_mach( - ... mach=0, - ... alpha=-90 * fl.u.deg, - ... thermal_state=fl.ThermalState(), - ... reference_mach=0.69, - ... ) - - - Define :class:`AerospaceCondition` with :py:attr:`velocity_magnitude`: - - >>> fl.AerospaceCondition(velocity_magnitude=40 * fl.u.m / fl.u.s) - - ==== - """ - - type_name: Literal["AerospaceCondition"] = pd.Field("AerospaceCondition", frozen=True) - alpha: Angle.Float64 = ConditionalField( - 0 * u.deg, description="The angle of attack.", context=CASE - ) - beta: Angle.Float64 = ConditionalField( - 0 * u.deg, description="The side slip angle.", context=CASE - ) - velocity_magnitude: Optional[ValueOrExpression[Velocity.NonNegativeFloat64]] = ConditionalField( - description="Freestream velocity magnitude. Used as reference velocity magnitude" - + " when :py:attr:`reference_velocity_magnitude` is not specified.", - context=CASE, - frozen=True, - ) - thermal_state: ThermalState = pd.Field( - ThermalState(), - alias="atmosphere", - description="Reference and freestream thermal state. Defaults to US standard atmosphere at sea level.", - ) - reference_velocity_magnitude: Optional[Velocity.PositiveFloat64] = CaseField( - None, - description="Reference velocity magnitude. Is required when :py:attr:`velocity_magnitude` is 0.", - frozen=True, - ) - private_attribute_input_cache: AerospaceConditionCache = AerospaceConditionCache() - - # pylint: disable=too-many-arguments, no-self-argument, not-callable - @MultiConstructorBaseModel.model_constructor - @pd.validate_call - def from_mach( - cls, - mach: pd.NonNegativeFloat, - alpha: Angle.Float64 = 0 * u.deg, - beta: Angle.Float64 = 0 * u.deg, - thermal_state: ThermalState = ThermalState(), - reference_mach: Optional[pd.PositiveFloat] = None, - ): - """ - Constructs an :class:`AerospaceCondition` instance from a Mach number and thermal state. - - Parameters - ---------- - mach : float - Freestream Mach number (non-negative). - Used as reference Mach number when ``reference_mach`` is not specified. - alpha : Angle.Float64, optional - The angle of attack. Defaults to ``0 * u.deg``. - beta : Angle.Float64, optional - The side slip angle. Defaults to ``0 * u.deg``. - thermal_state : ThermalState, optional - Reference and freestream thermal state. Defaults to US standard atmosphere at sea level. - reference_mach : float, optional - Reference Mach number (positive). If provided, calculates the reference velocity magnitude. - - Returns - ------- - AerospaceCondition - An instance of :class:`AerospaceCondition` with the calculated velocity magnitude and provided parameters. - - Notes - ----- - - The ``velocity_magnitude`` is calculated as ``mach * thermal_state.speed_of_sound``. - - If ``reference_mach`` is provided, the ``reference_velocity_magnitude`` is calculated as - ``reference_mach * thermal_state.speed_of_sound``. - - Examples - -------- - Create an aerospace condition with a Mach number of 0.85: - - >>> condition = AerospaceCondition.from_mach(mach=0.85) - >>> condition.velocity_magnitude - - - Specify angle of attack and side slip angle: - - >>> condition = AerospaceCondition.from_mach(mach=0.85, alpha=5 * u.deg, beta=2 * u.deg) - - Include a custom thermal state and reference Mach number: - - >>> custom_thermal = ThermalState(temperature=250 * u.K) - >>> condition = AerospaceCondition.from_mach( - ... mach=0.85, - ... thermal_state=custom_thermal, - ... reference_mach=0.8 - ... ) - """ - - velocity_magnitude = mach * thermal_state.speed_of_sound - - reference_velocity_magnitude = ( - reference_mach * thermal_state.speed_of_sound if reference_mach else None - ) - - return cls( - velocity_magnitude=velocity_magnitude, - alpha=alpha, - beta=beta, - thermal_state=thermal_state, - reference_velocity_magnitude=reference_velocity_magnitude, - ) - - # pylint: disable=too-many-arguments - @MultiConstructorBaseModel.model_constructor - @pd.validate_call - def from_mach_reynolds( - cls, - mach: pd.PositiveFloat, - reynolds_mesh_unit: pd.PositiveFloat, - project_length_unit: Optional[Length.PositiveFloat64], - alpha: Angle.Float64 = 0 * u.deg, - beta: Angle.Float64 = 0 * u.deg, - temperature: AbsoluteTemperature.Float64 = 288.15 * u.K, - reference_mach: Optional[pd.PositiveFloat] = None, - ): - """ - Create an `AerospaceCondition` from Mach number and Reynolds number. - - This function computes the thermal state based on the given Mach number, - Reynolds number, and temperature, and returns an `AerospaceCondition` object - initialized with the computed thermal state and given aerodynamic angles. - - Parameters - ---------- - mach : NonNegativeFloat - Freestream Mach number (must be non-negative). - reynolds_mesh_unit : PositiveFloat - Freestream Reynolds number scaled to mesh unit (must be positive). - For example if the mesh unit is 1 mm, the reynolds_mesh_unit should be - equal to a Reynolds number that has the characteristic length of 1 mm. - project_length_unit: Length.PositiveFloat64 - Project length unit used to compute the density (must be positive). - alpha : Angle.Float64, optional - Angle of attack. Default is 0 degrees. - beta : Angle.Float64, optional - Sideslip angle. Default is 0 degrees. - temperature : AbsoluteTemperature.Float64, optional - Freestream static temperature (must be above absolute zero, 0 K). Default is 288.15 Kelvin. - reference_mach : PositiveFloat, optional - Reference Mach number. Default is None. - - Returns - ------- - AerospaceCondition - An instance of :class:`AerospaceCondition` with calculated velocity, thermal state and provided parameters. - - Example - ------- - Example usage: - - >>> condition = fl.AerospaceCondition.from_mach_reynolds( - ... mach=0.85, - ... reynolds_mesh_unit=1e6, - ... project_length_unit=1 * u.mm, - ... temperature=288.15 * u.K, - ... alpha=2.0 * u.deg, - ... beta=0.0 * u.deg, - ... reference_mach=0.85, - ... ) - >>> print(condition) - AerospaceCondition(...) - - """ - - if temperature.units is u.K and temperature.value == 288.15: - log.info("Default value of 288.15 K will be used as temperature.") - - if project_length_unit is None: - validation_info = get_validation_info() - if validation_info is None or validation_info.project_length_unit is None: - raise ValueError("Project length unit must be provided.") - project_length_unit = validation_info.project_length_unit - - material = Air() - - velocity = mach * material.get_speed_of_sound(temperature) - - density = ( - reynolds_mesh_unit - * material.get_dynamic_viscosity(temperature) - / (velocity * project_length_unit) - ) - - thermal_state = ThermalState(temperature=temperature, density=density) - - velocity_magnitude = mach * thermal_state.speed_of_sound - - reference_velocity_magnitude = ( - reference_mach * thermal_state.speed_of_sound if reference_mach else None - ) - - log.info( - """Density and viscosity were calculated based on input data, ThermalState will be automatically created.""" - ) - - # pylint: disable=no-value-for-parameter - return cls( - velocity_magnitude=velocity_magnitude, - alpha=alpha, - beta=beta, - thermal_state=thermal_state, - reference_velocity_magnitude=reference_velocity_magnitude, - ) - - @property - def _evaluated_velocity_magnitude(self) -> Velocity.PositiveFloat64: - if isinstance(self.velocity_magnitude, Expression): - return self.velocity_magnitude.evaluate( - raise_on_non_evaluable=True, force_evaluate=True - ) - return self.velocity_magnitude - - @pd.model_validator(mode="after") - @context_validator(context=CASE) - def check_valid_reference_velocity(self) -> Self: - """Ensure reference velocity is provided when freestream velocity is 0.""" - if self.velocity_magnitude is None: - return self - if self.reference_velocity_magnitude is not None: - return self - - evaluated_velocity_magnitude = self._evaluated_velocity_magnitude - - if evaluated_velocity_magnitude.value == 0: - raise ValueError( - "Reference velocity magnitude/Mach must be provided when freestream velocity magnitude/Mach is 0." - ) - return self - - @property - def mach(self) -> pd.PositiveFloat: - """Computes Mach number.""" - return (self._evaluated_velocity_magnitude / self.thermal_state.speed_of_sound).value - - @pd.field_validator("alpha", "beta", "thermal_state", mode="after") - @classmethod - def _update_input_cache(cls, value, info: pd.ValidationInfo): - setattr(info.data["private_attribute_input_cache"], info.field_name, value) - return value - - @pd.validate_call - def flow360_reynolds_number(self, length_unit: Length.PositiveFloat64): - """ - Computes length_unit based Reynolds number. - :math:`Re = \\rho_{\\infty} \\cdot U_{\\infty} \\cdot L_{grid}/\\mu_{\\infty}` where - - - :math:`\\rho_{\\infty}` is the freestream fluid density. - - :math:`U_{\\infty}` is the freestream velocity magnitude. - - :math:`L_{grid}` is physical length represented by unit length in the given mesh/geometry file. - - :math:`\\mu_{\\infty}` is the dynamic eddy viscosity of the fluid of freestream. - - Parameters - ---------- - length_unit : Length.PositiveFloat64 - Physical length represented by unit length in the given mesh/geometry file. - """ - - return ( - self.thermal_state.density - * self._evaluated_velocity_magnitude - * length_unit - / self.thermal_state.dynamic_viscosity - ).value - - -class LiquidOperatingCondition(Flow360BaseModel): - """ - Operating condition for simulation of water as the only material. - - Example - ------- - - >>> fl.LiquidOperatingCondition( - ... velocity_magnitude=10 * fl.u.m / fl.u.s, - ... alpha=-90 * fl.u.deg, - ... beta=0 * fl.u.deg, - ... material=fl.Water(name="Water"), - ... reference_velocity_magnitude=5 * fl.u.m / fl.u.s, - ... ) - - ==== - """ - - type_name: Literal["LiquidOperatingCondition"] = pd.Field( - "LiquidOperatingCondition", frozen=True - ) - alpha: Angle.Float64 = ConditionalField( - 0 * u.deg, description="The angle of attack.", context=CASE - ) - beta: Angle.Float64 = ConditionalField( - 0 * u.deg, description="The side slip angle.", context=CASE - ) - velocity_magnitude: Optional[ValueOrExpression[Velocity.NonNegativeFloat64]] = ConditionalField( - context=CASE, - description="Incoming flow velocity magnitude. Used as reference velocity magnitude" - + " when :py:attr:`reference_velocity_magnitude` is not specified. Cannot change once specified.", - frozen=True, - ) - reference_velocity_magnitude: Optional[Velocity.PositiveFloat64] = CaseField( - None, - description="Reference velocity magnitude. Is required when :py:attr:`velocity_magnitude` is 0." - " Used as the velocity scale for nondimensionalization.", - frozen=True, - ) - material: Water = pd.Field( - Water(name="Water"), - description="Type of liquid material used.", - ) - - @property - def _evaluated_velocity_magnitude(self) -> Velocity.PositiveFloat64: - if isinstance(self.velocity_magnitude, Expression): - return self.velocity_magnitude.evaluate( - raise_on_non_evaluable=True, force_evaluate=True - ) - return self.velocity_magnitude - - @pd.model_validator(mode="after") - @context_validator(context=CASE) - def check_valid_reference_velocity(self) -> Self: - """Ensure reference velocity is provided when freestream velocity is 0.""" - if self.velocity_magnitude is None: - return self - if self.reference_velocity_magnitude is not None: - return self - - evaluated_velocity_magnitude = self._evaluated_velocity_magnitude - - if evaluated_velocity_magnitude.value == 0: - raise ValueError( - "Reference velocity magnitude/Mach must be provided when freestream velocity magnitude/Mach is 0." - ) - return self - - -# pylint: disable=fixme -# TODO: AutomotiveCondition -OperatingConditionTypes = Union[ - GenericReferenceCondition, AerospaceCondition, LiquidOperatingCondition -] diff --git a/flow360/component/simulation/outputs/output_fields.py b/flow360/component/simulation/outputs/output_fields.py index 913d0db1d..efe441477 100644 --- a/flow360/component/simulation/outputs/output_fields.py +++ b/flow360/component/simulation/outputs/output_fields.py @@ -1,608 +1,91 @@ -""" -Output field definitions +"""Output fields — re-import relay.""" -This module defines the available output field names for Flow360 simulations, -including both standard non-dimensional fields and dimensioned fields in physical units. +# pylint: disable=unused-import -It also provides support for dimensioned output fields, which automatically generate -UserDefinedField entries to output values in physical units rather than Flow360's -internal non-dimensional units. - -Dimensioned field format: - {base_field}_{component?}_{unit} - -Where: - - base_field: The base field name (velocity, pressure, temperature, etc.) - - component: Optional component for vector fields (x, y, z, magnitude) - - unit: The physical unit (m_per_s, pa, etc.) - -Examples: - - velocity_magnitude_m_per_s: Velocity magnitude in meters per second - - velocity_x_m_per_s: X-component of velocity in meters per second - - pressure_pa: Pressure in pascals -""" - -from typing import List, Literal, get_args, get_origin - -import unyt as u - -from flow360.component.simulation.conversion import ( - compute_udf_dimensionalization_factor, -) -from flow360.component.simulation.operating_condition.operating_condition import ( - LiquidOperatingCondition, -) - -# pylint:disable=invalid-name -_CD = "CD" -_CL = "CL" -_CFx = "CFx" -_CFy = "CFy" -_CFz = "CFz" -_CMx = "CMx" -_CMy = "CMy" -_CMz = "CMz" -_CD_PRESSURE = "CDPressure" -_CL_PRESSURE = "CLPressure" -_CFx_PRESSURE = "CFxPressure" -_CFy_PRESSURE = "CFyPressure" -_CFz_PRESSURE = "CFzPressure" -_CMx_PRESSURE = "CMxPressure" -_CMy_PRESSURE = "CMyPressure" -_CMz_PRESSURE = "CMzPressure" -_CD_SKIN_FRICTION = "CDSkinFriction" -_CL_SKIN_FRICTION = "CLSkinFriction" -_CFx_SKIN_FRICTION = "CFxSkinFriction" -_CFy_SKIN_FRICTION = "CFySkinFriction" -_CFz_SKIN_FRICTION = "CFzSkinFriction" -_CMx_SKIN_FRICTION = "CMxSkinFriction" -_CMy_SKIN_FRICTION = "CMySkinFriction" -_CMz_SKIN_FRICTION = "CMzSkinFriction" -_CL_VISCOUS = "CLViscous" -_CD_VISCOUS = "CDViscous" -_CFx_VISCOUS = "CFxViscous" -_CFy_VISCOUS = "CFyViscous" -_CFz_VISCOUS = "CFzViscous" -_CMx_VISCOUS = "CMxViscous" -_CMy_VISCOUS = "CMyViscous" -_CMz_VISCOUS = "CMzViscous" -_HEAT_TRANSFER = "HeatTransfer" -_HEAT_FLUX = "HeatFlux" -_X = "X" -_Y = "Y" -_NORMAL_DIRECTION = "normal_direction" -_CUMULATIVE_CD_CURVE = "Cumulative_CD_Curve" -_CD_PER_STRIP = "CD_per_strip" -_CFx_PER_SPAN = "CFx_per_span" -_CFy_PER_SPAN = "CFy_per_span" -_CFz_PER_SPAN = "CFz_per_span" -_CMx_PER_SPAN = "CMx_per_span" -_CMy_PER_SPAN = "CMy_per_span" -_CMz_PER_SPAN = "CMz_per_span" -_CFx_CUMULATIVE = "CFx_cumulative" -_CFy_CUMULATIVE = "CFy_cumulative" -_CFz_CUMULATIVE = "CFz_cumulative" -_CMx_CUMULATIVE = "CMx_cumulative" -_CMy_CUMULATIVE = "CMy_cumulative" -_CMz_CUMULATIVE = "CMz_cumulative" -# pylint:enable=invalid-name - -# Coefficient of pressure -# Coefficient of total pressure -# Gradient of primitive solution -# k and omega -# Mach number -# Turbulent viscosity -# Turbulent viscosity and freestream dynamic viscosity ratio -# Spalart-Almaras variable -# rho, u, v, w, p (density, 3 velocities and pressure) -# Q criterion -# N-S residual -# Transition residual -# Turbulence residual -# Entropy -# N-S solution -# Transition solution -# Turbulence solution -# Temperature -# Velocity (non-dimensional) -# Velocity X component (non-dimensional) -# Velocity Y component (non-dimensional) -# Velocity Z component (non-dimensional) -# Velocity Magnitude (non-dimensional) -# Pressure (non-dimensional) -# Vorticity -# Vorticity Magnitude -# Wall distance -# NumericalDissipationFactor sensor -# Heat equation residual -# Velocity with respect to non-inertial frame -# Low-Mach preconditioner factor -# Velocity (dimensioned, m/s) -# Velocity X component (dimensioned, m/s) -# Velocity Y component (dimensioned, m/s) -# Velocity Z component (dimensioned, m/s) -# Velocity Magnitude (dimensioned, m/s) -# Pressure (dimensioned, Pa) -CommonFieldNames = Literal[ - "Cp", - "Cpt", - "gradW", - "kOmega", - "Mach", - "mut", - "mutRatio", - "nuHat", - "primitiveVars", - "qcriterion", - "residualNavierStokes", - "residualTransition", - "residualTurbulence", - "s", - "solutionNavierStokes", - "solutionTransition", - "solutionTurbulence", - "T", - "velocity", - "velocity_x", - "velocity_y", - "velocity_z", - "velocity_magnitude", - "pressure", - "vorticity", - "vorticityMagnitude", - "vorticity_x", - "vorticity_y", - "vorticity_z", - "wallDistance", - "numericalDissipationFactor", - "residualHeatSolver", - "VelocityRelative", - "lowMachPreconditionerSensor", - # Include dimensioned fields here too - "velocity_m_per_s", - "velocity_x_m_per_s", - "velocity_y_m_per_s", - "velocity_z_m_per_s", - "velocity_magnitude_m_per_s", - "pressure_pa", -] - -# Skin friction coefficient vector -# Magnitude of CfVec -# Non-dimensional heat flux -# Wall normals -# Spalart-Allmaras variable -# Non-dimensional wall distance -# Wall function metrics -# Surface heat transfer coefficient (static temperature as reference) -# Surface heat transfer coefficient (total temperature as reference) -# Wall shear stress magnitude (non-dimensional) -# Wall shear stress magnitude (dimensioned, Pa) -SurfaceFieldNames = Literal[ - CommonFieldNames, - "CfVec", - "Cf", - "heatFlux", - "nodeNormals", - "nodeForcesPerUnitArea", - "yPlus", - "wallFunctionMetric", - "heatTransferCoefficientStaticTemperature", - "heatTransferCoefficientTotalTemperature", - "wall_shear_stress_magnitude", - "wall_shear_stress_magnitude_pa", -] - -# BET Metrics -# BET Metrics per Disk -# Linear residual of Navier-Stokes solver -# Linear residual of turbulence solver -# Linear residual of transition solver -# Hybrid RANS-LES output for Spalart-Allmaras solver -# Hybrid RANS-LES output for kOmegaSST solver -# Local CFL number -VolumeFieldNames = Literal[ - CommonFieldNames, - "betMetrics", - "betMetricsPerDisk", - "linearResidualNavierStokes", - "linearResidualTurbulence", - "linearResidualTransition", - "SpalartAllmaras_hybridModel", - "kOmegaSST_hybridModel", - "localCFL", -] - -SliceFieldNames = VolumeFieldNames - -# BET Metrics -# BET Metrics per Disk -VolumeProbeFieldNames = Literal[ - CommonFieldNames, - "betMetrics", - "betMetricsPerDisk", -] - -# Pressure -# Density -# Mach number -# Q criterion -# Entropy -# Temperature -# Coefficient of pressure -# Total pressure coefficient -# Turbulent viscosity -# Spalart-Almaras variable -# Vorticity magnitude -IsoSurfaceFieldNames = Literal[ - "Mach", - "qcriterion", - "s", - "T", - "Cp", - "Cpt", - "mut", - "nuHat", - "vorticityMagnitude", - "vorticity_x", - "vorticity_y", - "vorticity_z", - "velocity_magnitude", - "velocity_x", - "velocity_y", - "velocity_z", -] - -AllFieldNames = Literal[CommonFieldNames, SurfaceFieldNames, VolumeFieldNames, IsoSurfaceFieldNames] - -InvalidOutputFieldsForLiquid = Literal[ - "residualNavierStokes", - "residualTransition", - "residualTurbulence", - "solutionNavierStokes", - "T", - "Mach", - "linearResidualNavierStokes", - "linearResidualTurbulence", - "linearResidualTransition", - "SpalartAllmaras_DDES", - "kOmegaSST_DDES", - "heatFlux", - "heatTransferCoefficientStaticTemperature", - "heatTransferCoefficientTotalTemperature", -] - -ForceOutputCoefficientNames = Literal[ - _CL, +from flow360_schema.models.simulation.outputs.output_fields import ( _CD, + _CD_PER_STRIP, + _CL, + _CUMULATIVE_CD_CURVE, + _HEAT_FLUX, + _NORMAL_DIRECTION, + _X, + _Y, + PREDEFINED_UDF_EXPRESSIONS, + AllFieldNames, + CommonFieldNames, + ForceOutputCoefficientNames, + InvalidOutputFieldsForLiquid, + IsoSurfaceFieldNames, + SliceFieldNames, + SurfaceFieldNames, + VolumeFieldNames, + VolumeProbeFieldNames, _CFx, + _CFx_CUMULATIVE, + _CFx_PER_SPAN, _CFy, + _CFy_CUMULATIVE, + _CFy_PER_SPAN, _CFz, + _CFz_CUMULATIVE, + _CFz_PER_SPAN, _CMx, + _CMx_CUMULATIVE, + _CMx_PER_SPAN, _CMy, + _CMy_CUMULATIVE, + _CMy_PER_SPAN, _CMz, - _CL_PRESSURE, - _CD_PRESSURE, - _CFx_PRESSURE, - _CFy_PRESSURE, - _CFz_PRESSURE, - _CMx_PRESSURE, - _CMy_PRESSURE, - _CMz_PRESSURE, - _CL_SKIN_FRICTION, - _CD_SKIN_FRICTION, - _CFx_SKIN_FRICTION, - _CFy_SKIN_FRICTION, - _CFz_SKIN_FRICTION, - _CMx_SKIN_FRICTION, - _CMy_SKIN_FRICTION, - _CMz_SKIN_FRICTION, -] -# pylint: disable=no-member -_FIELD_UNIT_MAPPING = { - # Standard non-dimensioned fields - (unit, unit_system) - "*": (None, "flow360"), - # Dimensioned fields - (unit quantity, unit_system) - "velocity_m_per_s": (u.m / u.s, "SI"), - "velocity_magnitude_m_per_s": (u.m / u.s, "SI"), - "velocity_x_m_per_s": (u.m / u.s, "SI"), - "velocity_y_m_per_s": (u.m / u.s, "SI"), - "velocity_z_m_per_s": (u.m / u.s, "SI"), - "pressure_pa": (u.Pa, "SI"), - "wall_shear_stress_magnitude_pa": (u.Pa, "SI"), -} - -_FIELD_IS_SCALAR_MAPPING = { - "Cp": True, - "Cpt": True, - "gradW": False, - "kOmega": False, - "Mach": True, - "mut": True, - "mutRatio": True, - "nuHat": True, - "primitiveVars": False, - "qcriterion": True, - "residualNavierStokes": False, - "residualTransition": False, - "residualTurbulence": False, - "s": True, - "solutionNavierStokes": False, - "solutionTransition": False, - "solutionTurbulence": False, - "T": True, - "velocity": False, - "velocity_x": True, - "velocity_y": True, - "velocity_z": True, - "velocity_magnitude": True, - "pressure": True, - "vorticity": False, - "vorticityMagnitude": True, - "vorticity_x": True, - "vorticity_y": True, - "vorticity_z": True, - "wallDistance": True, - "numericalDissipationFactor": True, - "residualHeatSolver": False, - "VelocityRelative": False, - "lowMachPreconditionerSensor": True, - # Include dimensioned fields here too - "velocity_m_per_s": False, - "velocity_x_m_per_s": True, - "velocity_y_m_per_s": True, - "velocity_z_m_per_s": True, - "velocity_magnitude_m_per_s": True, - "pressure_pa": True, - # Surface fields - "CfVec": False, - "Cf": True, - "heatFlux": True, - "nodeNormals": False, - "nodeForcesPerUnitArea": False, - "yPlus": True, - "wallFunctionMetric": False, - "heatTransferCoefficientStaticTemperature": True, - "heatTransferCoefficientTotalTemperature": True, - "wall_shear_stress_magnitude": True, - "wall_shear_stress_magnitude_pa": True, - # Volume fields - "betMetrics": False, - "betMetricsPerDisk": False, - "linearResidualNavierStokes": False, - "linearResidualTurbulence": False, - "linearResidualTransition": False, - "SpalartAllmaras_hybridModel": False, - "kOmegaSST_hybridModel": False, - "localCFL": True, -} - - -def get_unit_for_field(field_name: str): - """ - Get the physical unit for a given field name. - - Parameters: - ----------- - field_name : str - The field name to get the unit for - - Returns: - -------- - Tuple[Optional[Union[str, unyt.Unit]], str] - A tuple containing (unit, unit_system) where: - - unit: None for non-dimensioned fields, unyt.Unit for dimensioned fields - - unit_system: "flow360" for non-dimensioned fields, "SI" for dimensioned fields - """ - if field_name in _FIELD_UNIT_MAPPING: - return _FIELD_UNIT_MAPPING[field_name] - - return _FIELD_UNIT_MAPPING["*"] - - -FIELD_TYPE_3DVECTOR = "3dvector" -FIELD_TYPE_SCALAR = "scalar" - -_FIELD_TYPE_INFO = { - "velocity_": { - "type": FIELD_TYPE_3DVECTOR, - }, - "velocity_magnitude": { - "type": FIELD_TYPE_SCALAR, - }, - "velocity_x": { - "type": FIELD_TYPE_SCALAR, - }, - "velocity_y": { - "type": FIELD_TYPE_SCALAR, - }, - "velocity_z": { - "type": FIELD_TYPE_SCALAR, - }, - "pressure": { - "type": FIELD_TYPE_SCALAR, - }, - "vorticity_x": { - "type": FIELD_TYPE_SCALAR, - }, - "vorticity_y": { - "type": FIELD_TYPE_SCALAR, - }, - "vorticity_z": { - "type": FIELD_TYPE_SCALAR, - }, -} - -# Predefined UDF expressions -PREDEFINED_UDF_EXPRESSIONS = { - "velocity_": "velocity_[0] = primitiveVars[1] * velocityScale;" - + "velocity_[1] = primitiveVars[2] * velocityScale;" - + "velocity_[2] = primitiveVars[3] * velocityScale;", - "velocity_magnitude": "double velocity[3];" - + "velocity[0] = primitiveVars[1];" - + "velocity[1] = primitiveVars[2];" - + "velocity[2] = primitiveVars[3];" - + "velocity_magnitude = magnitude(velocity) * velocityScale;", - "velocity_x": "velocity_x = primitiveVars[1] * velocityScale;", - "velocity_y": "velocity_y = primitiveVars[2] * velocityScale;", - "velocity_z": "velocity_z = primitiveVars[3] * velocityScale;", - "pressure_": "double gamma = 1.4;pressure_ = (usingLiquidAsMaterial) ? " - + "(primitiveVars[4] - 1.0 / gamma) * (velocityScale * velocityScale) : primitiveVars[4];", - "wall_shear_stress_magnitude": "wall_shear_stress_magnitude = " - + "magnitude(wallShearStress) * (velocityScale * velocityScale);", - "vorticity_x": "vorticity_x = (gradPrimitive[3][1] - gradPrimitive[2][2]) * velocityScale;", - "vorticity_y": "vorticity_y = (gradPrimitive[1][2] - gradPrimitive[3][0]) * velocityScale;", - "vorticity_z": "vorticity_z = (gradPrimitive[2][0] - gradPrimitive[1][1]) * velocityScale;", -} - - -def _apply_vector_conversion( - *, base_udf_expression: str, base_field: str, field_name: str, conversion_factor: float -): - """Apply conversion for vector fields""" - factor = 1.0 / conversion_factor - return ( - f"double {base_field}[3];" - f"{base_udf_expression}" - f"{field_name}[0] = {base_field}[0] * {factor};" - f"{field_name}[1] = {base_field}[1] * {factor};" - f"{field_name}[2] = {base_field}[2] * {factor};" - ) - - -def _apply_scalar_conversion( - *, base_udf_expression: str, base_field: str, field_name: str, conversion_factor: float -): - """Apply conversion for scalar fields""" - factor = 1.0 / conversion_factor - return ( - f"double {base_field};" f"{base_udf_expression}" f"{field_name} = {base_field} * {factor};" - ) - - -def generate_predefined_udf(field_name, params): - """ - Generate UserDefinedField expression for a dimensioned field. - - Parameters: - ----------- - field_name : str - Field name (e.g., 'velocity', 'velocity_m_per_s', 'pressure_pa', 'wall_shear_stress_magnitude_pa') - params : SimulationParams - The simulation parameters object for unit conversion - - Returns: - -------- - str or None - The expression for the UserDefinedField, or None if no matching base expression is found. - """ - valid_field_names = get_field_values(AllFieldNames) - if field_name not in valid_field_names: - return None - - matching_keys = [key for key in PREDEFINED_UDF_EXPRESSIONS if field_name.startswith(key)] - if not matching_keys: - return None - - # Longer keys take precedence (e.g., "velocity_x" over "velocity") - base_field = max(matching_keys, key=len) - base_expr = PREDEFINED_UDF_EXPRESSIONS[base_field] - - unit, _ = get_unit_for_field(field_name) - - if unit is None: - return base_expr - - coefficient, _ = compute_udf_dimensionalization_factor( - params=params, - requested_unit=unit, - using_liquid_op=isinstance(params.operating_condition, LiquidOperatingCondition), - ) - conversion_factor = 1.0 / coefficient - - field_info = _FIELD_TYPE_INFO.get(base_field, {"type": FIELD_TYPE_SCALAR}) - field_type = field_info["type"] - - if field_type == FIELD_TYPE_3DVECTOR: - return _apply_vector_conversion( - base_udf_expression=base_expr, - base_field=base_field, - field_name=field_name, - conversion_factor=conversion_factor, - ) - return _apply_scalar_conversion( - base_udf_expression=base_expr, - base_field=base_field, - field_name=field_name, - conversion_factor=conversion_factor, - ) - - -def _get_field_values(field_type, names): - for arg in get_args(field_type): - if get_origin(arg) is Literal: - _get_field_values(arg, names) - elif isinstance(arg, str): - names += [arg] - - -def get_field_values(field_type) -> List[str]: - """Retrieve field names from a nested literal type as list of strings""" - values = [] - _get_field_values(field_type, values) - return values - - -def append_component_to_output_fields(output_fields: List[str]) -> List[str]: - """ - If "velocity" or "vorticity" is in the list, append their respective magnitude in output - - Parameters: - ----------- - output_fields : List[str] - The list of output fields to modify. - - Returns: - -------- - List[str] - The modified list of output fields with the component appended. - """ - output_fields_with_component = [] - for field in output_fields: - output_fields_with_component.append(field) - if field == "velocity" and "velocity_magnitude" not in output_fields: - output_fields_with_component.append("velocity_magnitude") - if field == "vorticity" and "vorticityMagnitude" not in output_fields: - output_fields_with_component.append("vorticityMagnitude") - return output_fields_with_component - - -# In the C++ solver, "primitiveVars" expands to DataArrays named "rho", "velocity", and "p". -# "pressure" and "velocity" individually produce DataArrays with the same names ("p" and -# "velocity"). Having both creates duplicate DataArray names in VTK output, which causes -# ParaView to fail when loading a subset of fields. -_FIELDS_SUBSUMED_BY_PRIMITIVE_VARS = {"pressure", "velocity"} - - -def remove_fields_subsumed_by_primitive_vars(output_fields: List[str]) -> List[str]: - """ - Remove output fields that are already included as sub-fields of ``primitiveVars``. - - Must be called after :func:`append_component_to_output_fields` so that auto-appended - fields like ``velocity_magnitude`` are already in the list before ``velocity`` is removed. - - Parameters: - ----------- - output_fields : List[str] - The list of output fields to deduplicate. + _CMz_CUMULATIVE, + _CMz_PER_SPAN, + append_component_to_output_fields, + generate_predefined_udf, + get_field_values, + get_unit_for_field, + remove_fields_subsumed_by_primitive_vars, +) - Returns: - -------- - List[str] - The deduplicated list with ``pressure`` and ``velocity`` removed when - ``primitiveVars`` is present. - """ - if "primitiveVars" not in output_fields: - return output_fields - return [f for f in output_fields if f not in _FIELDS_SUBSUMED_BY_PRIMITIVE_VARS] +__all__ = [ + "AllFieldNames", + "CommonFieldNames", + "ForceOutputCoefficientNames", + "InvalidOutputFieldsForLiquid", + "IsoSurfaceFieldNames", + "PREDEFINED_UDF_EXPRESSIONS", + "SliceFieldNames", + "SurfaceFieldNames", + "VolumeFieldNames", + "VolumeProbeFieldNames", + "_CD", + "_CD_PER_STRIP", + "_CFx", + "_CFx_CUMULATIVE", + "_CFx_PER_SPAN", + "_CFy", + "_CFy_CUMULATIVE", + "_CFy_PER_SPAN", + "_CFz", + "_CFz_CUMULATIVE", + "_CFz_PER_SPAN", + "_CL", + "_CMx", + "_CMx_CUMULATIVE", + "_CMx_PER_SPAN", + "_CMy", + "_CMy_CUMULATIVE", + "_CMy_PER_SPAN", + "_CMz", + "_CMz_CUMULATIVE", + "_CMz_PER_SPAN", + "_CUMULATIVE_CD_CURVE", + "_HEAT_FLUX", + "_NORMAL_DIRECTION", + "_X", + "_Y", + "append_component_to_output_fields", + "generate_predefined_udf", + "get_field_values", + "get_unit_for_field", + "remove_fields_subsumed_by_primitive_vars", +] diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index b5332f1ac..f7ef7e26b 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -1,1875 +1,4 @@ -"""Mostly the same as Flow360Param counterparts. -Caveats: -1. Check if we support non-average and average output specified at the same time in solver. -(Yes but they share the same output_fields) -2. We do not support multiple output frequencies/file format for the same type of output. -""" +"""Relay import for simulation output models.""" -# pylint: disable=too-many-lines -import re -from typing import Annotated, ClassVar, List, Literal, Optional, Tuple, Union, get_args - -import pydantic as pd -from flow360_schema.framework.expression import ( - Expression, - UserVariable, - solver_variable_to_user_variable, -) -from flow360_schema.framework.physical_dimensions import Length, Time -from typing_extensions import deprecated - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.framework.entity_utils import generate_uuid -from flow360.component.simulation.framework.expressions import StringExpression -from flow360.component.simulation.framework.param_utils import serialize_model_obj_to_id -from flow360.component.simulation.framework.unique_list import UniqueItemList -from flow360.component.simulation.models.surface_models import Wall -from flow360.component.simulation.models.volume_models import ( - ActuatorDisk, - BETDisk, - PorousMedium, -) -from flow360.component.simulation.outputs.output_entities import ( - Isosurface, - Point, - PointArray, - PointArray2D, - Slice, -) -from flow360.component.simulation.outputs.output_fields import ( - AllFieldNames, - CommonFieldNames, - ForceOutputCoefficientNames, - InvalidOutputFieldsForLiquid, - SliceFieldNames, - SurfaceFieldNames, - VolumeFieldNames, - VolumeProbeFieldNames, - get_field_values, -) -from flow360.component.simulation.outputs.render_config import ( - Camera, - Environment, - FieldMaterial, - Lighting, - PBRMaterial, - SceneTransform, -) -from flow360.component.simulation.primitives import ( - GhostCircularPlane, - GhostSphere, - GhostSurface, - ImportedSurface, - MirroredSurface, - Surface, - WindTunnelGhostSurface, -) -from flow360.component.simulation.validation.validation_context import ( - ALL, - CASE, - ParamsValidationInfo, - TimeSteppingType, - add_validation_warning, - contextual_field_validator, - contextual_model_validator, - get_validation_levels, -) -from flow360.component.simulation.validation.validation_utils import ( - get_surface_full_name, - validate_entity_list_surface_existence, - validate_improper_surface_field_usage_for_imported_surface, -) -from flow360.component.types import Axis - -# Invalid characters for Linux filenames: / is path separator, \0 is null terminator -_INVALID_FILENAME_CHARS_PATTERN = re.compile(r"[/\0]") - - -def _validate_filename_string(value: str) -> str: - """ - Validate that a string is a valid Linux filename. - - Args: - value: The string to validate - - Returns: - The validated string - - Raises: - ValueError: If the string is not a valid filename - - Notes: - - Disallows forward slash (/) - path separator - - Disallows null byte (\\0) - - Disallows empty strings - - Disallows reserved names (. and ..) - """ - if not value: - raise ValueError("Filename cannot be empty") - - # Check for reserved names - if value in (".", ".."): - raise ValueError(f"Filename cannot be '{value}' (reserved name)") - - # Check for invalid characters - invalid_chars = _INVALID_FILENAME_CHARS_PATTERN.findall(value) - if invalid_chars: - # Show unique invalid characters found - unique_chars = sorted(set(invalid_chars)) - char_display = ", ".join(repr(c) for c in unique_chars) - raise ValueError( - f"Filename contains invalid characters: {char_display}. " - f"Linux filenames cannot contain '/' or null bytes. " - f"Got: '{value}'" - ) - - return value - - -# Type alias for a validated filename string -FileNameString = Annotated[ - str, - pd.AfterValidator(_validate_filename_string), -] - - -ForceOutputModelType = Annotated[ - Union[Wall, BETDisk, ActuatorDisk, PorousMedium], - pd.Field(discriminator="type"), -] - - -@deprecated("The `UserDefinedField` class is deprecated! Use `UserVariable` instead.") -class UserDefinedField(Flow360BaseModel): - """ - - Defines additional fields that can be used as output variables. - - - Example - ------- - - - Compute :code:`Mach` using :class:`UserDefinedField` - (Showcase use, already supported in :ref:`Output Fields `): - - >>> fl.UserDefinedField( - ... name="Mach_UDF", - ... expression="double Mach = sqrt(primitiveVars[1] * primitiveVars[1] + " - ... + "primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3])" - ... + " / sqrt(gamma * primitiveVars[4] / primitiveVars[0]);", - ... ) - - - - Compute :code:`PressureForce` using :class:`UserDefinedField`: - - >>> fl.UserDefinedField( - ... name="PressureForce", - ... expression="double prel = primitiveVars[4] - pressureFreestream; " - ... + "PressureForce[0] = prel * nodeNormals[0]; " - ... + "PressureForce[1] = prel * nodeNormals[1]; " - ... + "PressureForce[2] = prel * nodeNormals[2];", - ... ) - - ==== - - """ - - type_name: Literal["UserDefinedField"] = pd.Field("UserDefinedField", frozen=True) - name: str = pd.Field(description="The name of the output field.") - expression: StringExpression = pd.Field( - description="The mathematical expression for the field." - ) - - @pd.field_validator("name", mode="after") - @classmethod - def _check_redefined_user_defined_fields(cls, value): - current_levels = get_validation_levels() if get_validation_levels() else [] - if all(level not in current_levels for level in (ALL, CASE)): - return value - defined_field_names = get_field_values(AllFieldNames) - if value in defined_field_names: - raise ValueError( - f"User defined field variable name: {value} conflicts with pre-defined field names." - " Please consider renaming this user defined field variable." - ) - return value - - @contextual_model_validator(mode="after") - def _deprecation_warning(self): - add_validation_warning( - "The `UserDefinedField` class is deprecated! Please use `UserVariable` instead " - "which provides the same functionality but with better interface." - ) - return self - - -class MovingStatistic(Flow360BaseModel): - """ - - :class:`MovingStatistic` class for moving statistic settings in - :class:`ProbeOutput`, :class:`SurfaceProbeOutput`, - :class:`SurfaceIntegralOutput` and :class:`ForceOutput`. - - Notes - ----- - - The window size is defined by the number of data points recorded in the output. - - For steady simulations, the solver typically outputs a data point once every **10 pseudo steps**. - This means a :py:attr:`moving_window_size` = 10 would cover 100 pseudo steps. - Thus, the :py:attr:`start_step` value is automatically rounded up to - the nearest multiple of 10 for steady simulations. - - For unsteady simulations, the solver outputs a data point for **every physical step**. - A :py:attr:`moving_window_size` = 10 would cover 10 physical steps. - - When :py:attr:`method` is set to "standard_deviation", the standard deviation is computed as a - **sample standard deviation** normalized by :math:`n-1` (Bessel's correction), where :math:`n` - is the number of data points in the moving window. - - When :py:attr:`method` is set to "range", the difference between the maximum and minimum values of - the monitored field in the moving window is computed. - - Example - ------- - - Define a moving statistic to compute the standard deviation in a moving window of - 10 data points, with the initial 100 steps skipped. - - >>> fl.MovingStatistic( - ... moving_window_size=10, - ... method="standard_deviation", - ... start_step=100, - ... ) - - ==== - """ - - moving_window_size: pd.StrictInt = pd.Field( - 10, - ge=2, - description="The size of the moving window in data points over which the " - "statistic is calculated. Must be greater than or equal to 2.", - ) - method: Literal["mean", "min", "max", "standard_deviation", "range"] = pd.Field( - "mean", description="The statistical method to apply to the data within the moving window." - ) - start_step: pd.NonNegativeInt = pd.Field( - 0, - description="The number of steps (pseudo or physical) to skip at the beginning of the " - "simulation before the moving statistics calculation starts. For steady " - "simulations, this value is automatically rounded up to the nearest multiple of 10, " - "as the solver outputs data every 10 pseudo steps.", - ) - type_name: Literal["MovingStatistic"] = pd.Field("MovingStatistic", frozen=True) - - -class _OutputBase(Flow360BaseModel): - output_fields: UniqueItemList[str] = pd.Field() - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @pd.field_validator("output_fields", mode="after") - @classmethod - def _validate_improper_surface_field_usage(cls, value: UniqueItemList): - if any( - output_type in cls.__name__ - for output_type in [ - "SurfaceProbeOutput", - "SurfaceOutput", - "SurfaceSliceOutput", - "SurfaceIntegralOutput", - ] - ): - return value - for output_item in value.items: - if not isinstance(output_item, UserVariable) or not isinstance( - output_item.value, Expression - ): - continue - surface_solver_variable_names = output_item.value.solver_variable_names( - recursive=True, variable_type="Surface" - ) - if len(surface_solver_variable_names) > 0: - raise ValueError( - f"Variable `{output_item}` cannot be used in `{cls.__name__}` " - + "since it contains Surface solver variable(s): " - + f"{', '.join(sorted(surface_solver_variable_names))}.", - ) - return value - - @contextual_field_validator("output_fields", mode="after") - @classmethod - def _validate_non_liquid_output_fields( - cls, value: UniqueItemList, param_info: ParamsValidationInfo - ): - if param_info.using_liquid_as_material is False: - return value - for output_item in value.items: - if output_item in get_args(InvalidOutputFieldsForLiquid): - raise ValueError( - f"Output field {output_item} cannot be selected when using liquid as simulation material." - ) - return value - - @pd.field_validator("output_fields", mode="before") - @classmethod - def _convert_solver_variables_as_user_variables(cls, value): - # Handle both dict/list (deserialization) and UniqueItemList (python object) - # If input is a dict (from deserialization so no SolverVariable expected) - if isinstance(value, dict): - return value - # If input is a list (from Python mode) - if isinstance(value, list): - return [solver_variable_to_user_variable(item) for item in value] - # If input is a UniqueItemList (python object) - if hasattr(value, "items") and isinstance(value.items, list): - value.items = [solver_variable_to_user_variable(item) for item in value.items] - return value - return value - - -class _AnimationSettings(Flow360BaseModel): - """ - Controls how frequently the output files are generated. - """ - - frequency: Union[pd.PositiveInt, Literal[-1]] = pd.Field( - default=-1, - description="Frequency (in number of physical time steps) at which output is saved. " - + "-1 is at end of simulation. Important for child cases - this parameter refers to the " - + "**global** time step, which gets transferred from the parent case. Example: if the parent " - + "case finished at time_step=174, the child case will start from time_step=175. If " - + "frequency=100 (child case), the output will be saved at time steps 200 (25 time steps of " - + "the child simulation), 300 (125 time steps of the child simulation), etc. " - + "This setting is NOT applicable for steady cases.", - ) - frequency_offset: int = pd.Field( - default=0, - ge=0, - description="Offset (in number of physical time steps) at which output is started to be saved." - + " 0 is at beginning of simulation. Important for child cases - this parameter refers to the " - + "**global** time step, which gets transferred from the parent case (see `frequency` " - + "parameter for an example). Example: if an output has a frequency of 100 and a " - + "frequency_offset of 10, the output will be saved at **global** time step 10, 110, 210, " - + "etc. This setting is NOT applicable for steady cases.", - ) - - @contextual_field_validator("frequency", "frequency_offset", mode="after") - @classmethod - def disable_frequency_settings_in_steady_simulation( - cls, value, info: pd.ValidationInfo, param_info: ParamsValidationInfo - ): - """Disable frequency settings in a steady simulation""" - if param_info.time_stepping != TimeSteppingType.STEADY: - return value - # pylint: disable=unsubscriptable-object - if value != cls.model_fields[info.field_name].default: - raise ValueError( - f"Output {info.field_name} cannot be specified in a steady simulation." - ) - return value - - -class _AnimationAndFileFormatSettings(_AnimationSettings): - """ - Controls how frequently the output files are generated and the file format. - """ - - output_format: Literal["paraview", "tecplot", "both"] = pd.Field( - default="paraview", description=":code:`paraview`, :code:`tecplot` or :code:`both`." - ) - - -class SurfaceOutput(_AnimationAndFileFormatSettings, _OutputBase): - """ - - :class:`SurfaceOutput` class for surface output settings. - - Example - ------- - - - Define :class:`SurfaceOutput` on all surfaces of the geometry - using naming pattern :code:`"*"`. - - >>> fl.SurfaceOutput( - ... entities=[geometry['*']],, - ... output_format="paraview", - ... output_fields=["vorticity", "T"], - ... ) - - - Define :class:`SurfaceOutput` on the selected surfaces of the volume_mesh - using name pattern :code:`"fluid/inflow*"`. - - >>> fl.SurfaceOutput( - ... entities=[volume_mesh["fluid/inflow*"]],, - ... output_format="paraview", - ... output_fields=["vorticity", "T"], - ... ) - - ==== - """ - - # pylint: disable=fixme - # TODO: entities is None --> use all surfaces. This is not implemented yet. - - name: Optional[str] = pd.Field("Surface output", description="Name of the `SurfaceOutput`.") - entities: EntityList[ # pylint: disable=duplicate-code - Surface, - MirroredSurface, - GhostSurface, - WindTunnelGhostSurface, - GhostCircularPlane, - GhostSphere, - ImportedSurface, - ] = pd.Field( - alias="surfaces", - description="List of boundaries where output is generated.", - ) - write_single_file: bool = pd.Field( - default=False, - description="Enable writing all surface outputs into a single file instead of one file per surface." - + "This option currently only supports Tecplot output format." - + "Will choose the value of the last instance of this option of the same output type " - + "(:class:`SurfaceOutput` or :class:`TimeAverageSurfaceOutput`) in the output list.", - ) - output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." - ) - output_type: Literal["SurfaceOutput"] = pd.Field("SurfaceOutput", frozen=True) - - @contextual_field_validator("entities", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - return validate_entity_list_surface_existence(value, param_info) - - @contextual_model_validator(mode="after") - def validate_imported_surface_output_fields(self, param_info: ParamsValidationInfo): - """Validate output fields when using imported surfaces""" - expanded_entities = param_info.expand_entity_list(self.entities) - validate_improper_surface_field_usage_for_imported_surface( - expanded_entities, self.output_fields - ) - return self - - -class TimeAverageSurfaceOutput(SurfaceOutput): - """ - :class:`TimeAverageSurfaceOutput` class for time average surface output settings. - - Example - ------- - - Calculate the average value starting from the :math:`4^{th}` physical step. - The results are output every 10 physical step starting from the :math:`14^{th}` physical step - (14, 24, 34 etc.). - - >>> fl.TimeAverageSurfaceOutput( - ... output_format="paraview", - ... output_fields=["primitiveVars"], - ... entities=[ - ... volume_mesh["VOLUME/LEFT"], - ... volume_mesh["VOLUME/RIGHT"], - ... ], - ... start_step=4, - ... frequency=10, - ... frequency_offset=14, - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Time average surface output", description="Name of the `TimeAverageSurfaceOutput`." - ) - - start_step: Union[pd.NonNegativeInt, Literal[-1]] = pd.Field( - default=-1, - description="Physical time step to start calculating averaging. Important for child cases " - + "- this parameter refers to the **global** time step, which gets transferred from the " - + "parent case (see `frequency` parameter for an example).", - ) - output_type: Literal["TimeAverageSurfaceOutput"] = pd.Field( - "TimeAverageSurfaceOutput", frozen=True - ) - - -class VolumeOutput(_AnimationAndFileFormatSettings, _OutputBase): - """ - :class:`VolumeOutput` class for volume output settings. - - Example - ------- - - >>> fl.VolumeOutput( - ... output_format="paraview", - ... output_fields=["Mach", "vorticity", "T"], - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Volume output", description="Name of the `VolumeOutput`.") - output_fields: UniqueItemList[Union[VolumeFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to VolumeOutput`" - " and :class:`UserDefinedField`." - ) - output_type: Literal["VolumeOutput"] = pd.Field("VolumeOutput", frozen=True) - - -class TimeAverageVolumeOutput(VolumeOutput): - """ - :class:`TimeAverageVolumeOutput` class for time average volume output settings. - - Example - ------- - - Calculate the average value starting from the :math:`4^{th}` physical step. - The results are output every 10 physical step starting from the :math:`14^{th}` physical step - (14, 24, 34 etc.). - - >>> fl.TimeAverageVolumeOutput( - ... output_format="paraview", - ... output_fields=["primitiveVars"], - ... start_step=4, - ... frequency=10, - ... frequency_offset=14, - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Time average volume output", description="Name of the `TimeAverageVolumeOutput`." - ) - start_step: Union[pd.NonNegativeInt, Literal[-1]] = pd.Field( - default=-1, - description="Physical time step to start calculating averaging. Important for child cases " - + "- this parameter refers to the **global** time step, which gets transferred from the " - + "parent case (see `frequency` parameter for an example).", - ) - output_type: Literal["TimeAverageVolumeOutput"] = pd.Field( - "TimeAverageVolumeOutput", frozen=True - ) - - -class SliceOutput(_AnimationAndFileFormatSettings, _OutputBase): - """ - :class:`SliceOutput` class for slice output settings. - - Example - ------- - - >>> fl.SliceOutput( - ... slices=[ - ... fl.Slice( - ... name="Slice_1", - ... normal=(0, 1, 0), - ... origin=(0, 0.56, 0)*fl.u.m - ... ), - ... ], - ... output_format="paraview", - ... output_fields=["vorticity", "T"], - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("Slice output", description="Name of the `SliceOutput`.") - entities: EntityList[Slice] = pd.Field( - alias="slices", - description="List of output :class:`~flow360.Slice` entities.", - ) - output_fields: UniqueItemList[Union[SliceFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to SliceOutput`" - " and :class:`UserDefinedField`." - ) - output_type: Literal["SliceOutput"] = pd.Field("SliceOutput", frozen=True) - - -class TimeAverageSliceOutput(SliceOutput): - """ - - :class:`TimeAverageSliceOutput` class for time average slice output settings. - - Example - ------- - - Calculate the average value starting from the :math:`4^{th}` physical step. - The results are output every 10 physical step starting from the :math:`14^{th}` physical step - (14, 24, 34 etc.). - - >>> fl.TimeAverageSliceOutput( - ... entities=[ - ... fl.Slice(name="Slice_1", - ... origin=(0, 0, 0) * fl.u.m, - ... normal=(0, 0, 1), - ... ) - ... ], - ... output_fields=["s", "T"], - ... start_step=4, - ... frequency=10, - ... frequency_offset=14, - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Time average slice output", description="Name of the `TimeAverageSliceOutput`." - ) - start_step: Union[pd.NonNegativeInt, Literal[-1]] = pd.Field( - default=-1, - description="Physical time step to start calculating averaging. Important for child cases " - + "- this parameter refers to the **global** time step, which gets transferred from the " - + "parent case (see `frequency` parameter for an example).", - ) - output_type: Literal["TimeAverageSliceOutput"] = pd.Field("TimeAverageSliceOutput", frozen=True) - - -class IsosurfaceOutput(_AnimationAndFileFormatSettings, _OutputBase): - """ - - :class:`IsosurfaceOutput` class for isosurface output settings. - - Example - ------- - - Define the :class:`IsosurfaceOutput` of :code:`qcriterion` on two isosurfaces: - - - :code:`Isosurface_T_0.1` is the :class:`Isosurface` with its temperature equals - to 1.5 non-dimensional temperature; - - :code:`Isosurface_p_0.5` is the :class:`Isosurface` with its pressure equals - to 0.5 non-dimensional pressure. - - >>> fl.IsosurfaceOutput( - ... isosurfaces=[ - ... fl.Isosurface( - ... name="Isosurface_T_0.1", - ... iso_value=0.1, - ... field="T", - ... ), - ... fl.Isosurface( - ... name="Isosurface_p_0.5", - ... iso_value=0.5, - ... field="p", - ... ), - ... ], - ... output_fields=["qcriterion"], - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Isosurface output", description="Name of the `IsosurfaceOutput`." - ) - entities: UniqueItemList[Isosurface] = pd.Field( - alias="isosurfaces", - description="List of :class:`~flow360.Isosurface` entities.", - ) - output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including " - ":ref:`universal output variables` and :class:`UserDefinedField`." - ) - output_type: Literal["IsosurfaceOutput"] = pd.Field("IsosurfaceOutput", frozen=True) - - def preprocess( - self, - *, - params=None, - exclude: List[str] = None, - required_by: List[str] = None, - flow360_unit_system=None, - ) -> Flow360BaseModel: - exclude_isosurface_output = exclude + ["iso_value"] - return super().preprocess( - params=params, - exclude=exclude_isosurface_output, - required_by=required_by, - flow360_unit_system=flow360_unit_system, - ) - - -class TimeAverageIsosurfaceOutput(IsosurfaceOutput): - """ - - :class:`TimeAverageIsosurfaceOutput` class for isosurface output settings. - - Example - ------- - - Define the :class:`TimeAverageIsosurfaceOutput` of :code:`qcriterion` on two isosurfaces: - - - :code:`TimeAverageIsosurface_T_0.1` is the :class:`Isosurface` with its temperature equals - to 1.5 non-dimensional temperature; - - :code:`TimeAverageIsosurface_p_0.5` is the :class:`Isosurface` with its pressure equals - to 0.5 non-dimensional pressure. - - >>> fl.TimeAverageIsosurfaceOutput( - ... isosurfaces=[ - ... fl.Isosurface( - ... name="TimeAverageIsosurface_T_0.1", - ... iso_value=0.1, - ... field="T", - ... ), - ... fl.Isosurface( - ... name="TimeAverageIsosurface_p_0.5", - ... iso_value=0.5, - ... field="p", - ... ), - ... ], - ... output_fields=["qcriterion"], - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Time Average Isosurface output", description="Name of `TimeAverageIsosurfaceOutput`." - ) - start_step: Union[pd.NonNegativeInt, Literal[-1]] = pd.Field( - default=-1, - description="Physical time step to start calculating averaging. Important for child cases " - + "- this parameter refers to the **global** time step, which gets transferred from the " - + "parent case (see `frequency` parameter for an example).", - ) - output_type: Literal["TimeAverageIsosurfaceOutput"] = pd.Field( - "TimeAverageIsosurfaceOutput", frozen=True - ) - - -class SurfaceIntegralOutput(_OutputBase): - """ - - :class:`SurfaceIntegralOutput` class for surface integral output settings. - - Note - ---- - :class:`SurfaceIntegralOutput` can only be used with :class:`UserDefinedField`. - See :doc:`User Defined Postprocessing Tutorial ` - for more details about how to set up :class:`UserDefinedField`. - - Example - ------- - Define :class:`SurfaceIntegralOutput` of :code:`PressureForce`. - - >>> fl.SurfaceIntegralOutput( - ... name="surface_integral", - ... output_fields=["PressureForce"], - ... entities=[volume_mesh["wing1"], volume_mesh["wing2"]], - ... ) - - ==== - """ - - name: FileNameString = pd.Field( - "Surface integral output", - description="Name of integral. Must be a valid Linux filename (no slashes or null bytes).", - ) - entities: EntityList[ # pylint: disable=duplicate-code - Surface, - MirroredSurface, - GhostSurface, - WindTunnelGhostSurface, - GhostCircularPlane, - GhostSphere, - ImportedSurface, - ] = pd.Field( - alias="surfaces", - description="List of boundaries where the surface integral will be calculated.", - ) - output_fields: UniqueItemList[Union[str, UserVariable]] = pd.Field( - description="List of output variables, only the :class:`UserDefinedField` is allowed." - ) - moving_statistic: Optional[MovingStatistic] = pd.Field( - None, description="When specified, report moving statistics of the fields instead." - ) - output_type: Literal["SurfaceIntegralOutput"] = pd.Field("SurfaceIntegralOutput", frozen=True) - - @contextual_field_validator("entities", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - return validate_entity_list_surface_existence(value, param_info) - - @contextual_field_validator("entities", mode="after") - @classmethod - def allow_only_simulation_surfaces_or_imported_surfaces( - cls, value, param_info: ParamsValidationInfo - ): - """Support only simulation surfaces or imported surfaces in each SurfaceIntegralOutput""" - expanded = param_info.expand_entity_list(value) - has_imported = isinstance(expanded[0], ImportedSurface) - for entity in expanded[1:]: - if has_imported != isinstance(entity, ImportedSurface): - raise ValueError( - "Imported and simulation surfaces cannot be used together in the same SurfaceIntegralOutput." - " Please assign them to separate outputs." - ) - return value - - @contextual_model_validator(mode="after") - def validate_imported_surface_output_fields(self, param_info: ParamsValidationInfo): - """Validate output fields when using imported surfaces""" - expanded_entities = param_info.expand_entity_list(self.entities) - validate_improper_surface_field_usage_for_imported_surface( - expanded_entities, self.output_fields - ) - return self - - -class ForceOutput(_OutputBase): - """ - :class:`ForceOutput` class for setting total force output of specific surfaces. - - Example - ------- - - Define :class:`ForceOutput` to output total CL and CD on multiple wing surfaces and a BET disk. - - >>> wall = fl.Wall(name = 'wing', surfaces=[volume_mesh['1'], volume_mesh["wing2"]]) - >>> bet_disk = fl.BETDisk(...) - >>> fl.ForceOutput( - ... name="force_output", - ... models=[wall, bet_disk], - ... output_fields=["CL", "CD"] - ... ) - - ==== - """ - - name: str = pd.Field("Force output", description="Name of the force output.") - output_fields: UniqueItemList[ForceOutputCoefficientNames] = pd.Field( - description="List of force coefficients. Including CL, CD, CFx, CFy, CFz, CMx, CMy, CMz. " - "For surface forces, their SkinFriction/Pressure is also supported, such as CLSkinFriction and CLPressure." - ) - models: List[Union[ForceOutputModelType, str]] = pd.Field( - description="List of surface/volume models (or model ids) whose force contribution will be calculated.", - ) - moving_statistic: Optional[MovingStatistic] = pd.Field( - None, description="When specified, report moving statistics of the fields instead." - ) - output_type: Literal["ForceOutput"] = pd.Field("ForceOutput", frozen=True) - - @pd.field_validator("models", mode="after") - @classmethod - def _convert_model_obj_to_id(cls, value): - """Validate duplicate models and convert model object to id""" - model_ids = set() - for model in value: - model_id = ( - model if isinstance(model, str) else serialize_model_obj_to_id(model_obj=model) - ) - if model_id in model_ids: - raise ValueError("Duplicate models are not allowed in the same `ForceOutput`.") - model_ids.add(model_id) - return list(model_ids) - - @contextual_field_validator("models", mode="after", required_context=["physics_model_dict"]) - @classmethod - def _check_model_exist_in_model_list(cls, value, param_info: ParamsValidationInfo): - """Ensure all models exist in SimulationParams' model list.""" - for model_id in value: - model_obj = param_info.physics_model_dict.get(model_id) - if model_obj is None: - raise ValueError("The model does not exist in simulation params' models list.") - - return value - - @contextual_field_validator("models", mode="after", required_context=["physics_model_dict"]) - @classmethod - def _check_output_fields_with_volume_models_specified( - cls, value, info: pd.ValidationInfo, param_info: ParamsValidationInfo - ): - """Ensure the output field exists when volume models are specified.""" - - model_objs = [param_info.physics_model_dict.get(model_id) for model_id in value] - - if all(isinstance(model, Wall) for model in model_objs): - return value - output_fields = info.data.get("output_fields", None) - if all( - field in ["CL", "CD", "CFx", "CFy", "CFz", "CMx", "CMy", "CMz"] - for field in output_fields.items - ): - return value - raise ValueError( - "When ActuatorDisk/BETDisk/PorousMedium is specified, " - "only CL, CD, CFx, CFy, CFz, CMx, CMy, CMz can be set as output_fields." - ) - - -class RenderOutputGroup(Flow360BaseModel): - """ - - :class:`RenderOutputGroup` for defining a render output group - i.e. a set of - entities sharing a common material (display options) settings. - - Example - ------- - Define two :class:`RenderOutputGroup` objects, one assigning all boundaries of the - uploaded geometry to a flat metallic material, and another assigning a slice and an - isosurface to a material which will display a scalar field on the surface of the - entity. - - >>> fl.RenderOutputGroup( - ... surfaces=geometry["*"], - ... material=fl.PBRMaterial.metal(shine=0.8) - ... ), - ... fl.RenderOutputGroup( - ... slices=[ - ... fl.Slice(name="Example slice", normal=(0, 1, 0), origin=(0, 0, 0)) - ... ], - ... isosurfaces=[ - ... fl.Isosurface(name="Example isosurface", iso_value=0.1, field="T") - ... ], - ... material=fl.FieldMaterial.rainbow(field="T", min_value=0, max_value=1, alpha=0.4) - ... ) - ==== - - """ - - surfaces: Optional[EntityList[Surface, MirroredSurface]] = pd.Field( - None, description="List of of :class:`~flow360.Surface` entities." - ) - slices: Optional[EntityList[Slice]] = pd.Field( - None, description="List of of :class:`~flow360.Slice` entities." - ) - isosurfaces: Optional[UniqueItemList[Isosurface]] = pd.Field( - None, description="List of :class:`~flow360.Isosurface` entities." - ) - material: Union[PBRMaterial, FieldMaterial] = pd.Field( - description="Materials settings (color, surface field, roughness etc..) to be applied to the entire group" - ) - - @contextual_field_validator("surfaces", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - return validate_entity_list_surface_existence(value, param_info) - - @contextual_model_validator(mode="after") - def check_not_empty(self, param_info: ParamsValidationInfo): - """Verify the render group has at least one entity assigned to it""" - expanded_surfaces = ( - param_info.expand_entity_list(self.surfaces) if self.surfaces is not None else None - ) - if not expanded_surfaces and not self.slices and not self.isosurfaces: - raise ValueError( - "Render group should include at least one entity (surface, slice or isosurface)" - ) - return self - - -class RenderOutput(_AnimationSettings): - """ - - :class:`RenderOutput` class for backend rendered output settings. - - Example - ------- - - Define the :class:`RenderOutput` that outputs a basic image - boundaries and a Y-slice: - - >>> fl.RenderOutput( - ... name="Example render", - ... groups=[ - ... fl.RenderOutputGroup( - ... surfaces=geometry["*"], - ... material=fl.render.PBRMaterial.metal(shine=0.8) - ... ), - ... fl.RenderOutputGroup( - ... slices=[ - ... fl.Slice(name="Example slice", normal=(0, 1, 0), origin=(0, 0, 0)) - ... ], - ... material=fl.render.FieldMaterial.rainbow(field="T", min_value=0, max_value=1, alpha=0.4) - ... ) - ... ], - ... camera=fl.render.Camera.orthographic(scale=5, view=fl.Viewpoint.TOP + fl.Viewpoint.LEFT) - ... ) - ==== - """ - - name: str = pd.Field("Render output", description="Name of the `RenderOutput`.") - groups: List[RenderOutputGroup] = pd.Field([]) - camera: Camera = pd.Field(description="Camera settings", default_factory=Camera.orthographic) - lighting: Lighting = pd.Field(description="Lighting settings", default_factory=Lighting.default) - environment: Environment = pd.Field( - description="Environment settings", default_factory=Environment.simple - ) - transform: Optional[SceneTransform] = pd.Field( - None, description="Optional model transform to apply to all entities" - ) - output_type: Literal["RenderOutput"] = pd.Field("RenderOutput", frozen=True) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @pd.field_validator("groups", mode="after") - @classmethod - def check_has_output_groups(cls, value): - """Verify the render output has at least one group to render""" - if len(value) < 1: - raise ValueError("Render output requires at least one output group to be defined") - return value - - -class ProbeOutput(_OutputBase): - """ - :class:`ProbeOutput` class for setting output data probed at monitor points in the voulume of the domain. - Regardless of the motion of the mesh, the points retain their positions in the - global reference frame during the simulation. - - Example - ------- - - Define :class:`ProbeOutput` on multiple specific monitor points and monitor points along the line. - - - :code:`Point_1` and :code:`Point_2` are two specific points we want to monitor in this probe output group. - - :code:`Line_1` is from (1,0,0) * fl.u.m to (1.5,0,0) * fl.u.m and has 6 monitor points. - - :code:`Line_2` is from (-1,0,0) * fl.u.m to (-1.5,0,0) * fl.u.m and has 3 monitor points, - namely, (-1,0,0) * fl.u.m, (-1.25,0,0) * fl.u.m and (-1.5,0,0) * fl.u.m. - - >>> fl.ProbeOutput( - ... name="probe_group_points_and_lines", - ... entities=[ - ... fl.Point( - ... name="Point_1", - ... location=(0.0, 1.5, 0.0) * fl.u.m, - ... ), - ... fl.Point( - ... name="Point_2", - ... location=(0.0, -1.5, 0.0) * fl.u.m, - ... ), - ... fl.PointArray( - ... name="Line_1", - ... start=(1.0, 0.0, 0.0) * fl.u.m, - ... end=(1.5, 0.0, 0.0) * fl.u.m, - ... number_of_points=6, - ... ), - ... fl.PointArray( - ... name="Line_2", - ... start=(-1.0, 0.0, 0.0) * fl.u.m, - ... end=(-1.5, 0.0, 0.0) * fl.u.m, - ... number_of_points=3, - ... ), - ... ], - ... output_fields=["primitiveVars"], - ... ) - - ==== - """ - - name: str = pd.Field("Probe output", description="Name of the monitor group.") - entities: EntityList[Point, PointArray] = pd.Field( - alias="probe_points", - description="List of monitored :class:`~flow360.Point`/" - + ":class:`~flow360.PointArray` entities belonging to this " - + "monitor group. :class:`~flow360.PointArray` is used to " - + "define monitored points along a line.", - ) - output_fields: UniqueItemList[Union[VolumeProbeFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to VolumeOutput`" - " and :class:`UserDefinedField`." - ) - moving_statistic: Optional[MovingStatistic] = pd.Field( - None, description="When specified, report moving statistics of the fields instead." - ) - output_at_final_pseudo_step_only: bool = pd.Field( - False, - description="When True, the result is only written at the final pseudo step " - "of each physical step (or once at the end for steady simulations), " - "suppressing intermediate pseudo-step writes.", - ) - output_type: Literal["ProbeOutput"] = pd.Field("ProbeOutput", frozen=True) - - @contextual_model_validator(mode="after") - def _validate_final_pseudo_step_only_with_moving_statistic( - self, param_info: ParamsValidationInfo - ): - """Reject ``output_at_final_pseudo_step_only=True`` combined with ``moving_statistic`` - in steady simulations (only one data point would be produced).""" - if ( - self.output_at_final_pseudo_step_only - and self.moving_statistic is not None - and param_info.time_stepping == TimeSteppingType.STEADY - ): - raise ValueError( - "`output_at_final_pseudo_step_only=True` with `moving_statistic` is not allowed " - "for steady simulations (only one data point would be produced)." - ) - return self - - -class SurfaceProbeOutput(_OutputBase): - """ - :class:`SurfaceProbeOutput` class for setting surface output data probed at monitor points. - The specified monitor point will be projected to the :py:attr:`~SurfaceProbeOutput.target_surfaces` - closest to the point. The probed results on the projected point will be dumped. - The projection is executed at the start of the simulation. If the surface that the point was - projected to is moving (mesh motion), the point moves with it (it remains stationary - in the reference frame of the target surface). - - Example - ------- - - Define :class:`SurfaceProbeOutput` on the :code:`geometry["wall"]` surface - with multiple specific monitor points and monitor points along the line. - - - :code:`Point_1` and :code:`Point_2` are two specific points we want to monitor in this probe output group. - - :code:`Line_surface` is from (1,0,0) * fl.u.m to (1,0,-10) * fl.u.m and has 11 monitor points, - including both starting and end points. - - >>> fl.SurfaceProbeOutput( - ... name="surface_probe_group_points", - ... entities=[ - ... fl.Point( - ... name="Point_1", - ... location=(0.0, 1.5, 0.0) * fl.u.m, - ... ), - ... fl.Point( - ... name="Point_2", - ... location=(0.0, -1.5, 0.0) * fl.u.m, - ... ), - ... fl.PointArray( - ... name="Line_surface", - ... start=(1.0, 0.0, 0.0) * fl.u.m, - ... end=(1.0, 0.0, -10.0) * fl.u.m, - ... number_of_points=11, - ... ), - ... ], - ... target_surfaces=[ - ... geometry["wall"], - ... ], - ... output_fields=["heatFlux", "T"], - ... ) - - ==== - """ - - name: str = pd.Field("Surface probe output", description="Name of the surface monitor group.") - entities: EntityList[Point, PointArray] = pd.Field( - alias="probe_points", - description="List of monitored :class:`~flow360.Point`/" - + ":class:`~flow360.PointArray` entities belonging to this " - + "surface monitor group. :class:`~flow360.PointArray` " - + "is used to define monitored points along a line.", - ) - # Maybe add preprocess for this and by default add all Surfaces? - target_surfaces: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field( - description="List of :class:`~flow360.component.simulation.primitives.Surface` " - + "entities belonging to this monitor group." - ) - - output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." - ) - moving_statistic: Optional[MovingStatistic] = pd.Field( - None, description="When specified, report moving statistics of the fields instead." - ) - output_type: Literal["SurfaceProbeOutput"] = pd.Field("SurfaceProbeOutput", frozen=True) - - @contextual_field_validator("target_surfaces", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - return validate_entity_list_surface_existence(value, param_info) - - -class SurfaceSliceOutput(_AnimationAndFileFormatSettings, _OutputBase): - """ - Surface slice settings. - """ - - name: str = pd.Field("Surface slice output", description="Name of the `SurfaceSliceOutput`.") - entities: EntityList[Slice] = pd.Field( - alias="slices", description="List of :class:`Slice` entities." - ) - # Maybe add preprocess for this and by default add all Surfaces? - target_surfaces: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field( - description="List of :class:`Surface` entities on which the slice will cut through." - ) - - output_format: Literal["paraview"] = pd.Field(default="paraview") - - output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." - ) - output_type: Literal["SurfaceSliceOutput"] = pd.Field("SurfaceSliceOutput", frozen=True) - - @contextual_field_validator("target_surfaces", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - return validate_entity_list_surface_existence(value, param_info) - - -class TimeAverageProbeOutput(ProbeOutput): - """ - :class:`TimeAverageProbeOutput` class for time average probe monitor output settings. - Regardless of the motion of the mesh, the points retain their positions in the - global reference frame during the simulation. - - Example - ------- - - - Calculate the average value on multiple monitor points starting from the :math:`4^{th}` physical step. - The results are output every 10 physical step starting from the :math:`14^{th}` physical step - (14, 24, 34 etc.). - - >>> fl.TimeAverageProbeOutput( - ... name="time_average_probe_group_points", - ... entities=[ - ... fl.Point( - ... name="Point_1", - ... location=(0.0, 1.5, 0.0) * fl.u.m, - ... ), - ... fl.Point( - ... name="Point_2", - ... location=(0.0, -1.5, 0.0) * fl.u.m, - ... ), - ... ], - ... output_fields=["primitiveVars", "Mach"], - ... start_step=4, - ... frequency=10, - ... frequency_offset=14, - ... ) - - - Calculate the average value on multiple monitor points starting from the :math:`4^{th}` physical step. - The results are output every 10 physical step starting from the :math:`14^{th}` physical step - (14, 24, 34 etc.). - - - :code:`Line_1` is from (1,0,0) * fl.u.m to (1.5,0,0) * fl.u.m and has 6 monitor points. - - :code:`Line_2` is from (-1,0,0) * fl.u.m to (-1.5,0,0) * fl.u.m and has 3 monitor points, - namely, (-1,0,0) * fl.u.m, (-1.25,0,0) * fl.u.m and (-1.5,0,0) * fl.u.m. - - >>> fl.TimeAverageProbeOutput( - ... name="time_average_probe_group_points", - ... entities=[ - ... fl.PointArray( - ... name="Line_1", - ... start=(1.0, 0.0, 0.0) * fl.u.m, - ... end=(1.5, 0.0, 0.0) * fl.u.m, - ... number_of_points=6, - ... ), - ... fl.PointArray( - ... name="Line_2", - ... start=(-1.0, 0.0, 0.0) * fl.u.m, - ... end=(-1.5, 0.0, 0.0) * fl.u.m, - ... number_of_points=3, - ... ), - ... ], - ... output_fields=["primitiveVars", "Mach"], - ... start_step=4, - ... frequency=10, - ... frequency_offset=14, - ... ) - - ==== - - """ - - name: Optional[str] = pd.Field( - "Time average probe output", description="Name of the `TimeAverageProbeOutput`." - ) - # pylint: disable=abstract-method - frequency: Union[pd.PositiveInt, Literal[-1]] = pd.Field( - default=1, - description="Frequency (in number of physical time steps) at which output is saved. " - + "-1 is at end of simulation. Important for child cases - this parameter refers to the " - + "**global** time step, which gets transferred from the parent case. Example: if the parent " - + "case finished at time_step=174, the child case will start from time_step=175. If " - + "frequency=100 (child case), the output will be saved at time steps 200 (25 time steps of " - + "the child simulation), 300 (125 time steps of the child simulation), etc. " - + "This setting is NOT applicable for steady cases.", - ) - frequency_offset: int = pd.Field( - default=0, - ge=0, - description="Offset (in number of physical time steps) at which output is started to be saved." - + " 0 is at beginning of simulation. Important for child cases - this parameter refers to the " - + "**global** time step, which gets transferred from the parent case (see `frequency` " - + "parameter for an example). Example: if an output has a frequency of 100 and a " - + "frequency_offset of 10, the output will be saved at **global** time step 10, 110, 210, " - + "etc. This setting is NOT applicable for steady cases.", - ) - start_step: Union[pd.NonNegativeInt, Literal[-1]] = pd.Field( - default=-1, - description="Physical time step to start calculating averaging. Important for child cases " - + "- this parameter refers to the **global** time step, which gets transferred from the " - + "parent case (see `frequency` parameter for an example).", - ) - output_type: Literal["TimeAverageProbeOutput"] = pd.Field("TimeAverageProbeOutput", frozen=True) - - -class TimeAverageSurfaceProbeOutput(SurfaceProbeOutput): - """ - :class:`TimeAverageSurfaceProbeOutput` class for time average surface probe monitor output settings. - The specified monitor point will be projected to the :py:attr:`~TimeAverageSurfaceProbeOutput.target_surfaces` - closest to the point. The probed results on the projected point will be dumped. - The projection is executed at the start of the simulation. If the surface that the point was - projected to is moving (mesh motion), the point moves with it (it remains stationary - in the reference frame of the target surface). - - Example - ------- - - - Calculate the average value on the :code:`geometry["surface1"]` and :code:`geometry["surface2"]` surfaces - with multiple monitor points. The average is computed starting from the :math:`4^{th}` physical step. - The results are output every 10 physical step starting from the :math:`14^{th}` physical step - (14, 24, 34 etc.). - - >>> TimeAverageSurfaceProbeOutput( - ... name="time_average_surface_probe_group_points", - ... entities=[ - ... Point(name="Point_1", location=[1, 1.02, 0.03] * fl.u.cm), - ... Point(name="Point_2", location=[2, 1.01, 0.03] * fl.u.m), - ... Point(name="Point_3", location=[3, 1.02, 0.03] * fl.u.m), - ... ], - ... target_surfaces=[ - ... Surface(name="Surface_1", geometry["surface1"]), - ... Surface(name="Surface_2", geometry["surface2"]), - ... ], - ... output_fields=["Mach", "primitiveVars", "yPlus"], - ... start_step=4, - ... frequency=10, - ... frequency_offset=14, - ... ) - - - Calculate the average value on the :code:`geometry["surface1"]` and :code:`geometry["surface2"]` surfaces - with multiple monitor lines. The average is computed starting from the :math:`4^{th}` physical step. - The results are output every 10 physical step starting from the :math:`14^{th}` physical step - (14, 24, 34 etc.). - - - :code:`Line_1` is from (1,0,0) * fl.u.m to (1.5,0,0) * fl.u.m and has 6 monitor points. - - :code:`Line_2` is from (-1,0,0) * fl.u.m to (-1.5,0,0) * fl.u.m and has 3 monitor points, - namely, (-1,0,0) * fl.u.m, (-1.25,0,0) * fl.u.m and (-1.5,0,0) * fl.u.m. - - >>> TimeAverageSurfaceProbeOutput( - ... name="time_average_surface_probe_group_points", - ... entities=[ - ... fl.PointArray( - ... name="Line_1", - ... start=(1.0, 0.0, 0.0) * fl.u.m, - ... end=(1.5, 0.0, 0.0) * fl.u.m, - ... number_of_points=6, - ... ), - ... fl.PointArray( - ... name="Line_2", - ... start=(-1.0, 0.0, 0.0) * fl.u.m, - ... end=(-1.5, 0.0, 0.0) * fl.u.m, - ... number_of_points=3, - ... ), - ... ], - ... target_surfaces=[ - ... Surface(name="Surface_1", geometry["surface1"]), - ... Surface(name="Surface_2", geometry["surface2"]), - ... ], - ... output_fields=["Mach", "primitiveVars", "yPlus"], - ... start_step=4, - ... frequency=10, - ... frequency_offset=14, - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Time average surface probe output", - description="Name of the `TimeAverageSurfaceProbeOutput`.", - ) - # pylint: disable=abstract-method - frequency: Union[pd.PositiveInt, Literal[-1]] = pd.Field( - default=1, - description="Frequency (in number of physical time steps) at which output is saved. " - + "-1 is at end of simulation. Important for child cases - this parameter refers to the " - + "**global** time step, which gets transferred from the parent case. Example: if the parent " - + "case finished at time_step=174, the child case will start from time_step=175. If " - + "frequency=100 (child case), the output will be saved at time steps 200 (25 time steps of " - + "the child simulation), 300 (125 time steps of the child simulation), etc. " - + "This setting is NOT applicable for steady cases.", - ) - frequency_offset: int = pd.Field( - default=0, - ge=0, - description="Offset (in number of physical time steps) at which output is started to be saved." - + " 0 is at beginning of simulation. Important for child cases - this parameter refers to the " - + "**global** time step, which gets transferred from the parent case (see `frequency` " - + "parameter for an example). Example: if an output has a frequency of 100 and a " - + "frequency_offset of 10, the output will be saved at **global** time step 10, 110, 210, " - + "etc. This setting is NOT applicable for steady cases.", - ) - start_step: Union[pd.NonNegativeInt, Literal[-1]] = pd.Field( - default=-1, - description="Physical time step to start calculating averaging. Important for child cases " - + "- this parameter refers to the **global** time step, which gets transferred from the " - + "parent case (see `frequency` parameter for an example).", - ) - output_type: Literal["TimeAverageSurfaceProbeOutput"] = pd.Field( - "TimeAverageSurfaceProbeOutput", frozen=True - ) - - -class Observer(Flow360BaseModel): - """ - :class:`Observer` class for setting up the :py:attr:`AeroAcousticOutput.observers`. - - Example - ------- - - >>> fl.Observer(position=[1, 2, 3] * fl.u.m, group_name="1") - - ==== - """ - - # pylint: disable=no-member - position: Length.Vector3 = pd.Field( - description="Position at which time history of acoustic pressure signal " - + "is stored in aeroacoustic output file. The observer position can be outside the simulation domain, " - + "but cannot be on or inside the solid surfaces of the simulation domain." - ) - group_name: str = pd.Field( - description="Name of the group to which the observer will be assigned " - + "for postprocessing purposes in Flow360 web client." - ) - private_attribute_expand: Optional[bool] = pd.Field(None) - - -class AeroAcousticOutput(Flow360BaseModel): - """ - - :class:`AeroAcousticOutput` class for aeroacoustic output settings. - - Example - ------- - - >>> fl.AeroAcousticOutput( - ... observers=[ - ... fl.Observer(position=[1.0, 0.0, 1.75] * fl.u.m, group_name="1"), - ... fl.Observer(position=[0.2, 0.3, 1.725] * fl.u.m, group_name="1"), - ... ], - ... ) - - If using permeable surfaces: - - >>> fl.AeroAcousticOutput( - ... observers=[ - ... fl.Observer(position=[1.0, 0.0, 1.75] * fl.u.m, group_name="1"), - ... fl.Observer(position=[0.2, 0.3, 1.725] * fl.u.m, group_name="1"), - ... ], - ... patch_type="permeable", - ... permeable_surfaces=[volume_mesh["inner/interface*"]] - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Aeroacoustic output", description="Name of the `AeroAcousticOutput`." - ) - patch_type: Literal["solid", "permeable"] = pd.Field( - default="solid", - description="Type of aeroacoustic simulation to " - + "perform. `solid` uses solid walls to compute the " - + "aeroacoustic solution. `permeable` uses surfaces " - + "embedded in the volumetric domain as aeroacoustic solver " - + "input.", - ) - permeable_surfaces: Optional[ - EntityList[Surface, GhostSurface, GhostCircularPlane, GhostSphere, WindTunnelGhostSurface] - ] = pd.Field( - None, description="List of permeable surfaces. Left empty if `patch_type` is solid" - ) - # pylint: disable=no-member - observers: List[Observer] = pd.Field( - description="A List of :class:`Observer` objects specifying each observer's position and group name." - ) - write_per_surface_output: bool = pd.Field( - False, - description="Enable writing of aeroacoustic results on a per-surface basis, " - + "in addition to results for all wall surfaces combined.", - ) - observer_time_step_size: Optional[Time.PositiveFloat64] = pd.Field( - None, - description="Time step size for aeroacoustic output. " - + "A valid value is smaller than or equal to the time step size of the CFD simulation. " - + "Defaults to time step size of CFD.", - ) - aeroacoustic_solver_start_time: Time.NonNegativeFloat64 = pd.Field( - 0 * u.s, - description="Time to start the aeroacoustic solver. " - + "Signals emitted after this start time at the source surfaces are included in the output.", - ) - force_clean_start: bool = pd.Field( - False, description="Force a clean start when an aeroacoustic case is forked." - ) - - output_type: Literal["AeroAcousticOutput"] = pd.Field("AeroAcousticOutput", frozen=True) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - @pd.field_validator("observers", mode="after") - @classmethod - def validate_observer_has_same_unit(cls, input_value): - """ - All observer location should have the same length unit. - This is because UI has single toggle for all coordinates. - """ - unit_set = {} - for observer in input_value: - unit_set[observer.position.units] = None - if len(unit_set.keys()) > 1: - raise ValueError( - "All observer locations should have the same unit." - f" But now it has both `{list(unit_set.keys())[0]}` and `{list(unit_set.keys())[1]}`." - ) - return input_value - - @pd.model_validator(mode="after") - def check_consistent_patch_type_and_permeable_surfaces(self): - """Check if permeable_surfaces is None when patch_type is solid.""" - if self.patch_type == "solid" and self.permeable_surfaces is not None: - raise ValueError("`permeable_surfaces` cannot be specified when `patch_type` is solid.") - if self.patch_type == "permeable" and self.permeable_surfaces is None: - raise ValueError("`permeable_surfaces` cannot be empty when `patch_type` is permeable.") - - return self - - @contextual_field_validator("permeable_surfaces", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - return validate_entity_list_surface_existence(value, param_info) - - -class StreamlineOutput(_OutputBase): - """ - :class:`StreamlineOutput` class for calculating streamlines. - Stramtraces are computed upwind and downwind, and may originate from a single point, - from a line, or from points evenly distributed across a parallelogram. - - Example - ------- - - Define a :class:`StreamlineOutput` with streaptraces originating from points, - lines (:class:`~flow360.PointArray`), and parallelograms (:class:`~flow360.PointArray2D`). - - - :code:`Point_1` and :code:`Point_2` are two specific points we want to track the streamlines. - - :code:`Line_streamline` is from (1,0,0) * fl.u.m to (1,0,-10) * fl.u.m and has 11 points, - including both starting and end points. - - :code:`Parallelogram_streamline` is a parallelogram in 3D space with an origin at (1.0, 0.0, 0.0), a u-axis - orientation of (0, 2.0, 2.0) with 11 points in the u direction, and a v-axis orientation of (0, 1.0, 0) - with 20 points along the v direction. - - >>> fl.StreamlineOutput( - ... entities=[ - ... fl.Point( - ... name="Point_1", - ... location=(0.0, 1.5, 0.0) * fl.u.m, - ... ), - ... fl.Point( - ... name="Point_2", - ... location=(0.0, -1.5, 0.0) * fl.u.m, - ... ), - ... fl.PointArray( - ... name="Line_streamline", - ... start=(1.0, 0.0, 0.0) * fl.u.m, - ... end=(1.0, 0.0, -10.0) * fl.u.m, - ... number_of_points=11, - ... ), - ... fl.PointArray2D( - ... name="Parallelogram_streamline", - ... origin=(1.0, 0.0, 0.0) * fl.u.m, - ... u_axis_vector=(0, 2.0, 2.0) * fl.u.m, - ... v_axis_vector=(0, 1.0, 0) * fl.u.m, - ... u_number_of_points=11, - ... v_number_of_points=20 - ... ) - ... ], - ... output_fields = [fl.solution.pressure, fl.solution.velocity], - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Streamline output", description="Name of the `StreamlineOutput`." - ) - entities: EntityList[Point, PointArray, PointArray2D] = pd.Field( - alias="streamline_points", - description="List of monitored :class:`~flow360.Point`/" - + ":class:`~flow360.PointArray`/:class:`~flow360.PointArray2D` " - + "entities belonging to this " - + "streamline group. :class:`~flow360.PointArray` " - + "is used to define streamline originating along a line. " - + ":class:`~flow360.PointArray2D` " - + "is used to define streamline originating from a parallelogram.", - ) - output_fields: Optional[UniqueItemList[UserVariable]] = pd.Field( - [], - description="List of output variables. Vector-valued fields will be colored by their magnitude.", - ) - output_type: Literal["StreamlineOutput"] = pd.Field("StreamlineOutput", frozen=True) - private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) - - -class TimeAverageStreamlineOutput(StreamlineOutput): - """ - :class:`StreamlineOutput` class for calculating time-averaged streamlines. - Stramtraces are computed upwind and downwind, and may originate from a single point, - from a line, or from points evenly distributed across a parallelogram. - - Example - ------- - - Define a :class:`TimeAverageStreamlineOutput` with streaptraces originating from points, - lines (:class:`~flow360.PointArray`), and parallelograms (:class:`~flow360.PointArray2D`). - - - :code:`Point_1` and :code:`Point_2` are two specific points we want to track the streamlines. - - :code:`Line_streamline` is from (1,0,0) * fl.u.m to (1,0,-10) * fl.u.m and has 11 points, - including both starting and end points. - - :code:`Parallelogram_streamline` is a parallelogram in 3D space with an origin at (1.0, 0.0, 0.0), a u-axis - orientation of (0, 2.0, 2.0) with 11 points in the u direction, and a v-axis orientation of (0, 1.0, 0) - with 20 points along the v direction. - - >>> fl.TimeAverageStreamlineOutput( - ... entities=[ - ... fl.Point( - ... name="Point_1", - ... location=(0.0, 1.5, 0.0) * fl.u.m, - ... ), - ... fl.Point( - ... name="Point_2", - ... location=(0.0, -1.5, 0.0) * fl.u.m, - ... ), - ... fl.PointArray( - ... name="Line_streamline", - ... start=(1.0, 0.0, 0.0) * fl.u.m, - ... end=(1.0, 0.0, -10.0) * fl.u.m, - ... number_of_points=11, - ... ), - ... fl.PointArray2D( - ... name="Parallelogram_streamline", - ... origin=(1.0, 0.0, 0.0) * fl.u.m, - ... u_axis_vector=(0, 2.0, 2.0) * fl.u.m, - ... v_axis_vector=(0, 1.0, 0) * fl.u.m, - ... u_number_of_points=11, - ... v_number_of_points=20 - ... ) - ... ] - ... ) - - ==== - """ - - name: Optional[str] = pd.Field( - "Time-average Streamline output", description="Name of the `TimeAverageStreamlineOutput`." - ) - - start_step: Union[pd.NonNegativeInt, Literal[-1]] = pd.Field( - default=-1, - description="Physical time step to start calculating averaging. Important for child cases " - + "- this parameter refers to the **global** time step, which gets transferred from the " - + "parent case (see `frequency` parameter for an example).", - ) - - output_type: Literal["TimeAverageStreamlineOutput"] = pd.Field( - "TimeAverageStreamlineOutput", frozen=True - ) - - -class ForceDistributionOutput(Flow360BaseModel): - """ - :class:`ForceDistributionOutput` class for customized force and moment distribution output. - Axis-aligned components are output for force and moment coefficients at the end of the simulation. - - Example - ------- - - Basic usage with default settings (all wall surfaces): - - >>> fl.ForceDistributionOutput( - ... name="spanwise", - ... distribution_direction=[0.1, 0.9, 0.0], - ... ) - - Specifying specific surfaces to include in the force integration (useful for automotive cases - to exclude road/floor surfaces): - - >>> fl.ForceDistributionOutput( - ... name="vehicle_x_distribution", - ... distribution_direction=[1.0, 0.0, 0.0], - ... entities=[volume_mesh["vehicle_body"], volume_mesh["wheels"]], - ... number_of_segments=500, - ... ) - - ==== - """ - - _RESERVED_NAMES: ClassVar[Tuple[str, ...]] = ("X_slicing", "Y_slicing") - - name: str = pd.Field(description="Name of the `ForceDistributionOutput`.") - distribution_direction: Axis = pd.Field( - description="Direction of the force distribution output." - ) - distribution_type: Literal["incremental", "cumulative"] = pd.Field( - "incremental", description="Type of the distribution." - ) - entities: Optional[EntityList[Surface, MirroredSurface]] = pd.Field( - None, - alias="surfaces", - description="List of surfaces to include in the force integration. " - "If not specified, all wall surfaces are included. " - "This is useful for automotive cases to exclude road/floor surfaces.", - ) - number_of_segments: pd.PositiveInt = pd.Field( - 300, - description="Number of segments (bins) to use along the distribution direction. " - "Default is 300 segments. " - "Increasing this value provides higher resolution in the force distribution plot.", - ) - output_type: Literal["ForceDistributionOutput"] = pd.Field( - "ForceDistributionOutput", frozen=True - ) - - @contextual_field_validator("entities", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - return validate_entity_list_surface_existence(value, param_info) - - @contextual_model_validator(mode="after") - def ensure_surfaces_have_wall_bc(self, param_info: ParamsValidationInfo): - """Ensure all specified surfaces have Wall boundary conditions assigned.""" - if self.entities is None: - return self - - # Skip validation if physics_model_dict is not yet available - if param_info.physics_model_dict is None: - return self - - # Collect all surfaces that have Wall boundary conditions - wall_surface_names = set() - for model in param_info.physics_model_dict.values(): - if isinstance(model, Wall) and model.entities is not None: - expanded_entities = param_info.expand_entity_list(model.entities) - for entity in expanded_entities: - wall_surface_names.add(get_surface_full_name(entity, "Wall BC")) - - # Check that all specified surfaces have Wall BC - expanded_entities = param_info.expand_entity_list(self.entities) - non_wall_surfaces = [] - for entity in expanded_entities: - full_name = get_surface_full_name(entity, "force distribution output") - if full_name not in wall_surface_names: - non_wall_surfaces.append(full_name) - - if non_wall_surfaces: - raise ValueError( - f"The following surfaces do not have Wall boundary conditions assigned: " - f"{non_wall_surfaces}. Force distribution output can only be computed on " - f"surfaces with Wall boundary conditions." - ) - - return self - - @pd.field_validator("name", mode="after") - @classmethod - def _check_reserved_name(cls, name: str) -> str: - """Reject names reserved for built-in slicing force distributions (X_slicing, Y_slicing).""" - if name in cls._RESERVED_NAMES: - raise ValueError( - f"'{name}' is a reserved name and cannot be used for ForceDistributionOutput. " - f"Reserved names: {cls._RESERVED_NAMES}" - ) - return name - - -class TimeAverageForceDistributionOutput(ForceDistributionOutput): - """ - :class:`TimeAverageForceDistributionOutput` class for time-averaged customized force and moment distribution output. - Axis-aligned components are output for force and moment coefficients at the end of the simulation. - - Example - ------- - - Calculate the average value starting from the :math:`4^{th}` physical step. - - >>> fl.TimeAverageForceDistributionOutput( - ... name="spanwise", - ... distribution_direction=[0.1, 0.9, 0.0], - ... start_step=4, - ... ) - - Specifying specific surfaces to include in the force integration (useful for automotive cases - to exclude road/floor surfaces): - - >>> fl.TimeAverageForceDistributionOutput( - ... name="vehicle_x_distribution", - ... distribution_direction=[1.0, 0.0, 0.0], - ... entities=[volume_mesh["vehicle_body"], volume_mesh["wheels"]], - ... number_of_segments=500, - ... start_step=100, - ... ) - - ==== - """ - - name: str = pd.Field( - "Time average force distribution output", - description="Name of the `TimeAverageForceDistributionOutput`.", - ) - start_step: Union[pd.NonNegativeInt, Literal[-1]] = pd.Field( - default=-1, - description="Physical time step to start calculating averaging. Important for child cases " - + "- this parameter refers to the **global** time step, which gets transferred from the " - + "parent case (see `frequency` parameter for an example).", - ) - output_type: Literal["TimeAverageForceDistributionOutput"] = pd.Field( - "TimeAverageForceDistributionOutput", frozen=True - ) - - -OutputTypes = Annotated[ - Union[ - SurfaceOutput, - TimeAverageSurfaceOutput, - VolumeOutput, - TimeAverageVolumeOutput, - SliceOutput, - TimeAverageSliceOutput, - IsosurfaceOutput, - TimeAverageIsosurfaceOutput, - SurfaceIntegralOutput, - ProbeOutput, - SurfaceProbeOutput, - SurfaceSliceOutput, - TimeAverageProbeOutput, - TimeAverageSurfaceProbeOutput, - AeroAcousticOutput, - StreamlineOutput, - TimeAverageStreamlineOutput, - ForceDistributionOutput, - TimeAverageForceDistributionOutput, - ForceOutput, - RenderOutput, - ], - pd.Field(discriminator="output_type"), -] - -TimeAverageOutputTypes = ( - TimeAverageSurfaceOutput, - TimeAverageVolumeOutput, - TimeAverageSliceOutput, - TimeAverageIsosurfaceOutput, - TimeAverageProbeOutput, - TimeAverageSurfaceProbeOutput, - TimeAverageStreamlineOutput, - TimeAverageForceDistributionOutput, -) - -MonitorOutputType = Annotated[ - Union[ForceOutput, SurfaceIntegralOutput, ProbeOutput, SurfaceProbeOutput], - pd.Field(discriminator="output_type"), -] +# pylint: disable=wildcard-import,unused-wildcard-import +from flow360_schema.models.simulation.outputs.outputs import * diff --git a/flow360/component/simulation/outputs/render_config.py b/flow360/component/simulation/outputs/render_config.py index 48390d412..71c7793c5 100644 --- a/flow360/component/simulation/outputs/render_config.py +++ b/flow360/component/simulation/outputs/render_config.py @@ -1,790 +1,27 @@ -"""Defines render types for setting up a RenderOutput object""" - -import abc -import colorsys -from enum import Enum -from typing import List, Literal, Optional, Union - -import pydantic as pd -from flow360_schema.framework.expression import ( - Expression, - UnytQuantity, - UserVariable, - get_input_value_dimensions, - get_input_value_length, - solver_variable_to_user_variable, +"""Relay import for render configuration models.""" + +# pylint: disable=unused-import + +from flow360_schema.models.simulation.outputs.render_config import ( + AmbientLight, + AnimatedView, + BackgroundBase, + Camera, + Color, + DirectionalLight, + Environment, + FieldMaterial, + Int8, + Keyframe, + Lighting, + MaterialBase, + OrthographicProjection, + PBRMaterial, + PerspectiveProjection, + SceneTransform, + SkyboxBackground, + SkyboxTexture, + SolidBackground, + StaticView, + Viewpoint, ) -from flow360_schema.framework.expression.utils import is_runtime_expression -from flow360_schema.framework.physical_dimensions import Angle, Length, Time - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.outputs.output_fields import CommonFieldNames -from flow360.component.simulation.user_code.core.types import ( - ValueOrExpression, - infer_units_by_unit_system, - is_variable_with_unit_system_as_units, -) -from flow360.component.types import Axis, Color, Vector - - -class StaticView(Flow360BaseModel): - """ - :class:`StaticView` defines a fixed camera in the scene with a position, target, and up-direction. - - Example - ------- - Define a simple static camera positioned at (1, 1, 1) looking at the origin: - - >>> cam = StaticView( - ... position=(1, 1, 1), - ... target=(0, 0, 0), - ... up=(0, 0, 1), - ... ) - """ - - type_name: Literal["StaticView"] = pd.Field("StaticView", frozen=True) - # pylint: disable=no-member - position: Length.Vector3 = pd.Field(description="Position of the camera in the scene") - # pylint: disable=no-member - target: Length.Vector3 = pd.Field(description="Target point of the camera") - up: Optional[Vector] = pd.Field( - default=(0, 0, 1), description="Up vector, if not specified assume Z+" - ) - - -class Keyframe(Flow360BaseModel): - """ - :class:`Keyframe` represents a timestamped camera pose for animated rendering. - - Example - ------- - >>> Keyframe( - ... time=0.5, - ... view=StaticView(position=(2, 0, 1), target=(0, 0, 0)) - ... ) - """ - - type_name: Literal["Keyframe"] = pd.Field("Keyframe", frozen=True) - time: Time.Float64 = pd.Field( - 0, ge=0, description="Timestamp at which the keyframe should be reached" - ) - view: StaticView = pd.Field(description="Camera parameters at this keyframe") - - -class AnimatedView(Flow360BaseModel): - """ - :class:`AnimatedView` defines a sequence of camera keyframes to create motion. - - Example - ------- - >>> AnimatedView( - ... keyframes=[ - ... Keyframe(time=0, view=StaticView(position=(2,0,0), target=(0,0,0))), - ... Keyframe(time=1, view=StaticView(position=(0,2,0), target=(0,0,0))), - ... ] - ... ) - """ - - type_name: Literal["AnimatedView"] = pd.Field("AnimatedView", frozen=True) - keyframes: List[Keyframe] = pd.Field( - [], description="List of keyframes between which the animated camera interpolates" - ) - - @pd.field_validator("keyframes", mode="after") - @classmethod - def check_has_keyframes_and_sort(cls, value): - """Check if the view has any keyframes assigned, the - first frame is at time 0 and the frames are sorted""" - if len(value) < 1: - raise ValueError("Animated camera requires at least one keyframe to be defined") - - value = sorted(value, key=lambda v: v.time) - - if value[0].time != 0: - raise ValueError( - "The first keyframe needs to be defined at time = 0 (the starting camera position)" - ) - - -class OrthographicProjection(Flow360BaseModel): - """ - :class:`OrthographicProjection` defines an orthographic camera projection model. - - Example - ------- - >>> OrthographicProjection( - ... width=1.0 * u.m, - ... near=0.01 * u.m, - ... far=10 * u.m, - ... ) - """ - - type_name: Literal["OrthographicProjection"] = pd.Field("OrthographicProjection", frozen=True) - width: Length.Float64 = pd.Field(description="Width of the camera frustum in world units") - near: Length.Float64 = pd.Field( - description="Near clipping plane in world units, pixels closer to the camera than this value are culled" - ) - far: Length.Float64 = pd.Field( - description="Far clipping plane in world units, pixels further from the camera than this value are culled" - ) - - -class PerspectiveProjection(Flow360BaseModel): - """ - :class:`PerspectiveProjection` defines a perspective camera projection. - - Example - ------- - >>> PerspectiveProjection( - ... fov=60 * u.deg, - ... near=0.01 * u.m, - ... far=50 * u.m, - ... ) - """ - - type_name: Literal["PerspectiveProjection"] = pd.Field("PerspectiveProjection", frozen=True) - fov: Angle.Float64 = pd.Field(description="Field of view of the camera (angle)") - near: Length.Float64 = pd.Field( - description="Near clipping plane in world units, pixels closer to the camera than this value are culled" - ) - far: Length.Float64 = pd.Field( - description="Far clipping plane in world units, pixels further from the camera than this value are culled" - ) - - -class Viewpoint(Enum): - """ - :class:`View` provides predefined canonical view directions. - - Example - ------- - >>> Viewpoint.FRONT.value - (-1, 0, 0) - - >>> Viewpoint.FRONT + Viewpoint.TOP - (-1, 0, 1) - """ - - FRONT = (-1, 0, 0) - BACK = (1, 0, 0) - RIGHT = (0, -1, 0) - LEFT = (0, 1, 0) - TOP = (0, 0, 1) - BOTTOM = (0, 0, -1) - - def __getitem__(self, idx): - return self.value[idx] - - def __add__(self, other): - if isinstance(other, Viewpoint): - b = other.value - elif isinstance(other, tuple): - b = other - else: - return NotImplemented - - a = self.value - return tuple(x + y for x, y in zip(a, b)) - - def __radd__(self, other): - if isinstance(other, tuple): - a = other - elif isinstance(other, Viewpoint): - a = other.value - else: - return NotImplemented - - b = self.value - return tuple(x + y for x, y in zip(a, b)) - - -class Camera(Flow360BaseModel): - """ - :class:`Camera` configures the camera and projection used for rendering. - - Example - ------- - >>> Camera.perspective( - ... x=1, y=1, z=1, scale=2, view=Viewpoint.FRONT - ... ) - """ - - type_name: Literal["Camera"] = pd.Field("Camera", frozen=True) - view: Union[StaticView, AnimatedView] = pd.Field( - discriminator="type_name", description="View settings (position, target)" - ) - projection: Union[OrthographicProjection, PerspectiveProjection] = pd.Field( - discriminator="type_name", - description="Projection settings (FOV / width, near/far clipping planes)", - ) - - @classmethod - def orthographic(cls, position=(0, 0, 0), scale=1, view=None): - """ - Create an orthographic camera configuration. - - Example - ------- - >>> Camera.orthographic( - ... position=(0, 0, 0), scale=1.5, view=Viewpoint.TOP - ... ) - """ - if view is None: - view = Viewpoint.FRONT + Viewpoint.RIGHT + Viewpoint.TOP - - up = (0, 0, 1) - - if view in (Viewpoint.TOP, Viewpoint.BOTTOM): - up = (0, 1, 0) - - x = position[0] - y = position[1] - z = position[2] - - return Camera( - view=StaticView( - # pylint: disable=no-member - position=(x + view[0] * scale, y + view[1] * scale, z + view[2] * scale) * u.m, - target=(x, y, z), - up=up, - ), - projection=OrthographicProjection( - # pylint: disable=no-member - width=scale * u.m, - near=0.01 * u.m, - far=50 * scale * u.m, - ), - ) - - @classmethod - def perspective(cls, position=(0, 0, 0), scale=1, view=None): - """ - Create a perspective camera configuration. - - Example - ------- - >>> Camera.perspective( - ... position=(0, 0, 0), scale=3, view=Viewpoint.LEFT - ... ) - """ - if view is None: - view = Viewpoint.FRONT + Viewpoint.RIGHT + Viewpoint.TOP - - up = (0, 0, 1) - - if view in (Viewpoint.TOP, Viewpoint.BOTTOM): - up = (0, 1, 0) - - x = position[0] - y = position[1] - z = position[2] - - return Camera( - view=StaticView( - # pylint: disable=no-member - position=(x + view[0] * scale, y + view[1] * scale, z + view[2] * scale) * u.m, - # pylint: disable=no-member - target=(x, y, z) * u.m, - up=up, - ), - # pylint: disable=no-member - projection=PerspectiveProjection(fov=60 * u.deg, near=0.01 * u.m, far=50 * scale * u.m), - ) - - -class AmbientLight(Flow360BaseModel): - """ - :class:`AmbientLight` controls uniform ambient lighting in the scene. - - Example - ------- - >>> AmbientLight( - ... intensity=0.4, - ... color=(255, 255, 255) - ... ) - """ - - type_name: Literal["AmbientLight"] = pd.Field("AmbientLight", frozen=True) - intensity: float = pd.Field(ge=0, description="Light intensity multiplier") - color: Color = pd.Field(description="Color of the ambient light") - - -class DirectionalLight(Flow360BaseModel): - """ - :class:`DirectionalLight` defines a directional light source with intensity and color. - - Example - ------- - >>> DirectionalLight( - ... intensity=1.0, - ... color=(255, 255, 255), - ... direction=(-1, -1, -1), - ... ) - """ - - type_name: Literal["DirectionalLight"] = pd.Field("DirectionalLight", frozen=True) - intensity: float = pd.Field(ge=0, description="Light intensity multiplier") - color: Color = pd.Field(description="Color of the directional light beam") - direction: Axis = pd.Field( - description="The direction of the light beam (all beams are parallel)" - ) - - -class Lighting(Flow360BaseModel): - """ - :class:`Lighting` defines ambient and directional lighting for rendering. - - Example - ------- - >>> Lighting.default() - """ - - type_name: Literal["Lighting"] = pd.Field("Lighting", frozen=True) - directional: DirectionalLight = pd.Field( - description="Directional component of the light (falls from a single direction)" - ) - ambient: Optional[AmbientLight] = pd.Field( - description="Ambient component of the light (applied from all directions equally)" - ) - - @classmethod - def default(cls, direction=(-1.0, -1.0, -1.0)): - """ - Returns the default lighting configuration. - - Example - ------- - >>> light = Lighting.default() - """ - return Lighting( - ambient=AmbientLight(intensity=0.4, color=(255, 255, 255)), - directional=DirectionalLight(intensity=1.0, color=(255, 255, 255), direction=direction), - ) - - -class BackgroundBase(Flow360BaseModel, metaclass=abc.ABCMeta): - """ - :class:`RenderBackgroundBase` is an abstract base class for all background types. - """ - - type_name: str = pd.Field(default="", frozen=True) - - -class SolidBackground(BackgroundBase): - """ - :class:`SolidBackground` defines a single-color background. - - Example - ------- - >>> SolidBackground(color=(200, 200, 255)) - """ - - type_name: Literal["SolidBackground"] = pd.Field("SolidBackground", frozen=True) - color: Color = pd.Field(description="Flat background color") - - -class SkyboxTexture(str, Enum): - """ - :class:`SkyboxTexture` specifies available skybox texture presets. - - Example - ------- - >>> SkyboxTexture.SKY.value - 'sky' - """ - - SKY = "sky" - GRADIENT = "gradient" - - -class SkyboxBackground(BackgroundBase): - """ - :class:`SkyboxBackground` defines a skybox background using a sky or gradient texture. - - Example - ------- - >>> SkyboxBackground(texture=SkyboxTexture.SKY) - """ - - type_name: Literal["SkyboxBackground"] = pd.Field("SkyboxBackground", frozen=True) - texture: SkyboxTexture = pd.Field( - SkyboxTexture.SKY, description="Cubemap texture applied to the skybox" - ) - - -class Environment(Flow360BaseModel): - """ - :class:`Environment` configures the background environment for rendering. - - Example - ------- - >>> Environment.simple() - """ - - type_name: Literal["Environment"] = pd.Field("Environment", frozen=True) - background: Union[SolidBackground, SkyboxBackground] = pd.Field( - discriminator="type_name", description="Background image, solid or textured" - ) - - @classmethod - def simple(cls): - """ - Create a render environment with a solid background. - - Example - ------- - >>> Environment.simple() - """ - return Environment(background=SolidBackground(color=(207, 226, 230))) - - @classmethod - def sky(cls): - """ - Create a render environment using a sky texture. - - Example - ------- - >>> Environment.sky() - """ - return Environment(background=SkyboxBackground(texture=SkyboxTexture.SKY)) - - @classmethod - def gradient(cls): - """ - Create a render environment using a gradient skybox. - - Example - ------- - >>> Environment.gradient() - """ - return Environment(background=SkyboxBackground(texture=SkyboxTexture.GRADIENT)) - - -class MaterialBase(Flow360BaseModel, metaclass=abc.ABCMeta): - """ - :class:`MaterialBase` is an abstract base class for material definitions used during rendering. - """ - - type_name: str = pd.Field("", frozen=True) - - -class PBRMaterial(MaterialBase): - """ - :class:`PBRMaterial` defines a physically based rendering (PBR) material. - - Example - ------- - >>> PBRMaterial(color=(180, 180, 255), roughness=0.3) - """ - - type_name: Literal["PBRMaterial"] = pd.Field("PBRMaterial", frozen=True) - color: Color = pd.Field( - default=[255, 255, 255], description="Basic diffuse color of the material (base color)" - ) - opacity: float = pd.Field( - default=1, - ge=0, - le=1, - description="The transparency of the material 1 is fully opaque, 0 is fully transparent", - ) - roughness: float = pd.Field( - default=0.5, - ge=0, - le=1, - description="Material roughness, controls the fuzziness of reflections", - ) - f0: Vector = pd.Field( - default=(0.03, 0.03, 0.03), - description="Fresnel reflection coeff. at 0 incidence angle, controls reflectivity", - ) - - @classmethod - def metal(cls, shine=0.5, opacity=1.0): - """ - Create a metallic PBR material. - - Example - ------- - >>> PBRMaterial.metal(shine=0.8) - """ - return PBRMaterial( - color=(255, 255, 255), opacity=opacity, roughness=1 - shine, f0=(0.56, 0.56, 0.56) - ) - - @classmethod - def plastic(cls, shine=0.5, opacity=1.0): - """ - Create a plastic PBR material. - - Example - ------- - >>> PBRMaterial.plastic(shine=0.2) - """ - return PBRMaterial( - color=(255, 255, 255), opacity=opacity, roughness=1 - shine, f0=(0.03, 0.03, 0.03) - ) - - -class FieldMaterial(MaterialBase): - """ - :class:`FieldMaterial` maps scalar field values to colors for flow visualization. - - Example - ------- - >>> FieldMaterial.rainbow(field="pressure", min_value=0, max_value=100000) - """ - - type_name: Literal["FieldMaterial"] = pd.Field("FieldMaterial", frozen=True) - opacity: float = pd.Field( - default=1, - ge=0, - le=1, - description="The transparency of the material 1 is fully opaque, 0 is fully transparent", - ) - output_field: Union[CommonFieldNames, str, UserVariable] = pd.Field( - description="Scalar field applied to the surface via the colormap" - ) - min: ValueOrExpression[Union[UnytQuantity, float]] = pd.Field( - description="Reference min value (in solver units) representing the left boundary of the colormap" - ) - max: ValueOrExpression[Union[UnytQuantity, float]] = pd.Field( - description="Reference max value (in solver units) representing the right boundary of the colormap" - ) - colormap: List[Color] = pd.Field( - description="List of key colors distributed evenly across the gradient, defines value to color mappings" - ) - - @pd.field_validator("output_field", mode="before") - @classmethod - def _preprocess_expression_and_solver_variable(cls, value): - if isinstance(value, Expression): - raise ValueError( - f"Expression ({value}) cannot be directly used as output field, " - "please define a UserVariable first." - ) - return solver_variable_to_user_variable(value) - - @pd.field_validator("output_field", mode="after") - @classmethod - def check_runtime_expression(cls, v): - """Ensure the output field is a runtime expression but not a constant value.""" - if isinstance(v, UserVariable): - if not isinstance(v.value, Expression): - raise ValueError(f"The output field ({v}) cannot be a constant value.") - try: - result = v.value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - except Exception as err: - raise ValueError( - f"expression evaluation failed for the output field: {err}" - ) from err - if not is_runtime_expression(result): - raise ValueError(f"The output field ({v}) cannot be a constant value.") - return v - - @pd.field_validator("min", "max", mode="before") - @classmethod - def _preprocess_range_with_unit_system(cls, value, info: pd.ValidationInfo): - if is_variable_with_unit_system_as_units(value): - return value - if info.data.get("field") is None: - # `field` validation failed. - raise ValueError( - "The output field is invalid and therefore unit inference is not possible." - ) - units = value["units"] - field = info.data["field"] - value_dimensions = get_input_value_dimensions(value=field) - value = infer_units_by_unit_system( - value=value, value_dimensions=value_dimensions, unit_system=units - ) - return value - - @pd.field_validator("min", "max", mode="after") - @classmethod - def check_range_single_value(cls, v): - """Ensure the min/max range is a single value.""" - if get_input_value_length(v) == 0: - return v - raise ValueError(f"The min/max range ({v}) must be a scalar.") - - @pd.field_validator("min", "max", mode="after") - @classmethod - def check_range_dimensions(cls, v, info: pd.ValidationInfo): - """Ensure the min/max range has the same dimensions as the field.""" - field = info.data.get("output_field", None) - if not isinstance(field, UserVariable): - return v - range_dimensions = get_input_value_dimensions(value=v) - if range_dimensions is None: - return v - field_dimensions = get_input_value_dimensions(value=field) - if field_dimensions != range_dimensions: - raise ValueError( - f"The min/max range ({v}, dimensions:{range_dimensions}) should have the same dimensions as " - f"the output field ({field}, dimensions: {field_dimensions})." - ) - return v - - @pd.field_validator("min", "max", mode="after") - @classmethod - def check_iso_value_for_string_field(cls, v, info: pd.ValidationInfo): - """Ensure the iso_value is float when string field is used.""" - - field = info.data.get("output_field", None) - if isinstance(field, str) and not isinstance(v, float): - raise ValueError( - f"The output field ({field}) specified by string " - "can only be used with a nondimensional min/max range." - ) - return v - - @classmethod - def rainbow(cls, field, min_value, max_value, opacity=1): - """ - Create a rainbow-style colormap for scalar fields. - - Example - ------- - >>> FieldMaterial.rainbow("velocity_magnitude") - """ - - def _rainbow_rgb(t): - h = (((((1 - t) * 2) / 3) % 1) + 1) % 1 - r, g, b = colorsys.hsv_to_rgb(h, 1.0, 1.0) - return (int(round(r * 255)), int(round(g * 255)), int(round(b * 255))) - - colormap = [] - for i in range(20): - t = i / (20 - 1) - colormap.append(_rainbow_rgb(t)) - - # Approximated from TS rainbowGradient sampling - return FieldMaterial( - opacity=opacity, output_field=field, min=min_value, max=max_value, colormap=colormap - ) - - @classmethod - def orizon(cls, field, min_value, max_value, opacity=1): - """ - Create an Orizon-style (blue–orange) colormap. - - Example - ------- - >>> FieldMaterial.orizon("temperature") - """ - - def _orizon_rgb(t): - h = 0.7 * t + 0.025 - r, g, b = colorsys.hsv_to_rgb(h % 1.0, 0.9, 1.0) - return (int(round(r * 255)), int(round(g * 255)), int(round(b * 255))) - - colormap = [] - for i in range(20): - t = i / (20 - 1) - colormap.append(_orizon_rgb(t)) - - # Approximated from TS orizonGradient sampling - return FieldMaterial( - opacity=opacity, output_field=field, min=min_value, max=max_value, colormap=colormap - ) - - @classmethod - def viridis(cls, field, min_value, max_value, opacity=1): - """ - Create a Viridis colormap. - - Example - ------- - >>> FieldMaterial.viridis("vorticity") - """ - return FieldMaterial( - opacity=opacity, - output_field=field, - min=min_value, - max=max_value, - colormap=[ - (68, 1, 84), - (65, 68, 135), - (42, 120, 142), - (34, 168, 132), - (122, 209, 81), - (253, 231, 37), - ], - ) - - @classmethod - def magma(cls, field, min_value, max_value, opacity=1): - """ - Create a Magma colormap. - - Example - ------- - >>> FieldMaterial.magma("density") - """ - return FieldMaterial( - opacity=opacity, - output_field=field, - min=min_value, - max=max_value, - colormap=[ - (0, 0, 4), - (86, 20, 125), - (192, 58, 118), - (253, 154, 106), - (252, 253, 191), - ], - ) - - @classmethod - def airflow(cls, field, min_value, max_value, opacity=1): - """ - Create an Airflow-style visualization colormap. - - Example - ------- - >>> FieldMaterial.airflow("pressure_coefficient") - """ - return FieldMaterial( - opacity=opacity, - output_field=field, - min=min_value, - max=max_value, - colormap=[ - (0, 100, 60), - (97, 178, 156), - (123, 189, 240), - (241, 241, 240), - (254, 216, 139), - (247, 139, 141), - (252, 122, 76), - (176, 90, 249), - ], - ) - - -class SceneTransform(Flow360BaseModel): - """ - :class:`SceneTransform` applies translation, rotation, and scaling to renderable objects. - - This may be - - Example - ------- - >>> SceneTransform( - ... translation=(1, 0, 0) * u.m, - ... rotation=(0, 0, 90) * u.deg, - ... scale=(1, 2, 1), - ... ) - """ - - type_name: Literal["SceneTransform"] = pd.Field("SceneTransform", frozen=True) - # pylint: disable=no-member - translation: Length.Vector3 = pd.Field( - (0, 0, 0) * u.m, description="Translation applied to all scene objects" - ) - # pylint: disable=no-member - rotation: Angle.Vector3 = pd.Field( - (0, 0, 0) * u.deg, description="Rotation applied to all scene objects (Euler XYZ)" - ) - scale: Vector = pd.Field((1, 1, 1), description="Scaling applied to all scene objects") diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index a9fd27987..0f4d3c57e 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -1,25 +1,22 @@ -""" -Primitive type definitions for simulation entities. - -Re-import relay: all entity classes are defined in flow360_schema.models.entities. -Only ReferenceGeometry and VolumeEntityTypes remain client-owned. -""" +"""Relay primitive entity types and ReferenceGeometry from schema.""" # pylint: disable=unused-import -from typing import Optional, Union - -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Area, Length -from flow360_schema.models.entities import ( +from flow360_schema.models.entities.base import ( BOUNDARY_FULL_NAME_WHEN_NOT_FOUND, - AxisymmetricBody, - Box, - BoxCache, - CustomVolume, - Cylinder, + OrthogonalAxes, + SurfacePrivateAttributes, + _auto_symmetric_plane_exists_from_bbox, + _check_axis_is_orthogonal, + _get_generated_boundary_names, + _SurfaceEntityBase, + _VolumeEntityBase, +) +from flow360_schema.models.entities.geometry_entities import ( Edge, - GenericVolume, GeometryBodyGroup, + SnappyBody, +) +from flow360_schema.models.entities.surface_entities import ( GhostCircularPlane, GhostSphere, GhostSurface, @@ -27,89 +24,21 @@ ImportedSurface, MirroredGeometryBodyGroup, MirroredSurface, - OrthogonalAxes, - SeedpointVolume, - SnappyBody, - Sphere, Surface, SurfacePair, SurfacePairBase, - SurfacePrivateAttributes, WindTunnelGhostSurface, - _auto_symmetric_plane_exists_from_bbox, - _check_axis_is_orthogonal, - _get_generated_boundary_names, _MirroredEntityBase, - _SurfaceEntityBase, - _VolumeEntityBase, compute_bbox_tolerance, ) - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.user_code.core.types import ValueOrExpression - -VolumeEntityTypes = Union[GenericVolume, Cylinder, Sphere, Box, str] - - -class ReferenceGeometry(Flow360BaseModel): - """ - :class:`ReferenceGeometry` class contains all geometrical related reference values. - - Example - ------- - >>> ReferenceGeometry( - ... moment_center=(1, 2, 1) * u.m, - ... moment_length=(1, 1, 1) * u.m, - ... area=1.5 * u.m**2 - ... ) - >>> ReferenceGeometry( - ... moment_center=(1, 2, 1) * u.m, - ... moment_length=1 * u.m, - ... area=1.5 * u.m**2 - ... ) # Equivalent to above - - ==== - """ - - # pylint: disable=no-member - moment_center: Optional[Length.Vector3] = pd.Field( - None, description="The x, y, z coordinate of moment center." - ) - moment_length: Optional[Union[Length.PositiveFloat64, Length.PositiveVector3]] = pd.Field( - None, description="The x, y, z component-wise moment reference lengths." - ) - area: Optional[ValueOrExpression[Area.PositiveFloat64]] = pd.Field( - None, description="The reference area of the geometry." - ) - private_attribute_area_settings: Optional[dict] = pd.Field(None) - - @classmethod - def fill_defaults(cls, ref, params): # type: ignore[override] - """Return a new ReferenceGeometry with defaults filled using SimulationParams. - - Defaults when missing or when ref is None: - - area: 1 * (base_length)**2 - - moment_center: (0,0,0) * base_length - - moment_length: (1,1,1) * base_length - """ - # Determine base length unit from params - base_length_unit = params.base_length # LengthType quantity - - # Start from provided or empty - if ref is None: - ref = cls() - - # Compose output using provided values when available - area = ref.area - if area is None: - area = 1.0 * (base_length_unit**2) - - moment_center = ref.moment_center - if moment_center is None: - moment_center = (0, 0, 0) * base_length_unit - - moment_length = ref.moment_length - if moment_length is None: - moment_length = (1.0, 1.0, 1.0) * base_length_unit - - return cls(area=area, moment_center=moment_center, moment_length=moment_length) +from flow360_schema.models.entities.volume_entities import ( + AxisymmetricBody, + Box, + BoxCache, + CustomVolume, + Cylinder, + GenericVolume, + SeedpointVolume, + Sphere, +) +from flow360_schema.models.reference_geometry import ReferenceGeometry diff --git a/flow360/component/simulation/run_control/run_control.py b/flow360/component/simulation/run_control/run_control.py index 12a1864da..e351aaa90 100644 --- a/flow360/component/simulation/run_control/run_control.py +++ b/flow360/component/simulation/run_control/run_control.py @@ -1,33 +1,5 @@ -"""Module for the run control settings of simulation.""" +"""Relay run-control model definitions from schema.""" -from typing import List, Literal, Optional +# pylint: disable=unused-import -import pydantic as pd - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.run_control.stopping_criterion import ( - StoppingCriterion, -) - - -class RunControl(Flow360BaseModel): - """ - :class:`RunControl` class for run control settings. - - Example - ------- - - >>> criterion = fl.StoppingCriterion(...) - >>> fl.RunControl( - ... stopping_criteria = [criterion], - ... ) - - ==== - """ - - stopping_criteria: Optional[List[StoppingCriterion]] = pd.Field( - None, - description="A list of :class:`StoppingCriterion` for the solver. " - "All criteria must be met at the same time to stop the solver.", - ) - type_name: Literal["RunControl"] = pd.Field("RunControl", frozen=True) +from flow360_schema.models.simulation.run_control.run_control import RunControl diff --git a/flow360/component/simulation/run_control/stopping_criterion.py b/flow360/component/simulation/run_control/stopping_criterion.py index 642fbebbe..f36409aa6 100644 --- a/flow360/component/simulation/run_control/stopping_criterion.py +++ b/flow360/component/simulation/run_control/stopping_criterion.py @@ -1,242 +1,7 @@ -"""Module for setting up the stopping criterion of simulation.""" +"""Relay import for simulation stopping criterion.""" -from typing import List, Literal, Optional, Union +# pylint: disable=unused-import -import pydantic as pd -import unyt as u -from flow360_schema.framework.expression import ( - SolverVariable, - UnytQuantity, - UserVariable, - get_input_value_dimensions, - get_input_value_length, - solver_variable_to_user_variable, +from flow360_schema.models.simulation.run_control.stopping_criterion import ( + StoppingCriterion, ) - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.param_utils import serialize_model_obj_to_id -from flow360.component.simulation.outputs.output_entities import Point -from flow360.component.simulation.outputs.output_fields import _FIELD_IS_SCALAR_MAPPING -from flow360.component.simulation.outputs.outputs import ( - MonitorOutputType, - ProbeOutput, - SurfaceIntegralOutput, - SurfaceProbeOutput, -) -from flow360.component.simulation.user_code.core.types import ( - ValueOrExpression, - infer_units_by_unit_system, - is_variable_with_unit_system_as_units, -) -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - TimeSteppingType, - contextual_field_validator, -) - - -class StoppingCriterion(Flow360BaseModel): - """ - - :class:`StoppingCriterion` class for :py:attr:`RunControl.stopping_criteria` settings. - - Example - ------- - - Define a stopping criterion on a :class:`ProbeOutput` with a tolerance of 0.01. - The ProbeOutput monitors the moving range of Helicity in a moving window of 10 steps, - at the location of (0, 0, 0,005) * fl.u.m. - - >>> monitored_variable = fl.UserVariable( - ... name="Helicity_user", - ... value=fl.math.dot(fl.solution.velocity, fl.solution.vorticity), - ... ) - >>> criterion = fl.StoppingCriterion( - ... name="Criterion_1", - ... monitor_output=fl.ProbeOutput( - ... name="Helicity_probe", - ... output_fields=[ - ... monitored_variable, - ... ], - ... probe_points=fl.Point(name="Point1", location=(0, 0, 0.005) * fl.u.m), - ... moving_statistic = fl.MovingStatistic(method = "range", moving_window_size = 10) - ... ), - ... monitor_field=monitored_variable, - ... tolerance=0.01, - ... ) - - ==== - """ - - name: Optional[str] = pd.Field("StoppingCriterion", description="Name of this criterion.") - monitor_field: Union[UserVariable, str] = pd.Field( - description="The field to be monitored. This field must be " - "present in the `output_fields` of `monitor_output`." - ) - monitor_output: Union[MonitorOutputType, str] = pd.Field( - description="The monitored output or its id." - ) - tolerance: ValueOrExpression[Union[UnytQuantity, float]] = pd.Field( - description="The tolerance threshold of this criterion." - ) - tolerance_window_size: Optional[int] = pd.Field( - None, - description="The number of data points from the monitor_output to be used to check whether " - "the :math:`|max-min|/2` of the monitored field within this window is below tolerance or not. " - "If not set, the criterion will directly compare the latest value with tolerance.", - ge=2, - ) - type_name: Literal["StoppingCriterion"] = pd.Field("StoppingCriterion", frozen=True) - - def preprocess( - self, - *, - params=None, - exclude: List[str] = None, - required_by: List[str] = None, - flow360_unit_system=None, - ) -> Flow360BaseModel: - exclude_criterion = exclude + ["tolerance"] - return super().preprocess( - params=params, - exclude=exclude_criterion, - required_by=required_by, - flow360_unit_system=flow360_unit_system, - ) - - @pd.field_validator("monitor_field", mode="before") - @classmethod - def _convert_solver_variable_as_user_variable(cls, value): - if isinstance(value, SolverVariable): - return solver_variable_to_user_variable(value) - return value - - @pd.field_validator("monitor_field", mode="after") - @classmethod - def _check_monitor_field_is_scalar(cls, v): - if (isinstance(v, UserVariable) and get_input_value_length(v.value) != 0) or ( - isinstance(v, str) and v in _FIELD_IS_SCALAR_MAPPING and not _FIELD_IS_SCALAR_MAPPING[v] - ): - raise ValueError("The stopping criterion can only be defined on a scalar field.") - return v - - @pd.field_validator("monitor_output", mode="after") - @classmethod - def _convert_monitor_output_obj_to_id(cls, value): - """Convert monitor_output object to id""" - if isinstance(value, str): - return value - return serialize_model_obj_to_id(model_obj=value) - - @contextual_field_validator("monitor_output", mode="after", required_context=["output_dict"]) - @classmethod - def _check_monitor_exists_in_output_list(cls, v, param_info: ParamsValidationInfo): - """Ensure the monitor output exist in the outputs list of SimulationParams.""" - # output_dict is None if outputs field had validation errors - if param_info.output_dict.get(v) is None: - raise ValueError("The monitor output does not exist in the outputs list.") - return v - - @contextual_field_validator("monitor_output", mode="after", required_context=["output_dict"]) - @classmethod - def _check_not_final_pseudo_step_only_in_steady(cls, v, param_info: ParamsValidationInfo): - """In steady simulations, stopping criterion requires intermediate pseudo-step data to - evaluate convergence, so it cannot reference a monitor that suppresses those writes. - In unsteady simulations the check is fine because both the monitor write and - stopping criterion evaluation happen at the end of each physical step.""" - if param_info.time_stepping != TimeSteppingType.STEADY: - return v - monitor_output = param_info.output_dict.get(v) - if monitor_output is not None and getattr( - monitor_output, "output_at_final_pseudo_step_only", False - ): - raise ValueError( - "A monitor output with `output_at_final_pseudo_step_only=True` cannot be " - "referenced by a StoppingCriterion in a steady simulation." - ) - return v - - @contextual_field_validator("monitor_output", mode="after", required_context=["output_dict"]) - @classmethod - def _check_single_point_in_probe_output(cls, v, param_info: ParamsValidationInfo): - monitor_output = param_info.output_dict.get(v) - if not isinstance(monitor_output, (ProbeOutput, SurfaceProbeOutput)): - return v - if len(monitor_output.entities.stored_entities) == 1 and isinstance( - monitor_output.entities.stored_entities[0], Point - ): - return v - raise ValueError( - "For stopping criterion setup, only one single `Point` entity is allowed " - "in `ProbeOutput`/`SurfaceProbeOutput`." - ) - - @contextual_field_validator("monitor_output", mode="after", required_context=["output_dict"]) - @classmethod - def _check_field_exists_in_monitor_output( - cls, v, info: pd.ValidationInfo, param_info: ParamsValidationInfo - ): - """Ensure the monitor field exist in the monitor output.""" - monitor_output = param_info.output_dict.get(v) - monitor_field = info.data.get("monitor_field", None) - if monitor_field not in monitor_output.output_fields.items: - raise ValueError("The monitor field does not exist in the monitor output.") - return v - - @contextual_field_validator("tolerance", mode="before", required_context=["output_dict"]) - @classmethod - def _preprocess_field_with_unit_system( - cls, value, info: pd.ValidationInfo, param_info: ParamsValidationInfo - ): - if is_variable_with_unit_system_as_units(value): - return value - if info.data.get("monitor_field") is None: - # `field` validation failed. - raise ValueError( - "The monitor field is invalid and therefore unit inference is not possible." - ) - if info.data.get("monitor_output") is None: - raise ValueError( - "The monitor output is invalid and therefore unit inference is not possible." - ) - units = value["units"] - monitor_field = info.data["monitor_field"] - monitor_output = param_info.output_dict.get(info.data.get("monitor_output")) - field_dimensions = get_input_value_dimensions(value=monitor_field.value) - if isinstance(monitor_output, SurfaceIntegralOutput): - field_dimensions = field_dimensions * u.dimensions.length**2 - value = infer_units_by_unit_system( - value=value, value_dimensions=field_dimensions, unit_system=units - ) - return value - - @pd.field_validator("tolerance", mode="after") - @classmethod - def check_tolerance_value_for_string_monitor_field(cls, v, info: pd.ValidationInfo): - """Ensure the tolerance is float when string field is used.""" - - monitor_field = info.data.get("monitor_field", None) - if isinstance(monitor_field, str) and not isinstance(v, float): - raise ValueError( - f"The monitor field ({monitor_field}) specified by string " - "can only be used with a nondimensional tolerance." - ) - return v - - @contextual_field_validator("tolerance", mode="after", required_context=["output_dict"]) - @classmethod - def _check_tolerance_and_monitor_field_match_dimensions( - cls, v, info: pd.ValidationInfo, param_info: ParamsValidationInfo - ): - """Ensure the tolerance has the same dimensions as the monitor field.""" - monitor_field = info.data.get("monitor_field", None) - if not isinstance(monitor_field, UserVariable): - return v - field_dimensions = get_input_value_dimensions(value=monitor_field.value) - monitor_output = param_info.output_dict.get(info.data.get("monitor_output", None), None) - if isinstance(monitor_output, SurfaceIntegralOutput): - field_dimensions = field_dimensions * u.dimensions.length**2 - tolerance_dimensions = get_input_value_dimensions(value=v) - if tolerance_dimensions != field_dimensions: - raise ValueError("The dimensions of monitor field and tolerance do not match.") - return v diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index d960e53b2..2579c0353 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -1,1026 +1,9 @@ -""" -Flow360 simulation parameters -""" +"""Relay simulation parameter models from schema.""" -# pylint: disable=too-many-lines -from __future__ import annotations - -from typing import Annotated, List, Literal, Optional, Union - -import pydantic as pd -import unyt as u -from flow360_schema.framework.expression import ( - UserVariable, - batch_get_user_variable_units, - compute_surface_integral_unit, -) -from flow360_schema.framework.physical_dimensions import ( - AbsoluteTemperature, - Density, - Length, - Mass, - Time, - Velocity, -) -from flow360_schema.framework.validation.context import DeserializationContext -from flow360_schema.models.asset_cache import AssetCache - -from flow360.component.simulation.conversion import ( - LIQUID_IMAGINARY_FREESTREAM_MACH, - RestrictedUnitSystem, -) -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.boundary_split import ( - BoundaryNameLookupTable, - post_process_rotation_volume_entities, - post_process_wall_models_for_rotating, - update_entities_in_model, -) -from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.param_utils import ( - _set_boundary_full_name_with_zone_name, - _update_entity_full_name, - _update_zone_boundaries_with_metadata, - register_entity_list, -) -from flow360.component.simulation.framework.updater import updater -from flow360.component.simulation.framework.updater_utils import Flow360Version -from flow360.component.simulation.meshing_param.params import ( - MeshingParams, - ModularMeshingWorkflow, -) -from flow360.component.simulation.meshing_param.volume_params import ( - AutomatedFarfield, - RotationCylinder, - RotationSphere, - RotationVolume, -) -from flow360.component.simulation.models.surface_models import SurfaceModelTypes -from flow360.component.simulation.models.volume_models import ( - ActuatorDisk, - BETDisk, - Fluid, - Solid, - VolumeModelTypes, -) -from flow360.component.simulation.operating_condition.operating_condition import ( - OperatingConditionTypes, -) -from flow360.component.simulation.outputs.outputs import ( - AeroAcousticOutput, - ForceDistributionOutput, - ForceOutput, - IsosurfaceOutput, - OutputTypes, - ProbeOutput, - SurfaceIntegralOutput, - SurfaceProbeOutput, - UserDefinedField, - VolumeOutput, -) -from flow360.component.simulation.primitives import ( +# pylint: disable=unused-import +from flow360_schema.models.simulation.simulation_params import ( + ModelTypes, ReferenceGeometry, - _SurfaceEntityBase, - _VolumeEntityBase, -) -from flow360.component.simulation.run_control.run_control import RunControl -from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady -from flow360.component.simulation.unit_system import ( - DimensionedTypes, - UnitSystem, - UnitSystemConfig, - unit_system_manager, -) -from flow360.component.simulation.units import validate_length -from flow360.component.simulation.user_code.core.types import ( - get_post_processing_variables, + SimulationParams, + _ParamModelBase, ) -from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( - UserDefinedDynamic, -) -from flow360.component.simulation.utils import sanitize_params_dict -from flow360.component.simulation.validation.validation_output import ( - _check_aero_acoustics_observer_time_step_size, - _check_local_cfl_output, - _check_moving_statistic_applicability, - _check_output_fields, - _check_output_fields_valid_given_transition_model, - _check_output_fields_valid_given_turbulence_model, - _check_unique_force_distribution_output_names, - _check_unique_surface_volume_probe_entity_names, - _check_unique_surface_volume_probe_names, - _check_unsteadiness_to_use_aero_acoustics, -) -from flow360.component.simulation.validation.validation_simulation_params import ( - _check_and_add_noninertial_reference_frame_flag, - _check_cht_solver_settings, - _check_complete_boundary_condition_and_unknown_surface, - _check_consistency_hybrid_model_volume_output, - _check_consistency_wall_function_and_surface_output, - _check_coordinate_system_constraints, - _check_duplicate_actuator_disk_cylinder_names, - _check_duplicate_entities_in_models, - _check_duplicate_isosurface_names, - _check_duplicate_surface_usage, - _check_hybrid_model_to_use_zonal_enforcement, - _check_krylov_solver_restrictions, - _check_low_mach_preconditioner_output, - _check_numerical_dissipation_factor_output, - _check_parent_volume_is_rotating, - _check_time_average_output, - _check_tpg_not_with_isentropic_solver, - _check_unique_selector_names, - _check_unsteadiness_to_use_hybrid_model, - _check_valid_models_for_liquid, - _populate_validated_field_to_validation_context, -) -from flow360.component.simulation.validation.validation_utils import has_mirroring_usage -from flow360.error_messages import unit_system_inconsistent_msg -from flow360.exceptions import Flow360ConfigurationError, Flow360RuntimeError -from flow360.log import log -from flow360.version import __version__ - -from .validation.validation_context import ( - CASE, - SURFACE_MESH, - VOLUME_MESH, - CaseField, - ConditionalField, - ParamsValidationInfo, - context_validator, - contextual_field_validator, - contextual_model_validator, -) - -ModelTypes = Annotated[Union[VolumeModelTypes, SurfaceModelTypes], pd.Field(discriminator="type")] - - -class _ParamModelBase(Flow360BaseModel): - """ - Base class that abstracts out all Param type classes in Flow360. - """ - - version: str = pd.Field(__version__, frozen=True) - unit_system: UnitSystemConfig = pd.Field(frozen=True) - model_config = pd.ConfigDict(include_hash=True) - - @classmethod - def _init_check_unit_system(cls, **kwargs): - """ - Resolve the unit system from kwargs / active context / SI default. - Raises if an explicit kwarg unit_system conflicts with the active context. - Returns (resolved_unit_system, remaining_kwargs). - """ - if unit_system_manager.current is None: - raise Flow360RuntimeError( - "Please use a unit system context (e.g. `with SI_unit_system:`) " - "when constructing SimulationParams from Python." - ) - - kwarg_unit_system = kwargs.pop("unit_system", None) - if kwarg_unit_system is not None: - # Resolve to UnitSystem for comparison - if isinstance(kwarg_unit_system, UnitSystemConfig): - resolved = kwarg_unit_system.resolve() - elif isinstance(kwarg_unit_system, dict): - resolved = UnitSystemConfig.model_validate(kwarg_unit_system).resolve() - elif isinstance(kwarg_unit_system, UnitSystem): - resolved = kwarg_unit_system - else: - raise Flow360RuntimeError(f"Unexpected unit_system type: {type(kwarg_unit_system)}") - if resolved != unit_system_manager.current: - raise Flow360RuntimeError( - unit_system_inconsistent_msg( - resolved.system_repr(), - unit_system_manager.current.system_repr(), - ) - ) - else: - resolved = unit_system_manager.current - - return resolved, kwargs - - @classmethod - def _get_version_from_dict(cls, model_dict: dict) -> str: - version = model_dict.get("version", None) - if version is None: - raise Flow360RuntimeError("Failed to find SimulationParams version from the input.") - return version - - @classmethod - def _update_param_dict(cls, model_dict, version_to=__version__): - """ - 1. Find the version from the input dict. - 2. Update the input dict to `version_to` which by default is the current version. - 3. If the simulation.json has higher version, then return the dict as is without modification. - - Returns - ------- - dict - The updated parameters dictionary. - bool - Whether the `model_dict` has higher version than `version_to` (AKA forward compatibility mode). - """ - input_version = cls._get_version_from_dict(model_dict=model_dict) - forward_compatibility_mode = Flow360Version(input_version) > Flow360Version(version_to) - if not forward_compatibility_mode: - model_dict = updater( - version_from=input_version, - version_to=version_to, - params_as_dict=model_dict, - ) - return model_dict, forward_compatibility_mode - - @staticmethod - def _sanitize_params_dict(model_dict): - """ - !!!WARNING!!!: This function changes the input dict in place!!! - - Clean the redundant content in the params dict from WebUI - """ - return sanitize_params_dict(model_dict) - - @classmethod - def from_file(cls, filename: str): - """Override to run sanitizer and version updater before validation.""" - model_dict = cls._handle_file(filename=filename) - model_dict = cls._sanitize_params_dict(model_dict) - model_dict, _ = cls._update_param_dict(model_dict) - return cls.deserialize(model_dict) - - def _init_no_unit_context(self, filename, file_content, **kwargs): - """ - Initialize the simulation parameters from file or dict content. - """ - if filename is not None: - model_dict = self._handle_file(filename=filename, **kwargs) - else: - model_dict = self._handle_dict(**file_content) - - model_dict = _ParamModelBase._sanitize_params_dict(model_dict) - # When treating files/file like contents the updater will always be run. - model_dict, _ = _ParamModelBase._update_param_dict(model_dict) - - with DeserializationContext(): - super().__init__(**model_dict) - - def _init_with_unit_context(self, **kwargs): - """ - Initializes the simulation parameters with the given unit context. - This is the entry when user construct Param with Python script. - """ - # When treating dicts the updater is skipped. - _, kwargs = _ParamModelBase._init_check_unit_system(**kwargs) - - current = unit_system_manager.current - super().__init__(unit_system=UnitSystemConfig(name=current.name), **kwargs) - - # pylint: disable=super-init-not-called - # pylint: disable=fixme - # TODO: avoid overloading the __init__ so IDE can proper prompt root level keys - def __init__(self, filename: str = None, file_content: dict = None, **kwargs): - if filename is not None or file_content is not None: - self._init_no_unit_context(filename, file_content, **kwargs) - elif unit_system_manager.current is not None: - self._init_with_unit_context(**kwargs) - elif "unit_system" in kwargs: - # Deserialization path (model_validate) — unit_system already in dict - with DeserializationContext(): - super().__init__(**kwargs) - else: - raise Flow360RuntimeError( - "Please use a unit system context (e.g. `with SI_unit_system:`) " - "when constructing SimulationParams from Python." - ) - - def copy(self, update=None, **kwargs) -> _ParamModelBase: - if unit_system_manager.current is None: - # pylint: disable=not-context-manager,no-member - with self.unit_system.resolve(): - return super().copy(update=update, **kwargs) - - return super().copy(update=update, **kwargs) - - -# pylint: disable=too-many-public-methods -class SimulationParams(_ParamModelBase): - """All-in-one class for surface meshing + volume meshing + case configurations""" - - meshing: Optional[Union[MeshingParams, ModularMeshingWorkflow]] = ConditionalField( - None, - context=[SURFACE_MESH, VOLUME_MESH], - discriminator="type_name", - description="Surface and volume meshing parameters. See :class:`MeshingParams` for more details.", - ) - - reference_geometry: Optional[ReferenceGeometry] = CaseField( - None, - description="Global geometric reference values. See :class:`ReferenceGeometry` for more details.", - ) - operating_condition: Optional[OperatingConditionTypes] = CaseField( - None, - discriminator="type_name", - description="Global operating condition." - " See :ref:`Operating Condition ` for more details.", - ) - # - - # meshing->edge_refinement, face_refinement, zone_refinement, volumes and surfaces should be class which has the: - # 1. __getitem__ to allow [] access - # 2. __setitem__ to allow [] assignment - # 3. by_name(pattern:str) to use regexpr/glob to select all zones/surfaces with matched name - # 4. by_type(pattern:str) to use regexpr/glob to select all zones/surfaces with matched type - - models: Optional[List[ModelTypes]] = CaseField( - None, - description="Solver settings and numerical models and boundary condition settings." - " See :ref:`Volume Models ` and :ref:`Surface Models ` for more details.", - ) - time_stepping: Union[Steady, Unsteady] = CaseField( - Steady(), - discriminator="type_name", - description="Time stepping settings. See :ref:`Time Stepping ` for more details.", - ) - user_defined_dynamics: Optional[List[UserDefinedDynamic]] = CaseField( - None, - description="User defined dynamics. See :ref:`User Defined Dynamics ` for more details.", - ) - - user_defined_fields: List[UserDefinedField] = CaseField( - [], description="User defined fields that can be used in outputs." - ) - - # Support for user defined expression? - # If so: - # 1. Move over the expression validation functions. - # 2. Have camelCase to snake_case naming converter for consistent user experience. - # Limitations: - # 1. No per volume zone output. (single volume output) - outputs: Optional[List[OutputTypes]] = CaseField( - None, - description="Output settings. See :ref:`Outputs ` for more details.", - ) - - run_control: Optional[RunControl] = CaseField( - None, - description="Run control settings of the simulation.", - ) - - ##:: [INTERNAL USE ONLY] Private attributes that should not be modified manually. - private_attribute_asset_cache: AssetCache = pd.Field(AssetCache(), frozen=True) - private_attribute_dict: Optional[dict] = pd.Field(None) - - # pylint: disable=arguments-differ - def _preprocess(self, mesh_unit=None, exclude: list = None) -> SimulationParams: - """Internal function for non-dimensionalizing the simulation parameters""" - if exclude is None: - exclude = [] - - if mesh_unit is None: - raise Flow360ConfigurationError("Mesh unit has not been supplied.") - self._private_set_length_unit(validate_length(mesh_unit)) - if unit_system_manager.current is None: - # pylint: disable=not-context-manager,no-member - with self.unit_system.resolve(): - return super().preprocess( - params=self, - exclude=exclude, - flow360_unit_system=self.flow360_unit_system, - ) - return super().preprocess( - params=self, exclude=exclude, flow360_unit_system=self.flow360_unit_system - ) - - def _private_set_length_unit(self, validated_mesh_unit): - # pylint: disable=assigning-non-slot, no-member - self.private_attribute_asset_cache._force_set_attr( # pylint:disable=protected-access - "project_length_unit", validated_mesh_unit - ) - - @pd.validate_call - def convert_unit( - self, - value: DimensionedTypes, - target_system: Literal["SI", "Imperial", "flow360"], - length_unit: Optional[Length.Float64] = None, - ): - """ - Converts a given value to the specified unit system. - - This method takes a dimensioned quantity and converts it from its current unit system - to the target unit system, optionally considering a specific length unit for the conversion. - - Parameters - ---------- - value : DimensionedTypes - The dimensioned quantity to convert. This should have units compatible with Flow360's - unit system. - target_system : str - The target unit system for conversion. Common values include "SI", "Imperial", "flow360". - length_unit : Length.Float64, optional - The length unit to use for conversion. If not provided, the method defaults to - the project length unit stored in the `private_attribute_asset_cache`. - - Returns - ------- - DimensionedTypes - The converted value in the specified target unit system. - - Raises - ------ - Flow360RuntimeError - If the input unit system is not compatible with the target system, or if the required - length unit is missing. - - Examples - -------- - Convert a value from the current system to Flow360's V2 unit system: - - >>> simulation_params = SimulationParams() - >>> value = unyt_quantity(1.0, "meters") - >>> converted_value = simulation_params.convert_unit(value, target_system="flow360") - >>> print(converted_value) - 1.0 (flow360_length_unit) - """ - - if length_unit is not None: - # pylint: disable=no-member - self._private_set_length_unit(validate_length(length_unit)) - - if target_system in ("flow360", "flow360_v2"): - return value.in_base(unit_system=self.flow360_unit_system) - return value.in_base(unit_system=target_system) - - # pylint: disable=no-self-argument - @pd.field_validator("models", mode="after") - @classmethod - def apply_default_fluid_settings(cls, v): - """Apply default Fluid() settings if not found in mode`ls""" - if v is None: - v = [] - assert isinstance(v, list) - if not any(isinstance(item, Fluid) for item in v): - v.append(Fluid(private_attribute_id="__default_fluid")) - return v - - @contextual_field_validator("models", mode="after") - @classmethod - def check_parent_volume_is_rotating(cls, models, param_info: ParamsValidationInfo): - """Ensure that all the parent volumes listed in the `Rotation` model are not static""" - return _check_parent_volume_is_rotating(models, param_info) - - @contextual_field_validator("models", mode="after") - @classmethod - def check_valid_models_for_liquid(cls, models, param_info: ParamsValidationInfo): - """Ensure that all the boundary conditions used are valid.""" - return _check_valid_models_for_liquid(models, param_info) - - @contextual_field_validator("models", mode="after") - @classmethod - def check_duplicate_actuator_disk_cylinder_names(cls, models, param_info: ParamsValidationInfo): - """Ensure that all the cylinder names used in ActuatorDisks are unique.""" - return _check_duplicate_actuator_disk_cylinder_names(models, param_info) - - @contextual_field_validator("models", mode="after") - @classmethod - def populate_validated_models_to_validation_context( - cls, models, param_info: ParamsValidationInfo - ): - """After models are validated, store {id: model_obj} in validation context.""" - return _populate_validated_field_to_validation_context( - models, param_info, "physics_model_dict" - ) - - @contextual_field_validator("user_defined_fields", mode="after") - @classmethod - def _disable_expression_for_liquid( - cls, - value, - info: pd.ValidationInfo, - param_info: ParamsValidationInfo, - ): - """Ensure that string expressions are disabled for liquid simulation.""" - if param_info.using_liquid_as_material is False: - return value - if value: - raise ValueError( - f"{info.field_name} cannot be used when using liquid as simulation material." - ) - return value - - @pd.field_validator("outputs", mode="after") - @classmethod - def check_duplicate_isosurface_names(cls, outputs): - """Check if we have isosurfaces with a duplicate name""" - return _check_duplicate_isosurface_names(outputs) - - @contextual_field_validator("outputs", mode="after") - @classmethod - def check_duplicate_surface_usage(cls, outputs, param_info: ParamsValidationInfo): - """Disallow the same boundary/surface being used in multiple outputs""" - return _check_duplicate_surface_usage(outputs, param_info) - - @contextual_field_validator("outputs", mode="after") - @classmethod - def populate_validated_outputs_to_validation_context( - cls, outputs, param_info: ParamsValidationInfo - ): - """After outputs are validated, store {id: output_obj} in validation context.""" - return _populate_validated_field_to_validation_context(outputs, param_info, "output_dict") - - @pd.field_validator("user_defined_fields", mode="after") - @classmethod - def check_duplicate_user_defined_fields(cls, v): - """Check if we have duplicate user defined fields""" - if v == []: - return v - - known_user_defined_fields = set() - for field in v: - if field.name in known_user_defined_fields: - raise ValueError(f"Duplicate user defined field name: {field.name}") - known_user_defined_fields.add(field.name) - - return v - - @pd.model_validator(mode="after") - def check_cht_solver_settings(self): - """Check the Conjugate Heat Transfer settings, transferred from checkCHTSolverSettings""" - return _check_cht_solver_settings(self) - - @pd.model_validator(mode="after") - def check_consistency_wall_function_and_surface_output(self): - """Only allow wallFunctionMetric output field when there is a Wall model with a wall function enabled""" - return _check_consistency_wall_function_and_surface_output(self) - - @pd.model_validator(mode="after") - def check_consistency_hybrid_model_volume_output(self): - """Only allow hybrid RANS-LES output field when there is a corresponding solver with - hybrid RANS-LES enabled in models - """ - return _check_consistency_hybrid_model_volume_output(self) - - @pd.model_validator(mode="after") - def check_unsteadiness_to_use_hybrid_model(self): - """Only allow hybrid RANS-LES output field for unsteady simulations""" - return _check_unsteadiness_to_use_hybrid_model(self) - - @pd.model_validator(mode="after") - def check_hybrid_model_to_use_zonal_enforcement(self): - """Only allow LES/RANS zonal enforcement in hybrid RANS-LES mode""" - return _check_hybrid_model_to_use_zonal_enforcement(self) - - @pd.model_validator(mode="after") - def check_unsteadiness_to_use_aero_acoustics(self): - """Only allow Aero acoustics when using unsteady simulation""" - return _check_unsteadiness_to_use_aero_acoustics(self) - - @pd.model_validator(mode="after") - def check_local_cfl_output(self): - """Only allow localCFL output when using unsteady simulation""" - return _check_local_cfl_output(self) - - @pd.model_validator(mode="after") - def check_aero_acoustics_observer_time_step_size(self): - """Validate that observer time step size is smaller than CFD time step size""" - return _check_aero_acoustics_observer_time_step_size(self) - - @pd.model_validator(mode="after") - def check_unique_surface_volume_probe_names(self): - """Only allow unique probe names""" - return _check_unique_surface_volume_probe_names(self) - - @contextual_model_validator(mode="after") - def check_unique_surface_volume_probe_entity_names(self): - """Only allow unique probe entity names""" - # Note: Probes are not covered by Selectors so no need to expand them here. - return _check_unique_surface_volume_probe_entity_names(self) - - @pd.model_validator(mode="after") - def check_unique_force_distribution_output_names(self): - """Only allow unique force distribution names""" - return _check_unique_force_distribution_output_names(self) - - @contextual_model_validator(mode="after") - def check_duplicate_entities_in_models(self, param_info: ParamsValidationInfo): - """Only allow each Surface/Volume entity to appear once in the Surface/Volume model""" - return _check_duplicate_entities_in_models(self, param_info) - - @contextual_model_validator(mode="after") - def check_unique_selector_names(self): - """Ensure all EntitySelector names are unique""" - return _check_unique_selector_names(self) - - @pd.model_validator(mode="after") - def check_numerical_dissipation_factor_output(self): - """Only allow numericalDissipationFactor output field when the NS solver has low numerical dissipation""" - return _check_numerical_dissipation_factor_output(self) - - @pd.model_validator(mode="after") - def check_low_mach_preconditioner_output(self): - """Only allow lowMachPreconditioner output field when the lowMachPreconditioner is enabled in the NS solver""" - return _check_low_mach_preconditioner_output(self) - - @pd.model_validator(mode="after") - def check_tpg_not_with_isentropic_solver(self): - """Temperature-dependent gas properties are not supported with CompressibleIsentropic (4x4) solver. - - Constant-gamma coefficients (only a2 non-zero) are allowed. - """ - return _check_tpg_not_with_isentropic_solver(self) - - @pd.model_validator(mode="after") - def check_krylov_solver_restrictions(self): - """Krylov solver is not compatible with limiters or unsteady time stepping.""" - return _check_krylov_solver_restrictions(self) - - @contextual_model_validator(mode="after") - @context_validator(context=CASE) - def check_complete_boundary_condition_and_unknown_surface( - self, param_info: ParamsValidationInfo - ): - """Make sure that all boundaries have been assigned with a boundary condition""" - return _check_complete_boundary_condition_and_unknown_surface(self, param_info) - - @pd.model_validator(mode="after") - def check_output_fields(params): - """Check output fields and iso fields are valid""" - return _check_output_fields(params) - - @pd.model_validator(mode="after") - def check_output_fields_valid_given_turbulence_model(params): - """Check output fields are valid given the turbulence model""" - return _check_output_fields_valid_given_turbulence_model(params) - - @pd.model_validator(mode="after") - def check_output_fields_valid_given_transition_model(params): - """Check output fields are valid given the transition model""" - return _check_output_fields_valid_given_transition_model(params) - - @pd.model_validator(mode="after") - def check_and_add_rotating_reference_frame_model_flag_in_volumezones(params): - """Ensure that all volume zones have the rotating_reference_frame_model flag with correct values""" - return _check_and_add_noninertial_reference_frame_flag(params) - - @pd.model_validator(mode="after") - def check_time_average_output(params): - """Only allow TimeAverage output field in the unsteady simulations""" - return _check_time_average_output(params) - - @pd.model_validator(mode="after") - def check_moving_statistic_applicability(params): - """Check moving statistic settings are applicable to the simulation time stepping set up.""" - return _check_moving_statistic_applicability(params) - - @contextual_model_validator(mode="after") - def _validate_coordinate_system_constraints(self, param_info: ParamsValidationInfo): - """Validate coordinate system usage constraints.""" - return _check_coordinate_system_constraints(self, param_info) - - @contextual_model_validator(mode="after") - def _validate_mirroring_requires_geometry_ai(self, param_info: ParamsValidationInfo): - """Ensure mirroring is only used when GeometryAI is enabled.""" - if has_mirroring_usage(self.private_attribute_asset_cache): - if not param_info.use_geometry_AI: - raise ValueError("Mirroring is only supported when Geometry AI is enabled.") - return self - - def _register_assigned_entities(self, registry: EntityRegistry) -> EntityRegistry: - """Recursively register all entities listed in EntityList to the asset cache.""" - # pylint: disable=no-member - registry.clear() - register_entity_list(self, registry) - return registry - - def _update_entity_private_attrs(self, registry: EntityRegistry) -> EntityRegistry: - """ - Once the SimulationParams is set, extract and update information - into all used entities by parsing the params. - """ - - ##::1. Update full names in the Surface entities with zone names - # pylint: disable=no-member - if self.meshing is not None: - volume_zones = None - if isinstance(self.meshing, MeshingParams): - volume_zones = self.meshing.volume_zones - if ( - isinstance(self.meshing, ModularMeshingWorkflow) - and self.meshing.volume_meshing is not None - ): - volume_zones = self.meshing.zones - if volume_zones is not None: - for volume in volume_zones: - if isinstance(volume, AutomatedFarfield): - _set_boundary_full_name_with_zone_name( - registry, - "farfield", - volume.private_attribute_entity.name, - ) - _set_boundary_full_name_with_zone_name( - registry, - "symmetric*", - volume.private_attribute_entity.name, - ) - if isinstance(volume, (RotationCylinder, RotationVolume, RotationSphere)): - # pylint: disable=fixme - # TODO: Implement this - pass - - return registry - - @property - def base_length(self) -> Length.Float64: - """Get base length unit for non-dimensionalization""" - # pylint:disable=no-member - return self.private_attribute_asset_cache.project_length_unit.to("m") - - @property - def base_temperature(self) -> AbsoluteTemperature.Float64: - """Get base temperature unit for non-dimensionalization""" - # pylint:disable=no-member - if self.operating_condition.type_name == "LiquidOperatingCondition": - # Temperature in this condition has no effect because the thermal features will be disabled. - # Also the viscosity will be constant. - # pylint:disable = no-member - return 273 * u.K - return self.operating_condition.thermal_state.temperature.to("K") - - @property - def base_velocity(self) -> Velocity.Float64: - """Get base velocity unit for non-dimensionalization""" - # pylint:disable=no-member - if self.operating_condition.type_name == "LiquidOperatingCondition": - # Provides an imaginary "speed of sound" - # Resulting in a hardcoded freestream mach of `LIQUID_IMAGINARY_FREESTREAM_MACH` - # To ensure incompressible range. - # pylint: disable=protected-access - if self.operating_condition._evaluated_velocity_magnitude.value != 0: - return ( - self.operating_condition._evaluated_velocity_magnitude - / LIQUID_IMAGINARY_FREESTREAM_MACH - ).to("m/s") - return ( - self.operating_condition.reference_velocity_magnitude # pylint:disable=no-member - / LIQUID_IMAGINARY_FREESTREAM_MACH - ).to("m/s") - return self.operating_condition.thermal_state.speed_of_sound.to("m/s") - - @property - def reference_velocity(self) -> Velocity.Float64: - """ - This function returns the **reference velocity**. - Note that the reference velocity is **NOT** the non-dimensionalization velocity scale - - For dimensionalization of Flow360 output (converting FROM flow360 unit) - The solver output is already re-normalized by `reference velocity` due to "velocityScale" - So we need to find the `reference velocity`. - `reference_velocity_magnitude` takes precedence, consistent with how "velocityScale" is computed. - """ - # NOTE: GenericReferenceCondition does not define `reference_velocity_magnitude`. - # For GenericReferenceCondition, reference velocity is just `velocity_magnitude`. - reference_velocity_magnitude = getattr( - self.operating_condition, "reference_velocity_magnitude", None - ) - # pylint: disable=no-member - if reference_velocity_magnitude is not None: - reference_velocity = reference_velocity_magnitude.to("m/s") - elif self.operating_condition.type_name == "LiquidOperatingCondition": - reference_velocity = self.base_velocity.to("m/s") * LIQUID_IMAGINARY_FREESTREAM_MACH - else: - reference_velocity = self.operating_condition.velocity_magnitude.to("m/s") - return reference_velocity - - @property - def base_density(self) -> Density.Float64: - """Get base density unit for non-dimensionalization""" - # pylint:disable=no-member - if self.operating_condition.type_name == "LiquidOperatingCondition": - return self.operating_condition.material.density.to("kg/m**3") - return self.operating_condition.thermal_state.density.to("kg/m**3") - - @property - def base_mass(self) -> Mass.Float64: - """Get base mass unit for non-dimensionalization""" - return self.base_density * self.base_length**3 - - @property - def base_time(self) -> Time.Float64: - """Get base time unit for non-dimensionalization""" - return self.base_length / self.base_velocity - - @property - def flow360_unit_system(self) -> u.UnitSystem: - """Get the unit system for non-dimensionalization. - - In meshing-only mode (no operating_condition), returns a RestrictedUnitSystem - that only supports length conversions. Attempting to convert other dimensions - raises ValueError. - """ - if self.operating_condition is None: - return RestrictedUnitSystem("flow360_nondim", length_unit=self.base_length) - return RestrictedUnitSystem( - "flow360_nondim", - length_unit=self.base_length, - mass_unit=self.base_mass, - time_unit=self.base_time, - temperature_unit=self.base_temperature, - ) - - @property - def used_entity_registry(self) -> EntityRegistry: - """ - Get a entity registry that collects all the entities used in the simulation. - And also try to update the entities now that we have a global view of the simulation. - - # Hint: - Used by _set_up_params_non_persistent_entity_info & _update_param_with_actual_volume_mesh_meta - - # TODO: With the change of entity selector expansion, this no longer captures all non-draft entities. - """ - registry = EntityRegistry() - registry = self._register_assigned_entities(registry) - registry = self._update_entity_private_attrs(registry) - return registry - - def _update_param_with_actual_volume_mesh_meta(self, volume_mesh_meta_data: dict): - """ - Update the zone info from the actual volume mesh before solver execution. - Will be executed in the casePipeline as part of preprocessing. - Some thoughts: - Do we also need to update the params when the **surface meshing** is done? - """ - # Build lookup table for surface entity name mapping (base_name -> full_name) - lookup_table = BoundaryNameLookupTable.from_params(volume_mesh_meta_data, params=self) - - # Update surface entities using the lookup table - update_entities_in_model(self, lookup_table, _SurfaceEntityBase) - - # Update volume entities - _update_entity_full_name(self, _VolumeEntityBase, volume_mesh_meta_data) - _update_zone_boundaries_with_metadata(self.used_entity_registry, volume_mesh_meta_data) - - # Post-processing hooks for RotationVolume-specific logic - post_process_rotation_volume_entities(self, lookup_table) - post_process_wall_models_for_rotating(self, lookup_table) - - return self - - def is_steady(self): - """ - returns True when SimulationParams is steady state - """ - return isinstance(self.time_stepping, Steady) - - def has_solid(self): - """ - returns True when SimulationParams has Solid model - """ - if self.models is None: - return False - # pylint: disable=not-an-iterable - return any(isinstance(item, Solid) for item in self.models) - - def has_actuator_disks(self): - """ - returns True when SimulationParams has ActuatorDisk disk - """ - if self.models is None: - return False - # pylint: disable=not-an-iterable - return any(isinstance(item, ActuatorDisk) for item in self.models) - - def has_bet_disks(self): - """ - returns True when SimulationParams has BET disk - """ - if self.models is None: - return False - # pylint: disable=not-an-iterable - return any(isinstance(item, BETDisk) for item in self.models) - - def has_isosurfaces(self): - """ - returns True when SimulationParams has isosurfaces - """ - if self.outputs is None: - return False - # pylint: disable=not-an-iterable - return any(isinstance(item, IsosurfaceOutput) for item in self.outputs) - - def has_monitors(self): - """ - returns True when SimulationParams has monitors - """ - if self.outputs is None: - return False - # pylint: disable=not-an-iterable - return any( - isinstance(item, (ProbeOutput, SurfaceProbeOutput, SurfaceIntegralOutput)) - for item in self.outputs - ) - - def has_volume_output(self): - """ - returns True when SimulationParams has volume output - """ - if self.outputs is None: - return False - # pylint: disable=not-an-iterable - return any(isinstance(item, VolumeOutput) for item in self.outputs) - - def has_aeroacoustics(self): - """ - returns True when SimulationParams has aeroacoustics - """ - if self.outputs is None: - return False - # pylint: disable=not-an-iterable - return any(isinstance(item, (AeroAcousticOutput)) for item in self.outputs) - - def has_user_defined_dynamics(self): - """ - returns True when SimulationParams has user defined dynamics - """ - return self.user_defined_dynamics is not None and len(self.user_defined_dynamics) > 0 - - def has_force_distributions(self): - """ - returns True when SimulationParams has force distributions - """ - if self.outputs is None: - return False - # pylint: disable=not-an-iterable - return any(isinstance(item, ForceDistributionOutput) for item in self.outputs) - - def has_custom_forces(self): - """ - returns True when SimulationParams has any ForceOutputs - """ - if self.outputs is None: - return False - # pylint: disable=not-an-iterable - return any(isinstance(item, ForceOutput) for item in self.outputs) - - def display_output_units(self) -> None: - """ - Display all the output units for UserVariables used in `outputs`. - """ - if not self.outputs: - return - - post_processing_variables = get_post_processing_variables(self) - - # Sort for consistent behavior - post_processing_variables = sorted(post_processing_variables) - name_units_pair = batch_get_user_variable_units( - post_processing_variables, self.unit_system.name # pylint: disable=no-member - ) - - for output in self.outputs: - if isinstance(output, SurfaceIntegralOutput): - for field in output.output_fields.items: - if isinstance(field, UserVariable): - unit = compute_surface_integral_unit( - field, - unit_system_name=self.unit_system.name, # pylint: disable=no-member - unit_system=self.unit_system.resolve(), # pylint: disable=no-member - ) - name_units_pair[f"{field.name} (Surface integral)"] = unit - - if not name_units_pair: - return - - # Calculate column widths dynamically - name_column_width = max(len("Variable Name"), max(len(name) for name in name_units_pair)) - unit_column_width = max( - len("Unit"), max(len(str(unit)) for unit in name_units_pair.values()) - ) - - # Ensure minimum column widths - name_column_width = max(name_column_width, 15) - unit_column_width = max(unit_column_width, 10) - - # Create the table header - header = f"{'Variable Name':<{name_column_width}} | {'Unit':<{unit_column_width}}" - separator = "-" * len(header) - - # Print the table - log.info("") - log.info("Units of output `UserVariables`:") - log.info(separator) - log.info(header) - log.info(separator) - - # Print each row - for name, unit in name_units_pair.items(): - log.info(f"{name:<{name_column_width}} | {str(unit):<{unit_column_width}}") - - log.info(separator) - log.info("") - - def pre_submit_summary(self): - """ - Display a summary of the simulation params before submission. - """ - self.display_output_units() diff --git a/flow360/component/simulation/time_stepping/time_stepping.py b/flow360/component/simulation/time_stepping/time_stepping.py index 7ebbe2b77..73fe944ce 100644 --- a/flow360/component/simulation/time_stepping/time_stepping.py +++ b/flow360/component/simulation/time_stepping/time_stepping.py @@ -1,212 +1,10 @@ -"""Time stepping setting for simulation""" +"""Time stepping setting for simulation — re-import relay.""" -from typing import Literal, Optional, Union +# pylint: disable=unused-import -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Time - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.user_code.core.types import ValueOrExpression - - -def _apply_default_to_none(original, default): - for field_name, value in original.model_dump().items(): - if value is None: - setattr(original, field_name, default.model_dump()[field_name]) - return original - - -class RampCFL(Flow360BaseModel): - """ - :class:`RampCFL` class for the Ramp CFL setting of time stepping. - - Example - ------- - - >>> fl.RampCFL(initial=1, final=200, ramp_steps=200) - - ==== - """ - - type: Literal["ramp"] = pd.Field("ramp", frozen=True) - initial: Optional[pd.PositiveFloat] = pd.Field( - None, - description="Initial CFL for solving pseudo time step. " - + "In steady simulations default value is 5. In unsteady simulations default value is 1.", - ) - final: Optional[pd.PositiveFloat] = pd.Field( - None, - description="Final CFL for solving pseudo time step. " - + "In steady simulations default value is 200. In unsteady simulations default value is 1e6.", - ) - ramp_steps: Optional[pd.PositiveInt] = pd.Field( - None, - description="Number of pseudo steps before reaching :py:attr:`RampCFL.final` within 1 physical step. " - + "In steady simulations default value is 40. In unsteady simulations default value is 30.", - ) - - @classmethod - def default_unsteady(cls): - """ - returns default unsteady Ramp CFL settings - """ - return cls(initial=1, final=1e6, ramp_steps=30) - - @classmethod - def default_steady(cls): - """ - returns default steady Ramp CFL settings - """ - return cls(initial=5, final=200, ramp_steps=40) - - -class AdaptiveCFL(Flow360BaseModel): - """ - :class:`AdaptiveCFL` class for Adaptive CFL setting of time stepping. - - Example - ------- - - - Set up Adaptive CFL with convergence limiting factor: - - >>> fl.AdaptiveCFL(convergence_limiting_factor=0.5) - - - Set up Adaptive CFL with max relative change: - - >>> fl.AdaptiveCFL( - ... min=1, - ... max=100000, - ... max_relative_change=50 - ... ) - - ==== - """ - - type: Literal["adaptive"] = pd.Field("adaptive", frozen=True) - min: pd.PositiveFloat = pd.Field( - default=0.1, - description="The minimum allowable value for Adaptive CFL. " - + "Default value is 0.1 for both steady and unsteady simulations.", - ) - max: Optional[pd.PositiveFloat] = pd.Field( - None, - description="The maximum allowable value for Adaptive CFL. " - + "In steady simulations default value is 1e4. In unsteady simulations default value is 1e6.", - ) - max_relative_change: Optional[pd.PositiveFloat] = pd.Field( - None, - description="The maximum allowable relative change of CFL (%) at each pseudo step. " - + "In unsteady simulations, the value of :py:attr:`AdaptiveCFL.max_relative_change` " - + "is updated automatically depending on how well the solver converges in each physical step. " - + "In steady simulations default value is 1. In unsteady simulations default value is 50.", - ) - convergence_limiting_factor: Optional[pd.PositiveFloat] = pd.Field( - None, - description="This factor specifies the level of conservativeness when using Adaptive CFL. " - + "Smaller values correspond to a more conservative limitation on the value of CFL. " - + "In steady simulations default value is 0.25. In unsteady simulations default value is 1.", - ) - - @classmethod - def default_unsteady(cls): - """ - returns default unsteady Adaptive CFL settings - """ - return cls(max=1e6, max_relative_change=50, convergence_limiting_factor=1.0) - - @classmethod - def default_steady(cls): - """ - returns default steady Adaptive CFL settings - """ - return cls(max=1e4, max_relative_change=1, convergence_limiting_factor=0.25) - - -class Steady(Flow360BaseModel): - """ - :class:`Steady` class for specifying steady simulation. - - Example - ------- - - >>> fl.Steady( - ... CFL=fl.RampCFL(initial=1, final=200, ramp_steps=200), - ... max_steps=6000, - ... ) - - ==== - - """ - - type_name: Literal["Steady"] = pd.Field("Steady", frozen=True) - max_steps: int = pd.Field(2000, gt=0, le=100000, description="Maximum number of pseudo steps.") - # pylint: disable=duplicate-code - CFL: Union[RampCFL, AdaptiveCFL] = pd.Field( - default=AdaptiveCFL.default_steady(), description="CFL settings." - ) - - @pd.model_validator(mode="before") - @classmethod - def set_default_cfl(cls, values): - """ - Populate CFL's None fields with default - """ - if "CFL" not in values: - return values # will be handled by default value - cfl_input = values["CFL"] - if isinstance(cfl_input, AdaptiveCFL): - cfl_input = _apply_default_to_none(cfl_input, AdaptiveCFL.default_steady()) - elif isinstance(cfl_input, RampCFL): - cfl_input = _apply_default_to_none(cfl_input, RampCFL.default_steady()) - return values - - -class Unsteady(Flow360BaseModel): - """ - :class:`Unsteady` class for specifying unsteady simulation. - - Example - ------- - - >>> fl.Unsteady( - ... CFL=fl.AdaptiveCFL( - ... convergence_limiting_factor=0.5 - ... ), - ... step_size=0.01 * fl.u.s, - ... steps=120, - ... max_pseudo_steps=35, - ... ) - - ==== - """ - - type_name: Literal["Unsteady"] = pd.Field("Unsteady", frozen=True) - max_pseudo_steps: int = pd.Field( - 20, gt=0, le=100000, description="Maximum pseudo steps within one physical step." - ) - steps: pd.PositiveInt = pd.Field(description="Number of physical steps.") - # pylint: disable=no-member - step_size: ValueOrExpression[Time.PositiveFloat64] = pd.Field( - description="Time step size in physical step marching," - ) - # pylint: disable=duplicate-code - CFL: Union[RampCFL, AdaptiveCFL] = pd.Field( - default=AdaptiveCFL.default_unsteady(), - description="CFL settings within each physical step.", - ) - order_of_accuracy: Literal[1, 2] = pd.Field(2, description="Temporal order of accuracy.") - - @pd.model_validator(mode="before") - @classmethod - def set_default_cfl(cls, values): - """ - Populate CFL's None fields with default - """ - if "CFL" not in values: - return values # will be handled by default value - cfl_input = values["CFL"] - if isinstance(cfl_input, AdaptiveCFL): - cfl_input = _apply_default_to_none(cfl_input, AdaptiveCFL.default_unsteady()) - elif isinstance(cfl_input, RampCFL): - cfl_input = _apply_default_to_none(cfl_input, RampCFL.default_unsteady()) - return values +from flow360_schema.models.simulation.time_stepping.time_stepping import ( + AdaptiveCFL, + RampCFL, + Steady, + Unsteady, +) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index c5f28399d..2e8489517 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -216,9 +216,7 @@ def init_output_base(obj_list, class_type: Type, is_average: bool): "output_format", ) assert output_format is not None - if output_format == "both": - output_format = "paraview,tecplot" - base["outputFormat"] = output_format + base["outputFormat"] = ",".join(sorted(output_format)) if is_average: base = init_average_output(base, obj_list, class_type) diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index 74e767709..bea942076 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -31,6 +31,7 @@ from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.units import validate_length from flow360.component.simulation.utils import is_exact_instance +from flow360.component.simulation.validation.validation_context import ValidationContext from flow360.exceptions import Flow360TranslationError @@ -162,6 +163,7 @@ def wrapper(input_params, mesh_unit, *args, **kwargs): # pylint: disable=no-member if func.__name__ == "get_solver_json": preprocess_exclude = ["meshing"] + preprocess_validation_levels = None elif func.__name__ in ("get_surface_meshing_json", "get_volume_meshing_json"): preprocess_exclude = [ "reference_geometry", @@ -171,10 +173,20 @@ def wrapper(input_params, mesh_unit, *args, **kwargs): "user_defined_dynamics", "outputs", ] + # Preprocess is a unit-conversion step, not a validation step. Use an + # empty validation scope so mesh-only translation skips case validators + # without re-triggering conditional required-field checks. + preprocess_validation_levels = [] else: preprocess_exclude = [] + preprocess_validation_levels = None validated_mesh_unit = validate_length(mesh_unit) - processed_input = preprocess_param(input_params, validated_mesh_unit, preprocess_exclude) + processed_input = preprocess_param( + input_params, + validated_mesh_unit, + preprocess_exclude, + preprocess_validation_levels, + ) apply_coordinate_system_transformations(processed_input) @@ -192,6 +204,7 @@ def preprocess_param( input_params: SimulationParams | str | dict, validated_mesh_unit: Length.Float64, preprocess_exclude: list[str], + preprocess_validation_levels: list[str] | None, ): """ Get the dictionary of `SimulationParams`. @@ -214,6 +227,9 @@ def preprocess_param( if param is not None: # pylint: disable=protected-access param._private_set_length_unit(validated_mesh_unit) + if preprocess_validation_levels is not None: + with ValidationContext(levels=preprocess_validation_levels): + return param._preprocess(validated_mesh_unit, exclude=preprocess_exclude) return param._preprocess(validated_mesh_unit, exclude=preprocess_exclude) raise ValueError(f"Invalid input <{input_params.__class__.__name__}> for translator. ") diff --git a/flow360/component/simulation/units.py b/flow360/component/simulation/units.py index 18ef9d48d..a2de5d07b 100644 --- a/flow360/component/simulation/units.py +++ b/flow360/component/simulation/units.py @@ -1,59 +1,9 @@ -""" -This module is for accessing units and unit systems including flow360 unit system. -""" - -import functools +"""Relay unit helpers from schema.""" import unyt -from unyt import unit_symbols - -from flow360.component.simulation.unit_system import ( - BaseSystemType, - CGS_unit_system, - SI_unit_system, - UnitSystem, - imperial_unit_system, -) - -# pylint: disable=duplicate-code -__all__ = [ - "BaseSystemType", - "CGS_unit_system", - "SI_unit_system", - "UnitSystem", - "imperial_unit_system", -] - - -def import_units(module, namespace): - """Import Unit objects from a module into a namespace""" - for key, value in module.__dict__.items(): - if isinstance(value, (unyt.unyt_quantity, unyt.Unit)): - namespace[key] = value - - -import_units(unit_symbols, globals()) -del import_units - - -@functools.lru_cache(maxsize=1) -def _get_length_adapter(): - """Lazily build and cache TypeAdapter(Length.Float64) to avoid import-time cost.""" - from flow360_schema.framework.physical_dimensions import ( # pylint: disable=import-outside-toplevel - Length, - ) - from pydantic import TypeAdapter # pylint: disable=import-outside-toplevel - - return TypeAdapter(Length.Float64) - -def validate_length(value): - """Validate a value as Length.Float64 using a cached TypeAdapter. +# pylint: disable=wildcard-import,unused-wildcard-import +from flow360_schema.models.simulation.units import * +from flow360_schema.models.simulation.units import __all__ as _schema_all - Replacement for the old LengthType.validate() pattern. - Accepts unyt quantities, dicts, and bare numbers (interpreted as SI meters). - For backward compatibility, plain unit strings are interpreted as 1 * unit. - """ - if isinstance(value, str): - value = 1 * unyt.Unit(value) - return _get_length_adapter().validate_python(value) +__all__ = [*_schema_all, "unyt"] diff --git a/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py b/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py index e36da69e9..1a9f791c9 100644 --- a/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py +++ b/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py @@ -1,161 +1,7 @@ -"""User defined dynamic model for SimulationParams""" +"""Relay import for simulation user defined dynamics models.""" -from typing import Dict, List, Optional, Union +# pylint: disable=unused-import -import pydantic as pd - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.framework.expressions import StringExpression -from flow360.component.simulation.primitives import ( - CustomVolume, - Cylinder, - GenericVolume, - MirroredSurface, - SeedpointVolume, - Surface, -) -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - contextual_field_validator, +from flow360_schema.models.simulation.user_defined_dynamics.user_defined_dynamics import ( + UserDefinedDynamic, ) -from flow360.component.simulation.validation.validation_utils import ( - validate_entity_list_surface_existence, -) - - -class UserDefinedDynamic(Flow360BaseModel): - """ - :class:`UserDefinedDynamic` class for defining the user defined dynamics inputs. - Please refer to :doc:`this example ` - for an implementation example. - - Example - ------- - - >>> fl.UserDefinedDynamic( - ... name="dynamicTheta", - ... input_vars=["momentY"], - ... constants={ - ... "I": 0.443768309310345, - ... "zeta": zeta, - ... "K": K, - ... "omegaN": omegaN, - ... "theta0": theta0, - ... }, - ... output_vars={ - ... "omegaDot": "state[0];", - ... "omega": "state[1];", - ... "theta": "state[2];", - ... }, - ... state_vars_initial_value=[str(initOmegaDot), "0.0", str(initTheta)], - ... update_law=[ - ... "if (pseudoStep == 0) (momentY - K * ( state[2] - theta0 ) " - ... + "- 2 * zeta * omegaN * I *state[1] ) / I; else state[0];", - ... "if (pseudoStep == 0) state[1] + state[0] * timeStepSize; else state[1];", - ... "if (pseudoStep == 0) state[2] + state[1] * timeStepSize; else state[2];", - ... ], - ... input_boundary_patches=volume_mesh["plateBlock/noSlipWall"], - ... output_target=volume_mesh["plateBlock"], - ... ) - - ==== - - """ - - name: str = pd.Field( - "User defined dynamics", description="Name of the dynamics defined by the user." - ) - input_vars: List[str] = pd.Field( - description="List of the inputs to define the user defined dynamics. Inputs can be: :code:`CL`, :code:`CD`, " - + ":code:`bet_NUM_torque`, :code:`bet_NUM_thrust`, (NUM is the index of the BET disk starting from 0), " - + ":code:`momentX`, :code:`momentY`, :code:`momentZ` (X/Y/Z moments with respect to " - + ":py:attr:`~ReferenceGeometry.moment_center`), :code:`forceX`, :code:`forceY`, :code:`forceZ`. " - ) - constants: Optional[Dict[str, float]] = pd.Field( - None, description="A list of constants that can be used in the expressions." - ) - output_vars: Optional[Dict[str, StringExpression]] = pd.Field( - None, - description="Name of the output variables and the expression for the output variables using state " - + "variables. Outputs can be: :code:`alphaAngle`, :code:`betaAngle`, " - + ":code:`bet_NUM_omega` (NUM is the index of the BET disk starting from 0), " - + ":code:`theta`, :code:`omega` and :code:`omegaDot` (rotation angle/velocity/acceleration " - + "in radians for sliding interfaces), :code:`actuatorDisk__thrustMultiplier` " - + "and :code:`actuatorDisk__torqueMultiplier` (where is " - + "the name of the actuator disk entity). Please exercise caution when choosing output " - + "variables, as any modifications to their values will be directly mirrored in the solver.", - ) - state_vars_initial_value: List[StringExpression] = pd.Field( - description="The initial value of state variables are specified here. The entries could be either values " - + "(in the form of strings, e.g., :code:`0.0`) or expression with constants defined earlier or any input " - + "and output variable. (e.g., :code:`2.0 * alphaAngle + someConstant`). The list entries correspond to " - + "the initial values for :code:`state[0]`, :code:`state[1]`, ..., respectively." - ) - update_law: List[StringExpression] = pd.Field( - "List of expressions for updating state variables. The list entries correspond to the update laws for " - + ":code:`state[0]`, :code:`state[1]`, ..., respectively. These expressions follows similar guidelines as " - + ":ref:`user Defined Expressions`." - ) - input_boundary_patches: Optional[EntityList[Surface, MirroredSurface]] = pd.Field( - None, - description="The list of :class:`~flow360.Surface` entities to which the input variables belongs. " - + "If multiple boundaries are specified then the summation over the boundaries are used as the input. " - + "For input variables that already specified the source in the name (like bet_NUM_torque) " - + "this entry does not have any effect.", - ) - output_target: Optional[Union[Cylinder, GenericVolume, Surface, CustomVolume]] = pd.Field( - None, - description="The target to which the output variables belong to. For example this can be the rotating " - + "volume zone name. Only one output target is supported per user defined dynamics instance. Only " - + ":class:`~flow360.Cylinder` entity is supported as target for now.", - ) # Limited to `Cylinder` for now as we have only tested using UDD to control rotation. - - @contextual_field_validator("input_boundary_patches", mode="after") - @classmethod - def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure all boundaries will be present after mesher""" - return validate_entity_list_surface_existence(value, param_info) - - @contextual_field_validator("output_target", mode="after") - @classmethod - def ensure_output_surface_existence(cls, value, param_info: ParamsValidationInfo): - """Ensure that the output target surface is not a deleted surface""" - - # pylint: disable=fixme, duplicate-code - # TODO: We can make this a Surface's after model validator once entity info is separated from params. - # TODO: And therefore no need for duplicate-code override. - # pylint: disable=protected-access - if isinstance(value, Surface) and value._will_be_deleted_by_mesher( - entity_transformation_detected=param_info.entity_transformation_detected, - farfield_method=param_info.farfield_method, - global_bounding_box=param_info.global_bounding_box, - planar_face_tolerance=param_info.planar_face_tolerance, - half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y, - quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y, - farfield_domain_type=param_info.farfield_domain_type, - gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher, - ): - raise ValueError( - f"Boundary `{value.name}` will likely be deleted after mesh generation. Therefore it cannot be used." - ) - return value - - @contextual_field_validator("output_target", mode="after") - @classmethod - def _ensure_custom_volume_is_valid( - cls, - value: Optional[Union[GenericVolume, Cylinder, CustomVolume, SeedpointVolume]], - param_info: ParamsValidationInfo, - ): - """Ensure parent volume is a custom volume.""" - if value is None: - return value - if not isinstance(value, (CustomVolume, SeedpointVolume)): - return value - if value.name not in param_info.to_be_generated_custom_volumes: - raise ValueError( - f"Parent {type(value).__name__} {value.name} is not listed under meshing->volume_zones(or zones)" - + "->CustomZones." - ) - return value diff --git a/flow360/component/simulation/validation/validation_context.py b/flow360/component/simulation/validation/validation_context.py index c34e13c80..3e66008c5 100644 --- a/flow360/component/simulation/validation/validation_context.py +++ b/flow360/component/simulation/validation/validation_context.py @@ -1,1089 +1,43 @@ -# pylint: disable=too-many-lines -""" -Module for validation context handling in the simulation component of Flow360. - -This module provides context management for validation levels and specialized field -definitions for conditional validation scenarios. - -Features --------- -- This module allows for defining conditionally (context-based) required fields, - such as fields required only for specific scenarios like surface mesh or volume mesh. -- It supports running validation only for specific scenarios (surface mesh, volume mesh, or case), - allowing for targeted validation flows based on the current context. -- This module does NOT ignore validation errors; instead, it enriches errors with context - information, enabling downstream processes to filter and interpret errors based on scenario-specific requirements. -""" - -import inspect -from enum import Enum -from functools import wraps -from types import SimpleNamespace -from typing import Any, Callable, List, Literal, Union - -import pydantic as pd -from flow360_schema.framework.physical_dimensions import Length -from flow360_schema.framework.validation.context import ( - DeserializationContext, - _validation_info_ctx, - _validation_level_ctx, - _validation_warnings_ctx, +"""Validation context module — re-import relay.""" + +from flow360_schema.models.simulation.validation.validation_context import ( + ALL, + CASE, + SURFACE_MESH, + VOLUME_MESH, + CaseField, + ConditionalField, + ContextField, + FeatureUsageInfo, + ParamsValidationInfo, + TimeSteppingType, + ValidationContext, + add_validation_warning, + context_validator, + contextual_field_validator, + contextual_model_validator, + get_validation_info, + get_validation_levels, + get_value_with_path, ) -from pydantic import Field, TypeAdapter - -from flow360.component.simulation.utils import BoundingBoxType - -SURFACE_MESH = "SurfaceMesh" -VOLUME_MESH = "VolumeMesh" -CASE = "Case" -# when running validation with ALL, it will report errors happing in all scenarios in one validation pass -ALL = "All" - - -def get_value_with_path(param_as_dict: dict, path: list[str]): - """ - Get the value from the dictionary with the given path. - Return None if the path is not found. - """ - - value = param_as_dict - for key in path: - value = value.get(key, None) - if value is None: - return None - return value - - -class TimeSteppingType(Enum): - """ - Enum for time stepping type - - Attributes - ---------- - STEADY : str - Represents a steady simulation. - UNSTEADY : str - Represents an unsteady simulation. - UNSET : str - The time stepping is unset. - """ - - STEADY = "Steady" - UNSTEADY = "Unsteady" - UNSET = "Unset" - - -class FeatureUsageInfo: - """ - Model that provides the information for each individual feature usage. - """ - - # pylint: disable=too-few-public-methods - __slots__ = [ - "turbulence_model_type", - "transition_model_type", - "rotation_zone_count", - "bet_disk_count", - ] - - def __init__(self, param_as_dict: dict): - self.turbulence_model_type = None - self.transition_model_type = None - self.rotation_zone_count = 0 - self.bet_disk_count = 0 - - if "models" in param_as_dict and param_as_dict["models"]: - for model in param_as_dict["models"]: - if model["type"] == "Fluid": - self.turbulence_model_type = model.get("turbulence_model_solver", {}).get( - "type_name", None - ) - self.transition_model_type = model.get("transition_model_solver", {}).get( - "type_name", None - ) - - if model["type"] == "Rotation": - self.rotation_zone_count += 1 - - if model["type"] == "BETDisk": - self.bet_disk_count += 1 - - -class ParamsValidationInfo: # pylint:disable=too-few-public-methods,too-many-instance-attributes - """ - Model that provides the information for each individual validator that is out of their scope. - - This can be considered as a partially validated `SimulationParams`. - - - Why this model? - - -> Some validators needs information from other parts of the SimulationParams that is impossible to - get due to the information is out the scope of the validator. We can use a model validator on the - SimulationParams instead but then the validator implementation needs to represent the - structure of the SimulationParams and future feature change needs to be aware of this to make sure - the validation is performed. - E.g: All `Surface` entities needs to check if it will be deleted by the mesher depending - on mesher option (auto or quasi 3d). - """ - - __slots__ = [ - "farfield_method", - "farfield_domain_type", - "is_beta_mesher", - "use_geometry_AI", - "use_snappy", - "using_liquid_as_material", - "time_stepping", - "feature_usage", - "referenced_expressions", - "project_length_unit", - "global_bounding_box", - "planar_face_tolerance", - "output_dict", - "physics_model_dict", - "half_model_symmetry_plane_center_y", - "quasi_3d_symmetry_planes_center_y", - "entity_transformation_detected", - "to_be_generated_custom_volumes", - "farfield_enclosed_entities", - "root_asset_type", - # Entity expansion support - "_entity_info", # Owns the entities (keeps them alive), initialized eagerly - "_entity_registry", # References entities from _entity_info, initialized eagerly - "_selector_cache", # Lazy, populated as selectors are expanded - ] - - @classmethod - def _get_farfield_method_(cls, param_as_dict: dict): - meshing = param_as_dict.get("meshing") - if meshing is None: - # No meshing info. - return None - - if meshing["type_name"] == "MeshingParams": - volume_zones = meshing.get("volume_zones") - else: - volume_zones = meshing.get("zones") - if volume_zones: - has_custom_zones = False - for zone in volume_zones: - if zone["type"] == "AutomatedFarfield": - return zone["method"] - if zone["type"] == "UserDefinedFarfield": - return "user-defined" - if zone["type"] == "WindTunnelFarfield": - return "wind-tunnel" - if zone["type"] in ("CustomZones", "SeedpointVolume"): - has_custom_zones = True - if has_custom_zones: # CV + no FF => implicit UD - return "user-defined" - - return None - - @classmethod - def _get_farfield_domain_type_(cls, param_as_dict: dict): - try: - if param_as_dict["meshing"]: - volume_zones = param_as_dict["meshing"]["volume_zones"] - else: - return None - except KeyError: - return None - if not volume_zones: - return None - for zone in volume_zones: - if zone.get("type") in ( - "AutomatedFarfield", - "UserDefinedFarfield", - "WindTunnelFarfield", - ): - return zone.get("domain_type") - return None - - @classmethod - def _get_using_liquid_as_material_(cls, param_as_dict: dict): - try: - if param_as_dict["operating_condition"]: - return ( - param_as_dict["operating_condition"]["type_name"] == "LiquidOperatingCondition" - ) - except KeyError: - # No liquid operating condition info. - return False - return False - - @classmethod - def _get_is_beta_mesher_(cls, param_as_dict: dict): - try: - return param_as_dict["private_attribute_asset_cache"]["use_inhouse_mesher"] - except KeyError: - return False - - @classmethod - def _get_use_snappy_(cls, param_as_dict: dict): - if param_as_dict.get("meshing") and param_as_dict["meshing"].get("surface_meshing"): - return ( - param_as_dict["meshing"]["surface_meshing"]["type_name"] - == "SnappySurfaceMeshingParams" - ) - - return False - - @classmethod - def _get_use_geometry_AI_(cls, param_as_dict: dict): # pylint:disable=invalid-name - try: - return param_as_dict["private_attribute_asset_cache"]["use_geometry_AI"] - except KeyError: - return False - - @classmethod - def _get_time_stepping_(cls, param_as_dict: dict): - try: - if param_as_dict["time_stepping"]["type_name"] == "Unsteady": - return TimeSteppingType.UNSTEADY - return TimeSteppingType.STEADY - except KeyError: - return TimeSteppingType.UNSET - - @classmethod - def _get_feature_usage_info(cls, param_as_dict: dict): - # 1. Turbulence model type - # 2. Transition model type - # 3. Usage of Rotation zone - # 4. Usage of BETDisk - return FeatureUsageInfo(param_as_dict=param_as_dict) - - @classmethod - def _get_project_length_unit_(cls, param_as_dict: dict): - try: - project_length_unit_dict = param_as_dict["private_attribute_asset_cache"][ - "project_length_unit" - ] - if project_length_unit_dict: - # Serialized value is a bare float (SI), use DeserializationContext - # so the schema validator interprets it as SI meters. - adapter = TypeAdapter(Length.PositiveFloat64) - with DeserializationContext(): - return adapter.validate_python(project_length_unit_dict) - return None - except KeyError: - return None - - @classmethod - def _get_global_bounding_box(cls, param_as_dict: dict): - global_bounding_box = get_value_with_path( - param_as_dict, - ["private_attribute_asset_cache", "project_entity_info", "global_bounding_box"], - ) - if global_bounding_box: - # pylint: disable=no-member - return TypeAdapter(BoundingBoxType).validate_python(global_bounding_box) - return None - - @classmethod - def _get_planar_face_tolerance(cls, param_as_dict: dict): - planar_face_tolerance = None - if "meshing" in param_as_dict and param_as_dict["meshing"]: - if param_as_dict["meshing"]["type_name"] == "MeshingParams": - planar_face_tolerance = get_value_with_path( - param_as_dict, ["meshing", "defaults", "planar_face_tolerance"] - ) - else: - planar_face_tolerance = get_value_with_path( - param_as_dict, ["meshing", "volume_meshing", "planar_face_tolerance"] - ) - return planar_face_tolerance - - @classmethod - def _get_root_asset_type(cls, param_as_dict: dict): - """ - Returns root asset type based on project_entity_info.type_name - geometry -> GeometryEntityInfo - surface_mesh -> SurfaceMeshEntityInfo - volume_mesh -> VolumeMeshEntityInfo - """ - try: - pei = param_as_dict["private_attribute_asset_cache"]["project_entity_info"] - except KeyError: - return None - if pei is None: - return None - type_name = ( - pei.get("type_name") if isinstance(pei, dict) else getattr(pei, "type_name", None) - ) - if type_name == "GeometryEntityInfo": - return "geometry" - if type_name == "SurfaceMeshEntityInfo": - return "surface_mesh" - if type_name == "VolumeMeshEntityInfo": - return "volume_mesh" - return None - - @classmethod - def _get_half_model_symmetry_plane_center_y(cls, param_as_dict: dict): - ghost_entities = get_value_with_path( - param_as_dict, - ["private_attribute_asset_cache", "project_entity_info", "ghost_entities"], - ) - if not ghost_entities: - return None - for ghost_entity in ghost_entities: - if not ghost_entity["private_attribute_entity_type_name"] == "GhostCircularPlane": - continue - if ghost_entity["name"] == "symmetric": - return ghost_entity["center"][1] - return None - - @classmethod - def _get_quasi_3d_symmetry_planes_center_y(cls, param_as_dict: dict): - ghost_entities = get_value_with_path( - param_as_dict, - ["private_attribute_asset_cache", "project_entity_info", "ghost_entities"], - ) - if not ghost_entities: - return None - symmetric_1_center_y = None - symmetric_2_center_y = None - for ghost_entity in ghost_entities: - if not ghost_entity["private_attribute_entity_type_name"] == "GhostCircularPlane": - continue - if ghost_entity["name"] == "symmetric-1": - symmetric_1_center_y = ghost_entity["center"][1] - if ghost_entity["name"] == "symmetric-2": - symmetric_2_center_y = ghost_entity["center"][1] - if symmetric_1_center_y is None or symmetric_2_center_y is None: - return None - return (symmetric_1_center_y, symmetric_2_center_y) - - @classmethod - def _get_entity_transformation_detected( - cls, param_as_dict: dict - ): # pylint:disable=invalid-name - """ - Get the flag indicating if at least one body was transformed or mirrored. - This is used to skip boundary deletion/assignment checks since once translated - the bounding box as well as the boundary existence is no longer valid. - """ - # 1. Check for coordinate system transformations - coordinate_system_status_dict = get_value_with_path( - param_as_dict, ["private_attribute_asset_cache", "coordinate_system_status"] - ) - if coordinate_system_status_dict: - # Check if assignments list is non-empty - if coordinate_system_status_dict.get("assignments"): - return True - - # 2. Check for mirroring - mirror_status_dict = get_value_with_path( - param_as_dict, ["private_attribute_asset_cache", "mirror_status"] - ) - if mirror_status_dict: - # Check if either mirrored groups or surfaces list is non-empty - if mirror_status_dict.get("mirrored_geometry_body_groups") or mirror_status_dict.get( - "mirrored_surfaces" - ): - return True - - return False - - def _get_boundary_surface_ids(self, entity) -> set: - """Extract boundary surface IDs from a CustomVolume entity, expanding selectors if needed.""" - if entity.private_attribute_entity_type_name != "CustomVolume": - return set() - bounding_entities = getattr(entity, "bounding_entities", None) - if not bounding_entities: - return set() - # Expand selectors to get all boundary surfaces - expanded_entities = self.expand_entity_list(bounding_entities) - return {entity.private_attribute_id for entity in expanded_entities} - - def _get_to_be_generated_custom_volumes(self, param_as_dict: dict): - volume_zones = get_value_with_path( - param_as_dict, - ["meshing", "volume_zones"], - ) - - if not volume_zones: - volume_zones = get_value_with_path( - param_as_dict, - ["meshing", "zones"], - ) - - if not volume_zones: - return {} - - # Return a mapping: { custom_volume_name: {enforce_tetrahedra, boundary_surface_ids} } - custom_volume_info = {} - for zone in volume_zones: - if zone.get("type") != "CustomZones": - continue - enforce_tetrahedra = zone.get("element_type") == "tetrahedra" - stored_entities = zone.get("entities", {}).get("stored_entities", []) - - for entity in stored_entities: - if entity.private_attribute_entity_type_name not in ( - "CustomVolume", - "SeedpointVolume", - ): - continue - custom_volume_info[entity.name] = { - "enforce_tetrahedra": enforce_tetrahedra, - "boundary_surface_ids": self._get_boundary_surface_ids(entity), - } - return custom_volume_info - - def _get_farfield_enclosed_entities(self, param_as_dict: dict) -> dict[str, str]: - """Extract enclosed surface {id: name} from farfield zones. - - Only returns non-empty when a farfield zone has enclosed_entities set. - Expands selectors so that selector-only enclosed_entities inputs are handled. - """ - volume_zones = get_value_with_path(param_as_dict, ["meshing", "volume_zones"]) - if not volume_zones: - volume_zones = get_value_with_path(param_as_dict, ["meshing", "zones"]) - if not volume_zones: - return {} - - for zone in volume_zones: - if zone.get("type") not in ( - "AutomatedFarfield", - "UserDefinedFarfield", - "WindTunnelFarfield", - ): - continue - enclosed = zone.get("enclosed_entities") - if not enclosed: - return {} - # At this stage enclosed_entities is a dict with materialized stored_entities - # and optional selectors. Wrap as duck-typed object for expand_entity_list. - enclosed_obj = SimpleNamespace( - stored_entities=enclosed.get("stored_entities", []), - selectors=enclosed.get("selectors"), - ) - surfaces = self.expand_entity_list(enclosed_obj) - return {s.private_attribute_id: s.name for s in surfaces} - - return {} - - @property - def farfield_cv_dual_belonging_ids(self) -> set[str]: - """Surface IDs that appear in both farfield enclosed_entities and some CustomVolume bounding_entities.""" - if not self.farfield_enclosed_entities: - return set() - enclosed_ids = set(self.farfield_enclosed_entities.keys()) - cv_boundary_ids: set[str] = set() - for cv_info in self.to_be_generated_custom_volumes.values(): - cv_boundary_ids |= cv_info.get("boundary_surface_ids", set()) - return enclosed_ids & cv_boundary_ids - - def __init__(self, param_as_dict: dict, referenced_expressions: list): - self.farfield_method = self._get_farfield_method_(param_as_dict=param_as_dict) - self.farfield_domain_type = self._get_farfield_domain_type_(param_as_dict=param_as_dict) - self.is_beta_mesher = self._get_is_beta_mesher_(param_as_dict=param_as_dict) - self.use_geometry_AI = self._get_use_geometry_AI_( # pylint:disable=invalid-name - param_as_dict=param_as_dict - ) - self.use_snappy = self._get_use_snappy_(param_as_dict=param_as_dict) - self.using_liquid_as_material = self._get_using_liquid_as_material_( - param_as_dict=param_as_dict - ) - self.time_stepping = self._get_time_stepping_(param_as_dict=param_as_dict) - self.feature_usage = self._get_feature_usage_info(param_as_dict=param_as_dict) - self.referenced_expressions = referenced_expressions - self.project_length_unit = self._get_project_length_unit_(param_as_dict=param_as_dict) - self.global_bounding_box = self._get_global_bounding_box(param_as_dict=param_as_dict) - self.planar_face_tolerance = self._get_planar_face_tolerance(param_as_dict=param_as_dict) - # Initialized as None. When SimulationParams field validation succeeds, the - # field validators will populate these with validated objects. - # None = validation not yet complete (or failed) - # {} or {id: obj} = validation succeeded - self.output_dict = None - self.physics_model_dict = None - self.half_model_symmetry_plane_center_y = self._get_half_model_symmetry_plane_center_y( - param_as_dict=param_as_dict - ) - self.quasi_3d_symmetry_planes_center_y = self._get_quasi_3d_symmetry_planes_center_y( - param_as_dict=param_as_dict - ) - self.entity_transformation_detected = self._get_entity_transformation_detected( - param_as_dict=param_as_dict - ) - self.root_asset_type = self._get_root_asset_type(param_as_dict=param_as_dict) - - # Entity expansion support - # Eagerly deserialize entity_info and build registry (needed for selector expansion) - self._entity_info, self._entity_registry = self._build_entity_info_and_registry( - param_as_dict - ) - # Lazy initialization for selector-specific data - self._selector_cache = None - - # Must be after _entity_registry initialization (needs selector expansion) - self.to_be_generated_custom_volumes = self._get_to_be_generated_custom_volumes( - param_as_dict=param_as_dict - ) - self.farfield_enclosed_entities = self._get_farfield_enclosed_entities( - param_as_dict=param_as_dict - ) - - def will_generate_forced_symmetry_plane(self) -> bool: - """ - Check if the forced symmetry plane will be generated. - """ - return ( - self.use_geometry_AI - and self.is_beta_mesher - and self.farfield_domain_type in ("half_body_positive_y", "half_body_negative_y") - ) - - @classmethod - def _build_entity_info_and_registry(cls, param_as_dict: dict): - """Build entity_info and entity_registry from param_as_dict. - - The entity_info owns the deserialized entities, and entity_registry - holds references to them. - - Returns - ------- - tuple[EntityInfo, EntityRegistry] or (None, None) - The deserialized entity_info and registry, or (None, None) if not available. - """ - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.framework.entity_expansion_utils import ( - get_entity_info_and_registry_from_dict, - ) - - try: - return get_entity_info_and_registry_from_dict(param_as_dict) - except (KeyError, ValueError): - return None, None - - def _ensure_selector_cache(self): - """Lazily initialize selector cache.""" - if self._selector_cache is None: - self._selector_cache = {} - - def get_entity_info(self): - """Get the deserialized entity_info. - - This allows reusing the already-deserialized entity_info in the - SimulationParams constructor to avoid double deserialization. - - Returns - ------- - EntityInfo or None - The deserialized entity_info, or None if not available. - """ - return self._entity_info - - def get_entity_registry(self): - """Get the entity_registry. - - Returns - ------- - EntityRegistry or None - The entity_registry, or None if not available. - """ - return self._entity_registry - - def expand_entity_list(self, entity_list) -> list: - """ - Expand selectors in an EntityList and return the combined list of entities. - - This method performs on-demand expansion without modifying the original input. - Results are cached per selector to avoid recomputation across multiple validator calls. - - Why expand on-demand? - - This helps to avoid SimulationParams object being stateful (expanded vs not expanded) - With this function, we can always assume the entity_list is not expanded for all SimulationParams objects. - - Parameters - ---------- - entity_list : EntityList - A deserialized EntityList object with `stored_entities` and `selectors` attributes. - - Returns - ------- - list - Combined list of stored_entities and selector-matched entities. - Returns stored_entities directly if no selectors present. - """ - stored_entities = list(entity_list.stored_entities or []) - raw_selectors = entity_list.selectors or [] - - # Fast path: no selectors or no registry available - if not raw_selectors or self._entity_registry is None: - return stored_entities - - # Lazily initialize selector-specific infrastructure - self._ensure_selector_cache() - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.framework.entity_selector import ( - expand_entity_list_selectors, - ) - - return expand_entity_list_selectors( - self._entity_registry, - entity_list, - selector_cache=self._selector_cache, - merge_mode="merge", - ) - - -class ValidationContext: - """ - Context manager for setting the validation level and additional background. - - 1. Allows setting a specific validation level within a context, which influences - the conditional validation of fields based on the defined levels. - - 2. Allow associating additional information (usually info from the params) to serve as the - background for validators. - - Note: We cannot use Pydantic validation context - (see https://docs.pydantic.dev/latest/concepts/validators/#validation-context) because explicitly - defining constructor blocks context from passing in. - """ - - def __init__(self, levels: Union[str, List[str]], info: ParamsValidationInfo = None): - valid_levels = {SURFACE_MESH, VOLUME_MESH, CASE, ALL} - if isinstance(levels, str): - levels = [levels] - if ( - levels is None - or isinstance(levels, list) - and all(lvl in valid_levels for lvl in levels) - ): - self.levels = levels - self.level_token = None - self.validation_warnings = [] - else: - raise ValueError(f"Invalid validation level: {levels}") - - self.info = info - self.info_token = None - self.warnings_token = None - - def __enter__(self): - self.level_token = _validation_level_ctx.set(self.levels) - self.info_token = _validation_info_ctx.set(self.info) - self.warnings_token = _validation_warnings_ctx.set(self.validation_warnings) - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - _validation_level_ctx.reset(self.level_token) - _validation_info_ctx.reset(self.info_token) - if self.warnings_token is not None: - _validation_warnings_ctx.reset(self.warnings_token) - - -def get_validation_levels() -> list: - """ - Retrieves the current validation level from the context. - - Returns: - The current validation level, which can influence field validation behavior. - """ - return _validation_level_ctx.get() - - -def get_validation_info() -> ParamsValidationInfo: - """ - Retrieves the current validation background knowledge from the context. - - Returns: - The validation info, which can influence validation behavior. - """ - return _validation_info_ctx.get() - - -def add_validation_warning(message: str) -> None: - """ - Append a validation warning message to the active ValidationContext. - - Parameters - ---------- - message : str - Warning message to record. Converted to string if needed. - - Notes - ----- - No action is taken if there is no active ValidationContext. - """ - warnings_list = _validation_warnings_ctx.get() - if warnings_list is None: - return - message_str = str(message) - if any( - isinstance(existing, dict) and existing.get("msg") == message_str - for existing in warnings_list - ): - return - warnings_list.append( - { - "loc": (), - "msg": message_str, - "type": "value_error", - "ctx": {}, - } - ) - - -# pylint: disable=invalid-name -def ContextField( - default=None, *, context: Literal["SurfaceMesh", "VolumeMesh", "Case"] = None, **kwargs -): - """ - Creates a field with context validation (the field is not required). - - Parameters - ---------- - default : any, optional - The default value for the field. - context : str, optional - Specifies the scenario for which this field is relevant. The relevant_for=context is included in error->ctx - **kwargs : dict - Additional keyword arguments passed to Pydantic's Field. - - Returns - ------- - Field - A Pydantic Field configured with conditional validation context. - - Notes - ----- - Use this field for not required fields to provide context information during validation. - """ - return Field( - default, - json_schema_extra={"relevant_for": context, "conditionally_required": False}, - **kwargs, - ) - - -# pylint: disable=invalid-name -def ConditionalField( - default=None, - *, - context: Union[ - None, - Literal["SurfaceMesh", "VolumeMesh", "Case"], - List[Literal["SurfaceMesh", "VolumeMesh", "Case"]], - ] = None, - **kwargs, -): - """ - Creates a field with conditional context validation requirements. - - Parameters - ---------- - default : any, optional - The default value for the field. - context : Union[ - None, Literal['SurfaceMesh', 'VolumeMesh', 'Case'], - List[Literal['SurfaceMesh', 'VolumeMesh', 'Case']] - ], optional - Specifies the context(s) in which this field is relevant. This value is - included in the validation error context (`ctx`) as `relevant_for`. - **kwargs : dict - Additional keyword arguments passed to Pydantic's Field. - - Returns - ------- - Field - A Pydantic Field configured with conditional validation context. - - Notes - ----- - Use this field for required fields but only for certain scenarios, such as volume meshing only. - """ - return Field( - default, - json_schema_extra={"relevant_for": context, "conditionally_required": True}, - **kwargs, - ) - - -# pylint: disable=invalid-name -def CaseField(default=None, **kwargs): - """ - Creates a field specifically relevant for the Case scenario. - - Parameters - ---------- - default : any, optional - The default value for the field. - **kwargs : dict - Additional keyword arguments passed to the pd.Field(). - - Returns - ------- - Field - A Pydantic Field configured for fields relevant only to the Case scenario. - - Notes - ----- - Use this field for fields that are not required but make sense only for Case. - """ - return ContextField(default, context=CASE, **kwargs) - - -def context_validator(context: Literal["SurfaceMesh", "VolumeMesh", "Case"]): - """ - Decorator to conditionally run a validator based on the current validation context. - - This decorator runs the decorated validator function only if the current validation - level matches the specified context or if the validation level is set to ALL. - - Parameters - ---------- - context : Literal["SurfaceMesh", "VolumeMesh", "Case"] - The specific validation context in which the validator should be run. - - Returns - ------- - Callable - The decorated function that will only run when the specified context condition is met. - - Notes - ----- - This decorator is designed to be used with Pydantic model validators to ensure that - certain validations are only executed when the validation level matches the given context. - """ - - def decorator(func: Callable): - @wraps(func) - def wrapper(self: Any, *args, **kwargs): - current_levels = get_validation_levels() - # Run the validator only if the current levels matches the specified context or is ALL. - # When current_levels is None (no ValidationContext), skip context-aware validation - # — consistent with validate_conditionally_required_field and ConditionalField behavior. - if current_levels is not None and any(lvl in (context, ALL) for lvl in current_levels): - return func(self, *args, **kwargs) - return self - - return wrapper - - return decorator - - -# pylint: disable=invalid-name -def contextual_field_validator(*fields, mode="after", required_context=None, **kwargs): - """ - Wrapper around pydantic.field_validator that automatically skips validation - if get_validation_info() returns None or if required param_info attributes are None. - - This function accepts the same parameters as pydantic.field_validator and - returns a decorator that wraps the validator function. The validator will - be skipped if validation_info is not available or if any required attributes - in param_info are None. - - Parameters - ---------- - *fields : str - Field names to validate (same as pydantic.field_validator) - mode : str, optional - Validation mode: "before", "after", "wrap" (default: "after") - required_context : list of str, optional - List of ParamsValidationInfo attribute names that must be not None for validation to run. - If any of these attributes is None, the validator returns early without running. - Common values: ["output_dict"], ["physics_model_dict"], ["output_dict", "physics_model_dict"] - **kwargs : dict - Additional keyword arguments passed to pydantic.field_validator - - Returns - ------- - Callable - A decorator that wraps the validator function - - Usage - ----- - # Basic usage without requirements - @contextual_field_validator("volume_zones", mode="after") - @classmethod - def _check_volume_zones_have_unique_names(cls, v): - # No need to manually check get_validation_info() - # Validation logic here - return v - - # With required context attributes - @contextual_field_validator("monitor_output", mode="after", required_context=["output_dict"]) - @classmethod - def _check_monitor_exists_in_output_list(cls, v, param_info: ParamsValidationInfo): - # No need to manually check if output_dict is None - # param_info.output_dict is guaranteed to be not None here - if param_info.output_dict.get(v) is None: - raise ValueError("The monitor output does not exist in the outputs list.") - return v - - # With multiple required context attributes - @contextual_field_validator("models", mode="after", required_context=["output_dict", "physics_model_dict"]) - @classmethod - def _check_models_with_outputs(cls, v, param_info: ParamsValidationInfo): - # Both output_dict and physics_model_dict are guaranteed to be not None - # Validation logic here - return v - - Notes - ----- - This is equivalent to using pd.field_validator with manual checks: - @pd.field_validator("volume_zones", mode="after") - @classmethod - def _check_volume_zones_have_unique_names(cls, v, param_info: ParamsValidationInfo): - param_info = get_validation_info() - if param_info is None: - return v - if param_info.output_dict is None: - return v - # Validation logic here - return v - """ - - def decorator(func: Callable): - # Handle classmethod and staticmethod - is_classmethod = isinstance(func, classmethod) - is_staticmethod = isinstance(func, staticmethod) - - if is_classmethod: - original_func = func.__func__ - elif is_staticmethod: - original_func = func.__func__ - else: - original_func = func - - original_sig = inspect.signature(original_func) - pass_param_info = "param_info" in original_sig.parameters - new_signature = original_sig - original_signature_backup = getattr(original_func, "__signature__", None) - - if pass_param_info: - params_without = tuple( - param for name, param in original_sig.parameters.items() if name != "param_info" - ) - new_signature = original_sig.replace(parameters=params_without) - original_func.__signature__ = new_signature - - @wraps(original_func) - def wrapper(*args, **kwargs_inner): - param_info = get_validation_info() - if param_info is None: - if not args: - return None - # Determine the index of the value argument. - value_idx = 1 if isinstance(args[0], type) and len(args) >= 2 else 0 - return args[value_idx] - - # Check if required context attributes are available - if required_context: - for attr_name in required_context: - if not hasattr(param_info, attr_name): - raise ValueError(f"Invalid validation context attribute: {attr_name}") - if getattr(param_info, attr_name) is None: - # Required context attribute is None, skip validation - if not args: - return None - value_idx = 1 if isinstance(args[0], type) and len(args) >= 2 else 0 - return args[value_idx] - - # Call the original function (not the classmethod/staticmethod wrapper) - call_kwargs = dict(kwargs_inner) - if pass_param_info: - call_kwargs["param_info"] = param_info - return original_func(*args, **call_kwargs) - - if pass_param_info: - if original_signature_backup is None: - if hasattr(original_func, "__signature__"): - del original_func.__signature__ - else: - original_func.__signature__ = original_signature_backup - - # If original was classmethod/staticmethod, wrap so pydantic recognizes it - if is_classmethod: - wrapped_func = classmethod(wrapper) - elif is_staticmethod: - wrapped_func = staticmethod(wrapper) - else: - wrapped_func = wrapper - - return pd.field_validator(*fields, mode=mode, **kwargs)(wrapped_func) - - return decorator - - -# pylint: disable=invalid-name -def contextual_model_validator(mode="after", **kwargs): - """ - Wrapper around pydantic.model_validator that automatically skips validation - if get_validation_info() returns None. - - This function accepts the same parameters as pydantic.model_validator and - returns a decorator that wraps the validator function. The validator will - be skipped if validation_info is not available. - - Parameters - ---------- - mode : str, optional - Validation mode: "before", "after", "wrap" (default: "after") - **kwargs : dict - Additional keyword arguments passed to pydantic.model_validator - - Returns - ------- - Callable - A decorator that wraps the validator function - - Usage - ----- - @contextual_model_validator(mode="after") - def _check_no_reused_volume_entities(self): - # No need to manually check get_validation_info() - # Validation logic here - return self - - Notes - ----- - This is equivalent to using pd.model_validator with a manual check: - @pd.model_validator(mode="after") - def _check_no_reused_volume_entities(self): - if not get_validation_info(): - return self - # Validation logic here - return self - """ - - def decorator(func: Callable): - original_sig = inspect.signature(func) - pass_param_info = "param_info" in original_sig.parameters - new_signature = original_sig - original_signature_backup = getattr(func, "__signature__", None) - - if pass_param_info: - params_without = tuple( - param for name, param in original_sig.parameters.items() if name != "param_info" - ) - new_signature = original_sig.replace(parameters=params_without) - func.__signature__ = new_signature - - @wraps(func) - def wrapper(*args, **kwargs_inner): - param_info = get_validation_info() - if param_info is None: - if args: - return args[0] - return None - call_kwargs = dict(kwargs_inner) - if pass_param_info: - call_kwargs["param_info"] = param_info - return func(*args, **call_kwargs) - - if pass_param_info: - if original_signature_backup is None: - if hasattr(func, "__signature__"): - del func.__signature__ - else: - func.__signature__ = original_signature_backup - - return pd.model_validator(mode=mode, **kwargs)(wrapper) - return decorator +__all__ = [ + "ALL", + "CASE", + "SURFACE_MESH", + "VOLUME_MESH", + "CaseField", + "ConditionalField", + "ContextField", + "FeatureUsageInfo", + "ParamsValidationInfo", + "TimeSteppingType", + "ValidationContext", + "add_validation_warning", + "context_validator", + "contextual_field_validator", + "contextual_model_validator", + "get_validation_info", + "get_validation_levels", + "get_value_with_path", +] diff --git a/flow360/component/simulation/validation/validation_output.py b/flow360/component/simulation/validation/validation_output.py index b95216a14..093593b65 100644 --- a/flow360/component/simulation/validation/validation_output.py +++ b/flow360/component/simulation/validation/validation_output.py @@ -1,354 +1,16 @@ -""" -Validation for output parameters -""" - -import math -from typing import List, Literal, Union, get_args, get_origin - -from flow360_schema.framework.expression import Expression - -from flow360.component.simulation.models.volume_models import Fluid -from flow360.component.simulation.outputs.outputs import ( - AeroAcousticOutput, - ForceDistributionOutput, - ProbeOutput, - SurfaceIntegralOutput, - SurfaceProbeOutput, - TimeAverageForceDistributionOutput, +"""Relay import for output validation helpers.""" + +# pylint: disable=unused-import + +from flow360_schema.models.simulation.validation.validation_output import ( + _check_aero_acoustics_observer_time_step_size, + _check_local_cfl_output, + _check_moving_statistic_applicability, + _check_output_fields, + _check_output_fields_valid_given_transition_model, + _check_output_fields_valid_given_turbulence_model, + _check_unique_force_distribution_output_names, + _check_unique_surface_volume_probe_entity_names, + _check_unique_surface_volume_probe_names, + _check_unsteadiness_to_use_aero_acoustics, ) -from flow360.component.simulation.time_stepping.time_stepping import Steady -from flow360.component.simulation.validation.validation_utils import ( - customize_model_validator_error, -) - - -def _check_output_fields(params): - """Check the specified output fields for each output item is valid.""" - - # pylint: disable=too-many-branches - if params.outputs is None: - return params - - has_legacy_user_defined_field_in_surface_integral_output = False - for output in params.outputs: - if isinstance(output, SurfaceIntegralOutput): - for output_field in output.output_fields.items: - if isinstance(output_field, str): - has_legacy_user_defined_field_in_surface_integral_output = True - break - has_user_defined_fields = len(params.user_defined_fields) > 0 - - if ( - has_legacy_user_defined_field_in_surface_integral_output - and has_user_defined_fields is False - ): - raise ValueError( - "The legacy string output fields in `SurfaceIntegralOutput` must be used with `UserDefinedField`." - ) - - def extract_literal_values(annotation): - origin = get_origin(annotation) - if origin is Union: - # Traverse each Union argument - results = [] - for arg in get_args(annotation): - result = extract_literal_values(arg) - if result: - results.extend(result) - return results - if origin is list or origin is List: - # Apply the function to the List's element type - return extract_literal_values(get_args(annotation)[0]) - if origin is Literal: - return list(get_args(annotation)) - return [] - - additional_fields = [item.name for item in params.user_defined_fields] - - for output_index, output in enumerate(params.outputs): - if output.output_type in ( - "AeroAcousticOutput", - "StreamlineOutput", - "ForceDistributionOutput", - "TimeAverageForceDistributionOutput", - "RenderOutput", - ): - continue - # Get allowed output fields items: - natively_supported = extract_literal_values( - output.output_fields.__class__.model_fields["items"].annotation - ) - allowed_items = natively_supported + additional_fields - - for item in output.output_fields.items: - if isinstance(item, str) and item not in allowed_items: - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}:, {item} is not a" - f" valid output field name. Allowed fields are {allowed_items}." - ) - - if output.output_type == "IsosurfaceOutput": - # using the 1st item's allowed field as all isosurface have same field definition - allowed_items = ( - extract_literal_values( - output.entities.items[0].__class__.model_fields["field"].annotation - ) - + additional_fields - ) - for entity in output.entities.items: - if isinstance(entity.field, str) and entity.field not in allowed_items: - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}:, {entity.field} is not a" - f" valid iso field name. Allowed fields are {allowed_items}." - ) - - return params - - -def _check_output_fields_valid_given_turbulence_model(params): - """Ensure that the output fields are consistent with the turbulence model used.""" - - if not params.models or not params.outputs: - return params - - turbulence_model = None - - invalid_output_fields = { - "None": ("kOmega", "nuHat", "residualTurbulence", "solutionTurbulence"), - "SpalartAllmaras": ("kOmega"), - "kOmegaSST": ("nuHat"), - } - for model in params.models: - if isinstance(model, Fluid): - turbulence_model = model.turbulence_model_solver.type_name - break - - for output_index, output in enumerate(params.outputs): - if output.output_type in ( - "AeroAcousticOutput", - "StreamlineOutput", - "ForceDistributionOutput", - "TimeAverageForceDistributionOutput", - "RenderOutput", - ): - continue - for item in output.output_fields.items: - if isinstance(item, str) and item in invalid_output_fields[turbulence_model]: - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}: {item} is not a valid" - f" output field when using turbulence model: {turbulence_model}." - ) - - if output.output_type == "IsosurfaceOutput": - for entity in output.entities.items: - if ( - isinstance(entity.field, str) - and entity.field in invalid_output_fields[turbulence_model] - ): - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}: {entity.field} is not a valid" - f" iso field when using turbulence model: {turbulence_model}." - ) - return params - - -def _check_output_fields_valid_given_transition_model(params): - """Ensure that the output fields are consistent with the transition model used.""" - - if not params.models or not params.outputs: - return params - - transition_model = "None" - for model in params.models: - if isinstance(model, Fluid): - transition_model = model.transition_model_solver.type_name - break - - if transition_model != "None": - return params - - transition_output_fields = [ - "residualTransition", - "solutionTransition", - "linearResidualTransition", - ] - - for output_index, output in enumerate(params.outputs): - if output.output_type in ( - "AeroAcousticOutput", - "StreamlineOutput", - "ForceDistributionOutput", - "TimeAverageForceDistributionOutput", - "RenderOutput", - ): - continue - for item in output.output_fields.items: - if isinstance(item, str) and item in transition_output_fields: - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}: {item} is not a valid" - f" output field when transition model is not used." - ) - return params - - -def _check_unsteadiness_to_use_aero_acoustics(params): - - if not params.outputs: - return params - - if isinstance(params.time_stepping, Steady): - - for output_index, output in enumerate(params.outputs): - if isinstance(output, AeroAcousticOutput): - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}:" - "`AeroAcousticOutput` can only be activated with `Unsteady` simulation." - ) - # Not running case or is using unsteady - return params - - -def _check_local_cfl_output(params): - """localCFL output is only valid for unsteady simulations.""" - - if not params.outputs: - return params - - if not isinstance(params.time_stepping, Steady): - return params - - for output_index, output in enumerate(params.outputs): - if output.output_type in ( - "AeroAcousticOutput", - "StreamlineOutput", - "ForceDistributionOutput", - "TimeAverageForceDistributionOutput", - "RenderOutput", - ): - continue - for item in output.output_fields.items: - if isinstance(item, str) and item == "localCFL": - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}: " - "`localCFL` output is only supported for unsteady simulations." - ) - - return params - - -def _check_aero_acoustics_observer_time_step_size(params): - - if not params.outputs: - return params - - for output_index, output in enumerate(params.outputs): - if isinstance(output, AeroAcousticOutput): - time_step_size = params.time_stepping.step_size - if isinstance(params.time_stepping.step_size, Expression): - time_step_size = params.time_stepping.step_size.evaluate( - raise_on_non_evaluable=True, force_evaluate=True - ) - if output.observer_time_step_size and output.observer_time_step_size < time_step_size: - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}: " - f"`observer_time_size` ({output.observer_time_step_size}) is smaller than " - f"the time step size of CFD ({params.time_stepping.step_size})." - ) - return params - - -def _check_unique_surface_volume_probe_names(params): - - if not params.outputs: - return params - - active_probe_names = set() - - for output_index, output in enumerate(params.outputs): - if isinstance(output, (ProbeOutput, SurfaceProbeOutput)): - if output.name in active_probe_names: - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}: " - f"Output name {output.name} has already been used for a `ProbeOutput` " - "or `SurfaceProbeOutput`. Output names must be unique among all probe " - "outputs." - ) - active_probe_names.add(output.name) - - return params - - -def _check_unique_force_distribution_output_names(params): - - if not params.outputs: - return params - - active_names = set() - - for output_index, output in enumerate(params.outputs): - if isinstance(output, (ForceDistributionOutput, TimeAverageForceDistributionOutput)): - if output.name in active_names: - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}: " - f"Output name {output.name} has already been used for a `ForceDistributionOutput`. " - "Output names must be unique among all force distribution outputs." - ) - active_names.add(output.name) - - return params - - -def _check_unique_surface_volume_probe_entity_names(params): - - if not params.outputs: - return params - - for output_index, output in enumerate(params.outputs): - if isinstance(output, (ProbeOutput, SurfaceProbeOutput)): - active_entity_names = set() - for entity in output.entities.stored_entities: - if entity.name in active_entity_names: - raise ValueError( - f"In `outputs`[{output_index}] {output.output_type}: " - f"Entity name {entity.name} has already been used in the " - f"same `{output.output_type}`. Entity names must be unique." - ) - active_entity_names.add(entity.name) - - return params - - -def _check_moving_statistic_applicability(params): - - if not params.time_stepping: - return params - - if not params.outputs: - return params - - is_steady = isinstance(params.time_stepping, Steady) - max_steps = params.time_stepping.max_steps if is_steady else params.time_stepping.steps - - for output_index, output in enumerate(params.outputs): - if not hasattr(output, "moving_statistic") or output.moving_statistic is None: - continue - moving_window_size_in_step = ( - output.moving_statistic.moving_window_size * 10 - if is_steady - else output.moving_statistic.moving_window_size - ) - start_step = ( - math.ceil(output.moving_statistic.start_step / 10) * 10 - if is_steady - else output.moving_statistic.start_step - ) - if moving_window_size_in_step + start_step > max_steps: - raise customize_model_validator_error( - model_instance=params, - relative_location=("outputs", output_index, "moving_statistic"), - message="`moving_statistic`'s moving_window_size + start_step exceeds " - "the total number of steps in the simulation.", - input_value=output.moving_statistic.model_dump(), - ) - - return params diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 138afdd6c..c14863616 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -1,1006 +1,15 @@ -# pylint:disable = too-many-lines -""" -validation for SimulationParams -""" +"""Relay import for simulation parameter validation helpers.""" -from typing import Type, Union, get_args - -from flow360.component.simulation.draft_context.coordinate_system_manager import ( - CoordinateSystemManager, -) -from flow360.component.simulation.entity_operation import ( - _extract_scale_from_matrix, - _is_uniform_scale, -) -from flow360.component.simulation.meshing_param.params import ( - MeshingParams, - ModularMeshingWorkflow, -) -from flow360.component.simulation.meshing_param.volume_params import ( - CustomZones, - WindTunnelFarfield, -) -from flow360.component.simulation.models.material import Air -from flow360.component.simulation.models.solver_numerics import ( - KrylovLinearSolver, - NoneSolver, -) -from flow360.component.simulation.models.surface_models import ( - Inflow, - Outflow, - PorousJump, - SurfaceModelTypes, - Wall, +# pylint: disable=wildcard-import,unused-wildcard-import +from flow360_schema.models.simulation.validation.validation_simulation_params import * +from flow360_schema.models.simulation.validation.validation_simulation_params import ( + _check_coordinate_system_constraints, + _collect_farfield_custom_volume_interfaces, ) -from flow360.component.simulation.models.volume_models import ( - ActuatorDisk, - Fluid, - Rotation, - Solid, -) -from flow360.component.simulation.outputs.outputs import ( - IsosurfaceOutput, - ProbeOutput, - SliceOutput, - SurfaceOutput, - TimeAverageIsosurfaceOutput, - TimeAverageOutputTypes, - TimeAverageSurfaceOutput, - VolumeOutput, -) -from flow360.component.simulation.primitives import CustomVolume, SeedpointVolume -from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady -from flow360.component.simulation.utils import is_exact_instance -from flow360.component.simulation.validation.validation_context import ( - ALL, - CASE, - ParamsValidationInfo, - add_validation_warning, - get_validation_levels, -) -from flow360.component.simulation.validation.validation_utils import EntityUsageMap - - -def _populate_validated_field_to_validation_context(v, param_info, attribute_name): - """Populate validated objects to validation context. - - Sets the attribute to an empty dict {} when v is None or empty list, - distinguishing successful validation with no items from validation errors - (which leave the attribute as None). - """ - if v is None or len(v) == 0: - setattr(param_info, attribute_name, {}) - return v - setattr( - param_info, - attribute_name, - { - obj.private_attribute_id: obj - for obj in v - if hasattr(obj, "private_attribute_id") and obj.private_attribute_id is not None - }, - ) - return v - - -def _check_consistency_wall_function_and_surface_output(v): - models = v.models - - if models: - has_wall_function_model = False - for model in models: - if isinstance(model, Wall) and model.use_wall_function is not None: - has_wall_function_model = True - break - - if has_wall_function_model: - return v - - outputs = v.outputs - - if outputs is None: - return v - - for output in outputs: - if isinstance(output, SurfaceOutput): - if "wallFunctionMetric" in output.output_fields.items: - raise ValueError( - "To use 'wallFunctionMetric' for output specify a Wall model with use_wall_function=true. " - ) - - return v - - -def _check_duplicate_entities_in_models(params, param_info: ParamsValidationInfo): - if not params.models: - return params - - models = params.models - usage = EntityUsageMap() - - for model in models: - if hasattr(model, "entities"): - expanded_entities = param_info.expand_entity_list(model.entities) - # seen_entity_hashes: set[str] = set() - for entity in expanded_entities: - # # pylint: disable=protected-access - # entity_hash = entity._get_hash() - # if entity_hash in seen_entity_hashes: - # continue - # if entity_hash is not None: - # seen_entity_hashes.add(entity_hash) - usage.add_entity_usage(entity, model.type) - - error_msg = "" - for entity_type, entity_model_map in usage.dict_entity.items(): - for entity_info in entity_model_map.values(): - if len(entity_info["model_list"]) > 1: - model_set = set(entity_info["model_list"]) - model_string = ", ".join(f"`{x}`" for x in sorted(model_set)) - model_string += " models.\n" if len(model_set) > 1 else " model.\n" - error_msg += ( - f"{entity_type} entity `{entity_info['entity_name']}` " - + f"appears multiple times in {model_string}" - ) - - if error_msg: - raise ValueError(error_msg) - - return params - - -def _check_low_mach_preconditioner_output(v): - models = v.models - - if models: - has_low_mach_preconditioner = False - for model in models: - if isinstance(model, Fluid) and model.navier_stokes_solver: - preconditioner = model.navier_stokes_solver.low_mach_preconditioner - if preconditioner: - has_low_mach_preconditioner = True - break - - if has_low_mach_preconditioner: - return v - - outputs = v.outputs - - if not outputs: - return v - - for output in outputs: - if not hasattr(output, "output_fields"): - continue - if "lowMachPreconditionerSensor" in output.output_fields.items: - raise ValueError( - "Low-Mach preconditioner output requested, but low_mach_preconditioner is not enabled. " - "You can enable it via model.navier_stokes_solver.low_mach_preconditioner = True for a Fluid " - "model in the models field of the simulation object." - ) - - return v - - -def _check_numerical_dissipation_factor_output(v): - models = v.models - - if models: - low_dissipation_enabled = False - for model in models: - if isinstance(model, Fluid) and model.navier_stokes_solver: - numerical_dissipation_factor = ( - model.navier_stokes_solver.numerical_dissipation_factor - ) - low_dissipation_flag = int(round(1.0 / numerical_dissipation_factor)) - 1 - if low_dissipation_flag != 0: - low_dissipation_enabled = True - break - - if low_dissipation_enabled: - return v - - outputs = v.outputs - - if not outputs: - return v - - for output in outputs: - if not hasattr(output, "output_fields"): - continue - if "numericalDissipationFactor" in output.output_fields.items: - raise ValueError( - "Numerical dissipation factor output requested, but low dissipation mode is not enabled. " - "You can enable it via model.navier_stokes_solver.numerical_dissipation_factor = True for a Fluid " - "model in the models field of the simulation object." - ) - - return v - - -def _check_consistency_hybrid_model_volume_output(v): - model_type = None - models = v.models - - run_hybrid_model = False - - if models: - for model in models: - if isinstance(model, Fluid): - turbulence_model_solver = model.turbulence_model_solver - if ( - not isinstance(turbulence_model_solver, NoneSolver) - and turbulence_model_solver.hybrid_model is not None - ): - model_type = turbulence_model_solver.type_name - run_hybrid_model = True - break - - outputs = v.outputs - - if not outputs: - return v - - for output in outputs: - if isinstance(output, VolumeOutput) and output.output_fields is not None: - output_fields = output.output_fields.items - if "SpalartAllmaras_hybridModel" in output_fields and not ( - model_type == "SpalartAllmaras" and run_hybrid_model - ): - raise ValueError( - "SpalartAllmaras_hybridModel output can only be specified with " - "SpalartAllmaras turbulence model and hybrid RANS-LES used." - ) - if "kOmegaSST_hybridModel" in output_fields and not ( - model_type == "kOmegaSST" and run_hybrid_model - ): - raise ValueError( - "kOmegaSST_hybridModel output can only be specified with kOmegaSST turbulence model " - "and hybrid RANS-LES used." - ) - - return v - - -def _check_unsteadiness_to_use_hybrid_model(v): - models = v.models - - run_hybrid_model = False - - if models: - for model in models: - if isinstance(model, Fluid): - turbulence_model_solver = model.turbulence_model_solver - if ( - not isinstance(turbulence_model_solver, NoneSolver) - and turbulence_model_solver.hybrid_model is not None - ): - run_hybrid_model = True - break - - if run_hybrid_model and v.time_stepping is not None and isinstance(v.time_stepping, Steady): - raise ValueError("hybrid RANS-LES model can only be used in unsteady simulations.") - - return v - - -def _check_hybrid_model_to_use_zonal_enforcement(v): - models = v.models - if not models: - return v - - for model in models: - if isinstance(model, Fluid): - turbulence_model_solver = model.turbulence_model_solver - if not isinstance(turbulence_model_solver, NoneSolver): - if turbulence_model_solver.controls is None: - continue - for index, control in enumerate(turbulence_model_solver.controls): - if ( - control.enforcement is not None - and turbulence_model_solver.hybrid_model is None - ): - raise ValueError( - f"Control region {index} must be running in hybrid RANS-LES mode to " - "apply zonal turbulence enforcement." - ) - - return v - - -def _check_cht_solver_settings(params): - has_heat_transfer = False - - models = params.models - - if models: - for model in models: - if isinstance(model, Solid): - has_heat_transfer = True - - if has_heat_transfer is False: - params = _validate_cht_no_heat_transfer(params) - if has_heat_transfer is True: - params = _validate_cht_has_heat_transfer(params) - - return params - - -def _validate_cht_no_heat_transfer(params): - - if params.outputs: - for output in params.outputs: - if isinstance( - output, (SurfaceOutput, VolumeOutput, SliceOutput, ProbeOutput, IsosurfaceOutput) - ): - if "residualHeatSolver" in output.output_fields.items: - raise ValueError( - f"Heat equation output variables: residualHeatSolver is requested in {output.output_type} with" - " no `Solid` model defined." - ) - - return params - - -def _validate_cht_has_heat_transfer(params): - - time_stepping = params.time_stepping - if isinstance(time_stepping, Unsteady): - for model_solid in params.models: - if isinstance(model_solid, Solid): - if ( - model_solid.material.specific_heat_capacity is None - or model_solid.material.density is None - ): - raise ValueError( - "In `Solid` model -> material, both `specific_heat_capacity` and `density` " - "need to be specified for unsteady simulations." - ) - if model_solid.initial_condition is None: - raise ValueError( - "In `Solid` model, the initial condition needs to be specified " - "for unsteady simulations." - ) - return params - - -def _collect_volume_zones(params) -> list: - """Collect volume zones from meshing config in a schema-compatible way.""" - if isinstance(params.meshing, MeshingParams): - return params.meshing.volume_zones or [] - if isinstance(params.meshing, ModularMeshingWorkflow): - return params.meshing.zones or [] - return [] - - -def _collect_asset_boundary_entities(params, param_info: ParamsValidationInfo) -> tuple[list, bool]: - """Collect boundary entities that should be considered valid for BC completeness checks. - - This includes: - - Persistent boundaries from asset cache - - Farfield-related ghost boundaries, conditional on farfield method - - Wind tunnel ghost surfaces (when applicable) - - Returns: - tuple: (asset_boundary_entities, has_missing_private_attributes) - """ - # IMPORTANT: - # AssetCache.boundaries may return a direct reference into EntityInfo internal lists - # (e.g. GeometryEntityInfo.grouped_faces[*]). Always copy before appending to avoid - # mutating entity_info and corrupting subsequent serialization/validation. - asset_boundary_entities = list(params.private_attribute_asset_cache.boundaries or []) - farfield_method = params.meshing.farfield_method if params.meshing else None - has_missing_private_attributes = False - - if not farfield_method: - return asset_boundary_entities, has_missing_private_attributes - - # Check for legacy assets missing private_attributes before farfield-related processing - # This check is only relevant when we need bounding box information for farfield operations - # Only flag as legacy if ALL boundaries are missing private_attributes (not just some) - # AND the farfield method is one that performs automatic surface deletion (auto/quasi-3d/user-defined - # modes). For wind-tunnel farfield, missing BCs are always errors since no auto-deletion occurs - if ( - asset_boundary_entities - and farfield_method in ("auto", "quasi-3d", "quasi-3d-periodic", "user-defined") - and all( - getattr(item, "private_attributes", None) is None for item in asset_boundary_entities - ) - ): - has_missing_private_attributes = True - - # Filter out the ones that will be deleted by mesher (only when reliable) - if not param_info.entity_transformation_detected and not has_missing_private_attributes: - # pylint:disable=protected-access,duplicate-code - asset_boundary_entities = [ - item - for item in asset_boundary_entities - if item._will_be_deleted_by_mesher( - entity_transformation_detected=param_info.entity_transformation_detected, - farfield_method=farfield_method, - global_bounding_box=param_info.global_bounding_box, - planar_face_tolerance=param_info.planar_face_tolerance, - half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y, - quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y, - farfield_domain_type=param_info.farfield_domain_type, - gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher, - ) - is False - ] - - ghost_entities = getattr( - params.private_attribute_asset_cache.project_entity_info, "ghost_entities", [] - ) - - if farfield_method == "auto": - asset_boundary_entities += [ - item - for item in ghost_entities - if item.name in ("farfield", "symmetric") - and (param_info.entity_transformation_detected or item.exists(param_info)) - ] - elif farfield_method in ("quasi-3d", "quasi-3d-periodic"): - asset_boundary_entities += [ - item - for item in ghost_entities - if item.name in ("farfield", "symmetric-1", "symmetric-2") - ] - elif farfield_method == "user-defined": - if param_info.use_geometry_AI and param_info.is_beta_mesher: - asset_boundary_entities += [ - item - for item in ghost_entities - if item.name == "symmetric" - and (param_info.entity_transformation_detected or item.exists(param_info)) - ] - elif farfield_method == "wind-tunnel": - if param_info.will_generate_forced_symmetry_plane(): - asset_boundary_entities += [item for item in ghost_entities if item.name == "symmetric"] - # pylint: disable=protected-access - wind_tunnel = next( - z for z in params.meshing.volume_zones if isinstance(z, WindTunnelFarfield) - ) - asset_boundary_entities += WindTunnelFarfield._get_valid_ghost_surfaces( - wind_tunnel.floor_type.type_name, - wind_tunnel.domain_type, - ) - - return asset_boundary_entities, has_missing_private_attributes - - -def _collect_zone_zone_interfaces( - *, param_info: ParamsValidationInfo, volume_zones: list -) -> tuple[set, bool]: - """Collect potential zone-zone interfaces and snappy multizone flag.""" - snappy_multizone = False - potential_zone_zone_interfaces: set[str] = set() - - if param_info.farfield_method != "user-defined": - return potential_zone_zone_interfaces, snappy_multizone - - for zones in volume_zones: - # Support new CustomZones container - if not isinstance(zones, CustomZones): - continue - for custom_volume in zones.entities.stored_entities: - if isinstance(custom_volume, CustomVolume): - expanded = param_info.expand_entity_list(custom_volume.bounding_entities) - for boundary in expanded: - potential_zone_zone_interfaces.add(boundary.name) - if isinstance(custom_volume, SeedpointVolume): - # Disable missing boundaries with snappy multizone - snappy_multizone = True - - return potential_zone_zone_interfaces, snappy_multizone - - -def _collect_farfield_custom_volume_interfaces(*, param_info: ParamsValidationInfo) -> set[str]: - """Collect interface names for dual-belonging faces (farfield enclosed_entities ∩ CustomVolume bounding_entities). - - Returns names (not IDs) since _validate_boundary_completeness works with name sets. - """ - return { - param_info.farfield_enclosed_entities[sid] - for sid in param_info.farfield_cv_dual_belonging_ids - } - - -def _collect_used_boundary_names(params, param_info: ParamsValidationInfo) -> set: - """Collect all boundary names referenced in Surface BC models.""" - if len(params.models) == 1 and isinstance(params.models[0], Fluid): - raise ValueError("No boundary conditions are defined in the `models` section.") - - used_boundaries: set[str] = set() - - for model in params.models: - if not isinstance(model, get_args(SurfaceModelTypes)): - continue - if isinstance(model, PorousJump): - continue - - # pylint: disable=protected-access - if hasattr(model, "entities"): - entities = param_info.expand_entity_list(model.entities) - elif hasattr(model, "entity_pairs"): # Periodic BC - entities = [ - pair for surface_pair in model.entity_pairs.items for pair in surface_pair.pair - ] - else: - entities = [] - - for entity in entities: - used_boundaries.add(entity.name) - - return used_boundaries - - -def _validate_boundary_completeness( # pylint:disable=too-many-arguments - *, - asset_boundaries: set, - used_boundaries: set, - potential_zone_zone_interfaces: set, - snappy_multizone: bool, - entity_transformation_detected: bool, - has_missing_private_attributes: bool = False, - use_geometry_AI: bool = False, -) -> None: - """Validate missing/unknown boundary references with error/warning policy.""" - missing_boundaries = asset_boundaries - used_boundaries - potential_zone_zone_interfaces - unknown_boundaries = used_boundaries - asset_boundaries - - if missing_boundaries and not snappy_multizone: - missing_list = ", ".join(sorted(missing_boundaries)) - if entity_transformation_detected or has_missing_private_attributes or use_geometry_AI: - message = ( - f"The following boundaries do not have a boundary condition: {missing_list}. " - "If these boundaries are valid, please add them to a boundary condition model in the `models` section." - ) - add_validation_warning(message) - else: - message = ( - f"The following boundaries do not have a boundary condition: {missing_list}. " - "Please add them to a boundary condition model in the `models` section." - ) - raise ValueError(message) - - if unknown_boundaries: - unknown_list = ", ".join(sorted(unknown_boundaries)) - raise ValueError( - f"The following boundaries are not known `Surface` " - f"entities but appear in the `models` section: {unknown_list}." - ) - - -def _check_complete_boundary_condition_and_unknown_surface( - params, param_info -): # pylint:disable=too-many-branches, too-many-locals,too-many-statements - # Step 1: Determine whether this check should run - current_lvls = get_validation_levels() if get_validation_levels() else [] - if all(level not in current_lvls for level in (ALL, CASE)): - return params - - # Step 2: Collect asset boundaries - asset_boundary_entities, has_missing_private_attributes = _collect_asset_boundary_entities( - params, param_info - ) - if asset_boundary_entities is None or asset_boundary_entities == []: - raise ValueError("[Internal] Failed to retrieve asset boundaries") - - asset_boundaries = {boundary.name for boundary in asset_boundary_entities} - mirror_status = getattr(params.private_attribute_asset_cache, "mirror_status", None) - if mirror_status is not None and getattr(mirror_status, "mirrored_surfaces", None): - asset_boundaries |= {entity.name for entity in mirror_status.mirrored_surfaces} - - # Step 3: Compute special-case interfaces and used boundaries - volume_zones = _collect_volume_zones(params) - potential_zone_zone_interfaces, snappy_multizone = _collect_zone_zone_interfaces( - param_info=param_info, volume_zones=volume_zones - ) - potential_zone_zone_interfaces |= _collect_farfield_custom_volume_interfaces( - param_info=param_info - ) - used_boundaries = _collect_used_boundary_names(params, param_info) - - # Step 4: Validate set differences with policy - _validate_boundary_completeness( - asset_boundaries=asset_boundaries, - used_boundaries=used_boundaries, - potential_zone_zone_interfaces=potential_zone_zone_interfaces, - snappy_multizone=snappy_multizone, - entity_transformation_detected=param_info.entity_transformation_detected, - has_missing_private_attributes=has_missing_private_attributes, - use_geometry_AI=param_info.use_geometry_AI, - ) - - return params - - -def _check_parent_volume_is_rotating(models, param_info: ParamsValidationInfo): - - current_lvls = get_validation_levels() if get_validation_levels() else [] - if all(level not in current_lvls for level in (ALL, CASE)): - return models - - rotating_zone_names = { - entity.name - for model in models - if isinstance(model, Rotation) - for entity in (param_info.expand_entity_list(model.entities)) - } - - for model_index, model in enumerate(models): - if isinstance(model, Rotation) is False: - continue - if model.parent_volume is None: - continue - if model.parent_volume.name not in rotating_zone_names: - raise ValueError( - f"For model #{model_index}, the parent rotating volume ({model.parent_volume.name}) is not " - "used in any other `Rotation` model's `volumes`." - ) - return models - - -def _check_and_add_noninertial_reference_frame_flag(params): - - current_lvls = get_validation_levels() if get_validation_levels() else [] - if all(level not in current_lvls for level in (ALL, CASE)): - return params - - noninertial_reference_frame_default_value = True - is_steady = True - if isinstance(params.time_stepping, Unsteady): - noninertial_reference_frame_default_value = False - is_steady = False - - models = params.models - - for model_index, model in enumerate(models): - if isinstance(model, Rotation) is False: - continue - - if model.rotating_reference_frame_model is None: - model.rotating_reference_frame_model = noninertial_reference_frame_default_value - - if model.rotating_reference_frame_model is False and is_steady is True: - raise ValueError( - f"For model #{model_index}, the rotating_reference_frame_model may not be set to False " - "for steady state simulations." - ) - - return params - - -def _check_time_average_output(params): - if isinstance(params.time_stepping, Unsteady) or params.outputs is None: - return params - time_average_output_types = set() - for output in params.outputs: - if isinstance(output, TimeAverageOutputTypes): - time_average_output_types.add(output.output_type) - if len(time_average_output_types) > 0: - output_type_list = ",".join( - f"`{output_type}`" for output_type in sorted(time_average_output_types) - ) - output_type_list.strip(",") - raise ValueError(f"{output_type_list} can only be used in unsteady simulations.") - return params - - -def _check_valid_models_for_liquid(models, param_info): - if not models: - return models - if param_info.using_liquid_as_material is False: - return models - for model in models: - if isinstance(model, (Inflow, Outflow, Solid)): - raise ValueError( - f"`{model.type}` type model cannot be used when using liquid as simulation material." - ) - return models - - -def _check_duplicate_isosurface_names(outputs): - if outputs is None: - return outputs - isosurface_names = [] - isosurface_time_avg_names = [] - for output in outputs: - if isinstance(output, IsosurfaceOutput): - for entity in output.entities.items: - if entity.name == "qcriterion": - raise ValueError( - "The name `qcriterion` is reserved for the autovis isosurface from solver, " - "please rename the isosurface." - ) - if is_exact_instance(output, IsosurfaceOutput): - for entity in output.entities.items: - if entity.name in isosurface_names: - raise ValueError( - f"Another isosurface with name: `{entity.name}` already exists, please rename the isosurface." - ) - isosurface_names.append(entity.name) - if is_exact_instance(output, TimeAverageIsosurfaceOutput): - for entity in output.entities.items: - if entity.name in isosurface_time_avg_names: - raise ValueError( - "Another time average isosurface with name: " - f"`{entity.name}` already exists, please rename the isosurface." - ) - isosurface_time_avg_names.append(entity.name) - return outputs - - -def _check_duplicate_surface_usage(outputs, param_info: ParamsValidationInfo): - if outputs is None: - return outputs - - def _check_surface_usage( - outputs, output_type: Union[Type[SurfaceOutput], Type[TimeAverageSurfaceOutput]] - ): - surface_names = set() - for output in outputs: - if not is_exact_instance(output, output_type): - continue - for entity in param_info.expand_entity_list(output.entities): - if entity.name in surface_names: - raise ValueError( - f"The same surface `{entity.name}` is used in multiple `{output_type.__name__}`s." - " Please specify all settings for the same surface in one output." - ) - surface_names.add(entity.name) - - _check_surface_usage(outputs, SurfaceOutput) - _check_surface_usage(outputs, TimeAverageSurfaceOutput) - - return outputs - - -def _check_duplicate_actuator_disk_cylinder_names(models, param_info: ParamsValidationInfo): - if not models: - return models - - def _check_actuator_disk_names(models): - actuator_disk_names = set() - for model in models: - if not isinstance(model, ActuatorDisk): - continue - - for entity_index, entity in enumerate(param_info.expand_entity_list(model.entities)): - if entity.name in actuator_disk_names: - raise ValueError( - f"The ActuatorDisk cylinder name `{entity.name}` at index {entity_index}" - f" in model `{model.name}` has already been used." - " Please use unique Cylinder entity names among all ActuatorDisk instances." - ) - actuator_disk_names.add(entity.name) - - _check_actuator_disk_names(models) - - return models - - -def _check_unique_selector_names(params): - """Check that all EntitySelector names are unique across the entire SimulationParams. - - This validator checks the asset_cache.used_selectors field, which is populated - during the tokenization process in set_up_params_for_uploading(). - """ - asset_cache = getattr(params, "private_attribute_asset_cache", None) - if asset_cache is None: - return params - - used_selectors = getattr(asset_cache, "used_selectors", None) - if not used_selectors: - return params - - selector_names: set[str] = set() # name -> first occurrence info - - for selector in used_selectors: - selector_name = selector.name - if selector_name in selector_names: - raise ValueError( - f"Duplicate selector name '{selector_name}' found. " - f"Each selector must have a unique name." - ) - # Store location info for better error messages - selector_names.add(selector_name) - - return params - - -def _check_coordinate_system_constraints(params, param_info: ParamsValidationInfo): - """Validate coordinate system usage constraints. - - 1. GeometryBodyGroup assignments require GeometryAI to be enabled. - 2. Entities requiring uniform scaling (Box, Cylinder, AxisymmetricBody) - must not be assigned to coordinate systems with non-uniform scaling. - """ - coord_status = params.private_attribute_asset_cache.coordinate_system_status - - # No coordinate systems in use - if coord_status is None or not coord_status.assignments: - return params - - # Entity types requiring uniform scaling - uniform_scale_required_types = {"Box", "Cylinder", "AxisymmetricBody"} - - # Check 1: GAI requirement only for GeometryBodyGroup - has_geometry_body_group_assignment = False - for assignment_group in coord_status.assignments: - for entity_ref in assignment_group.entities: - if entity_ref.entity_type == "GeometryBodyGroup": - has_geometry_body_group_assignment = True - break - if has_geometry_body_group_assignment: - break - - if has_geometry_body_group_assignment and not param_info.use_geometry_AI: - raise ValueError( - "Coordinate system assignment to GeometryBodyGroup " - "is only supported when Geometry AI is enabled." - ) - - # Check 2: Early validation of uniform scaling for entities that require it - manager = CoordinateSystemManager._from_status( # pylint: disable=protected-access - status=coord_status - ) - - for assignment_group in coord_status.assignments: - # Get entities that require uniform scaling in this assignment - entities_requiring_uniform = [ - entity_ref - for entity_ref in assignment_group.entities - if entity_ref.entity_type in uniform_scale_required_types - ] - - if not entities_requiring_uniform: - continue - - # Get the coordinate system and its composed matrix - coord_sys = manager._get_coordinate_system_by_id( # pylint: disable=protected-access - assignment_group.coordinate_system_id - ) - if coord_sys is None: - continue # Should not happen if status is valid - - matrix = manager._get_coordinate_system_matrix( # pylint: disable=protected-access - coordinate_system=coord_sys - ) - - if not _is_uniform_scale(matrix): - scale_factors = _extract_scale_from_matrix(matrix) - entity_names = [f"{e.entity_type}:{e.entity_id}" for e in entities_requiring_uniform] - raise ValueError( - f"Coordinate system '{coord_sys.name}' has non-uniform scaling " - f"{scale_factors.tolist()}, which is incompatible with entities: " - f"{entity_names}. Box, Cylinder, and AxisymmetricBody only support " - f"uniform scaling." - ) - - return params - - -def _is_constant_gamma_coefficients(coefficients): - """ - Check if NASA 9-coefficient set represents constant gamma (calorically perfect gas). - - For constant gamma with CompressibleIsentropic solver, only a2 (index 2) should be non-zero. - All other coefficients (a0, a1, a3-a6, a7, a8) must be zero. - - cp/R = a0*T^-2 + a1*T^-1 + a2 + a3*T + a4*T^2 + a5*T^3 + a6*T^4 - - For constant cp compatible with the 4x4 isentropic solver, only a2 should be non-zero. - """ - tolerance = 1e-10 - # Check all coefficients except a2 (index 2) are zero - for i in range(9): - if i == 2: - continue # Skip a2, which should be non-zero - if abs(coefficients[i]) > tolerance: - return False - return True - - -def _has_temperature_dependent_coefficients(temperature_ranges): - """Check if any temperature range has non-constant-gamma coefficients.""" - for coeff_set in temperature_ranges: - if not _is_constant_gamma_coefficients(coeff_set.coefficients): - return True - return False - - -def _uses_compressible_isentropic_solver(params): - """Check if CompressibleIsentropic solver is being used.""" - if not params.models: - return False - for model in params.models: - if ( - isinstance(model, Fluid) - and model.navier_stokes_solver.type_name == "CompressibleIsentropic" - ): - return True - return False - - -def _get_air_material(params): - """Get Air material from operating condition, or None if not applicable.""" - if params.operating_condition is None: - return None - op = params.operating_condition - if not hasattr(op, "thermal_state") or op.thermal_state is None: - return None - material = op.thermal_state.material - if isinstance(material, Air): - return material - return None - - -def _material_has_temperature_dependent_gas(material): - """Check if Air material uses temperature-dependent gas properties.""" - # Check each species in the thermally perfect gas model - for species in material.thermally_perfect_gas.species: - if _has_temperature_dependent_coefficients(species.nasa_9_coefficients.temperature_ranges): - return True - return False - - -def _check_krylov_solver_restrictions(params): - """Validate that the Krylov solver is not used with incompatible settings.""" - models = params.models - if not models: - return params - - for model in models: - if not isinstance(model, Fluid): - continue - ns = model.navier_stokes_solver - if not isinstance(ns.linear_solver, KrylovLinearSolver): - continue - - if ns.limit_velocity: - raise ValueError( - "KrylovLinearSolver is not compatible with limit_velocity=True. " - "Please disable the velocity limiter when using the Krylov solver." - ) - if ns.limit_pressure_density: - raise ValueError( - "KrylovLinearSolver is not compatible with limit_pressure_density=True. " - "Please disable the pressure-density limiter when using the Krylov solver." - ) - if params.time_stepping is not None and isinstance(params.time_stepping, Unsteady): - raise ValueError( - "KrylovLinearSolver is not supported with Unsteady time stepping. " - "Please use Steady time stepping." - ) - - return params - - -def _check_tpg_not_with_isentropic_solver(params): - """ - Validate that temperature-dependent ThermallyPerfectGas is not used with CompressibleIsentropic solver. - - The CompressibleIsentropic solver (4x4 system) does not support true thermally perfect gas - models where cp varies with temperature. However, it does support constant gamma (CPG) - coefficients where only the a2 term is non-zero. - - Users must use the full Compressible solver when using temperature-dependent gas properties. - """ - if not _uses_compressible_isentropic_solver(params): - return params - - material = _get_air_material(params) - if material is None: - return params - if _material_has_temperature_dependent_gas(material): - raise ValueError( - "Temperature-dependent ThermallyPerfectGas model is not supported with the " - "CompressibleIsentropic solver. The CompressibleIsentropic solver uses a 4x4 system " - "that decouples the energy equation and requires constant gamma. " - "Only constant-gamma coefficients (where only a2 is non-zero) are allowed. " - "Please use type_name='Compressible' in NavierStokesSolver for thermally perfect gas simulations." - ) +_PRIVATE_EXPORTS = { + "_check_coordinate_system_constraints": _check_coordinate_system_constraints, + "_collect_farfield_custom_volume_interfaces": _collect_farfield_custom_volume_interfaces, +} - return params +__all__ = [name for name in globals() if not name.startswith("_")] + list(_PRIVATE_EXPORTS) diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index cf0cb06b5..93e4d29d3 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -1,518 +1,37 @@ -""" -validation utility functions -""" - -from functools import wraps -from typing import Any, Tuple, Union, get_args - -from flow360_schema.framework.expression import Expression, UserVariable -from pydantic import ValidationError -from pydantic_core import InitErrorDetails - -from flow360.component.simulation.entity_info import DraftEntityTypes -from flow360.component.simulation.outputs.output_fields import CommonFieldNames -from flow360.component.simulation.primitives import ( - ImportedSurface, - Surface, - _SurfaceEntityBase, - _VolumeEntityBase, +"""Validation utilities — re-import relay.""" + +# pylint: disable=unused-import + +from flow360_schema.models.simulation.validation.validation_utils import ( + EntityUsageMap, + _validator_append_instance_name, + check_deleted_surface_in_entity_list, + check_deleted_surface_pair, + check_geometry_ai_features, + check_ghost_surface_usage_policy_for_face_refinements, + check_symmetric_boundary_existence, + check_user_defined_farfield_symmetry_existence, + customize_model_validator_error, + get_surface_full_name, + has_coordinate_system_usage, + has_mirroring_usage, + validate_entity_list_surface_existence, + validate_improper_surface_field_usage_for_imported_surface, ) - -def _validator_append_instance_name(func): - """ - If the validation throw ValueError (expected), append the instance name to the error message. - """ - - def _get_prepend_message(type_object, instance_name): - if instance_name is None: - prepend_message = "In one of the " + type_object.__name__ - else: - prepend_message = f"{type_object.__name__} with name '{instance_name}'" - return prepend_message - - @wraps(func) - def wrapper(*args, **kwargs): - prepend_message = None - # for field validator - if len(args) == 3: - model_class = args[0] - validation_info = args[2] - if validation_info is not None: - name = validation_info.data.get("name", None) - prepend_message = _get_prepend_message(model_class, name) - else: - raise NotImplementedError( - "[Internal] Make sure your field_validator has validationInfo in the args or" - " this wrapper is used with a field_validator!!" - ) - # for model validator - elif len(args) == 1: - instance = args[0] - if "name" not in instance.model_dump(): - raise NotImplementedError("[Internal] Make sure the model has name field.") - prepend_message = _get_prepend_message(type(instance), instance.name) - else: - raise NotImplementedError( - f"[Internal] {_validator_append_instance_name.__qualname__} decorator only supports 1 or 3 arguments." - ) - - try: - result = func(*args, **kwargs) # Call the original function - return result - except ValueError as e: - raise ValueError(f"{prepend_message}: {str(e)}") from e - - return wrapper - - -def customize_model_validator_error( - model_instance, - relative_location: Tuple[Union[str, int], ...], - message: str, - input_value: Any = None, -): - """ - Create a Pydantic ValidationError with a custom field location. - - This function creates validation errors for model_validator that point to - specific fields in nested structures, making error messages more precise and useful. - - Args: - model_instance: The Pydantic model instance (e.g., self in a model_validator) - relative_location: Tuple specifying the field path relative to current model - e.g., ("field_name",) or ("outputs", 0, "output_fields", "items", 2) - message: The error message describing what went wrong - input_value: The invalid input value. If None, uses model_instance.model_dump() - - Returns: - ValidationError: A Pydantic ValidationError that can be raised or merged - - Example: - @model_validator(mode='after') - def validate_outputs(self): - if invalid_condition: - raise customized_model_validation_error( - self, - relative_location=("outputs", output_index, "output_fields", "items", item_index), - message=f"{item} is not a valid output field", - input_value=item - ) - return self - """ - - return ValidationError.from_exception_data( - title=model_instance.__class__.__name__, - line_errors=[ - InitErrorDetails( - type="value_error", - loc=relative_location, - input=input_value or model_instance.model_dump(), - ctx={"error": ValueError(message)}, - ) - ], - ) - - -def check_deleted_surface_in_entity_list(expanded_entities: list, param_info) -> None: - """ - Check if any boundary is meant to be deleted - value--> EntityList - """ - - # - Check if the surfaces are deleted. - deleted_boundaries = [] - for surface in expanded_entities: - if isinstance( - surface, Surface - ) and surface._will_be_deleted_by_mesher( # pylint:disable=protected-access - entity_transformation_detected=param_info.entity_transformation_detected, - farfield_method=param_info.farfield_method, - global_bounding_box=param_info.global_bounding_box, - planar_face_tolerance=param_info.planar_face_tolerance, - half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y, - quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y, - farfield_domain_type=param_info.farfield_domain_type, - gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher, - ): - deleted_boundaries.append(surface.name) - - if deleted_boundaries: - boundary_list = ", ".join(f"`{name}`" for name in deleted_boundaries) - plural = "Boundaries" if len(deleted_boundaries) > 1 else "Boundary" - raise ValueError( - f"{plural} {boundary_list} will likely be deleted after mesh generation. " - f"Therefore {'they' if len(deleted_boundaries) > 1 else 'it'} cannot be used." - ) - - -def validate_entity_list_surface_existence(value, param_info): - """ - Reusable validator to ensure all boundaries in an EntityList will be present after mesher. - - This function can be used in contextual_field_validator decorators to check that - surfaces in an entity list are not deleted by the mesher. - - Parameters - ---------- - value : EntityList or None - The entity list to validate - param_info : ParamsValidationInfo - Validation info containing entity expansion and mesher info - - Returns - ------- - The original value unchanged - - Raises - ------ - ValueError - If any surface in the list will be deleted by the mesher - """ - if value is None: - return value - expanded = param_info.expand_entity_list(value) - check_deleted_surface_in_entity_list(expanded, param_info) - return value - - -def check_deleted_surface_pair(value, param_info): - """ - Check if any boundary is meant to be deleted - value--> SurfacePair - """ - - # - Check if the surfaces are deleted. - deleted_boundaries = [] - for surface in value.pair: - if isinstance( - surface, Surface - ) and surface._will_be_deleted_by_mesher( # pylint:disable=protected-access - entity_transformation_detected=param_info.entity_transformation_detected, - farfield_method=param_info.farfield_method, - global_bounding_box=param_info.global_bounding_box, - planar_face_tolerance=param_info.planar_face_tolerance, - half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y, - quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y, - farfield_domain_type=param_info.farfield_domain_type, - gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher, - ): - deleted_boundaries.append(surface.name) - - if deleted_boundaries: - boundary_list = ", ".join(f"`{name}`" for name in deleted_boundaries) - plural = "Boundaries" if len(deleted_boundaries) > 1 else "Boundary" - raise ValueError( - f"{plural} {boundary_list} will likely be deleted after mesh generation. " - f"Therefore {'they' if len(deleted_boundaries) > 1 else 'it'} cannot be used." - ) - - return value - - -def check_user_defined_farfield_symmetry_existence(stored_entities, param_info): - """ - Ensure that when: - 1. UserDefinedFarfield is used - 2. GhostSurface(name="symmetric") is assigned - - That: - 1. GAI and beta mesher is used. - 2. Domain type is None (use auto detection), or explicitly set to half_body_positive_y, half_body_negative_y - """ - - if param_info.farfield_method != "user-defined": - return stored_entities - - for item in stored_entities: - if ( - item.private_attribute_entity_type_name != "GhostCircularPlane" - or item.name != "symmetric" - ): - continue - if not param_info.use_geometry_AI or not param_info.is_beta_mesher: - raise ValueError( - "Symmetry plane of user defined farfield will only be generated when both GAI and beta mesher are used." - ) - # If domain_type is not set, we attempt to automatically detect it from the bounding box. - if param_info.farfield_domain_type is None: - continue - if param_info.farfield_domain_type not in ( - "half_body_positive_y", - "half_body_negative_y", - ): - raise ValueError( - "Symmetry plane of user defined farfield is only supported for half body domains." - ) - return stored_entities - - -def check_symmetric_boundary_existence(stored_entities, param_info): - """For automated farfield, check according to the criteria if the symmetric plane exists.""" - for item in stored_entities: - if item.private_attribute_entity_type_name != "GhostCircularPlane": - continue - - if param_info.farfield_domain_type in ( - "half_body_positive_y", - "half_body_negative_y", - ): - continue - - if not item.exists(param_info): - # pylint: disable=protected-access - y_min, y_max, tolerance, largest_dimension = item._get_existence_dependency(param_info) - error_msg = ( - "`symmetric` boundary will not be generated: " - + f"model spans: [{y_min:.2g}, {y_max:.2g}], " - + f"tolerance = {param_info.planar_face_tolerance:.2g} x {largest_dimension:.2g}" - + f" = {tolerance:.2g}." - ) - - raise ValueError(error_msg) - - return stored_entities - - -def _ghost_surface_names(stored_entities) -> list[str]: - """Collect names of ghost-type boundaries in the list.""" - names = [] - for item in stored_entities: - entity_type = getattr(item, "private_attribute_entity_type_name", None) - if isinstance(entity_type, str) and entity_type.startswith("Ghost"): - names.append(getattr(item, "name", "")) - return names - - -def check_ghost_surface_usage_policy_for_face_refinements( - stored_entities, *, feature_name: str, param_info -): - """ - Enforce GhostSurface usage policy for face-based refinements (SurfaceRefinement, PassiveSpacing). - - Rules provided by product spec: - - If starting from Geometry, SurfaceRefinement and PassiveSpacing both can use GhostSurface, if: - - Automated farfield: if using beta mesher. - - User-defined farfield: if using GAI and beta mesher. - - If starting from Surface mesh: - - Automated farfield: allow GhostSurface for PassiveSpacing only. - - User-defined farfield: do not allow any GhostSurface. - """ - if not stored_entities: - return stored_entities - - ghost_names = _ghost_surface_names(stored_entities) - if not ghost_names: - return stored_entities - - root_asset_type = param_info.root_asset_type - farfield_method = param_info.farfield_method - use_beta = param_info.is_beta_mesher - use_gai = param_info.use_geometry_AI - - # Default error messages - def _err(msg): - raise ValueError(msg) - - names_str = ", ".join(sorted(set(ghost_names))) - - if root_asset_type == "geometry": - if farfield_method == "user-defined": - if not (use_beta and use_gai): - _err( - ( - f"Face refinements on '{names_str}' require both Geometry AI and the beta mesher " - "when using user-defined farfield." - ) - ) - else: - # automated variants (auto / quasi-3d / quasi-3d-periodic) - if not use_beta: - _err( - ( - f"Face refinements on '{names_str}' for automated farfield " - "requires beta mesher." - ) - ) - return stored_entities - - if root_asset_type == "surface_mesh": - if farfield_method == "user-defined": - _err( - ( - f"Boundary '{names_str}' is not allowed when starting from an uploaded surface mesh " - "with user-defined farfield." - ) - ) - # automated variants - if feature_name == "SurfaceRefinement": - _err( - ( - f"Boundary '{names_str}' is not allowed for SurfaceRefinement when starting from an " - "uploaded surface mesh with automated farfield." - ) - ) - return stored_entities - - # Other asset type: proceed without additional restriction - return stored_entities - - -class EntityUsageMap: # pylint:disable=too-few-public-methods - """ - A customized dict to store the entity name and its usage. - {"$EntityID": [$UsedInWhatModel]} - """ - - def __init__(self): - self.dict_entity = {"Surface": {}, "Volume": {}} - - @classmethod - def _get_entity_key(cls, entity) -> str: - """ - Get unique identifier for the entity. - """ - draft_entity_types = get_args(get_args(DraftEntityTypes)[0]) - if isinstance(entity, draft_entity_types): - return entity.private_attribute_id - return entity.name - - def add_entity_usage(self, entity, model_type): - """ - Add the entity usage to the dictionary. - """ - entity_type = None - if isinstance(entity, _SurfaceEntityBase): - entity_type = "Surface" - elif isinstance(entity, _VolumeEntityBase): - entity_type = "Volume" - else: - raise ValueError( - f"[Internal Error] Entity `{entity.name}` in the {model_type} model " - f"cannot be registered as a valid Surface or Volume entity." - ) - entity_key = self._get_entity_key(entity=entity) - entity_log = self.dict_entity[entity_type].get( - entity_key, {"entity_name": entity.name, "model_list": []} - ) - entity_log["model_list"].append(model_type) - self.dict_entity[entity_type][entity_key] = entity_log - - -def check_geometry_ai_features(cls, value, info, param_info): - """Ensure GAI features are not specified when GAI is not used""" - # pylint: disable=unsubscriptable-object - default_value = cls.model_fields[info.field_name].default - if value != default_value and not param_info.use_geometry_AI: - raise ValueError(f"{info.field_name} is only supported when geometry AI is used.") - - return value - - -def has_coordinate_system_usage(asset_cache) -> bool: - """Check if coordinate system feature is being used.""" - if asset_cache is None: - return False - coordinate_system_status = asset_cache.coordinate_system_status - if coordinate_system_status and coordinate_system_status.assignments: - return True - return False - - -def has_mirroring_usage(asset_cache) -> bool: - """Check if mirroring feature is being used.""" - if asset_cache is None: - return False - mirror_status = asset_cache.mirror_status - if mirror_status: - if mirror_status.mirrored_geometry_body_groups or mirror_status.mirrored_surfaces: - return True - return False - - -def validate_improper_surface_field_usage_for_imported_surface( - expanded_entities: list, output_fields -): - """ - Validate output fields when using imported surfaces. - Ensures that: - - String format output fields are only CommonFieldNames - - UserVariable expressions only contain Volume type solver variables - - Parameters - ---------- - expanded_entities : list - List of expanded entities (surfaces) - output_fields : UniqueItemList - List of output fields to validate - - Raises - ------ - ValueError - If any output field is not compatible with imported surfaces - """ - - # Check if any entity is an ImportedSurface - has_imported_surface = any(isinstance(entity, ImportedSurface) for entity in expanded_entities) - - if not has_imported_surface: - return - - # Get valid common field names - valid_common_fields = get_args(CommonFieldNames) - - # Validate each output field - for output_item in output_fields.items: - # Check string fields - if isinstance(output_item, str): - if output_item not in valid_common_fields: - raise ValueError( - f"Output field '{output_item}' is not allowed for imported surfaces. " - "Only non-Surface field names are allowed for string format output fields " - "when using imported surfaces." - ) - # Check UserVariable fields - elif isinstance(output_item, UserVariable) and isinstance(output_item.value, Expression): - surface_solver_variable_names = output_item.value.solver_variable_names( - recursive=True, variable_type="Surface" - ) - # Allow node_unit_normal surface variable for imported surfaces - disallowed_surface_vars = [ - var for var in surface_solver_variable_names if var != "solution.node_unit_normal" - ] - if len(disallowed_surface_vars) > 0: - raise ValueError( - f"Variable `{output_item}` cannot be used with imported surfaces " - f"since it contains Surface type solver variable(s): " - f"{', '.join(sorted(disallowed_surface_vars))}. " - "Only Volume type solver variables and 'solution.node_unit_normal' are allowed." - ) - - -def get_surface_full_name(entity, context: str = "force distribution output") -> str: - """ - Get the full_name from a surface entity. - - Parameters - ---------- - entity : Surface-like - An entity that should have a full_name attribute. - context : str - Description of where this is being used, for error messages. - - Returns - ------- - str - The full_name of the entity. - - Raises - ------ - TypeError - If the entity doesn't have a full_name attribute. - """ - if not hasattr(entity, "full_name"): - raise TypeError( - f"Unsupported entity type '{type(entity).__name__}' for {context}. " - f"Only Surface entities are supported." - ) - return entity.full_name +__all__ = [ + "EntityUsageMap", + "_validator_append_instance_name", + "check_deleted_surface_in_entity_list", + "check_deleted_surface_pair", + "check_geometry_ai_features", + "check_ghost_surface_usage_policy_for_face_refinements", + "check_symmetric_boundary_existence", + "check_user_defined_farfield_symmetry_existence", + "customize_model_validator_error", + "get_surface_full_name", + "has_coordinate_system_usage", + "has_mirroring_usage", + "validate_entity_list_surface_existence", + "validate_improper_surface_field_usage_for_imported_surface", +] diff --git a/poetry.lock b/poetry.lock index c40b5b1b2..769871375 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1468,14 +1468,14 @@ files = [ [[package]] name = "flow360-schema" -version = "0.1.24" +version = "0.1.25" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.24-py3-none-any.whl", hash = "sha256:ce44c694d88ffb42316f262603b3b1c4ea02e46fd8416564c8fa649fc5b825ab"}, - {file = "flow360_schema-0.1.24.tar.gz", hash = "sha256:d5ad3ecc5046976c9791d9530ec13d025298e906f73be26ed2f676f556c25e7f"}, + {file = "flow360_schema-0.1.25-py3-none-any.whl", hash = "sha256:4c9897a4f1b53df16615a0a4a0e69960cfd94d479acb3fe00f17d6b9be6d668b"}, + {file = "flow360_schema-0.1.25.tar.gz", hash = "sha256:81c1518f09559748fec8c4cf6715c52f3fa81a4d7008021faeb662b39a88a925"}, ] [package.dependencies] diff --git a/tests/ref/simulation/service_init_geometry.json b/tests/ref/simulation/service_init_geometry.json index 866101ad1..6381799ec 100644 --- a/tests/ref/simulation/service_init_geometry.json +++ b/tests/ref/simulation/service_init_geometry.json @@ -300,7 +300,9 @@ "CfVec" ] }, - "output_format": "paraview", + "output_format": [ + "paraview" + ], "output_type": "SurfaceOutput", "write_single_file": false } diff --git a/tests/ref/simulation/service_init_surface_mesh.json b/tests/ref/simulation/service_init_surface_mesh.json index 780fc8e33..728c47be1 100644 --- a/tests/ref/simulation/service_init_surface_mesh.json +++ b/tests/ref/simulation/service_init_surface_mesh.json @@ -300,7 +300,9 @@ "CfVec" ] }, - "output_format": "paraview", + "output_format": [ + "paraview" + ], "output_type": "SurfaceOutput", "write_single_file": false } diff --git a/tests/ref/simulation/service_init_volume_mesh.json b/tests/ref/simulation/service_init_volume_mesh.json index 70308e46e..81059ed4a 100644 --- a/tests/ref/simulation/service_init_volume_mesh.json +++ b/tests/ref/simulation/service_init_volume_mesh.json @@ -261,7 +261,9 @@ "CfVec" ] }, - "output_format": "paraview", + "output_format": [ + "paraview" + ], "output_type": "SurfaceOutput", "write_single_file": false } diff --git a/tests/simulation/ref/simulation_with_project_variables.json b/tests/simulation/ref/simulation_with_project_variables.json index 32d500aa1..eb2d167b6 100644 --- a/tests/simulation/ref/simulation_with_project_variables.json +++ b/tests/simulation/ref/simulation_with_project_variables.json @@ -246,7 +246,9 @@ } ] }, - "output_format": "paraview", + "output_format": [ + "paraview" + ], "output_type": "VolumeOutput", "private_attribute_id": "000" }, From 2e212aa2ba4a721d8afaada6b99c3e40b49b2d14 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:18:19 -0400 Subject: [PATCH 14/25] Remove simulation tests migrated to flow360-schema (#1994) --- .../framework/test_base_model_v2.py | 99 - .../framework/test_boundary_split.py | 166 -- .../test_boundary_split_rotation_bug.py | 179 -- .../framework/test_context_validators.py | 241 -- .../framework/test_multi_constructor_model.py | 151 +- .../framework/test_single_attribute_model.py | 28 - .../simulation/framework/test_unique_list.py | 167 -- .../framework/test_version_parser.py | 91 - .../outputs/test_filename_string.py | 130 - .../outputs/test_output_entities.py | 285 --- .../simulation/outputs/test_output_fields.py | 186 -- .../test_meshing_param_validation.py | 2275 +---------------- .../test_refinements_validation.py | 276 +- tests/simulation/params/test_actuator_disk.py | 139 +- .../params/test_automated_farfield.py | 127 - .../params/test_bet_disk_from_file.py | 148 -- tests/simulation/params/test_gravity.py | 148 +- .../test_meshing_defaults_deprecation.py | 19 - .../test_output_at_final_pseudo_step_only.py | 176 -- tests/simulation/params/test_porous_medium.py | 30 - .../test_rotating_boundaries_metadata.py | 592 ----- tests/simulation/params/test_rotation.py | 118 - .../params/test_simulation_params.py | 607 +---- .../params/test_validators_criterion.py | 451 +--- .../params/test_validators_material.py | 463 ---- .../params/test_validators_output.py | 1654 +----------- .../params/test_validators_params.py | 829 ------ tests/simulation/test_coordinate_system.py | 72 - tests/simulation/test_expressions.py | 25 - tests/simulation/test_krylov_solver.py | 209 +- .../test_reference_geometry_defaults.py | 33 - .../test_transformation_deprecation.py | 26 - tests/simulation/test_updater.py | 2204 +--------------- tests/simulation/test_validation_context.py | 249 -- tests/simulation/test_validation_utils.py | 1355 ---------- tests/simulation/test_value_or_expression.py | 12 - .../simulation/test_variable_context_skip.py | 32 - .../test_coordinate_system_constraints.py | 463 ---- 38 files changed, 111 insertions(+), 14344 deletions(-) delete mode 100644 tests/simulation/framework/test_base_model_v2.py delete mode 100644 tests/simulation/framework/test_boundary_split.py delete mode 100644 tests/simulation/framework/test_boundary_split_rotation_bug.py delete mode 100644 tests/simulation/framework/test_context_validators.py delete mode 100644 tests/simulation/framework/test_single_attribute_model.py delete mode 100644 tests/simulation/framework/test_unique_list.py delete mode 100644 tests/simulation/framework/test_version_parser.py delete mode 100644 tests/simulation/outputs/test_filename_string.py delete mode 100644 tests/simulation/outputs/test_output_entities.py delete mode 100644 tests/simulation/outputs/test_output_fields.py delete mode 100644 tests/simulation/params/test_bet_disk_from_file.py delete mode 100644 tests/simulation/params/test_meshing_defaults_deprecation.py delete mode 100644 tests/simulation/params/test_output_at_final_pseudo_step_only.py delete mode 100644 tests/simulation/params/test_porous_medium.py delete mode 100644 tests/simulation/params/test_rotating_boundaries_metadata.py delete mode 100644 tests/simulation/params/test_rotation.py delete mode 100644 tests/simulation/params/test_validators_material.py delete mode 100644 tests/simulation/test_coordinate_system.py delete mode 100644 tests/simulation/test_reference_geometry_defaults.py delete mode 100644 tests/simulation/test_transformation_deprecation.py delete mode 100644 tests/simulation/test_validation_context.py delete mode 100644 tests/simulation/test_validation_utils.py delete mode 100644 tests/simulation/test_variable_context_skip.py delete mode 100644 tests/simulation/validation/test_coordinate_system_constraints.py diff --git a/tests/simulation/framework/test_base_model_v2.py b/tests/simulation/framework/test_base_model_v2.py deleted file mode 100644 index 6798387ad..000000000 --- a/tests/simulation/framework/test_base_model_v2.py +++ /dev/null @@ -1,99 +0,0 @@ -import json -import os -import tempfile - -import pydantic as pd -import pytest - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.log import set_logging_level - -set_logging_level("DEBUG") - - -class BaseModelTestModel(Flow360BaseModel): - some_value: pd.StrictFloat = pd.Field() - - model_config = pd.ConfigDict(include_hash=True) - - def preprocess(self, **kwargs): - self.some_value *= 2 - return super().preprocess(**kwargs) - - -class TempParams(Flow360BaseModel): - some_value: pd.StrictFloat - pseudo_field: BaseModelTestModel - - def preprocess(self, **kwargs): - return super().preprocess(**kwargs) - - -def test_help(): - Flow360BaseModel().help() - Flow360BaseModel().help(methods=True) - - -def test_copy(): - base_model = BaseModelTestModel(some_value=123) - base_model_copy = base_model.copy() - assert base_model_copy.some_value == 123 - base_model.some_value = 12345 - assert base_model_copy.some_value == 123 - - -def test_from_file(): - file_content = {"some_value": 321} - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as temp_file: - json.dump(file_content, temp_file) - temp_file.flush() - temp_file_name = temp_file.name - - try: - base_model = BaseModelTestModel.from_file(temp_file_name) - assert base_model.some_value == 321 - finally: - os.remove(temp_file_name) - - -def test_dict_from_file(): - file_content = { - "some_value": 3210, - "hash": "e6d346f112fc2ba998a286f9736ce389abb79f154dc84a104d3b4eb8ba4d4529", - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as temp_file: - json.dump(file_content, temp_file) - temp_file.flush() - temp_file_name = temp_file.name - - try: - base_model_dict = BaseModelTestModel._dict_from_file(temp_file_name) - assert base_model_dict["some_value"] == 3210 - finally: - os.remove(temp_file_name) - - -def test_to_file(): - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as temp_file: - base_model = BaseModelTestModel(some_value=1230) - temp_file_name = temp_file.name - - try: - base_model.to_file(temp_file_name) - with open(temp_file_name) as fp: - base_model_dict = json.load(fp) - assert base_model_dict["some_value"] == 1230 - assert "hash" in base_model_dict - finally: - os.remove(temp_file_name) - - -def test_preprocess(): - value = 123 - test_params = TempParams(pseudo_field=BaseModelTestModel(some_value=value), some_value=value) - test_params = test_params.preprocess(params=test_params) - assert test_params.some_value == value - assert test_params.pseudo_field.some_value == value * 2 diff --git a/tests/simulation/framework/test_boundary_split.py b/tests/simulation/framework/test_boundary_split.py deleted file mode 100644 index 420980555..000000000 --- a/tests/simulation/framework/test_boundary_split.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Tests for boundary_split.py - BoundaryNameLookupTable and related functions. -""" - -import pytest - -from flow360.component.simulation.framework.boundary_split import ( - BoundaryNameLookupTable, - SplitType, - update_entities_in_model, -) -from flow360.component.simulation.primitives import Surface - - -class TestBoundaryNameLookupTable: - """Tests for BoundaryNameLookupTable.""" - - @pytest.fixture - def multizone_mesh_metadata(self): - """Mesh metadata for a multi-zone mesh (e.g., rotating sphere case).""" - return { - "zones": { - "farfieldBlock": { - "boundaryNames": [ - "farfieldBlock/farfield", - "farfieldBlock/rotationInterface", - ], - }, - "nearfieldBlock": { - "boundaryNames": [ - "nearfieldBlock/ball", - "nearfieldBlock/rotationInterface", - ], - }, - } - } - - @pytest.fixture - def single_zone_mesh_metadata(self): - """Mesh metadata for a single-zone mesh.""" - return { - "zones": { - "fluid": { - "boundaryNames": [ - "fluid/wing", - "fluid/fuselage", - "fluid/farfield", - ], - }, - } - } - - def test_lookup_by_base_name(self, multizone_mesh_metadata): - """Test that lookup by base_name works (original behavior).""" - lookup_table = BoundaryNameLookupTable(multizone_mesh_metadata) - - # Lookup by base name should find the full name - split_infos = lookup_table.get_split_info("farfield") - assert len(split_infos) == 1 - assert split_infos[0].full_name == "farfieldBlock/farfield" - assert split_infos[0].split_type == SplitType.ZONE_PREFIX - - split_infos = lookup_table.get_split_info("ball") - assert len(split_infos) == 1 - assert split_infos[0].full_name == "nearfieldBlock/ball" - - def test_lookup_by_full_name_passthrough(self, multizone_mesh_metadata): - """ - Test that lookup by full_name works (passthrough behavior). - - This is the bug fix case: when entity.name is already a full_name - (e.g., from volume mesh), the lookup should still succeed. - """ - lookup_table = BoundaryNameLookupTable(multizone_mesh_metadata) - - # Lookup by full name should also work (passthrough) - split_infos = lookup_table.get_split_info("farfieldBlock/farfield") - assert len(split_infos) == 1 - assert split_infos[0].full_name == "farfieldBlock/farfield" - - split_infos = lookup_table.get_split_info("nearfieldBlock/ball") - assert len(split_infos) == 1 - assert split_infos[0].full_name == "nearfieldBlock/ball" - - def test_lookup_multizone_same_base_name(self, multizone_mesh_metadata): - """ - Test lookup when the same base_name appears in multiple zones. - - rotationInterface appears in both farfieldBlock and nearfieldBlock. - """ - lookup_table = BoundaryNameLookupTable(multizone_mesh_metadata) - - # Base name lookup should return both - split_infos = lookup_table.get_split_info("rotationInterface") - assert len(split_infos) == 2 - - full_names = {info.full_name for info in split_infos} - assert full_names == { - "farfieldBlock/rotationInterface", - "nearfieldBlock/rotationInterface", - } - - # At least one should be MULTI_ZONE type - split_types = {info.split_type for info in split_infos} - assert SplitType.MULTI_ZONE in split_types - - def test_lookup_nonexistent_name(self, multizone_mesh_metadata): - """Test that lookup for nonexistent name returns empty list.""" - lookup_table = BoundaryNameLookupTable(multizone_mesh_metadata) - - split_infos = lookup_table.get_split_info("nonexistent") - assert split_infos == [] - - def test_single_zone_lookup(self, single_zone_mesh_metadata): - """Test lookup in single-zone mesh.""" - lookup_table = BoundaryNameLookupTable(single_zone_mesh_metadata) - - # Base name lookup - split_infos = lookup_table.get_split_info("wing") - assert len(split_infos) == 1 - assert split_infos[0].full_name == "fluid/wing" - - # Full name lookup (passthrough) - split_infos = lookup_table.get_split_info("fluid/wing") - assert len(split_infos) == 1 - assert split_infos[0].full_name == "fluid/wing" - - def test_lookup_trims_leading_and_trailing_whitespace(self, single_zone_mesh_metadata): - """Test that leading/trailing whitespace is ignored during lookup.""" - lookup_table = BoundaryNameLookupTable(single_zone_mesh_metadata) - - for candidate in (" wing", "wing ", " wing ", " fluid/wing ", "\twing\n"): - split_infos = lookup_table.get_split_info(candidate) - assert len(split_infos) == 1 - assert split_infos[0].full_name == "fluid/wing" - - def test_lookup_does_not_normalize_internal_whitespace(self): - """Test that internal whitespace differences do not match.""" - mesh_metadata = { - "zones": { - "fluid": { - "boundaryNames": [ - "fluid/panel left", - ], - }, - } - } - lookup_table = BoundaryNameLookupTable(mesh_metadata) - - split_infos = lookup_table.get_split_info("panel left") - assert len(split_infos) == 1 - assert split_infos[0].full_name == "fluid/panel left" - - assert lookup_table.get_split_info("panel left") == [] - assert lookup_table.get_split_info("fluid/panel left") == [] - - def test_empty_metadata(self): - """Test with empty metadata.""" - lookup_table = BoundaryNameLookupTable({}) - assert lookup_table.get_split_info("anything") == [] - - lookup_table = BoundaryNameLookupTable({"zones": {}}) - assert lookup_table.get_split_info("anything") == [] - - lookup_table = BoundaryNameLookupTable({"zones": None}) - assert lookup_table.get_split_info("anything") == [] diff --git a/tests/simulation/framework/test_boundary_split_rotation_bug.py b/tests/simulation/framework/test_boundary_split_rotation_bug.py deleted file mode 100644 index 754054a90..000000000 --- a/tests/simulation/framework/test_boundary_split_rotation_bug.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -Regression tests for boundary_split.py -""" - -from unittest.mock import MagicMock - -import pytest - -from flow360.component.simulation.entity_info import ( - GeometryEntityInfo, - VolumeMeshEntityInfo, -) -from flow360.component.simulation.framework.boundary_split import ( - BoundaryNameLookupTable, - RotationVolumeSplitProvider, - SplitType, -) -from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.meshing_param.params import MeshingParams -from flow360.component.simulation.meshing_param.volume_params import RotationVolume -from flow360.component.simulation.primitives import Surface -from flow360.component.simulation.simulation_params import SimulationParams - - -class TestBoundarySplitDefenses: - """Tests for the multi-layer defense in Boundary Split Logic.""" - - @pytest.fixture - def mock_simulation_params(self): - """Create a mock SimulationParams.""" - params = MagicMock(spec=SimulationParams) - - # Mock asset cache - asset_cache = MagicMock() - asset_cache.project_entity_info = MagicMock(spec=GeometryEntityInfo) - params.private_attribute_asset_cache = asset_cache - - # Mock meshing (no RotationVolume by default) - params.meshing = MagicMock(spec=MeshingParams) - params.meshing.volume_zones = [] - - return params - - def _create_mock_rotation_volume(self): - """Helper to create a valid Mock RotationVolume with enclosed entity.""" - mock_entity = MagicMock(spec=Surface) - mock_entity.name = "blade" - - rotation_volume = MagicMock(spec=RotationVolume) - rotation_volume.name = "rotatingZone" - rotation_volume.enclosed_entities = MagicMock(spec=EntityList) - rotation_volume.enclosed_entities.stored_entities = [mock_entity] - rotation_volume.stationary_enclosed_entities = None - - # Helper for _find_zone_name - rv_entity = MagicMock() - rv_entity.name = "rotatingZone" - rotation_volume.entities = MagicMock() - rotation_volume.entities.stored_entities = [rv_entity] - - return rotation_volume - - def test_layer_defense_no_active_rotation_volumes(self, mock_simulation_params): - """ - Layer 2 Defense: - If there are NO active rotation volumes in params, the provider should NOT be added. - """ - volume_mesh_meta_data = {"zones": {}} - - # Ensure no rotation volumes - mock_simulation_params.meshing.volume_zones = [] - - lookup_table = BoundaryNameLookupTable.from_params( - volume_mesh_meta_data, mock_simulation_params - ) - - # Assertion: Provider list should be empty - assert len(lookup_table._providers) == 0 - - def test_layer_defense_volume_mesh_entity_info(self, mock_simulation_params): - """ - Layer 1 Defense: - If entity info is VolumeMeshEntityInfo, the provider should NOT be added, - even if RotationVolume exists. - """ - volume_mesh_meta_data = {"zones": {}} - - # Setup: Valid RotationVolume - mock_simulation_params.meshing.volume_zones = [self._create_mock_rotation_volume()] - - # Setup: VolumeMeshEntityInfo - mock_simulation_params.private_attribute_asset_cache.project_entity_info = MagicMock( - spec=VolumeMeshEntityInfo - ) - - lookup_table = BoundaryNameLookupTable.from_params( - volume_mesh_meta_data, mock_simulation_params - ) - - # Assertion: Provider list should be empty - assert len(lookup_table._providers) == 0 - - def test_layer_defense_mismatched_boundary_name(self, mock_simulation_params): - """ - Layer 3 Defense (Strict Check): - Even if the provider IS added (because we have a valid RotationVolume), - it should NOT intercept boundaries that look like rotating boundaries but - were not generated by THIS provider. - """ - # Setup: Valid RotationVolume (so provider WILL be added) - mock_simulation_params.meshing.volume_zones = [self._create_mock_rotation_volume()] - - # Mesh metadata: contains a "suspicious" name that does NOT match the generated one - # Generated one would be: rotatingZone/blade__rotating_rotatingZone - suspicious_name = "farfield/sphere.lb8.ugrid__rotating_intersectingCylinder" - - volume_mesh_meta_data = { - "zones": { - "farfield": {"boundaryNames": [suspicious_name]}, - # We need rotatingZone present so provider doesn't crash during init - "rotatingZone": {"boundaryNames": []}, - } - } - - lookup_table = BoundaryNameLookupTable.from_params( - volume_mesh_meta_data, mock_simulation_params - ) - - # Assertion 1: Provider WAS added - assert len(lookup_table._providers) == 1 - provider = lookup_table._providers[0] - assert isinstance(provider, RotationVolumeSplitProvider) - - # Assertion 2: Suspicious name is NOT handled by provider - assert provider.handled_by_provider(suspicious_name) is False - - # Assertion 3: Suspicious name falls through to default logic (ZONE_PREFIX) - base_name = "sphere.lb8.ugrid__rotating_intersectingCylinder" - split_infos = lookup_table.get_split_info(base_name) - assert len(split_infos) == 1 - assert split_infos[0].full_name == suspicious_name - assert split_infos[0].split_type == SplitType.ZONE_PREFIX - - def test_provider_intercepts_correctly_generated_boundary(self, mock_simulation_params): - """ - Happy Path: - If RotationVolume exists AND the boundary name matches the generated one, - it SHOULD be intercepted. - """ - # Setup: Valid RotationVolume - mock_simulation_params.meshing.volume_zones = [self._create_mock_rotation_volume()] - - generated_full_name = "rotatingZone/blade__rotating_rotatingZone" - volume_mesh_meta_data = { - "zones": { - "rotatingZone": { - "boundaryNames": [ - "rotatingZone/blade", - generated_full_name, - ] - } - } - } - - lookup_table = BoundaryNameLookupTable.from_params( - volume_mesh_meta_data, mock_simulation_params - ) - - # Assertion 1: Provider WAS added - assert len(lookup_table._providers) == 1 - provider = lookup_table._providers[0] - - # Assertion 2: Generated name IS handled by provider - assert provider.handled_by_provider(generated_full_name) is True - - # Assertion 3: Lookup by base name 'blade' returns the generated split info - split_infos = lookup_table.get_split_info("blade") - full_names = [info.full_name for info in split_infos] - assert generated_full_name in full_names diff --git a/tests/simulation/framework/test_context_validators.py b/tests/simulation/framework/test_context_validators.py deleted file mode 100644 index de3d072f3..000000000 --- a/tests/simulation/framework/test_context_validators.py +++ /dev/null @@ -1,241 +0,0 @@ -from typing import Annotated, Literal, Optional, Union - -import pydantic as pd - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.validation import validation_context -from flow360.log import set_logging_level - -set_logging_level("DEBUG") - - -class Model(Flow360BaseModel): - a: str - b: Optional[int] = validation_context.ConditionalField(context=validation_context.SURFACE_MESH) - c: Optional[str] = validation_context.ConditionalField(context=validation_context.VOLUME_MESH) - d: Optional[float] = validation_context.ConditionalField(context=validation_context.CASE) - e: Optional[float] = validation_context.ContextField(context=validation_context.CASE) - - -class ModelA(Flow360BaseModel): - type: Literal["A"] - - -class ModelB(Flow360BaseModel): - type: Literal["B"] - - -class BaseModel(Flow360BaseModel): - m: Model - c: Optional[Model] = validation_context.ConditionalField(context=validation_context.CASE) - d: Model = validation_context.ContextField(context=validation_context.CASE) - e: Union[ModelA, ModelB] = validation_context.ContextField( - discriminator="type", context=validation_context.CASE - ) - - -# this data is missing m="Model" fields and is missing d field -test_data1 = dict(m=dict()) -# this data is correct -test_data2 = dict( - m=dict(a="f", b=1, c="d", d=1.2), - c=dict(a="f", b=1, c="d", d=1.2), - d=dict(a="f", b=1, c="d", d=1.2), - e=dict(type="B"), -) -# this data has incorrect type for d->a (no context defined for a but defined for d) and d->c -test_data3 = dict( - m=dict(a="f", b=1, c="d", d=1.2), - c=dict(a="f", b=1, c="d", d=1.2), - d=dict(a=1, c=1), - e=dict(type="B"), -) - - -def _test_with_given_context_and_data(context, data: dict, expected_errors): - try: - with validation_context.ValidationContext(context): - BaseModel(**data) - except pd.ValidationError as err: - errors = err.errors() - assert len(errors) == len(expected_errors) - for err, exp_err in zip(errors, expected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == err["type"] - if "ctx" in exp_err.keys(): - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - - -def test_no_context_validate(): - excpected_errors = [ - {"loc": ("m", "a"), "type": "missing"}, - {"loc": ("d",), "type": "model_type", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("e",), "type": "model_attributes_type", "ctx": {"relevant_for": ["Case"]}}, - ] - _test_with_given_context_and_data(None, test_data1, excpected_errors) - - -def test_with_sm_context_validate(): - excpected_errors = [ - {"loc": ("m", "a"), "type": "missing"}, - {"loc": ("m", "b"), "type": "missing", "ctx": {"relevant_for": ["SurfaceMesh"]}}, - {"loc": ("d",), "type": "model_type", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("e",), "type": "model_attributes_type", "ctx": {"relevant_for": ["Case"]}}, - ] - - _test_with_given_context_and_data(validation_context.SURFACE_MESH, test_data1, excpected_errors) - - -def test_with_vm_context_validate(): - excpected_errors = [ - {"loc": ("m", "a"), "type": "missing"}, - {"loc": ("m", "c"), "type": "missing", "ctx": {"relevant_for": ["VolumeMesh"]}}, - {"loc": ("d",), "type": "model_type", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("e",), "type": "model_attributes_type", "ctx": {"relevant_for": ["Case"]}}, - ] - - _test_with_given_context_and_data(validation_context.VOLUME_MESH, test_data1, excpected_errors) - - -def test_with_case_context_validate(): - excpected_errors = [ - {"loc": ("m", "a"), "type": "missing"}, - {"loc": ("m", "d"), "type": "missing", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("c",), "type": "missing", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("d",), "type": "model_type", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("e",), "type": "model_attributes_type", "ctx": {"relevant_for": ["Case"]}}, - ] - - _test_with_given_context_and_data(validation_context.CASE, test_data1, excpected_errors) - - -def test_with_all_context_validate(): - excpected_errors = [ - {"loc": ("m", "a"), "type": "missing"}, - {"loc": ("m", "b"), "type": "missing", "ctx": {"relevant_for": ["SurfaceMesh"]}}, - {"loc": ("m", "c"), "type": "missing", "ctx": {"relevant_for": ["VolumeMesh"]}}, - {"loc": ("m", "d"), "type": "missing", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("c",), "type": "missing", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("d",), "type": "model_type", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("e",), "type": "model_attributes_type", "ctx": {"relevant_for": ["Case"]}}, - ] - - _test_with_given_context_and_data(validation_context.ALL, test_data1, excpected_errors) - - -def test_with_sm_and_vm_context_validate(): - excpected_errors = [ - {"loc": ("m", "a"), "type": "missing"}, - {"loc": ("m", "b"), "type": "missing", "ctx": {"relevant_for": ["SurfaceMesh"]}}, - {"loc": ("m", "c"), "type": "missing", "ctx": {"relevant_for": ["VolumeMesh"]}}, - {"loc": ("d",), "type": "model_type", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("e",), "type": "model_attributes_type", "ctx": {"relevant_for": ["Case"]}}, - ] - - try: - with validation_context.ValidationContext( - [validation_context.SURFACE_MESH, validation_context.VOLUME_MESH] - ): - BaseModel(**test_data1) - except pd.ValidationError as err: - errors = err.errors() - assert len(errors) == len(excpected_errors) - for err, exp_err in zip(errors, excpected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - if "ctx" in exp_err.keys(): - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - - -def test_with_sm_and_vm_and_case_context_validate(): - excpected_errors = [ - {"loc": ("m", "a"), "type": "missing"}, - {"loc": ("m", "b"), "type": "missing", "ctx": {"relevant_for": ["SurfaceMesh"]}}, - {"loc": ("m", "c"), "type": "missing", "ctx": {"relevant_for": ["VolumeMesh"]}}, - {"loc": ("m", "d"), "type": "missing", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("c",), "type": "missing", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("d",), "type": "model_type", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("e",), "type": "model_attributes_type", "ctx": {"relevant_for": ["Case"]}}, - ] - - try: - with validation_context.ValidationContext( - [ - validation_context.SURFACE_MESH, - validation_context.VOLUME_MESH, - validation_context.CASE, - ] - ): - BaseModel(**test_data1) - except pd.ValidationError as err: - errors = err.errors() - assert len(errors) == len(excpected_errors) - for err, exp_err in zip(errors, excpected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - if "ctx" in exp_err.keys(): - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - - -def test_correct_context_validate(): - - BaseModel(**test_data2) - with validation_context.ValidationContext(validation_context.SURFACE_MESH): - BaseModel(**test_data2) - - with validation_context.ValidationContext(validation_context.VOLUME_MESH): - BaseModel(**test_data2) - - with validation_context.ValidationContext(validation_context.CASE): - BaseModel(**test_data2) - - with validation_context.ValidationContext(validation_context.ALL): - BaseModel(**test_data2) - - -def test_without_context_validate_not_required(): - excpected_errors = [ - {"loc": ("e",), "type": "float_parsing", "ctx": {"relevant_for": ["Case"]}}, - ] - - try: - Model(a="a", d=1, e="str") - except pd.ValidationError as err: - errors = err.errors() - assert len(errors) == len(excpected_errors) - for err, exp_err in zip(errors, excpected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - if "ctx" in exp_err.keys(): - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - - -def test_without_context_validate_not_required_2(): - excpected_errors = [ - {"loc": ("d", "a"), "type": "string_type", "ctx": {"relevant_for": ["Case"]}}, - {"loc": ("d", "c"), "type": "string_type", "ctx": {"relevant_for": ["VolumeMesh"]}}, - ] - _test_with_given_context_and_data(None, test_data3, excpected_errors) - - -def test_with_context_validate_required(): - data = dict(a="f", b=1, c=None, d=1.2) - with validation_context.ValidationContext(validation_context.SURFACE_MESH): - Model(**data) - - # Become invalid when validating against VOLUME_MESH - excpected_errors = [ - {"loc": ("c",), "type": "missing", "ctx": {"relevant_for": ["VolumeMesh"]}}, - ] - try: - with validation_context.ValidationContext(validation_context.VOLUME_MESH): - Model(**data) - except pd.ValidationError as err: - errors = err.errors() - assert len(errors) == len(excpected_errors) - for err, exp_err in zip(errors, excpected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - if "ctx" in exp_err.keys(): - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] diff --git a/tests/simulation/framework/test_multi_constructor_model.py b/tests/simulation/framework/test_multi_constructor_model.py index dd5964953..b916be429 100644 --- a/tests/simulation/framework/test_multi_constructor_model.py +++ b/tests/simulation/framework/test_multi_constructor_model.py @@ -1,166 +1,21 @@ import os -from copy import deepcopy -import pytest from flow360_schema.framework.validation.context import DeserializationContext -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.multi_constructor_model_base import ( parse_model_dict, ) from flow360.component.simulation.models.volume_models import BETDisk -from flow360.component.simulation.operating_condition.operating_condition import ( - AerospaceCondition, - ThermalState, -) from tests.simulation.converter.test_bet_translator import generate_BET_param -@pytest.fixture -def get_aerospace_condition_default(): - return AerospaceCondition( - velocity_magnitude=0.8 * u.km / u.s, - alpha=5 * u.deg, - thermal_state=ThermalState(), - reference_velocity_magnitude=100 * u.m / u.s, - ) - - -@pytest.fixture -def get_aerospace_condition_default_and_thermal_state_using_from(): - return AerospaceCondition( - velocity_magnitude=0.8 * u.km / u.s, - alpha=5 * u.deg, - thermal_state=ThermalState.from_standard_atmosphere(altitude=1000 * u.m), - reference_velocity_magnitude=100 * u.m / u.s, - ) - - -@pytest.fixture -def get_aerospace_condition_using_from_mach(): - return AerospaceCondition.from_mach( - mach=0.8, - alpha=5 * u.deg, - thermal_state=ThermalState.from_standard_atmosphere(altitude=1000 * u.m), - ) - - -@pytest.fixture -def get_aerospace_condition_using_from_mach_reynolds(): - return AerospaceCondition.from_mach_reynolds( - mach=0.8, - reynolds_mesh_unit=1e6, - project_length_unit=u.m, - alpha=5 * u.deg, - temperature=290 * u.K, - ) - - -def compare_objects_from_dict(dict1: dict, dict2: dict, object_class: type[Flow360BaseModel]): +def compare_objects_from_dict(dict1, dict2, object_class): with DeserializationContext(): obj1 = object_class.model_validate(dict1) obj2 = object_class.model_validate(dict2) assert obj1.model_dump_json() == obj2.model_dump_json() -def test_full_model( - get_aerospace_condition_default, - get_aerospace_condition_using_from_mach, - get_aerospace_condition_using_from_mach_reynolds, -): - full_data = get_aerospace_condition_default.model_dump(exclude_none=False) - data_parsed = parse_model_dict(full_data, globals()) - compare_objects_from_dict(full_data, data_parsed, AerospaceCondition) - - full_data = get_aerospace_condition_using_from_mach.model_dump(exclude_none=False) - data_parsed = parse_model_dict(full_data, globals()) - compare_objects_from_dict(full_data, data_parsed, AerospaceCondition) - - full_data = get_aerospace_condition_using_from_mach_reynolds.model_dump(exclude_none=False) - data_parsed = parse_model_dict(full_data, globals()) - compare_objects_from_dict(full_data, data_parsed, AerospaceCondition) - - -def test_incomplete_model( - get_aerospace_condition_default, - get_aerospace_condition_using_from_mach, - get_aerospace_condition_using_from_mach_reynolds, - get_aerospace_condition_default_and_thermal_state_using_from, -): - full_data = get_aerospace_condition_default.model_dump(exclude_none=False) - incomplete_data = deepcopy(full_data) - incomplete_data["private_attribute_input_cache"] = {} - data_parsed = parse_model_dict(incomplete_data, globals()) - compare_objects_from_dict(full_data, data_parsed, AerospaceCondition) - - full_data = get_aerospace_condition_using_from_mach.model_dump(exclude_none=False) - incomplete_data = { - "type_name": full_data["type_name"], - "private_attribute_constructor": full_data["private_attribute_constructor"], - "private_attribute_input_cache": full_data["private_attribute_input_cache"], - } - - data_parsed = parse_model_dict(incomplete_data, globals()) - compare_objects_from_dict(full_data, data_parsed, AerospaceCondition) - - full_data = get_aerospace_condition_using_from_mach_reynolds.model_dump(exclude_none=False) - incomplete_data = { - "type_name": full_data["type_name"], - "private_attribute_constructor": full_data["private_attribute_constructor"], - "private_attribute_input_cache": full_data["private_attribute_input_cache"], - } - - data_parsed = parse_model_dict(incomplete_data, globals()) - compare_objects_from_dict(full_data, data_parsed, AerospaceCondition) - - full_data = get_aerospace_condition_default_and_thermal_state_using_from.model_dump( - exclude_none=False - ) - incomplete_data = deepcopy(full_data) - incomplete_data["thermal_state"] = { - "type_name": full_data["thermal_state"]["type_name"], - "private_attribute_constructor": full_data["thermal_state"][ - "private_attribute_constructor" - ], - "private_attribute_input_cache": full_data["thermal_state"][ - "private_attribute_input_cache" - ], - } - - data_parsed = parse_model_dict(incomplete_data, globals()) - compare_objects_from_dict(full_data, data_parsed, AerospaceCondition) - - -def test_recursive_incomplete_model(get_aerospace_condition_using_from_mach): - # `incomplete_data` contains only the private_attribute_* for both the AerospaceCondition and ThermalState - full_data = get_aerospace_condition_using_from_mach.model_dump(exclude_none=False) - input_cache = full_data["private_attribute_input_cache"] - input_cache["thermal_state"] = { - "type_name": input_cache["thermal_state"]["type_name"], - "private_attribute_constructor": input_cache["thermal_state"][ - "private_attribute_constructor" - ], - "private_attribute_input_cache": input_cache["thermal_state"][ - "private_attribute_input_cache" - ], - } - incomplete_data = { - "type_name": full_data["type_name"], - "private_attribute_constructor": full_data["private_attribute_constructor"], - "private_attribute_input_cache": full_data["private_attribute_input_cache"], - } - - data_parsed = parse_model_dict(incomplete_data, globals()) - compare_objects_from_dict(full_data, data_parsed, AerospaceCondition) - - -def test_non_entity_modification_updates_input_cache(get_aerospace_condition_using_from_mach): - my_op = get_aerospace_condition_using_from_mach - my_op.alpha = -12 * u.rad - assert my_op.private_attribute_input_cache.alpha == -12 * u.rad - - def test_BETDisk_multi_constructor_full(): for bet_type in ["c81", "dfdc", "xfoil", "xrotor"]: bet = generate_BET_param(bet_type) @@ -173,11 +28,9 @@ def test_BETDisk_multi_constructor_cache_only(): for bet_type in ["c81", "dfdc", "xfoil", "xrotor"]: original_workdir = os.getcwd() try: - # Mimicking customer using a relative path for the files. os.chdir(os.path.dirname(os.path.abspath(__file__))) bet = generate_BET_param(bet_type, given_path_prefix="../converter/") finally: - # Ooops I changed my directory (trying using the json in some other folder) os.chdir(original_workdir) full_data = bet.model_dump(exclude_none=False) @@ -187,7 +40,5 @@ def test_BETDisk_multi_constructor_cache_only(): "private_attribute_input_cache": full_data["private_attribute_input_cache"], "private_attribute_id": full_data["private_attribute_id"], } - # Make sure cache only can be deserialized and that we won't have - # trouble even if we switch directory where the file path no longer is valid. data_parsed = parse_model_dict(incomplete_data, globals()) compare_objects_from_dict(full_data, data_parsed, BETDisk) diff --git a/tests/simulation/framework/test_single_attribute_model.py b/tests/simulation/framework/test_single_attribute_model.py deleted file mode 100644 index 2b83de38b..000000000 --- a/tests/simulation/framework/test_single_attribute_model.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Literal, Union - -import pydantic as pd -import pytest - -from flow360.component.simulation.framework.expressions import StringExpression -from flow360.component.simulation.framework.single_attribute_base import ( - SingleAttributeModel, -) - - -class MyTestClass(SingleAttributeModel): - type_name: Literal["MyTestClass"] = pd.Field("MyTestClass", frozen=True) - value: Union[pd.StrictFloat, StringExpression] = pd.Field() - - -def test_single_attribute_model(): - a = MyTestClass(1.0) - assert a.value == 1.0 - - a = MyTestClass(value=2.0) - assert a.value == 2.0 - - a = MyTestClass("1+2-4") - assert a.value == "1+2-4" - - with pytest.raises(ValueError, match="Value must be provided for MyTestClass."): - MyTestClass() diff --git a/tests/simulation/framework/test_unique_list.py b/tests/simulation/framework/test_unique_list.py deleted file mode 100644 index e2e46ed8c..000000000 --- a/tests/simulation/framework/test_unique_list.py +++ /dev/null @@ -1,167 +0,0 @@ -import re -from typing import Literal - -import pydantic as pd -import pytest - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.unique_list import ( - UniqueItemList, - UniqueStringList, -) -from flow360.component.simulation.outputs.output_fields import CommonFieldNames -from flow360.component.simulation.primitives import Surface, SurfacePair - - -class _OutputItemBase(Flow360BaseModel): - name: str = pd.Field() - - def __hash__(self): - return hash(self.name + "-" + self.__class__.__name__) - - def __eq__(self, other): - if isinstance(other, _OutputItemBase): - return (self.name + "-" + self.__class__.__name__) == ( - other.name + "-" + other.__class__.__name__ - ) - return False - - def __str__(self): - return f"{self.__class__.__name__} with name: {self.name}" - - -class TempIsosurface(_OutputItemBase): - field_magnitude: float = pd.Field() - - -class TempSlice(_OutputItemBase): - pass - - -class TempIsosurfaceOutput(Flow360BaseModel): - isosurfaces: UniqueItemList[TempIsosurface] = pd.Field() - output_fields: UniqueItemList[CommonFieldNames] = pd.Field() - - -class TempPeriodic(Flow360BaseModel): - surface_pairs: UniqueItemList[SurfacePair] - - -def test_unique_list(): - my_iso_1 = TempIsosurface(name="iso_1", field_magnitude=1.01) - my_iso_1_dup = TempIsosurface(name="iso_1", field_magnitude=1.02) - my_slice = TempSlice(name="slice_1") - # 1: Test duplicate isosurfaces - output = TempIsosurfaceOutput( - isosurfaces=[my_iso_1, my_iso_1_dup], output_fields=["wallDistance"] - ) - - assert len(output.isosurfaces.items) == 1 - - # 2: Test duplicate output_fields - output = TempIsosurfaceOutput( - isosurfaces=[my_iso_1], output_fields=["wallDistance", "wallDistance"] - ) - assert output.output_fields.items == ["wallDistance"] - - # 3: Test duplicate output_fields by aliased name - output = TempIsosurfaceOutput( - isosurfaces=[my_iso_1], - output_fields=[ - "wallDistance", - "Cp", - "wallDistance", - "qcriterion", - ], - ) - - assert output.output_fields.items == ["wallDistance", "Cp", "qcriterion"] - - # 4: Test unvalid types: - with pytest.raises( - ValueError, - match=re.escape("Input should be a valid dictionary or instance of TempIsosurface"), - ): - TempIsosurfaceOutput(isosurfaces=[my_iso_1, my_slice], output_fields=["wallDistance"]) - - with pytest.raises( - ValueError, - match=re.escape( - "Input should be " - "'Cp', " - "'Cpt', " - "'gradW', " - "'kOmega', " - "'Mach', " - "'mut', " - "'mutRatio', " - "'nuHat', " - "'primitiveVars', " - "'qcriterion', " - "'residualNavierStokes', " - "'residualTransition', " - "'residualTurbulence', " - "'s', " - "'solutionNavierStokes', " - "'solutionTransition', " - "'solutionTurbulence', " - "'T', " - "'velocity', " - "'velocity_x', " - "'velocity_y', " - "'velocity_z', " - "'velocity_magnitude', " - "'pressure', " - "'vorticity', " - "'vorticityMagnitude', " - "'vorticity_x', " - "'vorticity_y', " - "'vorticity_z', " - "'wallDistance', " - "'numericalDissipationFactor', " - "'residualHeatSolver', " - "'VelocityRelative', " - "'lowMachPreconditionerSensor', " - "'velocity_m_per_s', " - "'velocity_x_m_per_s', " - "'velocity_y_m_per_s', " - "'velocity_z_m_per_s', " - "'velocity_magnitude_m_per_s' " - "or 'pressure_pa'" - ), - ): - TempIsosurfaceOutput(isosurfaces=[my_iso_1], output_fields=["wallDistance", 1234]) - - # 5: Test append triggering validation - temp_iso = TempIsosurfaceOutput(isosurfaces=[my_iso_1], output_fields=["Cp", "wallDistance"]) - - assert len(temp_iso.isosurfaces.items) == 1 - - temp_iso.isosurfaces.append(my_iso_1) - assert len(temp_iso.isosurfaces.items) == 1 - - -def test_unique_list_with_surface_pair(): - surface1 = Surface(name="MySurface1") - surface2 = Surface(name="MySurface2") - periodic = TempPeriodic( - surface_pairs=[ - [surface1, surface2], - ] - ) - assert periodic - - with pytest.raises( - ValueError, - match=re.escape("A surface cannot be paired with itself."), - ): - SurfacePair(pair=[surface1, surface1]) - - surface = TempPeriodic( - surface_pairs=[ - [surface1, surface2], - [surface2, surface1], - ] - ) - - assert len(surface.surface_pairs.items) == 1 diff --git a/tests/simulation/framework/test_version_parser.py b/tests/simulation/framework/test_version_parser.py deleted file mode 100644 index 9b484692c..000000000 --- a/tests/simulation/framework/test_version_parser.py +++ /dev/null @@ -1,91 +0,0 @@ -import re - -import pydantic as pd -import pytest - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.framework.updater_utils import ( - Flow360Version, - deprecation_reminder, -) - - -def test_init_valid_version(): - v = Flow360Version("24.11.2") - assert v.major == 24 - assert v.minor == 11 - assert v.patch == 2 - - v = Flow360Version("25.2.0b1") - assert v.major == 25 - assert v.minor == 2 - assert v.patch == 0 - - -@pytest.mark.parametrize( - "invalid_version", - [ - "", - "1.2", - "1.2.3.4", - "abc.def.gh", - "24.11.2b", - ], # Only two parts # Four parts # Non-integer #No beta version -) -def test_init_invalid_version(invalid_version): - with pytest.raises(ValueError): - Flow360Version(invalid_version) - - -def test_equality(): - v1 = Flow360Version("1.2.3") - v2 = Flow360Version("1.2.3") - v3 = Flow360Version("1.2.4") - v4 = Flow360Version("1.2.3b4") - - assert v1 == v2 - assert v1 == v4 - assert v1 != v3 - - -def test_comparison(): - v1 = Flow360Version("1.2.3") - v2 = Flow360Version("1.2.4") - v3 = Flow360Version("2.0.0") - v4 = Flow360Version("1.2.3b4") - - # Test <, <=, >, >= - assert v1 < v2 - assert v1 <= v2 - assert v4 < v2 - assert v4 <= v2 - assert v2 < v3 - assert v2 <= v3 - assert v3 > v1 - assert v3 >= v1 - assert v3 > v4 - assert v3 >= v4 - - # Additional comparisons - assert v1 < v3 - assert v1 != v2 - assert v2 != v3 - - -def test_deprecation_reminder(): - class SomeModel(Flow360BaseModel): - field_a: int = 1 - - @pd.model_validator(mode="after") - @deprecation_reminder("20.1.2") - def _deprecation_validator(self): - return self - - with pytest.raises( - ValueError, - match=re.escape( - "[INTERNAL] This validator or function is detecting/handling deprecated schema that was scheduled " - "to be removed since 20.1.2. Please deprecate the schema now, write updater and remove related checks." - ), - ): - SomeModel(field_a=123) diff --git a/tests/simulation/outputs/test_filename_string.py b/tests/simulation/outputs/test_filename_string.py deleted file mode 100644 index 21175995c..000000000 --- a/tests/simulation/outputs/test_filename_string.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Unit tests for FileNameString validation.""" - -import pydantic as pd -import pytest - -from flow360.component.simulation.outputs.outputs import ( - FileNameString, - SurfaceIntegralOutput, -) - - -class TestFileNameString: - """Test suite for FileNameString validation.""" - - def test_valid_filenames(self): - """Test that valid filenames are accepted.""" - - class Model(pd.BaseModel): - name: FileNameString - - # Valid filenames - valid_names = [ - "surface_integral", - "test-123", - "MyFile", - "output_1", - "A_B_C", - "Surface integral output", # Spaces are allowed - "test file name", - ] - - for name in valid_names: - model = Model(name=name) - assert model.name == name - - def test_invalid_slash(self): - """Test that filenames with slashes are rejected.""" - - class Model(pd.BaseModel): - name: FileNameString - - with pytest.raises( - pd.ValidationError, - match="Filename contains invalid characters: '/'", - ): - Model(name="A/B") - - def test_invalid_null_byte(self): - """Test that filenames with null bytes are rejected.""" - - class Model(pd.BaseModel): - name: FileNameString - - with pytest.raises( - pd.ValidationError, - match="Filename contains invalid characters", - ): - Model(name="test\x00file") - - def test_empty_string(self): - """Test that empty strings are rejected.""" - - class Model(pd.BaseModel): - name: FileNameString - - with pytest.raises( - pd.ValidationError, - match="Filename cannot be empty", - ): - Model(name="") - - def test_reserved_names(self): - """Test that reserved names are rejected.""" - - class Model(pd.BaseModel): - name: FileNameString - - with pytest.raises( - pd.ValidationError, - match="Filename cannot be '.' \\(reserved name\\)", - ): - Model(name=".") - - with pytest.raises( - pd.ValidationError, - match="Filename cannot be '..' \\(reserved name\\)", - ): - Model(name="..") - - -class TestSurfaceIntegralOutputName: - """Test suite for SurfaceIntegralOutput name field with FileNameString.""" - - def test_default_name(self): - """Test that default name is valid.""" - - # We can't create a full SurfaceIntegralOutput without entities, - # but we can test that the default name would pass FileNameString validation - class Model(pd.BaseModel): - name: FileNameString = pd.Field("Surface integral output") - - model = Model() - assert model.name == "Surface integral output" - - def test_invalid_name_with_slash(self): - """Test that SurfaceIntegralOutput rejects names with slashes.""" - with pytest.raises( - pd.ValidationError, - match="Filename contains invalid characters: '/'", - ): - SurfaceIntegralOutput( - name="A/B", - output_fields=["PressureForce"], - entities=[], - ) - - def test_valid_custom_name(self): - """Test that valid custom names are accepted.""" - # This will fail on entities validation, but name should be OK - with pytest.raises(pd.ValidationError) as exc_info: - SurfaceIntegralOutput( - name="my_custom_output", - output_fields=["PressureForce"], - entities=[], - ) - - # Check that the error is about entities, not name - errors = exc_info.value.errors() - name_errors = [e for e in errors if e["loc"] == ("name",)] - assert len(name_errors) == 0, "Name validation should have passed" diff --git a/tests/simulation/outputs/test_output_entities.py b/tests/simulation/outputs/test_output_entities.py deleted file mode 100644 index 55e969b20..000000000 --- a/tests/simulation/outputs/test_output_entities.py +++ /dev/null @@ -1,285 +0,0 @@ -import re - -import pydantic as pd -import pytest -from flow360_schema.framework.expression import Expression, UserVariable -from flow360_schema.models.functions import math -from flow360_schema.models.variables import solution - -from flow360 import SI_unit_system, u -from flow360.component.simulation.outputs.output_entities import Isosurface -from flow360.component.simulation.services import clear_context - - -@pytest.fixture(autouse=True) -def reset_context(): - """Clear user variables from the context.""" - clear_context() - - -def test_isosurface_field_preprocess_expression_and_solver_variable(): - """ - Test the preprocessing in the before_validator for iso field - """ - - # Test that an Isosurface field cannot be defined using an Expression. - with pytest.raises( - pd.ValidationError, - match=re.escape( - "Expression (solution.vorticity[0]) cannot be directly used as isosurface field, " - "please define a UserVariable first." - ), - ): - Isosurface( - name="test_iso_vorticity_component", field=solution.vorticity[0], iso_value=0.8 / u.s - ) - - iso = Isosurface( - name="test_iso_vorticity_mag", - field=UserVariable(name="vorticity_component", value=solution.vorticity[0]), - iso_value=0.8 / u.s, - ) - assert iso.iso_value == 0.8 / u.s - - with pytest.raises( - pd.ValidationError, - match=re.escape( - "Expression (math.magnitude(solution.velocity)) cannot be directly used as isosurface field, " - "please define a UserVariable first." - ), - ): - Isosurface( - name="test_iso_velocity_mag", - field=math.magnitude(solution.velocity), - iso_value=0.8 * u.m / u.s, - ) - - # Test that an Isosurface field defined using a UserVariable. - iso = Isosurface( - name="test_iso_velocity_mag", - field=UserVariable(name="velocity_mag", value=math.magnitude(solution.velocity)), - iso_value=0.8 * u.m / u.s, - ) - assert iso.iso_value == 0.8 * u.m / u.s - - iso = Isosurface( - name="test_iso_rho_cgs", - field=solution.density.in_units(new_name="rho_cgs", new_unit=u.g / u.cm**3), - iso_value=0.8 * 10**3 * u.kg / u.m**3, - ) - assert iso.iso_value == 0.8 * 10**3 * u.kg / u.m**3 - - # Test that an Isosurface field defined using SolverVariable - # is correctly converted to a UserVariable and passes validation. - with SI_unit_system: - iso = Isosurface( - name="test_iso_density", - field=solution.density, - iso_value=0.8 * 10**3 * u.kg / u.m**3, - ) - assert isinstance(iso.field, UserVariable) - - -def test_isosurface_wall_distance_clip(): - """ - Test the preprocessing for wall_distance_clip_threshold option - """ - - # Test that an Isosurface field must have length units - with pytest.raises( - pd.ValidationError, - match="wall_distance_clip_threshold", - ): - Isosurface( - name="test_iso_vorticity_component", - field="T", - iso_value=0.5, - wall_distance_clip_threshold=0.0 / u.s, - ) - - # Bare numeric 0.0 is now accepted as SI (0 meters) — no longer a validation error. - - with pytest.raises( - pd.ValidationError, - match="wall_distance_clip_threshold", - ): - Isosurface( - name="test_iso_vorticity_component", - field="T", - iso_value=0.5, - wall_distance_clip_threshold=-0.1 * u.m, - ) - # check that wall_distance_clip_threshold defaults to None - iso = Isosurface( - name="test_iso_vorticity_mag", - field=UserVariable(name="vorticity_component", value=solution.vorticity[0]), - iso_value=0.8 / u.s, - ) - - assert iso.wall_distance_clip_threshold == None - - iso = Isosurface( - name="test_iso_vorticity_mag", - field=UserVariable(name="vorticity_component", value=solution.vorticity[0]), - iso_value=0.8 / u.s, - wall_distance_clip_threshold=0.1 * u.m, - ) - - assert iso.wall_distance_clip_threshold.v.item() == 0.1 - - -def test_isosurface_field_check_expression_length(): - uv_vector = UserVariable(name="uv_vector", value=solution.velocity) - with pytest.raises( - ValueError, - match=re.escape("The isosurface field (uv_vector) must be defined with a scalar variable."), - ): - Isosurface(name="test_iso_vector_field", field=uv_vector, iso_value=1.0) - - uv_list = UserVariable(name="uv_list", value=[solution.velocity[0], 1 * u.m / u.s]) - with pytest.raises( - ValueError, - match=re.escape("The isosurface field (uv_list) must be defined with a scalar variable."), - ): - Isosurface(name="test_iso_list", field=uv_list, iso_value=1.0) - - -def test_isosurface_field_check_runtime_expression(): - """ - Test that an Isosurface field defined with a UserVariable holding a constant - value (not an Expression) raises a ValueError. - """ - uv_float = UserVariable(name="my_const_float_var", value=10.0) - with pytest.raises( - ValueError, - match=re.escape("The isosurface field (my_const_float_var) cannot be a constant value."), - ): - Isosurface(name="test_iso", field=uv_float, iso_value=5.0) - - uv_float_derived = UserVariable(name="uv_float_derived", value=uv_float * 2) - with pytest.raises( - ValueError, - match=re.escape("The isosurface field (uv_float_derived) cannot be a constant value."), - ): - Isosurface(name="test_iso_expr_const", field=uv_float_derived, iso_value=20.0) - - uv_dim = UserVariable(name="my_const_dim_var", value=10.0 * u.m) - with pytest.raises( - ValueError, - match=re.escape("The isosurface field (my_const_dim_var) cannot be a constant value."), - ): - Isosurface(name="test_iso", field=uv_dim, iso_value=1.0 * u.m) - - uv_dim_derived = UserVariable(name="uv_dim_derived", value=uv_float * uv_dim) - with pytest.raises( - ValueError, - match=re.escape("The isosurface field (uv_dim_derived) cannot be a constant value."), - ): - Isosurface(name="test_iso_expr_const", field=uv_dim_derived, iso_value=20.0 * u.m) - - uv_dim2 = UserVariable(name="my_const_dim_var2", value=10.0 * u.s) - uv_dim_derived2 = UserVariable(name="uv_dim_derived2", value=uv_dim * uv_dim2) - with pytest.raises( - ValueError, - match=re.escape("The isosurface field (uv_dim_derived2) cannot be a constant value."), - ): - Isosurface( - name="test_iso_expr_const", field=uv_dim_derived2, iso_value=10 * u.m * 10.0 * u.m - ) - - -def test_isosurface_single_iso_value(): - """ - Test that an Isosurface iso_value defined with single value. - """ - - uv_vel = UserVariable(name="uv_vel", value=solution.velocity[0]) - with pytest.raises( - ValueError, - match=re.escape("Input should be a valid "), - ): - Isosurface(name="test_iso_list", field=uv_vel, iso_value=[1 * u.m / u.s]) - - with pytest.raises( - ValueError, - match=re.escape("Input should be a valid "), - ): - Isosurface(name="test_iso_unyt_array", field=uv_vel, iso_value=[1, 2] * u.m / u.s) - - with pytest.raises( - ValueError, - match=re.escape("Input should be a valid "), - ): - Isosurface( - name="test_iso_unyt_array", - field=uv_vel, - iso_value=Expression(expression="[1,2,3]*u.m/u.s"), - ) - - -def test_isosurface_check_iso_value_dimensions(): - """ - Test that an Isosurface field defined with a UserVariable has the same dimensions - as the iso_value. - """ - - uv_density = UserVariable( - name="uv_density", - value=solution.density.in_units(new_name="density_CGS", new_unit=u.g / u.m**3), - ) # Density dimension - iso = Isosurface(name="test_iso_dim_match", field=uv_density, iso_value=10 * u.kg / u.m**3) - assert iso.iso_value == 10 * u.kg / u.m**3 - - with pytest.raises( - ValueError, - match=re.escape( - "The iso_value (10.0 m, dimensions:(length)) should have the " - "same dimensions as the isosurface field (uv_density, dimensions: (mass)/(length)**3)." - ), - ): - Isosurface(name="test_iso_dim_mismatch", field=uv_density, iso_value=10.0 * u.m) - - uv_Cp = UserVariable(name="uv_Cp", value=solution.Cp) - iso = Isosurface(name="test_iso_nondim_match", field=uv_Cp, iso_value=10) - assert iso.iso_value == 10 - - with pytest.raises( - ValueError, - match=re.escape( - "The iso_value (10.0 m, dimensions:(length)) should have the " - "same dimensions as the isosurface field (uv_Cp, dimensions: 1)." - ), - ): - Isosurface(name="test_iso_dim_mismatch", field=uv_Cp, iso_value=10.0 * u.m) - - uv_pressure = UserVariable( - name="uv_pressure", - value=0.5 * solution.Cp * solution.density * math.magnitude(solution.velocity) ** 2, - ) - iso = Isosurface(name="test_iso_nondim_match", field=uv_pressure, iso_value=10 * u.Pa) - assert iso.iso_value == 10 - - with pytest.raises( - ValueError, - match=re.escape( - "The iso_value (10.0 m, dimensions:(length)) should have the " - "same dimensions as the isosurface field " - "(uv_pressure, dimensions: (mass)/((length)*(time)**2))." - ), - ): - Isosurface(name="test_iso_dim_mismatch", field=uv_pressure, iso_value=10.0 * u.m) - - -def test_isosurface_check_iso_value_for_string_field(): - """ - Test that an Isosurface field defined with a string field has a nondimensional iso_value. - """ - - with pytest.raises( - ValueError, - match=re.escape( - "The isosurface field (T) specified by string can only be " - "used with a nondimensional iso_value." - ), - ): - Isosurface(name="test_iso_dim_mismatch", field="T", iso_value=10.0 * u.K) diff --git a/tests/simulation/outputs/test_output_fields.py b/tests/simulation/outputs/test_output_fields.py deleted file mode 100644 index 1cd0920b8..000000000 --- a/tests/simulation/outputs/test_output_fields.py +++ /dev/null @@ -1,186 +0,0 @@ -import pytest - -import flow360 as fl -from flow360 import SI_unit_system, u -from flow360.component.simulation.outputs.output_fields import ( - generate_predefined_udf, - remove_fields_subsumed_by_primitive_vars, -) - - -@pytest.fixture -def simulation_params(): - """Create a simulation parameters object for testing.""" - with SI_unit_system: - params = fl.SimulationParams( - operating_condition=fl.AerospaceCondition( - velocity_magnitude=100, - ), - ) - params._private_set_length_unit(1 * u.m) - - return params - - -def test_generate_field_udf_with_unit(simulation_params): - """Test generating UDF expression for a field with units.""" - result = generate_predefined_udf("velocity_m_per_s", simulation_params) - - expected = ( - "double velocity_[3];" - "velocity_[0] = primitiveVars[1] * velocityScale;" - "velocity_[1] = primitiveVars[2] * velocityScale;" - "velocity_[2] = primitiveVars[3] * velocityScale;" - "velocity_m_per_s[0] = velocity_[0] * 340.2940058082124;" - "velocity_m_per_s[1] = velocity_[1] * 340.2940058082124;" - "velocity_m_per_s[2] = velocity_[2] * 340.2940058082124;" - ) - - assert result == expected - - -def test_generate_field_udf_velocity_components(simulation_params): - """Test generating UDF expressions for velocity components with units.""" - - result = generate_predefined_udf("velocity_x_m_per_s", simulation_params) - expected = ( - "double velocity_x;" - "velocity_x = primitiveVars[1] * velocityScale;" - "velocity_x_m_per_s = velocity_x * 340.2940058082124;" - ) - assert result == expected - - result = generate_predefined_udf("velocity_y_m_per_s", simulation_params) - expected = ( - "double velocity_y;" - "velocity_y = primitiveVars[2] * velocityScale;" - "velocity_y_m_per_s = velocity_y * 340.2940058082124;" - ) - assert result == expected - - result = generate_predefined_udf("velocity_z_m_per_s", simulation_params) - expected = ( - "double velocity_z;" - "velocity_z = primitiveVars[3] * velocityScale;" - "velocity_z_m_per_s = velocity_z * 340.2940058082124;" - ) - assert result == expected - - -def test_generate_field_velocity_magnitude_no_unit(simulation_params): - """Test generating UDF expression for velocity magnitude fields.""" - - result = generate_predefined_udf("velocity_magnitude", simulation_params) - expected = ( - "double velocity[3];" - "velocity[0] = primitiveVars[1];" - "velocity[1] = primitiveVars[2];" - "velocity[2] = primitiveVars[3];" - "velocity_magnitude = magnitude(velocity) * velocityScale;" - ) - assert result == expected - - -def test_generate_field_udf_velocity_magnitude(simulation_params): - """Test generating UDF expression for velocity magnitude.""" - - result = generate_predefined_udf("velocity_magnitude_m_per_s", simulation_params) - expected = ( - "double velocity_magnitude;" - "double velocity[3];" - "velocity[0] = primitiveVars[1];" - "velocity[1] = primitiveVars[2];" - "velocity[2] = primitiveVars[3];" - "velocity_magnitude = magnitude(velocity) * velocityScale;" - "velocity_magnitude_m_per_s = velocity_magnitude * 340.2940058082124;" - ) - assert result == expected - - -def test_generate_field_udf_pressure(simulation_params): - """Test generating UDF expression for pressure fields.""" - - result = generate_predefined_udf("pressure_pa", simulation_params) - expected = ( - "double pressure_;double gamma = 1.4;" - "pressure_ = (usingLiquidAsMaterial) ? (primitiveVars[4] - 1.0 / gamma) * (velocityScale * velocityScale) : primitiveVars[4];" - "pressure_pa = pressure_ * 141855.01272652458;" - ) - assert result == expected - - -def test_genereate_field_wall_shear_stress_no_unit(simulation_params): - """Test generating UDF expression for wall shear stress fields.""" - - result = generate_predefined_udf("wall_shear_stress_magnitude", simulation_params) - expected = "wall_shear_stress_magnitude = magnitude(wallShearStress) * (velocityScale * velocityScale);" - assert result == expected - - -def test_generate_field_udf_wall_shear_stress(simulation_params): - """Test generating UDF expression for wall shear stress fields.""" - - result = generate_predefined_udf("wall_shear_stress_magnitude_pa", simulation_params) - expected = ( - "double wall_shear_stress_magnitude;" - "wall_shear_stress_magnitude = magnitude(wallShearStress) * (velocityScale * velocityScale);" - "wall_shear_stress_magnitude_pa = wall_shear_stress_magnitude * 141855.01272652458;" - ) - assert result == expected - - -def test_generate_field_udf_precedence(simulation_params): - """Test that longer matching keys take precedence.""" - result = generate_predefined_udf("velocity_x", simulation_params) - expected = "velocity_x = primitiveVars[1] * velocityScale;" - assert result == expected - - -def test_generate_field_udf_no_match(simulation_params): - """Test behavior when no matching predefined expression is found.""" - result = generate_predefined_udf("non_existent_field_for_udf", simulation_params) - assert result is None - - -class TestRemoveFieldsSubsumedByPrimitiveVars: - def test_removes_pressure_and_velocity_when_primitive_vars_present(self): - fields = ["Cp", "primitiveVars", "pressure", "velocity", "Mach"] - result = remove_fields_subsumed_by_primitive_vars(fields) - assert "primitiveVars" in result - assert "pressure" not in result - assert "velocity" not in result - assert "Cp" in result - assert "Mach" in result - - def test_keeps_pressure_and_velocity_when_no_primitive_vars(self): - fields = ["pressure", "velocity", "Cp"] - result = remove_fields_subsumed_by_primitive_vars(fields) - assert result == ["pressure", "velocity", "Cp"] - - def test_preserves_velocity_magnitude_when_velocity_removed(self): - fields = ["primitiveVars", "velocity", "velocity_magnitude", "pressure"] - result = remove_fields_subsumed_by_primitive_vars(fields) - assert "velocity" not in result - assert "pressure" not in result - assert "velocity_magnitude" in result - assert "primitiveVars" in result - - def test_no_op_on_empty_list(self): - assert remove_fields_subsumed_by_primitive_vars([]) == [] - - def test_primitive_vars_only(self): - fields = ["primitiveVars"] - result = remove_fields_subsumed_by_primitive_vars(fields) - assert result == ["primitiveVars"] - - def test_removes_pressure_only_when_velocity_absent(self): - fields = ["primitiveVars", "pressure", "Cp"] - result = remove_fields_subsumed_by_primitive_vars(fields) - assert "pressure" not in result - assert result == ["primitiveVars", "Cp"] - - def test_removes_velocity_only_when_pressure_absent(self): - fields = ["primitiveVars", "velocity", "Mach"] - result = remove_fields_subsumed_by_primitive_vars(fields) - assert "velocity" not in result - assert result == ["primitiveVars", "Mach"] diff --git a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py index 9fe9f992a..834fe1efe 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -1,18 +1,9 @@ -import re - -import pydantic as pd -import pytest - from flow360 import u from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.meshing_param import snappy -from flow360.component.simulation.meshing_param.face_params import ( - GeometryRefinement, - SurfaceRefinement, -) +from flow360.component.simulation.meshing_param.face_params import GeometryRefinement from flow360.component.simulation.meshing_param.meshing_specs import ( MeshingDefaults, - OctreeSpacing, VolumeMeshingDefaults, ) from flow360.component.simulation.meshing_param.params import ( @@ -22,1651 +13,14 @@ ) from flow360.component.simulation.meshing_param.volume_params import ( AutomatedFarfield, - AxisymmetricRefinement, CustomZones, - FullyMovingFloor, - RotationSphere, - RotationVolume, - StaticFloor, - StructuredBoxRefinement, UniformRefinement, UserDefinedFarfield, - WheelBelts, - WindTunnelFarfield, -) -from flow360.component.simulation.primitives import ( - AxisymmetricBody, - Box, - CustomVolume, - Cylinder, - SeedpointVolume, - SnappyBody, - Sphere, - Surface, ) +from flow360.component.simulation.primitives import Box, CustomVolume, Surface from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import CGS_unit_system, SI_unit_system -from flow360.component.simulation.utils import BoundingBox -from flow360.component.simulation.validation.validation_context import ( - SURFACE_MESH, - VOLUME_MESH, - ParamsValidationInfo, - ValidationContext, -) - -non_beta_mesher_context = ParamsValidationInfo({}, []) -non_beta_mesher_context.is_beta_mesher = False - -non_gai_context = ParamsValidationInfo({}, []) -non_gai_context.use_geometry_AI = False - -beta_mesher_context = ParamsValidationInfo({}, []) -beta_mesher_context.is_beta_mesher = True -beta_mesher_context.project_length_unit = 1 * u.mm - -snappy_context = ParamsValidationInfo({}, []) -snappy_context.use_snappy = True -snappy_context.is_beta_mesher = True - - -def test_structured_box_only_in_beta_mesher(): - # raises when beta mesher is off - with pytest.raises( - pd.ValidationError, - match=r"`StructuredBoxRefinement` is only supported with the beta mesher.", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - with CGS_unit_system: - porous_medium = Box.from_principal_axes( - name="porousRegion", - center=(0, 1, 1), - size=(1, 2, 1), - axes=((2, 2, 0), (-2, 2, 0)), - ) - _ = StructuredBoxRefinement( - entities=[porous_medium], - spacing_axis1=10, - spacing_axis2=10, - spacing_normal=10, - ) - - # does not raise with beta mesher on - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - porous_medium = Box.from_principal_axes( - name="porousRegion", - center=(0, 1, 1), - size=(1, 2, 1), - axes=((2, 2, 0), (-2, 2, 0)), - ) - _ = StructuredBoxRefinement( - entities=[porous_medium], - spacing_axis1=10, - spacing_axis2=10, - spacing_normal=10, - ) - - -def test_no_reuse_box_in_refinements(): - with pytest.raises( - pd.ValidationError, - match=r"Using Volume entity `box-reused` in `StructuredBoxRefinement`, `UniformRefinement` at the same time is not allowed.", - ): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - porous_medium = Box.from_principal_axes( - name="box-reused", - center=(0, 1, 1), - size=(1, 2, 1), - axes=((2, 2, 0), (-2, 2, 0)), - ) - structured_box_refine = StructuredBoxRefinement( - entities=[porous_medium], - spacing_axis1=10, - spacing_axis2=10, - spacing_normal=10, - ) - uniform_refine = UniformRefinement(entities=[porous_medium], spacing=10) - - SimulationParams( - meshing=MeshingParams( - refinements=[uniform_refine, structured_box_refine], - ) - ) - - -def test_disable_invalid_axisymmetric_body_construction(): - import re - - with pytest.raises( - pd.ValidationError, - match=re.escape("Vector must have exactly 2 components, got 3"), - ): - with CGS_unit_system: - cylinder_1 = AxisymmetricBody( - name="1", - axis=(0, 0, 1), - center=(0, 5, 0), - profile_curve=[(-1, 0), (-1, 1, 3), (1, 1), (1, 0)], - ) - - with pytest.raises( - pd.ValidationError, - match=re.escape("should have at least 2 items"), - ): - with CGS_unit_system: - AxisymmetricBody( - name="1", - axis=(0, 0, 1), - center=(0, 5, 0), - profile_curve=[], - ) - - with pytest.raises( - pd.ValidationError, - match=re.escape( - "Expect first profile sample to be (Axial, 0.0). Found invalid point: [-1. 1.] cm." - ), - ): - with CGS_unit_system: - cylinder_1 = AxisymmetricBody( - name="1", - axis=(0, 0, 1), - center=(0, 5, 0), - profile_curve=[(-1, 1), (1, 2)], - ) - - with pytest.raises( - pd.ValidationError, - match=re.escape( - "Expect last profile sample to be (Axial, 0.0). Found invalid point: [1. 1.] cm." - ), - ): - with CGS_unit_system: - cylinder_1 = AxisymmetricBody( - name="1", - axis=(0, 0, 1), - center=(0, 5, 0), - profile_curve=[(-1, 0), (-1, 1), (1, 1)], - ) - - with pytest.raises( - pd.ValidationError, - match=re.escape("Profile curve has duplicate consecutive points at indices 1 and 2"), - ): - with CGS_unit_system: - invalid = AxisymmetricBody( - name="1", - axis=(1, 0, 0), - center=(0, 3, 0), - profile_curve=[(-1, 0), (-1, 1.23), (-1, 1.23), (1, 1), (1, 0)], - ) - - -def test_disable_multiple_cylinder_in_one_rotation_volume(mock_validation_context): - with ( - mock_validation_context, - pytest.raises( - pd.ValidationError, - match="Only single instance is allowed in entities for each `RotationVolume`.", - ), - ): - with CGS_unit_system: - cylinder_1 = Cylinder( - name="1", - outer_radius=12, - height=2, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - cylinder_2 = Cylinder( - name="2", - outer_radius=2, - height=2, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=MeshingParams( - volume_zones=[ - RotationVolume( - entities=[cylinder_1, cylinder_2], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ), - AutomatedFarfield(), - ], - ) - ) - with ( - mock_validation_context, - pytest.raises( - pd.ValidationError, - match="Only single instance is allowed in entities for each `RotationVolume`.", - ), - ): - with CGS_unit_system: - cylinder_1 = Cylinder( - name="1", - outer_radius=12, - height=2, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - cylinder_2 = Cylinder( - name="2", - outer_radius=2, - height=2, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=ModularMeshingWorkflow( - zones=[ - RotationVolume( - entities=[cylinder_1, cylinder_2], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ), - AutomatedFarfield(), - ], - ) - ) - - -def test_limit_cylinder_entity_name_length_in_rotation_volume(): - # raises when beta mesher is off - with pytest.raises( - pd.ValidationError, - match=r"The name \(very_long_cylinder_name\) of `Cylinder` entity in `RotationVolume`" - + " exceeds 18 characters limit.", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="very_long_cylinder_name", - outer_radius=12, - height=2, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ) - - # does not raise with beta mesher on - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder2 = Cylinder( - name="very_long_cylinder_name", - outer_radius=12, - height=2, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - _ = RotationVolume( - entities=[cylinder2], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ) - - -def test_limit_axisymmetric_body_in_rotation_volume(): - # raises when beta mesher is off - with pytest.raises( - pd.ValidationError, - match=r"`AxisymmetricBody` entity for `RotationVolume` is only supported with the beta mesher.", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - with CGS_unit_system: - cylinder_1 = AxisymmetricBody( - name="1", - axis=(0, 0, 1), - center=(0, 5, 0), - profile_curve=[(-1, 0), (-1, 1), (1, 1), (1, 0)], - ) - - _ = RotationVolume( - entities=[cylinder_1], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ) - - # does not raise with beta mesher on - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder_2 = AxisymmetricBody( - name="1", - axis=(0, 0, 1), - center=(0, 5, 0), - profile_curve=[(-1, 0), (-1, 1), (1, 1), (1, 0)], - ) - - _ = RotationVolume( - entities=[cylinder_2], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ) - - -def test_sphere_in_rotation_sphere_only_in_beta_mesher(): - """Test that Sphere entity for RotationSphere is only supported with the beta mesher.""" - # raises when beta mesher is off - with pytest.raises( - pd.ValidationError, - match=r"`Sphere` entity for `RotationSphere` is only supported with the beta mesher.", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - with CGS_unit_system: - sphere = Sphere( - name="rotation_sphere", - center=(0, 0, 0), - radius=10, - ) - _ = RotationSphere( - entities=[sphere], - spacing_circumferential=0.5, - ) - - # does not raise with beta mesher on - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - sphere = Sphere( - name="rotation_sphere", - center=(0, 0, 0), - radius=10, - ) - _ = RotationSphere( - entities=[sphere], - spacing_circumferential=0.5, - ) - - -def test_sphere_rotation_volume_spacing_requirements(): - """Test spacing requirements for RotationSphere vs RotationVolume.""" - # Test 1: RotationSphere without spacing_circumferential should raise error - with pytest.raises(pd.ValidationError, match=r"Field required"): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - sphere = Sphere(name="sphere", center=(0, 0, 0), radius=10) - _ = RotationSphere( - entities=[sphere], - ) - - # Test 2: RotationSphere with spacing_axial should raise error - with pytest.raises(pd.ValidationError, match=r"Extra inputs are not permitted"): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - sphere = Sphere(name="sphere", center=(0, 0, 0), radius=10) - _ = RotationSphere( - entities=[sphere], - spacing_circumferential=0.5, - spacing_axial=0.5, - ) - - # Test 3: RotationSphere with spacing_radial should raise error - with pytest.raises(pd.ValidationError, match=r"Extra inputs are not permitted"): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - sphere = Sphere(name="sphere", center=(0, 0, 0), radius=10) - _ = RotationSphere( - entities=[sphere], - spacing_circumferential=0.5, - spacing_radial=0.5, - ) - - # Test 4: Cylinder without spacing_axial should raise error - with pytest.raises(pd.ValidationError, match=r"Field required"): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cyl", - center=(0, 0, 0), - axis=(0, 0, 1), - height=10, - outer_radius=5, - ) - _ = RotationVolume( - entities=[cylinder], - spacing_circumferential=0.5, - spacing_radial=0.5, - ) - - # Test 5: Cylinder without spacing_radial should raise error - with pytest.raises(pd.ValidationError, match=r"Field required"): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cyl", - center=(0, 0, 0), - axis=(0, 0, 1), - height=10, - outer_radius=5, - ) - _ = RotationVolume( - entities=[cylinder], - spacing_circumferential=0.5, - spacing_axial=0.5, - ) - - # Test 6: Cylinder without spacing_circumferential should raise error - with pytest.raises(pd.ValidationError, match=r"Field required"): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cyl", - center=(0, 0, 0), - axis=(0, 0, 1), - height=10, - outer_radius=5, - ) - _ = RotationVolume( - entities=[cylinder], - spacing_axial=0.5, - spacing_radial=0.5, - ) - - -def test_sphere_rotation_volume_with_enclosed_entities(): - """Test that RotationSphere supports enclosed_entities.""" - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - sphere = Sphere(name="outer_sphere", center=(0, 0, 0), radius=10) - inner_sphere = Sphere(name="inner_sphere", center=(0, 0, 0), radius=5) - _ = RotationSphere( - entities=[sphere], - spacing_circumferential=0.5, - enclosed_entities=[inner_sphere, Surface(name="hub")], - ) - - -def test_sphere_in_enclosed_entities_only_in_beta_mesher(): - """Test that Sphere in enclosed_entities is only supported with the beta mesher.""" - # raises when beta mesher is off - with pytest.raises( - pd.ValidationError, - match=r"`Sphere` entity in `RotationVolume.enclosed_entities` is only supported with the beta mesher.", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="outer_cyl", - center=(0, 0, 0), - axis=(0, 0, 1), - height=10, - outer_radius=5, - ) - inner_sphere = Sphere(name="inner_sphere", center=(0, 0, 0), radius=2) - _ = RotationVolume( - entities=[cylinder], - spacing_axial=0.5, - spacing_radial=0.5, - spacing_circumferential=0.5, - enclosed_entities=[inner_sphere], - ) - - # does not raise with beta mesher on - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="outer_cyl", - center=(0, 0, 0), - axis=(0, 0, 1), - height=10, - outer_radius=5, - ) - inner_sphere = Sphere(name="inner_sphere", center=(0, 0, 0), radius=2) - _ = RotationVolume( - entities=[cylinder], - spacing_axial=0.5, - spacing_radial=0.5, - spacing_circumferential=0.5, - enclosed_entities=[inner_sphere], - ) - - -def test_reuse_of_same_cylinder(mock_validation_context): - with ( - mock_validation_context, - pytest.raises( - pd.ValidationError, - match=r"Using Volume entity `I am reused` in `AxisymmetricRefinement`, `RotationVolume` at the same time is not allowed.", - ), - ): - with CGS_unit_system: - cylinder = Cylinder( - name="I am reused", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=MeshingParams( - volume_zones=[ - RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ), - AutomatedFarfield(), - ], - refinements=[ - AxisymmetricRefinement( - entities=[cylinder], - spacing_axial=0.1, - spacing_radial=0.2, - spacing_circumferential=0.3, - ) - ], - ) - ) - - with ( - mock_validation_context, - pytest.raises( - pd.ValidationError, - match=r"Using Volume entity `I am reused` in `AxisymmetricRefinement`, `RotationVolume` at the same time is not allowed.", - ), - ): - with CGS_unit_system: - cylinder = Cylinder( - name="I am reused", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=ModularMeshingWorkflow( - volume_meshing=VolumeMeshingParams( - refinements=[ - AxisymmetricRefinement( - entities=[cylinder], - spacing_axial=0.1, - spacing_radial=0.2, - spacing_circumferential=0.3, - ) - ], - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1), - ), - zones=[ - RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ), - AutomatedFarfield(), - ], - ) - ) - - with CGS_unit_system: - cylinder = Cylinder( - name="Okay to reuse", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=MeshingParams( - volume_zones=[ - RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ), - AutomatedFarfield(), - ], - refinements=[ - UniformRefinement( - entities=[cylinder], - spacing=0.1, - ) - ], - ) - ) - - with CGS_unit_system: - cylinder = Cylinder( - name="Okay to reuse", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=ModularMeshingWorkflow( - volume_meshing=VolumeMeshingParams( - refinements=[ - UniformRefinement( - entities=[cylinder], - spacing=0.1, - ) - ], - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1), - ), - zones=[ - RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[ - Surface(name="hub"), - ], - ), - AutomatedFarfield(), - ], - ) - ) - - with ( - mock_validation_context, - pytest.raises( - pd.ValidationError, - match=r"Using Volume entity `I am reused` in `AxisymmetricRefinement`, `UniformRefinement` at the same time is not allowed.", - ), - ): - with CGS_unit_system: - cylinder = Cylinder( - name="I am reused", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=MeshingParams( - refinements=[ - UniformRefinement(entities=[cylinder], spacing=0.1), - AxisymmetricRefinement( - entities=[cylinder], - spacing_axial=0.1, - spacing_radial=0.1, - spacing_circumferential=0.1, - ), - ], - ) - ) - - with ( - mock_validation_context, - pytest.raises( - pd.ValidationError, - match=r"Using Volume entity `I am reused` in `AxisymmetricRefinement`, `UniformRefinement` at the same time is not allowed.", - ), - ): - with CGS_unit_system: - cylinder = Cylinder( - name="I am reused", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=ModularMeshingWorkflow( - volume_meshing=VolumeMeshingParams( - refinements=[ - UniformRefinement(entities=[cylinder], spacing=0.1), - AxisymmetricRefinement( - entities=[cylinder], - spacing_axial=0.1, - spacing_radial=0.1, - spacing_circumferential=0.1, - ), - ], - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1), - ), - zones=[AutomatedFarfield()], - ) - ) - - with ( - mock_validation_context, - pytest.raises( - pd.ValidationError, - match=r" Volume entity `I am reused` is used multiple times in `UniformRefinement`.", - ), - ): - with CGS_unit_system: - cylinder = Cylinder( - name="I am reused", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=MeshingParams( - refinements=[ - UniformRefinement(entities=[cylinder], spacing=0.1), - UniformRefinement(entities=[cylinder], spacing=0.2), - ], - ) - ) - - with ( - mock_validation_context, - pytest.raises( - pd.ValidationError, - match=r" Volume entity `I am reused` is used multiple times in `UniformRefinement`.", - ), - ): - with CGS_unit_system: - cylinder = Cylinder( - name="I am reused", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - SimulationParams( - meshing=ModularMeshingWorkflow( - volume_meshing=VolumeMeshingParams( - refinements=[ - UniformRefinement(entities=[cylinder], spacing=0.1), - UniformRefinement(entities=[cylinder], spacing=0.2), - ], - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1), - ), - zones=[AutomatedFarfield()], - ) - ) - - -def test_axisymmetric_body_in_uniform_refinement(): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - axisymmetric_body = AxisymmetricBody( - name="a", - axis=(0, 0, 1), - center=(0, 0, 0), - profile_curve=[(-2, 0), (-2, 1), (2, 1.5), (2, 0)], - ) - MeshingParams( - refinements=[ - UniformRefinement( - entities=[axisymmetric_body], - spacing=0.1, - ) - ], - ) - - # raises without beta mesher - with pytest.raises( - pd.ValidationError, - match=r"`AxisymmetricBody` entity for `UniformRefinement` is supported only with beta mesher", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - with CGS_unit_system: - axisymmetric_body = AxisymmetricBody( - name="1", - axis=(0, 0, 1), - center=(0, 0, 0), - profile_curve=[(-1, 0), (-1, 1), (1, 1), (1, 0)], - ) - UniformRefinement( - entities=[axisymmetric_body], - spacing=0.1, - ) - - -def test_sphere_in_uniform_refinement(): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - sphere = Sphere( - name="s", - center=(0, 0, 0), - radius=1.0, - ) - MeshingParams( - refinements=[ - UniformRefinement( - entities=[sphere], - spacing=0.1, - ) - ], - ) - - # also allowed with snappy - with ValidationContext(VOLUME_MESH, snappy_context): - with CGS_unit_system: - sphere = Sphere( - name="s_snappy", - center=(0, 0, 0), - radius=1.0, - ) - UniformRefinement( - entities=[sphere], - spacing=0.1, - ) - - # raises without beta mesher or snappy - with pytest.raises( - pd.ValidationError, - match=r"`Sphere` entity for `UniformRefinement` is supported only with beta mesher", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - with CGS_unit_system: - sphere = Sphere( - name="s2", - center=(0, 0, 0), - radius=1.0, - ) - UniformRefinement( - entities=[sphere], - spacing=0.1, - ) - - -def test_uniform_refinement_snappy_entity_restrictions(): - """With snappy, UniformRefinement only accepts Box, Cylinder, and Sphere entities.""" - # Box, Cylinder, Sphere all allowed - with ValidationContext(VOLUME_MESH, snappy_context): - with CGS_unit_system: - UniformRefinement( - entities=[ - Box(center=(0, 0, 0), size=(1, 1, 1), name="box"), - Cylinder( - name="cyl", - axis=(0, 0, 1), - center=(0, 0, 0), - height=1.0, - outer_radius=0.5, - ), - Sphere(name="sph", center=(0, 0, 0), radius=1.0), - ], - spacing=0.1, - ) - - # AxisymmetricBody rejected with snappy - with pytest.raises( - pd.ValidationError, - match=r"`AxisymmetricBody` entity for `UniformRefinement` is not supported with snappyHexMesh", - ): - with ValidationContext(VOLUME_MESH, snappy_context): - with CGS_unit_system: - axisymmetric_body = AxisymmetricBody( - name="axisymm", - axis=(0, 0, 1), - center=(0, 0, 0), - profile_curve=[(-1, 0), (-1, 1), (1, 1), (1, 0)], - ) - UniformRefinement( - entities=[axisymmetric_body], - spacing=0.1, - ) - - -def test_require_mesh_zones(): - with SI_unit_system: - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, - max_spacing=5 * u.mm, - gap_resolution=0.001 * u.mm, - ), - ), - zones=[AutomatedFarfield()], - ) - - with SI_unit_system: - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, - max_spacing=5 * u.mm, - gap_resolution=0.01 * u.mm, - ), - ), - zones=[ - CustomZones( - name="custom_zones", - entities=[SeedpointVolume(name="fluid", point_in_mesh=(0, 0, 0) * u.mm)], - ) - ], - ) - - message = "snappyHexMeshing requires at least one `SeedpointVolume` when not using `AutomatedFarfield`." - with pytest.raises( - ValueError, - match=re.escape(message), - ): - with SI_unit_system: - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, - max_spacing=5 * u.mm, - gap_resolution=0.01 * u.mm, - ) - ), - zones=[UserDefinedFarfield()], - ) - - -def test_bad_refinements(): - message = "Default maximum spacing (5.0 mm) is lower than refinement minimum spacing (6.0 mm) and maximum spacing is not provided for BodyRefinement." - with pytest.raises( - ValueError, - match=re.escape(message), - ): - snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=5 * u.mm, gap_resolution=0.01 * u.mm - ), - refinements=[ - snappy.BodyRefinement( - min_spacing=6 * u.mm, bodies=[SnappyBody(name="bbb", surfaces=[])] - ) - ], - ) - - message = "Default minimum spacing (1.0 mm) is higher than refinement maximum spacing (0.5 mm) and minimum spacing is not provided for BodyRefinement." - with pytest.raises( - ValueError, - match=re.escape(message), - ): - snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=5 * u.mm, gap_resolution=0.01 * u.mm - ), - refinements=[ - snappy.BodyRefinement( - max_spacing=0.5 * u.mm, bodies=[SnappyBody(name="bbb", surfaces=[])] - ) - ], - ) - - -def test_duplicate_refinement_type_per_entity(): - """Raise when the same refinement type is applied twice to one entity.""" - body = SnappyBody(name="car_body", surfaces=[]) - surface = Surface(name="wing") - defaults = snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=5 * u.mm, gap_resolution=0.01 * u.mm - ) - - # -- Two BodyRefinements targeting the same SnappyBody -- - with pytest.raises( - pd.ValidationError, - match=r"`BodyRefinement` is applied 2 times to entity `car_body`", - ): - snappy.SurfaceMeshingParams( - defaults=defaults, - refinements=[ - snappy.BodyRefinement(min_spacing=2 * u.mm, bodies=[body]), - snappy.BodyRefinement(max_spacing=4 * u.mm, bodies=[body]), - ], - ) - - # -- Two RegionRefinements targeting the same Surface -- - with pytest.raises( - pd.ValidationError, - match=r"`RegionRefinement` is applied 2 times to entity `wing`", - ): - snappy.SurfaceMeshingParams( - defaults=defaults, - refinements=[ - snappy.RegionRefinement( - min_spacing=1 * u.mm, max_spacing=3 * u.mm, regions=[surface] - ), - snappy.RegionRefinement( - min_spacing=2 * u.mm, max_spacing=4 * u.mm, regions=[surface] - ), - ], - ) - - # -- Two SurfaceEdgeRefinements targeting the same SnappyBody -- - with pytest.raises( - pd.ValidationError, - match=r"`SurfaceEdgeRefinement` is applied 2 times to entity `car_body`", - ): - snappy.SurfaceMeshingParams( - defaults=defaults, - refinements=[ - snappy.SurfaceEdgeRefinement(spacing=0.5 * u.mm, entities=[body]), - snappy.SurfaceEdgeRefinement(spacing=1 * u.mm, entities=[body]), - ], - ) - - # -- Two SurfaceEdgeRefinements targeting the same Surface -- - with pytest.raises( - pd.ValidationError, - match=r"`SurfaceEdgeRefinement` is applied 2 times to entity `wing`", - ): - snappy.SurfaceMeshingParams( - defaults=defaults, - refinements=[ - snappy.SurfaceEdgeRefinement(spacing=0.5 * u.mm, entities=[surface]), - snappy.SurfaceEdgeRefinement(spacing=1 * u.mm, entities=[surface]), - ], - ) - - -def test_duplicate_refinement_different_types_is_allowed(): - """Different refinement types on the same entity should NOT raise.""" - body = SnappyBody(name="car_body", surfaces=[]) - surface = Surface(name="wing") - defaults = snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=5 * u.mm, gap_resolution=0.01 * u.mm - ) - - # BodyRefinement + SurfaceEdgeRefinement on the same SnappyBody is fine - snappy.SurfaceMeshingParams( - defaults=defaults, - refinements=[ - snappy.BodyRefinement(min_spacing=2 * u.mm, bodies=[body]), - snappy.SurfaceEdgeRefinement(spacing=0.5 * u.mm, entities=[body]), - ], - ) - - # RegionRefinement + SurfaceEdgeRefinement on the same Surface is fine - snappy.SurfaceMeshingParams( - defaults=defaults, - refinements=[ - snappy.RegionRefinement(min_spacing=1 * u.mm, max_spacing=3 * u.mm, regions=[surface]), - snappy.SurfaceEdgeRefinement(spacing=0.5 * u.mm, entities=[surface]), - ], - ) - - -def test_duplicate_refinement_different_entities_is_allowed(): - """Same refinement type on different entities should NOT raise.""" - body1 = SnappyBody(name="car_body", surfaces=[]) - body2 = SnappyBody(name="other_body", surfaces=[]) - defaults = snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=5 * u.mm, gap_resolution=0.01 * u.mm - ) - - snappy.SurfaceMeshingParams( - defaults=defaults, - refinements=[ - snappy.BodyRefinement(min_spacing=2 * u.mm, bodies=[body1]), - snappy.BodyRefinement(min_spacing=3 * u.mm, bodies=[body2]), - ], - ) - - -def test_duplicate_refinement_body_and_surface_same_name_is_allowed(): - """SurfaceEdgeRefinement on a SnappyBody and a Surface sharing a name should NOT raise.""" - body = SnappyBody(name="shared_name", surfaces=[]) - surface = Surface(name="shared_name") - defaults = snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=5 * u.mm, gap_resolution=0.01 * u.mm - ) - - snappy.SurfaceMeshingParams( - defaults=defaults, - refinements=[ - snappy.SurfaceEdgeRefinement(spacing=0.5 * u.mm, entities=[body]), - snappy.SurfaceEdgeRefinement(spacing=1 * u.mm, entities=[surface]), - ], - ) - - -def test_box_entity_enclosed_only_in_beta_mesher(): - # raises when beta mesher is off - with pytest.raises( - pd.ValidationError, - match=r"`Box` entity in `RotationVolume.enclosed_entities` is only supported with the beta mesher.", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cylinder", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - box_entity = Box.from_principal_axes( - name="box", - center=(0, 1, 1), - size=(1, 2, 1), - axes=((2, 2, 0), (-2, 2, 0)), - ) - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[box_entity], - ) - - # does not raise with beta mesher on - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cylinder", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - box_entity = Box.from_principal_axes( - name="box", - center=(0, 1, 1), - size=(1, 2, 1), - axes=((2, 2, 0), (-2, 2, 0)), - ) - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[box_entity], - ) - - -def test_octree_spacing(): - spacing = OctreeSpacing(base_spacing=2 * u.mm) - - assert spacing[0] == 2 * u.mm - assert spacing[3] == 2 * u.mm * (2**-3) - assert spacing[-4] == 2 * u.mm * (2**4) - assert spacing[1] == 2 * u.mm * (2**-1) - - with pytest.raises(pd.ValidationError): - _ = spacing[0.2] - - assert spacing.to_level(2 * u.mm) == (0, True) - assert spacing.to_level(4 * u.mm) == (-1, True) - assert spacing.to_level(0.5 * u.mm) == (2, True) - assert spacing.to_level(3.9993 * u.mm) == (0, False) - assert spacing.to_level(3.9999999999993 * u.mm) == (-1, True) - - -def test_set_default_octree_spacing(): - surface_meshing = snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ) - - assert surface_meshing.octree_spacing is None - - with ValidationContext(SURFACE_MESH, beta_mesher_context): - surface_meshing = snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ) - - assert surface_meshing.octree_spacing.base_spacing == 1 * u.mm - assert surface_meshing.octree_spacing[2] == 0.25 * u.mm - assert surface_meshing.octree_spacing.to_level(2 * u.mm) == (-1, True) - - -def test_set_spacing_with_value(): - surface_meshing = snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ), - octree_spacing=OctreeSpacing(base_spacing=3 * u.mm), - ) - - assert surface_meshing.octree_spacing.base_spacing == 3 * u.mm - - with pytest.raises(pd.ValidationError): - surface_meshing = snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ), - octree_spacing=-3 * u.mm, - ) - - -def test_set_spacing_with_base_spacing_alias(): - """Test that base_spacing alias still works for backward compatibility.""" - surface_meshing = snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ), - base_spacing=OctreeSpacing(base_spacing=3 * u.mm), - ) - - assert surface_meshing.octree_spacing.base_spacing == 3 * u.mm - - -def test_quasi_3d_periodic_only_in_legacy_mesher(): - # raises when legacy mesher is off - with pytest.raises( - pd.ValidationError, - match=r"Only legacy mesher can support quasi-3d-periodic", - ): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - my_farfield = AutomatedFarfield(method="quasi-3d-periodic") - - # does not raise with legacy mesher on - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - my_farfield = AutomatedFarfield(method="quasi-3d-periodic") - - -def test_enforced_half_model_only_in_beta_mesher(): - # raises when beta mesher is off - with pytest.raises( - pd.ValidationError, - match=r"`domain_type` is only supported when using both GAI surface mesher and beta volume mesher.", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - AutomatedFarfield(domain_type="half_body_positive_y") - - # raise when GAI is off - with pytest.raises( - pd.ValidationError, - match=r"`domain_type` is only supported when using both GAI surface mesher and beta volume mesher.", - ): - with ValidationContext(VOLUME_MESH, non_gai_context): - AutomatedFarfield(domain_type="full_body") - - -def test_enclosed_entities_none_does_not_raise(): - with CGS_unit_system: - cylinder = Cylinder( - name="cylinder", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - # Should not raise even when enclosed_entities is explicitly None - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - ) - - -def test_stationary_enclosed_entities_only_in_beta_mesher(): - """Test that stationary_enclosed_entities is only supported with beta mesher.""" - # raises when beta mesher is off - with pytest.raises( - pd.ValidationError, - match=r"`stationary_enclosed_entities` in `RotationVolume` is only supported with the beta mesher.", - ): - with ValidationContext(VOLUME_MESH, non_beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cylinder", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - surface1 = Surface(name="hub") - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[surface1], - stationary_enclosed_entities=[surface1], - ) - - # does not raise with beta mesher on - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cylinder", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - surface1 = Surface(name="hub") - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[surface1], - stationary_enclosed_entities=[surface1], - ) - - -def test_stationary_enclosed_entities_requires_enclosed_entities(): - """Test that stationary_enclosed_entities cannot be specified when enclosed_entities is None.""" - with pytest.raises( - pd.ValidationError, - match=r"`stationary_enclosed_entities` cannot be specified when `enclosed_entities` is None.", - ): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cylinder", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - surface1 = Surface(name="hub") - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=None, - stationary_enclosed_entities=[surface1], - ) - - -def test_stationary_enclosed_entities_must_be_subset(): - """Test that stationary_enclosed_entities must be a subset of enclosed_entities.""" - with pytest.raises( - pd.ValidationError, - match=r"All entities in `stationary_enclosed_entities` must be present in `enclosed_entities`.", - ): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cylinder", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - surface1 = Surface(name="hub") - surface2 = Surface(name="shroud") - surface3 = Surface(name="stationary") - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[surface1, surface2], - stationary_enclosed_entities=[ - surface1, - surface3, - ], # surface3 not in enclosed_entities - ) - - -def test_stationary_enclosed_entities_valid_subset(): - """Test that stationary_enclosed_entities works correctly when it's a valid subset.""" - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cylinder", - outer_radius=1, - height=12, - axis=(0, 1, 0), - center=(0, 5, 0), - ) - surface1 = Surface(name="hub") - surface2 = Surface(name="shroud") - # Should not raise when stationary_enclosed_entities is a valid subset - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[surface1, surface2], - stationary_enclosed_entities=[surface1], # Valid subset - ) - - # Should also work with all entities being stationary - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[surface1, surface2], - stationary_enclosed_entities=[ - surface1, - surface2, - ], # All entities stationary - ) - - # Should work with empty stationary_enclosed_entities (None) - _ = RotationVolume( - entities=[cylinder], - spacing_axial=20, - spacing_radial=0.2, - spacing_circumferential=20, - enclosed_entities=[surface1, surface2], - stationary_enclosed_entities=None, - ) - - -def test_snappy_quality_metrics_validation(): - message = "Value must be less than or equal to 180 degrees." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - snappy.QualityMetrics(max_non_ortho=190 * u.deg) - - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - snappy.QualityMetrics(max_concave=190 * u.deg) - - snappy.QualityMetrics(max_non_ortho=90 * u.deg, max_concave=90 * u.deg) - - with SI_unit_system, pytest.raises(pd.ValidationError): - snappy.QualityMetrics(max_boundary_skewness=-2 * u.deg) - - with SI_unit_system, pytest.raises(pd.ValidationError): - snappy.QualityMetrics(max_internal_skewness=-2 * u.deg) - - snappy.QualityMetrics( - max_boundary_skewness=23 * u.deg, - max_internal_skewness=89 * u.deg, - zmetric_threshold=0.9, - feature_edge_deduplication_tolerance=0.1, - ) - with pytest.raises(pd.ValidationError): - snappy.QualityMetrics(zmetric_threshold=-0.1) - with pytest.raises(pd.ValidationError): - snappy.QualityMetrics(feature_edge_deduplication_tolerance=-0.1) - - snappy.QualityMetrics(zmetric_threshold=False, feature_edge_deduplication_tolerance=False) - - -def test_modular_workflow_zones_validation(): - message = "At least one zone defining the farfield is required." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm) - ), - zones=[], - ) - - message = "When using `CustomZones` the `UserDefinedFarfield` will be ignored." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm) - ), - zones=[ - UserDefinedFarfield(), - CustomZones( - name="custom_zones", - entities=[SeedpointVolume(name="fluid", point_in_mesh=(0, 0, 0) * u.mm)], - ), - ], - ) - - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm) - ), - zones=[ - CustomZones( - name="custom_zones", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), Surface(name="face2")], - ) - ], - ), - UserDefinedFarfield(), - ], - ) - - message = "Only one `AutomatedFarfield` zone is allowed in `zones`." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm) - ), - zones=[AutomatedFarfield(), AutomatedFarfield()], - ) - - message = "Only one `UserDefinedFarfield` zone is allowed in `zones`." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm) - ), - zones=[UserDefinedFarfield(), UserDefinedFarfield()], - ) - - message = "Cannot use `AutomatedFarfield` and `UserDefinedFarfield` simultaneously." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm) - ), - zones=[AutomatedFarfield(), UserDefinedFarfield()], - ) - - message = "`CustomZones` cannot be used with `AutomatedFarfield`." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm) - ), - zones=[ - AutomatedFarfield(), - CustomZones( - name="custom_zones", - entities=[SeedpointVolume(name="fluid", point_in_mesh=(0, 0, 0) * u.mm)], - ), - ], - ) - - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, max_spacing=2 * u.mm, gap_resolution=1 * u.mm - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm) - ), - zones=[ - AutomatedFarfield(), - CustomZones( - name="custom_zones", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), Surface(name="face2")], - ) - ], - ), - ], - ) - - -def test_modular_workflow_accepts_rotation_sphere_zone(): - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with SI_unit_system: - sphere = Sphere(name="sphere_modular_zone", center=(0, 0, 0) * u.m, radius=1 * u.m) - workflow = ModularMeshingWorkflow( - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm) - ), - zones=[ - AutomatedFarfield(), - RotationSphere( - entities=[sphere], - spacing_circumferential=0.2 * u.m, - ), - ], - ) - - assert isinstance(workflow.zones[1], RotationSphere) +from flow360.component.simulation.unit_system import SI_unit_system def test_uniform_project_only_with_snappy(): @@ -1689,27 +43,22 @@ def test_uniform_project_only_with_snappy(): defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm), refinements=[refinement], ), - zones=[ - AutomatedFarfield(), - ], + zones=[AutomatedFarfield()], ) ) - params_snappy, errors, _ = validate_model( + _, errors, _ = validate_model( params_as_dict=params_snappy.model_dump(mode="json"), validated_by=ValidationCalledBy.LOCAL, root_item_type="Geometry", validation_level="VolumeMesh", ) - assert errors is None with SI_unit_system: params = SimulationParams( meshing=MeshingParams( - volume_zones=[ - AutomatedFarfield(), - ], + volume_zones=[AutomatedFarfield()], refinements=[refinement], defaults=MeshingDefaults( curvature_resolution_angle=12 * u.deg, @@ -1719,7 +68,7 @@ def test_uniform_project_only_with_snappy(): ) ) - params, errors, _ = validate_model( + _, errors, _ = validate_model( params_as_dict=params.model_dump(mode="json"), validated_by=ValidationCalledBy.LOCAL, root_item_type="SurfaceMesh", @@ -1733,562 +82,7 @@ def test_uniform_project_only_with_snappy(): assert errors[0]["loc"] == ("meshing", "refinements", 0, "UniformRefinement") -def test_resolve_face_boundary_only_in_gai_mesher(): - # raise when GAI is off - with pytest.raises( - pd.ValidationError, - match=r"resolve_face_boundaries is only supported when geometry AI is used", - ): - with ValidationContext(SURFACE_MESH, non_gai_context): - with CGS_unit_system: - MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=0.1, - resolve_face_boundaries=True, - ) - ) - - -def test_surface_refinement_in_gai_mesher(): - # raise when both GAI and beta mesher are off - with pytest.raises( - pd.ValidationError, - match=r"curvature_resolution_angle is only supported by the beta mesher or when geometry AI is enabled", - ): - with ValidationContext(SURFACE_MESH, non_gai_context): - with CGS_unit_system: - SurfaceRefinement(max_edge_length=0.1, curvature_resolution_angle=10 * u.deg) - - # raise when GAI is off - with pytest.raises( - pd.ValidationError, - match=r"resolve_face_boundaries is only supported when geometry AI is used", - ): - with ValidationContext(SURFACE_MESH, non_gai_context): - with CGS_unit_system: - SurfaceRefinement(resolve_face_boundaries=True) - - # raise when no options are specified - with pytest.raises( - pd.ValidationError, - match=r"SurfaceRefinement requires at least one of 'max_edge_length', 'curvature_resolution_angle', or 'resolve_face_boundaries' to be specified", - ): - with ValidationContext(SURFACE_MESH, non_gai_context): - with CGS_unit_system: - SurfaceRefinement(entities=Surface(name="testFace")) - - -def test_curvature_resolution_angle_requires_geometry_ai_or_beta_mesher(): - """Test that curvature_resolution_angle is supported when either geometry AI or beta mesher is enabled.""" - # Test 1: When both GAI and beta mesher are off, should raise - with pytest.raises( - pd.ValidationError, - match=r"curvature_resolution_angle is only supported by the beta mesher or when geometry AI is enabled", - ): - with ValidationContext(SURFACE_MESH, non_gai_context): - with CGS_unit_system: - SurfaceRefinement( - entities=Surface(name="testFace"), - curvature_resolution_angle=15 * u.deg, - ) - - # Test 2: When curvature_resolution_angle is None, should not raise even if both are off - with ValidationContext(SURFACE_MESH, non_gai_context): - with CGS_unit_system: - surface_ref = SurfaceRefinement( - entities=Surface(name="testFace"), - max_edge_length=0.1, - curvature_resolution_angle=None, - ) - assert surface_ref.curvature_resolution_angle is None - - # Test 3: When GAI is enabled, should work - gai_context = ParamsValidationInfo({}, []) - gai_context.use_geometry_AI = True - - with ValidationContext(SURFACE_MESH, gai_context): - with CGS_unit_system: - surface_ref = SurfaceRefinement( - entities=Surface(name="testFace"), - curvature_resolution_angle=20 * u.deg, - ) - assert surface_ref.curvature_resolution_angle == 20 * u.deg - - # Test 4: When beta mesher is enabled, should work - with ValidationContext(SURFACE_MESH, beta_mesher_context): - with CGS_unit_system: - surface_ref = SurfaceRefinement( - entities=Surface(name="testFace"), - curvature_resolution_angle=25 * u.deg, - ) - assert surface_ref.curvature_resolution_angle == 25 * u.deg - - -def test_wind_tunnel_invalid_dimensions(): - with CGS_unit_system: - # invalid floors - with pytest.raises( - pd.ValidationError, - match=r"strictly increasing", - ): - # invalid range - _ = StaticFloor(friction_patch_x_range=(-100, -200), friction_patch_width=42) - - with pytest.raises( - pd.ValidationError, - match=r"All values must be positive", - ): - # invalid positive range - _ = WheelBelts( - central_belt_x_range=(-200, 256), - central_belt_width=67, - front_wheel_belt_x_range=(-30, 50), - front_wheel_belt_y_range=(70, 120), - rear_wheel_belt_x_range=(260, 380), - rear_wheel_belt_y_range=(-5, 101), # here - ) - - with pytest.raises( - pd.ValidationError, - match=r"must be less than rear wheel belt minimum x", - ): - # front, rear belt x ranges overlap - _ = WheelBelts( - central_belt_x_range=(-200, 256), - central_belt_width=67, - front_wheel_belt_x_range=(-30, 263), # here - front_wheel_belt_y_range=(70, 120), - rear_wheel_belt_x_range=(260, 380), - rear_wheel_belt_y_range=(70, 120), - ) - - # invalid tunnels - with pytest.raises( - pd.ValidationError, - match=r"must be less than outlet x position", - ): - # inlet behind outlet - _ = WindTunnelFarfield( - inlet_x_position=200, - outlet_x_position=182, - floor_type=FullyMovingFloor(), - ) - - with pytest.raises( - pd.ValidationError, - match=r"must be less than wind tunnel width", - ): - # friction patch too wide - _ = WindTunnelFarfield(width=2025, floor_type=StaticFloor(friction_patch_width=9001)) - - with pytest.raises( - pd.ValidationError, - match=r"must be greater than inlet x", - ): - # friction patch x min too small - _ = WindTunnelFarfield( - inlet_x_position=-2025, - floor_type=StaticFloor(friction_patch_x_range=(-9001, 333)), - ) - - with pytest.raises( - pd.ValidationError, - match=r"must be less than half of wind tunnel width", - ): - # wheel belt y outer too large - _ = WindTunnelFarfield( - width=538, - floor_type=WheelBelts( - central_belt_x_range=(-200, 256), - central_belt_width=120, - front_wheel_belt_x_range=(-30, 50), - front_wheel_belt_y_range=(70, 270), - rear_wheel_belt_x_range=(260, 380), - rear_wheel_belt_y_range=(70, 120), - ), - ) - - # legal, despite wheel belts being ahead/behind rather than left/right of central belt - _ = WindTunnelFarfield( - width=1024, - floor_type=WheelBelts( - central_belt_x_range=(-100, 105), - central_belt_width=90.1, - front_wheel_belt_x_range=(-30, 50), - front_wheel_belt_y_range=(70, 123), - rear_wheel_belt_x_range=(260, 380), - rear_wheel_belt_y_range=(70, 120), - ), - ) - - -def test_central_belt_width_validation(): - with CGS_unit_system: - # Test central belt width larger than 2x front wheel belt inner edge - with pytest.raises( - pd.ValidationError, - match=r"must be less than or equal to twice the front wheel belt inner edge", - ): - _ = WheelBelts( - central_belt_x_range=(-200, 256), - central_belt_width=150, # Width is 150 - front_wheel_belt_x_range=(-30, 50), - front_wheel_belt_y_range=( - 70, - 120, - ), # Inner edge is 70, 2×70 = 140 < 150 - rear_wheel_belt_x_range=(260, 380), - rear_wheel_belt_y_range=(80, 170), # Inner edge is 80, 2×80 = 160 > 150 - ) - - # Test central belt width larger than 2x rear wheel belt inner edge - with pytest.raises( - pd.ValidationError, - match=r"must be less than or equal to twice the rear wheel belt inner edge", - ): - _ = WheelBelts( - central_belt_x_range=(-200, 256), - central_belt_width=150, # Width is 150 - front_wheel_belt_x_range=(-30, 50), - front_wheel_belt_y_range=( - 80, - 170, - ), # Inner edge is 80, 2×80 = 160 > 150 - rear_wheel_belt_x_range=(260, 380), - rear_wheel_belt_y_range=(70, 200), # Inner edge is 70, 2×70 = 140 < 150 - ) - - # Test central belt width larger than both inner edges - with pytest.raises( - pd.ValidationError, - match=r"must be less than or equal to twice the front wheel belt inner edge", - ): - _ = WheelBelts( - central_belt_x_range=(-200, 256), - central_belt_width=200, # Width is 200 - front_wheel_belt_x_range=(-30, 50), - front_wheel_belt_y_range=( - 90, - 120, - ), # Inner edge is 90, 2×90 = 180 < 200 - rear_wheel_belt_x_range=(260, 380), - rear_wheel_belt_y_range=(95, 140), # Inner edge is 95, 2×95 = 190 < 200 - ) - - # Legal: central belt width equal to 2x inner edges - _ = WheelBelts( - central_belt_x_range=(-200, 256), - central_belt_width=140, # Width is 140 - front_wheel_belt_x_range=(-30, 50), - front_wheel_belt_y_range=(70, 170), # Inner edge is 70, 2×70 = 140 - rear_wheel_belt_x_range=(260, 380), - rear_wheel_belt_y_range=(70, 170), # Inner edge is 70, 2×70 = 140 - ) - - # Legal: central belt width less than 2x inner edges - _ = WheelBelts( - central_belt_x_range=(-200, 256), - central_belt_width=100, # Width is 100 - front_wheel_belt_x_range=(-30, 50), - front_wheel_belt_y_range=(70, 170), # Inner edge is 70, 2×70 = 140 > 100 - rear_wheel_belt_x_range=(260, 380), - rear_wheel_belt_y_range=(80, 190), # Inner edge is 80, 2×80 = 160 > 100 - ) - - -def test_wind_tunnel_farfield_requires_geometry_ai(): - """Test that WindTunnelFarfield is only supported when Geometry AI is enabled.""" - # Test: When GAI is disabled, should raise error - with pytest.raises( - pd.ValidationError, - match=r"WindTunnelFarfield is only supported when Geometry AI is enabled.", - ): - with ValidationContext(VOLUME_MESH, non_gai_context): - with CGS_unit_system: - WindTunnelFarfield() - - # Test: When GAI is enabled, should work - gai_context = ParamsValidationInfo({}, []) - gai_context.use_geometry_AI = True - - with ValidationContext(VOLUME_MESH, gai_context): - with CGS_unit_system: - farfield = WindTunnelFarfield() - assert farfield.type == "WindTunnelFarfield" - - -def test_min_passage_size_requires_remove_hidden_geometry(): - """Test that min_passage_size can only be specified when remove_hidden_geometry is True.""" - gai_context = ParamsValidationInfo({}, []) - gai_context.use_geometry_AI = True - - # Test 1: min_passage_size with remove_hidden_geometry=False should raise - with pytest.raises( - pd.ValidationError, - match=r"'min_passage_size' can only be specified when 'remove_hidden_geometry' is True", - ): - with ValidationContext(SURFACE_MESH, gai_context): - with SI_unit_system: - MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=False, - min_passage_size=0.005 * u.m, - ) - - # Test 2: min_passage_size with remove_hidden_geometry=True should work - with ValidationContext(SURFACE_MESH, gai_context): - with SI_unit_system: - defaults = MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=True, - min_passage_size=0.005 * u.m, - ) - assert defaults.min_passage_size == 0.005 * u.m - assert defaults.remove_hidden_geometry is True - - # Test 3: remove_hidden_geometry=True without min_passage_size should work (it's optional) - with ValidationContext(SURFACE_MESH, gai_context): - with SI_unit_system: - defaults = MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=True, - min_passage_size=None, - ) - assert defaults.min_passage_size is None - assert defaults.remove_hidden_geometry is True - - -def test_meshing_defaults_octree_spacing_explicit(): - """Test that octree_spacing can be explicitly set on MeshingDefaults.""" - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with SI_unit_system: - defaults = MeshingDefaults( - boundary_layer_first_layer_thickness=1e-5 * u.m, - octree_spacing=OctreeSpacing(base_spacing=2 * u.m), - ) - assert defaults.octree_spacing is not None - assert isinstance(defaults.octree_spacing, OctreeSpacing) - assert defaults.octree_spacing.base_spacing == 2 * u.m - # Verify indexing works through the field - assert defaults.octree_spacing[0] == 2 * u.m - assert defaults.octree_spacing[1] == 1 * u.m - - -def test_meshing_defaults_octree_spacing_auto_set_from_project_length_unit(): - """Test that octree_spacing is automatically set to 1 * project_length_unit when not specified.""" - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - defaults = MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - ) - # beta_mesher_context has project_length_unit = 1 * u.mm - assert defaults.octree_spacing is not None - assert isinstance(defaults.octree_spacing, OctreeSpacing) - assert defaults.octree_spacing.base_spacing == 1 * u.mm - - -def test_meshing_defaults_octree_spacing_none_without_context(): - """Test that octree_spacing stays None when no validation context is active.""" - with CGS_unit_system: - defaults = MeshingDefaults() - assert defaults.octree_spacing is None - - -def test_meshing_defaults_octree_spacing_warning_no_project_length_unit(): - """Test that a validation warning is emitted when project_length_unit is None.""" - no_unit_context = ParamsValidationInfo({}, []) - no_unit_context.is_beta_mesher = True - no_unit_context.project_length_unit = None - - with ValidationContext(VOLUME_MESH, no_unit_context) as ctx: - with CGS_unit_system: - defaults = MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - ) - assert defaults.octree_spacing is None - - warning_msgs = [w["msg"] if isinstance(w, dict) else str(w) for w in ctx.validation_warnings] - assert any( - "octree_spacing" in msg and "will not be set automatically" in msg for msg in warning_msgs - ) - - -def test_meshing_defaults_octree_spacing_negative_raises(): - """Test that negative octree_spacing raises a validation error.""" - with pytest.raises(pd.ValidationError): - with SI_unit_system: - MeshingDefaults(octree_spacing=OctreeSpacing(base_spacing=-1 * u.m)) - - -def test_meshing_defaults_octree_spacing_explicit_object(): - """Test that octree_spacing can be explicitly set as an OctreeSpacing object.""" - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with SI_unit_system: - spacing = OctreeSpacing(base_spacing=5 * u.m) - defaults = MeshingDefaults( - boundary_layer_first_layer_thickness=1e-5 * u.m, - octree_spacing=spacing, - ) - assert defaults.octree_spacing.base_spacing == 5 * u.m - - -def test_meshing_params_octree_check_skipped_for_non_beta(): - """Test that octree series check is skipped for non-beta mesher.""" - # Should not warn or raise — the validator returns early for non-beta - with ValidationContext(VOLUME_MESH, non_beta_mesher_context) as ctx: - with CGS_unit_system: - cylinder = Cylinder( - name="cyl", - outer_radius=10, - height=20, - axis=(0, 0, 1), - center=(0, 0, 0), - ) - MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - ), - refinements=[ - UniformRefinement(entities=[cylinder], spacing=0.3), - ], - volume_zones=[AutomatedFarfield()], - ) - # No octree-related warnings for non-beta mesher - warning_msgs = [w["msg"] if isinstance(w, dict) else str(w) for w in ctx.validation_warnings] - assert not any("octree series" in msg for msg in warning_msgs) - - -def test_meshing_params_octree_check_warns_for_non_aligned_spacing(capsys): - """Test that octree series check warns when spacing doesn't align with octree series.""" - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cyl", - outer_radius=10, - height=20, - axis=(0, 0, 1), - center=(0, 0, 0), - ) - MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - octree_spacing=OctreeSpacing(base_spacing=1 * u.mm), - ), - refinements=[ - # 0.3 mm is not a power-of-2 fraction of 1 mm - UniformRefinement(entities=[cylinder], spacing=0.3 * u.mm), - ], - volume_zones=[AutomatedFarfield()], - ) - captured = capsys.readouterr() - captured_text = " ".join(captured.out.split()) - assert "will be cast to the first lower refinement" in captured_text - - -def test_meshing_params_octree_check_no_warn_for_aligned_spacing(): - """Test that octree series check does not warn for aligned spacing.""" - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cyl", - outer_radius=10, - height=20, - axis=(0, 0, 1), - center=(0, 0, 0), - ) - # 0.5 mm = 1mm * 2^-1, so this is aligned - MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - octree_spacing=OctreeSpacing(base_spacing=1 * u.mm), - ), - refinements=[ - UniformRefinement(entities=[cylinder], spacing=0.5 * u.mm), - ], - volume_zones=[AutomatedFarfield()], - ) - - -def test_meshing_params_octree_check_skipped_when_octree_spacing_none(): - """Test that octree check is skipped when octree_spacing is None.""" - no_unit_context = ParamsValidationInfo({}, []) - no_unit_context.is_beta_mesher = True - no_unit_context.project_length_unit = None - - with ValidationContext(VOLUME_MESH, no_unit_context): - with CGS_unit_system: - cylinder = Cylinder( - name="cyl", - outer_radius=10, - height=20, - axis=(0, 0, 1), - center=(0, 0, 0), - ) - # Should not raise — validator just logs a warning and skips - MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - ), - refinements=[ - UniformRefinement(entities=[cylinder], spacing=0.3), - ], - volume_zones=[AutomatedFarfield()], - ) - - -def test_meshing_params_octree_check_multiple_refinements(): - """Test that octree series check runs on all UniformRefinements.""" - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - cylinder1 = Cylinder( - name="cyl1", - outer_radius=10, - height=20, - axis=(0, 0, 1), - center=(0, 0, 0), - ) - cylinder2 = Cylinder( - name="cyl2", - outer_radius=5, - height=10, - axis=(0, 0, 1), - center=(1, 0, 0), - ) - # Both spacings are powers of 2 of the base, should not warn - MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - octree_spacing=OctreeSpacing(base_spacing=1 * u.mm), - ), - refinements=[ - UniformRefinement(entities=[cylinder1], spacing=0.25 * u.mm), - UniformRefinement(entities=[cylinder2], spacing=0.125 * u.mm), - ], - volume_zones=[AutomatedFarfield()], - ) - - -def test_meshing_params_octree_check_no_refinements(): - """Test that octree check does not fail when there are no refinements.""" - with ValidationContext(VOLUME_MESH, beta_mesher_context): - with CGS_unit_system: - MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - octree_spacing=OctreeSpacing(base_spacing=1 * u.mm), - ), - refinements=[], - volume_zones=[AutomatedFarfield()], - ) - - def test_per_face_min_passage_size_warning_without_remove_hidden_geometry(): - """Test that per-face min_passage_size on GeometryRefinement warns when remove_hidden_geometry is disabled.""" - - # Test 1: min_passage_size on GeometryRefinement with remove_hidden_geometry=False → warning with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2322,7 +116,6 @@ def test_per_face_min_passage_size_warning_without_remove_hidden_geometry(): assert "min_passage_size" in warnings[0]["msg"] assert "remove_hidden_geometry" in warnings[0]["msg"] - # Test 2: min_passage_size on GeometryRefinement with remove_hidden_geometry=True → no warning with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2354,7 +147,6 @@ def test_per_face_min_passage_size_warning_without_remove_hidden_geometry(): assert errors is None assert warnings == [] - # Test 3: GeometryRefinement without min_passage_size, remove_hidden_geometry=False → no warning with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2387,9 +179,6 @@ def test_per_face_min_passage_size_warning_without_remove_hidden_geometry(): def test_multi_zone_remove_hidden_geometry_warning(): - """Test that remove_hidden_geometry with multiple farfield/custom volume zones triggers a warning.""" - - # Test 1: remove_hidden_geometry=True with AutomatedFarfield + CustomZones → warning with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2430,7 +219,6 @@ def test_multi_zone_remove_hidden_geometry_warning(): in warnings[0]["msg"].lower() ) - # Test 2: remove_hidden_geometry=True with a single AutomatedFarfield zone → no warning with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2456,7 +244,6 @@ def test_multi_zone_remove_hidden_geometry_warning(): assert errors is None assert warnings == [] - # Test 3: remove_hidden_geometry=False with AutomatedFarfield + CustomZones → no warning with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2493,7 +280,6 @@ def test_multi_zone_remove_hidden_geometry_warning(): assert errors is None assert warnings == [] - # Test 4: remove_hidden_geometry=True with a single CustomZones containing multiple CustomVolumes → warning with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2538,7 +324,6 @@ def test_multi_zone_remove_hidden_geometry_warning(): in warnings[0]["msg"].lower() ) - # Test 5: remove_hidden_geometry=True with UDF + 1 CV → no warning (UDF doesn't contribute a zone) with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2575,7 +360,6 @@ def test_multi_zone_remove_hidden_geometry_warning(): assert errors is None assert warnings == [] - # Test 6: remove_hidden_geometry=True with UDF + 2 CVs → warning (2 actual zones) with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2620,7 +404,6 @@ def test_multi_zone_remove_hidden_geometry_warning(): in warnings[0]["msg"].lower() ) - # Test 7: remove_hidden_geometry=True with a single CV and implicit farfield → no warning (1 actual zone) with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2656,47 +439,3 @@ def test_multi_zone_remove_hidden_geometry_warning(): ) assert errors is None assert warnings == [] - - -def test_geometry_accuracy_with_non_unit_project_length_scale(): - """geometry_accuracy validation must account for the project-length scale factor. - - When project_length_unit carries a non-1 scale (e.g. 1.2*mm), the bounding-box - diagonal in project coordinates must be scaled to physical units before the - comparison with geometry_accuracy. - - bbox diagonal in project coords ≈ sqrt(3) * 1e6 ≈ 1.732e6 - physical diagonal = 1.732e6 * 1.2 mm ≈ 2.079e6 mm - correct lower limit = 1e-6 * 2.079e6 mm ≈ 2.079 mm - """ - gai_ctx = ParamsValidationInfo({}, []) - gai_ctx.use_geometry_AI = True - gai_ctx.project_length_unit = 1.2 * u.mm - gai_ctx.global_bounding_box = BoundingBox([[-5e5, -5e5, -5e5], [5e5, 5e5, 5e5]]) - - expected_warning = ( - "geometry_accuracy (1.9 mm) is below the recommended value " - "of 1e-06 * bounding box diagonal (2.08e+00 mm). " - "Please increase geometry_accuracy." - ) - - # 1.9 mm < correct limit ~2.079 mm → warning emitted - with ValidationContext(SURFACE_MESH, gai_ctx) as ctx: - with SI_unit_system: - MeshingDefaults( - geometry_accuracy=1.9 * u.mm, - surface_max_edge_length=10 * u.m, - ) - warning_msgs = [w["msg"] if isinstance(w, dict) else str(w) for w in ctx.validation_warnings] - assert expected_warning in warning_msgs - - # 3.0 mm > correct limit ~2.079 mm → no warning - with ValidationContext(SURFACE_MESH, gai_ctx) as ctx: - with SI_unit_system: - defaults = MeshingDefaults( - geometry_accuracy=3.0 * u.mm, - surface_max_edge_length=10 * u.m, - ) - assert defaults.geometry_accuracy == 3.0 * u.mm - warning_msgs = [w["msg"] if isinstance(w, dict) else str(w) for w in ctx.validation_warnings] - assert expected_warning not in warning_msgs diff --git a/tests/simulation/params/meshing_validation/test_refinements_validation.py b/tests/simulation/params/meshing_validation/test_refinements_validation.py index a37363b6f..10d3a73e0 100644 --- a/tests/simulation/params/meshing_validation/test_refinements_validation.py +++ b/tests/simulation/params/meshing_validation/test_refinements_validation.py @@ -1,8 +1,3 @@ -import re - -import pydantic as pd -import pytest - import flow360.component.simulation.units as u from flow360.component.simulation.meshing_param import snappy from flow360.component.simulation.meshing_param.meshing_specs import ( @@ -16,217 +11,13 @@ AutomatedFarfield, UniformRefinement, ) -from flow360.component.simulation.primitives import Box, Cylinder, SnappyBody, Surface +from flow360.component.simulation.primitives import Box, Cylinder from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.unit_system import SI_unit_system -def test_snappy_refinements_validators(mock_validation_context): - message = "Minimum spacing must be lower than maximum spacing." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - snappy.RegionRefinement( - min_spacing=4.3 * u.mm, max_spacing=2.1 * u.mm, regions=[Surface(name="test")] - ) - - message = "UniformRefinement for snappy accepts only Boxes with axes aligned with the global coordinate system (angle_of_rotation=0)." - with ( - mock_validation_context, - SI_unit_system, - pytest.raises(ValueError, match=re.escape(message)), - ): - snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=3 * u.mm, max_spacing=10 * u.mm, gap_resolution=0.1 * u.mm - ), - refinements=[ - UniformRefinement( - name="unif", - spacing=6 * u.mm, - entities=[ - Box( - center=[2, 3, 4] * u.m, - size=[5, 6, 7] * u.m, - axis_of_rotation=[1, 3, 4], - angle_of_rotation=5 * u.deg, - name="box", - ) - ], - ) - ], - ) - - snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=3 * u.mm, max_spacing=10 * u.mm, gap_resolution=0.1 * u.mm - ), - refinements=[ - UniformRefinement( - name="unif", - spacing=6 * u.mm, - entities=[ - Box( - center=[2, 3, 4] * u.m, - size=[5, 6, 7] * u.m, - axis_of_rotation=[1, 3, 4], - angle_of_rotation=0 * u.deg, - name="box", - ) - ], - ) - ], - ) - - snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=3 * u.mm, max_spacing=10 * u.mm, gap_resolution=0.1 * u.mm - ), - refinements=[ - UniformRefinement( - name="unif", - spacing=6 * u.mm, - entities=[ - Box( - center=[2, 3, 4] * u.m, - size=[5, 6, 7] * u.m, - axis_of_rotation=[1, 3, 4], - angle_of_rotation=360 * u.deg, - name="box", - ) - ], - ) - ], - ) - - message = "UniformRefinement for snappy accepts only full cylinders (where inner_radius = 0)." - with ( - mock_validation_context, - SI_unit_system, - pytest.raises(ValueError, match=re.escape(message)), - ): - snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=3 * u.mm, max_spacing=10 * u.mm, gap_resolution=0.1 * u.mm - ), - refinements=[ - UniformRefinement( - name="unif", - spacing=6 * u.mm, - entities=[ - Cylinder( - name="cyl", - inner_radius=3 * u.mm, - outer_radius=7 * u.mm, - axis=[0, 0, 1], - center=[0, 0, 0] * u.m, - height=10 * u.mm, - ) - ], - ) - ], - ) - - -def test_snappy_edge_refinement_validators(): - message = "When using a distance spacing specification both spacing (2.0 mm) and distances ([5.] mm) fields must be arrays and the same length." - with pytest.raises( - ValueError, - match=re.escape(message), - ): - snappy.SurfaceEdgeRefinement( - spacing=2 * u.mm, distances=[5 * u.mm], entities=[Surface(name="test")] - ) - - with pytest.raises( - pd.ValidationError, - ): - snappy.SurfaceEdgeRefinement( - spacing=[2 * u.mm, 3 * u.mm], distances=[5 * u.mm], entities=[Surface(name="test")] - ) - - with pytest.raises(pd.ValidationError): - snappy.SurfaceEdgeRefinement( - spacing=2 * u.mm, distances=5 * u.mm, entities=[Surface(name="test")] - ) - - message = "When using a distance spacing specification both spacing ([2.] mm) and distances (None) fields must be arrays and the same length." - with pytest.raises( - ValueError, - match=re.escape(message), - ): - snappy.SurfaceEdgeRefinement(spacing=[2 * u.mm], entities=[Surface(name="test")]) - - snappy.SurfaceEdgeRefinement( - spacing=[2 * u.mm], distances=[5 * u.mm], entities=[Surface(name="test")] - ) - - snappy.SurfaceEdgeRefinement(entities=[Surface(name="test")]) - - snappy.SurfaceEdgeRefinement(spacing=2 * u.mm, entities=[Surface(name="test")]) - - snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=3 * u.mm, max_spacing=6 * u.mm, gap_resolution=0.1 * u.mm - ), - refinements=[ - snappy.SurfaceEdgeRefinement( - spacing=[2 * u.mm], distances=[5 * u.mm], entities=[Surface(name="test")] - ), - snappy.SurfaceEdgeRefinement(spacing=2 * u.mm, entities=[Surface(name="test2")]), - snappy.SurfaceEdgeRefinement(entities=[Surface(name="test3")]), - ], - ) - - -def test_snappy_edge_refinement_increasing_values_validator(): - message = "Spacings and distances must be increasing arrays." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - snappy.SurfaceEdgeRefinement( - spacing=[2 * u.mm, 1 * u.mm], - distances=[5 * u.mm, 6 * u.mm], - entities=[Surface(name="test")], - ) - - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - snappy.SurfaceEdgeRefinement( - spacing=[2 * u.mm, 3 * u.mm], - distances=[5 * u.mm, 4 * u.mm], - entities=[Surface(name="test")], - ) - - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - snappy.SurfaceEdgeRefinement( - spacing=[2 * u.mm, 1 * u.mm], - distances=[5 * u.mm, 4 * u.mm], - entities=[Surface(name="test")], - ) - - -def test_snappy_body_refinement_validator(): - message = "No refinement (gap_resolution, min_spacing, max_spacing, proximity_spacing) specified in `BodyRefinement`." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - snappy.BodyRefinement(bodies=SnappyBody(name="body1", surfaces=[Surface(name="surface")])) - - snappy.BodyRefinement( - bodies=SnappyBody(name="body1", surfaces=[Surface(name="surface")]), min_spacing=2 * u.mm - ) - - snappy.BodyRefinement( - bodies=SnappyBody(name="body1", surfaces=[Surface(name="surface")]), max_spacing=2 * u.mm - ) - - snappy.BodyRefinement( - bodies=SnappyBody(name="body1", surfaces=[Surface(name="surface")]), - proximity_spacing=2 * u.mm, - ) - - snappy.BodyRefinement( - bodies=SnappyBody(name="body1", surfaces=[Surface(name="surface")]), gap_resolution=2 * u.mm - ) - - def _make_snappy_params_with_volume_uniform_refinement(refinement): - """Helper to build SimulationParams with a UniformRefinement in volume meshing.""" with SI_unit_system: return SimulationParams( meshing=ModularMeshingWorkflow( @@ -249,11 +40,6 @@ def _make_snappy_params_with_volume_uniform_refinement(refinement): def test_volume_uniform_refinement_rotated_box_project_to_surface(): - """ - A UniformRefinement with a rotated Box placed in volume meshing with - project_to_surface=True must trigger the same snappy validation error - as if it were placed directly in the surface meshing refinements. - """ rotated_box = Box( center=[0, 0, 0] * u.m, size=[1, 1, 1] * u.m, @@ -261,13 +47,11 @@ def test_volume_uniform_refinement_rotated_box_project_to_surface(): angle_of_rotation=45 * u.deg, name="rotated_box", ) - refinement = UniformRefinement( spacing=5 * u.mm, entities=[rotated_box], project_to_surface=True, ) - params = _make_snappy_params_with_volume_uniform_refinement(refinement) _, errors, _ = validate_model( @@ -277,20 +61,12 @@ def test_volume_uniform_refinement_rotated_box_project_to_surface(): validation_level="VolumeMesh", ) - assert errors is not None, ( - "Expected validation error for rotated Box in volume UniformRefinement " - "with project_to_surface=True" - ) - error_messages = [e["msg"] for e in errors] + assert errors is not None + error_messages = [error["msg"] for error in errors] assert any("angle_of_rotation" in msg or "axes aligned" in msg for msg in error_messages) def test_volume_uniform_refinement_hollow_cylinder_project_to_surface(): - """ - A UniformRefinement with a hollow Cylinder (inner_radius > 0) placed in - volume meshing with project_to_surface=True must trigger the same snappy - validation error as if it were in the surface meshing refinements. - """ hollow_cylinder = Cylinder( name="hollow_cyl", inner_radius=3 * u.mm, @@ -299,13 +75,11 @@ def test_volume_uniform_refinement_hollow_cylinder_project_to_surface(): center=[0, 0, 0] * u.m, height=10 * u.mm, ) - refinement = UniformRefinement( spacing=5 * u.mm, entities=[hollow_cylinder], project_to_surface=True, ) - params = _make_snappy_params_with_volume_uniform_refinement(refinement) _, errors, _ = validate_model( @@ -315,19 +89,12 @@ def test_volume_uniform_refinement_hollow_cylinder_project_to_surface(): validation_level="VolumeMesh", ) - assert errors is not None, ( - "Expected validation error for hollow Cylinder in volume UniformRefinement " - "with project_to_surface=True" - ) - error_messages = [e["msg"] for e in errors] + assert errors is not None + error_messages = [error["msg"] for error in errors] assert any("inner_radius" in msg or "full cylinders" in msg for msg in error_messages) def test_volume_uniform_refinement_cylinder_none_inner_radius_project_to_surface(): - """ - A Cylinder with inner_radius=None is a full cylinder (equivalent to inner_radius=0) - and must NOT trigger a validation error. - """ full_cylinder = Cylinder( name="full_cyl_none", inner_radius=None, @@ -336,13 +103,11 @@ def test_volume_uniform_refinement_cylinder_none_inner_radius_project_to_surface center=[0, 0, 0] * u.m, height=10 * u.mm, ) - refinement = UniformRefinement( spacing=5 * u.mm, entities=[full_cylinder], project_to_surface=True, ) - params = _make_snappy_params_with_volume_uniform_refinement(refinement) _, errors, _ = validate_model( @@ -352,16 +117,10 @@ def test_volume_uniform_refinement_cylinder_none_inner_radius_project_to_surface validation_level="VolumeMesh", ) - assert ( - errors is None - ), "Cylinder with inner_radius=None is a full cylinder and should pass snappy validation" + assert errors is None def test_volume_uniform_refinement_default_project_to_surface(): - """ - When project_to_surface is None (the default, which acts as True for snappy), - the same snappy constraints should be enforced. - """ rotated_box = Box( center=[0, 0, 0] * u.m, size=[1, 1, 1] * u.m, @@ -369,12 +128,7 @@ def test_volume_uniform_refinement_default_project_to_surface(): angle_of_rotation=90 * u.deg, name="rotated_box_default", ) - - refinement = UniformRefinement( - spacing=5 * u.mm, - entities=[rotated_box], - ) - + refinement = UniformRefinement(spacing=5 * u.mm, entities=[rotated_box]) params = _make_snappy_params_with_volume_uniform_refinement(refinement) _, errors, _ = validate_model( @@ -384,20 +138,12 @@ def test_volume_uniform_refinement_default_project_to_surface(): validation_level="VolumeMesh", ) - assert errors is not None, ( - "Expected validation error for rotated Box in volume UniformRefinement " - "with default project_to_surface (None)" - ) - error_messages = [e["msg"] for e in errors] + assert errors is not None + error_messages = [error["msg"] for error in errors] assert any("angle_of_rotation" in msg or "axes aligned" in msg for msg in error_messages) def test_volume_uniform_refinement_project_to_surface_false_skips_validation(): - """ - When project_to_surface=False, snappy-specific constraints on entities - should NOT be enforced since the refinement won't be projected to the - surface mesh. - """ rotated_box = Box( center=[0, 0, 0] * u.m, size=[1, 1, 1] * u.m, @@ -405,13 +151,11 @@ def test_volume_uniform_refinement_project_to_surface_false_skips_validation(): angle_of_rotation=45 * u.deg, name="rotated_box_no_project", ) - refinement = UniformRefinement( spacing=5 * u.mm, entities=[rotated_box], project_to_surface=False, ) - params = _make_snappy_params_with_volume_uniform_refinement(refinement) _, errors, _ = validate_model( @@ -421,4 +165,4 @@ def test_volume_uniform_refinement_project_to_surface_false_skips_validation(): validation_level="VolumeMesh", ) - assert errors is None, "No snappy validation error expected when project_to_surface=False" + assert errors is None diff --git a/tests/simulation/params/test_actuator_disk.py b/tests/simulation/params/test_actuator_disk.py index 21f275880..7ec50e720 100644 --- a/tests/simulation/params/test_actuator_disk.py +++ b/tests/simulation/params/test_actuator_disk.py @@ -1,125 +1,16 @@ -import re - -import pytest import unyt as u from flow360.component.simulation.models.volume_models import ActuatorDisk, ForcePerArea from flow360.component.simulation.primitives import Cylinder -from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.translator.solver_translator import ( actuator_disk_translator, ) from flow360.component.simulation.unit_system import SI_unit_system -def test_actuator_disk(): - with SI_unit_system: - fpa = ForcePerArea(radius=[0, 1, 2, 4], thrust=[1, 1, 2, 2], circumferential=[1, 1, 3, 4]) - assert fpa - - my_cylinder_1 = Cylinder( - name="my_cylinder-1", - axis=(5, 0, 0), - center=(1.2, 2.3, 3.4), - height=3.0, - outer_radius=5.0, - ) - - ad = ActuatorDisk(volumes=[my_cylinder_1], force_per_area=fpa) - assert ad - - with pytest.raises( - ValueError, - match=re.escape( - "length of radius, thrust, circumferential must be the same, but got: " - + "len(radius)=3, len(thrust)=2, len(circumferential)=2" - ), - ): - fpa = ForcePerArea(radius=[0, 1, 3], thrust=[1, 1], circumferential=[1, 1]) - - with pytest.raises( - ValueError, - match=re.escape( - "length of radius, thrust, circumferential must be the same, but got: " - + "len(radius)=3, len(thrust)=2, len(circumferential)=2" - ), - ): - fpa = ForcePerArea( - radius=[0, 1, 3] * u.m, thrust=[1, 1] * u.Pa, circumferential=[1, 1] * u.Pa - ) - - -def test_actuator_disk_from_json(): - data = { - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Cylinder", - "name": "my_cylinder-1", - "axis": (1.0, 0.0, 0.0), - "center": {"value": (1.2, 2.3, 3.4), "units": "m"}, - "height": {"value": 3.0, "units": "m"}, - "outer_radius": {"value": 5.0, "units": "m"}, - } - ] - }, - "force_per_area": { - "radius": {"value": (0.0, 1.0, 2.0, 4.0), "units": "m"}, - "thrust": {"value": (1.0, 1.0, 2.0, 2.0), "units": "N/m**2"}, - "circumferential": {"value": (1.0, 1.0, 3.0, 4.0), "units": "N/m**2"}, - }, - "type": "ActuatorDisk", - } - ad = ActuatorDisk(**data) - - assert ad.force_per_area.radius[2].value == 2 - assert ad.force_per_area.radius[2].units == u.m - assert ad.entities.stored_entities[0].center[0].value == 1.2 - - -def test_actuator_disk_duplicate_cylinder_names(mock_validation_context): - with SI_unit_system: - fpa = ForcePerArea(radius=[0, 1, 2, 4], thrust=[1, 1, 2, 2], circumferential=[1, 1, 3, 4]) - my_cylinder_1 = Cylinder( - name="my_cylinder-1", - axis=(5, 0, 0), - center=(1.2, 2.3, 3.4), - height=3.0, - outer_radius=5.0, - ) - - my_cylinder_2 = Cylinder( - name="my_cylinder-2", - axis=(5, 0, 0), - center=(2.2, 2.3, 3.4), - height=3.0, - outer_radius=5.0, - ) - - ad = ActuatorDisk(volumes=[my_cylinder_1, my_cylinder_2], force_per_area=fpa) - sm = SimulationParams(models=[ad]) - - assert sm - - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - f"The ActuatorDisk cylinder name `my_cylinder-1` at index 1 in model `Actuator disk` " - "has already been used. Please use unique Cylinder entity names among all " - "ActuatorDisk instances." - ), - ), - ): - ad_duplicate = ActuatorDisk(volumes=[my_cylinder_1, my_cylinder_1], force_per_area=fpa) - sm = SimulationParams(models=[ad_duplicate]) - - def _make_actuator_disk(reference_velocity=None): - """Helper to create an ActuatorDisk with optional reference_velocity.""" - kwargs = dict( - volumes=[ + kwargs = { + "volumes": [ Cylinder( name="test_disk", axis=(1, 0, 0), @@ -128,31 +19,17 @@ def _make_actuator_disk(reference_velocity=None): outer_radius=5.0, ), ], - force_per_area=ForcePerArea(radius=[0, 1, 2], thrust=[10, 8, 0], circumferential=[2, 3, 0]), - ) + "force_per_area": ForcePerArea( + radius=[0, 1, 2], + thrust=[10, 8, 0], + circumferential=[2, 3, 0], + ), + } if reference_velocity is not None: kwargs["reference_velocity"] = reference_velocity return ActuatorDisk(**kwargs) -def test_actuator_disk_without_reference_velocity(): - with SI_unit_system: - ad = _make_actuator_disk() - assert ad.reference_velocity is None - - -def test_actuator_disk_with_reference_velocity(): - with SI_unit_system: - ad = _make_actuator_disk(reference_velocity=(10.0, 0.0, 0.0) * u.m / u.s) - assert ad.reference_velocity is not None - - -def test_actuator_disk_reference_velocity_different_units(): - with SI_unit_system: - ad = _make_actuator_disk(reference_velocity=(32.8084, 0.0, 0.0) * u.ft / u.s) - assert ad.reference_velocity is not None - - def test_actuator_disk_translator_omits_reference_velocity_when_not_set(): with SI_unit_system: ad = _make_actuator_disk() diff --git a/tests/simulation/params/test_automated_farfield.py b/tests/simulation/params/test_automated_farfield.py index 43bb0bd3d..cda8e2377 100644 --- a/tests/simulation/params/test_automated_farfield.py +++ b/tests/simulation/params/test_automated_farfield.py @@ -79,133 +79,6 @@ def _run_validation(params, surface_mesh_obj, use_beta_mesher=True, use_geometry return errors, warnings -def test_automated_farfield_surface_usage(): - # Test use of GhostSurface in meshing via ValidationContext (Surface mesh + automated farfield): - import pydantic as pd - - from flow360.component.simulation.validation.validation_context import ( - VOLUME_MESH, - ParamsValidationInfo, - ValidationContext, - ) - - with SI_unit_system: - my_farfield = AutomatedFarfield(name="my_farfield") - param_dict = { - "meshing": { - "type_name": "MeshingParams", - "volume_zones": [ - {"type": "AutomatedFarfield", "method": "auto"}, - ], - }, - "private_attribute_asset_cache": { - "use_inhouse_mesher": True, - "use_geometry_AI": True, - "project_entity_info": {"type_name": "SurfaceMeshEntityInfo"}, - }, - } - info = ParamsValidationInfo(param_as_dict=param_dict, referenced_expressions=[]) - with ValidationContext(levels=VOLUME_MESH, info=info): - with pytest.raises(pd.ValidationError): - _ = SimulationParams( - meshing=MeshingParams( - volume_zones=[ - my_farfield, - ], - refinements=[ - SurfaceRefinement( - name="does not work", - entities=[my_farfield.farfield], - max_edge_length=1e-4, - ) - ], - ), - ) - - # Boundary condition (Wall) does not accept GhostSurface by type; keep original type-level error - with SI_unit_system: - my_farfield = AutomatedFarfield(name="my_farfield") - with pytest.raises( - ValueError, - match=re.escape( - "Can not find any valid entity of type ['Surface', 'MirroredSurface', 'WindTunnelGhostSurface'] from the input." - ), - ): - _ = SimulationParams( - meshing=MeshingParams( - volume_zones=[ - my_farfield, - ], - refinements=[ - SurfaceRefinement( - name="does not work", - entities=[my_farfield.farfield], - max_edge_length=1e-4, - ) - ], - ), - models=[Wall(name="wall", surfaces=[my_farfield.farfield])], - ) - - with SI_unit_system: - my_farfield = AutomatedFarfield(name="my_farfield") - _ = SimulationParams( - models=[ - SlipWall(name="slipwall", entities=my_farfield.farfield), - SymmetryPlane(name="symm_plane", entities=my_farfield.symmetry_plane), - ], - ) - - with SI_unit_system: - my_farfield = AutomatedFarfield(name="my_farfield") - _ = SimulationParams( - models=[ - Freestream(name="fs", entities=my_farfield.farfield), - ], - ) - - # Test use of GhostSurface in SurfaceOutput - with SI_unit_system: - my_farfield = AutomatedFarfield(name="my_farfield") - _ = SimulationParams( - outputs=[ - SurfaceOutput(entities=my_farfield.farfield, output_fields=["Cp"]), - SurfaceIntegralOutput( - name="prb 110", - entities=[ - my_farfield.symmetry_plane, - Surface(name="surface2"), - ], - output_fields=["Cpt_user_defined"], - ), - ], - user_defined_fields=[UserDefinedField(name="Cpt_user_defined", expression="Cp-123")], - ) - - -def test_automated_farfield_import_export(): - - my_farfield = AutomatedFarfield(name="my_farfield") - model_as_dict = my_farfield.model_dump() - assert "private_attribute_entity" not in model_as_dict.keys() - - model_as_dict = {"name": "my_farfield", "method": "auto"} - my_farfield = AutomatedFarfield(**model_as_dict) - - model_as_dict = {"name": "my_farfield"} - my_farfield = AutomatedFarfield(**model_as_dict) - - with pytest.raises( - ValueError, - match=re.escape("Unable to extract tag using discriminator 'type'"), - ): - MeshingParams(**{"volume_zones": [model_as_dict]}) - - model_as_dict = {"name": "my_farfield", "type": "AutomatedFarfield"} - meshing = MeshingParams(**{"volume_zones": [model_as_dict]}) - assert isinstance(meshing.volume_zones[0], AutomatedFarfield) - - def test_symmetric_existence(surface_mesh): farfield = AutomatedFarfield() diff --git a/tests/simulation/params/test_bet_disk_from_file.py b/tests/simulation/params/test_bet_disk_from_file.py deleted file mode 100644 index b853cf990..000000000 --- a/tests/simulation/params/test_bet_disk_from_file.py +++ /dev/null @@ -1,148 +0,0 @@ -import json -import os -from unittest.mock import patch - -import pytest -from flow360_schema.framework.validation.context import DeserializationContext - -import flow360 as fl - - -def test_bet_disk_updater_and_override(tmp_path): - # Create a temporary JSON file with old version and extra fields - filename = tmp_path / "BET_Disk_example.json" - - # Minimal content recreating the structure of the example file - data = { - "version": "25.7.5", - "_id": "1076e3c2-d31e-4d2b-97bb-d0d21801fa7d", - "name": "BET 57", - "type": "BETDisk", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Cylinder", - "private_attribute_id": "77a28c82-1dfd-40fd-bf3b-1a8385aa10e0", - "private_attribute_registry_bucket_name": "DraftEntities", - "name": "BET 5", - "axis": [0, 0, 1], - "center": {"value": [2.7, -6, 1.06], "units": "m"}, - "height": {"value": 0.2, "units": "m"}, - "outer_radius": {"value": 1.5, "units": "m"}, - }, - { - "private_attribute_entity_type_name": "Cylinder", - "private_attribute_id": "4a28e9b9-4124-489c-a86a-2eabb02fa6c9", - "private_attribute_registry_bucket_name": "DraftEntities", - "name": "BET 7", - "axis": [0, 0, 1], - "center": {"value": [2.7, 2.65, 1.06], "units": "m"}, - "height": {"value": 0.2, "units": "m"}, - "outer_radius": {"value": 1.5, "units": "m"}, - }, - ] - }, - "rotation_direction_rule": "leftHand", - "number_of_blades": 2, - "omega": {"value": 800, "units": "rpm"}, - "chord_ref": {"value": 0.14, "units": "m"}, - "n_loading_nodes": 20, - "blade_line_chord": {"value": 0.25, "units": "m"}, - "initial_blade_direction": [1, 0, 0], - "tip_gap": "inf", - "mach_numbers": [0], - "reynolds_numbers": [1000000], - "alphas": {"value": [-180, 180], "units": "degree"}, - "sectional_radiuses": {"value": [0.1, 1.5], "units": "m"}, - "sectional_polars": [ - {"lift_coeffs": [[[0.0, 0.0]]], "drag_coeffs": [[[0.0, 0.0]]]}, - {"lift_coeffs": [[[0.0, 0.0]]], "drag_coeffs": [[[0.0, 0.0]]]}, - ], - "twists": [ - {"radius": {"value": 0.1, "units": "m"}, "twist": {"value": 0, "units": "degree"}}, - {"radius": {"value": 1.5, "units": "m"}, "twist": {"value": 0, "units": "degree"}}, - ], - "chords": [ - {"radius": {"value": 0.1, "units": "m"}, "chord": {"value": 0.1, "units": "m"}}, - {"radius": {"value": 1.5, "units": "m"}, "chord": {"value": 0.1, "units": "m"}}, - ], - } - - with open(filename, "w") as f: - json.dump(data, f) - - # Define override value - new_omega = 12345 * fl.u.rpm - - # Load from file with updater support and override - disk = fl.BETDisk.from_file(str(filename), omega=new_omega) - - # 1. Verify override worked - assert disk.omega == new_omega - - # 2. Verify basic properties loaded from JSON - assert disk.name == "BET 57" - assert disk.number_of_blades == 2 - assert len(disk.entities.stored_entities) == 2 - - -def test_bet_disk_from_file_uses_deserialization_context(tmp_path): - """BETDisk.from_file must wrap model_validate in DeserializationContext.""" - filename = tmp_path / "bet.json" - data = { - "version": "25.7.5", - "name": "BET", - "type": "BETDisk", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Cylinder", - "private_attribute_registry_bucket_name": "DraftEntities", - "name": "cyl", - "axis": [0, 0, 1], - "center": {"value": [0, 0, 0], "units": "m"}, - "height": {"value": 1, "units": "m"}, - "outer_radius": {"value": 1, "units": "m"}, - } - ] - }, - "rotation_direction_rule": "leftHand", - "number_of_blades": 2, - "omega": {"value": 800, "units": "rpm"}, - "chord_ref": {"value": 0.14, "units": "m"}, - "n_loading_nodes": 20, - "blade_line_chord": {"value": 0.25, "units": "m"}, - "initial_blade_direction": [1, 0, 0], - "tip_gap": "inf", - "mach_numbers": [0], - "reynolds_numbers": [1000000], - "alphas": {"value": [-180, 180], "units": "degree"}, - "sectional_radiuses": {"value": [0.1, 1], "units": "m"}, - "sectional_polars": [ - {"lift_coeffs": [[[0.0, 0.0]]], "drag_coeffs": [[[0.0, 0.0]]]}, - {"lift_coeffs": [[[0.0, 0.0]]], "drag_coeffs": [[[0.0, 0.0]]]}, - ], - "twists": [ - {"radius": {"value": 0.1, "units": "m"}, "twist": {"value": 0, "units": "degree"}}, - {"radius": {"value": 1, "units": "m"}, "twist": {"value": 0, "units": "degree"}}, - ], - "chords": [ - {"radius": {"value": 0.1, "units": "m"}, "chord": {"value": 0.1, "units": "m"}}, - {"radius": {"value": 1, "units": "m"}, "chord": {"value": 0.1, "units": "m"}}, - ], - } - with open(filename, "w") as f: - json.dump(data, f) - - entered = False - original_enter = DeserializationContext.__enter__ - - def tracking_enter(self): - nonlocal entered - entered = True - return original_enter(self) - - with patch.object(DeserializationContext, "__enter__", tracking_enter): - fl.BETDisk.from_file(str(filename)) - - assert entered, "BETDisk.from_file must use DeserializationContext" diff --git a/tests/simulation/params/test_gravity.py b/tests/simulation/params/test_gravity.py index e48607e2b..8e7068e0d 100644 --- a/tests/simulation/params/test_gravity.py +++ b/tests/simulation/params/test_gravity.py @@ -1,156 +1,11 @@ import math -import re - -import pytest import flow360.component.simulation.units as u -from flow360.component.simulation.models.volume_models import Fluid, Gravity -from flow360.component.simulation.simulation_params import SimulationParams +from flow360.component.simulation.models.volume_models import Gravity from flow360.component.simulation.translator.solver_translator import gravity_translator -from flow360.component.simulation.unit_system import SI_unit_system - -# ============================================================================ -# Gravity data class tests -# ============================================================================ - - -def test_gravity_defaults(): - """Test Gravity with default values (Earth gravity, downward).""" - gravity = Gravity() - assert tuple(gravity.direction) == (0.0, 0.0, -1.0) - assert math.isclose(gravity.magnitude.to("m/s**2").value, 9.81, rel_tol=1e-10) - - -def test_gravity_custom_values(): - """Test Gravity with custom direction and magnitude.""" - gravity = Gravity( - direction=(1, 0, 0), - magnitude=5.0 * u.m / u.s**2, - ) - assert tuple(gravity.direction) == (1.0, 0.0, 0.0) - assert math.isclose(gravity.magnitude.to("m/s**2").value, 5.0, rel_tol=1e-10) - - -def test_gravity_direction_normalization(): - """Test that direction is normalized automatically.""" - gravity = Gravity( - direction=(0, 3, -4), # magnitude is 5, will be normalized - ) - # Expected: (0, 0.6, -0.8) - assert math.isclose(gravity.direction[0], 0.0, abs_tol=1e-10) - assert math.isclose(gravity.direction[1], 0.6, rel_tol=1e-10) - assert math.isclose(gravity.direction[2], -0.8, rel_tol=1e-10) - - -def test_gravity_zero_direction_raises(): - """Test that zero direction vector raises an error.""" - with pytest.raises(ValueError, match=re.escape("Axis cannot be (0, 0, 0)")): - Gravity(direction=(0, 0, 0)) - - -def test_gravity_different_units(): - """Test Gravity with different acceleration units.""" - gravity = Gravity( - direction=(0, 0, -1), - magnitude=32.174 * u.ft / u.s**2, # ~9.8 m/s^2 - ) - assert gravity.magnitude.to("m/s**2").value > 9.7 - assert gravity.magnitude.to("m/s**2").value < 9.9 - - -def test_gravity_arbitrary_direction(): - """Test Gravity with arbitrary direction is normalized to unit vector.""" - gravity = Gravity(direction=(1, 1, 1)) - norm = math.sqrt(sum(d**2 for d in gravity.direction)) - assert math.isclose(norm, 1.0, rel_tol=1e-10) - - -def test_gravity_very_small_direction(): - """Test that very small but non-zero direction is normalized correctly.""" - gravity = Gravity(direction=(1e-10, 1e-10, 1e-10)) - norm = math.sqrt(sum(d**2 for d in gravity.direction)) - assert math.isclose(norm, 1.0, rel_tol=1e-10) - - -def test_gravity_negative_direction_components(): - """Test Gravity with all negative direction components.""" - gravity = Gravity(direction=(-1, -1, -1)) - expected = -1.0 / math.sqrt(3) - assert math.isclose(gravity.direction[0], expected, rel_tol=1e-10) - assert math.isclose(gravity.direction[1], expected, rel_tol=1e-10) - assert math.isclose(gravity.direction[2], expected, rel_tol=1e-10) - - -def test_gravity_large_magnitude(): - """Test Gravity with large magnitude (e.g., Jupiter-like).""" - gravity = Gravity( - direction=(0, 0, -1), - magnitude=24.79 * u.m / u.s**2, - ) - assert gravity.magnitude.to("m/s**2").value > 24.0 - assert gravity.magnitude.to("m/s**2").value < 25.0 - - -def test_gravity_small_magnitude(): - """Test Gravity with small magnitude (e.g., Moon-like).""" - gravity = Gravity( - direction=(0, 0, -1), - magnitude=1.62 * u.m / u.s**2, - ) - assert gravity.magnitude.to("m/s**2").value > 1.6 - assert gravity.magnitude.to("m/s**2").value < 1.7 - - -# ============================================================================ -# Fluid + Gravity integration tests -# ============================================================================ - - -def test_fluid_without_gravity(): - """Test that Fluid defaults to no gravity.""" - fluid = Fluid() - assert fluid.gravity is None - - -def test_fluid_with_default_gravity(): - """Test Fluid with default Earth gravity.""" - fluid = Fluid(gravity=Gravity()) - assert fluid.gravity is not None - assert tuple(fluid.gravity.direction) == (0.0, 0.0, -1.0) - assert math.isclose(fluid.gravity.magnitude.to("m/s**2").value, 9.81, rel_tol=1e-10) - - -def test_fluid_with_custom_gravity(): - """Test Fluid with custom gravity settings.""" - fluid = Fluid( - gravity=Gravity( - direction=(1, 0, 0), - magnitude=5.0 * u.m / u.s**2, - ) - ) - assert fluid.gravity is not None - assert tuple(fluid.gravity.direction) == (1.0, 0.0, 0.0) - assert math.isclose(fluid.gravity.magnitude.to("m/s**2").value, 5.0, rel_tol=1e-10) - - -def test_fluid_with_gravity_in_simulation_params(): - """Fluid with gravity should be accepted in SimulationParams.""" - fluid = Fluid(gravity=Gravity()) - with SI_unit_system: - params = SimulationParams(models=[fluid]) - assert params - - -# ============================================================================ -# Translator tests -# ============================================================================ def test_gravity_translator_default_direction(): - """Test that gravity_translator combines magnitude and direction correctly. - - With direction (0,0,-1), the gravityVector should be (0, 0, -magnitude). - """ nondim_magnitude = 8.49e-5 gravity = Gravity( direction=(0, 0, -1), @@ -167,7 +22,6 @@ def test_gravity_translator_default_direction(): def test_gravity_translator_custom_direction(): - """Test that gravity_translator preserves direction correctly.""" nondim_magnitude = 1e-3 gravity = Gravity( direction=(1, 0, 0), diff --git a/tests/simulation/params/test_meshing_defaults_deprecation.py b/tests/simulation/params/test_meshing_defaults_deprecation.py deleted file mode 100644 index 7b40eb583..000000000 --- a/tests/simulation/params/test_meshing_defaults_deprecation.py +++ /dev/null @@ -1,19 +0,0 @@ -from flow360.component.simulation.meshing_param.meshing_specs import MeshingDefaults -from flow360.component.simulation.validation.validation_context import ( - CASE, - ValidationContext, -) - - -def test_meshing_defaults_removes_deprecated_remove_non_manifold_faces(): - payload = {"remove_non_manifold_faces": False} - - with ValidationContext(levels=CASE) as validation_context: - defaults = MeshingDefaults.model_validate(payload) - - dumped = defaults.model_dump(mode="json", by_alias=True) - assert "remove_non_manifold_faces" not in dumped - - warning_messages = [warning["msg"] for warning in validation_context.validation_warnings] - assert len(warning_messages) == 1 - assert "remove_non_manifold_faces" in warning_messages[0] diff --git a/tests/simulation/params/test_output_at_final_pseudo_step_only.py b/tests/simulation/params/test_output_at_final_pseudo_step_only.py deleted file mode 100644 index b94c6336c..000000000 --- a/tests/simulation/params/test_output_at_final_pseudo_step_only.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Tests for the output_at_final_pseudo_step_only toggle on monitor output classes.""" - -import re - -import pydantic -import pytest -from flow360_schema.models.variables import solution - -import flow360.component.simulation.units as u -from flow360.component.simulation.outputs.output_entities import Point -from flow360.component.simulation.outputs.outputs import MovingStatistic, ProbeOutput -from flow360.component.simulation.run_control.stopping_criterion import ( - StoppingCriterion, -) -from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.user_code.core.types import UserVariable -from flow360.component.simulation.validation.validation_context import TimeSteppingType - - -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -# --------------------------------------------------------------------------- -# StoppingCriterion + toggle: rejected in steady, allowed in unsteady -# --------------------------------------------------------------------------- - - -@pytest.fixture() -def scalar_field(): - return UserVariable(name="density_field", value=solution.density) - - -@pytest.fixture() -def probe_with_toggle(scalar_field): - return ProbeOutput( - name="probe_toggle", - probe_points=[Point(name="pt", location=(0, 0, 0) * u.m)], - output_fields=[scalar_field], - output_at_final_pseudo_step_only=True, - ) - - -def test_stopping_criterion_rejects_toggle_in_steady( - scalar_field, probe_with_toggle, mock_validation_context -): - message = re.escape( - "A monitor output with `output_at_final_pseudo_step_only=True` cannot be " - "referenced by a StoppingCriterion in a steady simulation." - ) - mock_validation_context.info.output_dict = { - probe_with_toggle.private_attribute_id: probe_with_toggle - } - mock_validation_context.info.time_stepping = TimeSteppingType.STEADY - with ( - SI_unit_system, - mock_validation_context, - pytest.raises(pydantic.ValidationError, match=message), - ): - StoppingCriterion( - monitor_field=scalar_field, - monitor_output=probe_with_toggle, - tolerance=0.01 * u.kg / u.m**3, - ) - - -def test_stopping_criterion_allows_toggle_in_unsteady( - scalar_field, probe_with_toggle, mock_validation_context -): - mock_validation_context.info.output_dict = { - probe_with_toggle.private_attribute_id: probe_with_toggle - } - mock_validation_context.info.time_stepping = TimeSteppingType.UNSTEADY - with SI_unit_system, mock_validation_context: - criterion = StoppingCriterion( - monitor_field=scalar_field, - monitor_output=probe_with_toggle, - tolerance=0.01 * u.kg / u.m**3, - ) - assert criterion.monitor_output == probe_with_toggle.private_attribute_id - - -def test_stopping_criterion_allows_no_toggle_in_steady(scalar_field, mock_validation_context): - """StoppingCriterion with toggle=False (default) should be fine in steady.""" - probe_no_toggle = ProbeOutput( - name="probe_no_toggle", - probe_points=[Point(name="pt", location=(0, 0, 0) * u.m)], - output_fields=[scalar_field], - ) - mock_validation_context.info.output_dict = { - probe_no_toggle.private_attribute_id: probe_no_toggle - } - mock_validation_context.info.time_stepping = TimeSteppingType.STEADY - with SI_unit_system, mock_validation_context: - criterion = StoppingCriterion( - monitor_field=scalar_field, - monitor_output=probe_no_toggle, - tolerance=0.01 * u.kg / u.m**3, - ) - assert criterion.monitor_output == probe_no_toggle.private_attribute_id - - -# --------------------------------------------------------------------------- -# Steady + toggle + MovingStatistic: rejected -# --------------------------------------------------------------------------- - - -def test_toggle_with_moving_statistic_rejected_in_steady_probe(mock_validation_context): - message = re.escape( - "`output_at_final_pseudo_step_only=True` with `moving_statistic` is not allowed " - "for steady simulations (only one data point would be produced)." - ) - mock_validation_context.info.time_stepping = TimeSteppingType.STEADY - with pytest.raises(pydantic.ValidationError, match=message): - with SI_unit_system, mock_validation_context: - ProbeOutput( - name="probe", - probe_points=[Point(name="pt", location=(0, 0, 0) * u.m)], - output_fields=["Cp"], - output_at_final_pseudo_step_only=True, - moving_statistic=MovingStatistic( - method="mean", moving_window_size=10, start_step=100 - ), - ) - - -# --------------------------------------------------------------------------- -# Unsteady + toggle + MovingStatistic: allowed -# --------------------------------------------------------------------------- - - -def test_toggle_with_moving_statistic_allowed_in_unsteady(mock_validation_context): - mock_validation_context.info.time_stepping = TimeSteppingType.UNSTEADY - with SI_unit_system, mock_validation_context: - output = ProbeOutput( - name="probe", - probe_points=[Point(name="pt", location=(0, 0, 0) * u.m)], - output_fields=["Cp"], - output_at_final_pseudo_step_only=True, - moving_statistic=MovingStatistic(method="mean", moving_window_size=10, start_step=100), - ) - assert output.output_at_final_pseudo_step_only is True - assert output.moving_statistic is not None - - -def test_toggle_without_moving_statistic_allowed_in_steady(mock_validation_context): - """Toggle alone (no MovingStatistic) should be fine in steady.""" - mock_validation_context.info.time_stepping = TimeSteppingType.STEADY - with SI_unit_system, mock_validation_context: - output = ProbeOutput( - name="probe", - probe_points=[Point(name="pt", location=(0, 0, 0) * u.m)], - output_fields=["Cp"], - output_at_final_pseudo_step_only=True, - ) - assert output.output_at_final_pseudo_step_only is True - - -def test_toggle_defaults_to_false(): - output = ProbeOutput( - name="probe", - probe_points=[Point(name="pt", location=(0, 0, 0) * u.m)], - output_fields=["Cp"], - ) - assert output.output_at_final_pseudo_step_only is False - - -def test_probe_output_accepts_toggle(): - output = ProbeOutput( - name="probe", - probe_points=[Point(name="pt", location=(0, 0, 0) * u.m)], - output_fields=["Cp"], - output_at_final_pseudo_step_only=True, - ) - assert output.output_at_final_pseudo_step_only is True diff --git a/tests/simulation/params/test_porous_medium.py b/tests/simulation/params/test_porous_medium.py deleted file mode 100644 index 09b82c295..000000000 --- a/tests/simulation/params/test_porous_medium.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest - -import flow360.component.simulation.units as u -from flow360.component.simulation.models.volume_models import PorousMedium -from flow360.component.simulation.primitives import GenericVolume -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - ValidationContext, -) - - -def test_ensure_entities_have_sufficient_attributes(): - mock_context = ValidationContext( - levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) - ) - - with ( - mock_context, - pytest.raises( - ValueError, - match="Entity 'zone_with_no_axes' must specify `axes` to be used under `PorousMedium`.", - ), - ): - - PorousMedium( - volumes=[GenericVolume(name="zone_with_no_axes")], - darcy_coefficient=(0.1, 2, 1.0) / u.cm / u.m, - forchheimer_coefficient=(0.1, 2, 1.0) / u.ft, - volumetric_heat_source=123 * u.lb / u.s**3 / u.ft, - ) diff --git a/tests/simulation/params/test_rotating_boundaries_metadata.py b/tests/simulation/params/test_rotating_boundaries_metadata.py deleted file mode 100644 index 2980bd5ec..000000000 --- a/tests/simulation/params/test_rotating_boundaries_metadata.py +++ /dev/null @@ -1,592 +0,0 @@ -"""Unit tests for rotating boundaries metadata update functionality.""" - -from flow360 import u -from flow360.component.simulation.meshing_param.params import MeshingParams -from flow360.component.simulation.meshing_param.volume_params import ( - AutomatedFarfield, - RotationVolume, -) -from flow360.component.simulation.models.surface_models import Wall, WallRotation -from flow360.component.simulation.operating_condition.operating_condition import ( - AerospaceCondition, -) -from flow360.component.simulation.outputs.outputs import SurfaceOutput -from flow360.component.simulation.primitives import Cylinder, Surface -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system - - -def test_update_rotating_boundaries_with_metadata(): - """Test updating rotating boundaries with metadata from volume mesh.""" - volume_mesh_meta_data = { - "zones": { - "farfield": { - "boundaryNames": [ - "farfield/farfield", - "farfield/slidingInterface-intersectingCylinder", - "farfield/sphere.lb8.ugrid", - ], - "donorInterfaceNames": ["intersectingCylinder/inverted-intersectingCylinder"], - "donorZoneNames": ["intersectingCylinder"], - "receiverInterfaceNames": ["farfield/slidingInterface-intersectingCylinder"], - }, - "intersectingCylinder": { - "boundaryNames": [ - "intersectingCylinder/inverted-intersectingCylinder", - "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder", - ], - "donorInterfaceNames": ["farfield/slidingInterface-intersectingCylinder"], - "donorZoneNames": ["farfield"], - "receiverInterfaceNames": ["intersectingCylinder/inverted-intersectingCylinder"], - }, - } - } - - with SI_unit_system: - # Create entities - cylinder = Cylinder( - name="intersectingCylinder", - center=(0, 0, 0) * u.m, - outer_radius=1 * u.m, - height=2 * u.m, - axis=(0, 0, 1), - ) - sphere_surface = Surface(name="sphere.lb8.ugrid") - - # Create RotationVolume with enclosed_entities - rotation_volume = RotationVolume( - name="RotationVolume", - spacing_axial=0.5 * u.m, - spacing_circumferential=0.3 * u.m, - spacing_radial=1.5 * u.m, - entities=[cylinder], - enclosed_entities=[sphere_surface], - ) - - # Create meshing params - meshing = MeshingParams( - volume_zones=[ - AutomatedFarfield(name="Farfield"), - rotation_volume, - ] - ) - - # Create operating condition - op = AerospaceCondition(velocity_magnitude=10) - - # Create Wall model for the sphere surface - wall_model = Wall( - entities=[sphere_surface], - ) - - # Create simulation params - params = SimulationParams( - meshing=meshing, - operating_condition=op, - models=[wall_model], - ) - - # Update using the unified volume mesh metadata API - params._update_param_with_actual_volume_mesh_meta(volume_mesh_meta_data) - - # Verify that the enclosed_entity was updated to point to __rotating patch - updated_entity = rotation_volume.enclosed_entities.stored_entities[0] - assert ( - updated_entity.full_name - == "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder" - ) - assert ( - updated_entity.name - == "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder" - ) - - # Verify that a new Wall model was created for the __rotating patch - # Original model should still exist - assert len(params.models) > 1 - wall_models = [m for m in params.models if isinstance(m, Wall)] - assert len(wall_models) > 1 - - # Find the model for the rotating boundary - rotating_wall = None - for model in wall_models: - if model.entities and model.entities.stored_entities: - entity_full_name = model.entities.stored_entities[0].full_name - if "__rotating" in entity_full_name: - rotating_wall = model - break - - assert rotating_wall is not None - assert ( - rotating_wall.entities.stored_entities[0].full_name - == "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder" - ) - - -def test_update_rotating_boundaries_with_stationary_entities(): - """Test updating rotating boundaries with stationary_enclosed_entities (velocity=0).""" - volume_mesh_meta_data = { - "zones": { - "farfield": { - "boundaryNames": [ - "farfield/farfield", - "farfield/slidingInterface-intersectingCylinder", - "farfield/sphere.lb8.ugrid", - ], - }, - "intersectingCylinder": { - "boundaryNames": [ - "intersectingCylinder/inverted-intersectingCylinder", - "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder", - ], - }, - } - } - - with SI_unit_system: - # Create entities - cylinder = Cylinder( - name="intersectingCylinder", - center=(0, 0, 0) * u.m, - outer_radius=1 * u.m, - height=2 * u.m, - axis=(0, 0, 1), - ) - sphere_surface = Surface(name="sphere.lb8.ugrid") - - # Create RotationVolume with enclosed_entities and stationary_enclosed_entities - rotation_volume = RotationVolume( - name="RotationVolume", - spacing_axial=0.5 * u.m, - spacing_circumferential=0.3 * u.m, - spacing_radial=1.5 * u.m, - entities=[cylinder], - enclosed_entities=[sphere_surface], - stationary_enclosed_entities=[sphere_surface], # Mark as stationary - ) - - # Create meshing params - meshing = MeshingParams( - volume_zones=[ - AutomatedFarfield(name="Farfield"), - rotation_volume, - ] - ) - - # Create operating condition - op = AerospaceCondition(velocity_magnitude=10) - - # Create Wall model for the sphere surface with non-zero velocity - wall_model = Wall( - entities=[sphere_surface], - velocity=[1, 0, 0], # Non-zero velocity - ) - - # Create simulation params - params = SimulationParams( - meshing=meshing, - operating_condition=op, - models=[wall_model], - ) - - # Update using the unified volume mesh metadata API - params._update_param_with_actual_volume_mesh_meta(volume_mesh_meta_data) - - # Verify that the stationary_enclosed_entity was updated - updated_stationary_entity = rotation_volume.stationary_enclosed_entities.stored_entities[0] - assert ( - updated_stationary_entity.full_name - == "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder" - ) - - # Verify that a new Wall model was created for the __rotating patch with velocity=0 - wall_models = [m for m in params.models if isinstance(m, Wall)] - rotating_wall = None - for model in wall_models: - if model.entities and model.entities.stored_entities: - entity_full_name = model.entities.stored_entities[0].full_name - if "__rotating" in entity_full_name: - rotating_wall = model - break - - assert rotating_wall is not None - # Verify velocity is set to zero for stationary entities (as string expressions) - assert rotating_wall.velocity == ("0", "0", "0") - - -def test_multiple_entities_partial_rotating_patches(): - """Test multiple entities in enclosed_entities where only some have __rotating patches.""" - volume_mesh_meta_data = { - "zones": { - "farfield": { - "boundaryNames": [ - "farfield/farfield", - "farfield/slidingInterface-intersectingCylinder", - "farfield/sphere.lb8.ugrid", - "farfield/other_surface", - ], - }, - "intersectingCylinder": { - "boundaryNames": [ - "intersectingCylinder/inverted-intersectingCylinder", - "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder", - # Note: other_surface does NOT have a __rotating patch - ], - }, - } - } - - with SI_unit_system: - # Create entities - cylinder = Cylinder( - name="intersectingCylinder", - center=(0, 0, 0) * u.m, - outer_radius=1 * u.m, - height=2 * u.m, - axis=(0, 0, 1), - ) - sphere_surface = Surface(name="sphere.lb8.ugrid") - other_surface = Surface(name="other_surface") - - # Create RotationVolume with multiple enclosed_entities - rotation_volume = RotationVolume( - name="RotationVolume", - spacing_axial=0.5 * u.m, - spacing_circumferential=0.3 * u.m, - spacing_radial=1.5 * u.m, - entities=[cylinder], - enclosed_entities=[sphere_surface, other_surface], - ) - - # Create meshing params - meshing = MeshingParams( - volume_zones=[ - AutomatedFarfield(name="Farfield"), - rotation_volume, - ] - ) - - # Create operating condition - op = AerospaceCondition(velocity_magnitude=10) - - # Create Wall models for both surfaces - wall_model_sphere = Wall(entities=[sphere_surface], velocity=[1, 0, 0]) - wall_model_other = Wall(entities=[other_surface], velocity=[0, 1, 0]) - - # Create simulation params - params = SimulationParams( - meshing=meshing, - operating_condition=op, - models=[wall_model_sphere, wall_model_other], - ) - - # Update using the unified volume mesh metadata API - params._update_param_with_actual_volume_mesh_meta(volume_mesh_meta_data) - - # Verify that only sphere_surface was updated (has __rotating patch) - updated_entities = rotation_volume.enclosed_entities.stored_entities - sphere_updated = None - other_updated = None - - for entity in updated_entities: - if ( - entity.name - == "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder" - ): - sphere_updated = entity - elif entity.name == "other_surface": - other_updated = entity - - assert sphere_updated is not None - assert ( - sphere_updated.full_name - == "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder" - ) - - # other_surface should remain unchanged (no __rotating patch found) - assert other_updated is not None - assert other_updated.full_name == "farfield/other_surface" - assert other_updated.name == "other_surface" - - # Verify that only one new Wall model was created (for sphere, not other_surface) - wall_models = [m for m in params.models if isinstance(m, Wall)] - rotating_walls = [ - m - for m in wall_models - if m.entities - and m.entities.stored_entities - and "__rotating" in m.entities.stored_entities[0].full_name - ] - assert len(rotating_walls) == 1 - assert ( - rotating_walls[0].entities.stored_entities[0].full_name - == "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder" - ) - - -def test_no_wall_model_for_entity(): - """Test that entities without Wall models are handled correctly.""" - volume_mesh_meta_data = { - "zones": { - "farfield": { - "boundaryNames": [ - "farfield/farfield", - "farfield/slidingInterface-intersectingCylinder", - "farfield/sphere.lb8.ugrid", - ], - }, - "intersectingCylinder": { - "boundaryNames": [ - "intersectingCylinder/inverted-intersectingCylinder", - "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder", - ], - }, - } - } - - with SI_unit_system: - # Create entities - cylinder = Cylinder( - name="intersectingCylinder", - center=(0, 0, 0) * u.m, - outer_radius=1 * u.m, - height=2 * u.m, - axis=(0, 0, 1), - ) - sphere_surface = Surface(name="sphere.lb8.ugrid") - - # Create RotationVolume with enclosed_entities - rotation_volume = RotationVolume( - name="RotationVolume", - spacing_axial=0.5 * u.m, - spacing_circumferential=0.3 * u.m, - spacing_radial=1.5 * u.m, - entities=[cylinder], - enclosed_entities=[sphere_surface], - ) - - # Create meshing params - meshing = MeshingParams( - volume_zones=[ - AutomatedFarfield(name="Farfield"), - rotation_volume, - ] - ) - - # Create operating condition - op = AerospaceCondition(velocity_magnitude=10) - - # Create simulation params WITHOUT any Wall models - params = SimulationParams( - meshing=meshing, - operating_condition=op, - models=[], # No Wall models - ) - - # Update using the unified volume mesh metadata API - # This should not raise an error even though there's no Wall model - params._update_param_with_actual_volume_mesh_meta(volume_mesh_meta_data) - - # Verify that the enclosed_entity was still updated to point to __rotating patch - updated_entity = rotation_volume.enclosed_entities.stored_entities[0] - assert ( - updated_entity.full_name - == "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder" - ) - - # Verify that no new Wall models were created (since there were none to begin with) - wall_models = [m for m in params.models if isinstance(m, Wall)] - assert len(wall_models) == 0 - - -def test_wall_model_with_wall_rotation(): - """Test Wall model with WallRotation velocity instead of a simple velocity vector.""" - volume_mesh_meta_data = { - "zones": { - "farfield": { - "boundaryNames": [ - "farfield/farfield", - "farfield/slidingInterface-intersectingCylinder", - "farfield/sphere.lb8.ugrid", - ], - }, - "intersectingCylinder": { - "boundaryNames": [ - "intersectingCylinder/inverted-intersectingCylinder", - "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder", - ], - }, - } - } - - with SI_unit_system: - # Create entities - cylinder = Cylinder( - name="intersectingCylinder", - center=(0, 0, 0) * u.m, - outer_radius=1 * u.m, - height=2 * u.m, - axis=(0, 0, 1), - ) - sphere_surface = Surface(name="sphere.lb8.ugrid") - - # Create RotationVolume with enclosed_entities - rotation_volume = RotationVolume( - name="RotationVolume", - spacing_axial=0.5 * u.m, - spacing_circumferential=0.3 * u.m, - spacing_radial=1.5 * u.m, - entities=[cylinder], - enclosed_entities=[sphere_surface], - ) - - # Create meshing params - meshing = MeshingParams( - volume_zones=[ - AutomatedFarfield(name="Farfield"), - rotation_volume, - ] - ) - - # Create operating condition - op = AerospaceCondition(velocity_magnitude=10) - - # Create Wall model with WallRotation velocity - wall_rotation = WallRotation( - axis=(0, 0, 1), - center=(0, 0, 0) * u.m, - angular_velocity=100 * u.rpm, - ) - wall_model = Wall( - entities=[sphere_surface], - velocity=wall_rotation, - ) - - # Create simulation params - params = SimulationParams( - meshing=meshing, - operating_condition=op, - models=[wall_model], - ) - - # Update using the unified volume mesh metadata API - params._update_param_with_actual_volume_mesh_meta(volume_mesh_meta_data) - - # Verify that a new Wall model was created for the __rotating patch - wall_models = [m for m in params.models if isinstance(m, Wall)] - rotating_wall = None - for model in wall_models: - if model.entities and model.entities.stored_entities: - entity_full_name = model.entities.stored_entities[0].full_name - if "__rotating" in entity_full_name: - rotating_wall = model - break - - assert rotating_wall is not None - assert ( - rotating_wall.entities.stored_entities[0].full_name - == "intersectingCylinder/sphere.lb8.ugrid__rotating_intersectingCylinder" - ) - - # Verify that WallRotation velocity is preserved (not converted to tuple) - assert isinstance(rotating_wall.velocity, WallRotation) - assert rotating_wall.velocity.axis == (0, 0, 1) - assert all(rotating_wall.velocity.center == (0, 0, 0) * u.m) - assert rotating_wall.velocity.angular_velocity == 100 * u.rpm - - -def test_surface_output_expanded_while_rotation_volume_filtered(): - """ - Test that SurfaceOutput entities are expanded to include all split versions, - while RotationVolume.enclosed_entities are filtered to only keep __rotating patches. - - This verifies the key behavior difference: - - SurfaceOutput: expands to include BOTH farfield/surface AND zone/__rotating versions - - RotationVolume.enclosed_entities: filtered to ONLY keep __rotating version - """ - volume_mesh_meta_data = { - "zones": { - "farfield": { - "boundaryNames": [ - "farfield/farfield", - "farfield/slidingInterface-intersectingCylinder", - "farfield/blade", # Original surface in farfield zone - ], - }, - "intersectingCylinder": { - "boundaryNames": [ - "intersectingCylinder/inverted-intersectingCylinder", - "intersectingCylinder/blade__rotating_intersectingCylinder", # __rotating patch - ], - }, - } - } - - with SI_unit_system: - # Create entities - cylinder = Cylinder( - name="intersectingCylinder", - center=(0, 0, 0) * u.m, - outer_radius=1 * u.m, - height=2 * u.m, - axis=(0, 0, 1), - ) - blade_surface = Surface(name="blade") - - # Create RotationVolume with enclosed_entities - rotation_volume = RotationVolume( - name="RotationVolume", - spacing_axial=0.5 * u.m, - spacing_circumferential=0.3 * u.m, - spacing_radial=1.5 * u.m, - entities=[cylinder], - enclosed_entities=[blade_surface], - ) - - # Create meshing params - meshing = MeshingParams( - volume_zones=[ - AutomatedFarfield(name="Farfield"), - rotation_volume, - ] - ) - - # Create operating condition - op = AerospaceCondition(velocity_magnitude=10) - - # Create SurfaceOutput for the same blade surface - surface_output = SurfaceOutput( - entities=[blade_surface], - output_fields=["Cp", "Cf"], - ) - - # Create simulation params - params = SimulationParams( - meshing=meshing, - operating_condition=op, - outputs=[surface_output], - ) - - # Update using the unified volume mesh metadata API - params._update_param_with_actual_volume_mesh_meta(volume_mesh_meta_data) - - # === Verify RotationVolume.enclosed_entities is FILTERED === - # Should only have the __rotating version - enclosed_entities = rotation_volume.enclosed_entities.stored_entities - assert len(enclosed_entities) == 1 - assert ( - enclosed_entities[0].full_name - == "intersectingCylinder/blade__rotating_intersectingCylinder" - ) - assert ( - enclosed_entities[0].name == "intersectingCylinder/blade__rotating_intersectingCylinder" - ) - - # === Verify SurfaceOutput.entities is EXPANDED === - # Should have BOTH the farfield version AND the __rotating version - output_entities = surface_output.entities.stored_entities - assert len(output_entities) == 2 - - # Collect full_names for verification - output_full_names = {e.full_name for e in output_entities} - assert "farfield/blade" in output_full_names - assert "intersectingCylinder/blade__rotating_intersectingCylinder" in output_full_names diff --git a/tests/simulation/params/test_rotation.py b/tests/simulation/params/test_rotation.py deleted file mode 100644 index 72a74e5c2..000000000 --- a/tests/simulation/params/test_rotation.py +++ /dev/null @@ -1,118 +0,0 @@ -import pytest - -from flow360 import u -from flow360.component.simulation.models.surface_models import Wall, WallRotation -from flow360.component.simulation.models.volume_models import AngleExpression, Rotation -from flow360.component.simulation.operating_condition.operating_condition import ( - AerospaceCondition, -) -from flow360.component.simulation.primitives import GenericVolume, Surface -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system - - -def test_ensure_entities_have_sufficient_attributes(mock_validation_context): - - with ( - mock_validation_context, - pytest.raises( - ValueError, - match="Entity 'zone_with_no_axis' must specify `axis` to be used under `Rotation`.", - ), - ): - - Rotation( - volumes=[GenericVolume(name="zone_with_no_axis")], - spec=AngleExpression("0.45 * t"), - ) - - with ( - mock_validation_context, - pytest.raises( - ValueError, - match="Entity 'zone_with_no_axis' must specify `center` to be used under `Rotation`.", - ), - ): - - Rotation( - volumes=[GenericVolume(name="zone_with_no_axis", axis=[1, 2, 3])], - spec=AngleExpression("0.45 * t"), - ) - - -def test_wall_angular_velocity(): - my_wall_surface = Surface(name="my_wall") - Wall( - surfaces=[my_wall_surface], - velocity=WallRotation(axis=(0, 0, 1), center=(1, 2, 3) * u.m, angular_velocity=100 * u.rpm), - use_wall_function=True, - ) - - -def test_rotation_expression_with_t_seconds(): - - with pytest.raises( - ValueError, - match=r"Syntax error in expression `0.45 \* sin\(0.2\*\*\*tss`: invalid syntax\.", - ): - Rotation( - volumes=[GenericVolume(name="zone_1", axis=[1, 2, 3], center=(1, 1, 1) * u.cm)], - spec=AngleExpression("0.45 * sin(0.2***tss"), - ) - - with pytest.raises( - ValueError, - match=r"Unexpected variable `taa` found.", - ): - Rotation( - volumes=[GenericVolume(name="zone_1", axis=[1, 2, 3], center=(1, 1, 1) * u.cm)], - spec=AngleExpression("0.45 + taa"), - ) - - with pytest.raises( - ValueError, - match=r"t_seconds must be used as a multiplicative factor, not directly added/subtracted with a number.", - ): - Rotation( - volumes=[GenericVolume(name="zone_1", axis=[1, 2, 3], center=(1, 1, 1) * u.cm)], - spec=AngleExpression("sin(0.45 + t_seconds)"), - ) - - Rotation( - volumes=[GenericVolume(name="zone_1", axis=[1, 2, 3], center=(1, 1, 1) * u.cm)], - spec=AngleExpression( - "-180/pi * atan(2 * 3.00 * 20.00 * 2.00/180*pi * " - "cos(2.00/180*pi * sin(0.05877271 * t_seconds)) * cos(0.05877271 * t_seconds) / 50.00) +" - " 2 * 2.00 * sin(0.05877271 * t_seconds) - 2.00 * sin(0.05877271 * t_seconds)" - ), - ) - - with SI_unit_system: - op = AerospaceCondition(velocity_magnitude=10) - params = SimulationParams( - operating_condition=op, - models=[ - Rotation( - volumes=[GenericVolume(name="zone_1", axis=[1, 2, 3], center=(1, 1, 1) * u.cm)], - spec=AngleExpression("0.45 * sin(0.2*t_seconds)"), - ), - Rotation( - volumes=[ - GenericVolume(name="zone_2", axis=[11, 2, 3], center=(1, 1, 1) * u.cm) - ], - spec=AngleExpression("0.45 * sin(0.5*t_seconds +0.2)"), - ), - ], - ) - - flow360_time_in_seconds = 0.025 - processed_params = params._preprocess( - mesh_unit=(op.thermal_state.speed_of_sound.value * flow360_time_in_seconds) * u.m - ) - assert ( - processed_params.models[0].spec.value == f"0.45 * sin(0.2*({flow360_time_in_seconds} * t))" - ) - assert ( - processed_params.models[1].spec.value - == f"0.45 * sin(0.5*({flow360_time_in_seconds} * t) +0.2)" - ) diff --git a/tests/simulation/params/test_simulation_params.py b/tests/simulation/params/test_simulation_params.py index bef695c5b..0a265fb8d 100644 --- a/tests/simulation/params/test_simulation_params.py +++ b/tests/simulation/params/test_simulation_params.py @@ -1,460 +1,30 @@ import json +import os import unittest -import numpy as np -import pydantic as pd import pytest import flow360.component.simulation.units as u from flow360.component.project import create_draft from flow360.component.project_utils import set_up_params_for_uploading from flow360.component.simulation import services -from flow360.component.simulation.conversion import LIQUID_IMAGINARY_FREESTREAM_MACH -from flow360.component.simulation.entity_info import ( - GeometryEntityInfo, - VolumeMeshEntityInfo, -) +from flow360.component.simulation.entity_info import GeometryEntityInfo from flow360.component.simulation.entity_operation import CoordinateSystem -from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.meshing_param import snappy -from flow360.component.simulation.meshing_param.params import ( - MeshingDefaults, - MeshingParams, - ModularMeshingWorkflow, -) -from flow360.component.simulation.meshing_param.volume_params import ( - CustomZones, - UniformRefinement, -) from flow360.component.simulation.migration.extra_operating_condition import ( operating_condition_from_mach_muref, ) -from flow360.component.simulation.models.material import SolidMaterial, Water -from flow360.component.simulation.models.surface_models import ( - Freestream, - HeatFlux, - Inflow, - MassFlowRate, - SlipWall, - TotalPressure, - Wall, -) -from flow360.component.simulation.models.turbulence_quantities import ( - TurbulenceQuantities, -) -from flow360.component.simulation.models.volume_models import ( - AngularVelocity, - Fluid, - HeatEquationInitialCondition, - PorousMedium, - Rotation, - Solid, -) from flow360.component.simulation.operating_condition.operating_condition import ( AerospaceCondition, - LiquidOperatingCondition, - ThermalState, -) -from flow360.component.simulation.primitives import ( - Box, - Cylinder, - Edge, - GenericVolume, - ReferenceGeometry, - SeedpointVolume, - Surface, ) from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.time_stepping.time_stepping import Unsteady from flow360.component.simulation.translator.surface_meshing_translator import ( _inject_body_group_transformations_for_mesher, ) -from flow360.component.simulation.unit_system import CGS_unit_system, SI_unit_system -from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( - UserDefinedDynamic, -) -from tests.simulation.conftest import to_file_from_file_test_approx +from flow360.component.simulation.unit_system import SI_unit_system assertions = unittest.TestCase("__init__") -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -@pytest.fixture() -def get_the_param(): - my_wall_surface = Surface(name="my_wall") - my_slip_wall_surface = Surface(name="my_slip_wall") - my_inflow1 = Surface(name="my_inflow1") - my_inflow2 = Surface(name="my_inflow2") - with CGS_unit_system: - my_box = Box.from_principal_axes( - name="my_box", - center=(1.2, 2.3, 3.4) * u.m, - size=(1.0, 2.0, 3.0) * u.m, - axes=((3, 4, 0), (0, 0, 1)), - ) - my_cylinder_1 = Cylinder( - name="my_cylinder-1", - axis=(5, 0, 0), - center=(1.2, 2.3, 3.4) * u.m, - height=3.0 * u.m, - inner_radius=3.0 * u.m, - outer_radius=5.0 * u.m, - ) - my_solid_zone = GenericVolume( - name="my_cylinder-2", - ) - param = SimulationParams( - meshing=MeshingParams( - refinement_factor=1.0, - gap_treatment_strength=0.5, - defaults=MeshingDefaults(surface_edge_growth_rate=1.5), - refinements=[UniformRefinement(entities=[my_box], spacing=0.1 * u.m)], - ), - reference_geometry=ReferenceGeometry( - moment_center=(1, 2, 3), moment_length=1.0 * u.m, area=1.0 * u.cm**2 - ), - operating_condition=AerospaceCondition.from_mach( - mach=0.8, - alpha=30 * u.deg, - beta=20 * u.deg, - thermal_state=ThermalState(temperature=300 * u.K, density=1 * u.g / u.cm**3), - reference_mach=0.5, - ), - models=[ - Fluid(), - Wall( - entities=[my_wall_surface], - use_wall_function=True, - velocity=(1.0, 1.2, 2.4) * u.ft / u.s, - heat_spec=HeatFlux(1.0 * u.W / u.m**2), - ), - SlipWall(entities=[my_slip_wall_surface]), - Rotation(volumes=[my_cylinder_1], spec=AngularVelocity(0.45 * u.rad / u.s)), - PorousMedium( - volumes=[my_box], - darcy_coefficient=(0.1, 2, 1.0) / u.cm / u.m, - forchheimer_coefficient=(0.1, 2, 1.0) / u.ft, - volumetric_heat_source=123 * u.lb / u.s**3 / u.ft, - ), - Solid( - volumes=[my_solid_zone], - material=SolidMaterial( - name="abc", - thermal_conductivity=1.0 * u.W / u.m / u.K, - specific_heat_capacity=1.0 * u.J / u.kg / u.K, - density=1.0 * u.kg / u.m**3, - ), - initial_condition=HeatEquationInitialCondition(temperature="1"), - ), - Inflow( - surfaces=[my_inflow1], - total_temperature=300 * u.K, - spec=TotalPressure(value=123 * u.Pa), - turbulence_quantities=TurbulenceQuantities( - turbulent_kinetic_energy=123, specific_dissipation_rate=1e3 - ), - ), - Inflow( - surfaces=[my_inflow2], - total_temperature=300 * u.K, - spec=MassFlowRate(value=123 * u.lb / u.s), - ), - ], - time_stepping=Unsteady(step_size=2 * 0.2 * u.s, steps=123), - user_defined_dynamics=[ - UserDefinedDynamic( - name="fake", - input_vars=["fake"], - constants={"ff": 123}, - state_vars_initial_value=["fake"], - update_law=["fake"], - ) - ], - ) - return param - - -@pytest.fixture() -def get_param_with_liquid_operating_condition(): - with SI_unit_system: - water = Water( - name="h2o", density=1000 * u.kg / u.m**3, dynamic_viscosity=0.001 * u.kg / u.m / u.s - ) - param = SimulationParams( - operating_condition=LiquidOperatingCondition( - velocity_magnitude=50, - reference_velocity_magnitude=100, - material=water, - ), - models=[ - Fluid(material=water), - Wall( - entities=[Surface(name="wall1")], - velocity=(1.0, 1.2, 2.4), - ), - Rotation( - volumes=[ - Cylinder( - name="cyl-1", - axis=(5, 0, 0), - center=(1.2, 2.3, 3.4), - height=3.0, - inner_radius=3.0, - outer_radius=5.0, - ) - ], - spec=AngularVelocity(0.45 * u.rad / u.s), - ), - Freestream( - entities=Surface(name="my_fs"), - turbulence_quantities=TurbulenceQuantities( - modified_viscosity=10, - ), - ), - ], - time_stepping=Unsteady(step_size=2 * 0.2 * u.s, steps=123), - ) - return param - - -@pytest.fixture() -def get_param_with_list_of_lengths(): - with SI_unit_system: - params = SimulationParams( - meshing=ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=10 * u.mm, max_spacing=2 * u.m, gap_resolution=0.01 * u.m - ), - refinements=[ - snappy.SurfaceEdgeRefinement( - spacing=[1e-3, 8] * u.m, - distances=[0.4 * u.mm, 2 * u.m], - entities=[Surface(name="test")], - ) - ], - ), - zones=[ - CustomZones( - name="custom_zones", - entities=[SeedpointVolume(name="fluid", point_in_mesh=[1, 1, 1])], - ) - ], - ), - operating_condition=AerospaceCondition.from_mach( - mach=0.8, - alpha=30 * u.deg, - beta=20 * u.deg, - thermal_state=ThermalState(temperature=300 * u.K, density=1 * u.g / u.cm**3), - reference_mach=0.5, - ), - ) - return params - - -@pytest.mark.usefixtures("array_equality_override") -def test_simulation_params_serialization(get_the_param): - to_file_from_file_test_approx(get_the_param) - - -@pytest.mark.usefixtures("array_equality_override") -def test_simulation_params_unit_conversion(get_the_param): - converted = get_the_param._preprocess(mesh_unit=10 * u.m) - - # pylint: disable=fixme - # TODO: Please perform hand calculation and update the following assertions - # LengthType - assertions.assertAlmostEqual(converted.reference_geometry.moment_length.value, 0.1) - # AngleType - assertions.assertAlmostEqual(converted.operating_condition.alpha.value, 0.5235987755982988) - # TimeType - assertions.assertAlmostEqual(converted.time_stepping.step_size.value, 13.8888282) - # AbsoluteTemperatureType - assertions.assertAlmostEqual( - converted.models[0].material.dynamic_viscosity.effective_temperature.value, 0.368 - ) - # VelocityType - assertions.assertAlmostEqual(converted.operating_condition.velocity_magnitude.value, 0.8) - # AreaType - assertions.assertAlmostEqual(converted.reference_geometry.area.value, 1e-6) - # PressureType - assertions.assertAlmostEqual(converted.models[6].spec.value.value, 1.0454827495346328e-06) - # ViscosityType - assertions.assertAlmostEqual( - converted.models[0].material.dynamic_viscosity.reference_viscosity.value, - 1.0005830903790088e-11, - ) - # AngularVelocityType - assertions.assertAlmostEqual(converted.models[3].spec.value.value, 0.01296006) - # HeatFluxType - assertions.assertAlmostEqual(converted.models[1].heat_spec.value.value, 2.47809322e-11) - # HeatSourceType - assertions.assertAlmostEqual( - converted.models[4].volumetric_heat_source.value, 4.536005048050727e-08 - ) - # HeatSourceType - assertions.assertAlmostEqual( - converted.models[4].volumetric_heat_source.value, 4.536005048050727e-08 - ) - # HeatCapacityType - assertions.assertAlmostEqual( - converted.models[5].material.specific_heat_capacity.value, 0.00248834 - ) - # ThermalConductivityType - assertions.assertAlmostEqual( - converted.models[5].material.thermal_conductivity.value, 7.434279666747016e-10 - ) - # InverseAreaType - assertions.assertAlmostEqual(converted.models[4].darcy_coefficient.value[0], 1000.0) - # InverseLengthType - assertions.assertAlmostEqual( - converted.models[4].forchheimer_coefficient.value[0], 3.280839895013123 - ) - # MassFlowRateType - assertions.assertAlmostEqual(converted.models[7].spec.value.value, 1.6265848836734695e-06) - - # SpecificEnergyType - assertions.assertAlmostEqual( - converted.models[6].turbulence_quantities.turbulent_kinetic_energy.value, - 1.0454827495346325e-07, - ) - - # FrequencyType - assertions.assertAlmostEqual( - converted.models[6].turbulence_quantities.specific_dissipation_rate.value, - 28.80012584, - ) - - -@pytest.mark.usefixtures("array_equality_override") -def test_simulation_params_unit_conversion_with_list_of_lengths(get_param_with_list_of_lengths): - converted = get_param_with_list_of_lengths._preprocess(mesh_unit=10 * u.m) - - assertions.assertAlmostEqual(converted.meshing.surface_meshing.defaults.min_spacing.value, 1e-3) - assertions.assertAlmostEqual(converted.meshing.surface_meshing.defaults.max_spacing, 0.2) - - assertions.assertAlmostEqual(converted.meshing.surface_meshing.defaults.gap_resolution, 1e-3) - - assertions.assertAlmostEqual(converted.meshing.surface_meshing.refinements[0].spacing[0], 1e-4) - - assertions.assertAlmostEqual(converted.meshing.surface_meshing.refinements[0].spacing[1], 0.8) - - assertions.assertAlmostEqual( - converted.meshing.surface_meshing.refinements[0].distances[0], 4e-5 - ) - - assertions.assertAlmostEqual(converted.meshing.surface_meshing.refinements[0].distances[1], 0.2) - - -def test_standard_atmosphere(): - # ref values from here: https://aerospaceweb.org/design/scripts/atmosphere/ - # alt, temp_offset, temp, density, pressure, viscosity - ref_data = [ - (-1000, 0, 294.651, 1.347, 1.1393e5, 0.000018206), - (0, 0, 288.15, 1.225, 101325, 0.000017894), - (999, 0, 281.6575, 1.1118, 89887, 0.000017579), - (1000, 0, 281.651, 1.11164, 89876, 0.000017579), - (10000, 0, 223.2521, 0.41351, 26500, 0.000014577), - (15000, 0, 216.65, 0.19476, 12112, 0.000014216), - (20000, 0, 216.65, 0.088910, 5529.3, 0.000014216), - (30000, 0, 226.5091, 0.018410, 1197.0, 0.000014753), - (40000, 0, 250.3496, 0.0039957, 287.14, 0.000016009), - (70000, 0, 219.5848, 0.000082829, 5.2209, 0.000014377), - (0, -10, 278.15, 1.2690, 101325, 0.000017407), - (1000, -9, 272.651, 1.1484, 89876, 0.000017136), - ] - - for alt, temp_offset, temp, density, pressure, viscosity in ref_data: - atm = ThermalState.from_standard_atmosphere( - altitude=alt * u.m, temperature_offset=temp_offset * u.K - ) - - assert atm.temperature == pytest.approx(temp, rel=1e-6) - assert atm.density == pytest.approx(density, rel=1e-4) - assert atm.pressure == pytest.approx(pressure, rel=1e-4) - assert atm.dynamic_viscosity == pytest.approx(viscosity, rel=1e-4) - - for alt, temp_offset, temp, density, pressure, viscosity in ref_data: - delta_temp_in_F = (temp_offset * u.K).in_units("delta_degF") - atm = ThermalState.from_standard_atmosphere( - altitude=alt * u.m, temperature_offset=delta_temp_in_F - ) - - assert atm.temperature == pytest.approx(temp, rel=1e-6) - assert atm.density == pytest.approx(density, rel=1e-4) - assert atm.pressure == pytest.approx(pressure, rel=1e-4) - assert atm.dynamic_viscosity == pytest.approx(viscosity, rel=1e-4) - - -def test_subsequent_param_with_different_unit_system(): - with SI_unit_system: - param_SI = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=0.2) - ) - ) - with CGS_unit_system: - param_CGS = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=0.3) - ) - ) - assert param_SI.unit_system.name == "SI" - assert param_SI.meshing.defaults.boundary_layer_first_layer_thickness == 0.2 * u.m - assert param_CGS.unit_system.name == "CGS" - assert param_CGS.meshing.defaults.boundary_layer_first_layer_thickness == 0.3 * u.cm - - -@pytest.mark.parametrize( - ("unit_system", "match"), - [ - ({}, "Field required"), - ({"name": "Bogus"}, "Input should be 'SI', 'CGS' or 'Imperial'"), - ], -) -def test_invalid_unit_system_dict_raises_validation_error(unit_system, match): - with SI_unit_system, pytest.raises(pd.ValidationError, match=match) as exc_info: - SimulationParams(unit_system=unit_system) - - assert not isinstance(exc_info.value, KeyError) - - -def test_mach_reynolds_op_cond(): - condition = AerospaceCondition.from_mach_reynolds( - mach=0.2, - reynolds_mesh_unit=5e6, - temperature=288.15 * u.K, - alpha=2.0 * u.deg, - beta=0.0 * u.deg, - project_length_unit=u.m, - ) - assertions.assertAlmostEqual(condition.thermal_state.dynamic_viscosity.value, 1.78929763e-5) - assertions.assertAlmostEqual(condition.thermal_state.density.value, 1.31452332) - - condition = AerospaceCondition.from_mach_reynolds( - mach=0.2, - reynolds_mesh_unit=5e6, - temperature=288.15 * u.K, - alpha=2.0 * u.deg, - beta=0.0 * u.deg, - project_length_unit=u.m, - reference_mach=0.4, - ) - assertions.assertAlmostEqual(condition.thermal_state.density.value, 1.31452332) - - with pytest.raises(ValueError, match="Input should be greater than 0"): - condition = AerospaceCondition.from_mach_reynolds( - mach=0.2, - reynolds_mesh_unit=0, - temperature=288.15 * u.K, - project_length_unit=u.m, - ) - - def test_mach_muref_op_cond(): condition = operating_condition_from_mach_muref( mach=0.2, @@ -466,127 +36,27 @@ def test_mach_muref_op_cond(): ) assertions.assertAlmostEqual(condition.thermal_state.dynamic_viscosity.value, 1.78929763e-5) assertions.assertAlmostEqual(condition.thermal_state.density.value, 1.31452332) - assertions.assertAlmostEqual( - condition.flow360_reynolds_number(length_unit=1 * u.m), (1.0 / 4e-8) * condition.mach - ) # 1/muRef * freestream mach + condition.flow360_reynolds_number(length_unit=1 * u.m), + (1.0 / 4e-8) * condition.mach, + ) with pytest.raises(ValueError, match="Input should be greater than 0"): - condition = operating_condition_from_mach_muref( + operating_condition_from_mach_muref( mach=0.2, mu_ref=0, temperature=288.15 * u.K, ) -def test_delta_temperature_scaling(): - with CGS_unit_system: - param = SimulationParams( - operating_condition=AerospaceCondition( - thermal_state=ThermalState.from_standard_atmosphere( - temperature_offset=123 * u.delta_degF - ) - ) - ) - reference_temperature = param.operating_condition.thermal_state.temperature.to("K") - - scaled_temperature_offset = (123 * u.delta_degF / reference_temperature).value - processed_param = param._preprocess(mesh_unit=1 * u.m) - - assert ( - processed_param.operating_condition.thermal_state.temperature_offset.value - == scaled_temperature_offset - ) - - -def test_simulation_params_unit_conversion_with_liquid_condition( - get_param_with_liquid_operating_condition, -): - params: SimulationParams = get_param_with_liquid_operating_condition - converted = params._preprocess(mesh_unit=1 * u.m) - - fake_water_speed_of_sound = ( - params.operating_condition.velocity_magnitude / LIQUID_IMAGINARY_FREESTREAM_MACH - ) - # TimeType - assertions.assertAlmostEqual( - converted.time_stepping.step_size.value, - params.time_stepping.step_size / (1.0 / fake_water_speed_of_sound), - ) - - # VelocityType - assertions.assertAlmostEqual( - converted.models[1].velocity.value[0], - 1.0 / fake_water_speed_of_sound, - ) - - # AngularVelocityType - assertions.assertAlmostEqual( - converted.models[2].spec.value.value, - 0.45 / fake_water_speed_of_sound.value, - # Note: We did not use original value from params like the others b.c - # for some unknown reason THIS value in params will also converted to flow360 unit system... - ) - # ViscosityType - assertions.assertAlmostEqual( - converted.models[3].turbulence_quantities.modified_turbulent_viscosity.value, - 10 / (1 * fake_water_speed_of_sound.value), - # Note: We did not use original value from params like the others b.c - # for some unknown reason THIS value in params will also converted to flow360 unit system... - ) - - -def test_persistent_entity_info_update_geometry(): - #### Geometry Entity Info #### - with open("./data/geometry_metadata_asset_cache.json", "r") as fp: - geometry_info_dict = json.load(fp)["project_entity_info"] - geometry_info = GeometryEntityInfo.model_validate(geometry_info_dict) - # modify a surface - selected_surface = geometry_info.get_boundaries()[0] - selected_surface_dict = selected_surface.model_dump(mode="json") - selected_surface_dict["name"] = "new_surface_name" - brand_new_surface = Surface.model_validate(selected_surface_dict) - # modify an edge - selected_edge = geometry_info._get_list_of_entities(entity_type_name="edge")[0] - selected_edge_dict = selected_edge.model_dump(mode="json") - selected_edge_dict["name"] = "new_edge_name" - brand_new_edge = Edge.model_validate(selected_edge_dict) - - fake_asset_entity_registry = EntityRegistry() - fake_asset_entity_registry.register(brand_new_surface) - fake_asset_entity_registry.register(brand_new_edge) - - geometry_info.update_persistent_entities(asset_entity_registry=fake_asset_entity_registry) - - assert geometry_info.get_boundaries()[0].name == "new_surface_name" - assert geometry_info._get_list_of_entities(entity_type_name="edge")[0].name == "new_edge_name" - - -def test_persistent_entity_info_update_volume_mesh(): - #### VolumeMesh Entity Info #### - with open("./data/volume_mesh_metadata_asset_cache.json", "r") as fp: - volume_mesh_info_dict = json.load(fp) - volume_mesh_info = VolumeMeshEntityInfo.model_validate(volume_mesh_info_dict) - - # modify the axis and center of a zone - selected_zone = volume_mesh_info.zones[0] - selected_zone_dict = selected_zone.model_dump(mode="json") - selected_zone_dict["axes"] = [[1, 0, 0], [0, 0, 1]] - selected_zone_dict["center"] = {"units": "cm", "value": [1.2, 2.3, 3.4]} - brand_new_zone = GenericVolume.model_validate(selected_zone_dict) - - fake_asset_entity_registry = EntityRegistry() - fake_asset_entity_registry.register(brand_new_zone) - - volume_mesh_info.update_persistent_entities(asset_entity_registry=fake_asset_entity_registry) - - assert volume_mesh_info.zones[0].axes == ((1, 0, 0), (0, 0, 1)) - assert all(volume_mesh_info.zones[0].center == [1.2, 2.3, 3.4] * u.cm) - - def test_geometry_entity_info_to_file_list_and_entity_to_file_map(): - with open("./data/geometry_metadata_asset_cache_mixed_file.json", "r") as fp: - geometry_entity_info_dict = json.load(fp) + simulation_path = os.path.join( + os.path.dirname(__file__), + "data", + "geometry_metadata_asset_cache_mixed_file.json", + ) + with open(simulation_path, "r") as file: + geometry_entity_info_dict = json.load(file) geometry_entity_info = GeometryEntityInfo.model_validate(geometry_entity_info_dict) assert geometry_entity_info._get_processed_file_list() == ( @@ -605,43 +75,7 @@ def test_geometry_entity_info_to_file_list_and_entity_to_file_map(): ) -def test_geometry_entity_info_get_body_group_to_face_group_name_map(): - with open("./data/geometry_metadata_asset_cache_multiple_bodies.json", "r") as fp: - geometry_entity_info_dict = json.load(fp) - geometry_entity_info = GeometryEntityInfo.model_validate(geometry_entity_info_dict) - assert sorted(geometry_entity_info.get_body_group_to_face_group_name_map().items()) == sorted( - { - "cube-holes.egads": ["body00001", "body00002"], - "cylinder.stl": ["cylinder.stl"], - }.items() - ) - geometry_entity_info._group_entity_by_tag("face", "faceId") - assert sorted(geometry_entity_info.get_body_group_to_face_group_name_map().items()) == sorted( - { - "cube-holes.egads": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006", - "body00002_face00001", - "body00002_face00002", - "body00002_face00003", - "body00002_face00004", - "body00002_face00005", - "body00002_face00006", - ], - "cylinder.stl": ["cylinder.stl_body"], - }.items() - ) - - def test_transformation_matrix(mock_geometry): - """ - End-to-end test: DraftContext coordinate system assignment -> params setup -> mesher JSON injection. - """ - def _get_selected_grouped_bodies(entity_info_dict: dict) -> list[dict]: grouped_bodies = entity_info_dict.get("grouped_bodies", None) assert isinstance(grouped_bodies, list) @@ -658,8 +92,6 @@ def _get_selected_grouped_bodies(entity_info_dict: dict) -> list[dict]: assert selected_group return selected_group - # Prefer a body grouping that yields multiple GeometryBodyGroup entities (if available), - # so we can validate identity matrices for unassigned body groups too. entity_info = mock_geometry.entity_info candidate_body_group_tag = None if getattr(entity_info, "body_attribute_names", None): @@ -675,9 +107,6 @@ def _get_selected_grouped_bodies(entity_info_dict: dict) -> list[dict]: target_body_group = body_groups[0] with SI_unit_system: - # Nested coordinate system test: - # Parent: Translate [10, 0, 0] - # Child: Parent + Translate [0, 5, 0] -> Total [10, 5, 0] cs_parent = CoordinateSystem(name="parent", translation=[10, 0, 0] * u.m) cs_child = CoordinateSystem(name="child", translation=[0, 5, 0] * u.m) @@ -698,7 +127,9 @@ def _get_selected_grouped_bodies(entity_info_dict: dict) -> list[dict]: json_data = processed_params.model_dump(mode="json", exclude_none=True) _inject_body_group_transformations_for_mesher( - json_data=json_data, input_params=processed_params, mesh_unit=1 * u.m + json_data=json_data, + input_params=processed_params, + mesh_unit=1 * u.m, ) entity_info_dict = json_data["private_attribute_asset_cache"]["project_entity_info"] @@ -731,8 +162,6 @@ def _get_selected_grouped_bodies(entity_info_dict: dict) -> list[dict]: assert isinstance(target_body_group_dict, dict) - # All bodies in the selected grouping must have an explicit matrix payload. - # The transformation object should only contain private_attribute_matrix (no redundant fields). for body_group_dict in selected_group: transformation = body_group_dict.get("transformation") assert isinstance(transformation, dict) @@ -748,7 +177,6 @@ def _get_selected_grouped_bodies(entity_info_dict: dict) -> list[dict]: == expected_child_matrix ) - # Unassigned body groups (if any) default to identity. for body_group_dict in selected_group: if body_group_dict is target_body_group_dict: continue @@ -769,7 +197,6 @@ def _get_selected_grouped_bodies(entity_info_dict: dict) -> list[dict]: def test_default_params_for_local_test(): - # Test to ensure the default params for local test is validated with SI_unit_system: param = SimulationParams() diff --git a/tests/simulation/params/test_validators_criterion.py b/tests/simulation/params/test_validators_criterion.py index 58a2f140f..76cc82b80 100644 --- a/tests/simulation/params/test_validators_criterion.py +++ b/tests/simulation/params/test_validators_criterion.py @@ -1,439 +1,22 @@ import json -import re -import unittest +import os -import pydantic as pd -import pytest -from flow360_schema.framework.expression import UserVariable -from flow360_schema.models.functions import math -from flow360_schema.models.variables import solution - -import flow360.component.simulation.units as u -from flow360.component.simulation.models.volume_models import Fluid -from flow360.component.simulation.outputs.output_entities import Point, PointArray -from flow360.component.simulation.outputs.outputs import ( - MovingStatistic, - ProbeOutput, - SurfaceIntegralOutput, - SurfaceProbeOutput, -) -from flow360.component.simulation.primitives import Surface -from flow360.component.simulation.run_control.run_control import RunControl -from flow360.component.simulation.run_control.stopping_criterion import ( - StoppingCriterion, -) from flow360.component.simulation.services import ValidationCalledBy, validate_model -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system - -assertions = unittest.TestCase("__init__") - - -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -@pytest.fixture() -def scalar_user_variable_density(): - """A scalar UserVariable for testing.""" - return UserVariable( - name="scalar_field", - value=solution.density, - ) - - -@pytest.fixture() -def vector_user_variable_velocity(): - """A vector UserVariable for testing.""" - return UserVariable( - name="vector_field", - value=solution.velocity, - ) - - -@pytest.fixture() -def single_point_probe_output(scalar_user_variable_density, vector_user_variable_velocity): - """A ProbeOutput with a single point.""" - return ProbeOutput( - name="test_probe", - output_fields=[ - scalar_user_variable_density, - vector_user_variable_velocity, - "mut", - "VelocityRelative", - ], - probe_points=[Point(name="pt1", location=(0, 0, 0) * u.m)], - ) - - -@pytest.fixture() -def single_point_surface_probe_output(scalar_user_variable_density, vector_user_variable_velocity): - """A SurfaceProbeOutput with a single point.""" - return SurfaceProbeOutput( - name="test_surface_probe", - output_fields=[ - scalar_user_variable_density, - vector_user_variable_velocity, - "mut", - "VelocityRelative", - ], - probe_points=[Point(name="pt1", location=(0, 0, 0) * u.m)], - target_surfaces=[Surface(name="wall")], - ) - - -@pytest.fixture() -def surface_integral_output(scalar_user_variable_density): - """A SurfaceIntegralOutput for testing.""" - return SurfaceIntegralOutput( - name="test_integral", - output_fields=[scalar_user_variable_density], - surfaces=[Surface(name="wall")], - ) - - -def test_criterion_scalar_field_validation(scalar_user_variable_density, single_point_probe_output): - """Test that scalar fields are accepted.""" - with SI_unit_system: - criterion = StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=single_point_probe_output, - tolerance=0.01 * u.kg / u.m**3, - ) - assert criterion.monitor_field == scalar_user_variable_density - - -def test_criterion_vector_field_validation_fails( - vector_user_variable_velocity, single_point_probe_output -): - """Test that vector fields are rejected.""" - message = "The stopping criterion can only be defined on a scalar field." - with SI_unit_system, pytest.raises(ValueError, match=message): - StoppingCriterion( - monitor_field=vector_user_variable_velocity, - monitor_output=single_point_probe_output, - tolerance=0.01 * u.m / u.s, - ) - - with SI_unit_system, pytest.raises(ValueError, match=message): - StoppingCriterion( - monitor_field="VelocityRelative", - monitor_output=single_point_probe_output, - tolerance=0.01, - ) - - -def test_criterion_single_point_probe_validation( - scalar_user_variable_density, single_point_probe_output, single_point_surface_probe_output -): - """Test that single point ProbeOutput is accepted.""" - with SI_unit_system: - criterion = StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=single_point_probe_output, - tolerance=0.01 * u.kg / u.m**3, - ) - assert criterion.monitor_output == single_point_probe_output.private_attribute_id - - with SI_unit_system: - criterion = StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=single_point_surface_probe_output, - tolerance=0.01 * u.kg / u.m**3, - ) - assert criterion.monitor_output == single_point_surface_probe_output.private_attribute_id - - -def test_criterion_multi_entities_probe_validation_fails( - scalar_user_variable_density, - single_point_probe_output, - single_point_surface_probe_output, - mock_validation_context, -): - """Test that multi-entity ProbeOutput is rejected.""" - message = ( - "For stopping criterion setup, only one single `Point` entity is allowed " - "in `ProbeOutput`/`SurfaceProbeOutput`." - ) - - multi_point_probe_output = single_point_probe_output - multi_point_probe_output.entities.stored_entities.append( - Point(name="pt2", location=(1, 1, 1) * u.m) - ) - mock_validation_context.info.output_dict = { - multi_point_probe_output.private_attribute_id: multi_point_probe_output - } - with SI_unit_system, mock_validation_context, pytest.raises(ValueError, match=message): - StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=multi_point_probe_output, - tolerance=0.01 * u.kg / u.m**3, - ) - - point_array_surface_probe_output = single_point_surface_probe_output - point_array_surface_probe_output.entities.stored_entities = [ - PointArray( - name="point_array", - start=(0, 0, 0) * u.m, - end=(1, 1, 1) * u.m, - number_of_points=2, - ), - ] - mock_validation_context.info.output_dict = { - point_array_surface_probe_output.private_attribute_id: point_array_surface_probe_output - } - with SI_unit_system, mock_validation_context, pytest.raises(ValueError, match=message): - StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=point_array_surface_probe_output, - tolerance=0.01 * u.kg / u.m**3, - ) - - -def test_criterion_field_exists_in_output_validation( - single_point_probe_output, mock_validation_context -): - """Test that monitor field must exist in monitor output.""" - scalar_field = UserVariable(name="test_field", value=solution.pressure) - message = "The monitor field does not exist in the monitor output." - - mock_validation_context.info.output_dict = { - single_point_probe_output.private_attribute_id: single_point_probe_output - } - with SI_unit_system, mock_validation_context, pytest.raises(ValueError, match=message): - criterion = StoppingCriterion( - monitor_field=scalar_field, - monitor_output=single_point_probe_output, - tolerance=0.01 * u.Pa, - ) - - -def test_criterion_field_exists_in_output_validation_success(single_point_probe_output): - """Test successful validation when monitor field exists in output.""" - scalar_field = UserVariable(name="test_field", value=solution.pressure) - single_point_probe_output.output_fields.append(scalar_field) - with SI_unit_system: - criterion = StoppingCriterion( - monitor_field=scalar_field, - monitor_output=single_point_probe_output, - tolerance=0.01 * u.Pa, - ) - assert criterion.monitor_field == scalar_field - - -def test_criterion_string_field_tolerance_validation(single_point_probe_output): - """Test that string monitor fields require dimensionless tolerance.""" - with SI_unit_system: - criterion = StoppingCriterion( - monitor_field="mut", - monitor_output=single_point_probe_output, - tolerance=0.01, - ) - assert criterion.tolerance == 0.01 - - message = "The monitor field (mut) specified by string can only be used with a nondimensional tolerance." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - criterion = StoppingCriterion( - monitor_field="mut", - monitor_output=single_point_probe_output, - tolerance=0.01 * u.kg / u.m / u.s, - ) - - -def test_criterion_dimension_matching_validation( - scalar_user_variable_density, - single_point_probe_output, - surface_integral_output, - mock_validation_context, -): - """Test that monitor field and tolerance dimensions must match.""" - message = "The dimensions of monitor field and tolerance do not match." - mock_validation_context.info.output_dict = { - single_point_probe_output.private_attribute_id: single_point_probe_output - } - with SI_unit_system, mock_validation_context: - criterion = StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=single_point_probe_output, - tolerance=0.01 * u.kg / u.m**3, - ) - assert criterion.tolerance == 0.01 * u.kg / u.m**3 - - with SI_unit_system, mock_validation_context, pytest.raises(ValueError, match=message): - StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=single_point_probe_output, - tolerance=0.01, # Dimensionless tolerance for dimensional field - ) - - # Valid case: surface integral tolerance's dimenision should match with field_dimensions * (length)**2 - mock_validation_context.info.output_dict = { - surface_integral_output.private_attribute_id: surface_integral_output - } - with SI_unit_system, mock_validation_context: - criterion = StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=surface_integral_output, - tolerance=0.01 * u.kg / u.m, - ) - assert criterion.tolerance == 0.01 * u.kg / u.m - - with SI_unit_system, mock_validation_context, pytest.raises(ValueError, match=message): - criterion = StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=surface_integral_output, - tolerance=0.01 * u.kg / u.m**3, - ) - - -def test_tolerance_window_size_validation(scalar_user_variable_density, single_point_probe_output): - """Test tolerance_window_size validation.""" - - # Valid case: ge=2 constraint satisfied - with SI_unit_system: - criterion = StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=single_point_probe_output, - tolerance=0.01 * u.kg / u.m**3, - tolerance_window_size=5, - ) - assert criterion.tolerance_window_size == 5 - - # Invalid case: less than 2 - with SI_unit_system, pytest.raises(pd.ValidationError): - StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=single_point_probe_output, - tolerance=0.01 * u.kg / u.m**3, - tolerance_window_size=1, - ) - - -def test_criterion_with_moving_statistic( - scalar_user_variable_density, single_point_probe_output, mock_validation_context -): - """Test StoppingCriterion with MovingStatistic in output.""" - - single_point_probe_output.moving_statistic = MovingStatistic( - method="range", moving_window_size=10 - ) - with SI_unit_system, mock_validation_context: - criterion = StoppingCriterion( - name="Criterion_1", - monitor_output=single_point_probe_output, - monitor_field=scalar_user_variable_density, - tolerance=0.01 * u.kg / u.m**3, - ) - SimulationParams( - outputs=[single_point_probe_output], - run_control=RunControl(stopping_criteria=[criterion]), - ) - - assert criterion.name == "Criterion_1" - criterion_monitor_output = mock_validation_context.info.output_dict.get( - criterion.monitor_output - ) - assert criterion_monitor_output.moving_statistic.method == "range" - assert criterion_monitor_output.moving_statistic.moving_window_size == 10 - - -def test_criterion_default_values(scalar_user_variable_density, single_point_probe_output): - """Test default values for StoppingCriterion.""" - - with SI_unit_system: - criterion = StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=single_point_probe_output, - tolerance=0.01 * u.kg / u.m**3, - ) - - assert criterion.name == "StoppingCriterion" - assert criterion.tolerance_window_size is None - assert criterion.type_name == "StoppingCriterion" - - -def test_criterion_monitor_exists_in_outputs_validation( - scalar_user_variable_density, mock_validation_context -): - """Test that monitor output must exist in SimulationParams outputs list. - - This tests the _check_monitor_exists_in_output_list validator which ensures - that the monitor_output referenced in a StoppingCriterion exists in the - SimulationParams outputs list. - - """ - - # Create a probe output that will be in the outputs list - probe_in_list = ProbeOutput( - name="probe_in_list", - output_fields=[scalar_user_variable_density], - probe_points=[Point(name="pt1", location=(0, 0, 0) * u.m)], - ) - mock_validation_context.info.output_dict = {probe_in_list.private_attribute_id: probe_in_list} - # Success case: monitor_output exists in outputs list - # When validating SimulationParams, the outputs list is used to build an output_dict. - # The _check_monitor_exists_in_output_lists validator checks that the - # monitor_output's private_attribute_id exists in this output_dict. - with SI_unit_system, mock_validation_context: - params = SimulationParams( - models=[Fluid()], - outputs=[probe_in_list], - run_control=RunControl( - stopping_criteria=[ - StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=probe_in_list, - tolerance=0.01 * u.kg / u.m**3, - ) - ] - ), - ) - - # Verify the criterion was created successfully - assert len(params.run_control.stopping_criteria) == 1 - - # Verify the monitor_output is stored as id - assert ( - params.run_control.stopping_criteria[0].monitor_output == probe_in_list.private_attribute_id - ) - - message = "The monitor output does not exist in the outputs list." - probe_in_list2 = ProbeOutput( - name="probe_in_list2", - output_fields=[scalar_user_variable_density], - probe_points=[Point(name="pt1", location=(0, 0, 1) * u.m)], - ) - mock_validation_context.info.output_dict = {probe_in_list2.private_attribute_id: probe_in_list2} - with SI_unit_system, mock_validation_context, pytest.raises(ValueError, match=message): - StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=probe_in_list, - tolerance=0.01 * u.kg / u.m**3, - ) - - message = "The monitor output does not exist in the outputs list." - mock_validation_context.info.output_dict = None - with SI_unit_system, mock_validation_context: - params = SimulationParams( - models=[Fluid()], - ) - with SI_unit_system, mock_validation_context, pytest.raises(ValueError, match=message): - StoppingCriterion( - monitor_field=scalar_user_variable_density, - monitor_output=probe_in_list, - tolerance=0.01 * u.kg / u.m**3, - ) def test_criterion_with_monitor_output_id(): - # [Frontend] Simulating loading a StoppingCriterion object with the id of monitor_output, - # ensure the validation for monitor_output works - with open("data/simulation_stopping_criterion_webui.json", "r") as fh: - data = json.load(fh) + simulation_path = os.path.join( + os.path.dirname(__file__), + "data", + "simulation_stopping_criterion_webui.json", + ) + with open(simulation_path, "r") as file: + data = json.load(file) _, errors, _ = validate_model( - params_as_dict=data, validated_by=ValidationCalledBy.LOCAL, root_item_type="Geometry" + params_as_dict=data, + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Geometry", ) expected_errors = [ { @@ -458,14 +41,12 @@ def test_criterion_with_monitor_output_id(): "type": "value_error", "loc": ("run_control", "stopping_criteria", 3, "monitor_output"), "msg": "Value error, The monitor output does not exist in the outputs list.", - "input": "1234", "ctx": {"relevant_for": ["Case"]}, }, ] - assert len(errors) == len(expected_errors) - for err, exp_err in zip(errors, expected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - assert err["msg"] == exp_err["msg"] + for error, expected in zip(errors, expected_errors): + assert error["loc"] == expected["loc"] + assert error["type"] == expected["type"] + assert error["msg"] == expected["msg"] + assert error["ctx"]["relevant_for"] == expected["ctx"]["relevant_for"] diff --git a/tests/simulation/params/test_validators_material.py b/tests/simulation/params/test_validators_material.py deleted file mode 100644 index 8c4184553..000000000 --- a/tests/simulation/params/test_validators_material.py +++ /dev/null @@ -1,463 +0,0 @@ -"""Tests for material model validators (NASA9 coefficients, ThermallyPerfectGas).""" - -import pytest - -import flow360 as fl -from flow360.component.simulation.models.material import ( - FrozenSpecies, - NASA9Coefficients, - NASA9CoefficientSet, - ThermallyPerfectGas, -) -from flow360.component.simulation.unit_system import SI_unit_system - -# ============================================================================= -# NASA9CoefficientSet Tests -# ============================================================================= - - -def test_nasa9_coefficient_set_valid(): - """Test creating a valid NASA9CoefficientSet with exactly 9 coefficients.""" - with SI_unit_system: - coeff_set = NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ) - assert len(coeff_set.coefficients) == 9 - - -def test_nasa9_coefficient_set_wrong_count_raises(): - """Test that providing wrong number of coefficients raises ValueError.""" - with SI_unit_system: - with pytest.raises(ValueError, match="requires exactly 9 coefficients"): - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5], # Only 3 coefficients - ) - - -def test_nasa9_coefficient_set_too_many_coefficients_raises(): - """Test that providing too many coefficients raises ValueError.""" - with SI_unit_system: - with pytest.raises(ValueError, match="requires exactly 9 coefficients"): - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1000.0 * fl.u.K, - coefficients=[0.0] * 10, # 10 coefficients - ) - - -# ============================================================================= -# NASA9Coefficients Tests -# ============================================================================= - - -def test_nasa9_coefficients_single_range_valid(): - """Test creating NASA9Coefficients with a single temperature range.""" - with SI_unit_system: - coeffs = NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=6000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ) - ] - ) - assert len(coeffs.temperature_ranges) == 1 - - -def test_nasa9_coefficients_multiple_ranges_continuous_valid(): - """Test creating NASA9Coefficients with continuous temperature ranges.""" - with SI_unit_system: - coeffs = NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - NASA9CoefficientSet( - temperature_range_min=1000.0 * fl.u.K, - temperature_range_max=6000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - ] - ) - assert len(coeffs.temperature_ranges) == 2 - - -def test_nasa9_coefficients_discontinuous_ranges_raises(): - """Test that discontinuous temperature ranges raise ValueError.""" - with SI_unit_system: - with pytest.raises(ValueError, match="Temperature ranges must be continuous"): - NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - NASA9CoefficientSet( - temperature_range_min=1500.0 * fl.u.K, # Gap: 1000-1500 K - temperature_range_max=6000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - ] - ) - - -# ============================================================================= -# ThermallyPerfectGas Tests -# ============================================================================= - - -def _make_species(name: str, mass_fraction: float, t_min: float = 200.0, t_max: float = 6000.0): - """Helper to create a FrozenSpecies with given mass fraction. - - Note: Must be called within a SI_unit_system context. - """ - return FrozenSpecies( - name=name, - nasa_9_coefficients=NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=t_min * fl.u.K, - temperature_range_max=t_max * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ) - ] - ), - mass_fraction=mass_fraction, - ) - - -def test_thermally_perfect_gas_single_species_valid(): - """Test creating ThermallyPerfectGas with a single species (mass_fraction=1.0).""" - with SI_unit_system: - tpg = ThermallyPerfectGas(species=[_make_species("N2", 1.0)]) - assert len(tpg.species) == 1 - assert tpg.species[0].mass_fraction == 1.0 - - -def test_thermally_perfect_gas_multi_species_valid(): - """Test creating ThermallyPerfectGas with multiple species summing to 1.0.""" - with SI_unit_system: - tpg = ThermallyPerfectGas( - species=[ - _make_species("N2", 0.7555), - _make_species("O2", 0.2316), - _make_species("Ar", 0.0129), - ] - ) - assert len(tpg.species) == 3 - total = sum(s.mass_fraction for s in tpg.species) - assert 0.999 <= total <= 1.001 - - -def test_thermally_perfect_gas_mass_fractions_not_sum_to_one_raises(): - """Test that mass fractions not summing to 1.0 raise ValueError.""" - with SI_unit_system: - with pytest.raises(ValueError, match="Mass fractions must sum to 1.0"): - ThermallyPerfectGas( - species=[ - _make_species("N2", 0.5), - _make_species("O2", 0.3), - # Missing 0.2 to sum to 1.0 - ] - ) - - -def test_thermally_perfect_gas_mass_fractions_exceed_one_raises(): - """Test that mass fractions exceeding 1.0 raise ValueError.""" - with SI_unit_system: - with pytest.raises(ValueError, match="Mass fractions must sum to 1.0"): - ThermallyPerfectGas( - species=[ - _make_species("N2", 0.8), - _make_species("O2", 0.4), # Sum = 1.2 - ] - ) - - -def test_thermally_perfect_gas_temperature_ranges_match_valid(): - """Test that species with matching temperature ranges are accepted.""" - with SI_unit_system: - # Both species have same temperature range: 200-1000K, 1000-6000K - n2 = FrozenSpecies( - name="N2", - nasa_9_coefficients=NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - NASA9CoefficientSet( - temperature_range_min=1000.0 * fl.u.K, - temperature_range_max=6000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - ] - ), - mass_fraction=0.75, - ) - o2 = FrozenSpecies( - name="O2", - nasa_9_coefficients=NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - NASA9CoefficientSet( - temperature_range_min=1000.0 * fl.u.K, - temperature_range_max=6000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - ] - ), - mass_fraction=0.25, - ) - tpg = ThermallyPerfectGas(species=[n2, o2]) - assert len(tpg.species) == 2 - - -def test_thermally_perfect_gas_temperature_ranges_count_mismatch_raises(): - """Test that species with different number of temperature ranges raise ValueError.""" - with SI_unit_system: - # N2 has 2 ranges, O2 has 1 range - n2 = FrozenSpecies( - name="N2", - nasa_9_coefficients=NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - NASA9CoefficientSet( - temperature_range_min=1000.0 * fl.u.K, - temperature_range_max=6000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - ] - ), - mass_fraction=0.75, - ) - o2 = FrozenSpecies( - name="O2", - nasa_9_coefficients=NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=6000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - ] - ), - mass_fraction=0.25, - ) - with pytest.raises(ValueError, match="same number of temperature ranges"): - ThermallyPerfectGas(species=[n2, o2]) - - -def test_thermally_perfect_gas_temperature_ranges_boundary_mismatch_raises(): - """Test that species with mismatched temperature boundaries raise ValueError.""" - with SI_unit_system: - # N2: 200-1000K, O2: 200-1200K (different boundary) - n2 = FrozenSpecies( - name="N2", - nasa_9_coefficients=NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - ] - ), - mass_fraction=0.75, - ) - o2 = FrozenSpecies( - name="O2", - nasa_9_coefficients=NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=1200.0 * fl.u.K, # Different max - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ), - ] - ), - mass_fraction=0.25, - ) - with pytest.raises(ValueError, match="boundaries mismatch"): - ThermallyPerfectGas(species=[n2, o2]) - - -# ============================================================================= -# Air with ThermallyPerfectGas Tests -# ============================================================================= - - -def test_air_with_thermally_perfect_gas_valid(): - """Test creating Air material with ThermallyPerfectGas.""" - with SI_unit_system: - tpg = ThermallyPerfectGas( - species=[ - _make_species("N2", 0.7555), - _make_species("O2", 0.2316), - _make_species("Ar", 0.0129), - ] - ) - air = fl.Air(thermally_perfect_gas=tpg) - assert air.thermally_perfect_gas is not None - assert len(air.thermally_perfect_gas.species) == 3 - - -def test_air_with_custom_thermally_perfect_gas_valid(): - """Test creating Air material with custom thermally perfect gas coefficients.""" - with SI_unit_system: - air = fl.Air( - thermally_perfect_gas=ThermallyPerfectGas( - species=[ - FrozenSpecies( - name="Air", - nasa_9_coefficients=NASA9Coefficients( - temperature_ranges=[ - NASA9CoefficientSet( - temperature_range_min=200.0 * fl.u.K, - temperature_range_max=6000.0 * fl.u.K, - coefficients=[0.0, 0.0, 3.5, 1e-4, 0.0, 0.0, 0.0, 0.0, 0.0], - ) - ] - ), - mass_fraction=1.0, - ) - ] - ) - ) - assert air.thermally_perfect_gas is not None - assert len(air.thermally_perfect_gas.species) == 1 - assert len(air.thermally_perfect_gas.species[0].nasa_9_coefficients.temperature_ranges) == 1 - - -# ============================================================================= -# CompressibleIsentropic Solver with CPG Validation Tests -# ============================================================================= - - -def _is_constant_gamma_coefficients(coefficients): - """Helper to check if coefficients represent constant gamma (only a2 non-zero).""" - tolerance = 1e-10 - for i in range(9): - if i == 2: - continue # Skip a2 - if abs(coefficients[i]) > tolerance: - return False - return True - - -def test_is_constant_gamma_coefficients_cpg(): - """Test that CPG coefficients (only a2 non-zero) are identified as constant gamma.""" - # Default CPG: gamma=1.4, a2=3.5 - cpg_coeffs = [0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - assert _is_constant_gamma_coefficients(cpg_coeffs) is True - - # Different constant gamma (e.g., gamma=1.3) - cpg_coeffs_different = [0.0, 0.0, 4.333, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - assert _is_constant_gamma_coefficients(cpg_coeffs_different) is True - - -def test_is_constant_gamma_coefficients_tpg(): - """Test that TPG coefficients (temperature-dependent terms) are not constant gamma.""" - # a3 non-zero (linear temperature dependence) - tpg_a3 = [0.0, 0.0, 3.5, 1e-4, 0.0, 0.0, 0.0, 0.0, 0.0] - assert _is_constant_gamma_coefficients(tpg_a3) is False - - # a0 non-zero (inverse T^2 dependence) - tpg_a0 = [1e5, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - assert _is_constant_gamma_coefficients(tpg_a0) is False - - # a1 non-zero (inverse T dependence) - tpg_a1 = [0.0, 100.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] - assert _is_constant_gamma_coefficients(tpg_a1) is False - - # a4 non-zero (T^2 dependence) - tpg_a4 = [0.0, 0.0, 3.5, 0.0, 1e-7, 0.0, 0.0, 0.0, 0.0] - assert _is_constant_gamma_coefficients(tpg_a4) is False - - # a5 non-zero (T^3 dependence) - tpg_a5 = [0.0, 0.0, 3.5, 0.0, 0.0, 1e-9, 0.0, 0.0, 0.0] - assert _is_constant_gamma_coefficients(tpg_a5) is False - - # a6 non-zero (T^4 dependence) - tpg_a6 = [0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 1e-9, 0.0, 0.0] - assert _is_constant_gamma_coefficients(tpg_a6) is False - - # a7 non-zero (enthalpy integration constant) - tpg_a7 = [0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 1000.0, 0.0] - assert _is_constant_gamma_coefficients(tpg_a7) is False - - # a8 non-zero (entropy integration constant) - tpg_a8 = [0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 50.0] - assert _is_constant_gamma_coefficients(tpg_a8) is False - - -# ============================================================================= -# Additional Validation Tests -# ============================================================================= - - -def test_nasa9_coefficient_set_temperature_range_min_less_than_max(): - """Test that temperature_range_min must be less than temperature_range_max.""" - with SI_unit_system: - with pytest.raises(ValueError, match="must be less than"): - NASA9CoefficientSet( - temperature_range_min=1000.0 * fl.u.K, - temperature_range_max=200.0 * fl.u.K, # max < min - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ) - - -def test_nasa9_coefficient_set_temperature_range_min_equals_max_raises(): - """Test that temperature_range_min cannot equal temperature_range_max.""" - with SI_unit_system: - with pytest.raises(ValueError, match="must be less than"): - NASA9CoefficientSet( - temperature_range_min=500.0 * fl.u.K, - temperature_range_max=500.0 * fl.u.K, # min == max - coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], - ) - - -def test_thermally_perfect_gas_duplicate_species_names_raises(): - """Test that duplicate species names raise ValueError.""" - with SI_unit_system: - with pytest.raises(ValueError, match="Species names must be unique"): - ThermallyPerfectGas( - species=[ - _make_species("N2", 0.5), - _make_species("N2", 0.5), # Duplicate name - ] - ) - - -def test_thermally_perfect_gas_mass_fraction_renormalization(): - """Test that mass fractions within tolerance are renormalized to sum to exactly 1.0.""" - with SI_unit_system: - # Mass fractions sum to 0.9995 (within 1e-3 tolerance) - tpg = ThermallyPerfectGas( - species=[ - _make_species("N2", 0.7553), - _make_species("O2", 0.2315), - _make_species("Ar", 0.0127), # Sum = 0.9995 - ] - ) - # After renormalization, mass fractions should sum to exactly 1.0 - total = sum(s.mass_fraction for s in tpg.species) - assert total == pytest.approx(1.0, abs=1e-10) diff --git a/tests/simulation/params/test_validators_output.py b/tests/simulation/params/test_validators_output.py index 34170ea19..e0428aa40 100644 --- a/tests/simulation/params/test_validators_output.py +++ b/tests/simulation/params/test_validators_output.py @@ -1,1291 +1,43 @@ import json import os -import re - -import pydantic -import pytest - -import flow360 as fl -import flow360.component.simulation.units as u - - -def assert_validation_error_contains( - error: pydantic.ValidationError, - expected_loc: tuple, - expected_msg_contains: str, -): - """Helper function to assert validation error properties for moving_statistic tests""" - errors = error.errors() - # Find the error with matching location - matching_errors = [e for e in errors if e["loc"] == expected_loc] - assert ( - len(matching_errors) == 1 - ), f"Expected 1 error at {expected_loc}, found {len(matching_errors)}" - assert expected_msg_contains in matching_errors[0]["msg"], ( - f"Expected '{expected_msg_contains}' in error message, " - f"but got: '{matching_errors[0]['msg']}'" - ) - assert matching_errors[0]["type"] == "value_error" - - -from flow360_schema.framework.expression import UserVariable -from flow360_schema.models.functions import math -from flow360_schema.models.variables import solution from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.models.solver_numerics import ( - KOmegaSST, - NoneSolver, - SpalartAllmaras, -) from flow360.component.simulation.models.surface_models import Wall -from flow360.component.simulation.models.volume_models import Fluid, PorousMedium -from flow360.component.simulation.outputs.output_entities import Point, Slice -from flow360.component.simulation.outputs.outputs import ( - AeroAcousticOutput, - ForceDistributionOutput, - Isosurface, - IsosurfaceOutput, - MovingStatistic, - ProbeOutput, - SliceOutput, - SurfaceIntegralOutput, - SurfaceOutput, - SurfaceProbeOutput, - TimeAverageForceDistributionOutput, - TimeAverageSliceOutput, - TimeAverageSurfaceOutput, - TimeAverageVolumeOutput, - VolumeOutput, -) -from flow360.component.simulation.primitives import ImportedSurface, Surface -from flow360.component.simulation.services import ( - ValidationCalledBy, - clear_context, - validate_model, -) +from flow360.component.simulation.models.volume_models import Fluid +from flow360.component.simulation.outputs.outputs import SurfaceOutput, VolumeOutput +from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady -from flow360.component.simulation.unit_system import ( - SI_unit_system, - imperial_unit_system, -) -from flow360.component.simulation.validation.validation_context import ( - CASE, - ParamsValidationInfo, - ValidationContext, -) +from flow360.component.simulation.time_stepping.time_stepping import Steady +from flow360.component.simulation.unit_system import imperial_unit_system from flow360.component.volume_mesh import VolumeMeshV2 -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -@pytest.fixture() -def reset_context(): - clear_context() - - -def test_aeroacoustic_observer_unit_validator(): - with pytest.raises( - ValueError, - match=re.escape( - "All observer locations should have the same unit. But now it has both `cm` and `mm`." - ), - ): - AeroAcousticOutput( - name="test", - observers=[ - fl.Observer(position=[0.2, 0.02, 0.03] * u.cm, group_name="0"), - fl.Observer(position=[0.0001, 0.02, 0.03] * u.mm, group_name="1"), - ], - ) - - -def test_unsteadiness_to_use_aero_acoustics(): - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[1] AeroAcousticOutput:`AeroAcousticOutput` can only be activated with `Unsteady` simulation." - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(turbulence_model_solver=NoneSolver())], - outputs=[ - IsosurfaceOutput( - name="iso", - entities=[Isosurface(name="tmp", field="mut", iso_value=1)], - output_fields=["Cp"], - ), - AeroAcousticOutput( - name="test", - observers=[ - fl.Observer(position=[0.2, 0.02, 0.03] * u.mm, group_name="0"), - fl.Observer(position=[0.0001, 0.02, 0.03] * u.mm, group_name="1"), - ], - ), - ], - time_stepping=fl.Steady(), - ) - - -def test_local_cfl_output_requires_unsteady(): - """localCFL output field is only valid for unsteady simulations.""" - - # Steady + localCFL in VolumeOutput should raise - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] VolumeOutput: " - "`localCFL` output is only supported for unsteady simulations." - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[VolumeOutput(output_fields=["localCFL"])], - time_stepping=Steady(), - ) - - # Steady + localCFL in SliceOutput should raise - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] SliceOutput: " - "`localCFL` output is only supported for unsteady simulations." - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - SliceOutput( - name="slice", - output_fields=["localCFL"], - slices=[Slice(name="center", normal=(1, 0, 0), origin=(0, 0, 0))], - ) - ], - time_stepping=Steady(), - ) - - # Steady + localCFL in TimeAverageVolumeOutput should raise - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] TimeAverageVolumeOutput: " - "`localCFL` output is only supported for unsteady simulations." - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[TimeAverageVolumeOutput(output_fields=["localCFL"], start_step=10)], - time_stepping=Steady(), - ) - - # Steady + localCFL in TimeAverageSliceOutput should raise - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] TimeAverageSliceOutput: " - "`localCFL` output is only supported for unsteady simulations." - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - TimeAverageSliceOutput( - name="slice", - output_fields=["localCFL"], - slices=[Slice(name="center", normal=(1, 0, 0), origin=(0, 0, 0))], - start_step=10, - ) - ], - time_stepping=Steady(), - ) - - # Unsteady + localCFL should be valid - with imperial_unit_system: - SimulationParams( - outputs=[VolumeOutput(output_fields=["localCFL"])], - time_stepping=Unsteady(steps=100, step_size=1e-3), - ) - - -def test_aero_acoustics_observer_time_step_size(): - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] AeroAcousticOutput: " - "`observer_time_size` (0.05 s) is smaller than the time step size of CFD (0.1 s)." - ), - ): - with SI_unit_system: - SimulationParams( - outputs=[ - AeroAcousticOutput( - name="test", - observers=[ - fl.Observer(position=[0.2, 0.02, 0.03] * u.mm, group_name="0"), - fl.Observer(position=[0.0001, 0.02, 0.03] * u.mm, group_name="1"), - ], - observer_time_step_size=0.05, - ), - ], - time_stepping=Unsteady(steps=1, step_size=0.1), - ) - - -def test_turbulence_enabled_output_fields(): - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] IsosurfaceOutput: kOmega is not a valid output field when using turbulence model: None." - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(turbulence_model_solver=NoneSolver())], - outputs=[ - IsosurfaceOutput( - name="iso", - entities=[Isosurface(name="tmp", field="mut", iso_value=1)], - output_fields=["kOmega"], - ) - ], - ) - - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] IsosurfaceOutput: nuHat is not a valid iso field when using turbulence model: kOmegaSST." - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(turbulence_model_solver=KOmegaSST())], - outputs=[ - IsosurfaceOutput( - name="iso", - entities=[Isosurface(name="tmp", field="nuHat", iso_value=1)], - output_fields=["Cp"], - ) - ], - ) - - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] VolumeOutput: kOmega is not a valid output field when using turbulence model: SpalartAllmaras." - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(turbulence_model_solver=SpalartAllmaras())], - outputs=[VolumeOutput(output_fields=["kOmega"])], - ) - - -def test_transition_model_enabled_output_fields(): - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] IsosurfaceOutput: solutionTransition is not a valid output field when transition model is not used." - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(transition_model_solver=NoneSolver())], - outputs=[ - IsosurfaceOutput( - name="iso", - entities=[Isosurface(name="tmp", field="mut", iso_value=1)], - output_fields=["solutionTransition"], - ) - ], - ) - - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] SurfaceProbeOutput: residualTransition is not a valid output field when transition model is not used." - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(transition_model_solver=NoneSolver())], - outputs=[ - SurfaceProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["residualTransition"], - target_surfaces=[Surface(name="fluid/body")], - ) - ], - ) - - with pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] VolumeOutput: linearResidualTransition is not a valid output field when transition model is not used." - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(transition_model_solver=NoneSolver())], - outputs=[VolumeOutput(output_fields=["linearResidualTransition"])], - ) - - -def test_surface_user_variables_in_output_fields(): - uv_surface1 = UserVariable( - name="uv_surface1", value=math.dot(solution.velocity, solution.CfVec) - ) - uv_surface2 = UserVariable( - name="uv_surface2", value=solution.node_forces_per_unit_area[0] * solution.Cp * solution.Cf - ) - - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput( - entities=Surface(name="fluid/body"), output_fields=[uv_surface1, uv_surface2] - ) - ], - ) - - with pytest.raises( - ValueError, - match=re.escape( - "Variable `uv_surface1` cannot be used in `VolumeOutput` " - + "since it contains Surface solver variable(s): solution.CfVec." - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[VolumeOutput(output_fields=[uv_surface1])], - ) - - with pytest.raises( - ValueError, - match=re.escape( - "Variable `uv_surface2` cannot be used in `ProbeOutput` " - + "since it contains Surface solver variable(s): " - + "solution.Cf, solution.node_forces_per_unit_area." - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=[uv_surface2], - ) - ], - ) - - -def test_duplicate_surface_usage(mock_validation_context): - my_var = UserVariable(name="my_var", value=solution.node_forces_per_unit_area[1]) - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "The same surface `fluid/body` is used in multiple `SurfaceOutput`s. " - "Please specify all settings for the same surface in one output." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput(entities=Surface(name="fluid/body"), output_fields=[my_var]), - SurfaceOutput( - entities=Surface(name="fluid/body"), output_fields=[solution.CfVec] - ), - ], - ) - - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "The same surface `fluid/body` is used in multiple `TimeAverageSurfaceOutput`s. " - "Please specify all settings for the same surface in one output." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - TimeAverageSurfaceOutput( - entities=Surface(name="fluid/body"), output_fields=[my_var] - ), - TimeAverageSurfaceOutput( - entities=Surface(name="fluid/body"), output_fields=[solution.CfVec] - ), - ], - time_stepping=Unsteady(steps=10, step_size=1e-3), - ) - - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput(entities=Surface(name="fluid/body"), output_fields=[solution.CfVec]), - TimeAverageSurfaceOutput( - entities=Surface(name="fluid/body"), output_fields=[solution.CfVec] - ), - ], - time_stepping=Unsteady(steps=10, step_size=1e-3), - ) - - -def test_check_moving_statistic_applicability_steady_valid(): - """Test moving_statistic with steady simulation - valid case.""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - - # Valid: window_size=10 (becomes 100 steps) + start_step=100 (becomes 100) = 200 <= 5000 - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Steady(max_steps=5000), - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=10, start_step=100 - ), - ) - ], - ) - - # Valid: window_size=5 (becomes 50 steps) + start_step=50 (becomes 50) = 100 <= 1000 - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Steady(max_steps=1000), - outputs=[ - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - moving_statistic=MovingStatistic( - method="standard_deviation", moving_window_size=5, start_step=50 - ), - ) - ], - ) - - -def test_check_moving_statistic_applicability_steady_invalid(): - """Test moving_statistic with steady simulation - invalid case.""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - - # Invalid: window_size=50 (becomes 500 steps) + start_step=4600 (becomes 4600) = 5100 > 5000 - with pytest.raises(pydantic.ValidationError) as exc_info: - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Steady(max_steps=5000), - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=50, start_step=4600 - ), - ) - ], - ) - - assert_validation_error_contains( - exc_info.value, - expected_loc=("outputs", 0, "moving_statistic"), - expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " - "the total number of steps in the simulation.", - ) - - # Invalid: window_size=20 (becomes 200 steps) + start_step=850 (becomes 850) = 1060 > 1000 - with pytest.raises(pydantic.ValidationError) as exc_info: - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Steady(max_steps=1000), - outputs=[ - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - moving_statistic=MovingStatistic( - method="standard_deviation", moving_window_size=20, start_step=850 - ), - ) - ], - ) - - assert_validation_error_contains( - exc_info.value, - expected_loc=("outputs", 0, "moving_statistic"), - expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " - "the total number of steps in the simulation.", - ) - - -def test_check_moving_statistic_applicability_unsteady_valid(): - """Test moving_statistic with unsteady simulation - valid case.""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - - # Valid: window_size=100 + start_step=200 = 300 <= 1000 - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Unsteady(steps=1000, step_size=1e-3), - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=100, start_step=200 - ), - ) - ], - ) - - # Valid: window_size=50 + start_step=50 = 100 <= 500 - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Unsteady(steps=500, step_size=1e-3), - outputs=[ - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - moving_statistic=MovingStatistic( - method="standard_deviation", moving_window_size=50, start_step=50 - ), - ) - ], - ) - - -def test_check_moving_statistic_applicability_unsteady_invalid(): - """Test moving_statistic with unsteady simulation - invalid case.""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - - # Invalid: window_size=500 + start_step=600 = 1100 > 1000 - with pytest.raises(pydantic.ValidationError) as exc_info: - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Unsteady(steps=1000, step_size=1e-3), - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=500, start_step=600 - ), - ) - ], - ) - - assert_validation_error_contains( - exc_info.value, - expected_loc=("outputs", 0, "moving_statistic"), - expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " - "the total number of steps in the simulation.", - ) - - # Invalid: window_size=200 + start_step=350 = 550 > 500 - with pytest.raises(pydantic.ValidationError) as exc_info: - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Unsteady(steps=500, step_size=1e-3), - outputs=[ - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - moving_statistic=MovingStatistic( - method="standard_deviation", moving_window_size=200, start_step=350 - ), - ) - ], - ) - - assert_validation_error_contains( - exc_info.value, - expected_loc=("outputs", 0, "moving_statistic"), - expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " - "the total number of steps in the simulation.", - ) - - -def test_check_moving_statistic_applicability_steady_edge_cases(): - """Test moving_statistic with steady simulation - edge cases for rounding.""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - - # Edge case: start_step=47 rounds up to 50, window_size=10 becomes 100, total=150 <= 200 - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Steady(max_steps=200), - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=10, start_step=47 - ), - ) - ], - ) - - # Edge case: start_step=99 rounds up to 100, window_size=5 becomes 50, total=150 <= 200 - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Steady(max_steps=200), - outputs=[ - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - moving_statistic=MovingStatistic( - method="standard_deviation", moving_window_size=5, start_step=99 - ), - ) - ], - ) - - # Edge case: start_step=100 (already multiple of 10), window_size=10 becomes 100, total=200 <= 200 - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Steady(max_steps=200), - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=10, start_step=100 - ), - ) - ], - ) - - -def test_check_moving_statistic_applicability_multiple_outputs(): - """ - Test moving_statistic with multiple outputs - captures ALL errors from different outputs. - - The validation function collects all errors from all invalid outputs and raises them together. - This follows Pydantic's pattern of collecting errors from list items. - """ - wall_1 = Wall(entities=Surface(name="fluid/wing")) - uv_surface1 = UserVariable( - name="uv_surface1", value=math.dot(solution.velocity, solution.CfVec) - ) - - # Multiple outputs with errors - ALL errors should be collected - # All 4 outputs have invalid moving_statistic (500 + 600 = 1100 > 1000) - with pytest.raises(pydantic.ValidationError) as exc_info: - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Unsteady(steps=1000, step_size=1e-3), - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=100, start_step=600 - ), - ), - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - moving_statistic=MovingStatistic( - method="standard_deviation", moving_window_size=100, start_step=600 - ), - ), - SurfaceIntegralOutput( - entities=Surface(name="fluid/wing"), - output_fields=[uv_surface1], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=500, start_step=600 - ), - ), - SurfaceProbeOutput( - name="surface_probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - target_surfaces=[Surface(name="fluid/wing")], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=100, start_step=600 - ), - ), - ], - ) - - assert len(exc_info.value.errors()) == 1 - assert_validation_error_contains( - exc_info.value, - expected_loc=("outputs", 2, "moving_statistic"), - expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " - "the total number of steps in the simulation.", - ) - - -def test_check_moving_statistic_applicability_no_moving_statistic(): - """Test that outputs without moving_statistic are not validated.""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - - # Should pass - no moving_statistic specified - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - time_stepping=Steady(max_steps=1000), - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - ), - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - ), - ], - ) - - -def test_check_moving_statistic_applicability_no_time_stepping(): - """Test that function returns early when no time_stepping is provided.""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - - # Should pass - no time_stepping specified - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - moving_statistic=MovingStatistic( - method="mean", moving_window_size=100, start_step=200 - ), - ) - ], - ) - - -def test_duplicate_probe_names(): - - # should have no error - with imperial_unit_system: - SimulationParams( - outputs=[ - ProbeOutput( - name="probe_output_1", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - ), - ProbeOutput( - name="probe_output_2", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["velocity_x"], - ), - ], - ) - - with pytest.raises( - ValueError, - match=re.escape( - "`outputs`[1] ProbeOutput: Output name probe_output has already been used for a " - "`ProbeOutput` or `SurfaceProbeOutput`. Output names must be unique among all probe outputs." - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["Cp"], - ), - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["velocity_x"], - ), - ], - ) - - with pytest.raises( - ValueError, - match=re.escape( - "`outputs`[1] SurfaceProbeOutput: Output name probe_output has already been used for a " - "`ProbeOutput` or `SurfaceProbeOutput`. Output names must be unique among all probe outputs." - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - ProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["pressure"], - ), - SurfaceProbeOutput( - name="probe_output", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["velocity_y"], - target_surfaces=[Surface(name="fluid/body")], - ), - ], - ) - - -def test_duplicate_force_distribution_names(): - with pytest.raises( - ValueError, - match=re.escape( - "`outputs`[1] TimeAverageForceDistributionOutput: Output name test has already been used for a " - "`ForceDistributionOutput`. Output names must be unique among all force distribution outputs." - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - ForceDistributionOutput( - name="test", - distribution_direction=[1.0, 0.0, 0.0], - ), - TimeAverageForceDistributionOutput( - name="test", - distribution_direction=[0.0, 1.0, 0.0], - ), - ], - ) - - -def test_force_distribution_reserved_names(): - """Test that reserved names are rejected for ForceDistributionOutput.""" - - # Exact reserved names - for reserved in ("X_slicing", "Y_slicing"): - with pytest.raises(ValueError, match="reserved name"): - ForceDistributionOutput( - name=reserved, - distribution_direction=[1.0, 0.0, 0.0], - ) - - # Valid names should pass - ForceDistributionOutput( - name="my_custom_output", - distribution_direction=[1.0, 0.0, 0.0], - ) - - -def test_time_averaged_force_distribution_output_requires_unsteady(): - with pytest.raises( - ValueError, - match=re.escape( - "`TimeAverageForceDistributionOutput` can only be used in unsteady simulations." - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(turbulence_model_solver=NoneSolver())], - outputs=[ - TimeAverageForceDistributionOutput( - name="test", - distribution_direction=[1.0, 0.0, 0.0], - start_step=10, - ), - ], - time_stepping=Steady(), - ) - - -def test_duplicate_probe_entity_names(mock_validation_context): - - # should have no error - with imperial_unit_system, mock_validation_context: - SimulationParams( - outputs=[ - ProbeOutput( - name="probe_output", - probe_points=[ - Point(name="point_1", location=[1, 2, 3] * u.m), - Point(name="point_2", location=[1, 2, 3] * u.m), - ], - output_fields=["Cp"], - ), - ProbeOutput( - name="probe_output2", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["velocity_x"], - ), - ], - ) - - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] ProbeOutput: Entity name point_1 has already been used in the " - "same `ProbeOutput`. Entity names must be unique." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - ProbeOutput( - name="probe_output_1", - probe_points=[ - Point(name="point_1", location=[1, 2, 3] * u.m), - Point(name="point_1", location=[1, 2, 3] * u.m), - ], - output_fields=["Cp"], - ), - ProbeOutput( - name="probe_output_2", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["velocity_x"], - ), - ], - ) - - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "In `outputs`[0] SurfaceProbeOutput: Entity name point_1 has already been used in the " - "same `SurfaceProbeOutput`. Entity names must be unique." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceProbeOutput( - name="probe_output_1", - probe_points=[ - Point(name="point_1", location=[1, 2, 3] * u.m), - Point(name="point_1", location=[1, 2, 3] * u.m), - ], - output_fields=["pressure"], - target_surfaces=[Surface(name="fluid/body")], - ), - SurfaceProbeOutput( - name="probe_output_2", - probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], - output_fields=["velocity_y"], - target_surfaces=[Surface(name="fluid/body")], - ), - ], - ) - - -def test_surface_integral_entity_types(mock_validation_context): - uv_surface1 = UserVariable( - name="uv_surface1", value=math.dot(solution.velocity, solution.CfVec) - ) - surface = Surface(name="fluid/body") - imported_surface = ImportedSurface(name="imported", file_name="imported.stl") - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceIntegralOutput(entities=surface, output_fields=[uv_surface1]), - SurfaceIntegralOutput( - entities=imported_surface, - output_fields=[uv_surface1], - ), - ], - ) - - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "Imported and simulation surfaces cannot be used together in the same SurfaceIntegralOutput." - " Please assign them to separate outputs." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceIntegralOutput( - entities=[surface, imported_surface], output_fields=[uv_surface1] - ), - ], - ) - - -def test_imported_surface_output_fields_validation(mock_validation_context): - """Test that imported surfaces only allow CommonFieldNames and Volume solver variables""" - imported_surface = ImportedSurface(name="imported", file_name="imported.stl") - surface = Surface(name="fluid/body") - - # Test 1: Surface-specific field name (not in CommonFieldNames) should fail with imported surface - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "Output field 'Cf' is not allowed for imported surfaces. " - "Only non-Surface field names are allowed for string format output fields when using imported surfaces." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput( - entities=imported_surface, - output_fields=["Cf"], # Cf is surface-specific, not in CommonFieldNames - ) - ], - ) - - # Test 2: UserVariable with Surface solver variables should fail with imported surface - uv_surface = UserVariable(name="uv_surface", value=math.dot(solution.velocity, solution.CfVec)) - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "Variable `uv_surface` cannot be used with imported surfaces " - "since it contains Surface type solver variable(s): solution.CfVec. " - "Only Volume type solver variables and 'solution.node_unit_normal' are allowed." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput( - entities=imported_surface, - output_fields=[uv_surface], - ) - ], - ) - - # Test 3: Multiple Surface solver variables in UserVariable should fail with imported surface - uv_multiple_surface = UserVariable( - name="uv_multiple", - value=solution.node_forces_per_unit_area[0] * solution.Cp * solution.Cf, - ) - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "Variable `uv_multiple` cannot be used with imported surfaces " - "since it contains Surface type solver variable(s): solution.Cf, solution.node_forces_per_unit_area. " - "Only Volume type solver variables and 'solution.node_unit_normal' are allowed." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput( - entities=imported_surface, - output_fields=[uv_multiple_surface], - ) - ], - ) - - # Test 4: CommonFieldNames should work with imported surface - with mock_validation_context, imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput( - entities=imported_surface, - output_fields=["Cp", "Mach"], # These are CommonFieldNames - ) - ], - ) - - # Test 5: UserVariable with only Volume solver variables should work with imported surface - uv_volume = UserVariable( - name="uv_volume", value=math.dot(solution.velocity, solution.vorticity) - ) - with mock_validation_context, imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput( - entities=imported_surface, - output_fields=[uv_volume], - ) - ], - ) - # Test 5.5: UserVariable with node_unit_normal should work with imported surface - uv_node_normal = UserVariable( - name="uv_node_normal", value=math.dot(solution.velocity, solution.node_unit_normal) - ) - with mock_validation_context, imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput( - entities=imported_surface, - output_fields=[uv_node_normal], - ) - ], - ) - - # Test 6: Regular surfaces should not be affected - surface-specific fields should work - with mock_validation_context, imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput( - entities=surface, - output_fields=[ - "Cf" - ], # Surface-specific fields should work for regular surfaces - ) - ], - ) - - # Test 7: Mixed entities (imported + regular surfaces) should trigger validation - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "Output field 'Cf' is not allowed for imported surfaces. " - "Only non-Surface field names are allowed for string format output fields when using imported surfaces." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceOutput( - entities=[surface, imported_surface], - output_fields=["Cf"], - ) - ], - ) - - -def test_imported_surface_output_fields_validation_surface_integral(mock_validation_context): - """Test that imported surfaces in SurfaceIntegralOutput only allow CommonFieldNames and Volume solver variables""" - imported_surface = ImportedSurface(name="imported", file_name="imported.stl") - surface = Surface(name="fluid/body") - - # Test 1: UserVariable with Surface solver variables should fail with imported surface - uv_surface = UserVariable(name="uv_surface", value=math.dot(solution.velocity, solution.CfVec)) - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "Variable `uv_surface` cannot be used with imported surfaces " - "since it contains Surface type solver variable(s): solution.CfVec. " - "Only Volume type solver variables and 'solution.node_unit_normal' are allowed." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceIntegralOutput( - entities=imported_surface, - output_fields=[uv_surface], - ) - ], - ) - - # Test 2: UserVariable with only Volume solver variables should work with imported surface - uv_volume = UserVariable( - name="uv_volume", value=math.dot(solution.velocity, solution.vorticity) - ) - with mock_validation_context, imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceIntegralOutput( - entities=imported_surface, - output_fields=[uv_volume], - ) - ], - ) - - # Test 2.5: UserVariable with node_unit_normal should work with imported surface (MassFluxProjected use case) - uv_mass_flux_projected = UserVariable( - name="MassFluxProjected", - value=math.dot(solution.density * solution.velocity, solution.node_unit_normal), - ) - with mock_validation_context, imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceIntegralOutput( - entities=imported_surface, - output_fields=[uv_mass_flux_projected], - ) - ], - ) - - # Test 3: Regular surfaces should not be affected - surface variables should work - with mock_validation_context, imperial_unit_system: - SimulationParams( - outputs=[ - SurfaceIntegralOutput( - entities=surface, - output_fields=[uv_surface], - ) - ], - ) - - def test_output_frequency_settings_in_steady_simulation(): volume_mesh = VolumeMeshV2.from_local_storage( mesh_id=None, local_storage_path=os.path.join( - os.path.dirname(__file__), "..", "data", "vm_entity_provider" + os.path.dirname(__file__), + "..", + "data", + "vm_entity_provider", ), ) - with open( - os.path.join( - os.path.dirname(__file__), "..", "data", "vm_entity_provider", "simulation.json" - ), - "r", - ) as fh: - asset_cache_data = json.load(fh).pop("private_attribute_asset_cache") + simulation_path = os.path.join( + os.path.dirname(__file__), + "..", + "data", + "vm_entity_provider", + "simulation.json", + ) + with open(simulation_path, "r") as file: + asset_cache_data = json.load(file).pop("private_attribute_asset_cache") asset_cache = AssetCache.deserialize(asset_cache_data) with imperial_unit_system: params = SimulationParams( models=[Wall(name="wall", entities=volume_mesh["*"])], time_stepping=Steady(), outputs=[ - VolumeOutput( - output_fields=["Mach", "Cp"], - frequency=2, - ), + VolumeOutput(output_fields=["Mach", "Cp"], frequency=2), SurfaceOutput( output_fields=["Cp"], entities=volume_mesh["*"], @@ -1296,7 +48,7 @@ def test_output_frequency_settings_in_steady_simulation(): ) params_as_dict = params.model_dump(exclude_none=True, mode="json") - params, errors, _ = validate_model( + _, errors, _ = validate_model( params_as_dict=params_as_dict, validated_by=ValidationCalledBy.LOCAL, root_item_type="VolumeMesh", @@ -1317,185 +69,28 @@ def test_output_frequency_settings_in_steady_simulation(): "ctx": {"relevant_for": ["Case"]}, }, ] - - -def test_force_output_with_wall_models(): - """Test ForceOutput with Wall models works correctly.""" - wall_1 = Wall(entities=Surface(name="fluid/wing1")) - wall_2 = Wall(entities=Surface(name="fluid/wing2")) - - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1, wall_2], - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1, wall_2], - output_fields=["CL", "CD", "CMx"], - ) - ], - ) - - # Test with extended force coefficients (SkinFriction/Pressure) - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CLSkinFriction", "CLPressure", "CDSkinFriction"], - ) - ], - ) - - -def test_force_output_with_surface_and_volume_models(mock_validation_context): - """Test ForceOutput with volume models (BETDisk, ActuatorDisk, PorousMedium).""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - with imperial_unit_system: - fluid_model = Fluid() - porous_zone = fl.Box.from_principal_axes( - name="box", - axes=[[0, 1, 0], [0, 0, 1]], - center=[0, 0, 0] * fl.u.m, - size=[0.2, 0.3, 2] * fl.u.m, - ) - porous_medium = PorousMedium( - entities=[porous_zone], - darcy_coefficient=[1e6, 0, 0], - forchheimer_coefficient=[1, 0, 0], - volumetric_heat_source=0, - ) - - # Valid case: only basic force coefficients - mock_validation_context.info.physics_model_dict = { - fluid_model.private_attribute_id: fluid_model, - wall_1.private_attribute_id: wall_1, - porous_medium.private_attribute_id: porous_medium, - } - with imperial_unit_system, mock_validation_context: - SimulationParams( - models=[Fluid(), wall_1, porous_medium], - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1, porous_medium], - output_fields=["CL", "CD", "CFx", "CFy", "CFz", "CMx", "CMy", "CMz"], - ) - ], - ) - - mock_validation_context.info.physics_model_dict = { - fluid_model.private_attribute_id: fluid_model, - wall_1.private_attribute_id: wall_1, - porous_medium.private_attribute_id: porous_medium, - } - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape( - "When ActuatorDisk/BETDisk/PorousMedium is specified, " - "only CL, CD, CFx, CFy, CFz, CMx, CMy, CMz can be set as output_fields." - ), - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1, porous_medium], - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1, porous_medium], - output_fields=["CL", "CLSkinFriction"], - ) - ], - ) - - -def test_force_output_duplicate_models(): - """Test that ForceOutput rejects duplicate models.""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - - with pytest.raises( - ValueError, - match=re.escape("Duplicate models are not allowed in the same `ForceOutput`."), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1, wall_1], - output_fields=["CL", "CD"], - ) - ], - ) - - -def test_force_output_nonexistent_model(): - """Test that ForceOutput rejects models not in SimulationParams' models list.""" - wall_1 = Wall(entities=Surface(name="fluid/wing1")) - wall_2 = Wall(entities=Surface(name="fluid/wing2")) - - non_wall2_context = ParamsValidationInfo({}, []) - non_wall2_context.physics_model_dict = {wall_1.private_attribute_id: wall_1.model_dump()} - - with ( - ValidationContext(CASE, non_wall2_context), - pytest.raises( - ValueError, - match=re.escape("The model does not exist in simulation params' models list."), - ), - ): - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_2.private_attribute_id], - output_fields=["CL", "CD"], - ) - ], - ) - - -def test_force_output_with_moving_statistic(): - """Test ForceOutput with moving statistics.""" - wall_1 = Wall(entities=Surface(name="fluid/wing")) - - with imperial_unit_system: - SimulationParams( - models=[Fluid(), wall_1], - outputs=[ - fl.ForceOutput( - name="force_output", - models=[wall_1], - output_fields=["CL", "CD"], - moving_statistic=fl.MovingStatistic( - method="mean", moving_window_size=20, start_step=100 - ), - ) - ], - ) + assert len(errors) == len(expected_errors) + for error, expected in zip(errors, expected_errors): + assert error["loc"] == expected["loc"] + assert error["type"] == expected["type"] + assert error["msg"] == expected["msg"] + assert error["ctx"]["relevant_for"] == expected["ctx"]["relevant_for"] def test_force_output_with_model_id(): - # [Frontend] Simulating loading a ForceOutput object with the id of models, - # ensure the validation for models works - with open("data/simulation_force_output_webui.json", "r") as fh: - data = json.load(fh) + simulation_path = os.path.join( + os.path.dirname(__file__), + "data", + "simulation_force_output_webui.json", + ) + with open(simulation_path, "r") as file: + data = json.load(file) _, errors, _ = validate_model( - params_as_dict=data, validated_by=ValidationCalledBy.LOCAL, root_item_type="VolumeMesh" + params_as_dict=data, + validated_by=ValidationCalledBy.LOCAL, + root_item_type="VolumeMesh", ) - # Expected errors: - # - outputs[3,4,5] have validation errors in their models field - # - Since outputs field has validation errors, the output_dict is not populated - # - Therefore all stopping_criteria that reference outputs by ID fail with a clear error message expected_errors = [ { "type": "value_error", @@ -1519,175 +114,8 @@ def test_force_output_with_model_id(): ] assert len(errors) == len(expected_errors) - for err, exp_err in zip(errors, expected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - assert err["msg"] == exp_err["msg"] - - -def test_force_distribution_output_entities_validation(): - """Test ForceDistributionOutput entities validation.""" - - # Test 1: Valid case - ForceDistributionOutput without entities (default all walls) - with imperial_unit_system: - SimulationParams( - outputs=[ - ForceDistributionOutput( - name="test_default", - distribution_direction=[1.0, 0.0, 0.0], - ), - ], - ) - - # Test 2: Valid case - ForceDistributionOutput with surface entities - with imperial_unit_system: - SimulationParams( - outputs=[ - ForceDistributionOutput( - name="test_with_surfaces", - distribution_direction=[1.0, 0.0, 0.0], - entities=[Surface(name="fluid/wing")], - ), - ], - ) - - # Test 3: Valid case - TimeAverageForceDistributionOutput with entities - with imperial_unit_system: - SimulationParams( - outputs=[ - TimeAverageForceDistributionOutput( - name="test_time_avg", - distribution_direction=[0.0, 1.0, 0.0], - entities=[Surface(name="fluid/body")], - start_step=10, - ), - ], - time_stepping=Unsteady(steps=100, step_size=1e-3), - ) - - # Test 4: Valid case - ForceDistributionOutput with multiple surfaces - with imperial_unit_system: - SimulationParams( - outputs=[ - ForceDistributionOutput( - name="test_multiple_surfaces", - distribution_direction=[0.0, 0.0, 1.0], - entities=[ - Surface(name="fluid/wing"), - Surface(name="fluid/fuselage"), - ], - ), - ], - ) - - # Test 5: Valid case - ForceDistributionOutput with custom number_of_segments - with imperial_unit_system: - SimulationParams( - outputs=[ - ForceDistributionOutput( - name="test_custom_segments", - distribution_direction=[1.0, 0.0, 0.0], - entities=[Surface(name="fluid/wing")], - number_of_segments=500, - ), - ], - ) - - -def test_force_distribution_output_requires_wall_bc(mock_validation_context): - """Test that ForceDistributionOutput validates surfaces have Wall BC.""" - from flow360.component.simulation.models.surface_models import Freestream, SlipWall - - wing_surface = Surface(name="fluid/wing") - freestream_surface = Surface(name="fluid/farfield") - - # Test: Valid case - surface with Wall BC - with mock_validation_context, imperial_unit_system: - SimulationParams( - models=[ - Fluid(), - Wall(entities=[wing_surface]), - Freestream(entities=[freestream_surface]), - ], - outputs=[ - ForceDistributionOutput( - name="test_valid", - distribution_direction=[1.0, 0.0, 0.0], - entities=[wing_surface], - ), - ], - ) - - # Test: Invalid case - surface without Wall BC (has Freestream BC) - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape("The following surfaces do not have Wall boundary conditions assigned"), - ), - ): - with imperial_unit_system: - SimulationParams( - models=[ - Fluid(), - Wall(entities=[wing_surface]), - Freestream(entities=[freestream_surface]), - ], - outputs=[ - ForceDistributionOutput( - name="test_invalid", - distribution_direction=[1.0, 0.0, 0.0], - entities=[freestream_surface], # This has Freestream BC, not Wall - ), - ], - ) - - # Test: Invalid case - surface with SlipWall BC (not a no-slip Wall) - slipwall_surface = Surface(name="fluid/symmetry") - with ( - mock_validation_context, - pytest.raises( - ValueError, - match=re.escape("The following surfaces do not have Wall boundary conditions assigned"), - ), - ): - with imperial_unit_system: - SimulationParams( - models=[ - Fluid(), - Wall(entities=[wing_surface]), - SlipWall(entities=[slipwall_surface]), - ], - outputs=[ - ForceDistributionOutput( - name="test_slipwall", - distribution_direction=[1.0, 0.0, 0.0], - entities=[slipwall_surface], # SlipWall is not Wall - ), - ], - ) - - -def test_surface_output_write_single_file_validator(): - # write_single_file is now supported for all output formats - SurfaceOutput( - write_single_file=True, - entities=[Surface(name="noSlipWall")], - output_fields=["Cp"], - output_format="paraview", - ) - - SurfaceOutput( - write_single_file=True, - entities=[Surface(name="noSlipWall")], - output_fields=["Cp"], - output_format="tecplot", - ) - - SurfaceOutput( - write_single_file=True, - entities=[Surface(name="noSlipWall")], - output_fields=["Cp"], - output_format="both", - ) + for error, expected in zip(errors, expected_errors): + assert error["loc"] == expected["loc"] + assert error["type"] == expected["type"] + assert error["ctx"]["relevant_for"] == expected["ctx"]["relevant_for"] + assert error["msg"] == expected["msg"] diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 82ff48932..2e8263214 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -244,426 +244,6 @@ def fluid_model(): return fluid_model -def test_consistency_wall_function_validator( - surface_output_with_wall_metric, wall_model_with_function, wall_model_without_function -): - # Valid simulation params - with SI_unit_system: - params = SimulationParams( - models=[wall_model_with_function], outputs=[surface_output_with_wall_metric] - ) - - assert params - - message = ( - "To use 'wallFunctionMetric' for output specify a Wall model with use_wall_function=true. " - ) - - # Invalid simulation params - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams( - models=[wall_model_without_function], outputs=[surface_output_with_wall_metric] - ) - - -def test_consistency_wall_function_validator(): - - # Valid simulation params - with SI_unit_system: - params = SimulationParams( - models=[ - Wall(velocity=["0.1*t", "0.2*t", "0.3*t"], surfaces=[Surface(name="noSlipWall")]) - ] - ) - - assert params - - message = "Using `SlaterPorousBleed` with wall function is not supported currently." - - # Invalid simulation params - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams( - models=[ - Wall( - velocity=SlaterPorousBleed(porosity=0.2, static_pressure=0.1), - surfaces=[Surface(name="noSlipWall")], - use_wall_function=True, - ) - ] - ) - - -def test_wall_function_type_interface(): - """Test the use_wall_function field accepts WallFunction, bool (compat), and None.""" - surface = Surface(name="noSlipWall") - - # True is converted to WallFunction() with default BoundaryLayer and logs deprecation warning - wall = Wall(surfaces=[surface], use_wall_function=True) - assert wall.use_wall_function == WallFunction() - assert wall.use_wall_function.wall_function_type == "BoundaryLayer" - - # False is converted to None and logs deprecation warning - wall = Wall(surfaces=[surface], use_wall_function=False) - assert wall.use_wall_function is None - - # Default is None - wall = Wall(surfaces=[surface]) - assert wall.use_wall_function is None - - # WallFunction with default wall_function_type - wall = Wall(surfaces=[surface], use_wall_function=WallFunction()) - assert wall.use_wall_function.wall_function_type == "BoundaryLayer" - - # WallFunction with InnerLayer - wall = Wall(surfaces=[surface], use_wall_function=WallFunction(wall_function_type="InnerLayer")) - assert wall.use_wall_function.wall_function_type == "InnerLayer" - - # SlaterPorousBleed conflict applies to all wall function types - message = "Using `SlaterPorousBleed` with wall function is not supported currently." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - Wall( - velocity=SlaterPorousBleed(porosity=0.2, static_pressure=1e5 * u.Pa), - surfaces=[surface], - use_wall_function=WallFunction(wall_function_type="InnerLayer"), - ) - - # Invalid wall_function_type should be rejected by pydantic - with pytest.raises(pd.ValidationError): - Wall(surfaces=[surface], use_wall_function=WallFunction(wall_function_type="InvalidType")) - - -def test_low_mach_preconditioner_validator( - surface_output_with_low_mach_precond, fluid_model_with_low_mach_precond, fluid_model -): - # Valid simulation params - with SI_unit_system: - params = SimulationParams( - models=[fluid_model_with_low_mach_precond], - outputs=[surface_output_with_low_mach_precond], - ) - - assert params - - message = ( - "Low-Mach preconditioner output requested, but low_mach_preconditioner is not enabled. " - "You can enable it via model.navier_stokes_solver.low_mach_preconditioner = True for a Fluid " - "model in the models field of the simulation object." - ) - - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams(models=[fluid_model], outputs=[surface_output_with_low_mach_precond]) - - -def test_numerical_dissipation_mode_validator( - surface_output_with_numerical_dissipation, - fluid_model_with_low_numerical_dissipation, - fluid_model, -): - # Valid simulation params - with SI_unit_system: - params = SimulationParams( - models=[fluid_model_with_low_numerical_dissipation], - outputs=[surface_output_with_numerical_dissipation], - ) - - assert params - - message = ( - "Numerical dissipation factor output requested, but low dissipation mode is not enabled. " - "You can enable it via model.navier_stokes_solver.numerical_dissipation_factor = True for a Fluid " - "model in the models field of the simulation object." - ) - - # Invalid simulation params - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams( - models=[fluid_model], outputs=[surface_output_with_numerical_dissipation] - ) - - -def test_hybrid_model_wall_function_validator( - volume_output_with_SA_hybrid_model, - volume_output_with_kOmega_hybrid_model, - fluid_model_with_hybrid_model, - fluid_model, -): - # Valid simulation params - with SI_unit_system: - params = SimulationParams( - models=[fluid_model_with_hybrid_model], - outputs=[volume_output_with_SA_hybrid_model], - time_stepping=Unsteady(steps=12, step_size=0.1 * u.s), - ) - - assert params - - message = "kOmegaSST_hybridModel output can only be specified with kOmegaSST turbulence model and hybrid RANS-LES used." - - # Invalid simulation params (wrong output type) - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - SimulationParams( - models=[fluid_model_with_hybrid_model], - outputs=[volume_output_with_kOmega_hybrid_model], - time_stepping=Unsteady(steps=12, step_size=0.1 * u.s), - ) - - # Invalid simulation params (no hybrid) - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - SimulationParams( - models=[fluid_model], - outputs=[volume_output_with_kOmega_hybrid_model], - time_stepping=Unsteady(steps=12, step_size=0.1 * u.s), - ) - - -def test_hybrid_model_for_unsteady_validator( - fluid_model_with_hybrid_model, -): - - message = "hybrid RANS-LES model can only be used in unsteady simulations." - - # Invalid simulation params (using hybrid model for steady simulations) - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - SimulationParams(models=[fluid_model_with_hybrid_model]) - - -def test_hybrid_model_grid_size_for_LES(): - for valid_option in ["maxEdgeLength", "meanEdgeLength", "shearLayerAdapted"]: - des = DetachedEddySimulation(grid_size_for_LES=valid_option) - assert des.grid_size_for_LES == valid_option - - with pytest.raises(pd.ValidationError): - DetachedEddySimulation(grid_size_for_LES="invalidOption") - - -def test_hybrid_model_to_use_zonal_enforcement(fluid_model, fluid_model_with_hybrid_model): - - fluid_model_with_hybrid_model.turbulence_model_solver.controls = [ - TurbulenceModelControls(enforcement="RANS", entities=[GenericVolume(name="block-1")]) - ] - - # Valid simulation params - with SI_unit_system: - params = SimulationParams( - models=[fluid_model_with_hybrid_model], - time_stepping=Unsteady(steps=12, step_size=0.1 * u.s), - ) - - assert params - - fluid_model.turbulence_model_solver.controls = [ - TurbulenceModelControls(enforcement="RANS", entities=[GenericVolume(name="block-1")]) - ] - - message = "Control region 0 must be running in hybrid RANS-LES mode to apply zonal turbulence enforcement." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - SimulationParams( - models=[fluid_model], - time_stepping=Unsteady(steps=12, step_size=0.1 * u.s), - ) - - -def test_zonal_modeling_constants_consistency(fluid_model_with_hybrid_model): - fluid_model_with_hybrid_model.turbulence_model_solver.controls = [ - TurbulenceModelControls( - enforcement="RANS", - modeling_constants=SpalartAllmarasModelConstants(), - entities=[GenericVolume(name="block-1")], - ) - ] - - # Valid simulation params - with SI_unit_system: - params = SimulationParams( - models=[fluid_model_with_hybrid_model], - time_stepping=Unsteady(steps=12, step_size=0.1 * u.s), - ) - - assert params - - message = "Turbulence model is SpalartAllmaras, but controls.modeling_constants is of a " - "conflicting class in control region 0." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - TurbulenceModelSolver = SpalartAllmaras( - controls=[ - TurbulenceModelControls( - enforcement="LES", - modeling_constants=KOmegaSSTModelConstants(), - entities=[GenericVolume(name="block-1")], - ) - ] - ) - - message = "Turbulence model is KOmegaSST, but controls.modeling_constants is of a " - "conflicting class in control region 0." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - TurbulenceModelSolver = KOmegaSST( - controls=[ - TurbulenceModelControls( - enforcement="LES", - modeling_constants=SpalartAllmarasModelConstants(), - entities=[GenericVolume(name="block-1")], - ) - ] - ) - - -def test_cht_solver_settings_validator( - fluid_model, -): - timestepping_unsteady = Unsteady(steps=12, step_size=0.1 * u.s) - fluid_model_with_initial_condition = Fluid( - initial_condition=NavierStokesInitialCondition(rho="1;", u="1;", v="1;", w="1;", p="1;") - ) - solid_model = Solid( - volumes=[GenericVolume(name="CHTSolid")], - material=aluminum, - volumetric_heat_source="0", - initial_condition=HeatEquationInitialCondition(temperature="10"), - ) - solid_model_without_initial_condition = Solid( - volumes=[GenericVolume(name="CHTSolid")], - material=aluminum, - volumetric_heat_source="0", - ) - solid_model_without_specific_heat_capacity = Solid( - volumes=[GenericVolume(name="CHTSolid")], - material=SolidMaterial( - name="aluminum_without_specific_heat_capacity", - thermal_conductivity=235 * u.kg / u.s**3 * u.m / u.K, - density=2710 * u.kg / u.m**3, - ), - volumetric_heat_source="0", - initial_condition=HeatEquationInitialCondition(temperature="10;"), - ) - solid_model_without_density = Solid( - volumes=[GenericVolume(name="CHTSolid")], - material=SolidMaterial( - name="aluminum_without_density", - thermal_conductivity=235 * u.kg / u.s**3 * u.m / u.K, - specific_heat_capacity=903 * u.m**2 / u.s**2 / u.K, - ), - volumetric_heat_source="0", - initial_condition=HeatEquationInitialCondition(temperature="10;"), - ) - surface_output_with_residual_heat_solver = SurfaceOutput( - name="surface", - surfaces=[Surface(name="noSlipWall")], - write_single_file=True, - output_format="tecplot", - output_fields=["residualHeatSolver"], - ) - - # Valid simulation params - with SI_unit_system: - params = SimulationParams( - models=[fluid_model, solid_model], - time_stepping=timestepping_unsteady, - outputs=[surface_output_with_residual_heat_solver], - ) - - assert params - - message = ( - "Heat equation output variables: residualHeatSolver is requested in " - f"{surface_output_with_residual_heat_solver.output_type} with no `Solid` model defined." - ) - - # Invalid simulation params - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams( - models=[fluid_model], - time_stepping=timestepping_unsteady, - outputs=[surface_output_with_residual_heat_solver], - ) - - message = ( - "In `Solid` model -> material, both `specific_heat_capacity` and `density` " - "need to be specified for unsteady simulations." - ) - - # Invalid simulation params - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams( - models=[fluid_model, solid_model_without_specific_heat_capacity], - time_stepping=timestepping_unsteady, - outputs=[surface_output_with_residual_heat_solver], - ) - - # Invalid simulation params - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams( - models=[fluid_model, solid_model_without_density], - time_stepping=timestepping_unsteady, - outputs=[surface_output_with_residual_heat_solver], - ) - - message = ( - "In `Solid` model, the initial condition needs to be specified for unsteady simulations." - ) - - # Invalid simulation params - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams( - models=[fluid_model, solid_model_without_initial_condition], - time_stepping=timestepping_unsteady, - outputs=[surface_output_with_residual_heat_solver], - ) - - -def test_transition_model_solver_settings_validator(): - transition_model_solver = TransitionModelSolver() - assert transition_model_solver - - with SI_unit_system: - params = SimulationParams( - models=[Fluid(transition_model_solver=transition_model_solver)], - ) - assert params.models[0].transition_model_solver.N_crit == 8.15 - - with pytest.raises( - pd.ValidationError, - match="N_crit and turbulence_intensity_percent cannot be specified at the same time.", - ): - transition_model_solver = TransitionModelSolver( - update_jacobian_frequency=5, - equation_evaluation_frequency=10, - max_force_jac_update_physical_steps=10, - order_of_accuracy=1, - turbulence_intensity_percent=1.2, - N_crit=2, - ) - - transition_model_solver = TransitionModelSolver( - update_jacobian_frequency=5, - equation_evaluation_frequency=10, - max_force_jac_update_physical_steps=10, - order_of_accuracy=1, - turbulence_intensity_percent=1.2, - ) - - with SI_unit_system: - params = SimulationParams( - models=[Fluid(transition_model_solver=transition_model_solver)], - ) - assert params.models[0].transition_model_solver.N_crit == 2.3598473252999543 - assert params.models[0].transition_model_solver.turbulence_intensity_percent is None - - -def test_linear_solver_tolerance_conflict(): - with pytest.raises(pd.ValidationError, match="absolute_tolerance and relative_tolerance"): - LinearSolver(absolute_tolerance=1e-10, relative_tolerance=1e-6) - - # Only one is fine - ls = LinearSolver(absolute_tolerance=1e-10) - assert ls.absolute_tolerance == 1e-10 - assert ls.relative_tolerance is None - - ls = LinearSolver(relative_tolerance=1e-6) - assert ls.relative_tolerance == 1e-6 - assert ls.absolute_tolerance is None - - def test_BC_geometry(): """For a quasi 3D geometry test the check for the""" # --------------------------------------------------------# @@ -1059,326 +639,6 @@ def test_incomplete_BC_surface_mesh(): ) -def test_porousJump_entities_is_interface(mock_validation_context): - surface_1_is_interface = Surface(name="Surface-1", private_attribute_is_interface=True) - surface_2_is_not_interface = Surface(name="Surface-2", private_attribute_is_interface=False) - surface_3_is_interface = Surface(name="Surface-3", private_attribute_is_interface=True) - error_message = "Boundary `Surface-2` is not an interface" - with mock_validation_context, pytest.raises(ValueError, match=re.escape(error_message)): - PorousJump( - entity_pairs=[(surface_1_is_interface, surface_2_is_not_interface)], - darcy_coefficient=1e6 / (u.m * u.m), - forchheimer_coefficient=1e3 / u.m, - thickness=0.01 * u.m, - ) - - with mock_validation_context, pytest.raises(ValueError, match=re.escape(error_message)): - PorousJump( - entity_pairs=[(surface_2_is_not_interface, surface_1_is_interface)], - darcy_coefficient=1e6 / (u.m * u.m), - forchheimer_coefficient=1e3 / u.m, - thickness=0.01 * u.m, - ) - - PorousJump( - entity_pairs=[(surface_1_is_interface, surface_3_is_interface)], - darcy_coefficient=1e6 / (u.m * u.m), - forchheimer_coefficient=1e3 / u.m, - thickness=0.01 * u.m, - ) - - -def test_porousJump_cross_custom_volume_interface(mock_validation_context): - """Test that PorousJump allows non-interface surfaces when they belong to different CustomVolumes.""" - # Create surfaces that are NOT interfaces, with explicit IDs for tracking - surface_cv1 = Surface( - name="Surface-CV1", private_attribute_is_interface=False, private_attribute_id="cv1-surf-1" - ) - surface_cv2 = Surface( - name="Surface-CV2", private_attribute_is_interface=False, private_attribute_id="cv2-surf-1" - ) - surface_cv1_another = Surface( - name="Surface-CV1-Another", - private_attribute_is_interface=False, - private_attribute_id="cv1-surf-2", - ) - - # Set up to_be_generated_custom_volumes with two CustomVolumes having different boundaries - mock_validation_context.info.to_be_generated_custom_volumes = { - "CustomVolume1": { - "enforce_tetrahedra": False, - "boundary_surface_ids": {"cv1-surf-1", "cv1-surf-2"}, - }, - "CustomVolume2": { - "enforce_tetrahedra": False, - "boundary_surface_ids": {"cv2-surf-1"}, - }, - } - - # Cross-CustomVolume case: surfaces from different CustomVolumes should pass (no error) - with mock_validation_context: - PorousJump( - entity_pairs=[(surface_cv1, surface_cv2)], - darcy_coefficient=1e6 / (u.m * u.m), - forchheimer_coefficient=1e3 / u.m, - thickness=0.01 * u.m, - ) - - # Same-CustomVolume case: surfaces from the same CustomVolume should still fail - error_message = "Boundary `Surface-CV1` is not an interface" - with mock_validation_context, pytest.raises(ValueError, match=re.escape(error_message)): - PorousJump( - entity_pairs=[(surface_cv1, surface_cv1_another)], - darcy_coefficient=1e6 / (u.m * u.m), - forchheimer_coefficient=1e3 / u.m, - thickness=0.01 * u.m, - ) - - -def test_get_farfield_enclosed_entities_expands_selectors(): - """_get_farfield_enclosed_entities must expand selectors, not just read stored_entities.""" - from unittest.mock import patch - - param_info = ParamsValidationInfo({}, []) - - surface_stored = Surface(name="StoredSurface", private_attribute_id="id-stored") - surface_from_selector = Surface(name="SelectorSurface", private_attribute_id="id-sel") - - param_as_dict = { - "meshing": { - "volume_zones": [ - { - "type": "AutomatedFarfield", - "enclosed_entities": { - "stored_entities": [surface_stored], - "selectors": ["some-selector-token"], - }, - } - ] - } - } - - # Mock expand_entity_list on the class to return both stored and selector-resolved surfaces - with patch.object( - ParamsValidationInfo, - "expand_entity_list", - return_value=[surface_stored, surface_from_selector], - ): - result = param_info._get_farfield_enclosed_entities(param_as_dict) - - assert result == {"id-stored": "StoredSurface", "id-sel": "SelectorSurface"} - - -def test_collect_farfield_custom_volume_interfaces(): - """AutomatedFarfield + enclosed_entities + CustomZones: dual-belonging faces are recognized as interfaces.""" - from flow360.component.simulation.validation.validation_simulation_params import ( - _collect_farfield_custom_volume_interfaces, - ) - - param_info = ParamsValidationInfo({}, []) - - # Set up farfield enclosed surfaces: face1 and face2 are on the farfield boundary - param_info.farfield_enclosed_entities = { - "id-face1": "face1", - "id-face2": "face2", - "id-face3": "face3", - } - # Set up CustomVolume whose boundaries overlap with enclosed_entities - param_info.to_be_generated_custom_volumes = { - "CustomVolume1": { - "enforce_tetrahedra": False, - "boundary_surface_ids": {"id-face1", "id-face2"}, - }, - } - - # face1 and face2 are dual-belonging (enclosed + CV boundary) -> should be interfaces - result = _collect_farfield_custom_volume_interfaces(param_info=param_info) - assert result == {"face1", "face2"} - # face3 is only in enclosed_entities, not in any CV boundary -> should NOT be an interface - assert "face3" not in result - - -def test_collect_farfield_custom_volume_interfaces_empty_enclosed(): - """AutomatedFarfield without enclosed_entities: returns empty set (existing behavior unchanged).""" - from flow360.component.simulation.validation.validation_simulation_params import ( - _collect_farfield_custom_volume_interfaces, - ) - - param_info = ParamsValidationInfo({}, []) - - # No farfield enclosed surfaces - param_info.farfield_enclosed_entities = {} - param_info.to_be_generated_custom_volumes = { - "CustomVolume1": { - "enforce_tetrahedra": False, - "boundary_surface_ids": {"some-id"}, - }, - } - - result = _collect_farfield_custom_volume_interfaces(param_info=param_info) - assert result == set() - - -def test_porousJump_farfield_custom_volume_interface(mock_validation_context): - """PorousJump validation passes for cross-farfield-customvolume interface pairs.""" - # Surfaces that are NOT interfaces in the traditional sense (geometry-stage) - surface_a = Surface( - name="Surface-A", private_attribute_is_interface=False, private_attribute_id="id-a" - ) - surface_b = Surface( - name="Surface-B", private_attribute_is_interface=False, private_attribute_id="id-b" - ) - surface_non_dual = Surface( - name="Surface-Non-Dual", private_attribute_is_interface=False, private_attribute_id="id-non" - ) - - # Both surfaces are dual-belonging: in farfield enclosed_entities AND CustomVolume boundaries - mock_validation_context.info.farfield_enclosed_entities = { - "id-a": "Surface-A", - "id-b": "Surface-B", - "id-non": "Surface-Non-Dual", - } - mock_validation_context.info.to_be_generated_custom_volumes = { - "CustomVolume1": { - "enforce_tetrahedra": False, - "boundary_surface_ids": {"id-a", "id-b"}, - }, - } - - # Dual-belonging pair: should pass (no interface error) - with mock_validation_context: - PorousJump( - entity_pairs=[(surface_a, surface_b)], - darcy_coefficient=1e6 / (u.m * u.m), - forchheimer_coefficient=1e3 / u.m, - thickness=0.01 * u.m, - ) - - # Non-dual-belonging pair: surface_non_dual is in enclosed but NOT in any CV boundary. - # surface_a is iterated first and also fails the is_interface check. - error_message = "Boundary `Surface-A` is not an interface" - with mock_validation_context, pytest.raises(ValueError, match=re.escape(error_message)): - PorousJump( - entity_pairs=[(surface_a, surface_non_dual)], - darcy_coefficient=1e6 / (u.m * u.m), - forchheimer_coefficient=1e3 / u.m, - thickness=0.01 * u.m, - ) - - -def test_duplicate_entities_in_models(): - entity_generic_volume = GenericVolume(name="Duplicate Volume") - entity_surface = Surface(name="Duplicate Surface") - entity_cylinder = Cylinder( - name="Duplicate Cylinder", - outer_radius=1 * u.cm, - height=1 * u.cm, - center=(0, 0, 0) * u.cm, - axis=(0, 0, 1), - private_attribute_id="1", - ) - entity_box = Box( - name="Box", - axis_of_rotation=(1, 0, 0), - angle_of_rotation=45 * u.deg, - center=(1, 1, 1) * u.m, - size=(0.2, 0.3, 2) * u.m, - private_attribute_id="2", - ) - entity_box_same_name = Box( - name="Box", - axis_of_rotation=(1, 0, 0), - angle_of_rotation=45 * u.deg, - center=(1, 1, 1) * u.m, - size=(0.2, 0.3, 2) * u.m, - private_attribute_id="3", - ) - volume_model1 = Solid( - volumes=[entity_generic_volume, entity_generic_volume], - material=aluminum, - volumetric_heat_source="0", - ) - volume_model2 = volume_model1 - surface_model1 = SlipWall(entities=[entity_surface]) - surface_model2 = Wall(entities=[entity_surface]) - surface_model3 = surface_model1 - - rotation_model1 = Rotation( - volumes=[entity_cylinder], - name="innerRotation", - spec=AngleExpression("sin(t)"), - ) - rotation_model2 = Rotation( - volumes=[entity_cylinder], - name="outerRotation", - spec=AngleExpression("sin(2*t)"), - ) - porous_medium_model1 = PorousMedium( - volumes=entity_box, - darcy_coefficient=(1e6, 0, 0) / u.m**2, - forchheimer_coefficient=(1, 0, 0) / u.m, - volumetric_heat_source=1.0 * u.W / u.m**3, - ) - porous_medium_model2 = PorousMedium( - volumes=entity_box_same_name, - darcy_coefficient=(3e5, 0, 0) / u.m**2, - forchheimer_coefficient=(1, 0, 0) / u.m, - volumetric_heat_source=1.0 * u.W / u.m**3, - ) - - # Valid simulation params - with SI_unit_system: - params = SimulationParams( - models=[volume_model1, surface_model1], - ) - - assert params - - # Valid simulation params with the same Box name in the PorousMedium model - with SI_unit_system: - params = SimulationParams( - models=[porous_medium_model1, porous_medium_model2], - ) - - assert params - - message = ( - f"Surface entity `{entity_surface.name}` appears multiple times in `{surface_model1.type}`, `{surface_model2.type}` models.\n" - f"Volume entity `{entity_generic_volume.name}` appears multiple times in `{volume_model1.type}` model.\n" - ) - - mock_context = ValidationContext( - levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) - ) - # Invalid simulation params - with SI_unit_system, mock_context, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams( - models=[volume_model1, volume_model2, surface_model1, surface_model2, surface_model3], - ) - - message = f"Volume entity `{entity_cylinder.name}` appears multiple times in `{rotation_model1.type}` model.\n" - - # Invalid simulation params (Draft Entity) - with SI_unit_system, mock_context, pytest.raises(ValueError, match=re.escape(message)): - _ = SimulationParams( - models=[rotation_model1, rotation_model2], - ) - - -def test_valid_reference_velocity(): - mock_context = ValidationContext( - levels=[CASE], info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) - ) - with pytest.raises( - ValueError, - match=re.escape( - "Reference velocity magnitude/Mach must be provided when freestream velocity magnitude/Mach is 0." - ), - ): - with SI_unit_system, mock_context: - SimulationParams(operating_condition=AerospaceCondition(velocity_magnitude=0)) - - def test_output_fields_with_user_defined_fields(): surface_1 = Surface(name="some_random_surface") # 1: No user defined fields @@ -2871,55 +2131,6 @@ def test_redefined_user_defined_fields(): ) -def test_check_duplicate_isosurface_names(): - - isosurface_qcriterion = Isosurface(name="qcriterion", field="qcriterion", iso_value=0.1) - message = "The name `qcriterion` is reserved for the autovis isosurface from solver, please rename the isosurface." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - SimulationParams( - outputs=[IsosurfaceOutput(isosurfaces=[isosurface_qcriterion], output_fields=["Mach"])], - ) - - isosurface1 = Isosurface(name="isosurface1", field="qcriterion", iso_value=0.1) - isosurface2 = Isosurface(name="isosurface1", field="Mach", iso_value=0.2) - message = f"Another isosurface with name: `{isosurface2.name}` already exists, please rename the isosurface." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - SimulationParams( - outputs=[ - IsosurfaceOutput(isosurfaces=[isosurface1], output_fields=["Mach"]), - IsosurfaceOutput(isosurfaces=[isosurface2], output_fields=["pressure"]), - ], - ) - - message = f"Another time average isosurface with name: `{isosurface2.name}` already exists, please rename the isosurface." - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): - SimulationParams( - time_stepping=Unsteady(steps=12, step_size=0.1 * u.s), - outputs=[ - TimeAverageIsosurfaceOutput(isosurfaces=[isosurface1], output_fields=["Mach"]), - TimeAverageIsosurfaceOutput(isosurfaces=[isosurface2], output_fields=["pressure"]), - ], - ) - - -def test_custom_volume_legacy_boundaries_key(): - """Legacy ``boundaries`` key is accepted and migrated to ``bounding_entities``.""" - with SI_unit_system: - cv = CustomVolume(name="zone1", boundaries=[Surface(name="face1")]) - assert cv.bounding_entities is not None - assert cv.bounding_entities.stored_entities[0].name == "face1" - - -def test_custom_volume_legacy_boundaries_key_rejected_when_bounding_entities_present(): - """When ``bounding_entities`` is already present, ``boundaries`` is rejected as extra input.""" - with SI_unit_system, pytest.raises(pd.ValidationError, match="Extra inputs are not permitted"): - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1")], - boundaries=[Surface(name="face_ignored")], - ) - - def test_check_custom_volume_in_volume_zones(): from flow360.component.simulation.meshing_param.volume_params import CustomZones @@ -3079,46 +2290,6 @@ def test_check_custom_volume_in_volume_zones(): assert errors[0]["loc"] == ("models", 0, "entities") -def test_ghost_surface_pair_requires_quasi_3d_periodic_farfield(): - # Create two dummy ghost surfaces (Python workflow) - periodic_1 = GhostSurface(name="periodic_1", private_attribute_id="periodic_1") - periodic_2 = GhostSurface(name="periodic_2", private_attribute_id="periodic_2") - - # Case 1: Farfield method NOT "quasi-3d-periodic" → should raise ValueError - with ( - SI_unit_system, - ValidationContext(CASE, quasi_3d_farfield_context), - pytest.raises( - ValueError, - match="Farfield type must be 'quasi-3d-periodic' when using GhostSurfacePair.", - ), - ): - Periodic(surface_pairs=(periodic_1, periodic_2), spec=Translational()) - - # Case 2: Farfield method IS "quasi-3d-periodic" → should pass - with SI_unit_system, ValidationContext(CASE, quasi_3d_periodic_farfield_context): - Periodic(surface_pairs=(periodic_1, periodic_2), spec=Translational()) - - # Create two dummy ghost circular plane (Web UI workflow) - periodic_1 = GhostCircularPlane(name="periodic_1", private_attribute_id="periodic_1") - periodic_2 = GhostCircularPlane(name="periodic_2", private_attribute_id="periodic_2") - - # Case 3: Farfield method NOT "quasi-3d-periodic" → should raise ValueError - with ( - SI_unit_system, - ValidationContext(CASE, quasi_3d_farfield_context), - pytest.raises( - ValueError, - match="Farfield type must be 'quasi-3d-periodic' when using GhostSurfacePair.", - ), - ): - Periodic(surface_pairs=(periodic_1, periodic_2), spec=Translational()) - - # Case 4: Farfield method IS "quasi-3d-periodic" → should pass - with SI_unit_system, ValidationContext(CASE, quasi_3d_periodic_farfield_context): - Periodic(surface_pairs=(periodic_1, periodic_2), spec=Translational()) - - def test_seedpoint_zone_based_params(): from flow360.component.simulation.meshing_param.volume_params import CustomZones diff --git a/tests/simulation/test_coordinate_system.py b/tests/simulation/test_coordinate_system.py deleted file mode 100644 index b6884e6b2..000000000 --- a/tests/simulation/test_coordinate_system.py +++ /dev/null @@ -1,72 +0,0 @@ -import numpy as np - -import flow360.component.simulation.units as u -from flow360.component.simulation.entity_operation import ( - CoordinateSystem, - Transformation, -) - - -def _compose(parent: np.ndarray, child: np.ndarray) -> np.ndarray: - parent_rotation = parent[:, :3] - parent_translation = parent[:, 3] - - child_rotation = child[:, :3] - child_translation = child[:, 3] - - combined_rotation = parent_rotation @ child_rotation - combined_translation = parent_rotation @ child_translation + parent_translation - - return np.hstack([combined_rotation, combined_translation[:, np.newaxis]]) - - -def test_coordinate_system_transformation_matches_legacy_transformation(): - with u.SI_unit_system: - legacy_transform_args = { - "origin": (1.0, 2.0, 3.0) * u.m, - "axis_of_rotation": (0, 0, 1), - "angle_of_rotation": 90 * u.deg, - "scale": (2.0, 3.0, 4.0), - "translation": (5.0, 6.0, 7.0) * u.m, - } - coordinate_transform_args = { - "reference_point": (1.0, 2.0, 3.0) * u.m, - "axis_of_rotation": (0, 0, 1), - "angle_of_rotation": 90 * u.deg, - "scale": (2.0, 3.0, 4.0), - "translation": (5.0, 6.0, 7.0) * u.m, - } - transformation_matrix = Transformation(**legacy_transform_args).get_transformation_matrix() - coordinate_matrix = CoordinateSystem( - name="vehicle_frame", **coordinate_transform_args - ).get_transformation_matrix() - - np.testing.assert_allclose(coordinate_matrix, transformation_matrix) - - -def test_coordinate_system_inheritance_composes_transformations(): - with u.SI_unit_system: - root = CoordinateSystem( - name="root", - translation=(10, 0, 0) * u.m, - ) - child = CoordinateSystem( - name="child", - translation=(0, 1, 0) * u.m, - axis_of_rotation=(0, 0, 1), - angle_of_rotation=90 * u.deg, - ) - leaf = CoordinateSystem( - name="leaf", - translation=(1, 0, 0) * u.m, - ) - - # Manually compose (root ∘ child ∘ leaf) - expected = _compose( - root.get_transformation_matrix(), - _compose(child.get_transformation_matrix(), leaf.get_transformation_matrix()), - ) - - composed = expected - - np.testing.assert_allclose(composed, expected) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 8b9b689c5..41d17bfb5 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -153,31 +153,6 @@ def test_variable_space_init(): assert evaluated == 1.0 * u.m**2 -def test_to_file_from_file_expression( - constant_variable, constant_array, constant_unyt_quantity, constant_unyt_array -): - with SI_unit_system: - params = SimulationParams( - reference_geometry=ReferenceGeometry( - area=10 * u.m**2, - ), - outputs=[ - VolumeOutput( - output_fields=[ - solution.mut.in_units(new_name="mut_in_SI", new_unit="g/cm/min"), - constant_variable, - constant_array, - constant_unyt_quantity, - constant_unyt_array, - ] - ) - ], - ) - - to_file_from_file_test_approx(params) - params.display_output_units() # Just to make sure not exception. - - def test_udf_generator(): with SI_unit_system: params = SimulationParams( diff --git a/tests/simulation/test_krylov_solver.py b/tests/simulation/test_krylov_solver.py index 491005c0f..340a669aa 100644 --- a/tests/simulation/test_krylov_solver.py +++ b/tests/simulation/test_krylov_solver.py @@ -1,9 +1,6 @@ -import pytest - import flow360.component.simulation.units as u from flow360.component.simulation.models.solver_numerics import ( KrylovLinearSolver, - LinearSolver, LineSearch, NavierStokesSolver, ) @@ -14,18 +11,14 @@ ) from flow360.component.simulation.primitives import Surface from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady +from flow360.component.simulation.time_stepping.time_stepping import Steady from flow360.component.simulation.translator.solver_translator import get_solver_json from flow360.component.simulation.unit_system import SI_unit_system -# ── Helpers ────────────────────────────────────────────────────────────────── - -def _make_sim_params(navier_stokes_solver=None, time_stepping=None): - """Build a minimal SimulationParams with the given NS solver and time stepping.""" +def _make_sim_params(navier_stokes_solver=None): with SI_unit_system: ns = navier_stokes_solver or NavierStokesSolver() - ts = time_stepping or Steady() return SimulationParams( operating_condition=AerospaceCondition.from_mach(mach=0.5), models=[ @@ -33,202 +26,8 @@ def _make_sim_params(navier_stokes_solver=None, time_stepping=None): Wall(entities=Surface(name="fluid/wall")), Freestream(entities=Surface(name="fluid/farfield")), ], - time_stepping=ts, - ) - - -# ── LineSearch field constraints ───────────────────────────────────────────── - - -class TestLineSearchValidation: - def test_defaults(self): - ls = LineSearch() - assert ls.residual_growth_threshold == 0.85 - assert ls.max_residual_growth == 1.1 - assert ls.activation_step == 100 - - def test_residual_growth_threshold_bounds(self): - LineSearch(residual_growth_threshold=0.5) - LineSearch(residual_growth_threshold=1.0) - with pytest.raises(Exception): - LineSearch(residual_growth_threshold=0.49) - with pytest.raises(Exception): - LineSearch(residual_growth_threshold=1.1) - - def test_max_residual_growth_must_be_ge_one(self): - LineSearch(max_residual_growth=1.0) - LineSearch(max_residual_growth=5.0) - with pytest.raises(Exception): - LineSearch(max_residual_growth=0.99) - - def test_activation_step_must_be_positive(self): - LineSearch(activation_step=1) - with pytest.raises(Exception): - LineSearch(activation_step=0) - with pytest.raises(Exception): - LineSearch(activation_step=-1) - - -# ── KrylovLinearSolver defaults and validation ─────────────────────────────── - - -class TestKrylovLinearSolverDefaults: - def test_defaults(self): - kls = KrylovLinearSolver() - assert kls.max_iterations == 15 - assert kls.max_preconditioner_iterations == 25 - assert kls.relative_tolerance == 0.05 - - def test_user_overrides(self): - kls = KrylovLinearSolver( - max_iterations=10, - max_preconditioner_iterations=30, - relative_tolerance=0.01, - ) - assert kls.max_iterations == 10 - assert kls.max_preconditioner_iterations == 30 - assert kls.relative_tolerance == 0.01 - - def test_max_iterations_at_limit(self): - kls = KrylovLinearSolver(max_iterations=50) - assert kls.max_iterations == 50 - - def test_max_iterations_exceeds_limit(self): - with pytest.raises(Exception): - KrylovLinearSolver(max_iterations=51) - - def test_max_iterations_minimum(self): - kls = KrylovLinearSolver(max_iterations=1) - assert kls.max_iterations == 1 - - def test_inherits_linear_solver(self): - assert issubclass(KrylovLinearSolver, LinearSolver) - - def test_type_name(self): - assert KrylovLinearSolver().type_name == "KrylovLinearSolver" - assert LinearSolver().type_name == "LinearSolver" - - def test_type_name_in_dump(self): - dump = LinearSolver().model_dump() - assert dump["type_name"] == "LinearSolver" - dump = KrylovLinearSolver().model_dump() - assert dump["type_name"] == "KrylovLinearSolver" - - def test_type_name_frozen(self): - with pytest.raises(Exception): - LinearSolver(type_name="KrylovLinearSolver") - with pytest.raises(Exception): - KrylovLinearSolver(type_name="LinearSolver") - - -# ── NavierStokesSolver with different linear solvers ───────────────────────── - - -class TestNavierStokesLinearSolverTypes: - def test_default_is_linear_solver(self): - ns = NavierStokesSolver() - assert isinstance(ns.linear_solver, LinearSolver) - assert not isinstance(ns.linear_solver, KrylovLinearSolver) - - def test_accepts_krylov_linear_solver(self): - ns = NavierStokesSolver(linear_solver=KrylovLinearSolver()) - assert isinstance(ns.linear_solver, KrylovLinearSolver) - - def test_accepts_plain_linear_solver(self): - ns = NavierStokesSolver(linear_solver=LinearSolver(max_iterations=50)) - assert isinstance(ns.linear_solver, LinearSolver) - assert ns.linear_solver.max_iterations == 50 - - def test_plain_solver_no_cap_on_max_iterations(self): - ns = NavierStokesSolver(linear_solver=LinearSolver(max_iterations=100)) - assert ns.linear_solver.max_iterations == 100 - - def test_krylov_default_max_iterations_is_15(self): - ns = NavierStokesSolver(linear_solver=KrylovLinearSolver()) - assert ns.linear_solver.max_iterations == 15 - - def test_krylov_explicit_max_iterations_respected(self): - ns = NavierStokesSolver(linear_solver=KrylovLinearSolver(max_iterations=30)) - assert ns.linear_solver.max_iterations == 30 - - def test_plain_solver_default_max_iterations_is_30(self): - ns = NavierStokesSolver() - assert ns.linear_solver.max_iterations == 30 - - def test_dict_with_type_name_krylov(self): - ns = NavierStokesSolver( - linear_solver={"type_name": "KrylovLinearSolver", "max_iterations": 10} - ) - assert isinstance(ns.linear_solver, KrylovLinearSolver) - assert ns.linear_solver.max_iterations == 10 - - def test_dict_with_type_name_linear(self): - ns = NavierStokesSolver(linear_solver={"type_name": "LinearSolver", "max_iterations": 80}) - assert isinstance(ns.linear_solver, LinearSolver) - assert not isinstance(ns.linear_solver, KrylovLinearSolver) - assert ns.linear_solver.max_iterations == 80 - - def test_line_search_allowed_with_krylov(self): - ns = NavierStokesSolver(linear_solver=KrylovLinearSolver(), line_search=LineSearch()) - assert ns.line_search is not None - - def test_line_search_rejected_with_plain_linear_solver(self): - with pytest.raises(ValueError, match="line_search can only be set"): - NavierStokesSolver(linear_solver=LinearSolver(), line_search=LineSearch()) - - def test_line_search_default_is_none(self): - ns = NavierStokesSolver() - assert ns.line_search is None - - def test_line_search_none_with_krylov_is_ok(self): - ns = NavierStokesSolver(linear_solver=KrylovLinearSolver()) - assert ns.line_search is None - - -# ── Simulation-level Krylov restrictions ───────────────────────────────────── - - -class TestKrylovSimulationRestrictions: - def test_error_krylov_with_limit_velocity(self): - with pytest.raises(ValueError, match="limit_velocity"): - _make_sim_params( - navier_stokes_solver=NavierStokesSolver( - linear_solver=KrylovLinearSolver(), limit_velocity=True - ), - ) - - def test_error_krylov_with_limit_pressure_density(self): - with pytest.raises(ValueError, match="limit_pressure_density"): - _make_sim_params( - navier_stokes_solver=NavierStokesSolver( - linear_solver=KrylovLinearSolver(), limit_pressure_density=True - ), - ) - - def test_error_krylov_with_unsteady(self): - with pytest.raises(ValueError, match="Unsteady"): - _make_sim_params( - navier_stokes_solver=NavierStokesSolver(linear_solver=KrylovLinearSolver()), - time_stepping=Unsteady(steps=100, step_size=0.1 * u.s), - ) - - def test_krylov_with_steady_is_ok(self): - param = _make_sim_params( - navier_stokes_solver=NavierStokesSolver(linear_solver=KrylovLinearSolver()), time_stepping=Steady(), ) - assert param is not None - - def test_plain_solver_with_limiters_is_ok(self): - param = _make_sim_params( - navier_stokes_solver=NavierStokesSolver( - limit_velocity=True, limit_pressure_density=True - ), - ) - assert param is not None - - -# ── Translator: Krylov field handling ──────────────────────────────────────── class TestKrylovTranslation: @@ -252,9 +51,7 @@ def test_krylov_enabled_includes_fields(self): assert ns["lineSearch"]["activationStep"] == 100 def test_plain_solver_has_no_krylov_fields(self): - param = _make_sim_params( - navier_stokes_solver=NavierStokesSolver(), - ) + param = _make_sim_params(navier_stokes_solver=NavierStokesSolver()) translated = get_solver_json(param, mesh_unit=1 * u.m) ns = translated["navierStokesSolver"] ls = ns.get("linearSolver", {}) diff --git a/tests/simulation/test_reference_geometry_defaults.py b/tests/simulation/test_reference_geometry_defaults.py deleted file mode 100644 index 8eac7bfe3..000000000 --- a/tests/simulation/test_reference_geometry_defaults.py +++ /dev/null @@ -1,33 +0,0 @@ -import flow360 as fl -from flow360.component.simulation.framework.param_utils import AssetCache - - -def test_reference_geometry_fill_defaults_none(): - with fl.SI_unit_system: - params = fl.SimulationParams( - reference_geometry=None, - operating_condition=fl.AerospaceCondition(velocity_magnitude=100 * fl.u.m / fl.u.s), - private_attribute_asset_cache=AssetCache(project_length_unit=10 * fl.u.m), - ) - filled = fl.ReferenceGeometry.fill_defaults(None, params) - assert filled.area.to("m**2").value == 100 - assert all(c.to("m").value == 0.0 for c in filled.moment_center) - assert all(l.to("m").value == 10.0 for l in filled.moment_length) - - -def test_reference_geometry_fill_defaults_partial(): - with fl.SI_unit_system: - ref = fl.ReferenceGeometry( - area=None, - moment_center=(1, 2, 3) * fl.u.m, - moment_length=None, - ) - params = fl.SimulationParams( - reference_geometry=ref, - operating_condition=fl.AerospaceCondition(velocity_magnitude=100 * fl.u.m / fl.u.s), - private_attribute_asset_cache=AssetCache(project_length_unit=1 * fl.u.m), - ) - filled = fl.ReferenceGeometry.fill_defaults(ref, params) - assert filled.area.to("m**2").value == 1.0 - assert [c.to("m").value for c in filled.moment_center] == [1.0, 2.0, 3.0] - assert all(l.to("m").value == 1.0 for l in filled.moment_length) diff --git a/tests/simulation/test_transformation_deprecation.py b/tests/simulation/test_transformation_deprecation.py deleted file mode 100644 index 668796975..000000000 --- a/tests/simulation/test_transformation_deprecation.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -import flow360.component.simulation.units as u -from flow360.component.simulation.primitives import GeometryBodyGroup -from flow360.exceptions import Flow360DeprecationError - - -def test_transformation_deprecation_warning(capsys): - # Setup - bg = GeometryBodyGroup( - name="test_bg", private_attribute_tag_key="tag", private_attribute_sub_components=[] - ) - - # Test getter raises deprecation error - with pytest.raises(Flow360DeprecationError) as exc_info: - val = bg.transformation - - assert "GeometryBodyGroup.transformation is deprecated" in str(exc_info.value) - assert "Please use CoordinateSystem" in str(exc_info.value) - - # Test setter raises deprecation error - with pytest.raises(Flow360DeprecationError) as exc_info: - bg.transformation = "something" - - assert "GeometryBodyGroup.transformation is deprecated" in str(exc_info.value) - assert "Please use CoordinateSystem" in str(exc_info.value) diff --git a/tests/simulation/test_updater.py b/tests/simulation/test_updater.py index d50ec4695..5c1e0bf94 100644 --- a/tests/simulation/test_updater.py +++ b/tests/simulation/test_updater.py @@ -1,20 +1,11 @@ -import copy import json import os import re -from enum import Enum import pytest import toml -from flow360.component.simulation.framework.updater import ( - VERSION_MILESTONES, - _find_update_path, - _to_25_9_0, - _to_25_9_1, - _to_25_9_2, - updater, -) +from flow360.component.simulation.framework.updater import VERSION_MILESTONES from flow360.component.simulation.framework.updater_utils import Flow360Version from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.validation.validation_context import ALL @@ -30,14 +21,10 @@ def test_version_consistency(): project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")) pyproject_path = os.path.join(project_root, "pyproject.toml") - # Load the pyproject.toml file - with open(pyproject_path, "r") as f: - config = toml.load(f) + with open(pyproject_path, "r") as file: + config = toml.load(file) - # Extract the version value from the pyproject.toml under [tool.poetry] pyproject_version = config["tool"]["poetry"]["version"] - - # Assert the version in pyproject.toml matches the internal __version__ assert pyproject_version == "v" + __version__, ( f"Version mismatch: pyproject.toml version is {pyproject_version}, " f"but __version__ is {__version__}" @@ -63,2191 +50,14 @@ def test_version_greater_than_highest_updater_version(): ), "Highest version updater can handle is higher than Python client version. This is not allowed." -def test_milestone_ordering(): - for index in range(len(VERSION_MILESTONES) - 1): - assert VERSION_MILESTONES[index][0] < VERSION_MILESTONES[index + 1][0] - - -def test_updater_completeness(): - class DummyUpdaters(Enum): - to_1 = "to_1" - to_3 = "to_3" - to_5 = "to_5" - - version_milestones = [ - (Flow360Version("99.11.1"), DummyUpdaters.to_1), - (Flow360Version("99.11.3"), DummyUpdaters.to_3), - ] - - with pytest.raises( - ValueError, - match=r"Trying to update `SimulationParams` to a version lower than any known version.", - ): - # 1) from <99.11.1, to <99.11.1 => ValueError - _find_update_path( - version_from=Flow360Version("99.10.9"), - version_to=Flow360Version("99.10.10"), - version_milestones=version_milestones, - ) - - # 2) from <99.11.1, to ==99.11.1 => crosses milestone => [to_1] - res = _find_update_path( - version_from=Flow360Version("99.10.9"), - version_to=Flow360Version("99.11.1"), - version_milestones=version_milestones, - ) - assert res == [DummyUpdaters.to_1], "Case 2: crosses only 99.11.1 => [to_1]" - - # 3) from <99.11.1, to in-between (e.g., 99.11.2) => crosses 99.11.1 => [to_1] - res = _find_update_path( - version_from=Flow360Version("99.10.9"), - version_to=Flow360Version("99.11.2"), - version_milestones=version_milestones, - ) - assert res == [DummyUpdaters.to_1], "Case 3: crosses only 99.11.1 => [to_1]" - - # 4) from <99.11.1, to ==99.11.3 => crosses 99.11.1 and 99.11.3 => [to_1, to_3] - res = _find_update_path( - version_from=Flow360Version("99.10.9"), - version_to=Flow360Version("99.11.3"), - version_milestones=version_milestones, - ) - assert res == [ - DummyUpdaters.to_1, - DummyUpdaters.to_3, - ], "Case 4: crosses 99.11.1, 99.11.3 => [to_1, to_3]" - - # 5) from <99.11.1, to >99.11.3 => crosses 99.11.1 and 99.11.3 => [to_1, to_3] - res = _find_update_path( - version_from=Flow360Version("99.10.9"), - version_to=Flow360Version("99.12.0"), - version_milestones=version_milestones, - ) - assert res == [ - DummyUpdaters.to_1, - DummyUpdaters.to_3, - ], "Case 5: crosses 99.11.1, 99.11.3 => [to_1, to_3]" - - # 6) from ==99.11.1, to ==99.11.1 => no updates - res = _find_update_path( - version_from=Flow360Version("99.11.1"), - version_to=Flow360Version("99.11.1"), - version_milestones=version_milestones, - ) - assert res == [], "Case 6: same version => no updates" - - # 7) from ==99.11.1, to in-between (99.11.2) => no milestone crossed => [] - res = _find_update_path( - version_from=Flow360Version("99.11.1"), - version_to=Flow360Version("99.11.2"), - version_milestones=version_milestones, - ) - assert res == [], "Case 7: crosses nothing => no updates" - - # 8) from ==99.11.1, to ==99.11.3 => crosses milestone => [to_3] - res = _find_update_path( - version_from=Flow360Version("99.11.1"), - version_to=Flow360Version("99.11.3"), - version_milestones=version_milestones, - ) - assert res == [DummyUpdaters.to_3], "Case 8: crosses 99.11.3 => [to_3]" - - # 8.1) from ==99.11.1, to >99.11.3 => crosses milestone => [to_3] - res = _find_update_path( - version_from=Flow360Version("99.11.1"), - version_to=Flow360Version("99.11.4"), - version_milestones=version_milestones, - ) - assert res == [DummyUpdaters.to_3], "Case 8.1: crosses 99.11.3 => [to_3]" - - # 9) from in-between (99.11.2), to ==99.11.3 => crosses milestone => [to_3] - res = _find_update_path( - version_from=Flow360Version("99.11.2"), - version_to=Flow360Version("99.11.3"), - version_milestones=version_milestones, - ) - assert res == [DummyUpdaters.to_3], "Case 9: crosses 99.11.3 => [to_3]" - - # 10) from in-between (99.11.2), to >99.11.3 => crosses milestone => [to_3] - res = _find_update_path( - version_from=Flow360Version("99.11.2"), - version_to=Flow360Version("99.11.4"), - version_milestones=version_milestones, - ) - assert res == [DummyUpdaters.to_3], "Case 10: crosses 99.11.3 => [to_3]" - - # 11) from ==99.11.3, to >99.11.3 => crosses nothing => [] - res = _find_update_path( - version_from=Flow360Version("99.11.3"), - version_to=Flow360Version("99.11.4"), - version_milestones=version_milestones, - ) - assert res == [], "Case 11: crosses nothing => []" - - # 12) from >99.11.3, to >99.11.3 => forward compatability mode - res = _find_update_path( - version_from=Flow360Version("99.11.4"), - version_to=Flow360Version("99.11.5"), - version_milestones=version_milestones, - ) - assert res == [] - - # 13) to < from => forward compatability mode - - res = _find_update_path( - version_from=Flow360Version("99.11.3"), - version_to=Flow360Version("99.11.2"), - version_milestones=version_milestones, - ) - assert res == [] - - # 14) [more than 2 versions] to > max version - version_milestones = [ - (Flow360Version("99.11.1"), DummyUpdaters.to_1), - (Flow360Version("99.11.3"), DummyUpdaters.to_3), - (Flow360Version("99.11.5"), DummyUpdaters.to_5), - ] - - res = _find_update_path( - version_from=Flow360Version("99.11.0"), - version_to=Flow360Version("99.11.8"), - version_milestones=version_milestones, - ) - assert res == [ - DummyUpdaters.to_1, - DummyUpdaters.to_3, - DummyUpdaters.to_5, - ], "Case 14: crosses all 3" - - # 15) [more than 2 versions] to == second max version - res = _find_update_path( - version_from=Flow360Version("99.11.0"), - version_to=Flow360Version("99.11.3"), - version_milestones=version_milestones, - ) - assert res == [ - DummyUpdaters.to_1, - DummyUpdaters.to_3, - ], "Case 15: crosses first 2" - - -def test_updater_to_24_11_1(): - - with open("../data/simulation/simulation_pre_24_11_1.json", "r") as fp: - params_pre_24_11_1 = json.load(fp) - - params_24_11_1 = updater( - version_from="24.11.0", version_to="24.11.1", params_as_dict=params_pre_24_11_1 - ) - - assert params_24_11_1.get("meshing") is None - - for model in params_24_11_1["models"]: - if model["type"] == "Wall": - assert model["heat_spec"] == { - "type_name": "HeatFlux", - "value": {"value": 0, "units": "W / m**2"}, - } - - assert params_24_11_1["time_stepping"].get("order_of_accuracy") is None - - with open("../data/simulation/simulation_pre_24_11_1_symmetry.json", "r") as fp: - params_pre_24_11_1_symmetry = json.load(fp) - - params_pre_24_11_1_symmetry = updater( - version_from="24.11.0", version_to="24.11.1", params_as_dict=params_pre_24_11_1_symmetry - ) - - updated_surface_1 = params_pre_24_11_1_symmetry["private_attribute_asset_cache"][ - "project_entity_info" - ]["ghost_entities"][1] - updated_surface_2 = params_pre_24_11_1_symmetry["private_attribute_asset_cache"][ - "project_entity_info" - ]["ghost_entities"][2] - - assert updated_surface_1["name"] == "symmetric-1" - assert updated_surface_2["name"] == "symmetric-2" - - -def test_updater_to_24_11_7(): - - with open("../data/simulation/simulation_pre_24_11_7.json", "r") as fp: - params_pre_24_11_7 = json.load(fp) - - params_24_11_7 = updater( - version_from="24.11.6", version_to="24.11.7", params_as_dict=params_pre_24_11_7 - ) - - assert params_24_11_7["outputs"][0]["entities"]["stored_entities"][0]["private_attribute_id"] - assert params_24_11_7["outputs"][1]["entities"]["stored_entities"][0]["private_attribute_id"] - assert params_24_11_7["private_attribute_asset_cache"]["project_entity_info"]["draft_entities"][ - 0 - ]["private_attribute_id"] - assert params_24_11_7["private_attribute_asset_cache"]["project_entity_info"]["draft_entities"][ - 1 - ]["private_attribute_id"] - - assert ( - params_24_11_7["outputs"][1]["entities"]["stored_entities"][0]["private_attribute_id"] - == params_24_11_7["private_attribute_asset_cache"]["project_entity_info"]["draft_entities"][ - 0 - ]["private_attribute_id"] - ) - assert ( - params_24_11_7["outputs"][0]["entities"]["stored_entities"][0]["private_attribute_id"] - == params_24_11_7["private_attribute_asset_cache"]["project_entity_info"]["draft_entities"][ - 1 - ]["private_attribute_id"] - ) - - with open("../data/simulation/simulation_pre_24_11_1_symmetry.json", "r") as fp: - params_pre_24_11_1_symmetry = json.load(fp) - - params_pre_24_11_1_symmetry = updater( - version_from="24.11.6", version_to="24.11.7", params_as_dict=params_pre_24_11_1_symmetry - ) - - updated_surface_1 = params_pre_24_11_1_symmetry["private_attribute_asset_cache"][ - "project_entity_info" - ]["ghost_entities"][1] - updated_surface_2 = params_pre_24_11_1_symmetry["private_attribute_asset_cache"][ - "project_entity_info" - ]["ghost_entities"][2] - - assert updated_surface_1["name"] == "symmetric-1" - assert updated_surface_2["name"] == "symmetric-2" - - -def test_updater_to_25_2_0(): - with open("../data/simulation/simulation_pre_25_2_0.json", "r") as fp: - params = json.load(fp) - - params_pre_25_2_0 = copy.deepcopy(params) - params_new = updater( - version_from=f"24.11.8", - version_to=f"25.2.0", - params_as_dict=params, - ) - - for idx, model in enumerate(params_pre_25_2_0["models"]): - if model["type"] == "Fluid": - assert params_new["models"][idx]["turbulence_model_solver"]["hybrid_model"] == { - "shielding_function": "DDES", - "grid_size_for_LES": model["turbulence_model_solver"]["grid_size_for_LES"], - } - if model["type"] == "Inflow": - assert ( - params_new["models"][idx]["spec"]["velocity_direction"] - == params_pre_25_2_0["models"][idx]["velocity_direction"] - ) - if model["type"] == "Outflow": - assert params_new["models"][idx]["spec"]["ramp_steps"] == None - - for idx_output, output in enumerate(params_pre_25_2_0["outputs"]): - if output["output_type"] == "VolumeOutput": - for idx_field, field in enumerate(output["output_fields"]): - if field == "SpalartAllmaras_DDES": - assert ( - params_new["outputs"][idx_output]["output_fields"][idx_field] - == "SpalartAllmaras_hybridModel" - ) - if field == "kOmegaSST_DDES": - assert ( - params_new["outputs"][idx_output]["output_fields"][idx_field] - == "kOmegaSST_hybridModel" - ) - - if output["output_type"] == "AeroAcousticOutput": - for idx_observer, observer in enumerate(output["observers"]): - assert ( - params_new["outputs"][idx_output]["observers"][idx_observer]["position"] - == observer - ) - assert ( - params_new["outputs"][idx_output]["observers"][idx_observer]["group_name"] - == "0" - ) - assert ( - params_new["outputs"][idx_output]["observers"][idx_observer][ - "private_attribute_expand" - ] - is None - ) - - -def test_updater_to_24_11_10(): - with open("../data/simulation/simulation_24_11_9.json", "r") as fp: - params = json.load(fp) - - params_new = updater( - version_from=f"24.11.9", - version_to=f"24.11.10", - params_as_dict=params, - ) - updated_ghost_sphere = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "ghost_entities" - ][0] - assert updated_ghost_sphere["private_attribute_entity_type_name"] == "GhostSphere" - assert "type_name" not in updated_ghost_sphere - assert updated_ghost_sphere["center"] == [5.0007498695, 0, 0] - assert updated_ghost_sphere["max_radius"] == 504.16453591327473 - - -def test_updater_to_25_2_1(): - with open("../data/simulation/simulation_pre_25_2_1.json", "r") as fp: - params = json.load(fp) - - params_new = updater( - version_from=f"25.2.0", - version_to=f"25.2.1", - params_as_dict=params, - ) - updated_ghost_sphere = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "ghost_entities" - ][0] - assert updated_ghost_sphere["private_attribute_entity_type_name"] == "GhostSphere" - assert "type_name" not in updated_ghost_sphere - assert updated_ghost_sphere["center"] == [0, 0, 0] - assert updated_ghost_sphere["max_radius"] == 5.000000000000003 - - -def test_updater_to_25_2_3(): - with open("../data/simulation/simulation_pre_25_2_3_geo.json", "r") as fp: - params = json.load(fp) - - params_new = updater( - version_from=f"25.2.2", - version_to=f"25.2.3", - params_as_dict=params, - ) - updated_edge = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "grouped_edges" - ][0][0] - updated_face = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "grouped_faces" - ][0][0] - updated_ghost_entity = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "ghost_entities" - ][1] - updated_draft_entity = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "draft_entities" - ][0] - - updated_output_surface_entity = params_new["outputs"][1]["entities"]["stored_entities"][1] - updated_model_freestream_entity = params["models"][1]["entities"]["stored_entities"][0] - - assert updated_edge["private_attribute_id"] == updated_edge["name"] == "wingtrailingEdge" - assert updated_face["private_attribute_id"] == updated_face["name"] == "wingTrailing" - assert ( - updated_ghost_entity["private_attribute_id"] - == updated_ghost_entity["name"] - == "symmetric-1" - ) - assert ( - updated_output_surface_entity["private_attribute_id"] - == updated_output_surface_entity["name"] - == "wing" - ) - assert ( - updated_model_freestream_entity["private_attribute_id"] - == updated_model_freestream_entity["name"] - == "farfield" - ) - assert updated_draft_entity["private_attribute_id"] != updated_draft_entity["name"] - - with open("../data/simulation/simulation_pre_25_2_3_volume_zones.json", "r") as fp: - params = json.load(fp) - - params_new = updater( - version_from=f"25.2.1", - version_to=f"25.2.3", - params_as_dict=params, - ) - - updated_zone = params_new["private_attribute_asset_cache"]["project_entity_info"]["zones"][-1] - updated_boundary = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "boundaries" - ][0] - updated_model_rotation_entity = params["models"][1]["entities"]["stored_entities"][0] - - assert updated_zone["private_attribute_id"] == updated_zone["name"] == "stationaryField" - assert ( - updated_boundary["private_attribute_id"] - == updated_boundary["name"] - == "rotationField/blade" - ) - assert ( - updated_model_rotation_entity["private_attribute_id"] - == updated_model_rotation_entity["name"] - == "rotationField" - ) - - -def test_updater_to_25_4_1(): - with open("../data/simulation/simulation_pre_25_4_1.json", "r") as fp: - params = json.load(fp) - - geometry_relative_accuracy = params["meshing"]["defaults"]["geometry_relative_accuracy"] - - params_new = updater( - version_from=f"25.4.0", - version_to=f"25.4.1", - params_as_dict=params, - ) - - assert ( - params_new["meshing"]["defaults"]["geometry_accuracy"]["value"] - == geometry_relative_accuracy - ) - assert params_new["meshing"]["defaults"]["geometry_accuracy"]["units"] == "m" - - -def test_updater_to_25_6_2(): - with open("../data/simulation/simulation_pre_25_6_0.json", "r") as fp: - params = json.load(fp) - - def _update_to_25_6_2(pre_update_param_as_dict, version_from): - params_new = updater( - version_from=version_from, - version_to=f"25.6.2", - params_as_dict=pre_update_param_as_dict, - ) - return params_new - - def _ensure_validity(params): - params_new, _, _ = validate_model( - params_as_dict=copy.deepcopy(params), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - ) - assert params_new - - pre_update_param_as_dict = copy.deepcopy(params) - params_new = _update_to_25_6_2(pre_update_param_as_dict, version_from="25.5.1") - assert params_new["models"][2]["velocity_direction"] == [0, -1, 0] - assert "velocity_direction" not in params_new["models"][2]["spec"] - _ensure_validity(params_new) - assert params_new["outputs"] == pre_update_param_as_dict["outputs"] - - pre_update_param_as_dict = copy.deepcopy(params) - pre_update_param_as_dict["models"][2]["spec"]["velocity_direction"] = None - params_new = _update_to_25_6_2(pre_update_param_as_dict, version_from="25.5.1") - assert "velocity_direction" not in params_new["models"][2] - assert "velocity_direction" not in params_new["models"][2]["spec"] - _ensure_validity(params_new) - - pre_update_param_as_dict = copy.deepcopy(params) - pre_update_param_as_dict["models"][2]["spec"].pop("velocity_direction") - params_new = _update_to_25_6_2(pre_update_param_as_dict, version_from="25.5.1") - assert "velocity_direction" not in params_new["models"][2] - assert "velocity_direction" not in params_new["models"][2]["spec"] - _ensure_validity(params_new) - - pre_update_param_as_dict = copy.deepcopy(params) - pre_update_param_as_dict["models"][2]["spec"].pop("velocity_direction") - pre_update_param_as_dict["models"][2]["velocity_direction"] = [0, -1, 0] - params_new = _update_to_25_6_2(pre_update_param_as_dict, version_from="25.5.1") - assert params_new["models"][2]["velocity_direction"] == [0, -1, 0] - assert "velocity_direction" not in params_new["models"][2]["spec"] - _ensure_validity(params_new) - - pre_update_param_as_dict = copy.deepcopy(params) - params_new = _update_to_25_6_2(pre_update_param_as_dict, version_from="25.5.1") - reynolds = params["operating_condition"]["private_attribute_input_cache"]["reynolds"] - assert "reynolds" not in params_new["operating_condition"]["private_attribute_input_cache"] - assert ( - "reynolds_mesh_unit" in params_new["operating_condition"]["private_attribute_input_cache"] - ) - assert ( - params_new["operating_condition"]["private_attribute_input_cache"]["reynolds_mesh_unit"] - == reynolds - ) - _ensure_validity(params_new) - - # Ensure the updater can handle reynolds with None value correctly - pre_update_param_as_dict = copy.deepcopy(params) - pre_update_param_as_dict["operating_condition"]["private_attribute_input_cache"][ - "reynolds" - ] = None - params_new = _update_to_25_6_2(pre_update_param_as_dict, version_from="25.5.1") - assert ( - "reynolds_mesh_unit" - not in params_new["operating_condition"]["private_attribute_input_cache"].keys() - ) - - with open("../data/simulation/simulation_pre_25_6_0-2.json", "r") as fp: - params = json.load(fp) - params_new = _update_to_25_6_2(params, version_from="25.5.0") - _ensure_validity(params_new) - - assert len(params_new["outputs"]) == 5 - assert params_new["outputs"] == [ - { - "frequency": 16, - "frequency_offset": 0, - "name": "Volume output", - "output_fields": { - "items": [ - "vorticity", - "primitiveVars", - "residualNavierStokes", - "Mach", - "qcriterion", - "T", - "Cp", - "mut", - ] - }, - "output_format": "paraview", - "output_type": "VolumeOutput", - }, - { - "entities": { - "stored_entities": [ - { - "name": "middle/middle_bottom", - "private_attribute_color": None, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "middle/middle_bottom", - "private_attribute_id": "middle/middle_bottom", - "private_attribute_is_interface": False, - "private_attribute_potential_issues": [], - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [], - "private_attribute_tag_key": None, - }, - { - "name": "static/static_bottom", - "private_attribute_color": None, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "static/static_bottom", - "private_attribute_id": "static/static_bottom", - "private_attribute_is_interface": False, - "private_attribute_potential_issues": [], - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [], - "private_attribute_tag_key": None, - }, - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": {"items": ["Cp"]}, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "write_single_file": False, - }, - { - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": {"items": ["Cf", "Cp", "primitiveVars"]}, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "write_single_file": False, - "entities": { - "stored_entities": [ - { - "name": "middle/middle_top", - "private_attribute_color": None, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "middle/middle_top", - "private_attribute_id": "middle/middle_top", - "private_attribute_is_interface": False, - "private_attribute_potential_issues": [], - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [], - "private_attribute_tag_key": None, - } - ] - }, - }, - { - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": {"items": ["Cf", "Cp", "primitiveVars"]}, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "write_single_file": False, - "entities": { - "stored_entities": [ - { - "name": "static/static_top", - "private_attribute_color": None, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "static/static_top", - "private_attribute_id": "static/static_top", - "private_attribute_is_interface": False, - "private_attribute_potential_issues": [], - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [], - "private_attribute_tag_key": None, - } - ] - }, - }, - { - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": {"items": ["Cf", "Cp", "primitiveVars"]}, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "write_single_file": False, - "entities": { - "stored_entities": [ - { - "name": "inner/cylinder", - "private_attribute_color": None, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "inner/cylinder", - "private_attribute_id": "inner/cylinder", - "private_attribute_is_interface": False, - "private_attribute_potential_issues": [], - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [], - "private_attribute_tag_key": None, - } - ] - }, - }, - ] - - def test_deserialization_with_updater(): - # From 24.11.0 to 25.2.0 - with open("../data/simulation/simulation_24_11_0.json", "r") as fp: - params = json.load(fp) + simulation_path = os.path.join("..", "data", "simulation", "simulation_24_11_0.json") + with open(simulation_path, "r") as file: + params = json.load(file) + validate_model( params_as_dict=params, root_item_type="VolumeMesh", validated_by=ValidationCalledBy.LOCAL, validation_level=ALL, ) - - -def test_updater_to_25_6_4(): - with open("../data/simulation/simulation_pre_25_4_1.json", "r") as fp: - params_as_dict = json.load(fp) - - params_new = updater( - version_from="25.4.0b1", - version_to=f"25.6.4", - params_as_dict=params_as_dict, - ) - assert params_new["meshing"]["defaults"]["planar_face_tolerance"] == 1e-6 - params_new, _, _ = validate_model( - params_as_dict=params_new, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - ) - assert params_new - - -def test_updater_to_25_6_5(): - with open("../data/simulation/simulation_pre_25_4_1.json", "r") as fp: - params_as_dict = json.load(fp) - - params_new = updater( - version_from="25.4.0b1", - version_to=f"25.6.5", - params_as_dict=params_as_dict, - ) - assert params_new["meshing"]["defaults"]["planar_face_tolerance"] == 1e-6 - params_new, _, _ = validate_model( - params_as_dict=params_new, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - ) - assert params_new - - -def test_updater_to_25_7_2(): - with open("../data/simulation/simulation_pre_25_7_2.json", "r") as fp: - params_as_dict = json.load(fp) - - params_new = updater( - version_from="25.7.1", - version_to=f"25.7.2", - params_as_dict=params_as_dict, - ) - assert ( - params_new["private_attribute_asset_cache"]["variable_context"][0]["post_processing"] - == True - ) - assert ( - params_new["private_attribute_asset_cache"]["variable_context"][1]["post_processing"] - == True - ) - assert ( - params_new["private_attribute_asset_cache"]["variable_context"][2]["post_processing"] - == False - ) - assert ( - params_new["private_attribute_asset_cache"]["variable_context"][3]["post_processing"] - == False - ) - - -def test_updater_to_25_7_6_remove_entity_bucket_field(): - # Construct minimal params containing entity dicts with the legacy bucket field - params_as_dict = { - "outputs": [ - { - "output_type": "SurfaceOutput", - "output_fields": {"items": ["Cp"]}, - "entities": { - "stored_entities": [ - { - "name": "wing", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "wing", - "private_attribute_registry_bucket_name": "SurfaceEntityType", - }, - { - "name": "tail", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "tail", - "private_attribute_registry_bucket_name": "SurfaceEntityType", - }, - ] - }, - } - ], - "private_attribute_asset_cache": { - "project_entity_info": { - "ghost_entities": [ - { - "name": "symmetric-1", - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_registry_bucket_name": "GhostEntityType", - } - ], - "draft_entities": [ - { - "name": "point-array-1", - "private_attribute_entity_type_name": "PointArray", - "private_attribute_registry_bucket_name": "PointArrayEntityType", - } - ], - } - }, - # Non-entity dict should keep the field - "misc": {"private_attribute_registry_bucket_name": "keep_me"}, - } - - params_new = updater( - version_from="25.7.4", - version_to="25.7.6", - params_as_dict=params_as_dict, - ) - - # Verify removal from all entity dicts - stored_entities = params_new["outputs"][0]["entities"]["stored_entities"] - assert all("private_attribute_registry_bucket_name" not in entity for entity in stored_entities) - - ghost_entities = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "ghost_entities" - ] - assert all("private_attribute_registry_bucket_name" not in entity for entity in ghost_entities) - - draft_entities = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "draft_entities" - ] - assert all("private_attribute_registry_bucket_name" not in entity for entity in draft_entities) - - # Non-entity dict remains unchanged - assert params_new["misc"]["private_attribute_registry_bucket_name"] == "keep_me" - - -def test_updater_to_25_7_6_rename_rotation_cylinder(): - # Minimal input containing a RotationCylinder in meshing.volume_zones - params_as_dict = { - "meshing": { - "volume_zones": [ - { - "type": "RotationCylinder", - "name": "rot_zone", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Cylinder", - "name": "c1", - } - ] - }, - "spacing_axial": {"value": 1.0, "units": "m"}, - "spacing_radial": {"value": 1.0, "units": "m"}, - "spacing_circumferential": {"value": 1.0, "units": "m"}, - } - ] - }, - "unit_system": {"name": "SI"}, - "version": "25.7.2", - } - - params_new = updater(version_from="25.7.2", version_to="25.7.6", params_as_dict=params_as_dict) - - assert params_new["version"] == "25.7.6" - assert params_new["meshing"]["volume_zones"][0]["type"] == "RotationVolume" - - -def test_updater_to_25_7_7(): - """Test updater for version 25.7.7 which handles: - 1. Resetting frequency/frequency_offset to defaults for steady simulations - 2. Removing transition-specific output fields when transition model is None - """ - - # Construct test params with: - # - Steady simulation with non-default frequency settings - # - Transition model set to None with transition-specific output fields - params_as_dict = { - "version": "25.7.6", - "time_stepping": { - "type_name": "Steady", - "max_steps": 1000, - }, - "models": [ - { - "type": "Fluid", - "transition_model_solver": { - "type_name": "None", - }, - "turbulence_model_solver": { - "type_name": "SpalartAllmaras", - }, - } - ], - "outputs": [ - { - "output_type": "VolumeOutput", - "name": "Volume output", - "frequency": 10, - "frequency_offset": 5, - "output_format": "paraview", - "output_fields": { - "items": [ - "primitiveVars", - "residualNavierStokes", - "residualTransition", - "Mach", - ] - }, - }, - { - "output_type": "SurfaceOutput", - "name": "Surface output", - "frequency": 20, - "frequency_offset": 10, - "output_format": "paraview", - "entities": {"stored_entities": []}, - "output_fields": { - "items": [ - "Cp", - "Cf", - "solutionTransition", - ] - }, - }, - { - "output_type": "SliceOutput", - "name": "Slice output", - "frequency": 15, - "frequency_offset": 2, - "output_format": "paraview", - "entities": {"stored_entities": []}, - "output_fields": { - "items": [ - "vorticity", - "linearResidualTransition", - ] - }, - }, - { - "output_type": "ProbeOutput", - "name": "Probe output", - "entities": {"stored_entities": []}, - "output_fields": { - "items": [ - "primitiveVars", - "residualTransition", - ] - }, - }, - { - "output_type": "AeroAcousticOutput", - "name": "Aeroacoustic output", - "observers": [], - }, - ], - } - - params_new = updater( - version_from="25.7.6", - version_to="25.7.7", - params_as_dict=params_as_dict, - ) - - # Test 1: Verify frequency settings were reset to defaults for steady simulation - assert ( - params_new["outputs"][0]["frequency"] == -1 - ), "VolumeOutput frequency should be reset to -1" - assert ( - params_new["outputs"][0]["frequency_offset"] == 0 - ), "VolumeOutput frequency_offset should be reset to 0" - - assert ( - params_new["outputs"][1]["frequency"] == -1 - ), "SurfaceOutput frequency should be reset to -1" - assert ( - params_new["outputs"][1]["frequency_offset"] == 0 - ), "SurfaceOutput frequency_offset should be reset to 0" - - assert ( - params_new["outputs"][2]["frequency"] == -1 - ), "SliceOutput frequency should be reset to -1" - assert ( - params_new["outputs"][2]["frequency_offset"] == 0 - ), "SliceOutput frequency_offset should be reset to 0" - - # Test 2: Verify transition-specific output fields were removed - volume_output_fields = params_new["outputs"][0]["output_fields"]["items"] - assert "residualTransition" not in volume_output_fields, "residualTransition should be removed" - assert "primitiveVars" in volume_output_fields, "primitiveVars should remain" - assert "residualNavierStokes" in volume_output_fields, "residualNavierStokes should remain" - assert "Mach" in volume_output_fields, "Mach should remain" - - surface_output_fields = params_new["outputs"][1]["output_fields"]["items"] - assert "solutionTransition" not in surface_output_fields, "solutionTransition should be removed" - assert "Cp" in surface_output_fields, "Cp should remain" - assert "Cf" in surface_output_fields, "Cf should remain" - - slice_output_fields = params_new["outputs"][2]["output_fields"]["items"] - assert ( - "linearResidualTransition" not in slice_output_fields - ), "linearResidualTransition should be removed" - assert "vorticity" in slice_output_fields, "vorticity should remain" - - probe_output_fields = params_new["outputs"][3]["output_fields"]["items"] - assert ( - "residualTransition" not in probe_output_fields - ), "residualTransition should be removed from ProbeOutput" - assert "primitiveVars" in probe_output_fields, "primitiveVars should remain" - - # Test 3: Verify version was updated - assert params_new["version"] == "25.7.7" - - -def test_updater_to_25_7_7_unsteady_no_frequency_change(): - """Test that frequency settings are NOT changed for unsteady simulations""" - - params_as_dict = { - "version": "25.7.6", - "time_stepping": { - "type_name": "Unsteady", - "max_steps": 1000, - }, - "models": [ - { - "type": "Fluid", - "transition_model_solver": { - "type_name": "None", - }, - } - ], - "outputs": [ - { - "output_type": "VolumeOutput", - "name": "Volume output", - "frequency": 10, - "frequency_offset": 5, - "output_format": "paraview", - "output_fields": {"items": ["primitiveVars", "Mach"]}, - }, - ], - } - - params_new = updater( - version_from="25.7.6", - version_to="25.7.7", - params_as_dict=params_as_dict, - ) - - assert params_new["outputs"][0]["frequency"] == 10, "Unsteady frequency should not be changed" - assert ( - params_new["outputs"][0]["frequency_offset"] == 5 - ), "Unsteady frequency_offset should not be changed" - - -def test_updater_to_25_7_7_with_transition_model(): - """Test that transition output fields are NOT removed when transition model is enabled""" - - params_as_dict = { - "version": "25.7.6", - "time_stepping": { - "type_name": "Steady", - "max_steps": 1000, - }, - "models": [ - { - "type": "Fluid", - "transition_model_solver": { - "type_name": "AmplificationFactorTransport", - }, - } - ], - "outputs": [ - { - "output_type": "VolumeOutput", - "name": "Volume output", - "frequency": 10, - "frequency_offset": 5, - "output_format": "paraview", - "output_fields": { - "items": [ - "primitiveVars", - "residualTransition", - "solutionTransition", - ] - }, - }, - ], - } - - params_new = updater( - version_from="25.7.6", - version_to="25.7.7", - params_as_dict=params_as_dict, - ) - - # Frequency settings should still be reset for steady simulation - assert params_new["outputs"][0]["frequency"] == -1 - assert params_new["outputs"][0]["frequency_offset"] == 0 - - # Transition output fields should NOT be removed when transition model is enabled - volume_output_fields = params_new["outputs"][0]["output_fields"]["items"] - assert ( - "residualTransition" in volume_output_fields - ), "residualTransition should remain with AmplificationFactorTransport" - assert ( - "solutionTransition" in volume_output_fields - ), "solutionTransition should remain with AmplificationFactorTransport" - assert "primitiveVars" in volume_output_fields, "primitiveVars should remain" - - -def test_updater_to_25_8_0_add_meshing_type_name(): - params_as_dict = { - "meshing": { - "refinement_factor": 1, - "gap_treatment_strength": 0, - "defaults": { - "surface_edge_growth_rate": 1.2, - "surface_max_edge_length": {"value": 0.1, "units": "m"}, - "curvature_resolution_angle": {"value": 14, "units": "degree"}, - "boundary_layer_growth_rate": 1.2, - "boundary_layer_first_layer_thickness": {"value": 0.05, "units": "m"}, - "planar_face_tolerance": 0.01, - }, - "volume_zones": [ - { - "type": "AutomatedFarfield", - "name": "Farfield", - "method": "auto", - "_id": "0kd7rt12-7c82-0fma-js93-bf7jx7216532", - } - ], - "refinements": [], - } - } - - params_new = updater( - version_from="25.7.6", - version_to="25.8.0", - params_as_dict=params_as_dict, - ) - - assert "type_name" in params_new["meshing"] - assert params_new["meshing"]["type_name"] == "MeshingParams" - - -def test_updater_to_25_8_1_remove_transformation_key(): - params_as_dict = { - "version": "25.8.0", - "unit_system": {"name": "SI"}, - # Top-level key to be removed - "transformation": {"should_be_removed": True}, - # Nested key to be removed inside a likely real subtree - "private_attribute_asset_cache": { - "project_entity_info": { - "draft_entities": [ - { - "name": "cs-1", - "private_attribute_entity_type_name": "CoordinateSystem", - "transformation": {"matrix": [[1, 0, 0], [0, 1, 0], [0, 0, 1]]}, - # Ensure other unrelated keys are not affected - "other_key": {"keep_me": True}, - } - ] - } - }, - # List nesting - "outputs": [ - { - "output_type": "VolumeOutput", - "output_fields": {"items": ["Mach"]}, - "metadata": [{"transformation": 123}], - } - ], - } - - params_new = updater( - version_from="25.8.0", - version_to="25.8.1", - params_as_dict=params_as_dict, - ) - - assert params_new["version"] == "25.8.1" - assert "transformation" not in params_new - assert ( - "transformation" - not in params_new["private_attribute_asset_cache"]["project_entity_info"]["draft_entities"][ - 0 - ] - ) - assert ( - params_new["private_attribute_asset_cache"]["project_entity_info"]["draft_entities"][0][ - "other_key" - ]["keep_me"] - is True - ) - assert "transformation" not in params_new["outputs"][0]["metadata"][0] - - -def test_updater_to_25_8_3_rename_origin_to_reference_point(): - """Test updater for version 25.8.3 which renames 'origin' to 'reference_point' in CoordinateSystem""" - - params_as_dict = { - "version": "25.8.2", - "unit_system": {"name": "SI"}, - "private_attribute_asset_cache": { - "coordinate_system_status": { - "coordinate_systems": [ - { - "name": "frame1", - "type_name": "CoordinateSystem", - "origin": {"value": [1.0, 2.0, 3.0], "units": "m"}, - "axis_of_rotation": [0, 0, 1], - "angle_of_rotation": {"value": 90, "units": "degree"}, - "scale": [1, 1, 1], - "translation": {"value": [0, 0, 0], "units": "m"}, - "private_attribute_id": "cs-1", - }, - { - "name": "frame2", - "type_name": "CoordinateSystem", - "origin": {"value": [5.0, 6.0, 7.0], "units": "m"}, - "axis_of_rotation": [1, 0, 0], - "angle_of_rotation": {"value": 45, "units": "degree"}, - "scale": [2, 2, 2], - "translation": {"value": [1, 1, 1], "units": "m"}, - "private_attribute_id": "cs-2", - }, - ] - } - }, - } - - params_new = updater( - version_from="25.8.2", - version_to="25.8.3", - params_as_dict=params_as_dict, - ) - - assert params_new["version"] == "25.8.3" - - # Verify 'origin' is renamed to 'reference_point' in all coordinate systems - coord_systems = params_new["private_attribute_asset_cache"]["coordinate_system_status"][ - "coordinate_systems" - ] - - assert len(coord_systems) == 2 - - # Check first coordinate system - assert "origin" not in coord_systems[0], "'origin' should be removed from frame1" - assert "reference_point" in coord_systems[0], "'reference_point' should exist in frame1" - assert coord_systems[0]["reference_point"] == { - "value": [1.0, 2.0, 3.0], - "units": "m", - }, "reference_point should have the old origin value" - - # Check second coordinate system - assert "origin" not in coord_systems[1], "'origin' should be removed from frame2" - assert "reference_point" in coord_systems[1], "'reference_point' should exist in frame2" - assert coord_systems[1]["reference_point"] == { - "value": [5.0, 6.0, 7.0], - "units": "m", - }, "reference_point should have the old origin value" - - # Verify other fields remain unchanged - assert coord_systems[0]["name"] == "frame1" - assert coord_systems[0]["axis_of_rotation"] == [0, 0, 1] - assert coord_systems[1]["name"] == "frame2" - assert coord_systems[1]["scale"] == [2, 2, 2] - - -def test_updater_to_25_8_3_no_coordinate_systems(): - """Test updater handles cases where coordinate_system_status is missing or empty""" - - # Case 1: No asset_cache - params_as_dict_1 = { - "version": "25.8.2", - "unit_system": {"name": "SI"}, - } - - params_new_1 = updater( - version_from="25.8.2", - version_to="25.8.3", - params_as_dict=params_as_dict_1, - ) - - assert params_new_1["version"] == "25.8.3" - assert "private_attribute_asset_cache" not in params_new_1 - - # Case 2: No coordinate_system_status - params_as_dict_2 = { - "version": "25.8.2", - "unit_system": {"name": "SI"}, - "private_attribute_asset_cache": {}, - } - - params_new_2 = updater( - version_from="25.8.2", - version_to="25.8.3", - params_as_dict=params_as_dict_2, - ) - - assert params_new_2["version"] == "25.8.3" - assert params_new_2["private_attribute_asset_cache"] == {} - - # Case 3: Empty coordinate_systems list - params_as_dict_3 = { - "version": "25.8.2", - "unit_system": {"name": "SI"}, - "private_attribute_asset_cache": {"coordinate_system_status": {"coordinate_systems": []}}, - } - - params_new_3 = updater( - version_from="25.8.2", - version_to="25.8.3", - params_as_dict=params_as_dict_3, - ) - - assert params_new_3["version"] == "25.8.3" - assert ( - params_new_3["private_attribute_asset_cache"]["coordinate_system_status"][ - "coordinate_systems" - ] - == [] - ) - - -def test_updater_to_25_8_4_add_wind_tunnel_ghost_surfaces(): - """Ensures ghost_entities is populated with wind tunnel ghost surfaces""" - - # from translator/data/simulation_with_auto_area.json - params_as_dict = { - "version": "25.6.6", - "unit_system": {"name": "CGS"}, - "private_attribute_asset_cache": { - "project_entity_info": { - "ghost_entities": [ - { - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_entity_type_name": "GhostSphere", - "private_attribute_id": "farfield", - "name": "farfield", - "private_attribute_full_name": None, - "center": [11, 6, 5], - "max_radius": 1100.0000000000005, - }, - { - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-1", - "name": "symmetric-1", - "private_attribute_full_name": None, - "center": [11, 0, 5], - "max_radius": 22.00000000000001, - "normal_axis": [0, 1, 0], - }, - { - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-2", - "name": "symmetric-2", - "private_attribute_full_name": None, - "center": [11, 12, 5], - "max_radius": 22.00000000000001, - "normal_axis": [0, 1, 0], - }, - { - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric", - "name": "symmetric", - "private_attribute_full_name": None, - "center": [11, 0, 5], - "max_radius": 22.00000000000001, - "normal_axis": [0, 1, 0], - }, - ], - } - }, - } - - # Verify no WindTunnelGhostSurface currently exists - ghost_entities_before = params_as_dict["private_attribute_asset_cache"]["project_entity_info"][ - "ghost_entities" - ] - assert not any( - e.get("private_attribute_entity_type_name") == "WindTunnelGhostSurface" - for e in ghost_entities_before - ) - - # Update - params_new = updater( - version_from="25.6.6", - version_to="25.8.4", - params_as_dict=params_as_dict, - ) - assert params_new["version"] == "25.8.4" - - ghost_entities = params_new["private_attribute_asset_cache"]["project_entity_info"][ - "ghost_entities" - ] - - # Should still have original ghost entities (GhostSphere, GhostCircularPlane) - assert any(e["name"] == "farfield" for e in ghost_entities) - assert any(e["name"] == "symmetric" for e in ghost_entities) - - # Should now have all 10 wind tunnel ghost surfaces - wind_tunnel_names = [ - "windTunnelInlet", - "windTunnelOutlet", - "windTunnelCeiling", - "windTunnelFloor", - "windTunnelLeft", - "windTunnelRight", - "windTunnelFrictionPatch", - "windTunnelCentralBelt", - "windTunnelFrontWheelBelt", - "windTunnelRearWheelBelt", - ] - for name in wind_tunnel_names: - assert any( - e.get("private_attribute_entity_type_name") == "WindTunnelGhostSurface" - and e["name"] == name - for e in ghost_entities - ), f"Missing wind tunnel ghost surface: {name}" - - -def test_updater_to_25_8_4_fix_write_single_file_paraview(): - """Test updater for version 25.8.4 which fixes write_single_file incompatibility with Paraview format""" - - # Construct test params with write_single_file=True and various output formats - params_as_dict = { - "version": "25.8.3", - "unit_system": {"name": "SI"}, - "outputs": [ - { - "output_type": "SurfaceOutput", - "name": "Surface output paraview", - "write_single_file": True, - "output_format": "paraview", - "output_fields": {"items": ["Cp"]}, - "entities": {"stored_entities": []}, - }, - { - "output_type": "TimeAverageSurfaceOutput", - "name": "Time average surface output paraview", - "write_single_file": True, - "output_format": "paraview", - "output_fields": {"items": ["Cf"]}, - "entities": {"stored_entities": []}, - }, - { - "output_type": "SurfaceOutput", - "name": "Surface output both", - "write_single_file": True, - "output_format": "both", - "output_fields": {"items": ["Cp"]}, - "entities": {"stored_entities": []}, - }, - { - "output_type": "SurfaceOutput", - "name": "Surface output tecplot", - "write_single_file": True, - "output_format": "tecplot", - "output_fields": {"items": ["Cp"]}, - "entities": {"stored_entities": []}, - }, - { - "output_type": "VolumeOutput", - "name": "Volume output", - "output_format": "paraview", - "output_fields": {"items": ["Mach"]}, - }, - ], - } - - params_new = updater( - version_from="25.8.3", - version_to="25.8.4", - params_as_dict=params_as_dict, - ) - - assert params_new["version"] == "25.8.4" - - # Test 1: write_single_file should be reset to False for paraview format - assert ( - params_new["outputs"][0]["write_single_file"] is False - ), "SurfaceOutput with paraview should have write_single_file=False" - assert ( - params_new["outputs"][1]["write_single_file"] is False - ), "TimeAverageSurfaceOutput with paraview should have write_single_file=False" - - # Test 2: write_single_file should NOT be changed for "both" format (only warning, not error) - assert ( - params_new["outputs"][2]["write_single_file"] is True - ), "SurfaceOutput with 'both' format should keep write_single_file=True" - - # Test 3: write_single_file should NOT be changed for tecplot format (valid) - assert ( - params_new["outputs"][3]["write_single_file"] is True - ), "SurfaceOutput with tecplot should keep write_single_file=True" - - # Test 4: Non-SurfaceOutput types should not be affected - assert ( - "write_single_file" not in params_new["outputs"][4] - ), "VolumeOutput should not be affected" - - -def test_updater_to_25_8_4_no_outputs(): - """Test updater handles cases where outputs is missing or empty""" - - # Case 1: No outputs - params_as_dict_1 = { - "version": "25.8.3", - "unit_system": {"name": "SI"}, - } - - params_new_1 = updater( - version_from="25.8.3", - version_to="25.8.4", - params_as_dict=params_as_dict_1, - ) - - assert params_new_1["version"] == "25.8.4" - assert "outputs" not in params_new_1 - - # Case 2: Empty outputs list - params_as_dict_2 = { - "version": "25.8.3", - "unit_system": {"name": "SI"}, - "outputs": [], - } - - params_new_2 = updater( - version_from="25.8.3", - version_to="25.8.4", - params_as_dict=params_as_dict_2, - ) - - assert params_new_2["version"] == "25.8.4" - assert params_new_2["outputs"] == [] - - -def test_updater_to_25_8_4_write_single_file_false(): - """Test updater doesn't change outputs that already have write_single_file=False""" - - params_as_dict = { - "version": "25.8.3", - "unit_system": {"name": "SI"}, - "outputs": [ - { - "output_type": "SurfaceOutput", - "name": "Surface output", - "write_single_file": False, - "output_format": "paraview", - "output_fields": {"items": ["Cp"]}, - "entities": {"stored_entities": []}, - }, - ], - } - - params_new = updater( - version_from="25.8.3", - version_to="25.8.4", - params_as_dict=params_as_dict, - ) - - assert params_new["version"] == "25.8.4" - assert ( - params_new["outputs"][0]["write_single_file"] is False - ), "write_single_file should remain False" - - -def test_updater_to_25_9_0_remove_deprecated_remove_non_manifold_faces(): - """Test 25.9.0 updater step removes deprecated meshing.defaults.remove_non_manifold_faces key.""" - - params_as_dict = { - "version": "25.8.3", - "unit_system": {"name": "SI"}, - "meshing": { - "defaults": { - "surface_max_edge_length": {"value": 0.2, "units": "m"}, - "remove_non_manifold_faces": False, - } - }, - } - - params_new = _to_25_9_0(params_as_dict) - defaults = params_new["meshing"]["defaults"] - assert "remove_non_manifold_faces" not in defaults - - -def test_updater_to_25_9_0_convert_use_wall_function_bool(): - """Test 25.9.0 updater converts use_wall_function bool to WallFunction dict or removes it.""" - - params_as_dict = { - "version": "25.8.4", - "unit_system": {"name": "SI"}, - "models": [ - {"type": "Wall", "use_wall_function": True, "name": "Wall"}, - {"type": "Wall", "use_wall_function": False, "name": "NoSlipWall"}, - {"type": "Wall", "name": "DefaultWall"}, - {"type": "Freestream"}, - ], - } - - params_new = _to_25_9_0(params_as_dict) - models = params_new["models"] - - assert models[0]["use_wall_function"] == {"type_name": "BoundaryLayer"} - assert "use_wall_function" not in models[1] - assert "use_wall_function" not in models[2] - assert models[3].get("type") == "Freestream" - - -def test_updater_to_25_9_1_add_linear_solver_type_name(): - """Test 25.9.1 updater adds type_name to linear_solver inside navier_stokes_solver.""" - - params_as_dict = { - "version": "25.9.0", - "unit_system": {"name": "SI"}, - "models": [ - { - "type": "Fluid", - "navier_stokes_solver": { - "absolute_tolerance": 1e-10, - "linear_solver": { - "max_iterations": 30, - }, - }, - }, - { - "type": "Fluid", - "navier_stokes_solver": { - "absolute_tolerance": 1e-10, - "linear_solver": { - "type_name": "LinearSolver", - "max_iterations": 50, - }, - }, - }, - { - "type": "Fluid", - "navier_stokes_solver": { - "absolute_tolerance": 1e-10, - }, - }, - { - "type": "Wall", - "name": "wall-1", - }, - ], - } - - params_new = _to_25_9_1(params_as_dict) - models = params_new["models"] - - # linear_solver without type_name should get it added - assert models[0]["navier_stokes_solver"]["linear_solver"]["type_name"] == "LinearSolver" - assert models[0]["navier_stokes_solver"]["linear_solver"]["max_iterations"] == 30 - - # linear_solver that already has type_name should be unchanged - assert models[1]["navier_stokes_solver"]["linear_solver"]["type_name"] == "LinearSolver" - assert models[1]["navier_stokes_solver"]["linear_solver"]["max_iterations"] == 50 - - # navier_stokes_solver without linear_solver should be unaffected - assert "linear_solver" not in models[2]["navier_stokes_solver"] - - # Non-Fluid model without navier_stokes_solver should be unaffected - assert "navier_stokes_solver" not in models[3] - - -def test_updater_to_25_9_1_via_updater(): - """Test the full updater path from 25.9.0 to 25.9.1 adds linear_solver type_name.""" - - params_as_dict = { - "version": "25.9.0", - "unit_system": {"name": "SI"}, - "models": [ - { - "type": "Fluid", - "navier_stokes_solver": { - "linear_solver": { - "max_iterations": 25, - }, - }, - }, - ], - } - - params_new = updater( - version_from="25.9.0", - version_to="25.9.1", - params_as_dict=params_as_dict, - ) - - assert params_new["version"] == "25.9.1" - assert ( - params_new["models"][0]["navier_stokes_solver"]["linear_solver"]["type_name"] - == "LinearSolver" - ) - - -def test_updater_to_25_9_1_no_models(): - """Test 25.9.1 updater handles missing or empty models gracefully.""" - - # No models key at all - params_no_models = {"version": "25.9.0"} - params_new = _to_25_9_1(params_no_models) - assert "models" not in params_new - - # Empty models list - params_empty_models = {"version": "25.9.0", "models": []} - params_new = _to_25_9_1(params_empty_models) - assert params_new["models"] == [] - - -def test_updater_to_25_9_1_remove_local_cfl_for_steady(): - """Test 25.9.1 updater removes localCFL from output fields in steady simulations.""" - - params_as_dict = { - "version": "25.9.0", - "unit_system": {"name": "SI"}, - "time_stepping": {"type_name": "Steady"}, - "outputs": [ - { - "output_type": "VolumeOutput", - "output_fields": {"items": ["Cp", "localCFL", "Mach"]}, - }, - { - "output_type": "SliceOutput", - "output_fields": {"items": ["localCFL"]}, - }, - { - "output_type": "TimeAverageVolumeOutput", - "output_fields": {"items": ["localCFL", "pressure"]}, - }, - { - "output_type": "TimeAverageSliceOutput", - "output_fields": {"items": ["velocity", "localCFL"]}, - }, - ], - } - - params_new = _to_25_9_1(params_as_dict) - - assert params_new["outputs"][0]["output_fields"]["items"] == ["Cp", "Mach"] - assert params_new["outputs"][1]["output_fields"]["items"] == [] - assert params_new["outputs"][2]["output_fields"]["items"] == ["pressure"] - assert params_new["outputs"][3]["output_fields"]["items"] == ["velocity"] - - -def test_updater_to_25_9_1_keep_local_cfl_for_unsteady(): - """Test 25.9.1 updater preserves localCFL in output fields for unsteady simulations.""" - - params_as_dict = { - "version": "25.9.0", - "unit_system": {"name": "SI"}, - "time_stepping": {"type_name": "Unsteady", "steps": 100, "step_size": 0.001}, - "outputs": [ - { - "output_type": "VolumeOutput", - "output_fields": {"items": ["Cp", "localCFL", "Mach"]}, - }, - ], - } - - params_new = _to_25_9_1(params_as_dict) - - assert params_new["outputs"][0]["output_fields"]["items"] == ["Cp", "localCFL", "Mach"] - - -def test_updater_to_25_9_2_rotation_volume_sphere_to_rotation_sphere(): - """Test 25.9.2 updater migrates sphere-based RotationVolume to RotationSphere.""" - params_as_dict = { - "version": "25.9.1", - "unit_system": {"name": "SI"}, - "meshing": { - "volume_zones": [ - { - "type": "RotationVolume", - "name": "sphere_zone", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Sphere", - "name": "s1", - } - ] - }, - "spacing_axial": {"value": 0.1, "units": "m"}, - "spacing_radial": {"value": 0.2, "units": "m"}, - "spacing_circumferential": {"value": 0.3, "units": "m"}, - } - ] - }, - } - - params_new = _to_25_9_2(params_as_dict) - zone = params_new["meshing"]["volume_zones"][0] - - assert zone["type"] == "RotationSphere" - assert "spacing_axial" not in zone - assert "spacing_radial" not in zone - assert zone["spacing_circumferential"] == {"value": 0.3, "units": "m"} - - -def test_updater_to_25_9_2_keeps_non_sphere_rotation_volume(): - """Test 25.9.2 updater does not modify non-sphere RotationVolume zones.""" - params_as_dict = { - "version": "25.9.1", - "unit_system": {"name": "SI"}, - "meshing": { - "volume_zones": [ - { - "type": "RotationVolume", - "name": "cyl_zone", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Cylinder", - "name": "c1", - } - ] - }, - "spacing_axial": {"value": 0.1, "units": "m"}, - "spacing_radial": {"value": 0.2, "units": "m"}, - "spacing_circumferential": {"value": 0.3, "units": "m"}, - } - ] - }, - } - - params_new = _to_25_9_2(params_as_dict) - zone = params_new["meshing"]["volume_zones"][0] - - assert zone["type"] == "RotationVolume" - assert "spacing_axial" in zone - assert "spacing_radial" in zone - - -def test_updater_to_25_9_2_via_updater(): - """Test updater() path from 25.9.1 to 25.9.2 applies RotationSphere migration.""" - params_as_dict = { - "version": "25.9.1", - "unit_system": {"name": "SI"}, - "meshing": { - "volume_zones": [ - { - "type": "RotationVolume", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Sphere", - "name": "s2", - } - ] - }, - "spacing_axial": {"value": 1.0, "units": "m"}, - "spacing_radial": {"value": 2.0, "units": "m"}, - "spacing_circumferential": {"value": 3.0, "units": "m"}, - } - ] - }, - } - - params_new = updater( - version_from="25.9.1", - version_to="25.9.2", - params_as_dict=params_as_dict, - ) - - assert params_new["version"] == "25.9.2" - zone = params_new["meshing"]["volume_zones"][0] - assert zone["type"] == "RotationSphere" - assert "spacing_axial" not in zone - assert "spacing_radial" not in zone - - -def test_updater_to_25_9_2_modular_zones_rotation_volume_sphere_to_rotation_sphere(): - """Test 25.9.2 updater migrates sphere-based RotationVolume in meshing.zones.""" - params_as_dict = { - "version": "25.9.1", - "unit_system": {"name": "SI"}, - "meshing": { - "type_name": "ModularMeshingWorkflow", - "zones": [ - {"type": "AutomatedFarfield"}, - { - "type": "RotationVolume", - "name": "sphere_zone_modular", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Sphere", - "name": "s3", - } - ] - }, - "spacing_axial": {"value": 0.5, "units": "m"}, - "spacing_radial": {"value": 0.6, "units": "m"}, - "spacing_circumferential": {"value": 0.7, "units": "m"}, - }, - ], - }, - } - - params_new = _to_25_9_2(params_as_dict) - zone = params_new["meshing"]["zones"][1] - - assert zone["type"] == "RotationSphere" - assert "spacing_axial" not in zone - assert "spacing_radial" not in zone - assert zone["spacing_circumferential"] == {"value": 0.7, "units": "m"} - - -def test_updater_to_25_9_2_custom_volume_boundaries_to_bounding_entities(): - """Test 25.9.2 updater renames boundaries -> bounding_entities on CustomVolume.""" - params_as_dict = { - "version": "25.9.1", - "meshing": { - "volume_zones": [ - { - "type": "CustomZones", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "CustomVolume", - "name": "zone1", - "boundaries": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Surface", - "name": "face1", - } - ] - }, - } - ] - }, - }, - ], - }, - } - - params_new = _to_25_9_2(params_as_dict) - cv = params_new["meshing"]["volume_zones"][0]["entities"]["stored_entities"][0] - - assert "boundaries" not in cv - assert "bounding_entities" in cv - assert cv["bounding_entities"]["stored_entities"][0]["name"] == "face1" - - -def test_updater_to_25_9_2_custom_volume_in_farfield_enclosed_entities(): - """Test 25.9.2 updater handles CustomVolume inside farfield enclosed_entities.""" - params_as_dict = { - "version": "25.9.1", - "meshing": { - "volume_zones": [ - { - "type": "AutomatedFarfield", - "enclosed_entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "CustomVolume", - "name": "inner", - "boundaries": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Surface", - "name": "wall", - } - ] - }, - } - ] - }, - }, - ], - }, - } - - params_new = _to_25_9_2(params_as_dict) - cv = params_new["meshing"]["volume_zones"][0]["enclosed_entities"]["stored_entities"][0] - - assert "boundaries" not in cv - assert "bounding_entities" in cv - - -def test_updater_to_25_9_2_custom_volume_boundaries_modular_zones(): - """Test 25.9.2 updater handles CustomVolume under meshing.zones (modular workflow).""" - params_as_dict = { - "version": "25.9.1", - "meshing": { - "zones": [ - { - "type": "CustomZones", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "CustomVolume", - "name": "zone1", - "boundaries": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Surface", - "name": "f1", - } - ] - }, - } - ] - }, - }, - ], - }, - } - - params_new = _to_25_9_2(params_as_dict) - cv = params_new["meshing"]["zones"][0]["entities"]["stored_entities"][0] - - assert "boundaries" not in cv - assert "bounding_entities" in cv - - -def test_updater_to_25_9_2_custom_volume_no_op_when_already_bounding_entities(): - """Test 25.9.2 updater is a no-op when CustomVolume already uses bounding_entities.""" - params_as_dict = { - "version": "25.9.1", - "meshing": { - "volume_zones": [ - { - "type": "CustomZones", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "CustomVolume", - "name": "zone1", - "bounding_entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Surface", - "name": "f1", - } - ] - }, - } - ] - }, - }, - ], - }, - } - - params_new = _to_25_9_2(params_as_dict) - cv = params_new["meshing"]["volume_zones"][0]["entities"]["stored_entities"][0] - - assert "bounding_entities" in cv - assert "boundaries" not in cv - - -def test_updater_to_25_9_2_custom_volume_boundaries_via_updater(): - """Test updater() path from 25.9.1 to 25.9.2 applies boundaries rename.""" - params_as_dict = { - "version": "25.9.1", - "meshing": { - "volume_zones": [ - { - "type": "CustomZones", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "CustomVolume", - "name": "zone1", - "boundaries": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Surface", - "name": "f1", - } - ] - }, - } - ] - }, - }, - ], - }, - } - - params_new = updater("25.9.1", "25.9.2", params_as_dict) - cv = params_new["meshing"]["volume_zones"][0]["entities"]["stored_entities"][0] - - assert "boundaries" not in cv - assert "bounding_entities" in cv - - -def test_updater_to_25_9_3_rename_wall_function_type_name(): - """Test 25.9.3 updater renames type_name to wall_function_type in use_wall_function.""" - - params_as_dict = { - "version": "25.9.2", - "unit_system": {"name": "SI"}, - "models": [ - {"type": "Wall", "use_wall_function": {"type_name": "BoundaryLayer"}, "name": "w1"}, - {"type": "Wall", "use_wall_function": {"type_name": "InnerLayer"}, "name": "w2"}, - {"type": "Wall", "use_wall_function": None, "name": "w3"}, - {"type": "Wall", "name": "w4"}, - {"type": "Freestream"}, - ], - } - - params_new = updater("25.9.2", "25.9.3", params_as_dict) - models = params_new["models"] - - assert models[0]["use_wall_function"] == {"wall_function_type": "BoundaryLayer"} - assert models[1]["use_wall_function"] == {"wall_function_type": "InnerLayer"} - assert models[2]["use_wall_function"] is None - assert "use_wall_function" not in models[3] - assert models[4].get("type") == "Freestream" - - -def test_updater_to_25_8_8_total_pressure_expression(): - """String expressions are converted from ratio (P/P∞) to Flow360 nondim (P/(ρa²)).""" - params_as_dict = { - "version": "25.8.7", - "unit_system": {"name": "SI"}, - "operating_condition": {"type_name": "AerospaceCondition"}, - "models": [ - { - "type": "Inflow", - "spec": { - "type_name": "TotalPressure", - "value": "1.0 + 0.5 * sin(y)", - }, - }, - ], - } - - params_new = updater( - version_from="25.8.7", - version_to="25.8.8", - params_as_dict=params_as_dict, - ) - - assert params_new["version"] == "25.8.8" - assert params_new["models"][0]["spec"]["value"] == "(1.0 + 0.5 * sin(y)) / 1.4" - - -def test_updater_to_25_8_8_total_pressure_numeric_unchanged(): - """Numeric (dimensioned) TotalPressure values should not be touched by the updater.""" - params_as_dict = { - "version": "25.8.7", - "unit_system": {"name": "SI"}, - "operating_condition": {"type_name": "AerospaceCondition"}, - "models": [ - { - "type": "Inflow", - "spec": { - "type_name": "TotalPressure", - "value": {"value": 101325.0, "units": "Pa"}, - }, - }, - ], - } - - params_new = updater( - version_from="25.8.7", - version_to="25.8.8", - params_as_dict=params_as_dict, - ) - - assert params_new["models"][0]["spec"]["value"] == {"value": 101325.0, "units": "Pa"} - - -def test_updater_to_25_8_8_liquid_skipped(): - """LiquidOperatingCondition should skip the conversion (ratio=1.0).""" - params_as_dict = { - "version": "25.8.7", - "unit_system": {"name": "SI"}, - "operating_condition": {"type_name": "LiquidOperatingCondition"}, - "models": [ - { - "type": "Inflow", - "spec": { - "type_name": "TotalPressure", - "value": "1.0 + 0.5 * sin(y)", - }, - }, - ], - } - - params_new = updater( - version_from="25.8.7", - version_to="25.8.8", - params_as_dict=params_as_dict, - ) - - assert params_new["models"][0]["spec"]["value"] == "1.0 + 0.5 * sin(y)" - - -def test_updater_total_pressure_no_double_conversion(): - """Upgrading from 25.8.7 to 25.10.0 should only convert once (via 25.8.8), not again at 25.10.0.""" - params_as_dict = { - "version": "25.8.7", - "unit_system": {"name": "SI"}, - "operating_condition": {"type_name": "AerospaceCondition"}, - "models": [ - { - "type": "Inflow", - "spec": { - "type_name": "TotalPressure", - "value": "1.0 + 0.5 * sin(y)", - }, - }, - ], - } - - params_new = updater( - version_from="25.8.7", - version_to="25.10.0", - params_as_dict=params_as_dict, - ) - - assert params_new["models"][0]["spec"]["value"] == "(1.0 + 0.5 * sin(y)) / 1.4" diff --git a/tests/simulation/test_validation_context.py b/tests/simulation/test_validation_context.py deleted file mode 100644 index e8bdda981..000000000 --- a/tests/simulation/test_validation_context.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -Unit tests for validation_context.py contextual_field_validator function. - -This test suite validates the behavior of contextual_field_validator, -particularly the required_context parameter validation. -""" - -import pytest -from pydantic import ValidationError, field_validator - -from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.unit_system import SI_unit_system -from flow360.component.simulation.validation.validation_context import ( - ParamsValidationInfo, - ValidationContext, - add_validation_warning, - contextual_field_validator, -) - - -def test_contextual_field_validator_invalid_required_context_raises_error(): - """Test that invalid required_context attribute names raise ValueError. - - When a typo or non-existent attribute name is passed to required_context, - the validator should raise a ValueError to catch the mistake early. - """ - - class ModelWithInvalidRequiredContext(Flow360BaseModel): - value: str = "test" - - @contextual_field_validator("value", mode="after", required_context=["invalid_attribute"]) - @classmethod - def validate_value(cls, v, param_info: ParamsValidationInfo): - return v - - mock_context = ValidationContext( - levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) - ) - - with SI_unit_system, mock_context: - with pytest.raises(ValidationError) as exc_info: - ModelWithInvalidRequiredContext(value="test") - - # Check that the error message contains the expected text - errors = exc_info.value.errors() - assert len(errors) == 1 - assert "Invalid validation context attribute: invalid_attribute" in errors[0]["msg"] - - -def test_contextual_field_validator_valid_required_context_works(): - """Test that valid required_context attribute names work correctly.""" - - class ModelWithValidRequiredContext(Flow360BaseModel): - value: str = "test" - - @contextual_field_validator("value", mode="after", required_context=["output_dict"]) - @classmethod - def validate_value(cls, v, param_info: ParamsValidationInfo): - # This should only run when output_dict is not None - return v.upper() - - mock_context = ValidationContext( - levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) - ) - # Set output_dict to a non-None value - mock_context.info.output_dict = {} - - with SI_unit_system, mock_context: - model = ModelWithValidRequiredContext(value="test") - - # Validator should have run and converted to uppercase - assert model.value == "TEST" - - -def test_contextual_field_validator_skips_when_required_context_is_none(): - """Test that validator skips when required_context attribute is None.""" - - class ModelWithRequiredContext(Flow360BaseModel): - value: str = "test" - - @contextual_field_validator("value", mode="after", required_context=["output_dict"]) - @classmethod - def validate_value(cls, v, param_info: ParamsValidationInfo): - # This should NOT run when output_dict is None - return v.upper() - - mock_context = ValidationContext( - levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) - ) - # output_dict is None by default - - with SI_unit_system, mock_context: - model = ModelWithRequiredContext(value="test") - - # Validator should have been skipped, value unchanged - assert model.value == "test" - - -def test_contextual_field_validator_multiple_required_context_all_must_exist(): - """Test that all required_context attributes must be valid.""" - - class ModelWithMultipleRequiredContext(Flow360BaseModel): - value: str = "test" - - @contextual_field_validator( - "value", mode="after", required_context=["output_dict", "nonexistent_attr"] - ) - @classmethod - def validate_value(cls, v, param_info: ParamsValidationInfo): - return v - - mock_context = ValidationContext( - levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) - ) - mock_context.info.output_dict = {} - - with SI_unit_system, mock_context: - with pytest.raises(ValidationError) as exc_info: - ModelWithMultipleRequiredContext(value="test") - - # Should fail on the nonexistent attribute - errors = exc_info.value.errors() - assert len(errors) == 1 - assert "Invalid validation context attribute: nonexistent_attr" in errors[0]["msg"] - - -class TestParamsValidationInfo: - """Tests for ParamsValidationInfo class.""" - - def test_entity_transformation_detected_none(self): - """Test entity_transformation_detected when no transformation or mirroring exists.""" - param_dict = { - "private_attribute_asset_cache": { - "coordinate_system_status": None, - "mirror_status": None, - } - } - info = ParamsValidationInfo(param_dict, referenced_expressions=[]) - assert info.entity_transformation_detected is False - - def test_entity_transformation_detected_empty(self): - """Test entity_transformation_detected when status objects are empty.""" - param_dict = { - "private_attribute_asset_cache": { - "coordinate_system_status": {"assignments": []}, - "mirror_status": {"mirrored_geometry_body_groups": [], "mirrored_surfaces": []}, - } - } - info = ParamsValidationInfo(param_dict, referenced_expressions=[]) - assert info.entity_transformation_detected is False - - def test_entity_transformation_detected_coordinate_system(self): - """Test detection of coordinate system assignments.""" - param_dict = { - "private_attribute_asset_cache": { - "coordinate_system_status": {"assignments": [{"some": "assignment"}]}, - "mirror_status": None, - } - } - info = ParamsValidationInfo(param_dict, referenced_expressions=[]) - assert info.entity_transformation_detected is True - - def test_entity_transformation_detected_mirror_groups(self): - """Test detection of mirrored geometry groups.""" - param_dict = { - "private_attribute_asset_cache": { - "coordinate_system_status": None, - "mirror_status": { - "mirrored_geometry_body_groups": [{"some": "group"}], - "mirrored_surfaces": [], - }, - } - } - info = ParamsValidationInfo(param_dict, referenced_expressions=[]) - assert info.entity_transformation_detected is True - - def test_entity_transformation_detected_mirror_surfaces(self): - """Test detection of mirrored surfaces.""" - param_dict = { - "private_attribute_asset_cache": { - "coordinate_system_status": None, - "mirror_status": { - "mirrored_geometry_body_groups": [], - "mirrored_surfaces": [{"some": "surface"}], - }, - } - } - info = ParamsValidationInfo(param_dict, referenced_expressions=[]) - assert info.entity_transformation_detected is True - - def test_entity_transformation_detected_both(self): - """Test detection when both transformations are present.""" - param_dict = { - "private_attribute_asset_cache": { - "coordinate_system_status": {"assignments": [{"some": "assignment"}]}, - "mirror_status": {"mirrored_surfaces": [{"some": "surface"}]}, - } - } - info = ParamsValidationInfo(param_dict, referenced_expressions=[]) - assert info.entity_transformation_detected is True - - -def test_add_validation_warning_collects_messages_without_errors(): - """Ensures validation warnings are recorded when no validation errors occur.""" - - class WarningModel(Flow360BaseModel): - value: int - - @field_validator("value") - @classmethod - def _warn(cls, value): - add_validation_warning("value inspected") - return value - - mock_context = ValidationContext( - levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) - ) - - with SI_unit_system, mock_context: - WarningModel(value=1) - - assert mock_context.validation_warnings == [ - {"loc": (), "msg": "value inspected", "type": "value_error", "ctx": {}} - ] - - -def test_add_validation_warning_preserves_messages_on_error(): - """Ensures warnings raised prior to a validation error are retained.""" - - class WarningModel(Flow360BaseModel): - value: int - - @field_validator("value") - @classmethod - def _warn(cls, value): - add_validation_warning("value invalid") - raise ValueError("boom") - - mock_context = ValidationContext( - levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) - ) - - with SI_unit_system, mock_context: - with pytest.raises(ValidationError): - WarningModel(value=-1) - - assert mock_context.validation_warnings == [ - {"loc": (), "msg": "value invalid", "type": "value_error", "ctx": {}} - ] diff --git a/tests/simulation/test_validation_utils.py b/tests/simulation/test_validation_utils.py deleted file mode 100644 index fc842297d..000000000 --- a/tests/simulation/test_validation_utils.py +++ /dev/null @@ -1,1355 +0,0 @@ -""" -Unit tests for validation_utils.py customize_model_validator_error function - -This test suite validates the behavior of customize_model_validator_error with nested -Pydantic models, focusing on three-layer model structures and multiple error scenarios. - -Key Findings (documented through tests): -- Pydantic validates models bottom-up (innermost to outermost) -- Multiple errors from list items are collected in a single ValidationError -- When errors occur at multiple layers, innermost errors are captured first -- Parent model validators only run after all nested models validate successfully - -Test Organization: -================= - -1. Helper Functions: Validation assertion utilities -2. Shared Fixtures: Reusable model structures -3. Test Cases: Organized by complexity and validation scenarios -""" - -from typing import Optional - -import pytest -from pydantic import BaseModel, ValidationError, model_validator - -from flow360.component.simulation.validation.validation_utils import ( - customize_model_validator_error, -) - -# ============================================================================ -# Helper Functions -# ============================================================================ - - -def assert_validation_error( - error: ValidationError, - expected_loc: tuple, - expected_msg_contains: str, - expected_input=None, - expected_type: str = "value_error", -): - """Helper function to assert common validation error properties""" - errors = error.errors() - assert len(errors) == 1 - assert errors[0]["loc"] == expected_loc - assert expected_msg_contains in errors[0]["msg"] - assert errors[0]["type"] == expected_type - if expected_input is not None: - assert errors[0]["input"] == expected_input - - -def assert_multiple_validation_errors( - error: ValidationError, - expected_errors: list[dict], -): - """ - Helper function to assert multiple validation errors. - - Args: - error: The ValidationError exception - expected_errors: List of dicts with keys: 'loc', 'msg_contains', 'input' (optional) - """ - errors = error.errors() - assert len(errors) == len( - expected_errors - ), f"Expected {len(expected_errors)} errors but got {len(errors)}" - - for i, expected in enumerate(expected_errors): - assert ( - errors[i]["loc"] == expected["loc"] - ), f"Error {i}: Expected loc {expected['loc']}, got {errors[i]['loc']}" - assert ( - expected["msg_contains"] in errors[i]["msg"] - ), f"Error {i}: Expected '{expected['msg_contains']}' in '{errors[i]['msg']}'" - if "input" in expected: - assert ( - errors[i]["input"] == expected["input"] - ), f"Error {i}: Expected input {expected['input']}, got {errors[i]['input']}" - - -# ============================================================================ -# Shared Fixtures -# ============================================================================ - - -@pytest.fixture -def simple_model(): - """Single-layer model with basic validation""" - - class SimpleModel(BaseModel): - name: str - value: int - - @model_validator(mode="after") - def validate_name(self): - if self.name == "invalid": - raise customize_model_validator_error( - self, - relative_location=("name",), - message="name cannot be 'invalid'", - input_value=self.name, - ) - return self - - return SimpleModel - - -@pytest.fixture -def list_model(): - """Model with list validation for testing list indices in error locations""" - - class ListModel(BaseModel): - name: str - outputs: list[dict] - - @model_validator(mode="after") - def validate_outputs(self): - for i, output in enumerate(self.outputs): - if output.get("type") == "invalid": - raise customize_model_validator_error( - self, - relative_location=("outputs", i, "type"), - message=f"output type 'invalid' at index {i}", - input_value=output.get("type"), - ) - return self - - return ListModel - - -@pytest.fixture -def two_layer_models(): - """Two-layer parent/child models with validation at both levels""" - - class ChildModel(BaseModel): - nested_value: int - - @model_validator(mode="after") - def validate_nested(self): - if self.nested_value < 0: - raise customize_model_validator_error( - self, - relative_location=("nested_value",), - message="nested_value must be non-negative", - input_value=self.nested_value, - ) - return self - - class ParentModel(BaseModel): - items: list[ChildModel] - config_name: str - - @model_validator(mode="after") - def validate_config_name(self): - if self.config_name == "forbidden": - raise customize_model_validator_error( - self, - relative_location=("config_name",), - message="config_name cannot be 'forbidden'", - input_value=self.config_name, - ) - return self - - return {"child": ChildModel, "parent": ParentModel} - - -@pytest.fixture -def three_layer_models(): - """ - Generic three-layer model structure for comprehensive testing. - Used for: value validation, multiple errors, cascade validation, cross-field validation - """ - - class InnerConfig(BaseModel): - threshold: float - - @model_validator(mode="after") - def validate_threshold(self): - if self.threshold <= 0: - raise customize_model_validator_error( - self, - relative_location=("threshold",), - message="Inner: threshold must be positive", - input_value=self.threshold, - ) - return self - - class MiddleConfig(BaseModel): - config_id: int - inner_configs: list[InnerConfig] - - @model_validator(mode="after") - def validate_config_id(self): - if self.config_id < 0: - raise customize_model_validator_error( - self, - relative_location=("config_id",), - message="Middle: config_id must be non-negative", - input_value=self.config_id, - ) - return self - - class OuterConfig(BaseModel): - middle_configs: list[MiddleConfig] - - return {"inner": InnerConfig, "middle": MiddleConfig, "outer": OuterConfig} - - -@pytest.fixture -def three_layer_item_models(): - """Three-layer Item/Section/Configuration for range validation testing""" - - class Item(BaseModel): - name: str - value: float - - @model_validator(mode="after") - def validate_item(self): - if self.value < 0: - raise customize_model_validator_error( - self, - relative_location=("value",), - message=f"Item '{self.name}' has negative value", - input_value=self.value, - ) - if self.value > 1000: - raise customize_model_validator_error( - self, - relative_location=("value",), - message=f"Item '{self.name}' exceeds maximum", - input_value=self.value, - ) - return self - - class Section(BaseModel): - section_name: str - items: list[Item] - - @model_validator(mode="after") - def validate_section(self): - if not self.items: - raise customize_model_validator_error( - self, - relative_location=("items",), - message=f"Section '{self.section_name}' must have items", - input_value=self.items, - ) - return self - - @model_validator(mode="after") - def validate_section_name(self): - if self.section_name == "empty": - raise customize_model_validator_error( - self, - relative_location=("section_name",), - message="Section name cannot be empty", - input_value=self.section_name, - ) - return self - - class Configuration(BaseModel): - config_name: str - sections: list[Section] - - @model_validator(mode="after") - def validate_configuration(self): - if not self.sections: - raise customize_model_validator_error( - self, - relative_location=("sections",), - message="Configuration must have sections", - input_value=self.sections, - ) - return self - - return {"item": Item, "section": Section, "configuration": Configuration} - - -@pytest.fixture -def three_layer_parameter_models(): - """Three-layer Parameter/Group/Set for cross-field validation testing""" - - class Parameter(BaseModel): - param_name: str - min_value: float - max_value: float - - @model_validator(mode="after") - def validate_parameter(self): - if self.min_value >= self.max_value: - raise customize_model_validator_error( - self, - relative_location=("min_value",), - message=f"'{self.param_name}' min must be < max", - input_value=self.min_value, - ) - return self - - class ParameterGroup(BaseModel): - group_name: str - parameters: list[Parameter] - enabled: bool = True - - @model_validator(mode="after") - def validate_group(self): - if self.enabled and not self.parameters: - raise customize_model_validator_error( - self, - relative_location=("parameters",), - message=f"Enabled '{self.group_name}' needs parameters", - input_value=self.parameters, - ) - return self - - class ParameterSet(BaseModel): - set_name: str - groups: list[ParameterGroup] - - return {"parameter": Parameter, "group": ParameterGroup, "set": ParameterSet} - - -# ============================================================================ -# Test Cases - Basic Functionality -# ============================================================================ - - -def test_simple_validation_error(simple_model): - """Test basic validation error with simple field""" - with pytest.raises(ValidationError) as exc_info: - simple_model(name="invalid", value=10) - - assert_validation_error( - exc_info.value, - expected_loc=("name",), - expected_msg_contains="name cannot be 'invalid'", - expected_input="invalid", - ) - - -def test_error_properties(simple_model): - """Test that error has correct title and type""" - with pytest.raises(ValidationError) as exc_info: - simple_model(name="invalid", value=10) - - error = exc_info.value - assert error.title == "SimpleModel" - assert error.errors()[0]["type"] == "value_error" - assert "ctx" in error.errors()[0] - assert "error" in error.errors()[0]["ctx"] - - -# ============================================================================ -# Test Cases - Two-Layer Nested Models -# ============================================================================ - - -def test_nested_validation_error(two_layer_models): - """Test validation error in nested model""" - ParentModel = two_layer_models["parent"] - - with pytest.raises(ValidationError) as exc_info: - ParentModel( - config_name="test", - items=[ - {"nested_value": 1}, - {"nested_value": -5}, - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("items", 1, "nested_value"), - expected_msg_contains="nested_value must be non-negative", - expected_input=-5, - ) - - -def test_parent_validation_error(two_layer_models): - """Test validation error in parent model""" - ParentModel = two_layer_models["parent"] - - with pytest.raises(ValidationError) as exc_info: - ParentModel(config_name="forbidden", items=[{"nested_value": 1}]) - - assert_validation_error( - exc_info.value, - expected_loc=("config_name",), - expected_msg_contains="config_name cannot be 'forbidden'", - expected_input="forbidden", - ) - - -# ============================================================================ -# Test Cases - List Handling -# ============================================================================ - - -def test_list_index_in_location(list_model): - """Test that list indices are properly included in error location""" - with pytest.raises(ValidationError) as exc_info: - list_model( - name="test", - outputs=[ - {"type": "valid"}, - {"type": "valid"}, - {"type": "invalid"}, - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("outputs", 2, "type"), - expected_msg_contains="output type 'invalid' at index 2", - expected_input="invalid", - ) - - -# ============================================================================ -# Test Cases - Multiple Nesting Levels -# ============================================================================ - - -def test_multiple_nested_levels(): - """Test validation error location with multiple nesting levels""" - - class Level3(BaseModel): - value: int - - @model_validator(mode="after") - def validate_value(self): - if self.value == 999: - raise customize_model_validator_error( - self, - relative_location=("value",), - message="value cannot be 999", - input_value=self.value, - ) - return self - - class Level2(BaseModel): - items: list[Level3] - - class Level1(BaseModel): - nested: list[Level2] - - with pytest.raises(ValidationError) as exc_info: - Level1( - nested=[ - {"items": [{"value": 1}, {"value": 2}]}, - {"items": [{"value": 3}, {"value": 999}]}, - ] - ) - - assert_validation_error( - exc_info.value, - expected_loc=("nested", 1, "items", 1, "value"), - expected_msg_contains="value cannot be 999", - ) - - -# ============================================================================ -# Test Cases - Input Value Handling -# ============================================================================ - - -def test_without_input_value_parameter(): - """Test that omitting input_value uses model_dump() as fallback""" - - class ModelWithoutInputValue(BaseModel): - field1: str - field2: int - - @model_validator(mode="after") - def validate_field1(self): - if self.field1 == "error": - raise customize_model_validator_error( - self, - relative_location=("field1",), - message="field1 cannot be 'error'", - ) - return self - - with pytest.raises(ValidationError) as exc_info: - ModelWithoutInputValue(field1="error", field2=42) - - error = exc_info.value - errors = error.errors() - - assert len(errors) == 1 - assert errors[0]["loc"] == ("field1",) - assert isinstance(errors[0]["input"], dict) - assert errors[0]["input"]["field1"] == "error" - assert errors[0]["input"]["field2"] == 42 - - -# ============================================================================ -# Test Cases - Complex Scenarios -# ============================================================================ - - -def test_custom_error_message_with_special_chars(): - """Test that custom error messages with special characters are preserved""" - custom_message = "Special chars: @#$% and 'quotes' and \"double quotes\"" - - class CustomMessageModel(BaseModel): - field: str - - @model_validator(mode="after") - def validate_field(self): - if self.field == "trigger": - raise customize_model_validator_error( - self, - relative_location=("field",), - message=custom_message, - input_value=self.field, - ) - return self - - with pytest.raises(ValidationError) as exc_info: - CustomMessageModel(field="trigger") - - assert custom_message in exc_info.value.errors()[0]["msg"] - - -def test_complex_location_tuple(): - """Test validation with complex location tuple including strings and integers""" - - class ComplexLocationModel(BaseModel): - data: dict - - @model_validator(mode="after") - def validate_data(self): - raise customize_model_validator_error( - self, - relative_location=("data", "level1", 0, "level2", 5, "field"), - message="complex location test", - input_value="test_value", - ) - - with pytest.raises(ValidationError) as exc_info: - ComplexLocationModel(data={}) - - assert exc_info.value.errors()[0]["loc"] == ("data", "level1", 0, "level2", 5, "field") - - -# ============================================================================ -# Test Cases - Three Layer Models (Using three_layer_item_models fixture) -# ============================================================================ - - -def test_three_layer_error_at_innermost(three_layer_item_models): - """Test error at innermost layer of three-layer model""" - Configuration = three_layer_item_models["configuration"] - - with pytest.raises(ValidationError) as exc_info: - Configuration( - config_name="test", - sections=[ - { - "section_name": "section1", - "items": [ - {"name": "item1", "value": 10.0}, - {"name": "item2", "value": -5.0}, - ], - } - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("sections", 0, "items", 1, "value"), - expected_msg_contains="negative value", - expected_input=-5.0, - ) - - -def test_three_layer_error_at_middle(three_layer_item_models): - """Test error at middle layer of three-layer model""" - Configuration = three_layer_item_models["configuration"] - - with pytest.raises(ValidationError) as exc_info: - Configuration( - config_name="test", - sections=[{"section_name": "empty", "items": []}], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("sections", 0, "items"), - expected_msg_contains="must have items", - ) - - -def test_three_layer_error_at_outermost(three_layer_item_models): - """Test error at outermost layer of three-layer model""" - Configuration = three_layer_item_models["configuration"] - - with pytest.raises(ValidationError) as exc_info: - Configuration(config_name="test", sections=[]) - - assert_validation_error( - exc_info.value, - expected_loc=("sections",), - expected_msg_contains="must have sections", - ) - - -def test_three_layer_valid_model(three_layer_item_models): - """Test that valid three-layer model passes validation""" - Configuration = three_layer_item_models["configuration"] - - config = Configuration( - config_name="valid", - sections=[ - { - "section_name": "section1", - "items": [ - {"name": "item1", "value": 10.0}, - {"name": "item2", "value": 20.0}, - ], - }, - ], - ) - - assert config.config_name == "valid" - assert len(config.sections) == 1 - assert len(config.sections[0].items) == 2 - assert config.sections[0].items[0].value == 10.0 - - -def test_three_layer_cross_field_validation(three_layer_parameter_models): - """Test three-layer model with cross-field validation""" - ParameterSet = three_layer_parameter_models["set"] - - # Test cross-field validation error - with pytest.raises(ValidationError) as exc_info: - ParameterSet( - set_name="test", - groups=[ - { - "group_name": "group1", - "parameters": [{"param_name": "p1", "min_value": 100.0, "max_value": 50.0}], - } - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("groups", 0, "parameters", 0, "min_value"), - expected_msg_contains="min must be < max", - ) - - # Test valid model with disabled group - param_set = ParameterSet( - set_name="valid", - groups=[ - { - "group_name": "group1", - "parameters": [{"param_name": "p1", "min_value": 0.0, "max_value": 100.0}], - }, - { - "group_name": "group2", - "enabled": False, - "parameters": [], - }, - ], - ) - - assert param_set.groups[0].enabled is True - assert param_set.groups[1].enabled is False - - -# ============================================================================ -# Test Cases - Multiple Errors Across Layers -# ============================================================================ - - -def test_multiple_errors_in_innermost_layer(three_layer_item_models): - """Test that Pydantic collects ALL errors from multiple items in a list""" - Configuration = three_layer_item_models["configuration"] - - # Test: Multiple items fail in innermost layer - ALL errors collected! - with pytest.raises(ValidationError) as exc_info: - Configuration( - config_name="test", - sections=[ - { - "section_name": "section1", - "items": [ - {"name": "item1", "value": -5.0}, # Error 1: negative - {"name": "item2", "value": -10.0}, # Error 2: negative - {"name": "item3", "value": 1500.0}, # Error 3: exceeds maximum - ], - } - ], - ) - - # Pydantic collects all errors from list items - assert_multiple_validation_errors( - exc_info.value, - [ - { - "loc": ("sections", 0, "items", 0, "value"), - "msg_contains": "negative value", - "input": -5.0, - }, - { - "loc": ("sections", 0, "items", 1, "value"), - "msg_contains": "negative value", - "input": -10.0, - }, - { - "loc": ("sections", 0, "items", 2, "value"), - "msg_contains": "exceeds maximum", - "input": 1500.0, - }, - ], - ) - - -def test_multiple_errors_different_items_same_layer(three_layer_item_models): - """Test error capture when multiple different items fail validation""" - Configuration = three_layer_item_models["configuration"] - - # Multiple items at different positions fail - both errors collected - with pytest.raises(ValidationError) as exc_info: - Configuration( - config_name="test", - sections=[ - { - "section_name": "section1", - "items": [ - {"name": "item1", "value": 10.0}, # Valid - {"name": "item2", "value": -5.0}, # Invalid - error 1 - {"name": "item3", "value": 20.0}, # Valid - {"name": "item4", "value": -3.0}, # Invalid - error 2 - ], - } - ], - ) - - # Both invalid items' errors are captured - assert_multiple_validation_errors( - exc_info.value, - [ - { - "loc": ("sections", 0, "items", 1, "value"), - "msg_contains": "negative value", - "input": -5.0, - }, - { - "loc": ("sections", 0, "items", 3, "value"), - "msg_contains": "negative value", - "input": -3.0, - }, - ], - ) - - -def test_errors_in_middle_and_inner_layers(three_layer_item_models): - """Test validation order: inner models validate BEFORE parent model validators""" - Configuration = three_layer_item_models["configuration"] - - # Case 1: Inner layer error (middle layer validation passes) - with pytest.raises(ValidationError) as exc_info: - Configuration( - config_name="test", - sections=[ - { - "section_name": "section1", # Middle validation will pass - "items": [{"name": "item1", "value": -1.0}], # Inner validation will fail - } - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("sections", 0, "items", 0, "value"), - expected_msg_contains="negative value", - expected_input=-1.0, - ) - - # Case 2: Middle layer error (inner layer validation passes) - with pytest.raises(ValidationError) as exc_info: - Configuration( - config_name="test", - sections=[ - { - "section_name": "empty", # Middle validation will fail - "items": [{"name": "item1", "value": 1.0}], # Inner validation will pass - } - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("sections", 0, "section_name"), - expected_msg_contains="Section name cannot be empty", - expected_input="empty", - ) - - # Case 3: IMPORTANT - Inner validates BEFORE middle! - # Both layers would fail, but inner error is raised first (bottom-up validation) - with pytest.raises(ValidationError) as exc_info: - Configuration( - config_name="test", - sections=[ - { - "section_name": "empty", # Middle would fail (empty items) - "items": [{"name": "item1", "value": -1.0}], # Inner validates first and fails! - } - ], - ) - - # Inner error is raised before middle validator runs - assert_validation_error( - exc_info.value, - expected_loc=("sections", 0, "items", 0, "value"), - expected_msg_contains="negative value", - expected_input=-1.0, - ) - - # Case 4: Only middle layer fails (inner passes) - with pytest.raises(ValidationError) as exc_info: - Configuration( - config_name="test", - sections=[ - { - "section_name": "empty", # Middle validation will fail - "items": [], # Empty items - middle layer error - } - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("sections", 0, "items"), - expected_msg_contains="must have items", - ) - - -def test_multiple_middle_layer_errors_with_inner_errors(three_layer_models): - """ - Test validation with errors in both middle and inner layers across multiple items. - Demonstrates that Pydantic validates bottom-up and collects ALL errors. - """ - OuterConfig = three_layer_models["outer"] - - # IMPORTANT: Both inner and middle errors are captured! - # Item 0: Inner fails, Item 1: Middle fails - BOTH errors collected - with pytest.raises(ValidationError) as exc_info: - OuterConfig( - middle_configs=[ - { - "config_id": -1, # Middle would fail - "inner_configs": [{"threshold": -0.5}], # Inner validates FIRST and fails - }, - { - "config_id": -2, # Middle fails (inner passes so middle validator runs) - "inner_configs": [{"threshold": 1.0}], # Inner passes - }, - ] - ) - - # Both errors are collected: inner from item 0, middle from item 1 - assert_multiple_validation_errors( - exc_info.value, - [ - { - "loc": ("middle_configs", 0, "inner_configs", 0, "threshold"), - "msg_contains": "Inner: threshold must be positive", - "input": -0.5, - }, - { - "loc": ("middle_configs", 1, "config_id"), - "msg_contains": "Middle: config_id must be non-negative", - "input": -2, - }, - ], - ) - - # Case 2: Multiple inner errors across different middle items - with pytest.raises(ValidationError) as exc_info: - OuterConfig( - middle_configs=[ - { - "config_id": 1, # Middle passes - "inner_configs": [ - {"threshold": 1.0}, # Valid - {"threshold": -0.5}, # Inner fails - ], - }, - { - "config_id": 2, # Middle passes - "inner_configs": [ - {"threshold": -1.0}, # Inner fails - {"threshold": 1.0}, # Valid - ], - }, - ] - ) - - # Both inner errors are collected - assert_multiple_validation_errors( - exc_info.value, - [ - { - "loc": ("middle_configs", 0, "inner_configs", 1, "threshold"), - "msg_contains": "Inner: threshold must be positive", - "input": -0.5, - }, - { - "loc": ("middle_configs", 1, "inner_configs", 0, "threshold"), - "msg_contains": "Inner: threshold must be positive", - "input": -1.0, - }, - ], - ) - - -def test_three_layer_cascade_validation_order(): - """Test validation order through three layers to understand error capture behavior""" - - class Level3(BaseModel): - l3_value: str - - @model_validator(mode="after") - def validate_l3(self): - if self.l3_value == "error_l3": - raise customize_model_validator_error( - self, - relative_location=("l3_value",), - message="Level 3 validation failed", - input_value=self.l3_value, - ) - return self - - class Level2(BaseModel): - l2_value: str - level3_items: list[Level3] - - @model_validator(mode="after") - def validate_l2(self): - if self.l2_value == "error_l2": - raise customize_model_validator_error( - self, - relative_location=("l2_value",), - message="Level 2 validation failed", - input_value=self.l2_value, - ) - return self - - class Level1(BaseModel): - l1_value: str - level2_items: list[Level2] - - @model_validator(mode="after") - def validate_l1(self): - if self.l1_value == "error_l1": - raise customize_model_validator_error( - self, - relative_location=("l1_value",), - message="Level 1 validation failed", - input_value=self.l1_value, - ) - return self - - # Scenario 1: Only Level 3 (innermost) fails - with pytest.raises(ValidationError) as exc_info: - Level1( - l1_value="valid", - level2_items=[ - { - "l2_value": "valid", - "level3_items": [ - {"l3_value": "valid"}, - {"l3_value": "error_l3"}, # Innermost error - ], - } - ], - ) - - errors = exc_info.value.errors() - assert len(errors) == 1 - assert errors[0]["loc"] == ("level2_items", 0, "level3_items", 1, "l3_value") - assert "Level 3 validation failed" in errors[0]["msg"] - - # Scenario 2: CRITICAL - Both layers would fail but Level 3 validates FIRST (bottom-up) - with pytest.raises(ValidationError) as exc_info: - Level1( - l1_value="valid", - level2_items=[ - { - "l2_value": "error_l2", # Would fail, but L3 validates first! - "level3_items": [{"l3_value": "error_l3"}], # L3 validates FIRST (bottom-up) - } - ], - ) - - errors = exc_info.value.errors() - assert len(errors) == 1 - # Level 3 error is captured because it validates before Level 2! - assert errors[0]["loc"] == ("level2_items", 0, "level3_items", 0, "l3_value") - assert "Level 3 validation failed" in errors[0]["msg"] - - # Scenario 2b: Only Level 2 (middle) fails (Level 3 passes) - with pytest.raises(ValidationError) as exc_info: - Level1( - l1_value="valid", - level2_items=[ - { - "l2_value": "error_l2", # Middle fails - "level3_items": [ - {"l3_value": "valid"} # Level 3 passes, so Level 2 validator runs - ], - } - ], - ) - - errors = exc_info.value.errors() - assert len(errors) == 1 - assert errors[0]["loc"] == ("level2_items", 0, "l2_value") - assert "Level 2 validation failed" in errors[0]["msg"] - - # Scenario 3: All layers would fail - Level 3 (innermost) error captured (bottom-up!) - with pytest.raises(ValidationError) as exc_info: - Level1( - l1_value="error_l1", # Would fail - level2_items=[ - { - "l2_value": "error_l2", # Would fail - "level3_items": [{"l3_value": "error_l3"}], # L3 validates FIRST! - } - ], - ) - - errors = exc_info.value.errors() - assert len(errors) == 1 - # Even when all would fail, innermost layer validates first! - assert errors[0]["loc"] == ("level2_items", 0, "level3_items", 0, "l3_value") - assert "Level 3 validation failed" in errors[0]["msg"] - - # Scenario 3b: Only Level 1 (outer) fails (inner layers pass) - with pytest.raises(ValidationError) as exc_info: - Level1( - l1_value="error_l1", # Outer fails - level2_items=[ - { - "l2_value": "valid", # L2 passes - "level3_items": [{"l3_value": "valid"}], # L3 passes - } - ], - ) - - errors = exc_info.value.errors() - assert len(errors) == 1 - assert errors[0]["loc"] == ("l1_value",) - assert "Level 1 validation failed" in errors[0]["msg"] - - # Scenario 4: Multiple list items with errors - BOTH are collected - with pytest.raises(ValidationError) as exc_info: - Level1( - l1_value="valid", - level2_items=[ - { - "l2_value": "valid", - "level3_items": [{"l3_value": "error_l3"}], # Item 0: L3 fails - }, - { - "l2_value": "error_l2", # Item 1: L2 fails - "level3_items": [{"l3_value": "valid"}], - }, - ], - ) - - # Both errors are collected from different list items! - errors = exc_info.value.errors() - assert len(errors) == 2 - assert errors[0]["loc"] == ("level2_items", 0, "level3_items", 0, "l3_value") - assert "Level 3 validation failed" in errors[0]["msg"] - assert errors[1]["loc"] == ("level2_items", 1, "l2_value") - assert "Level 2 validation failed" in errors[1]["msg"] - - -def test_complex_three_layer_multiple_error_scenarios(): - """Test complex scenarios with multiple potential errors across all three layers""" - - class Metric(BaseModel): - name: str - min_val: float - max_val: float - - @model_validator(mode="after") - def validate_metric(self): - # Multiple validation rules in innermost layer - if self.min_val >= self.max_val: - raise customize_model_validator_error( - self, - relative_location=("min_val",), - message=f"Metric '{self.name}': min must be < max", - input_value=self.min_val, - ) - if self.min_val < 0: - raise customize_model_validator_error( - self, - relative_location=("min_val",), - message=f"Metric '{self.name}': min cannot be negative", - input_value=self.min_val, - ) - if self.max_val > 1000: - raise customize_model_validator_error( - self, - relative_location=("max_val",), - message=f"Metric '{self.name}': max exceeds limit", - input_value=self.max_val, - ) - return self - - class MetricGroup(BaseModel): - group_name: str - metrics: list[Metric] - is_active: bool = True - - @model_validator(mode="after") - def validate_group(self): - if self.is_active and len(self.metrics) == 0: - raise customize_model_validator_error( - self, - relative_location=("metrics",), - message=f"Group '{self.group_name}': active group needs metrics", - input_value=self.metrics, - ) - if len(self.metrics) > 10: - raise customize_model_validator_error( - self, - relative_location=("metrics",), - message=f"Group '{self.group_name}': too many metrics", - input_value=self.metrics, - ) - return self - - class MetricConfig(BaseModel): - config_version: int - groups: list[MetricGroup] - - @model_validator(mode="after") - def validate_config(self): - if self.config_version < 1: - raise customize_model_validator_error( - self, - relative_location=("config_version",), - message="Config version must be >= 1", - input_value=self.config_version, - ) - if len(self.groups) == 0: - raise customize_model_validator_error( - self, - relative_location=("groups",), - message="Config must have at least one group", - input_value=self.groups, - ) - return self - - # Test: Inner layer error (min >= max) - with pytest.raises(ValidationError) as exc_info: - MetricConfig( - config_version=1, - groups=[ - { - "group_name": "group1", - "metrics": [ - {"name": "m1", "min_val": 100.0, "max_val": 50.0}, # min >= max - ], - } - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("groups", 0, "metrics", 0, "min_val"), - expected_msg_contains="min must be < max", - ) - - # Test: Middle layer error (empty active group) - with pytest.raises(ValidationError) as exc_info: - MetricConfig( - config_version=1, - groups=[ - { - "group_name": "empty_group", - "is_active": True, - "metrics": [], # Active but empty - } - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("groups", 0, "metrics"), - expected_msg_contains="active group needs metrics", - ) - - # Test: Outer layer error (invalid version) - with pytest.raises(ValidationError) as exc_info: - MetricConfig( - config_version=0, # Invalid - groups=[ - { - "group_name": "group1", - "metrics": [{"name": "m1", "min_val": 0.0, "max_val": 100.0}], - } - ], - ) - - assert_validation_error( - exc_info.value, - expected_loc=("config_version",), - expected_msg_contains="must be >= 1", - ) - - # Test: Valid configuration passes all layers - config = MetricConfig( - config_version=1, - groups=[ - { - "group_name": "group1", - "metrics": [ - {"name": "m1", "min_val": 0.0, "max_val": 100.0}, - {"name": "m2", "min_val": 10.0, "max_val": 200.0}, - ], - }, - { - "group_name": "group2", - "is_active": False, - "metrics": [], # OK because not active - }, - ], - ) - - assert config.config_version == 1 - assert len(config.groups) == 2 - assert len(config.groups[0].metrics) == 2 - - -# ============================================================================ -# Test Cases - GAI Feature Usage Helper Functions -# ============================================================================ - - -def test_has_coordinate_system_usage(): - """Test the has_coordinate_system_usage helper function.""" - from flow360.component.simulation.draft_context.coordinate_system_manager import ( - CoordinateSystemAssignmentGroup, - CoordinateSystemEntityRef, - CoordinateSystemStatus, - ) - from flow360.component.simulation.entity_operation import CoordinateSystem - from flow360.component.simulation.framework.param_utils import AssetCache - from flow360.component.simulation.validation.validation_utils import ( - has_coordinate_system_usage, - ) - - # Test with None asset_cache - assert has_coordinate_system_usage(None) is False - - # Test with no coordinate_system_status - asset_cache = AssetCache(use_inhouse_mesher=False, use_geometry_AI=False) - assert has_coordinate_system_usage(asset_cache) is False - - # Test with empty assignments - cs = CoordinateSystem(name="test_cs") - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - assert has_coordinate_system_usage(asset_cache) is False - - # Test with non-empty assignments - cs_status_with_assignments = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[ - CoordinateSystemEntityRef(entity_type="GeometryBodyGroup", entity_id="test-id") - ], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status_with_assignments, - ) - assert has_coordinate_system_usage(asset_cache) is True - - -def test_has_mirroring_usage(): - """Test the has_mirroring_usage helper function.""" - import flow360.component.simulation.units as u - from flow360.component.simulation.draft_context.mirror import ( - MirrorPlane, - MirrorStatus, - ) - from flow360.component.simulation.framework.param_utils import AssetCache - from flow360.component.simulation.primitives import MirroredGeometryBodyGroup - from flow360.component.simulation.validation.validation_utils import ( - has_mirroring_usage, - ) - - # Test with None asset_cache - assert has_mirroring_usage(None) is False - - # Test with no mirror_status - asset_cache = AssetCache(use_inhouse_mesher=False, use_geometry_AI=False) - assert has_mirroring_usage(asset_cache) is False - - # Test with empty mirrored lists - plane = MirrorPlane(name="test", normal=(0, 1, 0), center=[0, 0, 0] * u.m) - mirror_status = MirrorStatus( - mirror_planes=[plane], - mirrored_geometry_body_groups=[], - mirrored_surfaces=[], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - mirror_status=mirror_status, - ) - assert has_mirroring_usage(asset_cache) is False - - # Test with non-empty mirrored_geometry_body_groups - mirrored_group = MirroredGeometryBodyGroup( - name="test_", - geometry_body_group_id="test-id", - mirror_plane_id=plane.private_attribute_id, - ) - mirror_status = MirrorStatus( - mirror_planes=[plane], - mirrored_geometry_body_groups=[mirrored_group], - mirrored_surfaces=[], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - mirror_status=mirror_status, - ) - assert has_mirroring_usage(asset_cache) is True diff --git a/tests/simulation/test_value_or_expression.py b/tests/simulation/test_value_or_expression.py index 8e0dd2886..62b667cb6 100644 --- a/tests/simulation/test_value_or_expression.py +++ b/tests/simulation/test_value_or_expression.py @@ -557,18 +557,6 @@ def test_get_referenced_expressions(): ) -def test_integer_validation(): - with SI_unit_system: - AerospaceCondition(velocity_magnitude=10) - - with pytest.raises( - ValueError, - match=re.escape("Value does not have units matching 'velocity' dimension"), - ): - with SI_unit_system: - AerospaceCondition(velocity_magnitude=Expression(expression="10")) - - def test_param_with_number_expression_in_and_out(): reset_context() vm = volume_mesh() diff --git a/tests/simulation/test_variable_context_skip.py b/tests/simulation/test_variable_context_skip.py deleted file mode 100644 index 432afbf0a..000000000 --- a/tests/simulation/test_variable_context_skip.py +++ /dev/null @@ -1,32 +0,0 @@ -from flow360_schema.framework.expression import ( - get_referenced_expressions_and_user_variables, -) - -from flow360.component.simulation.services import initialize_variable_space - - -def test_skip_variable_context_in_reference_collection(): - # Only variable_context contains an expression; it should be skipped entirely - param_as_dict = { - "private_attribute_asset_cache": { - "variable_context": [ - { - "name": "vc_only", - "value": {"type_name": "expression", "expression": "1 + 2"}, - "post_processing": False, - "description": None, - "metadata": None, - } - ] - } - } - - initialize_variable_space(param_as_dict, use_clear_context=True) - - expressions = get_referenced_expressions_and_user_variables(param_as_dict) - assert expressions == [] - - # If we add an expression outside of variable_context, it should be collected - param_as_dict["some_field"] = {"type_name": "expression", "expression": "3 + 4"} - expressions = get_referenced_expressions_and_user_variables(param_as_dict) - assert sorted(expressions) == ["3 + 4"] diff --git a/tests/simulation/validation/test_coordinate_system_constraints.py b/tests/simulation/validation/test_coordinate_system_constraints.py deleted file mode 100644 index dcc132ed9..000000000 --- a/tests/simulation/validation/test_coordinate_system_constraints.py +++ /dev/null @@ -1,463 +0,0 @@ -""" -Tests for _check_coordinate_system_constraints validation function. -""" - -import pytest - -import flow360.component.simulation.units as u -from flow360.component.simulation.draft_context.coordinate_system_manager import ( - CoordinateSystemAssignmentGroup, - CoordinateSystemEntityRef, - CoordinateSystemStatus, -) -from flow360.component.simulation.entity_operation import CoordinateSystem -from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.validation.validation_simulation_params import ( - _check_coordinate_system_constraints, -) - - -class MockParamsValidationInfo: - """Mock ParamsValidationInfo for testing.""" - - def __init__(self, use_geometry_AI: bool = False): - self.use_geometry_AI = use_geometry_AI - - -class MockParams: - """Mock SimulationParams for testing.""" - - def __init__(self, asset_cache: AssetCache): - self.private_attribute_asset_cache = asset_cache - - -# ============================================================================ -# Test Cases - GAI Requirement for GeometryBodyGroup -# ============================================================================ - - -def test_geometry_body_group_without_gai_raises(): - """GeometryBodyGroup + no GAI -> should raise.""" - cs = CoordinateSystem(name="test_cs") - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[ - CoordinateSystemEntityRef( - entity_type="GeometryBodyGroup", entity_id="body-group-id" - ) - ], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - with pytest.raises(ValueError, match="Coordinate system assignment to GeometryBodyGroup"): - _check_coordinate_system_constraints(params, param_info) - - -def test_geometry_body_group_with_gai_passes(): - """GeometryBodyGroup + GAI enabled -> should NOT raise.""" - cs = CoordinateSystem(name="test_cs") - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[ - CoordinateSystemEntityRef( - entity_type="GeometryBodyGroup", entity_id="body-group-id" - ) - ], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=True, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=True) - - # Should not raise - result = _check_coordinate_system_constraints(params, param_info) - assert result is params - - -def test_box_without_gai_passes(): - """Box assigned to coordinate system + no GAI -> should NOT raise (GAI not required).""" - cs = CoordinateSystem(name="test_cs") - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[CoordinateSystemEntityRef(entity_type="Box", entity_id="box-id")], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - # Should not raise - Box doesn't require GAI - result = _check_coordinate_system_constraints(params, param_info) - assert result is params - - -def test_cylinder_without_gai_passes(): - """Cylinder assigned to coordinate system + no GAI -> should NOT raise.""" - cs = CoordinateSystem(name="test_cs") - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[ - CoordinateSystemEntityRef(entity_type="Cylinder", entity_id="cylinder-id") - ], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - # Should not raise - Cylinder doesn't require GAI - result = _check_coordinate_system_constraints(params, param_info) - assert result is params - - -def test_surface_without_gai_passes(): - """Surface assigned to coordinate system + no GAI -> should NOT raise.""" - cs = CoordinateSystem(name="test_cs") - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[CoordinateSystemEntityRef(entity_type="Surface", entity_id="surface-id")], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - # Should not raise - Surface doesn't require GAI - result = _check_coordinate_system_constraints(params, param_info) - assert result is params - - -# ============================================================================ -# Test Cases - Uniform Scaling Validation -# ============================================================================ - - -def test_box_with_non_uniform_scale_raises(): - """Box assigned to non-uniform scale coordinate system -> should raise early.""" - # Create coordinate system with non-uniform scaling (2, 3, 4) - cs = CoordinateSystem(name="non_uniform_cs", scale=(2.0, 3.0, 4.0)) - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[CoordinateSystemEntityRef(entity_type="Box", entity_id="box-id")], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - with pytest.raises(ValueError, match="non-uniform scaling"): - _check_coordinate_system_constraints(params, param_info) - - -def test_cylinder_with_non_uniform_scale_raises(): - """Cylinder assigned to non-uniform scale coordinate system -> should raise early.""" - cs = CoordinateSystem(name="non_uniform_cs", scale=(1.0, 2.0, 1.0)) - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[ - CoordinateSystemEntityRef(entity_type="Cylinder", entity_id="cylinder-id") - ], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - with pytest.raises(ValueError, match="non-uniform scaling"): - _check_coordinate_system_constraints(params, param_info) - - -def test_axisymmetric_body_with_non_uniform_scale_raises(): - """AxisymmetricBody assigned to non-uniform scale coordinate system -> should raise early.""" - cs = CoordinateSystem(name="non_uniform_cs", scale=(1.5, 1.5, 2.0)) - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[ - CoordinateSystemEntityRef(entity_type="AxisymmetricBody", entity_id="axisym-id") - ], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - with pytest.raises(ValueError, match="non-uniform scaling"): - _check_coordinate_system_constraints(params, param_info) - - -def test_surface_with_non_uniform_scale_passes(): - """Surface assigned to non-uniform scale coordinate system -> should NOT raise.""" - cs = CoordinateSystem(name="non_uniform_cs", scale=(2.0, 3.0, 4.0)) - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[CoordinateSystemEntityRef(entity_type="Surface", entity_id="surface-id")], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - # Should not raise - Surface doesn't require uniform scaling - result = _check_coordinate_system_constraints(params, param_info) - assert result is params - - -def test_box_with_uniform_scale_passes(): - """Box assigned to uniform scale coordinate system -> should NOT raise.""" - cs = CoordinateSystem(name="uniform_cs", scale=(2.0, 2.0, 2.0)) - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[CoordinateSystemEntityRef(entity_type="Box", entity_id="box-id")], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - # Should not raise - uniform scaling is OK - result = _check_coordinate_system_constraints(params, param_info) - assert result is params - - -# ============================================================================ -# Test Cases - Hierarchical Coordinate Systems with Non-Uniform Scale -# ============================================================================ - - -def test_box_with_parent_non_uniform_scale_raises(): - """Box assigned to child coordinate system where parent has non-uniform scale -> should raise.""" - from flow360.component.simulation.draft_context.coordinate_system_manager import ( - CoordinateSystemParent, - ) - - # Parent with non-uniform scale - parent_cs = CoordinateSystem(name="parent_cs", scale=(1.0, 2.0, 1.0)) - # Child with uniform scale (but composed matrix will be non-uniform) - child_cs = CoordinateSystem(name="child_cs", scale=(1.0, 1.0, 1.0)) - - cs_status = CoordinateSystemStatus( - coordinate_systems=[parent_cs, child_cs], - parents=[ - CoordinateSystemParent( - coordinate_system_id=parent_cs.private_attribute_id, parent_id=None - ), - CoordinateSystemParent( - coordinate_system_id=child_cs.private_attribute_id, - parent_id=parent_cs.private_attribute_id, - ), - ], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=child_cs.private_attribute_id, - entities=[CoordinateSystemEntityRef(entity_type="Box", entity_id="box-id")], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - # Should raise because composed matrix has non-uniform scaling - with pytest.raises(ValueError, match="non-uniform scaling"): - _check_coordinate_system_constraints(params, param_info) - - -# ============================================================================ -# Test Cases - No Coordinate System Usage -# ============================================================================ - - -def test_no_coordinate_system_status_passes(): - """No coordinate system status -> should pass.""" - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=None, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - # Should not raise - result = _check_coordinate_system_constraints(params, param_info) - assert result is params - - -def test_empty_assignments_passes(): - """Empty assignments -> should pass.""" - cs = CoordinateSystem(name="test_cs") - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[], # Empty assignments - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - # Should not raise - result = _check_coordinate_system_constraints(params, param_info) - assert result is params - - -# ============================================================================ -# Test Cases - Error Message Quality -# ============================================================================ - - -def test_error_message_includes_coordinate_system_name(): - """Error message should include the coordinate system name.""" - cs = CoordinateSystem(name="my_special_cs", scale=(1.0, 2.0, 3.0)) - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[CoordinateSystemEntityRef(entity_type="Box", entity_id="box-id")], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - with pytest.raises(ValueError, match="my_special_cs"): - _check_coordinate_system_constraints(params, param_info) - - -def test_error_message_includes_entity_types(): - """Error message should include the entity types that require uniform scaling.""" - cs = CoordinateSystem(name="non_uniform_cs", scale=(1.0, 2.0, 3.0)) - cs_status = CoordinateSystemStatus( - coordinate_systems=[cs], - parents=[], - assignments=[ - CoordinateSystemAssignmentGroup( - coordinate_system_id=cs.private_attribute_id, - entities=[ - CoordinateSystemEntityRef(entity_type="Box", entity_id="box-id"), - CoordinateSystemEntityRef(entity_type="Cylinder", entity_id="cylinder-id"), - ], - ) - ], - ) - asset_cache = AssetCache( - use_inhouse_mesher=False, - use_geometry_AI=False, - coordinate_system_status=cs_status, - ) - params = MockParams(asset_cache) - param_info = MockParamsValidationInfo(use_geometry_AI=False) - - with pytest.raises(ValueError) as exc_info: - _check_coordinate_system_constraints(params, param_info) - - # Error should mention both Box and Cylinder - error_msg = str(exc_info.value) - assert "Box" in error_msg - assert "Cylinder" in error_msg From b0c6fa89bc6ec1b6afcf8be37e063e65ab55a94f Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:49:31 -0400 Subject: [PATCH 15/25] Avoid frozen sequence rewrites during translator transforms (#1996) --- .../component/simulation/translator/utils.py | 17 +++- poetry.lock | 6 +- pyproject.toml | 2 +- .../translator/test_translation_utils.py | 87 +++++++++++++++++++ 4 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 tests/simulation/translator/test_translation_utils.py diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index bea942076..a98e7706b 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -125,9 +125,20 @@ def _apply_transformations_to_model( setattr(model, field_name, new_entity_list) elif isinstance(field_value, (list, tuple)): - new_items = [_transform_sequence_item(item, manager) for item in field_value] - new_value = new_items if isinstance(field_value, list) else tuple(new_items) - setattr(model, field_name, new_value) + transformed_items = None + for index, item in enumerate(field_value): + transformed = _transform_sequence_item(item, manager) + if transformed is item: + continue + if transformed_items is None: + transformed_items = list(field_value) + transformed_items[index] = transformed + + if transformed_items is not None: + new_value = ( + transformed_items if isinstance(field_value, list) else tuple(transformed_items) + ) + setattr(model, field_name, new_value) elif isinstance(field_value, Flow360BaseModel): _apply_transformations_to_model(field_value, manager) diff --git a/poetry.lock b/poetry.lock index 769871375..c8e2a79fc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1468,14 +1468,14 @@ files = [ [[package]] name = "flow360-schema" -version = "0.1.25" +version = "0.1.26" description = "Pure Pydantic schemas for Flow360 - Single Source of Truth" optional = false python-versions = ">=3.10,<4.0" groups = ["main"] files = [ - {file = "flow360_schema-0.1.25-py3-none-any.whl", hash = "sha256:4c9897a4f1b53df16615a0a4a0e69960cfd94d479acb3fe00f17d6b9be6d668b"}, - {file = "flow360_schema-0.1.25.tar.gz", hash = "sha256:81c1518f09559748fec8c4cf6715c52f3fa81a4d7008021faeb662b39a88a925"}, + {file = "flow360_schema-0.1.26-py3-none-any.whl", hash = "sha256:393848512d87798f8a85614492feb9cb2e8b39b57e27a31e62243427865cd317"}, + {file = "flow360_schema-0.1.26.tar.gz", hash = "sha256:5edd47bdce8f6f329969a028af2c333cc0b88af250b0331bb54b8f1992519964"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index 8b3daa83a..a2cd89392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ priority = "explicit" python = ">=3.10,<3.14" pydantic = ">=2.8,<2.12" # -- Local dev (editable install, schema changes take effect immediately): -# flow360-schema = { path = "../flex/share/flow360-schema", develop = true } +# flow360-schema = { path = "../../../../flex/share/flow360-schema", develop = true } # -- CI / release (install from CodeArtifact, swap comments before pushing): flow360-schema = { version = "~0.1.24", source = "codeartifact" } pytest = "^7.1.2" diff --git a/tests/simulation/translator/test_translation_utils.py b/tests/simulation/translator/test_translation_utils.py new file mode 100644 index 000000000..8066314b6 --- /dev/null +++ b/tests/simulation/translator/test_translation_utils.py @@ -0,0 +1,87 @@ +import flow360.component.simulation.units as u +from flow360.component.simulation.draft_context.coordinate_system_manager import ( + CoordinateSystemAssignmentGroup, + CoordinateSystemEntityRef, + CoordinateSystemStatus, +) +from flow360.component.simulation.entity_operation import CoordinateSystem +from flow360.component.simulation.framework.param_utils import AssetCache +from flow360.component.simulation.models.volume_models import ( + BETDisk, + BETDiskChord, + BETDiskSectionalPolar, + BETDiskTwist, +) +from flow360.component.simulation.primitives import Cylinder +from flow360.component.simulation.simulation_params import SimulationParams +from flow360.component.simulation.translator.utils import ( + apply_coordinate_system_transformations, +) +from flow360.component.simulation.unit_system import SI_unit_system + + +def test_apply_coordinate_system_transformations_skips_frozen_non_entity_sequences(): + with SI_unit_system: + cylinder = Cylinder( + name="bet_disk", + center=(0, 0, 0) * u.m, + axis=(0, 0, 1), + height=1 * u.m, + outer_radius=1 * u.m, + ) + bet_disk = BETDisk( + entities=cylinder, + rotation_direction_rule="leftHand", + number_of_blades=3, + omega=100 * u.rpm, + chord_ref=1 * u.m, + n_loading_nodes=20, + mach_numbers=[0], + reynolds_numbers=[1000000], + twists=[BETDiskTwist(radius=0 * u.m, twist=0 * u.deg)], + chords=[BETDiskChord(radius=0 * u.m, chord=1 * u.m)], + alphas=[-2, 0, 2] * u.deg, + sectional_radiuses=[0.25, 0.5] * u.m, + sectional_polars=[ + BETDiskSectionalPolar( + lift_coeffs=[[[0.1, 0.2, 0.3]]], + drag_coeffs=[[[0.01, 0.02, 0.03]]], + ), + BETDiskSectionalPolar( + lift_coeffs=[[[0.15, 0.25, 0.35]]], + drag_coeffs=[[[0.015, 0.025, 0.035]]], + ), + ], + ) + coordinate_system = CoordinateSystem(name="cs", translation=(1, 2, 3) * u.m) + coordinate_system_status = CoordinateSystemStatus( + coordinate_systems=[coordinate_system], + parents=[], + assignments=[ + CoordinateSystemAssignmentGroup( + coordinate_system_id=coordinate_system.private_attribute_id, + entities=[ + CoordinateSystemEntityRef( + entity_type="Cylinder", + entity_id=cylinder.private_attribute_id, + ) + ], + ) + ], + ) + params = SimulationParams( + models=[bet_disk], + private_attribute_asset_cache=AssetCache( + coordinate_system_status=coordinate_system_status, + ), + ) + + apply_coordinate_system_transformations(params) + + transformed_bet_disk = params.models[0] + transformed_cylinder = transformed_bet_disk.entities.stored_entities[0] + + assert all(transformed_cylinder.center == [1, 2, 3] * u.m) + assert transformed_bet_disk.mach_numbers == [0] + assert transformed_bet_disk.twists[0].radius == 0 * u.m + assert transformed_bet_disk.chords[0].chord == 1 * u.m From 6c43f437b39f731497134d47dbc696c395980ecb Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Wed, 15 Apr 2026 19:24:51 -0400 Subject: [PATCH 16/25] Preserve no-unit values in surface meshing translator --- .../simulation/translator/surface_meshing_translator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/translator/surface_meshing_translator.py b/flow360/component/simulation/translator/surface_meshing_translator.py index c4087bfc9..03d73b49b 100644 --- a/flow360/component/simulation/translator/surface_meshing_translator.py +++ b/flow360/component/simulation/translator/surface_meshing_translator.py @@ -961,7 +961,9 @@ def filter_simulation_json(input_params: SimulationParams, mesh_units): """ # Get the JSON from the input_params - json_data = input_params.model_dump(mode="json", exclude_none=True) + json_data = input_params.model_dump( + mode="json", exclude_none=True, context={"no_unit": True} + ) _inject_body_group_transformations_for_mesher( json_data=json_data, input_params=input_params, mesh_unit=mesh_units From 8a7ee107f60e93f5b85ff8634cf5d59e37bf1bdb Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Wed, 15 Apr 2026 19:36:31 -0400 Subject: [PATCH 17/25] Formatted --- .../simulation/translator/surface_meshing_translator.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flow360/component/simulation/translator/surface_meshing_translator.py b/flow360/component/simulation/translator/surface_meshing_translator.py index 03d73b49b..231349f35 100644 --- a/flow360/component/simulation/translator/surface_meshing_translator.py +++ b/flow360/component/simulation/translator/surface_meshing_translator.py @@ -961,9 +961,7 @@ def filter_simulation_json(input_params: SimulationParams, mesh_units): """ # Get the JSON from the input_params - json_data = input_params.model_dump( - mode="json", exclude_none=True, context={"no_unit": True} - ) + json_data = input_params.model_dump(mode="json", exclude_none=True, context={"no_unit": True}) _inject_body_group_transformations_for_mesher( json_data=json_data, input_params=input_params, mesh_unit=mesh_units From b8e0123ce7414669b7ab6606a1afd22fa60351a8 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Wed, 15 Apr 2026 20:02:02 -0400 Subject: [PATCH 18/25] Relay simulation validation through schema service --- flow360/component/simulation/services.py | 539 +---------------------- 1 file changed, 23 insertions(+), 516 deletions(-) diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 1dd337041..88a89c3ec 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -4,12 +4,10 @@ import copy import json import os -from enum import Enum from typing import ( Any, Collection, Dict, - Iterable, List, Literal, Optional, @@ -18,20 +16,24 @@ ) import pydantic as pd -from flow360_schema.framework.expression.registry import ( # pylint: disable=unused-import +from flow360_schema.framework.physical_dimensions import Angle, Length +from flow360_schema.models.simulation.services import ( # pylint: disable=unused-import + ValidationCalledBy, + _determine_validation_level, + _insert_forward_compatibility_notice, + _intersect_validation_levels, + _normalize_union_branch_error_location, + _populate_error_context, + _sanitize_stack_trace, + _traverse_error_location, + clean_unrelated_setting_from_params_dict, clear_context, + handle_generic_exception, + initialize_variable_space, + validate_error_locations, + validate_model as _schema_validate_model, ) -from flow360_schema.framework.expression.variable import ( - RedeclaringVariableError, - get_referenced_expressions_and_user_variables, - restore_variable_space, -) - -# Required for correct global scope initialization -from flow360_schema.framework.physical_dimensions import Angle, Length -from flow360_schema.framework.validation.context import DeserializationContext from pydantic import TypeAdapter -from pydantic_core import ErrorDetails from flow360.component.simulation.entity_info import GeometryEntityInfo from flow360.component.simulation.entity_info import ( @@ -43,9 +45,6 @@ materialize_entities_and_selectors_in_place, ) from flow360.component.simulation.framework.entity_registry import EntityRegistry -from flow360.component.simulation.framework.multi_constructor_model_base import ( - parse_model_dict, -) from flow360.component.simulation.meshing_param.params import MeshingParams from flow360.component.simulation.meshing_param.volume_params import AutomatedFarfield from flow360.component.simulation.models.bet.bet_translator_interface import ( @@ -89,11 +88,7 @@ unit_system_manager, ) from flow360.component.simulation.units import validate_length -from flow360.component.simulation.validation.validation_context import ( - ALL, - ParamsValidationInfo, - ValidationContext, -) +from flow360.component.simulation.validation.validation_context import ALL from flow360.exceptions import ( Flow360RuntimeError, Flow360TranslationError, @@ -265,136 +260,6 @@ def get_default_params( ) -def _intersect_validation_levels(requested_levels, available_levels): - if requested_levels is not None and available_levels is not None: - if requested_levels == ALL: - validation_levels_to_use = [ - item for item in ["SurfaceMesh", "VolumeMesh", "Case"] if item in available_levels - ] - elif isinstance(requested_levels, str): - if requested_levels in available_levels: - validation_levels_to_use = [requested_levels] - else: - validation_levels_to_use = [] - else: - assert isinstance(requested_levels, list) - validation_levels_to_use = [ - item for item in requested_levels if item in available_levels - ] - return validation_levels_to_use - return [] - - -class ValidationCalledBy(Enum): - """ - Enum as indicator where `validate_model()` is called. - """ - - LOCAL = "Local" - SERVICE = "Service" - PIPELINE = "Pipeline" - - def get_forward_compatibility_error_message(self, version_from: str, version_to: str): - """ - Return error message string indicating that the forward compatibility is not guaranteed. - """ - error_suffix = " Errors may occur since forward compatibility is limited." - if self == ValidationCalledBy.LOCAL: - return { - "type": (f"{version_from} > {version_to}"), - "loc": [], - "msg": "The cloud `SimulationParam` (version: " - + version_from - + ") is too new for your local Python client (version: " - + version_to - + ")." - + error_suffix, - "ctx": {}, - } - if self == ValidationCalledBy.SERVICE: - return { - "type": (f"{version_from} > {version_to}"), - "loc": [], - "msg": "Your `SimulationParams` (version: " - + version_from - + ") is too new for the solver (version: " - + version_to - + ")." - + error_suffix, - "ctx": {}, - } - if self == ValidationCalledBy.PIPELINE: - # These will only appear in log. Ideally we should not rely on pipelines - # to emit useful error messages. Or else the local/service validation is not doing their jobs properly. - return { - # pylint:disable = protected-access - "type": (f"{version_from} > {version_to}"), - "loc": [], - "msg": "[Internal] Your `SimulationParams` (version: " - + version_from - + ") is too new for the solver (version: " - + version_to - + ")." - + error_suffix, - "ctx": {}, - } - return None - - -def _insert_forward_compatibility_notice( - validation_errors: list, - params_as_dict: dict, - validated_by: ValidationCalledBy, - version_to: str = __version__, -): - # If error occurs, inform user that the error message could due to failure in forward compatibility. - # pylint:disable=protected-access - version_from = SimulationParams._get_version_from_dict(model_dict=params_as_dict) - forward_compatibility_failure_error = validated_by.get_forward_compatibility_error_message( - version_from=version_from, version_to=version_to - ) - validation_errors.insert(0, forward_compatibility_failure_error) - return validation_errors - - -def initialize_variable_space(param_as_dict: dict, use_clear_context: bool = False) -> dict: - """Load all user variables from private attributes when a simulation params object is initialized.""" - if "private_attribute_asset_cache" not in param_as_dict.keys(): - return param_as_dict - asset_cache: dict = param_as_dict["private_attribute_asset_cache"] - if "variable_context" not in asset_cache.keys(): - return param_as_dict - if not isinstance(asset_cache["variable_context"], Iterable): - return param_as_dict - - variable_context = asset_cache["variable_context"] - - try: - restore_variable_space(variable_context, clear_first=use_clear_context) - except RedeclaringVariableError as e: - raise ValueError( - f"Loading user variable '{e.variable_name}' from simulation.json which is " - "already defined in local context. Please change your local user variable definition." - ) from e - except pd.ValidationError as e: - # Re-wrap with private_attribute_asset_cache prefix in loc - error_detail: dict = e.errors()[0] - loc = error_detail.get("loc", ()) - raise pd.ValidationError.from_exception_data( - "Invalid user variable/expression", - line_errors=[ - ErrorDetails( - type=error_detail["type"], - loc=("private_attribute_asset_cache",) + tuple(loc), - msg=error_detail.get("msg", "Unknown error"), - ctx=error_detail.get("ctx", {}), - ), - ], - ) from e - - return param_as_dict - - def validate_model( # pylint: disable=too-many-locals *, params_as_dict, @@ -427,361 +292,13 @@ def validate_model( # pylint: disable=too-many-locals validation_warnings : list A list of validation warnings (empty list if no warnings were recorded). """ - - def handle_multi_constructor_model(params_as_dict: dict) -> dict: - """ - Handle input cache of multi-constructor models. - """ - project_length_unit_dict = params_as_dict.get("private_attribute_asset_cache", {}).get( - "project_length_unit", None - ) - parse_model_info = ParamsValidationInfo( - {"private_attribute_asset_cache": {"project_length_unit": project_length_unit_dict}}, - [], - ) - with ( - ValidationContext(levels=validation_levels_to_use, info=parse_model_info), - DeserializationContext(), - ): - # Multi-constructor model support - updated_param_as_dict = parse_model_dict(params_as_dict, globals()) - return updated_param_as_dict - - def dict_preprocessing(params_as_dict: dict) -> dict: - """ - Preprocess the parameters dictionary before validation. - """ - # pylint: disable=protected-access - params_as_dict = SimulationParams._sanitize_params_dict(params_as_dict) - params_as_dict = handle_multi_constructor_model(params_as_dict) - - # Materialize stored_entities (dict -> shared instances) and per-list deduplication - # pylint: disable=fixme - # TODO: The need for materialization on entities? - # * Ideally stored_entities should store entity IDs only. And we do not even need to materialize them here. - # * This change has to wait for the front end to support entity IDs. - # * Although to be fair having entities esp the draft entities inlined allow copy pasting of - # * the SimulationParams, otherwise user will copy a bunch of links which become stale under a new context. - # * Benefits: - # * 1. Much shorter JSON. - # * 2. Deserialization ALL entities just once, not just the persistent ones. - # * 3. Strong requirement that ALL entities must come from entity_info/registry. - # * 4. Data structural-wise single source of truth. - - # TODO: Unifying Materialization and Entity Info? - # * As of now entities will still be separate instances when being - # * 1. materialized here versus - # * 2. deserialized in the entity info. - # * This impacts manually picked entities and all draft entities since they cannot be matched by Selectors. - # * validate_mode() is called in 3 main places: - # * 1. Local validation - # * 2. Service validation - # * 3. Local deserialization of cloud simulation.json - # * Only the last scenario is affected by this issue because in 1 and 2 user has done all the possible - # * editing so keeping data linkage is non-beneficial. - # * Although for the last scenario, if the user makes changes to the entities via create_draft(), the draft's - # * entity_info will be source of truth and the changes should be reflected in - # * both the assignment and the entity_info. - - params_as_dict = materialize_entities_and_selectors_in_place(params_as_dict) - return params_as_dict - - validation_errors = None - validation_warnings: List[Dict[str, Any]] = [] - validated_param = None - validation_context: Optional[ValidationContext] = None - - params_as_dict = clean_unrelated_setting_from_params_dict(params_as_dict, root_item_type) - - # The final validation levels will be the intersection of the requested levels and the levels available - # We always assume we want to run case so that we can expose as many errors as possible - available_levels = _determine_validation_level(up_to="Case", root_item_type=root_item_type) - validation_levels_to_use = _intersect_validation_levels(validation_level, available_levels) - forward_compatibility_mode = False - - # pylint: disable=protected-access - # Note: Need to run updater first to accommodate possible schema change in input caches. - params_as_dict, forward_compatibility_mode = SimulationParams._update_param_dict(params_as_dict) - - # The private_attribute_asset_cache should have been validated/deserialized here before all - # the following processes. And then you would just pass a validated asset cache instant to the SimulationParams. - # By design (not explicitly planned but in reality) The AssetCache is a pure context provider of the - # SimulationParams and its content does not, for most part, interact with each other or depends on the user setting - # part of the simulation.json. Therefore it should be fine for the asset cache to be deserialized independently - # before the user setting part of the simulation.json. - # ** There are several main benefit: - # 1. Validate asset cache gives correct and accurate error location if any. This usually only apply to - # front-end forms but it is also the front-end that rely on accurate link of error location for webUI error viewer - # to work properly. - # 2. We have to deserialize everything during the process anyway. For example the variables, the selectors, - # and the entity info. We are already actually doing this step by step but there are always places that we - # have not covered yet and thus we have issues like [FXC-5256]. - # 3. Validated asset cache gives proper type hint and also object interface instead of pure JSON interface. - # It is just much easier to work with. - # 4. We do not have to validate project_length_unit 10 times here and there. This speeds up the validation process - # as well as restrict to single source of truth even though: - # a) user cannot directly interact with the source and - # b) We manage the asset cache (source of truth). - # 5. I think this also goes well with the general direction of clear separation of different parts of - # simulation.json in terms of responsibility as well as purpose. - - try: - updated_param_as_dict = dict_preprocessing(params_as_dict) - - # Initialize variable space - use_clear_context = validated_by == ValidationCalledBy.SERVICE - initialize_variable_space(updated_param_as_dict, use_clear_context) - referenced_expressions = get_referenced_expressions_and_user_variables( - updated_param_as_dict - ) - - validation_info = ParamsValidationInfo( - param_as_dict=updated_param_as_dict, - referenced_expressions=referenced_expressions, - ) - - with ValidationContext( - levels=validation_levels_to_use, - info=validation_info, - ) as context: - validation_context = context - # Reuse pre-deserialized entity_info to avoid double deserialization - pre_deserialized_entity_info = validation_info.get_entity_info() - if pre_deserialized_entity_info is not None: - # Create shallow copy with entity_info substituted - updated_param_as_dict = {**updated_param_as_dict} - updated_param_as_dict["private_attribute_asset_cache"] = { - **updated_param_as_dict["private_attribute_asset_cache"], - "project_entity_info": pre_deserialized_entity_info, - } - with DeserializationContext(): - validated_param = SimulationParams.model_validate(updated_param_as_dict) - - except pd.ValidationError as err: - validation_errors = err.errors() - except Exception as err: # pylint: disable=broad-exception-caught - import traceback # pylint: disable=import-outside-toplevel - - stack = traceback.format_exc() - validation_errors = handle_generic_exception( - err, validation_errors, loc_prefix=None, error_stack=stack - ) - finally: - if validation_context is not None: - validation_warnings = list(validation_context.validation_warnings) - - if validation_errors is not None: - validation_errors = validate_error_locations(validation_errors, params_as_dict) - - if forward_compatibility_mode and validation_errors is not None: - # pylint: disable=fixme - # TODO: If forward compatibility issue found. Try to tell user how they can get around it. - # TODO: Recommend solver/python client version they should use instead. - validation_errors = _insert_forward_compatibility_notice( - validation_errors, params_as_dict, validated_by - ) - - return validated_param, validation_errors, validation_warnings - - -def clean_unrelated_setting_from_params_dict(params: dict, root_item_type: str) -> dict: - """ - Cleans the parameters dictionary by removing properties if they do not affect the remaining workflow. - - - Parameters - ---------- - params : dict - The original parameters dictionary. - root_item_type : str - The root item type determining specific cleaning actions. - - Returns - ------- - dict - The cleaned parameters dictionary. - """ - - if root_item_type == "VolumeMesh": - params.pop("meshing", None) - - return params - - -def _sanitize_stack_trace(stack: str) -> str: - """ - Sanitize file paths in stack trace to only show paths starting from 'flow360/'. - - Gracefully returns the original stack if sanitization fails. - - Parameters - ---------- - stack : str - The original stack trace string. - - Returns - ------- - str - The sanitized stack trace with shortened file paths, or the original - stack if sanitization fails. - """ - # pylint: disable=import-outside-toplevel - import re - - try: - # Remove the "Traceback (most recent call last):\n" prefix - stack = re.sub(r"^Traceback \(most recent call last\):\n\s*", "", stack) - - # Pattern to match file paths containing 'flow360/' - # Captures everything before 'flow360/' and replaces with just 'flow360/' - pattern = r'File "[^"]*[/\\](flow360[/\\][^"]*)"' - replacement = r'File "\1"' - return re.sub(pattern, replacement, stack) - except Exception: # pylint: disable=broad-exception-caught - return stack - - -def handle_generic_exception( - err: Exception, - validation_errors: Optional[list], - loc_prefix: Optional[list[str]] = None, - error_stack: Optional[str] = None, -) -> list: - """ - Handles generic exceptions during validation, adding to validation errors. - - Parameters - ---------- - err : Exception - The exception caught during validation. - validation_errors : list or None - Current list of validation errors, may be None. - loc_prefix : list or None - Prefix of the location of the generic error to help locate the issue - error_stack : str or None - The error stack trace, if available. - - Returns - ------- - list - The updated list of validation errors including the new error. - """ - if validation_errors is None: - validation_errors = [] - - error_entry = { - "type": err.__class__.__name__.lower().replace("error", "_error"), - "loc": ["unknown"] if loc_prefix is None else loc_prefix, - "msg": str(err), - "ctx": {}, - } - - if error_stack is not None: - error_entry["debug"] = _sanitize_stack_trace(error_stack) - - validation_errors.append(error_entry) - return validation_errors - - -def validate_error_locations(errors: list, params: dict) -> list: - """ - Validates the locations in the errors to ensure they correspond to the params dict. - - Parameters - ---------- - errors : list - The list of validation errors to process. - params : dict - The parameters dictionary being validated. - - Returns - ------- - list - The updated list of errors with validated locations and context. - """ - for error in errors: - current = params - for field in error["loc"][:-1]: - current, valid = _traverse_error_location(current, field) - if not valid: - error["loc"] = tuple(loc for loc in error["loc"] if loc != field) - - _normalize_union_branch_error_location(error, current) - _populate_error_context(error) - return errors - - -def _normalize_union_branch_error_location(error: dict, current) -> None: - """ - Hide internal tagged-union branch names from user-facing error locations. - - ValueOrExpression uses tagged union branches named ``number`` and ``expression``. - Pydantic includes the selected branch tag in ``loc``. When the original input is a - legacy ``{\"value\": ..., \"units\": ...}`` payload, restore the old ``value`` leaf. - Otherwise, collapse the synthetic branch name to the parent field. - """ - loc = error.get("loc") - if not isinstance(loc, tuple) or len(loc) == 0: - return - - branch = loc[-1] - if branch not in {"number", "expression"}: - return - - if isinstance(current, dict): - if branch == "number" and "value" in current: - error["loc"] = (*loc[:-1], "value") - return - if branch == "expression" and "expression" in current: - error["loc"] = (*loc[:-1], "expression") - return - - error["loc"] = loc[:-1] - - -def _traverse_error_location(current, field): - """ - Traverse through the error location path within the parameters. - - Parameters - ---------- - current : any - The current position in the params dict or list. - field : any - The current field being validated. - - Returns - ------- - tuple - The updated current position and whether the traversal was valid. - """ - if isinstance(field, int) and isinstance(current, list) and field in range(len(current)): - return current[field], True - if isinstance(field, str) and isinstance(current, dict) and current.get(field): - return current.get(field), True - return current, False - - -def _populate_error_context(error: dict): - """ - Populates the error context with relevant stringified values. - - Parameters - ---------- - error : dict - The error dictionary to update with context information. - """ - ctx = error.get("ctx") - if isinstance(ctx, dict): - for field_name, context in ctx.items(): - try: - error["ctx"][field_name] = ( - [str(item) for item in context] if isinstance(context, list) else str(context) - ) - except Exception: # pylint: disable=broad-exception-caught - error["ctx"][field_name] = "" - else: - error["ctx"] = {} + return _schema_validate_model( + params_as_dict=params_as_dict, + validated_by=validated_by, + root_item_type=root_item_type, + validation_level=validation_level, + version_to=__version__, + ) # pylint: disable=too-many-arguments @@ -873,16 +390,6 @@ def _get_mesh_unit(params_as_dict: dict) -> str: return mesh_unit -def _determine_validation_level( - up_to: Literal["SurfaceMesh", "VolumeMesh", "Case"], - root_item_type: Union[Literal["Geometry", "SurfaceMesh", "VolumeMesh"], None], -) -> list: - if root_item_type is None: - return None - all_lvls = ["Geometry", "SurfaceMesh", "VolumeMesh", "Case"] - return all_lvls[all_lvls.index(root_item_type) + 1 : all_lvls.index(up_to) + 1] - - def _process_surface_mesh( params: dict, root_item_type: str, mesh_unit: str ) -> Optional[Dict[str, Any]]: From 58791257c3efb909f51766437baff87ccc25bd1c Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 16 Apr 2026 13:16:18 -0400 Subject: [PATCH 19/25] Relay migrated simulation services through schema --- flow360/component/simulation/conversion.py | 247 +--- .../component/simulation/framework/updater.py | 989 +------------- .../simulation/framework/updater_functions.py | 112 +- .../simulation/framework/updater_utils.py | 191 +-- flow360/component/simulation/services.py | 320 +---- .../simulation/user_code/core/types.py | 22 +- flow360/component/simulation/utils.py | 65 +- .../simulation_stopping_criterion_webui.json | 1171 ----------------- .../test_meshing_param_validation.py | 441 ------- .../test_refinements_validation.py | 168 --- .../params/test_farfield_enclosed_entities.py | 509 ------- .../params/test_validators_criterion.py | 52 - .../params/test_validators_solid.py | 162 --- ...ependency_geometry_sphere1_simulation.json | 746 ----------- ...ependency_geometry_sphere2_simulation.json | 746 ----------- .../result_merged_geometry_entity_info1.json | 1048 --------------- .../result_merged_geometry_entity_info2.json | 1048 --------------- .../data/root_geometry_cube_simulation.json | 1154 ---------------- .../service/ref/updater_to_25_2_2.json | 705 ---------- .../service/test_apply_simulation_setting.py | 488 ------- tests/simulation/service/test_services_v2.py | 206 --- 21 files changed, 51 insertions(+), 10539 deletions(-) delete mode 100644 tests/simulation/params/data/simulation_stopping_criterion_webui.json delete mode 100644 tests/simulation/params/meshing_validation/test_meshing_param_validation.py delete mode 100644 tests/simulation/params/meshing_validation/test_refinements_validation.py delete mode 100644 tests/simulation/params/test_farfield_enclosed_entities.py delete mode 100644 tests/simulation/params/test_validators_criterion.py delete mode 100644 tests/simulation/params/test_validators_solid.py delete mode 100644 tests/simulation/service/data/dependency_geometry_sphere1_simulation.json delete mode 100644 tests/simulation/service/data/dependency_geometry_sphere2_simulation.json delete mode 100644 tests/simulation/service/data/result_merged_geometry_entity_info1.json delete mode 100644 tests/simulation/service/data/result_merged_geometry_entity_info2.json delete mode 100644 tests/simulation/service/data/root_geometry_cube_simulation.json delete mode 100644 tests/simulation/service/ref/updater_to_25_2_2.json delete mode 100644 tests/simulation/service/test_apply_simulation_setting.py diff --git a/flow360/component/simulation/conversion.py b/flow360/component/simulation/conversion.py index 157826cd5..bf30495ec 100644 --- a/flow360/component/simulation/conversion.py +++ b/flow360/component/simulation/conversion.py @@ -1,239 +1,8 @@ -""" -This module provides functions for handling unit conversion into flow360 solver unit system. -""" - -# pylint: disable=duplicate-code - -import operator -from functools import reduce - -import unyt as u - -from ...exceptions import Flow360ConfigurationError - -LIQUID_IMAGINARY_FREESTREAM_MACH = 0.05 - - -class RestrictedUnitSystem(u.UnitSystem): - """UnitSystem that blocks conversions for unsupported base dimensions. - - Automatically derives supported dimensions from which unit arguments are - provided. Missing base units get placeholder values internally but are - masked so that conversion attempts raise ValueError. - - Examples:: - - # Meshing mode: only length defined, velocity/mass/temperature blocked - RestrictedUnitSystem("nondim", length_unit=0.5 * u.m) - - # Full mode: all units provided, no restrictions - RestrictedUnitSystem("nondim", length_unit=..., mass_unit=..., - time_unit=..., temperature_unit=...) - """ - - def __init__( # pylint: disable=too-many-arguments - self, - name, - length_unit, - mass_unit=None, - time_unit=None, - temperature_unit=None, - **kwargs, - ): - supported = {u.dimensions.length, u.dimensions.angle} - if mass_unit is not None: - supported.add(u.dimensions.mass) - if time_unit is not None: - supported.add(u.dimensions.time) - if temperature_unit is not None: - supported.add(u.dimensions.temperature) - - super().__init__( - name, - length_unit=length_unit, - mass_unit=mass_unit or 1 * u.kg, - time_unit=time_unit or 1 * u.s, - temperature_unit=temperature_unit or 1 * u.K, - **kwargs, - ) - - # All 5 dims provided (length, angle + mass, time, temperature) — no restrictions - if len(supported) == 5: - self._supported_dims = None - return - - # Mask unsupported base dimensions in units_map so that - # get_base_equivalent's fast path doesn't bypass our check - self._supported_dims = supported - for dim in list(self.units_map.keys()): - if not dim.free_symbols <= supported: - self.units_map[dim] = None - - def __getitem__(self, key): - if isinstance(key, str): - key = getattr(u.dimensions, key) - if self._supported_dims is not None: - unsupported = key.free_symbols - self._supported_dims - if unsupported: - names = ", ".join(str(s) for s in unsupported) - raise ValueError( - f"Cannot non-dimensionalize {key}: " - f"base units for {names} are not defined in this context." - ) - return super().__getitem__(key) - - -def get_from_dict_by_key_list(key_list, data_dict): - """ - Get a value from a nested dictionary using a list of keys. - - Parameters - ---------- - key_list : List[str] - List of keys specifying the path to the desired value. - data_dict : dict - The dictionary from which to retrieve the value. - - Returns - ------- - value - The value located at the specified nested path in the dictionary. - """ - - return reduce(operator.getitem, key_list, data_dict) - - -def need_conversion(value): - """ - Check if a value needs conversion to flow360 units. - - Parameters - ---------- - value : Any - The value to check for conversion. - - Returns - ------- - bool - True if conversion is needed (i.e. value carries physical units), False otherwise. - """ - - return hasattr(value, "units") - - -def require(required_parameter, required_by, params): - """ - Ensure that required parameters are present in the provided dictionary. - - Parameters - ---------- - required_parameter : List[str] - List of keys specifying the path to the desired parameter that is required. - required_by : List[str] - List of keys specifying the path to the parameter that requires required_parameter for unit conversion. - params : SimulationParams - The dictionary containing the parameters. - - Raises - ------ - Flow360ConfigurationError - Configuration error due to missing parameter. - """ - - required_msg = f'required by {" -> ".join(required_by)} for unit conversion' - try: - params_as_dict = params - if not isinstance(params_as_dict, dict): - params_as_dict = params.model_dump() - value = get_from_dict_by_key_list(required_parameter, params_as_dict) - if value is None: - raise ValueError - - except Exception as err: - raise Flow360ConfigurationError( - message=f'{" -> ".join(required_parameter)} is {required_msg}.', - field=required_by, - dependency=required_parameter, - ) from err - - -def get_flow360_unit_system_liquid(params, to_flow360_unit: bool = False) -> u.UnitSystem: - """ - Returns the flow360 unit system when liquid operating condition is used. - - Parameters - ---------- - params : SimulationParams - The parameters needed for unit conversion that uses liquid operating condition. - to_flow360_unit : bool, optional - Whether we want user input to be converted to flow360 unit system. - The reverse path requires different conversion logic (from solver output to non-flow360 unit system) - since the solver output is already re-normalized by `reference velocity` due to "velocityScale". - - Returns - ------- - u.UnitSystem - The flow360 unit system. - - ##-- When to_flow360_unit is True, - ##-- time unit should be changed such that it takes into consideration - ##-- the fact that solver output already multiplied by "velocityScale" - """ - - if to_flow360_unit: - base_velocity = params.base_velocity - else: - base_velocity = params.reference_velocity # pylint:disable=protected-access - - time_unit = params.base_length / base_velocity - return u.UnitSystem( - name="flow360_liquid", - length_unit=params.base_length, - mass_unit=params.base_mass, - time_unit=time_unit, - temperature_unit=params.base_temperature, - ) - - -def compute_udf_dimensionalization_factor(params, requested_unit, using_liquid_op): - """ - - Returns the dimensionalization coefficient and factor given a requested unit - - Parameters - ---------- - params : SimulationParams - The parameters needed for unit conversion. - unit: u.Unit - The unit to compute the factors. - using_liquid_op : bool - If True, compute the factor based on the flow360_liquid unit system. - Returns - ------- - coefficient and offset for unit conversion from the requested unit to flow360 unit - - """ - - def _compute_coefficient_and_offset(source_unit: u.Unit, target_unit: u.Unit): - y2 = (2.0 * target_unit).in_units(source_unit).value - y1 = (1.0 * target_unit).in_units(source_unit).value - x2 = 2.0 - x1 = 1.0 - - coefficient = (y2 - y1) / (x2 - x1) - offset = y1 / coefficient - x1 - - return coefficient, offset - - flow360_unit_system = ( - params.flow360_unit_system - if not using_liquid_op - else get_flow360_unit_system_liquid(params=params) - ) - # Note: Effectively assuming that all the solver vars uses radians and also the expressions expect radians - flow360_unit_system["angle"] = u.rad # pylint:disable=no-member - flow360_unit = flow360_unit_system[requested_unit.dimensions] - coefficient, offset = _compute_coefficient_and_offset( - source_unit=requested_unit, target_unit=flow360_unit - ) - return coefficient, offset +"""Relay schema-owned conversion helpers for migrated simulation modules.""" + +from flow360_schema.models.simulation.conversion import ( + LIQUID_IMAGINARY_FREESTREAM_MACH, + RestrictedUnitSystem, + compute_udf_dimensionalization_factor, + get_flow360_unit_system_liquid, +) diff --git a/flow360/component/simulation/framework/updater.py b/flow360/component/simulation/framework/updater.py index 5ef6a9065..5b2ffc9bd 100644 --- a/flow360/component/simulation/framework/updater.py +++ b/flow360/component/simulation/framework/updater.py @@ -1,982 +1,9 @@ -""" -Module containing updaters from version to version - -TODO: remove duplication code with FLow360Params updater. -""" - -# pylint: disable=R0801 - - -import copy -import re -from typing import Any - -from flow360.component.simulation.framework.entity_utils import generate_uuid -from flow360.component.simulation.framework.updater_functions import ( - fix_ghost_sphere_schema, - populate_entity_id_with_name, - remove_entity_bucket_field, - update_symmetry_ghost_entity_name_to_symmetric, -) -from flow360.component.simulation.framework.updater_utils import ( - Flow360Version, - compare_dicts, - recursive_remove_key, +"""Relay schema-owned updater orchestration for simulation models.""" + +from flow360_schema.models.simulation.framework.updater import ( + DEFAULT_PLANAR_FACE_TOLERANCE, + DEFAULT_SLIDING_INTERFACE_TOLERANCE, + FLOW360_SCHEMA_DEFAULT_VERSION, + VERSION_MILESTONES, + updater, ) -from flow360.log import log -from flow360.version import __version__ - -DEFAULT_PLANAR_FACE_TOLERANCE = 1e-6 -DEFAULT_SLIDING_INTERFACE_TOLERANCE = 1e-2 - - -def _to_24_11_1(params_as_dict): - # Check and remove the 'meshing' node if conditions are met - if params_as_dict.get("meshing") is not None: - meshing_defaults = params_as_dict["meshing"].get("defaults", {}) - bl_thickness = meshing_defaults.get("boundary_layer_first_layer_thickness") - max_edge_length = meshing_defaults.get("surface_max_edge_length") - if bl_thickness is None and max_edge_length is None: - del params_as_dict["meshing"] - - # Iterate over models and update 'heat_spec' where necessary - for model in params_as_dict.get("models", []): - if model.get("type") == "Wall" and model.get("heat_spec") is None: - model["heat_spec"] = { - "type_name": "HeatFlux", - "value": {"value": 0, "units": "W / m**2"}, - } - - # Check and remove the 'time_stepping' -> order_of_accuracy node - if "time_stepping" in params_as_dict: - params_as_dict["time_stepping"].pop("order_of_accuracy", None) - - update_symmetry_ghost_entity_name_to_symmetric(params_as_dict=params_as_dict) - return params_as_dict - - -def _to_24_11_7(params_as_dict): - def _add_private_attribute_id_for_point_array(params_as_dict: dict) -> dict: - """ - Check if PointArray has private_attribute_id. If not, generate the uuid and assign the id - to all occurrence of the same PointArray - """ - if params_as_dict.get("outputs") is None: - return params_as_dict - - point_array_list = [] - for output in params_as_dict["outputs"]: - if output.get("entities", None) and output["entities"].get("stored_entities", None): - for entity in output["entities"]["stored_entities"]: - if ( - entity.get("private_attribute_entity_type_name") == "PointArray" - and entity.get("private_attribute_id") is None - ): - new_uuid = generate_uuid() - entity["private_attribute_id"] = new_uuid - point_array_list.append(entity) - - if not params_as_dict["private_attribute_asset_cache"].get("project_entity_info"): - return params_as_dict - if not params_as_dict["private_attribute_asset_cache"]["project_entity_info"].get( - "draft_entities" - ): - return params_as_dict - - for idx, draft_entity in enumerate( - params_as_dict["private_attribute_asset_cache"]["project_entity_info"]["draft_entities"] - ): - if draft_entity.get("private_attribute_entity_type_name") != "PointArray": - continue - for point_array in point_array_list: - if compare_dicts( - dict1=draft_entity, - dict2=point_array, - ignore_keys=["private_attribute_id"], - ): - params_as_dict["private_attribute_asset_cache"]["project_entity_info"][ - "draft_entities" - ][idx] = point_array - continue - return params_as_dict - - params_as_dict = _add_private_attribute_id_for_point_array(params_as_dict=params_as_dict) - update_symmetry_ghost_entity_name_to_symmetric(params_as_dict=params_as_dict) - return params_as_dict - - -# pylint: disable=invalid-name, too-many-branches -def _to_25_2_0(params_as_dict): - # Migrates the old DDES turbulence model interface to the new hybrid_model format. - for model in params_as_dict.get("models", []): - turb_dict = model.get("turbulence_model_solver") - if not turb_dict: - continue - - run_ddes = turb_dict.pop("DDES", None) - grid_size_for_LES = turb_dict.pop("grid_size_for_LES", None) - - if run_ddes: - turb_dict["hybrid_model"] = { - "shielding_function": "DDES", - "grid_size_for_LES": grid_size_for_LES, - } - - if params_as_dict.get("outputs") is not None: - for output in params_as_dict["outputs"]: - if output.get("output_type") == "VolumeOutput": - items = output.get("output_fields", {}).get("items", []) - for old, new in [ - ("SpalartAllmaras_DDES", "SpalartAllmaras_hybridModel"), - ("kOmegaSST_DDES", "kOmegaSST_hybridModel"), - ]: - if old in items: - items.remove(old) - items.append(new) - - # Convert the observers in the AeroAcousticOutput to new schema - if output.get("output_type") == "AeroAcousticOutput": - legacy_observers = output.get("observers", []) - converted_observers = [] - for position in legacy_observers: - converted_observers.append( - {"group_name": "0", "position": position, "private_attribute_expand": None} - ) - output["observers"] = converted_observers - - # Add ramping to MassFlowRate and move velocity direction to TotalPressure - for model in params_as_dict.get("models", []): - if model.get("type") == "Inflow" and "velocity_direction" in model.keys(): - velocity_direction = model.pop("velocity_direction", None) - model["spec"]["velocity_direction"] = velocity_direction - - if model.get("spec") and model["spec"].get("type_name") == "MassFlowRate": - model["spec"]["ramp_steps"] = None - - return params_as_dict - - -def _to_24_11_10(params_as_dict): - fix_ghost_sphere_schema(params_as_dict=params_as_dict) - return params_as_dict - - -def _to_25_2_1(params_as_dict): - ## We need a better mechanism to run updater function once. - fix_ghost_sphere_schema(params_as_dict=params_as_dict) - return params_as_dict - - -def _to_25_2_3(params_as_dict): - populate_entity_id_with_name(params_as_dict=params_as_dict) - return params_as_dict - - -def _to_25_4_1(params_as_dict): - if params_as_dict.get("meshing") is None: - return params_as_dict - meshing_defaults = params_as_dict["meshing"].get("defaults", {}) - if meshing_defaults.get("geometry_relative_accuracy"): - geometry_relative_accuracy = meshing_defaults.pop("geometry_relative_accuracy") - meshing_defaults["geometry_accuracy"] = {"value": geometry_relative_accuracy, "units": "m"} - return params_as_dict - - -def _fix_reynolds_mesh_unit(params_as_dict): - # Handling of the reynolds_mesh_unit rename - if "operating_condition" not in params_as_dict.keys(): - return params_as_dict - if "private_attribute_input_cache" not in params_as_dict["operating_condition"].keys(): - return params_as_dict - if ( - "reynolds" - not in params_as_dict["operating_condition"]["private_attribute_input_cache"].keys() - ): - return params_as_dict - reynolds_mesh_unit = params_as_dict["operating_condition"]["private_attribute_input_cache"].pop( - "reynolds", None - ) - if reynolds_mesh_unit is not None: - params_as_dict["operating_condition"]["private_attribute_input_cache"][ - "reynolds_mesh_unit" - ] = reynolds_mesh_unit - return params_as_dict - - -def _to_25_6_2(params_as_dict): - # Known: There can not be velocity_direction both under Inflow AND TotalPressure - - # Move the velocity_direction under TotalPressure to the Inflow level. - for model in params_as_dict.get("models", []): - if model.get("type") != "Inflow" or model.get("velocity_direction", None): - continue - - if model.get("spec") and model["spec"].get("type_name") == "TotalPressure": - velocity_direction = model["spec"].pop("velocity_direction", None) - if velocity_direction: - model["velocity_direction"] = velocity_direction - - params_as_dict = _fix_reynolds_mesh_unit(params_as_dict) - - # Handling the disable of same entity being in multiple outputs - if params_as_dict.get("outputs") is None: - return params_as_dict - - # Process each output type separately - # pylint: disable=too-many-nested-blocks - for output_type in ["SurfaceOutput", "TimeAverageSurfaceOutput"]: - entity_map = {} - # entity_name -> {"creates_new" : bool, "output_settings":dict, "entity":entity_dict} - for output in params_as_dict["outputs"]: - if output.get("output_type") != output_type: - continue - entity_names = set() - entity_deduplicated = [] - for entity in output["entities"]["stored_entities"]: - if entity["name"] in entity_names: - continue - entity_names.add(entity["name"]) - entity_deduplicated.append(entity) - output["entities"]["stored_entities"] = entity_deduplicated - - for entity in output["entities"]["stored_entities"]: - name = entity["name"] - if name in entity_map: - entity_map[name]["creates_new"] = True - entity_map[entity["name"]]["output_settings"]["output_fields"]["items"] = ( - sorted( - list( - set( - entity_map[entity["name"]]["output_settings"]["output_fields"][ - "items" - ] - + output["output_fields"]["items"] - ) - ) - ) - ) - else: - entity_map[entity["name"]] = {} - entity_map[entity["name"]]["creates_new"] = False - entity_map[entity["name"]]["output_settings"] = copy.deepcopy( - {key: value for key, value in output.items() if key != "entities"} - ) - entity_map[entity["name"]]["entity"] = entity - - for entity_info in entity_map.values(): - if entity_info["creates_new"]: - for index, output in enumerate(params_as_dict["outputs"]): - if output.get("output_type") != output_type: - continue - for entity in output["entities"]["stored_entities"]: - if entity["name"] == entity_info["entity"]["name"]: - params_as_dict["outputs"][index]["entities"]["stored_entities"].remove( - entity - ) - params_as_dict["outputs"].append( - { - **entity_info["output_settings"], - "entities": {"stored_entities": [entity_info["entity"]]}, - } - ) - # remove empty outputs - params_as_dict["outputs"] = [ - output - for output in params_as_dict["outputs"] - if "entities" not in output or output["entities"]["stored_entities"] - ] - - return params_as_dict - - -def _add_default_planar_face_tolerance(params_as_dict): - if params_as_dict.get("meshing") is None: - return params_as_dict - if "defaults" not in params_as_dict["meshing"]: - return params_as_dict - meshing_defaults = params_as_dict["meshing"].get("defaults", {}) - if meshing_defaults.get("planar_face_tolerance") is None: - meshing_defaults["planar_face_tolerance"] = DEFAULT_PLANAR_FACE_TOLERANCE - return params_as_dict - - -def _to_25_6_4(params_as_dict): - return _add_default_planar_face_tolerance(params_as_dict) - - -def _to_25_6_5(params_as_dict): - # Some 25.6.4 JSONs are also missing the planar_face_tolerance. - return _add_default_planar_face_tolerance(params_as_dict) - - -def _to_25_6_6(params_as_dict): - # Remove the "potential_issues" field from all surfaces. - # Recursively go through params_as_dict and remove the "private_attribute_potential_issues" - # field if the "private_attribute_entity_type_name" field is "Surface". - def _remove_potential_issues_recursive(data): - if isinstance(data, dict): - # First recursively process all nested elements - for key, value in data.items(): - if isinstance(value, (dict, list)): - data[key] = _remove_potential_issues_recursive(value) - - # Then check if current dict is a Surface and remove potential_issues - if data.get("private_attribute_entity_type_name") == "Surface": - data.pop("private_attribute_potential_issues", None) - - return data - if isinstance(data, list): - # Process each item in the list - return [_remove_potential_issues_recursive(item) for item in data] - - # Return primitive types as-is - return data - - return _remove_potential_issues_recursive(params_as_dict) - - -def _to_25_7_2(params_as_dict): - # Add post_processing_variable flag to variable_context entries - # Variables that are used in outputs should have post_processing_variable=True - # Variables that are not used in outputs should have post_processing_variable=False - - if params_as_dict.get("private_attribute_asset_cache") is None: - return params_as_dict - - variable_context = params_as_dict["private_attribute_asset_cache"].get("variable_context") - if variable_context is None: - return params_as_dict - - # Collect all user variable names used in outputs - used_variable_names = set() - - if params_as_dict.get("outputs") is not None: - for output in params_as_dict["outputs"]: - if output.get("output_fields") and output["output_fields"].get("items"): - for item in output["output_fields"]["items"]: - # Check if item is a user variable (has name and type_name fields) - if ( - isinstance(item, dict) - and "name" in item - and item.get("type_name") == "UserVariable" - ): - used_variable_names.add(item["name"]) - - # Update variable_context entries with post_processing flag - for var_context in variable_context: - if "name" in var_context: - var_context["post_processing"] = var_context["name"] in used_variable_names - - return params_as_dict - - -def _to_25_7_6(params_as_dict): - """ - - Rename deprecated RotationCylinder discriminator to RotationVolume in meshing.volume_zones - - Remove legacy entity bucket field from all entity dicts - """ - # 1) Update RotationCylinder -> RotationVolume - meshing = params_as_dict.get("meshing") - if isinstance(meshing, dict): - volume_zones = meshing.get("volume_zones") - if isinstance(volume_zones, list): - for volume_zone in volume_zones: - if isinstance(volume_zone, dict) and volume_zone.get("type") == "RotationCylinder": - volume_zone["type"] = "RotationVolume" - - # 2) Cleanup legacy entity bucket fields - return remove_entity_bucket_field(params_as_dict=params_as_dict) - - -def _to_25_7_7(params_as_dict): - """ - 1. Reset frequency and frequency_offset to defaults for steady simulations - 2. Remove invalid output fields based on transition model - """ - - # 1. Handle frequency settings in steady simulations - if params_as_dict.get("time_stepping", {}).get("type_name") == "Steady": - outputs = params_as_dict.get("outputs") or [] - for output in outputs: - # Output types that have frequency/frequency_offset settings - if output.get("output_type") in [ - "VolumeOutput", - "TimeAverageVolumeOutput", - "SurfaceOutput", - "TimeAverageSurfaceOutput", - "SliceOutput", - "TimeAverageSliceOutput", - "IsosurfaceOutput", - "TimeAverageIsosurfaceOutput", - "SurfaceSliceOutput", - ]: - # Reset to defaults: frequency=-1, frequency_offset=0 - if "frequency" in output: - output["frequency"] = -1 - if "frequency_offset" in output: - output["frequency_offset"] = 0 - - # 2. Remove invalid output fields based on transition model - # Get transition model type from models - transition_model_type = "None" - models = params_as_dict.get("models") or [] - for model in models: - if model.get("type") == "Fluid": - transition_solver = model.get("transition_model_solver") or {} - transition_model_type = transition_solver.get("type_name") - break - - # If transition model is None or not found, remove transition-specific fields - if transition_model_type == "None": - transition_output_fields = [ - "residualTransition", - "solutionTransition", - "linearResidualTransition", - ] - - outputs = params_as_dict.get("outputs") or [] - for output in outputs: - if output.get("output_type") in ["AeroAcousticOutput", "StreamlineOutput"]: - continue - if "output_fields" in output: - output_fields = output["output_fields"] - if isinstance(output_fields, dict) and "items" in output_fields: - items = output_fields["items"] - # Remove invalid fields - output_fields["items"] = [ - field for field in items if field not in transition_output_fields - ] - - return params_as_dict - - -def _to_25_8_0(params_as_dict): - # new method of specifying meshing was added, as well as the method discriminator - meshing = params_as_dict.get("meshing") - if meshing: - meshing["type_name"] = "MeshingParams" - - return params_as_dict - - -def _to_25_8_1(params_as_dict): - recursive_remove_key(params_as_dict, "transformation") - return params_as_dict - - -def _to_25_8_3(params_as_dict): - def rename_origin_to_reference_point(params_as_dict): - """ - For all CoordinateSystem instances under asset_cache->coordinate_system_status->coordinate_systems, - Rename the legacy "origin" key to "reference_point" - """ - if params_as_dict.get("private_attribute_asset_cache") is None: - return params_as_dict - - asset_cache = params_as_dict["private_attribute_asset_cache"] - coordinate_system_status = asset_cache.get("coordinate_system_status") - - if coordinate_system_status is None: - return params_as_dict - - coordinate_systems = coordinate_system_status.get("coordinate_systems", []) - - for cs in coordinate_systems: - # Rename "origin" to "reference_point" if it exists - if "origin" in cs: - cs["reference_point"] = cs.pop("origin") - - return params_as_dict - - rename_origin_to_reference_point(params_as_dict) - return params_as_dict - - -def _to_25_8_4(params_as_dict): - """ - Populate wind tunnel ghost surfaces in ghost_entities if they are not present, - ensuring that they are available for entity selection. - """ - - def add_wind_tunnel_ghost_surfaces(params_as_dict): - def _has_wind_tunnel_ghost_surfaces(ghost_entities): - """Check if ghost_entities already contains WindTunnelGhostSurface entities.""" - for entity in ghost_entities: - if entity.get("private_attribute_entity_type_name") == "WindTunnelGhostSurface": - return True - return False - - def _get_all_wind_tunnel_ghost_surfaces(): - """Return a list of all possible WindTunnelGhostSurface dicts.""" - return [ - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelInlet", - "private_attribute_id": "windTunnelInlet", - "used_by": ["all"], - }, - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelOutlet", - "private_attribute_id": "windTunnelOutlet", - "used_by": ["all"], - }, - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelCeiling", - "private_attribute_id": "windTunnelCeiling", - "used_by": ["all"], - }, - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelFloor", - "private_attribute_id": "windTunnelFloor", - "used_by": ["all"], - }, - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelLeft", - "private_attribute_id": "windTunnelLeft", - "used_by": ["all"], - }, - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelRight", - "private_attribute_id": "windTunnelRight", - "used_by": ["all"], - }, - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelFrictionPatch", - "private_attribute_id": "windTunnelFrictionPatch", - "used_by": ["StaticFloor"], - }, - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelCentralBelt", - "private_attribute_id": "windTunnelCentralBelt", - "used_by": ["CentralBelt", "WheelBelts"], - }, - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelFrontWheelBelt", - "private_attribute_id": "windTunnelFrontWheelBelt", - "used_by": ["WheelBelts"], - }, - { - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "name": "windTunnelRearWheelBelt", - "private_attribute_id": "windTunnelRearWheelBelt", - "used_by": ["WheelBelts"], - }, - ] - - # Get asset cache, entity info, and ghost entities - asset_cache = params_as_dict.get("private_attribute_asset_cache") - if asset_cache is None: - return params_as_dict - - entity_info = asset_cache.get("project_entity_info") - if entity_info is None: - return params_as_dict - - ghost_entities = entity_info.get("ghost_entities", []) - - # Check if a wind tunnel ghost surface is already included - if _has_wind_tunnel_ghost_surfaces(ghost_entities): - return params_as_dict - - # Add all wind tunnel ghost surfaces and update entity_info - ghost_entities.extend(_get_all_wind_tunnel_ghost_surfaces()) - entity_info["ghost_entities"] = ghost_entities - return params_as_dict - - def fix_write_single_file_for_paraview_format(params_as_dict): - """ - Fix write_single_file incompatibility with Paraview format. - - Before validation was added, users could set write_single_file=True with - output_format="paraview". This is invalid because write_single_file only - works with Tecplot format. Silently reset write_single_file to False when - Paraview-only format is used. - - Also handles the edge case where output_format is missing from JSON - (e.g., hand-edited files or very old JSONs), in which case we assume - the default value "paraview" and apply the fix. - """ - outputs = params_as_dict.get("outputs") - if not outputs: - return params_as_dict - - for output in outputs: - output_type = output.get("output_type") - # Only process SurfaceOutput and TimeAverageSurfaceOutput - if output_type not in ["SurfaceOutput", "TimeAverageSurfaceOutput"]: - continue - - # Check if write_single_file is True - write_single_file = output.get("write_single_file") - if not write_single_file: - continue - - # Get output_format, default to "paraview" if missing - # (This handles edge cases like hand-edited JSONs or very old versions) - output_format = output.get("output_format", "paraview") - - # Only fix paraview format (which raises error) - # "both" format only shows warning, so it's valid - if output_format == "paraview": - # Silently reset write_single_file to False - output["write_single_file"] = False - - return params_as_dict - - add_wind_tunnel_ghost_surfaces(params_as_dict) - fix_write_single_file_for_paraview_format(params_as_dict) - return params_as_dict - - -def _remove_non_manifold_faces_key(params_as_dict): - """Remove deprecated meshing defaults key ``remove_non_manifold_faces``.""" - meshing = params_as_dict.get("meshing") - if isinstance(meshing, dict): - meshing_defaults = meshing.get("defaults") - if isinstance(meshing_defaults, dict): - meshing_defaults.pop("remove_non_manifold_faces", None) - - -def _migrate_wall_function_bool(params_as_dict): - """Convert `use_wall_function` boolean values to the new WallFunction model format.""" - for model in params_as_dict.get("models", []): - if model.get("type") != "Wall": - continue - wall_fn = model.get("use_wall_function") - if wall_fn is True: - model["use_wall_function"] = {"type_name": "BoundaryLayer"} - elif wall_fn is False: - model.pop("use_wall_function", None) - - -def _add_linear_solver_type_name(params_as_dict): - """Add ``type_name`` discriminator to linear_solver dicts inside navier_stokes_solver.""" - models = params_as_dict.get("models") - if not isinstance(models, list): - return - for model in models: - if not isinstance(model, dict): - continue - ns = model.get("navier_stokes_solver") - if not isinstance(ns, dict): - continue - ls = ns.get("linear_solver") - if isinstance(ls, dict) and "type_name" not in ls: - ls["type_name"] = "LinearSolver" - - -def _remove_local_cfl_for_steady(params_as_dict): - """Remove ``localCFL`` from output fields when the simulation is steady.""" - if params_as_dict.get("time_stepping", {}).get("type_name") != "Steady": - return - - outputs = params_as_dict.get("outputs") or [] - for output in outputs: - if output.get("output_type") in ( - "AeroAcousticOutput", - "StreamlineOutput", - "ForceDistributionOutput", - "TimeAverageForceDistributionOutput", - "RenderOutput", - ): - continue - if "output_fields" in output: - output_fields = output["output_fields"] - if isinstance(output_fields, dict) and "items" in output_fields: - output_fields["items"] = [ - field for field in output_fields["items"] if field != "localCFL" - ] - - -def _to_25_9_0(params_as_dict): - """Remove ``remove_non_manifold_faces``, migrate wall function bools.""" - _remove_non_manifold_faces_key(params_as_dict) - _migrate_wall_function_bool(params_as_dict) - return params_as_dict - - -def _to_25_9_1(params_as_dict): - """Add ``type_name`` to linear_solver, remove ``localCFL`` from steady outputs.""" - _add_linear_solver_type_name(params_as_dict) - _remove_local_cfl_for_steady(params_as_dict) - return params_as_dict - - -def _to_25_9_2(params_as_dict): - """ - - Migrate sphere-based rotation zones from ``RotationVolume`` to ``RotationSphere``. - - Rename ``boundaries`` to ``bounding_entities`` on ``CustomVolume`` dicts. - - Applies to both ``meshing.volume_zones`` and ``meshing.zones``. - """ - - def _migrate_rotation_volume_to_rotation_sphere(params_dict): - meshing = params_dict.get("meshing") - if not isinstance(meshing, dict): - return - - for zone_key in ("volume_zones", "zones"): - zones = meshing.get(zone_key) - if not isinstance(zones, list): - continue - - for zone in zones: - if not isinstance(zone, dict) or zone.get("type") != "RotationVolume": - continue - - entities = zone.get("entities", {}).get("stored_entities", []) - if not entities: - continue - - if entities[0].get("private_attribute_entity_type_name") != "Sphere": - continue - - zone["type"] = "RotationSphere" - zone.pop("spacing_axial", None) - zone.pop("spacing_radial", None) - - def _rename_custom_volume_boundaries(params_dict): - def _rename_in_entity(entity): - if not isinstance(entity, dict): - return - if entity.get("private_attribute_entity_type_name") != "CustomVolume": - return - if "boundaries" in entity and "bounding_entities" not in entity: - entity["bounding_entities"] = entity.pop("boundaries") - - meshing = params_dict.get("meshing") - if not isinstance(meshing, dict): - return - - for zone_key in ("volume_zones", "zones"): - zones = meshing.get(zone_key) - if not isinstance(zones, list): - continue - - for zone in zones: - if not isinstance(zone, dict): - continue - - for container_key in ("entities", "enclosed_entities"): - container = zone.get(container_key) - if not isinstance(container, dict): - continue - for entity in container.get("stored_entities", []): - _rename_in_entity(entity) - - _migrate_rotation_volume_to_rotation_sphere(params_as_dict) - _rename_custom_volume_boundaries(params_as_dict) - - return params_as_dict - - -def _to_25_9_3(params_as_dict): - """Rename ``type_name`` to ``wall_function_type`` in ``use_wall_function`` dicts.""" - for model in params_as_dict.get("models", []): - if model.get("type") != "Wall": - continue - wall_fn = model.get("use_wall_function") - if isinstance(wall_fn, dict) and "type_name" in wall_fn: - wall_fn["wall_function_type"] = wall_fn.pop("type_name") - return params_as_dict - - -_TOTAL_PRESSURE_CONVERTED_KEY = "__total_pressure_nondim_applied" - - -def _convert_total_pressure_expression_from_ratio_to_nondim(params_as_dict): - """Convert TotalPressure string expressions from pressure ratio (P/P∞) to - Flow360 nondimensional pressure (P/(ρ∞a∞²)). - - Old semantics: expression = totalPressureRatio = P_total / P∞ - New semantics: expression = P_total / (ρ∞a∞²) = totalPressureRatio / γ - - Since ThermallyPerfectGas is a new feature that likely will no coexist with old - string expressions, γ=1.4 (standard Air) is safe for all legacy data. - Liquid operating conditions have ratio=1.0, so no conversion is needed. - - Referenced by both _to_25_8_8 and _to_25_10_0 milestones. A sentinel key on - the dict itself prevents double-conversion without module-level mutable state. - """ - if params_as_dict.get(_TOTAL_PRESSURE_CONVERTED_KEY): - return params_as_dict - params_as_dict[_TOTAL_PRESSURE_CONVERTED_KEY] = True - - operating_condition = params_as_dict.get("operating_condition", {}) - if operating_condition.get("type_name") in ("LiquidOperatingCondition",): - return params_as_dict - - gamma = 1.4 - - for model in params_as_dict.get("models", []): - if model.get("type") != "Inflow": - continue - spec = model.get("spec") - if not spec or spec.get("type_name") != "TotalPressure": - continue - if isinstance(spec.get("value"), str): - spec["value"] = f"({spec['value']}) / {gamma}" - - return params_as_dict - - -def _to_25_8_8(params_as_dict): - return _convert_total_pressure_expression_from_ratio_to_nondim(params_as_dict) - - -def _to_25_10_0(params_as_dict): - """Migrate to 25.10.0: output_format string to list, add vtkhdf/ensight support.""" - - def _migrate_output_format_to_list(params_as_dict): - """Convert string ``output_format`` values to list form. - - ``"both"`` becomes ``["paraview", "tecplot"]``, comma-separated strings are - split, and bare strings are wrapped in a list. - """ - outputs = params_as_dict.get("outputs") - if not outputs: - return - - for output in outputs: - fmt = output.get("output_format") - if isinstance(fmt, list): - output["output_format"] = sorted(set(fmt)) - continue - if not isinstance(fmt, str): - continue - if fmt == "both": - output["output_format"] = ["paraview", "tecplot"] - elif "," in fmt: - output["output_format"] = sorted(set(v.strip() for v in fmt.split(","))) - else: - output["output_format"] = [fmt] - - _migrate_output_format_to_list(params_as_dict) - _convert_total_pressure_expression_from_ratio_to_nondim(params_as_dict) - return params_as_dict - - -VERSION_MILESTONES = [ - (Flow360Version("24.11.1"), _to_24_11_1), - (Flow360Version("24.11.7"), _to_24_11_7), - (Flow360Version("24.11.10"), _to_24_11_10), - (Flow360Version("25.2.0"), _to_25_2_0), - (Flow360Version("25.2.1"), _to_25_2_1), - (Flow360Version("25.2.3"), _to_25_2_3), - (Flow360Version("25.4.1"), _to_25_4_1), - (Flow360Version("25.6.2"), _to_25_6_2), - (Flow360Version("25.6.4"), _to_25_6_4), - (Flow360Version("25.6.5"), _to_25_6_5), - (Flow360Version("25.6.6"), _to_25_6_6), - (Flow360Version("25.7.2"), _to_25_7_2), - (Flow360Version("25.7.6"), _to_25_7_6), - (Flow360Version("25.7.7"), _to_25_7_7), - (Flow360Version("25.8.0b4"), _to_25_8_0), - (Flow360Version("25.8.1"), _to_25_8_1), - (Flow360Version("25.8.3"), _to_25_8_3), - (Flow360Version("25.8.4"), _to_25_8_4), - (Flow360Version("25.8.8"), _to_25_8_8), - (Flow360Version("25.9.0"), _to_25_9_0), - (Flow360Version("25.9.1"), _to_25_9_1), - (Flow360Version("25.9.2"), _to_25_9_2), - (Flow360Version("25.9.3"), _to_25_9_3), -] # A list of the Python API version tuple with their corresponding updaters. - - -# pylint: disable=dangerous-default-value -def _find_update_path( - *, - version_from: Flow360Version, - version_to: Flow360Version, - version_milestones: list[tuple[Flow360Version, Any]], -): - - if version_from == version_to: - return [] - - if version_from >= version_milestones[-1][0]: - return [] - - if version_to < version_milestones[0][0]: - raise ValueError( - "Trying to update `SimulationParams` to a version lower than any known version." - ) - - def _get_path_start(): - for index, item in enumerate(version_milestones): - milestone_version = item[0] - if milestone_version > version_from: - # exclude equal because then it is already `milestone_version` version - return index - return None - - def _get_path_end(): - for index, item in enumerate(version_milestones): - milestone_version = item[0] - if milestone_version > version_to: - return index - 1 - return len(version_milestones) - 1 - - path_start = _get_path_start() - path_end = _get_path_end() - - return [ - item[1] for index, item in enumerate(version_milestones) if path_start <= index <= path_end - ] - - -def updater(version_from, version_to, params_as_dict) -> dict: - """ - Update parameters from version_from to version_to. - - Parameters - ---------- - version_from : str - The starting version. - version_to : str - The target version to update to. This has to be equal or higher than `version_from` - params_as_dict : dict - A dictionary containing parameters to be updated. - - Returns - ------- - dict - Updated parameters as a dictionary. - - Raises - ------ - Flow360NotImplementedError - If no update path exists from version_from to version_to. - - Notes - ----- - This function iterates through the update map starting from version_from and - updates the parameters based on the update path found. - """ - log.debug(f"Input SimulationParam has version: {version_from}.") - version_from_is_newer = Flow360Version(version_from) > Flow360Version(version_to) - - if version_from_is_newer: - raise ValueError( - f"[Internal] Misuse of updater, version_from ({version_from}) is higher than version_to ({version_to})" - ) - update_functions = _find_update_path( - version_from=Flow360Version(version_from), - version_to=Flow360Version(version_to), - version_milestones=VERSION_MILESTONES, - ) - for fun in update_functions: - _to_version = re.search(r"_to_(\d+_\d+_\d+)", fun.__name__).group(1) - log.debug(f"Updating input SimulationParam to {_to_version}...") - params_as_dict = fun(params_as_dict) - params_as_dict.pop(_TOTAL_PRESSURE_CONVERTED_KEY, None) - params_as_dict["version"] = str(version_to) - return params_as_dict diff --git a/flow360/component/simulation/framework/updater_functions.py b/flow360/component/simulation/framework/updater_functions.py index 76efb15e6..fec914eb0 100644 --- a/flow360/component/simulation/framework/updater_functions.py +++ b/flow360/component/simulation/framework/updater_functions.py @@ -1,103 +1,9 @@ -"""Implementation of the updater functions. The updated.py should just import functions from here.""" - - -def fix_ghost_sphere_schema(*, params_as_dict: dict): - """ - The previous ghost farfield has wrong schema (bug) and therefore needs data alternation. - """ - - def i_am_outdated_ghost_sphere(*, data: dict): - """Identify if the current dict is a outdated ghost sphere.""" - if "type_name" in data.keys() and data["type_name"] == "GhostSphere": - return True - return False - - def recursive_fix_ghost_surface(*, data): - if isinstance(data, dict): - # 1. Check if this is a ghost sphere instance - if i_am_outdated_ghost_sphere(data=data): - data.pop("type_name") - data["private_attribute_entity_type_name"] = "GhostSphere" - - # 2. Otherwise, recurse into each item in the dictionary - for _, val in data.items(): - recursive_fix_ghost_surface( - data=val, - ) - - elif isinstance(data, list): - # Recurse into each item in the list - for _, item in enumerate(data): - recursive_fix_ghost_surface(data=item) - - recursive_fix_ghost_surface(data=params_as_dict) - - -def is_entity_dict(data: dict): - """Check if current dict is an Entity item""" - return data.get("name") and (data.get("private_attribute_entity_type_name") is not None) - - -def remove_entity_bucket_field(*, params_as_dict: dict): - """Recursively remove legacy private_attribute_registry_bucket_name from all entity dicts.""" - - def _recursive_remove(data): - if isinstance(data, dict): - if is_entity_dict(data=data): - data.pop("private_attribute_registry_bucket_name", None) - for value in data.values(): - _recursive_remove(value) - elif isinstance(data, list): - for element in data: - _recursive_remove(element) - - _recursive_remove(params_as_dict) - return params_as_dict - - -def populate_entity_id_with_name(*, params_as_dict: dict): - """ - Recursively populates the entity item's private_attribute_id with its name if - the private_attribute_id is none. - """ - - def recursive_populate_entity_id_with_name(*, data): - if isinstance(data, dict): - # Check if current dict is an Entity item - if is_entity_dict(data=data): - if "private_attribute_id" not in data or data["private_attribute_id"] is None: - data["private_attribute_id"] = data["name"] - - for value in data.values(): - recursive_populate_entity_id_with_name(data=value) - - elif isinstance(data, list): - for element in data: - recursive_populate_entity_id_with_name(data=element) - - recursive_populate_entity_id_with_name(data=params_as_dict) - - -def update_symmetry_ghost_entity_name_to_symmetric(*, params_as_dict: dict): - """ - Recursively update ghost entity name from symmetric-* to symmetry-* - """ - - def recursive_update_symmetry_ghost_entity_name_to_symmetric(*, data): - if isinstance(data, dict): - # Check if current dict is an Entity item - if ( - is_entity_dict(data=data) - and data["private_attribute_entity_type_name"] == "GhostCircularPlane" - and data["name"].startswith("symmetry") - ): - data["name"] = data["name"].replace("symmetry", "symmetric") - - for value in data.values(): - recursive_update_symmetry_ghost_entity_name_to_symmetric(data=value) - - elif isinstance(data, list): - for element in data: - recursive_update_symmetry_ghost_entity_name_to_symmetric(data=element) - - recursive_update_symmetry_ghost_entity_name_to_symmetric(data=params_as_dict) +"""Relay schema-owned updater helper functions for simulation models.""" + +from flow360_schema.models.simulation.framework.updater_functions import ( + fix_ghost_sphere_schema, + is_entity_dict, + populate_entity_id_with_name, + remove_entity_bucket_field, + update_symmetry_ghost_entity_name_to_symmetric, +) diff --git a/flow360/component/simulation/framework/updater_utils.py b/flow360/component/simulation/framework/updater_utils.py index f16893efa..147709cac 100644 --- a/flow360/component/simulation/framework/updater_utils.py +++ b/flow360/component/simulation/framework/updater_utils.py @@ -1,179 +1,12 @@ -"""Utiliy functions for updater""" - -import re -from functools import wraps -from numbers import Number -from typing import Tuple - -import numpy as np - -from flow360.version import __version__ - - -def recursive_remove_key(data, key: str, *additional_keys: str): - """Recursively remove one or more keys from nested dict/list structures in place. - - This function performs an in-place traversal without unnecessary allocations - to preserve performance. It handles arbitrarily nested combinations of - dictionaries and lists. - """ - keys: Tuple[str, ...] = (key,) + additional_keys - - # Iterative traversal is typically faster than deep recursion in Python and - # avoids recursion depth limits for heavily nested WebUI payloads. - stack = [data] - while stack: - current = stack.pop() - - if isinstance(current, dict): - for item_key in keys: - current.pop(item_key, None) - stack.extend(current.values()) - elif isinstance(current, list): - stack.extend(current) - - -PYTHON_API_VERSION_REGEXP = r"^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?$" - - -def compare_dicts(dict1, dict2, atol=1e-15, rtol=1e-10, ignore_keys=None): - """Check two dictionaries are same or not""" - if ignore_keys is None: - ignore_keys = set() - - # Filter out the keys to be ignored - dict1_filtered = {k: v for k, v in dict1.items() if k not in ignore_keys} - dict2_filtered = {k: v for k, v in dict2.items() if k not in ignore_keys} - - if dict1_filtered.keys() != dict2_filtered.keys(): - print( - f"dict keys not equal:\n dict1 {sorted(dict1_filtered.keys())}\n dict2 {sorted(dict2_filtered.keys())}" - ) - return False - - for key in dict1_filtered: - value1 = dict1_filtered[key] - value2 = dict2_filtered[key] - - if not compare_values(value1, value2, atol, rtol, ignore_keys): - print(f"dict value of key {key} not equal:\n dict1 {dict1[key]}\n dict2 {dict2[key]}") - return False - - return True - - -def compare_values(value1, value2, atol=1e-15, rtol=1e-10, ignore_keys=None): - """Check two values are same or not""" - # Handle numerical comparisons first (including int vs float) - if isinstance(value1, Number) and isinstance(value2, Number): - return np.isclose(value1, value2, rtol, atol) - - # Tuples get converted to lists when writing the JSON file - if isinstance(value1, tuple): - value1 = list(value1) - - if isinstance(value2, tuple): - value2 = list(value2) - - # Handle type mismatches for non-numerical types - if type(value1) != type(value2): - return False - - if isinstance(value1, dict) and isinstance(value2, dict): - return compare_dicts(value1, value2, atol, rtol, ignore_keys) - if isinstance(value1, list) and isinstance(value2, list): - return compare_lists(value1, value2, atol, rtol, ignore_keys) - return value1 == value2 - - -def compare_lists(list1, list2, atol=1e-15, rtol=1e-10, ignore_keys=None): - """Check two lists are same or not""" - if len(list1) != len(list2): - return False - - # Only sort if the lists contain simple comparable types (not dicts, lists, etc.) - def is_simple_type(item): - return isinstance(item, (str, int, float, bool)) or ( - isinstance(item, Number) and not isinstance(item, (dict, list)) - ) - - if list1 and all(is_simple_type(item) for item in list1): - list1, list2 = sorted(list1), sorted(list2) - - for item1, item2 in zip(list1, list2): - if not compare_values(item1, item2, atol, rtol, ignore_keys): - print(f"list value not equal:\n list1 {item1}\n list2 {item2}") - return False - - return True - - -class Flow360Version: - """ - Parser for the Flow360 Python API version. - Expected pattern is `major.minor.patch` (integers). - """ - - __slots__ = ["major", "minor", "patch"] - - def __init__(self, version: str): - """ - Initialize the version by parsing a string like '23.1.2'. - Each of major, minor, patch should be numeric. - """ - # Match three groups of digits separated by dots - match = re.match(PYTHON_API_VERSION_REGEXP, version.strip()) - if not match: - raise ValueError(f"Invalid version string: {version}") - - self.major = int(match.group(1)) - self.minor = int(match.group(2)) - self.patch = int(match.group(3)) - - def __lt__(self, other): - return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch) - - def __le__(self, other): - return (self.major, self.minor, self.patch) <= (other.major, other.minor, other.patch) - - def __gt__(self, other): - return (self.major, self.minor, self.patch) > (other.major, other.minor, other.patch) - - def __ge__(self, other): - return (self.major, self.minor, self.patch) >= (other.major, other.minor, other.patch) - - def __eq__(self, other): - # Also check that 'other' is the same type or has the same attributes - if not isinstance(other, Flow360Version): - return NotImplemented - return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch) - - def __ne__(self, other): - return not self.__eq__(other) - - def __str__(self): - return f"{self.major}.{self.minor}.{self.patch}" - - -def deprecation_reminder(version: str): - """ - If your_package.__version__ > version, raise. - Otherwise, do nothing special. - """ - - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - current = Flow360Version(__version__) - target = Flow360Version(version) - if current > target: - raise ValueError( - f"[INTERNAL] This validator or function is detecting/handling deprecated schema that was" - f" scheduled to be removed since {version}. " - "Please deprecate the schema now, write updater and remove related checks." - ) - return func(*args, **kwargs) - - return wrapper - - return decorator +"""Relay schema-owned updater utilities for simulation models.""" + +from flow360_schema.models.simulation.framework.updater_utils import ( + FLOW360_SCHEMA_DEFAULT_VERSION, + PYTHON_API_VERSION_REGEXP, + Flow360Version, + compare_dicts, + compare_lists, + compare_values, + deprecation_reminder, + recursive_remove_key, +) diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 88a89c3ec..a5cfddde1 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -1,7 +1,5 @@ """Simulation services module.""" -# pylint: disable=duplicate-code, too-many-lines -import copy import json import os from typing import ( @@ -23,6 +21,7 @@ _insert_forward_compatibility_notice, _intersect_validation_levels, _normalize_union_branch_error_location, + _parse_root_item_type_from_simulation_json, _populate_error_context, _sanitize_stack_trace, _traverse_error_location, @@ -30,21 +29,18 @@ clear_context, handle_generic_exception, initialize_variable_space, + apply_simulation_setting_to_entity_info, + merge_geometry_entity_info, + update_simulation_json, validate_error_locations, validate_model as _schema_validate_model, ) from pydantic import TypeAdapter -from flow360.component.simulation.entity_info import GeometryEntityInfo -from flow360.component.simulation.entity_info import ( - merge_geometry_entity_info as merge_geometry_entity_info_obj, -) -from flow360.component.simulation.entity_info import parse_entity_info_model from flow360.component.simulation.exposed_units import supported_units_by_front_end from flow360.component.simulation.framework.entity_materializer import ( materialize_entities_and_selectors_in_place, ) -from flow360.component.simulation.framework.entity_registry import EntityRegistry from flow360.component.simulation.meshing_param.params import MeshingParams from flow360.component.simulation.meshing_param.volume_params import AutomatedFarfield from flow360.component.simulation.models.bet.bet_translator_interface import ( @@ -90,7 +86,6 @@ from flow360.component.simulation.units import validate_length from flow360.component.simulation.validation.validation_context import ALL from flow360.exceptions import ( - Flow360RuntimeError, Flow360TranslationError, Flow360ValueError, ) @@ -590,29 +585,6 @@ def change_unit_system(*, data: dict, target_unit_system: Literal["SI", "Imperia return data -def update_simulation_json(*, params_as_dict: dict, target_python_api_version: str): - """ - Run the SimulationParams' updater to update to specified version. - """ - errors = [] - updated_params_as_dict: dict = None - try: - # pylint:disable = protected-access - updated_params_as_dict, input_has_higher_version = SimulationParams._update_param_dict( - params_as_dict, target_python_api_version - ) - if input_has_higher_version: - raise ValueError( - f"[Internal] API misuse. Input version " - f"({SimulationParams._get_version_from_dict(model_dict=params_as_dict)}) is higher than " - f"requested target version ({target_python_api_version})." - ) - except (Flow360RuntimeError, ValueError, KeyError) as e: - # Expected exceptions - errors.append(str(e)) - return updated_params_as_dict, errors - - def _serialize_unit_in_dict(data): """ Recursively serialize unit type data in a dictionary or list. @@ -748,287 +720,3 @@ def translate_xfoil_c81_bet_disk( errors.append(str(e)) return bet_dict_list, errors - -def _parse_root_item_type_from_simulation_json(*, param_as_dict: dict): - """[External] Deduct the root item entity type from simulation.json""" - try: - entity_info_type = param_as_dict["private_attribute_asset_cache"]["project_entity_info"][ - "type_name" - ] - if entity_info_type == "GeometryEntityInfo": - return "Geometry" - if entity_info_type == "SurfaceMeshEntityInfo": - return "SurfaceMesh" - if entity_info_type == "VolumeMeshEntityInfo": - return "VolumeMesh" - raise ValueError(f"[INTERNAL] Invalid type of the entity info found: {entity_info_type}") - except KeyError: - # pylint:disable = raise-missing-from - raise ValueError("[INTERNAL] Failed to get the root item from the simulation.json!!!") - - -def merge_geometry_entity_info( - draft_param_as_dict: dict, geometry_dependencies_param_as_dict: list[dict] -): - """ - Merge the geometry entity info from geometry dependencies into the draft simulation param dict. - - Parameters - ---------- - draft_param_as_dict : dict - The draft simulation parameters dictionary. - geometry_dependencies_param_as_dict : list of dict - The list of geometry dependencies simulation parameters dictionaries. - - Returns - ------- - dict - The updated draft simulation parameters dictionary with merged geometry entity info. - """ - draft_param_entity_info_dict = draft_param_as_dict.get("private_attribute_asset_cache", {}).get( - "project_entity_info", {} - ) - if draft_param_entity_info_dict.get("type_name") != "GeometryEntityInfo": - return draft_param_as_dict - - current_entity_info = GeometryEntityInfo.deserialize(draft_param_entity_info_dict) - - entity_info_components = [] - for geometry_param_as_dict in geometry_dependencies_param_as_dict: - dependency_entity_info_dict = geometry_param_as_dict.get( - "private_attribute_asset_cache", {} - ).get("project_entity_info", {}) - if dependency_entity_info_dict.get("type_name") != "GeometryEntityInfo": - continue - entity_info_components.append(GeometryEntityInfo.deserialize(dependency_entity_info_dict)) - - merged_entity_info = merge_geometry_entity_info_obj( - current_entity_info=current_entity_info, - entity_info_components=entity_info_components, - ) - merged_entity_info_dict = merged_entity_info.model_dump(mode="json", exclude_none=True) - - return merged_entity_info_dict - - -# Draft entity type names that should be preserved during entity replacement. -# Draft entities (Box, Cylinder, etc.) are user-defined and not tied to uploaded files, -# so they should be kept from the source simulation settings. -# Ghost entities are associated with the geometry/mesh and should be replaced with target's. -def _get_draft_entity_type_names() -> set: - """Extract entity type names from DraftEntityTypes in entity_info.py.""" - # pylint: disable=import-outside-toplevel - import types - from typing import get_args, get_origin - - from flow360.component.simulation.entity_info import EntityInfoModel - - type_names = set() - - # Get draft_entities field type - draft_field = EntityInfoModel.model_fields[ # pylint:disable=unsubscriptable-object - "draft_entities" - ] - draft_annotation = draft_field.annotation - # Unwrap List[Annotated[Union[...], ...]] -> Union[...] - inner_type = get_args(draft_annotation)[0] # Get inner type from List - union_args = get_args(inner_type) # Get Annotated args - if union_args: - actual_union = union_args[0] # First arg is the Union - # Support both typing.Union and types.UnionType (X | Y syntax in Python 3.10+) - if get_origin(actual_union) is Union or isinstance(actual_union, types.UnionType): - for cls in get_args(actual_union): - type_names.add(cls.__name__) - - return type_names - - -DRAFT_ENTITY_TYPE_NAMES = _get_draft_entity_type_names() - - -def _replace_entities_by_type_and_name( - template_dict: dict, - target_registry: EntityRegistry, -) -> Tuple[dict, List[Dict[str, Any]]]: - """ - Traverse template_dict and replace stored_entities with matching entities from target_registry. - - Matching strategy: - - Use private_attribute_entity_type_name (e.g., "Surface", "Edge") to determine type - - Use name field for name matching - - Draft entity types (Box, Cylinder, etc.) are preserved without matching since they are - user-defined and not tied to uploaded files - - Ghost and persistent entity types are matched and replaced - - Parameters - ---------- - template_dict : dict - The simulation settings dictionary to process - target_registry : EntityRegistry - Registry containing target entities for replacement - - Returns - ------- - Tuple[dict, List[Dict[str, Any]]] - (Updated dictionary, List of warnings for unmatched entities) - """ - warnings = [] - - # Pre-build lookup dictionary for performance: {(type_name, name): entity_dict} - entity_lookup: Dict[Tuple[str, str], dict] = {} - for entity_list in target_registry.internal_registry.values(): - for entity in entity_list: - key = (entity.private_attribute_entity_type_name, entity.name) - entity_lookup[key] = entity.model_dump(mode="json", exclude_none=True) - - def process_stored_entities(stored_entities: list) -> list: - """Process stored_entities list, replacing or removing entities.""" - new_stored = [] - for entity_dict in stored_entities: - entity_type_name = entity_dict.get("private_attribute_entity_type_name") - entity_name = entity_dict.get("name") - - # Preserve Draft types directly (user-defined, not tied to uploaded files) - if entity_type_name in DRAFT_ENTITY_TYPE_NAMES: - new_stored.append(entity_dict) - continue - - # Persistent types need matching replacement - key = (entity_type_name, entity_name) - if key in entity_lookup: - new_stored.append(entity_lookup[key]) - else: - # Skip unmatched entities, record warning - warnings.append( - { - "type": "entity_not_found", - "loc": ["stored_entities"], - "msg": f"Entity '{entity_name}' (type: {entity_type_name}) not found in target entity info", - "ctx": {}, - } - ) - return new_stored - - def traverse_and_replace(obj): - """Recursively traverse dict/list to find and process stored_entities.""" - if isinstance(obj, dict): - if "stored_entities" in obj and isinstance(obj["stored_entities"], list): - obj["stored_entities"] = process_stored_entities(obj["stored_entities"]) - for value in obj.values(): - traverse_and_replace(value) - elif isinstance(obj, list): - for item in obj: - traverse_and_replace(item) - - traverse_and_replace(template_dict) - return template_dict, warnings - - -def apply_simulation_setting_to_entity_info( # pylint:disable=too-many-locals - simulation_setting_dict: dict, - entity_info_dict: dict, -): - """ - Apply simulation settings from one project to another project with different entity info. - - This function merges simulation settings (case/meshing configuration) from a source - simulation.json with the entity info from a target simulation.json. It handles entity - matching by type and name, preserving user-defined draft entities while replacing - persistent and ghost entities with those from the target. - - Parameters - ---------- - simulation_setting_dict : dict - A simulation.json dictionary that provides case/meshing settings. - This is the "source" that contains the settings to be applied. - entity_info_dict : dict - A simulation.json dictionary that provides the entity info (surfaces, edges, etc.). - This is the "target" whose entities will be used in the result. - - Returns - ------- - Tuple[dict, Optional[List], List[Dict[str, Any]]] - A tuple containing: - - result_dict: The merged simulation.json dictionary - - errors: List of validation errors, or None if validation passed - - warnings: List of warnings (unmatched entities + validation warnings) - - Notes - ----- - Entity handling: - - Persistent entities (Surface, Edge, GenericVolume, etc.): Matched by (type, name) - and replaced with target's entities. Unmatched entities are removed with warnings. - - Draft entities (Box, Cylinder, Point, etc.): Preserved from source without matching, - as they are user-defined and not tied to uploaded files. - - Ghost entities: Replaced with target's, as they are associated with the geometry/mesh. - - For GeometryEntityInfo, grouping tags (face_group_tag, body_group_tag, edge_group_tag) - are inherited from the source to ensure consistent entity selection. - """ - # pylint:disable=protected-access - # Step 1: Preprocess both input dicts - simulation_setting_dict = SimulationParams._sanitize_params_dict(simulation_setting_dict) - simulation_setting_dict, _ = SimulationParams._update_param_dict(simulation_setting_dict) - entity_info_dict = SimulationParams._sanitize_params_dict(entity_info_dict) - entity_info_dict, _ = SimulationParams._update_param_dict(entity_info_dict) - - # Step 2: Extract entity_info from both dicts - target_entity_info_data = entity_info_dict.get("private_attribute_asset_cache", {}).get( - "project_entity_info", {} - ) - source_entity_info = simulation_setting_dict.get("private_attribute_asset_cache", {}).get( - "project_entity_info", {} - ) - - # Step 3: Merge entity_info (use target's persistent entities, preserve source's draft entities) - merged_entity_info = copy.deepcopy(target_entity_info_data) - # Preserve draft entities from source (user-defined, not tied to uploaded files) - # Ghost entities stay from target as they are associated with the geometry/mesh - merged_entity_info["draft_entities"] = source_entity_info.get("draft_entities", []) - # Preserve grouping tags from source (only for GeometryEntityInfo) - # This ensures the registry is built with the correct grouping selection - # Only copy grouping tags if target is also GeometryEntityInfo to avoid invalid keys - if target_entity_info_data.get("type_name") == "GeometryEntityInfo": - # Map each tag to its corresponding attribute_names field - tag_to_attr_names = { - "face_group_tag": "face_attribute_names", - "body_group_tag": "body_attribute_names", - "edge_group_tag": "edge_attribute_names", - } - for tag_key, attr_names_key in tag_to_attr_names.items(): - source_tag = source_entity_info.get(tag_key) - if source_tag is not None: - # Only use source's tag if it exists in target's attribute_names - # Otherwise keep target's tag to avoid empty registry - target_attr_names = target_entity_info_data.get(attr_names_key, []) - if source_tag in target_attr_names: - merged_entity_info[tag_key] = source_tag - # else: keep target's original tag (already in merged_entity_info from deepcopy) - - # Step 4: Build registry from merged entity_info (with source's grouping tags) - merged_entity_info_obj = parse_entity_info_model(merged_entity_info) - target_registry = EntityRegistry.from_entity_info(merged_entity_info_obj) - - # Update simulation_setting_dict with merged entity_info - simulation_setting_dict["private_attribute_asset_cache"][ - "project_entity_info" - ] = merged_entity_info - - # Step 5: Replace entities in stored_entities - simulation_setting_dict, entity_warnings = _replace_entities_by_type_and_name( - simulation_setting_dict, target_registry - ) - - # Step 6: Validate and return results - root_item_type = _parse_root_item_type_from_simulation_json( - param_as_dict=simulation_setting_dict - ) - _, errors, validation_warnings = validate_model( - params_as_dict=copy.deepcopy(simulation_setting_dict), - validated_by=ValidationCalledBy.SERVICE, - root_item_type=root_item_type, - validation_level=ALL, - ) - - all_warnings = entity_warnings + validation_warnings - return simulation_setting_dict, errors, all_warnings diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index b8232e2fe..0c252efb2 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -6,7 +6,6 @@ from flow360_schema.framework.expression import ( SerializedValueOrExpression, UnytQuantity, - UserVariable, ValueOrExpression, ) from flow360_schema.framework.expression.value_or_expression import ( @@ -18,30 +17,15 @@ from flow360_schema.framework.expression.variable import ( save_user_variables as _schema_save_user_variables, ) +from flow360_schema.models.simulation.user_code.core.types import ( + get_post_processing_variables, +) from flow360.component.simulation.framework.updater_utils import deprecation_reminder register_deprecation_check(deprecation_reminder) -def get_post_processing_variables(params) -> set[str]: - """ - Get all the post processing related variables from the simulation params. - """ - post_processing_variables = set() - for item in params.outputs if params.outputs else []: - if item.output_type in ("IsosurfaceOutput", "TimeAverageIsosurfaceOutput"): - for isosurface in item.entities.items: - if isinstance(isosurface.field, UserVariable): - post_processing_variables.add(isosurface.field.name) - if "output_fields" not in item.__class__.model_fields: - continue - for output_field in item.output_fields.items: - if isinstance(output_field, UserVariable): - post_processing_variables.add(output_field.name) - return post_processing_variables - - def save_user_variables(params): """Client adapter: extract data from params, delegate to schema.""" post_processing_variables = get_post_processing_variables(params) diff --git a/flow360/component/simulation/utils.py b/flow360/component/simulation/utils.py index 93e07676e..0560af2b1 100644 --- a/flow360/component/simulation/utils.py +++ b/flow360/component/simulation/utils.py @@ -1,60 +1,9 @@ -"""Utility functions for the simulation component.""" - -# pylint: disable=unused-import -from typing import Annotated, Union, get_args, get_origin +"""Relay schema-owned utility functions for the simulation component.""" from flow360_schema.framework.bounding_box import BoundingBox, BoundingBoxType - -from flow360.component.simulation.framework.updater_utils import recursive_remove_key - - -def sanitize_params_dict(model_dict): - """ - !!!WARNING!!!: This function changes the input dict in place!!! - - Clean the redundant content in the params dict from WebUI - """ - recursive_remove_key(model_dict, "_id", "private_attribute_image_id") - - model_dict.pop("hash", None) - - return model_dict - - -def get_combined_subclasses(cls): - """get subclasses of cls""" - if isinstance(cls, tuple): - subclasses = set() - for single_cls in cls: - subclasses.update(single_cls.__subclasses__()) - return list(subclasses) - return cls.__subclasses__() - - -def is_exact_instance(obj, cls): - """Check if an object is an instance of a class and not a subclass.""" - if isinstance(cls, tuple): - return any(is_exact_instance(obj, c) for c in cls) - if not isinstance(obj, cls): - return False - # Check if there are any subclasses of cls - subclasses = get_combined_subclasses(cls) - for subclass in subclasses: - if isinstance(obj, subclass): - return False - return True - - -def is_instance_of_type_in_union(obj, typ) -> bool: - """Check whether input `obj` is instance of the types specified in the `Union`(`typ`)""" - # If typ is an Annotated type, extract the underlying type. - if get_origin(typ) is Annotated: - typ = get_args(typ)[0] - - # If the underlying type is a Union, extract its arguments (which are types). - if get_origin(typ) is Union: - types_tuple = get_args(typ) - return isinstance(obj, types_tuple) - - # Otherwise, do a normal isinstance check. - return isinstance(obj, typ) +from flow360_schema.models.simulation.utils import ( + get_combined_subclasses, + is_exact_instance, + is_instance_of_type_in_union, + sanitize_params_dict, +) diff --git a/tests/simulation/params/data/simulation_stopping_criterion_webui.json b/tests/simulation/params/data/simulation_stopping_criterion_webui.json deleted file mode 100644 index 74bd9465d..000000000 --- a/tests/simulation/params/data/simulation_stopping_criterion_webui.json +++ /dev/null @@ -1,1171 +0,0 @@ -{ - "meshing": { - "defaults": { - "boundary_layer_first_layer_thickness": { - "units": "m", - "value": 1e-06 - }, - "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": { - "units": "degree", - "value": 12 - }, - "planar_face_tolerance": 1e-06, - "surface_edge_growth_rate": 1.2, - "surface_max_edge_length": { - "units": "m", - "value": 0.15 - } - }, - "gap_treatment_strength": 0, - "refinement_factor": 1, - "volume_zones": [ - { - "_id": "e4c4828d-0d4c-4a55-b973-7fede4b5554a", - "method": "auto", - "name": "Farfield", - "type": "AutomatedFarfield" - } - ] - }, - "models": [ - { - "_id": "1296ad9c-6678-4195-817f-d6e9781fc6a3", - "initial_condition": { - "p": "p", - "rho": "rho", - "type_name": "NavierStokesInitialCondition", - "u": "u", - "v": "v", - "w": "w" - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30 - }, - "low_mach_preconditioner": false, - "low_mach_preconditioner_threshold": null, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1, - "order_of_accuracy": 2, - "relative_tolerance": 0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "transition_model_solver": { - "type_name": "None" - }, - "turbulence_model_solver": { - "CFL_multiplier": 2, - "absolute_tolerance": 1e-05, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20 - }, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_cb1": 0.1355, - "C_cb2": 0.622, - "C_d": 8, - "C_min_rd": 10, - "C_sigma": 0.6666666666666666, - "C_t3": 1.2, - "C_t4": 0.5, - "C_v1": 7.1, - "C_vonKarman": 0.41, - "C_w2": 0.3 - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - }, - { - "_id": "908d2d51-c73d-4a07-82cd-3ac84272ad7c", - "entities": { - "stored_entities": [ - { - "name": "body00001", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "groupByBodyId" - } - ] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": { - "units": "W/m**2", - "value": 0 - } - }, - "name": "Wall", - "roughness_height": { - "units": "m", - "value": 0 - }, - "type": "Wall", - "use_wall_function": false - }, - { - "_id": "c4daabf6-5af4-4dbb-a110-4d1723adf353", - "entities": { - "stored_entities": [ - { - "_id": "8ae37a4b-6970-5d88-aef5-43a1abcc845e", - "name": "farfield", - "private_attribute_entity_type_name": "GhostSphere", - "private_attribute_id": "farfield", - "private_attribute_registry_bucket_name": "SurfaceEntityType" - } - ] - }, - "name": "Freestream", - "type": "Freestream" - } - ], - "operating_condition": { - "alpha": { - "units": "degree", - "value": 0 - }, - "beta": { - "units": "degree", - "value": 0 - }, - "private_attribute_constructor": "default", - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "private_attribute_constructor": "default", - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition", - "velocity_magnitude": { - "type_name": "number", - "units": "m/s", - "value": 20 - } - }, - "outputs": [ - { - "_id": "a6cd480b-f5c8-4516-8188-8506ab42469b", - "entities": { - "stored_entities": [ - { - "name": "body00001", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "groupByBodyId" - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": { - "items": [ - "Cp", - "yPlus", - "Cf", - "CfVec" - ] - }, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "private_attribute_id": "a6cd480b-f5c8-4516-8188-8506ab42469b", - "write_single_file": false - }, - { - "_id": "8b6c81a9-a409-44a1-9a85-4866f596a36d", - "entities": { - "stored_entities": [ - { - "location": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "name": "Point", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "3b6432ec-0b16-4626-a75f-fdef7f93e2af" - }, - { - "end": { - "units": "m", - "value": [ - 1, - 0, - 0 - ] - }, - "name": "Point array", - "number_of_points": 10, - "private_attribute_entity_type_name": "PointArray", - "private_attribute_id": "91cbc3d3-807d-4efe-a41a-8ea5f45e80fb", - "start": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - } - } - ] - }, - "moving_statistic": { - "method": "max", - "moving_window_size": 10, - "start_step": 20, - "type_name": "MovingStatistic" - }, - "name": "Probe output", - "output_fields": { - "items": [ - "Mach" - ] - }, - "output_type": "ProbeOutput", - "private_attribute_id": "8b6c81a9-a409-44a1-9a85-4866f596a36d" - }, - { - "_id": "a0fd1488-73b9-404d-a6ab-ec8427f7483f", - "entities": { - "stored_entities": [ - { - "location": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "name": "Point", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "3b6432ec-0b16-4626-a75f-fdef7f93e2af" - }, - { - "end": { - "units": "m", - "value": [ - 1, - 0, - 0 - ] - }, - "name": "Point array", - "number_of_points": 10, - "private_attribute_entity_type_name": "PointArray", - "private_attribute_id": "91cbc3d3-807d-4efe-a41a-8ea5f45e80fb", - "start": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - } - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "moving_statistic": { - "method": "mean", - "moving_window_size": 10, - "start_step": 0, - "type_name": "MovingStatistic" - }, - "name": "Time-averaging probe output", - "output_fields": { - "items": [ - { - "name": "velocity_with_units", - "type_name": "UserVariable" - }, - "Cp" - ] - }, - "output_type": "TimeAverageProbeOutput", - "private_attribute_id": "a0fd1488-73b9-404d-a6ab-ec8427f7483f" - }, - { - "_id": "ad089c5d-f357-45e7-bbb9-c11297eea170", - "entities": { - "stored_entities": [ - { - "location": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "name": "Point", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "3b6432ec-0b16-4626-a75f-fdef7f93e2af" - } - ] - }, - "moving_statistic": { - "method": "min", - "moving_window_size": 10, - "start_step": 0, - "type_name": "MovingStatistic" - }, - "name": "Probe output", - "output_fields": { - "items": [ - { - "name": "velocity_magnitude_with_units", - "type_name": "UserVariable" - } - ] - }, - "output_type": "ProbeOutput", - "private_attribute_id": "ad089c5d-f357-45e7-bbb9-c11297eea170" - }, - { - "_id": "b76839cf-d46d-494e-a5cb-a1fa69d4e608", - "entities": { - "stored_entities": [ - { - "location": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "name": "Point", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "3b6432ec-0b16-4626-a75f-fdef7f93e2af" - }, - { - "end": { - "units": "m", - "value": [ - 1, - 0, - 0 - ] - }, - "name": "Point array", - "number_of_points": 10, - "private_attribute_entity_type_name": "PointArray", - "private_attribute_id": "91cbc3d3-807d-4efe-a41a-8ea5f45e80fb", - "start": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - } - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "moving_statistic": { - "method": "standard_deviation", - "moving_window_size": 10, - "start_step": 0, - "type_name": "MovingStatistic" - }, - "name": "Time-averaging probe output", - "output_fields": { - "items": [ - { - "name": "velocity_magnitude_with_units", - "type_name": "UserVariable" - }, - "Cp", - "Cpt", - "mut", - "solutionNavierStokes", - "residualTransition" - ] - }, - "output_type": "TimeAverageProbeOutput", - "private_attribute_id": "b76839cf-d46d-494e-a5cb-a1fa69d4e608", - "start_step": 1 - }, - { - "_id": "d68e6448-9862-435a-8b89-8d9cb3638df9", - "entities": { - "stored_entities": [ - { - "location": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "name": "Point", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "3b6432ec-0b16-4626-a75f-fdef7f93e2af" - }, - { - "end": { - "units": "m", - "value": [ - 1, - 0, - 0 - ] - }, - "name": "Point array", - "number_of_points": 10, - "private_attribute_entity_type_name": "PointArray", - "private_attribute_id": "91cbc3d3-807d-4efe-a41a-8ea5f45e80fb", - "start": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - } - } - ] - }, - "moving_statistic": { - "method": "range", - "moving_window_size": 10, - "start_step": 0, - "type_name": "MovingStatistic" - }, - "name": "Surface probe output", - "output_fields": { - "items": [ - "Mach", - { - "name": "velocity_with_units", - "type_name": "UserVariable" - }, - { - "name": "pressure_with_units", - "type_name": "UserVariable" - }, - "Cp", - "Cpt" - ] - }, - "output_type": "SurfaceProbeOutput", - "private_attribute_id": "d68e6448-9862-435a-8b89-8d9cb3638df9", - "target_surfaces": { - "stored_entities": [ - { - "name": "body00001", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "groupByBodyId" - } - ] - } - } - ], - "private_attribute_asset_cache": { - "project_entity_info": { - "body_attribute_names": [ - "bodyId", - "groupByFile" - ], - "body_group_tag": "groupByFile", - "body_ids": [ - "body00001" - ], - "draft_entities": [ - { - "location": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "name": "Point", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "3b6432ec-0b16-4626-a75f-fdef7f93e2af" - }, - { - "end": { - "units": "m", - "value": [ - 1, - 0, - 0 - ] - }, - "name": "Point array", - "number_of_points": 10, - "private_attribute_entity_type_name": "PointArray", - "private_attribute_id": "91cbc3d3-807d-4efe-a41a-8ea5f45e80fb", - "start": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - } - } - ], - "edge_attribute_names": [ - "edgeId" - ], - "edge_group_tag": "edgeId", - "edge_ids": [ - "body00001_edge00001", - "body00001_edge00002", - "body00001_edge00003", - "body00001_edge00004", - "body00001_edge00005", - "body00001_edge00006", - "body00001_edge00007", - "body00001_edge00008", - "body00001_edge00009", - "body00001_edge00010", - "body00001_edge00011", - "body00001_edge00012" - ], - "face_attribute_names": [ - "groupByBodyId", - "faceId" - ], - "face_group_tag": "groupByBodyId", - "face_ids": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "ghost_entities": [ - { - "center": [ - 0, - 0, - 0 - ], - "max_radius": 5.000010000000002, - "name": "farfield", - "private_attribute_entity_type_name": "GhostSphere", - "private_attribute_full_name": null, - "private_attribute_id": "farfield", - "private_attribute_registry_bucket_name": "SurfaceEntityType" - }, - { - "center": [ - 0, - -0.010000100000000005, - 0 - ], - "max_radius": 0.10000020000000005, - "name": "symmetric-1", - "normal_axis": [ - 0, - -1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_full_name": null, - "private_attribute_id": "symmetric-1", - "private_attribute_registry_bucket_name": "SurfaceEntityType" - }, - { - "center": [ - 0, - 0.010000100000000005, - 0 - ], - "max_radius": 0.10000020000000005, - "name": "symmetric-2", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_full_name": null, - "private_attribute_id": "symmetric-2", - "private_attribute_registry_bucket_name": "SurfaceEntityType" - }, - { - "center": [ - 0, - 0.010000100000000005, - 0 - ], - "max_radius": 0.10000020000000005, - "name": "symmetric", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_full_name": null, - "private_attribute_id": "symmetric", - "private_attribute_registry_bucket_name": "SurfaceEntityType" - } - ], - "global_bounding_box": [ - [ - -0.050000100000000026, - -0.010000100000000005, - -0.050000100000000026 - ], - [ - 0.050000100000000026, - 0.010000100000000005, - 0.050000100000000026 - ] - ], - "grouped_bodies": [ - [ - { - "mesh_exterior": true, - "name": "body00001", - "private_attribute_color": null, - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "body00001", - "private_attribute_registry_bucket_name": "GeometryBodyGroupEntityType", - "private_attribute_sub_components": [ - "body00001" - ], - "private_attribute_tag_key": "bodyId", - "transformation": { - "angle_of_rotation": { - "units": "degree", - "value": 0 - }, - "axis_of_rotation": [ - 1, - 0, - 0 - ], - "origin": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "scale": [ - 1, - 1, - 1 - ], - "translation": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "type_name": "BodyGroupTransformation" - } - } - ], - [ - { - "mesh_exterior": true, - "name": "wheel.step", - "private_attribute_color": null, - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "wheel.step", - "private_attribute_registry_bucket_name": "GeometryBodyGroupEntityType", - "private_attribute_sub_components": [ - "body00001" - ], - "private_attribute_tag_key": "groupByFile", - "transformation": { - "angle_of_rotation": { - "units": "degree", - "value": 0 - }, - "axis_of_rotation": [ - 1, - 0, - 0 - ], - "origin": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "scale": [ - 1, - 1, - 1 - ], - "translation": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "type_name": "BodyGroupTransformation" - } - } - ] - ], - "grouped_edges": [ - [ - { - "name": "body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00001", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00001" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00002", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00002", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00002" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00003", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00003" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00004", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00004", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00004" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00005", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00005", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00005" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00006", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00006", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00006" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00007", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00007", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00007" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00008", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00008", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00008" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00009", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00009", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00009" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00010", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00010", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00010" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00011", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00011", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00011" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00012", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "body00001_edge00012", - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body00001_edge00012" - ], - "private_attribute_tag_key": "edgeId" - } - ] - ], - "grouped_faces": [ - [ - { - "name": "body00001", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "groupByBodyId" - } - ], - [ - { - "name": "body00001_face00001", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001_face00001", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00001" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body00001_face00002", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001_face00002", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00002" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body00001_face00003", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001_face00003", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00003" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body00001_face00004", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001_face00004", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00004" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body00001_face00005", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001_face00005", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00005" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body00001_face00006", - "private_attribute_color": null, - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": "body00001_face00006", - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body00001_face00006" - ], - "private_attribute_tag_key": "faceId" - } - ] - ], - "type_name": "GeometryEntityInfo" - }, - "project_length_unit": { - "units": "m", - "value": 1 - }, - "use_geometry_AI": false, - "use_inhouse_mesher": false, - "variable_context": [ - { - "name": "velocity_with_units", - "value": { - "expression": "solution.velocity", - "output_units": "SI_unit_system", - "type_name": "expression" - } - }, - { - "name": "velocity_magnitude_with_units", - "value": { - "expression": "math.magnitude(solution.velocity)", - "output_units": "SI_unit_system", - "type_name": "expression" - } - }, - { - "name": "pressure_with_units", - "value": { - "expression": "solution.pressure", - "output_units": "SI_unit_system", - "type_name": "expression" - } - }, - { - "name": "wall_shear_stress_magnitude_with_units", - "value": { - "expression": "solution.wall_shear_stress_magnitude", - "output_units": "SI_unit_system", - "type_name": "expression" - } - } - ] - }, - "reference_geometry": { - "area": { - "type_name": "number", - "units": "m**2", - "value": 1 - }, - "moment_center": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "moment_length": { - "units": "m", - "value": [ - 1, - 1, - 1 - ] - } - }, - "run_control": { - "stopping_criteria": [ - { - "monitor_field": "Mach", - "monitor_output": "8b6c81a9-a409-44a1-9a85-4866f596a36d", - "name": "StoppingCriterion 1", - "tolerance": 0.2, - "tolerance_window_size": 10, - "type_name": "StoppingCriterion" - }, - { - "monitor_field": "Cp", - "monitor_output": "ad089c5d-f357-45e7-bbb9-c11297eea170", - "name": "StoppingCriterion 2", - "tolerance": 0.2, - "tolerance_window_size": 10, - "type_name": "StoppingCriterion" - }, - { - "monitor_field": { - "name": "velocity_magnitude_with_units", - "type_name": "UserVariable" - }, - "monitor_output": "ad089c5d-f357-45e7-bbb9-c11297eea170", - "name": "StoppingCriterion 3", - "tolerance": { - "type_name": "number", - "units": "m", - "value": 18.66 - }, - "tolerance_window_size": 10, - "type_name": "StoppingCriterion" - }, - { - "monitor_field": { - "name": "velocity_magnitude_with_units", - "type_name": "UserVariable" - }, - "monitor_output": "1234", - "name": "StoppingCriterion 4", - "tolerance": { - "type_name": "number", - "units": "m/s", - "value": 18.66 - }, - "tolerance_window_size": 10, - "type_name": "StoppingCriterion" - } - ], - "type_name": "RunControl" - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 1, - "max": 10000, - "max_relative_change": 50, - "min": 0.1, - "type": "adaptive" - }, - "max_pseudo_steps": 20, - "order_of_accuracy": 2, - "step_size": { - "type_name": "number", - "units": "s", - "value": 0.0001 - }, - "steps": 200, - "type_name": "Unsteady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_dynamics": null, - "version": "25.7.1b1" -} diff --git a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py deleted file mode 100644 index 834fe1efe..000000000 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ /dev/null @@ -1,441 +0,0 @@ -from flow360 import u -from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.meshing_param import snappy -from flow360.component.simulation.meshing_param.face_params import GeometryRefinement -from flow360.component.simulation.meshing_param.meshing_specs import ( - MeshingDefaults, - VolumeMeshingDefaults, -) -from flow360.component.simulation.meshing_param.params import ( - MeshingParams, - ModularMeshingWorkflow, - VolumeMeshingParams, -) -from flow360.component.simulation.meshing_param.volume_params import ( - AutomatedFarfield, - CustomZones, - UniformRefinement, - UserDefinedFarfield, -) -from flow360.component.simulation.primitives import Box, CustomVolume, Surface -from flow360.component.simulation.services import ValidationCalledBy, validate_model -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system - - -def test_uniform_project_only_with_snappy(): - refinement = UniformRefinement( - entities=[Box(center=(0, 0, 0) * u.m, size=(1, 1, 1) * u.m, name="box")], - spacing=0.1 * u.m, - project_to_surface=True, - ) - with SI_unit_system: - params_snappy = SimulationParams( - meshing=ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, - max_spacing=2 * u.mm, - gap_resolution=1 * u.mm, - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1 * u.mm), - refinements=[refinement], - ), - zones=[AutomatedFarfield()], - ) - ) - - _, errors, _ = validate_model( - params_as_dict=params_snappy.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="VolumeMesh", - ) - assert errors is None - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - volume_zones=[AutomatedFarfield()], - refinements=[refinement], - defaults=MeshingDefaults( - curvature_resolution_angle=12 * u.deg, - boundary_layer_growth_rate=1.1, - boundary_layer_first_layer_thickness=1e-5 * u.m, - ), - ) - ) - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="SurfaceMesh", - validation_level="VolumeMesh", - ) - - assert len(errors) == 1 - assert errors[0]["msg"] == ( - "Value error, project_to_surface is supported only for snappyHexMesh." - ) - assert errors[0]["loc"] == ("meshing", "refinements", 0, "UniformRefinement") - - -def test_per_face_min_passage_size_warning_without_remove_hidden_geometry(): - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=False, - ), - refinements=[ - GeometryRefinement( - geometry_accuracy=0.01 * u.m, - min_passage_size=0.05 * u.m, - faces=[Surface(name="face1")], - ), - ], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert len(warnings) == 1 - assert "min_passage_size" in warnings[0]["msg"] - assert "remove_hidden_geometry" in warnings[0]["msg"] - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=True, - ), - refinements=[ - GeometryRefinement( - geometry_accuracy=0.01 * u.m, - min_passage_size=0.05 * u.m, - faces=[Surface(name="face1")], - ), - ], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert warnings == [] - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=False, - ), - refinements=[ - GeometryRefinement( - geometry_accuracy=0.01 * u.m, - faces=[Surface(name="face1")], - ), - ], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert warnings == [] - - -def test_multi_zone_remove_hidden_geometry_warning(): - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=True, - ), - volume_zones=[ - AutomatedFarfield(enclosed_entities=[Surface(name="face1")]), - CustomZones( - name="custom_zones", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), Surface(name="face2")], - ) - ], - ), - ], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert len(warnings) == 1 - assert ( - "removal of hidden geometry for multi-zone cases is not fully supported" - in warnings[0]["msg"].lower() - ) - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=True, - ), - volume_zones=[AutomatedFarfield()], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert warnings == [] - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=False, - ), - volume_zones=[ - AutomatedFarfield(enclosed_entities=[Surface(name="face1")]), - CustomZones( - name="custom_zones", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), Surface(name="face2")], - ) - ], - ), - ], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert warnings == [] - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=True, - ), - volume_zones=[ - UserDefinedFarfield(), - CustomZones( - name="custom_zones", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), Surface(name="face2")], - ), - CustomVolume( - name="zone2", - bounding_entities=[Surface(name="face3"), Surface(name="face4")], - ), - ], - ), - ], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert len(warnings) == 1 - assert ( - "removal of hidden geometry for multi-zone cases is not fully supported" - in warnings[0]["msg"].lower() - ) - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=True, - ), - volume_zones=[ - UserDefinedFarfield(), - CustomZones( - name="custom_zones", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), Surface(name="face2")], - ) - ], - ), - ], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert warnings == [] - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=True, - ), - volume_zones=[ - UserDefinedFarfield(), - CustomZones( - name="custom_zones", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), Surface(name="face2")], - ), - CustomVolume( - name="zone2", - bounding_entities=[Surface(name="face3"), Surface(name="face4")], - ), - ], - ), - ], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert len(warnings) == 1 - assert ( - "removal of hidden geometry for multi-zone cases is not fully supported" - in warnings[0]["msg"].lower() - ) - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - geometry_accuracy=0.01 * u.m, - surface_max_edge_length=0.1 * u.m, - remove_hidden_geometry=True, - ), - volume_zones=[ - UserDefinedFarfield(), - CustomZones( - name="custom_zones", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), Surface(name="face2")], - ) - ], - ), - ], - ), - private_attribute_asset_cache=AssetCache( - use_geometry_AI=True, - use_inhouse_mesher=True, - project_length_unit=1 * u.m, - ), - ) - _, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="SurfaceMesh", - ) - assert errors is None - assert warnings == [] diff --git a/tests/simulation/params/meshing_validation/test_refinements_validation.py b/tests/simulation/params/meshing_validation/test_refinements_validation.py deleted file mode 100644 index f3f2da8fd..000000000 --- a/tests/simulation/params/meshing_validation/test_refinements_validation.py +++ /dev/null @@ -1,168 +0,0 @@ -import flow360.component.simulation.units as u -from flow360.component.simulation.meshing_param import snappy -from flow360.component.simulation.meshing_param.meshing_specs import ( - VolumeMeshingDefaults, -) -from flow360.component.simulation.meshing_param.params import ( - ModularMeshingWorkflow, - VolumeMeshingParams, -) -from flow360.component.simulation.meshing_param.volume_params import ( - AutomatedFarfield, - UniformRefinement, -) -from flow360.component.simulation.primitives import Box, Cylinder -from flow360.component.simulation.services import ValidationCalledBy, validate_model -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system - - -def _make_snappy_params_with_volume_uniform_refinement(refinement): - with SI_unit_system: - return SimulationParams( - meshing=ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=1 * u.mm, - max_spacing=10 * u.mm, - gap_resolution=0.1 * u.mm, - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults( - boundary_layer_first_layer_thickness=1 * u.mm, - ), - refinements=[refinement], - ), - zones=[AutomatedFarfield()], - ) - ) - - -def test_volume_uniform_refinement_rotated_box_project_to_surface(): - rotated_box = Box( - center=[0, 0, 0] * u.m, - size=[1, 1, 1] * u.m, - axis_of_rotation=[0, 0, 1], - angle_of_rotation=45 * u.deg, - name="rotated_box", - ) - refinement = UniformRefinement( - spacing=5 * u.mm, - entities=[rotated_box], - project_to_surface=True, - ) - params = _make_snappy_params_with_volume_uniform_refinement(refinement) - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="VolumeMesh", - ) - - assert errors is not None - error_messages = [error["msg"] for error in errors] - assert any("angle_of_rotation" in msg or "axes aligned" in msg for msg in error_messages) - - -def test_volume_uniform_refinement_hollow_cylinder_project_to_surface(): - hollow_cylinder = Cylinder( - name="hollow_cyl", - inner_radius=3 * u.mm, - outer_radius=7 * u.mm, - axis=[0, 0, 1], - center=[0, 0, 0] * u.m, - height=10 * u.mm, - ) - refinement = UniformRefinement( - spacing=5 * u.mm, - entities=[hollow_cylinder], - project_to_surface=True, - ) - params = _make_snappy_params_with_volume_uniform_refinement(refinement) - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="VolumeMesh", - ) - - assert errors is not None - error_messages = [error["msg"] for error in errors] - assert any("inner_radius" in msg or "full cylinders" in msg for msg in error_messages) - - -def test_volume_uniform_refinement_cylinder_none_inner_radius_project_to_surface(): - full_cylinder = Cylinder( - name="full_cyl_none", - inner_radius=None, - outer_radius=7 * u.mm, - axis=[0, 0, 1], - center=[0, 0, 0] * u.m, - height=10 * u.mm, - ) - refinement = UniformRefinement( - spacing=5 * u.mm, - entities=[full_cylinder], - project_to_surface=True, - ) - params = _make_snappy_params_with_volume_uniform_refinement(refinement) - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="VolumeMesh", - ) - - assert errors is None - - -def test_volume_uniform_refinement_default_project_to_surface(): - rotated_box = Box( - center=[0, 0, 0] * u.m, - size=[1, 1, 1] * u.m, - axis_of_rotation=[0, 0, 1], - angle_of_rotation=90 * u.deg, - name="rotated_box_default", - ) - refinement = UniformRefinement(spacing=5 * u.mm, entities=[rotated_box]) - params = _make_snappy_params_with_volume_uniform_refinement(refinement) - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="VolumeMesh", - ) - - assert errors is not None - error_messages = [error["msg"] for error in errors] - assert any("angle_of_rotation" in msg or "axes aligned" in msg for msg in error_messages) - - -def test_volume_uniform_refinement_project_to_surface_false_skips_validation(): - rotated_box = Box( - center=[0, 0, 0] * u.m, - size=[1, 1, 1] * u.m, - axis_of_rotation=[0, 0, 1], - angle_of_rotation=45 * u.deg, - name="rotated_box_no_project", - ) - refinement = UniformRefinement( - spacing=5 * u.mm, - entities=[rotated_box], - project_to_surface=False, - ) - params = _make_snappy_params_with_volume_uniform_refinement(refinement) - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="VolumeMesh", - ) - - assert errors is None, "No snappy validation error expected when project_to_surface=False" diff --git a/tests/simulation/params/test_farfield_enclosed_entities.py b/tests/simulation/params/test_farfield_enclosed_entities.py deleted file mode 100644 index 6694307b5..000000000 --- a/tests/simulation/params/test_farfield_enclosed_entities.py +++ /dev/null @@ -1,509 +0,0 @@ -"""Tests for farfield enclosed_entities validation across all farfield types.""" - -import pytest - -import flow360.component.simulation.units as u -from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.meshing_param.meshing_specs import MeshingDefaults -from flow360.component.simulation.meshing_param.params import MeshingParams -from flow360.component.simulation.meshing_param.volume_params import ( - AutomatedFarfield, - CustomZones, - FullyMovingFloor, - RotationVolume, - UserDefinedFarfield, - WindTunnelFarfield, -) -from flow360.component.simulation.primitives import ( - CustomVolume, - Cylinder, - Sphere, - Surface, -) -from flow360.component.simulation.services import ( - ValidationCalledBy, - clear_context, - validate_model, -) -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system - - -@pytest.fixture(autouse=True) -def reset_context(): - clear_context() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -FARFIELD_TYPES_WITH_ENCLOSED = [AutomatedFarfield, WindTunnelFarfield] - - -def _make_farfield(farfield_cls, **kwargs): - """Instantiate a farfield zone with type-specific defaults.""" - if farfield_cls is WindTunnelFarfield: - kwargs.setdefault("name", "wind tunnel") - kwargs.setdefault("floor_type", FullyMovingFloor()) - return farfield_cls(**kwargs) - - -def _make_defaults(farfield_cls): - """Return MeshingDefaults with type-specific extras (e.g. geometry_accuracy for WindTunnel).""" - kwargs = {"boundary_layer_first_layer_thickness": 1e-4} - if farfield_cls is WindTunnelFarfield: - kwargs["geometry_accuracy"] = 1e-4 - return MeshingDefaults(**kwargs) - - -def _make_asset_cache(farfield_cls, *, use_inhouse_mesher=True): - """Return AssetCache with type-specific extras (e.g. use_geometry_AI for WindTunnel).""" - kwargs = {"use_inhouse_mesher": use_inhouse_mesher} - if farfield_cls is WindTunnelFarfield: - kwargs["use_geometry_AI"] = True - return AssetCache(**kwargs) - - -def _validate(params): - """Run validation and return (warnings, errors, info).""" - return validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="SurfaceMesh", - validation_level="VolumeMesh", - ) - - -def _make_custom_zones_with_volume(): - """Standard CustomZones fixture used across many tests.""" - return CustomZones( - name="interior", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), Surface(name="face2")], - ) - ], - ) - - -def _make_rotor_disk(): - return Cylinder( - name="rotor", - center=(0, 0, 0) * u.m, - axis=(0, 0, 1), - height=1 * u.m, - outer_radius=5 * u.m, - ) - - -# --------------------------------------------------------------------------- -# Group A: enclosed_entities + beta mesher = PASS -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("farfield_cls", FARFIELD_TYPES_WITH_ENCLOSED, ids=lambda c: c.__name__) -def test_enclosed_entities_beta_mesher_positive(farfield_cls): - """enclosed_entities with beta mesher should pass validation.""" - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=_make_defaults(farfield_cls), - volume_zones=[ - _make_custom_zones_with_volume(), - _make_farfield( - farfield_cls, - enclosed_entities=[Surface(name="face1"), Surface(name="face2")], - ), - ], - ), - private_attribute_asset_cache=_make_asset_cache(farfield_cls), - ) - _, errors, _ = _validate(params) - assert errors is None - - -# --------------------------------------------------------------------------- -# Group B: enclosed_entities + legacy mesher = FAIL -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("farfield_cls", FARFIELD_TYPES_WITH_ENCLOSED, ids=lambda c: c.__name__) -def test_enclosed_entities_beta_mesher_negative(farfield_cls): - """enclosed_entities with legacy mesher should fail validation.""" - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=_make_defaults(farfield_cls), - volume_zones=[ - _make_farfield(farfield_cls, enclosed_entities=[Surface(name="face1")]), - ], - ), - private_attribute_asset_cache=_make_asset_cache(farfield_cls, use_inhouse_mesher=False), - ) - _, errors, _ = _validate(params) - assert errors is not None - assert any( - "`enclosed_entities` is only supported with the beta mesher" in e["msg"] for e in errors - ) - - -# --------------------------------------------------------------------------- -# Group C: Cylinder without RotationVolume = FAIL -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("farfield_cls", FARFIELD_TYPES_WITH_ENCLOSED, ids=lambda c: c.__name__) -def test_enclosed_entities_rotation_volume_association_negative(farfield_cls): - """Cylinder in enclosed_entities without a RotationVolume should fail.""" - rotor_disk = _make_rotor_disk() - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=_make_defaults(farfield_cls), - volume_zones=[ - CustomZones( - name="interior", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1")], - ) - ], - ), - _make_farfield( - farfield_cls, - enclosed_entities=[Surface(name="face1"), rotor_disk], - ), - ], - ), - private_attribute_asset_cache=_make_asset_cache(farfield_cls), - ) - _, errors, _ = _validate(params) - assert errors is not None - assert any( - "`Cylinder` entity `rotor` in `enclosed_entities` must be associated with a `RotationVolume`" - in e["msg"] - for e in errors - ) - - -# --------------------------------------------------------------------------- -# Group D: Cylinder with RotationVolume = PASS -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("farfield_cls", FARFIELD_TYPES_WITH_ENCLOSED, ids=lambda c: c.__name__) -def test_enclosed_entities_rotation_volume_association_positive(farfield_cls): - """Cylinder in enclosed_entities that is also in a RotationVolume should pass.""" - rotor_disk = _make_rotor_disk() - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=_make_defaults(farfield_cls), - volume_zones=[ - RotationVolume( - entities=[rotor_disk], - spacing_axial=0.5 * u.m, - spacing_radial=0.5 * u.m, - spacing_circumferential=0.3 * u.m, - ), - CustomZones( - name="interior", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1")], - ) - ], - ), - _make_farfield( - farfield_cls, - enclosed_entities=[Surface(name="face1"), rotor_disk], - ), - ], - ), - private_attribute_asset_cache=_make_asset_cache(farfield_cls), - ) - _, errors, _ = _validate(params) - assert errors is None - - -# --------------------------------------------------------------------------- -# Group E: enclosed_entities without CustomZones = FAIL (WindTunnel only) -# --------------------------------------------------------------------------- - - -def test_enclosed_entities_requires_custom_zones(): - """WindTunnelFarfield enclosed_entities without CustomZones should fail.""" - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=_make_defaults(WindTunnelFarfield), - volume_zones=[ - _make_farfield(WindTunnelFarfield, enclosed_entities=[Surface(name="face1")]), - ], - ), - private_attribute_asset_cache=_make_asset_cache(WindTunnelFarfield), - ) - _, errors, _ = _validate(params) - assert errors is not None - assert any("only allowed when `CustomVolume` entities are present" in e["msg"] for e in errors) - - -# --------------------------------------------------------------------------- -# AutomatedFarfield-specific tests — has separate validator with additional checks -# --------------------------------------------------------------------------- - - -def test_enclosed_entities_none_with_legacy_mesher(): - """enclosed_entities=None with legacy mesher should pass (no error from this validator).""" - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - volume_zones=[AutomatedFarfield()], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=False), - ) - _, errors, _ = _validate(params) - assert errors is None - - -def test_enclosed_entities_surfaces_only_no_rotation_volume_needed(): - """Only Surface entities in enclosed_entities should not require a RotationVolume.""" - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - volume_zones=[ - _make_custom_zones_with_volume(), - AutomatedFarfield( - enclosed_entities=[Surface(name="face1"), Surface(name="face2")], - ), - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) - _, errors, _ = _validate(params) - assert errors is None - - -# --------------------------------------------------------------------------- -# UserDefinedFarfield: no enclosed_entities support -# --------------------------------------------------------------------------- - - -def test_udf_rejects_enclosed_entities(): - """UserDefinedFarfield should not accept enclosed_entities.""" - import pydantic as pd - - with pytest.raises(pd.ValidationError, match="Extra inputs are not permitted"): - UserDefinedFarfield(enclosed_entities=[Surface(name="face1")]) - - -def test_udf_with_custom_volume_no_enclosed_entities(): - """UDF + CustomVolume without enclosed_entities should pass validation.""" - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - volume_zones=[ - _make_custom_zones_with_volume(), - UserDefinedFarfield(), - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) - _, errors, _ = _validate(params) - assert errors is None - - -# --------------------------------------------------------------------------- -# CustomVolume bounding_entities + rotation association tests -# --------------------------------------------------------------------------- - - -def test_custom_volume_enclosed_entities_rotation_volume_association_positive(): - """CustomVolume with Cylinder in bounding_entities that is in a RotationVolume should pass.""" - rotor = _make_rotor_disk() - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - volume_zones=[ - RotationVolume( - entities=[rotor], - spacing_axial=0.5 * u.m, - spacing_radial=0.5 * u.m, - spacing_circumferential=0.3 * u.m, - ), - CustomZones( - name="interior", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), rotor], - ) - ], - ), - AutomatedFarfield( - enclosed_entities=[Surface(name="face1"), rotor], - ), - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) - _, errors, _ = _validate(params) - assert errors is None - - -def test_custom_volume_enclosed_entities_rotation_volume_association_negative(): - """CustomVolume with Cylinder in bounding_entities without a RotationVolume should fail.""" - rotor = _make_rotor_disk() - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - volume_zones=[ - CustomZones( - name="interior", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), rotor], - ) - ], - ), - AutomatedFarfield( - enclosed_entities=[Surface(name="face1")], - ), - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) - _, errors, _ = _validate(params) - assert errors is not None - assert any("`Cylinder` entity `rotor` in `CustomVolume` `zone1`" in e["msg"] for e in errors) - - -def test_custom_volume_enclosed_entities_sphere_rotation_volume_negative(): - """CustomVolume with Sphere in bounding_entities without a RotationVolume should fail.""" - sph = Sphere( - name="sph", - center=(0, 0, 0) * u.m, - radius=5 * u.m, - ) - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - volume_zones=[ - CustomZones( - name="interior", - entities=[ - CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), sph], - ) - ], - ), - AutomatedFarfield( - enclosed_entities=[Surface(name="face1")], - ), - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) - _, errors, _ = _validate(params) - assert errors is not None - assert any("`Sphere` entity `sph` in `CustomVolume` `zone1`" in e["msg"] for e in errors) - - -def test_custom_volume_in_farfield_enclosed_entities_rotation_volume_negative(): - """CustomVolume inside farfield enclosed_entities with Cylinder not in RotationVolume should fail.""" - rotor = _make_rotor_disk() - with SI_unit_system: - cv = CustomVolume( - name="zone1", - bounding_entities=[Surface(name="face1"), rotor], - ) - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - volume_zones=[ - CustomZones( - name="interior", - entities=[ - cv, - CustomVolume( - name="zone2", - bounding_entities=[Surface(name="face2")], - ), - ], - ), - AutomatedFarfield( - enclosed_entities=[Surface(name="face2"), cv], - ), - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) - _, errors, _ = _validate(params) - assert errors is not None - assert any("`Cylinder` entity `rotor` in `CustomVolume` `zone1`" in e["msg"] for e in errors) - - -# --------------------------------------------------------------------------- -# Farfield + CustomVolume intersection tests -# --------------------------------------------------------------------------- - - -def test_farfield_custom_volume_no_intersection_positive(): - """CustomVolume in farfield enclosed_entities with disjoint entities should pass.""" - with SI_unit_system: - cv = CustomVolume( - name="inner_zone", - bounding_entities=[Surface(name="cv_face1"), Surface(name="cv_face2")], - ) - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - volume_zones=[ - CustomZones(name="interior", entities=[cv]), - AutomatedFarfield( - enclosed_entities=[Surface(name="outer_face"), cv], - ), - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) - _, errors, _ = _validate(params) - assert errors is None - - -def test_farfield_custom_volume_no_intersection_negative(): - """CustomVolume in farfield enclosed_entities sharing a surface with a sibling should fail.""" - shared_face = Surface(name="shared") - with SI_unit_system: - cv = CustomVolume( - name="inner_zone", - bounding_entities=[shared_face, Surface(name="cv_only")], - ) - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - volume_zones=[ - CustomZones(name="interior", entities=[cv]), - AutomatedFarfield( - enclosed_entities=[shared_face, cv], - ), - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) - _, errors, _ = _validate(params) - assert errors is not None - assert any("shares bounding entities" in e["msg"] for e in errors) - assert any("shared" in e["msg"] for e in errors) diff --git a/tests/simulation/params/test_validators_criterion.py b/tests/simulation/params/test_validators_criterion.py deleted file mode 100644 index 76cc82b80..000000000 --- a/tests/simulation/params/test_validators_criterion.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -import os - -from flow360.component.simulation.services import ValidationCalledBy, validate_model - - -def test_criterion_with_monitor_output_id(): - simulation_path = os.path.join( - os.path.dirname(__file__), - "data", - "simulation_stopping_criterion_webui.json", - ) - with open(simulation_path, "r") as file: - data = json.load(file) - - _, errors, _ = validate_model( - params_as_dict=data, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - ) - expected_errors = [ - { - "type": "value_error", - "loc": ("run_control", "stopping_criteria", 0, "monitor_output"), - "msg": "Value error, For stopping criterion setup, only one single `Point` entity is allowed in `ProbeOutput`/`SurfaceProbeOutput`.", - "ctx": {"relevant_for": ["Case"]}, - }, - { - "type": "value_error", - "loc": ("run_control", "stopping_criteria", 1, "monitor_output"), - "msg": "Value error, The monitor field does not exist in the monitor output.", - "ctx": {"relevant_for": ["Case"]}, - }, - { - "type": "value_error", - "loc": ("run_control", "stopping_criteria", 2, "tolerance"), - "msg": "Value error, The dimensions of monitor field and tolerance do not match.", - "ctx": {"relevant_for": ["Case"]}, - }, - { - "type": "value_error", - "loc": ("run_control", "stopping_criteria", 3, "monitor_output"), - "msg": "Value error, The monitor output does not exist in the outputs list.", - "ctx": {"relevant_for": ["Case"]}, - }, - ] - assert len(errors) == len(expected_errors) - for error, expected in zip(errors, expected_errors): - assert error["loc"] == expected["loc"] - assert error["type"] == expected["type"] - assert error["msg"] == expected["msg"] - assert error["ctx"]["relevant_for"] == expected["ctx"]["relevant_for"] diff --git a/tests/simulation/params/test_validators_solid.py b/tests/simulation/params/test_validators_solid.py deleted file mode 100644 index a0f959507..000000000 --- a/tests/simulation/params/test_validators_solid.py +++ /dev/null @@ -1,162 +0,0 @@ -import re - -import pytest - -import flow360.component.simulation.units as u -from flow360.component.simulation.entity_info import SurfaceMeshEntityInfo -from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.meshing_param.meshing_specs import ( - VolumeMeshingDefaults, -) -from flow360.component.simulation.meshing_param.params import ( - MeshingDefaults, - MeshingParams, - ModularMeshingWorkflow, - VolumeMeshingParams, - snappy, -) -from flow360.component.simulation.meshing_param.volume_params import ( - CustomZones, - UserDefinedFarfield, -) -from flow360.component.simulation.models.material import aluminum -from flow360.component.simulation.models.volume_models import Solid -from flow360.component.simulation.primitives import ( - CustomVolume, - SeedpointVolume, - Surface, -) -from flow360.component.simulation.services import ValidationCalledBy, validate_model -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.unit_system import SI_unit_system - - -def _build_params_with_custom_volume(element_type: str): - zone = CustomVolume(name="solid_zone", bounding_entities=[Surface(name="face1")]) - return SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=1e-4, - planar_face_tolerance=1e-4, - ), - volume_zones=[ - CustomZones( - name="custom_zones", - entities=[zone], - element_type=element_type, - ), - UserDefinedFarfield(), - ], - ), - models=[ - Solid( - entities=[zone], - material=aluminum, - ) - ], - private_attribute_asset_cache=AssetCache( - use_inhouse_mesher=True, - project_entity_info=SurfaceMeshEntityInfo(boundaries=[Surface(name="face1")]), - ), - ) - - -def _build_params_with_seedpoint_volume(element_type: str): - zone = SeedpointVolume(name="solid_zone", point_in_mesh=(0, 0, 0) * u.mm) - return SimulationParams( - meshing=ModularMeshingWorkflow( - surface_meshing=snappy.SurfaceMeshingParams( - defaults=snappy.SurfaceMeshingDefaults( - min_spacing=2 * u.mm, max_spacing=4 * u.mm, gap_resolution=1 * u.mm - ) - ), - volume_meshing=VolumeMeshingParams( - defaults=VolumeMeshingDefaults(boundary_layer_first_layer_thickness=1e-4), - ), - zones=[ - CustomZones( - name="custom_zones", - entities=[zone], - element_type=element_type, - ), - ], - ), - models=[ - Solid( - entities=[zone], - material=aluminum, - ) - ], - private_attribute_asset_cache=AssetCache( - use_inhouse_mesher=True, - project_entity_info=SurfaceMeshEntityInfo(boundaries=[Surface(name="face1")]), - ), - ) - - -def test_solid_custom_volume_requires_tetrahedra_raises_on_mixed(): - with SI_unit_system: - params = _build_params_with_custom_volume(element_type="mixed") - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="SurfaceMesh", - validation_level="All", - ) - - assert errors is not None and len(errors) == 1 - assert errors[0]["msg"] == ( - "Value error, CustomVolume 'solid_zone' must be meshed with " - "tetrahedra-only elements. Please adjust setting in `CustomZones`." - ) - assert errors[0]["loc"][0] == "models" - assert errors[0]["loc"][2] == "entities" - - -def test_solid_custom_volume_allows_tetrahedra(): - with SI_unit_system: - params = _build_params_with_custom_volume(element_type="tetrahedra") - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="SurfaceMesh", - validation_level="All", - ) - - assert errors is None - - -def test_solid_seedpoint_volume_requires_tetrahedra_raises_on_mixed(): - with SI_unit_system: - params = _build_params_with_seedpoint_volume(element_type="mixed") - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="All", - ) - - assert errors is not None and len(errors) == 1 - assert errors[0]["msg"] == ( - "Value error, SeedpointVolume 'solid_zone' must be meshed with " - "tetrahedra-only elements. Please adjust setting in `CustomZones`." - ) - assert errors[0]["loc"][0] == "models" - assert errors[0]["loc"][2] == "entities" - - -def test_solid_seedpoint_volume_allows_tetrahedra(): - with SI_unit_system: - params = _build_params_with_seedpoint_volume(element_type="tetrahedra") - - _, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level="All", - ) - - assert errors is None diff --git a/tests/simulation/service/data/dependency_geometry_sphere1_simulation.json b/tests/simulation/service/data/dependency_geometry_sphere1_simulation.json deleted file mode 100644 index c60744ae1..000000000 --- a/tests/simulation/service/data/dependency_geometry_sphere1_simulation.json +++ /dev/null @@ -1,746 +0,0 @@ -{ - "hash": "562ccb89a69df627df3a181b692b3467afdf05ae4e9586c828a98e86d765ce68", - "meshing": { - "defaults": { - "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": { - "units": "degree", - "value": 12.0 - }, - "planar_face_tolerance": 1e-06, - "preserve_thin_geometry": false, - "resolve_face_boundaries": false, - "sealing_size": { - "units": "m", - "value": 0.0 - }, - "sliding_interface_tolerance": 0.01, - "surface_edge_growth_rate": 1.2, - "surface_max_adaptation_iterations": 50, - "surface_max_aspect_ratio": 10.0 - }, - "outputs": [], - "refinement_factor": 1.0, - "refinements": [], - "type_name": "MeshingParams", - "volume_zones": [ - { - "method": "auto", - "name": "Farfield", - "relative_size": 50.0, - "type": "AutomatedFarfield" - } - ] - }, - "models": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": { - "units": "W/m**2", - "value": 0.0 - } - }, - "name": "Wall", - "private_attribute_id": "b7990356-c29b-44ee-aa5e-a163a18848d0", - "roughness_height": { - "units": "m", - "value": 0.0 - }, - "type": "Wall", - "use_wall_function": false - }, - { - "entities": { - "stored_entities": [ - { - "name": "farfield", - "private_attribute_entity_type_name": "GhostSurface", - "private_attribute_id": "farfield" - } - ] - }, - "name": "Freestream", - "private_attribute_id": "a00c9458-066e-4207-94c7-56474e62fe60", - "type": "Freestream" - }, - { - "initial_condition": { - "p": "p", - "rho": "rho", - "type_name": "NavierStokesInitialCondition", - "u": "u", - "v": "v", - "w": "w" - }, - "interface_interpolation_tolerance": 0.2, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1.0, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1.0, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30 - }, - "low_mach_preconditioner": false, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1.0, - "order_of_accuracy": 2, - "relative_tolerance": 0.0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "private_attribute_id": "__default_fluid", - "transition_model_solver": { - "type_name": "None" - }, - "turbulence_model_solver": { - "CFL_multiplier": 2.0, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20 - }, - "low_reynolds_correction": false, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_cb1": 0.1355, - "C_cb2": 0.622, - "C_d": 8.0, - "C_min_rd": 10.0, - "C_sigma": 0.6666666666666666, - "C_t3": 1.2, - "C_t4": 0.5, - "C_v1": 7.1, - "C_vonKarman": 0.41, - "C_w2": 0.3, - "C_w4": 0.21, - "C_w5": 1.5, - "type_name": "SpalartAllmarasConsts" - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0.0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - } - ], - "operating_condition": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - } - }, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition" - }, - "outputs": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": { - "items": [ - "Cp", - "yPlus", - "Cf", - "CfVec" - ] - }, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "private_attribute_id": "39c6e6c8-9756-401f-bb12-8d037d829908", - "write_single_file": false - } - ], - "private_attribute_asset_cache": { - "project_entity_info": { - "bodies_face_edge_ids": { - "sphere1_body00001": { - "edge_ids": [ - "sphere1_body00001_edge00001", - "sphere1_body00001_edge00003" - ], - "face_ids": [ - "sphere1_body00001_face00001", - "sphere1_body00001_face00002" - ] - } - }, - "body_attribute_names": [ - "bodyId", - "groupByFile" - ], - "body_group_tag": "groupByFile", - "body_ids": [ - "sphere1_body00001" - ], - "default_geometry_accuracy": { - "units": "m", - "value": 0.0001 - }, - "draft_entities": [], - "edge_attribute_names": [ - "name", - "edgeId" - ], - "edge_group_tag": "name", - "edge_ids": [ - "sphere1_body00001_edge00001", - "sphere1_body00001_edge00003" - ], - "face_attribute_names": [ - "builtinName", - "groupByBodyId", - "name", - "faceId" - ], - "face_group_tag": "name", - "face_ids": [ - "sphere1_body00001_face00001", - "sphere1_body00001_face00002" - ], - "ghost_entities": [ - { - "center": [ - 0, - 0, - 0 - ], - "max_radius": 200.0, - "name": "farfield", - "private_attribute_entity_type_name": "GhostSphere", - "private_attribute_id": "farfield" - }, - { - "center": [ - 0.0, - -2.0, - 0.0 - ], - "max_radius": 200.0, - "name": "symmetric-1", - "normal_axis": [ - 0, - -1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-1" - }, - { - "center": [ - 0.0, - 2.0, - 0.0 - ], - "max_radius": 200.0, - "name": "symmetric-2", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-2" - }, - { - "center": [ - 0.0, - 0, - 0.0 - ], - "max_radius": 200.0, - "name": "symmetric", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric" - }, - { - "name": "windTunnelInlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelInlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelOutlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelOutlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelCeiling", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCeiling", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFloor", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFloor", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelLeft", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelLeft", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelRight", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRight", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFrictionPatch", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrictionPatch", - "used_by": [ - "StaticFloor" - ] - }, - { - "name": "windTunnelCentralBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCentralBelt", - "used_by": [ - "CentralBelt", - "WheelBelts" - ] - }, - { - "name": "windTunnelFrontWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrontWheelBelt", - "used_by": [ - "WheelBelts" - ] - }, - { - "name": "windTunnelRearWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRearWheelBelt", - "used_by": [ - "WheelBelts" - ] - } - ], - "global_bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 2.0, - 2.0, - 2.0 - ] - ], - "grouped_bodies": [ - [ - { - "mesh_exterior": true, - "name": "sphere1_body00001", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "ddf34d3d-1354-46dd-9165-0c18f4010b31", - "private_attribute_sub_components": [ - "sphere1_body00001" - ], - "private_attribute_tag_key": "bodyId" - } - ], - [ - { - "mesh_exterior": true, - "name": "sphere_r2mm.step", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "442cc0e0-da72-4784-8036-fd5c1e643831", - "private_attribute_sub_components": [ - "sphere1_body00001" - ], - "private_attribute_tag_key": "groupByFile" - } - ] - ], - "grouped_edges": [ - [ - { - "name": "sphere1_body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "9cbefe8c-82e0-486d-b805-6ee5ecd49c21", - "private_attribute_sub_components": [ - "sphere1_body00001_edge00001" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "sphere1_body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "48869a6b-c99e-46fd-a816-2833aa16283c", - "private_attribute_sub_components": [ - "sphere1_body00001_edge00003" - ], - "private_attribute_tag_key": "name" - } - ], - [ - { - "name": "sphere1_body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "dc6dea8e-240d-4eb8-9c32-d4a42a0d99b7", - "private_attribute_sub_components": [ - "sphere1_body00001_edge00001" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "sphere1_body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "adb4961e-0e43-400f-97a6-ffb07c16e472", - "private_attribute_sub_components": [ - "sphere1_body00001_edge00003" - ], - "private_attribute_tag_key": "edgeId" - } - ] - ], - "grouped_faces": [ - [ - { - "name": "No Name", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "e8473b81-9840-441d-a45d-534320f3b330", - "private_attribute_sub_components": [ - "sphere1_body00001_face00001", - "sphere1_body00001_face00002" - ], - "private_attribute_tag_key": "builtinName", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 2.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "sphere1_body00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "3a150e35-76bc-4e47-84f1-238f9200d0ea", - "private_attribute_sub_components": [ - "sphere1_body00001_face00001", - "sphere1_body00001_face00002" - ], - "private_attribute_tag_key": "groupByBodyId", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 2.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "sphere1_body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "d5922440-36fc-4308-aac4-b040ee31d950", - "private_attribute_sub_components": [ - "sphere1_body00001_face00001" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -4.499279347985573e-32, - -2.0 - ], - [ - 2.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere1_body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "238489c6-b158-44bb-b5b4-d6cc17a69b51", - "private_attribute_sub_components": [ - "sphere1_body00001_face00002" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 2.0, - 2.4492935982947064e-16, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "sphere1_body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "3d8cd3d8-4719-40db-9929-d683a504e670", - "private_attribute_sub_components": [ - "sphere1_body00001_face00001" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -4.499279347985573e-32, - -2.0 - ], - [ - 2.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere1_body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "a932d870-d169-49aa-83e1-be9fbffcbe2d", - "private_attribute_sub_components": [ - "sphere1_body00001_face00002" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 2.0, - 2.4492935982947064e-16, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ] - ], - "type_name": "GeometryEntityInfo" - }, - "project_length_unit": { - "units": "m", - "value": 1.0 - }, - "use_geometry_AI": false, - "use_inhouse_mesher": false - }, - "reference_geometry": { - "area": { - "type_name": "number", - "units": "m**2", - "value": 1.0 - }, - "moment_center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "moment_length": { - "units": "m", - "value": [ - 1.0, - 1.0, - 1.0 - ] - } - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 0.25, - "max": 10000.0, - "max_relative_change": 1.0, - "min": 0.1, - "type": "adaptive" - }, - "max_steps": 2000, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_fields": [] -} diff --git a/tests/simulation/service/data/dependency_geometry_sphere2_simulation.json b/tests/simulation/service/data/dependency_geometry_sphere2_simulation.json deleted file mode 100644 index e2344dca8..000000000 --- a/tests/simulation/service/data/dependency_geometry_sphere2_simulation.json +++ /dev/null @@ -1,746 +0,0 @@ -{ - "hash": "800de3ec2de57facdf30ae67b66bda611402446426f2fd2882541995e92cac65", - "meshing": { - "defaults": { - "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": { - "units": "degree", - "value": 12.0 - }, - "planar_face_tolerance": 1e-06, - "preserve_thin_geometry": false, - "resolve_face_boundaries": false, - "sealing_size": { - "units": "m", - "value": 0.0 - }, - "sliding_interface_tolerance": 0.01, - "surface_edge_growth_rate": 1.2, - "surface_max_adaptation_iterations": 50, - "surface_max_aspect_ratio": 10.0 - }, - "outputs": [], - "refinement_factor": 1.0, - "refinements": [], - "type_name": "MeshingParams", - "volume_zones": [ - { - "method": "auto", - "name": "Farfield", - "relative_size": 50.0, - "type": "AutomatedFarfield" - } - ] - }, - "models": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": { - "units": "W/m**2", - "value": 0.0 - } - }, - "name": "Wall", - "private_attribute_id": "b7990356-c29b-44ee-aa5e-a163a18848d0", - "roughness_height": { - "units": "m", - "value": 0.0 - }, - "type": "Wall", - "use_wall_function": false - }, - { - "entities": { - "stored_entities": [ - { - "name": "farfield", - "private_attribute_entity_type_name": "GhostSurface", - "private_attribute_id": "farfield" - } - ] - }, - "name": "Freestream", - "private_attribute_id": "a00c9458-066e-4207-94c7-56474e62fe60", - "type": "Freestream" - }, - { - "initial_condition": { - "p": "p", - "rho": "rho", - "type_name": "NavierStokesInitialCondition", - "u": "u", - "v": "v", - "w": "w" - }, - "interface_interpolation_tolerance": 0.2, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1.0, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1.0, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30 - }, - "low_mach_preconditioner": false, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1.0, - "order_of_accuracy": 2, - "relative_tolerance": 0.0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "private_attribute_id": "__default_fluid", - "transition_model_solver": { - "type_name": "None" - }, - "turbulence_model_solver": { - "CFL_multiplier": 2.0, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20 - }, - "low_reynolds_correction": false, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_cb1": 0.1355, - "C_cb2": 0.622, - "C_d": 8.0, - "C_min_rd": 10.0, - "C_sigma": 0.6666666666666666, - "C_t3": 1.2, - "C_t4": 0.5, - "C_v1": 7.1, - "C_vonKarman": 0.41, - "C_w2": 0.3, - "C_w4": 0.21, - "C_w5": 1.5, - "type_name": "SpalartAllmarasConsts" - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0.0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - } - ], - "operating_condition": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - } - }, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition" - }, - "outputs": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": { - "items": [ - "Cp", - "yPlus", - "Cf", - "CfVec" - ] - }, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "private_attribute_id": "39c6e6c8-9756-401f-bb12-8d037d829908", - "write_single_file": false - } - ], - "private_attribute_asset_cache": { - "project_entity_info": { - "bodies_face_edge_ids": { - "sphere2_body00001": { - "edge_ids": [ - "sphere2_body00001_edge00001", - "sphere2_body00001_edge00003" - ], - "face_ids": [ - "sphere2_body00001_face00001", - "sphere2_body00001_face00002" - ] - } - }, - "body_attribute_names": [ - "bodyId", - "groupByFile" - ], - "body_group_tag": "groupByFile", - "body_ids": [ - "sphere2_body00001" - ], - "default_geometry_accuracy": { - "units": "m", - "value": 0.0001 - }, - "draft_entities": [], - "edge_attribute_names": [ - "name", - "edgeId" - ], - "edge_group_tag": "name", - "edge_ids": [ - "sphere2_body00001_edge00001", - "sphere2_body00001_edge00003" - ], - "face_attribute_names": [ - "builtinName", - "groupByBodyId", - "name", - "faceId" - ], - "face_group_tag": "name", - "face_ids": [ - "sphere2_body00001_face00001", - "sphere2_body00001_face00002" - ], - "ghost_entities": [ - { - "center": [ - 0, - 0, - 0 - ], - "max_radius": 200.0, - "name": "farfield", - "private_attribute_entity_type_name": "GhostSphere", - "private_attribute_id": "farfield" - }, - { - "center": [ - 5.0, - -2.0, - 0.0 - ], - "max_radius": 200.0, - "name": "symmetric-1", - "normal_axis": [ - 0, - -1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-1" - }, - { - "center": [ - 5.0, - 2.0, - 0.0 - ], - "max_radius": 200.0, - "name": "symmetric-2", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-2" - }, - { - "center": [ - 5.0, - 0, - 0.0 - ], - "max_radius": 200.0, - "name": "symmetric", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric" - }, - { - "name": "windTunnelInlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelInlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelOutlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelOutlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelCeiling", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCeiling", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFloor", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFloor", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelLeft", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelLeft", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelRight", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRight", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFrictionPatch", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrictionPatch", - "used_by": [ - "StaticFloor" - ] - }, - { - "name": "windTunnelCentralBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCentralBelt", - "used_by": [ - "CentralBelt", - "WheelBelts" - ] - }, - { - "name": "windTunnelFrontWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrontWheelBelt", - "used_by": [ - "WheelBelts" - ] - }, - { - "name": "windTunnelRearWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRearWheelBelt", - "used_by": [ - "WheelBelts" - ] - } - ], - "global_bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 7.0, - 2.0, - 2.0 - ] - ], - "grouped_bodies": [ - [ - { - "mesh_exterior": true, - "name": "sphere2_body00001", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "fad554fd-2de9-4064-a5b3-8488a235812a", - "private_attribute_sub_components": [ - "sphere2_body00001" - ], - "private_attribute_tag_key": "bodyId" - } - ], - [ - { - "mesh_exterior": true, - "name": "sphere_r2mm_center_5_0_0.step", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "0fb73bec-f430-4efd-8dc5-4b9955df849e", - "private_attribute_sub_components": [ - "sphere2_body00001" - ], - "private_attribute_tag_key": "groupByFile" - } - ] - ], - "grouped_edges": [ - [ - { - "name": "sphere2_body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "96b21c6a-21b5-4387-acd2-83ae71f17413", - "private_attribute_sub_components": [ - "sphere2_body00001_edge00001" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "sphere2_body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "4b30a96b-b8c1-4de2-9a5f-f447bb42f4a9", - "private_attribute_sub_components": [ - "sphere2_body00001_edge00003" - ], - "private_attribute_tag_key": "name" - } - ], - [ - { - "name": "sphere2_body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "3193fbf3-6379-42de-ac63-5ad9401dcce2", - "private_attribute_sub_components": [ - "sphere2_body00001_edge00001" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "sphere2_body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "ac831004-5a5e-4b8b-b66a-475b78308ab9", - "private_attribute_sub_components": [ - "sphere2_body00001_edge00003" - ], - "private_attribute_tag_key": "edgeId" - } - ] - ], - "grouped_faces": [ - [ - { - "name": "No Name", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "032d8c9b-9873-4557-b7c6-cde69f6507f7", - "private_attribute_sub_components": [ - "sphere2_body00001_face00001", - "sphere2_body00001_face00002" - ], - "private_attribute_tag_key": "builtinName", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 7.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "sphere2_body00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "6ce3c95d-1bd2-434c-be8b-d88151a099a0", - "private_attribute_sub_components": [ - "sphere2_body00001_face00001", - "sphere2_body00001_face00002" - ], - "private_attribute_tag_key": "groupByBodyId", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 7.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "sphere2_body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "ee6f39e2-15ed-4d3b-a77a-8383831bad15", - "private_attribute_sub_components": [ - "sphere2_body00001_face00001" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -4.499279347985573e-32, - -2.0 - ], - [ - 7.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere2_body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "ad2779dd-c479-4b20-b753-054a988f6fac", - "private_attribute_sub_components": [ - "sphere2_body00001_face00002" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 7.0, - 2.4492935982947064e-16, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "sphere2_body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "33dc2ccd-4b46-4444-b094-76e5fdd2249f", - "private_attribute_sub_components": [ - "sphere2_body00001_face00001" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -4.499279347985573e-32, - -2.0 - ], - [ - 7.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere2_body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "8c3e316d-fbd5-45ef-a79f-e60f764abcca", - "private_attribute_sub_components": [ - "sphere2_body00001_face00002" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 7.0, - 2.4492935982947064e-16, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ] - ], - "type_name": "GeometryEntityInfo" - }, - "project_length_unit": { - "units": "m", - "value": 1.0 - }, - "use_geometry_AI": false, - "use_inhouse_mesher": false - }, - "reference_geometry": { - "area": { - "type_name": "number", - "units": "m**2", - "value": 1.0 - }, - "moment_center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "moment_length": { - "units": "m", - "value": [ - 1.0, - 1.0, - 1.0 - ] - } - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 0.25, - "max": 10000.0, - "max_relative_change": 1.0, - "min": 0.1, - "type": "adaptive" - }, - "max_steps": 2000, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_fields": [] -} diff --git a/tests/simulation/service/data/result_merged_geometry_entity_info1.json b/tests/simulation/service/data/result_merged_geometry_entity_info1.json deleted file mode 100644 index b70a06379..000000000 --- a/tests/simulation/service/data/result_merged_geometry_entity_info1.json +++ /dev/null @@ -1,1048 +0,0 @@ -{ - "bodies_face_edge_ids": { - "body00001": { - "edge_ids": [ - "body00001_edge00001", - "body00001_edge00002", - "body00001_edge00003", - "body00001_edge00004", - "body00001_edge00005", - "body00001_edge00006", - "body00001_edge00007", - "body00001_edge00008", - "body00001_edge00009", - "body00001_edge00010", - "body00001_edge00011", - "body00001_edge00012" - ], - "face_ids": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ] - }, - "sphere1_body00001": { - "edge_ids": [ - "sphere1_body00001_edge00001", - "sphere1_body00001_edge00003" - ], - "face_ids": [ - "sphere1_body00001_face00001", - "sphere1_body00001_face00002" - ] - } - }, - "body_attribute_names": [ - "bodyId", - "groupByFile" - ], - "body_group_tag": "groupByFile", - "body_ids": [ - "body00001", - "sphere1_body00001" - ], - "default_geometry_accuracy": 0.0001, - "draft_entities": [], - "edge_attribute_names": [ - "name", - "edgeId" - ], - "edge_group_tag": "name", - "edge_ids": [ - "body00001_edge00001", - "body00001_edge00002", - "body00001_edge00003", - "body00001_edge00004", - "body00001_edge00005", - "body00001_edge00006", - "body00001_edge00007", - "body00001_edge00008", - "body00001_edge00009", - "body00001_edge00010", - "body00001_edge00011", - "body00001_edge00012", - "sphere1_body00001_edge00001", - "sphere1_body00001_edge00003" - ], - "face_attribute_names": [ - "builtinName", - "groupByBodyId", - "name", - "faceId" - ], - "face_group_tag": "groupByBodyId", - "face_ids": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006", - "sphere1_body00001_face00001", - "sphere1_body00001_face00002" - ], - "ghost_entities": [ - { - "center": [ - 0, - 0, - 0 - ], - "max_radius": 100.0, - "name": "farfield", - "private_attribute_entity_type_name": "GhostSphere", - "private_attribute_id": "farfield" - }, - { - "center": [ - 12.0, - -1.0, - 0.0 - ], - "max_radius": 100.0, - "name": "symmetric-1", - "normal_axis": [ - 0, - -1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-1" - }, - { - "center": [ - 12.0, - 1.0, - 0.0 - ], - "max_radius": 100.0, - "name": "symmetric-2", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-2" - }, - { - "center": [ - 12.0, - 0, - 0.0 - ], - "max_radius": 100.0, - "name": "symmetric", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric" - }, - { - "name": "windTunnelInlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelInlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelOutlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelOutlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelCeiling", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCeiling", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFloor", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFloor", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelLeft", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelLeft", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelRight", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRight", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFrictionPatch", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrictionPatch", - "used_by": [ - "StaticFloor" - ] - }, - { - "name": "windTunnelCentralBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCentralBelt", - "used_by": [ - "CentralBelt", - "WheelBelts" - ] - }, - { - "name": "windTunnelFrontWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrontWheelBelt", - "used_by": [ - "WheelBelts" - ] - }, - { - "name": "windTunnelRearWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRearWheelBelt", - "used_by": [ - "WheelBelts" - ] - } - ], - "global_bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 13.0, - 2.0, - 2.0 - ] - ], - "grouped_bodies": [ - [ - { - "mesh_exterior": true, - "name": "body00001", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "b496086c-136d-4f01-998d-c9e1eea120ae", - "private_attribute_sub_components": [ - "body00001" - ], - "private_attribute_tag_key": "bodyId" - }, - { - "mesh_exterior": true, - "name": "sphere1_body00001", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "ddf34d3d-1354-46dd-9165-0c18f4010b31", - "private_attribute_sub_components": [ - "sphere1_body00001" - ], - "private_attribute_tag_key": "bodyId" - } - ], - [ - { - "mesh_exterior": true, - "name": "sphere_r2mm.step", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "442cc0e0-da72-4784-8036-fd5c1e643831", - "private_attribute_sub_components": [ - "sphere1_body00001" - ], - "private_attribute_tag_key": "groupByFile" - }, - { - "mesh_exterior": false, - "name": "cube_2mm_center", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "7d2b9129-f5f4-4f92-b9b6-a554b420f162", - "private_attribute_sub_components": [ - "body00001" - ], - "private_attribute_tag_key": "groupByFile" - } - ] - ], - "grouped_edges": [ - [ - { - "name": "body00001_edge00005", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "0dcf993b-6ac4-4089-b4d6-2fd2d3a94d29", - "private_attribute_sub_components": [ - "body00001_edge00005" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00009", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "1442e021-6026-4e98-a0a2-d45f14e017a5", - "private_attribute_sub_components": [ - "body00001_edge00009" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00006", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "2e7e546a-753e-4b80-9d46-281f13070365", - "private_attribute_sub_components": [ - "body00001_edge00006" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "sphere1_body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "48869a6b-c99e-46fd-a816-2833aa16283c", - "private_attribute_sub_components": [ - "sphere1_body00001_edge00003" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00007", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "596a214f-3b47-448e-b18e-7e7b2999a845", - "private_attribute_sub_components": [ - "body00001_edge00007" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00002", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "67bf3b4f-b319-4cd2-a7b6-9c81281a7d2d", - "private_attribute_sub_components": [ - "body00001_edge00002" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00011", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "6e06acde-5ff1-475d-a327-ea330f44b267", - "private_attribute_sub_components": [ - "body00001_edge00011" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00008", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "75c1c7be-cbee-430e-9b0f-cf3fbf01ac61", - "private_attribute_sub_components": [ - "body00001_edge00008" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00012", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "791f24ba-efdd-4912-aa3c-66e5d6b667f9", - "private_attribute_sub_components": [ - "body00001_edge00012" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "99d61ea6-9f7e-4236-87c0-c6101dfb0ee0", - "private_attribute_sub_components": [ - "body00001_edge00001" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "sphere1_body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "9cbefe8c-82e0-486d-b805-6ee5ecd49c21", - "private_attribute_sub_components": [ - "sphere1_body00001_edge00001" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "a27906f8-10ff-4820-a32b-41fa6c6cfa3c", - "private_attribute_sub_components": [ - "body00001_edge00003" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00010", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "b60b9fa0-e08c-44f4-9869-0b80a92c3c7e", - "private_attribute_sub_components": [ - "body00001_edge00010" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00004", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "be4f9f1f-e938-492a-aa73-d90b089619f8", - "private_attribute_sub_components": [ - "body00001_edge00004" - ], - "private_attribute_tag_key": "name" - } - ], - [ - { - "name": "body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "05742309-f8f3-439b-a3d1-6a60570a35ed", - "private_attribute_sub_components": [ - "body00001_edge00001" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00010", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "177aac2c-d04b-4bd5-8e5f-758b472bf985", - "private_attribute_sub_components": [ - "body00001_edge00010" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00006", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "2fe046d7-7d58-4b37-b4f5-b391335a520a", - "private_attribute_sub_components": [ - "body00001_edge00006" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00008", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "4978befb-352c-4879-a1d5-c4dc0dc83a07", - "private_attribute_sub_components": [ - "body00001_edge00008" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00002", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "5297031f-260c-4ff3-ba06-3e08018a51a4", - "private_attribute_sub_components": [ - "body00001_edge00002" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00011", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "599a9e71-3d9c-4ae6-962b-6bb34e8ee961", - "private_attribute_sub_components": [ - "body00001_edge00011" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00004", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "8e8d80ee-35ed-431a-bee6-460118ba75e9", - "private_attribute_sub_components": [ - "body00001_edge00004" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00007", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "93549028-dbcd-4dd3-87f5-745d269f8243", - "private_attribute_sub_components": [ - "body00001_edge00007" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "a6462fd9-871c-4ee7-b33c-f36e5788a965", - "private_attribute_sub_components": [ - "body00001_edge00003" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "sphere1_body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "adb4961e-0e43-400f-97a6-ffb07c16e472", - "private_attribute_sub_components": [ - "sphere1_body00001_edge00003" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "sphere1_body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "dc6dea8e-240d-4eb8-9c32-d4a42a0d99b7", - "private_attribute_sub_components": [ - "sphere1_body00001_edge00001" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00012", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "e3c65004-eec0-469b-9dfe-bbd62705e412", - "private_attribute_sub_components": [ - "body00001_edge00012" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00005", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "e5e5b6e3-2835-4c1b-a0ce-056617827745", - "private_attribute_sub_components": [ - "body00001_edge00005" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00009", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "f0839250-331b-4d76-8409-824679adb5f5", - "private_attribute_sub_components": [ - "body00001_edge00009" - ], - "private_attribute_tag_key": "edgeId" - } - ] - ], - "grouped_faces": [ - [ - { - "name": "No Name", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "359eeb96-86b5-4952-ac23-192dfd3a40ac", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "builtinName", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "No Name", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "e8473b81-9840-441d-a45d-534320f3b330", - "private_attribute_sub_components": [ - "sphere1_body00001_face00001", - "sphere1_body00001_face00002" - ], - "private_attribute_tag_key": "builtinName", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 2.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "sphere1_body00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "3a150e35-76bc-4e47-84f1-238f9200d0ea", - "private_attribute_sub_components": [ - "sphere1_body00001_face00001", - "sphere1_body00001_face00002" - ], - "private_attribute_tag_key": "groupByBodyId", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 2.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "cube_body", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "dc724906-9b8f-4e62-ac58-ec97e2a12c76", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "groupByBodyId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "084ec7b0-6ff1-4a3f-993d-06128efa49f8", - "private_attribute_sub_components": [ - "body00001_face00002" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - -1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere1_body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "238489c6-b158-44bb-b5b4-d6cc17a69b51", - "private_attribute_sub_components": [ - "sphere1_body00001_face00002" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 2.0, - 2.4492935982947064e-16, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00003", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "59eda58d-0dac-4f77-b4c4-2fcdf4361378", - "private_attribute_sub_components": [ - "body00001_face00003" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 13.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00005", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "8277de84-8306-4547-9cb0-687d178b14b1", - "private_attribute_sub_components": [ - "body00001_face00005" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - -1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "900a49b1-6d7b-44df-89f9-2cf48ac243f8", - "private_attribute_sub_components": [ - "body00001_face00001" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 11.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00004", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "b4ca0f33-0d8f-4fc1-be3a-30aa7b2d22da", - "private_attribute_sub_components": [ - "body00001_face00004" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - 1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00006", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "d4efa00f-dfdd-4461-b317-25fc6a775d4e", - "private_attribute_sub_components": [ - "body00001_face00006" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - 1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere1_body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "d5922440-36fc-4308-aac4-b040ee31d950", - "private_attribute_sub_components": [ - "sphere1_body00001_face00001" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -4.499279347985573e-32, - -2.0 - ], - [ - 2.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "body00001_face00006", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "346806f8-3946-4a48-8616-56b538605814", - "private_attribute_sub_components": [ - "body00001_face00006" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - 1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere1_body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "3d8cd3d8-4719-40db-9929-d683a504e670", - "private_attribute_sub_components": [ - "sphere1_body00001_face00001" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -4.499279347985573e-32, - -2.0 - ], - [ - 2.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00003", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "3db752a1-2925-4fc6-89fe-100a490d141b", - "private_attribute_sub_components": [ - "body00001_face00003" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 13.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "a3c3b221-97e2-4368-ac13-addb3102b46c", - "private_attribute_sub_components": [ - "body00001_face00001" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 11.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere1_body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "a932d870-d169-49aa-83e1-be9fbffcbe2d", - "private_attribute_sub_components": [ - "sphere1_body00001_face00002" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - -2.0, - -2.0, - -2.0 - ], - [ - 2.0, - 2.4492935982947064e-16, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00005", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "c236edf3-4d79-4fbe-908d-0420dfdf53cc", - "private_attribute_sub_components": [ - "body00001_face00005" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - -1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "c89e0cf2-777a-413f-8e59-48f387f564ee", - "private_attribute_sub_components": [ - "body00001_face00002" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - -1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00004", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "cb676b21-1ecd-4062-94f6-3eb5fb279c7e", - "private_attribute_sub_components": [ - "body00001_face00004" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - 1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ] - ], - "type_name": "GeometryEntityInfo" -} diff --git a/tests/simulation/service/data/result_merged_geometry_entity_info2.json b/tests/simulation/service/data/result_merged_geometry_entity_info2.json deleted file mode 100644 index bcca5f01e..000000000 --- a/tests/simulation/service/data/result_merged_geometry_entity_info2.json +++ /dev/null @@ -1,1048 +0,0 @@ -{ - "bodies_face_edge_ids": { - "body00001": { - "edge_ids": [ - "body00001_edge00001", - "body00001_edge00002", - "body00001_edge00003", - "body00001_edge00004", - "body00001_edge00005", - "body00001_edge00006", - "body00001_edge00007", - "body00001_edge00008", - "body00001_edge00009", - "body00001_edge00010", - "body00001_edge00011", - "body00001_edge00012" - ], - "face_ids": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ] - }, - "sphere2_body00001": { - "edge_ids": [ - "sphere2_body00001_edge00001", - "sphere2_body00001_edge00003" - ], - "face_ids": [ - "sphere2_body00001_face00001", - "sphere2_body00001_face00002" - ] - } - }, - "body_attribute_names": [ - "bodyId", - "groupByFile" - ], - "body_group_tag": "groupByFile", - "body_ids": [ - "body00001", - "sphere2_body00001" - ], - "default_geometry_accuracy": 0.0001, - "draft_entities": [], - "edge_attribute_names": [ - "name", - "edgeId" - ], - "edge_group_tag": "name", - "edge_ids": [ - "body00001_edge00001", - "body00001_edge00002", - "body00001_edge00003", - "body00001_edge00004", - "body00001_edge00005", - "body00001_edge00006", - "body00001_edge00007", - "body00001_edge00008", - "body00001_edge00009", - "body00001_edge00010", - "body00001_edge00011", - "body00001_edge00012", - "sphere2_body00001_edge00001", - "sphere2_body00001_edge00003" - ], - "face_attribute_names": [ - "builtinName", - "groupByBodyId", - "name", - "faceId" - ], - "face_group_tag": "groupByBodyId", - "face_ids": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006", - "sphere2_body00001_face00001", - "sphere2_body00001_face00002" - ], - "ghost_entities": [ - { - "center": [ - 0, - 0, - 0 - ], - "max_radius": 100.0, - "name": "farfield", - "private_attribute_entity_type_name": "GhostSphere", - "private_attribute_id": "farfield" - }, - { - "center": [ - 12.0, - -1.0, - 0.0 - ], - "max_radius": 100.0, - "name": "symmetric-1", - "normal_axis": [ - 0, - -1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-1" - }, - { - "center": [ - 12.0, - 1.0, - 0.0 - ], - "max_radius": 100.0, - "name": "symmetric-2", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-2" - }, - { - "center": [ - 12.0, - 0, - 0.0 - ], - "max_radius": 100.0, - "name": "symmetric", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric" - }, - { - "name": "windTunnelInlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelInlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelOutlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelOutlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelCeiling", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCeiling", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFloor", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFloor", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelLeft", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelLeft", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelRight", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRight", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFrictionPatch", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrictionPatch", - "used_by": [ - "StaticFloor" - ] - }, - { - "name": "windTunnelCentralBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCentralBelt", - "used_by": [ - "CentralBelt", - "WheelBelts" - ] - }, - { - "name": "windTunnelFrontWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrontWheelBelt", - "used_by": [ - "WheelBelts" - ] - }, - { - "name": "windTunnelRearWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRearWheelBelt", - "used_by": [ - "WheelBelts" - ] - } - ], - "global_bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 13.0, - 2.0, - 2.0 - ] - ], - "grouped_bodies": [ - [ - { - "mesh_exterior": true, - "name": "body00001", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "b496086c-136d-4f01-998d-c9e1eea120ae", - "private_attribute_sub_components": [ - "body00001" - ], - "private_attribute_tag_key": "bodyId" - }, - { - "mesh_exterior": true, - "name": "sphere2_body00001", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "fad554fd-2de9-4064-a5b3-8488a235812a", - "private_attribute_sub_components": [ - "sphere2_body00001" - ], - "private_attribute_tag_key": "bodyId" - } - ], - [ - { - "mesh_exterior": true, - "name": "sphere_r2mm_center_5_0_0.step", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "0fb73bec-f430-4efd-8dc5-4b9955df849e", - "private_attribute_sub_components": [ - "sphere2_body00001" - ], - "private_attribute_tag_key": "groupByFile" - }, - { - "mesh_exterior": false, - "name": "cube_2mm_center", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "7d2b9129-f5f4-4f92-b9b6-a554b420f162", - "private_attribute_sub_components": [ - "body00001" - ], - "private_attribute_tag_key": "groupByFile" - } - ] - ], - "grouped_edges": [ - [ - { - "name": "body00001_edge00005", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "0dcf993b-6ac4-4089-b4d6-2fd2d3a94d29", - "private_attribute_sub_components": [ - "body00001_edge00005" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00009", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "1442e021-6026-4e98-a0a2-d45f14e017a5", - "private_attribute_sub_components": [ - "body00001_edge00009" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00006", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "2e7e546a-753e-4b80-9d46-281f13070365", - "private_attribute_sub_components": [ - "body00001_edge00006" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "sphere2_body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "4b30a96b-b8c1-4de2-9a5f-f447bb42f4a9", - "private_attribute_sub_components": [ - "sphere2_body00001_edge00003" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00007", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "596a214f-3b47-448e-b18e-7e7b2999a845", - "private_attribute_sub_components": [ - "body00001_edge00007" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00002", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "67bf3b4f-b319-4cd2-a7b6-9c81281a7d2d", - "private_attribute_sub_components": [ - "body00001_edge00002" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00011", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "6e06acde-5ff1-475d-a327-ea330f44b267", - "private_attribute_sub_components": [ - "body00001_edge00011" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00008", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "75c1c7be-cbee-430e-9b0f-cf3fbf01ac61", - "private_attribute_sub_components": [ - "body00001_edge00008" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00012", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "791f24ba-efdd-4912-aa3c-66e5d6b667f9", - "private_attribute_sub_components": [ - "body00001_edge00012" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "sphere2_body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "96b21c6a-21b5-4387-acd2-83ae71f17413", - "private_attribute_sub_components": [ - "sphere2_body00001_edge00001" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "99d61ea6-9f7e-4236-87c0-c6101dfb0ee0", - "private_attribute_sub_components": [ - "body00001_edge00001" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "a27906f8-10ff-4820-a32b-41fa6c6cfa3c", - "private_attribute_sub_components": [ - "body00001_edge00003" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00010", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "b60b9fa0-e08c-44f4-9869-0b80a92c3c7e", - "private_attribute_sub_components": [ - "body00001_edge00010" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00004", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "be4f9f1f-e938-492a-aa73-d90b089619f8", - "private_attribute_sub_components": [ - "body00001_edge00004" - ], - "private_attribute_tag_key": "name" - } - ], - [ - { - "name": "body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "05742309-f8f3-439b-a3d1-6a60570a35ed", - "private_attribute_sub_components": [ - "body00001_edge00001" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00010", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "177aac2c-d04b-4bd5-8e5f-758b472bf985", - "private_attribute_sub_components": [ - "body00001_edge00010" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00006", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "2fe046d7-7d58-4b37-b4f5-b391335a520a", - "private_attribute_sub_components": [ - "body00001_edge00006" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "sphere2_body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "3193fbf3-6379-42de-ac63-5ad9401dcce2", - "private_attribute_sub_components": [ - "sphere2_body00001_edge00001" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00008", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "4978befb-352c-4879-a1d5-c4dc0dc83a07", - "private_attribute_sub_components": [ - "body00001_edge00008" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00002", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "5297031f-260c-4ff3-ba06-3e08018a51a4", - "private_attribute_sub_components": [ - "body00001_edge00002" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00011", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "599a9e71-3d9c-4ae6-962b-6bb34e8ee961", - "private_attribute_sub_components": [ - "body00001_edge00011" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00004", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "8e8d80ee-35ed-431a-bee6-460118ba75e9", - "private_attribute_sub_components": [ - "body00001_edge00004" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00007", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "93549028-dbcd-4dd3-87f5-745d269f8243", - "private_attribute_sub_components": [ - "body00001_edge00007" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "a6462fd9-871c-4ee7-b33c-f36e5788a965", - "private_attribute_sub_components": [ - "body00001_edge00003" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "sphere2_body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "ac831004-5a5e-4b8b-b66a-475b78308ab9", - "private_attribute_sub_components": [ - "sphere2_body00001_edge00003" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00012", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "e3c65004-eec0-469b-9dfe-bbd62705e412", - "private_attribute_sub_components": [ - "body00001_edge00012" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00005", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "e5e5b6e3-2835-4c1b-a0ce-056617827745", - "private_attribute_sub_components": [ - "body00001_edge00005" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00009", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "f0839250-331b-4d76-8409-824679adb5f5", - "private_attribute_sub_components": [ - "body00001_edge00009" - ], - "private_attribute_tag_key": "edgeId" - } - ] - ], - "grouped_faces": [ - [ - { - "name": "No Name", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "032d8c9b-9873-4557-b7c6-cde69f6507f7", - "private_attribute_sub_components": [ - "sphere2_body00001_face00001", - "sphere2_body00001_face00002" - ], - "private_attribute_tag_key": "builtinName", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 7.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "No Name", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "359eeb96-86b5-4952-ac23-192dfd3a40ac", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "builtinName", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "sphere2_body00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "6ce3c95d-1bd2-434c-be8b-d88151a099a0", - "private_attribute_sub_components": [ - "sphere2_body00001_face00001", - "sphere2_body00001_face00002" - ], - "private_attribute_tag_key": "groupByBodyId", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 7.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "cube_body", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "dc724906-9b8f-4e62-ac58-ec97e2a12c76", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "groupByBodyId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "084ec7b0-6ff1-4a3f-993d-06128efa49f8", - "private_attribute_sub_components": [ - "body00001_face00002" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - -1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00003", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "59eda58d-0dac-4f77-b4c4-2fcdf4361378", - "private_attribute_sub_components": [ - "body00001_face00003" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 13.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00005", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "8277de84-8306-4547-9cb0-687d178b14b1", - "private_attribute_sub_components": [ - "body00001_face00005" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - -1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "900a49b1-6d7b-44df-89f9-2cf48ac243f8", - "private_attribute_sub_components": [ - "body00001_face00001" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 11.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere2_body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "ad2779dd-c479-4b20-b753-054a988f6fac", - "private_attribute_sub_components": [ - "sphere2_body00001_face00002" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 7.0, - 2.4492935982947064e-16, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00004", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "b4ca0f33-0d8f-4fc1-be3a-30aa7b2d22da", - "private_attribute_sub_components": [ - "body00001_face00004" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - 1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00006", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "d4efa00f-dfdd-4461-b317-25fc6a775d4e", - "private_attribute_sub_components": [ - "body00001_face00006" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - 1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere2_body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "ee6f39e2-15ed-4d3b-a77a-8383831bad15", - "private_attribute_sub_components": [ - "sphere2_body00001_face00001" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -4.499279347985573e-32, - -2.0 - ], - [ - 7.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "sphere2_body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "33dc2ccd-4b46-4444-b094-76e5fdd2249f", - "private_attribute_sub_components": [ - "sphere2_body00001_face00001" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -4.499279347985573e-32, - -2.0 - ], - [ - 7.0, - 2.0, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00006", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "346806f8-3946-4a48-8616-56b538605814", - "private_attribute_sub_components": [ - "body00001_face00006" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - 1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00003", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "3db752a1-2925-4fc6-89fe-100a490d141b", - "private_attribute_sub_components": [ - "body00001_face00003" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 13.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "sphere2_body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "8c3e316d-fbd5-45ef-a79f-e60f764abcca", - "private_attribute_sub_components": [ - "sphere2_body00001_face00002" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 3.0, - -2.0, - -2.0 - ], - [ - 7.0, - 2.4492935982947064e-16, - 2.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "a3c3b221-97e2-4368-ac13-addb3102b46c", - "private_attribute_sub_components": [ - "body00001_face00001" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 11.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00005", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "c236edf3-4d79-4fbe-908d-0420dfdf53cc", - "private_attribute_sub_components": [ - "body00001_face00005" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - -1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "c89e0cf2-777a-413f-8e59-48f387f564ee", - "private_attribute_sub_components": [ - "body00001_face00002" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - -1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00004", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "cb676b21-1ecd-4062-94f6-3eb5fb279c7e", - "private_attribute_sub_components": [ - "body00001_face00004" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - 1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ] - ], - "type_name": "GeometryEntityInfo" -} diff --git a/tests/simulation/service/data/root_geometry_cube_simulation.json b/tests/simulation/service/data/root_geometry_cube_simulation.json deleted file mode 100644 index 0e3c8d0f4..000000000 --- a/tests/simulation/service/data/root_geometry_cube_simulation.json +++ /dev/null @@ -1,1154 +0,0 @@ -{ - "hash": "4da4256d0f07d14933d36243edede09ade1cf4894b99c3eeb7145146f6b32752", - "meshing": { - "defaults": { - "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": { - "units": "degree", - "value": 12.0 - }, - "planar_face_tolerance": 1e-06, - "preserve_thin_geometry": false, - "resolve_face_boundaries": false, - "sealing_size": { - "units": "m", - "value": 0.0 - }, - "sliding_interface_tolerance": 0.01, - "surface_edge_growth_rate": 1.2, - "surface_max_adaptation_iterations": 50, - "surface_max_aspect_ratio": 10.0 - }, - "outputs": [], - "refinement_factor": 1.0, - "refinements": [], - "type_name": "MeshingParams", - "volume_zones": [ - { - "method": "auto", - "name": "Farfield", - "relative_size": 50.0, - "type": "AutomatedFarfield" - } - ] - }, - "models": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": { - "units": "W/m**2", - "value": 0.0 - } - }, - "name": "Wall", - "private_attribute_id": "b7990356-c29b-44ee-aa5e-a163a18848d0", - "roughness_height": { - "units": "m", - "value": 0.0 - }, - "type": "Wall", - "use_wall_function": false - }, - { - "entities": { - "stored_entities": [ - { - "name": "farfield", - "private_attribute_entity_type_name": "GhostSurface", - "private_attribute_id": "farfield" - } - ] - }, - "name": "Freestream", - "private_attribute_id": "a00c9458-066e-4207-94c7-56474e62fe60", - "type": "Freestream" - }, - { - "initial_condition": { - "p": "p", - "rho": "rho", - "type_name": "NavierStokesInitialCondition", - "u": "u", - "v": "v", - "w": "w" - }, - "interface_interpolation_tolerance": 0.2, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1.0, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1.0, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30 - }, - "low_mach_preconditioner": false, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1.0, - "order_of_accuracy": 2, - "relative_tolerance": 0.0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "private_attribute_id": "__default_fluid", - "transition_model_solver": { - "type_name": "None" - }, - "turbulence_model_solver": { - "CFL_multiplier": 2.0, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20 - }, - "low_reynolds_correction": false, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_cb1": 0.1355, - "C_cb2": 0.622, - "C_d": 8.0, - "C_min_rd": 10.0, - "C_sigma": 0.6666666666666666, - "C_t3": 1.2, - "C_t4": 0.5, - "C_v1": 7.1, - "C_vonKarman": 0.41, - "C_w2": 0.3, - "C_w4": 0.21, - "C_w5": 1.5, - "type_name": "SpalartAllmarasConsts" - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0.0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - } - ], - "operating_condition": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": { - "alpha": { - "units": "degree", - "value": 0.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - } - }, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition" - }, - "outputs": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": { - "items": [ - "Cp", - "yPlus", - "Cf", - "CfVec" - ] - }, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "private_attribute_id": "39c6e6c8-9756-401f-bb12-8d037d829908", - "write_single_file": false - } - ], - "private_attribute_asset_cache": { - "project_entity_info": { - "bodies_face_edge_ids": { - "body00001": { - "edge_ids": [ - "body00001_edge00001", - "body00001_edge00002", - "body00001_edge00003", - "body00001_edge00004", - "body00001_edge00005", - "body00001_edge00006", - "body00001_edge00007", - "body00001_edge00008", - "body00001_edge00009", - "body00001_edge00010", - "body00001_edge00011", - "body00001_edge00012" - ], - "face_ids": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ] - } - }, - "body_attribute_names": [ - "bodyId", - "groupByFile" - ], - "body_group_tag": "groupByFile", - "body_ids": [ - "body00001" - ], - "default_geometry_accuracy": { - "units": "m", - "value": 0.0001 - }, - "draft_entities": [], - "edge_attribute_names": [ - "name", - "edgeId" - ], - "edge_group_tag": "name", - "edge_ids": [ - "body00001_edge00001", - "body00001_edge00002", - "body00001_edge00003", - "body00001_edge00004", - "body00001_edge00005", - "body00001_edge00006", - "body00001_edge00007", - "body00001_edge00008", - "body00001_edge00009", - "body00001_edge00010", - "body00001_edge00011", - "body00001_edge00012" - ], - "face_attribute_names": [ - "builtinName", - "groupByBodyId", - "name", - "faceId" - ], - "face_group_tag": "groupByBodyId", - "face_ids": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "ghost_entities": [ - { - "center": [ - 0, - 0, - 0 - ], - "max_radius": 100.0, - "name": "farfield", - "private_attribute_entity_type_name": "GhostSphere", - "private_attribute_id": "farfield" - }, - { - "center": [ - 12.0, - -1.0, - 0.0 - ], - "max_radius": 100.0, - "name": "symmetric-1", - "normal_axis": [ - 0, - -1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-1" - }, - { - "center": [ - 12.0, - 1.0, - 0.0 - ], - "max_radius": 100.0, - "name": "symmetric-2", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric-2" - }, - { - "center": [ - 12.0, - 0, - 0.0 - ], - "max_radius": 100.0, - "name": "symmetric", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_id": "symmetric" - }, - { - "name": "windTunnelInlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelInlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelOutlet", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelOutlet", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelCeiling", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCeiling", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFloor", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFloor", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelLeft", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelLeft", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelRight", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRight", - "used_by": [ - "all" - ] - }, - { - "name": "windTunnelFrictionPatch", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrictionPatch", - "used_by": [ - "StaticFloor" - ] - }, - { - "name": "windTunnelCentralBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelCentralBelt", - "used_by": [ - "CentralBelt", - "WheelBelts" - ] - }, - { - "name": "windTunnelFrontWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelFrontWheelBelt", - "used_by": [ - "WheelBelts" - ] - }, - { - "name": "windTunnelRearWheelBelt", - "private_attribute_entity_type_name": "WindTunnelGhostSurface", - "private_attribute_id": "windTunnelRearWheelBelt", - "used_by": [ - "WheelBelts" - ] - } - ], - "global_bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "grouped_bodies": [ - [ - { - "mesh_exterior": true, - "name": "body00001", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "b496086c-136d-4f01-998d-c9e1eea120ae", - "private_attribute_sub_components": [ - "body00001" - ], - "private_attribute_tag_key": "bodyId" - } - ], - [ - { - "mesh_exterior": false, - "name": "cube_2mm_center", - "private_attribute_entity_type_name": "GeometryBodyGroup", - "private_attribute_id": "7d2b9129-f5f4-4f92-b9b6-a554b420f162", - "private_attribute_sub_components": [ - "body00001" - ], - "private_attribute_tag_key": "groupByFile" - } - ] - ], - "grouped_edges": [ - [ - { - "name": "body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "99d61ea6-9f7e-4236-87c0-c6101dfb0ee0", - "private_attribute_sub_components": [ - "body00001_edge00001" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00002", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "67bf3b4f-b319-4cd2-a7b6-9c81281a7d2d", - "private_attribute_sub_components": [ - "body00001_edge00002" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "a27906f8-10ff-4820-a32b-41fa6c6cfa3c", - "private_attribute_sub_components": [ - "body00001_edge00003" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00004", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "be4f9f1f-e938-492a-aa73-d90b089619f8", - "private_attribute_sub_components": [ - "body00001_edge00004" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00005", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "0dcf993b-6ac4-4089-b4d6-2fd2d3a94d29", - "private_attribute_sub_components": [ - "body00001_edge00005" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00006", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "2e7e546a-753e-4b80-9d46-281f13070365", - "private_attribute_sub_components": [ - "body00001_edge00006" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00007", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "596a214f-3b47-448e-b18e-7e7b2999a845", - "private_attribute_sub_components": [ - "body00001_edge00007" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00008", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "75c1c7be-cbee-430e-9b0f-cf3fbf01ac61", - "private_attribute_sub_components": [ - "body00001_edge00008" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00009", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "1442e021-6026-4e98-a0a2-d45f14e017a5", - "private_attribute_sub_components": [ - "body00001_edge00009" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00010", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "b60b9fa0-e08c-44f4-9869-0b80a92c3c7e", - "private_attribute_sub_components": [ - "body00001_edge00010" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00011", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "6e06acde-5ff1-475d-a327-ea330f44b267", - "private_attribute_sub_components": [ - "body00001_edge00011" - ], - "private_attribute_tag_key": "name" - }, - { - "name": "body00001_edge00012", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "791f24ba-efdd-4912-aa3c-66e5d6b667f9", - "private_attribute_sub_components": [ - "body00001_edge00012" - ], - "private_attribute_tag_key": "name" - } - ], - [ - { - "name": "body00001_edge00001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "05742309-f8f3-439b-a3d1-6a60570a35ed", - "private_attribute_sub_components": [ - "body00001_edge00001" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00002", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "5297031f-260c-4ff3-ba06-3e08018a51a4", - "private_attribute_sub_components": [ - "body00001_edge00002" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "a6462fd9-871c-4ee7-b33c-f36e5788a965", - "private_attribute_sub_components": [ - "body00001_edge00003" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00004", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "8e8d80ee-35ed-431a-bee6-460118ba75e9", - "private_attribute_sub_components": [ - "body00001_edge00004" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00005", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "e5e5b6e3-2835-4c1b-a0ce-056617827745", - "private_attribute_sub_components": [ - "body00001_edge00005" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00006", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "2fe046d7-7d58-4b37-b4f5-b391335a520a", - "private_attribute_sub_components": [ - "body00001_edge00006" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00007", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "93549028-dbcd-4dd3-87f5-745d269f8243", - "private_attribute_sub_components": [ - "body00001_edge00007" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00008", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "4978befb-352c-4879-a1d5-c4dc0dc83a07", - "private_attribute_sub_components": [ - "body00001_edge00008" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00009", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "f0839250-331b-4d76-8409-824679adb5f5", - "private_attribute_sub_components": [ - "body00001_edge00009" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00010", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "177aac2c-d04b-4bd5-8e5f-758b472bf985", - "private_attribute_sub_components": [ - "body00001_edge00010" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00011", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "599a9e71-3d9c-4ae6-962b-6bb34e8ee961", - "private_attribute_sub_components": [ - "body00001_edge00011" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body00001_edge00012", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": "e3c65004-eec0-469b-9dfe-bbd62705e412", - "private_attribute_sub_components": [ - "body00001_edge00012" - ], - "private_attribute_tag_key": "edgeId" - } - ] - ], - "grouped_faces": [ - [ - { - "name": "No Name", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "359eeb96-86b5-4952-ac23-192dfd3a40ac", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "builtinName", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "cube_body", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "dc724906-9b8f-4e62-ac58-ec97e2a12c76", - "private_attribute_sub_components": [ - "body00001_face00001", - "body00001_face00002", - "body00001_face00003", - "body00001_face00004", - "body00001_face00005", - "body00001_face00006" - ], - "private_attribute_tag_key": "groupByBodyId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "900a49b1-6d7b-44df-89f9-2cf48ac243f8", - "private_attribute_sub_components": [ - "body00001_face00001" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 11.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "084ec7b0-6ff1-4a3f-993d-06128efa49f8", - "private_attribute_sub_components": [ - "body00001_face00002" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - -1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00003", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "59eda58d-0dac-4f77-b4c4-2fcdf4361378", - "private_attribute_sub_components": [ - "body00001_face00003" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 13.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00004", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "b4ca0f33-0d8f-4fc1-be3a-30aa7b2d22da", - "private_attribute_sub_components": [ - "body00001_face00004" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - 1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00005", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "8277de84-8306-4547-9cb0-687d178b14b1", - "private_attribute_sub_components": [ - "body00001_face00005" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - -1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00006", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "d4efa00f-dfdd-4461-b317-25fc6a775d4e", - "private_attribute_sub_components": [ - "body00001_face00006" - ], - "private_attribute_tag_key": "name", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - 1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ], - [ - { - "name": "body00001_face00001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "a3c3b221-97e2-4368-ac13-addb3102b46c", - "private_attribute_sub_components": [ - "body00001_face00001" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 11.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "c89e0cf2-777a-413f-8e59-48f387f564ee", - "private_attribute_sub_components": [ - "body00001_face00002" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - -1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00003", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "3db752a1-2925-4fc6-89fe-100a490d141b", - "private_attribute_sub_components": [ - "body00001_face00003" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 13.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00004", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "cb676b21-1ecd-4062-94f6-3eb5fb279c7e", - "private_attribute_sub_components": [ - "body00001_face00004" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - 1.0, - -1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00005", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "c236edf3-4d79-4fbe-908d-0420dfdf53cc", - "private_attribute_sub_components": [ - "body00001_face00005" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - -1.0 - ], - [ - 13.0, - 1.0, - -1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - }, - { - "name": "body00001_face00006", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "346806f8-3946-4a48-8616-56b538605814", - "private_attribute_sub_components": [ - "body00001_face00006" - ], - "private_attribute_tag_key": "faceId", - "private_attributes": { - "bounding_box": [ - [ - 11.0, - -1.0, - 1.0 - ], - [ - 13.0, - 1.0, - 1.0 - ] - ], - "type_name": "SurfacePrivateAttributes" - } - } - ] - ], - "type_name": "GeometryEntityInfo" - }, - "project_length_unit": { - "units": "m", - "value": 1.0 - }, - "use_geometry_AI": false, - "use_inhouse_mesher": false - }, - "reference_geometry": { - "area": { - "type_name": "number", - "units": "m**2", - "value": 1.0 - }, - "moment_center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "moment_length": { - "units": "m", - "value": [ - 1.0, - 1.0, - 1.0 - ] - } - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 0.25, - "max": 10000.0, - "max_relative_change": 1.0, - "min": 0.1, - "type": "adaptive" - }, - "max_steps": 2000, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_fields": [] -} diff --git a/tests/simulation/service/ref/updater_to_25_2_2.json b/tests/simulation/service/ref/updater_to_25_2_2.json deleted file mode 100644 index 1577551ff..000000000 --- a/tests/simulation/service/ref/updater_to_25_2_2.json +++ /dev/null @@ -1,705 +0,0 @@ -{ - "meshing": { - "defaults": { - "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": { - "units": "degree", - "value": 12 - }, - "surface_edge_growth_rate": 1.2 - }, - "gap_treatment_strength": 0, - "refinement_factor": 1, - "volume_zones": [ - { - "_id": "565a2873-27f6-4459-bfe3-d39d86a79a30", - "method": "auto", - "name": "Farfield", - "type": "AutomatedFarfield" - } - ] - }, - "models": [ - { - "_id": "c470609f-54ce-4ea3-a357-47c94005728c", - "initial_condition": { - "p": "p", - "rho": "rho", - "type": "expression", - "u": "u", - "v": "v", - "w": "w" - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30 - }, - "low_mach_preconditioner": false, - "low_mach_preconditioner_threshold": null, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1, - "order_of_accuracy": 2, - "relative_tolerance": 0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "transition_model_solver": { - "N_crit": 8.15, - "absolute_tolerance": 1e-07, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20 - }, - "max_force_jac_update_physical_steps": 0, - "order_of_accuracy": 2, - "relative_tolerance": 0, - "turbulence_intensity_percent": 1, - "type_name": "AmplificationFactorTransport", - "update_jacobian_frequency": 4 - }, - "turbulence_model_solver": { - "CFL_multiplier": 2, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20 - }, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_d": 8 - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - }, - { - "_id": "3c37e273-0785-4025-be10-c099bf03e402", - "entities": { - "stored_entities": [ - { - "_id": "body0001_face0001", - "name": "body0001_face0001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0001" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0002", - "name": "body0001_face0002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0002" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0003", - "name": "body0001_face0003", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0003" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0004", - "name": "body0001_face0004", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0004" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0005", - "name": "body0001_face0005", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0005" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0006", - "name": "body0001_face0006", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0006" - ], - "private_attribute_tag_key": "faceId" - } - ] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": { - "units": "kg/s**3", - "value": 0 - } - }, - "name": "Wall", - "type": "Wall", - "use_wall_function": false - }, - { - "_id": "bca1513a-511b-4353-b3f4-904e68373277", - "entities": { - "stored_entities": [ - { - "_id": "8ae37a4b-6970-5d88-aef5-43a1abcc845e", - "name": "farfield", - "private_attribute_entity_type_name": "GhostSurface", - "private_attribute_registry_bucket_name": "SurfaceEntityType" - } - ] - }, - "name": "Freestream1", - "type": "Freestream" - } - ], - "operating_condition": { - "alpha": { - "units": "degree", - "value": 0 - }, - "beta": { - "units": "degree", - "value": 0 - }, - "private_attribute_constructor": "default", - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": { - "altitude": { - "units": "m", - "value": 0 - }, - "temperature_offset": { - "units": "K", - "value": 0 - } - }, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition", - "velocity_magnitude": { - "units": "m/s", - "value": 1 - } - }, - "outputs": [ - { - "_id": "4a34d924-294f-448e-ba22-7e8789d7d7de", - "entities": { - "stored_entities": [ - { - "_id": "body0001_face0001", - "name": "body0001_face0001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0001" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0002", - "name": "body0001_face0002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0002" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0003", - "name": "body0001_face0003", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0003" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0004", - "name": "body0001_face0004", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0004" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0005", - "name": "body0001_face0005", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0005" - ], - "private_attribute_tag_key": "faceId" - }, - { - "_id": "body0001_face0006", - "name": "body0001_face0006", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0006" - ], - "private_attribute_tag_key": "faceId" - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": { - "items": [ - "Cp", - "yPlus", - "Cf", - "CfVec" - ] - }, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "write_single_file": false - } - ], - "private_attribute_asset_cache": { - "project_entity_info": { - "draft_entities": [ - { - "name": "Slice", - "normal": [ - 0, - 0, - 1 - ], - "origin": { - "units": "m", - "value": [ - 1, - 1, - 1 - ] - }, - "private_attribute_entity_type_name": "Slice", - "private_attribute_id": "afea6db5-6e61-4c5c-8628-8c3ee399f0ad" - } - ], - "edge_attribute_names": [ - "edgeId" - ], - "edge_group_tag": "edgeId", - "edge_ids": [ - "body0001_edge0001", - "body0001_edge0002", - "body0001_edge0003", - "body0001_edge0004", - "body0001_edge0005", - "body0001_edge0006", - "body0001_edge0007", - "body0001_edge0008", - "body0001_edge0009", - "body0001_edge0010", - "body0001_edge0011", - "body0001_edge0012" - ], - "face_attribute_names": [ - "faceId" - ], - "face_group_tag": "faceId", - "face_ids": [ - "body0001_face0001", - "body0001_face0002", - "body0001_face0003", - "body0001_face0004", - "body0001_face0005", - "body0001_face0006" - ], - "ghost_entities": [ - { - "center": [ - 0, - 0, - 0 - ], - "max_radius": 5.000000000000003, - "name": "farfield", - "private_attribute_entity_type_name": "GhostSphere", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType" - }, - { - "center": [ - 0, - -0.010000000000000005, - 0 - ], - "max_radius": 0.10000000000000005, - "name": "symmetry-1", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType" - }, - { - "center": [ - 0, - 0.010000000000000005, - 0 - ], - "max_radius": 0.10000000000000005, - "name": "symmetry-2", - "normal_axis": [ - 0, - 1, - 0 - ], - "private_attribute_entity_type_name": "GhostCircularPlane", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType" - } - ], - "grouped_edges": [ - [ - { - "name": "body0001_edge0001", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0001" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0002", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0002" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0003", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0003" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0004", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0004" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0005", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0005" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0006", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0006" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0007", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0007" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0008", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0008" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0009", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0009" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0010", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0010" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0011", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0011" - ], - "private_attribute_tag_key": "edgeId" - }, - { - "name": "body0001_edge0012", - "private_attribute_entity_type_name": "Edge", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "EdgeEntityType", - "private_attribute_sub_components": [ - "body0001_edge0012" - ], - "private_attribute_tag_key": "edgeId" - } - ] - ], - "grouped_faces": [ - [ - { - "name": "body0001_face0001", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0001" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body0001_face0002", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0002" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body0001_face0003", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0003" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body0001_face0004", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0004" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body0001_face0005", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0005" - ], - "private_attribute_tag_key": "faceId" - }, - { - "name": "body0001_face0006", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": null, - "private_attribute_id": null, - "private_attribute_is_interface": null, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "body0001_face0006" - ], - "private_attribute_tag_key": "faceId" - } - ] - ], - "type_name": "GeometryEntityInfo" - }, - "project_length_unit": { - "units": "m", - "value": 1 - } - }, - "reference_geometry": { - "area": { - "units": "m**2", - "value": 11 - }, - "moment_center": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "moment_length": { - "units": "m", - "value": [ - 1, - 1, - 1 - ] - } - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 0.25, - "max": 10000, - "max_relative_change": 1, - "min": 0.1, - "type": "adaptive" - }, - "max_steps": 2000, - "order_of_accuracy": 2, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_dynamics": null, - "version": "25.2.2" -} diff --git a/tests/simulation/service/test_apply_simulation_setting.py b/tests/simulation/service/test_apply_simulation_setting.py deleted file mode 100644 index b3ab979e5..000000000 --- a/tests/simulation/service/test_apply_simulation_setting.py +++ /dev/null @@ -1,488 +0,0 @@ -"""Tests for apply_simulation_setting_to_entity_info service function.""" - -import copy - -import pytest - -from flow360.component.simulation import services - - -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -def _create_base_simulation_dict(entity_info_type="VolumeMeshEntityInfo", surfaces=None): - """Helper to create a base simulation dict with configurable entity info.""" - if surfaces is None: - surfaces = [] - - entity_info = { - "draft_entities": [], - "ghost_entities": [], - "type_name": entity_info_type, - } - - if entity_info_type == "VolumeMeshEntityInfo": - entity_info["zones"] = [] - entity_info["boundaries"] = surfaces - elif entity_info_type == "SurfaceMeshEntityInfo": - entity_info["boundaries"] = surfaces - elif entity_info_type == "GeometryEntityInfo": - entity_info["grouped_faces"] = [surfaces] if surfaces else [[]] - entity_info["grouped_edges"] = [[]] - entity_info["grouped_bodies"] = [[]] - entity_info["face_attribute_names"] = ["default"] - entity_info["edge_attribute_names"] = ["default"] - entity_info["body_attribute_names"] = ["default"] - entity_info["face_group_tag"] = "default" - entity_info["edge_group_tag"] = "default" - entity_info["body_group_tag"] = "default" - entity_info["body_ids"] = [] - entity_info["face_ids"] = [] - entity_info["edge_ids"] = [] - - return { - "meshing": { - "refinement_factor": 1.0, - "gap_treatment_strength": 0.2, - "defaults": { - "surface_edge_growth_rate": 1.5, - "boundary_layer_first_layer_thickness": {"value": 1, "units": "m"}, - "surface_max_edge_length": {"value": 1, "units": "m"}, - }, - "refinements": [], - "volume_zones": [], - }, - "reference_geometry": { - "moment_center": {"value": [0, 0, 0], "units": "m"}, - "moment_length": {"value": 1.0, "units": "m"}, - "area": {"value": 1.0, "units": "m**2"}, - }, - "time_stepping": { - "type_name": "Steady", - "max_steps": 10, - "CFL": {"type": "ramp", "initial": 1.5, "final": 1.5, "ramp_steps": 5}, - }, - "models": [], - "outputs": [], - "user_defined_dynamics": [], - "unit_system": {"name": "SI"}, - "version": "24.11.0", - "private_attribute_asset_cache": { - "project_length_unit": None, - "project_entity_info": entity_info, - "use_inhouse_mesher": True, - "use_geometry_AI": False, - }, - } - - -def _create_surface_entity(name, private_attribute_id=None): - """Helper to create a surface entity dict.""" - return { - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_entity_type_name": "Surface", - "name": name, - "private_attribute_id": private_attribute_id or name, - "private_attribute_is_interface": False, - "private_attribute_sub_components": [], - } - - -def _create_box_entity(name, center=None): - """Helper to create a box (draft) entity dict.""" - return { - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_entity_type_name": "Box", - "type_name": "Box", - "name": name, - "center": center or {"value": [0, 0, 0], "units": "m"}, - "size": {"value": [1, 1, 1], "units": "m"}, - "axis_of_rotation": [0, 0, 1], - "angle_of_rotation": {"value": 0, "units": "degree"}, - } - - -class TestApplySimulationSettingBasic: - """Basic tests for apply_simulation_setting_to_entity_info.""" - - def test_basic_entity_replacement(self): - """Test that entities are correctly replaced when names match.""" - # Source simulation with settings and entity "wing" - source_surface = _create_surface_entity("wing", "source_wing_id") - source_dict = _create_base_simulation_dict(surfaces=[source_surface]) - source_dict["models"] = [ - { - "type": "Wall", - "entities": {"stored_entities": [_create_surface_entity("wing", "source_wing_id")]}, - "use_wall_function": False, - } - ] - - # Target simulation with different entity info (same name "wing" but different id) - target_surface = _create_surface_entity("wing", "target_wing_id") - target_dict = _create_base_simulation_dict(surfaces=[target_surface]) - - # Apply settings from source to target's entity info - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: entity in models should now reference target's entity - stored_entity = result_dict["models"][0]["entities"]["stored_entities"][0] - assert stored_entity["private_attribute_id"] == "target_wing_id" - assert stored_entity["name"] == "wing" - - # Verify: project_entity_info should be from target - boundary = result_dict["private_attribute_asset_cache"]["project_entity_info"][ - "boundaries" - ][0] - assert boundary["private_attribute_id"] == "target_wing_id" - - -class TestApplySimulationSettingUnmatchedEntity: - """Tests for unmatched entity handling.""" - - def test_unmatched_entity_generates_warning(self): - """Test that unmatched entities are skipped and generate warnings.""" - # Source has entity "wing" and "fuselage" - source_surfaces = [ - _create_surface_entity("wing", "wing_id"), - _create_surface_entity("fuselage", "fuselage_id"), - ] - source_dict = _create_base_simulation_dict(surfaces=source_surfaces) - source_dict["models"] = [ - { - "type": "Wall", - "entities": { - "stored_entities": [ - _create_surface_entity("wing", "wing_id"), - _create_surface_entity("fuselage", "fuselage_id"), - ] - }, - "use_wall_function": False, - } - ] - - # Target only has "wing", no "fuselage" - target_surfaces = [_create_surface_entity("wing", "target_wing_id")] - target_dict = _create_base_simulation_dict(surfaces=target_surfaces) - - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: only "wing" should remain in stored_entities - stored = result_dict["models"][0]["entities"]["stored_entities"] - assert len(stored) == 1 - assert stored[0]["name"] == "wing" - - # Verify: warning generated for "fuselage" - entity_warnings = [w for w in warnings if w.get("type") == "entity_not_found"] - assert len(entity_warnings) == 1 - assert "fuselage" in entity_warnings[0]["msg"] - - -class TestApplySimulationSettingPreserveDraftEntities: - """Tests for draft/ghost entity preservation.""" - - def test_draft_entities_preserved_in_entity_info(self): - """Test that draft_entities from source are preserved in result.""" - # Source has a box in draft_entities - source_dict = _create_base_simulation_dict() - source_dict["private_attribute_asset_cache"]["project_entity_info"]["draft_entities"] = [ - _create_box_entity("refinement_box") - ] - - # Target has no draft entities - target_dict = _create_base_simulation_dict() - target_dict["private_attribute_asset_cache"]["project_entity_info"]["draft_entities"] = [] - - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: draft_entities from source should be preserved - draft_entities = result_dict["private_attribute_asset_cache"]["project_entity_info"][ - "draft_entities" - ] - assert len(draft_entities) == 1 - assert draft_entities[0]["name"] == "refinement_box" - - def test_draft_entities_in_stored_entities_preserved(self): - """Test that draft entities in stored_entities are preserved without matching.""" - # Source with a Box entity in stored_entities (used in refinement) - source_dict = _create_base_simulation_dict() - source_dict["meshing"]["refinements"] = [ - { - "type": "UniformRefinement", - "entities": {"stored_entities": [_create_box_entity("my_box")]}, - "spacing": {"value": 0.1, "units": "m"}, - } - ] - - # Target with different entity info - target_dict = _create_base_simulation_dict() - - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: Box entity should be preserved (not matched/removed) - stored = result_dict["meshing"]["refinements"][0]["entities"]["stored_entities"] - assert len(stored) == 1 - assert stored[0]["name"] == "my_box" - assert stored[0]["private_attribute_entity_type_name"] == "Box" - - # Verify: no warning for Box entity - entity_warnings = [w for w in warnings if w.get("type") == "entity_not_found"] - assert len(entity_warnings) == 0 - - -class TestApplySimulationSettingCrossEntityInfoType: - """Tests for applying settings across different entity info types.""" - - def test_volume_mesh_to_volume_mesh(self): - """Test applying settings from one VolumeMesh project to another.""" - source_surface = _create_surface_entity("inlet", "source_inlet_id") - source_dict = _create_base_simulation_dict(surfaces=[source_surface]) - source_dict["models"] = [ - { - "type": "Freestream", - "entities": { - "stored_entities": [_create_surface_entity("inlet", "source_inlet_id")] - }, - } - ] - - target_surface = _create_surface_entity("inlet", "target_inlet_id") - target_dict = _create_base_simulation_dict(surfaces=[target_surface]) - - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: entity replaced with target's - stored = result_dict["models"][0]["entities"]["stored_entities"] - assert stored[0]["private_attribute_id"] == "target_inlet_id" - - def test_geometry_to_volume_mesh_no_grouping_tags(self): - """Test that grouping tags from Geometry source don't leak into VolumeMesh target.""" - # Source is GeometryEntityInfo with grouping tags - source_dict = _create_base_simulation_dict(entity_info_type="GeometryEntityInfo") - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_group_tag" - ] = "some_grouping" - - # Target is VolumeMeshEntityInfo (no grouping tags) - target_surface = _create_surface_entity("wall", "target_wall_id") - target_dict = _create_base_simulation_dict( - entity_info_type="VolumeMeshEntityInfo", surfaces=[target_surface] - ) - - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: no grouping tags should be in result entity_info - result_entity_info = result_dict["private_attribute_asset_cache"]["project_entity_info"] - assert "face_group_tag" not in result_entity_info - assert "body_group_tag" not in result_entity_info - assert "edge_group_tag" not in result_entity_info - - # Verify: result type should still be VolumeMeshEntityInfo - assert result_entity_info["type_name"] == "VolumeMeshEntityInfo" - - -class TestApplySimulationSettingGroupingTags: - """Tests for grouping tag inheritance from source.""" - - def test_grouping_tags_inherited_from_source_when_available_in_target(self): - """Test that grouping tags are inherited from source when they exist in target's attribute_names.""" - # Source uses "groupA" for all groupings - source_dict = _create_base_simulation_dict(entity_info_type="GeometryEntityInfo") - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_group_tag" - ] = "groupA" - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "body_group_tag" - ] = "groupA" - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "edge_group_tag" - ] = "groupA" - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_attribute_names" - ] = ["groupA", "groupB"] - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "body_attribute_names" - ] = ["groupA", "groupB"] - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "edge_attribute_names" - ] = ["groupA", "groupB"] - - # Target has both groupA and groupB, but uses groupB by default - target_dict = _create_base_simulation_dict(entity_info_type="GeometryEntityInfo") - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_group_tag" - ] = "groupB" - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "body_group_tag" - ] = "groupB" - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "edge_group_tag" - ] = "groupB" - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_attribute_names" - ] = [ - "groupA", - "groupB", - ] # Target has groupA available - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "body_attribute_names" - ] = ["groupA", "groupB"] - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "edge_attribute_names" - ] = ["groupA", "groupB"] - - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: grouping tags should be from source since "groupA" exists in target's attribute_names - result_entity_info = result_dict["private_attribute_asset_cache"]["project_entity_info"] - assert result_entity_info["face_group_tag"] == "groupA" - assert result_entity_info["body_group_tag"] == "groupA" - assert result_entity_info["edge_group_tag"] == "groupA" - - def test_grouping_tags_use_target_when_source_is_none(self): - """Test that target's grouping tags are used when source has None.""" - # Source with None grouping tags - source_dict = _create_base_simulation_dict(entity_info_type="GeometryEntityInfo") - source_dict["private_attribute_asset_cache"]["project_entity_info"]["face_group_tag"] = None - - # Target with specific grouping tag - target_dict = _create_base_simulation_dict(entity_info_type="GeometryEntityInfo") - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_group_tag" - ] = "target_face_grouping" - - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: face_group_tag should remain from target since source is None - result_entity_info = result_dict["private_attribute_asset_cache"]["project_entity_info"] - assert result_entity_info["face_group_tag"] == "target_face_grouping" - - def test_geometry_entities_matched_with_source_grouping(self): - """Test that entities are matched using source's grouping selection.""" - # Create surfaces for two different groupings - wing_surface_group_a = _create_surface_entity("wing", "wing_group_a_id") - wing_surface_group_b = _create_surface_entity("wing", "wing_group_b_id") - - # Source uses "groupA" for face grouping - source_dict = _create_base_simulation_dict(entity_info_type="GeometryEntityInfo") - source_dict["private_attribute_asset_cache"]["project_entity_info"]["grouped_faces"] = [ - [wing_surface_group_a], # groupA surfaces - [], # groupB surfaces (empty in source) - ] - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_attribute_names" - ] = ["groupA", "groupB"] - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_group_tag" - ] = "groupA" - source_dict["models"] = [ - { - "type": "Wall", - "entities": { - "stored_entities": [_create_surface_entity("wing", "wing_group_a_id")] - }, - "use_wall_function": False, - } - ] - - # Target has surfaces in both groupings - target_wing_group_a = _create_surface_entity("wing", "target_wing_group_a_id") - target_wing_group_b = _create_surface_entity("wing", "target_wing_group_b_id") - target_dict = _create_base_simulation_dict(entity_info_type="GeometryEntityInfo") - target_dict["private_attribute_asset_cache"]["project_entity_info"]["grouped_faces"] = [ - [target_wing_group_a], # groupA surfaces - [target_wing_group_b], # groupB surfaces - ] - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_attribute_names" - ] = ["groupA", "groupB"] - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_group_tag" - ] = "groupB" # Target originally uses groupB - - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: result should use source's grouping (groupA), so entity should match groupA's wing - stored = result_dict["models"][0]["entities"]["stored_entities"] - assert len(stored) == 1 - assert stored[0]["private_attribute_id"] == "target_wing_group_a_id" - - # Verify: face_group_tag in result should be from source - result_entity_info = result_dict["private_attribute_asset_cache"]["project_entity_info"] - assert result_entity_info["face_group_tag"] == "groupA" - - def test_source_grouping_tag_not_in_target_falls_back_to_target_tag(self): - """Test that when source's grouping tag doesn't exist in target, target's tag is used.""" - # Source uses "groupA" which target doesn't have - source_dict = _create_base_simulation_dict(entity_info_type="GeometryEntityInfo") - source_dict["private_attribute_asset_cache"]["project_entity_info"]["grouped_faces"] = [ - [_create_surface_entity("wing", "source_wing_id")], - ] - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_attribute_names" - ] = ["groupA"] - source_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_group_tag" - ] = "groupA" - source_dict["models"] = [ - { - "type": "Wall", - "entities": {"stored_entities": [_create_surface_entity("wing", "source_wing_id")]}, - "use_wall_function": False, - } - ] - - # Target only has "groupB" (no "groupA") - target_dict = _create_base_simulation_dict(entity_info_type="GeometryEntityInfo") - target_dict["private_attribute_asset_cache"]["project_entity_info"]["grouped_faces"] = [ - [_create_surface_entity("wing", "target_wing_id")], - ] - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_attribute_names" - ] = ["groupB"] - target_dict["private_attribute_asset_cache"]["project_entity_info"][ - "face_group_tag" - ] = "groupB" - - result_dict, errors, warnings = services.apply_simulation_setting_to_entity_info( - simulation_setting_dict=copy.deepcopy(source_dict), - entity_info_dict=copy.deepcopy(target_dict), - ) - - # Verify: face_group_tag should fall back to target's "groupB" since "groupA" doesn't exist - result_entity_info = result_dict["private_attribute_asset_cache"]["project_entity_info"] - assert result_entity_info["face_group_tag"] == "groupB" - - # Verify: entity should still be matched using target's grouping - stored = result_dict["models"][0]["entities"]["stored_entities"] - assert len(stored) == 1 - assert stored[0]["private_attribute_id"] == "target_wing_id" diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index ab95072af..1f90956cf 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -9,7 +9,6 @@ import flow360.component.simulation.units as u from flow360.component.simulation import services -from flow360.component.simulation.entity_info import GeometryEntityInfo from flow360.component.simulation.exposed_units import supported_units_by_front_end from flow360.component.simulation.framework.updater_utils import compare_values from flow360.component.simulation.services_report import get_default_report_config @@ -1371,30 +1370,6 @@ def test_unchanged_BETDisk_length_unit(): } -def test_updater_service(): - with open("data/updater_should_pass.json", "r") as fp: - dict_to_update = json.load(fp) - updated_params_as_dict, errors = services.update_simulation_json( - params_as_dict=dict_to_update, target_python_api_version="25.2.2" - ) - - with open("ref/updater_to_25_2_2.json", "r") as fp: - ref_dict = json.load(fp) - assert compare_values(updated_params_as_dict, ref_dict) - assert not errors - - # ============# - dict_to_update["version"] = "999.999.999" - updated_params_as_dict, errors = services.update_simulation_json( - params_as_dict=dict_to_update, target_python_api_version="25.2.2" - ) - assert len(errors) == 1 - assert ( - errors[0] - == "[Internal] API misuse. Input version (999.999.999) is higher than requested target version (25.2.2)." - ) - - def test_unit_conversion_front_end_compatibility(): ##### 1. Ensure that the units are valid in `supported_units_by_front_end` @@ -1436,187 +1411,6 @@ def test_get_default_report_config_json(): assert compare_values(report_config_dict, ref_dict, ignore_keys=["formatter"]) -def test_merge_geometry_entity_info(): - """ - Test the merge_geometry_entity_info function to ensure proper merging of geometry entity information. - - Test scenarios: - 1. Merge root geometry with one dependency geometry, should preserve mesh_exterior and name settings - of geometryBodyGroup in root geometry. - 2. Start from result of (1), replace the dependency with another dependency geometry, should check: - a. the preservation of mesh_exterior and name settings of geometryBodyGroup in new_draft_param_as_dict - b. the new dependency geometry should have replaced the old dependency geometry in the new_draft_param_as_dict - """ - import copy - - def check_setting_preserved( - result_entity_info: GeometryEntityInfo, - reference_entity_infos: list[GeometryEntityInfo], - entity_type: str, - setting_name: str, - ): - """ - Check that a specific setting is preserved from reference entity infos. - - Args: - result_entity_info: The merged entity info to verify - reference_entity_infos: List of reference entity infos to check against - entity_type: Either "body" or "face" - setting_name: The setting to check (e.g., "mesh_exterior", "name") - """ - if entity_type == "body": - group_tag = result_entity_info.body_group_tag - attribute_names = result_entity_info.body_attribute_names - grouped_entities = result_entity_info.grouped_bodies - elif entity_type == "face": - group_tag = result_entity_info.face_group_tag - attribute_names = result_entity_info.face_attribute_names - grouped_entities = result_entity_info.grouped_faces - else: - raise ValueError(f"Invalid entity_type: {entity_type}") - - group_index_result = attribute_names.index(group_tag) - - for entity in grouped_entities[group_index_result]: - found = False - for reference_info in reference_entity_infos: - if entity_type == "body": - ref_attribute_names = reference_info.body_attribute_names - ref_grouped_entities = reference_info.grouped_bodies - else: # face - ref_attribute_names = reference_info.face_attribute_names - ref_grouped_entities = reference_info.grouped_faces - - group_index_ref = ref_attribute_names.index(group_tag) - - for ref_entity in ref_grouped_entities[group_index_ref]: - if entity.private_attribute_id == ref_entity.private_attribute_id: - result_value = getattr(entity, setting_name) - ref_value = getattr(ref_entity, setting_name) - assert result_value == ref_value, ( - f"{setting_name} mismatch for {entity_type} " - f"'{entity.name}' (id: {entity.private_attribute_id}): " - f"expected {ref_value}, got {result_value}" - ) - found = True - break - if found: - break - assert found, ( - f"{entity_type.capitalize()} '{entity.name}' (id: {entity.private_attribute_id}) " - f"not found in any reference entity info" - ) - - # Load test data - with open("data/root_geometry_cube_simulation.json", "r") as f: - root_cube_simulation_dict = json.load(f) - root_cube_entity_info = GeometryEntityInfo.deserialize( - root_cube_simulation_dict["private_attribute_asset_cache"]["project_entity_info"] - ) - with open("data/dependency_geometry_sphere1_simulation.json", "r") as f: - dependency_sphere1_simulation_dict = json.load(f) - dependency_sphere1_entity_info = GeometryEntityInfo.deserialize( - dependency_sphere1_simulation_dict["private_attribute_asset_cache"][ - "project_entity_info" - ] - ) - with open("data/dependency_geometry_sphere2_simulation.json", "r") as f: - dependency_sphere2_simulation_dict = json.load(f) - dependency_sphere2_entity_info = GeometryEntityInfo.deserialize( - dependency_sphere2_simulation_dict["private_attribute_asset_cache"][ - "project_entity_info" - ] - ) - - # Test 1: Merge root geometry and one dependency geometry - # Should preserve mesh_exterior and name settings of geometryBodyGroup in root geometry - result_entity_info_dict1 = services.merge_geometry_entity_info( - draft_param_as_dict=root_cube_simulation_dict, - geometry_dependencies_param_as_dict=[ - root_cube_simulation_dict, - dependency_sphere1_simulation_dict, - ], - ) - result_entity_info1 = GeometryEntityInfo.deserialize(result_entity_info_dict1) - - # Load expected result for test 1 - with open("data/result_merged_geometry_entity_info1.json", "r") as f: - expected_result1 = json.load(f) - - # Compare results - assert compare_values( - result_entity_info_dict1, expected_result1 - ), "Test 1 failed: Merged entity info does not match expected result" - - # Verify key properties are preserved using helper function - check_setting_preserved( - result_entity_info1, - [root_cube_entity_info, dependency_sphere1_entity_info], - entity_type="body", - setting_name="mesh_exterior", - ) - check_setting_preserved( - result_entity_info1, - [root_cube_entity_info, dependency_sphere1_entity_info], - entity_type="body", - setting_name="name", - ) - check_setting_preserved( - result_entity_info1, - [root_cube_entity_info, dependency_sphere1_entity_info], - entity_type="face", - setting_name="name", - ) - - # Test 2: Start from result of (1), replace the dependency with another dependency geometry - # Should check: - # a. the preservation of mesh_exterior and name settings of geometryBodyGroup in new_draft_param_as_dict - # b. the new dependency geometry should have replaced the old dependency geometry - new_draft_param_as_dict = copy.deepcopy(root_cube_simulation_dict) - new_draft_param_as_dict["private_attribute_asset_cache"]["project_entity_info"] = copy.deepcopy( - result_entity_info_dict1 - ) - - result_entity_info_dict2 = services.merge_geometry_entity_info( - draft_param_as_dict=new_draft_param_as_dict, - geometry_dependencies_param_as_dict=[ - root_cube_simulation_dict, - dependency_sphere2_simulation_dict, - ], - ) - - # Load expected result for test 2 - with open("data/result_merged_geometry_entity_info2.json", "r") as f: - expected_result2 = json.load(f) - - # Compare results - assert compare_values( - result_entity_info_dict2, expected_result2 - ), "Test 2 failed: Merged entity info with replaced dependency does not match expected result" - - result_entity_info2 = GeometryEntityInfo.deserialize(result_entity_info_dict2) - - # Verify key properties are preserved using helper function - check_setting_preserved( - result_entity_info2, - [root_cube_entity_info, dependency_sphere2_entity_info], - entity_type="body", - setting_name="mesh_exterior", - ) - check_setting_preserved( - result_entity_info2, - [root_cube_entity_info, dependency_sphere2_entity_info], - entity_type="body", - setting_name="name", - ) - check_setting_preserved( - result_entity_info2, - [root_cube_entity_info, dependency_sphere2_entity_info], - entity_type="face", - setting_name="name", - ) - - def test_sanitize_stack_trace(): """Test that _sanitize_stack_trace properly sanitizes file paths and removes traceback prefix.""" from flow360.component.simulation.services import _sanitize_stack_trace From fd3943ba046303f0d804cdb5471db266bc291300 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 16 Apr 2026 14:30:33 -0400 Subject: [PATCH 20/25] Relay more simulation default services through schema --- flow360/component/simulation/services.py | 189 +--- tests/data/simulation/simulation_24_11_0.json | 971 ------------------ .../ref/simulation/service_init_geometry.json | 347 ------- .../simulation/service_init_surface_mesh.json | 347 ------- .../simulation/service_init_volume_mesh.json | 308 ------ .../params/test_simulation_params.py | 19 - tests/simulation/service/test_services_v2.py | 154 --- tests/simulation/test_updater.py | 16 - 8 files changed, 7 insertions(+), 2344 deletions(-) delete mode 100644 tests/data/simulation/simulation_24_11_0.json delete mode 100644 tests/ref/simulation/service_init_geometry.json delete mode 100644 tests/ref/simulation/service_init_surface_mesh.json delete mode 100644 tests/ref/simulation/service_init_volume_mesh.json diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index a5cfddde1..158f00aa7 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -17,6 +17,7 @@ from flow360_schema.framework.physical_dimensions import Angle, Length from flow360_schema.models.simulation.services import ( # pylint: disable=unused-import ValidationCalledBy, + _get_default_reference_geometry, _determine_validation_level, _insert_forward_compatibility_notice, _intersect_validation_levels, @@ -24,12 +25,15 @@ _parse_root_item_type_from_simulation_json, _populate_error_context, _sanitize_stack_trace, + _store_project_length_unit, _traverse_error_location, + apply_simulation_setting_to_entity_info, clean_unrelated_setting_from_params_dict, clear_context, + get_default_params, handle_generic_exception, + init_unit_system, initialize_variable_space, - apply_simulation_setting_to_entity_info, merge_geometry_entity_info, update_simulation_json, validate_error_locations, @@ -41,29 +45,22 @@ from flow360.component.simulation.framework.entity_materializer import ( materialize_entities_and_selectors_in_place, ) -from flow360.component.simulation.meshing_param.params import MeshingParams -from flow360.component.simulation.meshing_param.volume_params import AutomatedFarfield from flow360.component.simulation.models.bet.bet_translator_interface import ( generate_polar_file_name_list, translate_xfoil_c81_to_bet_dict, translate_xrotor_dfdc_to_bet_dict, ) -from flow360.component.simulation.models.surface_models import Freestream, Wall # pylint: disable=unused-import # For parse_model_dict from flow360.component.simulation.models.volume_models import BETDisk from flow360.component.simulation.operating_condition.operating_condition import ( - AerospaceCondition, GenericReferenceCondition, ThermalState, ) from flow360.component.simulation.primitives import Box # pylint: enable=unused-import -from flow360.component.simulation.simulation_params import ( - ReferenceGeometry, - SimulationParams, -) +from flow360.component.simulation.simulation_params import SimulationParams # Required for correct global scope initialization from flow360.component.simulation.translator.solver_translator import ( @@ -76,14 +73,7 @@ from flow360.component.simulation.translator.volume_meshing_translator import ( get_volume_meshing_json, ) -from flow360.component.simulation.unit_system import ( - _UNIT_SYSTEMS, - UnitSystem, - _dimensioned_type_serializer, - u, - unit_system_manager, -) -from flow360.component.simulation.units import validate_length +from flow360.component.simulation.unit_system import _dimensioned_type_serializer, u from flow360.component.simulation.validation.validation_context import ALL from flow360.exceptions import ( Flow360TranslationError, @@ -91,170 +81,6 @@ ) from flow360.version import __version__ -# Required for correct global scope initialization - - -def init_unit_system(unit_system_name) -> UnitSystem: - """Returns UnitSystem object from string representation. - - Parameters - ---------- - unit_system_name : ["SI", "CGS", "Imperial"] - Unit system string representation - - Returns - ------- - UnitSystem - unit system - - Raises - ------ - ValueError - If unit system doesn't exist - RuntimeError - If this function is run inside unit system context - """ - - unit_system = _UNIT_SYSTEMS.get(unit_system_name) - if unit_system is None: - raise ValueError( - f"Unknown unit system: {unit_system_name!r}. " f"Available: {list(_UNIT_SYSTEMS)}" - ) - - if unit_system_manager.current is not None: - raise RuntimeError( - f"Services cannot be used inside unit system context. Used: {unit_system_manager.current.system_repr()}." - ) - return unit_system - - -def _store_project_length_unit(project_length_unit, params: SimulationParams): - if project_length_unit is not None: - # Store the length unit so downstream services/pipelines can use it - # pylint: disable=fixme - # TODO: client does not call this. We need to start using new webAPI for that - # pylint: disable=assigning-non-slot,no-member - params.private_attribute_asset_cache._force_set_attr( # pylint:disable=protected-access - "project_length_unit", project_length_unit - ) - return params - - -def _get_default_reference_geometry(length_unit: Length.Float64): - return ReferenceGeometry( - area=1 * length_unit**2, - moment_center=(0, 0, 0) * length_unit, - moment_length=(1, 1, 1) * length_unit, - ) - - -def get_default_params( - unit_system_name, length_unit, root_item_type: Literal["Geometry", "SurfaceMesh", "VolumeMesh"] -) -> dict: - """ - Returns default parameters in a given unit system. The defaults are not correct SimulationParams object as they may - contain empty required values. When generating default case settings: - - Use Model() if all fields has defaults or there are no required fields - - Use Model.construct() to disable validation - when there are required fields without value - - Parameters - ---------- - unit_system_name : str - The name of the unit system to use for parameter initialization. - - Returns - ------- - dict - Default parameters for Flow360 simulation stored in a dictionary. - - """ - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.outputs.outputs import SurfaceOutput - from flow360.component.simulation.primitives import Surface - - unit_system = init_unit_system(unit_system_name) - dummy_value = 0.1 - project_length_unit = validate_length(length_unit) - with unit_system: - reference_geometry = _get_default_reference_geometry(project_length_unit) - operating_condition = AerospaceCondition(velocity_magnitude=dummy_value) - surface_output = SurfaceOutput( - name="Surface output", - entities=[Surface(name="*")], - output_fields=["Cp", "yPlus", "Cf", "CfVec"], - ) - - if root_item_type in ("Geometry", "SurfaceMesh"): - automated_farfield = AutomatedFarfield(name="Farfield") - with unit_system: - params = SimulationParams( - reference_geometry=reference_geometry, - meshing=MeshingParams( - volume_zones=[automated_farfield], - ), - operating_condition=operating_condition, - models=[ - Wall( - name="Wall", - surfaces=[Surface(name="*")], - roughness_height=0 * project_length_unit, - ), - Freestream(name="Freestream", surfaces=[automated_farfield.farfield]), - ], - outputs=[surface_output], - ) - - params = _store_project_length_unit(project_length_unit, params) - - return params.model_dump( - mode="json", - exclude_none=True, - exclude={ - "operating_condition": {"velocity_magnitude": True}, - "private_attribute_asset_cache": {"registry": True}, - "meshing": { - "defaults": {"edge_split_layers": True} - }, # Due to beta mesher by default is disabled. - }, - ) - - if root_item_type == "VolumeMesh": - with unit_system: - params = SimulationParams( - reference_geometry=reference_geometry, - operating_condition=operating_condition, - models=[ - Wall( - name="Wall", - surfaces=[Surface(name="placeholder1")], - roughness_height=0 * project_length_unit, - ), # to make it consistent with geo - Freestream( - name="Freestream", surfaces=[Surface(name="placeholder2")] - ), # to make it consistent with geo - ], - outputs=[surface_output], - ) - # cleaning up stored entities in default settings to let user decide: - params.models[0].entities.stored_entities = [] # pylint: disable=unsubscriptable-object - params.models[1].entities.stored_entities = [] # pylint: disable=unsubscriptable-object - - params = _store_project_length_unit(project_length_unit, params) - - return params.model_dump( - mode="json", - exclude_none=True, - exclude={ - "operating_condition": {"velocity_magnitude": True}, - "private_attribute_asset_cache": {"registry": True}, - "meshing": True, - }, - ) - raise ValueError( - f"Unknown root item type: {root_item_type}. Expected one of Geometry or SurfaceMesh or VolumeMesh" - ) - - def validate_model( # pylint: disable=too-many-locals *, params_as_dict, @@ -719,4 +545,3 @@ def translate_xfoil_c81_bet_disk( # Expected exceptions errors.append(str(e)) return bet_dict_list, errors - diff --git a/tests/data/simulation/simulation_24_11_0.json b/tests/data/simulation/simulation_24_11_0.json deleted file mode 100644 index 3223cb74b..000000000 --- a/tests/data/simulation/simulation_24_11_0.json +++ /dev/null @@ -1,971 +0,0 @@ -{ - "models": [ - { - "_id": "5bf59e61-cd3b-48ae-87de-5b463046f90b", - "initial_condition": { - "p": "p", - "rho": "rho", - "type_name": "NavierStokesInitialCondition", - "u": "u", - "v": "v", - "w": "w" - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30 - }, - "low_mach_preconditioner": false, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1, - "order_of_accuracy": 2, - "relative_tolerance": 0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "transition_model_solver": { - "type_name": "None" - }, - "turbulence_model_solver": { - "CFL_multiplier": 2, - "DDES": false, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "grid_size_for_LES": "maxEdgeLength", - "linear_solver": { - "max_iterations": 20 - }, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_cb1": 0.1355, - "C_cb2": 0.622, - "C_d": 8, - "C_min_rd": 10, - "C_sigma": 0.6666666666666666, - "C_t3": 1.2, - "C_t4": 0.5, - "C_v1": 7.1, - "C_vonKarman": 0.41, - "C_w2": 0.3, - "type_name": "SpalartAllmarasConsts" - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - }, - { - "_id": "6e04f92b-1e83-4f13-9b5c-78dee1fd0875", - "entities": { - "stored_entities": [ - { - "_id": "fluid/centerbody", - "name": "fluid/centerbody", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "fluid/centerbody", - "private_attribute_id": null, - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - }, - { - "_id": "solid/adiabatic", - "name": "solid/adiabatic", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "solid/adiabatic", - "private_attribute_id": null, - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - } - ] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": { - "units": "W/m**2", - "value": 0 - } - }, - "name": "Wall", - "type": "Wall", - "use_wall_function": false - }, - { - "_id": "8c459180-db3a-498e-afee-19ed119373cf", - "entities": { - "stored_entities": [ - { - "_id": "fluid/farfield", - "name": "fluid/farfield", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "fluid/farfield", - "private_attribute_id": null, - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - } - ] - }, - "name": "Freestream", - "type": "Freestream" - } - ], - "operating_condition": { - "alpha": { - "units": "degree", - "value": 0 - }, - "beta": { - "units": "degree", - "value": 0 - }, - "private_attribute_constructor": "default", - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "private_attribute_constructor": "default", - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition", - "velocity_magnitude": { - "units": "m/s", - "value": 10 - } - }, - "outputs": [ - { - "_id": "4760998d-3716-439f-a4b8-b49ac235383e", - "entities": { - "stored_entities": [ - { - "_id": "fluid/centerbody", - "name": "fluid/centerbody", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "fluid/centerbody", - "private_attribute_id": null, - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - }, - { - "_id": "solid/adiabatic", - "name": "solid/adiabatic", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "solid/adiabatic", - "private_attribute_id": null, - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - }, - { - "_id": "fluid/farfield", - "name": "fluid/farfield", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "fluid/farfield", - "private_attribute_id": null, - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": { - "items": [ - "Cp", - "yPlus", - "Cf", - "CfVec" - ] - }, - "output_format": "paraview", - "output_type": "SurfaceOutput", - "write_single_file": false - }, - { - "_id": "3a7cd1f5-6f77-4d19-982a-f924b8891bcd", - "name": "Edited aeroacoustic output", - "observers": [ - { - "group_name": "1", - "position": { - "units": "m", - "value": [ - 12.4, - 5.1, - -50 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "7", - "position": { - "units": "m", - "value": [ - -6.3, - -16.4, - -50 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "1", - "position": { - "units": "m", - "value": [ - 18.3, - 8.8, - -49.9 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "9", - "position": { - "units": "m", - "value": [ - -13.9, - -5.3, - -49.9 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "9", - "position": { - "units": "m", - "value": [ - 10.1, - 9.5, - -49.8 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "9", - "position": { - "units": "m", - "value": [ - -8.3, - -17.7, - -49.8 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "4", - "position": { - "units": "m", - "value": [ - 11.7, - 14.9, - -49.7 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "8", - "position": { - "units": "m", - "value": [ - -5.6, - -9.8, - -49.7 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "1", - "position": { - "units": "m", - "value": [ - 17.8, - 8.2, - -49.6 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "7", - "position": { - "units": "m", - "value": [ - -10.2, - -17.5, - -49.6 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "7", - "position": { - "units": "m", - "value": [ - 11.8, - 14.5, - -49.5 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "4", - "position": { - "units": "m", - "value": [ - -13, - -9.6, - -49.5 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "1", - "position": { - "units": "m", - "value": [ - 18.6, - 5.1, - -49.4 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "4", - "position": { - "units": "m", - "value": [ - -6, - -6.3, - -49.4 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "9", - "position": { - "units": "m", - "value": [ - 8.8, - 17.3, - -49.3 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "6", - "position": { - "units": "m", - "value": [ - -7, - -9.7, - -49.3 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "5", - "position": { - "units": "m", - "value": [ - 7.6, - 19.8, - -49.2 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "2", - "position": { - "units": "m", - "value": [ - -8.9, - -5.9, - -49.2 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "5", - "position": { - "units": "m", - "value": [ - 5.4, - 13.4, - -49.1 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "0", - "position": { - "units": "m", - "value": [ - -5.8, - -13.1, - -49.1 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "8", - "position": { - "units": "m", - "value": [ - 7.8, - 9.1, - -49 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "0", - "position": { - "units": "m", - "value": [ - -11.1, - -16.6, - -49 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "8", - "position": { - "units": "m", - "value": [ - 6.7, - 12.3, - -48.9 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "8", - "position": { - "units": "m", - "value": [ - -7.9, - -13.7, - -48.9 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "3", - "position": { - "units": "m", - "value": [ - 14.5, - 19.3, - -48.8 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "3", - "position": { - "units": "m", - "value": [ - -10.7, - -18, - -48.8 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "2", - "position": { - "units": "m", - "value": [ - 15.7, - 6.1, - -48.7 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "5", - "position": { - "units": "m", - "value": [ - -10.5, - -9.6, - -48.7 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "0", - "position": { - "units": "m", - "value": [ - 19.8, - 15.6, - -48.6 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "4", - "position": { - "units": "m", - "value": [ - -8, - -5.5, - -48.6 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "2", - "position": { - "units": "m", - "value": [ - 11.7, - 8.6, - -48.5 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "6", - "position": { - "units": "m", - "value": [ - -16.9, - -6.4, - -48.5 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "6", - "position": { - "units": "m", - "value": [ - 6.7, - 16.4, - -48.4 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "3", - "position": { - "units": "m", - "value": [ - -12.7, - -8.8, - -48.4 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "3", - "position": { - "units": "m", - "value": [ - 7.3, - 5.3, - -48.3 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "6", - "position": { - "units": "m", - "value": [ - -16.6, - -11.6, - -48.3 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "0", - "position": { - "units": "m", - "value": [ - 19.7, - 6.6, - -48.2 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "7", - "position": { - "units": "m", - "value": [ - -14.5, - -11.8, - -48.2 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "4", - "position": { - "units": "m", - "value": [ - 15.1, - 5.4, - -48.1 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "5", - "position": { - "units": "m", - "value": [ - -15.2, - -19.7, - -48.1 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "8", - "position": { - "units": "m", - "value": [ - 18.8, - 15.4, - -48 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "7", - "position": { - "units": "m", - "value": [ - -11.9, - -15.6, - -48 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "3", - "position": { - "units": "m", - "value": [ - 16.3, - 12.2, - -47.9 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "1", - "position": { - "units": "m", - "value": [ - -11.7, - -10, - -47.9 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "3", - "position": { - "units": "m", - "value": [ - 13.8, - 19, - -47.8 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "0", - "position": { - "units": "m", - "value": [ - -8.1, - -10.8, - -47.8 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "0", - "position": { - "units": "m", - "value": [ - 5.3, - 15.7, - -47.7 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "9", - "position": { - "units": "m", - "value": [ - -9.9, - -17.4, - -47.7 - ] - }, - "private_attribute_expand": true - }, - { - "group_name": "0", - "position": { - "units": "m", - "value": [ - 17.5, - 6.9, - -47.6 - ] - }, - "private_attribute_expand": true - } - ], - "output_type": "AeroAcousticOutput", - "write_per_surface_output": false - } - ], - "private_attribute_asset_cache": { - "project_entity_info": { - "boundaries": [ - { - "name": "fluid/Interface_solid", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "fluid/Interface_solid", - "private_attribute_id": null, - "private_attribute_is_interface": true, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - }, - { - "name": "fluid/centerbody", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "fluid/centerbody", - "private_attribute_id": null, - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - }, - { - "name": "solid/adiabatic", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "solid/adiabatic", - "private_attribute_id": null, - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - }, - { - "name": "solid/Interface_fluid", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "solid/Interface_fluid", - "private_attribute_id": null, - "private_attribute_is_interface": true, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - }, - { - "name": "fluid/farfield", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "fluid/farfield", - "private_attribute_id": null, - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_tag_key": null - } - ], - "type_name": "VolumeMeshEntityInfo", - "zones": [ - { - "axes": null, - "axis": [ - 0, - 0, - 1 - ], - "center": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "name": "fluid", - "private_attribute_entity_type_name": "GenericVolume", - "private_attribute_full_name": "fluid", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_zone_boundary_names": { - "items": [ - "fluid/Interface_solid", - "fluid/centerbody", - "fluid/farfield" - ] - } - }, - { - "axes": null, - "axis": [ - 0, - 0, - 1 - ], - "center": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "name": "solid", - "private_attribute_entity_type_name": "GenericVolume", - "private_attribute_full_name": "solid", - "private_attribute_id": null, - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_zone_boundary_names": { - "items": [ - "solid/Interface_fluid", - "solid/adiabatic" - ] - } - } - ] - }, - "project_length_unit": { - "units": "m", - "value": 1 - } - }, - "reference_geometry": { - "area": { - "units": "m**2", - "value": 1 - }, - "moment_center": { - "units": "m", - "value": [ - 0, - 0, - 0 - ] - }, - "moment_length": { - "units": "m", - "value": [ - 1, - 1, - 1 - ] - } - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 0.25, - "max": 10000, - "max_relative_change": 1, - "min": 0.1, - "type": "adaptive" - }, - "max_steps": 10, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_dynamics": null, - "version": "24.11.0" -} diff --git a/tests/ref/simulation/service_init_geometry.json b/tests/ref/simulation/service_init_geometry.json deleted file mode 100644 index 6381799ec..000000000 --- a/tests/ref/simulation/service_init_geometry.json +++ /dev/null @@ -1,347 +0,0 @@ -{ - "meshing": { - "defaults": { - "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": 0.20943951023931956, - "planar_face_tolerance": 1e-06, - "preserve_thin_geometry": false, - "remove_hidden_geometry": false, - "resolve_face_boundaries": false, - "sealing_size": 0.0, - "sliding_interface_tolerance": 0.01, - "surface_edge_growth_rate": 1.2, - "surface_max_adaptation_iterations": 50, - "surface_max_aspect_ratio": 10.0 - }, - "outputs": [], - "refinement_factor": 1.0, - "refinements": [], - "type_name": "MeshingParams", - "volume_zones": [ - { - "method": "auto", - "name": "Farfield", - "relative_size": 50.0, - "type": "AutomatedFarfield" - } - ] - }, - "models": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": 0.0 - }, - "name": "Wall", - "roughness_height": 0.0, - "type": "Wall" - }, - { - "entities": { - "stored_entities": [ - { - "name": "farfield", - "private_attribute_entity_type_name": "GhostSurface", - "private_attribute_id": "farfield" - } - ] - }, - "name": "Freestream", - "type": "Freestream" - }, - { - "initial_condition": { - "p": "p", - "rho": "rho", - "type_name": "NavierStokesInitialCondition", - "u": "u", - "v": "v", - "w": "w" - }, - "interface_interpolation_tolerance": 0.2, - "material": { - "dynamic_viscosity": { - "effective_temperature": 110.4, - "reference_temperature": 273.15, - "reference_viscosity": 1.716e-05 - }, - "name": "air", - "prandtl_number": 0.72, - "thermally_perfect_gas": { - "species": [ - { - "mass_fraction": 1.0, - "name": "Air", - "nasa_9_coefficients": { - "temperature_ranges": [ - { - "coefficients": [ - 0.0, - 0.0, - 3.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "temperature_range_max": 6000.0, - "temperature_range_min": 200.0, - "type_name": "NASA9CoefficientSet" - } - ], - "type_name": "NASA9Coefficients" - }, - "type_name": "FrozenSpecies" - } - ], - "type_name": "ThermallyPerfectGas" - }, - "turbulent_prandtl_number": 0.9, - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1.0, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1.0, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30, - "type_name": "LinearSolver" - }, - "low_mach_preconditioner": false, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1.0, - "order_of_accuracy": 2, - "relative_tolerance": 0.0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "private_attribute_id": "__default_fluid", - "transition_model_solver": { - "type_name": "None" - }, - "turbulence_model_solver": { - "CFL_multiplier": 2.0, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20, - "type_name": "LinearSolver" - }, - "low_reynolds_correction": false, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_cb1": 0.1355, - "C_cb2": 0.622, - "C_d": 8.0, - "C_min_rd": 10.0, - "C_sigma": 0.6666666666666666, - "C_t3": 1.2, - "C_t4": 0.5, - "C_v1": 7.1, - "C_vonKarman": 0.41, - "C_w2": 0.3, - "C_w4": 0.21, - "C_w5": 1.5, - "type_name": "SpalartAllmarasConsts" - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0.0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - } - ], - "operating_condition": { - "alpha": 0.0, - "beta": 0.0, - "private_attribute_constructor": "default", - "private_attribute_input_cache": { - "alpha": 0.0, - "beta": 0.0, - "thermal_state": { - "density": 1.225, - "material": { - "dynamic_viscosity": { - "effective_temperature": 110.4, - "reference_temperature": 273.15, - "reference_viscosity": 1.716e-05 - }, - "name": "air", - "prandtl_number": 0.72, - "thermally_perfect_gas": { - "species": [ - { - "mass_fraction": 1.0, - "name": "Air", - "nasa_9_coefficients": { - "temperature_ranges": [ - { - "coefficients": [ - 0.0, - 0.0, - 3.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "temperature_range_max": 6000.0, - "temperature_range_min": 200.0, - "type_name": "NASA9CoefficientSet" - } - ], - "type_name": "NASA9Coefficients" - }, - "type_name": "FrozenSpecies" - } - ], - "type_name": "ThermallyPerfectGas" - }, - "turbulent_prandtl_number": 0.9, - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": 288.15, - "type_name": "ThermalState" - } - }, - "thermal_state": { - "density": 1.225, - "material": { - "dynamic_viscosity": { - "effective_temperature": 110.4, - "reference_temperature": 273.15, - "reference_viscosity": 1.716e-05 - }, - "name": "air", - "prandtl_number": 0.72, - "thermally_perfect_gas": { - "species": [ - { - "mass_fraction": 1.0, - "name": "Air", - "nasa_9_coefficients": { - "temperature_ranges": [ - { - "coefficients": [ - 0.0, - 0.0, - 3.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "temperature_range_max": 6000.0, - "temperature_range_min": 200.0, - "type_name": "NASA9CoefficientSet" - } - ], - "type_name": "NASA9Coefficients" - }, - "type_name": "FrozenSpecies" - } - ], - "type_name": "ThermallyPerfectGas" - }, - "turbulent_prandtl_number": 0.9, - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": 288.15, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition" - }, - "outputs": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": { - "items": [ - "Cp", - "yPlus", - "Cf", - "CfVec" - ] - }, - "output_format": [ - "paraview" - ], - "output_type": "SurfaceOutput", - "write_single_file": false - } - ], - "private_attribute_asset_cache": { - "project_length_unit": 1.0, - "use_geometry_AI": false, - "use_inhouse_mesher": false - }, - "reference_geometry": { - "area": { - "type_name": "number", - "units": "m**2", - "value": 1.0 - }, - "moment_center": [ - 0.0, - 0.0, - 0.0 - ], - "moment_length": [ - 1.0, - 1.0, - 1.0 - ] - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 0.25, - "max": 10000.0, - "max_relative_change": 1.0, - "min": 0.1, - "type": "adaptive" - }, - "max_steps": 2000, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_fields": [] -} diff --git a/tests/ref/simulation/service_init_surface_mesh.json b/tests/ref/simulation/service_init_surface_mesh.json deleted file mode 100644 index 728c47be1..000000000 --- a/tests/ref/simulation/service_init_surface_mesh.json +++ /dev/null @@ -1,347 +0,0 @@ -{ - "meshing": { - "defaults": { - "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": 0.20943951023931956, - "planar_face_tolerance": 1e-06, - "preserve_thin_geometry": false, - "remove_hidden_geometry": false, - "resolve_face_boundaries": false, - "sealing_size": 0.0, - "sliding_interface_tolerance": 0.01, - "surface_edge_growth_rate": 1.2, - "surface_max_adaptation_iterations": 50, - "surface_max_aspect_ratio": 10.0 - }, - "outputs": [], - "refinement_factor": 1.0, - "refinements": [], - "type_name": "MeshingParams", - "volume_zones": [ - { - "method": "auto", - "name": "Farfield", - "relative_size": 50.0, - "type": "AutomatedFarfield" - } - ] - }, - "models": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": 0.0 - }, - "name": "Wall", - "roughness_height": 0.0, - "type": "Wall" - }, - { - "entities": { - "stored_entities": [ - { - "name": "farfield", - "private_attribute_entity_type_name": "GhostSurface", - "private_attribute_id": "farfield" - } - ] - }, - "name": "Freestream", - "type": "Freestream" - }, - { - "initial_condition": { - "p": "p", - "rho": "rho", - "type_name": "NavierStokesInitialCondition", - "u": "u", - "v": "v", - "w": "w" - }, - "interface_interpolation_tolerance": 0.2, - "material": { - "dynamic_viscosity": { - "effective_temperature": 110.4, - "reference_temperature": 273.15, - "reference_viscosity": 1.716e-05 - }, - "name": "air", - "prandtl_number": 0.72, - "thermally_perfect_gas": { - "species": [ - { - "mass_fraction": 1.0, - "name": "Air", - "nasa_9_coefficients": { - "temperature_ranges": [ - { - "coefficients": [ - 0.0, - 0.0, - 3.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "temperature_range_max": 6000.0, - "temperature_range_min": 200.0, - "type_name": "NASA9CoefficientSet" - } - ], - "type_name": "NASA9Coefficients" - }, - "type_name": "FrozenSpecies" - } - ], - "type_name": "ThermallyPerfectGas" - }, - "turbulent_prandtl_number": 0.9, - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1.0, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1.0, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30, - "type_name": "LinearSolver" - }, - "low_mach_preconditioner": false, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1.0, - "order_of_accuracy": 2, - "relative_tolerance": 0.0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "private_attribute_id": "__default_fluid", - "transition_model_solver": { - "type_name": "None" - }, - "turbulence_model_solver": { - "CFL_multiplier": 2.0, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20, - "type_name": "LinearSolver" - }, - "low_reynolds_correction": false, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_cb1": 0.1355, - "C_cb2": 0.622, - "C_d": 8.0, - "C_min_rd": 10.0, - "C_sigma": 0.6666666666666666, - "C_t3": 1.2, - "C_t4": 0.5, - "C_v1": 7.1, - "C_vonKarman": 0.41, - "C_w2": 0.3, - "C_w4": 0.21, - "C_w5": 1.5, - "type_name": "SpalartAllmarasConsts" - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0.0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - } - ], - "operating_condition": { - "alpha": 0.0, - "beta": 0.0, - "private_attribute_constructor": "default", - "private_attribute_input_cache": { - "alpha": 0.0, - "beta": 0.0, - "thermal_state": { - "density": 1.225, - "material": { - "dynamic_viscosity": { - "effective_temperature": 110.4, - "reference_temperature": 273.15, - "reference_viscosity": 1.716e-05 - }, - "name": "air", - "prandtl_number": 0.72, - "thermally_perfect_gas": { - "species": [ - { - "mass_fraction": 1.0, - "name": "Air", - "nasa_9_coefficients": { - "temperature_ranges": [ - { - "coefficients": [ - 0.0, - 0.0, - 3.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "temperature_range_max": 6000.0, - "temperature_range_min": 200.0, - "type_name": "NASA9CoefficientSet" - } - ], - "type_name": "NASA9Coefficients" - }, - "type_name": "FrozenSpecies" - } - ], - "type_name": "ThermallyPerfectGas" - }, - "turbulent_prandtl_number": 0.9, - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": 288.15, - "type_name": "ThermalState" - } - }, - "thermal_state": { - "density": 1.225, - "material": { - "dynamic_viscosity": { - "effective_temperature": 110.4, - "reference_temperature": 273.15, - "reference_viscosity": 1.716e-05 - }, - "name": "air", - "prandtl_number": 0.72, - "thermally_perfect_gas": { - "species": [ - { - "mass_fraction": 1.0, - "name": "Air", - "nasa_9_coefficients": { - "temperature_ranges": [ - { - "coefficients": [ - 0.0, - 0.0, - 3.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "temperature_range_max": 6000.0, - "temperature_range_min": 200.0, - "type_name": "NASA9CoefficientSet" - } - ], - "type_name": "NASA9Coefficients" - }, - "type_name": "FrozenSpecies" - } - ], - "type_name": "ThermallyPerfectGas" - }, - "turbulent_prandtl_number": 0.9, - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": 288.15, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition" - }, - "outputs": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": { - "items": [ - "Cp", - "yPlus", - "Cf", - "CfVec" - ] - }, - "output_format": [ - "paraview" - ], - "output_type": "SurfaceOutput", - "write_single_file": false - } - ], - "private_attribute_asset_cache": { - "project_length_unit": 0.01, - "use_geometry_AI": false, - "use_inhouse_mesher": false - }, - "reference_geometry": { - "area": { - "type_name": "number", - "units": "cm**2", - "value": 1.0 - }, - "moment_center": [ - 0.0, - 0.0, - 0.0 - ], - "moment_length": [ - 0.01, - 0.01, - 0.01 - ] - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 0.25, - "max": 10000.0, - "max_relative_change": 1.0, - "min": 0.1, - "type": "adaptive" - }, - "max_steps": 2000, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_fields": [] -} diff --git a/tests/ref/simulation/service_init_volume_mesh.json b/tests/ref/simulation/service_init_volume_mesh.json deleted file mode 100644 index 81059ed4a..000000000 --- a/tests/ref/simulation/service_init_volume_mesh.json +++ /dev/null @@ -1,308 +0,0 @@ -{ - "models": [ - { - "entities": { - "stored_entities": [] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": 0.0 - }, - "name": "Wall", - "roughness_height": 0.0, - "type": "Wall" - }, - { - "entities": { - "stored_entities": [] - }, - "name": "Freestream", - "type": "Freestream" - }, - { - "initial_condition": { - "p": "p", - "rho": "rho", - "type_name": "NavierStokesInitialCondition", - "u": "u", - "v": "v", - "w": "w" - }, - "interface_interpolation_tolerance": 0.2, - "material": { - "dynamic_viscosity": { - "effective_temperature": 110.4, - "reference_temperature": 273.15, - "reference_viscosity": 1.716e-05 - }, - "name": "air", - "prandtl_number": 0.72, - "thermally_perfect_gas": { - "species": [ - { - "mass_fraction": 1.0, - "name": "Air", - "nasa_9_coefficients": { - "temperature_ranges": [ - { - "coefficients": [ - 0.0, - 0.0, - 3.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "temperature_range_max": 6000.0, - "temperature_range_min": 200.0, - "type_name": "NASA9CoefficientSet" - } - ], - "type_name": "NASA9Coefficients" - }, - "type_name": "FrozenSpecies" - } - ], - "type_name": "ThermallyPerfectGas" - }, - "turbulent_prandtl_number": 0.9, - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1.0, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1.0, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30, - "type_name": "LinearSolver" - }, - "low_mach_preconditioner": false, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1.0, - "order_of_accuracy": 2, - "relative_tolerance": 0.0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "private_attribute_id": "__default_fluid", - "transition_model_solver": { - "type_name": "None" - }, - "turbulence_model_solver": { - "CFL_multiplier": 2.0, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20, - "type_name": "LinearSolver" - }, - "low_reynolds_correction": false, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_cb1": 0.1355, - "C_cb2": 0.622, - "C_d": 8.0, - "C_min_rd": 10.0, - "C_sigma": 0.6666666666666666, - "C_t3": 1.2, - "C_t4": 0.5, - "C_v1": 7.1, - "C_vonKarman": 0.41, - "C_w2": 0.3, - "C_w4": 0.21, - "C_w5": 1.5, - "type_name": "SpalartAllmarasConsts" - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0.0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - } - ], - "operating_condition": { - "alpha": 0.0, - "beta": 0.0, - "private_attribute_constructor": "default", - "private_attribute_input_cache": { - "alpha": 0.0, - "beta": 0.0, - "thermal_state": { - "density": 1.225, - "material": { - "dynamic_viscosity": { - "effective_temperature": 110.4, - "reference_temperature": 273.15, - "reference_viscosity": 1.716e-05 - }, - "name": "air", - "prandtl_number": 0.72, - "thermally_perfect_gas": { - "species": [ - { - "mass_fraction": 1.0, - "name": "Air", - "nasa_9_coefficients": { - "temperature_ranges": [ - { - "coefficients": [ - 0.0, - 0.0, - 3.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "temperature_range_max": 6000.0, - "temperature_range_min": 200.0, - "type_name": "NASA9CoefficientSet" - } - ], - "type_name": "NASA9Coefficients" - }, - "type_name": "FrozenSpecies" - } - ], - "type_name": "ThermallyPerfectGas" - }, - "turbulent_prandtl_number": 0.9, - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": 288.15, - "type_name": "ThermalState" - } - }, - "thermal_state": { - "density": 1.225, - "material": { - "dynamic_viscosity": { - "effective_temperature": 110.4, - "reference_temperature": 273.15, - "reference_viscosity": 1.716e-05 - }, - "name": "air", - "prandtl_number": 0.72, - "thermally_perfect_gas": { - "species": [ - { - "mass_fraction": 1.0, - "name": "Air", - "nasa_9_coefficients": { - "temperature_ranges": [ - { - "coefficients": [ - 0.0, - 0.0, - 3.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "temperature_range_max": 6000.0, - "temperature_range_min": 200.0, - "type_name": "NASA9CoefficientSet" - } - ], - "type_name": "NASA9Coefficients" - }, - "type_name": "FrozenSpecies" - } - ], - "type_name": "ThermallyPerfectGas" - }, - "turbulent_prandtl_number": 0.9, - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": 288.15, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition" - }, - "outputs": [ - { - "entities": { - "stored_entities": [ - { - "name": "*", - "private_attribute_entity_type_name": "Surface", - "private_attribute_sub_components": [] - } - ] - }, - "frequency": -1, - "frequency_offset": 0, - "name": "Surface output", - "output_fields": { - "items": [ - "Cp", - "yPlus", - "Cf", - "CfVec" - ] - }, - "output_format": [ - "paraview" - ], - "output_type": "SurfaceOutput", - "write_single_file": false - } - ], - "private_attribute_asset_cache": { - "project_length_unit": 1.0, - "use_geometry_AI": false, - "use_inhouse_mesher": false - }, - "reference_geometry": { - "area": { - "type_name": "number", - "units": "m**2", - "value": 1.0 - }, - "moment_center": [ - 0.0, - 0.0, - 0.0 - ], - "moment_length": [ - 1.0, - 1.0, - 1.0 - ] - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 0.25, - "max": 10000.0, - "max_relative_change": 1.0, - "min": 0.1, - "type": "adaptive" - }, - "max_steps": 2000, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_fields": [] -} diff --git a/tests/simulation/params/test_simulation_params.py b/tests/simulation/params/test_simulation_params.py index 0a265fb8d..5daf1165f 100644 --- a/tests/simulation/params/test_simulation_params.py +++ b/tests/simulation/params/test_simulation_params.py @@ -4,10 +4,8 @@ import pytest -import flow360.component.simulation.units as u from flow360.component.project import create_draft from flow360.component.project_utils import set_up_params_for_uploading -from flow360.component.simulation import services from flow360.component.simulation.entity_info import GeometryEntityInfo from flow360.component.simulation.entity_operation import CoordinateSystem from flow360.component.simulation.migration.extra_operating_condition import ( @@ -194,20 +192,3 @@ def _get_selected_grouped_bodies(entity_info_dict: dict) -> list[dict]: 1.0, 0.0, ] - - -def test_default_params_for_local_test(): - with SI_unit_system: - param = SimulationParams() - - param = services._store_project_length_unit(1 * u.m, param) - param_as_dict = param.model_dump( - exclude_none=True, - exclude={ - "operating_condition": {"velocity_magnitude": True}, - "private_attribute_asset_cache": {"registry": True}, - }, - ) - - with SI_unit_system: - SimulationParams(**param_as_dict) diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index 1f90956cf..66032f9ef 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -20,9 +20,6 @@ get_validation_info, ) from flow360.version import __version__ -from tests.utils import compare_dict_to_ref - - @pytest.fixture(autouse=True) def change_test_dir(request, monkeypatch): monkeypatch.chdir(request.fspath.dirname) @@ -645,157 +642,6 @@ def _compare_validation_errors(err, exp_err): _compare_validation_errors(errors, expected_errors) -def test_init(): - def remove_model_and_output_id_in_default_dict(data): - data["outputs"][0].pop("private_attribute_id", None) - data["models"][0].pop("private_attribute_id", None) - data["models"][1].pop("private_attribute_id", None) - - ##1: test default values for geometry starting point - data = services.get_default_params( - unit_system_name="SI", length_unit="m", root_item_type="Geometry" - ) - assert data["operating_condition"]["alpha"] == 0 - assert "velocity_magnitude" not in data["operating_condition"].keys() - remove_model_and_output_id_in_default_dict(data) - # to convert tuples to lists: - data = json.loads(json.dumps(data)) - - compare_dict_to_ref(data, "../../ref/simulation/service_init_geometry.json") - - ##2: test default values for volume mesh starting point - data = services.get_default_params( - unit_system_name="SI", length_unit="m", root_item_type="VolumeMesh" - ) - assert "meshing" not in data - remove_model_and_output_id_in_default_dict(data) - # to convert tuples to lists: - data = json.loads(json.dumps(data)) - compare_dict_to_ref(data, "../../ref/simulation/service_init_volume_mesh.json") - - ##3: test default values for surface mesh starting point - data = services.get_default_params( - unit_system_name="SI", length_unit="cm", root_item_type="SurfaceMesh" - ) - assert data["reference_geometry"]["area"]["units"] == "cm**2" - # New schema types serialize moment_center/moment_length as bare SI values - assert data["reference_geometry"]["moment_center"] == [0.0, 0.0, 0.0] - assert data["reference_geometry"]["moment_length"] == [0.01, 0.01, 0.01] - assert data["private_attribute_asset_cache"]["project_length_unit"] == 0.01 - - # roughness_height now serializes as bare SI float (new dimension types) - assert data["models"][0]["roughness_height"] == 0.0 - remove_model_and_output_id_in_default_dict(data) - # to convert tuples to lists: - data = json.loads(json.dumps(data)) - compare_dict_to_ref(data, "../../ref/simulation/service_init_surface_mesh.json") - - -def test_validate_init_data_errors(): - - data = services.get_default_params( - unit_system_name="SI", length_unit="m", root_item_type="Geometry" - ) - _, errors, _ = services.validate_model( - params_as_dict=data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="Geometry", - ) - - expected_errors = [ - { - "loc": ("meshing", "defaults", "boundary_layer_first_layer_thickness"), - "type": "missing", - "ctx": {"relevant_for": ["VolumeMesh"]}, - }, - { - "loc": ("meshing", "defaults", "surface_max_edge_length"), - "type": "missing", - "ctx": {"relevant_for": ["SurfaceMesh"]}, - }, - { - "loc": ("operating_condition", "velocity_magnitude"), - "type": "missing", - "ctx": {"relevant_for": ["Case"]}, - }, - ] - - assert len(errors) == len(expected_errors) - for err, exp_err in zip(errors, expected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - - -def test_validate_init_data_for_sm_and_vm_errors(): - - data = services.get_default_params( - unit_system_name="SI", length_unit="m", root_item_type="Geometry" - ) - _, errors, _ = services.validate_model( - params_as_dict=data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level=[SURFACE_MESH, VOLUME_MESH], - ) - - expected_errors = [ - { - "loc": ("meshing", "defaults", "boundary_layer_first_layer_thickness"), - "type": "missing", - "ctx": {"relevant_for": ["VolumeMesh"]}, - }, - { - "loc": ("meshing", "defaults", "surface_max_edge_length"), - "type": "missing", - "ctx": {"relevant_for": ["SurfaceMesh"]}, - }, - ] - - assert len(errors) == len(expected_errors) - for err, exp_err in zip(errors, expected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - - -def test_validate_init_data_vm_workflow_errors(): - - data = services.get_default_params( - unit_system_name="SI", length_unit="m", root_item_type="VolumeMesh" - ) - _, errors, _ = services.validate_model( - params_as_dict=data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - validation_level=CASE, - ) - - expected_errors = [ - { - "loc": ("operating_condition", "velocity_magnitude"), - "type": "missing", - "ctx": {"relevant_for": ["Case"]}, - }, - { - "loc": ("models", 0, "entities"), - "type": "value_error", - "ctx": {"relevant_for": ["Case"]}, - }, - { - "loc": ("models", 1, "entities"), - "type": "value_error", - "ctx": {"relevant_for": ["Case"]}, - }, - ] - - assert len(errors) == len(expected_errors), print(">>> errors:", errors) - for err, exp_err in zip(errors, expected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - - def test_front_end_JSON_with_multi_constructor(): params_data = { "meshing": { diff --git a/tests/simulation/test_updater.py b/tests/simulation/test_updater.py index 5c1e0bf94..db631735c 100644 --- a/tests/simulation/test_updater.py +++ b/tests/simulation/test_updater.py @@ -1,4 +1,3 @@ -import json import os import re @@ -7,8 +6,6 @@ from flow360.component.simulation.framework.updater import VERSION_MILESTONES from flow360.component.simulation.framework.updater_utils import Flow360Version -from flow360.component.simulation.services import ValidationCalledBy, validate_model -from flow360.component.simulation.validation.validation_context import ALL from flow360.version import __solver_version__, __version__ @@ -48,16 +45,3 @@ def test_version_greater_than_highest_updater_version(): assert ( current_python_version >= VERSION_MILESTONES[-1][0] ), "Highest version updater can handle is higher than Python client version. This is not allowed." - - -def test_deserialization_with_updater(): - simulation_path = os.path.join("..", "data", "simulation", "simulation_24_11_0.json") - with open(simulation_path, "r") as file: - params = json.load(file) - - validate_model( - params_as_dict=params, - root_item_type="VolumeMesh", - validated_by=ValidationCalledBy.LOCAL, - validation_level=ALL, - ) From 46453a21897e6b9eaa07194945fb90feab43edb6 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 16 Apr 2026 16:08:40 -0400 Subject: [PATCH 21/25] Remove migrated validation-service tests from client --- tests/simulation/service/test_services_v2.py | 682 ------------------- 1 file changed, 682 deletions(-) diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index 66032f9ef..9c7156724 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -15,9 +15,6 @@ from flow360.component.simulation.unit_system import DimensionedTypes from flow360.component.simulation.validation.validation_context import ( CASE, - SURFACE_MESH, - VOLUME_MESH, - get_validation_info, ) from flow360.version import __version__ @pytest.fixture(autouse=True) @@ -25,292 +22,6 @@ def change_test_dir(request, monkeypatch): monkeypatch.chdir(request.fspath.dirname) -def test_validate_service(): - - params_data_from_vm = { - "meshing": { - "refinement_factor": 1.0, - "gap_treatment_strength": 0.2, - "defaults": {"surface_edge_growth_rate": 1.5}, - "refinements": [], - "volume_zones": [ - { - "method": "auto", - "type": "AutomatedFarfield", - "private_attribute_entity": { - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_entity_type_name": "GenericVolume", - "name": "automated_farfield_entity", - "private_attribute_zone_boundary_names": {"items": []}, - }, - "_id": "137854c4-dea1-47a4-b352-b545ffb0b85c", - } - ], - }, - "reference_geometry": { - "moment_center": {"value": [0, 0, 0], "units": "m"}, - "moment_length": {"value": 1.0, "units": "m"}, - "area": {"value": 1.0, "units": "m**2"}, - }, - "time_stepping": { - "type_name": "Steady", - "max_steps": 10, - "CFL": {"type": "ramp", "initial": 1.5, "final": 1.5, "ramp_steps": 5}, - }, - "models": [ - { - "_id": "09435427-c2dd-4535-935c-b131ab7d1a5b", - "type": "Wall", - "entities": { - "stored_entities": [ - { - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_entity_type_name": "Surface", - "name": "Mysurface", - "private_attribute_is_interface": False, - "private_attribute_sub_components": [], - "_id": "Mysurface", - } - ] - }, - "use_wall_function": False, - } - ], - "user_defined_dynamics": [], - "unit_system": {"name": "SI"}, - "private_attribute_asset_cache": { - "project_length_unit": None, - "project_entity_info": { - "draft_entities": [], - "type_name": "VolumeMeshEntityInfo", - "zones": [], - "boundaries": [ - { - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_entity_type_name": "Surface", - "name": "Mysurface", - "private_attribute_full_name": None, - "private_attribute_is_interface": False, - "private_attribute_tag_key": None, - "private_attribute_sub_components": [], - "_id": "Mysurface", - } - ], - }, - }, - } - - params_data_from_geo = params_data_from_vm - params_data_from_geo["meshing"]["defaults"] = { - "surface_edge_growth_rate": 1.5, - "boundary_layer_first_layer_thickness": {"value": 1, "units": "m"}, - "surface_max_edge_length": {"value": 1, "units": "m"}, - } - params_data_from_geo["version"] = "24.11.0" - - params_data_op_from_mach_reynolds = params_data_from_vm.copy() - params_data_op_from_mach_reynolds["private_attribute_asset_cache"]["project_length_unit"] = { - "value": 0.8059, - "units": "m", - } - params_data_op_from_mach_reynolds["operating_condition"] = { - "type_name": "AerospaceCondition", - "private_attribute_constructor": "from_mach_reynolds", - "private_attribute_input_cache": { - "mach": 0.84, - "reynolds_mesh_unit": 10.0, - "alpha": {"value": 3.06, "units": "degree"}, - "beta": {"value": 0.0, "units": "degree"}, - "temperature": {"value": 288.15, "units": "K"}, - }, - "alpha": {"value": 3.06, "units": "degree"}, - "beta": {"value": 0.0, "units": "degree"}, - "velocity_magnitude": { - "type_name": "number", - "value": 285.84696487889875, - "units": "m/s", - }, - "thermal_state": { - "type_name": "ThermalState", - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": {"value": 288.15, "units": "K"}, - "density": {"value": 7.767260032496146e-07, "units": "Pa*s**2/m**2"}, - "material": { - "type": "air", - "name": "air", - "dynamic_viscosity": { - "reference_viscosity": {"value": 1.716e-05, "units": "Pa*s"}, - "reference_temperature": {"value": 273.15, "units": "K"}, - "effective_temperature": {"value": 110.4, "units": "K"}, - }, - }, - }, - } - - _, errors, _ = services.validate_model( - params_as_dict=params_data_from_geo, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="Geometry", - ) - - assert errors is None - - _, errors, _ = services.validate_model( - params_as_dict=params_data_from_vm, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - validation_level=CASE, - ) - - assert errors is None - - _, errors, _ = services.validate_model( - params_as_dict=params_data_op_from_mach_reynolds, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - validation_level=CASE, - ) - - assert errors is None - - -def test_validate_error(): - params_data = { - "meshing": { - "farfield": "invalid", - "refinement_factor": 1.0, - "gap_treatment_strength": 0.2, - "defaults": {"surface_edge_growth_rate": 1.5}, - "refinements": [], - "volume_zones": [ - { - "method": "auto", - "type": "AutomatedFarfield", - "private_attribute_entity": { - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_entity_type_name": "GenericVolume", - "name": "automated_farfield_entity", - "private_attribute_zone_boundary_names": {"items": []}, - }, - } - ], - }, - "reference_geometry": { - "moment_center": {"value": [0, 0, 0], "units": "m"}, - "moment_length": {"value": 1.0, "units": "m"}, - "area": {"value": 1.0, "units": "m**2", "type_name": "number"}, - }, - "time_stepping": { - "type_name": "Steady", - "max_steps": 10, - "CFL": {"type": "ramp", "initial": 1.5, "final": 1.5, "ramp_steps": 5}, - }, - "user_defined_dynamics": [], - "unit_system": {"name": "SI"}, - "version": "24.11.5", - } - - _, errors, _ = services.validate_model( - params_as_dict=params_data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="Geometry", - ) - - expected_errors = [ - { - "loc": ("meshing", "defaults", "boundary_layer_first_layer_thickness"), - "type": "missing", - "ctx": {"relevant_for": ["VolumeMesh"]}, - }, - { - "loc": ("meshing", "defaults", "surface_max_edge_length"), - "type": "missing", - "ctx": {"relevant_for": ["SurfaceMesh"]}, - }, - { - "loc": ("meshing", "farfield"), - "type": "extra_forbidden", - "ctx": {"relevant_for": ["SurfaceMesh", "VolumeMesh"]}, - }, - ] - assert len(errors) == len(expected_errors) - for err, exp_err in zip(errors, expected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - - -def test_validate_multiple_errors(): - params_data = { - "meshing": { - "farfield": "invalid", - "refinement_factor": 1.0, - "gap_treatment_strength": 0.2, - "defaults": { - "surface_edge_growth_rate": 1.5, - "boundary_layer_first_layer_thickness": {"value": 1, "units": "m"}, - "surface_max_edge_length": {"value": 1, "units": "s"}, - }, - "refinements": [], - "volume_zones": [ - { - "method": "auto", - "type": "AutomatedFarfield", - "private_attribute_entity": { - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_entity_type_name": "GenericVolume", - "name": "automated_farfield_entity", - "private_attribute_zone_boundary_names": {"items": []}, - }, - } - ], - }, - "reference_geometry": { - "moment_center": {"value": [0, 0, 0], "units": "m"}, - "moment_length": {"value": 1.0, "units": "m"}, - "area": {"value": -10.0, "units": "m**2"}, - }, - "time_stepping": { - "type_name": "Steady", - "max_steps": 10, - "CFL": {"type": "ramp", "initial": 1.5, "final": 1.5, "ramp_steps": 5}, - }, - "user_defined_dynamics": [], - "unit_system": {"name": "SI"}, - "version": "24.11.5", - } - - _, errors, _ = services.validate_model( - params_as_dict=params_data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="Geometry", - ) - - expected_errors = [ - { - "loc": ("meshing", "defaults", "surface_max_edge_length"), - "type": "value_error", - "ctx": {"relevant_for": ["SurfaceMesh"]}, - }, - { - "loc": ("meshing", "farfield"), - "type": "extra_forbidden", - "ctx": {"relevant_for": ["SurfaceMesh", "VolumeMesh"]}, - }, - { - "loc": ("reference_geometry", "area", "value"), - "type": "value_error", - "ctx": {"relevant_for": ["Case"]}, - }, - ] - assert len(errors) == len(expected_errors) - for err, exp_err in zip(errors, expected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"] - - def test_validate_error_from_initialize_variable_space(): with open("../translator/data/simulation_isosurface.json", "r") as fp: param_dict = json.load(fp) @@ -345,303 +56,6 @@ def test_validate_error_from_initialize_variable_space(): assert errors is None -def test_validate_error_from_multi_constructor(): - - def _compare_validation_errors(err, exp_err): - assert len(errors) == len(expected_errors) - for err, exp_err in zip(errors, expected_errors): - for key in exp_err.keys(): - assert err[key] == exp_err[key] - - # test from_mach() with two validation errors within private_attribute_input_cache - params_data = { - "operating_condition": { - "private_attribute_constructor": "from_mach", - "type_name": "AerospaceCondition", - "private_attribute_input_cache": { - "mach": -1, - "alpha": {"value": 0, "units": "degree"}, - "beta": {"value": 0, "units": "degree"}, - "thermal_state": { - "type_name": "ThermalState", - "private_attribute_constructor": "default", - "density": {"value": -2, "units": "kg/m**3"}, - "temperature": {"value": 288.15, "units": "K"}, - }, - }, - }, - "unit_system": {"name": "SI"}, - "version": "24.11.5", - } - - _, errors, _ = services.validate_model( - params_as_dict=params_data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - ) - - expected_errors = [ - { - "loc": ("operating_condition", "private_attribute_input_cache", "mach"), - "type": "greater_than_equal", - "msg": "Input should be greater than or equal to 0", - "input": -1, - "ctx": {"ge": "0.0"}, - }, - { - "loc": ( - "operating_condition", - "private_attribute_input_cache", - "thermal_state", - "density", - ), - "type": "value_error", - "msg": "Value error, Value must be positive (>0), got -2.0", - }, - ] - _compare_validation_errors(errors, expected_errors) - - # test BETDisk.from_dfdc() with: - # 1. one validation error within private_attribute_input_cache - # 2. a missing BETDiskCache argument for the dfdc constructor - # 3. one validation error outside the input_cache - params_data = { - "models": [ - { - "name": "BET disk", - "private_attribute_constructor": "from_dfdc", - "private_attribute_input_cache": { - "angle_unit": {"units": "degree", "value": 1.0}, - "blade_line_chord": {"units": "m", "value": 5.0}, - "chord_ref": {"units": "m", "value": -14.0}, - "entities": { - "stored_entities": [ - { - "axis": [0.0, 0.0, 1.0], - "center": {"units": "m", "value": [0.0, 0.0, 0.0]}, - "height": {"units": "m", "value": -15.0}, - "inner_radius": {"units": "m", "value": 0.0}, - "name": "BET_cylinder", - "outer_radius": {"units": "m", "value": 3.81}, - "private_attribute_entity_type_name": "Cylinder", - "private_attribute_full_name": None, - "private_attribute_id": "ca0d3a3f-49cb-4637-a789-744c643ce955", - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_zone_boundary_names": {"items": []}, - } - ] - }, - "file": { - "content": "DFDC Version 0.70E+03\nvb block 1 c 25 HP SLS\n\nOPER\n! Vinf Vref RPM1\n 0.000 10.000 15.0\n! Rho Vso Rmu Alt\n 1.0 342.0 0.17791E-04 0.30000E-01\n! XDwake Nwake\n 1.0000 20\n! Lwkrlx\n F\nENDOPER\n\nAERO\n! #sections\n 5\n! Xisection\n 0.09\n ! A0deg dCLdA CLmax CLmin\n -6.5 4.000 1.2500 -0.0000\n ! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n ! CDmin CLCDmin dCDdCL^2\n 0.075000E-01 0.00000 0.40000E-02\n ! REref REexp\n 0.30000E+06 -0.70000\n 0.17\n ! A0deg dCLdA CLmax CLmin\n -6.0 6.0 1.300 -0.55000\n ! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n ! CDmin CLCDmin dCDdCL^2\n 0.075000E-01 0.10000 0.40000E-02\n ! REref REexp\n 0.30000E+06 -0.70000\n 0.51\n ! A0deg dCLdA CLmax CLmin\n -1.0 6.00 1.400 -1.4000\n ! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n ! CDmin CLCDmin dCDdCL^2\n 0.05000E-01 0.10000 0.40000E-02\n ! REref REexp\n 0.30000E+06 -0.70000\n 0.8\n ! A0deg dCLdA CLmax CLmin\n -1.0 6.0 1.600 -1.500\n ! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n ! CDmin CLCDmin dCDdCL^2\n 0.03000E-01 0.10000 0.40000E-02\n ! REref REexp\n 0.30000E+06 -0.70000\n 1.0\n ! A0deg dCLdA CLmax CLmin\n -1 6.0 1.0 -1.8000\n ! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n ! CDmin CLCDmin dCDdCL^2\n 0.04000E-01 0.10000 0.40000E-02\n ! REref REexp\n 0.30000E+06 -0.70000\nENDAERO\n\nROTOR\n! Xdisk Nblds NRsta\n 150 3 63\n! #stations\n 63\n! r C Beta0deg\n0.087645023\t0.432162215\t33.27048712\n0.149032825\t0.432162215\t32.37853609\n0.230063394\t0.432162215\t31.42712165\n0.31845882\t0.432162215\t30.65409742\n0.354421836\t0.432162215\t30.13214089\n0.412445831\t0.432162215\t29.41523066\n0.465733177\t0.425230275\t28.75567325\n0.535598785\t0.418298338\t27.89538097\n0.607834576\t0.411366397\t26.69097178\n0.657570537\t0.404434457\t25.88803232\n0.695464717\t0.39750252\t25.25715132\n0.733359236\t0.390570579\t24.5689175\n0.773621567\t0.383638638\t23.93803649\n0.818620882\t0.376706702\t23.19244985\n0.86598886\t0.369774761\t22.36083398\n0.912171916\t0.36284282\t21.67260016\n0.958355818\t0.355910895\t20.84098429\n1.008092457\t0.355906578\t19.92333919\n1.051908539\t0.355902261\t19.03437051\n1.090987133\t0.355897941\t18.34613668\n1.134803219\t0.355893624\t17.457168\n1.17980135\t0.355889307\t16.91231622\n1.229536976\t0.355884987\t16.16672958\n1.275719693\t0.35588067\t15.53584858\n1.314797781\t0.355876353\t14.93364398\n1.358613021\t0.355872033\t14.18805734\n1.398875352\t0.355867716\t13.55717634\n1.42966541\t0.355863399\t12.86894251\n1.468744004\t0.355859079\t12.18070869\n1.51019075\t0.355854762\t11.49247487\n1.549268331\t0.355850445\t10.9762995\n1.596633604\t0.355846125\t10.60350618\n1.6629458\t0.355841808\t9.94394877\n1.717417226\t0.355837491\t9.28439136\n1.773072894\t0.355833171\t8.59615753\n1.81688746\t0.355828854\t7.96527653\n1.866622407\t0.355824537\t7.33439553\n1.893858878\t0.355820217\t6.87557298\n1.949512352\t0.3558159\t6.56013248\n2.015823535\t0.355811583\t6.07263352\n2.07503076\t0.355807263\t5.49910533\n2.124764355\t0.355802946\t5.0976356\n2.169761643\t0.355798629\t4.69616587\n2.231337023\t0.355794309\t4.12263769\n2.273965822\t0.355789992\t3.77852078\n2.321330759\t0.355785675\t3.46308028\n2.395930475\t0.355781355\t2.97558132\n2.533289143\t0.355777038\t2.0005834\n2.61499265\t0.355772721\t1.62779008\n2.664726078\t0.355768401\t1.25499676\n2.709722688\t0.355764084\t0.96823267\n2.768929239\t0.355759767\t0.50941012\n2.839977235\t0.355755447\t-0.064118065\n2.906288246\t0.35575113\t-0.522940614\n3.03772619\t0.355746813\t-1.44058571\n3.208240195\t0.355742493\t-2.61631849\n3.342045113\t0.355738176\t-3.333228722\n3.481771091\t0.355733859\t-4.164844591\n3.56702767\t0.355729539\t-4.681019957\n3.647547098\t0.355725222\t-5.053813278\n3.725699048\t0.355720905\t-5.541312235\n3.770695491\t0.355716585\t-5.799399919\n3.81\t0.355714554\t-6.1\nENDROTOR\n\nGEOM\n /IGNORED BELOW THIS POINT\n", - "file_path": "aba", - "type_name": "DFDCFile", - }, - "initial_blade_direction": [1.0, 0.0, 0.0], - "length_unit": {"units": "m", "value": 1.0}, - "omega": {"units": "degree/s", "value": 0.0046}, - "rotation_direction_rule": "leftHand", - "number_of_blades": 2, - }, - "type": "BETDisk", - "type_name": "BETDisk", - }, - { - "entities": {"stored_entities": []}, - "heat_spec": {"type_name": "HeatFlux", "value": {"units": "W/m**2", "value": 0.0}}, - "name": "Wall", - "roughness_height": {"units": "m", "value": -10.0}, - "type": "Wall", - "use_wall_function": False, - "velocity": None, - }, - ], - "unit_system": {"name": "SI"}, - "version": "24.11.5", - } - - _, errors, _ = services.validate_model( - params_as_dict=params_data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - ) - - expected_errors = [ - { - "loc": ("models", 0, "private_attribute_input_cache", "chord_ref"), - "type": "value_error", - "msg": "Value error, Value must be positive (>0), got -14.0", - }, - { - "type": "missing_argument", - "loc": ("models", 0, "private_attribute_input_cache", "n_loading_nodes"), - "msg": "Missing required argument", - "ctx": {}, - }, - { - "loc": ( - "models", - 0, - "private_attribute_input_cache", - "entities", - "stored_entities", - 0, - "height", - ), - "type": "value_error", - "msg": "Value error, Value must be positive (>0), got -15.0", - }, - ] - - _compare_validation_errors(errors, expected_errors) - - # test Box.from_principal_axes() with one validation error within private_attribute_input_cache - # the multiconstructor call is within a default constructor call - params_data = { - "models": [ - { - "darcy_coefficient": {"units": "m**(-2)", "value": [1000000.0, 0.0, 0.0]}, - "entities": { - "stored_entities": [ - { - "angle_of_rotation": {"units": "rad", "value": -2.0943951023931953}, - "axis_of_rotation": [ - -0.5773502691896257, - -0.5773502691896257, - -0.5773502691896261, - ], - "center": {"units": "m", "value": [0, 0, 0]}, - "name": "porous_zone", - "private_attribute_constructor": "from_principal_axes", - "private_attribute_entity_type_name": "Box", - "private_attribute_full_name": None, - "private_attribute_id": "69751367-210b-4df3-b4cd-1f2adbd866ed", - "private_attribute_input_cache": { - "axes": [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], - "center": {"units": "m", "value": [0, 0, 0]}, - "name": "porous_zone", - "size": {"units": "m", "value": [0.2, 0.3, -2.0]}, - }, - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_zone_boundary_names": {"items": []}, - "size": {"units": "m", "value": [0.2, 0.3, 2.0]}, - "type_name": "Box", - } - ] - }, - "forchheimer_coefficient": {"units": "1/m", "value": [1, 0, 0]}, - "name": "Porous medium", - "type": "PorousMedium", - "volumetric_heat_source": {"units": "W/m**3", "value": 1.0}, - } - ], - "unit_system": {"name": "SI"}, - "version": "24.11.5", - } - - _, errors, _ = services.validate_model( - params_as_dict=params_data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - ) - expected_errors = [ - { - "type": "value_error", - "loc": ( - "models", - 0, - "entities", - "stored_entities", - 0, - "private_attribute_input_cache", - "size", - ), - "msg": "Value error, All vector components must be positive (>0), got -2.0", - } - ] - _compare_validation_errors(errors, expected_errors) - - # test ThermalState.from_standard_atmosphere() with one validation error within private_attribute_input_cache - # the multiconstructor call is nested in another multiconstructor call - params_data = { - "operating_condition": { - "alpha": {"units": "degree", "value": 0.0}, - "beta": {"units": "degree", "value": 0.0}, - "private_attribute_constructor": "from_mach", - "private_attribute_input_cache": { - "alpha": {"units": "degree", "value": 0.0}, - "beta": {"units": "degree", "value": 0.0}, - "mach": -1, - "reference_mach": None, - "thermal_state": { - "density": {"units": "kg/m**3", "value": 1.1724995324950298}, - "material": { - "dynamic_viscosity": { - "effective_temperature": {"units": "K", "value": 110.4}, - "reference_temperature": {"units": "K", "value": 273.15}, - "reference_viscosity": {"units": "Pa*s", "value": 1.716e-05}, - }, - "name": "air", - "type": "air", - }, - "private_attribute_constructor": "from_standard_atmosphere", - "private_attribute_input_cache": { - "altitude": {"units": "m", "value": 100.0}, - "temperature_offset": {"units": "K", "value": 10.0}, - }, - "temperature": {"units": "K", "value": 297.5000102251644}, - "type_name": "ThermalState", - }, - }, - "reference_velocity_magnitude": None, - "thermal_state": { - "density": {"units": "kg/m**3", "value": 1.1724995324950298}, - "material": { - "dynamic_viscosity": { - "effective_temperature": {"units": "K", "value": 110.4}, - "reference_temperature": {"units": "K", "value": 273.15}, - "reference_viscosity": {"units": "Pa*s", "value": 1.716e-05}, - }, - "name": "air", - "type": "air", - }, - "private_attribute_constructor": "from_standard_atmosphere", - "private_attribute_input_cache": { - "altitude": {"units": "K", "value": 100.0}, - "temperature_offset": {"units": "K", "value": 10.0}, - }, - "temperature": {"units": "K", "value": 297.5000102251644}, - "type_name": "ThermalState", - }, - "type_name": "AerospaceCondition", - "velocity_magnitude": {"units": "m/s", "value": 34.57709313392731}, - }, - "unit_system": {"name": "SI"}, - "version": "24.11.5", - } - - _, errors, _ = services.validate_model( - params_as_dict=params_data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - ) - - expected_errors = [ - { - "type": "value_error", - "loc": ( - "operating_condition", - "thermal_state", - "private_attribute_input_cache", - "altitude", - ), - "msg": "Value error, Dimension mismatch: expected length (meter), got (temperature)", - "input": {"units": "K", "value": 100.0}, - "ctx": {"error": "Dimension mismatch: expected length (meter), got (temperature)"}, - } - ] - _compare_validation_errors(errors, expected_errors) - - def test_front_end_JSON_with_multi_constructor(): params_data = { "meshing": { @@ -984,38 +398,6 @@ def test_generate_process_json_skips_case_validation_for_meshing(): assert res2 is None assert res3 is None - -def test_default_validation_contest(): - "Ensure that the default validation context is None which is the escaper for many validators" - assert get_validation_info() is None - - -def test_validation_level_intersection(): - def get_validation_levels_to_use(root_item_type, requested_levels): - available_levels = services._determine_validation_level( - up_to="Case", root_item_type=root_item_type - ) - return services._intersect_validation_levels(requested_levels, available_levels) - - assert get_validation_levels_to_use("Geometry", "All") == ["SurfaceMesh", "VolumeMesh", "Case"] - - assert get_validation_levels_to_use("SurfaceMesh", "All") == ["VolumeMesh", "Case"] - - assert get_validation_levels_to_use("VolumeMesh", "All") == [ - "Case", - ] - - assert get_validation_levels_to_use("SurfaceMesh", ["Case", "VolumeMesh", "SurfaceMesh"]) == [ - "Case", - "VolumeMesh", - ] - - # When validation_level=None, all context-aware validators should be skipped - assert get_validation_levels_to_use("Geometry", None) == [] - assert get_validation_levels_to_use("SurfaceMesh", None) == [] - assert get_validation_levels_to_use("VolumeMesh", None) == [] - - def test_forward_compatibility_error(): from flow360.version import __version__ @@ -1325,70 +707,6 @@ def test_sanitize_stack_trace(): result_windows = _sanitize_stack_trace(input_windows) assert result_windows == expected_windows - -def test_validate_error_location_with_selector(): - """ - Test that validation error locations are correctly preserved when errors occur - within EntitySelector's children field. - - This test verifies the fix for the bug where error locations were incorrectly - reduced to just ("children",) instead of the full path like - ("models", 0, "entities", "selectors", 0, "children", 0, "value"). - - The bug was in _traverse_error_location which used `current.get(field)` instead - of `field in current`, causing fields with falsy values (empty list, 0, etc.) - to be incorrectly filtered out. - """ - params_data = { - "models": [ - { - "type": "Wall", - "name": "Wall with selector", - "entities": { - "stored_entities": [], - "selectors": ["test-selector-id"], - }, - "use_wall_function": False, - } - ], - "unit_system": {"name": "SI"}, - "version": __version__, - "private_attribute_asset_cache": { - "project_entity_info": { - "type_name": "VolumeMeshEntityInfo", - "draft_entities": [], - "zones": [], - "boundaries": [], - }, - "used_selectors": [ - { - "name": "test_selector", - "target_class": "Surface", - "logic": "AND", - "selector_id": "test-selector-id", - } - ], - }, - } - - _, errors, _ = services.validate_model( - params_as_dict=params_data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - ) - - assert errors is not None, "Expected validation errors but got None" - assert len(errors) == 1, f"Expected 1 error, got {len(errors)}" - - # Verify the location contains the full path, not just "children" - loc = errors[0]["loc"] - assert loc == ("private_attribute_asset_cache", "used_selectors", 0, "children"), print( - "Wrong localtion: ", loc - ) - - # Verify key path components are present for tokenized selectors in used_selectors - - @pytest.mark.parametrize("unit_system_name", ["SI", "Imperial", "CGS"]) def test_validate_model_preserves_unit_system(unit_system_name): """validate_model must not mutate the unit_system entry in the input dict.""" From d326fa7cb1f911062a195e5f5e0dd2dc06a65717 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 16 Apr 2026 20:29:43 -0400 Subject: [PATCH 22/25] Relay more simulation service utilities through schema --- .../component/simulation/services_utils.py | 154 +------ ...tion_json_with_multi_constructor_used.json | 424 ------------------ tests/simulation/service/test_services_v2.py | 260 ----------- .../test_entity_processing_service.py | 227 ---------- .../services/test_services_utils.py | 54 --- 5 files changed, 3 insertions(+), 1116 deletions(-) delete mode 100644 tests/ref/simulation/simulation_json_with_multi_constructor_used.json diff --git a/flow360/component/simulation/services_utils.py b/flow360/component/simulation/services_utils.py index a53ecacea..44d41c422 100644 --- a/flow360/component/simulation/services_utils.py +++ b/flow360/component/simulation/services_utils.py @@ -1,154 +1,6 @@ """Utility functions for the simulation services.""" -from typing import TYPE_CHECKING, Any - -from flow360.component.simulation.framework.entity_expansion_utils import ( - get_registry_from_asset_cache, -) -from flow360.component.simulation.framework.entity_selector import _process_selectors -from flow360.component.simulation.framework.entity_utils import ( - walk_object_tree_with_cycle_detection, +from flow360_schema.models.simulation.services_utils import ( + strip_implicit_edge_split_layers_inplace, + strip_selector_matches_and_broken_entities_inplace, ) - -_MIRRORED_ENTITY_TYPE_NAMES = ("MirroredSurface", "MirroredGeometryBodyGroup") - -if TYPE_CHECKING: - from flow360.component.simulation.simulation_params import SimulationParams - - -def strip_implicit_edge_split_layers_inplace(params: "SimulationParams", params_dict: dict) -> dict: - """ - Remove implicitly injected `edge_split_layers` from serialized params. - This extra and specific function was added due to a change in schema during lifecycle of a release (uncommon) - - Why not use `exclude_unset` or `exclude_defaults` globally during `model_dump()`? - - `exclude_unset` strips many unrelated defaulted fields and can affect downstream workflows. - - `exclude_defaults` also strips explicitly user-set values that equal the default. - """ - meshing = getattr(params, "meshing", None) - defaults = getattr(meshing, "defaults", None) - if defaults is None: - return params_dict - - if "edge_split_layers" in defaults.model_fields_set: - # Keep explicit user setting (including explicit value equal to default). - return params_dict - - meshing_dict = params_dict.get("meshing") - if not isinstance(meshing_dict, dict): - return params_dict - - defaults_dict = meshing_dict.get("defaults") - if not isinstance(defaults_dict, dict): - return params_dict - - defaults_dict.pop("edge_split_layers", None) - return params_dict - - -def strip_selector_matches_and_broken_entities_inplace(params) -> Any: - """ - In stored_entities: - 1. Remove entities matched by selectors from each EntityList's stored_entities, in place. - 2. Remove registry-backed entities that are not valid for the current params registry (broken/foreign), - in place. This primarily targets mirrored entities, but also protects against stale persistent entities. - - Rationale: - - Keep user hand-picked entities distinguishable for the UI by stripping items that are - implied by EntitySelector expansion. - - Operate on the deserialized params object to avoid dict-level selector handling. - - Behavior: - - For every EntityList-like object that has a non-empty `selectors` list, compute the set - of entities implied by those selectors over the registry, and remove those implied entities - from its `stored_entities` list. - - For every EntityList-like object, check if it contains any mirror entities that no longer - have a corresponding geometry entity, and remove them from the list. - - Returns the same object for chaining. - """ - asset_cache = getattr(params, "private_attribute_asset_cache", None) - project_entity_info = getattr(asset_cache, "project_entity_info", None) - if asset_cache is None or project_entity_info is None: - # Compatibility with some unit tests. - return params - - selector_cache: dict = {} - registry = get_registry_from_asset_cache(asset_cache) - - valid_mirrored_registry_keys = { - (entity.private_attribute_entity_type_name, entity.private_attribute_id) - for entity in registry.find_by_type_name(list(_MIRRORED_ENTITY_TYPE_NAMES)) - } - - def _extract_entity_key(item) -> tuple: - """Extract stable key from entity object.""" - entity_type = getattr(item, "private_attribute_entity_type_name", None) - entity_id = getattr(item, "private_attribute_id", None) - return (entity_type, entity_id) - - def _matched_keyset_for_selectors(selectors_list: list) -> set[tuple]: - additions_by_class, _ = _process_selectors( - registry, - selectors_list, - selector_cache, - ) - keys: set = set() - for items in additions_by_class.values(): - for entity in items: - keys.add(_extract_entity_key(entity)) - return keys - - def _strip_selector_matches_and_broken_entities(obj) -> bool: - """ - Strip entities matched by selectors from EntityList's stored_entities, then drop mirrored entities - that are not present in the current params registry. - Returns True to continue traversing, False to stop. - """ - selectors_list = getattr(obj, "selectors", None) - stored_entities = getattr(obj, "stored_entities", None) - if not isinstance(stored_entities, list): - return True - - if not stored_entities: - obj.stored_entities = [] - return False - - updated_entities = stored_entities - - if isinstance(selectors_list, list) and selectors_list: - matched_keys = _matched_keyset_for_selectors(selectors_list) - if matched_keys: - updated_entities = [ - item - for item in updated_entities - if _extract_entity_key(item) not in matched_keys - ] - if not updated_entities: - obj.stored_entities = [] - return False - - cleaned_entities = [] - for item in updated_entities: - entity_type, entity_id = _extract_entity_key(item) - # Keep non-entity objects (or entities without stable keys) untouched. - if entity_type is None or entity_id is None: - cleaned_entities.append(item) - continue - - if entity_type in _MIRRORED_ENTITY_TYPE_NAMES: - if (entity_type, entity_id) in valid_mirrored_registry_keys: - cleaned_entities.append(item) - continue - - cleaned_entities.append(item) - - obj.stored_entities = cleaned_entities - - return False # Don't traverse into EntityList internals - # Continue traversing - - walk_object_tree_with_cycle_detection( - params, _strip_selector_matches_and_broken_entities, check_dict=False - ) - return params diff --git a/tests/ref/simulation/simulation_json_with_multi_constructor_used.json b/tests/ref/simulation/simulation_json_with_multi_constructor_used.json deleted file mode 100644 index d1b845189..000000000 --- a/tests/ref/simulation/simulation_json_with_multi_constructor_used.json +++ /dev/null @@ -1,424 +0,0 @@ -{ - "meshing": { - "defaults": { - "boundary_layer_first_layer_thickness": { - "units": "m", - "value": 1 - }, - "boundary_layer_growth_rate": 1.2, - "curvature_resolution_angle": { - "units": "degree", - "value": 12.0 - }, - "surface_edge_growth_rate": 1.2, - "surface_max_edge_length": { - "units": "m", - "value": 1 - } - }, - "refinement_factor": 1.45, - "refinements": [ - { - "entities": { - "stored_entities": [ - { - "angle_of_rotation": { - "units": "degree", - "value": 20.0 - }, - "axis_of_rotation": [ - 1.0, - 0.0, - 0.0 - ], - "center": { - "units": "m", - "value": [ - 1.0, - 2.0, - 3.0 - ] - }, - "name": "my_box_default", - "private_attribute_constructor": "default", - "private_attribute_entity_type_name": "Box", - "private_attribute_id": "hardcoded_id-1", - "private_attribute_input_cache": { - "axes": [ - [ - 1.0, - 0.0, - 0.0 - ], - [ - 0.0, - 0.9396926207859084, - 0.3420201433256687 - ] - ] - }, - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_zone_boundary_names": { - "items": [] - }, - "size": { - "units": "m", - "value": [ - 2.0, - 2.0, - 3.0 - ] - }, - "type_name": "Box" - }, - { - "angle_of_rotation": { - "units": "rad", - "value": -3.141592653589793 - }, - "axis_of_rotation": [ - 0.894427190999916, - 0.447213595499958, - 0.0 - ], - "center": { - "units": "m", - "value": [ - 7.0, - 1.0, - 2.0 - ] - }, - "name": "my_box_from", - "private_attribute_constructor": "from_principal_axes", - "private_attribute_entity_type_name": "Box", - "private_attribute_id": "hardcoded_id-2", - "private_attribute_input_cache": { - "axes": [ - [ - 0.6, - 0.8, - 0.0 - ], - [ - 0.8, - -0.6, - 0.0 - ] - ], - "center": { - "units": "m", - "value": [ - 7.0, - 1.0, - 2.0 - ] - }, - "name": "my_box_from", - "size": { - "units": "m", - "value": [ - 2.0, - 2.0, - 3.0 - ] - } - }, - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_zone_boundary_names": { - "items": [] - }, - "size": { - "units": "m", - "value": [ - 2.0, - 2.0, - 3.0 - ] - }, - "type_name": "Box" - }, - { - "axis": [ - 0.0, - 1.0, - 0.0 - ], - "center": { - "units": "m", - "value": [ - 1.0, - 2.0, - 3.0 - ] - }, - "height": { - "units": "m", - "value": 3.0 - }, - "name": "my_cylinder_default", - "outer_radius": { - "units": "m", - "value": 2.0 - }, - "private_attribute_entity_type_name": "Cylinder", - "private_attribute_id": "hardcoded_id-3", - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_zone_boundary_names": { - "items": [] - } - } - ] - }, - "refinement_type": "UniformRefinement", - "spacing": { - "units": "cm", - "value": 7.5 - } - } - ], - "volume_zones": [ - { - "method": "auto", - "type": "AutomatedFarfield" - } - ] - }, - "models": [ - { - "entities": { - "stored_entities": [ - { - "name": "surface_x", - "private_attribute_entity_type_name": "Surface", - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "face_x_1", - "face_x_2", - "face_x_3" - ] - } - ] - }, - "private_attribute_id": "wall1", - "type": "Wall", - "use_wall_function": false - }, - { - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1.0, - "absolute_tolerance": 1e-10, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1.0, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 30, - "type_name": "LinearSolver" - }, - "low_mach_preconditioner": false, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1.0, - "order_of_accuracy": 2, - "relative_tolerance": 0.0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "private_attribute_id": "__default_fluid", - "turbulence_model_solver": { - "CFL_multiplier": 2.0, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 20, - "type_name": "LinearSolver" - }, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_d": 8.0, - "type_name": "SpalartAllmarasConsts" - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0.0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - } - ], - "operating_condition": { - "alpha": { - "units": "degree", - "value": 5.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "private_attribute_constructor": "from_mach", - "private_attribute_input_cache": { - "alpha": { - "units": "degree", - "value": 5.0 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "mach": 0.8, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "from_standard_atmosphere", - "private_attribute_input_cache": { - "altitude": { - "units": "m", - "value": 1000.0 - }, - "temperature_offset": { - "units": "K", - "value": 0.0 - } - }, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - } - }, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "from_standard_atmosphere", - "private_attribute_input_cache": { - "altitude": { - "units": "m", - "value": 1000.0 - }, - "temperature_offset": { - "units": "K", - "value": 0.0 - } - }, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition", - "velocity_magnitude": { - "units": "m/s", - "value": 272.23520464657025 - } - }, - "private_attribute_asset_cache": { - "project_entity_info": { - "face_attribute_names": [ - "some_tag" - ], - "face_group_tag": "some_tag", - "face_ids": [ - "face_x_1", - "face_x_2", - "face_x_3" - ], - "grouped_faces": [ - [ - { - "name": "surface_x", - "private_attribute_entity_type_name": "Surface", - "private_attribute_is_interface": false, - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_sub_components": [ - "face_x_1", - "face_x_2", - "face_x_3" - ] - } - ] - ], - "type_name": "GeometryEntityInfo" - }, - "project_length_unit": 1.0 - }, - "time_stepping": { - "CFL": { - "convergence_limiting_factor": 0.25, - "max": 10000.0, - "max_relative_change": 1.0, - "min": 0.1, - "type": "adaptive" - }, - "max_steps": 2000, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "version": "24.2.0" -} diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index 9c7156724..9ac9780ab 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -4,7 +4,6 @@ from typing import get_args import pytest -from flow360_schema.framework.expression import UserVariable from unyt import Unit import flow360.component.simulation.units as u @@ -21,196 +20,6 @@ def change_test_dir(request, monkeypatch): monkeypatch.chdir(request.fspath.dirname) - -def test_validate_error_from_initialize_variable_space(): - with open("../translator/data/simulation_isosurface.json", "r") as fp: - param_dict = json.load(fp) - - UserVariable(name="my_time_stepping_var", value=0.6 * u.s) - _, errors, _ = services.validate_model( - params_as_dict=param_dict, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - ) - expected_errors = [ - { - "type": "value_error", - "loc": ["unknown"], - "msg": "Loading user variable 'my_time_stepping_var' from simulation.json " - "which is already defined in local context. Please change your local user variable definition.", - } - ] - assert len(errors) == len(expected_errors) - for err, exp_err in zip(errors, expected_errors): - assert err["loc"] == exp_err["loc"] - assert err["type"] == exp_err["type"] - assert err["msg"] == exp_err["msg"] - - services.clear_context() - _ = UserVariable(name="my_time_stepping_var", value=0.6 * u.s) - _, errors, _ = services.validate_model( - params_as_dict=param_dict, - validated_by=services.ValidationCalledBy.SERVICE, - root_item_type="VolumeMesh", - ) - assert errors is None - - -def test_front_end_JSON_with_multi_constructor(): - params_data = { - "meshing": { - "defaults": { - "boundary_layer_first_layer_thickness": {"value": 1, "units": "m"}, - "surface_max_edge_length": {"value": 1, "units": "m"}, - }, - "refinement_factor": 1.45, - "refinements": [ - { - "entities": { - "stored_entities": [ - { - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_entity_type_name": "Box", - "private_attribute_id": "hardcoded_id-1", - "name": "my_box_default", - "private_attribute_zone_boundary_names": {"items": []}, - "type_name": "Box", - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "center": {"value": [1.0, 2.0, 3.0], "units": "m"}, - "size": {"value": [2.0, 2.0, 3.0], "units": "m"}, - "axis_of_rotation": [1.0, 0.0, 0.0], - "angle_of_rotation": {"value": 20.0, "units": "degree"}, - }, - { - "type_name": "Box", - "private_attribute_id": "hardcoded_id-2", - "private_attribute_constructor": "from_principal_axes", - "private_attribute_input_cache": { - "axes": [[0.6, 0.8, 0.0], [0.8, -0.6, 0.0]], - "center": {"value": [7.0, 1.0, 2.0], "units": "m"}, - "size": {"value": [2.0, 2.0, 3.0], "units": "m"}, - "name": "my_box_from", - }, - }, - { - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_entity_type_name": "Cylinder", - "private_attribute_id": "hardcoded_id-3", - "name": "my_cylinder_default", - "private_attribute_zone_boundary_names": {"items": []}, - "axis": [0.0, 1.0, 0.0], - "center": {"value": [1.0, 2.0, 3.0], "units": "m"}, - "height": {"value": 3.0, "units": "m"}, - "outer_radius": {"value": 2.0, "units": "m"}, - }, - ] - }, - "refinement_type": "UniformRefinement", - "spacing": {"units": "cm", "value": 7.5}, - } - ], - "volume_zones": [ - { - "method": "auto", - "type": "AutomatedFarfield", - "private_attribute_entity": { - "private_attribute_registry_bucket_name": "VolumetricEntityType", - "private_attribute_entity_type_name": "GenericVolume", - "private_attribute_id": "hardcoded_id-4", - "name": "automated_farfield_entity", - "private_attribute_zone_boundary_names": {"items": []}, - }, - } - ], - }, - "unit_system": {"name": "SI"}, - "version": "24.2.0", - "private_attribute_asset_cache": { - "project_length_unit": 1.0, - "project_entity_info": { - "type_name": "GeometryEntityInfo", - "face_ids": ["face_x_1", "face_x_2", "face_x_3"], - "face_group_tag": "some_tag", - "face_attribute_names": ["some_tag"], - "grouped_faces": [ - [ - { - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_entity_type_name": "Surface", - "name": "surface_x", - "private_attribute_is_interface": False, - "private_attribute_sub_components": [ - "face_x_1", - "face_x_2", - "face_x_3", - ], - } - ] - ], - }, - }, - "models": [ - { - "type": "Wall", - "entities": { - "stored_entities": [ - { - "private_attribute_registry_bucket_name": "SurfaceEntityType", - "private_attribute_entity_type_name": "Surface", - "name": "surface_x", - "private_attribute_is_interface": False, - "private_attribute_sub_components": [ - "face_x_1", - "face_x_2", - "face_x_3", - ], - } - ] - }, - "use_wall_function": False, - "private_attribute_id": "wall1", - } - ], - "operating_condition": { - "type_name": "AerospaceCondition", - "private_attribute_constructor": "from_mach", - "private_attribute_input_cache": { - "alpha": {"value": 5.0, "units": "degree"}, - "beta": {"value": 0.0, "units": "degree"}, - "thermal_state": { - "type_name": "ThermalState", - "private_attribute_constructor": "from_standard_atmosphere", - "private_attribute_input_cache": { - "altitude": {"value": 1000.0, "units": "m"}, - "temperature_offset": {"value": 0.0, "units": "K"}, - }, - }, - "mach": 0.8, - }, - }, - } - - simulation_param, errors, _ = services.validate_model( - params_as_dict=params_data, - validated_by=services.ValidationCalledBy.LOCAL, - root_item_type="Geometry", - ) - assert errors is None - with open("../../ref/simulation/simulation_json_with_multi_constructor_used.json", "r") as f: - ref_data = json.load(f) - ref_param, err, _ = services.validate_model( - params_as_dict=ref_data, - root_item_type="Geometry", - validated_by=services.ValidationCalledBy.LOCAL, - ) - assert err is None - - param_dict = simulation_param.model_dump(exclude_none=True) - ref_param_dict = ref_param.model_dump(exclude_none=True) - assert compare_values(ref_param_dict, param_dict) - - def test_generate_process_json(): params_data = { "meshing": { @@ -638,75 +447,6 @@ def test_get_default_report_config_json(): ref_dict = json.load(fp) assert compare_values(report_config_dict, ref_dict, ignore_keys=["formatter"]) - -def test_sanitize_stack_trace(): - """Test that _sanitize_stack_trace properly sanitizes file paths and removes traceback prefix.""" - from flow360.component.simulation.services import _sanitize_stack_trace - - # Test case 1: Full stack trace with traceback prefix and absolute paths - input_stack = """Traceback (most recent call last): - File "/disk2/ben/Flow360-R2/flow360/component/simulation/services.py", line 553, in validate_model - validation_info = ParamsValidationInfo( - File "/disk2/ben/Flow360-R2/flow360/component/simulation/validation/validation_context.py", line 437, in __init__ - self.farfield_method = self._get_farfield_method_(param_as_dict=param_as_dict) - File "/disk2/ben/Flow360-R2/flow360/component/simulation/validation/validation_context.py", line 162, in _get_farfield_method_ - if meshing["type_name"] == "MeshingParams": -KeyError: 'type_name'""" - - expected_output = """File "flow360/component/simulation/services.py", line 553, in validate_model - validation_info = ParamsValidationInfo( - File "flow360/component/simulation/validation/validation_context.py", line 437, in __init__ - self.farfield_method = self._get_farfield_method_(param_as_dict=param_as_dict) - File "flow360/component/simulation/validation/validation_context.py", line 162, in _get_farfield_method_ - if meshing["type_name"] == "MeshingParams": -KeyError: 'type_name'""" - - result = _sanitize_stack_trace(input_stack) - assert result == expected_output - - # Test case 2: Stack trace without traceback prefix (already sanitized prefix) - input_stack_no_prefix = """File "/home/user/projects/flow360/component/simulation/services.py", line 100, in some_function - some_code()""" - - expected_no_prefix = """File "flow360/component/simulation/services.py", line 100, in some_function - some_code()""" - - result_no_prefix = _sanitize_stack_trace(input_stack_no_prefix) - assert result_no_prefix == expected_no_prefix - - # Test case 3: Stack trace with non-flow360 paths should remain unchanged for those paths - input_mixed = """Traceback (most recent call last): - File "/usr/lib/python3.10/site-packages/pydantic/main.py", line 100, in validate - return cls.model_validate(obj) - File "/disk2/ben/Flow360-R2/flow360/component/simulation/services.py", line 50, in my_func - do_something()""" - - expected_mixed = """File "/usr/lib/python3.10/site-packages/pydantic/main.py", line 100, in validate - return cls.model_validate(obj) - File "flow360/component/simulation/services.py", line 50, in my_func - do_something()""" - - result_mixed = _sanitize_stack_trace(input_mixed) - assert result_mixed == expected_mixed - - # Test case 4: Empty string should return empty string - assert _sanitize_stack_trace("") == "" - - # Test case 5: String with no file paths should remain unchanged (except traceback prefix) - input_no_paths = "Some error message without file paths" - assert _sanitize_stack_trace(input_no_paths) == input_no_paths - - # Test case 6: Windows-style paths - input_windows = """Traceback (most recent call last): - File "C:\\Users\\dev\\Flow360-R2\\flow360\\component\\simulation\\services.py", line 100, in func - code()""" - - expected_windows = """File "flow360\\component\\simulation\\services.py", line 100, in func - code()""" - - result_windows = _sanitize_stack_trace(input_windows) - assert result_windows == expected_windows - @pytest.mark.parametrize("unit_system_name", ["SI", "Imperial", "CGS"]) def test_validate_model_preserves_unit_system(unit_system_name): """validate_model must not mutate the unit_system entry in the input dict.""" diff --git a/tests/simulation/services/test_entity_processing_service.py b/tests/simulation/services/test_entity_processing_service.py index 45a502bf8..51b017bf3 100644 --- a/tests/simulation/services/test_entity_processing_service.py +++ b/tests/simulation/services/test_entity_processing_service.py @@ -18,9 +18,6 @@ from flow360.component.simulation.models.surface_models import Wall from flow360.component.simulation.primitives import MirroredSurface, Surface from flow360.component.simulation.services import ValidationCalledBy, validate_model -from flow360.component.simulation.services_utils import ( - strip_selector_matches_and_broken_entities_inplace, -) from flow360.component.volume_mesh import VolumeMeshMetaV2, VolumeMeshV2 @@ -182,81 +179,6 @@ def test_validate_model_materializes_dict_and_preserves_selectors(): assert len(preserved_selectors) == 1 assert preserved_selectors[0].model_dump(exclude_none=True) == selector_dict - -def test_validate_model_deduplicates_non_point_entities(): - """ - Test: `validate_model` deduplicates non-Point entities based on (type, id). - """ - params = { - "version": "25.7.6b0", - "operating_condition": { - "type_name": "AerospaceCondition", - "velocity_magnitude": {"units": "m/s", "value": 10}, - }, - "outputs": [ - { - "output_type": "SurfaceOutput", - "name": "o1", - "output_fields": ["Cp"], - "entities": { - "stored_entities": [ - { - "name": "wing", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "s-1", - }, - { - "name": "wing", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "s-1", - }, - ] - }, - } - ], - "private_attribute_asset_cache": { - "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []} - }, - "unit_system": {"name": "SI"}, - } - - validated, errors, _ = validate_model( - params_as_dict=params, validated_by=ValidationCalledBy.LOCAL, root_item_type="Case" - ) - assert not errors - final_entities = validated.outputs[0].entities.stored_entities - assert len(final_entities) == 1 - assert final_entities[0].name == "wing" - - -def test_strip_selector_matches_removes_selector_overlap(): - """Ensure selector-overlap entities are dropped prior to upload.""" - with fl.SI_unit_system: - params = fl.SimulationParams( - outputs=[ - fl.SurfaceOutput( - name="surface_output", - output_fields=[fl.UserVariable(name="var", value=1)], - entities=[ - Surface(name="front", private_attribute_id="s-1"), - Surface(name="rear", private_attribute_id="s-2"), - SurfaceSelector(name="front_selector").any_of(["front"]), - ], - ) - ], - private_attribute_asset_cache=AssetCache( - project_entity_info=SurfaceMeshEntityInfo( - boundaries=[ - Surface(name="front", private_attribute_id="s-1"), - Surface(name="rear", private_attribute_id="s-2"), - ] - ) - ), - ) - strip_selector_matches_and_broken_entities_inplace(params) - assert [entity.name for entity in params.outputs[0].entities.stored_entities] == ["rear"] - - def test_expand_entity_list_in_context_includes_mirrored_entities_from_mirror_status(): """Ensure selector expansion can see mirrored entities registered from mirror_status.""" mirror_plane = MirrorPlane( @@ -311,155 +233,6 @@ def test_expand_entity_list_in_context_includes_mirrored_entities_from_mirror_st expanded_type_names = {entity.private_attribute_entity_type_name for entity in expanded} assert "Surface" in expanded_type_names assert "MirroredSurface" in expanded_type_names - - -def test_strip_broken_and_foreign_mirror_entities_from_stored_entities(): - """Ensure stored_entities drops mirrored entities that are not valid for the current params registry.""" - mirror_plane = MirrorPlane( - name="plane", - normal=(1, 0, 0), - center=(0, 0, 0) * fl.u.m, - private_attribute_id="mp-1", - ) - - valid_mirrored_surface = MirroredSurface( - name="front_", - surface_id="s-1", - mirror_plane_id="mp-1", - private_attribute_id="ms-valid", - ) - broken_mirrored_surface = MirroredSurface( - name="broken_", - surface_id="s-missing", - mirror_plane_id="mp-1", - private_attribute_id="ms-broken", - ) - foreign_mirrored_surface = MirroredSurface( - name="front_", - surface_id="s-1", - mirror_plane_id="mp-1", - private_attribute_id="ms-foreign", - ) - - with fl.SI_unit_system: - params = fl.SimulationParams( - outputs=[ - fl.SurfaceOutput( - name="surface_output", - output_fields=[fl.UserVariable(name="var", value=1)], - entities=[ - Surface(name="front", private_attribute_id="s-1"), - Surface(name="stale", private_attribute_id="s-stale"), - valid_mirrored_surface, - broken_mirrored_surface, - foreign_mirrored_surface, - ], - ) - ], - private_attribute_asset_cache=AssetCache( - project_entity_info=SurfaceMeshEntityInfo( - boundaries=[Surface(name="front", private_attribute_id="s-1")] - ), - mirror_status=MirrorStatus( - mirror_planes=[mirror_plane], - mirrored_geometry_body_groups=[], - mirrored_surfaces=[valid_mirrored_surface], - ), - ), - ) - - strip_selector_matches_and_broken_entities_inplace(params) - remaining_names = [entity.name for entity in params.outputs[0].entities.stored_entities] - assert remaining_names == ["front", "stale", "front_"] - - -def test_validate_model_does_not_deduplicate_point_entities(): - """ - Test: `validate_model` preserves duplicate Point entities. - """ - params = { - "version": "25.7.6b0", - "operating_condition": { - "type_name": "AerospaceCondition", - "velocity_magnitude": {"value": 10, "units": "m/s"}, - }, - "outputs": [ - { - "output_type": "StreamlineOutput", - "name": "o2", - "entities": { - "stored_entities": [ - { - "name": "p1", - "private_attribute_entity_type_name": "Point", - "location": {"value": [0, 0, 0], "units": "m"}, - }, - { - "name": "p1", - "private_attribute_entity_type_name": "Point", - "location": {"value": [0, 0, 0], "units": "m"}, - }, - ] - }, - } - ], - "private_attribute_asset_cache": { - "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []} - }, - "unit_system": {"name": "SI"}, - } - - validated, errors, _ = validate_model( - params_as_dict=params, validated_by=ValidationCalledBy.LOCAL, root_item_type="Case" - ) - assert not errors - final_entities = validated.outputs[0].entities.stored_entities - assert len(final_entities) == 2 - assert all(e.name == "p1" for e in final_entities) - - -def test_validate_model_shares_entity_instances_across_lists(): - """ - Test: `validate_model` uses a global cache to share entity instances, - ensuring that an entity with the same ID is the same Python object everywhere. - """ - entity_dict = { - "name": "s", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "s-1", - } - params = { - "version": "25.7.6b0", - "unit_system": {"name": "SI"}, - "operating_condition": { - "type_name": "AerospaceCondition", - "velocity_magnitude": {"value": 10, "units": "m/s"}, - }, - "models": [ - {"type": "Wall", "name": "Wall", "entities": {"stored_entities": [entity_dict]}} - ], - "outputs": [ - { - "output_type": "SurfaceOutput", - "name": "o3", - "output_fields": ["Cp"], - "entities": {"stored_entities": [entity_dict]}, - } - ], - "private_attribute_asset_cache": { - "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []} - }, - } - - validated, errors, _ = validate_model( - params_as_dict=params, validated_by=ValidationCalledBy.LOCAL, root_item_type="Case" - ) - assert not errors - entity_in_model = validated.models[0].entities.stored_entities[0] - entity_in_output = validated.outputs[0].entities.stored_entities[0] - assert entity_in_model is entity_in_output - - def test_delayed_expansion_round_trip_preserves_semantics(): """ simulation.json -> validate -> round-trip -> compare diff --git a/tests/simulation/services/test_services_utils.py b/tests/simulation/services/test_services_utils.py index a874b9877..0b860d18f 100644 --- a/tests/simulation/services/test_services_utils.py +++ b/tests/simulation/services/test_services_utils.py @@ -1,21 +1,12 @@ import json -from types import SimpleNamespace import flow360 as fl import flow360.component.simulation.units as u from flow360.component.project_utils import validate_params_with_context from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.meshing_param.meshing_specs import MeshingDefaults -from flow360.component.simulation.services_utils import ( - strip_implicit_edge_split_layers_inplace, -) from flow360.component.simulation.web.draft import Draft -def _build_dummy_params(defaults: MeshingDefaults): - return SimpleNamespace(meshing=SimpleNamespace(defaults=defaults)) - - def _build_simulation_params(*, edge_split_layers=None): defaults_kwargs = dict( boundary_layer_first_layer_thickness=1e-4, @@ -35,51 +26,6 @@ def _build_simulation_params(*, edge_split_layers=None): ), ) - -def test_strip_implicit_edge_split_layers_removes_default_injected_field(): - with fl.SI_unit_system: - defaults = MeshingDefaults(boundary_layer_first_layer_thickness=1e-4) - - params = _build_dummy_params(defaults) - params_dict = { - "meshing": {"defaults": {"edge_split_layers": 1, "surface_edge_growth_rate": 1.2}} - } - - out = strip_implicit_edge_split_layers_inplace(params, params_dict) - - assert out is params_dict - assert "edge_split_layers" not in out["meshing"]["defaults"] - assert out["meshing"]["defaults"]["surface_edge_growth_rate"] == 1.2 - - -def test_strip_implicit_edge_split_layers_keeps_explicit_default_value(): - with fl.SI_unit_system: - defaults = MeshingDefaults(boundary_layer_first_layer_thickness=1e-4, edge_split_layers=1) - - params = _build_dummy_params(defaults) - params_dict = { - "meshing": {"defaults": {"edge_split_layers": 1, "surface_edge_growth_rate": 1.2}} - } - - out = strip_implicit_edge_split_layers_inplace(params, params_dict) - - assert out["meshing"]["defaults"]["edge_split_layers"] == 1 - - -def test_strip_implicit_edge_split_layers_keeps_explicit_non_default_value(): - with fl.SI_unit_system: - defaults = MeshingDefaults(boundary_layer_first_layer_thickness=1e-4, edge_split_layers=3) - - params = _build_dummy_params(defaults) - params_dict = { - "meshing": {"defaults": {"edge_split_layers": 3, "surface_edge_growth_rate": 1.2}} - } - - out = strip_implicit_edge_split_layers_inplace(params, params_dict) - - assert out["meshing"]["defaults"]["edge_split_layers"] == 3 - - def test_validate_params_with_context_no_warning_for_implicit_default(): params = _build_simulation_params() From 9bf503f52cd4f8c7b75019c195456bbdaebdebb7 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 16 Apr 2026 20:47:57 -0400 Subject: [PATCH 23/25] Remove migrated simulation test duplicates --- .../params/test_unit_conversions.py | 108 ---------- .../params/test_validators_bet_disk.py | 140 ------------- .../test_entity_processing_service.py | 185 ------------------ 3 files changed, 433 deletions(-) delete mode 100644 tests/simulation/params/test_unit_conversions.py delete mode 100644 tests/simulation/params/test_validators_bet_disk.py diff --git a/tests/simulation/params/test_unit_conversions.py b/tests/simulation/params/test_unit_conversions.py deleted file mode 100644 index d1e87d744..000000000 --- a/tests/simulation/params/test_unit_conversions.py +++ /dev/null @@ -1,108 +0,0 @@ -import re -import unittest - -import pydantic as pd -import pytest - -import flow360 as fl -from flow360 import SI_unit_system, u -from flow360.component.simulation.operating_condition.operating_condition import ( - ThermalState, -) -from flow360.component.simulation.primitives import Surface - -assertions = unittest.TestCase("__init__") - - -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -# test_unit_conversions: REMOVED — tested convert_unit with flow360_*_unit (deleted in Phase 4) -# Tracked in plans/removed_tests.markdown for future migration to schema side. -def _removed_test_unit_conversions(): - pass - - -def test_temperature_offset(): - ThermalState.from_standard_atmosphere( - altitude=10 * u.m, temperature_offset=11.11 * u.delta_degC - ) - with pytest.raises(ValueError, match="Use delta units"): - ThermalState.from_standard_atmosphere(altitude=10 * u.m, temperature_offset=11.11 * u.degC) - - with fl.imperial_unit_system: - ts: ThermalState = ThermalState.from_standard_atmosphere( - altitude=10 * u.m, temperature_offset=11.11 - ) - assert ts.temperature_offset == 11.11 * u.delta_degF - - with fl.SI_unit_system: - ts: ThermalState = ThermalState.from_standard_atmosphere( - altitude=10 * u.m, temperature_offset=11.11 - ) - assert ts.temperature_offset == 11.11 * u.K - - with fl.CGS_unit_system: - ts: ThermalState = ThermalState.from_standard_atmosphere( - altitude=10 * u.m, temperature_offset=11.11 - ) - assert ts.temperature_offset == 11.11 * u.K - - -def test_operations_on_units(): - - with SI_unit_system: - far_field_zone = fl.AutomatedFarfield() - params = fl.SimulationParams( - meshing=fl.MeshingParams( - defaults=fl.MeshingDefaults( - boundary_layer_first_layer_thickness=0.001, - surface_max_edge_length=1, - ), - volume_zones=[far_field_zone], - ), - reference_geometry=fl.ReferenceGeometry(), - operating_condition=fl.AerospaceCondition( - velocity_magnitude=100 * fl.u.m / 3 / fl.u.s * 7 * fl.u.inch / fl.u.cm, - alpha=5 * u.deg, - ), - time_stepping=fl.Steady(max_steps=1000), - models=[ - fl.Wall( - surfaces=[Surface(name="surface")], - name="Wall", - ), - fl.Freestream( - surfaces=[far_field_zone.farfield], - name="Freestream", - ), - ], - ) - - replaced = params.operating_condition.velocity_magnitude * 3 - assertions.assertAlmostEqual(replaced.value, 70000) - assert str(replaced.units) == "inch/s" - - replaced = params.operating_condition.velocity_magnitude / (27.3 * fl.u.m / fl.u.s) - assertions.assertAlmostEqual(replaced.value, 21.70940170940171) - assert str(replaced.units) == "dimensionless" - - replaced = params.operating_condition.velocity_magnitude**5 - (1 / 50 * (fl.u.km / fl.u.s) ** 5) - assertions.assertAlmostEqual(replaced.value, 502472105493.3395, 3) - assert str(replaced.units) == "inch**5*m**5/(cm**5*s**5)" - - replaced = ( - params.operating_condition.thermal_state.temperature.to("degC") - 25 * fl.u.degC - ).to("K") - assertions.assertAlmostEqual(replaced.value, -10) - assert str(replaced.units) == "K" - - replaced = params.operating_condition.thermal_state.temperature.to("degC") - 25 * fl.u.degC - assertions.assertAlmostEqual(replaced.value, -10) - assert str(replaced.units.expr) == "delta_degC" # unyt 3.0+ - - replaced = params.operating_condition.thermal_state.density + 2 * fl.u.g / fl.u.cm**3 - assertions.assertAlmostEqual(replaced.value, 2001.2249999999997) - assert str(replaced.units) == "kg/m**3" diff --git a/tests/simulation/params/test_validators_bet_disk.py b/tests/simulation/params/test_validators_bet_disk.py deleted file mode 100644 index b6788c96d..000000000 --- a/tests/simulation/params/test_validators_bet_disk.py +++ /dev/null @@ -1,140 +0,0 @@ -import unittest - -import pytest - -import flow360.component.simulation.units as u -from flow360.component.simulation import services -from flow360.component.simulation.models.volume_models import BETDisk -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.time_stepping.time_stepping import Unsteady -from tests.simulation.translator.utils.xv15_bet_disk_helper import createBETDiskSteady -from tests.simulation.translator.utils.xv15BETDisk_param_generator import ( - _BET_cylinder, - _rpm_hover_mode, -) - -assertions = unittest.TestCase("__init__") - - -@pytest.fixture -def create_steady_bet_disk(): - bet_disk = createBETDiskSteady(_BET_cylinder, 10, _rpm_hover_mode) - return bet_disk - - -@pytest.fixture(autouse=True) -def change_test_dir(request, monkeypatch): - monkeypatch.chdir(request.fspath.dirname) - - -def test_bet_disk_blade_line_chord(create_steady_bet_disk): - bet_disk = create_steady_bet_disk - with pytest.raises( - ValueError, - match="BETDisk with name 'BET disk': the blade_line_chord has to be positive since its initial_blade_direction is specified.", - ): - bet_disk.initial_blade_direction = (1, 0, 0) - - -def test_bet_disk_initial_blade_direction(create_steady_bet_disk): - bet_disk = create_steady_bet_disk - BETDisk.model_validate(bet_disk) - - with pytest.raises( - ValueError, - match="BETDisk with name 'BET disk': the initial_blade_direction is required to specify since its blade_line_chord is non-zero", - ): - bet_disk_2 = bet_disk.model_copy(deep=True) - bet_disk_2.blade_line_chord = 0.1 * u.inch - - -def test_bet_disk_initial_blade_direction_with_bet_name(create_steady_bet_disk): - with pytest.raises( - ValueError, - match="BETDisk with name 'custom_bet_disk_name': the initial_blade_direction is required to specify since its blade_line_chord is non-zero", - ): - bet_disk = create_steady_bet_disk - bet_disk.name = "custom_bet_disk_name" - bet_disk.blade_line_chord = 0.1 * u.inch - - -def test_bet_disk_disorder_alphas(create_steady_bet_disk): - bet_disk = create_steady_bet_disk - with pytest.raises( - ValueError, - match="BETDisk with name 'BET disk': the alphas are not in increasing order.", - ): - tmp = bet_disk.alphas[0] - bet_disk.alphas[0] = bet_disk.alphas[1] - bet_disk.alphas[1] = tmp - BETDisk.deserialize(bet_disk.model_dump()) - - -def test_bet_disk_duplicate_chords(create_steady_bet_disk): - bet_disk = create_steady_bet_disk - with pytest.raises( - ValueError, - match=r"BETDisk with name 'diskABC': it has duplicated radius at .+ in chords\.", - ): - bet_disk.name = "diskABC" - bet_disk.chords.append(bet_disk.chords[-1]) - BETDisk.deserialize(bet_disk.model_dump()) - - -def test_bet_disk_duplicate_twists(create_steady_bet_disk): - bet_disk = create_steady_bet_disk - with pytest.raises( - ValueError, - match=r"BETDisk with name 'diskABC': it has duplicated radius at .+ in twists\.", - ): - bet_disk.name = "diskABC" - bet_disk.twists.append(bet_disk.twists[-1]) - BETDisk.deserialize(bet_disk.model_dump()) - - -def test_bet_disk_nonequal_sectional_radiuses_and_polars(create_steady_bet_disk): - bet_disk = create_steady_bet_disk - with pytest.raises( - ValueError, - match=r"BETDisk with name 'diskABC': the length of sectional_radiuses \(7\) is not the same as that of sectional_polars \(6\).", - ): - bet_disk.name = "diskABC" - bet_disk_dict = bet_disk.model_dump() - bet_disk_dict["sectional_radiuses"] = bet_disk_dict["sectional_radiuses"] + [ - bet_disk_dict["sectional_radiuses"][-1], - ] - BETDisk.deserialize(bet_disk_dict) - - -def test_bet_disk_3d_coefficients_dimension_wrong_mach_numbers(create_steady_bet_disk): - bet_disk = create_steady_bet_disk - with pytest.raises( - ValueError, - match=r"BETDisk with name 'diskABC': \(cross section: 0\): number of mach_numbers = 2, but the first dimension of lift_coeffs is 1", - ): - bet_disk.name = "diskABC" - bet_disk.mach_numbers.append(bet_disk.mach_numbers[-1]) - BETDisk.model_validate(bet_disk) - - -def test_bet_disk_3d_coefficients_dimension_wrong_re_numbers(create_steady_bet_disk): - bet_disk = create_steady_bet_disk - with pytest.raises( - ValueError, - match=r"BETDisk with name 'diskABC': \(cross section: 0\) \(Mach index \(0-based\) 0\): number of Reynolds = 2, but the second dimension of lift_coeffs is 1", - ): - bet_disk.name = "diskABC" - bet_disk.reynolds_numbers.append(bet_disk.reynolds_numbers[-1]) - BETDisk.model_validate(bet_disk) - - -def test_bet_disk_3d_coefficients_dimension_wrong_alpha_numbers(create_steady_bet_disk): - bet_disk = create_steady_bet_disk - with pytest.raises( - ValueError, - match=r"BETDisk with name 'diskABC': \(cross section: 0\) \(Mach index \(0-based\) 0, Reynolds index \(0-based\) 0\): number of Alphas = 18, but the third dimension of lift_coeffs is 17.", - ): - bet_disk.name = "diskABC" - bet_disk_dict = bet_disk.model_dump() - bet_disk_dict["alphas"] = bet_disk_dict["alphas"] + [bet_disk_dict["alphas"][-1]] - BETDisk.deserialize(bet_disk_dict) diff --git a/tests/simulation/services/test_entity_processing_service.py b/tests/simulation/services/test_entity_processing_service.py index 51b017bf3..be1331be1 100644 --- a/tests/simulation/services/test_entity_processing_service.py +++ b/tests/simulation/services/test_entity_processing_service.py @@ -1,5 +1,4 @@ import copy -import json import os import pytest @@ -43,14 +42,6 @@ def _load_local_vm(): ), ) - -def _load_json(path_from_tests_dir: str) -> dict: - """Helper to load a JSON file from the tests/simulation directory.""" - base = os.path.dirname(os.path.abspath(__file__)) - with open(os.path.join(base, "..", path_from_tests_dir), "r", encoding="utf-8") as file: - return json.load(file) - - def test_validate_model_keeps_selectors_unexpanded(): """ Test: End-to-end validation with delayed selector expansion. @@ -120,65 +111,6 @@ def test_validate_model_keeps_selectors_unexpanded(): validated.model_dump(mode="json"), validated_again.model_dump(mode="json") ) - -def test_validate_model_materializes_dict_and_preserves_selectors(): - """ - Test: `validate_model` correctly materializes explicit entity dicts into objects - while preserving the original selectors from a raw dictionary input. - - With delayed expansion, selectors are NOT expanded into stored_entities during - validation. Expansion happens later during translation. - """ - params = _load_json("data/geometry_grouped_by_file/simulation.json") - - # Inject a selector into the params dict and assign all entities to a Wall - # to satisfy the boundary condition validation. - selector_dict = { - "target_class": "Surface", - "name": "some_selector_name", - "logic": "AND", - "selector_id": "some_selector_id", - "children": [{"attribute": "name", "operator": "matches", "value": "*"}], - } - outputs = params.get("outputs") or [] - entities = outputs[0].get("entities") or {} - entities["selectors"] = [selector_dict] - entities["stored_entities"] = [] # Start with no materialized entities - - # Assign all boundaries to a default wall to pass validation - all_boundaries_selector = { - "target_class": "Surface", - "name": "all_boundaries", - "children": [ - {"attribute": "name", "operator": "matches", "value": "*"}, - {"attribute": "name", "operator": "not_matches", "value": "farfield"}, - {"attribute": "name", "operator": "not_matches", "value": "symmetric"}, - ], - } - params["models"].append( - { - "type": "Wall", - "name": "DefaultWall", - "entities": {"selectors": [all_boundaries_selector]}, - } - ) - - validated, errors, _ = validate_model( - params_as_dict=params, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Case", - ) - assert not errors, f"Unexpected validation errors: {errors}" - - # With delayed expansion, stored_entities should remain empty since only selectors were specified - stored_entities = validated.outputs[0].entities.stored_entities - assert len(stored_entities) == 0, "stored_entities should be empty with delayed expansion" - - # Verify selectors are preserved - preserved_selectors = validated.outputs[0].entities.selectors - assert len(preserved_selectors) == 1 - assert preserved_selectors[0].model_dump(exclude_none=True) == selector_dict - def test_expand_entity_list_in_context_includes_mirrored_entities_from_mirror_status(): """Ensure selector expansion can see mirrored entities registered from mirror_status.""" mirror_plane = MirrorPlane( @@ -233,120 +165,3 @@ def test_expand_entity_list_in_context_includes_mirrored_entities_from_mirror_st expanded_type_names = {entity.private_attribute_entity_type_name for entity in expanded} assert "Surface" in expanded_type_names assert "MirroredSurface" in expanded_type_names -def test_delayed_expansion_round_trip_preserves_semantics(): - """ - simulation.json -> validate -> round-trip -> compare - Ensures delayed expansion maintains consistency across round-trips. - - With delayed expansion, selectors are NOT expanded into stored_entities during - validation. This test verifies: - - Explicit entities remain in stored_entities - - Selectors are preserved - - Round-trip maintains consistency - """ - # Use a large, real geometry with many faces - params = _load_json("../data/geo-fcbe1113-a70b-43b9-a4f3-bbeb122d64fb/simulation.json") - - # Set face grouping tag so selector operates on faceId groups - pei = params["private_attribute_asset_cache"]["project_entity_info"] - pei["face_group_tag"] = "faceId" - # Remove obsolete/unknown meshing defaults to avoid validation noise in Case-level - params.get("meshing", {}).get("defaults", {}).pop("geometry_tolerance", None) - - # Build mixed EntityList with overlap under outputs[0].entities - outputs = params.get("outputs") or [] - assert outputs, "Test fixture lacks outputs" - entities = outputs[0].get("entities") or {} - entities["stored_entities"] = [ - { - "private_attribute_entity_type_name": "Surface", - "name": "body00001_face00001", - "private_attribute_id": "body00001_face00001", - }, - { - "private_attribute_entity_type_name": "Surface", - "name": "body00001_face00014", - "private_attribute_id": "body00001_face00014", - }, - ] - entities["selectors"] = [ - { - "target_class": "Surface", - "name": "some_overlap", - "children": [ - { - "attribute": "name", - "operator": "any_of", - "value": ["body00001_face00001", "body00001_face00002"], - } - ], - } - ] - outputs[0]["entities"] = entities - params["outputs"] = outputs - - # Ensure models contain a DefaultWall that matches all to satisfy BC validation - all_boundaries_selector = { - "target_class": "Surface", - "name": "all_boundaries", - "children": [ - {"attribute": "name", "operator": "matches", "value": "*"}, - {"attribute": "name", "operator": "not_matches", "value": "farfield"}, - ], - } - params.setdefault("models", []).append( - { - "type": "Wall", - "name": "DefaultWall", - "entities": {"selectors": [all_boundaries_selector]}, - } - ) - - # Baseline validation (with delayed expansion) - validated, errors, _ = validate_model( - params_as_dict=params, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Case", - ) - assert not errors, f"Unexpected validation errors: {errors}" - - # With delayed expansion, stored_entities should only contain explicit entities - baseline_entities = validated.outputs[0].entities.stored_entities # type: ignore[index] - baseline_names = sorted( - [f"{e.private_attribute_entity_type_name}:{e.name}" for e in baseline_entities] - ) - # Only explicitly specified entities should be present (NOT selector matches) - expected_explicit = ["Surface:body00001_face00001", "Surface:body00001_face00014"] - assert baseline_names == expected_explicit, ( - f"Expected only explicit entities in stored_entities\n" - f"Got: {baseline_names}\n" - f"Expected: {expected_explicit}" - ) - - # Verify selectors are preserved - baseline_selectors = validated.outputs[0].entities.selectors - assert len(baseline_selectors) == 1 - assert baseline_selectors[0].name == "some_overlap" - - # Round-trip: serialize and re-validate - round_trip_dict = validated.model_dump(mode="json", exclude_none=True) - validated2, errors2, _ = validate_model( - params_as_dict=round_trip_dict, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Case", - ) - assert not errors2, f"Unexpected validation errors on round-trip: {errors2}" - - # Verify round-trip consistency - post_entities = validated2.outputs[0].entities.stored_entities # type: ignore[index] - post_names = sorted([f"{e.private_attribute_entity_type_name}:{e.name}" for e in post_entities]) - assert baseline_names == post_names, ( - "Entity list mismatch after round-trip\n" - + f"Baseline: {baseline_names}\n" - + f"Post : {post_names}\n" - ) - - # Verify selectors are still preserved after round-trip - post_selectors = validated2.outputs[0].entities.selectors - assert len(post_selectors) == 1 - assert post_selectors[0].name == "some_overlap" From 2def56e8483b4bbaf35a2601b11db9a7669d2a91 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 17 Apr 2026 12:40:08 -0400 Subject: [PATCH 24/25] Remove migrated simulation params test duplicates --- .../data/simulation_force_output_webui.json | 1026 ----------------- .../params/test_validators_output.py | 121 -- .../params/test_validators_params.py | 239 ---- 3 files changed, 1386 deletions(-) delete mode 100644 tests/simulation/params/data/simulation_force_output_webui.json delete mode 100644 tests/simulation/params/test_validators_output.py diff --git a/tests/simulation/params/data/simulation_force_output_webui.json b/tests/simulation/params/data/simulation_force_output_webui.json deleted file mode 100644 index 79143275a..000000000 --- a/tests/simulation/params/data/simulation_force_output_webui.json +++ /dev/null @@ -1,1026 +0,0 @@ -{ - "models": [ - { - "_id": "f94a410b-31d4-4696-97f6-db4ba8d75fcc", - "initial_condition": { - "p": "p", - "rho": "rho", - "type_name": "NavierStokesInitialCondition", - "u": "u", - "v": "v", - "w": "w" - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 273.15 - }, - "reference_viscosity": { - "units": "Pa*s", - "value": 1.716e-05 - } - }, - "name": "air", - "type": "air" - }, - "navier_stokes_solver": { - "CFL_multiplier": 1.0, - "absolute_tolerance": 1e-09, - "equation_evaluation_frequency": 1, - "kappa_MUSCL": -1.0, - "limit_pressure_density": false, - "limit_velocity": false, - "linear_solver": { - "max_iterations": 25 - }, - "low_mach_preconditioner": false, - "max_force_jac_update_physical_steps": 0, - "numerical_dissipation_factor": 1.0, - "order_of_accuracy": 2, - "relative_tolerance": 0.0, - "type_name": "Compressible", - "update_jacobian_frequency": 4 - }, - "private_attribute_id": "f94a410b-31d4-4696-97f6-db4ba8d75fcc", - "transition_model_solver": { - "type_name": "None" - }, - "turbulence_model_solver": { - "CFL_multiplier": 2.0, - "absolute_tolerance": 1e-08, - "equation_evaluation_frequency": 4, - "linear_solver": { - "max_iterations": 15 - }, - "low_reynolds_correction": false, - "max_force_jac_update_physical_steps": 0, - "modeling_constants": { - "C_DES": 0.72, - "C_cb1": 0.1355, - "C_cb2": 0.622, - "C_d": 8.0, - "C_min_rd": 10.0, - "C_sigma": 0.6666666666666666, - "C_t3": 1.2, - "C_t4": 0.5, - "C_v1": 7.1, - "C_vonKarman": 0.41, - "C_w2": 0.3, - "C_w4": 0.21, - "C_w5": 1.5, - "type_name": "SpalartAllmarasConsts" - }, - "order_of_accuracy": 2, - "quadratic_constitutive_relation": false, - "reconstruction_gradient_limiter": 0.5, - "relative_tolerance": 0.0, - "rotation_correction": false, - "type_name": "SpalartAllmaras", - "update_jacobian_frequency": 4 - }, - "type": "Fluid" - }, - { - "_id": "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", - "entities": { - "stored_entities": [ - { - "name": "1", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "1", - "private_attribute_id": "1", - "private_attribute_is_interface": false, - "private_attribute_sub_components": [] - } - ] - }, - "heat_spec": { - "type_name": "HeatFlux", - "value": { - "units": "W/m**2", - "value": 0.0 - } - }, - "name": "wing", - "private_attribute_id": "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", - "roughness_height": { - "units": "m", - "value": 0.0 - }, - "type": "Wall", - "use_wall_function": false - }, - { - "entities": { - "stored_entities": [ - { - "name": "2", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "2", - "private_attribute_id": "2", - "private_attribute_is_interface": false, - "private_attribute_sub_components": [] - } - ] - }, - "name": "symmetry", - "private_attribute_id": "d517b40d-cab9-49df-b260-9291937bd86b", - "type": "SlipWall" - }, - { - "_id": "58a41587-1ad2-48d6-afbe-9e8bafc2a51d", - "entities": { - "stored_entities": [ - { - "name": "3", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "3", - "private_attribute_id": "3", - "private_attribute_is_interface": false, - "private_attribute_sub_components": [] - } - ] - }, - "name": "Freestream", - "private_attribute_id": "58a41587-1ad2-48d6-afbe-9e8bafc2a51d", - "type": "Freestream" - }, - { - "_id": "42a99183-65eb-45eb-956a-7b41cae69efd", - "darcy_coefficient": { - "units": "m**(-2)", - "value": [ - 1000000.0, - 0.0, - 0.0 - ] - }, - "entities": { - "stored_entities": [ - { - "angle_of_rotation": { - "units": "rad", - "value": -2.0943951023931953 - }, - "axis_of_rotation": [ - -0.5773502691896256, - -0.5773502691896256, - -0.577350269189626 - ], - "center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "name": "box", - "private_attribute_constructor": "from_principal_axes", - "private_attribute_entity_type_name": "Box", - "private_attribute_id": "07c017de-b078-48c6-bd65-f6bc11b321a2", - "private_attribute_input_cache": { - "axes": [ - [ - 0.0, - 1.0, - 0.0 - ], - [ - 0.0, - 0.0, - 1.0 - ] - ], - "center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "name": "box", - "size": { - "units": "m", - "value": [ - 0.2, - 0.3, - 2.0 - ] - } - }, - "private_attribute_zone_boundary_names": { - "items": [] - }, - "size": { - "units": "m", - "value": [ - 0.2, - 0.3, - 2.0 - ] - }, - "type_name": "Box" - } - ] - }, - "forchheimer_coefficient": { - "units": "1/m", - "value": [ - 1.0, - 0.0, - 0.0 - ] - }, - "name": "Porous medium", - "private_attribute_id": "42a99183-65eb-45eb-956a-7b41cae69efd", - "type": "PorousMedium", - "volumetric_heat_source": { - "units": "kg/(m*s**3)", - "value": 0.0 - } - }, - { - "_id": "38c82a7f-b0cf-4b38-a93f-31fd1561a946", - "blade_line_chord": { - "units": "m", - "value": 0 - }, - "name": "BET11222", - "number_of_blades": null, - "private_attribute_constructor": "from_xrotor", - "private_attribute_id": "38c82a7f-b0cf-4b38-a93f-31fd1561a946", - "private_attribute_input_cache": { - "angle_unit": { - "units": "degree", - "value": 1 - }, - "blade_line_chord": { - "units": "m", - "value": 0 - }, - "chord_ref": { - "units": "m", - "value": 3 - }, - "entities": { - "stored_entities": [ - { - "axis": [ - 0, - 1, - 0 - ], - "center": { - "units": "cm", - "value": [ - 0, - -10, - 0 - ] - }, - "height": { - "units": "cm", - "value": 1 - }, - "inner_radius": { - "units": "m", - "value": 0 - }, - "name": "Cylinder", - "outer_radius": { - "units": "cm", - "value": 1 - }, - "private_attribute_entity_type_name": "Cylinder", - "private_attribute_id": "9e2d69cd-460e-456d-b0df-b232be7198a3" - }, - { - "axis": [ - 0, - 1, - 0 - ], - "center": { - "units": "cm", - "value": [ - 0, - -8, - 0 - ] - }, - "height": { - "units": "cm", - "value": 1 - }, - "inner_radius": { - "units": "m", - "value": 0 - }, - "name": "Cylinder3", - "outer_radius": { - "units": "cm", - "value": 1 - }, - "private_attribute_entity_type_name": "Cylinder", - "private_attribute_id": "41cc394a-0ac6-4e0f-97f9-e1971edacbe3" - } - ] - }, - "file": { - "content": "XROTOR VERSION: 7.54\nxv15_fromXrotor\n! Rho Vso Rmu Alt\n 1.00 342.00 0.17170E-04 2000.0\n! Rad Vel Adv Rake\n 150.00 1.0 4.23765e-3 0.0000\n! XI0 XIW\n 0.13592 0.0000\n! Naero\n 5\n! Xisection\n 0.09\n! A0deg dCLdA CLmax CLmin\n -6.5 4.00 1.2500 -0.0000\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.075000E-01 0.00000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n! Xisection\n 0.17\n! A0deg dCLdA CLmax CLmin\n -6.0 6.0 1.300 -0.55000\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.075000E-01 0.10000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n! Xisection\n 0.51\n! A0deg dCLdA CLmax CLmin\n-1.0 6.00 1.400 -1.4000\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.05000E-01 0.10000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n! Xisection\n 0.8\n! A0deg dCLdA CLmax CLmin\n -1.0 6.0 1.600 -1.500\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.03000E-01 0.10000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n! Xisection\n 1.0\n! A0deg dCLdA CLmax CLmin\n -1 6.0 1.0 -1.8000\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.04000E-01 0.10000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n!LVDuct LDuct LWind\n T F F\n! II Nblds\n 63 3\n! r/R C/R Beta0deg Ubody\n0.023003943\t0.113428403\t33.27048712\t0\n0.039116227\t0.113428403\t32.37853609\t0\n0.060384093\t0.113428403\t31.42712165\t0\n0.083584992\t0.113428403\t30.65409742\t0\n0.093024104\t0.113428403\t30.13214089\t0\n0.108253499\t0.113428403\t29.41523066\t0\n0.122239679\t0.111608996\t28.75567325\t0\n0.140577109\t0.10978959\t27.89538097\t0\n0.159536634\t0.107970183\t26.69097178\t0\n0.172590692\t0.106150776\t25.88803232\t0\n0.182536671\t0.10433137\t25.25715132\t0\n0.192482739\t0.102511963\t24.5689175\t0\n0.20305028\t0.100692556\t23.93803649\t0\n0.214861124\t0.09887315\t23.19244985\t0\n0.227293664\t0.097053743\t22.36083398\t0\n0.239415201\t0.095234336\t21.67260016\t0\n0.25153696\t0.093414933\t20.84098429\t0\n0.264591196\t0.0934138\t19.92333919\t0\n0.27609148\t0.093412667\t19.03437051\t0\n0.286348329\t0.093411533\t18.34613668\t0\n0.297848614\t0.0934104\t17.457168\t0\n0.309659147\t0.093409267\t16.91231622\t0\n0.322713117\t0.093408133\t16.16672958\t0\n0.334834565\t0.093407\t15.53584858\t0\n0.345091281\t0.093405867\t14.93364398\t0\n0.356591344\t0.093404733\t14.18805734\t0\n0.367158885\t0.0934036\t13.55717634\t0\n0.375240265\t0.093402467\t12.86894251\t0\n0.385497114\t0.093401333\t12.18070869\t0\n0.396375525\t0.0934002\t11.49247487\t0\n0.406632108\t0.093399067\t10.9762995\t0\n0.419063938\t0.093397933\t10.60350618\t0\n0.436468714\t0.0933968\t9.94394877\t0\n0.450765676\t0.093395667\t9.28439136\t0\n0.465373463\t0.093394533\t8.59615753\t0\n0.476873349\t0.0933934\t7.96527653\t0\n0.489927141\t0.093392267\t7.33439553\t0\n0.497075821\t0.093391133\t6.87557298\t0\n0.511683032\t0.09339\t6.56013248\t0\n0.529087542\t0.093388867\t6.07263352\t0\n0.544627496\t0.093387733\t5.49910533\t0\n0.557680933\t0.0933866\t5.0976356\t0\n0.569491245\t0.093385467\t4.69616587\t0\n0.585652762\t0.093384333\t4.12263769\t0\n0.596841423\t0.0933832\t3.77852078\t0\n0.609273165\t0.093382067\t3.46308028\t0\n0.628853143\t0.093380933\t2.97558132\t0\n0.664905287\t0.0933798\t2.0005834\t0\n0.686349777\t0.093378667\t1.62779008\t0\n0.69940317\t0.093377533\t1.25499676\t0\n0.711213304\t0.0933764\t0.96823267\t0\n0.726753081\t0.093375267\t0.50941012\t0\n0.745400849\t0.093374133 -0.064118065 0\n0.762805314\t0.093373 -0.522940614 0\n0.797303462\t0.093371867\t-1.44058571 0\n0.842057794\t0.093370733 -2.61631849 0\n0.877177195\t0.0933696\t-3.333228722 0\n0.91385068\t0.093368467\t-4.164844591 0\n0.936227735\t0.093367333\t-4.681019957 0\n0.957361443\t0.0933662 -5.053813278 0\n0.977873766\t0.093365067 -5.541312235 0\n0.989683856\t0.093363933 -5.799399919 0\n1\t0.0933634\t-6.1\t0\n! URDuct\n 1.0000", - "file_path": "xrotorTest.xrotor", - "type_name": "XRotorFile" - }, - "length_unit": { - "units": "m", - "value": 1 - }, - "n_loading_nodes": 3, - "name": "BET11222", - "number_of_blades": 3, - "omega": { - "units": "rad/s", - "value": 10 - }, - "rotation_direction_rule": "leftHand", - "tip_gap": "inf" - }, - "rotation_direction_rule": "leftHand", - "tip_gap": "inf", - "type": "BETDisk", - "type_name": "BETDisk" - }, - { - "_id": "f929834a-0adb-4b42-a880-3d034edce883", - "entities": { - "stored_entities": [ - { - "axis": [ - 0, - 0, - 1 - ], - "center": { - "units": "cm", - "value": [ - 10, - 0, - 0 - ] - }, - "height": { - "units": "cm", - "value": 1 - }, - "inner_radius": { - "units": "m", - "value": 0 - }, - "name": "ad1", - "outer_radius": { - "units": "cm", - "value": 1 - }, - "private_attribute_entity_type_name": "Cylinder", - "private_attribute_id": "c007c4ff-65af-41d9-847b-e0058f31ae51" - } - ] - }, - "force_per_area": { - "circumferential": { - "units": "N/m**2", - "value": [ - 0, - 20, - 1 - ] - }, - "radius": { - "units": "m", - "value": [ - 0, - 1, - 10 - ] - }, - "thrust": { - "units": "N/m**2", - "value": [ - 1, - 2, - 1 - ] - } - }, - "name": "Actuator disk1", - "private_attribute_id": "f929834a-0adb-4b42-a880-3d034edce883", - "type": "ActuatorDisk" - } - ], - "operating_condition": { - "alpha": { - "units": "degree", - "value": 3.06 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "private_attribute_constructor": "from_mach", - "private_attribute_input_cache": { - "alpha": { - "units": "degree", - "value": 3.06 - }, - "beta": { - "units": "degree", - "value": 0.0 - }, - "mach": 0.84, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 288.15 - }, - "reference_viscosity": { - "units": "kg/(m*s)", - "value": 1.9328492090409794e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - } - }, - "thermal_state": { - "density": { - "units": "kg/m**3", - "value": 1.225 - }, - "material": { - "dynamic_viscosity": { - "effective_temperature": { - "units": "K", - "value": 110.4 - }, - "reference_temperature": { - "units": "K", - "value": 288.15 - }, - "reference_viscosity": { - "units": "kg/(m*s)", - "value": 1.9328492090409794e-05 - } - }, - "name": "air", - "type": "air" - }, - "private_attribute_constructor": "default", - "private_attribute_input_cache": {}, - "temperature": { - "units": "K", - "value": 288.15 - }, - "type_name": "ThermalState" - }, - "type_name": "AerospaceCondition", - "velocity_magnitude": { - "type_name": "number", - "units": "m/s", - "value": 285.84696487889875 - } - }, - "outputs": [ - { - "_id": "84906625-9279-4cb4-8812-dafb831b2cfa", - "frequency": -1, - "frequency_offset": 0, - "name": "Volume output", - "output_fields": { - "items": [ - "primitiveVars", - "residualNavierStokes", - "residualTurbulence", - "Mach", - "Sidewash", - "Upwash", - "Helicity" - ] - }, - "output_format": "paraview", - "output_type": "VolumeOutput", - "private_attribute_id": "84906625-9279-4cb4-8812-dafb831b2cfa" - }, - { - "entities": { - "stored_entities": [ - { - "location": { - "units": "m", - "value": [ - -0.026642, - 0.56614, - 0.0 - ] - }, - "name": "Point_1", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "b4f78cfe-f42d-47c9-94f5-d7afdb616a06" - } - ] - }, - "moving_statistic": { - "method": "mean", - "moving_window_size": 200, - "start_step": 0, - "type_name": "MovingStatistic" - }, - "name": "point_legacy1", - "output_fields": { - "items": [ - { - "name": "Helicity_user", - "type_name": "UserVariable" - } - ] - }, - "output_type": "ProbeOutput", - "private_attribute_id": "b0d034e3-87ce-4b79-8c5a-d22b7386000c" - }, - { - "entities": { - "stored_entities": [ - { - "location": { - "units": "m", - "value": [ - -0.026642, - 0.56614, - 0.0 - ] - }, - "name": "Point_1", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "5d0433c0-b202-4c14-bdde-e8e993b47f9e" - } - ] - }, - "moving_statistic": { - "method": "standard_deviation", - "moving_window_size": 100, - "start_step": 0, - "type_name": "MovingStatistic" - }, - "name": "point_legacy2", - "output_fields": { - "items": [ - { - "name": "Mach_SI", - "type_name": "UserVariable" - } - ] - }, - "output_type": "ProbeOutput", - "private_attribute_id": "9c72b148-84ff-4e78-82d7-8d4c5d11ebd3" - }, - { - "models": [ - "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", - "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c" - ], - "moving_statistic": { - "method": "mean", - "moving_window_size": 100, - "start_step": 0, - "type_name": "MovingStatistic" - }, - "name": "forceOutput2", - "output_fields": { - "items": [ - "CL", - "CFx", - "CFySkinFriction" - ] - }, - "output_type": "ForceOutput", - "private_attribute_id": "61863d9a-baf6-499b-87d3-509a2cea2b56" - }, - { - "models": [ - "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", - "42a99183-65eb-45eb-956a-7b41cae69efd" - ], - "name": "forceOutput", - "output_fields": { - "items": [ - "CL", - "CFx", - "CFySkinFriction" - ] - }, - "output_type": "ForceOutput", - "private_attribute_id": "61863d9a-baf6-499b-87d3-509a2cea2b51" - }, - { - "models": [ - "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", - "111" - ], - "name": "forceOutput", - "output_fields": { - "items": [ - "CL", - "CFx", - "CFySkinFriction" - ] - }, - "output_type": "ForceOutput", - "private_attribute_id": "61863d9a-baf6-499b-87d3-509a2cea2b51" - }, - { - "models": [ - "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", - "f929834a-0adb-4b42-a880-3d034edce883", - "38c82a7f-b0cf-4b38-a93f-31fd1561a946" - ], - "name": "forceOutput", - "output_fields": { - "items": [ - "CL", - "CFx" - ] - }, - "output_type": "ForceOutput", - "private_attribute_id": "61863d9a-baf6-499b-87d3-509a2cea2b51" - } - ], - "private_attribute_asset_cache": { - "project_entity_info": { - "boundaries": [ - { - "name": "3", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "3", - "private_attribute_id": "3", - "private_attribute_is_interface": false, - "private_attribute_sub_components": [] - }, - { - "name": "2", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "2", - "private_attribute_id": "2", - "private_attribute_is_interface": false, - "private_attribute_sub_components": [] - }, - { - "name": "1", - "private_attribute_entity_type_name": "Surface", - "private_attribute_full_name": "1", - "private_attribute_id": "1", - "private_attribute_is_interface": false, - "private_attribute_sub_components": [] - } - ], - "draft_entities": [ - { - "location": { - "units": "m", - "value": [ - 0.0, - 1.0, - 0.04 - ] - }, - "name": "point_streamline", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "37083fc7-2aef-4d8a-aa63-e47491f334c3" - }, - { - "location": { - "units": "m", - "value": [ - -0.026642, - 0.56614, - 0.0 - ] - }, - "name": "Point_1", - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "b4f78cfe-f42d-47c9-94f5-d7afdb616a06" - }, - { - "end": { - "units": "m", - "value": [ - 0.0, - 1.0, - 0.2 - ] - }, - "name": "pointarray_streamline", - "number_of_points": 20, - "private_attribute_entity_type_name": "PointArray", - "private_attribute_id": "36662ed5-ce40-4456-8f91-bb8372169ce8", - "start": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.2 - ] - } - }, - { - "name": "pointarray2d_streamline", - "origin": { - "units": "m", - "value": [ - 0.0, - 0.0, - -0.2 - ] - }, - "private_attribute_entity_type_name": "PointArray2D", - "private_attribute_id": "1d5db652-4fb8-4bcf-938e-ac49638e3a41", - "u_axis_vector": { - "units": "m", - "value": [ - 0.0, - 1.4, - 0.0 - ] - }, - "u_number_of_points": 10, - "v_axis_vector": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.4 - ] - }, - "v_number_of_points": 10 - }, - { - "name": "sliceName_1", - "normal": [ - 0.0, - 1.0, - 0.0 - ], - "origin": { - "units": "m", - "value": [ - 0.0, - 0.56413, - 0.0 - ] - }, - "private_attribute_entity_type_name": "Slice", - "private_attribute_id": "2553b0de-97cc-481e-b9a7-05d39e57f7d8" - }, - { - "angle_of_rotation": { - "units": "rad", - "value": -2.0943951023931953 - }, - "axis_of_rotation": [ - -0.5773502691896256, - -0.5773502691896256, - -0.577350269189626 - ], - "center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "name": "box", - "private_attribute_constructor": "from_principal_axes", - "private_attribute_entity_type_name": "Box", - "private_attribute_id": "07c017de-b078-48c6-bd65-f6bc11b321a2", - "private_attribute_input_cache": { - "axes": [ - [ - 0.0, - 1.0, - 0.0 - ], - [ - 0.0, - 0.0, - 1.0 - ] - ], - "center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "name": "box", - "size": { - "units": "m", - "value": [ - 0.2, - 0.3, - 2.0 - ] - } - }, - "private_attribute_zone_boundary_names": { - "items": [] - }, - "size": { - "units": "m", - "value": [ - 0.2, - 0.3, - 2.0 - ] - }, - "type_name": "Box" - } - ], - "ghost_entities": [], - "type_name": "VolumeMeshEntityInfo", - "zones": [ - { - "name": "1", - "private_attribute_entity_type_name": "GenericVolume", - "private_attribute_full_name": "1", - "private_attribute_id": "1", - "private_attribute_zone_boundary_names": { - "items": [ - "1", - "2", - "3" - ] - } - } - ] - }, - "project_length_unit": { - "units": "m", - "value": 0.8059 - }, - "use_geometry_AI": false, - "use_inhouse_mesher": false, - "variable_context": [ - { - "name": "Helicity_user", - "post_processing": true, - "value": { - "expression": "solution.velocity[0] * solution.vorticity[0] + solution.velocity[1] * solution.vorticity[1] + solution.velocity[2] * solution.vorticity[2]", - "type_name": "expression" - } - }, - { - "name": "Mach_SI", - "post_processing": true, - "value": { - "expression": "solution.Mach", - "type_name": "expression" - } - }, - { - "name": "Cp_SI", - "post_processing": true, - "value": { - "expression": "solution.Cp", - "type_name": "expression" - } - }, - { - "name": "velocity_SI", - "post_processing": true, - "value": { - "expression": "solution.velocity", - "type_name": "expression" - } - } - ] - }, - "reference_geometry": { - "area": { - "type_name": "number", - "units": "m**2", - "value": 0.748844455929999 - }, - "moment_center": { - "units": "m", - "value": [ - 0.0, - 0.0, - 0.0 - ] - }, - "moment_length": { - "units": "m", - "value": 0.6460682372650963 - } - }, - "run_control": { - "stopping_criteria": [ - { - "monitor_field": { - "name": "Helicity_user", - "type_name": "UserVariable" - }, - "monitor_output": "b0d034e3-87ce-4b79-8c5a-d22b7386000c", - "name": "Criterion_Helicity", - "tolerance": { - "type_name": "number", - "units": "cm/s**2", - "value": 0.005 - }, - "tolerance_window_size": 3, - "type_name": "StoppingCriterion" - }, - { - "monitor_field": { - "name": "Mach_SI", - "type_name": "UserVariable" - }, - "monitor_output": "9c72b148-84ff-4e78-82d7-8d4c5d11ebd3", - "name": "Criterion_Mach", - "tolerance": { - "type_name": "number", - "value": 1e-05 - }, - "type_name": "StoppingCriterion" - }, - { - "monitor_field": "CL", - "monitor_output": "61863d9a-baf6-499b-87d3-509a2cea2b56", - "name": "Criterion_ForceOutput", - "tolerance": { - "type_name": "number", - "value": 0.265 - }, - "type_name": "StoppingCriterion" - } - ], - "type_name": "RunControl" - }, - "time_stepping": { - "CFL": { - "final": 200.0, - "initial": 5.0, - "ramp_steps": 40, - "type": "ramp" - }, - "max_steps": 2000, - "type_name": "Steady" - }, - "unit_system": { - "name": "SI" - }, - "user_defined_fields": [ - { - "expression": "Sidewash = atan(primitiveVars[1]/abs(primitiveVars[0]))", - "name": "Sidewash", - "type_name": "UserDefinedField" - }, - { - "expression": "Upwash = atan(primitiveVars[2]/abs(primitiveVars[0]))", - "name": "Upwash", - "type_name": "UserDefinedField" - }, - { - "expression": "double vorticity[3];vorticity[0] = gradPrimitive[3][1] - gradPrimitive[2][2];vorticity[1] = gradPrimitive[1][2] - gradPrimitive[3][0];vorticity[2] = gradPrimitive[2][0] - gradPrimitive[1][1];double velocity[3];velocity[0] = primitiveVars[1];velocity[1] = primitiveVars[2];velocity[2] = primitiveVars[3];Helicity = dot(velocity, vorticity);", - "name": "Helicity", - "type_name": "UserDefinedField" - } - ], - "version": "25.7.6b0" -} diff --git a/tests/simulation/params/test_validators_output.py b/tests/simulation/params/test_validators_output.py deleted file mode 100644 index e0428aa40..000000000 --- a/tests/simulation/params/test_validators_output.py +++ /dev/null @@ -1,121 +0,0 @@ -import json -import os - -from flow360.component.simulation.framework.param_utils import AssetCache -from flow360.component.simulation.models.surface_models import Wall -from flow360.component.simulation.models.volume_models import Fluid -from flow360.component.simulation.outputs.outputs import SurfaceOutput, VolumeOutput -from flow360.component.simulation.services import ValidationCalledBy, validate_model -from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.time_stepping.time_stepping import Steady -from flow360.component.simulation.unit_system import imperial_unit_system -from flow360.component.volume_mesh import VolumeMeshV2 - - -def test_output_frequency_settings_in_steady_simulation(): - volume_mesh = VolumeMeshV2.from_local_storage( - mesh_id=None, - local_storage_path=os.path.join( - os.path.dirname(__file__), - "..", - "data", - "vm_entity_provider", - ), - ) - simulation_path = os.path.join( - os.path.dirname(__file__), - "..", - "data", - "vm_entity_provider", - "simulation.json", - ) - with open(simulation_path, "r") as file: - asset_cache_data = json.load(file).pop("private_attribute_asset_cache") - asset_cache = AssetCache.deserialize(asset_cache_data) - with imperial_unit_system: - params = SimulationParams( - models=[Wall(name="wall", entities=volume_mesh["*"])], - time_stepping=Steady(), - outputs=[ - VolumeOutput(output_fields=["Mach", "Cp"], frequency=2), - SurfaceOutput( - output_fields=["Cp"], - entities=volume_mesh["*"], - frequency_offset=10, - ), - ], - private_attribute_asset_cache=asset_cache, - ) - - params_as_dict = params.model_dump(exclude_none=True, mode="json") - _, errors, _ = validate_model( - params_as_dict=params_as_dict, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - validation_level="All", - ) - - expected_errors = [ - { - "loc": ("outputs", 0, "frequency"), - "type": "value_error", - "msg": "Value error, Output frequency cannot be specified in a steady simulation.", - "ctx": {"relevant_for": ["Case"]}, - }, - { - "loc": ("outputs", 1, "frequency_offset"), - "type": "value_error", - "msg": "Value error, Output frequency_offset cannot be specified in a steady simulation.", - "ctx": {"relevant_for": ["Case"]}, - }, - ] - assert len(errors) == len(expected_errors) - for error, expected in zip(errors, expected_errors): - assert error["loc"] == expected["loc"] - assert error["type"] == expected["type"] - assert error["msg"] == expected["msg"] - assert error["ctx"]["relevant_for"] == expected["ctx"]["relevant_for"] - - -def test_force_output_with_model_id(): - simulation_path = os.path.join( - os.path.dirname(__file__), - "data", - "simulation_force_output_webui.json", - ) - with open(simulation_path, "r") as file: - data = json.load(file) - - _, errors, _ = validate_model( - params_as_dict=data, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - ) - expected_errors = [ - { - "type": "value_error", - "loc": ("outputs", 3, "models"), - "msg": "Value error, Duplicate models are not allowed in the same `ForceOutput`.", - "ctx": {"relevant_for": ["Case"]}, - }, - { - "type": "value_error", - "loc": ("outputs", 4, "models"), - "msg": "Value error, When ActuatorDisk/BETDisk/PorousMedium is specified, " - "only CL, CD, CFx, CFy, CFz, CMx, CMy, CMz can be set as output_fields.", - "ctx": {"relevant_for": ["Case"]}, - }, - { - "type": "value_error", - "loc": ("outputs", 5, "models"), - "msg": "Value error, The model does not exist in simulation params' models list.", - "ctx": {"relevant_for": ["Case"]}, - }, - ] - - assert len(errors) == len(expected_errors) - for error, expected in zip(errors, expected_errors): - assert error["loc"] == expected["loc"] - assert error["type"] == expected["type"] - assert error["ctx"]["relevant_for"] == expected["ctx"]["relevant_for"] - assert error["msg"] == expected["msg"] diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 2e8263214..0d364c1ce 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -1079,221 +1079,6 @@ def test_output_fields_with_time_average_output(): params.time_stepping = Steady(max_steps=1000) -def test_wall_deserialization(): - # Wall->velocity accept discriminated AND non-discriminated unions. - # Need to check if all works when deserializing. - dummy_boundary = Surface(name="chameleon") - with DeserializationContext(): - simple_wall = Wall(**Wall(entities=dummy_boundary).model_dump(mode="json")) - assert simple_wall.velocity is None - - with DeserializationContext(): - const_vel_wall = Wall( - **Wall(entities=dummy_boundary, velocity=[1, 2, 3] * u.m / u.s).model_dump(mode="json") - ) - assert all(const_vel_wall.velocity == [1, 2, 3] * u.m / u.s) - - with DeserializationContext(): - slater_bleed_wall = Wall( - **Wall( - entities=dummy_boundary, - velocity=SlaterPorousBleed(porosity=0.2, static_pressure=0.1 * u.Pa), - ).model_dump(mode="json") - ) - assert slater_bleed_wall.velocity.porosity == 0.2 - assert slater_bleed_wall.velocity.static_pressure == 0.1 * u.Pa - - -def test_populate_validated_models_to_validation_context(mock_validation_context): - """Test that models are properly populated to validation context.""" - # Create models with private_attribute_id - fluid_model = Fluid() - wall_model = Wall( - name="wall_bc", - surfaces=[Surface(name="wall_surface")], - ) - - # Before validation, physics_model_dict should be None - assert mock_validation_context.info.physics_model_dict is None - - with SI_unit_system, mock_validation_context: - params = SimulationParams( - models=[fluid_model, wall_model], - ) - - # After validation, physics_model_dict should be populated - assert mock_validation_context.info.physics_model_dict is not None - assert isinstance(mock_validation_context.info.physics_model_dict, dict) - - # Check that models are in the dict with their IDs as keys - assert len(mock_validation_context.info.physics_model_dict) == 2 - assert fluid_model.private_attribute_id in mock_validation_context.info.physics_model_dict - assert wall_model.private_attribute_id in mock_validation_context.info.physics_model_dict - - # Verify the objects are the same - assert ( - mock_validation_context.info.physics_model_dict[fluid_model.private_attribute_id] - == fluid_model - ) - assert ( - mock_validation_context.info.physics_model_dict[wall_model.private_attribute_id] - == wall_model - ) - - -def test_populate_validated_outputs_to_validation_context(mock_validation_context): - """Test that outputs are properly populated to validation context.""" - # Create outputs with private_attribute_id - probe_output = ProbeOutput( - name="probe1", - output_fields=["Cp"], - probe_points=[Point(name="pt1", location=(1, 2, 3) * u.m)], - ) - - surface_output = SurfaceOutput( - name="surface1", - output_fields=["Cp"], - entities=[Surface(name="wall")], - ) - - volume_output = VolumeOutput( - name="volume1", - output_fields=["primitiveVars"], - ) - - # Before validation, output_dict should be None - assert mock_validation_context.info.output_dict is None - - with SI_unit_system, mock_validation_context: - params = SimulationParams( - outputs=[probe_output, surface_output, volume_output], - ) - - # After validation, output_dict should be populated - assert mock_validation_context.info.output_dict is not None - assert isinstance(mock_validation_context.info.output_dict, dict) - - # Check that outputs are in the dict with their IDs as keys - assert len(mock_validation_context.info.output_dict) == 3 - assert probe_output.private_attribute_id in mock_validation_context.info.output_dict - assert surface_output.private_attribute_id in mock_validation_context.info.output_dict - assert volume_output.private_attribute_id in mock_validation_context.info.output_dict - - # Verify the objects are the same - assert ( - mock_validation_context.info.output_dict[probe_output.private_attribute_id] == probe_output - ) - assert ( - mock_validation_context.info.output_dict[surface_output.private_attribute_id] - == surface_output - ) - assert ( - mock_validation_context.info.output_dict[volume_output.private_attribute_id] - == volume_output - ) - - -def test_populate_both_models_and_outputs_to_validation_context(mock_validation_context): - """Test that both models and outputs are properly populated to the same validation context.""" - # Create models and outputs - fluid_model = Fluid() - probe_output = ProbeOutput( - name="probe1", - output_fields=["Cp"], - probe_points=[Point(name="pt1", location=(1, 2, 3) * u.m)], - ) - - # Before validation, both should be None - assert mock_validation_context.info.physics_model_dict is None - assert mock_validation_context.info.output_dict is None - - with SI_unit_system, mock_validation_context: - params = SimulationParams( - models=[fluid_model], - outputs=[probe_output], - ) - - # After validation, both should be populated - assert mock_validation_context.info.physics_model_dict is not None - assert mock_validation_context.info.output_dict is not None - - # Verify both dicts are populated correctly - assert fluid_model.private_attribute_id in mock_validation_context.info.physics_model_dict - assert probe_output.private_attribute_id in mock_validation_context.info.output_dict - - assert ( - mock_validation_context.info.physics_model_dict[fluid_model.private_attribute_id] - == fluid_model - ) - assert ( - mock_validation_context.info.output_dict[probe_output.private_attribute_id] == probe_output - ) - - -def test_populate_outputs_none_sets_empty_dict(mock_validation_context): - """Test that output_dict is set to {} when outputs=None. - - This distinguishes successful validation with no outputs (output_dict={}) - from validation errors (output_dict=None). - """ - assert mock_validation_context.info.output_dict is None - - with SI_unit_system, mock_validation_context: - params = SimulationParams(outputs=None) - - # output_dict should be set to empty dict, not None - assert mock_validation_context.info.output_dict == {} - - -def test_populate_outputs_empty_list_sets_empty_dict(mock_validation_context): - """Test that output_dict is set to {} when outputs=[].""" - assert mock_validation_context.info.output_dict is None - - with SI_unit_system, mock_validation_context: - params = SimulationParams(outputs=[]) - - # output_dict should be set to empty dict - assert mock_validation_context.info.output_dict == {} - - -def test_populate_models_none_sets_dict_with_default(mock_validation_context): - """Test that physics_model_dict is populated when models=None. - - Note: SimulationParams automatically adds a default Fluid model when models=None, - so physics_model_dict will contain the default model, not be empty. - This still distinguishes successful validation from validation errors (physics_model_dict=None). - """ - assert mock_validation_context.info.physics_model_dict is None - - with SI_unit_system, mock_validation_context: - params = SimulationParams(models=None) - - # physics_model_dict should be populated with default Fluid model - assert mock_validation_context.info.physics_model_dict is not None - assert isinstance(mock_validation_context.info.physics_model_dict, dict) - # Should contain the default fluid model - assert len(mock_validation_context.info.physics_model_dict) == 1 - assert "__default_fluid" in mock_validation_context.info.physics_model_dict - - -def test_populate_models_empty_list_sets_dict_with_default(mock_validation_context): - """Test that physics_model_dict is populated when models=[]. - - Note: SimulationParams automatically adds a default Fluid model when models=[], - so physics_model_dict will contain the default model. - """ - assert mock_validation_context.info.physics_model_dict is None - - with SI_unit_system, mock_validation_context: - params = SimulationParams(models=[]) - - # physics_model_dict should be populated with default Fluid model - assert mock_validation_context.info.physics_model_dict is not None - assert isinstance(mock_validation_context.info.physics_model_dict, dict) - assert len(mock_validation_context.info.physics_model_dict) == 1 - assert "__default_fluid" in mock_validation_context.info.physics_model_dict - - @pytest.fixture(autouse=True) def change_test_dir(request, monkeypatch): monkeypatch.chdir(request.fspath.dirname) @@ -1978,30 +1763,6 @@ def test_beta_mesher_only_features(mock_validation_context): assert errors[0]["loc"] == () -def test_edge_split_layers_default_no_warning_for_dict_input(): - non_beta_context = ParamsValidationInfo({}, []) - non_beta_context.is_beta_mesher = False - non_beta_context.project_length_unit = 1 * u.m - - with SI_unit_system, ValidationContext(VOLUME_MESH, non_beta_context) as validation_context: - defaults = MeshingDefaults.model_validate({"boundary_layer_first_layer_thickness": 1e-4}) - - assert "edge_split_layers" not in defaults.model_fields_set - assert validation_context.validation_warnings == [] - - -def test_edge_split_layers_default_no_warning_for_constructor_input(): - non_beta_context = ParamsValidationInfo({}, []) - non_beta_context.is_beta_mesher = False - non_beta_context.project_length_unit = 1 * u.m - - with SI_unit_system, ValidationContext(VOLUME_MESH, non_beta_context) as validation_context: - defaults = MeshingDefaults(boundary_layer_first_layer_thickness=1e-4) - - assert "edge_split_layers" not in defaults.model_fields_set - assert validation_context.validation_warnings == [] - - def test_geometry_AI_only_features(): with SI_unit_system: params = SimulationParams( From 816c0a057b32b5a209dcf6fc7f95d60b31a0d74a Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 17 Apr 2026 14:05:19 -0400 Subject: [PATCH 25/25] Remove more migrated simulation params test duplicates --- .../params/test_validators_params.py | 333 ------------------ 1 file changed, 333 deletions(-) diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 0d364c1ce..94aafdc8a 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -866,157 +866,6 @@ def test_output_fields_with_user_defined_fields(): ) -def test_rotation_parent_volumes(mock_case_validation_context): - - c_1 = Cylinder( - name="inner_rotating_cylinder", - outer_radius=1 * u.cm, - height=1 * u.cm, - center=(0, 0, 0) * u.cm, - axis=(0, 0, 1), - ) - - c_2 = Cylinder( - name="outer_rotating_cylinder", - outer_radius=12 * u.cm, - height=12 * u.cm, - center=(0, 0, 0) * u.cm, - axis=(0, 0, 1), - ) - - c_3 = Cylinder( - name="stationary_cylinder", - outer_radius=12 * u.m, - height=13 * u.m, - center=(0, 0, 0) * u.m, - axis=(0, 1, 2), - ) - - my_wall = Surface(name="my_wall", private_attribute_is_interface=False) - - msg = "For model #1, the parent rotating volume (stationary_cylinder) is not " - "used in any other `Rotation` model's `volumes`." - with mock_case_validation_context, pytest.raises(ValueError, match=re.escape(msg)): - with SI_unit_system: - SimulationParams( - models=[ - Fluid(), - Rotation(entities=[c_1], spec=AngleExpression("1+2"), parent_volume=c_3), - ] - ) - - with ValidationContext(CASE): - with SI_unit_system: - SimulationParams( - models=[ - Fluid(), - Rotation(entities=[c_1], spec=AngleExpression("1+2"), parent_volume=c_2), - Rotation(entities=[c_2], spec=AngleExpression("1+5")), - Wall(entities=[my_wall]), - ], - private_attribute_asset_cache=AssetCache( - project_length_unit=1 * u.cm, - project_entity_info=VolumeMeshEntityInfo(boundaries=[my_wall]), - ), - ) - - -def test_meshing_validator_dual_context(): - errors = None - try: - with SI_unit_system: - with ValidationContext(VOLUME_MESH): - SimulationParams(meshing=None) - except pd.ValidationError as err: - errors = err.errors() - assert len(errors) == 1 - assert errors[0]["type"] == "missing" - assert errors[0]["ctx"] == {"relevant_for": ["SurfaceMesh", "VolumeMesh"]} - assert errors[0]["loc"] == ("meshing",) - - -def test_rotating_reference_frame_model_flag(): - - c_1 = Cylinder( - name="inner_rotating_cylinder", - outer_radius=1 * u.cm, - height=1 * u.cm, - center=(0, 0, 0) * u.cm, - axis=(0, 0, 1), - ) - - c_2 = Cylinder( - name="outer_rotating_cylinder", - outer_radius=12 * u.cm, - height=12 * u.cm, - center=(0, 0, 0) * u.cm, - axis=(0, 0, 1), - ) - - c_3 = Cylinder( - name="another_cylinder", - outer_radius=12 * u.m, - height=13 * u.m, - center=(0, 0, 0) * u.m, - axis=(0, 1, 2), - ) - - my_wall = Surface(name="my_wall", private_attribute_is_interface=False) - timestepping_unsteady = Unsteady(steps=12, step_size=0.1 * u.s) - timestepping_steady = Steady(max_steps=1000) - - msg = "For model #1, the rotating_reference_frame_model may not be set to False for " - "steady state simulations." - - with pytest.raises(ValueError, match=re.escape(msg)): - with ValidationContext(CASE): - with SI_unit_system: - SimulationParams( - models=[ - Fluid(), - Rotation( - entities=[c_1], - spec=AngleExpression("1+2"), - rotating_reference_frame_model=False, - ), - Wall(entities=[my_wall]), - ], - time_stepping=timestepping_steady, - private_attribute_asset_cache=AssetCache( - project_length_unit=1 * u.cm, - project_entity_info=VolumeMeshEntityInfo(boundaries=[my_wall]), - ), - ) - - with ValidationContext(CASE): - with SI_unit_system: - test_param = SimulationParams( - models=[ - Fluid(), - Rotation( - entities=[c_1], - spec=AngleExpression("1+2"), - parent_volume=c_2, - rotating_reference_frame_model=True, - ), - Rotation( - entities=[c_2], - spec=AngleExpression("1+5"), - rotating_reference_frame_model=False, - ), - Rotation(entities=[c_3], spec=AngleExpression("3+5")), - Wall(entities=[my_wall]), - ], - time_stepping=timestepping_unsteady, - private_attribute_asset_cache=AssetCache( - project_length_unit=1 * u.cm, - project_entity_info=VolumeMeshEntityInfo(boundaries=[my_wall]), - ), - ) - - assert test_param.models[3].rotating_reference_frame_model == False - - def test_output_fields_with_time_average_output(): # Valid simulation params @@ -2330,97 +2179,6 @@ def test_deleted_surfaces_domain_type(): assert "Boundary `pos_surf` will likely be deleted" in errors[0]["msg"] -def test_unique_selector_names(): - """Test that duplicate selector names are detected and raise an error.""" - from flow360.component.simulation.framework.entity_selector import ( - SurfaceSelector, - collect_and_tokenize_selectors_in_place, - ) - from flow360.component.simulation.models.surface_models import Wall - from flow360.component.simulation.primitives import Surface - - # Create actual Surface entities to avoid selector expansion issues - surface1 = Surface(name="surface1") - surface2 = Surface(name="surface2") - - # Create selectors with duplicate names - selector1 = SurfaceSelector(name="duplicate_name").match("wing*") - selector2 = SurfaceSelector(name="duplicate_name").match("tail*") - - # Test duplicate selector names in different EntityLists (different Wall models) - with SI_unit_system: - params = SimulationParams( - models=[ - Wall(entities=[surface1, selector1]), - Wall(entities=[surface2, selector2]), - ], - ) - - # Tokenize selectors to populate used_selectors (simulating what happens in set_up_params_for_uploading) - params_dict = params.model_dump(mode="json", exclude_none=True) - params_dict = collect_and_tokenize_selectors_in_place(params_dict) - - # Now validate using validate_model which will materialize and validate - _, errors, _ = validate_model( - params_as_dict=params_dict, - validated_by=ValidationCalledBy.LOCAL, - root_item_type=None, - validation_level=None, - ) - - assert errors is not None - assert len(errors) == 1 - assert "Duplicate selector name 'duplicate_name'" in errors[0]["msg"] - - # Test duplicate selector names in the same EntityList - with SI_unit_system: - params2 = SimulationParams( - models=[ - Wall(entities=[surface1, selector1, selector2]), - ], - ) - - params_dict2 = params2.model_dump(mode="json", exclude_none=True) - params_dict2 = collect_and_tokenize_selectors_in_place(params_dict2) - - _, errors2, _ = validate_model( - params_as_dict=params_dict2, - validated_by=ValidationCalledBy.LOCAL, - root_item_type=None, - validation_level=None, - ) - - assert errors2 is not None - assert len(errors2) == 1 - assert "Duplicate selector name 'duplicate_name'" in errors2[0]["msg"] - - # Test that unique selector names work fine - selector3 = SurfaceSelector(name="unique_name_1").match("wing*") - selector4 = SurfaceSelector(name="unique_name_2").match("tail*") - - with SI_unit_system: - params3 = SimulationParams( - models=[ - Wall(entities=[surface1, selector3]), - Wall(entities=[surface2, selector4]), - ], - ) - - params_dict3 = params3.model_dump(mode="json", exclude_none=True) - params_dict3 = collect_and_tokenize_selectors_in_place(params_dict3) - - validated_params, errors3, _ = validate_model( - params_as_dict=params_dict3, - validated_by=ValidationCalledBy.LOCAL, - root_item_type=None, - validation_level=None, - ) - - # Should not have errors for unique names - assert errors3 is None or len(errors3) == 0 - assert validated_params is not None - - def test_coordinate_system_requires_geometry_ai(): """Test that CoordinateSystem is only supported when Geometry AI is enabled.""" # Create a CoordinateSystemStatus with assignments @@ -2699,97 +2457,6 @@ def test_domain_type_bbox_mismatch_downgraded_to_warning_when_transformed(): ), warnings -def test_incomplete_BC_with_geometry_AI(): - """Test that missing boundary conditions produce warnings (not errors) when using Geometry AI.""" - # Construct a dummy asset cache with GAI enabled - wall = Surface(name="wall", private_attribute_is_interface=False, private_attribute_id="wall") - no_bc = Surface( - name="no_bc", private_attribute_is_interface=False, private_attribute_id="no_bc" - ) - - asset_cache = AssetCache( - project_length_unit=1 * u.m, - project_entity_info=VolumeMeshEntityInfo(boundaries=[wall, no_bc]), - use_geometry_AI=True, # Enable GAI - ) - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=1e-10, - surface_max_edge_length=1e-10, - ) - ), - models=[ - Fluid(), - Wall(entities=[wall]), - # no_bc is intentionally missing - ], - private_attribute_asset_cache=asset_cache, - ) - - params, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - validation_level="All", - ) - - # Should not have errors, only warnings - assert errors is None or len(errors) == 0 - assert len(warnings) > 0 - assert any( - "no_bc" in w.get("msg", "") and "do not have a boundary condition" in w.get("msg", "") - for w in warnings - ), f"Expected warning about missing boundary condition for 'no_bc', got: {warnings}" - - -def test_incomplete_BC_without_geometry_AI(): - """Test that missing boundary conditions produce errors when NOT using Geometry AI.""" - # Construct a dummy asset cache without GAI - wall = Surface(name="wall", private_attribute_is_interface=False, private_attribute_id="wall") - no_bc = Surface( - name="no_bc", private_attribute_is_interface=False, private_attribute_id="no_bc" - ) - - asset_cache = AssetCache( - project_length_unit=1 * u.m, - project_entity_info=VolumeMeshEntityInfo(boundaries=[wall, no_bc]), - use_geometry_AI=False, # Disable GAI - ) - - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=1e-10, - surface_max_edge_length=1e-10, - ) - ), - models=[ - Fluid(), - Wall(entities=[wall]), - # no_bc is intentionally missing - ], - private_attribute_asset_cache=asset_cache, - ) - - params, errors, warnings = validate_model( - params_as_dict=params.model_dump(mode="json"), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - validation_level="All", - ) - - # Should have errors - assert len(errors) == 1 - assert errors[0]["msg"] == ( - "Value error, The following boundaries do not have a boundary condition: no_bc. " - "Please add them to a boundary condition model in the `models` section." - ) - - def test_automated_farfield_with_custom_zones(): """AutomatedFarfield + CustomZones: enclosed_entities is required when CustomVolumes exist."""