metafold 0.12.dev8__tar.gz → 0.12.dev10__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.dev8 → metafold-0.12.dev10}/PKG-INFO +1 -1
  2. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/simulation/compression_experiment.py +4 -0
  3. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/simulation/compression_simulation.py +202 -115
  4. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/simulation/run_experiment.py +4 -6
  5. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold.egg-info/PKG-INFO +1 -1
  6. {metafold-0.12.dev8 → metafold-0.12.dev10}/pyproject.toml +1 -1
  7. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_compression_simulation.py +209 -67
  8. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_run_experiment.py +2 -11
  9. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_shear_simulation.py +2 -2
  10. {metafold-0.12.dev8 → metafold-0.12.dev10}/LICENSE +0 -0
  11. {metafold-0.12.dev8 → metafold-0.12.dev10}/README.md +0 -0
  12. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/__init__.py +0 -0
  13. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/api.py +0 -0
  14. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/assets.py +0 -0
  15. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/auth.py +0 -0
  16. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/client.py +0 -0
  17. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/exceptions.py +0 -0
  18. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/jobs.py +0 -0
  19. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/materials.py +0 -0
  20. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/projects.py +0 -0
  21. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/simulation/__init__.py +0 -0
  22. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/utils.py +0 -0
  23. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold/workflows.py +0 -0
  24. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold.egg-info/SOURCES.txt +0 -0
  25. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold.egg-info/dependency_links.txt +0 -0
  26. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold.egg-info/requires.txt +0 -0
  27. {metafold-0.12.dev8 → metafold-0.12.dev10}/metafold.egg-info/top_level.txt +0 -0
  28. {metafold-0.12.dev8 → metafold-0.12.dev10}/setup.cfg +0 -0
  29. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_assets.py +0 -0
  30. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_compession_experiment.py +0 -0
  31. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_jobs.py +0 -0
  32. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_materials.py +0 -0
  33. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_projects.py +0 -0
  34. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_utils.py +0 -0
  35. {metafold-0.12.dev8 → metafold-0.12.dev10}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev8
3
+ Version: 0.12.dev10
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
@@ -389,7 +388,6 @@ class CompressionSimulation:
389
388
  stl_folder: Optional[Path] = None
390
389
  out_dir: Optional[Path] = None
391
390
  results: list = []
392
- representative_part: str = ""
393
391
  simulation_name: str = ""
394
392
  simulation_parameters: SimulationParameters
395
393
  piston_velocity: list[list[float]]
@@ -416,9 +414,9 @@ class CompressionSimulation:
416
414
  prep_workflows: list[Workflow] = []
417
415
  prep_workflow_batch_size: int = 10
418
416
  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.
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.
422
420
  sample_spacing: Optional[float] = None
423
421
 
424
422
  def __init__(
@@ -478,7 +476,6 @@ class CompressionSimulation:
478
476
  else:
479
477
  self.part_infos.append(inner_part)
480
478
 
481
- self.validate_parts()
482
479
  self.setup_ply_files(stl_folder_path)
483
480
  self.setup_results(output_path)
484
481
  if client is None:
@@ -538,17 +535,6 @@ class CompressionSimulation:
538
535
  def download_results(self):
539
536
  self.write_results()
540
537
 
541
- def validate_parts(self):
542
- for p in self.part_infos:
543
- if hasattr(p.part, "representative_part") and p.part.representative_part:
544
- if self.representative_part:
545
- raise ValueError(
546
- f"At most one part can have representative_part set to True"
547
- )
548
- self.representative_part = p.part.name
549
- if not self.representative_part:
550
- raise ValueError(f"One part must have representative_part set to True")
551
-
552
538
  def setup_results(self, output_path):
553
539
  self.out_dir = Path(output_path)
554
540
  self.out_dir.mkdir(parents=True, exist_ok=True)
@@ -735,7 +721,12 @@ class CompressionSimulation:
735
721
 
736
722
  for info in part_infos:
737
723
  if info.file_path and info.asset is None:
738
- existing = self.client.assets.list(q=f"filename:{info.part.filename}")
724
+ # Quote the filename so the search parser keeps it as one term,
725
+ # and match it exactly (the search is a case-insensitive ILIKE).
726
+ existing = [
727
+ a for a in self.client.assets.list(q=f'filename:"{info.part.filename}"')
728
+ if a.filename == info.part.filename
729
+ ]
739
730
  asset = None
740
731
  if existing:
741
732
  asset = existing[0]
@@ -906,38 +897,65 @@ class CompressionSimulation:
906
897
  mx = np.array(bounds["max"], dtype=np.float64)
907
898
  return float(np.max(mx - mn))
908
899
 
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
- )
900
+ @staticmethod
901
+ def _part_bounds_mm(info):
902
+ """(min, max) axis-aligned bounds of a part in mm, or None. Mesh parts
903
+ report bounds via the pass-1 preprocess job (mm); primitives report
904
+ them analytically via get_bounds() (metres)."""
905
+ part = info.part
906
+ if isinstance(part, ExperimentPrimitive):
907
+ pmin, pmax = part.get_bounds()
908
+ return (
909
+ np.array(pmin, dtype=np.float64) * 1000.0,
910
+ np.array(pmax, dtype=np.float64) * 1000.0,
911
+ )
912
+ if info.bounds:
913
+ return (
914
+ np.array(info.bounds["min"], dtype=np.float64),
915
+ np.array(info.bounds["max"], dtype=np.float64),
916
+ )
917
+ return None
931
918
 
919
+ def _union_bounds_mm(self, part_infos: list):
920
+ """Union ("total box") of every part's bounds in mm, or None."""
921
+ mins, maxs = [], []
932
922
  for info in part_infos:
