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.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/tune.py ADDED
@@ -0,0 +1,437 @@
1
+ """
2
+ Tune a field graph's numbers against a simulation of the part it makes.
3
+
4
+ A field graph is a recipe with knobs (a web's height, a fillet, a shell's
5
+ thickness, a lattice's wall). `tune_field` turns chosen knobs until the part
6
+ is as light as it can be while it still carries the study's loads: every
7
+ candidate is meshed, analyzed by `verify_part` (the same FE check a study's
8
+ outcome gets) and scored by mass, worst-case safety factor and deflection.
9
+ The search is COBYQA over the knobs scaled to [0, 1] (derivative-free: an
10
+ analysis on a voxel grid is not smooth in its inputs), and what comes back
11
+ is the lightest candidate that met every limit, checked again on a grid
12
+ twice as fine (and, for a part with a thin shell, on two grids with at
13
+ least two cells through its thinnest wall, CELLS_THROUGH_WALL), not
14
+ wherever the search stopped. A candidate that fails that check is never
15
+ returned as ok. A part with a lattice in
16
+ it is analyzed with the lattice homogenized (homogenized.py): its walls are
17
+ far finer than a grid a search can afford.
18
+
19
+ The spec lives in the graph under `tune`:
20
+
21
+ "tune": {"study": "example:arm-link",
22
+ "params": [{"path": "nodes/web/shape/max/2", "also": ["-nodes/web/shape/min/2"],
23
+ "min": 3, "max": 9}],
24
+ "safetyFactor": 3, "maxDeflection": 0.5, "evaluations": 20}
25
+
26
+ `path` is where the number sits in the graph ("/"-separated, the same keys
27
+ the editor's sliders use); `also` are numbers that move with it, "-" in
28
+ front for ones that move the other way (a box's min when its max grows).
29
+ Limits default to the study's goal.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import copy
34
+ import json
35
+ import os
36
+ import time
37
+
38
+ import numpy as np
39
+
40
+ __all__ = ["tune_field", "get_at", "set_at", "thinnest_wall", "check_cells"]
41
+
42
+ # A thin wall read on a grid about one cell through it moves 10-35% as the
43
+ # wall slides across the grid (the arm-link sandwich: 0.554 mm on 120k
44
+ # cells, 0.748 on 240k, 0.569 on 500k, 0.580 on 1M). From about two cells
45
+ # through the thinnest wall the readings settle to a few percent, so the
46
+ # confirming check runs there, on two grids, and both must pass.
47
+ CELLS_THROUGH_WALL = 2.0
48
+ MAX_CHECK_CELLS = 2_000_000
49
+
50
+
51
+ def _keys(path):
52
+ neg = path.startswith("-")
53
+ keys = [int(k) if k.lstrip("-").isdigit() else k for k in path.lstrip("-").split("/")]
54
+ return neg, keys
55
+
56
+
57
+ def get_at(doc, path):
58
+ neg, keys = _keys(path)
59
+ v = doc
60
+ for k in keys:
61
+ try:
62
+ v = v[k]
63
+ except (KeyError, IndexError, TypeError):
64
+ raise ValueError(f"tune: nothing at {path!r}") from None
65
+ if not isinstance(v, (int, float)) or isinstance(v, bool):
66
+ raise ValueError(f"tune: {path!r} is not a number ({v!r})")
67
+ return -float(v) if neg else float(v)
68
+
69
+
70
+ def set_at(doc, path, value):
71
+ neg, keys = _keys(path)
72
+ v = doc
73
+ for k in keys[:-1]:
74
+ v = v[k]
75
+ v[keys[-1]] = -float(value) if neg else float(value)
76
+
77
+
78
+ def _study(spec, base_dir, material=None):
79
+ from .fields import load_study
80
+
81
+ s = spec.get("study")
82
+ if not isinstance(s, str):
83
+ raise ValueError("tune needs a 'study' (a study JSON path or example:<name>) to load the part with")
84
+ return load_study(s, base_dir, material)
85
+
86
+
87
+ def tune_field(doc, base_dir=None, out_dir=None, progress=None, evaluations=None):
88
+ """Tune `doc["tune"]["params"]` for the lightest part that meets the
89
+ limits. Returns {"best", "start", "history", "params", "doc"} where
90
+ `doc` is the graph with the best values written in (and `tune` kept);
91
+ with `out_dir`, also writes tuned.json and tune.json there."""
92
+ from scipy.optimize import minimize
93
+
94
+ from .editor import _Resources
95
+ from .fields import FieldGraph, expand_groups, node_doc
96
+ from .homogenized import homogenized_check, lattice_nodes
97
+ from .verify import verify_part
98
+
99
+ spec = doc.get("tune")
100
+ if not isinstance(spec, dict):
101
+ raise ValueError("the graph has no 'tune' block (study, params, limits)")
102
+ params = spec.get("params") or []
103
+ if not params:
104
+ raise ValueError("tune: give at least one param {path, min, max}")
105
+ for p in params:
106
+ if not (isinstance(p.get("min"), (int, float)) and isinstance(p.get("max"), (int, float))
107
+ and p["max"] > p["min"]):
108
+ raise ValueError(f"tune: param {p.get('path')!r} needs min < max")
109
+ for q in [p["path"], *p.get("also", [])]:
110
+ get_at(doc, q)
111
+ study = _study(spec, base_dir, doc.get("material"))
112
+ goal = study.goal
113
+ lim = {"safetyFactor": spec.get("safetyFactor", goal.safety_factor),
114
+ "maxDeflection": spec.get("maxDeflection", goal.max_deflection)}
115
+ if lim["safetyFactor"] is None and lim["maxDeflection"] is None:
116
+ raise ValueError("tune: no limit to hold (safetyFactor or maxDeflection, here or in the study's goal)")
117
+ sweep = spec.get("sweep") or {}
118
+ if sweep and (len(sweep) != 1 or next(iter(sweep)) not in lim
119
+ or not all(isinstance(v, (int, float)) and v > 0 for v in next(iter(sweep.values())))):
120
+ raise ValueError("tune.sweep is one limit and its values: {\"maxDeflection\": [0.4, 0.6]} "
121
+ "or {\"safetyFactor\": [2, 4]}")
122
+ node = spec.get("node") or doc.get("output")
123
+ pitch, cells = float(spec.get("pitch", 1.0)), int(spec.get("cells", 30_000))
124
+ max_check = int(spec.get("checkCells", MAX_CHECK_CELLS))
125
+ from . import quick
126
+ budget = quick.tune_evaluations(int(evaluations or spec.get("evaluations", 20)))
127
+
128
+ # a lattice's walls are finer than any analysis grid a search can
129
+ # afford: a part with one is analyzed with the lattice homogenized
130
+ analysis = spec.get("analysis", "auto")
131
+ if analysis not in ("auto", "solid", "homogenized"):
132
+ raise ValueError("tune.analysis must be auto, solid or homogenized")
133
+ probe = FieldGraph(node_doc(doc, node) if node else doc, base_dir=base_dir)
134
+ homogenized = analysis == "homogenized" or (analysis == "auto" and bool(lattice_nodes(probe)))
135
+ resources = _Resources() # a `result` node's study runs once, not once per candidate
136
+ lo = np.array([p["min"] for p in params], float)
137
+ hi = np.array([p["max"] for p in params], float)
138
+ start = np.array([get_at(doc, p["path"]) for p in params])
139
+ # the other numbers keep their offset from the param (a box's min tracks its max)
140
+ offsets = [[get_at(doc, q) - s for q in p.get("also", [])] for p, s in zip(params, start)]
141
+ base = {k: v for k, v in doc.items() if k != "tune"}
142
+
143
+ def apply(values):
144
+ d = copy.deepcopy(base)
145
+ for p, v, offs in zip(params, values, offsets):
146
+ set_at(d, p["path"], v)
147
+ for q, o in zip(p.get("also", []), offs):
148
+ set_at(d, q, v + o)
149
+ return d
150
+
151
+ history, memo = [], {}
152
+ # The search runs on a coarse grid, whose readings drift from a finer
153
+ # one's by a few percent (more on thin sections). The start is analyzed
154
+ # on both and every coarse reading is scaled by that ratio before it is
155
+ # held to the limits.
156
+ bias = {"safetyFactor": 1.0, "deflection": 1.0}
157
+
158
+ def judge(rec):
159
+ if "error" in rec:
160
+ rec["ok"] = False
161
+ return rec
162
+ raw = rec.setdefault("coarse", {"safetyFactor": rec["safetyFactor"], "deflection": rec["deflection"]})
163
+ rec["safetyFactor"] = raw["safetyFactor"] * bias["safetyFactor"]
164
+ rec["deflection"] = raw["deflection"] * bias["deflection"]
165
+ rec["ok"] = margin(rec) >= 0
166
+ return rec
167
+
168
+ def evaluate(x):
169
+ x = np.clip(np.asarray(x, float), 0.0, 1.0)
170
+ key = tuple(np.round(x, 6))
171
+ if key in memo:
172
+ return memo[key]
173
+ rec = judge(analyze(lo + x * (hi - lo), cells))
174
+ history.append(rec)
175
+ memo[key] = rec
176
+ if progress:
177
+ progress({"event": "tune", "evaluation": len(history), "of": budget,
178
+ **{k: v for k, v in rec.items() if isinstance(v, (int, float, bool, str))},
179
+ "values": ", ".join(f"{v:.3g}" for v in rec["values"])})
180
+ return rec
181
+
182
+ def analyze(values, n_cells):
183
+ """mass, worst safety factor and deflection of the part at these
184
+ values, uncorrected; `ok` against the limits as read."""
185
+ t0 = time.time()
186
+ d = apply(values)
187
+ rec = {"values": [float(v) for v in values]}
188
+ try:
189
+ dd = node_doc(d, node) if node else expand_groups(d)
190
+ g = resources.graph(dd, base_dir)
191
+ if homogenized:
192
+ rep = homogenized_check(dd, study, base_dir=base_dir, cells=n_cells, graph=g, solid_ok=True)
193
+ else:
194
+ rep = verify_part(study, g.mesh(pitch=pitch, project=1), max_cells=n_cells, refine=0, fractional=True)
195
+ resources.keep(g, dd)
196
+ rec.update(mass=rep["mass"], safetyFactor=rep["safetyFactor"],
197
+ deflection=max((c.get("deflection") or 0.0) for c in rep["cases"]),
198
+ bodies=rep["bodies"])
199
+ rec["ok"] = margin(rec) >= 0
200
+ except Exception as exc: # noqa: BLE001 - a knob setting that breaks the part is a bad candidate
201
+ rec.update(error=str(exc), ok=False)
202
+ rec["seconds"] = round(time.time() - t0, 1)
203
+ return rec
204
+
205
+ def confirm(values, rec=None):
206
+ """The candidate on the confirming grid (check_cells): twice the
207
+ search's cells, more for a thin shell, and then on a second grid a
208
+ little coarser too, so a wall that happens to sit well on one grid
209
+ can't pass; the worse reading of the two counts."""
210
+ try:
211
+ d = apply(values)
212
+ dd = node_doc(d, node) if node else expand_groups(d)
213
+ g = resources.graph(dd, base_dir)
214
+ vol = None
215
+ if not homogenized and rec is not None and "mass" in rec:
216
+ vol = rec["mass"] * 1000.0 / float(study.material.density)
217
+ n, wall = check_cells(g, None, cells, homogenized, volume=vol, cap=max_check)
218
+ except Exception as exc: # noqa: BLE001
219
+ return {"values": [float(v) for v in values], "error": str(exc), "ok": False}
220
+ out = analyze(values, n)
221
+ out["cells"], out["thinnestWall"] = [n], wall
222
+ if wall is not None and n > 2 * cells and "error" not in out:
223
+ other = analyze(values, int(0.7 * n))
224
+ out["cells"].append(int(0.7 * n))
225
+ if "error" in other:
226
+ return {**out, "error": other["error"], "ok": False}
227
+ out["safetyFactor"] = min(out["safetyFactor"], other["safetyFactor"])
228
+ out["deflection"] = max(out["deflection"], other["deflection"])
229
+ out["seconds"] = round(out["seconds"] + other["seconds"], 1)
230
+ out["ok"] = margin(out) >= 0
231
+ return out
232
+
233
+ def margin(rec):
234
+ """>= 0 when every limit holds; the worst limit's relative slack."""
235
+ if "error" in rec:
236
+ return -1.0
237
+ m = [] if rec["bodies"] == 1 else [-0.5]
238
+ if lim["safetyFactor"] is not None:
239
+ m.append(rec["safetyFactor"] / lim["safetyFactor"] - 1.0)
240
+ if lim["maxDeflection"] is not None:
241
+ m.append(1.0 - rec["deflection"] / max(lim["maxDeflection"], 1e-12))
242
+ return min(m)
243
+
244
+ def report_check(r, check):
245
+ r["check"] = {k: check.get(k) for k in ("safetyFactor", "deflection", "ok", "seconds", "cells",
246
+ "thinnestWall")}
247
+ if progress:
248
+ progress({"event": "tune-check", "mass": r.get("mass"),
249
+ **{k: v for k, v in r["check"].items() if v is not None},
250
+ "values": ", ".join(f"{v:.3g}" for v in r["values"])})
251
+
252
+ first, first_fine = evaluate((start - lo) / (hi - lo)), None
253
+ if "error" not in first:
254
+ fine = confirm(first["values"], first)
255
+ if "error" not in fine:
256
+ for k in bias:
257
+ c = first["coarse"][k]
258
+ bias[k] = fine[k] / c if c > 0 else 1.0
259
+ judge(first)
260
+ first_fine = fine
261
+ m0 = first.get("mass") or 1.0
262
+ cons = [{"type": "ineq", "fun": lambda x: margin(evaluate(x))}]
263
+
264
+ def objective(x):
265
+ rec = evaluate(x)
266
+ return rec["mass"] / m0 if "mass" in rec else 10.0
267
+
268
+ def search(x0, fev=None):
269
+ # COBYQA keeps every candidate inside the knobs' ranges and spends
270
+ # one analysis per point (objective and limits come from the same
271
+ # one, cached across searches)
272
+ minimize(objective, x0, method="COBYQA", bounds=[(0.0, 1.0)] * len(params), constraints=cons,
273
+ options={"initial_tr_radius": 0.25, "final_tr_radius": 5e-3, "maxfev": max(fev or budget, 2)})
274
+
275
+ checks = {}
276
+
277
+ def learn(r, check):
278
+ """A candidate that passed on the search's grid failed on the
279
+ confirming one: the search's correction was too kind there. Take
280
+ the ratio this check saw, if it is worse than the one in use, and
281
+ judge every candidate again by it."""
282
+ raw = r.get("coarse")
283
+ if not raw or "error" in check:
284
+ return
285
+ for k, worse in (("deflection", max), ("safetyFactor", min)):
286
+ if raw.get(k) and check.get(k):
287
+ bias[k] = worse(bias[k], check[k] / raw[k])
288
+ for h in history:
289
+ judge(h)
290
+
291
+ def pick(tries: int = 4):
292
+ """The lightest candidate that meets the current limits on the fine
293
+ grid too. The coarse/fine ratio holds only roughly away from the
294
+ start, and a voxel reading moves a few percent as the part slides
295
+ across its grid: the lightest that passes is analyzed again on the
296
+ confirming grid (confirm); if it fails there, the ratio it showed
297
+ is learned (learn) and the next lightest that still passes is
298
+ tried, up to `tries` checks."""
299
+ for _ in range(tries):
300
+ good = sorted((r for r in history if "error" not in r and margin(r) >= 0), key=lambda r: r["mass"])
301
+ r = next((g for g in good if tuple(g["values"]) not in checks), None)
302
+ if r is None:
303
+ break
304
+ key = tuple(r["values"])
305
+ checks[key] = first_fine if r is first and first_fine is not None else confirm(r["values"], r)
306
+ check = checks[key]
307
+ report_check(r, {**check, "ok": "error" not in check and margin(check) >= 0})
308
+ if r["check"]["ok"]:
309
+ return {**r, "ok": True}
310
+ learn(r, check)
311
+ for key, check in checks.items():
312
+ # an earlier check of a candidate that still passes
313
+ r = next((h for h in history if tuple(h["values"]) == key), None)
314
+ if r is not None and "error" not in check and margin(check) >= 0:
315
+ return {**r, "ok": True}
316
+ # passed on the search's grid, not on the confirming one: not a
317
+ # result to build, so not ok
318
+ good = sorted((r for r in history if "error" not in r and margin(r) >= 0), key=lambda r: r["mass"])
319
+ return {**good[0], "ok": False, "unconfirmed": True} if good else None
320
+
321
+ def x_of(r):
322
+ return (np.asarray(r["values"]) - lo) / (hi - lo)
323
+
324
+ search(np.clip(x_of(first), 0.0, 1.0))
325
+ best = pick()
326
+ if best is not None and best.get("unconfirmed"):
327
+ # the corrections learned from the failed checks move the limit:
328
+ # search again from the lightest candidate that meets it now
329
+ near = [r for r in history if "error" not in r and margin(r) >= 0]
330
+ x0 = x_of(min(near, key=lambda r: r["mass"])) if near else x_of(first)
331
+ if progress:
332
+ progress({"event": "tune-research", "bias": dict(bias)})
333
+ search(np.clip(x0, 0.0, 1.0), fev=max(budget // 2, 4))
334
+ best = pick()
335
+ out = {"best": best, "start": first, "analysis": "homogenized" if homogenized else "solid",
336
+ "limits": dict(lim), "params": [{"path": p["path"], "min": p["min"], "max": p["max"]} for p in params]}
337
+ if sweep:
338
+ # the trade-off: the same search held to each value of one limit in
339
+ # turn, each warm-started from the lightest candidate that already
340
+ # meets it (or the start)
341
+ key, values = next(iter(sweep.items()))
342
+ base_limit, out["sweep"] = lim[key], []
343
+ for v in values:
344
+ lim[key] = float(v)
345
+ near = [r for r in history if "error" not in r and margin(r) >= 0]
346
+ x0 = x_of(min(near, key=lambda r: r["mass"])) if near else x_of(first)
347
+ if progress:
348
+ progress({"event": "tune-sweep", key: float(v)})
349
+ search(np.clip(x0, 0.0, 1.0))
350
+ out["sweep"].append({key: float(v), "best": pick()})
351
+ lim[key] = base_limit
352
+ out["history"] = history
353
+
354
+ def assign(values):
355
+ """Every number these values set, as written into the graph."""
356
+ d = apply(values)
357
+ return [[q.lstrip("-"), get_at(d, q.lstrip("-"))] for p in params for q in [p["path"], *p.get("also", [])]]
358
+
359
+ out["front"] = [{**r, "assign": assign(r["values"])} for r in _front(history)]
360
+ for sp in out.get("sweep", []):
361
+ if sp["best"] is not None:
362
+ sp["best"]["assign"] = assign(sp["best"]["values"])
363
+ if best is not None:
364
+ best["assign"] = assign(best["values"])
365
+ tuned = doc if best is None else {**apply(best["values"]), "tune": doc["tune"]}
366
+ out["doc"] = tuned
367
+ if out_dir:
368
+ os.makedirs(out_dir, exist_ok=True)
369
+ with open(os.path.join(out_dir, "tuned.json"), "w") as fh:
370
+ json.dump(tuned, fh, indent=2)
371
+ fh.write("\n")
372
+ with open(os.path.join(out_dir, "tune.json"), "w") as fh:
373
+ json.dump({k: v for k, v in out.items() if k != "doc"}, fh, indent=2)
374
+ fh.write("\n")
375
+ return out
376
+
377
+
378
+ def thinnest_wall(graph, name=None, samples: int = 20_000):
379
+ """The thinnest shell wall (mm) in the part: every `shell` node the
380
+ output depends on, its thickness a number or the least its field takes
381
+ over the part's box. None when the part has no shell."""
382
+ name = name or graph.output
383
+ b = graph.bounds(name)
384
+ pts = None
385
+ seen, todo, walls = set(), [name], []
386
+ while todo:
387
+ k = todo.pop()
388
+ if k in seen or k not in graph.nodes:
389
+ continue
390
+ seen.add(k)
391
+ todo.extend(graph.deps.get(k, []))
392
+ nd = graph.nodes[k]
393
+ if nd.op != "shell":
394
+ continue
395
+ ref = nd.refs.get("thickness", nd.spec.get("thickness"))
396
+ if isinstance(ref, (int, float)) and not isinstance(ref, bool):
397
+ walls.append(float(ref))
398
+ elif isinstance(ref, str) and b is not None:
399
+ if pts is None:
400
+ pts = np.random.default_rng(0).uniform(b[0], b[1], (samples, 3))
401
+ t = np.asarray(graph.evaluate(pts, ref), float)
402
+ t = t[np.isfinite(t) & (t > 0)]
403
+ if t.size:
404
+ walls.append(float(t.min()))
405
+ return min(walls) if walls else None
406
+
407
+
408
+ def check_cells(graph, name, cells: int, homogenized: bool, volume: float | None = None,
409
+ cap: int = MAX_CHECK_CELLS):
410
+ """Analysis cells for the confirming check: twice the search's, and
411
+ enough for CELLS_THROUGH_WALL cells through the thinnest shell wall
412
+ (capped at `cap`). Returns (cells, thinnest wall or None)."""
413
+ wall = thinnest_wall(graph, name)
414
+ n = 2 * cells
415
+ if wall is not None:
416
+ pitch = wall / CELLS_THROUGH_WALL
417
+ if homogenized:
418
+ lo, hi = graph.bounds(name)
419
+ need = float(np.prod(np.asarray(hi) - np.asarray(lo))) * 0.6 / pitch ** 3
420
+ else:
421
+ need = (volume or 0.0) / pitch ** 3
422
+ n = max(n, int(min(need, cap)))
423
+ return n, wall
424
+
425
+
426
+ def _front(history):
427
+ """The candidates no other beats on mass, deflection and safety factor
428
+ all at once (coarse readings, corrected), lightest first."""
429
+ rs = [r for r in history if "error" not in r and r.get("bodies") == 1]
430
+ out = []
431
+ for r in rs:
432
+ if not any(o["mass"] <= r["mass"] and o["deflection"] <= r["deflection"]
433
+ and o["safetyFactor"] >= r["safetyFactor"]
434
+ and (o["mass"], o["deflection"], -o["safetyFactor"]) != (r["mass"], r["deflection"], -r["safetyFactor"])
435
+ for o in rs):
436
+ out.append({k: r[k] for k in ("values", "mass", "deflection", "safetyFactor")})
437
+ return sorted(out, key=lambda r: r["mass"])
loadpath/validation.py ADDED
@@ -0,0 +1,145 @@
1
+ """
2
+ Published experiments loadpath's models are checked against.
3
+
4
+ `data/published_experiments.json` holds printed lattice specimens from the
5
+ literature whose geometry is fully specified (cell type, cell size, wall or
6
+ strut, specimen size) with their measured compression modulus and strength
7
+ and the solid material's properties, each with its source. This module
8
+ turns a specimen into the inputs loadpath's checks take:
9
+
10
+ - `table_prediction`: the periodic unit cell's reading (lattice_tensors),
11
+ modulus along the build axis and the stress at which the first wall
12
+ yields;
13
+ - `specimen_model`: the specimen as a field graph and study (lattice cut
14
+ to the cube, bonded 2 mm platens, the top one pushed down and held
15
+ sideways), for `homogenized_check` or a resolved voxel solve
16
+ (bench/published_experiments.py runs both).
17
+
18
+ Results and the comparison with the measurements: docs/VALIDATION.md and
19
+ bench/results/published-experiments.json.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from functools import lru_cache
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+
29
+ DATA_PATH = Path(__file__).parent / "data" / "published_experiments.json"
30
+
31
+ __all__ = ["load_experiments", "measured", "lattice_thickness", "geometry_key", "specimen_model", "process_kind",
32
+ "table_prediction"]
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def _load() -> str:
37
+ return DATA_PATH.read_text()
38
+
39
+
40
+ def load_experiments() -> dict:
41
+ """The published specimens, materials and sources (a fresh copy)."""
42
+ return json.loads(_load())
43
+
44
+
45
+ def measured(spec: dict) -> dict:
46
+ """Measured modulus and strength (MPa) of a specimen, with standard
47
+ deviations where the paper gives them. A source that published only a
48
+ power-law fit over its measured relative density is read at the
49
+ specimen's measured density (sd None)."""
50
+ m = spec["measured"]
51
+ if "fit" in m:
52
+ rho = float(m["rho"])
53
+ e = m["fit"]["E"][0] * rho ** m["fit"]["E"][1]
54
+ s = m["fit"]["strength"][0] * rho ** m["fit"]["strength"][1]
55
+ return {"E": e, "Esd": None, "strength": s, "strengthSd": None, "strengthKind": m["strengthKind"],
56
+ "rho": rho}
57
+ return {"E": m["E"][0], "Esd": m["E"][1], "strength": m["strength"][0], "strengthSd": m["strength"][1],
58
+ "strengthKind": m["strengthKind"], "rho": m.get("rho")}
59
+
60
+
61
+ def lattice_thickness(spec: dict) -> float:
62
+ """Wall (sheet) or strut diameter (mm) of the specimen's lattice: the
63
+ stated one, or the one that gives the paper's relative density when it
64
+ states that (the papers make sheets by thickening the level set, not
65
+ the distance, so matching the density is the fairer reading)."""
66
+ from .lattice import wall_for_density
67
+ from .struts import is_strut, strut_diameter_for_density
68
+
69
+ lat = spec["lattice"]
70
+ kind, cell = lat["type"], float(lat["cell"])
71
+ if is_strut(kind):
72
+ if "diameter" in lat:
73
+ return float(lat["diameter"])
74
+ return float(strut_diameter_for_density(kind, float(lat["rho"]), cell))
75
+ if "rho" in lat:
76
+ return float(wall_for_density(kind, float(lat["rho"]), cell))
77
+ return float(lat["wall"])
78
+
79
+
80
+ def _rho(spec: dict) -> float:
81
+ from .lattice import relative_density
82
+
83
+ lat = spec["lattice"]
84
+ return float(relative_density(lat["type"], lattice_thickness(spec), float(lat["cell"])))
85
+
86
+
87
+ def geometry_key(spec: dict) -> str:
88
+ lat = spec["lattice"]
89
+ size = "x".join(f"{float(s):g}" for s in spec["size"])
90
+ return f"{lat['type']}-c{float(lat['cell']):g}-t{lattice_thickness(spec):.4f}-{size}"
91
+
92
+
93
+ def table_prediction(spec: dict, mat: dict) -> dict:
94
+ """The infinite lattice's reading: modulus along z (MPa) and the
95
+ uniaxial z stress (MPa) at which its peak wall stress reaches the
96
+ solid's strength."""
97
+ from .lattice_tensors import directional_modulus, stress_surface, tensors_at
98
+
99
+ rho = _rho(spec)
100
+ c, p, q, f = tensors_at(spec["lattice"]["type"], rho)
101
+ e = directional_modulus(c[0], [0.0, 0.0, 1.0]) * float(mat["E"])
102
+ k = float(stress_surface(np.array([[0.0, 0.0, 1.0, 0.0, 0.0, 0.0]]), p[0], q[0], f[0])[0])
103
+ return {"rho": rho, "thickness": lattice_thickness(spec), "E": e, "firstYield": float(mat["strength"]) / k,
104
+ "stressFactor": k}
105
+
106
+
107
+ def specimen_model(spec: dict, mat: dict, plate: float = 2.0, stress: float = 1.0):
108
+ """(field graph, study dict, loaded area mm^2, lattice height mm) of a
109
+ compression specimen: the lattice (origin at the cube's corner) cut to
110
+ the cube, `plate` mm solid platens bonded below and above. The bottom
111
+ platen is held; the top one carries `stress` MPa down and is held
112
+ sideways, as a rough platen would."""
113
+ lat = spec["lattice"]
114
+ sx, sy, sz = (float(s) for s in spec["size"])
115
+ nodes = {
116
+ "lat": {"op": "lattice", "type": lat["type"], "cell": float(lat["cell"]),
117
+ "thickness": lattice_thickness(spec), "origin": [0.0, 0.0, 0.0]},
118
+ "box": {"op": "shape", "shape": {"type": "box", "min": [0, 0, 0], "max": [sx, sy, sz]}},
119
+ "fill": {"op": "intersect", "inputs": ["box", "lat"]},
120
+ "plates": {"op": "shape", "shape": {"type": "union", "shapes": [
121
+ {"type": "box", "min": [0, 0, -plate], "max": [sx, sy, 0]},
122
+ {"type": "box", "min": [0, 0, sz], "max": [sx, sy, sz + plate]}]}},
123
+ "part": {"op": "union", "inputs": ["fill", "plates"]},
124
+ }
125
+ doc = {"name": spec["id"], "nodes": nodes, "output": "part"}
126
+ area = sx * sy
127
+ study = {
128
+ "name": spec["id"],
129
+ "designSpace": [{"type": "box", "min": [0, 0, -plate], "max": [sx, sy, sz + plate]}],
130
+ "preserve": [{"id": "bottom", "shape": {"type": "box", "min": [0, 0, -plate], "max": [sx, sy, 0]}},
131
+ {"id": "top", "shape": {"type": "box", "min": [0, 0, sz], "max": [sx, sy, sz + plate]}}],
132
+ "supports": [{"region": "bottom"}, {"region": "top", "fix": [True, True, False]}],
133
+ "loadCases": [{"name": "compress", "loads": [{"region": "top", "force": [0, 0, -stress * area]}]}],
134
+ "material": {"E": float(mat["E"]), "nu": float(mat["nu"]), "strength": float(mat["strength"]),
135
+ "density": float(mat["density"])},
136
+ "goal": {"safetyFactor": 1.0},
137
+ }
138
+ return doc, study, area, sz
139
+
140
+
141
+ def process_kind(process: str) -> str:
142
+ """loadpath's process type for a specimen's process label ("SLS PA12",
143
+ "FDM ABS 0.25 mm", "LCD resin"): LCD is whole-layer vat exposure, dlp."""
144
+ word = str(process).split()[0].lower()
145
+ return {"lcd": "dlp"}.get(word, word)