loadpath 0.1.1__py3-none-any.whl
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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/spec.py
ADDED
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The study: everything a topology optimization needs, as one JSON document.
|
|
3
|
+
|
|
4
|
+
Units are fixed so nobody has to guess: millimetres, newtons, megapascals
|
|
5
|
+
(N/mm^2), grams per cubic centimetre. A study an agent writes looks like:
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"name": "arm-link",
|
|
9
|
+
"designSpace": [{"type": "box", "min": [0, -20, -12], "max": [160, 20, 12]}],
|
|
10
|
+
"preserve": [
|
|
11
|
+
{"id": "shoulder", "shape": {"type": "cylinder", "center": [0, 0, 0],
|
|
12
|
+
"axis": "z", "length": 24, "r": 16}}
|
|
13
|
+
],
|
|
14
|
+
"keepOut": [
|
|
15
|
+
{"id": "shoulder-bore", "shape": {"type": "cylinder", "center": [0, 0, 0],
|
|
16
|
+
"axis": "z", "length": 30, "r": 11}}
|
|
17
|
+
],
|
|
18
|
+
"supports": [{"region": "shoulder-bore"}],
|
|
19
|
+
"loadCases": [
|
|
20
|
+
{"name": "payload", "loads": [{"region": "wrist-bore", "force": [0, 0, -40]}]}
|
|
21
|
+
],
|
|
22
|
+
"material": "pa12",
|
|
23
|
+
"goal": {"volumeFraction": 0.25},
|
|
24
|
+
"minMember": 3.0,
|
|
25
|
+
"symmetry": ["z"],
|
|
26
|
+
"resolution": {"maxElements": 80000}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
A load or support names a region: a preserve or keep-out id, or an inline
|
|
30
|
+
shape. Loading a keep-out (a bore) means "through whatever material lines it",
|
|
31
|
+
which is how a bolt or a shaft actually pushes on a part.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
|
|
40
|
+
from .am import parse_build_direction
|
|
41
|
+
from .materials import Material, material_from_spec
|
|
42
|
+
from .shapes import Shape, shape_from_spec
|
|
43
|
+
|
|
44
|
+
__all__ = ["Region", "Load", "LoadCase", "Support", "Goal", "Study", "study_from_dict"]
|
|
45
|
+
|
|
46
|
+
DEFAULT_MAX_ELEMENTS = 60_000
|
|
47
|
+
# mm: the member size the filter holds a study to when it names neither a
|
|
48
|
+
# minMember nor a process: about three FDM perimeters, and under FDM's own
|
|
49
|
+
# floor (2 x its 0.8 mm minimum feature) on purpose. At 1.6 the filter grew
|
|
50
|
+
# on grids coarser than a millimetre too (the mbb-beam eval, 1.29 mm cells,
|
|
51
|
+
# went from passing at SF 1.52 to a search that stalled at 1.49); at 1.4 it
|
|
52
|
+
# only acts below ~1.2 mm cells, where the sub-millimetre skins come from.
|
|
53
|
+
DEFAULT_MIN_MEMBER = 1.4
|
|
54
|
+
# Hard ceiling regardless of what a study asks for. From 100k cells the
|
|
55
|
+
# optimizer is matrix-free multigrid (mg.py), ~1.5 KB per cell: an 845k-cell
|
|
56
|
+
# study ran end to end in 20 min and 1.6 GB on a 4-core box. Past a few
|
|
57
|
+
# million the meshing and verification steps, not the solver, set the limit.
|
|
58
|
+
MAX_ELEMENTS_CEILING = 4_000_000
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Region:
|
|
63
|
+
id: str
|
|
64
|
+
shape: Shape
|
|
65
|
+
# keepOut only: false for a clearance volume (a drive, a spring's
|
|
66
|
+
# swept space) that may end against material, unlike a bore a screw or
|
|
67
|
+
# pin has to pass through (verify's closed-bore check skips it)
|
|
68
|
+
bore: bool = True
|
|
69
|
+
# keepOut only: mm of material kept round it, pinned solid (a duct's or
|
|
70
|
+
# a plenum's wall; see pressure.py). Set for every pressurized keep-out.
|
|
71
|
+
wall: float | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Load:
|
|
76
|
+
region: str | None
|
|
77
|
+
shape: Shape | None
|
|
78
|
+
force: np.ndarray # N, total over the region
|
|
79
|
+
# N·mm about the region's centroid (a motor's torque on a hub).
|
|
80
|
+
moment: np.ndarray = field(default_factory=lambda: np.zeros(3))
|
|
81
|
+
# "uniform", or "bearing": a shaft pushing on one side of a bore, with
|
|
82
|
+
# the force spread as a cosine over the half facing it.
|
|
83
|
+
distribution: str = "uniform"
|
|
84
|
+
# MPa gauge in the keep-out `region` names, on the material lining it
|
|
85
|
+
# (pressure.py); 0 = no pressure. Negative is a vacuum.
|
|
86
|
+
pressure: float = 0.0
|
|
87
|
+
# The force's direction is not known: None (as written), "any" (its size
|
|
88
|
+
# in every direction) or "cone" (within `cone` degrees of it). The
|
|
89
|
+
# optimizer and verification design for the worst one (directions.py).
|
|
90
|
+
direction: str | None = None
|
|
91
|
+
cone: float | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class LoadCase:
|
|
96
|
+
name: str
|
|
97
|
+
loads: list[Load]
|
|
98
|
+
weight: float = 1.0
|
|
99
|
+
# durability (durability.py): times applied over the part's life
|
|
100
|
+
# (fatigue), hours held (creep), and the temperature then (C)
|
|
101
|
+
cycles: float | None = None
|
|
102
|
+
hours: float | None = None
|
|
103
|
+
temperature: float | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Support:
|
|
108
|
+
region: str | None
|
|
109
|
+
shape: Shape | None
|
|
110
|
+
fix: tuple[bool, bool, bool] = (True, True, True)
|
|
111
|
+
# A pin through a bore: held radially and axially, free to rotate about
|
|
112
|
+
# the bore's axis (a bolt or a shaft in a plain hole).
|
|
113
|
+
pin: bool = False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Goal:
|
|
118
|
+
# Exactly one of these drives the optimizer.
|
|
119
|
+
volume_fraction: float | None = None
|
|
120
|
+
# "As light as possible while every load case keeps this safety factor"
|
|
121
|
+
# and/or "deflects no more than this (mm) at its loads": the study
|
|
122
|
+
# searches over volume fractions, verifying each outcome. A robot arm is
|
|
123
|
+
# usually stiffness-limited long before it is strength-limited.
|
|
124
|
+
safety_factor: float | None = None
|
|
125
|
+
max_deflection: float | None = None
|
|
126
|
+
# "search": compliance designs at a sequence of volume fractions until
|
|
127
|
+
# one passes; "constrained": minimize mass with the limits as MMA
|
|
128
|
+
# constraints, corrected against the verified mesh (constrained.py);
|
|
129
|
+
# "auto": the search, then the constrained method too when the search's
|
|
130
|
+
# part idles (stress utilization under 0.15, not deflection-bound),
|
|
131
|
+
# keeping the lighter (the default; study._run_auto).
|
|
132
|
+
method: str = "auto"
|
|
133
|
+
# How the search's pick is confirmed before delivery: "voxel" re-verifies
|
|
134
|
+
# it on a finer voxel grid; "tet" on a body-fitted tetrahedral mesh
|
|
135
|
+
# (crosscheck.py), whose reading also calibrates the search's voxel
|
|
136
|
+
# stress so it can step down to the real target; "auto" is "tet" when
|
|
137
|
+
# the cross-check is installed and covers the study, else "voxel".
|
|
138
|
+
confirm: str = "auto"
|
|
139
|
+
# Extra fixed fractions to return alongside, for a trade-off gallery.
|
|
140
|
+
outcomes: list[float] = field(default_factory=list)
|
|
141
|
+
# Which passing outcome is delivered: the lightest ("mass"), or for FDM
|
|
142
|
+
# the one a slicer prints fastest ("printTime") or with the least
|
|
143
|
+
# filament, support included ("filament"); see slicer.py.
|
|
144
|
+
minimize: str = "mass"
|
|
145
|
+
# Search this many times from different starting fractions (the goal's
|
|
146
|
+
# volumeFraction times RESTART_STARTS) and deliver the lightest confirmed
|
|
147
|
+
# part. One search settles in the local optimum its start leads to: the
|
|
148
|
+
# same study started at 0.8x and 1.25x delivered 68.7-80.6 g (wall
|
|
149
|
+
# bracket), 140.7-150.1 g (GRIP turret). For a final production part;
|
|
150
|
+
# N times the time. See docs/study-format.md.
|
|
151
|
+
restarts: int = 1
|
|
152
|
+
# A safety factor holds against buckling too: the part's linear
|
|
153
|
+
# buckling load factor (verify._buckling) must reach it, as the stress
|
|
154
|
+
# safety factor must. False reports the factor without failing on it.
|
|
155
|
+
buckling: bool = True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# How the part will be made. Each process sets what the design must respect:
|
|
159
|
+
# whether layers make it weaker across them (FDM bonds layer to layer at a
|
|
160
|
+
# fraction of the in-plane strength), whether overhangs need control, and
|
|
161
|
+
# the smallest feature it holds, and the smallest hole that stays open (unfused
|
|
162
|
+
# powder sinters shut in a narrow SLS/MJF hole, resin cures shut in a narrow
|
|
163
|
+
# SLA one, a nozzle bridges over a narrow FDM one). Values are typical
|
|
164
|
+
# published ranges, chosen conservative; override any of them in the study.
|
|
165
|
+
PROCESSES = {
|
|
166
|
+
"fdm": {"interlayer": 0.6, "minFeature": 0.8, "minHole": 2.0, "overhangs": True,
|
|
167
|
+
"note": "filament: layer bonds ~50-70% of in-plane strength; self-supporting at 45 degrees"},
|
|
168
|
+
"sls": {"interlayer": 0.9, "minFeature": 0.8, "minHole": 2.0, "overhangs": False,
|
|
169
|
+
"note": "powder bed (PA12, PA11): nearly isotropic, no supports; leave powder escape holes in closed cavities"},
|
|
170
|
+
"mjf": {"interlayer": 0.95, "minFeature": 0.8, "minHole": 2.0, "overhangs": False,
|
|
171
|
+
"note": "HP Multi Jet Fusion: as SLS, slightly more isotropic"},
|
|
172
|
+
"sla": {"interlayer": 1.0, "minFeature": 0.5, "minHole": 1.0, "overhangs": False,
|
|
173
|
+
"note": "resin vat: isotropic once cured; supports are added by the slicer"},
|
|
174
|
+
"dlp": {"interlayer": 1.0, "minFeature": 0.5, "minHole": 1.0, "overhangs": False,
|
|
175
|
+
"note": "resin vat, whole-layer exposure (DLP/LCD): as SLA; large cross-sections peel hard"},
|
|
176
|
+
"volumetric": {"interlayer": 1.0, "minFeature": 0.3, "minHole": 0.6, "overhangs": False,
|
|
177
|
+
"note": "volumetric / tomographic resin printing: no layers, no supports"},
|
|
178
|
+
"jetting": {"interlayer": 0.95, "minFeature": 0.4, "minHole": 1.0, "overhangs": False,
|
|
179
|
+
"note": "material jetting (PolyJet): photopolymer droplets cured layer by layer, several materials "
|
|
180
|
+
"mixed voxel by voxel; gel support everywhere, so no overhang limit; nearly isotropic"},
|
|
181
|
+
"lpbf": {"interlayer": 0.85, "minFeature": 0.4, "minHole": 1.0, "overhangs": True,
|
|
182
|
+
"note": "laser powder-bed fusion metal (AlSi10Mg, Ti64, 316L): strength across layers ~85-90% "
|
|
183
|
+
"as built; overhangs past 45 degrees need supports, which cannot come out of closed "
|
|
184
|
+
"channels; bridges only ~2 mm; enclosed powder must drain"},
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class Process:
|
|
190
|
+
kind: str
|
|
191
|
+
# (axis, sign) or a unit vector the layers stack along, if layered;
|
|
192
|
+
# "auto" until orient.choose_build_direction picks one
|
|
193
|
+
build: Any
|
|
194
|
+
interlayer: float # strength across layers / in-plane strength
|
|
195
|
+
min_feature: float # mm
|
|
196
|
+
overhangs: bool # design self-supporting along `build`
|
|
197
|
+
# stiffness across layers / in-plane (modulus, shear); None = the
|
|
198
|
+
# process default in am._LAYER_STIFFNESS
|
|
199
|
+
ez: float | None = None
|
|
200
|
+
gz: float | None = None
|
|
201
|
+
# what the optimizer designs with: False = isotropic (verification
|
|
202
|
+
# always uses the layered stiffness), True = the physical layered
|
|
203
|
+
# stiffness, "bond" = across-layer terms cut to the bond's strength
|
|
204
|
+
# ratio (am.layered_elasticity). Neither layered option beat isotropic
|
|
205
|
+
# reliably on the motor mount and hook (2026-10-29), so off by default.
|
|
206
|
+
anisotropic: Any = False
|
|
207
|
+
# mm: the smallest powder escape or resin drain hole the part's cavities
|
|
208
|
+
# may rely on (processcheck.RULES default)
|
|
209
|
+
escape_hole: float | None = None
|
|
210
|
+
min_hole: float | None = None # mm
|
|
211
|
+
# mm: longest unsupported bridge the printer manages (printcheck.BRIDGE_SPAN default)
|
|
212
|
+
bridge_span: float | None = None
|
|
213
|
+
# lattice stiffness as printed over designed: None = the published-test
|
|
214
|
+
# fit (lattice_print.print_factor), a number, or "designed" (1)
|
|
215
|
+
lattice_stiffness: Any = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _process(spec) -> Process | None:
|
|
219
|
+
if spec is None:
|
|
220
|
+
return None
|
|
221
|
+
if isinstance(spec, str):
|
|
222
|
+
spec = {"type": spec}
|
|
223
|
+
kind = str(spec.get("type", "")).lower()
|
|
224
|
+
if kind not in PROCESSES:
|
|
225
|
+
raise ValueError(f"process.type must be one of {sorted(PROCESSES)}, got {kind!r}")
|
|
226
|
+
base = PROCESSES[kind]
|
|
227
|
+
from .am import parse_build_direction
|
|
228
|
+
raw_build = spec.get("buildDirection", "+z" if kind in ("fdm", "lpbf") else None)
|
|
229
|
+
build = "auto" if isinstance(raw_build, str) and raw_build.strip().lower() == "auto" \
|
|
230
|
+
else parse_build_direction(raw_build)
|
|
231
|
+
inter = float(spec.get("interlayer", base["interlayer"]))
|
|
232
|
+
if not 0.0 < inter <= 1.0:
|
|
233
|
+
raise ValueError("process.interlayer is a fraction of the in-plane strength, in (0, 1]")
|
|
234
|
+
stiff = {}
|
|
235
|
+
for key, name in (("ez", "stiffnessAcrossLayers"), ("gz", "shearAcrossLayers")):
|
|
236
|
+
if spec.get(name) is not None:
|
|
237
|
+
v = float(spec[name])
|
|
238
|
+
if not 0.0 < v <= 1.0:
|
|
239
|
+
raise ValueError(f"process.{name} is a fraction of the in-plane value, in (0, 1]")
|
|
240
|
+
stiff[key] = v
|
|
241
|
+
esc = spec.get("escapeHole")
|
|
242
|
+
if esc is not None and float(esc) <= 0:
|
|
243
|
+
raise ValueError("process.escapeHole is a hole diameter in mm, > 0")
|
|
244
|
+
span = spec.get("bridgeSpan")
|
|
245
|
+
if span is not None and float(span) <= 0:
|
|
246
|
+
raise ValueError("process.bridgeSpan is the longest bridge the printer prints cleanly, in mm, > 0")
|
|
247
|
+
lat_k = spec.get("latticeStiffness")
|
|
248
|
+
if lat_k is not None:
|
|
249
|
+
if isinstance(lat_k, str):
|
|
250
|
+
if lat_k.strip().lower() not in ("designed", "as-printed"):
|
|
251
|
+
raise ValueError('process.latticeStiffness is a factor in (0, 1.5], "as-printed" or "designed"')
|
|
252
|
+
lat_k = None if lat_k.strip().lower() == "as-printed" else "designed"
|
|
253
|
+
elif not 0.0 < float(lat_k) <= 1.5:
|
|
254
|
+
raise ValueError("process.latticeStiffness is the printed lattice's stiffness over the designed "
|
|
255
|
+
"geometry's, in (0, 1.5]")
|
|
256
|
+
else:
|
|
257
|
+
lat_k = float(lat_k)
|
|
258
|
+
return Process(kind, build, inter, float(spec.get("minFeature", base["minFeature"])),
|
|
259
|
+
bool(spec.get("overhangs", base["overhangs"])),
|
|
260
|
+
anisotropic=_anisotropic(spec.get("anisotropic", False)),
|
|
261
|
+
escape_hole=float(esc) if esc is not None else None,
|
|
262
|
+
min_hole=float(spec.get("minHole", base["minHole"])),
|
|
263
|
+
bridge_span=float(span) if span is not None else None, lattice_stiffness=lat_k, **stiff)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _drains(spec, process):
|
|
267
|
+
"""`drains`: "auto" (default; powder and resin processes and LPBF), true,
|
|
268
|
+
false, or an object of escape.EscapeOptions keys. Resolved to an
|
|
269
|
+
EscapeOptions, or None when off."""
|
|
270
|
+
from .drains import drains_wanted
|
|
271
|
+
from .escape import EscapeOptions
|
|
272
|
+
|
|
273
|
+
if spec in ("auto", None):
|
|
274
|
+
return EscapeOptions.for_process(process) if drains_wanted("auto", process) else None
|
|
275
|
+
if not drains_wanted(spec, process):
|
|
276
|
+
return None
|
|
277
|
+
return EscapeOptions.from_spec(spec if isinstance(spec, dict) else {}, process)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _card_process(process, mat, spec):
|
|
281
|
+
"""A library card that measured across-layer strength and stiffness on
|
|
282
|
+
the study's process (material_library.py) sets the process's factors,
|
|
283
|
+
unless the study sets its own; a calibrated card then overrides both."""
|
|
284
|
+
if process is None or not mat.measured or mat.measured != process.kind:
|
|
285
|
+
return process
|
|
286
|
+
spec = spec if isinstance(spec, dict) else {}
|
|
287
|
+
inter, ez = mat.across_layers()
|
|
288
|
+
if inter is not None and "interlayer" not in spec:
|
|
289
|
+
process.interlayer = inter
|
|
290
|
+
if ez is not None and "stiffnessAcrossLayers" not in spec:
|
|
291
|
+
process.ez = ez
|
|
292
|
+
return process
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _calibrated_process(process, mat_in, spec):
|
|
296
|
+
"""A calibrated card (calibration.py) measured its printer's across-layer
|
|
297
|
+
strength and stiffness: use them unless the study sets its own."""
|
|
298
|
+
if process is None or not isinstance(mat_in, str):
|
|
299
|
+
return process
|
|
300
|
+
from .calibration import load_card
|
|
301
|
+
card = load_card(mat_in)
|
|
302
|
+
cp = (card or {}).get("process") or {}
|
|
303
|
+
if cp.get("type") != process.kind:
|
|
304
|
+
return process
|
|
305
|
+
spec = spec or {}
|
|
306
|
+
if "interlayer" not in spec and cp.get("interlayer"):
|
|
307
|
+
process.interlayer = float(cp["interlayer"])
|
|
308
|
+
if "stiffnessAcrossLayers" not in spec and cp.get("stiffnessAcrossLayers"):
|
|
309
|
+
process.ez = float(cp["stiffnessAcrossLayers"])
|
|
310
|
+
# the gyroid coupon measured this printer's lattice against the model
|
|
311
|
+
lat = (card or {}).get("lattice") or {}
|
|
312
|
+
if "latticeStiffness" not in spec and lat.get("stiffnessFactor"):
|
|
313
|
+
process.lattice_stiffness = float(lat["stiffnessFactor"])
|
|
314
|
+
return process
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
_BUILD_SIM_KEYS = {"layer": "layer", "plate": "plate", "scale": "scale", "release": "release",
|
|
318
|
+
"bedTemperature": "bed", "chamberTemperature": "chamber", "bedStiffness": "bed_stiffness",
|
|
319
|
+
"relax": "relax", "recoaterGap": "recoater_gap", "plateCells": "plate_cells",
|
|
320
|
+
"bedAdhesion": "bed_adhesion", "printLayer": "print_layer", "brim": "brim"}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _build_sim(spec, process, material):
|
|
324
|
+
"""`buildSim`: true or options -> {"setup": buildsim.BuildSetup,
|
|
325
|
+
"compensate": bool, "cells": int}; None when absent or false."""
|
|
326
|
+
if spec is None or spec is False:
|
|
327
|
+
return None
|
|
328
|
+
if spec is True:
|
|
329
|
+
spec = {}
|
|
330
|
+
kind = process.kind if process is not None else None
|
|
331
|
+
if kind not in ("lpbf", "fdm", "sls", "mjf"):
|
|
332
|
+
raise ValueError("buildSim simulates lpbf, fdm, sls or mjf builds: give process.type")
|
|
333
|
+
from .buildsim import BuildSetup
|
|
334
|
+
kw = {v: spec[k] for k, v in _BUILD_SIM_KEYS.items() if k in spec}
|
|
335
|
+
if "strain" in spec:
|
|
336
|
+
kw["strain"] = tuple(float(x) for x in spec["strain"])
|
|
337
|
+
if kw.get("release", "full") not in ("full", "none"):
|
|
338
|
+
raise ValueError("buildSim.release is 'full' (off the plate) or 'none' (as built, on it)")
|
|
339
|
+
mat = material if isinstance(material, str) else "pla"
|
|
340
|
+
setup = BuildSetup(process="sls" if kind == "mjf" else kind, material=mat, **kw)
|
|
341
|
+
warp = float(spec.get("warpWeight", 0.0))
|
|
342
|
+
if warp < 0:
|
|
343
|
+
raise ValueError("buildSim.warpWeight is a weight >= 0 (0: verify only)")
|
|
344
|
+
lim_d = float(spec["maxDistortion"]) if spec.get("maxDistortion") is not None else None
|
|
345
|
+
lim_l = float(spec["maxRecoaterLift"]) if spec.get("maxRecoaterLift") is not None else None
|
|
346
|
+
for name, v in (("maxDistortion", lim_d), ("maxRecoaterLift", lim_l)):
|
|
347
|
+
if v is not None and v <= 0:
|
|
348
|
+
raise ValueError(f"buildSim.{name} is a limit in mm > 0")
|
|
349
|
+
if warp > 0 or lim_d is not None or lim_l is not None:
|
|
350
|
+
what = "warpWeight" if warp > 0 else "maxDistortion / maxRecoaterLift"
|
|
351
|
+
if kind not in ("lpbf", "fdm"):
|
|
352
|
+
raise ValueError(f"buildSim.{what} designs against a layer-by-layer build: process lpbf or fdm")
|
|
353
|
+
if process.build == "auto" or process.build is None or len(process.build) != 2:
|
|
354
|
+
raise ValueError(f"buildSim.{what} needs process.buildDirection along a grid axis "
|
|
355
|
+
"('+z', '-y', ...), not 'auto' or a tilted vector")
|
|
356
|
+
coarsen = spec.get("warpCoarsen")
|
|
357
|
+
ps = spec.get("printSetup")
|
|
358
|
+
if ps is True:
|
|
359
|
+
ps = {}
|
|
360
|
+
if ps:
|
|
361
|
+
if kind != "fdm":
|
|
362
|
+
raise ValueError("buildSim.printSetup searches FDM print setups: process fdm")
|
|
363
|
+
ps = {"materials": ps.get("materials"), "builds": ps.get("orientations"), "beds": ps.get("bedTemperatures"),
|
|
364
|
+
"brims": tuple(float(b) for b in ps.get("brims", (0.0, 5.0))), "max_bed": float(ps.get("maxBed", 110.0)),
|
|
365
|
+
"reoptimize": bool(ps.get("reoptimize", True))}
|
|
366
|
+
return {"printSetup": ps or None, "setup": setup, "compensate": bool(spec.get("compensate", False)),
|
|
367
|
+
"cells": int(spec.get("cells", 60_000)), "iterations": int(spec.get("iterations", 2)),
|
|
368
|
+
"warpWeight": warp, "warpLayer": float(spec["warpLayer"]) if spec.get("warpLayer") else None,
|
|
369
|
+
"maxDistortion": lim_d, "maxRecoaterLift": lim_l,
|
|
370
|
+
"warpCoarsen": int(coarsen) if coarsen is not None else None,
|
|
371
|
+
"warpInOptimizer": bool(spec.get("warpInOptimizer", kind != "fdm"))}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _slicer(spec, process, base_dir, goal):
|
|
375
|
+
"""slicer.SlicerOptions for FDM studies (None otherwise)."""
|
|
376
|
+
from .slicer import SlicerOptions
|
|
377
|
+
|
|
378
|
+
if spec is not None and (process is None or process.kind != "fdm"):
|
|
379
|
+
raise ValueError("slicer is for FDM studies: give process {\"type\": \"fdm\"}")
|
|
380
|
+
if goal.minimize != "mass" and (process is None or process.kind != "fdm"):
|
|
381
|
+
raise ValueError(f"goal.minimize {goal.minimize!r} slices the part: it needs process fdm")
|
|
382
|
+
if process is None or process.kind != "fdm":
|
|
383
|
+
return None
|
|
384
|
+
opts = SlicerOptions.from_spec(spec, base_dir)
|
|
385
|
+
if goal.minimize != "mass" and opts.enabled is False:
|
|
386
|
+
raise ValueError(f"goal.minimize {goal.minimize!r} needs the slicer: drop slicer.enabled false")
|
|
387
|
+
if opts.grade is not None:
|
|
388
|
+
from .infill import GradeOptions
|
|
389
|
+
|
|
390
|
+
GradeOptions.from_spec(opts.grade, goal.safety_factor) # validate now, not after the solve
|
|
391
|
+
return opts
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _anisotropic(v):
|
|
395
|
+
if v in (True, False, "bond"):
|
|
396
|
+
return v
|
|
397
|
+
if v in ("physical", "true"):
|
|
398
|
+
return True
|
|
399
|
+
raise ValueError("process.anisotropic is false, true (physical layered stiffness) or \"bond\"")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass
|
|
403
|
+
class Thermal:
|
|
404
|
+
"""A study's `thermal` block (see thermal.study_heat): temperatures in C,
|
|
405
|
+
heat in W, convection in W/(m^2 K); each target is (region id, shape)."""
|
|
406
|
+
ambient: float = 25.0
|
|
407
|
+
stress_free: float | None = None # None: ambient
|
|
408
|
+
temperatures: list = field(default_factory=list) # (region, shape, C)
|
|
409
|
+
heat: list = field(default_factory=list) # (region, shape, W, "surface" | "volume")
|
|
410
|
+
convection: list = field(default_factory=list) # (region, shape, h, fluid C | None)
|
|
411
|
+
conductivity: float | None = None # W/(m K); None: the material card's
|
|
412
|
+
expansion: Any = True # True (card), 1/K, or False
|
|
413
|
+
soften: bool = True
|
|
414
|
+
specific_heat: float | None = None # J/(kg K); None: the material card's
|
|
415
|
+
cycle: Any = None # thermal.Cycle: heat switched on and off, or None (steady)
|
|
416
|
+
|
|
417
|
+
@property
|
|
418
|
+
def t_ref(self):
|
|
419
|
+
return self.ambient if self.stress_free is None else self.stress_free
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
STILL_AIR = 8.0 # W/(m^2 K): the convection a thermal block gets when it names none
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _thermal(t, base_dir) -> Thermal | None:
|
|
426
|
+
if t is None:
|
|
427
|
+
return None
|
|
428
|
+
if not isinstance(t, dict):
|
|
429
|
+
raise ValueError("thermal must be an object: {ambient, temperatures, heat, convection, ...}")
|
|
430
|
+
|
|
431
|
+
def num(v, label, positive=False):
|
|
432
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or not np.isfinite(v) or (positive and v <= 0):
|
|
433
|
+
raise ValueError(f"{label} must be a {'positive ' if positive else ''}number")
|
|
434
|
+
return float(v)
|
|
435
|
+
|
|
436
|
+
out = Thermal(ambient=num(t.get("ambient", 25.0), "thermal.ambient"))
|
|
437
|
+
if t.get("stressFree") is not None:
|
|
438
|
+
out.stress_free = num(t["stressFree"], "thermal.stressFree")
|
|
439
|
+
for i, it in enumerate(t.get("temperatures") or []):
|
|
440
|
+
label = f"thermal.temperatures[{i}]"
|
|
441
|
+
region, shape = _target(it, label, base_dir)
|
|
442
|
+
out.temperatures.append((region, shape, num(it.get("t"), f"{label}.t (C)")))
|
|
443
|
+
for i, it in enumerate(t.get("heat") or []):
|
|
444
|
+
label = f"thermal.heat[{i}]"
|
|
445
|
+
region, shape = _target(it, label, base_dir)
|
|
446
|
+
spread = it.get("spread", "surface")
|
|
447
|
+
if spread not in ("surface", "volume"):
|
|
448
|
+
raise ValueError(f"{label}.spread must be 'surface' or 'volume'")
|
|
449
|
+
out.heat.append((region, shape, num(it.get("watts"), f"{label}.watts", True), spread))
|
|
450
|
+
conv = t.get("convection", STILL_AIR)
|
|
451
|
+
if isinstance(conv, (int, float)) and not isinstance(conv, bool):
|
|
452
|
+
conv = [{"h": conv}]
|
|
453
|
+
if not isinstance(conv, list):
|
|
454
|
+
raise ValueError("thermal.convection is a coefficient in W/(m² K) or a list of {h, region|shape, fluid}")
|
|
455
|
+
for i, it in enumerate(conv):
|
|
456
|
+
label = f"thermal.convection[{i}]"
|
|
457
|
+
h = num(it.get("h"), f"{label}.h (W/(m² K))")
|
|
458
|
+
if h < 0:
|
|
459
|
+
raise ValueError(f"{label}.h must be >= 0")
|
|
460
|
+
region, shape = _target(it, label, base_dir) if ("region" in it or "shape" in it) else (None, None)
|
|
461
|
+
fluid = num(it["fluid"], f"{label}.fluid (C)") if it.get("fluid") is not None else None
|
|
462
|
+
out.convection.append((region, shape, h, fluid))
|
|
463
|
+
if t.get("conductivity") is not None:
|
|
464
|
+
out.conductivity = num(t["conductivity"], "thermal.conductivity", True)
|
|
465
|
+
e = t.get("expansion", True)
|
|
466
|
+
if not isinstance(e, bool):
|
|
467
|
+
e = num(e, "thermal.expansion (1/K, or true/false)")
|
|
468
|
+
if e < 0:
|
|
469
|
+
raise ValueError("thermal.expansion must be >= 0")
|
|
470
|
+
out.expansion = e
|
|
471
|
+
out.soften = bool(t.get("soften", True))
|
|
472
|
+
if t.get("specificHeat") is not None:
|
|
473
|
+
out.specific_heat = num(t["specificHeat"], "thermal.specificHeat (J/(kg K))", True)
|
|
474
|
+
cy = t.get("cycle")
|
|
475
|
+
if cy is not None:
|
|
476
|
+
from .thermal import Cycle
|
|
477
|
+
|
|
478
|
+
if not isinstance(cy, dict):
|
|
479
|
+
raise ValueError("thermal.cycle is {period, on, cycles}: seconds per cycle, seconds the heat is on")
|
|
480
|
+
period = num(cy.get("period"), "thermal.cycle.period (s)", True)
|
|
481
|
+
on = num(cy.get("on"), "thermal.cycle.on (s the heat is on each period)", True)
|
|
482
|
+
if on >= period:
|
|
483
|
+
raise ValueError("thermal.cycle.on must be shorter than the period (for heat on all the time, "
|
|
484
|
+
"leave cycle out)")
|
|
485
|
+
if not out.heat:
|
|
486
|
+
raise ValueError("thermal.cycle switches the heat inputs on and off: give thermal.heat")
|
|
487
|
+
n = cy.get("cycles")
|
|
488
|
+
if n is not None:
|
|
489
|
+
n = num(n, "thermal.cycle.cycles (over the part's life)", True)
|
|
490
|
+
steps = cy.get("steps", 48)
|
|
491
|
+
if isinstance(steps, bool) or not isinstance(steps, int) or steps < 8:
|
|
492
|
+
raise ValueError("thermal.cycle.steps is a whole number of time steps per period, at least 8")
|
|
493
|
+
fw = cy.get("fatigueWeight", 0.0)
|
|
494
|
+
fw = num(fw, "thermal.cycle.fatigueWeight")
|
|
495
|
+
if fw < 0:
|
|
496
|
+
raise ValueError("thermal.cycle.fatigueWeight must be >= 0")
|
|
497
|
+
if fw > 0 and out.expansion is False:
|
|
498
|
+
raise ValueError("thermal.cycle.fatigueWeight needs expansion: without it the swing carries no stress")
|
|
499
|
+
fsf = cy.get("fatigueSafetyFactor")
|
|
500
|
+
if fsf is not None:
|
|
501
|
+
fsf = num(fsf, "thermal.cycle.fatigueSafetyFactor", True)
|
|
502
|
+
if n is None:
|
|
503
|
+
raise ValueError("thermal.cycle.fatigueSafetyFactor needs cycles: the fatigue strength it holds "
|
|
504
|
+
"the swing under is the one at that many cycles")
|
|
505
|
+
if out.expansion is False:
|
|
506
|
+
raise ValueError("thermal.cycle.fatigueSafetyFactor needs expansion: without it the swing "
|
|
507
|
+
"carries no stress")
|
|
508
|
+
if fw > 0:
|
|
509
|
+
raise ValueError("thermal.cycle: give fatigueWeight (objective) or fatigueSafetyFactor "
|
|
510
|
+
"(constraint), not both")
|
|
511
|
+
out.cycle = Cycle(period, on, n, steps, fw, fsf)
|
|
512
|
+
if not out.temperatures and not any(h > 0 for *_, h, _ in out.convection):
|
|
513
|
+
raise ValueError("thermal needs a way for heat out: a fixed temperature (temperatures) or convection")
|
|
514
|
+
# a part that just runs warm (inside a 55 C enclosure): ambient alone,
|
|
515
|
+
# every cell at it, softened for it; with no heat and no fixed
|
|
516
|
+
# temperature it is that or nothing
|
|
517
|
+
if not out.temperatures and not out.heat and out.t_ref == out.ambient \
|
|
518
|
+
and (not out.soften or abs(out.ambient - 25.0) < 1.0):
|
|
519
|
+
raise ValueError("thermal has nothing to do: no heat input, no fixed temperature, the stress-free "
|
|
520
|
+
"temperature is the ambient, and the ambient is room temperature (for a part that "
|
|
521
|
+
"runs warm, set ambient to its service temperature)")
|
|
522
|
+
return out
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@dataclass
|
|
526
|
+
class Study:
|
|
527
|
+
name: str
|
|
528
|
+
design_space: list[Shape]
|
|
529
|
+
preserve: list[Region]
|
|
530
|
+
keep_out: list[Region]
|
|
531
|
+
supports: list[Support]
|
|
532
|
+
load_cases: list[LoadCase]
|
|
533
|
+
material: Material
|
|
534
|
+
goal: Goal
|
|
535
|
+
min_member: float | None
|
|
536
|
+
symmetry: list[str]
|
|
537
|
+
pitch: float | None
|
|
538
|
+
max_elements: int
|
|
539
|
+
max_iterations: int = 120
|
|
540
|
+
# m/s², applied to the part's own mass in every load case.
|
|
541
|
+
gravity: np.ndarray | None = None
|
|
542
|
+
# Build direction for overhang control, as (axis, sign); see am.py.
|
|
543
|
+
print_direction: Any = None
|
|
544
|
+
# Heat in the part (spec.Thermal), applied in every load case, or None.
|
|
545
|
+
thermal: Any = None
|
|
546
|
+
# 2.5D manufacturing: the part is a profile extruded along this axis
|
|
547
|
+
# (0, 1, 2), i.e. cut from plate by waterjet, laser or a 2.5-axis mill.
|
|
548
|
+
extrusion: int | None = None
|
|
549
|
+
# Milling or casting access (manufacture.Access), a circular pattern
|
|
550
|
+
# (manufacture.Pattern), and the thickest member allowed (mm).
|
|
551
|
+
access: Any = None
|
|
552
|
+
pattern: Any = None
|
|
553
|
+
max_member: float | None = None
|
|
554
|
+
# Every material to try (a list in the study); `material` is the first.
|
|
555
|
+
materials: list = field(default_factory=list)
|
|
556
|
+
# How it is made (spec.PROCESSES): layer weakness, overhangs, min feature.
|
|
557
|
+
process: Process | None = None
|
|
558
|
+
# (region id, thickness mm) of every wall and skin pinned solid
|
|
559
|
+
shells: list = field(default_factory=list)
|
|
560
|
+
# fatigue / creep settings (durability.Durability), and each fastener's
|
|
561
|
+
# preload and head bearing area for the relaxation check
|
|
562
|
+
durability: Any = None
|
|
563
|
+
joints: list = field(default_factory=list)
|
|
564
|
+
# FDM: slicer.SlicerOptions (print time, filament, support per outcome;
|
|
565
|
+
# graded infill); None for other processes
|
|
566
|
+
slicer: Any = None
|
|
567
|
+
# Smallest hole (mm) the optimizer may leave, or None: see simp.SimpOptions.dilate.
|
|
568
|
+
min_hole: float | None = None
|
|
569
|
+
# Escape holes into sealed voids: "auto" (powder and resin processes),
|
|
570
|
+
# True, False, or an escape.EscapeOptions; see escape.py.
|
|
571
|
+
drains: Any = "auto"
|
|
572
|
+
# multimat.MultiMaterial: the optimizer places several materials
|
|
573
|
+
multi_material: Any = None
|
|
574
|
+
# Build-process simulation of the delivered part (buildsim.py): a
|
|
575
|
+
# buildsim.BuildSetup plus {"compensate": bool, "cells": int}, or None
|
|
576
|
+
build_sim: Any = None
|
|
577
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
578
|
+
|
|
579
|
+
@property
|
|
580
|
+
def length_scale(self) -> float:
|
|
581
|
+
"""The member size the density filter enforces (mm): minMember, else
|
|
582
|
+
DEFAULT_MIN_MEMBER. A grid fine enough to resolve a sub-millimetre
|
|
583
|
+
sheet would otherwise build one (the 845k-cell arm link with no
|
|
584
|
+
minMember came out a 1 mm skin sealed round its cable channel)."""
|
|
585
|
+
return float(self.min_member) if self.min_member else DEFAULT_MIN_MEMBER
|
|
586
|
+
|
|
587
|
+
@property
|
|
588
|
+
def self_equilibrated(self) -> bool:
|
|
589
|
+
"""No supports: every load is a pressure, held by pressure.stabilize."""
|
|
590
|
+
return not self.supports
|
|
591
|
+
|
|
592
|
+
def elastic(self, material: Material | None = None, design: bool = False):
|
|
593
|
+
"""What the solver takes for `nu`: the material's Poisson's ratio, or
|
|
594
|
+
a fem.Layered when the process builds in layers (am.layered_elasticity).
|
|
595
|
+
design=True is the optimizer's surrogate stiffness."""
|
|
596
|
+
from .am import layered_elasticity
|
|
597
|
+
|
|
598
|
+
mat = material or self.material
|
|
599
|
+
if self.process is None or (design and not self.process.anisotropic):
|
|
600
|
+
return mat.nu
|
|
601
|
+
return layered_elasticity(mat.nu, self.process, design=design and self.process.anisotropic == "bond")
|
|
602
|
+
|
|
603
|
+
def region_shape(self, region_id: str) -> tuple[Shape, str]:
|
|
604
|
+
for r in self.preserve:
|
|
605
|
+
if r.id == region_id:
|
|
606
|
+
return r.shape, "preserve"
|
|
607
|
+
for r in self.keep_out:
|
|
608
|
+
if r.id == region_id:
|
|
609
|
+
return r.shape, "keepOut"
|
|
610
|
+
known = [r.id for r in self.preserve] + [r.id for r in self.keep_out]
|
|
611
|
+
raise ValueError(f"unknown region {region_id!r}; known regions: {known}")
|
|
612
|
+
|
|
613
|
+
def bounds(self) -> tuple[np.ndarray, np.ndarray]:
|
|
614
|
+
los, his = [], []
|
|
615
|
+
for s in self.design_space + [r.shape for r in self.preserve]:
|
|
616
|
+
lo, hi = s.bounds()
|
|
617
|
+
los.append(lo)
|
|
618
|
+
his.append(hi)
|
|
619
|
+
return np.min(los, axis=0), np.max(his, axis=0)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _regions(items, label, base_dir):
|
|
623
|
+
out, seen = [], set()
|
|
624
|
+
for i, item in enumerate(items or []):
|
|
625
|
+
if not isinstance(item, dict) or "shape" not in item:
|
|
626
|
+
raise ValueError(f"{label}[{i}] must be {{'id', 'shape'}}")
|
|
627
|
+
rid = str(item.get("id") or f"{label}-{i}")
|
|
628
|
+
if rid in seen:
|
|
629
|
+
raise ValueError(f"duplicate {label} id {rid!r}")
|
|
630
|
+
seen.add(rid)
|
|
631
|
+
wall = item.get("wall")
|
|
632
|
+
if wall is not None and not (isinstance(wall, (int, float)) and wall > 0):
|
|
633
|
+
raise ValueError(f"{label}[{i}].wall is a thickness in mm (> 0)")
|
|
634
|
+
out.append(Region(rid, shape_from_spec(item["shape"], base_dir), bool(item.get("bore", True)),
|
|
635
|
+
float(wall) if wall is not None else None))
|
|
636
|
+
return out
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
_FACES = {f"{s}{a}": (i, 1 if s == "+" else -1) for i, a in enumerate("xyz") for s in "+-"}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _skin(v, design):
|
|
643
|
+
"""Top-level `skin`: a thickness (mm) or {thickness, open: ['-z', ..]}.
|
|
644
|
+
Returns (thickness, the skin shape)."""
|
|
645
|
+
from .shapes import Shell, Union, shrink
|
|
646
|
+
|
|
647
|
+
if isinstance(v, (int, float)):
|
|
648
|
+
v = {"thickness": v}
|
|
649
|
+
if not isinstance(v, dict):
|
|
650
|
+
raise ValueError("skin is a thickness in mm, or {thickness, open: ['-z', ...]}")
|
|
651
|
+
t = v.get("thickness")
|
|
652
|
+
if not isinstance(t, (int, float)) or t <= 0:
|
|
653
|
+
raise ValueError("skin.thickness is the wall in mm (> 0)")
|
|
654
|
+
faces = []
|
|
655
|
+
for f in v.get("open") or []:
|
|
656
|
+
key = str(f).strip().lower()
|
|
657
|
+
key = key if key[:1] in "+-" else "+" + key
|
|
658
|
+
if key not in _FACES:
|
|
659
|
+
raise ValueError(f"skin.open entries are design-space faces like '-z' or '+x', got {f!r}")
|
|
660
|
+
faces.append(_FACES[key])
|
|
661
|
+
ds = design[0] if len(design) == 1 else Union(design)
|
|
662
|
+
return float(t), Shell(ds, shrink(ds, float(t), faces))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _target(item, label, base_dir):
|
|
666
|
+
if "region" in item:
|
|
667
|
+
return str(item["region"]), None
|
|
668
|
+
if "shape" in item:
|
|
669
|
+
return None, shape_from_spec(item["shape"], base_dir)
|
|
670
|
+
raise ValueError(f"{label} needs a 'region' id or an inline 'shape'")
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _extrusion_axis(v):
|
|
674
|
+
if v is None:
|
|
675
|
+
return None
|
|
676
|
+
a = str(v).strip().lower().lstrip("+-")
|
|
677
|
+
if a not in ("x", "y", "z"):
|
|
678
|
+
raise ValueError("extrusion must be 'x', 'y' or 'z' (the axis the profile is extruded along)")
|
|
679
|
+
return "xyz".index(a)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _mass_load(ld, f, m, region, shape, label, preserve, keep_out):
|
|
683
|
+
"""A carried mass as the force and moment it puts on its region: weight
|
|
684
|
+
x dynamic factor along gravity, and the moment of that force about the
|
|
685
|
+
region's centre when the mass sits elsewhere ('at')."""
|
|
686
|
+
from .am import build_vector
|
|
687
|
+
from .shapes import _vec
|
|
688
|
+
|
|
689
|
+
mass, dyn = ld["mass"], ld.get("dynamic", 1.0)
|
|
690
|
+
if not isinstance(mass, (int, float)) or mass <= 0:
|
|
691
|
+
raise ValueError(f"{label}.mass is the carried mass in kg (> 0)")
|
|
692
|
+
if not isinstance(dyn, (int, float)) or dyn < 1:
|
|
693
|
+
raise ValueError(f"{label}.dynamic multiplies the weight: 1 static, 2 a moving joint, 3 a crash")
|
|
694
|
+
try:
|
|
695
|
+
g = build_vector(parse_build_direction(ld.get("gravity", "-z")))
|
|
696
|
+
except (ValueError, TypeError, KeyError):
|
|
697
|
+
raise ValueError(f"{label}.gravity is the way the mass pulls: '-z', '+y', ... or [x, y, z]") from None
|
|
698
|
+
w = 9.81 * float(mass) * float(dyn) * np.asarray(g, float) / np.linalg.norm(g)
|
|
699
|
+
return f + w, m
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _at_moment(ld, f, m, region, shape, label, preserve, keep_out):
|
|
703
|
+
"""A force (or carried mass) applied away from its region ('at'): the
|
|
704
|
+
moment of the whole force about the region's centre. A belt pulling on
|
|
705
|
+
a pulley in front of a motor face, a gripper's mass 80 mm out."""
|
|
706
|
+
from .shapes import _vec
|
|
707
|
+
|
|
708
|
+
if ld.get("direction") is not None or ld.get("cone") is not None:
|
|
709
|
+
raise ValueError(f"{label}: a force 'at' an offset makes a moment that would have to turn with an "
|
|
710
|
+
"unknown direction; give each direction you need as a load case of its own (or use an "
|
|
711
|
+
"in-plane direction such as \"xz\", which turns the force round 'at'; it takes a plain force, so write a mass as kg x 9.81 x the dynamic factor in N)")
|
|
712
|
+
if shape is None:
|
|
713
|
+
regs = {r.id: r.shape for r in list(preserve) + list(keep_out)}
|
|
714
|
+
shape = regs.get(region)
|
|
715
|
+
if shape is None:
|
|
716
|
+
raise ValueError(f"{label}: 'at' needs the region's shape to measure the lever from")
|
|
717
|
+
lo, hi = shape.bounds()
|
|
718
|
+
lever = _vec(ld["at"], name=f"{label}.at") - 0.5 * (np.asarray(lo) + np.asarray(hi))
|
|
719
|
+
return m + np.cross(lever, f)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
_PLANES = {"xy": (0, 1), "xz": (0, 2), "yz": (1, 2)}
|
|
723
|
+
_PLANAR_STEPS = 8 # 22.5 degrees apart: the worst direction is within cos(11.25) = 98%
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _expand_planar(d: dict) -> dict:
|
|
727
|
+
"""A load whose direction is any in a plane ("direction": "xz") becomes
|
|
728
|
+
eight plain cases round that plane. Von Mises stress does not care about
|
|
729
|
+
the force's sign, so half a turn covers it. Brief-eval agents wrote these
|
|
730
|
+
cases by hand (a pillow block's radial load, a hook's swinging strap): a
|
|
731
|
+
3D 'any' or a cone also pushes out of plane, which a thin part cannot
|
|
732
|
+
carry and need not."""
|
|
733
|
+
cases = d.get("loadCases")
|
|
734
|
+
if not isinstance(cases, list) or not any(
|
|
735
|
+
isinstance(ld, dict) and ld.get("direction") in _PLANES
|
|
736
|
+
for c in cases if isinstance(c, dict) for ld in (c.get("loads") or [])):
|
|
737
|
+
return d
|
|
738
|
+
out = []
|
|
739
|
+
for ci, c in enumerate(cases):
|
|
740
|
+
loads = c.get("loads") or [] if isinstance(c, dict) else []
|
|
741
|
+
planar = [i for i, ld in enumerate(loads) if isinstance(ld, dict) and ld.get("direction") in _PLANES]
|
|
742
|
+
if not planar:
|
|
743
|
+
out.append(c)
|
|
744
|
+
continue
|
|
745
|
+
if len(planar) > 1:
|
|
746
|
+
raise ValueError(f"loadCases[{ci}]: at most one load per case may have an in-plane direction; "
|
|
747
|
+
"put another in a load case of its own")
|
|
748
|
+
li = planar[0]
|
|
749
|
+
ld = loads[li]
|
|
750
|
+
if ld.get("cone") is not None or ld.get("mass") is not None or ld.get("pressure") is not None:
|
|
751
|
+
raise ValueError(f"loadCases[{ci}].loads[{li}]: an in-plane direction goes with a force, not a cone, "
|
|
752
|
+
"mass or pressure. For a swinging payload, write the force out: mass (kg) x 9.81 x the dynamic "
|
|
753
|
+
"factor, in N, e.g. 0.6 kg at 2 g is \"force\": [11.8, 0, 0]")
|
|
754
|
+
a, b = _PLANES[ld["direction"]]
|
|
755
|
+
f = np.asarray(ld.get("force") or [0, 0, 0], float)
|
|
756
|
+
size = float(np.linalg.norm(f))
|
|
757
|
+
if size <= 0:
|
|
758
|
+
raise ValueError(f"loadCases[{ci}].loads[{li}]: direction {ld['direction']!r} needs a force "
|
|
759
|
+
"(its size, and the first direction to try)")
|
|
760
|
+
start = float(np.arctan2(f[b], f[a])) if abs(f[a]) + abs(f[b]) > 1e-9 * size else 0.0
|
|
761
|
+
normal = np.zeros(3)
|
|
762
|
+
normal[3 - a - b] = 1.0 if (a, b) != (0, 2) else -1.0 # right-handed: a x b
|
|
763
|
+
m0 = np.asarray(ld.get("moment") or [0, 0, 0], float)
|
|
764
|
+
name = c.get("name") or f"case{ci}"
|
|
765
|
+
# Half a circle covers it only when the stress under -F is the stress
|
|
766
|
+
# under F: the case's one load, spread evenly, with no moment and an
|
|
767
|
+
# isotropic, tension-blind check. A bearing load presses on one side
|
|
768
|
+
# of the bore, other loads add, a moment does not turn round, and a
|
|
769
|
+
# layered process fails on interlayer tension: the full circle then.
|
|
770
|
+
full = (len(loads) > 1 or ld.get("distribution") == "bearing" or ld.get("moment") is not None
|
|
771
|
+
or d.get("process") is not None or d.get("printDirection") is not None)
|
|
772
|
+
steps, span = (2 * _PLANAR_STEPS, 2 * np.pi) if full else (_PLANAR_STEPS, np.pi)
|
|
773
|
+
for k in range(steps):
|
|
774
|
+
t = span * k / steps
|
|
775
|
+
fk = np.zeros(3)
|
|
776
|
+
fk[a], fk[b] = size * np.cos(start + t), size * np.sin(start + t)
|
|
777
|
+
# the moment turns with the force about the plane's normal
|
|
778
|
+
cth, sth = np.cos(t), np.sin(t)
|
|
779
|
+
mk = m0 * cth + np.cross(normal, m0) * sth + normal * (normal @ m0) * (1 - cth)
|
|
780
|
+
new = {k2: v for k2, v in ld.items() if k2 != "direction"}
|
|
781
|
+
new["force"] = fk.round(9).tolist()
|
|
782
|
+
if ld.get("moment") is not None:
|
|
783
|
+
new["moment"] = mk.round(9).tolist()
|
|
784
|
+
out.append({**c, "name": f"{name}@{180.0 * k / _PLANAR_STEPS:g}",
|
|
785
|
+
"loads": [new if i == li else x for i, x in enumerate(loads)]})
|
|
786
|
+
return {**d, "loadCases": out}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def study_from_dict(d: dict, base_dir: str | None = None) -> Study:
|
|
790
|
+
"""Validate and parse a study. Every error names the field to fix."""
|
|
791
|
+
if not isinstance(d, dict):
|
|
792
|
+
raise ValueError("study must be a JSON object")
|
|
793
|
+
from . import quick
|
|
794
|
+
from .fasteners import expand_fasteners
|
|
795
|
+
d = quick.study(d)
|
|
796
|
+
d = expand_fasteners(d)
|
|
797
|
+
d = _expand_planar(d)
|
|
798
|
+
joints = d.pop("_joints", []) if isinstance(d.get("_joints"), list) else []
|
|
799
|
+
ds = d.get("designSpace")
|
|
800
|
+
if isinstance(ds, dict):
|
|
801
|
+
ds = [ds]
|
|
802
|
+
if not ds:
|
|
803
|
+
raise ValueError("designSpace is required: one shape or a list of shapes")
|
|
804
|
+
design_space = [shape_from_spec(s, base_dir) for s in ds]
|
|
805
|
+
preserve = _regions(d.get("preserve"), "preserve", base_dir)
|
|
806
|
+
keep_out = _regions(d.get("keepOut"), "keepOut", base_dir)
|
|
807
|
+
clash = {r.id for r in preserve} & {r.id for r in keep_out}
|
|
808
|
+
if clash:
|
|
809
|
+
raise ValueError(f"region ids must be unique across preserve and keepOut: {sorted(clash)}")
|
|
810
|
+
|
|
811
|
+
supports = []
|
|
812
|
+
for i, s in enumerate(d.get("supports") or []):
|
|
813
|
+
region, shape = _target(s, f"supports[{i}]", base_dir)
|
|
814
|
+
fix = s.get("fix", "all")
|
|
815
|
+
pin = fix == "pin"
|
|
816
|
+
if pin:
|
|
817
|
+
if region is None and not hasattr(shape, "axis"):
|
|
818
|
+
raise ValueError(f"supports[{i}]: a pin needs a cylinder (a bore keep-out or a cylinder shape)")
|
|
819
|
+
fix_t = (False, False, False)
|
|
820
|
+
elif fix == "all":
|
|
821
|
+
fix_t = (True, True, True)
|
|
822
|
+
else:
|
|
823
|
+
fix_t = tuple(a in fix for a in "xyz") if isinstance(fix, str) else tuple(bool(v) for v in fix)
|
|
824
|
+
if len(fix_t) != 3 or not any(fix_t):
|
|
825
|
+
raise ValueError(f"supports[{i}].fix must be 'all', 'pin', axes like 'xz', or 3 booleans")
|
|
826
|
+
supports.append(Support(region, shape, fix_t, pin))
|
|
827
|
+
|
|
828
|
+
cases_in = d.get("loadCases")
|
|
829
|
+
if cases_in is None and "loads" in d:
|
|
830
|
+
cases_in = [{"name": "default", "loads": d["loads"]}]
|
|
831
|
+
cases = []
|
|
832
|
+
for ci, c in enumerate(cases_in or []):
|
|
833
|
+
loads = []
|
|
834
|
+
for li, ld in enumerate(c.get("loads") or []):
|
|
835
|
+
region, shape = _target(ld, f"loadCases[{ci}].loads[{li}]", base_dir)
|
|
836
|
+
label = f"loadCases[{ci}].loads[{li}]"
|
|
837
|
+
p = ld.get("pressure", 0.0)
|
|
838
|
+
if not isinstance(p, (int, float)) or not np.isfinite(p):
|
|
839
|
+
raise ValueError(f"{label}.pressure is a gauge pressure in MPa (1 bar = 0.1 MPa)")
|
|
840
|
+
if p:
|
|
841
|
+
if region is None:
|
|
842
|
+
raise ValueError(f"{label}: a pressure acts in a keepOut (the fluid's volume); "
|
|
843
|
+
"name it with 'region'")
|
|
844
|
+
if any(k in ld for k in ("force", "moment", "torque", "distribution")):
|
|
845
|
+
raise ValueError(f"{label}: a pressure load takes no force, moment or distribution; "
|
|
846
|
+
"put those in a load of their own")
|
|
847
|
+
loads.append(Load(region, None, np.zeros(3), pressure=float(p)))
|
|
848
|
+
continue
|
|
849
|
+
f = np.asarray(ld.get("force", [0, 0, 0]), float).reshape(-1)
|
|
850
|
+
m = np.asarray(ld.get("moment", ld.get("torque", [0, 0, 0])), float).reshape(-1)
|
|
851
|
+
if f.shape != (3,) or m.shape != (3,) or not np.all(np.isfinite(f)) or not np.all(np.isfinite(m)):
|
|
852
|
+
raise ValueError(f"{label}: force [fx, fy, fz] (N) and moment [mx, my, mz] (N·mm) must be 3 numbers")
|
|
853
|
+
if ld.get("mass") is not None:
|
|
854
|
+
f, m = _mass_load(ld, f, m, region, shape, label, preserve, keep_out)
|
|
855
|
+
if ld.get("at") is not None:
|
|
856
|
+
m = _at_moment(ld, f, m, region, shape, label, preserve, keep_out)
|
|
857
|
+
if not np.any(f) and not np.any(m):
|
|
858
|
+
raise ValueError(f"{label} needs a nonzero force (N), moment (N·mm), mass (kg) or pressure (MPa)")
|
|
859
|
+
dist = str(ld.get("distribution", "uniform"))
|
|
860
|
+
if dist not in ("uniform", "bearing"):
|
|
861
|
+
raise ValueError(f"{label}.distribution must be 'uniform' or 'bearing'")
|
|
862
|
+
if dist == "bearing" and not np.any(f):
|
|
863
|
+
raise ValueError(f"{label}: a bearing load needs a force")
|
|
864
|
+
spread, cone = None, None
|
|
865
|
+
if ld.get("direction") is not None:
|
|
866
|
+
if ld["direction"] != "any":
|
|
867
|
+
raise ValueError(f"{label}.direction must be 'any' (the force's size, in every direction) "
|
|
868
|
+
"or a plane, 'xy', 'xz' or 'yz' (any direction in it)")
|
|
869
|
+
spread = "any"
|
|
870
|
+
if ld.get("cone") is not None:
|
|
871
|
+
cone = ld["cone"]
|
|
872
|
+
if spread or not isinstance(cone, (int, float)) or not 0 < cone <= 180:
|
|
873
|
+
raise ValueError(f"{label}.cone is a half-angle in degrees (0-180] round the force's "
|
|
874
|
+
"direction, and cannot go with direction 'any'")
|
|
875
|
+
spread, cone = ("any", None) if cone >= 180 else ("cone", float(cone))
|
|
876
|
+
if spread and not np.any(f):
|
|
877
|
+
raise ValueError(f"{label}: direction 'any' or a cone needs a force (its size and, for a cone, "
|
|
878
|
+
"its axis)")
|
|
879
|
+
loads.append(Load(region, shape, f, m, dist, direction=spread, cone=cone))
|
|
880
|
+
if not loads:
|
|
881
|
+
raise ValueError(f"loadCases[{ci}] has no loads")
|
|
882
|
+
if sum(ld.direction is not None for ld in loads) > 1:
|
|
883
|
+
raise ValueError(f"loadCases[{ci}]: at most one load per case may have an unknown direction "
|
|
884
|
+
"(direction 'any' or a cone); put another in a load case of its own")
|
|
885
|
+
life = {}
|
|
886
|
+
for key in ("cycles", "hours"):
|
|
887
|
+
if c.get(key) is not None:
|
|
888
|
+
v = c[key]
|
|
889
|
+
if not isinstance(v, (int, float)) or not np.isfinite(v) or v <= 0:
|
|
890
|
+
raise ValueError(f"loadCases[{ci}].{key} must be a positive number "
|
|
891
|
+
+ ("(load cycles over the part's life)" if key == "cycles" else
|
|
892
|
+
"(hours the load is held over the part's life)"))
|
|
893
|
+
life[key] = float(v)
|
|
894
|
+
if c.get("temperature") is not None:
|
|
895
|
+
life["temperature"] = float(c["temperature"])
|
|
896
|
+
cases.append(LoadCase(str(c.get("name") or f"case-{ci}"), loads, float(c.get("weight", 1.0)), **life))
|
|
897
|
+
if not cases:
|
|
898
|
+
raise ValueError("at least one load case is required (loadCases: [{name, loads}])")
|
|
899
|
+
pressurized = {ld.region for c in cases for ld in c.loads if ld.pressure}
|
|
900
|
+
ko_ids = {r.id for r in keep_out}
|
|
901
|
+
for rid in sorted(pressurized - ko_ids):
|
|
902
|
+
raise ValueError(f"pressure on {rid!r}: a pressure acts in a keepOut (the fluid's volume); "
|
|
903
|
+
f"keepOut ids: {sorted(ko_ids)}")
|
|
904
|
+
if not supports and not all(ld.pressure for c in cases for ld in c.loads):
|
|
905
|
+
raise ValueError("at least one support is required (where the part is held); only a study whose "
|
|
906
|
+
"every load is a pressure may leave supports out")
|
|
907
|
+
gravity = d.get("gravity")
|
|
908
|
+
if gravity is not None and not supports:
|
|
909
|
+
raise ValueError("gravity needs supports: a part's own weight has to be held somewhere")
|
|
910
|
+
if gravity is not None:
|
|
911
|
+
gravity = np.asarray(gravity, float).reshape(-1)
|
|
912
|
+
if gravity.shape != (3,) or not np.all(np.isfinite(gravity)):
|
|
913
|
+
raise ValueError("gravity must be an acceleration vector in m/s², e.g. [0, 0, -9.81]")
|
|
914
|
+
thermal = _thermal(d.get("thermal"), base_dir)
|
|
915
|
+
|
|
916
|
+
g = d.get("goal") or {"volumeFraction": 0.3}
|
|
917
|
+
goal = Goal(
|
|
918
|
+
volume_fraction=g.get("volumeFraction"),
|
|
919
|
+
safety_factor=g.get("safetyFactor"),
|
|
920
|
+
max_deflection=g.get("maxDeflection"),
|
|
921
|
+
method=str(g.get("method", "auto")),
|
|
922
|
+
confirm=str(g.get("confirm", "auto")),
|
|
923
|
+
outcomes=[float(v) for v in g.get("outcomes") or []],
|
|
924
|
+
minimize=str(g.get("minimize", "mass")),
|
|
925
|
+
restarts=int(g.get("restarts", 1)),
|
|
926
|
+
buckling=bool(g.get("buckling", True)),
|
|
927
|
+
)
|
|
928
|
+
if goal.restarts < 1:
|
|
929
|
+
raise ValueError("goal.restarts must be 1 or more")
|
|
930
|
+
if goal.minimize not in ("mass", "printTime", "filament"):
|
|
931
|
+
raise ValueError("goal.minimize must be 'mass', 'printTime' or 'filament'")
|
|
932
|
+
if goal.volume_fraction is None and goal.safety_factor is None and goal.max_deflection is None:
|
|
933
|
+
raise ValueError("goal needs volumeFraction (0..1), safetyFactor (>1) and/or maxDeflection (mm)")
|
|
934
|
+
if goal.method not in ("search", "constrained", "auto"):
|
|
935
|
+
raise ValueError("goal.method must be 'search', 'constrained' or 'auto'")
|
|
936
|
+
if goal.confirm not in ("auto", "voxel", "tet"):
|
|
937
|
+
raise ValueError("goal.confirm must be 'auto', 'voxel' or 'tet'")
|
|
938
|
+
if goal.max_deflection is not None and goal.max_deflection <= 0:
|
|
939
|
+
raise ValueError("goal.maxDeflection must be a positive distance in mm")
|
|
940
|
+
if goal.volume_fraction is not None and not (0.02 <= goal.volume_fraction <= 0.95):
|
|
941
|
+
raise ValueError("goal.volumeFraction must be between 0.02 and 0.95")
|
|
942
|
+
if goal.safety_factor is not None and goal.safety_factor <= 0:
|
|
943
|
+
raise ValueError("goal.safetyFactor must be positive")
|
|
944
|
+
for v in goal.outcomes:
|
|
945
|
+
if not (0.02 <= v <= 0.95):
|
|
946
|
+
raise ValueError("goal.outcomes values must be between 0.02 and 0.95")
|
|
947
|
+
|
|
948
|
+
mat_in = d.get("material", "pla")
|
|
949
|
+
materials = [material_from_spec(m) for m in mat_in] if isinstance(mat_in, list) else [material_from_spec(mat_in)]
|
|
950
|
+
if not materials:
|
|
951
|
+
raise ValueError("material list is empty")
|
|
952
|
+
|
|
953
|
+
sym = [str(a).lower() for a in d.get("symmetry") or []]
|
|
954
|
+
if any(a not in ("x", "y", "z") for a in sym):
|
|
955
|
+
raise ValueError("symmetry entries must be 'x', 'y' or 'z' (planes through the design space center)")
|
|
956
|
+
|
|
957
|
+
res = d.get("resolution") or {}
|
|
958
|
+
pitch = res.get("pitch")
|
|
959
|
+
max_el = int(res.get("maxElements") or DEFAULT_MAX_ELEMENTS)
|
|
960
|
+
if max_el > MAX_ELEMENTS_CEILING:
|
|
961
|
+
raise ValueError(f"resolution.maxElements is capped at {MAX_ELEMENTS_CEILING}")
|
|
962
|
+
if pitch is not None and float(pitch) <= 0:
|
|
963
|
+
raise ValueError("resolution.pitch must be positive (mm)")
|
|
964
|
+
ps = res.get("pitchStarts", "auto")
|
|
965
|
+
if ps != "auto" and (isinstance(ps, bool) or not isinstance(ps, int) or not 1 <= ps <= 5):
|
|
966
|
+
raise ValueError("resolution.pitchStarts must be 'auto' or an integer 1-5")
|
|
967
|
+
|
|
968
|
+
min_member = d.get("minMember")
|
|
969
|
+
process = _process(d.get("process"))
|
|
970
|
+
process = _card_process(process, materials[0], d.get("process"))
|
|
971
|
+
process = _calibrated_process(process, mat_in, d.get("process"))
|
|
972
|
+
print_direction = parse_build_direction(d.get("printDirection"))
|
|
973
|
+
if process is not None:
|
|
974
|
+
# the process sets what a study left open: overhang control along the
|
|
975
|
+
# build direction, and a minimum member it can actually make
|
|
976
|
+
if print_direction is None and process.overhangs and process.build not in (None, "auto"):
|
|
977
|
+
print_direction = process.build
|
|
978
|
+
floor = 2.0 * process.min_feature
|
|
979
|
+
min_member = max(float(min_member), floor) if min_member is not None else floor
|
|
980
|
+
min_hole = d.get("minHole")
|
|
981
|
+
if min_hole is not None and not (isinstance(min_hole, (int, float)) and min_hole > 0):
|
|
982
|
+
raise ValueError("minHole is the narrowest hole or gap allowed, in mm (> 0)")
|
|
983
|
+
if min_hole is None and process is not None:
|
|
984
|
+
min_hole = process.min_hole
|
|
985
|
+
# Walls round keep-outs and the design space's skin are preserved
|
|
986
|
+
# material: fixed, pinned solid, with only what is behind them designed
|
|
987
|
+
# (skin-and-rib). Every pressurized keep-out has one: the pressure needs
|
|
988
|
+
# a surface to push on that the optimizer cannot open.
|
|
989
|
+
from .shapes import Shell, Union, grow
|
|
990
|
+
|
|
991
|
+
shells = []
|
|
992
|
+
clip = design_space[0] if len(design_space) == 1 else Union(design_space)
|
|
993
|
+
taken = {r.id for r in preserve} | {r.id for r in keep_out}
|
|
994
|
+
for r in keep_out:
|
|
995
|
+
t = r.wall
|
|
996
|
+
if t is None and r.id in pressurized:
|
|
997
|
+
if min_member is None:
|
|
998
|
+
raise ValueError(f"keepOut {r.id!r} is pressurized: give it a 'wall' (mm), the thickness of "
|
|
999
|
+
"the skin the pressure acts on, or set minMember")
|
|
1000
|
+
t = r.wall = float(min_member)
|
|
1001
|
+
if t is None:
|
|
1002
|
+
continue
|
|
1003
|
+
rid = f"{r.id}-wall"
|
|
1004
|
+
if rid in taken:
|
|
1005
|
+
raise ValueError(f"region id {rid!r} is taken; it names keepOut {r.id!r}'s wall")
|
|
1006
|
+
preserve.append(Region(rid, Shell(grow(r.shape, t), r.shape, clip)))
|
|
1007
|
+
shells.append((rid, t))
|
|
1008
|
+
if "bore" not in next((k for k in d.get("keepOut") or [] if k.get("id") == r.id), {}):
|
|
1009
|
+
# a pressure vessel's cavity is closed on purpose
|
|
1010
|
+
r.bore = r.bore and r.id not in pressurized
|
|
1011
|
+
if d.get("skin") is not None:
|
|
1012
|
+
t, shape = _skin(d["skin"], design_space)
|
|
1013
|
+
if "skin" in taken:
|
|
1014
|
+
raise ValueError("region id 'skin' is taken; it names the design space's skin")
|
|
1015
|
+
preserve.append(Region("skin", shape))
|
|
1016
|
+
shells.append(("skin", t))
|
|
1017
|
+
from .durability import durability_from_spec
|
|
1018
|
+
durability = durability_from_spec(d.get("durability"))
|
|
1019
|
+
from .manufacture import parse_casting, parse_milling, parse_pattern
|
|
1020
|
+
|
|
1021
|
+
if d.get("milling") is not None and d.get("casting") is not None:
|
|
1022
|
+
raise ValueError("a study is milled or cast, not both: give milling or casting")
|
|
1023
|
+
access = parse_milling(d.get("milling")) or parse_casting(d.get("casting"))
|
|
1024
|
+
if access is not None and print_direction is not None:
|
|
1025
|
+
raise ValueError(f"{access.kind} and overhang control (printDirection, or a process that prints with "
|
|
1026
|
+
"overhang limits) do not go together: drop one")
|
|
1027
|
+
max_member = d.get("maxMember")
|
|
1028
|
+
if max_member is not None:
|
|
1029
|
+
if not (isinstance(max_member, (int, float)) and max_member > 0):
|
|
1030
|
+
raise ValueError("maxMember is the thickest member allowed, in mm (> 0)")
|
|
1031
|
+
if min_member is not None and max_member < 2.0 * float(min_member):
|
|
1032
|
+
raise ValueError(f"maxMember ({max_member:g}mm) must be at least twice minMember "
|
|
1033
|
+
f"({float(min_member):g}mm) to leave the optimizer room")
|
|
1034
|
+
st = Study(
|
|
1035
|
+
name=str(d.get("name") or "study"),
|
|
1036
|
+
design_space=design_space,
|
|
1037
|
+
preserve=preserve,
|
|
1038
|
+
keep_out=keep_out,
|
|
1039
|
+
supports=supports,
|
|
1040
|
+
load_cases=cases,
|
|
1041
|
+
material=materials[0],
|
|
1042
|
+
materials=materials,
|
|
1043
|
+
goal=goal,
|
|
1044
|
+
min_member=float(min_member) if min_member is not None else None,
|
|
1045
|
+
symmetry=sym,
|
|
1046
|
+
pitch=float(pitch) if pitch is not None else None,
|
|
1047
|
+
max_elements=max_el,
|
|
1048
|
+
max_iterations=int(d.get("maxIterations", 120)),
|
|
1049
|
+
gravity=gravity,
|
|
1050
|
+
thermal=thermal,
|
|
1051
|
+
print_direction=print_direction,
|
|
1052
|
+
extrusion=_extrusion_axis(d.get("extrusion")),
|
|
1053
|
+
access=access,
|
|
1054
|
+
pattern=parse_pattern(d.get("pattern")),
|
|
1055
|
+
max_member=float(max_member) if max_member is not None else None,
|
|
1056
|
+
process=process,
|
|
1057
|
+
shells=shells,
|
|
1058
|
+
durability=durability,
|
|
1059
|
+
joints=joints,
|
|
1060
|
+
slicer=_slicer(d.get("slicer"), process, base_dir, goal),
|
|
1061
|
+
min_hole=float(min_hole) if min_hole is not None else None,
|
|
1062
|
+
drains=_drains(d.get("drains", "auto"), process),
|
|
1063
|
+
build_sim=_build_sim(d.get("buildSim"), process, d.get("material")),
|
|
1064
|
+
raw=d,
|
|
1065
|
+
)
|
|
1066
|
+
if d.get("multiMaterial") is not None:
|
|
1067
|
+
from .multimat import parse_multimaterial
|
|
1068
|
+
|
|
1069
|
+
st.multi_material = parse_multimaterial(d["multiMaterial"], st, base_dir)
|
|
1070
|
+
if "material" not in d:
|
|
1071
|
+
st.material = st.materials[0] = st.multi_material.materials[0]
|
|
1072
|
+
return st
|