metafold 0.12.dev8__tar.gz → 0.12.dev9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {metafold-0.12.dev8 → metafold-0.12.dev9}/PKG-INFO +1 -1
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/simulation/compression_experiment.py +4 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/simulation/compression_simulation.py +194 -100
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/simulation/run_experiment.py +4 -6
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold.egg-info/PKG-INFO +1 -1
- {metafold-0.12.dev8 → metafold-0.12.dev9}/pyproject.toml +1 -1
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_compression_simulation.py +178 -66
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_run_experiment.py +2 -11
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_shear_simulation.py +2 -2
- {metafold-0.12.dev8 → metafold-0.12.dev9}/LICENSE +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/README.md +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/__init__.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/api.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/assets.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/auth.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/client.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/exceptions.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/jobs.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/materials.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/projects.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/simulation/__init__.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/utils.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold/workflows.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold.egg-info/SOURCES.txt +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold.egg-info/dependency_links.txt +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold.egg-info/requires.txt +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/metafold.egg-info/top_level.txt +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/setup.cfg +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_assets.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_compession_experiment.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_jobs.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_materials.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_projects.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_utils.py +0 -0
- {metafold-0.12.dev8 → metafold-0.12.dev9}/tests/test_workflows.py +0 -0
|
@@ -246,6 +246,10 @@ class CompressionExperiment:
|
|
|
246
246
|
self.base_simulation.populate_assets(self.experiment_part_infos)
|
|
247
247
|
self._log("Sampling assets...")
|
|
248
248
|
self.base_simulation.sample_assets(self.experiment_part_infos)
|
|
249
|
+
# Clones were deep-copied before sampling, so copy over the spacing that
|
|
250
|
+
# was anchored during sampling — each clone's grid must match it.
|
|
251
|
+
for local_sim in self.sims:
|
|
252
|
+
local_sim.sample_spacing = self.base_simulation.sample_spacing
|
|
249
253
|
self._log("Collecting sampled volumes...")
|
|
250
254
|
self.base_simulation.collect_sampled_volumes(self.experiment_part_infos)
|
|
251
255
|
self._log("Prepare complete.")
|
|
@@ -6,9 +6,9 @@ from typing import Any, Optional, Union
|
|
|
6
6
|
from io import BytesIO
|
|
7
7
|
import uuid
|
|
8
8
|
|
|
9
|
-
from dotenv import load_dotenv
|
|
10
9
|
from requests import HTTPError
|
|
11
10
|
import yaml
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
12
|
from metafold import MetafoldClient
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from numpy.typing import DTypeLike
|
|
@@ -171,7 +171,6 @@ class ExperimentPart:
|
|
|
171
171
|
@dataclass
|
|
172
172
|
class ExperimentMesh(ExperimentPart):
|
|
173
173
|
filename: str = ""
|
|
174
|
-
representative_part: bool = False
|
|
175
174
|
|
|
176
175
|
|
|
177
176
|
@dataclass
|
|
@@ -389,7 +388,6 @@ class CompressionSimulation:
|
|
|
389
388
|
stl_folder: Optional[Path] = None
|
|
390
389
|
out_dir: Optional[Path] = None
|
|
391
390
|
results: list = []
|
|
392
|
-
representative_part: str = ""
|
|
393
391
|
simulation_name: str = ""
|
|
394
392
|
simulation_parameters: SimulationParameters
|
|
395
393
|
piston_velocity: list[list[float]]
|
|
@@ -416,9 +414,9 @@ class CompressionSimulation:
|
|
|
416
414
|
prep_workflows: list[Workflow] = []
|
|
417
415
|
prep_workflow_batch_size: int = 10
|
|
418
416
|
write_ups: bool = True
|
|
419
|
-
# Sample spacing (mm) anchored to the
|
|
420
|
-
# longest_axis / (max_resolution - 1). Cached so
|
|
421
|
-
# sampled later match the base simulation's density.
|
|
417
|
+
# Sample spacing (mm) anchored to the union ("total box") of every part's
|
|
418
|
+
# bounds: longest_axis(total_box) / (max_resolution - 1). Cached so
|
|
419
|
+
# experiment variants sampled later match the base simulation's density.
|
|
422
420
|
sample_spacing: Optional[float] = None
|
|
423
421
|
|
|
424
422
|
def __init__(
|
|
@@ -478,7 +476,6 @@ class CompressionSimulation:
|
|
|
478
476
|
else:
|
|
479
477
|
self.part_infos.append(inner_part)
|
|
480
478
|
|
|
481
|
-
self.validate_parts()
|
|
482
479
|
self.setup_ply_files(stl_folder_path)
|
|
483
480
|
self.setup_results(output_path)
|
|
484
481
|
if client is None:
|
|
@@ -538,16 +535,19 @@ class CompressionSimulation:
|
|
|
538
535
|
def download_results(self):
|
|
539
536
|
self.write_results()
|
|
540
537
|
|
|
541
|
-
def
|
|
542
|
-
for
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
538
|
+
def _get_primary_deformable_info(self):
|
|
539
|
+
"""The specimen part for stress/strain and the featured histogram: the
|
|
540
|
+
first part that isn't a rigid piston/support (pistons are inserted at
|
|
541
|
+
index 0, so this is the first deformable body in the stack)."""
|
|
542
|
+
return next(
|
|
543
|
+
(
|
|
544
|
+
p for p in self.part_infos
|
|
545
|
+
if not isinstance(
|
|
546
|
+
p.part, (ExperimentPistonBase, ExperimentSupportBase)
|
|
547
|
+
)
|
|
548
|
+
),
|
|
549
|
+
None,
|
|
550
|
+
)
|
|
551
551
|
|
|
552
552
|
def setup_results(self, output_path):
|
|
553
553
|
self.out_dir = Path(output_path)
|
|
@@ -735,7 +735,12 @@ class CompressionSimulation:
|
|
|
735
735
|
|
|
736
736
|
for info in part_infos:
|
|
737
737
|
if info.file_path and info.asset is None:
|
|
738
|
-
|
|
738
|
+
# Quote the filename so the search parser keeps it as one term,
|
|
739
|
+
# and match it exactly (the search is a case-insensitive ILIKE).
|
|
740
|
+
existing = [
|
|
741
|
+
a for a in self.client.assets.list(q=f'filename:"{info.part.filename}"')
|
|
742
|
+
if a.filename == info.part.filename
|
|
743
|
+
]
|
|
739
744
|
asset = None
|
|
740
745
|
if existing:
|
|
741
746
|
asset = existing[0]
|
|
@@ -906,38 +911,65 @@ class CompressionSimulation:
|
|
|
906
911
|
mx = np.array(bounds["max"], dtype=np.float64)
|
|
907
912
|
return float(np.max(mx - mn))
|
|
908
913
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
),
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
if rep_info is not None and rep_info.bounds:
|
|
928
|
-
self.sample_spacing = self._bounds_longest_axis(rep_info.bounds) / (
|
|
929
|
-
max_resolution - 1
|
|
930
|
-
)
|
|
914
|
+
@staticmethod
|
|
915
|
+
def _part_bounds_mm(info):
|
|
916
|
+
"""(min, max) axis-aligned bounds of a part in mm, or None. Mesh parts
|
|
917
|
+
report bounds via the pass-1 preprocess job (mm); primitives report
|
|
918
|
+
them analytically via get_bounds() (metres)."""
|
|
919
|
+
part = info.part
|
|
920
|
+
if isinstance(part, ExperimentPrimitive):
|
|
921
|
+
pmin, pmax = part.get_bounds()
|
|
922
|
+
return (
|
|
923
|
+
np.array(pmin, dtype=np.float64) * 1000.0,
|
|
924
|
+
np.array(pmax, dtype=np.float64) * 1000.0,
|
|
925
|
+
)
|
|
926
|
+
if info.bounds:
|
|
927
|
+
return (
|
|
928
|
+
np.array(info.bounds["min"], dtype=np.float64),
|
|
929
|
+
np.array(info.bounds["max"], dtype=np.float64),
|
|
930
|
+
)
|
|
931
|
+
return None
|
|
931
932
|
|
|
933
|
+
def _union_bounds_mm(self, part_infos: list):
|
|
934
|
+
"""Union ("total box") of every part's bounds in mm, or None."""
|
|
935
|
+
mins, maxs = [], []
|
|
932
936
|
for info in part_infos:
|
|
937
|
+
b = self._part_bounds_mm(info)
|
|
938
|
+
if b is not None:
|
|
939
|
+
mins.append(b[0])
|
|
940
|
+
maxs.append(b[1])
|
|
941
|
+
if not mins:
|
|
942
|
+
return None
|
|
943
|
+
return np.min(mins, axis=0), np.max(maxs, axis=0)
|
|
944
|
+
|
|
945
|
+
def _ensure_sample_spacing(self, part_infos: list):
|
|
946
|
+
"""Set sample_spacing (mm) from the total box of all parts, if unset.
|
|
947
|
+
Anchoring to the union — not one 'representative' part — makes
|
|
948
|
+
max_resolution the resolution of the whole simulation's longest axis."""
|
|
949
|
+
if self.sample_spacing is not None:
|
|
950
|
+
return
|
|
951
|
+
max_resolution = max(2, int(self.simulation_parameters.max_resolution))
|
|
952
|
+
union = self._union_bounds_mm(part_infos)
|
|
953
|
+
if union is not None:
|
|
954
|
+
longest = float(np.max(union[1] - union[0]))
|
|
955
|
+
if longest > 0:
|
|
956
|
+
self.sample_spacing = longest / (max_resolution - 1)
|
|
957
|
+
|
|
958
|
+
def _assign_sample_resolutions(self, all_infos: list, mesh_infos: list):
|
|
959
|
+
"""Anchor sample spacing to the total box (union of all part bounds),
|
|
960
|
+
then give each mesh a resolution scaled to its own size so every part
|
|
961
|
+
samples at the same spatial density."""
|
|
962
|
+
max_resolution = max(2, int(self.simulation_parameters.max_resolution))
|
|
963
|
+
self._ensure_sample_spacing(all_infos)
|
|
964
|
+
|
|
965
|
+
for info in mesh_infos:
|
|
933
966
|
if self.sample_spacing and info.bounds:
|
|
934
967
|
longest = self._bounds_longest_axis(info.bounds)
|
|
935
968
|
info.sample_resolution = max(
|
|
936
969
|
2, int(round(longest / self.sample_spacing)) + 1
|
|
937
970
|
)
|
|
938
971
|
else:
|
|
939
|
-
#
|
|
940
|
-
# its own bounds (density then depends on the part's size).
|
|
972
|
+
# Fallback: sample at max_resolution over the part's own bounds.
|
|
941
973
|
info.sample_resolution = max_resolution
|
|
942
974
|
|
|
943
975
|
def sample_assets(self, part_infos=None):
|
|
@@ -958,10 +990,9 @@ class CompressionSimulation:
|
|
|
958
990
|
)
|
|
959
991
|
self._collect_preprocess_outputs(mesh_parts, preprocess_workflows)
|
|
960
992
|
|
|
961
|
-
# Anchor sample spacing to the
|
|
962
|
-
#
|
|
963
|
-
|
|
964
|
-
self._assign_sample_resolutions(mesh_parts)
|
|
993
|
+
# Anchor sample spacing to the total box (union of all parts, primitives
|
|
994
|
+
# included) so every part samples at the same spatial density.
|
|
995
|
+
self._assign_sample_resolutions(part_infos, mesh_parts)
|
|
965
996
|
|
|
966
997
|
# Pass 2: sample each mesh at its density-matched resolution, reusing
|
|
967
998
|
# the preprocessed/BVH assets from pass 1.
|
|
@@ -1016,11 +1047,11 @@ class CompressionSimulation:
|
|
|
1016
1047
|
|
|
1017
1048
|
def create_sim_config(self, name_suffix=""):
|
|
1018
1049
|
"""
|
|
1019
|
-
|
|
1050
|
+
The grid spans the total box (union of every part's bounds) and is
|
|
1051
|
+
sized by the shared sample spacing — no representative part.
|
|
1020
1052
|
Piston velocity is [0,0,0] in the UPS — actual motion driven by velocity.txt.
|
|
1021
1053
|
"""
|
|
1022
1054
|
name = self.simulation_name
|
|
1023
|
-
grid_patch = self.get_part_info(self.representative_part).patch
|
|
1024
1055
|
|
|
1025
1056
|
sim = Simulation(
|
|
1026
1057
|
name,
|
|
@@ -1031,13 +1062,11 @@ class CompressionSimulation:
|
|
|
1031
1062
|
timestep_multiplier=self.simulation_parameters.timestep_multiplier,
|
|
1032
1063
|
)
|
|
1033
1064
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
# part). Primitives report bounds via get_bounds() in metres, mesh
|
|
1040
|
-
# parts via their sampled patch (offset/size in mm)
|
|
1065
|
+
# Total box: union of every part's bounding box so nothing is clipped.
|
|
1066
|
+
# Primitives report bounds via get_bounds() in metres, mesh parts via
|
|
1067
|
+
# their sampled patch (offset/size in mm).
|
|
1068
|
+
grid_min = np.array([np.inf, np.inf, np.inf], dtype=np.float32)
|
|
1069
|
+
grid_max = np.array([-np.inf, -np.inf, -np.inf], dtype=np.float32)
|
|
1041
1070
|
for info in self.part_infos:
|
|
1042
1071
|
part = info.part
|
|
1043
1072
|
if isinstance(part, ExperimentPrimitive):
|
|
@@ -1057,11 +1086,10 @@ class CompressionSimulation:
|
|
|
1057
1086
|
grid_max[2] += self.simulation_parameters.margin_z
|
|
1058
1087
|
|
|
1059
1088
|
grid_size = grid_max - grid_min
|
|
1060
|
-
spacing
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
)
|
|
1089
|
+
# Every part is sampled at this uniform (isotropic) spacing, so the grid
|
|
1090
|
+
# cells match particle spacing. mm -> m.
|
|
1091
|
+
self._ensure_sample_spacing(self.part_infos)
|
|
1092
|
+
spacing = (self.sample_spacing or 1.0) * 1e-3
|
|
1065
1093
|
grid_resolution = np.ceil(
|
|
1066
1094
|
grid_size / (self.simulation_parameters.points_per_cell * spacing)
|
|
1067
1095
|
).astype(np.int32)
|
|
@@ -1371,20 +1399,36 @@ class CompressionSimulation:
|
|
|
1371
1399
|
)
|
|
1372
1400
|
|
|
1373
1401
|
def _add_to_workflow_stress_strain(self, part_infos: list[PartInfo]):
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
representative_patch = self.get_part_info(self.representative_part).patch
|
|
1382
|
-
self.workflow_params[f"{job_name_base}.compression_area"] = str(
|
|
1383
|
-
representative_patch["size"][0] * representative_patch["size"][1]
|
|
1384
|
-
)
|
|
1385
|
-
self.workflow_params[f"{job_name_base}.initial_length"] = str(
|
|
1386
|
-
representative_patch["size"][2]
|
|
1402
|
+
# One stress-strain job per deformable part, each normalizing the shared
|
|
1403
|
+
# (global) force-displacement curve by that part's own cross-section
|
|
1404
|
+
# (X*Y) and height (Z). Named stress-strain-<part> like metrics-<part>.
|
|
1405
|
+
fd_job = next(
|
|
1406
|
+
(p.jobs["force-displacement"] for p in part_infos
|
|
1407
|
+
if "force-displacement" in p.jobs),
|
|
1408
|
+
None,
|
|
1387
1409
|
)
|
|
1410
|
+
if fd_job is None:
|
|
1411
|
+
return
|
|
1412
|
+
for part_info in part_infos:
|
|
1413
|
+
if not self._is_analysis_target(part_info.part):
|
|
1414
|
+
continue
|
|
1415
|
+
if part_info.disabled or not part_info.patch:
|
|
1416
|
+
continue
|
|
1417
|
+
job_name = f"stress-strain-{part_info.part_unique_name}"
|
|
1418
|
+
self.workflow_jobs[job_name] = {
|
|
1419
|
+
"type": "sim/postprocess/stress-strain",
|
|
1420
|
+
"needs": [fd_job],
|
|
1421
|
+
"assets": {"data": fd_job},
|
|
1422
|
+
}
|
|
1423
|
+
self.workflow_params[f"{job_name}.keys"] = json.dumps(
|
|
1424
|
+
["/force_displacement"]
|
|
1425
|
+
)
|
|
1426
|
+
size = part_info.patch["size"]
|
|
1427
|
+
self.workflow_params[f"{job_name}.compression_area"] = str(
|
|
1428
|
+
size[0] * size[1]
|
|
1429
|
+
)
|
|
1430
|
+
self.workflow_params[f"{job_name}.initial_length"] = str(size[2])
|
|
1431
|
+
part_info.jobs["stress-strain"] = job_name
|
|
1388
1432
|
|
|
1389
1433
|
def _add_to_workflow_particle_displacement(self, part_infos: list[PartInfo]):
|
|
1390
1434
|
self._add_to_workflow_postprocess(
|
|
@@ -1973,16 +2017,25 @@ class CompressionSimulation:
|
|
|
1973
2017
|
loading.to_csv(f)
|
|
1974
2018
|
data["loadingForceDisplacement"] = filename
|
|
1975
2019
|
|
|
1976
|
-
# Stress-strain
|
|
2020
|
+
# Stress-strain: one curve per deformable part.
|
|
1977
2021
|
if self._contains_step(WorkflowStepType.STRESS_STRAIN):
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2022
|
+
for part_info in self.part_infos:
|
|
2023
|
+
if not self._is_analysis_target(part_info.part):
|
|
2024
|
+
continue
|
|
2025
|
+
if part_info.disabled:
|
|
2026
|
+
continue
|
|
2027
|
+
i = part_info.material_index
|
|
2028
|
+
ss_asset = w.get_asset(
|
|
2029
|
+
f"stress-strain-{part_info.part_unique_name}.output"
|
|
2030
|
+
)
|
|
2031
|
+
if ss_asset is None:
|
|
2032
|
+
continue
|
|
2033
|
+
self.client.assets.download_file(ss_asset.id, hdf_filename)
|
|
2034
|
+
with pd.HDFStore(hdf_filename) as store:
|
|
2035
|
+
filename = f"{name}/stressStrain{i}.csv"
|
|
2036
|
+
with zf.open(filename, "w") as f:
|
|
2037
|
+
store["/stress_strain"].to_csv(f)
|
|
2038
|
+
data[f"stressStrain{i}"] = filename
|
|
1986
2039
|
|
|
1987
2040
|
# ----------------------------------------------------------
|
|
1988
2041
|
# Write PLY files from the combined all-material dataframe.
|
|
@@ -2218,18 +2271,26 @@ class CompressionSimulation:
|
|
|
2218
2271
|
if raw_ue is not None:
|
|
2219
2272
|
unloading_energy = float(raw_ue)
|
|
2220
2273
|
|
|
2221
|
-
# Stress-strain: ship whole.
|
|
2274
|
+
# Stress-strain: ship one whole file per deformable part.
|
|
2222
2275
|
if self._contains_step(WorkflowStepType.STRESS_STRAIN):
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
"
|
|
2231
|
-
|
|
2232
|
-
|
|
2276
|
+
for part_info in self.part_infos:
|
|
2277
|
+
if not self._is_analysis_target(part_info.part):
|
|
2278
|
+
continue
|
|
2279
|
+
if part_info.disabled:
|
|
2280
|
+
continue
|
|
2281
|
+
i = part_info.material_index
|
|
2282
|
+
ss_asset = w.get_asset(
|
|
2283
|
+
f"stress-strain-{part_info.part_unique_name}.output"
|
|
2284
|
+
)
|
|
2285
|
+
if ss_asset is not None:
|
|
2286
|
+
ss_hdf = tempdir_path / f"stress_strain_{i}.h5"
|
|
2287
|
+
self.client.assets.download_file(ss_asset.id, ss_hdf)
|
|
2288
|
+
ss_zip_path = f"{name}/stress_strain_{i}.h5"
|
|
2289
|
+
zf.write(ss_hdf, arcname=ss_zip_path)
|
|
2290
|
+
data[f"stressStrain{i}"] = {
|
|
2291
|
+
"name": ss_zip_path,
|
|
2292
|
+
"path": "/stress_strain",
|
|
2293
|
+
}
|
|
2233
2294
|
|
|
2234
2295
|
# Sum interior volumes across analysis-target parts.
|
|
2235
2296
|
total_volume = 0.0
|
|
@@ -2375,11 +2436,19 @@ class CompressionSimulation:
|
|
|
2375
2436
|
}
|
|
2376
2437
|
|
|
2377
2438
|
if self._contains_step(WorkflowStepType.STRESS_STRAIN):
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2439
|
+
for part_info in self.part_infos:
|
|
2440
|
+
if not self._is_analysis_target(part_info.part):
|
|
2441
|
+
continue
|
|
2442
|
+
if part_info.disabled:
|
|
2443
|
+
continue
|
|
2444
|
+
i = part_info.material_index
|
|
2445
|
+
server_data[f"stressStrain{i}"] = {
|
|
2446
|
+
"jobId": job_id_lookup.get(
|
|
2447
|
+
f"stress-strain-{part_info.part_unique_name}", ""
|
|
2448
|
+
),
|
|
2449
|
+
"assetName": "output",
|
|
2450
|
+
"path": "/stress_strain",
|
|
2451
|
+
}
|
|
2383
2452
|
|
|
2384
2453
|
return server_data
|
|
2385
2454
|
|
|
@@ -2605,8 +2674,9 @@ class CompressionSimulation:
|
|
|
2605
2674
|
]
|
|
2606
2675
|
|
|
2607
2676
|
self.manifest["cardsConfig"]["B"] = []
|
|
2608
|
-
|
|
2609
|
-
|
|
2677
|
+
specimen = self._get_primary_deformable_info()
|
|
2678
|
+
if self._contains_step(WorkflowStepType.VON_MISES_STRESS) and specimen is not None:
|
|
2679
|
+
rep_idx = specimen.material_index
|
|
2610
2680
|
self.manifest["cardsConfig"]["B"].append(
|
|
2611
2681
|
{
|
|
2612
2682
|
"id": "vonMisesStress",
|
|
@@ -2620,6 +2690,30 @@ class CompressionSimulation:
|
|
|
2620
2690
|
}
|
|
2621
2691
|
)
|
|
2622
2692
|
|
|
2693
|
+
# One stress-strain card per deformable part (keyed stressStrain{i}).
|
|
2694
|
+
if self._contains_step(WorkflowStepType.STRESS_STRAIN):
|
|
2695
|
+
for part_info in self.part_infos:
|
|
2696
|
+
if not self._is_analysis_target(part_info.part):
|
|
2697
|
+
continue
|
|
2698
|
+
if part_info.disabled:
|
|
2699
|
+
continue
|
|
2700
|
+
i = part_info.material_index
|
|
2701
|
+
self.manifest["cardsConfig"]["B"].append(
|
|
2702
|
+
{
|
|
2703
|
+
"id": f"stressStrain{i}",
|
|
2704
|
+
"title": f"{part_info.part_unique_name} Stress-Strain",
|
|
2705
|
+
"component": "MultiExperimentLineChart",
|
|
2706
|
+
"props": {
|
|
2707
|
+
"xColumn": "strain",
|
|
2708
|
+
"yColumn": "stress",
|
|
2709
|
+
"yMin": 0.0,
|
|
2710
|
+
"xAxisLabel": "Strain",
|
|
2711
|
+
"yAxisLabel": "Stress (MPa)",
|
|
2712
|
+
"dataSource": f"stressStrain{i}",
|
|
2713
|
+
},
|
|
2714
|
+
}
|
|
2715
|
+
)
|
|
2716
|
+
|
|
2623
2717
|
if self._results_have_energy_metrics(results):
|
|
2624
2718
|
self.manifest["resultsListConfig"].append(
|
|
2625
2719
|
{"key": "volume", "heading": "Volume (mm³)", "filtering": False},
|
|
@@ -36,8 +36,7 @@ JSON manifest format
|
|
|
36
36
|
"type": "mesh",
|
|
37
37
|
"name": "midsole",
|
|
38
38
|
"material": "default_midsole_nominal",
|
|
39
|
-
"file": "mid.ply"
|
|
40
|
-
"representative": true
|
|
39
|
+
"file": "mid.ply"
|
|
41
40
|
}
|
|
42
41
|
],
|
|
43
42
|
|
|
@@ -61,9 +60,9 @@ JSON manifest format
|
|
|
61
60
|
|
|
62
61
|
"simulation": {
|
|
63
62
|
"max_time": 0.04,
|
|
64
|
-
# Sampling resolution along the
|
|
65
|
-
#
|
|
66
|
-
# part shares the same spatial density.
|
|
63
|
+
# Sampling resolution along the longest axis of the total box (the
|
|
64
|
+
# union of every part's bounds). Each part samples at a resolution
|
|
65
|
+
# scaled to its own size so every part shares the same spatial density.
|
|
67
66
|
"max_resolution": 512,
|
|
68
67
|
|
|
69
68
|
# Force source for force-displacement: "boundary_force" (default) or
|
|
@@ -217,7 +216,6 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
|
|
|
217
216
|
name=entry["name"],
|
|
218
217
|
material=_resolve_material(entry["material"]),
|
|
219
218
|
filename=entry["file"],
|
|
220
|
-
representative_part=entry.get("representative", False),
|
|
221
219
|
)
|
|
222
220
|
)
|
|
223
221
|
return parts
|
|
@@ -14,6 +14,7 @@ from metafold.simulation.compression_simulation import (
|
|
|
14
14
|
ExperimentMesh,
|
|
15
15
|
ExperimentPistonBase,
|
|
16
16
|
ExperimentPistonCylinder,
|
|
17
|
+
ExperimentPistonMesh,
|
|
17
18
|
SimulationParameters,
|
|
18
19
|
WorkflowStep,
|
|
19
20
|
WorkflowStepType,
|
|
@@ -38,7 +39,7 @@ def basic_parts():
|
|
|
38
39
|
return [
|
|
39
40
|
ExperimentPistonCylinder(),
|
|
40
41
|
ExperimentMesh("upper_foam", DEFAULT_UPPER_FOAM, "top.ply"),
|
|
41
|
-
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"
|
|
42
|
+
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
|
|
42
43
|
ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply"),
|
|
43
44
|
]
|
|
44
45
|
|
|
@@ -83,7 +84,7 @@ class TestConstruction:
|
|
|
83
84
|
|
|
84
85
|
def test_missing_ply_raises(self, ply_folder, tmp_path):
|
|
85
86
|
parts = [
|
|
86
|
-
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "nonexistent.ply"
|
|
87
|
+
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "nonexistent.ply"),
|
|
87
88
|
]
|
|
88
89
|
with pytest.raises(ValueError, match="not found"):
|
|
89
90
|
CompressionSimulation(
|
|
@@ -94,36 +95,6 @@ class TestConstruction:
|
|
|
94
95
|
client=MagicMock(),
|
|
95
96
|
)
|
|
96
97
|
|
|
97
|
-
class TestValidation:
|
|
98
|
-
def test_requires_representative_part(self, ply_folder, tmp_path):
|
|
99
|
-
parts = [
|
|
100
|
-
ExperimentPistonCylinder(),
|
|
101
|
-
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"), # no rep
|
|
102
|
-
]
|
|
103
|
-
with pytest.raises(ValueError, match="representative_part"):
|
|
104
|
-
CompressionSimulation(
|
|
105
|
-
parts=parts,
|
|
106
|
-
simulation_name="t",
|
|
107
|
-
stl_folder_path=str(ply_folder),
|
|
108
|
-
output_path=str(tmp_path / "out"),
|
|
109
|
-
client=MagicMock(),
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
def test_only_one_representative_part_allowed(self, ply_folder, tmp_path):
|
|
113
|
-
parts = [
|
|
114
|
-
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply", representative_part=True),
|
|
115
|
-
ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply", representative_part=True),
|
|
116
|
-
]
|
|
117
|
-
with pytest.raises(ValueError, match="At most one"):
|
|
118
|
-
CompressionSimulation(
|
|
119
|
-
parts=parts,
|
|
120
|
-
simulation_name="t",
|
|
121
|
-
stl_folder_path=str(ply_folder),
|
|
122
|
-
output_path=str(tmp_path / "out"),
|
|
123
|
-
client=MagicMock(),
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
127
98
|
class TestWorkflowStepsInput:
|
|
128
99
|
def test_accepts_workflow_step_instance(self):
|
|
129
100
|
steps = CompressionSimulation._clean_workflow_steps_input([
|
|
@@ -248,8 +219,8 @@ class TestBuildWorkflow:
|
|
|
248
219
|
|
|
249
220
|
|
|
250
221
|
class TestGridBounds:
|
|
251
|
-
"""The grid must enclose every part,
|
|
252
|
-
|
|
222
|
+
"""The grid (total box) must enclose every part, so nothing (e.g. an
|
|
223
|
+
outsole below the midsole) gets clipped."""
|
|
253
224
|
|
|
254
225
|
def _grid_bounds(self, sim):
|
|
255
226
|
"""Return (lower, upper) of the generated UPS grid box, in metres."""
|
|
@@ -267,9 +238,9 @@ class TestGridBounds:
|
|
|
267
238
|
sim.create_sim_config()
|
|
268
239
|
|
|
269
240
|
def test_low_mesh_part_expands_grid_downward(self, sim):
|
|
270
|
-
# The regression: a mesh part (outsole) below the
|
|
271
|
-
#
|
|
272
|
-
#
|
|
241
|
+
# The regression: a mesh part (outsole) below the midsole must still
|
|
242
|
+
# expand the grid down. Before the fix, mesh parts were skipped and the
|
|
243
|
+
# outsole's bottom got clipped.
|
|
273
244
|
# Patch offsets/sizes are in mm; outsole bottom at -50 mm is below all
|
|
274
245
|
# other parts (incl. the piston cylinder, whose lowest point is +25 mm).
|
|
275
246
|
rep = {"size": [100.0, 100.0, 50.0], "offset": [0.0, 0.0, 0.0], "resolution": [32, 32, 16]}
|
|
@@ -415,8 +386,9 @@ class TestSampleAssets:
|
|
|
415
386
|
|
|
416
387
|
|
|
417
388
|
class TestDensityMatchedSampling:
|
|
418
|
-
"""Sampling resolutions
|
|
419
|
-
|
|
389
|
+
"""Sampling resolutions scale per part so every mesh shares one sample
|
|
390
|
+
spacing anchored to the total box (union of all parts) — the piston-density
|
|
391
|
+
regression, now decoupled from any 'representative' part."""
|
|
420
392
|
|
|
421
393
|
def _make_job(self, name, longest_axis=None, asset_key=None, filename=None):
|
|
422
394
|
params = {}
|
|
@@ -430,19 +402,32 @@ class TestDensityMatchedSampling:
|
|
|
430
402
|
)
|
|
431
403
|
|
|
432
404
|
@pytest.fixture
|
|
433
|
-
def sampled_sim(self,
|
|
434
|
-
|
|
405
|
+
def sampled_sim(self, tmp_path):
|
|
406
|
+
folder = tmp_path / "ply"
|
|
407
|
+
folder.mkdir()
|
|
408
|
+
for n in ["pist.ply", "top.ply", "mid.ply", "out.ply"]:
|
|
409
|
+
(folder / n).write_bytes(b"ply\n")
|
|
410
|
+
|
|
411
|
+
# Total box longest axis = 600 (outsole); max_resolution=31 → spacing 20.
|
|
412
|
+
parts = [
|
|
413
|
+
ExperimentPistonMesh(filename="pist.ply"),
|
|
414
|
+
ExperimentMesh("upper_foam", DEFAULT_UPPER_FOAM, "top.ply"),
|
|
415
|
+
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
|
|
416
|
+
ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply"),
|
|
417
|
+
]
|
|
435
418
|
sim = CompressionSimulation(
|
|
436
|
-
parts=
|
|
419
|
+
parts=parts,
|
|
437
420
|
simulation_name="t",
|
|
438
|
-
stl_folder_path=str(
|
|
421
|
+
stl_folder_path=str(folder),
|
|
439
422
|
output_path=str(tmp_path / "out"),
|
|
440
423
|
client=MagicMock(),
|
|
441
424
|
simulation_parameters=SimulationParameters(max_resolution=31),
|
|
442
425
|
)
|
|
443
426
|
|
|
444
427
|
jobs = {
|
|
445
|
-
"p-
|
|
428
|
+
"p-pi": self._make_job("preprocess-mesh-piston", 100.0, "mesh", "pre_pi.ply"),
|
|
429
|
+
"b-pi": self._make_job("compute-bvh-piston", None, "bvh", "bvh_pi.bin"),
|
|
430
|
+
"p-up": self._make_job("preprocess-mesh-upper_foam", 160.0, "mesh", "pre_up.ply"),
|
|
446
431
|
"b-up": self._make_job("compute-bvh-upper_foam", None, "bvh", "bvh_up.bin"),
|
|
447
432
|
"p-mid": self._make_job("preprocess-mesh-midsole", 300.0, "mesh", "pre_mid.ply"),
|
|
448
433
|
"b-mid": self._make_job("compute-bvh-midsole", None, "bvh", "bvh_mid.bin"),
|
|
@@ -457,28 +442,29 @@ class TestDensityMatchedSampling:
|
|
|
457
442
|
sim.sample_assets()
|
|
458
443
|
return sim
|
|
459
444
|
|
|
460
|
-
def
|
|
461
|
-
|
|
445
|
+
def test_spacing_anchored_to_total_box(self, sampled_sim):
|
|
446
|
+
# Union longest axis 600 / (31 - 1) = 20
|
|
447
|
+
assert sampled_sim.sample_spacing == pytest.approx(20.0)
|
|
462
448
|
|
|
463
|
-
def
|
|
464
|
-
assert sampled_sim.get_part_info("
|
|
449
|
+
def test_largest_part_gets_max_resolution(self, sampled_sim):
|
|
450
|
+
assert sampled_sim.get_part_info("outsole").sample_resolution == 31
|
|
465
451
|
|
|
466
|
-
def
|
|
467
|
-
|
|
468
|
-
assert sampled_sim.get_part_info("upper_foam").sample_resolution ==
|
|
469
|
-
|
|
470
|
-
def test_larger_part_gets_higher_resolution(self, sampled_sim):
|
|
471
|
-
# 600 mm at 10 mm spacing → 60 cells → 61 samples (above max_resolution)
|
|
472
|
-
assert sampled_sim.get_part_info("outsole").sample_resolution == 61
|
|
452
|
+
def test_parts_scale_to_shared_spacing(self, sampled_sim):
|
|
453
|
+
assert sampled_sim.get_part_info("piston").sample_resolution == 6 # 100/20
|
|
454
|
+
assert sampled_sim.get_part_info("upper_foam").sample_resolution == 9 # 160/20
|
|
455
|
+
assert sampled_sim.get_part_info("midsole").sample_resolution == 16 # 300/20
|
|
473
456
|
|
|
474
457
|
def test_sampling_pass_binds_pass1_outputs(self, sampled_sim):
|
|
475
458
|
args, kwargs = sampled_sim.client.workflows.run_async.call_args_list[1]
|
|
476
459
|
assert kwargs["parameters"] == {
|
|
477
|
-
"sample-mesh-
|
|
478
|
-
"sample-mesh-
|
|
479
|
-
"sample-mesh-
|
|
460
|
+
"sample-mesh-piston.resolution": "6",
|
|
461
|
+
"sample-mesh-upper_foam.resolution": "9",
|
|
462
|
+
"sample-mesh-midsole.resolution": "16",
|
|
463
|
+
"sample-mesh-outsole.resolution": "31",
|
|
480
464
|
}
|
|
481
465
|
assert kwargs["assets"] == {
|
|
466
|
+
"sample-mesh-piston.mesh": "pre_pi.ply",
|
|
467
|
+
"sample-mesh-piston.bvh": "bvh_pi.bin",
|
|
482
468
|
"sample-mesh-upper_foam.mesh": "pre_up.ply",
|
|
483
469
|
"sample-mesh-upper_foam.bvh": "bvh_up.bin",
|
|
484
470
|
"sample-mesh-midsole.mesh": "pre_mid.ply",
|
|
@@ -495,15 +481,38 @@ class TestDensityMatchedSampling:
|
|
|
495
481
|
assert "mesh/preprocess" in yaml_arg
|
|
496
482
|
|
|
497
483
|
def test_spacing_cached_for_later_variant_sampling(self, sampled_sim):
|
|
498
|
-
#
|
|
499
|
-
# even though the representative part isn't in that batch.
|
|
484
|
+
# A variant sampled in a later call reuses the cached spacing.
|
|
500
485
|
variant = CompressionSimulation.PartInfo(
|
|
501
486
|
ExperimentMesh("midsole_v1", None, "mid.ply")
|
|
502
487
|
)
|
|
503
488
|
variant.part_unique_name = "midsole_v1"
|
|
504
489
|
variant.bounds = {"min": [0.0, 0.0, 0.0], "max": [300.0, 50.0, 20.0]}
|
|
505
|
-
sampled_sim._assign_sample_resolutions([variant])
|
|
506
|
-
assert variant.sample_resolution ==
|
|
490
|
+
sampled_sim._assign_sample_resolutions([variant], [variant])
|
|
491
|
+
assert variant.sample_resolution == 16 # 300 / 20 spacing
|
|
492
|
+
|
|
493
|
+
def test_union_bounds_includes_primitive(self, tmp_path):
|
|
494
|
+
# A primitive's analytic bounds (metres) contribute to the total box.
|
|
495
|
+
folder = tmp_path / "ply"
|
|
496
|
+
folder.mkdir()
|
|
497
|
+
(folder / "mid.ply").write_bytes(b"ply\n")
|
|
498
|
+
sim = CompressionSimulation(
|
|
499
|
+
parts=[
|
|
500
|
+
ExperimentPistonCylinder(),
|
|
501
|
+
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
|
|
502
|
+
],
|
|
503
|
+
simulation_name="t",
|
|
504
|
+
stl_folder_path=str(folder),
|
|
505
|
+
output_path=str(tmp_path / "out"),
|
|
506
|
+
client=MagicMock(),
|
|
507
|
+
)
|
|
508
|
+
sim.get_part_info("midsole").bounds = {
|
|
509
|
+
"min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]
|
|
510
|
+
}
|
|
511
|
+
umin, umax = sim._union_bounds_mm(sim.part_infos)
|
|
512
|
+
pmin, pmax = sim._part_bounds_mm(sim.get_part_info("piston"))
|
|
513
|
+
# Union contains both the mesh box and the (mm-converted) primitive box
|
|
514
|
+
assert (umin <= pmin).all() and (umax >= pmax).all()
|
|
515
|
+
assert umin[0] <= 0.0 and umax[0] >= 10.0
|
|
507
516
|
|
|
508
517
|
|
|
509
518
|
|
|
@@ -556,7 +565,19 @@ jobs:
|
|
|
556
565
|
- compress
|
|
557
566
|
assets:
|
|
558
567
|
data: compress
|
|
559
|
-
stress-strain:
|
|
568
|
+
stress-strain-upper_foam:
|
|
569
|
+
type: sim/postprocess/stress-strain
|
|
570
|
+
needs:
|
|
571
|
+
- force-displacement
|
|
572
|
+
assets:
|
|
573
|
+
data: force-displacement
|
|
574
|
+
stress-strain-midsole:
|
|
575
|
+
type: sim/postprocess/stress-strain
|
|
576
|
+
needs:
|
|
577
|
+
- force-displacement
|
|
578
|
+
assets:
|
|
579
|
+
data: force-displacement
|
|
580
|
+
stress-strain-outsole:
|
|
560
581
|
type: sim/postprocess/stress-strain
|
|
561
582
|
needs:
|
|
562
583
|
- force-displacement
|
|
@@ -617,8 +638,12 @@ jobs:
|
|
|
617
638
|
"metrics-upper_foam.volume_resolution": "[32, 32, 16]",
|
|
618
639
|
"metrics-upper_foam.volume_size": "[0.1, 0.1, 0.05]",
|
|
619
640
|
"particle-displacement.keys": "[\"/material0/position\", \"/material1/position\", \"/material2/position\", \"/material3/position\"]",
|
|
620
|
-
"stress-strain.initial_length": "0.05",
|
|
621
|
-
"stress-strain.keys": "[\"/force_displacement\"]",
|
|
641
|
+
"stress-strain-midsole.initial_length": "0.05",
|
|
642
|
+
"stress-strain-midsole.keys": "[\"/force_displacement\"]",
|
|
643
|
+
"stress-strain-outsole.initial_length": "0.05",
|
|
644
|
+
"stress-strain-outsole.keys": "[\"/force_displacement\"]",
|
|
645
|
+
"stress-strain-upper_foam.initial_length": "0.05",
|
|
646
|
+
"stress-strain-upper_foam.keys": "[\"/force_displacement\"]",
|
|
622
647
|
"von-mises-stress.keys": "[\"/material0/cauchy_stress\", \"/material1/cauchy_stress\", \"/material2/cauchy_stress\", \"/material3/cauchy_stress\"]"
|
|
623
648
|
}
|
|
624
649
|
""")
|
|
@@ -627,6 +652,52 @@ jobs:
|
|
|
627
652
|
assert workflow_params[key] == value, f"mismatch for {key}: {workflow_params[key]!r} != {value!r}"
|
|
628
653
|
|
|
629
654
|
|
|
655
|
+
class TestStressStrainPerPart:
|
|
656
|
+
"""stress-strain runs once per deformable part (not one representative)."""
|
|
657
|
+
|
|
658
|
+
def _built_sim(self, ply_folder, basic_parts, tmp_path):
|
|
659
|
+
sim = CompressionSimulation(
|
|
660
|
+
parts=basic_parts,
|
|
661
|
+
simulation_name="t",
|
|
662
|
+
stl_folder_path=str(ply_folder),
|
|
663
|
+
output_path=str(tmp_path / "out"),
|
|
664
|
+
client=MagicMock(),
|
|
665
|
+
)
|
|
666
|
+
# Distinct X/Y so compression_area = X*Y is unambiguous.
|
|
667
|
+
fake_patch = {"size": [0.1, 0.2, 0.05], "offset": [0.0, 0.0, 0.0], "resolution": [32, 32, 16]}
|
|
668
|
+
for info in sim.part_infos:
|
|
669
|
+
info.patch = fake_patch
|
|
670
|
+
if hasattr(info.part, "filename"):
|
|
671
|
+
info.volume_filename = f"{info.part_unique_name}_volume.bin"
|
|
672
|
+
sim.create_sim_config()
|
|
673
|
+
sim.build_workflow()
|
|
674
|
+
return sim
|
|
675
|
+
|
|
676
|
+
def test_one_job_per_deformable_part(self, ply_folder, basic_parts, tmp_path):
|
|
677
|
+
sim = self._built_sim(ply_folder, basic_parts, tmp_path)
|
|
678
|
+
for name in ("upper_foam", "midsole", "outsole"):
|
|
679
|
+
assert f"stress-strain-{name}" in sim.workflow_jobs
|
|
680
|
+
|
|
681
|
+
def test_no_single_or_rigid_job(self, ply_folder, basic_parts, tmp_path):
|
|
682
|
+
sim = self._built_sim(ply_folder, basic_parts, tmp_path)
|
|
683
|
+
assert "stress-strain" not in sim.workflow_jobs
|
|
684
|
+
assert "stress-strain-piston" not in sim.workflow_jobs
|
|
685
|
+
|
|
686
|
+
def test_each_job_reads_the_shared_force_displacement(self, ply_folder, basic_parts, tmp_path):
|
|
687
|
+
sim = self._built_sim(ply_folder, basic_parts, tmp_path)
|
|
688
|
+
for name in ("upper_foam", "midsole", "outsole"):
|
|
689
|
+
job = sim.workflow_jobs[f"stress-strain-{name}"]
|
|
690
|
+
assert job["type"] == "sim/postprocess/stress-strain"
|
|
691
|
+
assert job["needs"] == ["force-displacement"]
|
|
692
|
+
assert job["assets"] == {"data": "force-displacement"}
|
|
693
|
+
|
|
694
|
+
def test_params_normalized_by_own_patch(self, ply_folder, basic_parts, tmp_path):
|
|
695
|
+
sim = self._built_sim(ply_folder, basic_parts, tmp_path)
|
|
696
|
+
p = sim.workflow_params
|
|
697
|
+
assert p["stress-strain-midsole.compression_area"] == str(0.1 * 0.2)
|
|
698
|
+
assert p["stress-strain-midsole.initial_length"] == "0.05"
|
|
699
|
+
assert p["stress-strain-midsole.keys"] == json.dumps(["/force_displacement"])
|
|
700
|
+
|
|
630
701
|
|
|
631
702
|
class TestMeshDataKey:
|
|
632
703
|
def test_simple_name(self):
|
|
@@ -644,13 +715,18 @@ class TestMakeManifestV2:
|
|
|
644
715
|
|
|
645
716
|
@pytest.fixture
|
|
646
717
|
def sim_with_full_steps(self, ply_folder, basic_parts, tmp_path):
|
|
647
|
-
|
|
718
|
+
sim = CompressionSimulation(
|
|
648
719
|
parts=basic_parts,
|
|
649
720
|
simulation_name="t",
|
|
650
721
|
stl_folder_path=str(ply_folder),
|
|
651
722
|
output_path=str(tmp_path / "out"),
|
|
652
723
|
client=MagicMock(),
|
|
653
724
|
)
|
|
725
|
+
# In the real flow create_sim_config() assigns material indices before
|
|
726
|
+
# make_manifest_v2() runs; mirror that so per-part cards key correctly.
|
|
727
|
+
for i, info in enumerate(sim.part_infos):
|
|
728
|
+
info.material_index = i
|
|
729
|
+
return sim
|
|
654
730
|
|
|
655
731
|
def test_results_list_config_has_name_column_always(self, sim_with_full_steps):
|
|
656
732
|
sim_with_full_steps.make_manifest_v2(results=[])
|
|
@@ -771,6 +847,42 @@ class TestMakeManifestV2:
|
|
|
771
847
|
sim.make_manifest_v2()
|
|
772
848
|
assert sim.manifest["cardsConfig"]["A"] == []
|
|
773
849
|
|
|
850
|
+
def test_stress_strain_cards_one_per_deformable_part(self, sim_with_full_steps):
|
|
851
|
+
sim_with_full_steps.make_manifest_v2()
|
|
852
|
+
ids = [c["id"] for c in sim_with_full_steps.manifest["cardsConfig"]["B"]]
|
|
853
|
+
assert "stressStrain1" in ids
|
|
854
|
+
assert "stressStrain2" in ids
|
|
855
|
+
assert "stressStrain3" in ids
|
|
856
|
+
# Piston (material_index 0) gets no stress-strain card.
|
|
857
|
+
assert "stressStrain0" not in ids
|
|
858
|
+
|
|
859
|
+
def test_stress_strain_card_props(self, sim_with_full_steps):
|
|
860
|
+
sim_with_full_steps.make_manifest_v2()
|
|
861
|
+
cards = [
|
|
862
|
+
c
|
|
863
|
+
for c in sim_with_full_steps.manifest["cardsConfig"]["B"]
|
|
864
|
+
if c["id"].startswith("stressStrain")
|
|
865
|
+
]
|
|
866
|
+
assert cards
|
|
867
|
+
for card in cards:
|
|
868
|
+
assert card["component"] == "MultiExperimentLineChart"
|
|
869
|
+
assert card["props"]["xColumn"] == "strain"
|
|
870
|
+
assert card["props"]["yColumn"] == "stress"
|
|
871
|
+
assert card["props"]["dataSource"] == card["id"]
|
|
872
|
+
|
|
873
|
+
def test_stress_strain_cards_absent_when_step_missing(self, ply_folder, basic_parts, tmp_path):
|
|
874
|
+
sim = CompressionSimulation(
|
|
875
|
+
parts=basic_parts,
|
|
876
|
+
simulation_name="t",
|
|
877
|
+
stl_folder_path=str(ply_folder),
|
|
878
|
+
output_path=str(tmp_path / "out"),
|
|
879
|
+
client=MagicMock(),
|
|
880
|
+
workflow_steps=[WorkflowStep(WorkflowStepType.COMPRESS)],
|
|
881
|
+
)
|
|
882
|
+
sim.make_manifest_v2()
|
|
883
|
+
ids = [c["id"] for c in sim.manifest["cardsConfig"]["B"]]
|
|
884
|
+
assert not any(i.startswith("stressStrain") for i in ids)
|
|
885
|
+
|
|
774
886
|
|
|
775
887
|
class TestResultsHaveEnergyMetrics:
|
|
776
888
|
def test_returns_true_when_energy_metrics_step_present(self, ply_folder, basic_parts, tmp_path):
|
|
@@ -153,7 +153,6 @@ class TestBuildParts:
|
|
|
153
153
|
"name": "midsole",
|
|
154
154
|
"material": "default_midsole_nominal",
|
|
155
155
|
"file": "mid.ply",
|
|
156
|
-
"representative": True,
|
|
157
156
|
}
|
|
158
157
|
])
|
|
159
158
|
p = parts[0]
|
|
@@ -161,7 +160,6 @@ class TestBuildParts:
|
|
|
161
160
|
assert p.name == "midsole"
|
|
162
161
|
assert p.material is DEFAULT_MIDSOLE_NOMINAL
|
|
163
162
|
assert p.filename == "mid.ply"
|
|
164
|
-
assert p.representative_part is True
|
|
165
163
|
|
|
166
164
|
def test_mesh_part_with_material_instance(self):
|
|
167
165
|
parts = _build_parts([
|
|
@@ -169,12 +167,6 @@ class TestBuildParts:
|
|
|
169
167
|
])
|
|
170
168
|
assert parts[0].material is DEFAULT_OUTSOLE
|
|
171
169
|
|
|
172
|
-
def test_representative_defaults_to_false(self):
|
|
173
|
-
parts = _build_parts([
|
|
174
|
-
{"name": "upper_foam", "material": "default_upper_foam", "file": "top.ply"}
|
|
175
|
-
])
|
|
176
|
-
assert parts[0].representative_part is False
|
|
177
|
-
|
|
178
170
|
def test_missing_name_raises(self):
|
|
179
171
|
with pytest.raises(ValueError, match="missing 'name'"):
|
|
180
172
|
_build_parts([{"material": "default_outsole", "file": "out.ply"}])
|
|
@@ -191,7 +183,7 @@ class TestBuildParts:
|
|
|
191
183
|
parts = _build_parts([
|
|
192
184
|
{"type": "piston_cylinder"},
|
|
193
185
|
{"name": "upper_foam", "material": "default_upper_foam", "file": "top.ply"},
|
|
194
|
-
{"name": "midsole", "material": "default_midsole_nominal", "file": "mid.ply"
|
|
186
|
+
{"name": "midsole", "material": "default_midsole_nominal", "file": "mid.ply"},
|
|
195
187
|
{"name": "outsole", "material": "default_outsole", "file": "out.ply"},
|
|
196
188
|
])
|
|
197
189
|
assert len(parts) == 4
|
|
@@ -339,7 +331,7 @@ class TestRunExperiment:
|
|
|
339
331
|
"ply_folder": str(ply_folder),
|
|
340
332
|
"parts": [
|
|
341
333
|
{"type": "piston_cylinder"},
|
|
342
|
-
{"name": "midsole", "material": "default_midsole_nominal", "file": "mid.ply"
|
|
334
|
+
{"name": "midsole", "material": "default_midsole_nominal", "file": "mid.ply"},
|
|
343
335
|
],
|
|
344
336
|
})
|
|
345
337
|
assert result == "proj-abc"
|
|
@@ -604,7 +596,6 @@ class TestRunExperimentFromZip:
|
|
|
604
596
|
"name": "midsole",
|
|
605
597
|
"material": "default_midsole_nominal",
|
|
606
598
|
"file": "mid.ply",
|
|
607
|
-
"representative": True,
|
|
608
599
|
}],
|
|
609
600
|
}
|
|
610
601
|
zip_path = self._make_zip(tmp_path, manifest, mesh_files=["mid.ply"])
|
|
@@ -31,7 +31,7 @@ def basic_parts():
|
|
|
31
31
|
return [
|
|
32
32
|
ExperimentPistonCylinder(),
|
|
33
33
|
ExperimentMesh("upper_foam", DEFAULT_UPPER_FOAM, "top.ply"),
|
|
34
|
-
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"
|
|
34
|
+
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
|
|
35
35
|
ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply"),
|
|
36
36
|
]
|
|
37
37
|
|
|
@@ -290,7 +290,7 @@ class TestShearUPS:
|
|
|
290
290
|
material=piston_mat,
|
|
291
291
|
velocity=self.SHEAR_VELOCITY,
|
|
292
292
|
),
|
|
293
|
-
ExperimentMesh("puck", puck_mat, "puck.stl"
|
|
293
|
+
ExperimentMesh("puck", puck_mat, "puck.stl"),
|
|
294
294
|
]
|
|
295
295
|
|
|
296
296
|
sim_params = SimulationParameters(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|