metafold 0.12.dev1__tar.gz → 0.12.dev3__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.dev1 → metafold-0.12.dev3}/PKG-INFO +1 -1
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/client.py +9 -4
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/simulation/__init__.py +2 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/simulation/compression_simulation.py +149 -21
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/simulation/run_experiment.py +27 -2
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/PKG-INFO +1 -1
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/SOURCES.txt +1 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/pyproject.toml +1 -1
- {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_compression_simulation.py +114 -1
- {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_run_experiment.py +44 -0
- metafold-0.12.dev3/tests/test_shear_simulation.py +414 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/LICENSE +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/README.md +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/__init__.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/api.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/assets.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/auth.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/exceptions.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/jobs.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/materials.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/projects.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/simulation/compression_experiment.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/utils.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/workflows.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/dependency_links.txt +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/requires.txt +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/top_level.txt +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/setup.cfg +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_assets.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_compession_experiment.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_jobs.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_projects.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_utils.py +0 -0
- {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_workflows.py +0 -0
|
@@ -55,11 +55,16 @@ class Client:
|
|
|
55
55
|
headers = {"Authorization": f"Bearer {self._auth.get_token()}"}
|
|
56
56
|
r: Response = request(url, *args, **kwargs, headers=headers)
|
|
57
57
|
if not r.ok:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
# Not all error responses are JSON so fall back to the status reason
|
|
59
|
+
try:
|
|
60
|
+
body: dict[str, Any] = r.json()
|
|
61
|
+
except ValueError:
|
|
62
|
+
body = {}
|
|
61
63
|
reason = body.get("errors") or body.get("msg") or body.get("description")
|
|
62
|
-
raise HTTPError(
|
|
64
|
+
raise HTTPError(
|
|
65
|
+
f"HTTP error occurred: {reason or r.reason} "
|
|
66
|
+
f"(status {r.status_code} for {r.request.method} {r.url})"
|
|
67
|
+
)
|
|
63
68
|
return r
|
|
64
69
|
|
|
65
70
|
def get(self, url: str, *args: Any, **kwargs: Any) -> Response:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from metafold.simulation.compression_simulation import (
|
|
2
|
+
BoundaryCondition,
|
|
2
3
|
CompressionSimulation,
|
|
3
4
|
ExperimentBox,
|
|
4
5
|
ExperimentCylinder,
|
|
@@ -14,6 +15,7 @@ from metafold.simulation.compression_simulation import (
|
|
|
14
15
|
ExperimentSupportCylinder,
|
|
15
16
|
ExperimentSupportMesh,
|
|
16
17
|
ExperimentSupportParallelepiped,
|
|
18
|
+
ForceSource,
|
|
17
19
|
ReferenceData,
|
|
18
20
|
SimulationParameters,
|
|
19
21
|
WorkflowStep,
|
|
@@ -21,6 +21,7 @@ from simulation_configurator import (
|
|
|
21
21
|
Contact,
|
|
22
22
|
Mpm,
|
|
23
23
|
)
|
|
24
|
+
from simulation_configurator.element import CompositeElement, Element
|
|
24
25
|
from simulation_configurator.grid import Face
|
|
25
26
|
from simulation_configurator.shapes import Box, File, Cylinder, Parallelepiped
|
|
26
27
|
from plyfile import PlyData, PlyElement
|
|
@@ -71,6 +72,68 @@ ZERO_VELOCITY = [
|
|
|
71
72
|
]
|
|
72
73
|
|
|
73
74
|
|
|
75
|
+
class BoundaryCondition(Enum):
|
|
76
|
+
SYMMETRIC = "symmetric" # symmetry plane (acts as frictionless wall)
|
|
77
|
+
VELOCITY_DIRICHLET = "velocity_dirichlet" # velocity fixed to zero at the face
|
|
78
|
+
VELOCITY_NEUMANN = "velocity_neumann" # zero velocity gradient (free face)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
DEFAULT_BOUNDARY_CONDITIONS = {
|
|
82
|
+
"x-": BoundaryCondition.SYMMETRIC,
|
|
83
|
+
"x+": BoundaryCondition.SYMMETRIC,
|
|
84
|
+
"y-": BoundaryCondition.SYMMETRIC,
|
|
85
|
+
"y+": BoundaryCondition.SYMMETRIC,
|
|
86
|
+
"z-": BoundaryCondition.VELOCITY_DIRICHLET,
|
|
87
|
+
"z+": BoundaryCondition.VELOCITY_DIRICHLET,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _velocity_neumann_face(side: str):
|
|
92
|
+
# simulation_configurator.Face only provides fixed (symmetry) and unfixed
|
|
93
|
+
# (velocity Dirichlet) faces; build the Neumann variant here until it can
|
|
94
|
+
# move into a simulation-configurator release.
|
|
95
|
+
face = CompositeElement("Face", {"side": side})
|
|
96
|
+
bc_type = CompositeElement(
|
|
97
|
+
"BCType",
|
|
98
|
+
{"id": "all", "label": "Velocity", "var": "Neumann"},
|
|
99
|
+
)
|
|
100
|
+
bc_type.add(Element("value", data="[0.0, 0.0, 0.0]"))
|
|
101
|
+
face.add(bc_type)
|
|
102
|
+
return face
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_FACE_BUILDERS = {
|
|
106
|
+
BoundaryCondition.SYMMETRIC: Face.fixed,
|
|
107
|
+
BoundaryCondition.VELOCITY_DIRICHLET: Face.unfixed,
|
|
108
|
+
BoundaryCondition.VELOCITY_NEUMANN: _velocity_neumann_face,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _normalize_boundary_conditions(
|
|
113
|
+
boundary_conditions: dict,
|
|
114
|
+
) -> dict[str, BoundaryCondition]:
|
|
115
|
+
"""Merge per-face boundary conditions over the defaults.
|
|
116
|
+
|
|
117
|
+
Values may be BoundaryCondition members or their string values (as parsed
|
|
118
|
+
from an experiment manifest). Faces not present keep their default.
|
|
119
|
+
"""
|
|
120
|
+
unknown = set(boundary_conditions) - set(DEFAULT_BOUNDARY_CONDITIONS)
|
|
121
|
+
if unknown:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Unknown boundary condition face(s) {sorted(unknown)}; "
|
|
124
|
+
f"expected faces {sorted(DEFAULT_BOUNDARY_CONDITIONS)}"
|
|
125
|
+
)
|
|
126
|
+
resolved = dict(DEFAULT_BOUNDARY_CONDITIONS)
|
|
127
|
+
for side, bc in boundary_conditions.items():
|
|
128
|
+
resolved[side] = BoundaryCondition(bc) if isinstance(bc, str) else bc
|
|
129
|
+
return resolved
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ForceSource(Enum):
|
|
133
|
+
BOUNDARY_FORCE = "boundary_force" # uses boundary_force_zminus (default)
|
|
134
|
+
RIGID_REACTION_FORCE = "rigid_reaction_force" # uses rigid reaction force of force_source_part
|
|
135
|
+
|
|
136
|
+
|
|
74
137
|
@dataclass
|
|
75
138
|
class SimulationParameters:
|
|
76
139
|
init_time: float = 0.0
|
|
@@ -89,6 +152,15 @@ class SimulationParameters:
|
|
|
89
152
|
extra_cells: list[int] = field(default_factory=lambda: [1, 1, 1])
|
|
90
153
|
patches: list[int] = field(default_factory=lambda: [4, 2, 2])
|
|
91
154
|
|
|
155
|
+
# Per-face boundary conditions; faces omitted here use the default.
|
|
156
|
+
boundary_conditions: dict[str, BoundaryCondition] = field(
|
|
157
|
+
default_factory=lambda: dict(DEFAULT_BOUNDARY_CONDITIONS)
|
|
158
|
+
)
|
|
159
|
+
force_source: ForceSource = ForceSource.BOUNDARY_FORCE
|
|
160
|
+
# Part whose rigid_reaction_force feeds force-displacement when
|
|
161
|
+
# force_source is RIGID_REACTION_FORCE. Empty string means the piston.
|
|
162
|
+
force_source_part: str = ""
|
|
163
|
+
|
|
92
164
|
|
|
93
165
|
@dataclass
|
|
94
166
|
class ExperimentPart:
|
|
@@ -506,6 +578,21 @@ class CompressionSimulation:
|
|
|
506
578
|
p.restore_from_state_dict(saved)
|
|
507
579
|
break
|
|
508
580
|
|
|
581
|
+
@staticmethod
|
|
582
|
+
def cancel_active_workflows(client: MetafoldClient, project_id: str) -> list[str]:
|
|
583
|
+
"""Cancel all in-flight (pending/started) workflows for a project.
|
|
584
|
+
|
|
585
|
+
Returns the number of workflows cancelled. Used when re-running an
|
|
586
|
+
experiment on an existing project, whose previous results we're about
|
|
587
|
+
to overwrite anyway.
|
|
588
|
+
"""
|
|
589
|
+
cancelled = []
|
|
590
|
+
for state in ("pending", "started"):
|
|
591
|
+
for wf in client.workflows.list(q=f"state:{state}", project_id=project_id):
|
|
592
|
+
client.workflows.cancel(wf.id, project_id=project_id)
|
|
593
|
+
cancelled.append(wf.id)
|
|
594
|
+
return cancelled
|
|
595
|
+
|
|
509
596
|
@staticmethod
|
|
510
597
|
def find_or_create_project(
|
|
511
598
|
project_name: str,
|
|
@@ -514,12 +601,17 @@ class CompressionSimulation:
|
|
|
514
601
|
client_secret: Optional[str] = None,
|
|
515
602
|
auth_domain: str = "metafold3d.us.auth0.com",
|
|
516
603
|
base_url: str = "https://api.metafold3d.com/",
|
|
604
|
+
cancel_existing_workflows: bool = True,
|
|
517
605
|
) -> str:
|
|
518
606
|
"""Find an existing project by name or create a new one.
|
|
519
607
|
|
|
520
608
|
Supply either access_token (server context — never reads from env) or
|
|
521
609
|
client_id + client_secret (local/programmable use, reads from env via
|
|
522
610
|
setup_client). base_url must match the audience the token was issued for.
|
|
611
|
+
|
|
612
|
+
When cancel_existing_workflows is True and an existing project is
|
|
613
|
+
reused, its running workflows are cancelled first.
|
|
614
|
+
|
|
523
615
|
Returns the project_id string.
|
|
524
616
|
"""
|
|
525
617
|
client = MetafoldClient(
|
|
@@ -534,7 +626,10 @@ class CompressionSimulation:
|
|
|
534
626
|
except HTTPError:
|
|
535
627
|
existing = []
|
|
536
628
|
if existing:
|
|
537
|
-
|
|
629
|
+
project_id = existing[0].id
|
|
630
|
+
if cancel_existing_workflows:
|
|
631
|
+
CompressionSimulation.cancel_active_workflows(client, project_id)
|
|
632
|
+
return project_id
|
|
538
633
|
created = client.projects.create(
|
|
539
634
|
project_name,
|
|
540
635
|
access=Access.PRIVATE,
|
|
@@ -775,17 +870,28 @@ class CompressionSimulation:
|
|
|
775
870
|
|
|
776
871
|
grid_min = np.array(grid_patch["offset"], dtype=np.float32) * 1e-3
|
|
777
872
|
grid_max = np.array(grid_patch["size"], dtype=np.float32) * 1e-3 + grid_min
|
|
778
|
-
grid_min[:2] -= self.simulation_parameters.margin_xy
|
|
779
|
-
grid_max[:2] += self.simulation_parameters.margin_xy
|
|
780
|
-
grid_max[2] += self.simulation_parameters.margin_z
|
|
781
873
|
|
|
782
|
-
# Expand grid to contain every
|
|
874
|
+
# Expand grid to contain every part's bounding box, so nothing is
|
|
875
|
+
# clipped (e.g. a support/outsole extending below the representative
|
|
876
|
+
# part). Primitives report bounds via get_bounds() in metres, mesh
|
|
877
|
+
# parts via their sampled patch (offset/size in mm)
|
|
783
878
|
for info in self.part_infos:
|
|
784
879
|
part = info.part
|
|
785
880
|
if isinstance(part, ExperimentPrimitive):
|
|
786
881
|
pmin, pmax = part.get_bounds()
|
|
787
|
-
|
|
788
|
-
|
|
882
|
+
elif info.patch:
|
|
883
|
+
pmin = np.array(info.patch["offset"], dtype=np.float32) / 1000.0
|
|
884
|
+
pmax = np.array(info.patch["size"], dtype=np.float32) / 1000.0 + pmin
|
|
885
|
+
else:
|
|
886
|
+
continue
|
|
887
|
+
grid_min = np.minimum(grid_min, pmin)
|
|
888
|
+
grid_max = np.maximum(grid_max, pmax)
|
|
889
|
+
|
|
890
|
+
# Apply margins after all parts included
|
|
891
|
+
grid_min[:2] -= self.simulation_parameters.margin_xy
|
|
892
|
+
grid_max[:2] += self.simulation_parameters.margin_xy
|
|
893
|
+
grid_min[2] -= self.simulation_parameters.margin_z
|
|
894
|
+
grid_max[2] += self.simulation_parameters.margin_z
|
|
789
895
|
|
|
790
896
|
grid_size = grid_max - grid_min
|
|
791
897
|
spacing = (
|
|
@@ -807,10 +913,11 @@ class CompressionSimulation:
|
|
|
807
913
|
}
|
|
808
914
|
)
|
|
809
915
|
bcs = BoundaryConditions()
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
916
|
+
boundary_conditions = _normalize_boundary_conditions(
|
|
917
|
+
self.simulation_parameters.boundary_conditions
|
|
918
|
+
)
|
|
919
|
+
for side, bc in boundary_conditions.items():
|
|
920
|
+
bcs._sub_elements[side] = _FACE_BUILDERS[bc](side)
|
|
814
921
|
grid.add(bcs)
|
|
815
922
|
|
|
816
923
|
store = GeometryStore()
|
|
@@ -867,16 +974,18 @@ class CompressionSimulation:
|
|
|
867
974
|
store.add(geometry_element)
|
|
868
975
|
|
|
869
976
|
outputs_indices_key = ",".join(str(i) for i in range(len(self.part_infos)))
|
|
977
|
+
all_outputs = [
|
|
978
|
+
"boundary_force_zminus",
|
|
979
|
+
"boundary_force_zplus",
|
|
980
|
+
"kinetic_energy",
|
|
981
|
+
"strain_energy",
|
|
982
|
+
]
|
|
983
|
+
if self.simulation_parameters.force_source == ForceSource.RIGID_REACTION_FORCE:
|
|
984
|
+
all_outputs.append("rigid_reaction_force")
|
|
870
985
|
archive = Archive(
|
|
871
986
|
outputs={
|
|
872
987
|
outputs_indices_key: ["deformation_measure", "position", "stress"],
|
|
873
|
-
"all":
|
|
874
|
-
"boundary_force_zminus",
|
|
875
|
-
"boundary_force_zplus",
|
|
876
|
-
"rigid_reaction_force_0",
|
|
877
|
-
"kinetic_energy",
|
|
878
|
-
"strain_energy",
|
|
879
|
-
],
|
|
988
|
+
"all": all_outputs,
|
|
880
989
|
},
|
|
881
990
|
output_interval=self.simulation_parameters.output_int,
|
|
882
991
|
)
|
|
@@ -1052,6 +1161,22 @@ class CompressionSimulation:
|
|
|
1052
1161
|
)
|
|
1053
1162
|
return piston_info
|
|
1054
1163
|
|
|
1164
|
+
def _get_force_source_info(self):
|
|
1165
|
+
"""PartInfo of the body whose rigid_reaction_force is measured."""
|
|
1166
|
+
name = self.simulation_parameters.force_source_part
|
|
1167
|
+
if name:
|
|
1168
|
+
info = self.get_part_info(name)
|
|
1169
|
+
if info is None:
|
|
1170
|
+
raise RuntimeError(f"Unknown force_source_part: {name}")
|
|
1171
|
+
return info
|
|
1172
|
+
piston_info = self._get_piston_info()
|
|
1173
|
+
if piston_info is None:
|
|
1174
|
+
raise RuntimeError(
|
|
1175
|
+
"force_source RIGID_REACTION_FORCE requires a piston part "
|
|
1176
|
+
"or an explicit force_source_part"
|
|
1177
|
+
)
|
|
1178
|
+
return piston_info
|
|
1179
|
+
|
|
1055
1180
|
def _add_to_workflow_von_mises_stress(self, part_infos: list[PartInfo]):
|
|
1056
1181
|
self._add_to_workflow_postprocess(
|
|
1057
1182
|
part_infos, "compress", "von-mises-stress", "cauchy_stress"
|
|
@@ -1065,9 +1190,12 @@ class CompressionSimulation:
|
|
|
1065
1190
|
def _add_to_workflow_force_displacement(self, part_infos: list[PartInfo]):
|
|
1066
1191
|
job_name_base = "force-displacement"
|
|
1067
1192
|
self._add_to_workflow_postprocess(part_infos, "compress", job_name_base)
|
|
1068
|
-
self.
|
|
1069
|
-
|
|
1070
|
-
|
|
1193
|
+
if self.simulation_parameters.force_source == ForceSource.RIGID_REACTION_FORCE:
|
|
1194
|
+
source = self._get_force_source_info()
|
|
1195
|
+
keys = [f"/material{source.material_index}/rigid_reaction_force"]
|
|
1196
|
+
else:
|
|
1197
|
+
keys = ["/boundary_force_zminus"]
|
|
1198
|
+
self.workflow_params[f"{job_name_base}.keys"] = json.dumps(keys)
|
|
1071
1199
|
self.workflow_params[f"{job_name_base}.method"] = "BoundaryForce"
|
|
1072
1200
|
# Find the piston part — there should be exactly one
|
|
1073
1201
|
piston_info = self._get_piston_info()
|
|
@@ -56,7 +56,19 @@ JSON manifest format
|
|
|
56
56
|
|
|
57
57
|
"simulation": {
|
|
58
58
|
"max_time": 0.04,
|
|
59
|
-
"max_resolution": 512
|
|
59
|
+
"max_resolution": 512,
|
|
60
|
+
|
|
61
|
+
# Force source for force-displacement: "boundary_force" (default) or
|
|
62
|
+
# "rigid_reaction_force". When using rigid_reaction_force, optionally
|
|
63
|
+
# name the part it's measured on (defaults to the piston):
|
|
64
|
+
"force_source": "rigid_reaction_force",
|
|
65
|
+
"force_source_part": "puck",
|
|
66
|
+
|
|
67
|
+
# Per-face boundary conditions. Only the faces you list are overridden;
|
|
68
|
+
# the rest keep their defaults (x/y faces "symmetric", z faces
|
|
69
|
+
# "velocity_dirichlet"). Each value is one of: "symmetric",
|
|
70
|
+
# "velocity_dirichlet", "velocity_neumann".
|
|
71
|
+
"boundary_conditions": {"z-": "symmetric", "y+": "velocity_neumann"}
|
|
60
72
|
},
|
|
61
73
|
|
|
62
74
|
"workflow_steps": [
|
|
@@ -100,6 +112,7 @@ from __future__ import annotations
|
|
|
100
112
|
|
|
101
113
|
import json
|
|
102
114
|
import tempfile
|
|
115
|
+
from enum import Enum
|
|
103
116
|
from pathlib import Path
|
|
104
117
|
from typing import Optional, Union
|
|
105
118
|
from zipfile import ZipFile
|
|
@@ -234,6 +247,13 @@ def _build_simulation_parameters(sim_config: dict) -> SimulationParameters:
|
|
|
234
247
|
obj = params
|
|
235
248
|
for part in parts[:-1]:
|
|
236
249
|
obj = getattr(obj, part)
|
|
250
|
+
# Manifest values for enum fields (e.g. force_source) arrive as plain
|
|
251
|
+
# strings; coerce them to the field's enum type. Non-enum fields (e.g.
|
|
252
|
+
# the boundary_conditions dict) pass through and are normalized later
|
|
253
|
+
# in create_sim_config.
|
|
254
|
+
current = getattr(obj, parts[-1], None)
|
|
255
|
+
if isinstance(current, Enum) and isinstance(value, str):
|
|
256
|
+
value = type(current)(value)
|
|
237
257
|
setattr(obj, parts[-1], value)
|
|
238
258
|
return params
|
|
239
259
|
|
|
@@ -289,9 +309,14 @@ def run_experiment(
|
|
|
289
309
|
output_path=resolved_output_path,
|
|
290
310
|
simulation_parameters=sim_params,
|
|
291
311
|
project_id=project_id,
|
|
292
|
-
project_name=project_name,
|
|
293
312
|
create_project_if_needed=not bool(project_id),
|
|
294
313
|
)
|
|
314
|
+
# Only pass project_name when we have to create a project. When a
|
|
315
|
+
# project_id is supplied the project already exists, and passing the name
|
|
316
|
+
# would keep the find-or-create-by-name path alive — risking a duplicate
|
|
317
|
+
# project. simulation_name (set above) drives UPS/result naming regardless.
|
|
318
|
+
if not project_id:
|
|
319
|
+
sim_kwargs["project_name"] = project_name
|
|
295
320
|
if "workflow_steps" in config:
|
|
296
321
|
sim_kwargs["workflow_steps"] = _build_workflow_steps(config["workflow_steps"])
|
|
297
322
|
|
|
@@ -245,6 +245,55 @@ class TestBuildWorkflow:
|
|
|
245
245
|
assert not any("force-displacement" in k for k in loaded["jobs"])
|
|
246
246
|
|
|
247
247
|
|
|
248
|
+
class TestGridBounds:
|
|
249
|
+
"""The grid must enclose every part, not just the representative one, so
|
|
250
|
+
nothing (e.g. an outsole below the midsole) gets clipped."""
|
|
251
|
+
|
|
252
|
+
def _grid_bounds(self, sim):
|
|
253
|
+
"""Return (lower, upper) of the generated UPS grid box, in metres."""
|
|
254
|
+
root = sim.ups.getroot()
|
|
255
|
+
lower = json.loads(root.findtext(".//Grid//Box/lower"))
|
|
256
|
+
upper = json.loads(root.findtext(".//Grid//Box/upper"))
|
|
257
|
+
return lower, upper
|
|
258
|
+
|
|
259
|
+
def _prepare(self, sim, patches):
|
|
260
|
+
"""Assign each part_info a patch (by unique name) and build the config."""
|
|
261
|
+
for info in sim.part_infos:
|
|
262
|
+
info.patch = patches[info.part_unique_name]
|
|
263
|
+
if hasattr(info.part, "filename"):
|
|
264
|
+
info.volume_filename = f"{info.part_unique_name}_volume.bin"
|
|
265
|
+
sim.create_sim_config()
|
|
266
|
+
|
|
267
|
+
def test_low_mesh_part_expands_grid_downward(self, sim):
|
|
268
|
+
# The regression: a mesh part (outsole) below the representative midsole
|
|
269
|
+
# must still expand the grid down. Before the fix, mesh parts were
|
|
270
|
+
# skipped and the outsole's bottom got clipped.
|
|
271
|
+
# Patch offsets/sizes are in mm; outsole bottom at -50 mm is below all
|
|
272
|
+
# other parts (incl. the piston cylinder, whose lowest point is +25 mm).
|
|
273
|
+
rep = {"size": [100.0, 100.0, 50.0], "offset": [0.0, 0.0, 0.0], "resolution": [32, 32, 16]}
|
|
274
|
+
low = {"size": [100.0, 100.0, 20.0], "offset": [0.0, 0.0, -50.0], "resolution": [32, 32, 8]}
|
|
275
|
+
patches = {
|
|
276
|
+
"piston": rep, "upper_foam": rep, "midsole": rep, "outsole": low,
|
|
277
|
+
}
|
|
278
|
+
self._prepare(sim, patches)
|
|
279
|
+
lower, _ = self._grid_bounds(sim)
|
|
280
|
+
mz = sim.simulation_parameters.margin_z
|
|
281
|
+
# grid bottom (metres) = outsole bottom (-50 mm = -0.05 m) minus margin.
|
|
282
|
+
assert lower[2] == pytest.approx(-0.05 - mz, abs=1e-6)
|
|
283
|
+
|
|
284
|
+
def test_bottom_margin_clearance_added(self, sim):
|
|
285
|
+
# Bottom (z-) clearance was previously missing entirely. With all parts
|
|
286
|
+
# flush at z=0, the grid bottom must sit margin_z below the lowest part.
|
|
287
|
+
flat = {"size": [0.1, 0.1, 0.05], "offset": [0.0, 0.0, 0.0], "resolution": [32, 32, 16]}
|
|
288
|
+
self._prepare(sim, {n: flat for n in
|
|
289
|
+
["piston", "upper_foam", "midsole", "outsole"]})
|
|
290
|
+
lower, _ = self._grid_bounds(sim)
|
|
291
|
+
mz = sim.simulation_parameters.margin_z
|
|
292
|
+
# Lowest part bottom is z=0 (piston cylinder sits higher), so the grid
|
|
293
|
+
# bottom is exactly -margin_z.
|
|
294
|
+
assert lower[2] == pytest.approx(-mz, abs=1e-6)
|
|
295
|
+
|
|
296
|
+
|
|
248
297
|
class TestSampleAssets:
|
|
249
298
|
def _make_success_workflow(self, job_names=()):
|
|
250
299
|
"""Return a mock workflow that is already in 'success' state."""
|
|
@@ -917,4 +966,68 @@ class TestSetupClient:
|
|
|
917
966
|
|
|
918
967
|
assert sim.project_id == "fallback-pid"
|
|
919
968
|
first_client.projects.create.assert_called_once()
|
|
920
|
-
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
class TestFindOrCreateProjectCancel:
|
|
972
|
+
"""find_or_create_project should cancel a reused project's in-flight
|
|
973
|
+
workflows when asked, since the new run overwrites those results."""
|
|
974
|
+
|
|
975
|
+
@pytest.fixture
|
|
976
|
+
def patched_client_class(self, monkeypatch):
|
|
977
|
+
from metafold.simulation import compression_simulation
|
|
978
|
+
fake_class = MagicMock()
|
|
979
|
+
monkeypatch.setattr(compression_simulation, "MetafoldClient", fake_class)
|
|
980
|
+
return fake_class
|
|
981
|
+
|
|
982
|
+
def _wf(self, wid, state):
|
|
983
|
+
w = MagicMock()
|
|
984
|
+
w.id = wid
|
|
985
|
+
w.state = state
|
|
986
|
+
return w
|
|
987
|
+
|
|
988
|
+
def test_cancels_active_workflows_on_existing_project(self, patched_client_class):
|
|
989
|
+
client = patched_client_class.return_value
|
|
990
|
+
existing = MagicMock()
|
|
991
|
+
existing.id = "pid-1"
|
|
992
|
+
client.projects.list.return_value = [existing]
|
|
993
|
+
# list(q="state:pending") -> [w1]; list(q="state:started") -> [w2]
|
|
994
|
+
client.workflows.list.side_effect = [
|
|
995
|
+
[self._wf("w1", "pending")],
|
|
996
|
+
[self._wf("w2", "started")],
|
|
997
|
+
]
|
|
998
|
+
|
|
999
|
+
pid = CompressionSimulation.find_or_create_project(
|
|
1000
|
+
"existing", access_token="tok", base_url="http://x/",
|
|
1001
|
+
cancel_existing_workflows=True,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
assert pid == "pid-1"
|
|
1005
|
+
cancelled = {c.args[0] for c in client.workflows.cancel.call_args_list}
|
|
1006
|
+
assert cancelled == {"w1", "w2"}
|
|
1007
|
+
|
|
1008
|
+
def test_no_cancel_when_flag_off(self, patched_client_class):
|
|
1009
|
+
client = patched_client_class.return_value
|
|
1010
|
+
existing = MagicMock()
|
|
1011
|
+
existing.id = "pid-1"
|
|
1012
|
+
client.projects.list.return_value = [existing]
|
|
1013
|
+
|
|
1014
|
+
CompressionSimulation.find_or_create_project(
|
|
1015
|
+
"existing", access_token="tok", base_url="http://x/",
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
client.workflows.cancel.assert_not_called()
|
|
1019
|
+
|
|
1020
|
+
def test_no_cancel_when_creating_new_project(self, patched_client_class):
|
|
1021
|
+
client = patched_client_class.return_value
|
|
1022
|
+
client.projects.list.return_value = [] # nothing existing -> create
|
|
1023
|
+
created = MagicMock()
|
|
1024
|
+
created.id = "new-pid"
|
|
1025
|
+
client.projects.create.return_value = created
|
|
1026
|
+
|
|
1027
|
+
pid = CompressionSimulation.find_or_create_project(
|
|
1028
|
+
"fresh", access_token="tok", base_url="http://x/",
|
|
1029
|
+
cancel_existing_workflows=True,
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
assert pid == "new-pid"
|
|
1033
|
+
client.workflows.cancel.assert_not_called()
|
|
@@ -249,6 +249,21 @@ class TestBuildSimulationParameters:
|
|
|
249
249
|
params = _build_simulation_parameters({"max_time": 0.02})
|
|
250
250
|
assert params.output_int == default.output_int
|
|
251
251
|
|
|
252
|
+
def test_enum_field_coerced_from_string(self):
|
|
253
|
+
from metafold.simulation.compression_simulation import ForceSource
|
|
254
|
+
params = _build_simulation_parameters({"force_source": "rigid_reaction_force"})
|
|
255
|
+
assert params.force_source == ForceSource.RIGID_REACTION_FORCE
|
|
256
|
+
|
|
257
|
+
def test_invalid_enum_string_raises(self):
|
|
258
|
+
with pytest.raises(ValueError):
|
|
259
|
+
_build_simulation_parameters({"force_source": "vibes"})
|
|
260
|
+
|
|
261
|
+
def test_boundary_conditions_dict_passes_through(self):
|
|
262
|
+
# String values are normalized later by create_sim_config
|
|
263
|
+
params = _build_simulation_parameters(
|
|
264
|
+
{"boundary_conditions": {"z-": "symmetric"}})
|
|
265
|
+
assert params.boundary_conditions == {"z-": "symmetric"}
|
|
266
|
+
|
|
252
267
|
|
|
253
268
|
class TestRunExperiment:
|
|
254
269
|
"""Integration-level tests: verify run_experiment wires up
|
|
@@ -505,6 +520,35 @@ class TestRunExperimentFromZip:
|
|
|
505
520
|
with pytest.raises(ValueError, match="experiment.json"):
|
|
506
521
|
run_experiment_from_zip(zip_path, output_path=str(tmp_path / "out"))
|
|
507
522
|
|
|
523
|
+
def test_shear_simulation_params_from_manifest(self, tmp_path):
|
|
524
|
+
from metafold.simulation.compression_simulation import ForceSource
|
|
525
|
+
|
|
526
|
+
manifest = {
|
|
527
|
+
"project_name": "shear",
|
|
528
|
+
"parts": [{"type": "piston_cylinder"}],
|
|
529
|
+
"simulation": {
|
|
530
|
+
"force_source": "rigid_reaction_force",
|
|
531
|
+
"boundary_conditions": {"z-": "symmetric"},
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
zip_path = self._make_zip(tmp_path, manifest)
|
|
535
|
+
captured = {}
|
|
536
|
+
fake_sim = MagicMock(project_id="p", stl_folder=tmp_path)
|
|
537
|
+
|
|
538
|
+
def capture_sim(*args, **kwargs):
|
|
539
|
+
captured.update(kwargs)
|
|
540
|
+
return fake_sim
|
|
541
|
+
|
|
542
|
+
with (
|
|
543
|
+
patch("metafold.simulation.run_experiment.CompressionSimulation", side_effect=capture_sim),
|
|
544
|
+
patch("metafold.simulation.run_experiment.CompressionExperiment"),
|
|
545
|
+
):
|
|
546
|
+
run_experiment_from_zip(zip_path, output_path=str(tmp_path / "out"))
|
|
547
|
+
|
|
548
|
+
params = captured["simulation_parameters"]
|
|
549
|
+
assert params.force_source == ForceSource.RIGID_REACTION_FORCE
|
|
550
|
+
assert params.boundary_conditions == {"z-": "symmetric"}
|
|
551
|
+
|
|
508
552
|
def test_generator_and_created_fields_ignored(self, tmp_path):
|
|
509
553
|
manifest = {
|
|
510
554
|
"project_name": "grasshopper_export",
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pytest
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
|
|
5
|
+
from metafold.simulation.compression_simulation import (
|
|
6
|
+
BoundaryCondition,
|
|
7
|
+
CompressionSimulation,
|
|
8
|
+
DEFAULT_BOUNDARY_CONDITIONS,
|
|
9
|
+
ExperimentMesh,
|
|
10
|
+
ExperimentPistonMesh,
|
|
11
|
+
ForceSource,
|
|
12
|
+
SimulationParameters,
|
|
13
|
+
WorkflowStep,
|
|
14
|
+
WorkflowStepType,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
ALL_FACES = ["x-", "x+", "y-", "y+", "z-", "z+"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def ply_folder(tmp_path):
|
|
22
|
+
for name in ["top.ply", "mid.ply", "out.ply"]:
|
|
23
|
+
(tmp_path / name).write_bytes(b"ply\n")
|
|
24
|
+
return tmp_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def basic_parts():
|
|
29
|
+
from metafold.materials import DEFAULT_MIDSOLE_NOMINAL, DEFAULT_OUTSOLE, DEFAULT_UPPER_FOAM
|
|
30
|
+
from metafold.simulation.compression_simulation import ExperimentPistonCylinder
|
|
31
|
+
return [
|
|
32
|
+
ExperimentPistonCylinder(),
|
|
33
|
+
ExperimentMesh("upper_foam", DEFAULT_UPPER_FOAM, "top.ply"),
|
|
34
|
+
ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply", representative_part=True),
|
|
35
|
+
ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _prepare_sim(parts, ply_folder, tmp_path, params=None, workflow_steps=None):
|
|
40
|
+
"""Build a sim with a fixed fake patch and run create_sim_config."""
|
|
41
|
+
kwargs = {}
|
|
42
|
+
if workflow_steps is not None:
|
|
43
|
+
kwargs["workflow_steps"] = workflow_steps
|
|
44
|
+
sim = CompressionSimulation(
|
|
45
|
+
parts=parts,
|
|
46
|
+
simulation_name="t",
|
|
47
|
+
stl_folder_path=str(ply_folder),
|
|
48
|
+
output_path=str(tmp_path / "out"),
|
|
49
|
+
client=MagicMock(),
|
|
50
|
+
simulation_parameters=params or SimulationParameters(),
|
|
51
|
+
**kwargs,
|
|
52
|
+
)
|
|
53
|
+
fake_patch = {"size": [0.1, 0.1, 0.05], "offset": [0.0, 0.0, 0.0], "resolution": [32, 32, 16]}
|
|
54
|
+
for info in sim.part_infos:
|
|
55
|
+
info.patch = fake_patch
|
|
56
|
+
if hasattr(info.part, "filename"):
|
|
57
|
+
info.volume_filename = f"{info.part_unique_name}_volume.bin"
|
|
58
|
+
sim.create_sim_config()
|
|
59
|
+
return sim
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestSimulationParametersBoundaries:
|
|
63
|
+
"""Tests for per-face boundary_conditions and force_source."""
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def prepared_sim(self, ply_folder, basic_parts, tmp_path, request):
|
|
67
|
+
params = getattr(request, "param", SimulationParameters())
|
|
68
|
+
return _prepare_sim(basic_parts, ply_folder, tmp_path, params)
|
|
69
|
+
|
|
70
|
+
def _bc(self, sim, side):
|
|
71
|
+
face = sim.ups.getroot().find(f".//BoundaryConditions/Face[@side='{side}']")
|
|
72
|
+
assert face is not None, f"Missing face side='{side}'"
|
|
73
|
+
return face.find("BCType")
|
|
74
|
+
|
|
75
|
+
def _bc_var(self, sim, side):
|
|
76
|
+
return self._bc(sim, side).get("var")
|
|
77
|
+
|
|
78
|
+
def _archive_labels(self, sim):
|
|
79
|
+
return [el.get("label") for el in sim.ups.getroot().findall(".//DataArchiver/save")]
|
|
80
|
+
|
|
81
|
+
# --- defaults ---
|
|
82
|
+
|
|
83
|
+
def test_defaults(self):
|
|
84
|
+
p = SimulationParameters()
|
|
85
|
+
assert p.boundary_conditions == {
|
|
86
|
+
"x-": BoundaryCondition.SYMMETRIC,
|
|
87
|
+
"x+": BoundaryCondition.SYMMETRIC,
|
|
88
|
+
"y-": BoundaryCondition.SYMMETRIC,
|
|
89
|
+
"y+": BoundaryCondition.SYMMETRIC,
|
|
90
|
+
"z-": BoundaryCondition.VELOCITY_DIRICHLET,
|
|
91
|
+
"z+": BoundaryCondition.VELOCITY_DIRICHLET,
|
|
92
|
+
}
|
|
93
|
+
assert p.force_source == ForceSource.BOUNDARY_FORCE
|
|
94
|
+
assert p.force_source_part == ""
|
|
95
|
+
|
|
96
|
+
def test_default_dict_is_not_shared(self):
|
|
97
|
+
SimulationParameters().boundary_conditions["z-"] = BoundaryCondition.SYMMETRIC
|
|
98
|
+
assert SimulationParameters().boundary_conditions["z-"] == BoundaryCondition.VELOCITY_DIRICHLET
|
|
99
|
+
assert DEFAULT_BOUNDARY_CONDITIONS["z-"] == BoundaryCondition.VELOCITY_DIRICHLET
|
|
100
|
+
|
|
101
|
+
def test_default_lateral_is_symmetric(self, prepared_sim):
|
|
102
|
+
for side in ["x-", "x+", "y-", "y+"]:
|
|
103
|
+
assert self._bc_var(prepared_sim, side) == "symmetry"
|
|
104
|
+
|
|
105
|
+
def test_default_zminus_is_dirichlet(self, prepared_sim):
|
|
106
|
+
assert self._bc_var(prepared_sim, "z-") == "Dirichlet"
|
|
107
|
+
|
|
108
|
+
def test_zplus_default_is_dirichlet(self, prepared_sim):
|
|
109
|
+
assert self._bc_var(prepared_sim, "z+") == "Dirichlet"
|
|
110
|
+
|
|
111
|
+
def test_all_faces_present(self, prepared_sim):
|
|
112
|
+
for side in ALL_FACES:
|
|
113
|
+
assert self._bc(prepared_sim, side) is not None
|
|
114
|
+
|
|
115
|
+
def test_default_no_rigid_reaction_force_in_archive(self, prepared_sim):
|
|
116
|
+
assert "RigidReactionForce" not in self._archive_labels(prepared_sim)
|
|
117
|
+
|
|
118
|
+
# --- per-face overrides ---
|
|
119
|
+
|
|
120
|
+
def test_free_lateral_sets_dirichlet_on_xy(self, ply_folder, basic_parts, tmp_path):
|
|
121
|
+
params = SimulationParameters(
|
|
122
|
+
boundary_conditions={
|
|
123
|
+
side: BoundaryCondition.VELOCITY_DIRICHLET
|
|
124
|
+
for side in ["x-", "x+", "y-", "y+"]
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
sim = _prepare_sim(basic_parts, ply_folder, tmp_path, params)
|
|
128
|
+
for side in ["x-", "x+", "y-", "y+"]:
|
|
129
|
+
assert self._bc_var(sim, side) == "Dirichlet"
|
|
130
|
+
|
|
131
|
+
def test_symmetric_zminus(self, ply_folder, basic_parts, tmp_path):
|
|
132
|
+
params = SimulationParameters(
|
|
133
|
+
boundary_conditions={"z-": BoundaryCondition.SYMMETRIC},
|
|
134
|
+
)
|
|
135
|
+
sim = _prepare_sim(basic_parts, ply_folder, tmp_path, params)
|
|
136
|
+
assert self._bc_var(sim, "z-") == "symmetry"
|
|
137
|
+
assert self._bc_var(sim, "z+") == "Dirichlet"
|
|
138
|
+
|
|
139
|
+
def test_neumann_face(self, ply_folder, basic_parts, tmp_path):
|
|
140
|
+
params = SimulationParameters(
|
|
141
|
+
boundary_conditions={"y+": BoundaryCondition.VELOCITY_NEUMANN},
|
|
142
|
+
)
|
|
143
|
+
sim = _prepare_sim(basic_parts, ply_folder, tmp_path, params)
|
|
144
|
+
bc = self._bc(sim, "y+")
|
|
145
|
+
assert bc.get("var") == "Neumann"
|
|
146
|
+
assert bc.get("label") == "Velocity"
|
|
147
|
+
assert bc.get("id") == "all"
|
|
148
|
+
assert bc.findtext("value") == "[0.0, 0.0, 0.0]"
|
|
149
|
+
|
|
150
|
+
def test_partial_dict_merges_over_defaults(self, ply_folder, basic_parts, tmp_path):
|
|
151
|
+
params = SimulationParameters(
|
|
152
|
+
boundary_conditions={"z-": BoundaryCondition.SYMMETRIC},
|
|
153
|
+
)
|
|
154
|
+
sim = _prepare_sim(basic_parts, ply_folder, tmp_path, params)
|
|
155
|
+
# untouched faces keep their defaults
|
|
156
|
+
for side in ["x-", "x+", "y-", "y+"]:
|
|
157
|
+
assert self._bc_var(sim, side) == "symmetry"
|
|
158
|
+
assert self._bc_var(sim, "z+") == "Dirichlet"
|
|
159
|
+
|
|
160
|
+
def test_string_values_accepted(self, ply_folder, basic_parts, tmp_path):
|
|
161
|
+
# Manifest JSON delivers plain strings
|
|
162
|
+
params = SimulationParameters(
|
|
163
|
+
boundary_conditions={"z-": "symmetric", "x-": "velocity_neumann"},
|
|
164
|
+
)
|
|
165
|
+
sim = _prepare_sim(basic_parts, ply_folder, tmp_path, params)
|
|
166
|
+
assert self._bc_var(sim, "z-") == "symmetry"
|
|
167
|
+
assert self._bc_var(sim, "x-") == "Neumann"
|
|
168
|
+
|
|
169
|
+
def test_unknown_face_raises(self, ply_folder, basic_parts, tmp_path):
|
|
170
|
+
params = SimulationParameters(
|
|
171
|
+
boundary_conditions={"w-": BoundaryCondition.SYMMETRIC},
|
|
172
|
+
)
|
|
173
|
+
with pytest.raises(ValueError, match="Unknown boundary condition face"):
|
|
174
|
+
_prepare_sim(basic_parts, ply_folder, tmp_path, params)
|
|
175
|
+
|
|
176
|
+
def test_invalid_condition_raises(self, ply_folder, basic_parts, tmp_path):
|
|
177
|
+
params = SimulationParameters(boundary_conditions={"z-": "bolted"})
|
|
178
|
+
with pytest.raises(ValueError, match="bolted"):
|
|
179
|
+
_prepare_sim(basic_parts, ply_folder, tmp_path, params)
|
|
180
|
+
|
|
181
|
+
# --- force_source = RIGID_REACTION_FORCE ---
|
|
182
|
+
|
|
183
|
+
def test_rigid_reaction_force_adds_archive_label(self, ply_folder, basic_parts, tmp_path):
|
|
184
|
+
params = SimulationParameters(force_source=ForceSource.RIGID_REACTION_FORCE)
|
|
185
|
+
sim = _prepare_sim(basic_parts, ply_folder, tmp_path, params)
|
|
186
|
+
assert "RigidReactionForce" in self._archive_labels(sim)
|
|
187
|
+
assert "BndyForce_zminus" in self._archive_labels(sim)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TestForceDisplacementKeys:
|
|
191
|
+
"""force-displacement workflow params reflect force_source/force_source_part."""
|
|
192
|
+
|
|
193
|
+
FD_STEPS = [WorkflowStep(WorkflowStepType.FORCE_DISPLACEMENT)]
|
|
194
|
+
|
|
195
|
+
def _built_sim(self, parts, ply_folder, tmp_path, params):
|
|
196
|
+
sim = _prepare_sim(
|
|
197
|
+
parts, ply_folder, tmp_path, params, workflow_steps=self.FD_STEPS)
|
|
198
|
+
sim.build_workflow()
|
|
199
|
+
return sim
|
|
200
|
+
|
|
201
|
+
def _fd_keys(self, sim):
|
|
202
|
+
return json.loads(sim.workflow_params["force-displacement.keys"])
|
|
203
|
+
|
|
204
|
+
def test_default_uses_boundary_force(self, ply_folder, basic_parts, tmp_path):
|
|
205
|
+
sim = self._built_sim(
|
|
206
|
+
basic_parts, ply_folder, tmp_path, SimulationParameters())
|
|
207
|
+
assert self._fd_keys(sim) == ["/boundary_force_zminus"]
|
|
208
|
+
assert sim.workflow_params["force-displacement.method"] == "BoundaryForce"
|
|
209
|
+
|
|
210
|
+
def test_rigid_reaction_defaults_to_piston(self, ply_folder, basic_parts, tmp_path):
|
|
211
|
+
params = SimulationParameters(force_source=ForceSource.RIGID_REACTION_FORCE)
|
|
212
|
+
sim = self._built_sim(basic_parts, ply_folder, tmp_path, params)
|
|
213
|
+
# The piston is always part_infos[0], i.e. material 0
|
|
214
|
+
assert self._fd_keys(sim) == ["/material0/rigid_reaction_force"]
|
|
215
|
+
|
|
216
|
+
def test_rigid_reaction_from_named_part(self, ply_folder, basic_parts, tmp_path):
|
|
217
|
+
params = SimulationParameters(
|
|
218
|
+
force_source=ForceSource.RIGID_REACTION_FORCE,
|
|
219
|
+
force_source_part="midsole",
|
|
220
|
+
)
|
|
221
|
+
sim = self._built_sim(basic_parts, ply_folder, tmp_path, params)
|
|
222
|
+
midsole_index = sim.get_part_info("midsole").material_index
|
|
223
|
+
assert self._fd_keys(sim) == [
|
|
224
|
+
f"/material{midsole_index}/rigid_reaction_force"
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
def test_rigid_reaction_unknown_part_raises(self, ply_folder, basic_parts, tmp_path):
|
|
228
|
+
params = SimulationParameters(
|
|
229
|
+
force_source=ForceSource.RIGID_REACTION_FORCE,
|
|
230
|
+
force_source_part="flux_capacitor",
|
|
231
|
+
)
|
|
232
|
+
with pytest.raises(RuntimeError, match="flux_capacitor"):
|
|
233
|
+
self._built_sim(basic_parts, ply_folder, tmp_path, params)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TestShearUPS:
|
|
237
|
+
"""Verify that CompressionSimulation generates a UPS structurally identical
|
|
238
|
+
to the reference shear test script for the same inputs.
|
|
239
|
+
|
|
240
|
+
The grid dimensions depend on the sampled mesh patch and can't be matched
|
|
241
|
+
exactly without running the mesh sampling job, so we test every structural
|
|
242
|
+
property the script controls: BCs, archive outputs, contact, materials,
|
|
243
|
+
viscoelastic modes, and time parameters.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
PISTON_MATERIAL = {
|
|
247
|
+
"density": 1730.0,
|
|
248
|
+
"thermal_conductivity": 45,
|
|
249
|
+
"specific_heat": 4.8e-4,
|
|
250
|
+
"constitutive_model": {
|
|
251
|
+
"type": "rigid",
|
|
252
|
+
"params": {"shear_modulus": 2667.0e6, "bulk_modulus": 8000.0e6},
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
PUCK_MATERIAL = {
|
|
257
|
+
"density": 130.0,
|
|
258
|
+
"thermal_conductivity": 45,
|
|
259
|
+
"specific_heat": 4.8e-4,
|
|
260
|
+
"constitutive_model": {
|
|
261
|
+
"type": "Maxwell_Weichert",
|
|
262
|
+
"params": {
|
|
263
|
+
"bulk_modulus": 600000,
|
|
264
|
+
"terminal_shear_modulus": 200000,
|
|
265
|
+
"viscoelastic_series": [
|
|
266
|
+
{"mode": "mode1", "relaxation_time": 0.005, "partial_shear_modulus": 100000},
|
|
267
|
+
{"mode": "mode2", "relaxation_time": 0.01, "partial_shear_modulus": 100000},
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
SHEAR_VELOCITY = [
|
|
274
|
+
[0.0, 0, 0, 0.0],
|
|
275
|
+
[0.0025, 0, 0, -0.25],
|
|
276
|
+
[0.01, 0, 1.732, -1.0],
|
|
277
|
+
[0.02, 0, 1.732, -1.0],
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
@pytest.fixture
|
|
281
|
+
def shear_sim(self, tmp_path):
|
|
282
|
+
from metafold.materials import Material
|
|
283
|
+
piston_mat = Material.from_dict(self.PISTON_MATERIAL)
|
|
284
|
+
puck_mat = Material.from_dict(self.PUCK_MATERIAL)
|
|
285
|
+
|
|
286
|
+
parts = [
|
|
287
|
+
ExperimentPistonMesh(
|
|
288
|
+
name="piston_top",
|
|
289
|
+
filename="piston_top.stl",
|
|
290
|
+
material=piston_mat,
|
|
291
|
+
velocity=self.SHEAR_VELOCITY,
|
|
292
|
+
),
|
|
293
|
+
ExperimentMesh("puck", puck_mat, "puck.stl", representative_part=True),
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
sim_params = SimulationParameters(
|
|
297
|
+
max_time=0.013,
|
|
298
|
+
init_time=0.0,
|
|
299
|
+
delt_min=0.0,
|
|
300
|
+
delt_max=0.001,
|
|
301
|
+
timestep_multiplier=0.4,
|
|
302
|
+
output_int=0.00075,
|
|
303
|
+
boundary_conditions={
|
|
304
|
+
side: BoundaryCondition.VELOCITY_DIRICHLET for side in ALL_FACES
|
|
305
|
+
},
|
|
306
|
+
force_source=ForceSource.RIGID_REACTION_FORCE,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
for name in ["piston_top.stl", "puck.stl"]:
|
|
310
|
+
(tmp_path / name).write_bytes(b"ply\n")
|
|
311
|
+
|
|
312
|
+
sim = CompressionSimulation(
|
|
313
|
+
parts=parts,
|
|
314
|
+
simulation_name="puck_5_A",
|
|
315
|
+
stl_folder_path=str(tmp_path),
|
|
316
|
+
output_path=str(tmp_path / "out"),
|
|
317
|
+
client=MagicMock(),
|
|
318
|
+
simulation_parameters=sim_params,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Use a fixed patch (grid dims vary with mesh; we only verify structure)
|
|
322
|
+
fake_patch = {"size": [0.1, 0.1, 0.03], "offset": [-0.05, -0.05, 0.0], "resolution": [64, 64, 20]}
|
|
323
|
+
for info in sim.part_infos:
|
|
324
|
+
info.patch = fake_patch
|
|
325
|
+
if hasattr(info.part, "filename"):
|
|
326
|
+
info.volume_filename = f"{info.part_unique_name}_volume.bin"
|
|
327
|
+
sim.create_sim_config()
|
|
328
|
+
return sim
|
|
329
|
+
|
|
330
|
+
def _root(self, shear_sim):
|
|
331
|
+
return shear_sim.ups.getroot()
|
|
332
|
+
|
|
333
|
+
def _bc(self, shear_sim, side):
|
|
334
|
+
face = self._root(shear_sim).find(f".//BoundaryConditions/Face[@side='{side}']")
|
|
335
|
+
assert face is not None, f"Missing face side='{side}'"
|
|
336
|
+
return face.find("BCType")
|
|
337
|
+
|
|
338
|
+
def _archive_labels(self, shear_sim):
|
|
339
|
+
return [el.get("label") for el in self._root(shear_sim).findall(".//DataArchiver/save")]
|
|
340
|
+
|
|
341
|
+
# --- time parameters ---
|
|
342
|
+
|
|
343
|
+
def test_max_time(self, shear_sim):
|
|
344
|
+
assert self._root(shear_sim).findtext(".//Time/maxTime") == "0.013"
|
|
345
|
+
|
|
346
|
+
def test_timestep_multiplier(self, shear_sim):
|
|
347
|
+
assert self._root(shear_sim).findtext(".//Time/timestep_multiplier") == "0.4"
|
|
348
|
+
|
|
349
|
+
def test_output_interval(self, shear_sim):
|
|
350
|
+
assert self._root(shear_sim).findtext(".//DataArchiver/outputInterval") == "0.00075"
|
|
351
|
+
|
|
352
|
+
# --- boundary conditions ---
|
|
353
|
+
|
|
354
|
+
def test_lateral_faces_are_dirichlet(self, shear_sim):
|
|
355
|
+
for side in ["x-", "x+", "y-", "y+"]:
|
|
356
|
+
assert self._bc(shear_sim, side).get("var") == "Dirichlet", f"side={side}"
|
|
357
|
+
|
|
358
|
+
def test_zminus_is_dirichlet(self, shear_sim):
|
|
359
|
+
assert self._bc(shear_sim, "z-").get("var") == "Dirichlet"
|
|
360
|
+
|
|
361
|
+
def test_zplus_is_dirichlet(self, shear_sim):
|
|
362
|
+
assert self._bc(shear_sim, "z+").get("var") == "Dirichlet"
|
|
363
|
+
|
|
364
|
+
# --- archive outputs ---
|
|
365
|
+
|
|
366
|
+
def test_archive_includes_rigid_reaction_force(self, shear_sim):
|
|
367
|
+
assert "RigidReactionForce" in self._archive_labels(shear_sim)
|
|
368
|
+
|
|
369
|
+
def test_archive_includes_boundary_forces(self, shear_sim):
|
|
370
|
+
labels = self._archive_labels(shear_sim)
|
|
371
|
+
assert "BndyForce_zminus" in labels
|
|
372
|
+
assert "BndyForce_zplus" in labels
|
|
373
|
+
|
|
374
|
+
def test_archive_includes_particle_data(self, shear_sim):
|
|
375
|
+
labels = self._archive_labels(shear_sim)
|
|
376
|
+
assert "p.x" in labels
|
|
377
|
+
assert "p.stress" in labels
|
|
378
|
+
|
|
379
|
+
# --- contact ---
|
|
380
|
+
|
|
381
|
+
def test_contact_type_is_rigid(self, shear_sim):
|
|
382
|
+
contact = self._root(shear_sim).find(".//contact")
|
|
383
|
+
assert contact is not None
|
|
384
|
+
assert contact.findtext("type") == "rigid"
|
|
385
|
+
|
|
386
|
+
def test_contact_has_velocity_file(self, shear_sim):
|
|
387
|
+
contact = self._root(shear_sim).find(".//contact")
|
|
388
|
+
assert contact.findtext("filename") == "velocity_piston_top.txt"
|
|
389
|
+
|
|
390
|
+
def test_contact_mu_is_zero(self, shear_sim):
|
|
391
|
+
contact = self._root(shear_sim).find(".//contact")
|
|
392
|
+
assert float(contact.findtext("mu")) == 0.0
|
|
393
|
+
|
|
394
|
+
def test_contact_direction(self, shear_sim):
|
|
395
|
+
contact = self._root(shear_sim).find(".//contact")
|
|
396
|
+
assert contact.findtext("direction") == "[1, 1, 1]"
|
|
397
|
+
|
|
398
|
+
# --- viscoelastic modes ---
|
|
399
|
+
|
|
400
|
+
def test_viscoelastic_modes_are_xml_elements_not_json(self, shear_sim):
|
|
401
|
+
vs = self._root(shear_sim).find(".//viscoelastic_series")
|
|
402
|
+
assert vs is not None
|
|
403
|
+
modes = vs.findall("mode")
|
|
404
|
+
assert len(modes) == 2
|
|
405
|
+
|
|
406
|
+
def test_viscoelastic_mode_names(self, shear_sim):
|
|
407
|
+
modes = self._root(shear_sim).findall(".//viscoelastic_series/mode")
|
|
408
|
+
names = [m.get("name") for m in modes]
|
|
409
|
+
assert names == ["mode1", "mode2"]
|
|
410
|
+
|
|
411
|
+
def test_viscoelastic_relaxation_times(self, shear_sim):
|
|
412
|
+
modes = self._root(shear_sim).findall(".//viscoelastic_series/mode")
|
|
413
|
+
times = [float(m.findtext("relaxation_time")) for m in modes]
|
|
414
|
+
assert times == [0.005, 0.01]
|
|
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
|