metafold 0.12.dev7__tar.gz → 0.12.dev9__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.dev7 → metafold-0.12.dev9}/PKG-INFO +1 -1
  2. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/simulation/compression_experiment.py +4 -0
  3. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/simulation/compression_simulation.py +352 -100
  4. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/simulation/run_experiment.py +4 -3
  5. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/PKG-INFO +1 -1
  6. {metafold-0.12.dev7 → metafold-0.12.dev9}/pyproject.toml +1 -1
  7. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_compression_simulation.py +308 -59
  8. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_run_experiment.py +2 -11
  9. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_shear_simulation.py +2 -2
  10. {metafold-0.12.dev7 → metafold-0.12.dev9}/LICENSE +0 -0
  11. {metafold-0.12.dev7 → metafold-0.12.dev9}/README.md +0 -0
  12. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/__init__.py +0 -0
  13. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/api.py +0 -0
  14. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/assets.py +0 -0
  15. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/auth.py +0 -0
  16. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/client.py +0 -0
  17. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/exceptions.py +0 -0
  18. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/jobs.py +0 -0
  19. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/materials.py +0 -0
  20. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/projects.py +0 -0
  21. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/simulation/__init__.py +0 -0
  22. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/utils.py +0 -0
  23. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/workflows.py +0 -0
  24. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/SOURCES.txt +0 -0
  25. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/dependency_links.txt +0 -0
  26. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/requires.txt +0 -0
  27. {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/top_level.txt +0 -0
  28. {metafold-0.12.dev7 → metafold-0.12.dev9}/setup.cfg +0 -0
  29. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_assets.py +0 -0
  30. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_compession_experiment.py +0 -0
  31. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_jobs.py +0 -0
  32. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_materials.py +0 -0
  33. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_projects.py +0 -0
  34. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_utils.py +0 -0
  35. {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev7
3
+ Version: 0.12.dev9
4
4
  Summary: Metafold SDK for Python
5
5
  Author-email: Metafold 3D <info@metafold3d.com>
6
6
  License: Copyright 2024 Metafold 3D
@@ -246,6 +246,10 @@ class CompressionExperiment:
246
246
  self.base_simulation.populate_assets(self.experiment_part_infos)
247
247
  self._log("Sampling assets...")
248
248
  self.base_simulation.sample_assets(self.experiment_part_infos)
249
+ # Clones were deep-copied before sampling, so copy over the spacing that
250
+ # was anchored during sampling — each clone's grid must match it.
251
+ for local_sim in self.sims:
252
+ local_sim.sample_spacing = self.base_simulation.sample_spacing
249
253
  self._log("Collecting sampled volumes...")
250
254
  self.base_simulation.collect_sampled_volumes(self.experiment_part_infos)
251
255
  self._log("Prepare complete.")
@@ -6,9 +6,9 @@ from typing import Any, Optional, Union
6
6
  from io import BytesIO
7
7
  import uuid
8
8
 
9
- from dotenv import load_dotenv
10
9
  from requests import HTTPError
11
10
  import yaml
11
+ from dotenv import load_dotenv
12
12
  from metafold import MetafoldClient
13
13
  from pathlib import Path
14
14
  from numpy.typing import DTypeLike
@@ -171,7 +171,6 @@ class ExperimentPart:
171
171
  @dataclass
172
172
  class ExperimentMesh(ExperimentPart):
173
173
  filename: str = ""
174
- representative_part: bool = False
175
174
 
176
175
 
177
176
  @dataclass
@@ -345,6 +344,15 @@ class CompressionSimulation:
345
344
  # workflow. Downstream main-workflow jobs (metrics, compress) consume
346
345
  # this directly as an asset by filename.
347
346
  volume_filename: Optional[str] = None
347
+ # Mesh bounds ({"min": [...], "max": [...]}, mm) reported by the
348
+ # pass-1 preprocess job; used to density-match sampling resolutions.
349
+ bounds: Optional[dict] = None
350
+ # Output asset filenames from the pass-1 preprocess/BVH jobs, bound
351
+ # as inputs to the pass-2 sample job so nothing is recomputed.
352
+ preprocessed_filename: Optional[str] = None
353
+ bvh_filename: Optional[str] = None
354
+ # Sampling resolution (longest axis) assigned for the pass-2 sample job.
355
+ sample_resolution: Optional[int] = None
348
356
  patch: dict = field(default_factory=dict)
349
357
  jobs: dict[str, Any] = field(default_factory=lambda: {})
350
358
  part_unique_name = ""
@@ -380,7 +388,6 @@ class CompressionSimulation:
380
388
  stl_folder: Optional[Path] = None
381
389
  out_dir: Optional[Path] = None
382
390
  results: list = []
383
- representative_part: str = ""
384
391
  simulation_name: str = ""
385
392
  simulation_parameters: SimulationParameters
386
393
  piston_velocity: list[list[float]]
@@ -407,6 +414,10 @@ class CompressionSimulation:
407
414
  prep_workflows: list[Workflow] = []
408
415
  prep_workflow_batch_size: int = 10
409
416
  write_ups: bool = True
417
+ # Sample spacing (mm) anchored to the union ("total box") of every part's
418
+ # bounds: longest_axis(total_box) / (max_resolution - 1). Cached so
419
+ # experiment variants sampled later match the base simulation's density.
420
+ sample_spacing: Optional[float] = None
410
421
 
411
422
  def __init__(
412
423
  self,
@@ -465,7 +476,6 @@ class CompressionSimulation:
465
476
  else:
466
477
  self.part_infos.append(inner_part)
467
478
 
468
- self.validate_parts()
469
479
  self.setup_ply_files(stl_folder_path)
470
480
  self.setup_results(output_path)
471
481
  if client is None:
@@ -525,16 +535,19 @@ class CompressionSimulation:
525
535
  def download_results(self):
526
536
  self.write_results()
527
537
 
528
- def validate_parts(self):
529
- for p in self.part_infos:
530
- if hasattr(p.part, "representative_part") and p.part.representative_part:
531
- if self.representative_part:
532
- raise ValueError(
533
- f"At most one part can have representative_part set to True"
534
- )
535
- self.representative_part = p.part.name
536
- if not self.representative_part:
537
- raise ValueError(f"One part must have representative_part set to True")
538
+ def _get_primary_deformable_info(self):
539
+ """The specimen part for stress/strain and the featured histogram: the
540
+ first part that isn't a rigid piston/support (pistons are inserted at
541
+ index 0, so this is the first deformable body in the stack)."""
542
+ return next(
543
+ (
544
+ p for p in self.part_infos
545
+ if not isinstance(
546
+ p.part, (ExperimentPistonBase, ExperimentSupportBase)
547
+ )
548
+ ),
549
+ None,
550
+ )
538
551
 
539
552
  def setup_results(self, output_path):
540
553
  self.out_dir = Path(output_path)
@@ -722,7 +735,12 @@ class CompressionSimulation:
722
735
 
723
736
  for info in part_infos:
724
737
  if info.file_path and info.asset is None:
725
- existing = self.client.assets.list(q=f"filename:{info.part.filename}")
738
+ # Quote the filename so the search parser keeps it as one term,
739
+ # and match it exactly (the search is a case-insensitive ILIKE).
740
+ existing = [
741
+ a for a in self.client.assets.list(q=f'filename:"{info.part.filename}"')
742
+ if a.filename == info.part.filename
743
+ ]
726
744
  asset = None
727
745
  if existing:
728
746
  asset = existing[0]
@@ -735,8 +753,10 @@ class CompressionSimulation:
735
753
  asset = self.client.assets.create(str(info.file_path))
736
754
  info.asset = asset
737
755
 
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."""
756
+ def _build_preprocess_workflow_for_batch(self, batch: list) -> tuple[str, dict, dict]:
757
+ """Build the pass-1 prep workflow: preprocess (+ BVH) per part. The
758
+ preprocess job outputs each mesh's exact bounds, used to density-match
759
+ the pass-2 sampling resolutions. No sampling happens in this pass."""
740
760
  jobs: dict = {}
741
761
  params: dict = {}
742
762
  assets: dict = {}
@@ -753,7 +773,6 @@ class CompressionSimulation:
753
773
  assets[f"{preprocess_job}.mesh"] = part_info.part.filename
754
774
  part_info.jobs["preprocess-mesh"] = preprocess_job
755
775
 
756
- compute_bvh_job = ""
757
776
  if self._get_step(WorkflowStepType.COMPUTE_BVH, part_info.part.name):
758
777
  compute_bvh_job = f"compute-bvh-{unique_name}"
759
778
  jobs[compute_bvh_job] = {
@@ -763,25 +782,196 @@ class CompressionSimulation:
763
782
  }
764
783
  part_info.jobs["compute-bvh"] = compute_bvh_job
765
784
 
785
+ workflow_yaml = yaml.dump({"jobs": jobs}, default_flow_style=False)
786
+ return workflow_yaml, params, assets
787
+
788
+ def _build_sample_workflow_for_batch(self, batch: list) -> tuple[str, dict, dict]:
789
+ """Build the pass-2 sampling workflow: one implicit/from-mesh job per
790
+ part at its density-matched resolution, reusing the preprocessed mesh
791
+ and BVH assets produced in pass 1."""
792
+ jobs: dict = {}
793
+ params: dict = {}
794
+ assets: dict = {}
795
+
796
+ for part_info in batch:
797
+ if not hasattr(part_info.part, "filename"):
798
+ continue
799
+ if part_info.part.filename is None:
800
+ continue
801
+ unique_name = part_info.part_unique_name
802
+
766
803
  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
804
+ if part_info.preprocessed_filename:
805
+ # Bind the pass-1 outputs directly; nothing is recomputed.
806
+ jobs[sample_mesh_job] = {"type": "implicit/from-mesh"}
807
+ assets[f"{sample_mesh_job}.mesh"] = part_info.preprocessed_filename
808
+ if part_info.bvh_filename:
809
+ assets[f"{sample_mesh_job}.bvh"] = part_info.bvh_filename
810
+ else:
811
+ # Pass-1 outputs unavailable for this part: fall back to the
812
+ # full in-workflow chain (slightly wasteful, never wrong).
813
+ preprocess_job = f"preprocess-mesh-{unique_name}"
814
+ jobs[preprocess_job] = {"type": "mesh/preprocess"}
815
+ assets[f"{preprocess_job}.mesh"] = part_info.part.filename
816
+ part_info.jobs["preprocess-mesh"] = preprocess_job
817
+
818
+ sample_mesh_def: dict = {
819
+ "type": "implicit/from-mesh",
820
+ "needs": [preprocess_job],
821
+ "assets": {"mesh": preprocess_job},
822
+ }
823
+ if self._get_step(WorkflowStepType.COMPUTE_BVH, part_info.part.name):
824
+ compute_bvh_job = f"compute-bvh-{unique_name}"
825
+ jobs[compute_bvh_job] = {
826
+ "type": "mesh/compute-bvh",
827
+ "needs": [preprocess_job],
828
+ "assets": {"mesh": preprocess_job},
829
+ }
830
+ part_info.jobs["compute-bvh"] = compute_bvh_job
831
+ sample_mesh_def["needs"].append(compute_bvh_job)
832
+ sample_mesh_def["assets"]["bvh"] = compute_bvh_job
833
+ jobs[sample_mesh_job] = sample_mesh_def
776
834
 
777
- params[f"{sample_mesh_job}.resolution"] = (
778
- f"{self.simulation_parameters.max_resolution}"
835
+ resolution = part_info.sample_resolution or int(
836
+ self.simulation_parameters.max_resolution
779
837
  )
838
+ params[f"{sample_mesh_job}.resolution"] = f"{resolution}"
780
839
  part_info.jobs["sample-mesh"] = sample_mesh_job
781
840
 
782
841
  workflow_yaml = yaml.dump({"jobs": jobs}, default_flow_style=False)
783
842
  return workflow_yaml, params, assets
784
843
 
844
+ def _run_workflow_batches(self, batches: list, build_workflow_fn) -> list:
845
+ """Dispatch one workflow per batch (all in parallel), then wait for
846
+ every workflow to finish. Raises if any failed."""
847
+ workflows = []
848
+ for batch in batches:
849
+ workflow_yaml, params, assets = build_workflow_fn(batch)
850
+ wf = self.client.workflows.run_async(
851
+ workflow_yaml, parameters=params, assets=assets
852
+ )
853
+ workflows.append(wf)
854
+
855
+ for i, wf in enumerate(workflows):
856
+ while wf.state not in ["success", "failure", "canceled"]:
857
+ sleep(1)
858
+ wf = self.client.workflows.get(wf.id)
859
+ workflows[i] = wf
860
+
861
+ failed = [wf for wf in workflows if wf.state != "success"]
862
+ if failed:
863
+ raise RuntimeError(f"{len(failed)} prep workflow(s) failed")
864
+ return workflows
865
+
866
+ @staticmethod
867
+ def _job_output_asset_filename(job) -> Optional[str]:
868
+ """First output asset filename of a job, preferring the named outputs
869
+ mapping and falling back to the deprecated flat asset list."""
870
+ outputs = getattr(job, "outputs", None)
871
+ named = getattr(outputs, "assets", None) if outputs is not None else None
872
+ if isinstance(named, dict):
873
+ for asset in named.values():
874
+ filename = getattr(asset, "filename", None)
875
+ if filename:
876
+ return filename
877
+ for asset in getattr(job, "assets", None) or []:
878
+ filename = getattr(asset, "filename", None)
879
+ if filename:
880
+ return filename
881
+ return None
882
+
883
+ def _collect_preprocess_outputs(self, part_infos: list, workflows: list):
884
+ """Read mesh bounds and output asset filenames from the pass-1
885
+ preprocess/BVH jobs onto each part_info."""
886
+ jobs_by_name = {
887
+ job.name: job
888
+ for wf in workflows
889
+ for job_id in wf.jobs
890
+ if (job := self.client.jobs.get(job_id)) is not None
891
+ }
892
+ for info in part_infos:
893
+ pre_job = jobs_by_name.get(info.jobs.get("preprocess-mesh", ""), None)
894
+ if pre_job is None:
895
+ continue
896
+
897
+ outputs = getattr(pre_job, "outputs", None)
898
+ out_params = getattr(outputs, "params", None) if outputs is not None else None
899
+ if isinstance(out_params, dict) and "bounds" in out_params:
900
+ bounds = out_params["bounds"]
901
+ info.bounds = json.loads(bounds) if isinstance(bounds, str) else bounds
902
+ info.preprocessed_filename = self._job_output_asset_filename(pre_job)
903
+
904
+ bvh_job = jobs_by_name.get(info.jobs.get("compute-bvh", ""), None)
905
+ if bvh_job is not None:
906
+ info.bvh_filename = self._job_output_asset_filename(bvh_job)
907
+
908
+ @staticmethod
909
+ def _bounds_longest_axis(bounds: dict) -> float:
910
+ mn = np.array(bounds["min"], dtype=np.float64)
911
+ mx = np.array(bounds["max"], dtype=np.float64)
912
+ return float(np.max(mx - mn))
913
+
914
+ @staticmethod
915
+ def _part_bounds_mm(info):
916
+ """(min, max) axis-aligned bounds of a part in mm, or None. Mesh parts
917
+ report bounds via the pass-1 preprocess job (mm); primitives report
918
+ them analytically via get_bounds() (metres)."""
919
+ part = info.part
920
+ if isinstance(part, ExperimentPrimitive):
921
+ pmin, pmax = part.get_bounds()
922
+ return (
923
+ np.array(pmin, dtype=np.float64) * 1000.0,
924
+ np.array(pmax, dtype=np.float64) * 1000.0,
925
+ )
926
+ if info.bounds:
927
+ return (
928
+ np.array(info.bounds["min"], dtype=np.float64),
929
+ np.array(info.bounds["max"], dtype=np.float64),
930
+ )
931
+ return None
932
+
933
+ def _union_bounds_mm(self, part_infos: list):
934
+ """Union ("total box") of every part's bounds in mm, or None."""
935
+ mins, maxs = [], []
936
+ for info in part_infos:
937
+ b = self._part_bounds_mm(info)
938
+ if b is not None:
939
+ mins.append(b[0])
940
+ maxs.append(b[1])
941
+ if not mins:
942
+ return None
943
+ return np.min(mins, axis=0), np.max(maxs, axis=0)
944
+
945
+ def _ensure_sample_spacing(self, part_infos: list):
946
+ """Set sample_spacing (mm) from the total box of all parts, if unset.
947
+ Anchoring to the union — not one 'representative' part — makes
948
+ max_resolution the resolution of the whole simulation's longest axis."""
949
+ if self.sample_spacing is not None:
950
+ return
951
+ max_resolution = max(2, int(self.simulation_parameters.max_resolution))
952
+ union = self._union_bounds_mm(part_infos)
953
+ if union is not None:
954
+ longest = float(np.max(union[1] - union[0]))
955
+ if longest > 0:
956
+ self.sample_spacing = longest / (max_resolution - 1)
957
+
958
+ def _assign_sample_resolutions(self, all_infos: list, mesh_infos: list):
959
+ """Anchor sample spacing to the total box (union of all part bounds),
960
+ then give each mesh a resolution scaled to its own size so every part
961
+ samples at the same spatial density."""
962
+ max_resolution = max(2, int(self.simulation_parameters.max_resolution))
963
+ self._ensure_sample_spacing(all_infos)
964
+
965
+ for info in mesh_infos:
966
+ if self.sample_spacing and info.bounds:
967
+ longest = self._bounds_longest_axis(info.bounds)
968
+ info.sample_resolution = max(
969
+ 2, int(round(longest / self.sample_spacing)) + 1
970
+ )
971
+ else:
972
+ # Fallback: sample at max_resolution over the part's own bounds.
973
+ info.sample_resolution = max_resolution
974
+
785
975
  def sample_assets(self, part_infos=None):
786
976
  if part_infos is None:
787
977
  part_infos = self.part_infos
@@ -793,25 +983,24 @@ class CompressionSimulation:
793
983
  for i in range(0, max(len(mesh_parts), 1), batch_size)
794
984
  ]
795
985
 
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)
986
+ # Pass 1: preprocess (+ BVH) every mesh. The preprocess job reports
987
+ # each mesh's exact bounds without sampling anything.
988
+ preprocess_workflows = self._run_workflow_batches(
989
+ batches, self._build_preprocess_workflow_for_batch
990
+ )
991
+ self._collect_preprocess_outputs(mesh_parts, preprocess_workflows)
804
992
 
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
993
+ # Anchor sample spacing to the total box (union of all parts, primitives
994
+ # included) so every part samples at the same spatial density.
995
+ self._assign_sample_resolutions(part_infos, mesh_parts)
811
996
 
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")
997
+ # Pass 2: sample each mesh at its density-matched resolution, reusing
998
+ # the preprocessed/BVH assets from pass 1.
999
+ sample_workflows = self._run_workflow_batches(
1000
+ batches, self._build_sample_workflow_for_batch
1001
+ )
1002
+
1003
+ self.prep_workflows = preprocess_workflows + sample_workflows
815
1004
 
816
1005
  def collect_sampled_volumes(self, part_infos=None):
817
1006
  if part_infos is None:
@@ -858,11 +1047,11 @@ class CompressionSimulation:
858
1047
 
859
1048
  def create_sim_config(self, name_suffix=""):
860
1049
  """
861
- grid_patch: the midsole patch, used as representative for grid sizing.
1050
+ The grid spans the total box (union of every part's bounds) and is
1051
+ sized by the shared sample spacing — no representative part.
862
1052
  Piston velocity is [0,0,0] in the UPS — actual motion driven by velocity.txt.
863
1053
  """
864
1054
  name = self.simulation_name
865
- grid_patch = self.get_part_info(self.representative_part).patch
866
1055
 
867
1056
  sim = Simulation(
868
1057
  name,
@@ -873,13 +1062,11 @@ class CompressionSimulation:
873
1062
  timestep_multiplier=self.simulation_parameters.timestep_multiplier,
874
1063
  )
875
1064
 
876
- grid_min = np.array(grid_patch["offset"], dtype=np.float32) * 1e-3
877
- grid_max = np.array(grid_patch["size"], dtype=np.float32) * 1e-3 + grid_min
878
-
879
- # Expand grid to contain every part's bounding box, so nothing is
880
- # clipped (e.g. a support/outsole extending below the representative
881
- # part). Primitives report bounds via get_bounds() in metres, mesh
882
- # parts via their sampled patch (offset/size in mm)
1065
+ # Total box: union of every part's bounding box so nothing is clipped.
1066
+ # Primitives report bounds via get_bounds() in metres, mesh parts via
1067
+ # their sampled patch (offset/size in mm).
1068
+ grid_min = np.array([np.inf, np.inf, np.inf], dtype=np.float32)
1069
+ grid_max = np.array([-np.inf, -np.inf, -np.inf], dtype=np.float32)
883
1070
  for info in self.part_infos:
884
1071
  part = info.part
885
1072
  if isinstance(part, ExperimentPrimitive):
@@ -899,11 +1086,10 @@ class CompressionSimulation:
899
1086
  grid_max[2] += self.simulation_parameters.margin_z
900
1087
 
901
1088
  grid_size = grid_max - grid_min
902
- spacing = (
903
- np.array(grid_patch["size"], dtype=np.float32)
904
- / (np.array(grid_patch["resolution"]) - 1)
905
- * 1e-3
906
- )
1089
+ # Every part is sampled at this uniform (isotropic) spacing, so the grid
1090
+ # cells match particle spacing. mm -> m.
1091
+ self._ensure_sample_spacing(self.part_infos)
1092
+ spacing = (self.sample_spacing or 1.0) * 1e-3
907
1093
  grid_resolution = np.ceil(
908
1094
  grid_size / (self.simulation_parameters.points_per_cell * spacing)
909
1095
  ).astype(np.int32)
@@ -1213,20 +1399,36 @@ class CompressionSimulation:
1213
1399
  )
