metafold 0.12.dev2__tar.gz → 0.12.dev4__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.
Files changed (34) hide show
  1. {metafold-0.12.dev2 → metafold-0.12.dev4}/PKG-INFO +1 -1
  2. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/client.py +9 -4
  3. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/simulation/__init__.py +2 -0
  4. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/simulation/compression_simulation.py +153 -21
  5. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/simulation/run_experiment.py +21 -1
  6. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold.egg-info/PKG-INFO +1 -1
  7. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold.egg-info/SOURCES.txt +1 -0
  8. {metafold-0.12.dev2 → metafold-0.12.dev4}/pyproject.toml +1 -1
  9. {metafold-0.12.dev2 → metafold-0.12.dev4}/tests/test_compression_simulation.py +116 -1
  10. {metafold-0.12.dev2 → metafold-0.12.dev4}/tests/test_run_experiment.py +44 -0
  11. metafold-0.12.dev4/tests/test_shear_simulation.py +414 -0
  12. {metafold-0.12.dev2 → metafold-0.12.dev4}/LICENSE +0 -0
  13. {metafold-0.12.dev2 → metafold-0.12.dev4}/README.md +0 -0
  14. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/__init__.py +0 -0
  15. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/api.py +0 -0
  16. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/assets.py +0 -0
  17. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/auth.py +0 -0
  18. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/exceptions.py +0 -0
  19. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/jobs.py +0 -0
  20. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/materials.py +0 -0
  21. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/projects.py +0 -0
  22. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/simulation/compression_experiment.py +0 -0
  23. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/utils.py +0 -0
  24. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold/workflows.py +0 -0
  25. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold.egg-info/dependency_links.txt +0 -0
  26. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold.egg-info/requires.txt +0 -0
  27. {metafold-0.12.dev2 → metafold-0.12.dev4}/metafold.egg-info/top_level.txt +0 -0
  28. {metafold-0.12.dev2 → metafold-0.12.dev4}/setup.cfg +0 -0
  29. {metafold-0.12.dev2 → metafold-0.12.dev4}/tests/test_assets.py +0 -0
  30. {metafold-0.12.dev2 → metafold-0.12.dev4}/tests/test_compession_experiment.py +0 -0
  31. {metafold-0.12.dev2 → metafold-0.12.dev4}/tests/test_jobs.py +0 -0
  32. {metafold-0.12.dev2 → metafold-0.12.dev4}/tests/test_projects.py +0 -0
  33. {metafold-0.12.dev2 → metafold-0.12.dev4}/tests/test_utils.py +0 -0
  34. {metafold-0.12.dev2 → metafold-0.12.dev4}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev2
3
+ Version: 0.12.dev4
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -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
- body: dict[str, Any] = r.json()
59
- # Error responses aren't entirely consistent in the Metafold API,
60
- # for now we check for a handful of possible fields.
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(f"HTTP error occurred: {reason or r.reason}")
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,25 @@ 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
+ try:
593
+ client.workflows.cancel(wf.id, project_id=project_id)
594
+ cancelled.append(wf.id)
595
+ except HTTPError:
596
+ # Workflow already finished/uncancellable — ignore and move on.
597
+ pass
598
+ return cancelled
599
+
509
600
  @staticmethod
510
601
  def find_or_create_project(
511
602
  project_name: str,
@@ -514,12 +605,17 @@ class CompressionSimulation:
514
605
  client_secret: Optional[str] = None,
515
606
  auth_domain: str = "metafold3d.us.auth0.com",
516
607
  base_url: str = "https://api.metafold3d.com/",
608
+ cancel_existing_workflows: bool = True,
517
609
  ) -> str:
518
610
  """Find an existing project by name or create a new one.
519
611
 
520
612
  Supply either access_token (server context — never reads from env) or
521
613
  client_id + client_secret (local/programmable use, reads from env via
522
614
  setup_client). base_url must match the audience the token was issued for.
615
+
616
+ When cancel_existing_workflows is True and an existing project is
617
+ reused, its running workflows are cancelled first.
618
+
523
619
  Returns the project_id string.
524
620
  """
