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.
- {metafold-0.12.dev11 → metafold-0.13.0}/PKG-INFO +3 -3
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/projects.py +3 -1
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/simulation/compression_experiment.py +96 -4
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/simulation/compression_simulation.py +96 -45
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/simulation/run_experiment.py +30 -2
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/PKG-INFO +3 -3
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/requires.txt +1 -1
- {metafold-0.12.dev11 → metafold-0.13.0}/pyproject.toml +5 -5
- {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_compession_experiment.py +133 -1
- {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_compression_simulation.py +24 -4
- {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_run_experiment.py +47 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/LICENSE +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/README.md +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/__init__.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/api.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/assets.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/auth.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/client.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/exceptions.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/jobs.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/materials.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/simulation/__init__.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/utils.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold/workflows.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/SOURCES.txt +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/dependency_links.txt +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/metafold.egg-info/top_level.txt +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/setup.cfg +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_assets.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_jobs.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_materials.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_projects.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_shear_simulation.py +0 -0
- {metafold-0.12.dev11 → metafold-0.13.0}/tests/test_utils.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
161
|
-
#
|
|
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 =
|
|
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=
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "metafold"
|
|
7
|
-
version = "0.
|
|
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.
|
|
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.
|
|
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 = "
|
|
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 = "
|
|
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
|
|
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
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|