metafold 0.13.dev4__tar.gz → 0.13.1__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 (35) hide show
  1. {metafold-0.13.dev4 → metafold-0.13.1}/PKG-INFO +1 -1
  2. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/jobs.py +0 -76
  3. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/simulation/compression_simulation.py +44 -0
  4. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/simulation/run_experiment.py +11 -0
  5. {metafold-0.13.dev4 → metafold-0.13.1}/metafold.egg-info/PKG-INFO +1 -1
  6. {metafold-0.13.dev4 → metafold-0.13.1}/pyproject.toml +1 -1
  7. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_compression_simulation.py +78 -0
  8. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_jobs.py +0 -137
  9. {metafold-0.13.dev4 → metafold-0.13.1}/LICENSE +0 -0
  10. {metafold-0.13.dev4 → metafold-0.13.1}/README.md +0 -0
  11. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/__init__.py +0 -0
  12. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/api.py +0 -0
  13. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/assets.py +0 -0
  14. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/auth.py +0 -0
  15. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/client.py +0 -0
  16. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/exceptions.py +0 -0
  17. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/materials.py +0 -0
  18. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/projects.py +0 -0
  19. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/simulation/__init__.py +0 -0
  20. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/simulation/compression_experiment.py +0 -0
  21. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/utils.py +0 -0
  22. {metafold-0.13.dev4 → metafold-0.13.1}/metafold/workflows.py +0 -0
  23. {metafold-0.13.dev4 → metafold-0.13.1}/metafold.egg-info/SOURCES.txt +0 -0
  24. {metafold-0.13.dev4 → metafold-0.13.1}/metafold.egg-info/dependency_links.txt +0 -0
  25. {metafold-0.13.dev4 → metafold-0.13.1}/metafold.egg-info/requires.txt +0 -0
  26. {metafold-0.13.dev4 → metafold-0.13.1}/metafold.egg-info/top_level.txt +0 -0
  27. {metafold-0.13.dev4 → metafold-0.13.1}/setup.cfg +0 -0
  28. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_assets.py +0 -0
  29. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_compession_experiment.py +0 -0
  30. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_materials.py +0 -0
  31. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_projects.py +0 -0
  32. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_run_experiment.py +0 -0
  33. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_shear_simulation.py +0 -0
  34. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_utils.py +0 -0
  35. {metafold-0.13.dev4 → metafold-0.13.1}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.13.dev4
3
+ Version: 0.13.1
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -135,82 +135,6 @@ class JobsEndpoint:
135
135
  r: Response = self._client.get(url)
136
136
  return Job(**r.json())
137
137
 
138
- def run(
139
- self, type: str, params: dict[str, Any],
140
- name: str | None = None,
141
- timeout: int | float = 120,
142
- project_id: str | None = None,
143
- ) -> Job:
144
- """Dispatch a new job and wait for a result.
145
-
146
- See Metafold API docs for the full list of jobs.
147
-
148
- Args:
149
- type: Job type.
150
- params: Job parameters.
151
- name: Optional job name.
152
- timeout: Time in seconds to wait for a result.
153
- project_id: Job project ID.
154
-
155
- Returns:
156
- Completed job resource.
157
- """
158
- url = self.run_status(type, params, name=name, project_id=project_id)
159
- try:
160
- r: Response = self.poll(url, timeout)
161
- except PollTimeout as e:
162
- raise RuntimeError(
163
- f"Job '{name or type}' failed to complete within {timeout} seconds"
164
- ) from e
165
- return Job(**r.json())
166
-
167
- def run_status(
168
- self, type: str, params: dict[str, Any],
169
- name: str | None = None,
170
- project_id: str | None = None,
171
- ) -> str:
172
- """Dispatch a new job and return immediately without waiting for result.
173
-
174
- See Metafold API docs for the full list of jobs.
175
-
176
- Args:
177
- type: Job type.
178
- params: Job parameters.
179
- name: Optional job name.
180
- project_id: Job project ID.
181
-
182
- Returns:
183
- Job status url.
184
- """
185
- project_id = self._client.project_id(project_id)
186
- payload = asdict(type=type, parameters=params, name=name)
187
- r: Response = self._client.post(f"/projects/{project_id}/jobs", json=payload)
188
- return r.json()["link"]
189
-
190
- def poll(self, *args, **kwargs):
191
- return self._client.poll(*args, **kwargs)
192
-
193
- def update(
194
- self, job_id: str,
195
- name: str | None = None,
196
- project_id: str | None = None,
197
- ) -> Job:
198
- """Update a job.
199
-
200
- Args:
201
- job_id: ID of job to update.
202
- name: New job name. The existing name remains unchanged if None.
203
- project_id: Job project ID.
204
-
205
- Returns:
206
- Updated job resource.
207
- """
208
- project_id = self._client.project_id(project_id)
209
- url = f"/projects/{project_id}/jobs/{job_id}"
210
- payload = asdict(name=name)
211
- r: Response = self._client.patch(url, data=payload)
212
- return Job(**r.json())
213
-
214
138
  def delete(self, job_id: str, project_id: str | None = None):