1214
1400
 
1215
1401
  def _add_to_workflow_stress_strain(self, part_infos: list[PartInfo]):
1216
- job_name_base = "stress-strain"
1217
- self._add_to_workflow_postprocess(
1218
- part_infos, "force-displacement", job_name_base
1219
- )
1220
- self.workflow_params[f"{job_name_base}.keys"] = json.dumps(
1221
- ["/force_displacement"]
1222
- )
1223
- representative_patch = self.get_part_info(self.representative_part).patch
1224
- self.workflow_params[f"{job_name_base}.compression_area"] = str(
1225
- representative_patch["size"][0] * representative_patch["size"][1]
1226
- )
1227
- self.workflow_params[f"{job_name_base}.initial_length"] = str(
1228
- representative_patch["size"][2]
1402
+ # One stress-strain job per deformable part, each normalizing the shared
1403
+ # (global) force-displacement curve by that part's own cross-section
1404
+ # (X*Y) and height (Z). Named stress-strain-<part> like metrics-<part>.
1405
+ fd_job = next(
1406
+ (p.jobs["force-displacement"] for p in part_infos
1407
+ if "force-displacement" in p.jobs),
1408
+ None,
1229
1409
  )
1410
+ if fd_job is None:
1411
+ return
1412
+ for part_info in part_infos:
1413
+ if not self._is_analysis_target(part_info.part):
1414
+ continue
1415
+ if part_info.disabled or not part_info.patch:
1416
+ continue
1417
+ job_name = f"stress-strain-{part_info.part_unique_name}"
1418
+ self.workflow_jobs[job_name] = {
1419
+ "type": "sim/postprocess/stress-strain",
1420
+ "needs": [fd_job],
1421
+ "assets": {"data": fd_job},
1422
+ }
1423
+ self.workflow_params[f"{job_name}.keys"] = json.dumps(
1424
+ ["/force_displacement"]
1425
+ )
1426
+ size = part_info.patch["size"]
1427
+ self.workflow_params[f"{job_name}.compression_area"] = str(
1428
+ size[0] * size[1]
1429
+ )
1430
+ self.workflow_params[f"{job_name}.initial_length"] = str(size[2])
1431
+ part_info.jobs["stress-strain"] = job_name
1230
1432
 
1231
1433
  def _add_to_workflow_particle_displacement(self, part_infos: list[PartInfo]):
1232
1434
  self._add_to_workflow_postprocess(
@@ -1815,16 +2017,25 @@ class CompressionSimulation:
1815
2017
  loading.to_csv(f)
1816
2018
  data["loadingForceDisplacement"] = filename
1817
2019
 
1818
- # Stress-strain
2020
+ # Stress-strain: one curve per deformable part.
1819
2021
  if self._contains_step(WorkflowStepType.STRESS_STRAIN):
1820
- ss_asset = w.get_asset("stress-strain.output")
1821
- assert ss_asset
1822
- self.client.assets.download_file(ss_asset.id, hdf_filename)
1823
- with pd.HDFStore(hdf_filename) as store:
1824
- filename = f"{name}/stressStrain.csv"
1825
- with zf.open(filename, "w") as f:
1826
- store["/stress_strain"].to_csv(f)
1827
- data["stressStrain"] = filename
2022
+ for part_info in self.part_infos:
2023
+ if not self._is_analysis_target(part_info.part):
2024
+ continue
2025
+ if part_info.disabled:
2026
+ continue
2027
+ i = part_info.material_index
2028
+ ss_asset = w.get_asset(
2029
+ f"stress-strain-{part_info.part_unique_name}.output"
2030
+ )
2031
+ if ss_asset is None:
2032
+ continue
2033
+ self.client.assets.download_file(ss_asset.id, hdf_filename)
2034
+ with pd.HDFStore(hdf_filename) as store:
2035
+ filename = f"{name}/stressStrain{i}.csv"
2036
+ with zf.open(filename, "w") as f:
2037
+ store["/stress_strain"].to_csv(f)
2038
+ data[f"stressStrain{i}"] = filename
1828
2039
 
1829
2040
  # ----------------------------------------------------------
1830
2041
  # Write PLY files from the combined all-material dataframe.
@@ -2060,18 +2271,26 @@ class CompressionSimulation:
2060
2271
  if raw_ue is not None:
2061
2272
  unloading_energy = float(raw_ue)
2062
2273
 
2063
- # Stress-strain: ship whole.
2274
+ # Stress-strain: ship one whole file per deformable part.
2064
2275
  if self._contains_step(WorkflowStepType.STRESS_STRAIN):
2065
- ss_asset = w.get_asset("stress-strain.output")
2066
- if ss_asset is not None:
2067
- ss_hdf = tempdir_path / "stress_strain.h5"
2068
- self.client.assets.download_file(ss_asset.id, ss_hdf)
2069
- ss_zip_path = f"{name}/stress_strain.h5"
2070
- zf.write(ss_hdf, arcname=ss_zip_path)
2071
- data["stressStrain"] = {
2072
- "name": ss_zip_path,
2073
- "path": "/stress_strain",
2074
- }
2276
+ for part_info in self.part_infos:
2277
+ if not self._is_analysis_target(part_info.part):
2278
+ continue
2279
+ if part_info.disabled:
2280
+ continue
2281
+ i = part_info.material_index
2282
+ ss_asset = w.get_asset(
2283
+ f"stress-strain-{part_info.part_unique_name}.output"
2284
+ )
2285
+ if ss_asset is not None:
2286
+ ss_hdf = tempdir_path / f"stress_strain_{i}.h5"
2287
+ self.client.assets.download_file(ss_asset.id, ss_hdf)
2288
+ ss_zip_path = f"{name}/stress_strain_{i}.h5"
2289
+ zf.write(ss_hdf, arcname=ss_zip_path)
2290
+ data[f"stressStrain{i}"] = {
2291
+ "name": ss_zip_path,
2292
+ "path": "/stress_strain",
2293
+ }
2075
2294
 
2076
2295
  # Sum interior volumes across analysis-target parts.
2077
2296
  total_volume = 0.0
@@ -2217,11 +2436,19 @@ class CompressionSimulation:
2217
2436
  }
2218
2437
 
2219
2438
  if self._contains_step(WorkflowStepType.STRESS_STRAIN):
2220
- server_data["stressStrain"] = {
2221
- "jobId": job_id_lookup.get("stress-strain", ""),
2222
- "assetName": "output",
2223
- "path": "/stress_strain",
2224
- }
2439
+ for part_info in self.part_infos:
2440
+ if not self._is_analysis_target(part_info.part):
2441
+ continue
2442
+ if part_info.disabled:
2443
+ continue
2444
+ i = part_info.material_index
2445
+ server_data[f"stressStrain{i}"] = {
2446
+ "jobId": job_id_lookup.get(
2447
+ f"stress-strain-{part_info.part_unique_name}", ""
2448
+ ),
2449
+ "assetName": "output",
2450
+ "path": "/stress_strain",
2451
+ }
2225
2452
 
2226
2453
  return server_data
2227
2454
 
@@ -2447,8 +2674,9 @@ class CompressionSimulation:
2447
2674
  ]