923
+ b = self._part_bounds_mm(info)
924
+ if b is not None:
925
+ mins.append(b[0])
926
+ maxs.append(b[1])
927
+ if not mins:
928
+ return None
929
+ return np.min(mins, axis=0), np.max(maxs, axis=0)
930
+
931
+ def _ensure_sample_spacing(self, part_infos: list):
932
+ """Set sample_spacing (mm) from the total box of all parts, if unset.
933
+ Anchoring to the union — not one 'representative' part — makes
934
+ max_resolution the resolution of the whole simulation's longest axis."""
935
+ if self.sample_spacing is not None:
936
+ return
937
+ max_resolution = max(2, int(self.simulation_parameters.max_resolution))
938
+ union = self._union_bounds_mm(part_infos)
939
+ if union is not None:
940
+ longest = float(np.max(union[1] - union[0]))
941
+ if longest > 0:
942
+ self.sample_spacing = longest / (max_resolution - 1)
943
+
944
+ def _assign_sample_resolutions(self, all_infos: list, mesh_infos: list):
945
+ """Anchor sample spacing to the total box (union of all part bounds),
946
+ then give each mesh a resolution scaled to its own size so every part
947
+ samples at the same spatial density."""
948
+ max_resolution = max(2, int(self.simulation_parameters.max_resolution))
949
+ self._ensure_sample_spacing(all_infos)
950
+
951
+ for info in mesh_infos:
933
952
  if self.sample_spacing and info.bounds:
934
953
  longest = self._bounds_longest_axis(info.bounds)
935
954
  info.sample_resolution = max(
936
955
  2, int(round(longest / self.sample_spacing)) + 1
937
956
  )
938
957
  else:
939
- # Legacy fallback: this part samples at max_resolution over
940
- # its own bounds (density then depends on the part's size).
958
+ # Fallback: sample at max_resolution over the part's own bounds.
941
959
  info.sample_resolution = max_resolution
942
960
 
943
961
  def sample_assets(self, part_infos=None):
@@ -958,10 +976,9 @@ class CompressionSimulation:
958
976
  )
959
977
  self._collect_preprocess_outputs(mesh_parts, preprocess_workflows)
960
978
 
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)
979
+ # Anchor sample spacing to the total box (union of all parts, primitives
980
+ # included) so every part samples at the same spatial density.
981
+ self._assign_sample_resolutions(part_infos, mesh_parts)
965
982
 
966
983
  # Pass 2: sample each mesh at its density-matched resolution, reusing
967
984
  # the preprocessed/BVH assets from pass 1.
@@ -1016,11 +1033,11 @@ class CompressionSimulation:
1016
1033
 
1017
1034
  def create_sim_config(self, name_suffix=""):
1018
1035
  """
1019
- grid_patch: the midsole patch, used as representative for grid sizing.
1036
+ The grid spans the total box (union of every part's bounds) and is
1037
+ sized by the shared sample spacing — no representative part.
1020
1038
  Piston velocity is [0,0,0] in the UPS — actual motion driven by velocity.txt.
1021
1039
  """
1022
1040
  name = self.simulation_name
1023
- grid_patch = self.get_part_info(self.representative_part).patch
1024
1041
 
1025
1042
  sim = Simulation(
1026
1043
  name,
@@ -1031,13 +1048,11 @@ class CompressionSimulation:
1031
1048
  timestep_multiplier=self.simulation_parameters.timestep_multiplier,
1032
1049
  )
1033
1050
 
1034
- grid_min = np.array(grid_patch["offset"], dtype=np.float32) * 1e-3
1035
- grid_max = np.array(grid_patch["size"], dtype=np.float32) * 1e-3 + grid_min
1036
-
1037
- # Expand grid to contain every part's bounding box, so nothing is
1038
- # clipped (e.g. a support/outsole extending below the representative
1039
- # part). Primitives report bounds via get_bounds() in metres, mesh
1040
- # parts via their sampled patch (offset/size in mm)
1051
+ # Total box: union of every part's bounding box so nothing is clipped.
1052
+ # Primitives report bounds via get_bounds() in metres, mesh parts via
1053
+ # their sampled patch (offset/size in mm).
1054
+ grid_min = np.array([np.inf, np.inf, np.inf], dtype=np.float32)
1055
+ grid_max = np.array([-np.inf, -np.inf, -np.inf], dtype=np.float32)
1041
1056
  for info in self.part_infos:
1042
1057
  part = info.part
1043
1058
  if isinstance(part, ExperimentPrimitive):
@@ -1057,11 +1072,10 @@ class CompressionSimulation:
1057
1072
  grid_max[2] += self.simulation_parameters.margin_z
1058
1073
 
1059
1074
  grid_size = grid_max - grid_min
1060
- spacing = (
1061
- np.array(grid_patch["size"], dtype=np.float32)
1062
- / (np.array(grid_patch["resolution"]) - 1)
1063
- * 1e-3
1064
- )
1075
+ # Every part is sampled at this uniform (isotropic) spacing, so the grid
1076
+ # cells match particle spacing. mm -> m.
1077
+ self._ensure_sample_spacing(self.part_infos)
1078
+ spacing = (self.sample_spacing or 1.0) * 1e-3
1065
1079
  grid_resolution = np.ceil(
1066
1080
  grid_size / (self.simulation_parameters.points_per_cell * spacing)
1067
1081
  ).astype(np.int32)
@@ -1371,20 +1385,36 @@ class CompressionSimulation:
1371
1385
  )
1372
1386
 
1373
1387
  def _add_to_workflow_stress_strain(self, part_infos: list[PartInfo]):
