metafold 0.12.dev2__py3-none-any.whl → 0.12.dev3__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,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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev2
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
@@ -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=q1LEGR7M3HIPttR1xQwNg5xIUswdHxQjdmczQ95H3nM,102969
15
+ metafold/simulation/run_experiment.py,sha256=jj5tZgOhEdPGZZcJFPL_pELfcFlosTMlWMRxgtur9Uk,14748
16
+ metafold-0.12.dev3.dist-info/licenses/LICENSE,sha256=LejZXzGwe9t0Ezk6g0bRmWUuFSI9vQSk1wJAc1NrrhE,1059
17
+ metafold-0.12.dev3.dist-info/METADATA,sha256=LrUlTJrbM8KcoTWxfbuQpFMLP4HfWYT1WEhnyJynVbM,3495
18
+ metafold-0.12.dev3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
+ metafold-0.12.dev3.dist-info/top_level.txt,sha256=0dvwa2N6gvl2x4T9c62V4MbYD2soFedI6hm3-lWgyew,9
20
+ metafold-0.12.dev3.dist-info/RECORD,,