metafold 0.12.dev11__tar.gz → 0.13.0__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.dev11 → metafold-0.13.0}/PKG-INFO +3 -3
  2. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/projects.py +3 -1
  3. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/simulation/compression_experiment.py +96 -4
  4. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/simulation/compression_simulation.py +96 -45
  5. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/simulation/run_experiment.py +30 -2
  6. {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/PKG-INFO +3 -3
  7. {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/requires.txt +1 -1
  8. {metafold-0.12.dev11 → metafold-0.13.0}/pyproject.toml +5 -5
  9. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_compession_experiment.py +133 -1
  10. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_compression_simulation.py +24 -4
  11. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_run_experiment.py +47 -0
  12. {metafold-0.12.dev11 → metafold-0.13.0}/LICENSE +0 -0
  13. {metafold-0.12.dev11 → metafold-0.13.0}/README.md +0 -0
  14. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/__init__.py +0 -0
  15. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/api.py +0 -0
  16. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/assets.py +0 -0
  17. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/auth.py +0 -0
  18. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/client.py +0 -0
  19. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/exceptions.py +0 -0
  20. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/jobs.py +0 -0
  21. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/materials.py +0 -0
  22. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/simulation/__init__.py +0 -0
  23. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/utils.py +0 -0
  24. {metafold-0.12.dev11 → metafold-0.13.0}/metafold/workflows.py +0 -0
  25. {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/SOURCES.txt +0 -0
  26. {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/dependency_links.txt +0 -0
  27. {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/top_level.txt +0 -0
  28. {metafold-0.12.dev11 → metafold-0.13.0}/setup.cfg +0 -0
  29. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_assets.py +0 -0
  30. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_jobs.py +0 -0
  31. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_materials.py +0 -0
  32. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_projects.py +0 -0
  33. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_shear_simulation.py +0 -0
  34. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_utils.py +0 -0
  35. {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev11
3
+ Version: 0.13.0
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -25,7 +25,7 @@ Classifier: Programming Language :: Python :: 3.12
25
25
  Classifier: Programming Language :: Python :: 3.13
26
26
  Classifier: Topic :: Scientific/Engineering
27
27
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
- Requires-Python: >=3.12
28
+ Requires-Python: >=3.11
29
29
  Description-Content-Type: text/markdown
30
30
  License-File: LICENSE
31
31
  Requires-Dist: attrs~=23.2
@@ -38,7 +38,7 @@ Requires-Dist: dotenv>=0.9.9; extra == "simulation"
38
38
  Requires-Dist: pandas>=2.3.3; extra == "simulation"
39
39
  Requires-Dist: plyfile>=1.1.3; extra == "simulation"
40
40
  Requires-Dist: pyyaml>=6.0.3; extra == "simulation"
41
- Requires-Dist: simulation-configurator==0.1.2; extra == "simulation"
41
+ Requires-Dist: simulation-configurator==0.2.*; extra == "simulation"
42
42
  Requires-Dist: tables>=3.11.1; extra == "simulation"
43
43
  Dynamic: license-file
44
44
 
@@ -13,9 +13,11 @@ class Access(Enum):
13
13
  Attributes:
14
14
  PRIVATE: Project is private to owner.
15
15
  PUBLIC: Project may be accessed by unauthenticated users.
16
+ ORG: Project may be accessed by members of the same organization.
16
17
  """
17
18
  PRIVATE = "private"
18
19
  PUBLIC = "public"
20
+ ORG = "org"
19
21
 
20
22
 
21
23
  class ProjectType(Enum):
@@ -65,7 +67,7 @@ class Project:
65
67
  access: Access = field(converter=check_access)
66
68
  created: datetime = field(converter=asdatetime)
67
69
  modified: datetime = field(converter=asdatetime)
68
- type: ProjectType = field(converter=check_project_type, default=ProjectType.UNSPECIFIED)
70
+ type: ProjectType = field(converter=check_project_type, default=ProjectType.UNSPECIFIED)
69
71
  thumbnail: str | None = None
70
72
  project: dict[str, Any] | None = None
71
73
  graph: dict[str, Any] | None = None
@@ -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,59 @@ 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 (None or "" = use the
210
+ auto name) and resolve to a unique name per sim (they become filenames
211
+ and zip member paths)."""
212
+ if not isinstance(self.simulation_names, list) or not all(
213
+ n is None or isinstance(n, str) for n in self.simulation_names
214
+ ):
215
+ raise ValueError(
216
+ f"simulation_names must be a list of strings, got: "
217
+ f"{self.simulation_names!r}"
218
+ )
219
+ self.simulation_names = [n or "" for n in self.simulation_names]
220
+ for name in self.simulation_names:
221
+ if any(c in name for c in "/\\:"):
222
+ raise ValueError(
223
+ f"simulation name {name!r} contains a path separator"
224
+ )
225
+
226
+ sim_count = self.varying[0].sim_count if self.varying else 1
227
+ resolved = [self._sim_name(i) for i in range(sim_count)]
228
+ duplicates = {n for n in resolved if resolved.count(n) > 1}
229
+ if duplicates:
230
+ raise ValueError(
231
+ f"simulation names must be unique, duplicated: {sorted(duplicates)}"
232
+ )
233
+
148
234
  @property
149
235
  def _experiment_state_filename(self) -> Path:
150
236
  assert self.base_simulation.out_dir is not None
@@ -157,12 +243,18 @@ class CompressionExperiment:
157
243
  """Delete the experiment state file and all per-sim results files."""
158
244
  if self._experiment_state_filename.is_file():
159
245
  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
246
+ # delete per-sim results: auto-named sims match the glob pattern,
247
+ # custom-named sims are deleted by name
162
248
  for f in self.base_simulation.out_dir.glob(
163
249
  f"{self.base_simulation.simulation_name}_sim*_results.json"
164
250
  ):
165
251
  f.unlink()
252
+ for name in self.simulation_names:
253
+ if not name:
254
+ continue
255
+ f = self.base_simulation.out_dir / f"{name}_results.json"
256
+ if f.is_file():
257
+ f.unlink()
166
258
  # delete out.zip if it exists
167
259
  out_zip = self.base_simulation.out_dir / "out.zip"
168
260
  if out_zip.is_file():
@@ -193,7 +285,7 @@ class CompressionExperiment:
193
285
  self.base_simulation.part_infos = base_part_infos
194
286
 
195
287
  # 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}"
288
+ clone.simulation_name = self._sim_name(sim_index)
197
289
 
198
290
  # ok now for the part infos we want to do something different, we want to keep shared elements the same but fork
199
291
  # the part infos which we are varying, that way we dont mess up other copies when experiments vary things
@@ -8,7 +8,7 @@ import uuid
8
8
 
9
9
  from requests import HTTPError
10
10
  import yaml
11
- from dotenv import load_dotenv
11
+ from dotenv import load_dotenv # type: ignore
12
12
  from metafold import MetafoldClient
13
13
  from pathlib import Path
14
14
  from numpy.typing import DTypeLike
@@ -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:
@@ -353,6 +354,8 @@ class CompressionSimulation:
353
354
  bvh_filename: Optional[str] = None
354
355
  # Sampling resolution (longest axis) assigned for the pass-2 sample job.
355
356
  sample_resolution: Optional[int] = None
357
+ # From the prep metrics job; None until prep completes.
358
+ interior_volume: Optional[float] = None
356
359
  patch: dict = field(default_factory=dict)
357
360
  jobs: dict[str, Any] = field(default_factory=lambda: {})
358
361
  part_unique_name = ""
@@ -374,6 +377,7 @@ class CompressionSimulation:
374
377
  "is_piston": isinstance(self.part, ExperimentPistonBase),
375
378
  "part_name": self.part.name,
376
379
  "disabled": self.disabled,
380
+ "interior_volume": self.interior_volume,
377
381
  }
378
382
 
379
383
  def restore_from_state_dict(self, saved: dict):
@@ -382,6 +386,7 @@ class CompressionSimulation:
382
386
  self.material_name = saved["material_name"]
383
387
  self.jobs = saved["jobs"]
384
388
  self.disabled = saved.get("disabled", False)
389
+ self.interior_volume = saved.get("interior_volume")
385
390
 
386
391
  client: MetafoldClient
387
392
  project_id: str = ""
@@ -425,7 +430,7 @@ class CompressionSimulation:
425
430
  simulation_name: str,
426
431
  project_id: str = "",
427
432
  stl_folder_path: str = "PLY",
428
- env_source: str = ".env.dev",
433
+ env_source: str | None = ".env.dev",
429
434
  output_path: str = "",
430
435
  client: Optional[MetafoldClient] = None,
431
436
  simulation_parameters: SimulationParameters = SimulationParameters(),
@@ -440,6 +445,7 @@ class CompressionSimulation:
440
445
  # stress_strain is opt-in (not default): callers add it via workflow_steps.
441
446
  WorkflowStep(WorkflowStepType.PARTICLE_DISPLACEMENT),
442
447
  WorkflowStep(WorkflowStepType.ENERGY_METRICS),
448
+ # reduce-results is auto-added in build_workflow (not a step).
443
449
  ],
444
450
  force_reupload_files=False,
445
451
  prep_workflow_batch_size: int = 10,
@@ -605,6 +611,7 @@ class CompressionSimulation:
605
611
  auth_domain: str = "metafold3d.us.auth0.com",
606
612
  base_url: str = "https://api.metafold3d.com/",
607
613
  cancel_existing_workflows: bool = True,
614
+ project_access: Access = Access.PRIVATE,
608
615
  ) -> str:
609
616
  """Find an existing project by name or create a new one.
610
617
 
@@ -636,13 +643,14 @@ class CompressionSimulation:
636
643
  return project_id
637
644
  created = client.projects.create(
638
645
  project_name,
639
- access=Access.PRIVATE,
646
+ access=project_access,
640
647
  type=ProjectType.DIGITAL_TEST_BENCH_EXPERIMENT,
641
648
  )
642
649
  return created.id
643
650
 
644
651
  def setup_client(self, env_source, project_id) -> MetafoldClient:
645
- load_dotenv(env_source)
652
+ if env_source is not None:
653
+ load_dotenv(env_source)
646
654
 
647
655
  if not project_id:
648
656
  self.project_id = os.environ.get("METAFOLD_PROJECT_ID", "")
@@ -824,6 +832,24 @@ class CompressionSimulation:
824
832
  params[f"{sample_mesh_job}.resolution"] = f"{resolution}"
825
833
  part_info.jobs["sample-mesh"] = sample_mesh_job
826
834
 
835
+ # Metrics run in prep (not the main workflow) so interior volume
836
+ # is known before dispatch, when the server manifest is uploaded.
837
+ if self._is_analysis_target(part_info.part) and self._get_step(
838
+ WorkflowStepType.METRICS, part_info.part.name
839
+ ):
840
+ metrics_job = f"metrics-{unique_name}"
841
+ jobs[metrics_job] = {
842
+ "type": "implicit/metrics",
843
+ "needs": [sample_mesh_job],
844
+ "assets": {"volume": sample_mesh_job},
845
+ "parameters": {
846
+ "volume_size": {"job": sample_mesh_job, "parameter": "patch_size"},
847
+ "volume_offset": {"job": sample_mesh_job, "parameter": "patch_offset"},
848
+ "volume_resolution": {"job": sample_mesh_job, "parameter": "patch_resolution"},
849
+ },
850
+ }
851
+ part_info.jobs["metrics"] = metrics_job
852
+
827
853
  workflow_yaml = yaml.dump({"jobs": jobs}, default_flow_style=False)
828
854
  return workflow_yaml, params, assets
829
855
 
@@ -1025,6 +1051,30 @@ class CompressionSimulation:
1025
1051
  if volume_asset is not None:
1026
1052
  info.volume_filename = volume_asset.filename
1027
1053
 
1054
+ metrics_job = prep_workflow_jobs.get(info.jobs.get("metrics", ""))
1055
+ if metrics_job is not None:
1056
+ raw = metrics_job.outputs.params.get("interior_volume")
1057
+ if raw is not None:
1058
+ info.interior_volume = float(raw)
1059
+
1060
+ def _total_interior_volume(self, workflow: Optional[Workflow] = None) -> float:
1061
+ """Sum interior volumes across analysis-target parts. Falls back to
1062
+ the main-workflow metrics readback for experiments dispatched before
1063
+ metrics moved to the prep workflow."""
1064
+ total = 0.0
1065
+ for part_info in self.part_infos:
1066
+ if not self._is_analysis_target(part_info.part) or part_info.disabled:
1067
+ continue
1068
+ if part_info.interior_volume is not None:
1069
+ total += part_info.interior_volume
1070
+ continue
1071
+ metrics_job = part_info.jobs.get("metrics", "")
1072
+ if workflow is not None and metrics_job:
1073
+ raw = workflow.get_parameter(f"{metrics_job}.interior_volume")
1074
+ if raw is not None:
1075
+ total += float(raw)
1076
+ return total
1077
+
1028
1078
  def get_part_info(self, name):
1029
1079
  for info in self.part_infos:
1030
1080
  if info.part.name == name:
@@ -1218,6 +1268,9 @@ class CompressionSimulation:
1218
1268
  ElementTree.indent(self.ups)
1219
1269
 
1220
1270
  def _add_to_workflow_metrics(self, part_infos: list[PartInfo]):
1271
+ # Duplicates the prep metrics jobs (which feed the server manifest):
1272
+ # removing these changed main-workflow scheduling and left it pending,
1273
+ # so they stay until that's understood.
1221
1274
  for part_info in part_infos:
1222
1275
  part = part_info.part
1223
1276
  if not self._is_analysis_target(part):
@@ -1241,7 +1294,6 @@ class CompressionSimulation:
1241
1294
  self.workflow_params[f"{metrics_job_name}.volume_resolution"] = json.dumps(
1242
1295
  part_info.patch["resolution"]
1243
1296
  )
1244
- part_info.jobs["metrics"] = metrics_job_name
1245
1297
 
1246
1298
  def _add_to_workflow_compress(self, part_infos: list[PartInfo]):
1247
1299
  compress_job_name = "compress"
@@ -1426,6 +1478,25 @@ class CompressionSimulation:
1426
1478
  part_infos, "force-displacement", "energy-metrics"
1427
1479
  )
1428
1480
 
1481
+ # One merged reduce-results job consumes the particle-data outputs the
1482
+ # browser renders and produces a compact (trimmed + int16 + decimated) HDF5.
1483
+ # Auto-added whenever compress ran; the manifest points DTB at its output.
1484
+ _REDUCE_SOURCE_JOBS = [
1485
+ "compress", "von-mises-stress", "effective-strain",
1486
+ "particle-displacement",
1487
+ ]
1488
+ _REDUCE_JOB_NAME = "reduce-results"
1489
+
1490
+ def _add_to_workflow_reduce_results(self):
1491
+ sources = [j for j in self._REDUCE_SOURCE_JOBS if j in self.workflow_jobs]
1492
+ if "compress" not in sources:
1493
+ return # nothing to preview without particle positions
1494
+ self.workflow_jobs[self._REDUCE_JOB_NAME] = {
1495
+ "type": "sim/postprocess/reduce-results",
1496
+ "needs": sources,
1497
+ "assets": {"data": [{"job": s, "asset": "output"} for s in sources]},
1498
+ }
1499
+
1429
1500
  def build_workflow(self, name_suffix=""):
1430
1501
  self.workflow_yaml = ""
1431
1502
  self.workflow_params = {}
@@ -1462,6 +1533,9 @@ class CompressionSimulation:
1462
1533
  elif step.type == WorkflowStepType.COMPUTE_BVH:
1463
1534
  pass # this is done on the prep workflow, not here
1464
1535
 
1536
+ # Always emit the browser preview when there's particle data to preview.
1537
+ self._add_to_workflow_reduce_results()
1538
+
1465
1539
  self.workflow_yaml = yaml.dump(
1466
1540
  {"jobs": self.workflow_jobs}, default_flow_style=False, sort_keys=False
1467
1541
  )
@@ -2046,16 +2120,7 @@ class CompressionSimulation:
2046
2120
  data["ply"] = f"{name}/ply/part.####.ply"
2047
2121
 
2048
2122
  # Sum interior volumes from all parts
2049
- total_volume = 0.0
2050
- for part_info in self.part_infos:
2051
- if self._is_analysis_target(part_info.part):
2052
- if part_info.disabled:
2053
- continue
2054
- metrics_job = part_info.jobs.get("metrics", "")
2055
- if metrics_job:
2056
- raw = w.get_parameter(f"{metrics_job}.interior_volume")
2057
- if raw is not None:
2058
- total_volume += float(raw)
2123
+ total_volume = self._total_interior_volume(w)
2059
2124
 
2060
2125
  result.update(
2061
2126
  {
@@ -2279,18 +2344,7 @@ class CompressionSimulation:
2279
2344
  }
2280
2345
 
2281
2346
  # Sum interior volumes across analysis-target parts.
2282
- total_volume = 0.0
2283
- for part_info in self.part_infos:
2284
- if not self._is_analysis_target(part_info.part):
2285
- continue
2286
- if part_info.disabled:
2287
- continue
2288
- metrics_job = part_info.jobs.get("metrics", "")
2289
- if not metrics_job:
2290
- continue
2291
- raw = w.get_parameter(f"{metrics_job}.interior_volume")
2292
- if raw is not None:
2293
- total_volume += float(raw)
2347
+ total_volume = self._total_interior_volume(w)
2294
2348
 
2295
2349
  result.update(
2296
2350
  {
@@ -2384,27 +2438,32 @@ class CompressionSimulation:
2384
2438
  "particle-displacement",
2385
2439
  ),
2386
2440
  ]
2441
+ # Point the particle datasets DTB renders at the merged reduce-results
2442
+ # job (compact int16/decimated), falling back to the raw job if preview
2443
+ # didn't run. Histograms are tiny — leave them on the raw job.
2444
+ reduce_job_id = job_id_lookup.get(self._REDUCE_JOB_NAME, "")
2387
2445
  for step, dataset_root, key_prefix, has_histogram, local_job in full_hdf_refs:
2388
2446
  if not self._contains_step(step):
2389
2447
  continue
2390
- job_id = job_id_lookup.get(local_job, "")
2448
+ raw_job_id = job_id_lookup.get(local_job, "")
2449
+ data_job_id = reduce_job_id or raw_job_id
2391
2450
  for i in range(n_materials):
2392
2451
  if self.part_infos[i].disabled:
2393
2452
  continue
2394
2453
  server_data[f"{key_prefix}{i}"] = {
2395
- "jobId": job_id,
2454
+ "jobId": data_job_id,
2396
2455
  "assetName": "output",
2397
2456
  "path": f"/material{i}/{dataset_root}",
2398
2457
  }
2399
2458
  if has_histogram:
2400
2459
  server_data[f"{key_prefix}Histogram{i}"] = {
2401
- "jobId": job_id,
2460
+ "jobId": raw_job_id,
2402
2461
  "assetName": "output",
2403
2462
  "path": f"/material{i}/{dataset_root}_histogram",
2404
2463
  }
2405
2464
 
2406
2465
  if self._contains_step(WorkflowStepType.COMPRESS):
2407
- job_id = job_id_lookup.get("compress", "")
2466
+ job_id = reduce_job_id or job_id_lookup.get("compress", "")
2408
2467
  for i in range(n_materials):
2409
2468
  if self.part_infos[i].disabled:
2410
2469
  continue
@@ -2445,19 +2504,7 @@ class CompressionSimulation:
2445
2504
  Volume is inlined (from prep workflow parameters); energy values are
2446
2505
  refs that the server resolves from the energy-metrics job output."""
2447
2506
  scalars: dict[str, Any] = {}
2448
-
2449
- total_volume = 0.0
2450
- for part_info in self.part_infos:
2451
- if not self._is_analysis_target(part_info.part):
2452
- continue
2453
- if part_info.disabled:
2454
- continue
2455
- metrics_job = part_info.jobs.get("metrics", "")
2456
- if metrics_job:
2457
- raw = workflow.get_parameter(f"{metrics_job}.interior_volume")
2458
- if raw is not None:
2459
- total_volume += float(raw)
2460
- scalars["volume"] = total_volume
2507
+ scalars["volume"] = self._total_interior_volume(workflow)
2461
2508
 
2462
2509
  if self._contains_step(WorkflowStepType.ENERGY_METRICS):
2463
2510
  em_job_id = job_id_lookup.get("energy-metrics", "")
@@ -2498,6 +2545,10 @@ class CompressionSimulation:
2498
2545
  server_result["data"] = {}
2499
2546
  server_results.append(server_result)
2500
2547
 
2548
+ # ensure material index is set on all parts for a multi-simulation experiment
2549
+ for i, part_info in enumerate(self.part_infos):
2550
+ part_info.material_index = i
2551
+
2501
2552
  self.make_manifest_v2(results=server_results)
2502
2553
  manifest = copy.deepcopy(self.manifest)
2503
2554
  manifest["results"] = server_results
@@ -2717,7 +2768,7 @@ class CompressionSimulation:
2717
2768
  "heading": "Absorbed Energy (J)",
2718
2769
  "filtering": False,
2719
2770
  }
2720
- )
2771
+ )
2721
2772
  self.manifest["resultsListConfig"].append(
2722
2773
  {
2723
2774
  "key": "loadingEnergy",
@@ -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 null/blank/missing entry falls back to
96
+ # "<name>_sim<i>". Names must be unique and must not contain path separators.
97
+ "simulation_names": ["slow piston", null, "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,
@@ -180,6 +193,8 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
180
193
 
181
194
  if part_type == "piston_cylinder":
182
195
  kwargs = {}
196
+ if "name" in entry:
197
+ kwargs["name"] = entry["name"]
183
198
  if "velocity" in entry:
184
199
  kwargs["velocity"] = entry["velocity"]
185
200
  if "shape_parameters" in entry:
@@ -190,6 +205,8 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
190
205
 
191
206
  elif part_type == "piston_box":
192
207
  kwargs = {}
208
+ if "name" in entry:
209
+ kwargs["name"] = entry["name"]
193
210
  if "velocity" in entry:
194
211
  kwargs["velocity"] = entry["velocity"]
195
212
  if "shape_parameters" in entry:
@@ -200,6 +217,8 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
200
217
 
201
218
  elif part_type == "piston_mesh":
202
219
  kwargs = {"filename": entry["file"]}
220
+ if "name" in entry:
221
+ kwargs["name"] = entry["name"]
203
222
  if "velocity" in entry:
204
223
  kwargs["velocity"] = entry["velocity"]
205
224
  if "material" in entry:
@@ -243,6 +262,14 @@ def _build_varying(varying_config: list[dict]) -> list[ExperimentVarying]:
243
262
  VaryMaterial(entry["part"], [_resolve_material(m) for m in materials])
244
263
  )
245
264
 
265
+ elif "part" in entry and "velocity" in entry:
266
+ velocities = entry["velocity"]
267
+ if not isinstance(velocities, list):
268
+ raise ValueError(
269
+ f"varying 'velocity' must be a list of velocity profiles, got: {velocities!r}"
270
+ )
271
+ varying.append(VaryVelocity(entry["part"], velocities))
272
+
246
273
  elif "field" in entry and "values" in entry:
247
274
  varying.append(VarySimulationParameter(entry["field"], entry["values"]))
248
275
 
@@ -366,6 +393,7 @@ def run_experiment(
366
393
  CompressionExperiment(
367
394
  simulation=sim,
368
395
  varying=varying,
396
+ simulation_names=config.get("simulation_names"),
369
397
  auto_download_results=wait_for_results,
370
398
  )
371
399
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev11
3
+ Version: 0.13.0
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -25,7 +25,7 @@ Classifier: Programming Language :: Python :: 3.12
25
25
  Classifier: Programming Language :: Python :: 3.13
26
26
  Classifier: Topic :: Scientific/Engineering
27
27
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
- Requires-Python: >=3.12
28
+ Requires-Python: >=3.11
29
29
  Description-Content-Type: text/markdown
30
30
  License-File: LICENSE
31
31
  Requires-Dist: attrs~=23.2
@@ -38,7 +38,7 @@ Requires-Dist: dotenv>=0.9.9; extra == "simulation"
38
38
  Requires-Dist: pandas>=2.3.3; extra == "simulation"
39
39
  Requires-Dist: plyfile>=1.1.3; extra == "simulation"
40
40
  Requires-Dist: pyyaml>=6.0.3; extra == "simulation"
41
- Requires-Dist: simulation-configurator==0.1.2; extra == "simulation"
41
+ Requires-Dist: simulation-configurator==0.2.*; extra == "simulation"
42
42
  Requires-Dist: tables>=3.11.1; extra == "simulation"
43
43
  Dynamic: license-file
44
44
 
@@ -9,5 +9,5 @@ dotenv>=0.9.9
9
9
  pandas>=2.3.3
10
10
  plyfile>=1.1.3
11
11
  pyyaml>=6.0.3
12
- simulation-configurator==0.1.2
12
+ simulation-configurator==0.2.*
13
13
  tables>=3.11.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "metafold"
7
- version = "0.12.dev11"
7
+ version = "0.13.0"
8
8
  authors = [
9
9
  {name = "Metafold 3D", email = "info@metafold3d.com"},
10
10
  ]
@@ -18,7 +18,7 @@ dependencies = [
18
18
  readme = "README.md"
19
19
  license = {file = "LICENSE"}
20
20
  description = "Metafold SDK for Python"
21
- requires-python = ">=3.12"
21
+ requires-python = ">=3.11"
22
22
  keywords = ["metafold", "api", "sdk", "implicit geometry"]
23
23
  classifiers = [
24
24
  "Development Status :: 3 - Alpha",
@@ -38,7 +38,7 @@ simulation = [
38
38
  "pandas>=2.3.3",
39
39
  "plyfile>=1.1.3",
40
40
  "pyyaml>=6.0.3",
41
- "simulation-configurator==0.1.2",
41
+ "simulation-configurator==0.2.*",
42
42
  "tables>=3.11.1",
43
43
  ]
44
44
 
@@ -68,7 +68,7 @@ packages = ["metafold", "metafold.simulation"]
68
68
  environments = ["sys_platform == 'linux'"]
69
69
 
70
70
  [tool.uv.sources]
71
- simulation-configurator = { index = "sim-config-idx" }
71
+ simulation-configurator = { index = "gitlab" }
72
72
 
73
73
  [[tool.mypy.overrides]]
74
74
  module = [
@@ -79,7 +79,7 @@ module = [
79
79
  ignore_missing_imports = true
80
80
 
81
81
  [[tool.uv.index]]
82
- name = "sim-config-idx"
82
+ name = "gitlab"
83
83
  url = "https://gitlab.com/api/v4/projects/75460563/packages/pypi/simple"
84
84
  explicit = true
85
85
  authenticate = "always"
@@ -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,132 @@ 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_none_entry_falls_back_to_auto(self, mock_sim):
335
+ exp = self._experiment(mock_sim, [None, "custom2", None])
336
+ exp.prepare()
337
+ assert [s.simulation_name for s in exp.sims] == [
338
+ "base_sim_sim0", "custom2", "base_sim_sim2"]
339
+
340
+ def test_non_list_rejected(self, mock_sim):
341
+ with pytest.raises(ValueError, match="list of strings"):
342
+ self._experiment(mock_sim, "baseline")
343
+
344
+ def test_non_string_entry_rejected(self, mock_sim):
345
+ with pytest.raises(ValueError, match="list of strings"):
346
+ self._experiment(mock_sim, ["a", 2])
347
+
348
+ def test_force_rerun_clears_custom_named_results(self, mock_sim):
349
+ stale = mock_sim.out_dir / "custom_a_results.json"
350
+ stale.write_text("{}")
351
+ exp = self._experiment(mock_sim, ["custom_a", "b", "c"])
352
+ exp.force_rerun = True
353
+ exp._clear_saved_state()
354
+ assert not stale.exists()
355
+
356
+
357
+ class TestVaryingCountMismatch:
358
+ def test_mismatched_counts_rejected(self, mock_sim):
359
+ with pytest.raises(ValueError, match="same number"):
360
+ CompressionExperiment(
361
+ mock_sim,
362
+ [
363
+ VaryMesh("midsole", "mid-*.ply"), # 3 files
364
+ VaryVelocity("piston", PROFILES), # 2 profiles
365
+ ],
366
+ auto_run=False,
367
+ )
368
+
369
+ def test_matching_counts_accepted(self, mock_sim):
370
+ exp = CompressionExperiment(
371
+ mock_sim,
372
+ [
373
+ VarySimulationParameter("max_time", [0.02, 0.04]),
374
+ VaryVelocity("piston", PROFILES),
375
+ ],
376
+ auto_run=False,
377
+ )
378
+ assert exp.varying[0].sim_count == exp.varying[1].sim_count == 2
379
+
380
+
249
381
  class TestVarySimulationParameter:
250
382
  def test_resolve_sets_sim_count(self, tmp_path):
251
383
  v = VarySimulationParameter("max_time", [0.02, 0.04, 0.06])
@@ -167,13 +167,15 @@ class TestBuildWorkflow:
167
167
  assert isinstance(piston.part, ExperimentPistonBase)
168
168
  assert piston.material_index == 0
169
169
 
170
- def test_build_workflow_has_per_part_metrics_jobs(self, prepared_sim):
171
- yaml_str, _ = prepared_sim.build_workflow()
170
+ def test_prep_workflow_has_per_part_metrics_jobs(self, prepared_sim):
171
+ mesh_parts = [p for p in prepared_sim.part_infos if hasattr(p.part, "filename")]
172
+ yaml_str, _, _ = prepared_sim._build_sample_workflow_for_batch(mesh_parts)
172
173
  loaded = yaml.safe_load(yaml_str)
173
- # one metrics job per non-piston mesh part
174
+ # one metrics job per non-piston mesh part, chained to its sample job
174
175
  for info in prepared_sim.part_infos:
175
176
  if not isinstance(info.part, ExperimentPistonBase) and hasattr(info.part, "filename"):
176
- assert f"metrics-{info.part_unique_name}" in loaded["jobs"]
177
+ job = loaded["jobs"][f"metrics-{info.part_unique_name}"]
178
+ assert job["needs"] == [f"sample-mesh-{info.part_unique_name}"]
177
179
 
178
180
  def test_build_workflow_compress_has_velocity(self, prepared_sim):
179
181
  _, params = prepared_sim.build_workflow()
@@ -577,6 +579,23 @@ jobs:
577
579
  - force-displacement
578
580
  assets:
579
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
580
599
  """
581
600
  assert yaml_str.strip() == expected_yaml.strip()
582
601
 
@@ -1130,6 +1149,7 @@ class TestSetupClient:
1130
1149
  simulation_name="t",
1131
1150
  stl_folder_path=str(ply_folder),
1132
1151
  output_path=str(tmp_path / "out"),
1152
+ env_source=None, # Ensure local .env files do not pollute test environment
1133
1153
  **kwargs,
1134
1154
  )
1135
1155
 
@@ -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
 
@@ -109,6 +110,18 @@ class TestBuildParts:
109
110
  assert isinstance(parts[0], ExperimentPistonMesh)
110
111
  assert parts[0].filename == "piston.ply"
111
112
 
113
+ def test_piston_parts_custom_name(self):
114
+ parts = _build_parts([
115
+ {"type": "piston_mesh", "name": "last", "file": "last.ply"},
116
+ {"type": "piston_cylinder", "name": "press"},
117
+ {"type": "piston_box", "name": "anvil"},
118
+ ])
119
+ assert [p.name for p in parts] == ["last", "press", "anvil"]
120
+
121
+ def test_piston_parts_default_name_when_omitted(self):
122
+ parts = _build_parts([{"type": "piston_mesh", "file": "piston.ply"}])
123
+ assert parts[0].name == "piston"
124
+
112
125
  def test_piston_mesh_with_velocity(self):
113
126
  velocity = [[0.0, 0, 0, 0.0], [0.02, 0, 0, -1.25], [0.04, 0, 0, 0.0]]
114
127
  parts = _build_parts([{"type": "piston_mesh", "file": "piston.ply", "velocity": velocity}])
@@ -220,6 +233,20 @@ class TestBuildVarying:
220
233
  assert varying[0].field_path == "max_time"
221
234
  assert varying[0].values == [0.02, 0.04, 0.06]
222
235
 
236
+ def test_vary_velocity(self):
237
+ profiles = [
238
+ [[0.0, 0, 0, 0.0], [0.04, 0, 0, -1.25]],
239
+ [[0.0, 0, 0, 0.0], [0.04, 0, 0, -2.50]],
240
+ ]
241
+ varying = _build_varying([{"part": "piston", "velocity": profiles}])
242
+ assert isinstance(varying[0], VaryVelocity)
243
+ assert varying[0].part_name == "piston"
244
+ assert varying[0].velocities == profiles
245
+
246
+ def test_vary_velocity_non_list_rejected(self):
247
+ with pytest.raises(ValueError, match="velocity"):
248
+ _build_varying([{"part": "piston", "velocity": "nope"}])
249
+
223
250
  def test_unrecognised_entry_raises(self):
224
251
  with pytest.raises(ValueError, match="Unrecognised varying"):
225
252
  _build_varying([{"unknown_key": "foo"}])
@@ -394,6 +421,26 @@ class TestRunExperiment:
394
421
  assert params.max_time == 0.02
395
422
  assert params.max_resolution == 128
396
423
 
424
+ def test_simulation_names_passed_to_experiment(self, ply_folder, tmp_path):
425
+ fake_sim = self._fake_sim(ply_folder)
426
+ captured = {}
427
+
428
+ def capture_experiment(*args, **kwargs):
429
+ captured.update(kwargs)
430
+
431
+ with (
432
+ patch("metafold.simulation.run_experiment.CompressionSimulation", return_value=fake_sim),
433
+ patch("metafold.simulation.run_experiment.CompressionExperiment", side_effect=capture_experiment),
434
+ ):
435
+ run_experiment({
436
+ "project_name": "t",
437
+ "output_path": str(tmp_path / "out"),
438
+ "parts": [],
439
+ "simulation_names": ["baseline", "stiff outsole"],
440
+ })
441
+
442
+ assert captured["simulation_names"] == ["baseline", "stiff outsole"]
443
+
397
444
  def test_workflow_steps_config_passed_to_simulation(self, ply_folder, tmp_path):
398
445
  fake_sim = self._fake_sim(ply_folder)
399
446
  captured = {}
File without changes
File without changes
File without changes
File without changes