metafold 0.12.dev2__py3-none-any.whl → 0.12.dev4__py3-none-any.whl

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/client.py CHANGED
@@ -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
@@ -2,19 +2,19 @@ metafold/__init__.py,sha256=fDZ2HCBNDOJfs1zAhCse6h2lOiqxl2uUMJT-JuO0XNE,1899
2
2
  metafold/api.py,sha256=SrlDgvaqy0Vjf9w67OmmOUs3YAQaIEFHb1rpxvpwFWw,1052
3
3
  metafold/assets.py,sha256=41OUQ1wKiYitEZMBRPbFfxtM3m4TYb_5S6wXNEbgi40,4569
4
4
  metafold/auth.py,sha256=O4B5vGUr2UmHbw_1BfWqZn-GWvmf_eFpZEpKGgBGrQQ,1079
5
- metafold/client.py,sha256=z34w7vN7sN3CD4QtfHXy-rBIpObykI3D_uOC-Jef6B8,3592
5
+ metafold/client.py,sha256=ZFftCr5gbRFkrxgBK7NYspBQEQnNzj2tWCbjPqxg5gw,3714
6
6
  metafold/exceptions.py,sha256=dJhBgbryBhJUf20FT3eU1rjQJQ-aDEZ25g-YCn_TwQk,110
7
7
  metafold/jobs.py,sha256=DxpQ9yrN7Th67XyW8UqjAIX0wfhwgPPCA9mNSKG_Z_Y,7029
8
8
  metafold/materials.py,sha256=heiK_kpU3YhKMPy1P9Zfds8yyUib0yqq3zethufchOc,10814
9
9
  metafold/projects.py,sha256=4APFtJQjycFhlIHCWTrOAuU3CLX8drSlgPz8lhVXhzI,6295
10
10
  metafold/utils.py,sha256=DnsW1Xr9mLpVHBNdPrdxNLLcjxSNMkWVvr7cH7UTAxY,1174
11
11
  metafold/workflows.py,sha256=Mhv3n3ChUHw1Vaj3q8HNDbOgljn03AF6WslZfXzexsI,7802
12
- metafold/simulation/__init__.py,sha256=siIIJv3hjiHLohSztAZcMi0eePqEkc_K5Q8Azsf90d4,776
12
+ metafold/simulation/__init__.py,sha256=tUW4PfyWnI6UFvIR__JKUD2FjNTM74fSeKuee7Ewjfo,816
13
13
  metafold/simulation/compression_experiment.py,sha256=Dc7EKPAmKEZsNcH5vnKuj3dQcFFeh0hsus9CIA0Ycv4,14221
14
- metafold/simulation/compression_simulation.py,sha256=-amZGNGJthXI4zvFlYm9xdYPjRJue04JJu8Ra2QAfaE,97589
15
- metafold/simulation/run_experiment.py,sha256=i-ZsYDYTbo-0EFXAjCqBvmA67b8VnRtLimGQNF76z8k,13647
16
- metafold-0.12.dev2.dist-info/licenses/LICENSE,sha256=LejZXzGwe9t0Ezk6g0bRmWUuFSI9vQSk1wJAc1NrrhE,1059
17
- metafold-0.12.dev2.dist-info/METADATA,sha256=I4rXO9-j762tHilLAHCvP1yYLYkgdCqfK2CTO4XFHNM,3495
18
- metafold-0.12.dev2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
- metafold-0.12.dev2.dist-info/top_level.txt,sha256=0dvwa2N6gvl2x4T9c62V4MbYD2soFedI6hm3-lWgyew,9
20
- metafold-0.12.dev2.dist-info/RECORD,,
14
+ metafold/simulation/compression_simulation.py,sha256=akAEJhYQrfWATnVXsOaa-zfx-ufWykEYEjEpR_ov0l4,103193
15
+ metafold/simulation/run_experiment.py,sha256=jj5tZgOhEdPGZZcJFPL_pELfcFlosTMlWMRxgtur9Uk,14748
16
+ metafold-0.12.dev4.dist-info/licenses/LICENSE,sha256=LejZXzGwe9t0Ezk6g0bRmWUuFSI9vQSk1wJAc1NrrhE,1059
17
+ metafold-0.12.dev4.dist-info/METADATA,sha256=mqzqN_sPPpFDIspJiBtnRi8PqkxuZEaDGVCXAfokWdU,3495
18
+ metafold-0.12.dev4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
+ metafold-0.12.dev4.dist-info/top_level.txt,sha256=0dvwa2N6gvl2x4T9c62V4MbYD2soFedI6hm3-lWgyew,9
20
+ metafold-0.12.dev4.dist-info/RECORD,,