Skip to content

Commit 0c5ff77

Browse files
authored
Add section cut export methods (#850)
Add methods `export` and `export_becas_input` to the `SectionCut` class. The two export types (CDB / BECAS) are implemented as separate methods to mirror the API, rather than in the same method like in ACP scripting. Combining them would be tricky because their signatures are completely different: - the `path` argument is expected to be a filename for CDB export, and a directory for BECAS export - their effect is also distinct: CDB export creates one file, BECAS export creates multiple - they have distinct _additional_ keyword arguments (this alone would be manageable) To enable the multiple-file export, an additional parameter `is_directory` is added to the `FileTransferStrategy.to_export_path` interface. The `auto_download` context manager cannot be used in the multi-file export context. Its logic is reimplemented / adapted directly in `export_becas_input`. If / once there are multiple such cases, we may want to either extend `auto_download`, or add an alternative context manager which directly supports it. Closes #770.
1 parent 5326a43 commit 0c5ff77

File tree

7 files changed

+206
-6
lines changed

7 files changed

+206
-6
lines changed

doc/source/api/enum_types.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Enumeration data types
4747
RosetteSelectionMethod
4848
RosetteType
4949
SectionCutType
50+
SectionCutCDBExportType
5051
SensorType
5152
SnapToGeometryOrientationType
5253
SolidModelExportFormat

src/ansys/acp/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
RosetteType,
127127
SamplingPoint,
128128
SectionCut,
129+
SectionCutCDBExportType,
129130
SectionCutType,
130131
Sensor,
131132
SensorType,
@@ -259,6 +260,7 @@
259260
"RosetteType",
260261
"SamplingPoint",
261262
"SectionCut",
263+
"SectionCutCDBExportType",
262264
"SectionCutType",
263265
"Sensor",
264266
"SensorType",

src/ansys/acp/core/_server/acp_instance.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def upload_file(self, local_path: _PATH) -> pathlib.PurePath: ...
5151

5252
def download_file(self, remote_path: _PATH, local_path: _PATH) -> None: ...
5353

54-
def to_export_path(self, path: _PATH) -> _PATH: ...
54+
def to_export_path(self, path: _PATH, is_directory: bool = False) -> pathlib.Path: ...
5555

5656

5757
class LocalFileTransferStrategy(FileTransferStrategy):
@@ -70,7 +70,7 @@ def download_file(self, remote_path: _PATH, local_path: _PATH) -> None:
7070
return
7171
shutil.copyfile(remote_path_aslocal, local_path)
7272

73-
def to_export_path(self, path: _PATH) -> _PATH:
73+
def to_export_path(self, path: _PATH, is_directory: bool = False) -> pathlib.Path:
7474
return self._get_remote_path(path)
7575

7676
def _get_remote_path(self, path: _PATH) -> pathlib.Path:
@@ -125,9 +125,11 @@ def download_file(self, remote_path: _PATH, local_path: _PATH) -> None:
125125
remote_filename=str(remote_path), local_filename=str(local_path)
126126
)
127127

128-
def to_export_path(self, path: _PATH) -> _PATH:
128+
def to_export_path(self, path: _PATH, is_directory: bool = False) -> pathlib.Path:
129129
# Export to the working directory of the server
130-
return pathlib.Path(path).name
130+
if is_directory:
131+
return pathlib.Path(".")
132+
return pathlib.Path(pathlib.Path(path).name)
131133

132134

133135
class FileTransferHandler:
@@ -150,9 +152,9 @@ def download_file_if_autotransfer(self, remote_path: _PATH, local_path: _PATH) -
150152
# same as the local path. Otherwise, this is a bug in our code.
151153
assert remote_path == local_path
152154

153-
def to_export_path(self, path: _PATH) -> _PATH:
155+
def to_export_path(self, path: _PATH, is_directory: bool = False) -> _PATH:
154156
if self._auto_transfer_files:
155-
return self._filetransfer_strategy.to_export_path(path)
157+
return self._filetransfer_strategy.to_export_path(path, is_directory=is_directory)
156158
return path
157159

158160
def upload_file(self, local_path: _PATH) -> pathlib.PurePath:

src/ansys/acp/core/_tree_objects/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
ReinforcingBehavior,
7979
RosetteSelectionMethod,
8080
RosetteType,
81+
SectionCutCDBExportType,
8182
SectionCutType,
8283
SensorType,
8384
SnapToGeometryOrientationType,
@@ -290,6 +291,7 @@
290291
"SamplingPoint",
291292
"ScalarData",
292293
"SectionCut",
294+
"SectionCutCDBExportType",
293295
"SectionCutType",
294296
"Sensor",
295297
"SensorType",