1374
- job_name_base = "stress-strain"
1375
- self._add_to_workflow_postprocess(
1376
- part_infos, "force-displacement", job_name_base
1377
- )
1378
- self.workflow_params[f"{job_name_base}.keys"] = json.dumps(
1379
- ["/force_displacement"]
1380
- )
1381
- representative_patch = self.get_part_info(self.representative_part).patch
1382
- self.workflow_params[f"{job_name_base}.compression_area"] = str(
1383
- representative_patch["size"][0] * representative_patch["size"][1]
1384
- )
1385
- self.workflow_params[f"{job_name_base}.initial_length"] = str(
1386
- representative_patch["size"][2]
1388
+ # One stress-strain job per deformable part, each normalizing the shared
1389
+ # (global) force-displacement curve by that part's own cross-section
1390
+ # (X*Y) and height (Z). Named stress-strain-<part> like metrics-<part>.
1391
+ fd_job = next(
1392
+ (p.jobs["force-displacement"] for p in part_infos
1393
+ if "force-displacement" in p.jobs),
1394
+ None,
1387
1395
  )
1396
+ if fd_job is None:
1397
+ return
1398
+ for part_info in part_infos:
1399
+ if not self._is_analysis_target(part_info.part):
1400
+ continue
1401
+ if part_info.disabled or not part_info.patch:
1402
+ continue
1403
+ job_name = f"stress-strain-{part_info.part_unique_name}"
1404
+ self.workflow_jobs[job_name] = {
1405
+ "type": "sim/postprocess/stress-strain",
1406
+ "needs": [fd_job],
1407
+ "assets": {"data": fd_job},
1408
+ }
1409
+ self.workflow_params[f"{job_name}.keys"] = json.dumps(
1410
+ ["/force_displacement"]
1411
+ )
1412
+ size = part_info.patch["size"]
1413
+ self.workflow_params[f"{job_name}.compression_area"] = str(
1414
+ size[0] * size[1]
1415
+ )
1416
+ self.workflow_params[f"{job_name}.initial_length"] = str(size[2])
1417
+ part_info.jobs["stress-strain"] = job_name
1388
1418
 
1389
1419
  def _add_to_workflow_particle_displacement(self, part_infos: list[PartInfo]):
1390
1420
  self._add_to_workflow_postprocess(
@@ -1973,16 +2003,25 @@ class CompressionSimulation:
1973
2003
  loading.to_csv(f)
1974
2004
  data["loadingForceDisplacement"] = filename
1975
2005
 
1976
- # Stress-strain
2006
+ # Stress-strain: one curve per deformable part.
1977
2007
  if self._contains_step(WorkflowStepType.STRESS_STRAIN):
1978
- ss_asset = w.get_asset("stress-strain.output")
1979
- assert ss_asset
1980
- self.client.assets.download_file(ss_asset.id, hdf_filename)
1981
- with pd.HDFStore(hdf_filename) as store:
1982
- filename = f"{name}/stressStrain.csv"
1983
- with zf.open(filename, "w") as f:
1984
- store["/stress_strain"].to_csv(f)
1985
- data["stressStrain"] = filename
2008
+ for part_info in self.part_infos:
2009
+ if not self._is_analysis_target(part_info.part):
2010
+ continue
2011
+ if part_info.disabled:
2012
+ continue
2013
+ i = part_info.material_index
2014
+ ss_asset = w.get_asset(
2015
+ f"stress-strain-{part_info.part_unique_name}.output"
2016
+ )
2017
+ if ss_asset is None:
2018
+ continue
2019
+ self.client.assets.download_file(ss_asset.id, hdf_filename)
2020
+ with pd.HDFStore(hdf_filename) as store:
2021
+ filename = f"{name}/stressStrain{i}.csv"
2022
+ with zf.open(filename, "w") as f:
2023
+ store["/stress_strain"].to_csv(f)
2024
+ data[f"stressStrain{i}"] = filename
1986
2025
 
1987
2026
  # ----------------------------------------------------------
1988
2027
  # Write PLY files from the combined all-material dataframe.
@@ -2218,18 +2257,26 @@ class CompressionSimulation:
2218
2257
  if raw_ue is not None:
2219
2258
  unloading_energy = float(raw_ue)
2220
2259
 
2221
- # Stress-strain: ship whole.
2260
+ # Stress-strain: ship one whole file per deformable part.
2222
2261
  if self._contains_step(WorkflowStepType.STRESS_STRAIN):
2223
- ss_asset = w.get_asset("stress-strain.output")
2224
- if ss_asset is not None:
2225
- ss_hdf = tempdir_path / "stress_strain.h5"
2226
- self.client.assets.download_file(ss_asset.id, ss_hdf)
2227
- ss_zip_path = f"{name}/stress_strain.h5"
2228
- zf.write(ss_hdf, arcname=ss_zip_path)
2229
- data["stressStrain"] = {
2230
- "name": ss_zip_path,
2231
- "path": "/stress_strain",
2232
- }
2262
+ for part_info in self.part_infos:
2263
+ if not self._is_analysis_target(part_info.part):
2264
+ continue
2265
+ if part_info.disabled:
2266
+ continue
2267
+ i = part_info.material_index
2268
+ ss_asset = w.get_asset(
2269
+ f"stress-strain-{part_info.part_unique_name}.output"
2270
+ )
2271
+ if ss_asset is not None:
2272
+ ss_hdf = tempdir_path / f"stress_strain_{i}.h5"
2273
+ self.client.assets.download_file(ss_asset.id, ss_hdf)
2274
+ ss_zip_path = f"{name}/stress_strain_{i}.h5"
2275
+ zf.write(ss_hdf, arcname=ss_zip_path)
2276
+ data[f"stressStrain{i}"] = {
2277
+ "name": ss_zip_path,
2278
+ "path": "/stress_strain",
2279
+ }
2233
2280
 
2234
2281
  # Sum interior volumes across analysis-target parts.
2235
2282
  total_volume = 0.0
@@ -2375,11 +2422,19 @@ class CompressionSimulation:
2375
2422
  }
2376
2423
 
2377
2424
  if self._contains_step(WorkflowStepType.STRESS_STRAIN):
2378
- server_data["stressStrain"] = {
2379
- "jobId": job_id_lookup.get("stress-strain", ""),
2380
- "assetName": "output",
2381
- "path": "/stress_strain",
2382
- }
2425
+ for part_info in self.part_infos:
2426
+ if not self._is_analysis_target(part_info.part):
2427
+ continue
2428
+ if part_info.disabled:
2429
+ continue
2430
+ i = part_info.material_index
2431
+ server_data[f"stressStrain{i}"] = {
2432
+ "jobId": job_id_lookup.get(
2433
+ f"stress-strain-{part_info.part_unique_name}", ""
2434
+ ),
2435
+ "assetName": "output",
2436
+ "path": "/stress_strain",
2437
+ }
2383
2438
 
2384
2439
  return server_data
2385
2440
 
@@ -2604,21 +2659,53 @@ class CompressionSimulation:
2604
2659
  },
