metafold 0.12.1__tar.gz → 0.13.dev1__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.12.1 → metafold-0.13.dev1}/PKG-INFO +1 -1
  2. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/simulation/compression_experiment.py +94 -4
  3. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/simulation/compression_simulation.py +33 -4
  4. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/simulation/run_experiment.py +32 -2
  5. {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/PKG-INFO +1 -1
  6. {metafold-0.12.1 → metafold-0.13.dev1}/pyproject.toml +1 -1
  7. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_compession_experiment.py +127 -1
  8. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_compression_simulation.py +17 -0
  9. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_run_experiment.py +35 -0
  10. {metafold-0.12.1 → metafold-0.13.dev1}/LICENSE +0 -0
  11. {metafold-0.12.1 → metafold-0.13.dev1}/README.md +0 -0
  12. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/__init__.py +0 -0
  13. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/api.py +0 -0
  14. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/assets.py +0 -0
  15. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/auth.py +0 -0
  16. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/client.py +0 -0
  17. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/exceptions.py +0 -0
  18. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/jobs.py +0 -0
  19. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/materials.py +0 -0
  20. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/projects.py +0 -0
  21. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/simulation/__init__.py +0 -0
  22. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/utils.py +0 -0
  23. {metafold-0.12.1 → metafold-0.13.dev1}/metafold/workflows.py +0 -0
  24. {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/SOURCES.txt +0 -0
  25. {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/dependency_links.txt +0 -0
  26. {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/requires.txt +0 -0
  27. {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/top_level.txt +0 -0
  28. {metafold-0.12.1 → metafold-0.13.dev1}/setup.cfg +0 -0
  29. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_assets.py +0 -0
  30. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_jobs.py +0 -0
  31. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_materials.py +0 -0
  32. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_projects.py +0 -0
  33. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_shear_simulation.py +0 -0
  34. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_utils.py +0 -0
  35. {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.1
3
+ Version: 0.13.dev1
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -1,6 +1,6 @@
1
1
  import copy
2
2
  import json
3
- from typing import Any, List, Union, cast
3
+ from typing import Any, List, Optional, Union, cast
4
4
  import glob
5
5
  from pathlib import Path
6
6
  from metafold.simulation.compression_simulation import (
@@ -106,6 +106,41 @@ class VarySimulationParameter(ExperimentVarying):
106
106
  setattr(obj, parts[-1], self.values[sim_index])
107
107
 
108
108
 
109
+ class VaryVelocity(ExperimentVarying):
110
+ """Vary a rigid part's velocity keyframes (e.g. a piston's motion profile).
111
+ Each value is a full velocity profile: a list of [t, vx, vy, vz] rows."""
112
+
113
+ part_name: str
114
+ velocities: List[list]
115
+
116
+ def __init__(self, part_name: str, velocities: List[list]):
117
+ # Each element is one profile: a list of [t,vx,vy,vz] rows.
118
+ for i, profile in enumerate(velocities):
119
+ if (
120
+ not isinstance(profile, list)
121
+ or not profile
122
+ or not all(isinstance(row, list) and len(row) == 4 for row in profile)
123
+ ):
124
+ raise ValueError(
125
+ f"velocity profile {i} for part {part_name!r} must be a list "
126
+ f"of [t, vx, vy, vz] rows, got: {profile!r}"
127
+ )
128
+ self.part_name = part_name
129
+ self.velocities = velocities
130
+
131
+ def resolve(self, _base_dir: Path) -> None:
132
+ self.sim_count = len(self.velocities)
133
+
134
+ def apply_to(self, sim_index: int, sim: CompressionSimulation):
135
+ part_info: CompressionSimulation.PartInfo = sim.get_part_info(self.part_name)
136
+ if not hasattr(part_info.part, "velocity"):
137
+ # Only rigid (piston/support) parts consume a velocity profile.
138
+ raise ValueError(
139
+ f"Part {self.part_name!r} is not a rigid (piston/support) part "
140
+ )
141
+ part_info.part.velocity = self.velocities[sim_index]
142
+
143
+
109
144
  class CompressionExperiment:
110
145
  base_simulation: CompressionSimulation
111
146
  varying: List[ExperimentVarying] = []
@@ -124,12 +159,16 @@ class CompressionExperiment:
124
159
  auto_upload_server_manifest: bool = True,
125
160
  use_legacy_results_format: bool = False,
126
161
  write_ups: bool = True,
162
+ simulation_names: Optional[List[str]] = None,
127
163
  ):
128
164
  simulation.use_legacy_results_format = use_legacy_results_format
129
165
  simulation.write_ups = write_ups
130
166
  self.use_legacy_results_format = use_legacy_results_format
131
167
  self.base_simulation = simulation
132
168
  self.varying = varying
169
+ # Optional per-simulation display names (index-aligned). A blank/missing
170
+ # entry falls back to the auto "<base>_sim<index>" name.
171
+ self.simulation_names = simulation_names or []
133
172
  self.verbose = verbose
134
173
  self.force_rerun = force_rerun
135
174
  self.varying_part_names = []
@@ -139,12 +178,57 @@ class CompressionExperiment:
139
178
  if hasattr(v, "part_name"):
140
179
  self.varying_part_names.append(v.part_name)
141
180
 
181
+ # Every varying must produce the same number of variants — prepare()
182
+ # sizes the sim loop from one of them and indexes into all of them.
183
+ counts = {v.sim_count for v in self.varying}
184
+ if len(counts) > 1:
185
+ raise ValueError(
186
+ "All varying entries must have the same number of values, got "
187
+ f"counts: {sorted(counts)}"
188
+ )
189
+
190
+ self._validate_simulation_names()
191
+
142
192
  if auto_run:
143
193
  self.prepare()
144
194
  self.run(auto_upload_server_manifest)
145
195
  if auto_download_results:
146
196
  self.download_results()
147
197
 
198
+ def _sim_name(self, sim_index: int) -> str:
199
+ """Name for the sim at sim_index: the caller-supplied name when present,
200
+ otherwise the auto "<base>_sim<index>" name."""
201
+ custom = (
202
+ self.simulation_names[sim_index]
203
+ if sim_index < len(self.simulation_names)
204
+ else None
205
+ )
206
+ return custom or f"{self.base_simulation.simulation_name}_sim{sim_index}"
207
+
208
+ def _validate_simulation_names(self):
209
+ """Names must be a list of path-safe strings and resolve to a unique
210
+ name per sim (they become filenames and zip member paths)."""
211
+ if not isinstance(self.simulation_names, list) or not all(
212
+ isinstance(n, str) for n in self.simulation_names
213
+ ):
214
+ raise ValueError(
215
+ f"simulation_names must be a list of strings, got: "
216
+ f"{self.simulation_names!r}"
217
+ )
218
+ for name in self.simulation_names:
219
+ if any(c in name for c in "/\\:"):
220
+ raise ValueError(
221
+ f"simulation name {name!r} contains a path separator"
222
+ )
223
+
224
+ sim_count = self.varying[0].sim_count if self.varying else 1
225
+ resolved = [self._sim_name(i) for i in range(sim_count)]
226
+ duplicates = {n for n in resolved if resolved.count(n) > 1}
227
+ if duplicates:
228
+ raise ValueError(
229
+ f"simulation names must be unique, duplicated: {sorted(duplicates)}"
230
+ )
231
+
148
232
  @property
149
233
  def _experiment_state_filename(self) -> Path:
150
234
  assert self.base_simulation.out_dir is not None
@@ -157,12 +241,18 @@ class CompressionExperiment:
157
241
  """Delete the experiment state file and all per-sim results files."""
158
242
  if self._experiment_state_filename.is_file():
159
243
  self._experiment_state_filename.unlink()
160
- # delete per-sim results — we don't know sim names without state,
161
- # so just glob for anything matching the pattern
244
+ # delete per-sim results: auto-named sims match the glob pattern,
245
+ # custom-named sims are deleted by name
162
246
  for f in self.base_simulation.out_dir.glob(
163
247
  f"{self.base_simulation.simulation_name}_sim*_results.json"
164
248
  ):
165
249
  f.unlink()
250
+ for name in self.simulation_names:
251
+ if not name:
252
+ continue
253
+ f = self.base_simulation.out_dir / f"{name}_results.json"
254
+ if f.is_file():
255
+ f.unlink()
166
256
  # delete out.zip if it exists
167
257
  out_zip = self.base_simulation.out_dir / "out.zip"
168
258
  if out_zip.is_file():
@@ -193,7 +283,7 @@ class CompressionExperiment:
193
283
  self.base_simulation.part_infos = base_part_infos
194
284
 
195
285
  # give each sim a unique name so zip paths don't collide
196
- clone.simulation_name = f"{self.base_simulation.simulation_name}_sim{sim_index}"
286
+ clone.simulation_name = self._sim_name(sim_index)
197
287
 
198
288
  # ok now for the part infos we want to do something different, we want to keep shared elements the same but fork
199
289
  # the part infos which we are varying, that way we dont mess up other copies when experiments vary things
@@ -311,6 +311,7 @@ class WorkflowStepType(Enum):
311
311
  STRESS_STRAIN = "stress_strain"
312
312
  PARTICLE_DISPLACEMENT = "particle_displacement"
313
313
  ENERGY_METRICS = "energy_metrics"
314
+ REDUCE_RESULTS = "reduce_results"
314
315
 
315
316
 
316
317
  class WorkflowStep:
@@ -444,6 +445,7 @@ class CompressionSimulation:
444
445
  # stress_strain is opt-in (not default): callers add it via workflow_steps.
445
446
  WorkflowStep(WorkflowStepType.PARTICLE_DISPLACEMENT),
446
447
  WorkflowStep(WorkflowStepType.ENERGY_METRICS),
448
+ # reduce-results is auto-added in build_workflow (not a step).
447
449
  ],
448
450
  force_reupload_files=False,
449
451
  prep_workflow_batch_size: int = 10,
@@ -1475,6 +1477,25 @@ class CompressionSimulation:
1475
1477
  part_infos, "force-displacement", "energy-metrics"
1476
1478
  )
1477
1479
 
1480
+ # One merged reduce-results job consumes the particle-data outputs the
1481
+ # browser renders and produces a compact (trimmed + int16 + decimated) HDF5.
1482
+ # Auto-added whenever compress ran; the manifest points DTB at its output.
1483
+ _REDUCE_SOURCE_JOBS = [
1484
+ "compress", "von-mises-stress", "effective-strain",
1485
+ "particle-displacement",
1486
+ ]
1487
+ _REDUCE_JOB_NAME = "reduce-results"
1488
+
1489
+ def _add_to_workflow_reduce_results(self):
1490
+ sources = [j for j in self._REDUCE_SOURCE_JOBS if j in self.workflow_jobs]
1491
+ if "compress" not in sources:
1492
+ return # nothing to preview without particle positions
1493
+ self.workflow_jobs[self._REDUCE_JOB_NAME] = {
1494
+ "type": "sim/postprocess/reduce-results",
1495
+ "needs": sources,
1496
+ "assets": {"data": [{"job": s, "asset": "output"} for s in sources]},
1497
+ }
1498
+
1478
1499
  def build_workflow(self, name_suffix=""):
1479
1500
  self.workflow_yaml = ""
1480
1501
  self.workflow_params = {}
@@ -1511,6 +1532,9 @@ class CompressionSimulation:
1511
1532
  elif step.type == WorkflowStepType.COMPUTE_BVH:
1512
1533
  pass # this is done on the prep workflow, not here
1513
1534
 
1535
+ # Always emit the browser preview when there's particle data to preview.
1536
+ self._add_to_workflow_reduce_results()
1537
+
1514
1538
  self.workflow_yaml = yaml.dump(
1515
1539
  {"jobs": self.workflow_jobs}, default_flow_style=False, sort_keys=False
1516
1540
  )
@@ -2413,27 +2437,32 @@ class CompressionSimulation:
2413
2437
  "particle-displacement",
2414
2438
  ),
2415
2439
  ]
2440
+ # Point the particle datasets DTB renders at the merged reduce-results
2441
+ # job (compact int16/decimated), falling back to the raw job if preview
2442
+ # didn't run. Histograms are tiny — leave them on the raw job.
2443
+ reduce_job_id = job_id_lookup.get(self._REDUCE_JOB_NAME, "")
2416
2444
  for step, dataset_root, key_prefix, has_histogram, local_job in full_hdf_refs:
2417
2445
  if not self._contains_step(step):
2418
2446
  continue
2419
- job_id = job_id_lookup.get(local_job, "")
2447
+ raw_job_id = job_id_lookup.get(local_job, "")
2448
+ data_job_id = reduce_job_id or raw_job_id
2420
2449
  for i in range(n_materials):
2421
2450
  if self.part_infos[i].disabled:
2422
2451
  continue
2423
2452
  server_data[f"{key_prefix}{i}"] = {
2424
- "jobId": job_id,
2453
+ "jobId": data_job_id,
2425
2454
  "assetName": "output",
2426
2455
  "path": f"/material{i}/{dataset_root}",
2427
2456
  }
2428
2457
  if has_histogram:
2429
2458
  server_data[f"{key_prefix}Histogram{i}"] = {
2430
- "jobId": job_id,
2459
+ "jobId": raw_job_id,
2431
2460
  "assetName": "output",
2432
2461
  "path": f"/material{i}/{dataset_root}_histogram",
2433
2462
  }
2434
2463
 
2435
2464
  if self._contains_step(WorkflowStepType.COMPRESS):
2436
- job_id = job_id_lookup.get("compress", "")
2465
+ job_id = reduce_job_id or job_id_lookup.get("compress", "")
2437
2466
  for i in range(n_materials):
2438
2467
  if self.part_infos[i].disabled:
2439
2468
  continue
@@ -55,7 +55,14 @@ JSON manifest format
55
55
  }
56
56
  }
