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.
- {metafold-0.12.dev7 → metafold-0.12.dev9}/PKG-INFO +1 -1
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/simulation/compression_experiment.py +4 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/simulation/compression_simulation.py +352 -100
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/simulation/run_experiment.py +4 -3
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/PKG-INFO +1 -1
- {metafold-0.12.dev7 → metafold-0.12.dev9}/pyproject.toml +1 -1
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_compression_simulation.py +308 -59
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_run_experiment.py +2 -11
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_shear_simulation.py +2 -2
- {metafold-0.12.dev7 → metafold-0.12.dev9}/LICENSE +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/README.md +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/__init__.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/api.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/assets.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/auth.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/client.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/exceptions.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/jobs.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/materials.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/projects.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/simulation/__init__.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/utils.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold/workflows.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/SOURCES.txt +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/dependency_links.txt +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/requires.txt +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/metafold.egg-info/top_level.txt +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/setup.cfg +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_assets.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_compession_experiment.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_jobs.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_materials.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_projects.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_utils.py +0 -0
- {metafold-0.12.dev7 → metafold-0.12.dev9}/tests/test_workflows.py +0 -0
|
@@ -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
|
|
529
|
-
for
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|
|
739
|
-
"""Build the
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
"
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
778
|
-
|
|
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
|
-
#
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
#
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
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
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
"
|
|
2073
|
-
|
|
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
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
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
|
-
|
|
2451
|
-
|
|
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
|