525
621
  client = MetafoldClient(
@@ -534,7 +630,10 @@ class CompressionSimulation:
534
630
  except HTTPError:
535
631
  existing = []
536
632
  if existing:
537
- return existing[0].id
633
+ project_id = existing[0].id
634
+ if cancel_existing_workflows:
635
+ CompressionSimulation.cancel_active_workflows(client, project_id)
636
+ return project_id
538
637
  created = client.projects.create(
539
638
  project_name,
540
639
  access=Access.PRIVATE,
@@ -775,17 +874,28 @@ class CompressionSimulation:
775
874
 
776
875
  grid_min = np.array(grid_patch["offset"], dtype=np.float32) * 1e-3
777
876
  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
877
 
782
- # Expand grid to contain every rigid primitive's bounding box
878
+ # Expand grid to contain every part's bounding box, so nothing is
879
+ # clipped (e.g. a support/outsole extending below the representative
880
+ # part). Primitives report bounds via get_bounds() in metres, mesh
881
+ # parts via their sampled patch (offset/size in mm)
783
882
  for info in self.part_infos:
784
883
  part = info.part
785
884
  if isinstance(part, ExperimentPrimitive):
786
885
  pmin, pmax = part.get_bounds()
787
- grid_min = np.minimum(grid_min, pmin)
788
- grid_max = np.maximum(grid_max, pmax)
886
+ elif info.patch:
887
+ pmin = np.array(info.patch["offset"], dtype=np.float32) / 1000.0
888
+ pmax = np.array(info.patch["size"], dtype=np.float32) / 1000.0 + pmin
889
+ else:
890
+ continue
891
+ grid_min = np.minimum(grid_min, pmin)
892
+ grid_max = np.maximum(grid_max, pmax)
893
+
894
+ # Apply margins after all parts are included. Note z- gets no margin
895
+ # since the bottom of the grid is the support boundary the stack sits on
896
+ grid_min[:2] -= self.simulation_parameters.margin_xy
897
+ grid_max[:2] += self.simulation_parameters.margin_xy
898
+ grid_max[2] += self.simulation_parameters.margin_z
789
899
 
790
900
  grid_size = grid_max - grid_min
791
901
  spacing = (
@@ -807,10 +917,11 @@ class CompressionSimulation:
807
917
  }
808
918
  )
809
919
  bcs = BoundaryConditions()
810
- for side in ["x-", "x+", "y-", "y+"]:
811
- bcs._sub_elements[side] = Face.fixed(side)
812
- for side in ["z-", "z+"]:
813
- bcs._sub_elements[side] = Face.unfixed(side)
920
+ boundary_conditions = _normalize_boundary_conditions(
921
+ self.simulation_parameters.boundary_conditions
922
+ )
923
+ for side, bc in boundary_conditions.items():
924
+ bcs._sub_elements[side] = _FACE_BUILDERS[bc](side)
814
925
  grid.add(bcs)
815
926
 
816
927
  store = GeometryStore()
@@ -867,16 +978,18 @@ class CompressionSimulation:
867
978
  store.add(geometry_element)
868
979
 
869
980
  outputs_indices_key = ",".join(str(i) for i in range(len(self.part_infos)))
981
+ all_outputs = [
982
+ "boundary_force_zminus",
983
+ "boundary_force_zplus",
984
+ "kinetic_energy",
985
+ "strain_energy",
986
+ ]
987
+ if self.simulation_parameters.force_source == ForceSource.RIGID_REACTION_FORCE:
988
+ all_outputs.append("rigid_reaction_force")
870
989
  archive = Archive(
871
990
  outputs={
872
991
  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
- ],
992
+ "all": all_outputs,
880
993
  },
881
994
  output_interval=self.simulation_parameters.output_int,
882
995
  )
@@ -1052,6 +1165,22 @@ class CompressionSimulation:
1052
1165
  )
1053
1166
  return piston_info
1054
1167
 
1168
+ def _get_force_source_info(self):
1169
+ """PartInfo of the body whose rigid_reaction_force is measured."""
1170
+ name = self.simulation_parameters.force_source_part
1171
+ if name:
1172
+ info = self.get_part_info(name)
1173
+ if info is None:
1174
+ raise RuntimeError(f"Unknown force_source_part: {name}")
1175
+ return info
1176
+ piston_info = self._get_piston_info()
1177
+ if piston_info is None:
1178
+ raise RuntimeError(
1179
+ "force_source RIGID_REACTION_FORCE requires a piston part "
1180
+ "or an explicit force_source_part"
1181
+ )
1182
+ return piston_info
1183
+
1055
1184
  def _add_to_workflow_von_mises_stress(self, part_infos: list[PartInfo]):
