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.
Files changed (35) hide show
  1. {metafold-0.12.dev6 → metafold-0.12.dev8}/PKG-INFO +1 -1
  2. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/simulation/compression_simulation.py +189 -31
  3. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/simulation/run_experiment.py +12 -1
  4. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/PKG-INFO +1 -1
  5. {metafold-0.12.dev6 → metafold-0.12.dev8}/pyproject.toml +1 -1
  6. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_compression_simulation.py +155 -18
  7. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_run_experiment.py +33 -0
  8. {metafold-0.12.dev6 → metafold-0.12.dev8}/LICENSE +0 -0
  9. {metafold-0.12.dev6 → metafold-0.12.dev8}/README.md +0 -0
  10. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/__init__.py +0 -0
  11. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/api.py +0 -0
  12. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/assets.py +0 -0
  13. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/auth.py +0 -0
  14. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/client.py +0 -0
  15. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/exceptions.py +0 -0
  16. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/jobs.py +0 -0
  17. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/materials.py +0 -0
  18. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/projects.py +0 -0
  19. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/simulation/__init__.py +0 -0
  20. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/simulation/compression_experiment.py +0 -0
  21. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/utils.py +0 -0
  22. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold/workflows.py +0 -0
  23. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/SOURCES.txt +0 -0
  24. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/dependency_links.txt +0 -0
  25. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/requires.txt +0 -0
  26. {metafold-0.12.dev6 → metafold-0.12.dev8}/metafold.egg-info/top_level.txt +0 -0
  27. {metafold-0.12.dev6 → metafold-0.12.dev8}/setup.cfg +0 -0
  28. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_assets.py +0 -0
  29. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_compession_experiment.py +0 -0
  30. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_jobs.py +0 -0
  31. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_materials.py +0 -0
  32. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_projects.py +0 -0
  33. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_shear_simulation.py +0 -0
  34. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_utils.py +0 -0
  35. {metafold-0.12.dev6 → metafold-0.12.dev8}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev6
3
+ Version: 0.12.dev8
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -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 _build_prep_workflow_for_batch(self, batch: list) -> tuple[str, dict, dict]:
739
- """Build the YAML, params, and assets for one batch of part_infos."""
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
- sample_mesh_def: dict = {
768
- "type": "implicit/from-mesh",
769
- "needs": [preprocess_job],
770
- "assets": {"mesh": preprocess_job},
771
- }
772
- if compute_bvh_job:
773
- sample_mesh_def["needs"].append(compute_bvh_job)
774
- sample_mesh_def["assets"]["bvh"] = compute_bvh_job
775
- jobs[sample_mesh_job] = sample_mesh_def
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
- params[f"{sample_mesh_job}.resolution"] = (
778
- f"{self.simulation_parameters.max_resolution}"
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
- # Launch all batch workflows in parallel
797
- self.prep_workflows = []
798
- for i, batch in enumerate(batches):
799
- workflow_yaml, params, assets = self._build_prep_workflow_for_batch(batch)
800
- wf = self.client.workflows.run_async(
801
- workflow_yaml, parameters=params, assets=assets
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
- # Wait for all to finish
806
- for i, wf in enumerate(self.prep_workflows):
807
- while wf.state not in ["success", "failure", "canceled"]:
808
- sleep(1)
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
- failed = [wf for wf in self.prep_workflows if wf.state != "success"]
813
- if failed:
814
- raise RuntimeError(f"{len(failed)} prep workflow(s) failed")
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
- {"type": "piston_mesh", "file": "piston.ply", "velocity": [...]},
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev6
3
+ Version: 0.12.dev8
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "metafold"
7
- version = "0.12.dev6"
7
+ version = "0.12.dev8"
8
8
  authors = [
9
9
  {name = "Metafold 3D", email = "info@metafold3d.com"},
10
10
  ]
@@ -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 test_fewer_parts_than_batch_size_runs_one_workflow(self, sim):
319
- # 3 mesh parts, batch_size=10 → 1 workflow
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 == 1
323
- assert len(sim.prep_workflows) == 1
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 workflows
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 == 2
338
- assert len(sim.prep_workflows) == 2
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(self, sim):
341
- # run_async should be called for all batches before workflows.get is polled
342
- call_log = []
343
- sim.client.workflows.run_async.side_effect = lambda *a, **kw: (
344
- call_log.append("launch") or self._make_success_workflow()
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.client.workflows.get.side_effect = lambda id: (
347
- call_log.append("poll") or self._make_success_workflow()
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
- # all launches happened before any polls
351
- last_launch = max(i for i, e in enumerate(call_log) if e == "launch")
352
- first_poll = next((i for i, e in enumerate(call_log) if e == "poll"), len(call_log))
353
- assert last_launch < first_poll
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