Skip to content

Commit 2621c32

Browse files
AI reviewed and modified
1 parent 22fe823 commit 2621c32

7 files changed

Lines changed: 207 additions & 71 deletions

File tree

flow360/component/simulation/outputs/outputs.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@
9292

9393
def _validate_filename_string(value: str) -> str:
9494
"""
95-
Validate that a string is a valid Linux filename.
95+
Validate that a string is safe to use as (or inside) an output filename.
9696
9797
Args:
9898
value: The string to validate
@@ -101,7 +101,7 @@ def _validate_filename_string(value: str) -> str:
101101
The validated string
102102
103103
Raises:
104-
ValueError: If the string is not a valid filename
104+
ValueError: If the string is not safe for use in output filenames
105105
106106
Notes:
107107
- Disallows forward slash (/) - path separator
@@ -501,6 +501,7 @@ class SurfaceOutput(_AnimationAndFileFormatSettings, _OutputBase):
501501
@property
502502
def has_default_name(self) -> bool:
503503
"""Whether this output has no custom name assigned."""
504+
# pylint: disable=unsubscriptable-object
504505
return self.name == type(self).model_fields["name"].default
505506

506507
@contextual_field_validator("entities", mode="after")

flow360/component/simulation/simulation_params.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,12 @@
120120
_check_duplicate_actuator_disk_cylinder_names,
121121
_check_duplicate_entities_in_models,
122122
_check_duplicate_isosurface_names,
123-
_check_duplicate_surface_usage_in_surface_output,
124123
_check_hybrid_model_to_use_zonal_enforcement,
125124
_check_krylov_solver_restrictions,
126125
_check_low_mach_preconditioner_output,
127126
_check_numerical_dissipation_factor_output,
128127
_check_parent_volume_is_rotating,
128+
_check_surface_output_naming,
129129
_check_time_average_output,
130130
_check_tpg_not_with_isentropic_solver,
131131
_check_unique_selector_names,
@@ -508,8 +508,9 @@ def check_duplicate_isosurface_names(cls, outputs):
508508
@contextual_field_validator("outputs", mode="after")
509509
@classmethod
510510
def check_duplicate_surface_usage(cls, outputs, param_info: ParamsValidationInfo):
511-
"""Require unique non-default names when the same surface appears in multiple outputs"""
512-
return _check_duplicate_surface_usage_in_surface_output(outputs, param_info)
511+
"""Validate surface output naming: unique names for outputs sharing a
512+
surface, and unique file-suffixes for outputs with ``write_single_file=True``."""
513+
return _check_surface_output_naming(outputs, param_info)
513514

514515
@contextual_field_validator("outputs", mode="after")
515516
@classmethod

flow360/component/simulation/translator/solver_translator.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,9 +609,10 @@ def translate_surface_output(
609609
"""Translate surface output settings.
610610
611611
Returns a list of per-instance solver config dicts (one per SurfaceOutput instance).
612+
``translated`` is required only as a precondition guard: boundaries must already
613+
be translated before surface outputs so downstream lookups resolve correctly.
612614
"""
613-
614-
assert "boundaries" in translated # "Boundaries must be translated before surface output"
615+
assert "boundaries" in translated, "Boundaries must be translated before surface output"
615616

616617
return [
617618
_translate_single_surface_output(obj, surface_output_class)

flow360/component/simulation/validation/validation_simulation_params.py

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -731,16 +731,24 @@ def _check_duplicate_isosurface_names(outputs):
731731
return outputs
732732

733733

734-
def _check_duplicate_surface_usage_in_surface_output(outputs, param_info: ParamsValidationInfo):
735-
"""Validate surface output naming:
736-
1. When the same surface appears in multiple outputs of the same type,
737-
all those outputs must have unique, non-default names.
738-
2. Across all instances of the same type, no two custom (non-default)
739-
names may be identical (prevents writeSingleFile filename collisions)."""
734+
def _check_surface_output_naming(outputs, param_info: ParamsValidationInfo):
735+
"""Validate ``SurfaceOutput`` / ``TimeAverageSurfaceOutput`` naming.
736+
737+
Rule 1 (shared-surface uniqueness): When the same surface appears in multiple
738+
instances of the same output type, every sharing instance must carry a unique,
739+
non-default ``name``.
740+
741+
Rule 2 (write-single-file uniqueness): Among instances of the same output type
742+
with ``write_single_file=True``, no two may resolve to the same solver filename
743+
suffix. The solver assigns ``_<name>`` for custom names and ``_<index>`` for
744+
default-named outputs when more than one instance exists, so Rule 2 catches
745+
both duplicate custom names and the edge case where a custom name equals an
746+
index that a default-named peer would receive.
747+
"""
740748
if outputs is None:
741749
return outputs
742750

743-
def _check_surface_usage(
751+
def _check_shared_surface_uniqueness(
744752
outputs, output_type: Union[Type[SurfaceOutput], Type[TimeAverageSurfaceOutput]]
745753
):
746754
surface_to_outputs: dict[str, list[SurfaceOutput]] = {}
@@ -768,29 +776,40 @@ def _check_surface_usage(
768776
"output instance that shares the same surface."
769777
)
770778

771-
def _check_write_single_file_name_uniqueness(
779+
def _check_write_single_file_suffix_uniqueness(
772780
outputs, output_type: Union[Type[SurfaceOutput], Type[TimeAverageSurfaceOutput]]
773781
):
774-
single_file_names = [
775-
o.name
776-
for o in outputs
777-
if is_exact_instance(o, output_type)
778-
and o.write_single_file
779-
and not o.has_default_name
780-
]
781-
if len(single_file_names) != len(set(single_file_names)):
782-
seen = set()
783-
dupes = sorted({n for n in single_file_names if n in seen or seen.add(n)})
782+
instances = [o for o in outputs if is_exact_instance(o, output_type)]
783+
if not any(o.write_single_file for o in instances):
784+
return
785+
786+
# Mirror the translator's sort key (empty strings first, then by name)
787+
# so the indices line up with what the solver will see.
788+
sorted_instances = sorted(instances, key=lambda o: "" if o.has_default_name else o.name)
789+
790+
suffix_to_outputs: dict[str, list] = {}
791+
for index, output in enumerate(sorted_instances):
792+
if output.has_default_name:
793+
# Solver fallback: only append an index when the array has more than one entry.
794+
suffix = f"_{index}" if len(sorted_instances) > 1 else ""
795+
else:
796+
suffix = f"_{output.name}"
797+
if output.write_single_file:
798+
suffix_to_outputs.setdefault(suffix, []).append(output)
799+
800+
colliding = sorted(s for s, outs in suffix_to_outputs.items() if len(outs) > 1)
801+
if colliding:
784802
raise ValueError(
785803
f"Multiple `{output_type.__name__}` instances with `write_single_file=True` "
786-
f"share the same name: {dupes}. When single-file output is enabled, "
787-
"each instance must have a unique name to avoid filename collisions."
804+
f"resolve to the same output filename suffix(es): {colliding}. "
805+
"Pick unique non-default names, and avoid names that look like an array "
806+
"index of a default-named peer (e.g. `0`, `1`)."
788807
)
789808

790-
_check_surface_usage(outputs, SurfaceOutput)
791-
_check_surface_usage(outputs, TimeAverageSurfaceOutput)
792-
_check_write_single_file_name_uniqueness(outputs, SurfaceOutput)
793-
_check_write_single_file_name_uniqueness(outputs, TimeAverageSurfaceOutput)
809+
_check_shared_surface_uniqueness(outputs, SurfaceOutput)
810+
_check_shared_surface_uniqueness(outputs, TimeAverageSurfaceOutput)
811+
_check_write_single_file_suffix_uniqueness(outputs, SurfaceOutput)
812+
_check_write_single_file_suffix_uniqueness(outputs, TimeAverageSurfaceOutput)
794813

795814
return outputs
796815

surface-output-scenarios.md

Lines changed: 0 additions & 39 deletions
This file was deleted.

tests/simulation/params/test_validators_output.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ def test_duplicate_surface_usage(mock_validation_context):
447447
# Same custom name on different surfaces WITH writeSingleFile should fail
448448
with (
449449
mock_validation_context,
450-
pytest.raises(ValueError, match=re.escape("write_single_file=True")),
450+
pytest.raises(ValueError, match="resolve to the same output filename suffix"),
451451
):
452452
with imperial_unit_system:
453453
SimulationParams(
@@ -572,6 +572,137 @@ def test_surface_output_name_invalid_filename():
572572
)
573573

574574

575+
def test_has_default_name_property():
576+
"""``has_default_name`` correctly identifies outputs using the type-specific default."""
577+
default_surface = SurfaceOutput(
578+
entities=Surface(name="fluid/body"),
579+
output_fields=["Cp"],
580+
)
581+
named_surface = SurfaceOutput(
582+
name="custom",
583+
entities=Surface(name="fluid/body"),
584+
output_fields=["Cp"],
585+
)
586+
default_time_avg = TimeAverageSurfaceOutput(
587+
entities=Surface(name="fluid/body"),
588+
output_fields=["Cp"],
589+
)
590+
named_time_avg = TimeAverageSurfaceOutput(
591+
name="custom",
592+
entities=Surface(name="fluid/body"),
593+
output_fields=["Cp"],
594+
)
595+
assert default_surface.has_default_name is True
596+
assert named_surface.has_default_name is False
597+
assert default_time_avg.has_default_name is True
598+
assert named_time_avg.has_default_name is False
599+
600+
# Setting a SurfaceOutput's name to the TimeAverageSurfaceOutput default
601+
# should NOT count as default — the property must consult the subclass default.
602+
surface_with_timeavg_default = SurfaceOutput(
603+
name="Time average surface output",
604+
entities=Surface(name="fluid/body"),
605+
output_fields=["Cp"],
606+
)
607+
assert surface_with_timeavg_default.has_default_name is False
608+
609+
610+
def test_write_single_file_time_average_surface_output(mock_validation_context):
611+
"""Rule 2 also applies to TimeAverageSurfaceOutput."""
612+
with (
613+
mock_validation_context,
614+
pytest.raises(ValueError, match="resolve to the same output filename suffix"),
615+
):
616+
with imperial_unit_system:
617+
SimulationParams(
618+
outputs=[
619+
TimeAverageSurfaceOutput(
620+
name="shared",
621+
entities=Surface(name="fluid/wing"),
622+
output_fields=["Cp"],
623+
write_single_file=True,
624+
),
625+
TimeAverageSurfaceOutput(
626+
name="shared",
627+
entities=Surface(name="fluid/tail"),
628+
output_fields=["Cp"],
629+
write_single_file=True,
630+
),
631+
],
632+
time_stepping=Unsteady(steps=10, step_size=1e-3),
633+
)
634+
635+
636+
def test_write_single_file_default_plus_numeric_name_collision(mock_validation_context):
637+
"""Edge case: default-named output + custom-named ``"0"`` both with
638+
``write_single_file=True`` would collide at the solver's ``multiPatchDataMap``
639+
because the translator sorts defaults first (index 0 → suffix ``_0``) and the
640+
custom ``"0"`` name also produces suffix ``_0``."""
641+
with (
642+
mock_validation_context,
643+
pytest.raises(ValueError, match="resolve to the same output filename suffix"),
644+
):
645+
with imperial_unit_system:
646+
SimulationParams(
647+
outputs=[
648+
SurfaceOutput(
649+
entities=Surface(name="fluid/wing"),
650+
output_fields=["Cp"],
651+
write_single_file=True,
652+
),
653+
SurfaceOutput(
654+
name="0",
655+
entities=Surface(name="fluid/tail"),
656+
output_fields=["Cp"],
657+
write_single_file=True,
658+
),
659+
],
660+
)
661+
662+
663+
def test_write_single_file_asymmetric_flag_with_same_name(mock_validation_context):
664+
"""Same custom name is allowed when only one output has ``write_single_file=True``
665+
(only that one contributes to the single-file map)."""
666+
with mock_validation_context, imperial_unit_system:
667+
SimulationParams(
668+
outputs=[
669+
SurfaceOutput(
670+
name="shared",
671+
entities=Surface(name="fluid/wing"),
672+
output_fields=["Cp"],
673+
write_single_file=True,
674+
),
675+
SurfaceOutput(
676+
name="shared",
677+
entities=Surface(name="fluid/tail"),
678+
output_fields=["Cp"],
679+
write_single_file=False,
680+
),
681+
],
682+
)
683+
684+
685+
def test_write_single_file_default_names_on_disjoint_surfaces(mock_validation_context):
686+
"""Two default-named SurfaceOutputs on disjoint surfaces with
687+
``write_single_file=True`` are allowed — the solver assigns distinct
688+
index-based suffixes ``_0`` and ``_1``."""
689+
with mock_validation_context, imperial_unit_system:
690+
SimulationParams(
691+
outputs=[
692+
SurfaceOutput(
693+
entities=Surface(name="fluid/wing"),
694+
output_fields=["Cp"],
695+
write_single_file=True,
696+
),
697+
SurfaceOutput(
698+
entities=Surface(name="fluid/tail"),
699+
output_fields=["Cp"],
700+
write_single_file=True,
701+
),
702+
],
703+
)
704+
705+
575706
def test_check_moving_statistic_applicability_steady_valid():
576707
"""Test moving_statistic with steady simulation - valid case."""
577708
wall_1 = Wall(entities=Surface(name="fluid/wing"))

tests/simulation/translator/test_output_translation.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,28 @@ def test_single_surface_output_emits_array():
447447
assert "wing" in translated["surfaceOutput"][0]["surfaces"]
448448

449449

450+
def test_single_time_average_surface_output_emits_array():
451+
"""A single TimeAverageSurfaceOutput should also produce an array."""
452+
with SI_unit_system:
453+
param = SimulationParams(
454+
time_stepping=Unsteady(step_size=0.1 * u.s, steps=10),
455+
outputs=[
456+
TimeAverageSurfaceOutput(
457+
entities=[Surface(name="wing")],
458+
output_fields=["Cp"],
459+
output_format="paraview",
460+
),
461+
],
462+
)
463+
translated = {"boundaries": {}}
464+
translated = translate_output(param, translated)
465+
466+
assert isinstance(translated["timeAverageSurfaceOutput"], list)
467+
assert len(translated["timeAverageSurfaceOutput"]) == 1
468+
assert translated["timeAverageSurfaceOutput"][0]["name"] == ""
469+
assert "wing" in translated["timeAverageSurfaceOutput"][0]["surfaces"]
470+
471+
450472
@pytest.fixture()
451473
def slice_output_config(vel_in_km_per_hr):
452474
return (

0 commit comments

Comments
 (0)