1056
1185
  self._add_to_workflow_postprocess(
1057
1186
  part_infos, "compress", "von-mises-stress", "cauchy_stress"
@@ -1065,9 +1194,12 @@ class CompressionSimulation:
1065
1194
  def _add_to_workflow_force_displacement(self, part_infos: list[PartInfo]):
1066
1195
  job_name_base = "force-displacement"
1067
1196
  self._add_to_workflow_postprocess(part_infos, "compress", job_name_base)
1068
- self.workflow_params[f"{job_name_base}.keys"] = json.dumps(
1069
- ["/boundary_force_zminus"]
1070
- )
1197
+ if self.simulation_parameters.force_source == ForceSource.RIGID_REACTION_FORCE:
1198
+ source = self._get_force_source_info()
1199
+ keys = [f"/material{source.material_index}/rigid_reaction_force"]
1200
+ else:
1201
+ keys = ["/boundary_force_zminus"]
1202
+ self.workflow_params[f"{job_name_base}.keys"] = json.dumps(keys)
1071
1203
  self.workflow_params[f"{job_name_base}.method"] = "BoundaryForce"
1072
1204
  # Find the piston part — there should be exactly one
1073
1205
  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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev2
3
+ Version: 0.12.dev4
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -27,5 +27,6 @@ tests/test_compression_simulation.py
27
27
  tests/test_jobs.py
28
28
  tests/test_projects.py
29
29
  tests/test_run_experiment.py
30
+ tests/test_shear_simulation.py
30
31
  tests/test_utils.py
31
32
  tests/test_workflows.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "metafold"
7
- version = "0.12.dev2"
7
+ version = "0.12.dev4"
8
8
  authors = [
9
9
  {name = "Metafold 3D", email = "info@metafold3d.com"},
10
10
  ]
@@ -245,6 +245,57 @@ 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
+ # grid bottom (metres) = outsole bottom (-50 mm = -0.05 m), flush — the
281
+ # z- face is the support boundary and gets NO margin.
282
+ assert lower[2] == pytest.approx(-0.05, abs=1e-6)
283
+
284
+ def test_bottom_is_flush_no_margin(self, sim):
285
+ # The z- face is the support boundary the stack reacts against, so it
286
+ # must sit flush with the lowest part — adding margin there leaves the
287
+ # stack unsupported and it sags under the piston before compressing.
288
+ flat = {"size": [100.0, 100.0, 50.0], "offset": [0.0, 0.0, 0.0], "resolution": [32, 32, 16]}
289
+ self._prepare(sim, {n: flat for n in
290
+ ["piston", "upper_foam", "midsole", "outsole"]})
291
+ lower, upper = self._grid_bounds(sim)
292
+ mz = sim.simulation_parameters.margin_z
293
+ # Lowest part bottom is z=0 -> grid bottom is exactly 0 (no margin).
294
+ assert lower[2] == pytest.approx(0.0, abs=1e-6)
295
+ # Top (z+, above the piston) still gets clearance.
296
+ assert upper[2] > 0.05 + mz - 1e-6
297
+
298
+
248
299
  class TestSampleAssets:
249
300
  def _make_success_workflow(self, job_names=()):
250
301
  """Return a mock workflow that is already in 'success' state."""
@@ -917,4 +968,68 @@ class TestSetupClient:
917
968
 
918
969
  assert sim.project_id == "fallback-pid"
919
970
  first_client.projects.create.assert_called_once()
920
-
971
+
972
+
973
+ class TestFindOrCreateProjectCancel:
974
+ """find_or_create_project should cancel a reused project's in-flight
975
+ workflows when asked, since the new run overwrites those results."""
976
+
977
+ @pytest.fixture
978
+ def patched_client_class(self, monkeypatch):
979
+ from metafold.simulation import compression_simulation
980
+ fake_class = MagicMock()
981
+ monkeypatch.setattr(compression_simulation, "MetafoldClient", fake_class)
982
+ return fake_class
983
+
984
+ def _wf(self, wid, state):
985
+ w = MagicMock()
986
+ w.id = wid
987
+ w.state = state
988
+ return w
989
+
990
+ def test_cancels_active_workflows_on_existing_project(self, patched_client_class):
991
+ client = patched_client_class.return_value
992
+ existing = MagicMock()
993
+ existing.id = "pid-1"
994
+ client.projects.list.return_value = [existing]
995
+ # list(q="state:pending") -> [w1]; list(q="state:started") -> [w2]
996
+ client.workflows.list.side_effect = [
997
+ [self._wf("w1", "pending")],
998
+ [self._wf("w2", "started")],
999
+ ]
1000
+
1001
+ pid = CompressionSimulation.find_or_create_project(
1002
+ "existing", access_token="tok", base_url="http://x/",
1003
+ cancel_existing_workflows=True,
1004
+ )
1005
+
1006
+ assert pid == "pid-1"
1007
+ cancelled = {c.args[0] for c in client.workflows.cancel.call_args_list}
1008
+ assert cancelled == {"w1", "w2"}
1009
+
1010
+ def test_no_cancel_when_flag_off(self, patched_client_class):
1011
+ client = patched_client_class.return_value
1012
+ existing = MagicMock()
1013
+ existing.id = "pid-1"
1014
+ client.projects.list.return_value = [existing]
1015
+
1016
+ CompressionSimulation.find_or_create_project(
1017
+ "existing", access_token="tok", base_url="http://x/",
1018
+ )
1019
+
1020
+ client.workflows.cancel.assert_not_called()
1021
+
1022
+ def test_no_cancel_when_creating_new_project(self, patched_client_class):
1023
+ client = patched_client_class.return_value
1024
+ client.projects.list.return_value = [] # nothing existing -> create
1025
+ created = MagicMock()
1026
+ created.id = "new-pid"
1027
+ client.projects.create.return_value = created
1028
+
1029
+ pid = CompressionSimulation.find_or_create_project(
1030
+ "fresh", access_token="tok", base_url="http://x/",
1031
+ cancel_existing_workflows=True,
1032
+ )
1033
+
1034
+ assert pid == "new-pid"
1035
+ 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