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.
Files changed (34) hide show
  1. {metafold-0.12.dev1 → metafold-0.12.dev3}/PKG-INFO +1 -1
  2. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/client.py +9 -4
  3. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/simulation/__init__.py +2 -0
  4. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/simulation/compression_simulation.py +149 -21
  5. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/simulation/run_experiment.py +27 -2
  6. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/PKG-INFO +1 -1
  7. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/SOURCES.txt +1 -0
  8. {metafold-0.12.dev1 → metafold-0.12.dev3}/pyproject.toml +1 -1
  9. {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_compression_simulation.py +114 -1
  10. {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_run_experiment.py +44 -0
  11. metafold-0.12.dev3/tests/test_shear_simulation.py +414 -0
  12. {metafold-0.12.dev1 → metafold-0.12.dev3}/LICENSE +0 -0
  13. {metafold-0.12.dev1 → metafold-0.12.dev3}/README.md +0 -0
  14. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/__init__.py +0 -0
  15. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/api.py +0 -0
  16. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/assets.py +0 -0
  17. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/auth.py +0 -0
  18. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/exceptions.py +0 -0
  19. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/jobs.py +0 -0
  20. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/materials.py +0 -0
  21. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/projects.py +0 -0
  22. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/simulation/compression_experiment.py +0 -0
  23. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/utils.py +0 -0
  24. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold/workflows.py +0 -0
  25. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/dependency_links.txt +0 -0
  26. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/requires.txt +0 -0
  27. {metafold-0.12.dev1 → metafold-0.12.dev3}/metafold.egg-info/top_level.txt +0 -0
  28. {metafold-0.12.dev1 → metafold-0.12.dev3}/setup.cfg +0 -0
  29. {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_assets.py +0 -0
  30. {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_compession_experiment.py +0 -0
  31. {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_jobs.py +0 -0
  32. {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_projects.py +0 -0
  33. {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_utils.py +0 -0
  34. {metafold-0.12.dev1 → metafold-0.12.dev3}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev1
3
+ Version: 0.12.dev3
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,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
- return existing[0].id
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 rigid primitive's bounding box
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
- grid_min = np.minimum(grid_min, pmin)
788
- grid_max = np.maximum(grid_max, pmax)
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
- 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)
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.workflow_params[f"{job_name_base}.keys"] = json.dumps(
1069
- ["/boundary_force_zminus"]
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev1
3
+ Version: 0.12.dev3
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.dev1"
7
+ version = "0.12.dev3"
8
8
  authors = [
9
9
  {name = "Metafold 3D", email = "info@metafold3d.com"},
10
10
  ]
@@ -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