2605
2660
  ]
2606
2661
 
2662
+ # One stress-strain card per deformable part (keyed stressStrain{i}),
2663
+ # under force-displacement: same data, per-part normalizations.
2664
+ if self._contains_step(WorkflowStepType.STRESS_STRAIN):
2665
+ for part_info in self.part_infos:
2666
+ if not self._is_analysis_target(part_info.part):
2667
+ continue
2668
+ if part_info.disabled:
2669
+ continue
2670
+ i = part_info.material_index
2671
+ self.manifest["cardsConfig"]["A"].append(
2672
+ {
2673
+ "id": f"stressStrain{i}",
2674
+ "title": f"{part_info.part_unique_name} Stress-Strain",
2675
+ "component": "MultiExperimentLineChart",
2676
+ "props": {
2677
+ "xColumn": "strain",
2678
+ "yColumn": "stress",
2679
+ "yMin": 0.0,
2680
+ "xAxisLabel": "Strain",
2681
+ "yAxisLabel": "Stress (MPa)",
2682
+ "dataSource": f"stressStrain{i}",
2683
+ },
2684
+ }
2685
+ )
2686
+
2687
+ # One von Mises histogram card per deformable part (keyed
2688
+ # vonMisesStressHistogram{i}).
2607
2689
  self.manifest["cardsConfig"]["B"] = []
2608
2690
  if self._contains_step(WorkflowStepType.VON_MISES_STRESS):
2609
- rep_idx = self.get_part_info(self.representative_part).material_index
2610
- self.manifest["cardsConfig"]["B"].append(
2611
- {
2612
- "id": "vonMisesStress",
2613
- "title": "von Mises Stress",
2614
- "component": "SingleExperimentHistogram",
2615
- "props": {
2616
- "dataSource": f"vonMisesStressHistogram{rep_idx}",
2617
- "xAxisLabel": "von Mises Stress",
2618
- "yAxisLabel": "Frequency",
2619
- },
2620
- }
2621
- )
2691
+ for part_info in self.part_infos:
2692
+ if not self._is_analysis_target(part_info.part):
2693
+ continue
2694
+ if part_info.disabled:
2695
+ continue
2696
+ i = part_info.material_index
2697
+ self.manifest["cardsConfig"]["B"].append(
2698
+ {
2699
+ "id": f"vonMisesStress{i}",
2700
+ "title": f"{part_info.part_unique_name} von Mises Stress",
2701
+ "component": "SingleExperimentHistogram",
2702
+ "props": {
2703
+ "dataSource": f"vonMisesStressHistogram{i}",
2704
+ "xAxisLabel": "von Mises Stress",
2705
+ "yAxisLabel": "Frequency",
2706
+ },
2707
+ }
2708
+ )
2622
2709
 
2623
2710
  if self._results_have_energy_metrics(results):
2624
2711
  self.manifest["resultsListConfig"].append(
@@ -2645,7 +2732,7 @@ class CompressionSimulation:
2645
2732
  "filtering": False,
2646
2733
  }
2647
2734
  )
2648
- self.manifest["cardsConfig"]["C"] = [
2735
+ self.manifest["cardsConfig"]["C"].append(
2649
2736
  {
2650
2737
  "id": "energyVolume",
2651
2738
  "title": "Energy Absorbed-Interior Volume",
@@ -2657,7 +2744,7 @@ class CompressionSimulation:
2657
2744
  "yAxisLabel": "Energy Absorbed (J)",
2658
2745
  "colorScaleMeasurement": "energyAbsorbed",
2659
2746
  },
2660
- },
2661
- ]
2747
+ }
2748
+ )
2662
2749
 
2663
2750
 
@@ -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,9 +60,9 @@ JSON manifest format
61
60
 
62
61
  "simulation": {
63
62
  "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.
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.
67
66
  "max_resolution": 512,
68
67
 
69
68
  # Force source for force-displacement: "boundary_force" (default) or
@@ -217,7 +216,6 @@ def _build_parts(parts_config: list[dict]) -> list[ExperimentPart]:
217
216
  name=entry["name"],
218
217
  material=_resolve_material(entry["material"]),
219
218
  filename=entry["file"],
220
- representative_part=entry.get("representative", False),
221
219
  )
222
220
  )
223
221
  return parts
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metafold
3
- Version: 0.12.dev8
3
+ Version: 0.12.dev10
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.dev8"
7
+ version = "0.12.dev10"
8
8
  authors = [
9
9
  {name = "Metafold 3D", email = "info@metafold3d.com"},
10
10
  ]
@@ -14,6 +14,7 @@ from metafold.simulation.compression_simulation import (
14
14
  ExperimentMesh,
15
15
  ExperimentPistonBase,
16
16
  ExperimentPistonCylinder,
17
+ ExperimentPistonMesh,
17
18
  SimulationParameters,
18
19
  WorkflowStep,
19
20
  WorkflowStepType,
@@ -38,7 +39,7 @@ def basic_parts():
38
39
  return [
39
40
  ExperimentPistonCylinder(),
40
41
  ExperimentMesh("upper_foam", DEFAULT_UPPER_FOAM, "top.ply"),
41
- ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply", representative_part=True),
42
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
42
43
  ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply"),
43
44
  ]
44
45
 
@@ -83,7 +84,7 @@ class TestConstruction:
83
84
 
84
85
  def test_missing_ply_raises(self, ply_folder, tmp_path):
85
86
  parts = [
86
- ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "nonexistent.ply", representative_part=True),
87
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "nonexistent.ply"),
87
88
  ]