57
57
  ]},
58
- {"field": "max_time", "values": [0.02, 0.04]}
58
+ # A rigid part's velocity keyframes; each value is a full [t,vx,vy,vz] profile:
59
+ {"part": "piston", "velocity": [
60
+ [[0.0, 0, 0, 0.0], [0.04, 0, 0, -1.25]],
61
+ [[0.0, 0, 0, 0.0], [0.04, 0, 0, -2.50]],
62
+ [[0.0, 0, 0, 0.0], [0.04, 0, 0, -5.00]]
63
+ ]},
64
+ # Every varying entry must have the same number of values (one per variant).
65
+ {"field": "max_time", "values": [0.02, 0.04, 0.06]}
59
66
  ],
60
67
 
61
68
  "simulation": {
@@ -82,7 +89,12 @@ JSON manifest format
82
89
  "compress",
83
90
  "force_displacement",
84
91
  {"step": "metrics", "parts": ["midsole", "outsole"]}
85
- ]
92
+ ],
93
+
94
+ # Optional display name per simulation variant, index-aligned with the variants
95
+ # the varying entries produce. A blank/missing entry falls back to "<name>_sim<i>".
96
+ # Names must be unique and must not contain path separators.
97
+ "simulation_names": ["slow piston", "fast piston", "fastest piston"]
86
98
  }
