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.
- {metafold-0.12.1 → metafold-0.13.dev1}/PKG-INFO +1 -1
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/simulation/compression_experiment.py +94 -4
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/simulation/compression_simulation.py +33 -4
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/simulation/run_experiment.py +32 -2
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/PKG-INFO +1 -1
- {metafold-0.12.1 → metafold-0.13.dev1}/pyproject.toml +1 -1
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_compession_experiment.py +127 -1
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_compression_simulation.py +17 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_run_experiment.py +35 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/LICENSE +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/README.md +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/__init__.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/api.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/assets.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/auth.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/client.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/exceptions.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/jobs.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/materials.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/projects.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/simulation/__init__.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/utils.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold/workflows.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/SOURCES.txt +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/dependency_links.txt +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/requires.txt +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/metafold.egg-info/top_level.txt +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/setup.cfg +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_assets.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_jobs.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_materials.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_projects.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_shear_simulation.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_utils.py +0 -0
- {metafold-0.12.1 → metafold-0.13.dev1}/tests/test_workflows.py +0 -0
|
@@ -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
|
|
161
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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
|
|
|
@@ -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,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
|
|
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
|