88
89
  with pytest.raises(ValueError, match="not found"):
89
90
  CompressionSimulation(
@@ -94,36 +95,6 @@ class TestConstruction:
94
95
  client=MagicMock(),
95
96
  )
96
97
 
97
- class TestValidation:
98
- def test_requires_representative_part(self, ply_folder, tmp_path):
99
- parts = [
100
- ExperimentPistonCylinder(),
101
- ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"), # no rep
102
- ]
103
- with pytest.raises(ValueError, match="representative_part"):
104
- CompressionSimulation(
105
- parts=parts,
106
- simulation_name="t",
107
- stl_folder_path=str(ply_folder),
108
- output_path=str(tmp_path / "out"),
109
- client=MagicMock(),
110
- )
111
-
112
- def test_only_one_representative_part_allowed(self, ply_folder, tmp_path):
113
- parts = [
114
- ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply", representative_part=True),
115
- ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply", representative_part=True),
116
- ]
117
- with pytest.raises(ValueError, match="At most one"):
118
- CompressionSimulation(
119
- parts=parts,
120
- simulation_name="t",
121
- stl_folder_path=str(ply_folder),
122
- output_path=str(tmp_path / "out"),
123
- client=MagicMock(),
124
- )
125
-
126
-
127
98
  class TestWorkflowStepsInput:
128
99
  def test_accepts_workflow_step_instance(self):
