metafold 0.12.dev6__tar.gz → 0.12.dev8__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.dev6 → metafold-0.12.dev8}/PKG-INFO +1 -1
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/simulation/compression_simulation.py +189 -31
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/simulation/run_experiment.py +12 -1
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/PKG-INFO +1 -1
- {metafold-0.12.dev6 → metafold-0.12.dev8}/pyproject.toml +1 -1
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_compression_simulation.py +155 -18
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_run_experiment.py +33 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/LICENSE +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/README.md +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/__init__.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/api.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/assets.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/auth.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/client.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/exceptions.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/jobs.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/materials.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/projects.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/simulation/__init__.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/simulation/compression_experiment.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/utils.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/workflows.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/SOURCES.txt +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/dependency_links.txt +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/requires.txt +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/top_level.txt +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/setup.cfg +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_assets.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_compession_experiment.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_jobs.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_materials.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_projects.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_shear_simulation.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_utils.py +0 -0
- {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_workflows.py +0 -0
|
@@ -345,6 +345,15 @@ class CompressionSimulation:
|
|
|
345
345
|
# workflow. Downstream main-workflow jobs (metrics, compress) consume
|
|
346
346
|
# this directly as an asset by filename.
|
|
347
347
|
volume_filename: Optional[str] = None
|
|
348
|
+
# Mesh bounds ({"min": [...], "max": [...]}, mm) reported by the
|
|
349
|
+
# pass-1 preprocess job; used to density-match sampling resolutions.
|
|
350
|
+
bounds: Optional[dict] = None
|
|
351
|
+
# Output asset filenames from the pass-1 preprocess/BVH jobs, bound
|
|
352
|
+
# as inputs to the pass-2 sample job so nothing is recomputed.
|
|
353
|
+
preprocessed_filename: Optional[str] = None
|
|
354
|
+
bvh_filename: Optional[str] = None
|
|
355
|
+
# Sampling resolution (longest axis) assigned for the pass-2 sample job.
|
|
356
|
+
sample_resolution: Optional[int] = None
|
|
348
357
|
patch: dict = field(default_factory=dict)
|
|
349
358
|
jobs: dict[str, Any] = field(default_factory=lambda: {})
|
|
350
359
|
part_unique_name = ""
|
|
@@ -407,6 +416,10 @@ class CompressionSimulation:
|
|
|
407
416
|
prep_workflows: list[Workflow] = []
|
|
408
417
|
prep_workflow_batch_size: int = 10
|
|
409
418
|
write_ups: bool = True
|
|
419
|
+
# Sample spacing (mm) anchored to the representative part's longest axis:
|
|
420
|
+
# longest_axis / (max_resolution - 1). Cached so experiment variants
|
|
421
|
+
# sampled later match the base simulation's density.
|
|
422
|
+
sample_spacing: Optional[float] = None
|
|
410
423
|
|
|
411
424
|
def __init__(
|
|
412
425
|
self,
|
|
@@ -735,8 +748,10 @@ class CompressionSimulation:
|
|
|
735
748
|
asset = self.client.assets.create(str(info.file_path))
|
|
736
749
|
info.asset = asset
|
|
737
750
|
|
|
738
|
-
def
|
|
739
|
-
"""Build the
|
|
751
|
+
def _build_preprocess_workflow_for_batch(self, batch: list) -> tuple[str, dict, dict]:
|
|
752
|
+
"""Build the pass-1 prep workflow: preprocess (+ BVH) per part. The
|
|
753
|
+
preprocess job outputs each mesh's exact bounds, used to density-match
|
|
754
|
+
the pass-2 sampling resolutions. No sampling happens in this pass."""
|
|
740
755
|
jobs: dict = {}
|
|
741
756
|
params: dict = {}
|
|
742
757
|
assets: dict = {}
|
|
@@ -753,7 +768,6 @@ class CompressionSimulation:
|
|
|
753
768
|
assets[f"{preprocess_job}.mesh"] = part_info.part.filename
|
|
754
769
|
part_info.jobs["preprocess-mesh"] = preprocess_job
|
|
755
770
|
|
|
756
|
-
compute_bvh_job = ""
|
|
757
771
|
if self._get_step(WorkflowStepType.COMPUTE_BVH, part_info.part.name):
|
|
758
772
|
compute_bvh_job = f"compute-bvh-{unique_name}"
|
|
759
773
|
jobs[compute_bvh_job] = {
|
|
@@ -763,25 +777,169 @@ class CompressionSimulation:
|
|
|
763
777
|
}
|
|
764
778
|
part_info.jobs["compute-bvh"] = compute_bvh_job
|
|
765
779
|
|
|
780
|
+
workflow_yaml = yaml.dump({"jobs": jobs}, default_flow_style=False)
|
|
781
|
+
return workflow_yaml, params, assets
|
|
782
|
+
|
|
783
|
+
def _build_sample_workflow_for_batch(self, batch: list) -> tuple[str, dict, dict]:
|
|
784
|
+
"""Build the pass-2 sampling workflow: one implicit/from-mesh job per
|
|
785
|
+
part at its density-matched resolution, reusing the preprocessed mesh
|
|
786
|
+
and BVH assets produced in pass 1."""
|
|
787
|
+
jobs: dict = {}
|
|
788
|
+
params: dict = {}
|
|
789
|
+
assets: dict = {}
|
|
790
|
+
|
|
791
|
+
for part_info in batch:
|
|
792
|
+
if not hasattr(part_info.part, "filename"):
|
|
793
|
+
continue
|
|
794
|
+
if part_info.part.filename is None:
|
|
795
|
+
continue
|
|
796
|
+
unique_name = part_info.part_unique_name
|
|
797
|
+
|
|
766
798
|
sample_mesh_job = f"sample-mesh-{unique_name}"
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
"
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
799
|
+
if part_info.preprocessed_filename:
|
|
800
|
+
# Bind the pass-1 outputs directly; nothing is recomputed.
|
|
801
|
+
jobs[sample_mesh_job] = {"type": "implicit/from-mesh"}
|
|
802
|
+
assets[f"{sample_mesh_job}.mesh"] = part_info.preprocessed_filename
|
|
803
|
+
if part_info.bvh_filename:
|
|
804
|
+
assets[f"{sample_mesh_job}.bvh"] = part_info.bvh_filename
|
|
805
|
+
else:
|
|
806
|
+
# Pass-1 outputs unavailable for this part: fall back to the
|
|
807
|
+
# full in-workflow chain (slightly wasteful, never wrong).
|
|
808
|
+
preprocess_job = f"preprocess-mesh-{unique_name}"
|
|
809
|
+
jobs[preprocess_job] = {"type": "mesh/preprocess"}
|
|
810
|
+
assets[f"{preprocess_job}.mesh"] = part_info.part.filename
|
|
811
|
+
part_info.jobs["preprocess-mesh"] = preprocess_job
|
|
812
|
+
|
|
813
|
+
sample_mesh_def: dict = {
|
|
814
|
+
"type": "implicit/from-mesh",
|
|
815
|
+
"needs": [preprocess_job],
|
|
816
|
+
"assets": {"mesh": preprocess_job},
|
|
817
|
+
}
|
|
818
|
+
if self._get_step(WorkflowStepType.COMPUTE_BVH, part_info.part.name):
|
|
819
|
+
compute_bvh_job = f"compute-bvh-{unique_name}"
|
|
820
|
+
jobs[compute_bvh_job] = {
|
|
821
|
+
"type": "mesh/compute-bvh",
|
|
822
|
+
"needs": [preprocess_job],
|
|
823
|
+
"assets": {"mesh": preprocess_job},
|
|
824
|
+
}
|
|
825
|
+
part_info.jobs["compute-bvh"] = compute_bvh_job
|
|
826
|
+
sample_mesh_def["needs"].append(compute_bvh_job)
|
|
827
|
+
sample_mesh_def["assets"]["bvh"] = compute_bvh_job
|
|
828
|
+
jobs[sample_mesh_job] = sample_mesh_def
|
|
776
829
|
|
|
777
|
-
|
|
778
|
-
|
|
830
|
+
resolution = part_info.sample_resolution or int(
|
|
831
|
+
self.simulation_parameters.max_resolution
|
|
779
832
|
)
|
|
833
|
+
params[f"{sample_mesh_job}.resolution"] = f"{resolution}"
|
|
780
834
|
part_info.jobs["sample-mesh"] = sample_mesh_job
|
|
781
835
|
|
|
782
836
|
workflow_yaml = yaml.dump({"jobs": jobs}, default_flow_style=False)
|
|
783
837
|
return workflow_yaml, params, assets
|
|
784
838
|
|
|
839
|
+
def _run_workflow_batches(self, batches: list, build_workflow_fn) -> list:
|
|
840
|
+
"""Dispatch one workflow per batch (all in parallel), then wait for
|
|
841
|
+
every workflow to finish. Raises if any failed."""
|
|
842
|
+
workflows = []
|
|
843
|
+
for batch in batches:
|
|
844
|
+
workflow_yaml, params, assets = build_workflow_fn(batch)
|
|
845
|
+
wf = self.client.workflows.run_async(
|
|
846
|
+
workflow_yaml, parameters=params, assets=assets
|
|
847
|
+
)
|
|
848
|
+
workflows.append(wf)
|
|
849
|
+
|
|
850
|
+
for i, wf in enumerate(workflows):
|
|
851
|
+
while wf.state not in ["success", "failure", "canceled"]:
|
|
852
|
+
sleep(1)
|
|
853
|
+
wf = self.client.workflows.get(wf.id)
|
|
854
|
+
workflows[i] = wf
|
|
855
|
+
|
|
856
|
+
failed = [wf for wf in workflows if wf.state != "success"]
|
|
857
|
+
if failed:
|
|
858
|
+
raise RuntimeError(f"{len(failed)} prep workflow(s) failed")
|
|
859
|
+
return workflows
|
|
860
|
+
|
|
861
|
+
@staticmethod
|
|
862
|
+
def _job_output_asset_filename(job) -> Optional[str]:
|
|
863
|
+
"""First output asset filename of a job, preferring the named outputs
|
|
864
|
+
mapping and falling back to the deprecated flat asset list."""
|
|
865
|
+
outputs = getattr(job, "outputs", None)
|
|
866
|
+
named = getattr(outputs, "assets", None) if outputs is not None else None
|
|
867
|
+
if isinstance(named, dict):
|
|
868
|
+
for asset in named.values():
|
|
869
|
+
filename = getattr(asset, "filename", None)
|
|
870
|
+
if filename:
|
|
871
|
+
return filename
|
|
872
|
+
for asset in getattr(job, "assets", None) or []:
|
|
873
|
+
filename = getattr(asset, "filename", None)
|
|
874
|
+
if filename:
|
|
875
|
+
return filename
|
|
876
|
+
return None
|
|
877
|
+
|
|
878
|
+
def _collect_preprocess_outputs(self, part_infos: list, workflows: list):
|
|
879
|
+
"""Read mesh bounds and output asset filenames from the pass-1
|
|
880
|
+
preprocess/BVH jobs onto each part_info."""
|
|
881
|
+
jobs_by_name = {
|
|
882
|
+
job.name: job
|
|
883
|
+
for wf in workflows
|
|
884
|
+
for job_id in wf.jobs
|
|
885
|
+
if (job := self.client.jobs.get(job_id)) is not None
|
|
886
|
+
}
|
|
887
|
+
for info in part_infos:
|
|
888
|
+
pre_job = jobs_by_name.get(info.jobs.get("preprocess-mesh", ""), None)
|
|
889
|
+
if pre_job is None:
|
|
890
|
+
continue
|
|
891
|
+
|
|
892
|
+
outputs = getattr(pre_job, "outputs", None)
|
|
893
|
+
out_params = getattr(outputs, "params", None) if outputs is not None else None
|
|
894
|
+
if isinstance(out_params, dict) and "bounds" in out_params:
|
|
895
|
+
bounds = out_params["bounds"]
|
|
896
|
+
info.bounds = json.loads(bounds) if isinstance(bounds, str) else bounds
|
|
897
|
+
info.preprocessed_filename = self._job_output_asset_filename(pre_job)
|
|
898
|
+
|
|
899
|
+
bvh_job = jobs_by_name.get(info.jobs.get("compute-bvh", ""), None)
|
|
900
|
+
if bvh_job is not None:
|
|
901
|
+
info.bvh_filename = self._job_output_asset_filename(bvh_job)
|
|
902
|
+
|
|
903
|
+
@staticmethod
|
|
904
|
+
def _bounds_longest_axis(bounds: dict) -> float:
|
|
905
|
+
mn = np.array(bounds["min"], dtype=np.float64)
|
|
906
|
+
mx = np.array(bounds["max"], dtype=np.float64)
|
|
907
|
+
return float(np.max(mx - mn))
|
|
908
|
+
|
|
909
|
+
def _assign_sample_resolutions(self, part_infos: list):
|
|
910
|
+
"""Assign each part a sampling resolution so all parts share the
|
|
911
|
+
representative part's sample spacing. Parts smaller than the
|
|
912
|
+
representative get proportionally lower resolutions (and larger ones
|
|
913
|
+
higher), keeping particle density uniform across the simulation."""
|
|
914
|
+
max_resolution = max(2, int(self.simulation_parameters.max_resolution))
|
|
915
|
+
|
|
916
|
+
if self.sample_spacing is None:
|
|
917
|
+
# Prefer a representative-named info from the batch being sampled:
|
|
918
|
+
# experiments fork varied parts, so the base sim's own info may
|
|
919
|
+
# not carry the pass-1 bounds.
|
|
920
|
+
rep_info = next(
|
|
921
|
+
(
|
|
922
|
+
i for i in part_infos
|
|
923
|
+
if i.part.name == self.representative_part and i.bounds
|
|
924
|
+
),
|
|
925
|
+
None,
|
|
926
|
+
) or self.get_part_info(self.representative_part)
|
|
927
|
+
if rep_info is not None and rep_info.bounds:
|
|
928
|
+
self.sample_spacing = self._bounds_longest_axis(rep_info.bounds) / (
|
|
929
|
+
max_resolution - 1
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
for info in part_infos:
|
|
933
|
+
if self.sample_spacing and info.bounds:
|
|
934
|
+
longest = self._bounds_longest_axis(info.bounds)
|
|
935
|
+
info.sample_resolution = max(
|
|
936
|
+
2, int(round(longest / self.sample_spacing)) + 1
|
|
937
|
+
)
|
|
938
|
+
else:
|
|
939
|
+
# Legacy fallback: this part samples at max_resolution over
|
|
940
|
+
# its own bounds (density then depends on the part's size).
|
|
941
|
+
info.sample_resolution = max_resolution
|
|
942
|
+
|
|
785
943
|
def sample_assets(self, part_infos=None):
|
|
786
944
|
if part_infos is None:
|
|
787
945
|
part_infos = self.part_infos
|
|
@@ -793,25 +951,25 @@ class CompressionSimulation:
|
|
|
793
951
|
for i in range(0, max(len(mesh_parts), 1), batch_size)
|
|
794
952
|
]
|
|
795
953
|
|
|
796
|
-
#
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
)
|
|
803
|
-
self.prep_workflows.append(wf)
|
|
954
|
+
# Pass 1: preprocess (+ BVH) every mesh. The preprocess job reports
|
|
955
|
+
# each mesh's exact bounds without sampling anything.
|
|
956
|
+
preprocess_workflows = self._run_workflow_batches(
|
|
957
|
+
batches, self._build_preprocess_workflow_for_batch
|
|
958
|
+
)
|
|
959
|
+
self._collect_preprocess_outputs(mesh_parts, preprocess_workflows)
|
|
804
960
|
|
|
805
|
-
#
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
wf = self.client.workflows.get(wf.id)
|
|
810
|
-
self.prep_workflows[i] = wf
|
|
961
|
+
# Anchor sample spacing to the representative part so every part is
|
|
962
|
+
# sampled at the same spatial density (the grid is sized from the
|
|
963
|
+
# representative patch downstream).
|
|
964
|
+
self._assign_sample_resolutions(mesh_parts)
|
|
811
965
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
966
|
+
# Pass 2: sample each mesh at its density-matched resolution, reusing
|
|
967
|
+
# the preprocessed/BVH assets from pass 1.
|
|
968
|
+
sample_workflows = self._run_workflow_batches(
|
|
969
|
+
batches, self._build_sample_workflow_for_batch
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
self.prep_workflows = preprocess_workflows + sample_workflows
|
|
815
973
|
|
|
816
974
|
def collect_sampled_volumes(self, part_infos=None):
|
|
817
975
|
if part_infos is None:
|
|
@@ -29,7 +29,9 @@ JSON manifest format
|
|
|
29
29
|
]
|
|
30
30
|
},
|
|
31
31
|
{"type": "piston_box", "shape_parameters": {"min": [...], "max": [...]}},
|
|
32
|
-
|
|
32
|
+
# piston parts take an optional "material" (preset key or inline dict);
|
|
33
|
+
# omit to use DEFAULT_PISTON_MATERIAL
|
|
34
|
+
{"type": "piston_mesh", "file": "piston.ply", "velocity": [...], "material": "default_piston_material"},
|
|
33
35
|
{
|
|
34
36
|
"type": "mesh",
|
|
35
37
|
"name": "midsole",
|
|
@@ -59,6 +61,9 @@ JSON manifest format
|
|
|
59
61
|
|
|
60
62
|
"simulation": {
|
|
61
63
|
"max_time": 0.04,
|
|
64
|
+
# Sampling resolution along the representative part's longest axis.
|
|
65
|
+
# Other parts sample at resolutions scaled to their size so every
|
|
66
|
+
# part shares the same spatial density.
|
|
62
67
|
"max_resolution": 512,
|
|
63
68
|
|
|
64
69
|
# Force source for force-displacement: "boundary_force" (default) or
|
|
@@ -177,6 +182,8 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
|
|
|
177
182
|
kwargs["velocity"] = entry["velocity"]
|
|
178
183
|
if "shape_parameters" in entry:
|
|
179
184
|
kwargs["shape_parameters"] = entry["shape_parameters"]
|
|
185
|
+
if "material" in entry:
|
|
186
|
+
kwargs["material"] = _resolve_material(entry["material"])
|
|
180
187
|
parts.append(ExperimentPistonCylinder(**kwargs))
|
|
181
188
|
|
|
182
189
|
elif part_type == "piston_box":
|
|
@@ -185,12 +192,16 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
|
|
|
185
192
|
kwargs["velocity"] = entry["velocity"]
|
|
186
193
|
if "shape_parameters" in entry:
|
|
187
194
|
kwargs["shape_parameters"] = entry["shape_parameters"]
|
|
195
|
+
if "material" in entry:
|
|
196
|
+
kwargs["material"] = _resolve_material(entry["material"])
|
|
188
197
|
parts.append(ExperimentPistonBox(**kwargs))
|
|
189
198
|
|
|
190
199
|
elif part_type == "piston_mesh":
|
|
191
200
|
kwargs = {"filename": entry["file"]}
|
|
192
201
|
if "velocity" in entry:
|
|
193
202
|
kwargs["velocity"] = entry["velocity"]
|
|
203
|
+
if "material" in entry:
|
|
204
|
+
kwargs["material"] = _resolve_material(entry["material"])
|
|
194
205
|
parts.append(ExperimentPistonMesh(**kwargs))
|
|
195
206
|
|
|
196
207
|
else:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from io import BytesIO
|
|
2
|
+
from types import SimpleNamespace
|
|
2
3
|
from zipfile import ZipFile
|
|
3
4
|
|
|
4
5
|
import pytest
|
|
@@ -13,6 +14,7 @@ from metafold.simulation.compression_simulation import (
|
|
|
13
14
|
ExperimentMesh,
|
|
14
15
|
ExperimentPistonBase,
|
|
15
16
|
ExperimentPistonCylinder,
|
|
17
|
+
SimulationParameters,
|
|
16
18
|
WorkflowStep,
|
|
17
19
|
WorkflowStepType,
|
|
18
20
|
)
|
|
@@ -315,15 +317,15 @@ class TestSampleAssets:
|
|
|
315
317
|
)
|
|
316
318
|
assert s.prep_workflow_batch_size == 5
|
|
317
319
|
|
|
318
|
-
def
|
|
319
|
-
# 3 mesh parts, batch_size=10 → 1
|
|
320
|
+
def test_fewer_parts_than_batch_size_runs_one_workflow_per_pass(self, sim):
|
|
321
|
+
# 3 mesh parts, batch_size=10 → 1 batch × 2 passes (preprocess, sample)
|
|
320
322
|
sim.client.workflows.run_async.return_value = self._make_success_workflow()
|
|
321
323
|
sim.sample_assets()
|
|
322
|
-
assert sim.client.workflows.run_async.call_count ==
|
|
323
|
-
assert len(sim.prep_workflows) ==
|
|
324
|
+
assert sim.client.workflows.run_async.call_count == 2
|
|
325
|
+
assert len(sim.prep_workflows) == 2
|
|
324
326
|
|
|
325
327
|
def test_parts_split_into_multiple_batches(self, ply_folder, basic_parts, tmp_path):
|
|
326
|
-
# 3 mesh parts, batch_size=2 → 2
|
|
328
|
+
# 3 mesh parts, batch_size=2 → 2 batches × 2 passes
|
|
327
329
|
sim = CompressionSimulation(
|
|
328
330
|
parts=basic_parts,
|
|
329
331
|
simulation_name="t",
|
|
@@ -334,23 +336,48 @@ class TestSampleAssets:
|
|
|
334
336
|
)
|
|
335
337
|
sim.client.workflows.run_async.return_value = self._make_success_workflow()
|
|
336
338
|
sim.sample_assets()
|
|
337
|
-
assert sim.client.workflows.run_async.call_count ==
|
|
338
|
-
assert len(sim.prep_workflows) ==
|
|
339
|
+
assert sim.client.workflows.run_async.call_count == 4
|
|
340
|
+
assert len(sim.prep_workflows) == 4
|
|
339
341
|
|
|
340
|
-
def test_all_workflows_launched_before_any_wait(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
342
|
+
def test_all_workflows_launched_before_any_wait(
|
|
343
|
+
self, ply_folder, basic_parts, tmp_path, monkeypatch
|
|
344
|
+
):
|
|
345
|
+
# Within a pass, run_async is called for every batch before
|
|
346
|
+
# workflows.get is polled (batches run in parallel).
|
|
347
|
+
monkeypatch.setattr(
|
|
348
|
+
"metafold.simulation.compression_simulation.sleep", lambda s: None
|
|
345
349
|
)
|
|
346
|
-
sim
|
|
347
|
-
|
|
350
|
+
sim = CompressionSimulation(
|
|
351
|
+
parts=basic_parts,
|
|
352
|
+
simulation_name="t",
|
|
353
|
+
stl_folder_path=str(ply_folder),
|
|
354
|
+
output_path=str(tmp_path / "out"),
|
|
355
|
+
client=MagicMock(),
|
|
356
|
+
prep_workflow_batch_size=2, # 2 batches per pass
|
|
348
357
|
)
|
|
358
|
+
call_log = []
|
|
359
|
+
pending_then_done = []
|
|
360
|
+
|
|
361
|
+
def launch(*a, **kw):
|
|
362
|
+
call_log.append("launch")
|
|
363
|
+
wf = MagicMock()
|
|
364
|
+
wf.state = "pending"
|
|
365
|
+
wf.jobs = []
|
|
366
|
+
wf.id = f"wf{len(call_log)}"
|
|
367
|
+
pending_then_done.append(wf)
|
|
368
|
+
return wf
|
|
369
|
+
|
|
370
|
+
def poll(id):
|
|
371
|
+
call_log.append("poll")
|
|
372
|
+
return self._make_success_workflow()
|
|
373
|
+
|
|
374
|
+
sim.client.workflows.run_async.side_effect = launch
|
|
375
|
+
sim.client.workflows.get.side_effect = poll
|
|
349
376
|
sim.sample_assets()
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
first_poll =
|
|
353
|
-
assert
|
|
377
|
+
|
|
378
|
+
# Pass 1: both batch launches happen before the first poll
|
|
379
|
+
first_poll = call_log.index("poll")
|
|
380
|
+
assert call_log[:first_poll].count("launch") == 2
|
|
354
381
|
|
|
355
382
|
def test_failed_workflow_raises(self, sim):
|
|
356
383
|
failed_wf = MagicMock()
|
|
@@ -368,6 +395,116 @@ class TestSampleAssets:
|
|
|
368
395
|
assert "sample-mesh" in info.jobs
|
|
369
396
|
assert "preprocess-mesh" in info.jobs
|
|
370
397
|
|
|
398
|
+
def test_fallback_resamples_at_max_resolution_with_full_chain(self, sim):
|
|
399
|
+
# When pass-1 outputs are unavailable (no bounds, no preprocessed
|
|
400
|
+
# asset), pass 2 falls back to the legacy per-part chain at
|
|
401
|
+
# max_resolution.
|
|
402
|
+
sim.client.workflows.run_async.return_value = self._make_success_workflow()
|
|
403
|
+
sim.sample_assets()
|
|
404
|
+
|
|
405
|
+
max_res = int(sim.simulation_parameters.max_resolution)
|
|
406
|
+
for info in sim.part_infos:
|
|
407
|
+
if hasattr(info.part, "filename"):
|
|
408
|
+
assert info.sample_resolution == max_res
|
|
409
|
+
|
|
410
|
+
_, kwargs = sim.client.workflows.run_async.call_args_list[1]
|
|
411
|
+
assert kwargs["parameters"]["sample-mesh-midsole.resolution"] == str(max_res)
|
|
412
|
+
# Full chain rebuilt in the sampling workflow
|
|
413
|
+
yaml_arg = sim.client.workflows.run_async.call_args_list[1][0][0]
|
|
414
|
+
assert "mesh/preprocess" in yaml_arg
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class TestDensityMatchedSampling:
|
|
418
|
+
"""Sampling resolutions are scaled per part so every mesh shares the
|
|
419
|
+
representative part's sample spacing (the piston-density regression)."""
|
|
420
|
+
|
|
421
|
+
def _make_job(self, name, longest_axis=None, asset_key=None, filename=None):
|
|
422
|
+
params = {}
|
|
423
|
+
if longest_axis is not None:
|
|
424
|
+
params["bounds"] = json.dumps(
|
|
425
|
+
{"min": [0.0, 0.0, 0.0], "max": [longest_axis, 50.0, 20.0]}
|
|
426
|
+
)
|
|
427
|
+
assets = {asset_key: SimpleNamespace(filename=filename)} if asset_key else {}
|
|
428
|
+
return SimpleNamespace(
|
|
429
|
+
name=name, outputs=SimpleNamespace(params=params, assets=assets), assets=[]
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
@pytest.fixture
|
|
433
|
+
def sampled_sim(self, ply_folder, basic_parts, tmp_path):
|
|
434
|
+
# max_resolution=31 and rep (midsole) longest axis 300 → spacing 10
|
|
435
|
+
sim = CompressionSimulation(
|
|
436
|
+
parts=basic_parts,
|
|
437
|
+
simulation_name="t",
|
|
438
|
+
stl_folder_path=str(ply_folder),
|
|
439
|
+
output_path=str(tmp_path / "out"),
|
|
440
|
+
client=MagicMock(),
|
|
441
|
+
simulation_parameters=SimulationParameters(max_resolution=31),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
jobs = {
|
|
445
|
+
"p-up": self._make_job("preprocess-mesh-upper_foam", 150.0, "mesh", "pre_up.ply"),
|
|
446
|
+
"b-up": self._make_job("compute-bvh-upper_foam", None, "bvh", "bvh_up.bin"),
|
|
447
|
+
"p-mid": self._make_job("preprocess-mesh-midsole", 300.0, "mesh", "pre_mid.ply"),
|
|
448
|
+
"b-mid": self._make_job("compute-bvh-midsole", None, "bvh", "bvh_mid.bin"),
|
|
449
|
+
"p-out": self._make_job("preprocess-mesh-outsole", 600.0, "mesh", "pre_out.ply"),
|
|
450
|
+
"b-out": self._make_job("compute-bvh-outsole", None, "bvh", "bvh_out.bin"),
|
|
451
|
+
}
|
|
452
|
+
pass1_wf = SimpleNamespace(state="success", jobs=list(jobs), id="wf1")
|
|
453
|
+
pass2_wf = SimpleNamespace(state="success", jobs=[], id="wf2")
|
|
454
|
+
sim.client.workflows.run_async.side_effect = [pass1_wf, pass2_wf]
|
|
455
|
+
sim.client.jobs.get.side_effect = lambda job_id: jobs[job_id]
|
|
456
|
+
|
|
457
|
+
sim.sample_assets()
|
|
458
|
+
return sim
|
|
459
|
+
|
|
460
|
+
def test_spacing_anchored_to_representative(self, sampled_sim):
|
|
461
|
+
assert sampled_sim.sample_spacing == pytest.approx(10.0)
|
|
462
|
+
|
|
463
|
+
def test_representative_part_gets_max_resolution(self, sampled_sim):
|
|
464
|
+
assert sampled_sim.get_part_info("midsole").sample_resolution == 31
|
|
465
|
+
|
|
466
|
+
def test_smaller_part_gets_lower_resolution(self, sampled_sim):
|
|
467
|
+
# 150 mm at 10 mm spacing → 15 cells → 16 samples
|
|
468
|
+
assert sampled_sim.get_part_info("upper_foam").sample_resolution == 16
|
|
469
|
+
|
|
470
|
+
def test_larger_part_gets_higher_resolution(self, sampled_sim):
|
|
471
|
+
# 600 mm at 10 mm spacing → 60 cells → 61 samples (above max_resolution)
|
|
472
|
+
assert sampled_sim.get_part_info("outsole").sample_resolution == 61
|
|
473
|
+
|
|
474
|
+
def test_sampling_pass_binds_pass1_outputs(self, sampled_sim):
|
|
475
|
+
args, kwargs = sampled_sim.client.workflows.run_async.call_args_list[1]
|
|
476
|
+
assert kwargs["parameters"] == {
|
|
477
|
+
"sample-mesh-upper_foam.resolution": "16",
|
|
478
|
+
"sample-mesh-midsole.resolution": "31",
|
|
479
|
+
"sample-mesh-outsole.resolution": "61",
|
|
480
|
+
}
|
|
481
|
+
assert kwargs["assets"] == {
|
|
482
|
+
"sample-mesh-upper_foam.mesh": "pre_up.ply",
|
|
483
|
+
"sample-mesh-upper_foam.bvh": "bvh_up.bin",
|
|
484
|
+
"sample-mesh-midsole.mesh": "pre_mid.ply",
|
|
485
|
+
"sample-mesh-midsole.bvh": "bvh_mid.bin",
|
|
486
|
+
"sample-mesh-outsole.mesh": "pre_out.ply",
|
|
487
|
+
"sample-mesh-outsole.bvh": "bvh_out.bin",
|
|
488
|
+
}
|
|
489
|
+
# No preprocess re-run in the sampling pass
|
|
490
|
+
assert "mesh/preprocess" not in args[0]
|
|
491
|
+
|
|
492
|
+
def test_preprocess_pass_has_no_sampling(self, sampled_sim):
|
|
493
|
+
yaml_arg = sampled_sim.client.workflows.run_async.call_args_list[0][0][0]
|
|
494
|
+
assert "implicit/from-mesh" not in yaml_arg
|
|
495
|
+
assert "mesh/preprocess" in yaml_arg
|
|
496
|
+
|
|
497
|
+
def test_spacing_cached_for_later_variant_sampling(self, sampled_sim):
|
|
498
|
+
# Experiment variants sampled in a later call reuse the cached spacing
|
|
499
|
+
# even though the representative part isn't in that batch.
|
|
500
|
+
variant = CompressionSimulation.PartInfo(
|
|
501
|
+
ExperimentMesh("midsole_v1", None, "mid.ply")
|
|
502
|
+
)
|
|
503
|
+
variant.part_unique_name = "midsole_v1"
|
|
504
|
+
variant.bounds = {"min": [0.0, 0.0, 0.0], "max": [300.0, 50.0, 20.0]}
|
|
505
|
+
sampled_sim._assign_sample_resolutions([variant])
|
|
506
|
+
assert variant.sample_resolution == 31
|
|
507
|
+
|
|
371
508
|
|
|
372
509
|
|
|
373
510
|
class TestWorkflowYaml:
|
|
@@ -114,6 +114,39 @@ class TestBuildParts:
|
|
|
114
114
|
parts = _build_parts([{"type": "piston_mesh", "file": "piston.ply", "velocity": velocity}])
|
|
115
115
|
assert parts[0].velocity == velocity
|
|
116
116
|
|
|
117
|
+
def test_piston_mesh_default_material_when_omitted(self):
|
|
118
|
+
from metafold.materials import DEFAULT_PISTON_MATERIAL
|
|
119
|
+
parts = _build_parts([{"type": "piston_mesh", "file": "piston.ply"}])
|
|
120
|
+
assert parts[0].material is DEFAULT_PISTON_MATERIAL
|
|
121
|
+
|
|
122
|
+
def test_piston_mesh_with_preset_material(self):
|
|
123
|
+
parts = _build_parts([
|
|
124
|
+
{"type": "piston_mesh", "file": "piston.ply", "material": "material_aluminum"}
|
|
125
|
+
])
|
|
126
|
+
assert parts[0].material is MATERIAL_ALUMINUM
|
|
127
|
+
|
|
128
|
+
def test_piston_mesh_with_inline_material(self):
|
|
129
|
+
parts = _build_parts([
|
|
130
|
+
{
|
|
131
|
+
"type": "piston_mesh",
|
|
132
|
+
"file": "piston.ply",
|
|
133
|
+
"material": {
|
|
134
|
+
"density": 1730.0,
|
|
135
|
+
"thermal_conductivity": 45,
|
|
136
|
+
"specific_heat": 4.8e-4,
|
|
137
|
+
"constitutive_model": {
|
|
138
|
+
"type": "rigid",
|
|
139
|
+
"params": {"shear_modulus": 2667.0e6, "bulk_modulus": 8000.0e6},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
])
|
|
144
|
+
assert parts[0].material.constitutive_model.params.get_type() == "rigid"
|
|
145
|
+
|
|
146
|
+
def test_piston_cylinder_with_material(self):
|
|
147
|
+
parts = _build_parts([{"type": "piston_cylinder", "material": "material_aluminum"}])
|
|
148
|
+
assert parts[0].material is MATERIAL_ALUMINUM
|
|
149
|
+
|
|
117
150
|
def test_mesh_part_with_preset_material(self):
|
|
118
151
|
parts = _build_parts([
|
|
119
152
|
{
|
|
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
|
|
File without changes
|
|
File without changes
|