src/ansys/acp/core/_tree_objects/enums.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"ReinforcingBehavior",
8181
"RosetteSelectionMethod",
8282
"RosetteType",
83+
"SectionCutCDBExportType",
8384
"SectionCutType",
8485
"SensorType",
8586
"SnapToGeometryOrientationType",
@@ -472,6 +473,13 @@
472473
doc="Determines how the intersection is computed for wireframe section cuts.",
473474
)
474475

476+
SectionCutCDBExportType, section_cut_cdb_export_type_to_pb, _ = wrap_to_string_enum(
477+
"SectionCutCDBExportType",
478+
section_cut_pb2.CDBExportType,
479+
module=__name__,
480+
doc="Determines the type of section cut model to be exported to ANSYS Mechanical APDL.",
481+
)
482+
475483
(ExtrusionMethod, extrusion_method_type_to_pb, extrusion_method_type_from_pb) = wrap_to_string_enum(
476484
"ExtrusionMethod",
477485
solid_model_pb2.ExtrusionMethodType,

src/ansys/acp/core/_tree_objects/section_cut.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,16 @@
2323
from __future__ import annotations
2424

2525
from collections.abc import Sequence
26+
import pathlib
27+
import typing
2628

2729
from ansys.api.acp.v0 import section_cut_pb2, section_cut_pb2_grpc
2830

2931
from .._utils.array_conversions import to_1D_double_array, to_tuple_from_1D_array
32+
from .._utils.path_to_str import path_to_str_checked
3033
from .._utils.property_protocols import ReadOnlyProperty, ReadWriteProperty
34+
from .._utils.typing_helper import PATH as _PATH
35+
from ._grpc_helpers.exceptions import wrap_grpc_errors
3136
from ._grpc_helpers.linked_object_list import define_linked_object_list
3237
from ._grpc_helpers.property_helper import (
3338
grpc_data_property,
@@ -39,11 +44,13 @@
3944
from .enums import (
4045
ExtrusionType,
4146
IntersectionType,
47+
SectionCutCDBExportType,
4248
SectionCutType,
4349
extrusion_type_from_pb,
4450
extrusion_type_to_pb,
4551
intersection_type_from_pb,
4652
intersection_type_to_pb,
53+
section_cut_cdb_export_type_to_pb,
4754
section_cut_type_from_pb,
4855
section_cut_type_to_pb,
4956
status_type_from_pb,
@@ -225,3 +232,81 @@ def _create_stub(self) -> section_cut_pb2_grpc.ObjectServiceStub:
225232
number_of_interpolation_points: ReadWriteProperty[int, int] = grpc_data_property(
226233
"properties.number_of_interpolation_points"
227234
)
235+
236+
def export(
237+
self,
238+
path: _PATH,
239+
*,
240+
export_type: SectionCutCDBExportType = "mesh_only",
241+
) -> None:
242+
"""Export the section cut to a CDB file.
243+
244+
Parameters
245+
----------
246+
path :
247+
Path to the file where the section cut is saved.
248+
export_type :
249+
Determines what is exported to the CDB file. Options are:
250+
251+
- ``"mesh_only"``: Only the mesh (elements and nodes) is exported.
252+
- ``"solid_model"``: The section cut is expanded into a slice of
253+
solid elements. In addition, the material properties are exported
254+
and the element coordinate systems are aligned with the fiber
255+
direction. This model can be used to compute the equivalent
256+
beam properties of the section cut.
257+
258+
"""
259+
with self._server_wrapper.auto_download(path) as export_path:
260+
with wrap_grpc_errors():
261+
self._get_stub().ExportToCDB( # type: ignore
262+
section_cut_pb2.ExportToCDBRequest(
263+
resource_path=self._resource_path,
264+
path=export_path,
265+
export_type=typing.cast(
266+
typing.Any, section_cut_cdb_export_type_to_pb(export_type)
267+
),
268+
)
269+
)
270+
271+
def export_becas_input(
272+
self,
273+
path: _PATH,
274+
*,
275+
export_strength_limits: bool = True,
276+
) -> None:
277+
"""Export the section cut to BECAS input files.
278+
279+
Parameters
280+
----------
281+
path :
282+
Path to the directory where the input files are saved.
283+
export_strength_limits :
284+
Determines whether strength limits are exported to the
285+
BECAS input files.
286+
287+
"""
288+
# The 'auto_download' context manager cannot be directly used here
289+
# because the export path is not a file but a directory. The BECAS
290+
# export produces multiple files, which we all need to download.
291+
# Currently (May '25), this is the only export that produces multiple
292+
# files at once. If there are more, we should either extend 'auto_download',
293+
# or create a new context manager for this purpose.
294+
export_path = pathlib.Path(
295+
self._server_wrapper.filetransfer_handler.to_export_path(path, is_directory=True)
296+
)
297+
expected_filenames = ["E2D.in", "EMAT.in", "MATPROPS.in", "N2D.in"]
298+
if export_strength_limits:
299+
expected_filenames.append("FAILMAT.in")
300+
301+
with wrap_grpc_errors():
302+
self._get_stub().ExportToBECAS( # type: ignore
303+
section_cut_pb2.ExportToBECASRequest(
304+
resource_path=self._resource_path,
305+
path=path_to_str_checked(export_path),
306+
export_strength_limits=export_strength_limits,
307+
)
308+
)
309+
for filename in expected_filenames:
310+
self._server_wrapper.filetransfer_handler.download_file_if_autotransfer(
311+
export_path / filename, pathlib.Path(path) / filename
312+
)

tests/unittests/test_section_cut.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
# SOFTWARE.
2222

2323
import math
24+
import os
25+
import pathlib
26+
import tempfile
2427

2528
from packaging.version import parse as parse_version
2629
import pytest
@@ -109,3 +112,100 @@ def object_properties(parent_object):
109112
("locked", True),
110113
],
111114
)
115+
116+
117+
@pytest.mark.parametrize("export_type", ["mesh_only", "solid_model"])
118+
def test_section_cut_export(parent_object, export_type):
119+
"""Test the export to CDB. Only the presence of the file is checked."""
120+
121+
model = parent_object
122+
section_cut = model.create_section_cut()
123+
section_cut.normal = (1, 0, 0)
124+
section_cut.extrusion_type = ExtrusionType.SURFACE_NORMAL
125+
section_cut.section_cut_type = SectionCutType.ANALYSIS_PLY_WISE
126+
model.update()
127+
128+
with tempfile.TemporaryDirectory() as tempdir:
129+
path = pathlib.Path(tempdir) / "section_cut.cdb"
130+
section_cut.export(path=path, export_type=export_type)
131+
assert path.exists()
132+
assert path.is_file()
133+
assert os.stat(path).st_size > 0
134+
135+
136+
def test_section_cut_export_outofdate(parent_object):
137+
"""Test that the CDB export fails for an out-of-date section cut."""
138+
139+
model = parent_object
140+
section_cut = model.create_section_cut()
141+
section_cut.normal = (1, 0, 0)
142+
section_cut.extrusion_type = ExtrusionType.SURFACE_NORMAL
143+
section_cut.section_cut_type = SectionCutType.ANALYSIS_PLY_WISE
144+
145+
with tempfile.TemporaryDirectory() as tempdir:
146+
path = pathlib.Path(tempdir) / "section_cut.cdb"
147+
with pytest.raises(RuntimeError):
148+
# without update, the export should fail
149+
section_cut.export(path=path)
150+
151+
# after update, the export should work
152+
model.update()
153+
section_cut.export(path=path)
154+
155+
156+
@pytest.fixture(params=[True, False])
157+
def use_pathlib_path(request):
158+
return request.param
159+
160+
161+
@pytest.fixture(params=[True, False])
162+
def export_strength_limits(request):
163+
return request.param
164+
165+
166+
def test_section_cut_becas_export(parent_object, export_strength_limits, use_pathlib_path):
167+
"""Test the export to CDB. Only the presence of the file is checked."""
168+
169+
model = parent_object
170+
section_cut = model.create_section_cut()
171+
section_cut.normal = (1, 0, 0)
172+
section_cut.extrusion_type = ExtrusionType.SURFACE_NORMAL
173+
section_cut.section_cut_type = SectionCutType.ANALYSIS_PLY_WISE
174+
model.update()
175+
176+
expected_filename = ["E2D.in", "EMAT.in", "MATPROPS.in", "N2D.in"]
177+
if export_strength_limits:
178+
expected_filename.append("FAILMAT.in")
179+
180+
with tempfile.TemporaryDirectory() as tempdir:
181+
tempdir_path = pathlib.Path(tempdir)
182+
if use_pathlib_path:
183+
section_cut.export_becas_input(
184+
path=tempdir_path, export_strength_limits=export_strength_limits
185+
)
186+
else:
187+
section_cut.export_becas_input(
188+
path=tempdir, export_strength_limits=export_strength_limits
189+
)
190+
191+
assert sorted(tempdir_path.iterdir()) == sorted(
192+
[tempdir_path / filename for filename in expected_filename]
193+
)
194+
195+
196+
def test_section_cut_becas_export_outofdate(parent_object):
197+
"""Test that the BECAS export fails for an out-of-date section cut."""
198+
199+
model = parent_object
200+
section_cut = model.create_section_cut()
201+
section_cut.normal = (1, 0, 0)
202+
section_cut.extrusion_type = ExtrusionType.SURFACE_NORMAL
203+
section_cut.section_cut_type = SectionCutType.ANALYSIS_PLY_WISE
204+
205+
with tempfile.TemporaryDirectory() as tempdir:
206+
with pytest.raises(RuntimeError):
207+
# without update, the export should fail
208+
section_cut.export_becas_input(path=tempdir)
209+
# after update, the export should work
210+
model.update()
211+
section_cut.export_becas_input(path=tempdir)

0 commit comments

Comments
 (0)