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/tune.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tune a field graph's numbers against a simulation of the part it makes.
|
|
3
|
+
|
|
4
|
+
A field graph is a recipe with knobs (a web's height, a fillet, a shell's
|
|
5
|
+
thickness, a lattice's wall). `tune_field` turns chosen knobs until the part
|
|
6
|
+
is as light as it can be while it still carries the study's loads: every
|
|
7
|
+
candidate is meshed, analyzed by `verify_part` (the same FE check a study's
|
|
8
|
+
outcome gets) and scored by mass, worst-case safety factor and deflection.
|
|
9
|
+
The search is COBYQA over the knobs scaled to [0, 1] (derivative-free: an
|
|
10
|
+
analysis on a voxel grid is not smooth in its inputs), and what comes back
|
|
11
|
+
is the lightest candidate that met every limit, checked again on a grid
|
|
12
|
+
twice as fine (and, for a part with a thin shell, on two grids with at
|
|
13
|
+
least two cells through its thinnest wall, CELLS_THROUGH_WALL), not
|
|
14
|
+
wherever the search stopped. A candidate that fails that check is never
|
|
15
|
+
returned as ok. A part with a lattice in
|
|
16
|
+
it is analyzed with the lattice homogenized (homogenized.py): its walls are
|
|
17
|
+
far finer than a grid a search can afford.
|
|
18
|
+
|
|
19
|
+
The spec lives in the graph under `tune`:
|
|
20
|
+
|
|
21
|
+
"tune": {"study": "example:arm-link",
|
|
22
|
+
"params": [{"path": "nodes/web/shape/max/2", "also": ["-nodes/web/shape/min/2"],
|
|
23
|
+
"min": 3, "max": 9}],
|
|
24
|
+
"safetyFactor": 3, "maxDeflection": 0.5, "evaluations": 20}
|
|
25
|
+
|
|
26
|
+
`path` is where the number sits in the graph ("/"-separated, the same keys
|
|
27
|
+
the editor's sliders use); `also` are numbers that move with it, "-" in
|
|
28
|
+
front for ones that move the other way (a box's min when its max grows).
|
|
29
|
+
Limits default to the study's goal.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import copy
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import time
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
|
|
40
|
+
__all__ = ["tune_field", "get_at", "set_at", "thinnest_wall", "check_cells"]
|
|
41
|
+
|
|
42
|
+
# A thin wall read on a grid about one cell through it moves 10-35% as the
|
|
43
|
+
# wall slides across the grid (the arm-link sandwich: 0.554 mm on 120k
|
|
44
|
+
# cells, 0.748 on 240k, 0.569 on 500k, 0.580 on 1M). From about two cells
|
|
45
|
+
# through the thinnest wall the readings settle to a few percent, so the
|
|
46
|
+
# confirming check runs there, on two grids, and both must pass.
|
|
47
|
+
CELLS_THROUGH_WALL = 2.0
|
|
48
|
+
MAX_CHECK_CELLS = 2_000_000
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _keys(path):
|
|
52
|
+
neg = path.startswith("-")
|
|
53
|
+
keys = [int(k) if k.lstrip("-").isdigit() else k for k in path.lstrip("-").split("/")]
|
|
54
|
+
return neg, keys
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_at(doc, path):
|
|
58
|
+
neg, keys = _keys(path)
|
|
59
|
+
v = doc
|
|
60
|
+
for k in keys:
|
|
61
|
+
try:
|
|
62
|
+
v = v[k]
|
|
63
|
+
except (KeyError, IndexError, TypeError):
|
|
64
|
+
raise ValueError(f"tune: nothing at {path!r}") from None
|
|
65
|
+
if not isinstance(v, (int, float)) or isinstance(v, bool):
|
|
66
|
+
raise ValueError(f"tune: {path!r} is not a number ({v!r})")
|
|
67
|
+
return -float(v) if neg else float(v)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def set_at(doc, path, value):
|
|
71
|
+
neg, keys = _keys(path)
|
|
72
|
+
v = doc
|
|
73
|
+
for k in keys[:-1]:
|
|
74
|
+
v = v[k]
|
|
75
|
+
v[keys[-1]] = -float(value) if neg else float(value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _study(spec, base_dir, material=None):
|
|
79
|
+
from .fields import load_study
|
|
80
|
+
|
|
81
|
+
s = spec.get("study")
|
|
82
|
+
if not isinstance(s, str):
|
|
83
|
+
raise ValueError("tune needs a 'study' (a study JSON path or example:<name>) to load the part with")
|
|
84
|
+
return load_study(s, base_dir, material)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def tune_field(doc, base_dir=None, out_dir=None, progress=None, evaluations=None):
|
|
88
|
+
"""Tune `doc["tune"]["params"]` for the lightest part that meets the
|
|
89
|
+
limits. Returns {"best", "start", "history", "params", "doc"} where
|
|
90
|
+
`doc` is the graph with the best values written in (and `tune` kept);
|
|
91
|
+
with `out_dir`, also writes tuned.json and tune.json there."""
|
|
92
|
+
from scipy.optimize import minimize
|
|
93
|
+
|
|
94
|
+
from .editor import _Resources
|
|
95
|
+
from .fields import FieldGraph, expand_groups, node_doc
|
|
96
|
+
from .homogenized import homogenized_check, lattice_nodes
|
|
97
|
+
from .verify import verify_part
|
|
98
|
+
|
|
99
|
+
spec = doc.get("tune")
|
|
100
|
+
if not isinstance(spec, dict):
|
|
101
|
+
raise ValueError("the graph has no 'tune' block (study, params, limits)")
|
|
102
|
+
params = spec.get("params") or []
|
|
103
|
+
if not params:
|
|
104
|
+
raise ValueError("tune: give at least one param {path, min, max}")
|
|
105
|
+
for p in params:
|
|
106
|
+
if not (isinstance(p.get("min"), (int, float)) and isinstance(p.get("max"), (int, float))
|
|
107
|
+
and p["max"] > p["min"]):
|
|
108
|
+
raise ValueError(f"tune: param {p.get('path')!r} needs min < max")
|
|
109
|
+
for q in [p["path"], *p.get("also", [])]:
|
|
110
|
+
get_at(doc, q)
|
|
111
|
+
study = _study(spec, base_dir, doc.get("material"))
|
|
112
|
+
goal = study.goal
|
|
113
|
+
lim = {"safetyFactor": spec.get("safetyFactor", goal.safety_factor),
|
|
114
|
+
"maxDeflection": spec.get("maxDeflection", goal.max_deflection)}
|
|
115
|
+
if lim["safetyFactor"] is None and lim["maxDeflection"] is None:
|
|
116
|
+
raise ValueError("tune: no limit to hold (safetyFactor or maxDeflection, here or in the study's goal)")
|
|
117
|
+
sweep = spec.get("sweep") or {}
|
|
118
|
+
if sweep and (len(sweep) != 1 or next(iter(sweep)) not in lim
|
|
119
|
+
or not all(isinstance(v, (int, float)) and v > 0 for v in next(iter(sweep.values())))):
|
|
120
|
+
raise ValueError("tune.sweep is one limit and its values: {\"maxDeflection\": [0.4, 0.6]} "
|
|
121
|
+
"or {\"safetyFactor\": [2, 4]}")
|
|
122
|
+
node = spec.get("node") or doc.get("output")
|
|
123
|
+
pitch, cells = float(spec.get("pitch", 1.0)), int(spec.get("cells", 30_000))
|
|
124
|
+
max_check = int(spec.get("checkCells", MAX_CHECK_CELLS))
|
|
125
|
+
from . import quick
|
|
126
|
+
budget = quick.tune_evaluations(int(evaluations or spec.get("evaluations", 20)))
|
|
127
|
+
|
|
128
|
+
# a lattice's walls are finer than any analysis grid a search can
|
|
129
|
+
# afford: a part with one is analyzed with the lattice homogenized
|
|
130
|
+
analysis = spec.get("analysis", "auto")
|
|
131
|
+
if analysis not in ("auto", "solid", "homogenized"):
|
|
132
|
+
raise ValueError("tune.analysis must be auto, solid or homogenized")
|
|
133
|
+
probe = FieldGraph(node_doc(doc, node) if node else doc, base_dir=base_dir)
|
|
134
|
+
homogenized = analysis == "homogenized" or (analysis == "auto" and bool(lattice_nodes(probe)))
|
|
135
|
+
resources = _Resources() # a `result` node's study runs once, not once per candidate
|
|
136
|
+
lo = np.array([p["min"] for p in params], float)
|
|
137
|
+
hi = np.array([p["max"] for p in params], float)
|
|
138
|
+
start = np.array([get_at(doc, p["path"]) for p in params])
|
|
139
|
+
# the other numbers keep their offset from the param (a box's min tracks its max)
|
|
140
|
+
offsets = [[get_at(doc, q) - s for q in p.get("also", [])] for p, s in zip(params, start)]
|
|
141
|
+
base = {k: v for k, v in doc.items() if k != "tune"}
|
|
142
|
+
|
|
143
|
+
def apply(values):
|
|
144
|
+
d = copy.deepcopy(base)
|
|
145
|
+
for p, v, offs in zip(params, values, offsets):
|
|
146
|
+
set_at(d, p["path"], v)
|
|
147
|
+
for q, o in zip(p.get("also", []), offs):
|
|
148
|
+
set_at(d, q, v + o)
|
|
149
|
+
return d
|
|
150
|
+
|
|
151
|
+
history, memo = [], {}
|
|
152
|
+
# The search runs on a coarse grid, whose readings drift from a finer
|
|
153
|
+
# one's by a few percent (more on thin sections). The start is analyzed
|
|
154
|
+
# on both and every coarse reading is scaled by that ratio before it is
|
|
155
|
+
# held to the limits.
|
|
156
|
+
bias = {"safetyFactor": 1.0, "deflection": 1.0}
|
|
157
|
+
|
|
158
|
+
def judge(rec):
|
|
159
|
+
if "error" in rec:
|
|
160
|
+
rec["ok"] = False
|
|
161
|
+
return rec
|
|
162
|
+
raw = rec.setdefault("coarse", {"safetyFactor": rec["safetyFactor"], "deflection": rec["deflection"]})
|
|
163
|
+
rec["safetyFactor"] = raw["safetyFactor"] * bias["safetyFactor"]
|
|
164
|
+
rec["deflection"] = raw["deflection"] * bias["deflection"]
|
|
165
|
+
rec["ok"] = margin(rec) >= 0
|
|
166
|
+
return rec
|
|
167
|
+
|
|
168
|
+
def evaluate(x):
|
|
169
|
+
x = np.clip(np.asarray(x, float), 0.0, 1.0)
|
|
170
|
+
key = tuple(np.round(x, 6))
|
|
171
|
+
if key in memo:
|
|
172
|
+
return memo[key]
|
|
173
|
+
rec = judge(analyze(lo + x * (hi - lo), cells))
|
|
174
|
+
history.append(rec)
|
|
175
|
+
memo[key] = rec
|
|
176
|
+
if progress:
|
|
177
|
+
progress({"event": "tune", "evaluation": len(history), "of": budget,
|
|
178
|
+
**{k: v for k, v in rec.items() if isinstance(v, (int, float, bool, str))},
|
|
179
|
+
"values": ", ".join(f"{v:.3g}" for v in rec["values"])})
|
|
180
|
+
return rec
|
|
181
|
+
|
|
182
|
+
def analyze(values, n_cells):
|
|
183
|
+
"""mass, worst safety factor and deflection of the part at these
|
|
184
|
+
values, uncorrected; `ok` against the limits as read."""
|
|
185
|
+
t0 = time.time()
|
|
186
|
+
d = apply(values)
|
|
187
|
+
rec = {"values": [float(v) for v in values]}
|
|
188
|
+
try:
|
|
189
|
+
dd = node_doc(d, node) if node else expand_groups(d)
|
|
190
|
+
g = resources.graph(dd, base_dir)
|
|
191
|
+
if homogenized:
|
|
192
|
+
rep = homogenized_check(dd, study, base_dir=base_dir, cells=n_cells, graph=g, solid_ok=True)
|
|
193
|
+
else:
|
|
194
|
+
rep = verify_part(study, g.mesh(pitch=pitch, project=1), max_cells=n_cells, refine=0, fractional=True)
|
|
195
|
+
resources.keep(g, dd)
|
|
196
|
+
rec.update(mass=rep["mass"], safetyFactor=rep["safetyFactor"],
|
|
197
|
+
deflection=max((c.get("deflection") or 0.0) for c in rep["cases"]),
|
|
198
|
+
bodies=rep["bodies"])
|
|
199
|
+
rec["ok"] = margin(rec) >= 0
|
|
200
|
+
except Exception as exc: # noqa: BLE001 - a knob setting that breaks the part is a bad candidate
|
|
201
|
+
rec.update(error=str(exc), ok=False)
|
|
202
|
+
rec["seconds"] = round(time.time() - t0, 1)
|
|
203
|
+
return rec
|
|
204
|
+
|
|
205
|
+
def confirm(values, rec=None):
|
|
206
|
+
"""The candidate on the confirming grid (check_cells): twice the
|
|
207
|
+
search's cells, more for a thin shell, and then on a second grid a
|
|
208
|
+
little coarser too, so a wall that happens to sit well on one grid
|
|
209
|
+
can't pass; the worse reading of the two counts."""
|
|
210
|
+
try:
|
|
211
|
+
d = apply(values)
|
|
212
|
+
dd = node_doc(d, node) if node else expand_groups(d)
|
|
213
|
+
g = resources.graph(dd, base_dir)
|
|
214
|
+
vol = None
|
|
215
|
+
if not homogenized and rec is not None and "mass" in rec:
|
|
216
|
+
vol = rec["mass"] * 1000.0 / float(study.material.density)
|
|
217
|
+
n, wall = check_cells(g, None, cells, homogenized, volume=vol, cap=max_check)
|
|
218
|
+
except Exception as exc: # noqa: BLE001
|
|
219
|
+
return {"values": [float(v) for v in values], "error": str(exc), "ok": False}
|
|
220
|
+
out = analyze(values, n)
|
|
221
|
+
out["cells"], out["thinnestWall"] = [n], wall
|
|
222
|
+
if wall is not None and n > 2 * cells and "error" not in out:
|
|
223
|
+
other = analyze(values, int(0.7 * n))
|
|
224
|
+
out["cells"].append(int(0.7 * n))
|
|
225
|
+
if "error" in other:
|
|
226
|
+
return {**out, "error": other["error"], "ok": False}
|
|
227
|
+
out["safetyFactor"] = min(out["safetyFactor"], other["safetyFactor"])
|
|
228
|
+
out["deflection"] = max(out["deflection"], other["deflection"])
|
|
229
|
+
out["seconds"] = round(out["seconds"] + other["seconds"], 1)
|
|
230
|
+
out["ok"] = margin(out) >= 0
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
def margin(rec):
|
|
234
|
+
""">= 0 when every limit holds; the worst limit's relative slack."""
|
|
235
|
+
if "error" in rec:
|
|
236
|
+
return -1.0
|
|
237
|
+
m = [] if rec["bodies"] == 1 else [-0.5]
|
|
238
|
+
if lim["safetyFactor"] is not None:
|
|
239
|
+
m.append(rec["safetyFactor"] / lim["safetyFactor"] - 1.0)
|
|
240
|
+
if lim["maxDeflection"] is not None:
|
|
241
|
+
m.append(1.0 - rec["deflection"] / max(lim["maxDeflection"], 1e-12))
|
|
242
|
+
return min(m)
|
|
243
|
+
|
|
244
|
+
def report_check(r, check):
|
|
245
|
+
r["check"] = {k: check.get(k) for k in ("safetyFactor", "deflection", "ok", "seconds", "cells",
|
|
246
|
+
"thinnestWall")}
|
|
247
|
+
if progress:
|
|
248
|
+
progress({"event": "tune-check", "mass": r.get("mass"),
|
|
249
|
+
**{k: v for k, v in r["check"].items() if v is not None},
|
|
250
|
+
"values": ", ".join(f"{v:.3g}" for v in r["values"])})
|
|
251
|
+
|
|
252
|
+
first, first_fine = evaluate((start - lo) / (hi - lo)), None
|
|
253
|
+
if "error" not in first:
|
|
254
|
+
fine = confirm(first["values"], first)
|
|
255
|
+
if "error" not in fine:
|
|
256
|
+
for k in bias:
|
|
257
|
+
c = first["coarse"][k]
|
|
258
|
+
bias[k] = fine[k] / c if c > 0 else 1.0
|
|
259
|
+
judge(first)
|
|
260
|
+
first_fine = fine
|
|
261
|
+
m0 = first.get("mass") or 1.0
|
|
262
|
+
cons = [{"type": "ineq", "fun": lambda x: margin(evaluate(x))}]
|
|
263
|
+
|
|
264
|
+
def objective(x):
|
|
265
|
+
rec = evaluate(x)
|
|
266
|
+
return rec["mass"] / m0 if "mass" in rec else 10.0
|
|
267
|
+
|
|
268
|
+
def search(x0, fev=None):
|
|
269
|
+
# COBYQA keeps every candidate inside the knobs' ranges and spends
|
|
270
|
+
# one analysis per point (objective and limits come from the same
|
|
271
|
+
# one, cached across searches)
|
|
272
|
+
minimize(objective, x0, method="COBYQA", bounds=[(0.0, 1.0)] * len(params), constraints=cons,
|
|
273
|
+
options={"initial_tr_radius": 0.25, "final_tr_radius": 5e-3, "maxfev": max(fev or budget, 2)})
|
|
274
|
+
|
|
275
|
+
checks = {}
|
|
276
|
+
|
|
277
|
+
def learn(r, check):
|
|
278
|
+
"""A candidate that passed on the search's grid failed on the
|
|
279
|
+
confirming one: the search's correction was too kind there. Take
|
|
280
|
+
the ratio this check saw, if it is worse than the one in use, and
|
|
281
|
+
judge every candidate again by it."""
|
|
282
|
+
raw = r.get("coarse")
|
|
283
|
+
if not raw or "error" in check:
|
|
284
|
+
return
|
|
285
|
+
for k, worse in (("deflection", max), ("safetyFactor", min)):
|
|
286
|
+
if raw.get(k) and check.get(k):
|
|
287
|
+
bias[k] = worse(bias[k], check[k] / raw[k])
|
|
288
|
+
for h in history:
|
|
289
|
+
judge(h)
|
|
290
|
+
|
|
291
|
+
def pick(tries: int = 4):
|
|
292
|
+
"""The lightest candidate that meets the current limits on the fine
|
|
293
|
+
grid too. The coarse/fine ratio holds only roughly away from the
|
|
294
|
+
start, and a voxel reading moves a few percent as the part slides
|
|
295
|
+
across its grid: the lightest that passes is analyzed again on the
|
|
296
|
+
confirming grid (confirm); if it fails there, the ratio it showed
|
|
297
|
+
is learned (learn) and the next lightest that still passes is
|
|
298
|
+
tried, up to `tries` checks."""
|
|
299
|
+
for _ in range(tries):
|
|
300
|
+
good = sorted((r for r in history if "error" not in r and margin(r) >= 0), key=lambda r: r["mass"])
|
|
301
|
+
r = next((g for g in good if tuple(g["values"]) not in checks), None)
|
|
302
|
+
if r is None:
|
|
303
|
+
break
|
|
304
|
+
key = tuple(r["values"])
|
|
305
|
+
checks[key] = first_fine if r is first and first_fine is not None else confirm(r["values"], r)
|
|
306
|
+
check = checks[key]
|
|
307
|
+
report_check(r, {**check, "ok": "error" not in check and margin(check) >= 0})
|
|
308
|
+
if r["check"]["ok"]:
|
|
309
|
+
return {**r, "ok": True}
|
|
310
|
+
learn(r, check)
|
|
311
|
+
for key, check in checks.items():
|
|
312
|
+
# an earlier check of a candidate that still passes
|
|
313
|
+
r = next((h for h in history if tuple(h["values"]) == key), None)
|
|
314
|
+
if r is not None and "error" not in check and margin(check) >= 0:
|
|
315
|
+
return {**r, "ok": True}
|
|
316
|
+
# passed on the search's grid, not on the confirming one: not a
|
|
317
|
+
# result to build, so not ok
|
|
318
|
+
good = sorted((r for r in history if "error" not in r and margin(r) >= 0), key=lambda r: r["mass"])
|
|
319
|
+
return {**good[0], "ok": False, "unconfirmed": True} if good else None
|
|
320
|
+
|
|
321
|
+
def x_of(r):
|
|
322
|
+
return (np.asarray(r["values"]) - lo) / (hi - lo)
|
|
323
|
+
|
|
324
|
+
search(np.clip(x_of(first), 0.0, 1.0))
|
|
325
|
+
best = pick()
|
|
326
|
+
if best is not None and best.get("unconfirmed"):
|
|
327
|
+
# the corrections learned from the failed checks move the limit:
|
|
328
|
+
# search again from the lightest candidate that meets it now
|
|
329
|
+
near = [r for r in history if "error" not in r and margin(r) >= 0]
|
|
330
|
+
x0 = x_of(min(near, key=lambda r: r["mass"])) if near else x_of(first)
|
|
331
|
+
if progress:
|
|
332
|
+
progress({"event": "tune-research", "bias": dict(bias)})
|
|
333
|
+
search(np.clip(x0, 0.0, 1.0), fev=max(budget // 2, 4))
|
|
334
|
+
best = pick()
|
|
335
|
+
out = {"best": best, "start": first, "analysis": "homogenized" if homogenized else "solid",
|
|
336
|
+
"limits": dict(lim), "params": [{"path": p["path"], "min": p["min"], "max": p["max"]} for p in params]}
|
|
337
|
+
if sweep:
|
|
338
|
+
# the trade-off: the same search held to each value of one limit in
|
|
339
|
+
# turn, each warm-started from the lightest candidate that already
|
|
340
|
+
# meets it (or the start)
|
|
341
|
+
key, values = next(iter(sweep.items()))
|
|
342
|
+
base_limit, out["sweep"] = lim[key], []
|
|
343
|
+
for v in values:
|
|
344
|
+
lim[key] = float(v)
|
|
345
|
+
near = [r for r in history if "error" not in r and margin(r) >= 0]
|
|
346
|
+
x0 = x_of(min(near, key=lambda r: r["mass"])) if near else x_of(first)
|
|
347
|
+
if progress:
|
|
348
|
+
progress({"event": "tune-sweep", key: float(v)})
|
|
349
|
+
search(np.clip(x0, 0.0, 1.0))
|
|
350
|
+
out["sweep"].append({key: float(v), "best": pick()})
|
|
351
|
+
lim[key] = base_limit
|
|
352
|
+
out["history"] = history
|
|
353
|
+
|
|
354
|
+
def assign(values):
|
|
355
|
+
"""Every number these values set, as written into the graph."""
|
|
356
|
+
d = apply(values)
|
|
357
|
+
return [[q.lstrip("-"), get_at(d, q.lstrip("-"))] for p in params for q in [p["path"], *p.get("also", [])]]
|
|
358
|
+
|
|
359
|
+
out["front"] = [{**r, "assign": assign(r["values"])} for r in _front(history)]
|
|
360
|
+
for sp in out.get("sweep", []):
|
|
361
|
+
if sp["best"] is not None:
|
|
362
|
+
sp["best"]["assign"] = assign(sp["best"]["values"])
|
|
363
|
+
if best is not None:
|
|
364
|
+
best["assign"] = assign(best["values"])
|
|
365
|
+
tuned = doc if best is None else {**apply(best["values"]), "tune": doc["tune"]}
|
|
366
|
+
out["doc"] = tuned
|
|
367
|
+
if out_dir:
|
|
368
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
369
|
+
with open(os.path.join(out_dir, "tuned.json"), "w") as fh:
|
|
370
|
+
json.dump(tuned, fh, indent=2)
|
|
371
|
+
fh.write("\n")
|
|
372
|
+
with open(os.path.join(out_dir, "tune.json"), "w") as fh:
|
|
373
|
+
json.dump({k: v for k, v in out.items() if k != "doc"}, fh, indent=2)
|
|
374
|
+
fh.write("\n")
|
|
375
|
+
return out
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def thinnest_wall(graph, name=None, samples: int = 20_000):
|
|
379
|
+
"""The thinnest shell wall (mm) in the part: every `shell` node the
|
|
380
|
+
output depends on, its thickness a number or the least its field takes
|
|
381
|
+
over the part's box. None when the part has no shell."""
|
|
382
|
+
name = name or graph.output
|
|
383
|
+
b = graph.bounds(name)
|
|
384
|
+
pts = None
|
|
385
|
+
seen, todo, walls = set(), [name], []
|
|
386
|
+
while todo:
|
|
387
|
+
k = todo.pop()
|
|
388
|
+
if k in seen or k not in graph.nodes:
|
|
389
|
+
continue
|
|
390
|
+
seen.add(k)
|
|
391
|
+
todo.extend(graph.deps.get(k, []))
|
|
392
|
+
nd = graph.nodes[k]
|
|
393
|
+
if nd.op != "shell":
|
|
394
|
+
continue
|
|
395
|
+
ref = nd.refs.get("thickness", nd.spec.get("thickness"))
|
|
396
|
+
if isinstance(ref, (int, float)) and not isinstance(ref, bool):
|
|
397
|
+
walls.append(float(ref))
|
|
398
|
+
elif isinstance(ref, str) and b is not None:
|
|
399
|
+
if pts is None:
|
|
400
|
+
pts = np.random.default_rng(0).uniform(b[0], b[1], (samples, 3))
|
|
401
|
+
t = np.asarray(graph.evaluate(pts, ref), float)
|
|
402
|
+
t = t[np.isfinite(t) & (t > 0)]
|
|
403
|
+
if t.size:
|
|
404
|
+
walls.append(float(t.min()))
|
|
405
|
+
return min(walls) if walls else None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def check_cells(graph, name, cells: int, homogenized: bool, volume: float | None = None,
|
|
409
|
+
cap: int = MAX_CHECK_CELLS):
|
|
410
|
+
"""Analysis cells for the confirming check: twice the search's, and
|
|
411
|
+
enough for CELLS_THROUGH_WALL cells through the thinnest shell wall
|
|
412
|
+
(capped at `cap`). Returns (cells, thinnest wall or None)."""
|
|
413
|
+
wall = thinnest_wall(graph, name)
|
|
414
|
+
n = 2 * cells
|
|
415
|
+
if wall is not None:
|
|
416
|
+
pitch = wall / CELLS_THROUGH_WALL
|
|
417
|
+
if homogenized:
|
|
418
|
+
lo, hi = graph.bounds(name)
|
|
419
|
+
need = float(np.prod(np.asarray(hi) - np.asarray(lo))) * 0.6 / pitch ** 3
|
|
420
|
+
else:
|
|
421
|
+
need = (volume or 0.0) / pitch ** 3
|
|
422
|
+
n = max(n, int(min(need, cap)))
|
|
423
|
+
return n, wall
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _front(history):
|
|
427
|
+
"""The candidates no other beats on mass, deflection and safety factor
|
|
428
|
+
all at once (coarse readings, corrected), lightest first."""
|
|
429
|
+
rs = [r for r in history if "error" not in r and r.get("bodies") == 1]
|
|
430
|
+
out = []
|
|
431
|
+
for r in rs:
|
|
432
|
+
if not any(o["mass"] <= r["mass"] and o["deflection"] <= r["deflection"]
|
|
433
|
+
and o["safetyFactor"] >= r["safetyFactor"]
|
|
434
|
+
and (o["mass"], o["deflection"], -o["safetyFactor"]) != (r["mass"], r["deflection"], -r["safetyFactor"])
|
|
435
|
+
for o in rs):
|
|
436
|
+
out.append({k: r[k] for k in ("values", "mass", "deflection", "safetyFactor")})
|
|
437
|
+
return sorted(out, key=lambda r: r["mass"])
|
loadpath/validation.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Published experiments loadpath's models are checked against.
|
|
3
|
+
|
|
4
|
+
`data/published_experiments.json` holds printed lattice specimens from the
|
|
5
|
+
literature whose geometry is fully specified (cell type, cell size, wall or
|
|
6
|
+
strut, specimen size) with their measured compression modulus and strength
|
|
7
|
+
and the solid material's properties, each with its source. This module
|
|
8
|
+
turns a specimen into the inputs loadpath's checks take:
|
|
9
|
+
|
|
10
|
+
- `table_prediction`: the periodic unit cell's reading (lattice_tensors),
|
|
11
|
+
modulus along the build axis and the stress at which the first wall
|
|
12
|
+
yields;
|
|
13
|
+
- `specimen_model`: the specimen as a field graph and study (lattice cut
|
|
14
|
+
to the cube, bonded 2 mm platens, the top one pushed down and held
|
|
15
|
+
sideways), for `homogenized_check` or a resolved voxel solve
|
|
16
|
+
(bench/published_experiments.py runs both).
|
|
17
|
+
|
|
18
|
+
Results and the comparison with the measurements: docs/VALIDATION.md and
|
|
19
|
+
bench/results/published-experiments.json.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
DATA_PATH = Path(__file__).parent / "data" / "published_experiments.json"
|
|
30
|
+
|
|
31
|
+
__all__ = ["load_experiments", "measured", "lattice_thickness", "geometry_key", "specimen_model", "process_kind",
|
|
32
|
+
"table_prediction"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache(maxsize=1)
|
|
36
|
+
def _load() -> str:
|
|
37
|
+
return DATA_PATH.read_text()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_experiments() -> dict:
|
|
41
|
+
"""The published specimens, materials and sources (a fresh copy)."""
|
|
42
|
+
return json.loads(_load())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def measured(spec: dict) -> dict:
|
|
46
|
+
"""Measured modulus and strength (MPa) of a specimen, with standard
|
|
47
|
+
deviations where the paper gives them. A source that published only a
|
|
48
|
+
power-law fit over its measured relative density is read at the
|
|
49
|
+
specimen's measured density (sd None)."""
|
|
50
|
+
m = spec["measured"]
|
|
51
|
+
if "fit" in m:
|
|
52
|
+
rho = float(m["rho"])
|
|
53
|
+
e = m["fit"]["E"][0] * rho ** m["fit"]["E"][1]
|
|
54
|
+
s = m["fit"]["strength"][0] * rho ** m["fit"]["strength"][1]
|
|
55
|
+
return {"E": e, "Esd": None, "strength": s, "strengthSd": None, "strengthKind": m["strengthKind"],
|
|
56
|
+
"rho": rho}
|
|
57
|
+
return {"E": m["E"][0], "Esd": m["E"][1], "strength": m["strength"][0], "strengthSd": m["strength"][1],
|
|
58
|
+
"strengthKind": m["strengthKind"], "rho": m.get("rho")}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def lattice_thickness(spec: dict) -> float:
|
|
62
|
+
"""Wall (sheet) or strut diameter (mm) of the specimen's lattice: the
|
|
63
|
+
stated one, or the one that gives the paper's relative density when it
|
|
64
|
+
states that (the papers make sheets by thickening the level set, not
|
|
65
|
+
the distance, so matching the density is the fairer reading)."""
|
|
66
|
+
from .lattice import wall_for_density
|
|
67
|
+
from .struts import is_strut, strut_diameter_for_density
|
|
68
|
+
|
|
69
|
+
lat = spec["lattice"]
|
|
70
|
+
kind, cell = lat["type"], float(lat["cell"])
|
|
71
|
+
if is_strut(kind):
|
|
72
|
+
if "diameter" in lat:
|
|
73
|
+
return float(lat["diameter"])
|
|
74
|
+
return float(strut_diameter_for_density(kind, float(lat["rho"]), cell))
|
|
75
|
+
if "rho" in lat:
|
|
76
|
+
return float(wall_for_density(kind, float(lat["rho"]), cell))
|
|
77
|
+
return float(lat["wall"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _rho(spec: dict) -> float:
|
|
81
|
+
from .lattice import relative_density
|
|
82
|
+
|
|
83
|
+
lat = spec["lattice"]
|
|
84
|
+
return float(relative_density(lat["type"], lattice_thickness(spec), float(lat["cell"])))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def geometry_key(spec: dict) -> str:
|
|
88
|
+
lat = spec["lattice"]
|
|
89
|
+
size = "x".join(f"{float(s):g}" for s in spec["size"])
|
|
90
|
+
return f"{lat['type']}-c{float(lat['cell']):g}-t{lattice_thickness(spec):.4f}-{size}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def table_prediction(spec: dict, mat: dict) -> dict:
|
|
94
|
+
"""The infinite lattice's reading: modulus along z (MPa) and the
|
|
95
|
+
uniaxial z stress (MPa) at which its peak wall stress reaches the
|
|
96
|
+
solid's strength."""
|
|
97
|
+
from .lattice_tensors import directional_modulus, stress_surface, tensors_at
|
|
98
|
+
|
|
99
|
+
rho = _rho(spec)
|
|
100
|
+
c, p, q, f = tensors_at(spec["lattice"]["type"], rho)
|
|
101
|
+
e = directional_modulus(c[0], [0.0, 0.0, 1.0]) * float(mat["E"])
|
|
102
|
+
k = float(stress_surface(np.array([[0.0, 0.0, 1.0, 0.0, 0.0, 0.0]]), p[0], q[0], f[0])[0])
|
|
103
|
+
return {"rho": rho, "thickness": lattice_thickness(spec), "E": e, "firstYield": float(mat["strength"]) / k,
|
|
104
|
+
"stressFactor": k}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def specimen_model(spec: dict, mat: dict, plate: float = 2.0, stress: float = 1.0):
|
|
108
|
+
"""(field graph, study dict, loaded area mm^2, lattice height mm) of a
|
|
109
|
+
compression specimen: the lattice (origin at the cube's corner) cut to
|
|
110
|
+
the cube, `plate` mm solid platens bonded below and above. The bottom
|
|
111
|
+
platen is held; the top one carries `stress` MPa down and is held
|
|
112
|
+
sideways, as a rough platen would."""
|
|
113
|
+
lat = spec["lattice"]
|
|
114
|
+
sx, sy, sz = (float(s) for s in spec["size"])
|
|
115
|
+
nodes = {
|
|
116
|
+
"lat": {"op": "lattice", "type": lat["type"], "cell": float(lat["cell"]),
|
|
117
|
+
"thickness": lattice_thickness(spec), "origin": [0.0, 0.0, 0.0]},
|
|
118
|
+
"box": {"op": "shape", "shape": {"type": "box", "min": [0, 0, 0], "max": [sx, sy, sz]}},
|
|
119
|
+
"fill": {"op": "intersect", "inputs": ["box", "lat"]},
|
|
120
|
+
"plates": {"op": "shape", "shape": {"type": "union", "shapes": [
|
|
121
|
+
{"type": "box", "min": [0, 0, -plate], "max": [sx, sy, 0]},
|
|
122
|
+
{"type": "box", "min": [0, 0, sz], "max": [sx, sy, sz + plate]}]}},
|
|
123
|
+
"part": {"op": "union", "inputs": ["fill", "plates"]},
|
|
124
|
+
}
|
|
125
|
+
doc = {"name": spec["id"], "nodes": nodes, "output": "part"}
|
|
126
|
+
area = sx * sy
|
|
127
|
+
study = {
|
|
128
|
+
"name": spec["id"],
|
|
129
|
+
"designSpace": [{"type": "box", "min": [0, 0, -plate], "max": [sx, sy, sz + plate]}],
|
|
130
|
+
"preserve": [{"id": "bottom", "shape": {"type": "box", "min": [0, 0, -plate], "max": [sx, sy, 0]}},
|
|
131
|
+
{"id": "top", "shape": {"type": "box", "min": [0, 0, sz], "max": [sx, sy, sz + plate]}}],
|
|
132
|
+
"supports": [{"region": "bottom"}, {"region": "top", "fix": [True, True, False]}],
|
|
133
|
+
"loadCases": [{"name": "compress", "loads": [{"region": "top", "force": [0, 0, -stress * area]}]}],
|
|
134
|
+
"material": {"E": float(mat["E"]), "nu": float(mat["nu"]), "strength": float(mat["strength"]),
|
|
135
|
+
"density": float(mat["density"])},
|
|
136
|
+
"goal": {"safetyFactor": 1.0},
|
|
137
|
+
}
|
|
138
|
+
return doc, study, area, sz
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def process_kind(process: str) -> str:
|
|
142
|
+
"""loadpath's process type for a specimen's process label ("SLS PA12",
|
|
143
|
+
"FDM ABS 0.25 mm", "LCD resin"): LCD is whole-layer vat exposure, dlp."""
|
|
144
|
+
word = str(process).split()[0].lower()
|
|
145
|
+
return {"lcd": "dlp"}.get(word, word)
|