2448
2675
 
2449
2676
  self.manifest["cardsConfig"]["B"] = []
2450
- if self._contains_step(WorkflowStepType.VON_MISES_STRESS):
2451
- rep_idx = self.get_part_info(self.representative_part).material_index
2677
+ specimen = self._get_primary_deformable_info()
2678
+ if self._contains_step(WorkflowStepType.VON_MISES_STRESS) and specimen is not None:
2679
+ rep_idx = specimen.material_index
2452
2680
  self.manifest["cardsConfig"]["B"].append(
2453
2681
  {
2454
2682
  "id": "vonMisesStress",
@@ -2462,6 +2690,30 @@ class CompressionSimulation:
2462
2690
  }
2463
2691
  )
2464
2692
 
2693
+ # One stress-strain card per deformable part (keyed stressStrain{i}).
2694
+ if self._contains_step(WorkflowStepType.STRESS_STRAIN):
2695
+ for part_info in self.part_infos:
2696
+ if not self._is_analysis_target(part_info.part):
2697
+ continue
2698
+ if part_info.disabled:
2699
+ continue
2700
+ i = part_info.material_index
2701
+ self.manifest["cardsConfig"]["B"].append(
2702
+ {
2703
+ "id": f"stressStrain{i}",
2704
+ "title": f"{part_info.part_unique_name} Stress-Strain",
2705
+ "component": "MultiExperimentLineChart",
2706
+ "props": {
2707
+ "xColumn": "strain",
2708
+ "yColumn": "stress",
2709
+ "yMin": 0.0,
2710
+ "xAxisLabel": "Strain",
2711
+ "yAxisLabel": "Stress (MPa)",
2712
+ "dataSource": f"stressStrain{i}",
2713
+ },
2714
+ }
2715
+ )
2716
+
2465
2717
  if self._results_have_energy_metrics(results):
2466
2718
  self.manifest["resultsListConfig"].append(
2467
2719
  {"key": "volume", "heading": "Volume (mm³)", "filtering": False},
@@ -36,8 +36,7 @@ JSON manifest format
36
36
  "type": "mesh",
37
37
  "name": "midsole",
38
38
  "material": "default_midsole_nominal",
39
- "file": "mid.ply",
40
- "representative": true
39
+ "file": "mid.ply"
41
40
  }
42
41
  ],
43
42
 
@@ -61,6 +60,9 @@ JSON manifest format
61
60
 
62
61
  "simulation": {
63
62
  "max_time": 0.04,
63
+ # Sampling resolution along the longest axis of the total box (the
64
+ # union of every part's bounds). Each part samples at a resolution
65
+ # scaled to its own size so every part shares the same spatial density.
64
66
  "max_resolution": 512,
65
67
 
66
68
  # Force source for force-displacement: "boundary_force" (default) or
@@ -214,7 +216,6 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
214
216
  name=entry["name"],
215
217
  material=_resolve_material(entry["material"]),
216
218
  filename=entry["file"],
217
- representative_part=entry.get("representative", False),
218
219
  )
219
220
  )
220
221
  return parts