87
99
 
88
100
  Available material presets
@@ -137,6 +149,7 @@ from metafold.simulation.compression_experiment import (
137
149
  VaryMaterial,
138
150
  VaryMesh,
139
151
  VarySimulationParameter,
152
+ VaryVelocity,
140
153
  )
141
154
  from metafold.simulation.compression_simulation import (
142
155
  CompressionSimulation,
@@ -227,6 +240,14 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
227
240
  def _build_varying(varying_config: list[dict]) -> list[ExperimentVarying]:
228
241
  varying: list[ExperimentVarying] = []
229
242
  for entry in varying_config:
243
+ # Each entry varies exactly one thing; to vary several, use several entries.
244
+ kinds = [k for k in ("files", "material", "velocity", "values") if k in entry]
245
+ if len(kinds) > 1:
246
+ raise ValueError(
247
+ f"varying entry may only vary one of files/material/velocity/values, "
248
+ f"got {kinds}: {entry}"
249
+ )
250
+
230
251
  if "part" in entry and "files" in entry:
231
252
  files = entry["files"]
232
253
  if not isinstance(files, list):
@@ -243,6 +264,14 @@ def _build_varying(varying_config: list[dict]) -> list[ExperimentVarying]:
243
264
  VaryMaterial(entry["part"], [_resolve_material(m) for m in materials])
244
265
  )