215
139
  """Delete a job.
216
140
 
@@ -72,6 +72,14 @@ ZERO_VELOCITY = [
72
72
  ]
73
73
 
74
74
 
75
+ def _has_motion(velocity: Optional[list]) -> bool:
76
+ """True if a velocity profile has any non-zero component.
77
+ [time, vx, vy, vz]."""
78
+ if not velocity:
79
+ return False
80
+ return any(any(c for c in row[1:4]) for row in velocity)
81
+
82
+
75
83
  class BoundaryCondition(Enum):
76
84
  SYMMETRIC = "symmetric" # symmetry plane (acts as frictionless wall)
77
85
  VELOCITY_DIRICHLET = "velocity_dirichlet" # velocity fixed to zero at the face
@@ -291,6 +299,39 @@ class ExperimentSupportParallelepiped(ExperimentParallelepiped, ExperimentSuppor
291
299
  material: Material = field(default_factory=lambda: DEFAULT_SUPPORT_MATERIAL)
292
300
 
293
301
 
302
+ def validate_simulation_parts(parts: list) -> None:
303
+ """Raise ValueError if the parts can't produce a compression simulation.
304
+
305
+ Callers that dispatch work asynchronously should call this up front so
306
+ invalid input fails before any project or job is created.
307
+ """
308
+ if not parts:
309
+ return
310
+
311
+ has_piston = False
312
+ has_support = False
313
+ piston_moves = False
314
+ for p in parts:
315
+ if isinstance(p, ExperimentPistonBase):
316
+ has_piston = True
317
+ piston_moves = piston_moves or _has_motion(p.velocity)
318
+ elif isinstance(p, ExperimentSupportBase):
319
+ has_support = True
320
+
321
+ if not has_piston and not has_support:
322
+ raise ValueError(
323
+ "No piston part: a compression simulation needs "
324
+ "a rigid part to apply load. Add a part of type piston_mesh, "
325
+ "piston_cylinder, or piston_box."
326
+ )
327
+ # Supports are stationary by design, so only pistons must move.
328
+ if has_piston and not piston_moves:
329
+ raise ValueError(
330
+ "Piston velocity is zero. Set a velocity "
331
+ "profile of [time, vx, vy, vz] rows with a non-zero component."
332
+ )
333
+
334
+
294
335
  @dataclass
295
336
  class ReferenceData:
296
337
  csv_path: Path = Path("")
@@ -482,6 +523,9 @@ class CompressionSimulation:
482
523
  else:
483
524
  self.part_infos.append(inner_part)
484
525
 
526
+ # A rigid part drives the compression.
527
+ validate_simulation_parts(parts)
528
+
485
529
  self.setup_ply_files(stl_folder_path)
486
530
  self.setup_results(output_path)
487
531
  if client is None:
@@ -160,6 +160,7 @@ from metafold.simulation.compression_simulation import (
160
160
  ExperimentPistonMesh,
161
161
  SimulationParameters,
162
162
  WorkflowStep,
163
+ validate_simulation_parts,
163
164
  )
164
165
 
165
166
  # Build preset lookup from all Material constants in the materials module.
@@ -243,6 +244,16 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
243
244
  return parts
244
245
 
245
246
 
247
+ def validate_experiment_config(config: dict) -> None:
248
+ """Raise ValueError if a manifest can't produce a runnable experiment.
249
+ Mirrors the checks run_experiment performs so an API can reject bad
250
+ input before creating a project.
251
+ """
252
+ if not config.get("project_name", ""):
253
+ raise ValueError("project_name is required")
254
+ validate_simulation_parts(_build_parts(config.get("parts", [])))
255
+
256
+
246
257
  def _build_varying(varying_config: list[dict]) -> list[ExperimentVarying]:
247
258
  varying: list[ExperimentVarying] = []
248
259
  for entry in varying_config:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.13.dev4
3
+ Version: 0.13.1
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "metafold"
7
- version = "0.13.dev4"
7
+ version = "0.13.1"
8
8
  authors = [
9
9
  {name = "Metafold 3D", email = "info@metafold3d.com"},
10
10
  ]
@@ -15,6 +15,7 @@ from metafold.simulation.compression_simulation import (
15
15
  ExperimentPistonBase,
16
16
  ExperimentPistonCylinder,
17
17
  ExperimentPistonMesh,
18
+ ExperimentSupportMesh,
18
19
  SimulationParameters,
19
20
  WorkflowStep,
20
21
  WorkflowStepType,
@@ -84,6 +85,7 @@ class TestConstruction:
84
85
 
85
86
  def test_missing_ply_raises(self, ply_folder, tmp_path):
86
87
  parts = [
88
+ ExperimentPistonCylinder(),
87
89
  ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "nonexistent.ply"),
88
90
  ]
89
91
  with pytest.raises(ValueError, match="not found"):
@@ -95,6 +97,82 @@ class TestConstruction:
95
97
  client=MagicMock(),
96
98
  )
97
99
 
100
+ def test_no_piston_raises(self, ply_folder, tmp_path):
101
+ # Without a rigid part nothing drives the compression
102
+ parts = [
103
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
104
+ ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply"),
105
+ ]
106
+ with pytest.raises(ValueError, match="piston"):
107
+ CompressionSimulation(
108
+ parts=parts,
109
+ simulation_name="t",
110
+ stl_folder_path=str(ply_folder),
111
+ output_path=str(tmp_path / "out"),
112
+ client=MagicMock(),
113
+ )
114
+
115
+ def test_zero_piston_velocity_raises(self, ply_folder, tmp_path):
116
+ # A piston that never moves applies no load
117
+ parts = [
118
+ ExperimentPistonCylinder(velocity=[[0.0, 0, 0, 0.0], [0.04, 0, 0, 0.0]]),
119
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
120
+ ]
121
+ with pytest.raises(ValueError, match="velocity is zero"):
122
+ CompressionSimulation(
123
+ parts=parts,
124
+ simulation_name="t",
125
+ stl_folder_path=str(ply_folder),
126
+ output_path=str(tmp_path / "out"),
127
+ client=MagicMock(),
128
+ )
129
+
130
+ def test_empty_piston_velocity_raises(self, ply_folder, tmp_path):
131
+ parts = [
132
+ ExperimentPistonCylinder(velocity=[]),
133
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
134
+ ]
135
+ with pytest.raises(ValueError, match="velocity is zero"):
136
+ CompressionSimulation(
137
+ parts=parts,
138
+ simulation_name="t",
139
+ stl_folder_path=str(ply_folder),
140
+ output_path=str(tmp_path / "out"),
141
+ client=MagicMock(),
142
+ )
143
+
144
+ def test_stationary_support_with_moving_piston_ok(self, ply_folder, tmp_path):
145
+ # Supports are stationary by design; only the piston must move.
146
+ parts = [
147
+ ExperimentPistonCylinder(), # default velocity moves
148
+ ExperimentSupportMesh(name="base", filename="top.ply"),
149
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
150
+ ]
151
+ sim = CompressionSimulation(
152
+ parts=parts,
153
+ simulation_name="t",
154
+ stl_folder_path=str(ply_folder),
155
+ output_path=str(tmp_path / "out"),
156
+ client=MagicMock(),
157
+ )
158
+ assert len(sim.part_infos) == 3
159
+
160
+ def test_support_part_satisfies_rigid_requirement(self, ply_folder, tmp_path):
161
+ # A support is also a rigid driver, so it's a valid alternative to a piston.
162
+ parts = [
163
+ ExperimentSupportMesh(name="base", filename="top.ply"),
164
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
165
+ ]
166
+ sim = CompressionSimulation(
167
+ parts=parts,
168
+ simulation_name="t",
169
+ stl_folder_path=str(ply_folder),
170
+ output_path=str(tmp_path / "out"),
171
+ client=MagicMock(),
172
+ )
173
+ assert len(sim.part_infos) == 2
174
+
175
+
98
176
  class TestWorkflowStepsInput:
99
177
  def test_accepts_workflow_step_instance(self):
100
178
  steps = CompressionSimulation._clean_workflow_steps_input([
@@ -102,38 +102,6 @@ job_list = [
102
102
  },
103
103
  ]
104
104
 
105
- new_job = {
106
- "id": "1",
107
- "name": "My Job",
108
- "type": "test_job",
109
- "created": "Mon, 01 Jan 2024 00:00:00 GMT",
110
- "started": "Mon, 01 Jan 2024 00:00:00 GMT",
111
- "finished": "Mon, 01 Jan 2024 00:00:00 GMT",
112
- "error": None,
113
- "inputs": {
114
- "params": {
115
- "foo": "1",
116
- "bar": "a",
117
- "baz": "[2, \"b\"]",
118
- },
119
- },
120
- "outputs": {
121
- "params": None,
122
- },
123
- "needs": [],
124
- "project_id": "1",
125
- "workflow_id": None,
126
- "assets": [],
127
- "parameters": {
128
- "foo": "1",
129
- "bar": "a",
130
- "baz": "[2, \"b\"]",
131
- },
132
- "meta": None,
133
- }
134
-
135
- poll_count: int = 0
136
-
137
105
 
138
106
  class MockRequestHandler(BaseHTTPRequestHandler):
139
107
  def do_GET(self):
@@ -156,51 +124,6 @@ class MockRequestHandler(BaseHTTPRequestHandler):
156
124
  payload = deepcopy(job_list[-1])
157
125
  payload["assets"] = [asset_json]
158
126
  self.wfile.write(json.dumps(payload).encode())
159
- elif u.path == "/projects/1/jobs/1/status":
160
- global poll_count
161
- poll_count += 1
162
- if poll_count < 3:
163
- self.send_response(HTTPStatus.ACCEPTED)
164
- payload = deepcopy(new_job)
165
- payload["state"] = "started"
166
- else:
167
- self.send_response(HTTPStatus.CREATED)
168
- payload = deepcopy(new_job)
169
- payload.update({
170
- "state": "success",
171
- "assets": [asset_json],
172
- })
173
- self.send_header("Content-Type", "application/json")
174
- self.end_headers()
175
- self.wfile.write(json.dumps(payload).encode())
176
- else:
177
- self.send_error(HTTPStatus.NOT_FOUND)
178
-
179
- def do_POST(self):
180
- if self.path == "/projects/1/jobs":
181
- self.send_response(HTTPStatus.ACCEPTED)
182
- self.send_header("Content-Type", "application/json")
183
- self.end_headers()
184
- payload = deepcopy(new_job)
185
- payload.update({
186
- "state": "pending",
187
- "link": "http://localhost:8000/projects/1/jobs/1/status",
188
- })
189
- self.wfile.write(json.dumps(payload).encode())
190
- else:
191
- self.send_error(HTTPStatus.NOT_FOUND)
192
-
193
- def do_PATCH(self):
194
- if self.path == "/projects/1/jobs/1":
195
- self.send_response(HTTPStatus.OK)
196
- self.send_header("Content-Type", "application/json")
197
- self.end_headers()
198
- payload = deepcopy(job_list[-1])
199
- payload.update({
200
- "name": "baz",
201
- "assets": [asset_json],
202
- })
203
- self.wfile.write(json.dumps(payload).encode())
204
127
  else:
205
128
  self.send_error(HTTPStatus.NOT_FOUND)
206
129
 
@@ -245,63 +168,3 @@ def test_get_job(client):
245
168
  parameters=default_params,
246
169
  meta=None,
247
170
  )
248
-
249
-
250
- def test_run_job(client):
251
- params = {
252
- "foo": "1",
253
- "bar": "a",
254
- "baz": "[2, \"b\"]",
255
- }
256
- j = client.jobs.run("test_job", params, name="My Job")
257
- assert j == Job(
258
- id="1",
259
- name="My Job",
260
- type="test_job",
261
- state="success",
262
- created=default_dt,
263
- started=default_dt,
264
- finished=default_dt,
265
- inputs=IO(params=params),
266
- outputs=IO(),
267
- needs=[],
268
- project_id="1",
269
- workflow_id=None,
270
- assets=[asset_obj],
271
- parameters=params,
272
- meta=None,
273
- )
274
-
275
-
276
- def test_poll_job(client):
277
- params = {
278
- "foo": "1",
279
- "bar": "a",
280
- "baz": "[2, \"b\"]",
281
- }
282
- url = client.jobs.run_status("test_job", params, name="My Job")
283
- assert url == "http://localhost:8000/projects/1/jobs/1/status"
284
-
285
- r = client.jobs.poll(url)
286
- assert Job(**r.json()) == Job(
287
- id="1",
288
- name="My Job",
289
- type="test_job",
290
- state="success",
291
- created=default_dt,
292
- started=default_dt,
293
- finished=default_dt,
294
- inputs=IO(params=params),
295
- outputs=IO(),
296
- needs=[],
297
- project_id="1",
298
- workflow_id=None,
299
- assets=[asset_obj],
300
- parameters=params,
301
- meta=None,
302
- )
303
-
304
-
305
- def test_update_job(client):
306
- j = client.jobs.update("1", name="baz")
307
- assert j.name == "baz"
File without changes
File without changes
File without changes
File without changes
File without changes