129
100
  steps = CompressionSimulation._clean_workflow_steps_input([
@@ -248,8 +219,8 @@ class TestBuildWorkflow:
248
219
 
249
220
 
250
221
  class TestGridBounds:
251
- """The grid must enclose every part, not just the representative one, so
252
- nothing (e.g. an outsole below the midsole) gets clipped."""
222
+ """The grid (total box) must enclose every part, so nothing (e.g. an
223
+ outsole below the midsole) gets clipped."""
253
224
 
254
225
  def _grid_bounds(self, sim):
255
226
  """Return (lower, upper) of the generated UPS grid box, in metres."""
@@ -267,9 +238,9 @@ class TestGridBounds:
267
238
  sim.create_sim_config()
268
239
 
269
240
  def test_low_mesh_part_expands_grid_downward(self, sim):
270
- # The regression: a mesh part (outsole) below the representative midsole
271
- # must still expand the grid down. Before the fix, mesh parts were
272
- # skipped and the outsole's bottom got clipped.
241
+ # The regression: a mesh part (outsole) below the midsole must still
242
+ # expand the grid down. Before the fix, mesh parts were skipped and the
243
+ # outsole's bottom got clipped.
273
244
  # Patch offsets/sizes are in mm; outsole bottom at -50 mm is below all
274
245
  # other parts (incl. the piston cylinder, whose lowest point is +25 mm).
275
246
  rep = {"size": [100.0, 100.0, 50.0], "offset": [0.0, 0.0, 0.0], "resolution": [32, 32, 16]}
@@ -415,8 +386,9 @@ class TestSampleAssets:
415
386
 
416
387
 
417
388
  class TestDensityMatchedSampling:
418
- """Sampling resolutions are scaled per part so every mesh shares the
419
- representative part's sample spacing (the piston-density regression)."""
389
+ """Sampling resolutions scale per part so every mesh shares one sample
390
+ spacing anchored to the total box (union of all parts) — the piston-density
391
+ regression, now decoupled from any 'representative' part."""
420
392
 
421
393
  def _make_job(self, name, longest_axis=None, asset_key=None, filename=None):
422
394
  params = {}
@@ -430,19 +402,32 @@ class TestDensityMatchedSampling:
430
402
  )
431
403
 
432
404
  @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
405
+ def sampled_sim(self, tmp_path):
406
+ folder = tmp_path / "ply"
407
+ folder.mkdir()
408
+ for n in ["pist.ply", "top.ply", "mid.ply", "out.ply"]:
409
+ (folder / n).write_bytes(b"ply\n")
410
+
411
+ # Total box longest axis = 600 (outsole); max_resolution=31 → spacing 20.
412
+ parts = [
413
+ ExperimentPistonMesh(filename="pist.ply"),
414
+ ExperimentMesh("upper_foam", DEFAULT_UPPER_FOAM, "top.ply"),
415
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
416
+ ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply"),
417
+ ]
435
418
  sim = CompressionSimulation(
436
- parts=basic_parts,
419
+ parts=parts,
437
420
  simulation_name="t",
438
- stl_folder_path=str(ply_folder),
421
+ stl_folder_path=str(folder),
439
422
  output_path=str(tmp_path / "out"),
440
423
  client=MagicMock(),
441
424
  simulation_parameters=SimulationParameters(max_resolution=31),
442
425
  )
443
426
 
444
427
  jobs = {
445
- "p-up": self._make_job("preprocess-mesh-upper_foam", 150.0, "mesh", "pre_up.ply"),
428
+ "p-pi": self._make_job("preprocess-mesh-piston", 100.0, "mesh", "pre_pi.ply"),
429
+ "b-pi": self._make_job("compute-bvh-piston", None, "bvh", "bvh_pi.bin"),
430
+ "p-up": self._make_job("preprocess-mesh-upper_foam", 160.0, "mesh", "pre_up.ply"),
446
431
  "b-up": self._make_job("compute-bvh-upper_foam", None, "bvh", "bvh_up.bin"),
447
432
  "p-mid": self._make_job("preprocess-mesh-midsole", 300.0, "mesh", "pre_mid.ply"),
448
433
  "b-mid": self._make_job("compute-bvh-midsole", None, "bvh", "bvh_mid.bin"),
@@ -457,28 +442,29 @@ class TestDensityMatchedSampling:
457
442
  sim.sample_assets()
458
443
  return sim
459
444
 
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
445
+ def test_spacing_anchored_to_total_box(self, sampled_sim):
446
+ # Union longest axis 600 / (31 - 1) = 20
447
+ assert sampled_sim.sample_spacing == pytest.approx(20.0)
465
448
 
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
449
+ def test_largest_part_gets_max_resolution(self, sampled_sim):
450
+ assert sampled_sim.get_part_info("outsole").sample_resolution == 31
469
451
 
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
452
+ def test_parts_scale_to_shared_spacing(self, sampled_sim):
453
+ assert sampled_sim.get_part_info("piston").sample_resolution == 6 # 100/20
454
+ assert sampled_sim.get_part_info("upper_foam").sample_resolution == 9 # 160/20
455
+ assert sampled_sim.get_part_info("midsole").sample_resolution == 16 # 300/20
473
456
 
474
457
  def test_sampling_pass_binds_pass1_outputs(self, sampled_sim):
475
458
  args, kwargs = sampled_sim.client.workflows.run_async.call_args_list[1]
476
459
  assert kwargs["parameters"] == {
477
- "sample-mesh-upper_foam.resolution": "16",
478
- "sample-mesh-midsole.resolution": "31",
479
- "sample-mesh-outsole.resolution": "61",
460
+ "sample-mesh-piston.resolution": "6",
461
+ "sample-mesh-upper_foam.resolution": "9",
462
+ "sample-mesh-midsole.resolution": "16",
463
+ "sample-mesh-outsole.resolution": "31",
480
464
  }
481
465
  assert kwargs["assets"] == {
466
+ "sample-mesh-piston.mesh": "pre_pi.ply",
467
+ "sample-mesh-piston.bvh": "bvh_pi.bin",
482
468
  "sample-mesh-upper_foam.mesh": "pre_up.ply",
483
469
  "sample-mesh-upper_foam.bvh": "bvh_up.bin",
484
470
  "sample-mesh-midsole.mesh": "pre_mid.ply",
@@ -495,15 +481,38 @@ class TestDensityMatchedSampling:
495
481
  assert "mesh/preprocess" in yaml_arg
496
482
 
497
483
  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.
484
+ # A variant sampled in a later call reuses the cached spacing.
500
485
  variant = CompressionSimulation.PartInfo(
501
486
  ExperimentMesh("midsole_v1", None, "mid.ply")
502
487
  )
503
488
  variant.part_unique_name = "midsole_v1"
504
489
  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
490
+ sampled_sim._assign_sample_resolutions([variant], [variant])
491
+ assert variant.sample_resolution == 16 # 300 / 20 spacing
492
+
493
+ def test_union_bounds_includes_primitive(self, tmp_path):
494
+ # A primitive's analytic bounds (metres) contribute to the total box.
495
+ folder = tmp_path / "ply"
496
+ folder.mkdir()
497
+ (folder / "mid.ply").write_bytes(b"ply\n")
498
+ sim = CompressionSimulation(
499
+ parts=[
500
+ ExperimentPistonCylinder(),
501
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
502
+ ],
503
+ simulation_name="t",
504
+ stl_folder_path=str(folder),
505
+ output_path=str(tmp_path / "out"),
506
+ client=MagicMock(),
507
+ )
508
+ sim.get_part_info("midsole").bounds = {
509
+ "min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]
510
+ }
511
+ umin, umax = sim._union_bounds_mm(sim.part_infos)
512
+ pmin, pmax = sim._part_bounds_mm(sim.get_part_info("piston"))
513
+ # Union contains both the mesh box and the (mm-converted) primitive box
514
+ assert (umin <= pmin).all() and (umax >= pmax).all()
515
+ assert umin[0] <= 0.0 and umax[0] >= 10.0
507
516
 
508
517
 
509
518
 
@@ -556,7 +565,19 @@ jobs:
556
565
  - compress
557
566
  assets:
558
567
  data: compress
559
- stress-strain:
568
+ stress-strain-upper_foam:
569
+ type: sim/postprocess/stress-strain
570
+ needs:
571
+ - force-displacement
572
+ assets:
573
+ data: force-displacement
574
+ stress-strain-midsole:
575
+ type: sim/postprocess/stress-strain
576
+ needs:
577
+ - force-displacement
578
+ assets:
579
+ data: force-displacement
580
+ stress-strain-outsole:
560
581
  type: sim/postprocess/stress-strain
561
582
  needs:
562
583
  - force-displacement
@@ -617,8 +638,12 @@ jobs:
617
638
  "metrics-upper_foam.volume_resolution": "[32, 32, 16]",
618
639
  "metrics-upper_foam.volume_size": "[0.1, 0.1, 0.05]",
619
640
  "particle-displacement.keys": "[\"/material0/position\", \"/material1/position\", \"/material2/position\", \"/material3/position\"]",
620
- "stress-strain.initial_length": "0.05",
621
- "stress-strain.keys": "[\"/force_displacement\"]",
641
+ "stress-strain-midsole.initial_length": "0.05",
642
+ "stress-strain-midsole.keys": "[\"/force_displacement\"]",
643
+ "stress-strain-outsole.initial_length": "0.05",
644
+ "stress-strain-outsole.keys": "[\"/force_displacement\"]",
645
+ "stress-strain-upper_foam.initial_length": "0.05",
646
+ "stress-strain-upper_foam.keys": "[\"/force_displacement\"]",
622
647
  "von-mises-stress.keys": "[\"/material0/cauchy_stress\", \"/material1/cauchy_stress\", \"/material2/cauchy_stress\", \"/material3/cauchy_stress\"]"
623
648
  }
624
649
  """)
@@ -627,6 +652,52 @@ jobs:
627
652
  assert workflow_params[key] == value, f"mismatch for {key}: {workflow_params[key]!r} != {value!r}"
628
653
 
629
654
 
655
+ class TestStressStrainPerPart:
656
+ """stress-strain runs once per deformable part (not one representative)."""
657
+
658
+ def _built_sim(self, ply_folder, basic_parts, tmp_path):
659
+ sim = CompressionSimulation(
660
+ parts=basic_parts,
661
+ simulation_name="t",
662
+ stl_folder_path=str(ply_folder),
663
+ output_path=str(tmp_path / "out"),
664
+ client=MagicMock(),
665
+ )
666
+ # Distinct X/Y so compression_area = X*Y is unambiguous.
667
+ fake_patch = {"size": [0.1, 0.2, 0.05], "offset": [0.0, 0.0, 0.0], "resolution": [32, 32, 16]}
668
+ for info in sim.part_infos:
669
+ info.patch = fake_patch
670
+ if hasattr(info.part, "filename"):
671
+ info.volume_filename = f"{info.part_unique_name}_volume.bin"
672
+ sim.create_sim_config()
673
+ sim.build_workflow()
674
+ return sim
675
+
676
+ def test_one_job_per_deformable_part(self, ply_folder, basic_parts, tmp_path):
677
+ sim = self._built_sim(ply_folder, basic_parts, tmp_path)
678
+ for name in ("upper_foam", "midsole", "outsole"):
679
+ assert f"stress-strain-{name}" in sim.workflow_jobs
680
+
681
+ def test_no_single_or_rigid_job(self, ply_folder, basic_parts, tmp_path):
682
+ sim = self._built_sim(ply_folder, basic_parts, tmp_path)
683
+ assert "stress-strain" not in sim.workflow_jobs
684
+ assert "stress-strain-piston" not in sim.workflow_jobs
685
+
686
+ def test_each_job_reads_the_shared_force_displacement(self, ply_folder, basic_parts, tmp_path):
687
+ sim = self._built_sim(ply_folder, basic_parts, tmp_path)
688
+ for name in ("upper_foam", "midsole", "outsole"):
689
+ job = sim.workflow_jobs[f"stress-strain-{name}"]
690
+ assert job["type"] == "sim/postprocess/stress-strain"
691
+ assert job["needs"] == ["force-displacement"]
692
+ assert job["assets"] == {"data": "force-displacement"}
693
+
694
+ def test_params_normalized_by_own_patch(self, ply_folder, basic_parts, tmp_path):
695
+ sim = self._built_sim(ply_folder, basic_parts, tmp_path)
696
+ p = sim.workflow_params
697
+ assert p["stress-strain-midsole.compression_area"] == str(0.1 * 0.2)
698
+ assert p["stress-strain-midsole.initial_length"] == "0.05"
699
+ assert p["stress-strain-midsole.keys"] == json.dumps(["/force_displacement"])
700
+
630
701
 
631
702
  class TestMeshDataKey:
632
703
  def test_simple_name(self):
@@ -644,13 +715,18 @@ class TestMakeManifestV2:
644
715
 
645
716
  @pytest.fixture
646
717
  def sim_with_full_steps(self, ply_folder, basic_parts, tmp_path):
647
- return CompressionSimulation(
718
+ sim = CompressionSimulation(
648
719
  parts=basic_parts,
649
720
  simulation_name="t",
650
721
  stl_folder_path=str(ply_folder),
651
722
  output_path=str(tmp_path / "out"),
652
723
  client=MagicMock(),
653
724
  )
725
+ # In the real flow create_sim_config() assigns material indices before
726
+ # make_manifest_v2() runs; mirror that so per-part cards key correctly.
727
+ for i, info in enumerate(sim.part_infos):
728
+ info.material_index = i
729
+ return sim
654
730
 
655
731
  def test_results_list_config_has_name_column_always(self, sim_with_full_steps):
656
732
  sim_with_full_steps.make_manifest_v2(results=[])
@@ -678,7 +754,6 @@ class TestMakeManifestV2:
678
754
  def test_card_c_present_when_energy_metrics_step_present(self, sim_with_full_steps):
679
755
  sim_with_full_steps.make_manifest_v2(results=[])
680
756
  card_c = sim_with_full_steps.manifest["cardsConfig"]["C"]
681
- assert len(card_c) == 1
682
757
  assert card_c[0]["id"] == "energyVolume"
683
758
 
684
759
  def test_card_c_absent_when_no_energy_metrics(self, ply_folder, basic_parts, tmp_path):
@@ -771,6 +846,73 @@ class TestMakeManifestV2:
771
846
  sim.make_manifest_v2()
772
847
  assert sim.manifest["cardsConfig"]["A"] == []
773
848
 
849
+ def test_von_mises_cards_one_per_deformable_part(self, sim_with_full_steps):
850
+ sim_with_full_steps.make_manifest_v2()
851
+ cards = sim_with_full_steps.manifest["cardsConfig"]["B"]
852
+ ids = [c["id"] for c in cards]
853
+ assert ids == ["vonMisesStress1", "vonMisesStress2", "vonMisesStress3"]
854
+ for card in cards:
855
+ assert card["component"] == "SingleExperimentHistogram"
856
+ assert card["props"]["dataSource"] == card["id"].replace(
857
+ "vonMisesStress", "vonMisesStressHistogram"
858
+ )
859
+
860
+ def test_von_mises_cards_absent_when_step_missing(self, ply_folder, basic_parts, tmp_path):
861
+ sim = CompressionSimulation(
862
+ parts=basic_parts,
863
+ simulation_name="t",
864
+ stl_folder_path=str(ply_folder),
865
+ output_path=str(tmp_path / "out"),
866
+ client=MagicMock(),
867
+ workflow_steps=[WorkflowStep(WorkflowStepType.COMPRESS)],
868
+ )
869
+ sim.make_manifest_v2()
870
+ assert sim.manifest["cardsConfig"]["B"] == []
871
+
872
+ def test_stress_strain_cards_in_pane_a_after_force_displacement(self, sim_with_full_steps):
873
+ sim_with_full_steps.make_manifest_v2()
874
+ ids = [c["id"] for c in sim_with_full_steps.manifest["cardsConfig"]["A"]]
875
+ assert ids == [
876
+ "forceDisplacement",
877
+ "stressStrain1",
878
+ "stressStrain2",
879
+ "stressStrain3",
880
+ ]
881
+ # Piston (material_index 0) gets no stress-strain card.
882
+ assert "stressStrain0" not in ids
883
+
884
+ def test_stress_strain_card_props(self, sim_with_full_steps):
885
+ sim_with_full_steps.make_manifest_v2()
886
+ cards = [
887
+ c
888
+ for c in sim_with_full_steps.manifest["cardsConfig"]["A"]
889
+ if c["id"].startswith("stressStrain")
890
+ ]
891
+ assert cards
892
+ for card in cards:
893
+ assert card["component"] == "MultiExperimentLineChart"
894
+ assert card["props"]["xColumn"] == "strain"
895
+ assert card["props"]["yColumn"] == "stress"
896
+ assert card["props"]["dataSource"] == card["id"]
897
+
898
+ def test_stress_strain_cards_absent_when_step_missing(self, ply_folder, basic_parts, tmp_path):
899
+ sim = CompressionSimulation(
900
+ parts=basic_parts,
901
+ simulation_name="t",
902
+ stl_folder_path=str(ply_folder),
903
+ output_path=str(tmp_path / "out"),
904
+ client=MagicMock(),
905
+ workflow_steps=[WorkflowStep(WorkflowStepType.COMPRESS)],
906
+ )
907
+ sim.make_manifest_v2()
908
+ ids = [c["id"] for c in sim.manifest["cardsConfig"]["A"]]
909
+ assert not any(i.startswith("stressStrain") for i in ids)
910
+
911
+ def test_pane_c_is_energy_volume_only(self, sim_with_full_steps):
912
+ sim_with_full_steps.make_manifest_v2(results=[])
913
+ ids = [c["id"] for c in sim_with_full_steps.manifest["cardsConfig"]["C"]]
914
+ assert ids == ["energyVolume"]
915
+
774
916
 
775
917
  class TestResultsHaveEnergyMetrics:
776
918
  def test_returns_true_when_energy_metrics_step_present(self, ply_folder, basic_parts, tmp_path):
@@ -153,7 +153,6 @@ class TestBuildParts:
153
153
  "name": "midsole",
154
154
  "material": "default_midsole_nominal",
155
155
  "file": "mid.ply",
156
- "representative": True,
157
156
  }
158
157
  ])
159
158
  p = parts[0]
@@ -161,7 +160,6 @@ class TestBuildParts:
161
160
  assert p.name == "midsole"
162
161
  assert p.material is DEFAULT_MIDSOLE_NOMINAL
163
162
  assert p.filename == "mid.ply"
164
- assert p.representative_part is True
165
163
 
166
164
  def test_mesh_part_with_material_instance(self):
167
165
  parts = _build_parts([
@@ -169,12 +167,6 @@ class TestBuildParts:
169
167
  ])
170
168
  assert parts[0].material is DEFAULT_OUTSOLE
171
169
 
172
- def test_representative_defaults_to_false(self):
173
- parts = _build_parts([
174
- {"name": "upper_foam", "material": "default_upper_foam", "file": "top.ply"}
175
- ])
176
- assert parts[0].representative_part is False
177
-
178
170
  def test_missing_name_raises(self):
179
171
  with pytest.raises(ValueError, match="missing 'name'"):
180
172
  _build_parts([{"material": "default_outsole", "file": "out.ply"}])
@@ -191,7 +183,7 @@ class TestBuildParts:
191
183
  parts = _build_parts([
192
184
  {"type": "piston_cylinder"},
193
185
  {"name": "upper_foam", "material": "default_upper_foam", "file": "top.ply"},
194
- {"name": "midsole", "material": "default_midsole_nominal", "file": "mid.ply", "representative": True},
186
+ {"name": "midsole", "material": "default_midsole_nominal", "file": "mid.ply"},
195
187
  {"name": "outsole", "material": "default_outsole", "file": "out.ply"},
196
188
  ])
197
189
  assert len(parts) == 4
@@ -339,7 +331,7 @@ class TestRunExperiment:
339
331
  "ply_folder": str(ply_folder),
340
332
  "parts": [
341
333
  {"type": "piston_cylinder"},
342
- {"name": "midsole", "material": "default_midsole_nominal", "file": "mid.ply", "representative": True},
334
+ {"name": "midsole", "material": "default_midsole_nominal", "file": "mid.ply"},
343
335
  ],
344
336
  })
345
337
  assert result == "proj-abc"
@@ -604,7 +596,6 @@ class TestRunExperimentFromZip:
604
596
  "name": "midsole",
605
597
  "material": "default_midsole_nominal",
606
598
  "file": "mid.ply",
607
- "representative": True,
608
599
  }],
609
600
  }
610
601
  zip_path = self._make_zip(tmp_path, manifest, mesh_files=["mid.ply"])
@@ -31,7 +31,7 @@ def basic_parts():
31
31
  return [
32
32
  ExperimentPistonCylinder(),
33
33
  ExperimentMesh("upper_foam", DEFAULT_UPPER_FOAM, "top.ply"),
34
- ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply", representative_part=True),
34
+ ExperimentMesh("midsole", DEFAULT_MIDSOLE_NOMINAL, "mid.ply"),
35
35
  ExperimentMesh("outsole", DEFAULT_OUTSOLE, "out.ply"),
36
36
  ]
37
37
 
@@ -290,7 +290,7 @@ class TestShearUPS:
290
290
  material=piston_mat,
291
291
  velocity=self.SHEAR_VELOCITY,
292
292
  ),
293
- ExperimentMesh("puck", puck_mat, "puck.stl", representative_part=True),
293
+ ExperimentMesh("puck", puck_mat, "puck.stl"),
294
294
  ]
295
295
 
296
296
  sim_params = SimulationParameters(
File without changes
File without changes
File without changes