245
266
 
267
+ elif "part" in entry and "velocity" in entry:
268
+ velocities = entry["velocity"]
269
+ if not isinstance(velocities, list):
270
+ raise ValueError(
271
+ f"varying 'velocity' must be a list of velocity profiles, got: {velocities!r}"
272
+ )
273
+ varying.append(VaryVelocity(entry["part"], velocities))
274
+
246
275
  elif "field" in entry and "values" in entry:
247
276
  varying.append(VarySimulationParameter(entry["field"], entry["values"]))
248
277
 
@@ -366,6 +395,7 @@ def run_experiment(
366
395
  CompressionExperiment(
367
396
  simulation=sim,
368
397
  varying=varying,
398
+ simulation_names=config.get("simulation_names"),
369
399
  auto_download_results=wait_for_results,
370
400
  )
371
401
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.1
3
+ Version: 0.13.dev1
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.12.1"
7
+ version = "0.13.dev1"
8
8
  authors = [
9
9
  {name = "Metafold 3D", email = "info@metafold3d.com"},
10
10
  ]
@@ -4,7 +4,13 @@ import copy
4
4
  from pathlib import Path
5
5
  from unittest.mock import MagicMock
6
6
 
7
- from metafold.simulation.compression_experiment import CompressionExperiment, VaryMesh, VaryMaterial, VarySimulationParameter
7
+ from metafold.simulation.compression_experiment import (
8
+ CompressionExperiment,
9
+ VaryMesh,
10
+ VaryMaterial,
11
+ VarySimulationParameter,
12
+ VaryVelocity,
13
+ )
8
14
  from metafold.materials import Material, ConstitutiveModel, RigidParams
9
15
 
10
16
 
@@ -246,6 +252,126 @@ class TestVaryMaterialSweep:
246
252
  assert v.materials[1].constitutive_model.params.shear_modulus == 20.0
247
253
 
248
254
 
255
+ PROFILES = [
256
+ [[0.0, 0, 0, 0.0], [0.04, 0, 0, -1.25]],
257
+ [[0.0, 0, 0, 0.0], [0.04, 0, 0, -2.50]],
258
+ ]
259
+
260
+
261
+ class TestVaryVelocity:
262
+ def test_resolve_sets_sim_count(self, tmp_path):
263
+ v = VaryVelocity("piston", PROFILES)
264
+ v.resolve(tmp_path)
265
+ assert v.sim_count == 2
266
+
267
+ def test_apply_to_sets_velocity(self, mock_sim):
268
+ v = VaryVelocity("piston", PROFILES)
269
+ v.apply_to(0, mock_sim)
270
+ assert mock_sim.get_part_info("piston").part.velocity == PROFILES[0]
271
+ v.apply_to(1, mock_sim)
272
+ assert mock_sim.get_part_info("piston").part.velocity == PROFILES[1]
273
+
274
+ def test_single_flat_profile_rejected(self):
275
+ # one profile passed where a list of profiles is expected
276
+ with pytest.raises(ValueError, match="list of \\[t, vx, vy, vz\\] rows"):
277
+ VaryVelocity("piston", [[0.0, 0, 0, 0.0], [0.04, 0, 0, -1.25]])
278
+
279
+ def test_malformed_row_rejected(self):
280
+ with pytest.raises(ValueError, match="rows"):
281
+ VaryVelocity("piston", [[[0.0, 0, 0]]]) # 3 floats, not 4
282
+
283
+ def test_non_rigid_part_rejected(self, mock_sim):
284
+ class PlainPart: # no velocity attribute, unlike piston/support parts
285
+ name = "midsole"
286
+
287
+ info = MagicMock()
288
+ info.part = PlainPart()
289
+ mock_sim.get_part_info.side_effect = lambda n: info
290
+
291
+ v = VaryVelocity("midsole", PROFILES)
292
+ with pytest.raises(ValueError, match="not a rigid"):
293
+ v.apply_to(0, mock_sim)
294
+
295
+
296
+ class TestSimulationNames:
297
+ def _experiment(self, mock_sim, names, varying=None):
298
+ return CompressionExperiment(
299
+ mock_sim,
300
+ varying if varying is not None else [VaryMesh("midsole", "mid-*.ply")],
301
+ simulation_names=names,
302
+ auto_run=False,
303
+ )
304
+
305
+ def test_custom_names_used(self, mock_sim):
306
+ exp = self._experiment(mock_sim, ["a", "b", "c"])
307
+ exp.prepare()
308
+ assert [s.simulation_name for s in exp.sims] == ["a", "b", "c"]
309
+
310
+ def test_blank_name_falls_back_to_auto(self, mock_sim):
311
+ exp = self._experiment(mock_sim, ["a", "", "c"])
312
+ exp.prepare()
313
+ assert [s.simulation_name for s in exp.sims] == ["a", "base_sim_sim1", "c"]
314
+
315
+ def test_short_list_falls_back_to_auto(self, mock_sim):
316
+ exp = self._experiment(mock_sim, ["a"])
317
+ exp.prepare()
318
+ assert [s.simulation_name for s in exp.sims] == [
319
+ "a", "base_sim_sim1", "base_sim_sim2"]
320
+
321
+ def test_duplicate_names_rejected(self, mock_sim):
322
+ with pytest.raises(ValueError, match="unique"):
323
+ self._experiment(mock_sim, ["a", "a", "c"])
324
+
325
+ def test_name_colliding_with_auto_name_rejected(self, mock_sim):
326
+ # sim1's fallback name is base_sim_sim1; naming sim0 the same collides
327
+ with pytest.raises(ValueError, match="unique"):
328
+ self._experiment(mock_sim, ["base_sim_sim1"])
329
+
330
+ def test_path_separator_rejected(self, mock_sim):
331
+ with pytest.raises(ValueError, match="path separator"):
332
+ self._experiment(mock_sim, ["run/1"])
333
+
334
+ def test_non_list_rejected(self, mock_sim):
335
+ with pytest.raises(ValueError, match="list of strings"):
336
+ self._experiment(mock_sim, "baseline")
337
+
338
+ def test_non_string_entry_rejected(self, mock_sim):
339
+ with pytest.raises(ValueError, match="list of strings"):
340
+ self._experiment(mock_sim, ["a", 2])
341
+
342
+ def test_force_rerun_clears_custom_named_results(self, mock_sim):
343
+ stale = mock_sim.out_dir / "custom_a_results.json"
344
+ stale.write_text("{}")
345
+ exp = self._experiment(mock_sim, ["custom_a", "b", "c"])
346
+ exp.force_rerun = True
347
+ exp._clear_saved_state()
348
+ assert not stale.exists()
349
+
350
+
351
+ class TestVaryingCountMismatch:
352
+ def test_mismatched_counts_rejected(self, mock_sim):
353
+ with pytest.raises(ValueError, match="same number"):
354
+ CompressionExperiment(
355
+ mock_sim,
356
+ [
357
+ VaryMesh("midsole", "mid-*.ply"), # 3 files
358
+ VaryVelocity("piston", PROFILES), # 2 profiles
359
+ ],
360
+ auto_run=False,
361
+ )
362
+
363
+ def test_matching_counts_accepted(self, mock_sim):
364
+ exp = CompressionExperiment(
365
+ mock_sim,
366
+ [
367
+ VarySimulationParameter("max_time", [0.02, 0.04]),
368
+ VaryVelocity("piston", PROFILES),
369
+ ],
370
+ auto_run=False,
371
+ )
372
+ assert exp.varying[0].sim_count == exp.varying[1].sim_count == 2
373
+
374
+
249
375
  class TestVarySimulationParameter:
250
376
  def test_resolve_sets_sim_count(self, tmp_path):
251
377
  v = VarySimulationParameter("max_time", [0.02, 0.04, 0.06])
@@ -579,6 +579,23 @@ jobs:
579
579
  - force-displacement
580
580
  assets:
581
581
  data: force-displacement
582
+ reduce-results:
583
+ type: sim/postprocess/reduce-results
584
+ needs:
585
+ - compress
586
+ - von-mises-stress
587
+ - effective-strain
588
+ - particle-displacement
589
+ assets:
590
+ data:
591
+ - job: compress
592
+ asset: output
593
+ - job: von-mises-stress
594
+ asset: output
595
+ - job: effective-strain
596
+ asset: output
597
+ - job: particle-displacement
598
+ asset: output
582
599
  """
583
600
  assert yaml_str.strip() == expected_yaml.strip()
584
601
 
@@ -36,6 +36,7 @@ from metafold.simulation.compression_experiment import (
36
36
  VaryMaterial,
37
37
  VaryMesh,
38
38
  VarySimulationParameter,
39
+ VaryVelocity,
39
40
  )
40
41
 
41
42
 
@@ -220,6 +221,20 @@ class TestBuildVarying:
220
221
  assert varying[0].field_path == "max_time"
221
222
  assert varying[0].values == [0.02, 0.04, 0.06]
222
223
 
224
+ def test_vary_velocity(self):
225
+ profiles = [
226
+ [[0.0, 0, 0, 0.0], [0.04, 0, 0, -1.25]],
227
+ [[0.0, 0, 0, 0.0], [0.04, 0, 0, -2.50]],
228
+ ]
229
+ varying = _build_varying([{"part": "piston", "velocity": profiles}])
230
+ assert isinstance(varying[0], VaryVelocity)
231
+ assert varying[0].part_name == "piston"
232
+ assert varying[0].velocities == profiles
233
+
234
+ def test_vary_velocity_non_list_rejected(self):
235
+ with pytest.raises(ValueError, match="velocity"):
236
+ _build_varying([{"part": "piston", "velocity": "nope"}])
237
+
223
238
  def test_unrecognised_entry_raises(self):
224
239
  with pytest.raises(ValueError, match="Unrecognised varying"):
225
240
  _build_varying([{"unknown_key": "foo"}])
@@ -394,6 +409,26 @@ class TestRunExperiment:
394
409
  assert params.max_time == 0.02
395
410
  assert params.max_resolution == 128
396
411
 
412
+ def test_simulation_names_passed_to_experiment(self, ply_folder, tmp_path):
413
+ fake_sim = self._fake_sim(ply_folder)
414
+ captured = {}
415
+
416
+ def capture_experiment(*args, **kwargs):
417
+ captured.update(kwargs)
418
+
419
+ with (
420
+ patch("metafold.simulation.run_experiment.CompressionSimulation", return_value=fake_sim),
421
+ patch("metafold.simulation.run_experiment.CompressionExperiment", side_effect=capture_experiment),
422
+ ):
423
+ run_experiment({
424
+ "project_name": "t",
425
+ "output_path": str(tmp_path / "out"),
426
+ "parts": [],
427
+ "simulation_names": ["baseline", "stiff outsole"],
428
+ })
429
+
430
+ assert captured["simulation_names"] == ["baseline", "stiff outsole"]
431
+
397
432
  def test_workflow_steps_config_passed_to_simulation(self, ply_folder, tmp_path):
398
433
  fake_sim = self._fake_sim(ply_folder)
399
434
  captured = {}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes