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/study.py
ADDED
|
@@ -0,0 +1,1937 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run a study end to end: voxelize, optimize, mesh, verify.
|
|
3
|
+
|
|
4
|
+
Two kinds of goal:
|
|
5
|
+
|
|
6
|
+
- `volumeFraction`: keep that share of the design space (plus any extra
|
|
7
|
+
`outcomes` fractions, for a trade-off gallery).
|
|
8
|
+
- `safetyFactor` and/or `maxDeflection`: as light as possible while every
|
|
9
|
+
load case keeps at least that safety factor against the material's
|
|
10
|
+
strength and deflects at most that much at its loads. Neither is something
|
|
11
|
+
compliance optimization controls directly, so this searches over volume
|
|
12
|
+
fractions, verifying the delivered mesh at each step, and returns the
|
|
13
|
+
lightest outcome that passes. Each step warm-starts from the nearest
|
|
14
|
+
fraction above it already run.
|
|
15
|
+
With `durability.constrain` the search also holds the fatigue and creep
|
|
16
|
+
safety factor to `durability.safetyFactor` (and a held load's crept
|
|
17
|
+
deflection to `maxDeflection`); see durability.py.
|
|
18
|
+
|
|
19
|
+
The search works on one number, the margin: the worst of (safety factor /
|
|
20
|
+
target) and (deflection limit / deflection). Both scale roughly linearly with
|
|
21
|
+
the amount of material in an optimized part, so the next volume fraction is
|
|
22
|
+
the current one divided by the margin.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from .domain import Domain, build_domain, cell_cap
|
|
33
|
+
from .manufacture import report as manufacturing_report
|
|
34
|
+
from .reconstruct import ReconstructOptions, reconstruct
|
|
35
|
+
from .simp import SimpOptions, filter_radius, optimize
|
|
36
|
+
from .spec import Study
|
|
37
|
+
from .verify import verify_part
|
|
38
|
+
|
|
39
|
+
__all__ = ["Outcome", "StudyResult", "run_study", "design_at"]
|
|
40
|
+
|
|
41
|
+
# Bounds for the safety-factor search. (A floor of 0.04 stopped GRIP's upper
|
|
42
|
+
# arm there with SF 5.4 against a goal of 2: large design spaces around
|
|
43
|
+
# small parts end at a few percent.)
|
|
44
|
+
_VF_MIN, _VF_MAX = 0.02, 0.7
|
|
45
|
+
_MAX_SEARCH_RUNS = 7
|
|
46
|
+
# Coarse-to-fine: below this many cells the fine solve is cheap enough alone.
|
|
47
|
+
_COARSE_MIN_CELLS = 12_000
|
|
48
|
+
# Projection sharpness a warm-started run resumes at.
|
|
49
|
+
_WARM_BETA = 4.0
|
|
50
|
+
# runs of the constrained method's limit search (study.run, method constrained)
|
|
51
|
+
_MAX_CONSTRAINED_RUNS = 6
|
|
52
|
+
# goal.buckling in the constrained method: hold the design's buckling load
|
|
53
|
+
# factor in the optimizer itself (bucklingopt.py), starting at the safety
|
|
54
|
+
# factor times _BUCKLING_START (recalibrated against the verified part after
|
|
55
|
+
# each run). LOADPATH_BUCKLING_OPT=0 leaves buckling to the check alone.
|
|
56
|
+
_BUCKLING_IN_OPTIMIZER = os.environ.get("LOADPATH_BUCKLING_OPT", "1") != "0"
|
|
57
|
+
_BUCKLING_START = 1.2
|
|
58
|
+
# The search (volume fractions, simp.optimize) can hold buckling too: an
|
|
59
|
+
# adaptive penalty on its step, turned on once a part's buckling factor is
|
|
60
|
+
# under _BUCKLING_SEARCH_ON times the safety factor. OPT-IN
|
|
61
|
+
# (LOADPATH_BUCKLING_SEARCH=1): on the full-tier printed hook, the one eval
|
|
62
|
+
# part whose search buckling binds (2.61 for 2.5 at 29.3 g), it gave 29.8 g
|
|
63
|
+
# (buckling 2.50, SF 3.22) in 1.8x the time; the lighter fractions failed on
|
|
64
|
+
# stress (voxel SF 0.1-0.6 at 24-29 g), not buckling. MMA with buckling as a
|
|
65
|
+
# hard constraint built weaker layouts still (SF 0.3-2.0 at 28-31 g).
|
|
66
|
+
_BUCKLING_SEARCH = os.environ.get("LOADPATH_BUCKLING_SEARCH", "0") == "1"
|
|
67
|
+
_BUCKLING_SEARCH_ON = 1.5
|
|
68
|
+
# method auto: run the constrained method after the search when the search's
|
|
69
|
+
# part idles (verify's stressUtilization, mean von Mises over the near peak,
|
|
70
|
+
# under this) and is not deflection-bound. Search picks' utilization, and
|
|
71
|
+
# tet-confirmed search / auto masses (2026-11-01): fastener bracket 0.10
|
|
72
|
+
# (41.0 / 26.4 g), wall bracket 0.11 (64.4 / 26.6 g), where constrained wins;
|
|
73
|
+
# MBB 0.19, bell crank 0.20 and GRIP yoke 0.21, where it found nothing
|
|
74
|
+
# lighter in 2-5x the search's time; hook 0.30 and arm link 0.33, where it
|
|
75
|
+
# was 10-40% heavier.
|
|
76
|
+
_AUTO_UTILIZATION = 0.15
|
|
77
|
+
# Cut cells (verify's default since 2026-11-05) read the surface stress
|
|
78
|
+
# without the staircase's spurious corner peaks, so the same part reads
|
|
79
|
+
# utilization 7-16% higher (wrist yoke 0.168 -> 0.184, motor mount 0.160 ->
|
|
80
|
+
# 0.186, hook 0.328 -> 0.350): at 0.15 the yoke skipped the constrained
|
|
81
|
+
# method and came out 18.6 g against 14.1; at 0.19, 13.9.
|
|
82
|
+
_AUTO_UTILIZATION_CUT = 0.19
|
|
83
|
+
# and gives it at most this many times the search's own time
|
|
84
|
+
_AUTO_BUDGET = 3.5
|
|
85
|
+
# The pick of a safety-factor search is checked again on a grid this many
|
|
86
|
+
# cells finer before it is delivered. The in-loop check resolves a fillet
|
|
87
|
+
# only as well as its grid: GRIP's turret read SF 2.26 there and 1.65 at
|
|
88
|
+
# 120k cells, its peak at a corner the coarser grid had averaged away.
|
|
89
|
+
_CONFIRM_CELLS = 150_000
|
|
90
|
+
_CONFIRM_RUNS = 3
|
|
91
|
+
# With goal.confirm "tet", at most this many picks are confirmed on the
|
|
92
|
+
# tetrahedral mesh, and the search runs at most this many more fractions
|
|
93
|
+
# after its first pick (the step down the calibration allows, and back).
|
|
94
|
+
_TET_CONFIRMS = 6
|
|
95
|
+
_TET_EXTRA_RUNS = 4
|
|
96
|
+
# A tet-confirmed pick within this of its target is close enough.
|
|
97
|
+
_TET_CLOSE = 1.08
|
|
98
|
+
# Optimizations the layout search below a confirmed pick may spend.
|
|
99
|
+
_LAYOUT_RUNS = 3
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _making(study: Study) -> dict:
|
|
103
|
+
"""The optimizer options that say how the part is made."""
|
|
104
|
+
return dict(build_direction=study.print_direction, extrusion=study.extrusion, access=study.access,
|
|
105
|
+
pattern=study.pattern, max_member=study.max_member,
|
|
106
|
+
dilate=_DILATE if study.min_hole else 0.0, escape=study.drains)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# simp.SimpOptions.dilate when the study has a minimum hole: the erosion's twin
|
|
110
|
+
_DILATE = 0.15
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _coarse_start(study: Study, domain: Domain, vf: float, radius: float, should_stop) -> np.ndarray | None:
|
|
114
|
+
"""Find the layout on a grid twice as coarse (an eighth of the cells, so
|
|
115
|
+
each iteration is roughly ten times cheaper), stopping while it is still
|
|
116
|
+
soft (beta 4), then interpolate it onto the fine grid as the starting
|
|
117
|
+
design. The fine run only has to sharpen and refine it."""
|
|
118
|
+
from scipy import ndimage
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
coarse = build_domain(study, pitch=2.0 * domain.pitch)
|
|
122
|
+
except ValueError:
|
|
123
|
+
# The coarse grid can lose what the fine one resolves (a fastener's
|
|
124
|
+
# seat and face rings landing on the same nodes, a thin load pad):
|
|
125
|
+
# then start cold on the fine grid, which `check` validated.
|
|
126
|
+
return None
|
|
127
|
+
res = optimize(coarse, study.elastic(design=True),
|
|
128
|
+
SimpOptions(volume_fraction=vf, filter_radius=max(radius, 1.5 * coarse.pitch),
|
|
129
|
+
max_iterations=60, beta_max=_WARM_BETA, beta_every=12,
|
|
130
|
+
**{**_making(study), "escape": None}),
|
|
131
|
+
should_stop=should_stop)
|
|
132
|
+
grid = coarse.full_grid(res.x)
|
|
133
|
+
coords = (domain.mesh.elem_centers() - coarse.origin) / coarse.pitch - 0.5
|
|
134
|
+
x0 = ndimage.map_coordinates(grid, coords.T, order=1, mode="nearest")
|
|
135
|
+
return np.clip(x0, 0.0, 1.0)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class Outcome:
|
|
140
|
+
volume_fraction: float
|
|
141
|
+
mesh: object
|
|
142
|
+
report: dict
|
|
143
|
+
optimizer: dict
|
|
144
|
+
reconstruction: dict
|
|
145
|
+
passes: bool | None = None
|
|
146
|
+
x: object = None
|
|
147
|
+
# the optimized density field this mesh was drawn from (per domain cell),
|
|
148
|
+
# so a part can be re-meshed without optimizing again
|
|
149
|
+
xphys: object = None
|
|
150
|
+
# infill.grade_infill's result (modifier meshes for the graded 3MF)
|
|
151
|
+
grading: object = None
|
|
152
|
+
# buildsim.compensate_mesh's pre-distorted part (buildSim.compensate)
|
|
153
|
+
compensated: object = None
|
|
154
|
+
|
|
155
|
+
def summary(self) -> dict:
|
|
156
|
+
r = self.report
|
|
157
|
+
return {
|
|
158
|
+
"material": r.get("material", {}).get("name"),
|
|
159
|
+
"volumeFraction": self.volume_fraction,
|
|
160
|
+
"mass": r.get("mass"),
|
|
161
|
+
"massReduction": r.get("massReduction"),
|
|
162
|
+
"safetyFactor": r.get("safetyFactor"),
|
|
163
|
+
**({"voxelSafetyFactor": r["voxelSafetyFactor"]} if "voxelSafetyFactor" in r else {}),
|
|
164
|
+
**({"bucklingFactor": r["bucklingFactor"]} if r.get("bucklingFactor") is not None else {}),
|
|
165
|
+
**({"confirm": r["confirm"]} if "confirm" in r else {}),
|
|
166
|
+
**({"calibration": r["calibration"]} if "calibration" in r else {}),
|
|
167
|
+
**({"tetCheck": r["tetCheck"]} if "tetCheck" in r else {}),
|
|
168
|
+
"cases": r.get("cases"),
|
|
169
|
+
"minMember": r.get("minMember"),
|
|
170
|
+
"overhangArea": r.get("overhangArea"),
|
|
171
|
+
"keepOutIntrusion": r.get("keepOutIntrusion"),
|
|
172
|
+
"preserveMissing": r.get("preserveMissing"),
|
|
173
|
+
"bodies": r.get("bodies"),
|
|
174
|
+
"boundaryCutArea": r.get("boundaryCutArea"),
|
|
175
|
+
**({"thermal": r["thermal"]} if "thermal" in r else {}),
|
|
176
|
+
**({"durability": r["durability"]} if "durability" in r else {}),
|
|
177
|
+
**({"print": {k: v for k, v in r["print"].items() if k != "bridgeList"}} if "print" in r else {}),
|
|
178
|
+
**({"process": r["process"]} if "process" in r else {}),
|
|
179
|
+
**({"slicer": r["slicer"]} if "slicer" in r else {}),
|
|
180
|
+
**({"buildSim": r["buildSim"]} if "buildSim" in r else {}),
|
|
181
|
+
**({"graded": r["graded"]} if "graded" in r else {}),
|
|
182
|
+
"warnings": r.get("warnings", []),
|
|
183
|
+
"margin": r.get("margin"),
|
|
184
|
+
"passes": self.passes,
|
|
185
|
+
"start": r.get("start"),
|
|
186
|
+
"alternative": r.get("alternative"),
|
|
187
|
+
"optimizer": self.optimizer,
|
|
188
|
+
"reconstruction": self.reconstruction,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class StudyResult:
|
|
194
|
+
study: Study
|
|
195
|
+
domain: dict
|
|
196
|
+
outcomes: list[Outcome]
|
|
197
|
+
best: int | None
|
|
198
|
+
seconds: float
|
|
199
|
+
warnings: list = field(default_factory=list)
|
|
200
|
+
# method auto: which method made `best` and why
|
|
201
|
+
choice: dict | None = None
|
|
202
|
+
# resolution.pitchStarts: each grid's pitch, pick and whether it passed
|
|
203
|
+
starts: list | None = None
|
|
204
|
+
|
|
205
|
+
def to_dict(self) -> dict:
|
|
206
|
+
return {
|
|
207
|
+
**({"pitchStarts": self.starts} if self.starts else {}),
|
|
208
|
+
"name": self.study.name,
|
|
209
|
+
"domain": self.domain,
|
|
210
|
+
"outcomes": [o.summary() for o in self.outcomes],
|
|
211
|
+
"best": self.best,
|
|
212
|
+
"seconds": self.seconds,
|
|
213
|
+
"warnings": self.warnings,
|
|
214
|
+
**({"methodChoice": self.choice} if self.choice else {}),
|
|
215
|
+
**({"orientation": self.study.raw["orientation"]} if "orientation" in self.study.raw else {}),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _durability_drives(study: Study) -> bool:
|
|
220
|
+
return study.durability is not None and study.durability.constrain
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _margin(study: Study, report: dict) -> float | None:
|
|
224
|
+
g = study.goal
|
|
225
|
+
ms = []
|
|
226
|
+
if g.safety_factor is not None:
|
|
227
|
+
ms.append(report["safetyFactor"] / g.safety_factor)
|
|
228
|
+
if g.buckling and report.get("bucklingFactor") is not None:
|
|
229
|
+
# a part that buckles under safetyFactor x its loads fails as one
|
|
230
|
+
# that yields there does (verify._buckling)
|
|
231
|
+
ms.append(report["bucklingFactor"] / g.safety_factor)
|
|
232
|
+
dur = report.get("durability") if _durability_drives(study) else None
|
|
233
|
+
if g.max_deflection is not None:
|
|
234
|
+
worst = max((c["deflection"] for c in report["cases"]), default=float("inf"))
|
|
235
|
+
if dur:
|
|
236
|
+
# a held load's deflection after creep, at its temperature
|
|
237
|
+
worst = max([worst] + [c["creepDeflection"] for c in dur["cases"] if "creepDeflection" in c])
|
|
238
|
+
ms.append(g.max_deflection / worst if worst > 0 else float("inf"))
|
|
239
|
+
if dur and dur.get("safetyFactor") is not None:
|
|
240
|
+
ms.append(dur["safetyFactor"] / dur["target"])
|
|
241
|
+
warp = report.get("warp")
|
|
242
|
+
if warp:
|
|
243
|
+
# buildSim warp limits: the build of the delivered part when it was
|
|
244
|
+
# re-simulated (_warp_delivered), else the optimizer's on its field
|
|
245
|
+
got = warp.get("delivered") or warp
|
|
246
|
+
lims = warp.get("userLimits") or warp["limits"]
|
|
247
|
+
for key, lim in (("maxDistortion", "maxDistortion"), ("recoaterLift", "maxRecoaterLift")):
|
|
248
|
+
if lims.get(lim) and got.get(key) is not None:
|
|
249
|
+
ms.append(lims[lim] / max(got[key], 1e-12))
|
|
250
|
+
return min(ms) if ms else None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _whole(report: dict) -> bool:
|
|
254
|
+
"""A part that can be delivered at all, whatever its margin: one body,
|
|
255
|
+
and every bore open at both ends. A capped bore takes no screw (the
|
|
256
|
+
wall bracket with fasteners at 5.6%, tets 3.52 for 2.5, came out with
|
|
257
|
+
one closed over; the search delivered it), so the search counts it
|
|
258
|
+
failing and steps on to a fraction that keeps the bore."""
|
|
259
|
+
return report.get("bodies", 1) <= 1 and not report.get("closedBores")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _warp_delivered(study: Study, mesh) -> dict | None:
|
|
263
|
+
"""The as-built measures of the delivered part (buildsim.simulate_part
|
|
264
|
+
at buildSim.cells, on the plate or bed at the end of the build, as the
|
|
265
|
+
optimizer's limits are): the largest movement (along the build for
|
|
266
|
+
FDM, |u| otherwise) and the recoater lift, mm. The optimizer's build
|
|
267
|
+
model runs on its coarse density grid, which under-resolves thin
|
|
268
|
+
members (FDM warp on the PA12 beam read 2-3.5x low there), so FDM warp
|
|
269
|
+
limits are checked on this one."""
|
|
270
|
+
import dataclasses as _dc
|
|
271
|
+
from .buildsim import simulate_part
|
|
272
|
+
bs = study.build_sim
|
|
273
|
+
build = study.process.build if study.process is not None and study.process.build not in (None, "auto") else "+z"
|
|
274
|
+
if study.print_direction is not None:
|
|
275
|
+
build = study.print_direction
|
|
276
|
+
setup = _dc.replace(bs["setup"], release="none")
|
|
277
|
+
t0 = time.time()
|
|
278
|
+
try:
|
|
279
|
+
r = simulate_part(mesh, setup, build=build, cells=min(int(bs.get("cells") or 8000), 30_000))
|
|
280
|
+
except Exception as exc: # noqa: BLE001 - leave the optimizer's numbers standing
|
|
281
|
+
return {"error": f"{type(exc).__name__}: {exc}"}
|
|
282
|
+
m = r.mesh
|
|
283
|
+
on = np.zeros(m.n_nodes, bool)
|
|
284
|
+
on[m.enode[r.part].ravel()] = True
|
|
285
|
+
u = r.u_built[on]
|
|
286
|
+
d = np.abs(u[:, 2]) if setup.process.lower() == "fdm" else np.linalg.norm(u, axis=1)
|
|
287
|
+
return {"maxDistortion": float(d.max()),
|
|
288
|
+
"recoaterLift": (r.report.get("recoater") or {}).get("maxLift"),
|
|
289
|
+
"pitch": r.report.get("pitch"), "seconds": round(time.time() - t0, 1)}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def design_at(study: Study, domain: Domain, radius: float, vf: float, x0=None, beta0: float = 1.0,
|
|
293
|
+
max_iterations: int | None = None, on_iter=None, should_stop=None, emit=None,
|
|
294
|
+
verify: bool = True, verify_cells: int = 60_000, refine: int = 3) -> Outcome:
|
|
295
|
+
"""One optimize -> mesh -> verify pass at volume fraction `vf`, from
|
|
296
|
+
design `x0` (None: uniform) with projection starting at `beta0`. The
|
|
297
|
+
outcome carries its margin and pass/fail against the study's goal
|
|
298
|
+
(verify=False skips the check: an empty report, for a design that is
|
|
299
|
+
only a starting point; verify_cells and refine set how closely it is
|
|
300
|
+
checked, see verify_part)."""
|
|
301
|
+
def note(event):
|
|
302
|
+
if emit is not None:
|
|
303
|
+
emit(event)
|
|
304
|
+
|
|
305
|
+
res = optimize(domain, study.elastic(design=True),
|
|
306
|
+
SimpOptions(volume_fraction=vf, filter_radius=radius,
|
|
307
|
+
max_iterations=max_iterations or study.max_iterations, x0=x0, beta_start=beta0,
|
|
308
|
+
**_making(study)),
|
|
309
|
+
on_iter=on_iter, should_stop=should_stop)
|
|
310
|
+
note({"stage": "mesh", "volumeFraction": vf})
|
|
311
|
+
mesh, rinfo = reconstruct(study, domain, res.xphys, ReconstructOptions())
|
|
312
|
+
opt = {"iterations": res.iterations, "converged": res.converged, "seconds": res.seconds,
|
|
313
|
+
"compliance": res.compliance, "caseCompliance": res.case_compliance,
|
|
314
|
+
**({"caseDirection": res.case_direction} if any(d is not None for d in res.case_direction) else {}),
|
|
315
|
+
"achievedVolumeFraction": res.volume_fraction,
|
|
316
|
+
"beta": res.history[-1]["beta"] if res.history else beta0,
|
|
317
|
+
**({"escape": res.escape} if res.escape else {})}
|
|
318
|
+
o = Outcome(vf, mesh, {}, opt, rinfo)
|
|
319
|
+
o.xphys = res.xphys
|
|
320
|
+
o.x = res.x
|
|
321
|
+
if not verify:
|
|
322
|
+
return o
|
|
323
|
+
note({"stage": "verify", "volumeFraction": vf})
|
|
324
|
+
try:
|
|
325
|
+
report = verify_part(study, mesh, max_cells=verify_cells, min_pitch=0.5 * domain.pitch,
|
|
326
|
+
contact_band=domain.pitch, refine=refine)
|
|
327
|
+
except (RuntimeError, ValueError) as exc:
|
|
328
|
+
# one outcome verification cannot solve (a piece the supports do
|
|
329
|
+
# not hold) is a failed outcome, not a failed study: the search
|
|
330
|
+
# goes on past it (GRIP's wrist link died here)
|
|
331
|
+
mat = study.material
|
|
332
|
+
report = {"mass": float(mesh.volume) * mat.density / 1000.0, "safetyFactor": 0.0,
|
|
333
|
+
"cases": [], "warnings": [f"verification failed: {exc}"]}
|
|
334
|
+
making = manufacturing_report(study, domain, mesh)
|
|
335
|
+
if making:
|
|
336
|
+
report["manufacturing"] = making
|
|
337
|
+
if res.warp:
|
|
338
|
+
report["warp"] = res.warp
|
|
339
|
+
o.report = report
|
|
340
|
+
o.voxel_sf = report.get("safetyFactor")
|
|
341
|
+
m = _margin(study, report)
|
|
342
|
+
if m is not None:
|
|
343
|
+
report["margin"] = m
|
|
344
|
+
o.passes = bool(m >= 1.0)
|
|
345
|
+
if report.get("bodies", 1) > 1:
|
|
346
|
+
# A preserved piece no load path reaches (a mount with no load
|
|
347
|
+
# through it) comes out as a loose part. It is not a design that
|
|
348
|
+
# meets the brief, and the fix is in the study: say so.
|
|
349
|
+
o.passes = False
|
|
350
|
+
report.setdefault("warnings", []).append(
|
|
351
|
+
f"the part came out in {report['bodies']} pieces: either each piece has its own supports and "
|
|
352
|
+
"load path and nothing ties them together (two mirror halves, each on its own bolts), or some "
|
|
353
|
+
"preserved geometry is on no load path. For one part, preserve a link between the pieces (a "
|
|
354
|
+
"ring, a web, a plate) or add a load case that runs between them; for a floating preserve, "
|
|
355
|
+
"load it or drop it.")
|
|
356
|
+
if report.get("closedBores"):
|
|
357
|
+
o.passes = False
|
|
358
|
+
report.setdefault("warnings", []).append(
|
|
359
|
+
f"the part closes over {len(report['closedBores'])} bore end(s) "
|
|
360
|
+
f"({', '.join(b['id'] + ' ' + b['end'] for b in report['closedBores'])}): no fastener goes through, "
|
|
361
|
+
"so it does not pass. Extend the keep-out over the head and the driver's reach if this repeats.")
|
|
362
|
+
return o
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _confirm_on_tets(study: Study, outcomes: list) -> bool:
|
|
366
|
+
"""goal.confirm says tet (or auto, and the cross-check covers it)."""
|
|
367
|
+
mode = study.goal.confirm
|
|
368
|
+
if mode == "voxel":
|
|
369
|
+
return False
|
|
370
|
+
from .crosscheck import unsupported
|
|
371
|
+
|
|
372
|
+
why = unsupported(study)
|
|
373
|
+
if why is None:
|
|
374
|
+
return True
|
|
375
|
+
if outcomes and (mode == "tet" or why.startswith("no tetrahedral mesher")):
|
|
376
|
+
# A missing mesher is the install, not the study: say so on every
|
|
377
|
+
# part it changes. The voxel grid reads peak stress high (the MBB
|
|
378
|
+
# beam 0.86 on voxels, 1.94 on tets at 30%), so without the tet
|
|
379
|
+
# confirm a search stops heavier: the MBB beam's eval went from
|
|
380
|
+
# 14.5 g to 40.4 g and failed its mass reduction, silently.
|
|
381
|
+
outcomes[0].report.setdefault("warnings", []).append(
|
|
382
|
+
f"the tetrahedral cross-check does not cover this study ({why}): confirmed on voxels instead, "
|
|
383
|
+
"which read peak stress high, so the part comes out heavier than it needs to be")
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _domain_info(d: Domain, radius: float) -> dict:
|
|
388
|
+
return {
|
|
389
|
+
"pitch": d.pitch,
|
|
390
|
+
"cells": d.n_elem,
|
|
391
|
+
"dims": list(d.dims),
|
|
392
|
+
"preservedCells": int(d.preserve.sum()),
|
|
393
|
+
"filterRadius": radius,
|
|
394
|
+
"loadCases": d.case_names,
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# Peak memory of one worker, roughly, by the most cells it solves at once
|
|
399
|
+
# (study._study_cells): GRIP's turret, a safety-factor search confirmed at
|
|
400
|
+
# 150k cells, peaked at 8.4 GB.
|
|
401
|
+
_RUN_BASE_BYTES = 0.6e9
|
|
402
|
+
_RUN_CELL_BYTES = 55e3
|
|
403
|
+
def _run_bytes(cells: int) -> float:
|
|
404
|
+
return _RUN_BASE_BYTES + _RUN_CELL_BYTES * int(cells)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _cgroup_limit() -> float | None:
|
|
408
|
+
"""The memory limit of the container this runs in, if any (cgroup v2,
|
|
409
|
+
then v1): MemAvailable reports the host's, and the cloud box's 14.3 GB
|
|
410
|
+
limit is what the kernel enforces."""
|
|
411
|
+
paths = []
|
|
412
|
+
try:
|
|
413
|
+
with open("/proc/self/cgroup") as f:
|
|
414
|
+
for line in f:
|
|
415
|
+
_, ctrl, rel = line.strip().split(":", 2)
|
|
416
|
+
if ctrl == "":
|
|
417
|
+
paths.append(f"/sys/fs/cgroup{rel}/memory.max")
|
|
418
|
+
elif "memory" in ctrl.split(","):
|
|
419
|
+
paths.append(f"/sys/fs/cgroup/memory{rel}/memory.limit_in_bytes")
|
|
420
|
+
except (OSError, ValueError):
|
|
421
|
+
pass
|
|
422
|
+
for path in paths + ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]:
|
|
423
|
+
try:
|
|
424
|
+
with open(path) as f:
|
|
425
|
+
v = f.read().strip()
|
|
426
|
+
if v.isdigit() and int(v) < 1 << 60:
|
|
427
|
+
return float(v)
|
|
428
|
+
except OSError:
|
|
429
|
+
pass
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _cgroup_free() -> float | None:
|
|
434
|
+
"""The container's limit less what it holds now (reclaimable page cache
|
|
435
|
+
not counted): the limit alone let eight brief-eval agents' runs each
|
|
436
|
+
count the whole 14 GB as theirs, and the kernel killed them."""
|
|
437
|
+
lim = _cgroup_limit()
|
|
438
|
+
if not lim:
|
|
439
|
+
return None
|
|
440
|
+
paths = []
|
|
441
|
+
try:
|
|
442
|
+
with open("/proc/self/cgroup") as f:
|
|
443
|
+
for line in f:
|
|
444
|
+
_, ctrl, rel = line.strip().split(":", 2)
|
|
445
|
+
if ctrl == "":
|
|
446
|
+
paths.append((f"/sys/fs/cgroup{rel}/memory.current", f"/sys/fs/cgroup{rel}/memory.stat",
|
|
447
|
+
"inactive_file"))
|
|
448
|
+
elif "memory" in ctrl.split(","):
|
|
449
|
+
paths.append((f"/sys/fs/cgroup/memory{rel}/memory.usage_in_bytes",
|
|
450
|
+
f"/sys/fs/cgroup/memory{rel}/memory.stat", "total_inactive_file"))
|
|
451
|
+
except (OSError, ValueError):
|
|
452
|
+
return lim
|
|
453
|
+
for used_p, stat_p, key in paths:
|
|
454
|
+
try:
|
|
455
|
+
with open(used_p) as f:
|
|
456
|
+
used = float(f.read().strip())
|
|
457
|
+
except (OSError, ValueError):
|
|
458
|
+
continue
|
|
459
|
+
cache = 0.0
|
|
460
|
+
try:
|
|
461
|
+
with open(stat_p) as f:
|
|
462
|
+
for line in f:
|
|
463
|
+
k, _, v = line.partition(" ")
|
|
464
|
+
if k == key:
|
|
465
|
+
cache = float(v)
|
|
466
|
+
except (OSError, ValueError):
|
|
467
|
+
pass
|
|
468
|
+
return max(0.0, lim - (used - cache))
|
|
469
|
+
return lim
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _available_bytes() -> float | None:
|
|
473
|
+
avail = _meminfo_available()
|
|
474
|
+
cap = _cgroup_free()
|
|
475
|
+
if avail is None:
|
|
476
|
+
return cap
|
|
477
|
+
return min(avail, cap) if cap is not None else avail
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _meminfo_available() -> float | None:
|
|
481
|
+
try:
|
|
482
|
+
with open("/proc/meminfo") as f:
|
|
483
|
+
for line in f:
|
|
484
|
+
if line.startswith("MemAvailable:"):
|
|
485
|
+
return float(line.split()[1]) * 1024.0
|
|
486
|
+
except OSError:
|
|
487
|
+
pass
|
|
488
|
+
try:
|
|
489
|
+
import psutil # optional
|
|
490
|
+
return float(psutil.virtual_memory().available)
|
|
491
|
+
except Exception: # noqa: BLE001
|
|
492
|
+
pass
|
|
493
|
+
try:
|
|
494
|
+
# macOS has no /proc: count most of physical memory as available
|
|
495
|
+
return 0.6 * os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
|
|
496
|
+
except (ValueError, OSError, AttributeError):
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _workers(n: int, cells: int | None = None) -> int:
|
|
501
|
+
"""Processes for independent outcomes: LOADPATH_WORKERS, else one per
|
|
502
|
+
core, and no more than fit in the memory available when each solves
|
|
503
|
+
`cells` cells (the 4-core, 15 GB cloud box ran out at two GRIP cases at
|
|
504
|
+
once). (This was half the cores, leaving room for the thread per load
|
|
505
|
+
case each solve runs; but Gauss-Seidel holds the GIL, so those threads
|
|
506
|
+
mostly take turns, and with one BLAS thread four arm-link directions
|
|
507
|
+
took 121 s on four workers against 194 s on two.)"""
|
|
508
|
+
env = os.environ.get("LOADPATH_WORKERS")
|
|
509
|
+
if env:
|
|
510
|
+
return max(1, min(n, int(env)))
|
|
511
|
+
w = max(1, min(n, os.cpu_count() or 1))
|
|
512
|
+
avail = _available_bytes() if cells else None
|
|
513
|
+
if avail:
|
|
514
|
+
w = min(w, max(1, int(0.8 * avail // _run_bytes(cells))))
|
|
515
|
+
return w
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _save_partial(o) -> None:
|
|
519
|
+
"""With LOADPATH_PARTIAL_DIR set (`loadpath run` sets it to out/partial),
|
|
520
|
+
write each outcome's STL and summary as the search finds it: four
|
|
521
|
+
brief-eval runs were killed in their last stage and kept nothing of
|
|
522
|
+
half an hour of verified outcomes."""
|
|
523
|
+
d = os.environ.get("LOADPATH_PARTIAL_DIR")
|
|
524
|
+
if not d or o.mesh is None:
|
|
525
|
+
return
|
|
526
|
+
try:
|
|
527
|
+
import json
|
|
528
|
+
|
|
529
|
+
os.makedirs(d, exist_ok=True)
|
|
530
|
+
stem = f"vf{o.volume_fraction:.4f}".replace(".", "p")
|
|
531
|
+
o.mesh.export(os.path.join(d, stem + ".stl"))
|
|
532
|
+
with open(os.path.join(d, "outcomes.jsonl"), "a") as fh:
|
|
533
|
+
fh.write(json.dumps({"stl": stem + ".stl", **o.summary()}, default=str) + "\n")
|
|
534
|
+
except Exception: # noqa: BLE001 (a convenience: never fail the run for it)
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _confirm_cells(st: Study | None = None) -> int:
|
|
539
|
+
"""Cells for the confirming re-analysis: LOADPATH_CONFIRM_CELLS, else
|
|
540
|
+
150k, but no more than fit in the memory free right now. A brief-eval
|
|
541
|
+
agent's search on a shared 15 GB box was OOM-killed at this stage after
|
|
542
|
+
35 minutes, losing every verified outcome; a coarser confirm (never
|
|
543
|
+
under the design grid) is better than none."""
|
|
544
|
+
env = os.environ.get("LOADPATH_CONFIRM_CELLS")
|
|
545
|
+
if env:
|
|
546
|
+
return max(1000, int(env))
|
|
547
|
+
floor = max(st.max_elements if st is not None else 0, 20_000)
|
|
548
|
+
avail = _available_bytes()
|
|
549
|
+
if not avail:
|
|
550
|
+
return _CONFIRM_CELLS
|
|
551
|
+
fit = int((0.6 * avail - _RUN_BASE_BYTES) // _RUN_CELL_BYTES)
|
|
552
|
+
return int(max(floor, min(_CONFIRM_CELLS, fit)))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _solve_alone(study: Study) -> StudyResult:
|
|
556
|
+
return _run_single(study)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _study_cells(st: Study) -> int:
|
|
560
|
+
"""The most cells one run of `st` solves at once."""
|
|
561
|
+
g = st.goal
|
|
562
|
+
search = g.safety_factor is not None or g.max_deflection is not None
|
|
563
|
+
return max(cell_cap(st), _CONFIRM_CELLS if search else 60_000)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _parallel(studies: list, on_progress, should_stop) -> list:
|
|
567
|
+
"""Run independent single-goal studies, in processes when it helps.
|
|
568
|
+
Progress inside a worker is not streamed; each finished outcome is."""
|
|
569
|
+
workers = _workers(len(studies), cells=max(_study_cells(s) for s in studies)) if studies else 1
|
|
570
|
+
if workers <= 1:
|
|
571
|
+
return [_run_single(s, on_progress, should_stop) for s in studies]
|
|
572
|
+
import multiprocessing as mp
|
|
573
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
574
|
+
|
|
575
|
+
results = [None] * len(studies)
|
|
576
|
+
with ProcessPoolExecutor(workers, mp_context=mp.get_context("spawn")) as ex:
|
|
577
|
+
futs = {ex.submit(_solve_alone, s): i for i, s in enumerate(studies)}
|
|
578
|
+
from concurrent.futures import as_completed
|
|
579
|
+
for f in as_completed(futs):
|
|
580
|
+
i = futs[f]
|
|
581
|
+
results[i] = f.result()
|
|
582
|
+
if on_progress is not None:
|
|
583
|
+
for o in results[i].outcomes:
|
|
584
|
+
on_progress({"stage": "outcome", **o.summary()}, None)
|
|
585
|
+
return results
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# resolution.pitchStarts: the extra grids are this much coarser each (coarser
|
|
589
|
+
# so maxElements still holds), and "auto" runs 3 up to this many cells
|
|
590
|
+
PITCH_STEP = 0.01
|
|
591
|
+
PITCH_STARTS_MAX_CELLS = 30_000
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def pitch_starts(study: Study) -> int:
|
|
595
|
+
"""How many grids the study's search runs on (resolution.pitchStarts).
|
|
596
|
+
|
|
597
|
+
One grid's answer is a sample, not the answer: the search's mass swings
|
|
598
|
+
with sub-percent changes of the pitch, because the optimizer settles in
|
|
599
|
+
a different layout and the verified stress, read near supports and
|
|
600
|
+
sharp junctions, differs between layouts. Over six pitches 0-5% apart,
|
|
601
|
+
the 16 coarse-tier safety-factor cases ranged 24% from lightest to
|
|
602
|
+
heaviest (median; 138% at worst, corpus-bracket-4 82-196 g) and 3 of 96
|
|
603
|
+
runs failed. Paired runs of the coarse tier 3% apart in pitch, one grid
|
|
604
|
+
against three (the lightest passing of grids 1% apart): massAtTarget
|
|
605
|
+
spread mean 17% -> 10%, worst 80% -> 46%, delivered mass 8% lower, no
|
|
606
|
+
lost passes, 2.7x the time; the median spread did not move
|
|
607
|
+
(bench/results/pitch_starts.json, docs/BENCHMARKS.md)."""
|
|
608
|
+
v = (study.raw.get("resolution") or {}).get("pitchStarts", "auto")
|
|
609
|
+
if v == "auto" and os.environ.get("LOADPATH_PITCH_STARTS"):
|
|
610
|
+
v = int(os.environ["LOADPATH_PITCH_STARTS"]) # the default, for a whole run (evals)
|
|
611
|
+
if v != "auto":
|
|
612
|
+
return int(v)
|
|
613
|
+
g = study.goal
|
|
614
|
+
if g.safety_factor is None and g.max_deflection is None and not _durability_drives(study):
|
|
615
|
+
return 1
|
|
616
|
+
if study.pitch:
|
|
617
|
+
return 1
|
|
618
|
+
return 3 if cell_cap(study) <= PITCH_STARTS_MAX_CELLS else 1
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _run_pitches(study: Study, starts: int, on_progress, should_stop) -> StudyResult:
|
|
622
|
+
"""The search on `starts` grids, PITCH_STEP coarser each; the result of
|
|
623
|
+
the grid whose lightest part passes (or of the first grid when none
|
|
624
|
+
does), with every grid's pick in result.starts."""
|
|
625
|
+
import dataclasses
|
|
626
|
+
|
|
627
|
+
p0 = float(study.pitch) if study.pitch else build_domain(study).pitch
|
|
628
|
+
studies = [dataclasses.replace(study, pitch=p0 * (1.0 + PITCH_STEP * k)) for k in range(starts)]
|
|
629
|
+
t0 = time.time()
|
|
630
|
+
results = _parallel(studies, on_progress, should_stop)
|
|
631
|
+
rows = []
|
|
632
|
+
for st, r in zip(studies, results):
|
|
633
|
+
o = r.outcomes[r.best] if r.best is not None else None
|
|
634
|
+
rows.append({"pitch": st.pitch, "mass": o.report["mass"] if o is not None else None,
|
|
635
|
+
"passes": bool(o is not None and o.passes), "outcomes": len(r.outcomes)})
|
|
636
|
+
passing = [i for i, x in enumerate(rows) if x["passes"]]
|
|
637
|
+
k = min(passing, key=lambda i: rows[i]["mass"]) if passing else 0
|
|
638
|
+
r = results[k]
|
|
639
|
+
rows[k]["picked"] = True
|
|
640
|
+
r.starts = rows
|
|
641
|
+
r.study = study
|
|
642
|
+
r.seconds = time.time() - t0
|
|
643
|
+
return r
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def run_study(study: Study, on_progress=None, should_stop=None) -> StudyResult:
|
|
647
|
+
"""on_progress(event: dict, xphys | None) is called with
|
|
648
|
+
{"stage": "domain"|"optimize"|"mesh"|"verify"|"outcome", ...}.
|
|
649
|
+
|
|
650
|
+
With several materials, the goal is solved for each (a stiffer material
|
|
651
|
+
meets a deflection limit with less of it, a denser one weighs more for
|
|
652
|
+
the same shape) and the outcomes are pooled; `best` is the lightest that
|
|
653
|
+
passes, or the lightest overall when the goal has no pass/fail."""
|
|
654
|
+
import dataclasses
|
|
655
|
+
|
|
656
|
+
if study.process is not None and study.process.build == "auto":
|
|
657
|
+
# opt-in: compare build directions on a coarse grid and keep the best
|
|
658
|
+
from .orient import choose_build_direction
|
|
659
|
+
|
|
660
|
+
study = choose_build_direction(study, on_progress=on_progress)["study"]
|
|
661
|
+
mats = study.materials or [study.material]
|
|
662
|
+
if len(mats) == 1:
|
|
663
|
+
if study.goal.method == "auto":
|
|
664
|
+
# the search (with its restarts and pitch starts), then perhaps
|
|
665
|
+
# the constrained method once
|
|
666
|
+
r = _run_auto(study, on_progress, should_stop,
|
|
667
|
+
search=lambda st: _run_restarts(st, on_progress, should_stop))
|
|
668
|
+
else:
|
|
669
|
+
r = _run_restarts(study, on_progress, should_stop)
|
|
670
|
+
return _build_sim(_slice(_process_checks(r, on_progress), on_progress), on_progress)
|
|
671
|
+
t0 = time.time()
|
|
672
|
+
outcomes, warnings, domain = [], [], None
|
|
673
|
+
for r in _parallel([dataclasses.replace(study, material=m, materials=[m]) for m in mats],
|
|
674
|
+
on_progress, should_stop):
|
|
675
|
+
outcomes.extend(r.outcomes)
|
|
676
|
+
warnings.extend(w for w in r.warnings if w not in warnings)
|
|
677
|
+
domain = domain or r.domain
|
|
678
|
+
passing = [i for i, o in enumerate(outcomes) if o.passes]
|
|
679
|
+
pool = passing if passing else ([] if any(o.passes is False for o in outcomes) else range(len(outcomes)))
|
|
680
|
+
best = min(pool, key=lambda i: outcomes[i].report["mass"]) if pool else None
|
|
681
|
+
return _build_sim(_slice(_process_checks(StudyResult(study=study, domain=domain, outcomes=outcomes, best=best,
|
|
682
|
+
seconds=time.time() - t0, warnings=warnings), on_progress),
|
|
683
|
+
on_progress), on_progress)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# goal.restarts: the k-th search starts at the goal's volumeFraction times
|
|
687
|
+
# RESTART_STARTS[k] (the first is the study as written; then below, above,
|
|
688
|
+
# further below, further above)
|
|
689
|
+
RESTART_STARTS = (1.0, 0.8, 1.25, 0.65, 1.5, 0.55, 1.75, 0.45)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _run_restarts(study: Study, on_progress=None, should_stop=None) -> StudyResult:
|
|
693
|
+
"""goal.restarts searches from different starting fractions, pooled: the
|
|
694
|
+
outcomes of all, `best` the lightest of the searches' own (confirmed)
|
|
695
|
+
picks. Each search's other outcomes keep their in-loop readings only, so
|
|
696
|
+
they are not candidates."""
|
|
697
|
+
import dataclasses
|
|
698
|
+
|
|
699
|
+
def search(st):
|
|
700
|
+
starts = pitch_starts(st)
|
|
701
|
+
return (_run_single(st, on_progress, should_stop) if starts <= 1 else
|
|
702
|
+
_run_pitches(st, starts, on_progress, should_stop))
|
|
703
|
+
|
|
704
|
+
g = study.goal
|
|
705
|
+
n = min(g.restarts, len(RESTART_STARTS))
|
|
706
|
+
if n <= 1 or (g.safety_factor is None and g.max_deflection is None):
|
|
707
|
+
return search(study)
|
|
708
|
+
t0 = time.time()
|
|
709
|
+
vf0 = float(g.volume_fraction or 0.3)
|
|
710
|
+
outcomes, picks, warnings, domain = [], [], [], None
|
|
711
|
+
for k in range(n):
|
|
712
|
+
if k and should_stop is not None and should_stop():
|
|
713
|
+
break
|
|
714
|
+
vf = float(np.clip(vf0 * RESTART_STARTS[k], _VF_MIN, _VF_MAX))
|
|
715
|
+
if on_progress is not None:
|
|
716
|
+
on_progress({"stage": "restart", "run": k + 1, "of": n, "volumeFraction": vf}, None)
|
|
717
|
+
st = dataclasses.replace(study, goal=dataclasses.replace(g, volume_fraction=vf))
|
|
718
|
+
r = search(st)
|
|
719
|
+
for o in r.outcomes:
|
|
720
|
+
o.report["restart"] = k + 1
|
|
721
|
+
if r.best is not None:
|
|
722
|
+
picks.append(len(outcomes) + r.best)
|
|
723
|
+
outcomes.extend(r.outcomes)
|
|
724
|
+
warnings.extend(w for w in r.warnings if w not in warnings)
|
|
725
|
+
domain = domain or r.domain
|
|
726
|
+
ok = [i for i in picks if outcomes[i].passes]
|
|
727
|
+
pool = ok or picks
|
|
728
|
+
best = min(pool, key=lambda i: outcomes[i].report["mass"]) if pool else None
|
|
729
|
+
if domain is not None:
|
|
730
|
+
domain = dict(domain, restarts=[{"run": outcomes[i].report.get("restart"),
|
|
731
|
+
"volumeFraction": outcomes[i].volume_fraction,
|
|
732
|
+
"mass": outcomes[i].report.get("mass"), "passes": outcomes[i].passes}
|
|
733
|
+
for i in picks])
|
|
734
|
+
return StudyResult(study=study, domain=domain, outcomes=outcomes, best=best,
|
|
735
|
+
seconds=time.time() - t0, warnings=warnings)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _build_sim(result: StudyResult, on_progress=None) -> StudyResult:
|
|
739
|
+
"""buildSim: simulate printing the delivered outcome (buildsim.py) into
|
|
740
|
+
its report as "buildSim" (distortion, residual stress, recoater lift,
|
|
741
|
+
bed peel), with its warnings; with compensate, also a pre-distorted
|
|
742
|
+
mesh on the outcome (`compensated`, written as <name>-compensated.stl)."""
|
|
743
|
+
bs = result.study.build_sim
|
|
744
|
+
if bs is None:
|
|
745
|
+
return result
|
|
746
|
+
if result.best is None:
|
|
747
|
+
if bs.get("printSetup"):
|
|
748
|
+
return _print_setup_rescue(result, on_progress)
|
|
749
|
+
return result
|
|
750
|
+
o = result.outcomes[result.best]
|
|
751
|
+
if getattr(o, "mesh", None) is None:
|
|
752
|
+
return result
|
|
753
|
+
import dataclasses as _dc
|
|
754
|
+
from .buildsim import compensate_mesh, simulate_part
|
|
755
|
+
|
|
756
|
+
st = result.study
|
|
757
|
+
mat = (o.report.get("material") or {}).get("name") or bs["setup"].material
|
|
758
|
+
setup = _dc.replace(bs["setup"], material=mat)
|
|
759
|
+
build = st.process.build if st.process is not None and st.process.build not in (None, "auto") else "+z"
|
|
760
|
+
if st.print_direction is not None:
|
|
761
|
+
build = st.print_direction
|
|
762
|
+
if on_progress is not None:
|
|
763
|
+
on_progress({"stage": "buildSim", "process": setup.process}, None)
|
|
764
|
+
try:
|
|
765
|
+
if bs["compensate"]:
|
|
766
|
+
comp, crep = compensate_mesh(o.mesh, setup, build=build, iterations=bs["iterations"], cells=bs["cells"])
|
|
767
|
+
rep = simulate_part(o.mesh, setup, pitch=crep["pitch"], build=build).report
|
|
768
|
+
rep["compensation"] = crep
|
|
769
|
+
o.compensated = comp
|
|
770
|
+
else:
|
|
771
|
+
rep = simulate_part(o.mesh, setup, build=build, cells=bs["cells"]).report
|
|
772
|
+
except Exception as exc: # noqa: BLE001 - a failed simulation leaves the part's report standing
|
|
773
|
+
rep = {"process": setup.process, "error": f"{type(exc).__name__}: {exc}", "warnings": []}
|
|
774
|
+
o.report["buildSim"] = rep
|
|
775
|
+
o.report.setdefault("warnings", []).extend(rep.get("warnings", []))
|
|
776
|
+
if bs.get("printSetup"):
|
|
777
|
+
# FDM: rank orientation, material, bed temperature and brim for this part
|
|
778
|
+
from .printsetup import search_print_setup
|
|
779
|
+
if on_progress is not None:
|
|
780
|
+
on_progress({"stage": "printSetup"}, None)
|
|
781
|
+
try:
|
|
782
|
+
ps = search_print_setup(st, o.mesh, cells=min(bs["cells"], 4000), **bs["printSetup"])
|
|
783
|
+
o.report["printSetup"] = ps
|
|
784
|
+
except Exception as exc: # noqa: BLE001 - the search is advice; the part's report stands
|
|
785
|
+
o.report["printSetup"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
786
|
+
return result
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _build_label(study):
|
|
790
|
+
from .printsetup import _build_name
|
|
791
|
+
return _build_name(study)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _print_setup_rescue(result: StudyResult, on_progress=None) -> StudyResult:
|
|
795
|
+
"""No outcome passed (an FDM warp limit the shape cannot meet): run the
|
|
796
|
+
print setup search on the lightest outcome that meets the rest of the
|
|
797
|
+
goal, so the report says which orientation, material, bed temperature
|
|
798
|
+
and brim would make it pass."""
|
|
799
|
+
from .printsetup import search_print_setup
|
|
800
|
+
st, bs = result.study, result.study.build_sim
|
|
801
|
+
sf = st.goal.safety_factor
|
|
802
|
+
|
|
803
|
+
def ok(o):
|
|
804
|
+
return sf is None or (o.report.get("safetyFactor") or 0.0) >= sf
|
|
805
|
+
|
|
806
|
+
pool = [o for o in result.outcomes if getattr(o, "mesh", None) is not None and ok(o)]
|
|
807
|
+
if not pool:
|
|
808
|
+
return result
|
|
809
|
+
o = min(pool, key=lambda q: q.report.get("mass", float("inf")))
|
|
810
|
+
if on_progress is not None:
|
|
811
|
+
on_progress({"stage": "printSetup"}, None)
|
|
812
|
+
try:
|
|
813
|
+
ps = search_print_setup(st, o.mesh, cells=min(bs["cells"], 4000), **bs["printSetup"])
|
|
814
|
+
except Exception as exc: # noqa: BLE001
|
|
815
|
+
ps = {"error": f"{type(exc).__name__}: {exc}"}
|
|
816
|
+
o.report["printSetup"] = ps
|
|
817
|
+
b = ps.get("best")
|
|
818
|
+
re = ps.get("rerun")
|
|
819
|
+
if not (b and b.get("passes")) and re and bs["printSetup"].get("reoptimize", True):
|
|
820
|
+
# no setup makes this shape pass: re-optimize for the setup with
|
|
821
|
+
# room under the warp limit and the most strength per gram
|
|
822
|
+
from .printsetup import study_for
|
|
823
|
+
if on_progress is not None:
|
|
824
|
+
on_progress({"stage": "printSetup", "rerun": {k: re[k] for k in ("material", "build", "bed", "brim")}},
|
|
825
|
+
None)
|
|
826
|
+
again = run_study(study_for(st, re), on_progress)
|
|
827
|
+
again.warnings.insert(0, f"no outcome met the warp limit as {st.material.name}, build "
|
|
828
|
+
f"{_build_label(st)}; re-optimized for {re['material']}, build {re['build']}, "
|
|
829
|
+
f"bed {re['bed']:g} C" + (f", {re['brim']:g} mm brim" if re["brim"] else "")
|
|
830
|
+
+ " (report printSetup has the search)")
|
|
831
|
+
if again.best is not None:
|
|
832
|
+
again.outcomes[again.best].report["printSetup"] = {**ps, "reoptimizedFor": re}
|
|
833
|
+
return again
|
|
834
|
+
if b and b.get("passes"):
|
|
835
|
+
result.warnings.append(
|
|
836
|
+
f"no outcome met the warp limit as set up; the {o.volume_fraction:.0%} outcome does printed as "
|
|
837
|
+
f"{b['material']}, build {b['build']}, bed {b['bed']:g} C"
|
|
838
|
+
+ (f", {b['brim']:g} mm brim" if b["brim"] else "") + f" ({b['mass']:.1f} g, lifts {b['lift']:.2f} mm, "
|
|
839
|
+
f"SF {b['safetyFactor']:.2f}): set that in the study and run it again (report printSetup)")
|
|
840
|
+
return result
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _process_checks(result: StudyResult, on_progress=None) -> StudyResult:
|
|
844
|
+
"""Powder-bed polymer and resin parts: the process checks
|
|
845
|
+
(processcheck.check_process) on the delivered outcome, into its report
|
|
846
|
+
as "process", with its warnings added to the outcome's."""
|
|
847
|
+
proc = result.study.process
|
|
848
|
+
if proc is None or result.best is None:
|
|
849
|
+
return result
|
|
850
|
+
from .processcheck import RULES, check_process
|
|
851
|
+
|
|
852
|
+
if proc.kind not in RULES:
|
|
853
|
+
return result
|
|
854
|
+
o = result.outcomes[result.best]
|
|
855
|
+
if getattr(o, "mesh", None) is None:
|
|
856
|
+
return result
|
|
857
|
+
if on_progress is not None:
|
|
858
|
+
on_progress({"stage": "process", "process": proc.kind}, None)
|
|
859
|
+
try:
|
|
860
|
+
mat = _material_named((o.report.get("material") or {}).get("name"), result.study)
|
|
861
|
+
rep = check_process(o.mesh, proc, material=mat)
|
|
862
|
+
except Exception as exc: # noqa: BLE001 - a failed check leaves the part's report standing
|
|
863
|
+
rep = {"process": proc.kind, "error": f"{type(exc).__name__}: {exc}", "ok": None, "warnings": []}
|
|
864
|
+
o.report["process"] = rep
|
|
865
|
+
o.report.setdefault("warnings", []).extend(rep.get("warnings", []))
|
|
866
|
+
return result
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# goal.minimize -> the slicer report's number it minimizes
|
|
870
|
+
_SLICED_COST = {"printTime": "printTime", "filament": "filamentVolume"}
|
|
871
|
+
# graded infill is sized for at most this many passing outcomes when the
|
|
872
|
+
# goal picks by print time (each is a few FEA solves)
|
|
873
|
+
_GRADE_CANDIDATES = 3
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _slice(result: StudyResult, on_progress=None) -> StudyResult:
|
|
877
|
+
"""FDM: slice every outcome (slicer.slice_mesh) for print time, filament
|
|
878
|
+
and support, into its report as "slicer"; size graded infill
|
|
879
|
+
(infill.py) when `slicer.grade` asks; and with goal.minimize printTime or
|
|
880
|
+
filament, deliver the passing outcome that is cheapest by it rather than
|
|
881
|
+
the lightest."""
|
|
882
|
+
study = result.study
|
|
883
|
+
opts = study.slicer
|
|
884
|
+
if opts is None or opts.enabled is False or not result.outcomes:
|
|
885
|
+
return result
|
|
886
|
+
from .slicer import find_slicer, slice_mesh
|
|
887
|
+
|
|
888
|
+
goal = study.goal
|
|
889
|
+
if find_slicer(opts.executable) is None:
|
|
890
|
+
if opts.enabled is True or goal.minimize != "mass" or opts.grade is not None:
|
|
891
|
+
result.warnings.append("no slicer found, so print time and filament were not measured (and the "
|
|
892
|
+
"outcome is the lightest): install PrusaSlicer, or set LOADPATH_SLICER")
|
|
893
|
+
return result
|
|
894
|
+
build = study.process.build if study.process.build != "auto" else None
|
|
895
|
+
for i, o in enumerate(result.outcomes):
|
|
896
|
+
if getattr(o, "mesh", None) is None:
|
|
897
|
+
continue
|
|
898
|
+
if on_progress is not None:
|
|
899
|
+
on_progress({"stage": "slice", "volumeFraction": o.volume_fraction}, None)
|
|
900
|
+
mat = _material_named((o.report.get("material") or {}).get("name"), study)
|
|
901
|
+
try:
|
|
902
|
+
o.report["slicer"] = slice_mesh(o.mesh, build, mat, opts, name=f"{study.name}-{i}")
|
|
903
|
+
except Exception as exc: # noqa: BLE001 - a failed slice leaves the part's report standing
|
|
904
|
+
o.report["slicer"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
905
|
+
cost = _SLICED_COST.get(goal.minimize)
|
|
906
|
+
pool = [i for i, o in enumerate(result.outcomes) if o.passes] if any(o.passes is not None
|
|
907
|
+
for o in result.outcomes) else []
|
|
908
|
+
if opts.grade is not None:
|
|
909
|
+
if cost is not None and pool:
|
|
910
|
+
light = sorted(pool, key=lambda i: result.outcomes[i].report["mass"])[:_GRADE_CANDIDATES]
|
|
911
|
+
todo = sorted(set(light) | ({result.best} if result.best is not None else set()))
|
|
912
|
+
else:
|
|
913
|
+
todo = [result.best] if result.best is not None else []
|
|
914
|
+
for i in todo:
|
|
915
|
+
_grade(result, i, opts, on_progress)
|
|
916
|
+
if cost is not None and pool:
|
|
917
|
+
def sliced(i):
|
|
918
|
+
# the cheaper of its solid and graded prints: both are checked,
|
|
919
|
+
# and a thin part can print slower graded (region boundaries)
|
|
920
|
+
r = result.outcomes[i].report
|
|
921
|
+
vals = [(r.get(k) or {}).get(cost) for k in ("slicer",)] + \
|
|
922
|
+
[((r.get("graded") or {}).get("slicer") or {}).get(cost)]
|
|
923
|
+
vals = [v for v in vals if v is not None]
|
|
924
|
+
return min(vals) if vals else float("inf")
|
|
925
|
+
pick = min(pool, key=lambda i: (sliced(i), result.outcomes[i].report["mass"]))
|
|
926
|
+
if sliced(pick) < float("inf") and pick != result.best:
|
|
927
|
+
prev = result.best
|
|
928
|
+
result.best = pick
|
|
929
|
+
if opts.grade is not None and result.outcomes[pick].grading is None:
|
|
930
|
+
_grade(result, pick, opts, on_progress)
|
|
931
|
+
result.warnings.append(
|
|
932
|
+
f"goal.minimize {goal.minimize}: delivered outcome {pick} (vf {result.outcomes[pick].volume_fraction:.3f}, "
|
|
933
|
+
f"{result.outcomes[pick].report['mass']:.1f} g) over the lightest passing, outcome {prev}")
|
|
934
|
+
return result
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _grade(result: StudyResult, i: int, opts, on_progress=None) -> None:
|
|
938
|
+
"""Graded infill for outcome i: report["graded"] (sizing, and its slice
|
|
939
|
+
next to the solid one) and o.grading (the modifier meshes)."""
|
|
940
|
+
from .infill import GradeOptions, grade_infill
|
|
941
|
+
from .slicer import slice_mesh
|
|
942
|
+
|
|
943
|
+
study = result.study
|
|
944
|
+
o = result.outcomes[i]
|
|
945
|
+
mat = _material_named((o.report.get("material") or {}).get("name"), study)
|
|
946
|
+
import dataclasses
|
|
947
|
+
st = dataclasses.replace(study, material=mat, materials=[mat])
|
|
948
|
+
if on_progress is not None:
|
|
949
|
+
on_progress({"stage": "grade", "volumeFraction": o.volume_fraction}, None)
|
|
950
|
+
try:
|
|
951
|
+
g = grade_infill(st, o.mesh, o.report, GradeOptions.from_spec(opts.grade, study.goal.safety_factor))
|
|
952
|
+
except Exception as exc: # noqa: BLE001
|
|
953
|
+
o.report["graded"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
954
|
+
return
|
|
955
|
+
o.grading = g
|
|
956
|
+
rep = {k: v for k, v in g.items() if k not in ("modifiers", "history")}
|
|
957
|
+
rep["modifiers"] = [{"name": m["name"], "settings": m["settings"], "faces": int(len(m["mesh"].faces))}
|
|
958
|
+
for m in g["modifiers"]]
|
|
959
|
+
build = study.process.build if study.process.build != "auto" else None
|
|
960
|
+
try:
|
|
961
|
+
rep["slicer"] = slice_mesh(o.mesh, build, mat, opts, name=f"{study.name}-{i}-graded",
|
|
962
|
+
object_settings=g["objectSettings"], modifiers=g["modifiers"])
|
|
963
|
+
solid = o.report.get("slicer") or {}
|
|
964
|
+
if solid.get("printTime") and rep["slicer"].get("printTime"):
|
|
965
|
+
rep["printTimeSaved"] = 1.0 - rep["slicer"]["printTime"] / solid["printTime"]
|
|
966
|
+
rep["filamentSaved"] = 1.0 - rep["slicer"]["filamentVolume"] / solid["filamentVolume"]
|
|
967
|
+
except Exception as exc: # noqa: BLE001
|
|
968
|
+
rep["slicer"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
969
|
+
goal = study.goal.safety_factor
|
|
970
|
+
if goal and g["safetyFactor"] < goal:
|
|
971
|
+
o.report.setdefault("warnings", []).append(
|
|
972
|
+
f"graded infill reads SF {g['safetyFactor']:.2f} under the goal's {goal:g}: print the solid 3MF")
|
|
973
|
+
o.report["graded"] = rep
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def mesh_fidelity(domain, xphys, mesh) -> dict | None:
|
|
978
|
+
"""How much of the optimized field the delivered mesh kept: the share of
|
|
979
|
+
solid cells (xphys > 0.5) whose centre lies outside the mesh, overall, on
|
|
980
|
+
the surface of the solid, and in thin members (gone under a one-cell
|
|
981
|
+
opening). The camera boom (2026-11-01) lost 21% of its solid cells: 44%
|
|
982
|
+
of its thin members and 34% of its surface cells, against 8% inside.
|
|
983
|
+
Meshed, it read 16x softer than the optimizer's own model on the same
|
|
984
|
+
grid and supports, so the run reported SF 0.99 for a field that carries
|
|
985
|
+
the load with room to spare."""
|
|
986
|
+
from scipy import ndimage
|
|
987
|
+
|
|
988
|
+
from .voxels import mesh_solid
|
|
989
|
+
|
|
990
|
+
solid = np.asarray(xphys) > 0.5
|
|
991
|
+
if not solid.any() or mesh is None or len(getattr(mesh, "faces", ())) == 0:
|
|
992
|
+
return None
|
|
993
|
+
step = domain.pitch / 2.0
|
|
994
|
+
lo = np.asarray(domain.origin, float)
|
|
995
|
+
hi = lo + np.asarray(domain.dims) * domain.pitch
|
|
996
|
+
try:
|
|
997
|
+
g, org, *_ = mesh_solid(mesh, step, pad=2, bounds=(lo, hi))
|
|
998
|
+
except (MemoryError, ValueError):
|
|
999
|
+
return None
|
|
1000
|
+
c = domain.mesh.elem_centers()
|
|
1001
|
+
ijk = np.clip(np.floor((c - np.asarray(org)) / step).astype(int), 0, np.array(g.shape) - 1)
|
|
1002
|
+
inside = g[tuple(ijk.T)]
|
|
1003
|
+
at = tuple(domain.mesh.elem_ijk.T)
|
|
1004
|
+
grid = np.zeros(domain.dims, bool)
|
|
1005
|
+
grid[at] = solid
|
|
1006
|
+
cross = ndimage.generate_binary_structure(3, 1)
|
|
1007
|
+
thin = (grid & ~ndimage.binary_opening(grid, structure=cross))[at]
|
|
1008
|
+
surface = (grid & ~ndimage.binary_erosion(grid, structure=cross))[at] & ~thin
|
|
1009
|
+
lost = solid & ~inside
|
|
1010
|
+
|
|
1011
|
+
def share(m):
|
|
1012
|
+
return float((lost & m).sum() / max(int(m.sum()), 1))
|
|
1013
|
+
|
|
1014
|
+
return {"lostSolidCells": share(solid), "lostThinCells": share(thin), "lostSurfaceCells": share(surface),
|
|
1015
|
+
"thinCells": float(thin.sum() / solid.sum())}
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
MESH_LOSS_WARN = 0.10
|
|
1019
|
+
|
|
1020
|
+
def _material_named(name, study):
|
|
1021
|
+
"""The study's Material called `name` (a study can try several)."""
|
|
1022
|
+
for m in study.materials or [study.material]:
|
|
1023
|
+
if m.name == name:
|
|
1024
|
+
return m
|
|
1025
|
+
return study.material
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _constrained_applies(study: Study) -> bool:
|
|
1029
|
+
goal = study.goal
|
|
1030
|
+
return (goal.safety_factor is not None or goal.max_deflection is not None) \
|
|
1031
|
+
and study.gravity is None and not _durability_drives(study) \
|
|
1032
|
+
and study.access is None and study.max_member is None \
|
|
1033
|
+
and not any(ld.direction for c in study.load_cases for ld in c.loads)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _run_auto(study: Study, on_progress=None, should_stop=None, search=None) -> StudyResult:
|
|
1037
|
+
"""goal.method auto: the search, then the constrained method only when the
|
|
1038
|
+
search's part idles (stressUtilization under _AUTO_UTILIZATION), keeping
|
|
1039
|
+
the lighter passing part. The constrained method costs about as much as
|
|
1040
|
+
the search again, so it runs only where it tends to win."""
|
|
1041
|
+
import dataclasses
|
|
1042
|
+
|
|
1043
|
+
goal = study.goal
|
|
1044
|
+
st = dataclasses.replace(study, goal=dataclasses.replace(goal, method="search"))
|
|
1045
|
+
# `search` runs the search side (run_study: restarts and pitch starts)
|
|
1046
|
+
r = search(st) if search is not None else _run_single(st, on_progress, should_stop)
|
|
1047
|
+
choice = {"method": "search"}
|
|
1048
|
+
pick = r.outcomes[r.best] if r.best is not None else None
|
|
1049
|
+
u = pick.report.get("stressUtilization") if pick is not None else None
|
|
1050
|
+
if u is not None:
|
|
1051
|
+
choice["stressUtilization"] = u
|
|
1052
|
+
if pick is None or not pick.passes or u is None or not _constrained_applies(study):
|
|
1053
|
+
choice["reason"] = "the constrained method does not apply" if pick is not None and pick.passes \
|
|
1054
|
+
else "no passing search part to compare"
|
|
1055
|
+
r.choice = choice
|
|
1056
|
+
return r
|
|
1057
|
+
rep = pick.report
|
|
1058
|
+
sf = rep.get("safetyFactor")
|
|
1059
|
+
if goal.max_deflection and rep.get("cases") \
|
|
1060
|
+
and max(c.get("deflection") or 0.0 for c in rep["cases"]) >= 0.85 * goal.max_deflection \
|
|
1061
|
+
and (goal.safety_factor is None or (sf is not None and sf >= 1.3 * goal.safety_factor)):
|
|
1062
|
+
# stiffness sets the mass (the arm link, the GRIP upper arm at SF
|
|
1063
|
+
# 11 for 2): the compliance search is already the right design
|
|
1064
|
+
choice["reason"] = "deflection-bound: the search's compliance design is the right one"
|
|
1065
|
+
r.choice = choice
|
|
1066
|
+
return r
|
|
1067
|
+
from .verify import use_cut_cells
|
|
1068
|
+
limit = _AUTO_UTILIZATION_CUT if use_cut_cells(study) else _AUTO_UTILIZATION
|
|
1069
|
+
if u >= limit:
|
|
1070
|
+
choice["reason"] = f"stress utilization {u:.2f} >= {limit}: the search part is worked evenly"
|
|
1071
|
+
r.choice = choice
|
|
1072
|
+
return r
|
|
1073
|
+
if should_stop is not None and should_stop():
|
|
1074
|
+
r.choice = choice
|
|
1075
|
+
return r
|
|
1076
|
+
if on_progress is not None:
|
|
1077
|
+
on_progress({"stage": "method", "method": "constrained", "stressUtilization": u}, None)
|
|
1078
|
+
# time box: the constrained method runs on a finer grid and took about
|
|
1079
|
+
# 2.9x the search on the yoke (2.5x cut it before its 16 g run); on the
|
|
1080
|
+
# wrist link it found no passing part in 90 minutes (the search: 24).
|
|
1081
|
+
# Past the budget the runs it has stand.
|
|
1082
|
+
# The buckling constraint's eigensolve roughly doubles a constrained
|
|
1083
|
+
# iteration on a full-size grid (10-25 s of 25-50 at 35k cells on the
|
|
1084
|
+
# wall bracket), so it gets twice the time.
|
|
1085
|
+
budget = _AUTO_BUDGET * (2.0 if goal.buckling and goal.safety_factor and _BUCKLING_IN_OPTIMIZER else 1.0)
|
|
1086
|
+
deadline = time.time() + max(600.0, budget * r.seconds)
|
|
1087
|
+
|
|
1088
|
+
def stop():
|
|
1089
|
+
return time.time() > deadline or (should_stop is not None and should_stop())
|
|
1090
|
+
|
|
1091
|
+
try:
|
|
1092
|
+
rc = _run_single(dataclasses.replace(study, goal=dataclasses.replace(goal, method="constrained")),
|
|
1093
|
+
on_progress, stop, fallback=False)
|
|
1094
|
+
except Exception as exc: # noqa: BLE001 - the constrained run is a bonus: any failure keeps the search's part
|
|
1095
|
+
choice["reason"] = f"the constrained method failed ({exc})"
|
|
1096
|
+
r.choice = choice
|
|
1097
|
+
return r
|
|
1098
|
+
ms, mc = pick.report["mass"], None
|
|
1099
|
+
if rc.best is not None and rc.outcomes[rc.best].passes:
|
|
1100
|
+
mc = rc.outcomes[rc.best].report["mass"]
|
|
1101
|
+
choice.update({"searchMass": ms, "constrainedMass": mc})
|
|
1102
|
+
if mc is not None and mc < ms:
|
|
1103
|
+
choice["method"] = "constrained"
|
|
1104
|
+
choice["reason"] = (f"stress utilization {u:.2f}: constrained {mc:.1f} g against "
|
|
1105
|
+
f"search {ms:.1f} g")
|
|
1106
|
+
rc.choice = choice
|
|
1107
|
+
rc.seconds += r.seconds
|
|
1108
|
+
return rc
|
|
1109
|
+
choice["reason"] = (f"stress utilization {u:.2f}: constrained "
|
|
1110
|
+
+ (f"{mc:.1f} g" if mc is not None else "found no passing part")
|
|
1111
|
+
+ f" against search {ms:.1f} g")
|
|
1112
|
+
r.choice = choice
|
|
1113
|
+
r.seconds += rc.seconds
|
|
1114
|
+
return r
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _run_single(study: Study, on_progress=None, should_stop=None, fallback: bool = True) -> StudyResult:
|
|
1118
|
+
if study.goal.method == "auto":
|
|
1119
|
+
return _run_auto(study, on_progress, should_stop)
|
|
1120
|
+
t0 = time.time()
|
|
1121
|
+
|
|
1122
|
+
def emit(event, field_=None):
|
|
1123
|
+
if on_progress is not None:
|
|
1124
|
+
on_progress(event, field_)
|
|
1125
|
+
|
|
1126
|
+
domain = build_domain(study)
|
|
1127
|
+
radius = filter_radius(domain, study.length_scale, study.min_hole)
|
|
1128
|
+
info = _domain_info(domain, radius)
|
|
1129
|
+
emit({"stage": "domain", **info, "warnings": domain.warnings})
|
|
1130
|
+
outcomes: list[Outcome] = []
|
|
1131
|
+
started = False
|
|
1132
|
+
# what earlier runs on this domain found (recall.py), when turned on
|
|
1133
|
+
from .recall import Recall
|
|
1134
|
+
memory = Recall.open(study, domain, radius, _making(study))
|
|
1135
|
+
# FDM warp limits are checked on each outcome's delivered part,
|
|
1136
|
+
# re-simulated (the optimizer's coarse grid reads FDM warp 2-3.5x low,
|
|
1137
|
+
# and the ratio moves from design to design). The optimizer carries
|
|
1138
|
+
# them only with buildSim.warpInOptimizer; then its limits for the next
|
|
1139
|
+
# attempt are scaled by how far it read off on the last one. Metal
|
|
1140
|
+
# limits stay in the optimizer and are checked on its full grid.
|
|
1141
|
+
ws = domain.warp
|
|
1142
|
+
bsim = study.build_sim or {}
|
|
1143
|
+
limits0 = (bsim.get("maxDistortion"), bsim.get("maxRecoaterLift")) \
|
|
1144
|
+
if bsim and (bsim.get("maxDistortion") or bsim.get("maxRecoaterLift")) \
|
|
1145
|
+
and bsim["setup"].process.lower() == "fdm" else None
|
|
1146
|
+
|
|
1147
|
+
def _check_warp(warp, mesh):
|
|
1148
|
+
warp["userLimits"] = {"maxDistortion": limits0[0], "maxRecoaterLift": limits0[1]}
|
|
1149
|
+
d = _warp_delivered(study, mesh)
|
|
1150
|
+
warp["delivered"] = d
|
|
1151
|
+
if not d or "error" in d or ws is None:
|
|
1152
|
+
return
|
|
1153
|
+
for key, attr, lim in (("maxDistortion", "max_distortion", limits0[0]),
|
|
1154
|
+
("recoaterLift", "max_lift", limits0[1])):
|
|
1155
|
+
if lim and d.get(key) and warp.get(key):
|
|
1156
|
+
ratio = d[key] / warp[key]
|
|
1157
|
+
setattr(ws, attr, lim / ratio)
|
|
1158
|
+
warp.setdefault("deliveredOverOptimizer", {})[key] = round(ratio, 3)
|
|
1159
|
+
|
|
1160
|
+
goal_ = study.goal
|
|
1161
|
+
buck_state = {"on": False, "kb": 1.0,
|
|
1162
|
+
"sf": goal_.safety_factor if (goal_.buckling and goal_.safety_factor and _BUCKLING_SEARCH
|
|
1163
|
+
and study.gravity is None and study.thermal is None) else None}
|
|
1164
|
+
|
|
1165
|
+
def attempt(vf: float, x0, beta0, replay=None) -> Outcome:
|
|
1166
|
+
def on_iter(rec, xp):
|
|
1167
|
+
emit({"stage": "optimize", "volumeFraction": vf, **rec}, xp)
|
|
1168
|
+
|
|
1169
|
+
if replay is None:
|
|
1170
|
+
breq = buck_state["sf"] * buck_state["kb"] if buck_state["on"] else None
|
|
1171
|
+
res = optimize(domain, study.elastic(design=True),
|
|
1172
|
+
SimpOptions(volume_fraction=vf, filter_radius=radius,
|
|
1173
|
+
max_iterations=study.max_iterations, x0=x0, beta_start=beta0,
|
|
1174
|
+
buckling=breq, buckling_modulus=study.material.E,
|
|
1175
|
+
**_making(study)),
|
|
1176
|
+
on_iter=on_iter, should_stop=should_stop)
|
|
1177
|
+
x, xphys = res.x, res.xphys
|
|
1178
|
+
warp = res.warp
|
|
1179
|
+
opt = {"iterations": res.iterations, "converged": res.converged, "seconds": res.seconds,
|
|
1180
|
+
"compliance": res.compliance, "caseCompliance": res.case_compliance,
|
|
1181
|
+
**({"caseDirection": res.case_direction} if any(d is not None for d in res.case_direction) else {}),
|
|
1182
|
+
"achievedVolumeFraction": res.volume_fraction,
|
|
1183
|
+
**({"escape": res.escape} if res.escape else {})}
|
|
1184
|
+
else:
|
|
1185
|
+
# a stored design for this very problem: mesh and verify it, no optimizing
|
|
1186
|
+
x, xphys = replay[0], replay[1]
|
|
1187
|
+
warp = None
|
|
1188
|
+
opt = {"iterations": 0, "converged": True, "seconds": 0.0, "recalled": True,
|
|
1189
|
+
"achievedVolumeFraction": float(xphys.mean())}
|
|
1190
|
+
emit({"stage": "mesh", "volumeFraction": vf})
|
|
1191
|
+
mesh, rinfo = reconstruct(study, domain, xphys, ReconstructOptions())
|
|
1192
|
+
emit({"stage": "verify", "volumeFraction": vf})
|
|
1193
|
+
try:
|
|
1194
|
+
report = verify_part(study, mesh, min_pitch=0.5 * domain.pitch, contact_band=domain.pitch)
|
|
1195
|
+
except (RuntimeError, ValueError) as exc:
|
|
1196
|
+
# one outcome verification cannot solve (or a load pad the
|
|
1197
|
+
# design let go of: GRIP's wrist link at 5% after 43 g at 6%) (a piece the supports do
|
|
1198
|
+
# not hold) is a failed outcome, not a failed study: the search
|
|
1199
|
+
# goes on past it (GRIP's wrist link died here)
|
|
1200
|
+
mat = study.material
|
|
1201
|
+
report = {"mass": float(mesh.volume) * mat.density / 1000.0, "safetyFactor": 0.0,
|
|
1202
|
+
"cases": [], "warnings": [f"verification failed: {exc}"]}
|
|
1203
|
+
if buck_state["sf"] is not None and report.get("bucklingFactor") is not None:
|
|
1204
|
+
# Buckling in the search (simp.optimize's buckling constraint):
|
|
1205
|
+
# on from the first part whose buckling factor comes within
|
|
1206
|
+
# _BUCKLING_SEARCH_ON of the safety factor, at sf * kb, kb the
|
|
1207
|
+
# optimizer's own reading over the verified one (last run)
|
|
1208
|
+
lv = report["bucklingFactor"]
|
|
1209
|
+
lo = res.history[-1].get("buckling") if replay is None and res.history else None
|
|
1210
|
+
if lo is not None and np.isfinite(lo) and lo > 0 and lv > 0:
|
|
1211
|
+
kb = buck_state["kb"]
|
|
1212
|
+
buck_state["kb"] = float(np.clip(lo / lv, 0.5 * kb, 2.0 * kb))
|
|
1213
|
+
opt["bucklingFactor"] = float(lo)
|
|
1214
|
+
if lv < _BUCKLING_SEARCH_ON * buck_state["sf"]:
|
|
1215
|
+
buck_state["on"] = True
|
|
1216
|
+
making = manufacturing_report(study, domain, mesh)
|
|
1217
|
+
if making:
|
|
1218
|
+
report["manufacturing"] = making
|
|
1219
|
+
fid = mesh_fidelity(domain, xphys, mesh)
|
|
1220
|
+
if fid is not None:
|
|
1221
|
+
report["meshFidelity"] = fid
|
|
1222
|
+
if fid["lostSolidCells"] > MESH_LOSS_WARN:
|
|
1223
|
+
report.setdefault("warnings", []).append(
|
|
1224
|
+
f"the mesh lost {fid['lostSolidCells']:.0%} of the optimized solid cells "
|
|
1225
|
+
f"({fid['lostSurfaceCells']:.0%} of the surface cells, {fid['lostThinCells']:.0%} of the thin "
|
|
1226
|
+
"members): the meshed part is softer and weaker than the design the optimizer found, so "
|
|
1227
|
+
"the outcome search goes heavier than the design needs. The verified numbers are the "
|
|
1228
|
+
"ones that hold.")
|
|
1229
|
+
if limits0 is not None and not warp:
|
|
1230
|
+
warp = {"limits": {"maxDistortion": limits0[0], "maxRecoaterLift": limits0[1]}}
|
|
1231
|
+
if warp:
|
|
1232
|
+
report["warp"] = warp
|
|
1233
|
+
if limits0 is not None:
|
|
1234
|
+
_check_warp(warp, mesh)
|
|
1235
|
+
o = Outcome(vf, mesh, report, opt, rinfo)
|
|
1236
|
+
o.xphys = xphys
|
|
1237
|
+
o.x = x
|
|
1238
|
+
o.voxel_sf = report.get("safetyFactor")
|
|
1239
|
+
if memory is not None:
|
|
1240
|
+
memory.add(vf, x, xphys, report)
|
|
1241
|
+
m = _margin(study, report)
|
|
1242
|
+
if m is not None:
|
|
1243
|
+
report["margin"] = m
|
|
1244
|
+
o.passes = bool(m >= 1.0)
|
|
1245
|
+
if report.get("bodies", 1) > 1:
|
|
1246
|
+
# A preserved piece no load path reaches (a mount with no load
|
|
1247
|
+
# through it) comes out as a loose part. It is not a design that
|
|
1248
|
+
# meets the brief, and the fix is in the study: say so.
|
|
1249
|
+
o.passes = False
|
|
1250
|
+
report.setdefault("warnings", []).append(
|
|
1251
|
+
f"the part came out in {report['bodies']} pieces: either each piece has its own supports and "
|
|
1252
|
+
"load path and nothing ties them together (two mirror halves, each on its own bolts), or some "
|
|
1253
|
+
"preserved geometry is on no load path. For one part, preserve a link between the pieces (a "
|
|
1254
|
+
"ring, a web, a plate) or add a load case that runs between them; for a floating preserve, "
|
|
1255
|
+
"load it or drop it.")
|
|
1256
|
+
if report.get("closedBores"):
|
|
1257
|
+
o.passes = False
|
|
1258
|
+
report.setdefault("warnings", []).append(
|
|
1259
|
+
f"the part closes over {len(report['closedBores'])} bore end(s) "
|
|
1260
|
+
f"({', '.join(b['id'] + ' ' + b['end'] for b in report['closedBores'])}): no fastener goes through, "
|
|
1261
|
+
"so it does not pass. Extend the keep-out over the head and the driver's reach if this repeats.")
|
|
1262
|
+
return o
|
|
1263
|
+
|
|
1264
|
+
def run_one(vf: float) -> Outcome:
|
|
1265
|
+
nonlocal started
|
|
1266
|
+
emit({"stage": "optimize", "volumeFraction": vf})
|
|
1267
|
+
big = domain.n_elem >= _COARSE_MIN_CELLS
|
|
1268
|
+
|
|
1269
|
+
def cold():
|
|
1270
|
+
if big:
|
|
1271
|
+
x0 = _coarse_start(study, domain, vf, radius, should_stop)
|
|
1272
|
+
return attempt(vf, x0, _WARM_BETA if x0 is not None else 1.0)
|
|
1273
|
+
return attempt(vf, None, 1.0)
|
|
1274
|
+
|
|
1275
|
+
rp = memory.replay(vf) if memory is not None else None
|
|
1276
|
+
w = memory.warm_start(vf) if memory is not None and rp is None and not started else None
|
|
1277
|
+
if rp is not None:
|
|
1278
|
+
o = attempt(vf, None, None, replay=rp)
|
|
1279
|
+
o.report["start"] = f"recalled {vf:.3f}"
|
|
1280
|
+
elif w is not None:
|
|
1281
|
+
try:
|
|
1282
|
+
o = attempt(vf, w[0], _WARM_BETA)
|
|
1283
|
+
o.report["start"] = w[1]
|
|
1284
|
+
except RuntimeError:
|
|
1285
|
+
o = cold()
|
|
1286
|
+
o.report["start"] = "cold (the recalled start did not solve)"
|
|
1287
|
+
elif not any(q.x is not None for q in outcomes):
|
|
1288
|
+
o = cold()
|
|
1289
|
+
o.report["start"] = "cold"
|
|
1290
|
+
else:
|
|
1291
|
+
# Each fraction warm-starts from a design already found, which is
|
|
1292
|
+
# what lets a stiffness-bound part follow its layout down (GRIP's
|
|
1293
|
+
# upper arm: 7.0% passed warm at margin 1.06 and failed cold at
|
|
1294
|
+
# 0.68). From the nearest fraction above, never the last one run:
|
|
1295
|
+
# the wall bracket chained 0.150 -> 0.104 -> 0.127 -> 0.115 (up,
|
|
1296
|
+
# then down again) read SF 1.69 at 0.115 and 2.34 at 0.110 carried
|
|
1297
|
+
# straight down from 0.150, so its mass swung with the order the
|
|
1298
|
+
# fractions came in (57-68 g). Carried down, the verified SF rises
|
|
1299
|
+
# smoothly with the fraction (0.88, 2.00, 2.34, 2.83, 3.11, 3.36
|
|
1300
|
+
# at 9-14%). Not always from the first design either: the turret
|
|
1301
|
+
# carried 0.120 -> 0.029 in one jump read SF 1.56, and 2.54 at
|
|
1302
|
+
# 0.025 stepped down through 0.068 and 0.042.
|
|
1303
|
+
above = [q for q in outcomes if q.x is not None and q.volume_fraction > vf]
|
|
1304
|
+
src = (min(above, key=lambda q: q.volume_fraction) if above else
|
|
1305
|
+
max((q for q in outcomes if q.x is not None), key=lambda q: q.volume_fraction))
|
|
1306
|
+
try:
|
|
1307
|
+
o = attempt(vf, src.x, _WARM_BETA)
|
|
1308
|
+
o.report["start"] = f"warm from {src.volume_fraction:.3f}"
|
|
1309
|
+
except RuntimeError:
|
|
1310
|
+
# A carried layout can leave a support hanging on void
|
|
1311
|
+
# (GRIP's wrist link: a pin-held idler with its material
|
|
1312
|
+
# gone, so the stiffness solve never converged). Start this
|
|
1313
|
+
# fraction afresh instead.
|
|
1314
|
+
o = cold()
|
|
1315
|
+
o.report["start"] = "cold (the warm start did not solve)"
|
|
1316
|
+
if big and len(outcomes) == 1 and vf < outcomes[0].volume_fraction \
|
|
1317
|
+
and "recalled" not in str(outcomes[0].report.get("start")) \
|
|
1318
|
+
and not (should_stop is not None and should_stop()):
|
|
1319
|
+
# ...but carried from a fraction well above, the layout can
|
|
1320
|
+
# be one the new fraction no longer suits: the wall bracket
|
|
1321
|
+
# read SF 1.0-1.5 at 8-14% chained down from 15%, and 3.2 at
|
|
1322
|
+
# 10% started fresh. So the first step down is also run from
|
|
1323
|
+
# its own coarse layout, and the better margin kept; one
|
|
1324
|
+
# extra run per search.
|
|
1325
|
+
c = cold()
|
|
1326
|
+
c.report["start"] = "cold"
|
|
1327
|
+
if (c.report.get("margin") or 0.0) > (o.report.get("margin") or 0.0):
|
|
1328
|
+
o, c = c, o
|
|
1329
|
+
o.report["alternative"] = {k: c.report.get(k) for k in
|
|
1330
|
+
("start", "mass", "safetyFactor", "margin")} | {"passes": c.passes}
|
|
1331
|
+
started = True
|
|
1332
|
+
emit({"stage": "outcome", **o.summary()})
|
|
1333
|
+
_save_partial(o)
|
|
1334
|
+
return o
|
|
1335
|
+
|
|
1336
|
+
def _confirm(o: Outcome) -> float:
|
|
1337
|
+
# a replayed design's mesh is the one confirmed before: rescale that reading
|
|
1338
|
+
rep = memory.confirm_for(o.volume_fraction) if memory is not None and o.optimizer.get("recalled") else None
|
|
1339
|
+
recalled = rep is not None
|
|
1340
|
+
if rep is None:
|
|
1341
|
+
try:
|
|
1342
|
+
cc = _confirm_cells(study)
|
|
1343
|
+
rep = verify_part(study, o.mesh, max_cells=cc, min_pitch=0.3 * domain.pitch,
|
|
1344
|
+
contact_band=domain.pitch)
|
|
1345
|
+
except RuntimeError as exc:
|
|
1346
|
+
o.report.setdefault("warnings", []).append(f"confirmation failed: {exc}")
|
|
1347
|
+
o.report["margin"] = 0.0
|
|
1348
|
+
return 0.0
|
|
1349
|
+
if memory is not None:
|
|
1350
|
+
memory.confirmed(o.volume_fraction, rep)
|
|
1351
|
+
mc = _margin(study, rep)
|
|
1352
|
+
o.report["confirm"] = {"safetyFactor": rep["safetyFactor"], "margin": mc,
|
|
1353
|
+
"analysisPitch": rep["analysisPitch"], "analysisCells": rep["analysisCells"],
|
|
1354
|
+
**({"cellsCapped": "by free memory (LOADPATH_CONFIRM_CELLS sets it)"}
|
|
1355
|
+
if not recalled and cc < _CONFIRM_CELLS and not os.environ.get("LOADPATH_CONFIRM_CELLS")
|
|
1356
|
+
else {}),
|
|
1357
|
+
"cases": [{k: c[k] for k in ("name", "peakStress", "peakAt", "deflection")}
|
|
1358
|
+
for c in rep["cases"]]}
|
|
1359
|
+
# recorded, not adopted: the finer grid also resolves small features
|
|
1360
|
+
# the analysis grid smooths over, so its percentile is not comparable
|
|
1361
|
+
o.report["confirm"].update(p5Member=rep["p5Member"], minMember=rep["minMember"])
|
|
1362
|
+
if recalled:
|
|
1363
|
+
o.report["confirm"]["recalled"] = True
|
|
1364
|
+
if mc < o.report["margin"]:
|
|
1365
|
+
# report the finer (lower) reading: it is the one to trust
|
|
1366
|
+
o.report["margin"], o.report["safetyFactor"], o.report["cases"] = mc, rep["safetyFactor"], rep["cases"]
|
|
1367
|
+
for k in ("thermal", "durability"):
|
|
1368
|
+
if k in rep:
|
|
1369
|
+
o.report[k] = rep[k]
|
|
1370
|
+
return mc
|
|
1371
|
+
|
|
1372
|
+
def tet_confirm(o: Outcome) -> float | None:
|
|
1373
|
+
"""Confirm `o` on the tetrahedral cross-check (crosscheck.py). Its
|
|
1374
|
+
safety factor replaces the voxel one, and o.tet_k (tet / voxel) is
|
|
1375
|
+
the calibration for the rest of the search. Returns the confirmed
|
|
1376
|
+
margin, or None when the cross-check could not run."""
|
|
1377
|
+
from .crosscheck import crosscheck, element_size
|
|
1378
|
+
|
|
1379
|
+
emit({"stage": "confirm", "volumeFraction": o.volume_fraction, "method": "tet"})
|
|
1380
|
+
# Stress is left out within two elements of a load or support, and
|
|
1381
|
+
# no less than the voxel analysis leaves out: nearer, the peak is the
|
|
1382
|
+
# edge of an idealized rigid support, not the part. (The MBB beam's
|
|
1383
|
+
# read 23-26 MPa there on three meshes of one part, and 13.7-13.9
|
|
1384
|
+
# MPa at its real peak mid-span beyond the band.)
|
|
1385
|
+
band = max(2.0 * element_size(study), 1.5 * domain.pitch)
|
|
1386
|
+
try:
|
|
1387
|
+
cc = crosscheck(study, o.mesh, contact_band=band)
|
|
1388
|
+
except Exception as exc: # noqa: BLE001 - a mesher or solver failure means: confirm on voxels instead
|
|
1389
|
+
o.report.setdefault("warnings", []).append(f"tetrahedral confirmation failed: {exc}")
|
|
1390
|
+
return None
|
|
1391
|
+
r = o.report
|
|
1392
|
+
o.tet_k = cc["safetyFactor"] / o.voxel_sf if o.voxel_sf else 1.0
|
|
1393
|
+
r["voxelSafetyFactor"] = o.voxel_sf
|
|
1394
|
+
r["safetyFactor"] = cc["safetyFactor"]
|
|
1395
|
+
r.pop("calibration", None)
|
|
1396
|
+
mt = _margin(study, r)
|
|
1397
|
+
r["margin"] = mt
|
|
1398
|
+
r["confirm"] = {"method": "tet", "safetyFactor": cc["safetyFactor"], "margin": mt,
|
|
1399
|
+
"voxelSafetyFactor": o.voxel_sf, "tetOverVoxel": o.tet_k,
|
|
1400
|
+
"elementSize": cc["elementSize"], "contactBand": band, "nodes": cc["nodes"], "elements": cc["elements"],
|
|
1401
|
+
"seconds": cc["seconds"],
|
|
1402
|
+
"cases": [{k: c[k] for k in ("name", "peakStress", "peakAt", "deflection")}
|
|
1403
|
+
for c in cc["cases"]]}
|
|
1404
|
+
o.passes = bool(mt >= 1.0) and _whole(r)
|
|
1405
|
+
emit({"stage": "confirmed", "volumeFraction": o.volume_fraction, "margin": mt, "method": "tet"})
|
|
1406
|
+
return mt
|
|
1407
|
+
|
|
1408
|
+
def untet(o: Outcome):
|
|
1409
|
+
"""Put `o` back on its voxel reading, keeping the tet one as a
|
|
1410
|
+
warning (where, how far below target)."""
|
|
1411
|
+
r = o.report
|
|
1412
|
+
c = r.pop("confirm")
|
|
1413
|
+
o.tet_k = None
|
|
1414
|
+
r.pop("voxelSafetyFactor", None)
|
|
1415
|
+
r["safetyFactor"] = o.voxel_sf
|
|
1416
|
+
r["margin"] = _margin(study, r)
|
|
1417
|
+
o.passes = bool(r["margin"] >= 1.0) and _whole(r)
|
|
1418
|
+
r["tetCheck"] = c
|
|
1419
|
+
worst = min(c["cases"], key=lambda q: -q["peakStress"])
|
|
1420
|
+
r.setdefault("warnings", []).append(
|
|
1421
|
+
f"the tetrahedral cross-check reads SF {c['safetyFactor']:.2f} (voxel {o.voxel_sf:.2f}), its peak "
|
|
1422
|
+
f"{worst['peakStress']:.1f} MPa in {worst['name']!r} at "
|
|
1423
|
+
f"({', '.join(f'{v:.1f}' for v in worst['peakAt'])}): a stress the voxel grid does not resolve. "
|
|
1424
|
+
"Check it there (a fillet, a thicker wall, or how the part is really held).")
|
|
1425
|
+
|
|
1426
|
+
def layout_search(k: float):
|
|
1427
|
+
"""Other layouts below a tet-confirmed pick with margin to spare.
|
|
1428
|
+
|
|
1429
|
+
The fraction chain follows one layout down, warm-starting each step
|
|
1430
|
+
from the last, and a part whose strength is not monotone in the
|
|
1431
|
+
fraction stops on it: the wall bracket passed at 12.6% (tets 4.95
|
|
1432
|
+
for 2.5) with 10.2% and 11.4% failing (2.29, 2.12), all three
|
|
1433
|
+
descendants of the 15% design. So a few differently started designs
|
|
1434
|
+
are tried between the lightest confirmed design and the fraction
|
|
1435
|
+
its margin points at: warm from it, then a fresh coarse layout.
|
|
1436
|
+
Each that passes on its scaled voxel reading, or misses by under
|
|
1437
|
+
15%, is confirmed on tets; one that confirms becomes the design the
|
|
1438
|
+
next try starts below. (The bell crank went 24.3 -> 22.4 g this
|
|
1439
|
+
way, warm from its pick. A 1.5x wider filter, fresh or warm, came
|
|
1440
|
+
apart at these fractions, SF 0 on four parts, so it is not tried;
|
|
1441
|
+
fresh layouts confirmed nowhere either, but are the one start not
|
|
1442
|
+
descended from the chain.)"""
|
|
1443
|
+
confirmed = [i for i, q in enumerate(outcomes) if getattr(q, "tet_k", None) and q.passes]
|
|
1444
|
+
if not confirmed:
|
|
1445
|
+
return
|
|
1446
|
+
o = outcomes[min(confirmed, key=lambda i: outcomes[i].report["mass"])]
|
|
1447
|
+
mt = o.report["margin"]
|
|
1448
|
+
if mt < _TET_CLOSE:
|
|
1449
|
+
return
|
|
1450
|
+
floor = max(o.volume_fraction * 1.03 / mt, 0.5 * o.volume_fraction, _VF_MIN)
|
|
1451
|
+
big = domain.n_elem >= _COARSE_MIN_CELLS
|
|
1452
|
+
starts = [("warm from the pick", lambda vf, o: attempt(vf, o.x, _WARM_BETA)),
|
|
1453
|
+
("cold", lambda vf, o: attempt(vf, _coarse_start(study, domain, vf, radius, should_stop)
|
|
1454
|
+
if big else None, _WARM_BETA if big else 1.0))]
|
|
1455
|
+
runs = 0
|
|
1456
|
+
for name, start in starts:
|
|
1457
|
+
while runs < _LAYOUT_RUNS and not (should_stop is not None and should_stop()):
|
|
1458
|
+
top = o.volume_fraction
|
|
1459
|
+
if o.report["margin"] < _TET_CLOSE:
|
|
1460
|
+
return
|
|
1461
|
+
vf = 0.5 * (floor + top)
|
|
1462
|
+
retry = False
|
|
1463
|
+
if name == "cold":
|
|
1464
|
+
# a fraction the chain failed warm, between the floor
|
|
1465
|
+
# and the pick, first: its own fresh layout is the one
|
|
1466
|
+
# the chain lost (GRIP's MJF wrist link: 5.25% warm from
|
|
1467
|
+
# 6% read tets 0.99, fresh 4.00 for 2, 37.2 g against
|
|
1468
|
+
# 41.0; the midpoint, 4.2%, read 0.41)
|
|
1469
|
+
fresh = [q.volume_fraction for q in outcomes
|
|
1470
|
+
if str(q.report.get("start", "")).startswith(("cold", "layout search: cold"))]
|
|
1471
|
+
lost = [q.volume_fraction for q in outcomes
|
|
1472
|
+
if not q.passes and floor < q.volume_fraction < top and q.voxel_sf
|
|
1473
|
+
and q.report.get("bodies", 1) <= 1 and str(q.report.get("start", "")).startswith("warm")
|
|
1474
|
+
and not any(abs(q.volume_fraction - v) < 0.003 for v in fresh)]
|
|
1475
|
+
if lost:
|
|
1476
|
+
vf, retry = max(lost), True
|
|
1477
|
+
if top - vf < max(0.005, 0.05 * top) and not retry:
|
|
1478
|
+
break
|
|
1479
|
+
if name.startswith("warm") and any(abs(q.volume_fraction - vf) < 0.003 for q in outcomes):
|
|
1480
|
+
break # the chain already ran warm there
|
|
1481
|
+
emit({"stage": "optimize", "volumeFraction": vf, "start": name})
|
|
1482
|
+
try:
|
|
1483
|
+
q = start(vf, o)
|
|
1484
|
+
except RuntimeError:
|
|
1485
|
+
break
|
|
1486
|
+
runs += 1
|
|
1487
|
+
q.report["start"] = f"layout search: {name}"
|
|
1488
|
+
outcomes.append(q)
|
|
1489
|
+
calibrate(q, k)
|
|
1490
|
+
emit({"stage": "outcome", **q.summary()})
|
|
1491
|
+
if q.report.get("bodies", 1) > 1 or (q.report.get("margin") or 0.0) < 0.85:
|
|
1492
|
+
break
|
|
1493
|
+
mq = tet_confirm(q)
|
|
1494
|
+
if mq is None or mq < 1.0:
|
|
1495
|
+
if mq is None:
|
|
1496
|
+
q.tet_k, q.passes = 0.0, False
|
|
1497
|
+
break
|
|
1498
|
+
o = q # it held: try once more, below it
|
|
1499
|
+
if runs >= _LAYOUT_RUNS:
|
|
1500
|
+
break
|
|
1501
|
+
|
|
1502
|
+
def calibrate(o: Outcome, k: float):
|
|
1503
|
+
"""Read `o`'s voxel safety factor through the tet calibration k (1
|
|
1504
|
+
restores the voxel reading). A tet-confirmed outcome keeps its own."""
|
|
1505
|
+
if getattr(o, "tet_k", None) is not None or not o.voxel_sf:
|
|
1506
|
+
return
|
|
1507
|
+
r = o.report
|
|
1508
|
+
if k == 1.0:
|
|
1509
|
+
r.pop("calibration", None)
|
|
1510
|
+
m = _margin(study, r)
|
|
1511
|
+
else:
|
|
1512
|
+
r["calibration"] = {"tetOverVoxel": k, "safetyFactor": o.voxel_sf * k}
|
|
1513
|
+
m = _margin(study, {**r, "safetyFactor": o.voxel_sf * k})
|
|
1514
|
+
r["margin"] = m
|
|
1515
|
+
o.passes = bool(m >= 1.0) and _whole(r)
|
|
1516
|
+
|
|
1517
|
+
def run_constrained(limits, x0) -> Outcome:
|
|
1518
|
+
from .constrained import optimize_constrained
|
|
1519
|
+
|
|
1520
|
+
emit({"stage": "optimize", "limits": {"stress": limits.stress, "deflection": limits.deflection}})
|
|
1521
|
+
|
|
1522
|
+
def on_iter(rec, xp):
|
|
1523
|
+
emit({"stage": "optimize", **rec}, xp)
|
|
1524
|
+
|
|
1525
|
+
beta0 = _WARM_BETA if x0 is not None else 1.0
|
|
1526
|
+
if x0 is None and domain.n_elem >= _COARSE_MIN_CELLS:
|
|
1527
|
+
x0 = _coarse_start(study, domain, float(goal.volume_fraction or 0.3), radius, should_stop)
|
|
1528
|
+
beta0 = _WARM_BETA if x0 is not None else 1.0
|
|
1529
|
+
res = optimize_constrained(
|
|
1530
|
+
domain, study.elastic(),
|
|
1531
|
+
SimpOptions(volume_fraction=float(goal.volume_fraction or 0.3), filter_radius=radius,
|
|
1532
|
+
max_iterations=study.max_iterations, x0=x0, beta_start=beta0,
|
|
1533
|
+
extrusion=study.extrusion, pattern=study.pattern,
|
|
1534
|
+
build_direction=study.print_direction),
|
|
1535
|
+
limits, on_iter=on_iter, should_stop=should_stop)
|
|
1536
|
+
mesh, rinfo = reconstruct(study, domain, res.xphys, ReconstructOptions())
|
|
1537
|
+
report = verify_part(study, mesh, min_pitch=0.5 * domain.pitch, contact_band=domain.pitch)
|
|
1538
|
+
opt = {"iterations": res.iterations, "converged": res.converged, "seconds": res.seconds,
|
|
1539
|
+
"achievedVolumeFraction": res.volume_fraction, "method": "constrained",
|
|
1540
|
+
"limits": {"stress": limits.stress, "deflection": limits.deflection,
|
|
1541
|
+
**({"buckling": limits.buckling} if limits.buckling is not None else {})}}
|
|
1542
|
+
last = res.history[-1] if res.history else {}
|
|
1543
|
+
bks = [v for kk, v in last.items() if kk.startswith("buckling.")]
|
|
1544
|
+
if bks:
|
|
1545
|
+
opt["bucklingFactor"] = float(min(bks)) # the optimizer's own reading, eroded grey design
|
|
1546
|
+
o = Outcome(res.volume_fraction, mesh, report, opt, rinfo)
|
|
1547
|
+
o.voxel_sf = report.get("safetyFactor")
|
|
1548
|
+
m = _margin(study, report)
|
|
1549
|
+
report["margin"] = m
|
|
1550
|
+
o.passes = bool(m >= 1.0)
|
|
1551
|
+
o.x = res.x
|
|
1552
|
+
emit({"stage": "outcome", **o.summary()})
|
|
1553
|
+
return o
|
|
1554
|
+
|
|
1555
|
+
goal = study.goal
|
|
1556
|
+
if goal.method == "constrained" and _constrained_applies(study):
|
|
1557
|
+
from .constrained import Limits
|
|
1558
|
+
from .verify import STRESS_ALLOWANCE
|
|
1559
|
+
|
|
1560
|
+
mat = study.material
|
|
1561
|
+
stress = mat.strength / (goal.safety_factor * STRESS_ALLOWANCE) if goal.safety_factor else None
|
|
1562
|
+
# The verified mesh reads differently from the optimizer's model
|
|
1563
|
+
# (finer grid, refined hotspots, members a cell thick that mesh
|
|
1564
|
+
# thinner), so the limits are scaled by one factor t until the
|
|
1565
|
+
# verified part lands on the target. Rescaling by each run's ratio
|
|
1566
|
+
# alone swung: the wall bracket's first run reads 17 MPa in the
|
|
1567
|
+
# optimizer and 377 MPa verified, the next limit (0.8 MPa) built
|
|
1568
|
+
# 100 g at SF 13, and four runs ended anywhere from 43 g to 174 g
|
|
1569
|
+
# with the target nudged by 2%. Once one run passes and one fails,
|
|
1570
|
+
# bisect t between them (geometrically), and warm-start only from a
|
|
1571
|
+
# passing design, never from a collapsed one.
|
|
1572
|
+
# With the tet cross-check (goal.confirm), each run is read on tets
|
|
1573
|
+
# and t is steered by that reading: the voxel one left the wall
|
|
1574
|
+
# brackets at SF 2.7-2.8 on voxels and 3.6 on tets for 2.5.
|
|
1575
|
+
tets = goal.safety_factor is not None and _confirm_on_tets(study, outcomes)
|
|
1576
|
+
# Buckling (goal.buckling): the optimizer holds the design's smallest
|
|
1577
|
+
# buckling load factor at sf * kb / t (bucklingopt.py), so t relaxes
|
|
1578
|
+
# or tightens stress and stability together and the bisection below
|
|
1579
|
+
# stays one-dimensional. kb is the optimizer's reading over the
|
|
1580
|
+
# verified part's (the grey eroded design on the fine grid against
|
|
1581
|
+
# the meshed part on the check's coarser grid), taken from the last
|
|
1582
|
+
# run: the two differ by a factor that holds roughly steady from one
|
|
1583
|
+
# design to the next.
|
|
1584
|
+
sf_b = goal.safety_factor if (goal.buckling and goal.safety_factor and _BUCKLING_IN_OPTIMIZER) else None
|
|
1585
|
+
kb = _BUCKLING_START
|
|
1586
|
+
fails = []
|
|
1587
|
+
t, t_pass, t_fail, x_pass = 1.0, None, None, None
|
|
1588
|
+
for _ in range(_MAX_CONSTRAINED_RUNS):
|
|
1589
|
+
if should_stop is not None and should_stop():
|
|
1590
|
+
break
|
|
1591
|
+
limits = Limits(stress * t if stress is not None else None,
|
|
1592
|
+
goal.max_deflection * t if goal.max_deflection is not None else None, mat.E,
|
|
1593
|
+
buckling=sf_b * kb / t if sf_b is not None else None)
|
|
1594
|
+
try:
|
|
1595
|
+
o = run_constrained(limits, x_pass)
|
|
1596
|
+
except (RuntimeError, ValueError) as exc:
|
|
1597
|
+
# (ValueError: the design let go of a load's region, which
|
|
1598
|
+
# then touches no material in the verify)
|
|
1599
|
+
# a run whose solve broke down (the design let go of a
|
|
1600
|
+
# support: GRIP's yoke, wrist link and upper arm) is a
|
|
1601
|
+
# failed run; one that fails first ends the constrained
|
|
1602
|
+
# attempt, and the search below takes over
|
|
1603
|
+
fails.append(f"t {t:.3g}: {exc}")
|
|
1604
|
+
if not any(q.optimizer.get("method") == "constrained" for q in outcomes):
|
|
1605
|
+
break
|
|
1606
|
+
t_fail = t if t_fail is None else min(t_fail, t)
|
|
1607
|
+
t = (t_pass * t_fail) ** 0.5 if t_pass is not None else 0.5 * t
|
|
1608
|
+
continue
|
|
1609
|
+
outcomes.append(o)
|
|
1610
|
+
if tets and o.voxel_sf and o.report.get("bodies", 1) <= 1 and tet_confirm(o) is None:
|
|
1611
|
+
tets = False # the mesher failed: voxels from here on
|
|
1612
|
+
r = o.report
|
|
1613
|
+
rs = r["safetyFactor"] / goal.safety_factor if goal.safety_factor else None
|
|
1614
|
+
rd = goal.max_deflection / max(c["deflection"] for c in r["cases"]) if goal.max_deflection else None
|
|
1615
|
+
rb = None
|
|
1616
|
+
if sf_b is not None and r.get("bucklingFactor") is not None:
|
|
1617
|
+
rb = r["bucklingFactor"] / sf_b
|
|
1618
|
+
lo = o.optimizer.get("bucklingFactor")
|
|
1619
|
+
if lo is not None and np.isfinite(lo) and lo > 0:
|
|
1620
|
+
kb = float(np.clip(lo / r["bucklingFactor"], 0.5 * kb, 2.0 * kb))
|
|
1621
|
+
ratio = min(v for v in (rs, rd, rb) if v is not None)
|
|
1622
|
+
if ratio >= 1.0:
|
|
1623
|
+
if ratio < 1.12:
|
|
1624
|
+
break
|
|
1625
|
+
if t_pass is None or t > t_pass:
|
|
1626
|
+
t_pass, x_pass = t, o.x
|
|
1627
|
+
elif t_fail is None or t < t_fail:
|
|
1628
|
+
t_fail = t
|
|
1629
|
+
if t_pass is not None and t_fail is not None:
|
|
1630
|
+
if t_fail / t_pass < 1.15:
|
|
1631
|
+
break
|
|
1632
|
+
t = (t_pass * t_fail) ** 0.5
|
|
1633
|
+
elif t_fail is not None:
|
|
1634
|
+
# a stress limit acts roughly in proportion; never more than
|
|
1635
|
+
# 4x a step, so one wild reading cannot overshoot far
|
|
1636
|
+
t *= min(max(ratio, 0.25), 0.9)
|
|
1637
|
+
else:
|
|
1638
|
+
t *= min(ratio ** 0.5, 4.0)
|
|
1639
|
+
passing = [i for i, o in enumerate(outcomes) if o.passes]
|
|
1640
|
+
best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
|
|
1641
|
+
if best is None and fallback:
|
|
1642
|
+
# Search instead, on the search's own grid: the constrained
|
|
1643
|
+
# method runs finer (minimum member 2.5 cells across), and the
|
|
1644
|
+
# search on that grid broke down on the GRIP yoke too.
|
|
1645
|
+
import dataclasses
|
|
1646
|
+
|
|
1647
|
+
why = ("the constrained method found no passing design"
|
|
1648
|
+
+ (f" ({'; '.join(fails)})" if fails else "") + ": searched over volume fractions instead")
|
|
1649
|
+
r = _run_single(dataclasses.replace(study, goal=dataclasses.replace(goal, method="search")),
|
|
1650
|
+
on_progress, should_stop)
|
|
1651
|
+
r.warnings.insert(0, why)
|
|
1652
|
+
return r
|
|
1653
|
+
elif goal.safety_factor is not None or goal.max_deflection is not None or _durability_drives(study):
|
|
1654
|
+
vf = float(goal.volume_fraction or 0.3)
|
|
1655
|
+
if memory is not None:
|
|
1656
|
+
vf, why = memory.first_fraction(vf)
|
|
1657
|
+
if why:
|
|
1658
|
+
emit({"stage": "recall", "volumeFraction": vf, "reason": why})
|
|
1659
|
+
lo_fail, hi_pass = None, None # (vf, margin) brackets
|
|
1660
|
+
for _ in range(_MAX_SEARCH_RUNS):
|
|
1661
|
+
if should_stop is not None and should_stop():
|
|
1662
|
+
break
|
|
1663
|
+
o = run_one(vf)
|
|
1664
|
+
outcomes.append(o)
|
|
1665
|
+
m = o.report["margin"]
|
|
1666
|
+
if m >= 1.0 and o.report.get("bodies", 1) > 1:
|
|
1667
|
+
# strong enough but in pieces (a fastener's rings or an
|
|
1668
|
+
# unloaded pad left floating) is a failed outcome: step up
|
|
1669
|
+
# about a third, more material reconnects it. It used to
|
|
1670
|
+
# count as passing and end the search with no passing part.
|
|
1671
|
+
m = 0.8
|
|
1672
|
+
if m >= 1.0:
|
|
1673
|
+
hi_pass = (vf, m) if hi_pass is None or vf < hi_pass[0] else hi_pass
|
|
1674
|
+
else:
|
|
1675
|
+
lo_fail = (vf, m) if lo_fail is None or vf > lo_fail[0] else lo_fail
|
|
1676
|
+
# bracket closed to within 10% of the passing fraction (an
|
|
1677
|
+
# absolute 0.02 was 30% at the low fractions large design spaces
|
|
1678
|
+
# end at: the GRIP upper arm stopped at 0.075 with 0.058 failing,
|
|
1679
|
+
# 222 g where ~0.066 would have passed lighter)
|
|
1680
|
+
if hi_pass and lo_fail and hi_pass[0] - lo_fail[0] < max(0.005, 0.1 * hi_pass[0]):
|
|
1681
|
+
break
|
|
1682
|
+
if hi_pass and 1.0 <= hi_pass[1] < 1.12:
|
|
1683
|
+
break # passing with under 12% to spare: close enough
|
|
1684
|
+
if hi_pass and lo_fail:
|
|
1685
|
+
# Bracketed: bisect. The margin is not smooth in the volume
|
|
1686
|
+
# fraction (a slightly different fraction can settle in a
|
|
1687
|
+
# different layout: the arm link read 0.49 at 30% and 1.30 at
|
|
1688
|
+
# 38%), so interpolating between brackets kept landing on the
|
|
1689
|
+
# wrong side of a jump; halving the bracket always closes it.
|
|
1690
|
+
(v1, m1), (v2, m2) = lo_fail, hi_pass
|
|
1691
|
+
nxt = 0.5 * (v1 + v2)
|
|
1692
|
+
if memory is not None:
|
|
1693
|
+
# any fraction inside the bracket closes it; a stored one is free
|
|
1694
|
+
nxt = memory.snap(nxt, v1, v2, skip=[q.volume_fraction for q in outcomes])
|
|
1695
|
+
else:
|
|
1696
|
+
# Aim a little past the target; never more than halve or
|
|
1697
|
+
# double in one step, where the linear model stops holding.
|
|
1698
|
+
nxt = vf * 1.05 / max(m, 1e-9)
|
|
1699
|
+
nxt = float(np.clip(nxt, vf * 0.5, vf * 2.0))
|
|
1700
|
+
if memory is not None:
|
|
1701
|
+
nxt = memory.snap(nxt, skip=[q.volume_fraction for q in outcomes])
|
|
1702
|
+
nxt = float(np.clip(nxt, _VF_MIN, _VF_MAX))
|
|
1703
|
+
# A step under 0.005 stops an unbracketed search. A bracket is
|
|
1704
|
+
# left to the 10% rule above: its midpoint sits half the gap from
|
|
1705
|
+
# either end, and at low fractions that is under 0.005 while the
|
|
1706
|
+
# gap is still worth closing (GRIP's MJF wrist link passed at 6%,
|
|
1707
|
+
# SF 4.36 for 2, failed at 5.2%, and stopped there at 43.5 g
|
|
1708
|
+
# without trying 5.6%).
|
|
1709
|
+
if abs(nxt - vf) < 0.005 and not (hi_pass and lo_fail):
|
|
1710
|
+
break
|
|
1711
|
+
vf = nxt
|
|
1712
|
+
passing = [i for i, o in enumerate(outcomes) if o.passes]
|
|
1713
|
+
best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
|
|
1714
|
+
voxel_confirm = True
|
|
1715
|
+
if goal.safety_factor is not None and _confirm_on_tets(study, outcomes):
|
|
1716
|
+
# The voxel analysis reads peak stress high against a body-fitted
|
|
1717
|
+
# mesh (24-49% on the motor mount and bell crank), so a pick
|
|
1718
|
+
# that meets the target on voxels carries that much extra
|
|
1719
|
+
# material. Confirm the pick on tetrahedra instead, scale every
|
|
1720
|
+
# voxel safety factor by what that showed (tet / voxel), and let
|
|
1721
|
+
# the search step down to the fraction that meets the target on
|
|
1722
|
+
# the tet mesh; each new pick is confirmed there in turn.
|
|
1723
|
+
voxel_confirm = False
|
|
1724
|
+
k, tet_fail, confirms, extra = 1.0, None, 0, 0
|
|
1725
|
+
while not (should_stop is not None and should_stop()):
|
|
1726
|
+
passing = [i for i, q in enumerate(outcomes) if q.passes
|
|
1727
|
+
and (tet_fail is None or q.volume_fraction > tet_fail)]
|
|
1728
|
+
best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
|
|
1729
|
+
if best is None:
|
|
1730
|
+
# nothing passes: step up from the largest fraction tried
|
|
1731
|
+
top = max(outcomes, key=lambda q: q.volume_fraction)
|
|
1732
|
+
if extra >= _TET_EXTRA_RUNS or top.volume_fraction >= _VF_MAX:
|
|
1733
|
+
break
|
|
1734
|
+
vf = float(np.clip(top.volume_fraction * 1.05 / max(top.report["margin"], 1e-9),
|
|
1735
|
+
top.volume_fraction + 0.01, _VF_MAX))
|
|
1736
|
+
outcomes.append(run_one(vf))
|
|
1737
|
+
calibrate(outcomes[-1], k)
|
|
1738
|
+
extra += 1
|
|
1739
|
+
continue
|
|
1740
|
+
o = outcomes[best]
|
|
1741
|
+
if getattr(o, "tet_k", None) is None:
|
|
1742
|
+
if confirms >= _TET_CONFIRMS:
|
|
1743
|
+
break
|
|
1744
|
+
confirms += 1
|
|
1745
|
+
mt = tet_confirm(o)
|
|
1746
|
+
if mt is None:
|
|
1747
|
+
voxel_confirm = True
|
|
1748
|
+
break
|
|
1749
|
+
if mt < 1.0 and confirms == 1:
|
|
1750
|
+
# The tet mesh reads the voxel search's own pick
|
|
1751
|
+
# below target: the voxel grid missed a peak, so the
|
|
1752
|
+
# calibration has nothing to step down on. Such a
|
|
1753
|
+
# peak has sat in geometry the search cannot change
|
|
1754
|
+
# (GRIP's turret: SF 1.20 on the wall of a bolt's
|
|
1755
|
+
# driver access, 1.3 mm from the bore that holds it,
|
|
1756
|
+
# and three steps up to 0.24 left it at 230 g), so
|
|
1757
|
+
# it is not stepped up on that reading either: the
|
|
1758
|
+
# pick is confirmed on voxels, as without the tet
|
|
1759
|
+
# mesh, and the tet reading goes out as a warning.
|
|
1760
|
+
untet(o)
|
|
1761
|
+
voxel_confirm = True
|
|
1762
|
+
break
|
|
1763
|
+
k = o.tet_k
|
|
1764
|
+
for q in outcomes:
|
|
1765
|
+
calibrate(q, k)
|
|
1766
|
+
if mt < 1.0:
|
|
1767
|
+
tet_fail = max(tet_fail or 0.0, o.volume_fraction)
|
|
1768
|
+
continue # re-pick: the calibration may pass a lighter one
|
|
1769
|
+
# o is confirmed and the lightest that passes: step down
|
|
1770
|
+
# toward the fraction the confirmed margin points at, unless
|
|
1771
|
+
# it is already close, or a failing fraction sits within 10%
|
|
1772
|
+
mt = o.report["margin"]
|
|
1773
|
+
# The fraction just below that failed only on its scaled voxel
|
|
1774
|
+
# reading is confirmed on tets directly before the search
|
|
1775
|
+
# stops on it: the voxel reading can fall off a cliff the tet
|
|
1776
|
+
# mesh does not see (the wall bracket: voxel SF 2.65 at 7.8%
|
|
1777
|
+
# and 0.81 at 7.1%, tets 4.06 at 7.8%), and the ratio of one
|
|
1778
|
+
# design says little about the next.
|
|
1779
|
+
near = [i for i, q in enumerate(outcomes)
|
|
1780
|
+
if getattr(q, "tet_k", None) is None and not q.passes and q.voxel_sf
|
|
1781
|
+
and q.volume_fraction < o.volume_fraction and q.report.get("bodies", 1) <= 1
|
|
1782
|
+
and (tet_fail is None or q.volume_fraction > tet_fail)]
|
|
1783
|
+
if mt >= _TET_CLOSE and near and confirms < _TET_CONFIRMS:
|
|
1784
|
+
q = outcomes[max(near, key=lambda i: outcomes[i].volume_fraction)]
|
|
1785
|
+
confirms += 1
|
|
1786
|
+
mq = tet_confirm(q)
|
|
1787
|
+
if mq is None or mq < 1.0:
|
|
1788
|
+
if mq is None:
|
|
1789
|
+
q.tet_k = 0.0 # do not try it again
|
|
1790
|
+
q.passes = False
|
|
1791
|
+
tet_fail = max(tet_fail or 0.0, q.volume_fraction)
|
|
1792
|
+
continue
|
|
1793
|
+
lo = max((q.volume_fraction for q in outcomes
|
|
1794
|
+
if not q.passes and q.volume_fraction < o.volume_fraction), default=None)
|
|
1795
|
+
if mt < _TET_CLOSE or extra >= _TET_EXTRA_RUNS or \
|
|
1796
|
+
(lo is not None and o.volume_fraction - lo < max(0.005, 0.1 * o.volume_fraction)):
|
|
1797
|
+
break
|
|
1798
|
+
nxt = max(o.volume_fraction * 1.03 / mt, 0.5 * o.volume_fraction)
|
|
1799
|
+
if lo is not None:
|
|
1800
|
+
nxt = max(nxt, 0.5 * (lo + o.volume_fraction))
|
|
1801
|
+
nxt = float(np.clip(nxt, _VF_MIN, _VF_MAX))
|
|
1802
|
+
# already tried there; inside a bracket the bracket sets the
|
|
1803
|
+
# scale (the bell crank confirmed 7.03%, SF 4.85 for 3, with
|
|
1804
|
+
# 6.25% failing at 2.93, and its midpoint 6.64% sat under
|
|
1805
|
+
# 0.005 from 6.25%, so nothing in between was tried)
|
|
1806
|
+
tol = 0.005 if lo is None else min(0.005, 0.25 * (o.volume_fraction - lo))
|
|
1807
|
+
if any(abs(nxt - q.volume_fraction) < tol for q in outcomes):
|
|
1808
|
+
break
|
|
1809
|
+
outcomes.append(run_one(nxt))
|
|
1810
|
+
calibrate(outcomes[-1], k)
|
|
1811
|
+
extra += 1
|
|
1812
|
+
if not voxel_confirm and not (should_stop is not None and should_stop()):
|
|
1813
|
+
layout_search(k)
|
|
1814
|
+
confirmed_ok = [i for i, q in enumerate(outcomes) if getattr(q, "tet_k", None) is not None and q.passes]
|
|
1815
|
+
if not voxel_confirm:
|
|
1816
|
+
# deliver only what the tet mesh confirmed; the rest keep
|
|
1817
|
+
# their calibrated reading, for the record
|
|
1818
|
+
for i, q in enumerate(outcomes):
|
|
1819
|
+
if i not in confirmed_ok:
|
|
1820
|
+
q.passes = False
|
|
1821
|
+
best = min(confirmed_ok, key=lambda i: outcomes[i].report["mass"]) if confirmed_ok else None
|
|
1822
|
+
else:
|
|
1823
|
+
for q in outcomes:
|
|
1824
|
+
if getattr(q, "tet_k", None) is None:
|
|
1825
|
+
calibrate(q, 1.0)
|
|
1826
|
+
passing = [i for i, q in enumerate(outcomes) if q.passes]
|
|
1827
|
+
best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
|
|
1828
|
+
# Confirm the pick on a finer grid; if it fails there, it no longer
|
|
1829
|
+
# passes, and the search steps up from it by the confirmed margin.
|
|
1830
|
+
# Once a pick confirms, bisect back down into the gap to the fraction
|
|
1831
|
+
# that failed confirmation while that gap is over 10%: the step up
|
|
1832
|
+
# overshoots (GRIP's wrist yoke failed at 0.080 and confirmed at
|
|
1833
|
+
# 0.102, 27.5 g, with SF 2 near 0.09).
|
|
1834
|
+
confirmed, conf_fail = set(), None
|
|
1835
|
+
for _ in range(2 * _CONFIRM_RUNS if voxel_confirm else 0):
|
|
1836
|
+
if best is None or (should_stop is not None and should_stop()):
|
|
1837
|
+
break
|
|
1838
|
+
o = outcomes[best]
|
|
1839
|
+
if best not in confirmed:
|
|
1840
|
+
emit({"stage": "confirm", "volumeFraction": o.volume_fraction})
|
|
1841
|
+
mc = _confirm(o)
|
|
1842
|
+
emit({"stage": "confirmed", "volumeFraction": o.volume_fraction, "margin": mc})
|
|
1843
|
+
if mc < 1.0:
|
|
1844
|
+
o.passes = False
|
|
1845
|
+
conf_fail = max(conf_fail or 0.0, o.volume_fraction)
|
|
1846
|
+
up = [i for i in confirmed if outcomes[i].volume_fraction > o.volume_fraction]
|
|
1847
|
+
if up:
|
|
1848
|
+
best = min(up, key=lambda i: outcomes[i].report["mass"])
|
|
1849
|
+
continue
|
|
1850
|
+
vf = float(np.clip(o.volume_fraction * 1.05 / max(mc, 1e-9), o.volume_fraction + 0.01, _VF_MAX))
|
|
1851
|
+
if any(abs(vf - q.volume_fraction) < 0.005 for q in outcomes):
|
|
1852
|
+
vf = min(vf + 0.02, _VF_MAX)
|
|
1853
|
+
if memory is not None:
|
|
1854
|
+
vf = memory.snap(vf, skip=[q.volume_fraction for q in outcomes])
|
|
1855
|
+
outcomes.append(run_one(vf))
|
|
1856
|
+
passing = [i for i, q in enumerate(outcomes) if q.passes]
|
|
1857
|
+
best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
|
|
1858
|
+
continue
|
|
1859
|
+
confirmed.add(best)
|
|
1860
|
+
if conf_fail is None or o.volume_fraction - conf_fail < max(0.005, 0.1 * o.volume_fraction):
|
|
1861
|
+
break
|
|
1862
|
+
q = run_one(0.5 * (conf_fail + o.volume_fraction))
|
|
1863
|
+
outcomes.append(q)
|
|
1864
|
+
if q.passes:
|
|
1865
|
+
best = len(outcomes) - 1
|
|
1866
|
+
else:
|
|
1867
|
+
conf_fail = q.volume_fraction
|
|
1868
|
+
# The voxel-confirmed counterpart of layout_search: a fraction below
|
|
1869
|
+
# the pick that failed warm, carried down from the pick's layout, is
|
|
1870
|
+
# run once more from its own coarse layout. The warm chain can lose
|
|
1871
|
+
# the layout on the way down: GRIP's MJF wrist link (no tet confirm,
|
|
1872
|
+
# a layered process) read SF 1.42 at 5.25% warm from 6% (tets 1.16)
|
|
1873
|
+
# and 2.06 started fresh (tets 4.49), 37.2 g against the 41.0 g pick.
|
|
1874
|
+
# Each fresh design that passes is confirmed on the finer grid like
|
|
1875
|
+
# any pick; one that holds becomes the pick, and the next failed
|
|
1876
|
+
# fraction below it is tried the same way.
|
|
1877
|
+
cold_runs = 0
|
|
1878
|
+
while voxel_confirm and best is not None and best in confirmed and cold_runs < _LAYOUT_RUNS \
|
|
1879
|
+
and not (should_stop is not None and should_stop()):
|
|
1880
|
+
o = outcomes[best]
|
|
1881
|
+
if o.report["margin"] < _TET_CLOSE:
|
|
1882
|
+
break
|
|
1883
|
+
fresh = [q.volume_fraction for q in outcomes if str(q.report.get("start", "")).startswith(
|
|
1884
|
+
("cold", "layout search: cold"))]
|
|
1885
|
+
lost = [q.volume_fraction for q in outcomes
|
|
1886
|
+
if not q.passes and q.volume_fraction < o.volume_fraction and q.voxel_sf
|
|
1887
|
+
and q.report.get("bodies", 1) <= 1 and str(q.report.get("start", "")).startswith("warm")
|
|
1888
|
+
and q.volume_fraction > o.volume_fraction * 1.03 / max(o.report["margin"], 1e-9)
|
|
1889
|
+
and not any(abs(q.volume_fraction - v) < 0.003 for v in fresh)]
|
|
1890
|
+
if not lost:
|
|
1891
|
+
break
|
|
1892
|
+
vf = max(lost)
|
|
1893
|
+
emit({"stage": "optimize", "volumeFraction": vf, "start": "layout search: cold"})
|
|
1894
|
+
try:
|
|
1895
|
+
if domain.n_elem >= _COARSE_MIN_CELLS:
|
|
1896
|
+
x0 = _coarse_start(study, domain, vf, radius, should_stop)
|
|
1897
|
+
q = attempt(vf, x0, _WARM_BETA if x0 is not None else 1.0)
|
|
1898
|
+
else:
|
|
1899
|
+
q = attempt(vf, None, 1.0)
|
|
1900
|
+
except RuntimeError:
|
|
1901
|
+
break
|
|
1902
|
+
cold_runs += 1
|
|
1903
|
+
q.report["start"] = "layout search: cold"
|
|
1904
|
+
outcomes.append(q)
|
|
1905
|
+
emit({"stage": "outcome", **q.summary()})
|
|
1906
|
+
if not q.passes:
|
|
1907
|
+
break # fresh fails too: the fractions below it would not do better
|
|
1908
|
+
emit({"stage": "confirm", "volumeFraction": q.volume_fraction})
|
|
1909
|
+
mc = _confirm(q)
|
|
1910
|
+
emit({"stage": "confirmed", "volumeFraction": q.volume_fraction, "margin": mc})
|
|
1911
|
+
if mc < 1.0:
|
|
1912
|
+
q.passes = False
|
|
1913
|
+
break
|
|
1914
|
+
best = len(outcomes) - 1
|
|
1915
|
+
confirmed.add(best)
|
|
1916
|
+
else:
|
|
1917
|
+
fractions = [float(goal.volume_fraction)] + [v for v in goal.outcomes if v != goal.volume_fraction]
|
|
1918
|
+
if len(fractions) > 1 and _workers(len(fractions), cells=_study_cells(study)) > 1:
|
|
1919
|
+
import dataclasses
|
|
1920
|
+
singles = [dataclasses.replace(study, goal=dataclasses.replace(goal, volume_fraction=vf, outcomes=[]))
|
|
1921
|
+
for vf in fractions]
|
|
1922
|
+
for r in _parallel(singles, None, should_stop):
|
|
1923
|
+
outcomes.extend(r.outcomes)
|
|
1924
|
+
for o in r.outcomes:
|
|
1925
|
+
emit({"stage": "outcome", **o.summary()})
|
|
1926
|
+
else:
|
|
1927
|
+
for vf in fractions:
|
|
1928
|
+
if should_stop is not None and should_stop():
|
|
1929
|
+
break
|
|
1930
|
+
outcomes.append(run_one(vf))
|
|
1931
|
+
best = 0 if outcomes else None
|
|
1932
|
+
|
|
1933
|
+
if memory is not None:
|
|
1934
|
+
memory.save()
|
|
1935
|
+
info["recall"] = dict(memory.used)
|
|
1936
|
+
return StudyResult(study=study, domain=info, outcomes=outcomes, best=best,
|
|
1937
|
+
seconds=time.time() - t0, warnings=list(domain.warnings))
|