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/recall.py ADDED
@@ -0,0 +1,406 @@
1
+ """
2
+ Design memory: what earlier runs of the same design space found, so a rerun
3
+ starts at the answer instead of searching for it again.
4
+
5
+ A safety-factor study spends most of its time finding the volume fraction:
6
+ the bell crank optimizes six fractions (0.25, 0.125, 0.0625, 0.094, 0.078,
7
+ 0.070) and confirms two picks on a finer grid before it settles, each
8
+ optimization 20-30 s and each confirm about a minute. Agents rerun studies
9
+ with small edits (another target, a stronger material, a heavier load), and
10
+ every rerun used to walk that whole path again.
11
+
12
+ What carries over, and why it is safe:
13
+
14
+ - The optimizer minimizes compliance at a fixed volume fraction. Its design
15
+ does not depend on the load's overall scale or the material's E or
16
+ strength, only on the domain, the loads' directions and relative sizes,
17
+ Poisson's ratio and the code. Stress is linear in load and in nothing else
18
+ here (verification is linear elastic). So for a past outcome on the same
19
+ domain under the same load pattern, its safety factor and deflection under
20
+ the new study are its old ones rescaled by strength, E and load scale, and
21
+ its margin is known before anything runs.
22
+ - With the same model (Poisson's ratio, process, code) a past outcome is
23
+ *replayed*: the stored density field is meshed and verified afresh (the
24
+ part delivered is always verified by today's code) and the optimizer is
25
+ skipped. The fine-grid confirmation of a replayed pick is rescaled from
26
+ the stored one rather than run again.
27
+ - With a different model (another Poisson's ratio, a changed solver), the
28
+ stored margins still say roughly where the answer is, and the nearest
29
+ stored design is the starting design instead of a coarse solve.
30
+
31
+ Off unless asked for: LOADPATH_RECALL=1 (or a folder path) turns it on for
32
+ everything, `loadpath run --recall` for one run. Eval runs leave it off so a
33
+ scorecard measures the optimizer, not the cache. Stored under
34
+ ~/.cache/loadpath/designs, one folder per domain; LOADPATH_RECALL_MB caps the
35
+ total (default 1000, oldest domains dropped first).
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import json
41
+ import os
42
+ import shutil
43
+ import time
44
+ from dataclasses import dataclass
45
+
46
+ import numpy as np
47
+
48
+ __all__ = ["Recall", "enabled"]
49
+
50
+ _MAX_ENTRIES = 32 # per domain, newest kept
51
+ # A replayed fraction stands in for a requested one this close to it
52
+ # (relative): the search's own stopping rule accepts a bracket of 10%.
53
+ _SNAP = 0.04
54
+
55
+
56
+ def enabled() -> bool:
57
+ v = os.environ.get("LOADPATH_RECALL", "").strip()
58
+ return bool(v) and v.lower() not in ("0", "false", "off", "no")
59
+
60
+
61
+ def _root() -> str:
62
+ v = os.environ.get("LOADPATH_RECALL", "").strip()
63
+ if v and v not in ("1", "true", "on", "yes") and (os.sep in v or v.startswith(".")):
64
+ return os.path.expanduser(v)
65
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
66
+ return os.path.join(base, "loadpath", "designs")
67
+
68
+
69
+ def _h(*parts) -> str:
70
+ h = hashlib.sha1()
71
+ for p in parts:
72
+ if isinstance(p, np.ndarray):
73
+ h.update(str((p.dtype.str, p.shape)).encode())
74
+ h.update(np.ascontiguousarray(p).tobytes())
75
+ elif hasattr(p, "tocsr"):
76
+ c = p.tocsr()
77
+ h.update(_h(c.indptr, c.indices, np.round(c.data, 9)).encode())
78
+ else:
79
+ h.update(repr(p).encode())
80
+ h.update(b"|")
81
+ return h.hexdigest()[:20]
82
+
83
+
84
+ _CODE = None
85
+
86
+
87
+ # The modules a design, its mesh and its verification come out of (and what
88
+ # they import). A change to any of them retires replays; a change elsewhere
89
+ # (the search in study.py, rendering, the server) leaves them standing.
90
+ _DESIGN_CODE = ("am", "constrained", "domain", "durability", "fasteners", "fem", "manufacture", "materials",
91
+ "mg", "mma", "pressure", "printcheck", "reconstruct", "shapes", "simp", "spec", "thermal",
92
+ "verify", "voxels")
93
+
94
+
95
+ def _code_key() -> str:
96
+ global _CODE
97
+ if _CODE is None:
98
+ here = os.path.dirname(os.path.abspath(__file__))
99
+ h = hashlib.sha1()
100
+ for m in _DESIGN_CODE:
101
+ with open(os.path.join(here, m + ".py"), "rb") as fh:
102
+ h.update(fh.read())
103
+ _CODE = h.hexdigest()[:20]
104
+ return _CODE
105
+
106
+
107
+ def _load_scale(domain) -> float:
108
+ s = max((float(np.abs(f).max()) for f in domain.loads if f is not None and np.size(f)), default=0.0)
109
+ return s if s > 0 else 1.0
110
+
111
+
112
+ @dataclass
113
+ class _Pred:
114
+ entry: dict
115
+ margin: float | None # predicted, under this study
116
+ confirm: float | None # predicted fine-grid margin, when one is stored
117
+ exact: bool # same model: replayable
118
+
119
+
120
+ class Recall:
121
+ """One study's view of the memory for its domain. Build with `open`."""
122
+
123
+ def __init__(self, folder, keys, study, domain, scale):
124
+ self.folder = folder
125
+ self.keys = keys
126
+ self.study = study
127
+ self.domain = domain
128
+ self.scale = scale
129
+ self.index = []
130
+ self.dirty = False
131
+ self.used = {"replayed": 0, "warm": 0, "confirmsSkipped": 0}
132
+ try:
133
+ with open(os.path.join(folder, "index.json")) as fh:
134
+ self.index = json.load(fh).get("entries", [])
135
+ except (OSError, ValueError):
136
+ self.index = []
137
+
138
+ @classmethod
139
+ def open(cls, study, domain, radius: float, making: dict) -> "Recall | None":
140
+ if not enabled():
141
+ return None
142
+ d = domain
143
+ mirror = {k: _h(np.asarray(v)) for k, v in sorted(d.mirror.items())}
144
+ geometry = _h(d.dims, round(float(d.pitch), 6), np.round(np.asarray(d.origin, float), 6),
145
+ d.mesh.elem_ijk.astype(np.int32), d.preserve, d.fixed, d.support_nodes, d.load_nodes,
146
+ d.springs, mirror, sorted((k, repr(v)) for k, v in making.items()),
147
+ round(float(radius), 6))
148
+ scale = _load_scale(d)
149
+ loads = _h([np.round(np.asarray(f, float) / scale, 9) for f in d.loads],
150
+ None if d.gravity is None else np.round(np.asarray(d.gravity, float) / scale, 12),
151
+ list(d.case_weights), study.max_iterations)
152
+ proc = study.raw.get("process")
153
+ model = _h(repr(study.elastic(design=True)), repr(study.elastic()),
154
+ json.dumps(proc, sort_keys=True, default=str), _code_key())
155
+ folder = os.path.join(_root(), geometry)
156
+ return cls(folder, {"geometry": geometry, "loads": loads, "model": model}, study, domain, scale)
157
+
158
+ # -- predictions -----------------------------------------------------
159
+
160
+ def _margin(self, sf, defl, e) -> float | None:
161
+ """`e`'s reading (sf, max deflection) rescaled to this study."""
162
+ st = self.study
163
+ g = st.goal
164
+ mat = st.material
165
+ k = self.scale / e["loadScale"]
166
+ if st.durability is not None and st.durability.constrain:
167
+ return None
168
+ ms = []
169
+ if g.safety_factor is not None:
170
+ if sf is None:
171
+ return None
172
+ ms.append(sf * (mat.strength / e["strength"]) / k / g.safety_factor)
173
+ if g.max_deflection is not None:
174
+ if defl is None:
175
+ return None
176
+ d = defl * k * (e["E"] / mat.E)
177
+ ms.append(g.max_deflection / d if d > 0 else float("inf"))
178
+ return min(ms) if ms else None
179
+
180
+ def predictions(self) -> list[_Pred]:
181
+ out = []
182
+ for e in self.index:
183
+ if e["loads"] != self.keys["loads"]:
184
+ continue
185
+ m = self._margin(e.get("sf"), e.get("defl"), e)
186
+ c = e.get("confirm")
187
+ mc = self._margin(c.get("safetyFactor"), c.get("defl"), e) if c else None
188
+ out.append(_Pred(e, m, mc, e["model"] == self.keys["model"]))
189
+ return out
190
+
191
+ def _decisive(self, p: _Pred, ratio: float = 1.0) -> float | None:
192
+ """The margin a pick is judged by: its confirmation when stored, else
193
+ its analysis-grid margin discounted by `ratio`, the worst confirm /
194
+ analysis ratio seen (the bell crank's 0.083 read 1.03 rescaled and
195
+ 0.97 on the fine grid)."""
196
+ if p.margin is None:
197
+ return None
198
+ return min(p.margin, p.confirm) if p.confirm is not None else p.margin * ratio
199
+
200
+ def _confirm_ratio(self, preds) -> float:
201
+ rs = [p.confirm / p.margin for p in preds if p.confirm is not None and p.margin]
202
+ return min(1.0, min(rs)) if rs else 1.0
203
+
204
+ def first_fraction(self, default: float) -> tuple[float, str | None]:
205
+ """Where a safety-factor search should start: the lightest stored
206
+ fraction predicted to pass, or one step past the heaviest that is
207
+ predicted to fail."""
208
+ ps = self.predictions()
209
+ ratio = self._confirm_ratio(ps)
210
+ preds = [(p.entry["vf"], self._decisive(p, ratio), p.exact) for p in ps]
211
+ preds = [q for q in preds if q[1] is not None]
212
+ if not preds:
213
+ return default, None
214
+ passing = [q for q in preds if q[1] >= 1.0]
215
+ failing = [q for q in preds if q[1] < 1.0]
216
+ if passing:
217
+ vf, m, _ = min(passing, key=lambda q: q[0])
218
+ lo = max((q for q in failing if q[0] < vf), key=lambda q: q[0], default=None)
219
+ if m >= 1.12 and lo is not None:
220
+ # the answer lies between: interpolate log-log (margin ~ vf^a)
221
+ a = np.log(m / lo[1]) / np.log(vf / lo[0]) if vf > lo[0] and m > lo[1] > 0 else 1.0
222
+ guess = vf * (1.05 / m) ** (1.0 / max(a, 0.3))
223
+ return float(self.snap(np.clip(guess, lo[0], vf))), f"between recalled {lo[0]:.3f} and {vf:.3f}"
224
+ if m >= 1.12:
225
+ return float(self.snap(vf * 1.05 / m)), f"below recalled {vf:.3f} (margin {m:.2f})"
226
+ return float(vf), f"recalled {vf:.3f} (margin {m:.2f})"
227
+ vf, m, _ = max(failing, key=lambda q: q[0])
228
+ return float(self.snap(min(vf * 1.05 / max(m, 1e-9), vf * 2.0))), f"above recalled {vf:.3f} (margin {m:.2f})"
229
+
230
+ def _replayable(self):
231
+ return [p.entry for p in self.predictions() if p.exact]
232
+
233
+ def snap(self, vf: float, lo: float | None = None, hi: float | None = None, skip=()) -> float:
234
+ """A replayable stored fraction near `vf` (within _SNAP), or strictly
235
+ inside (lo, hi) when the search is bisecting a bracket."""
236
+ cands = [e["vf"] for e in self._replayable() if all(abs(e["vf"] - v) > 1e-9 for v in skip)]
237
+ if lo is not None and hi is not None:
238
+ inside = [v for v in cands if lo < v < hi]
239
+ if inside:
240
+ return min(inside, key=lambda v: abs(v - vf))
241
+ near = [v for v in cands if abs(v - vf) <= _SNAP * vf]
242
+ return min(near, key=lambda v: abs(v - vf)) if near else vf
243
+
244
+ def replay(self, vf: float):
245
+ """A stored outcome at exactly this fraction under the same model:
246
+ (x, xphys, entry), or None."""
247
+ for e in sorted(self._replayable(), key=lambda e: -e["time"]):
248
+ if abs(e["vf"] - vf) <= 1e-9:
249
+ arrays = self._arrays(e)
250
+ if arrays is not None:
251
+ self.used["replayed"] += 1
252
+ return arrays[0], arrays[1], e
253
+ return None
254
+
255
+ def warm_start(self, vf: float):
256
+ """The stored design under these loads nearest to `vf` (any model):
257
+ (x, label), or None."""
258
+ cands = [e for e in self.index if e["loads"] == self.keys["loads"]]
259
+ if not cands:
260
+ return None
261
+ e = min(cands, key=lambda e: (abs(np.log(e["vf"] / vf)), -e["time"]))
262
+ arrays = self._arrays(e)
263
+ if arrays is None:
264
+ return None
265
+ self.used["warm"] += 1
266
+ return arrays[0], f"warm from recalled {e['vf']:.3f}"
267
+
268
+ def _rescaled(self, e) -> dict | None:
269
+ """`e`'s stored fine-grid reading in this study's units, in
270
+ verify_part's shape (the fields study._confirm reads)."""
271
+ c = e.get("confirm")
272
+ if not c or self.study.durability is not None:
273
+ return None
274
+ mat = self.study.material
275
+ k = self.scale / e["loadScale"]
276
+ s = (mat.strength / e["strength"]) / k
277
+ d = k * (e["E"] / mat.E)
278
+ return {"safetyFactor": c["safetyFactor"] * s, "analysisPitch": c["analysisPitch"],
279
+ "analysisCells": c["analysisCells"], "p5Member": c.get("p5Member"),
280
+ "minMember": c.get("minMember"),
281
+ "cases": [{**cs, "peakStress": cs["peakStress"] * k, "deflection": cs["deflection"] * d,
282
+ **({"safetyFactor": cs["safetyFactor"] * s} if "safetyFactor" in cs else {})}
283
+ for cs in c["cases"]]}
284
+
285
+ def confirm_for(self, vf: float) -> dict | None:
286
+ """The stored fine-grid reading of a replayed outcome, rescaled."""
287
+ for e in self._replayable():
288
+ if abs(e["vf"] - vf) <= 1e-9:
289
+ rep = self._rescaled(e)
290
+ if rep is not None:
291
+ self.used["confirmsSkipped"] += 1
292
+ return rep
293
+ return None
294
+
295
+ # -- storing -----------------------------------------------------------
296
+
297
+ def _arrays(self, e):
298
+ try:
299
+ with np.load(os.path.join(self.folder, e["file"])) as z:
300
+ x, xp = z["x"], z["xphys"]
301
+ except (OSError, KeyError, ValueError):
302
+ return None
303
+ if len(x) != self.domain.n_elem:
304
+ return None
305
+ return x.astype(float), xp.astype(float)
306
+
307
+ def add(self, vf: float, x, xphys, report: dict):
308
+ """Remember one outcome (replacing a stored one at the same fraction
309
+ under the same loads and model)."""
310
+ if x is None or xphys is None:
311
+ return
312
+ cases = report.get("cases") or []
313
+ st = self.study
314
+ e = {"vf": float(vf), "loads": self.keys["loads"], "model": self.keys["model"],
315
+ "loadScale": self.scale, "strength": st.material.strength, "E": st.material.E,
316
+ "sf": report.get("safetyFactor"),
317
+ "defl": max((c["deflection"] for c in cases), default=None),
318
+ "mass": report.get("mass"), "material": st.material.name, "time": time.time()}
319
+ old = [o for o in self.index if o["loads"] == e["loads"] and o["model"] == e["model"]
320
+ and abs(o["vf"] - e["vf"]) <= 1e-9]
321
+ if old and old[0].get("confirm") and self._rescaled(old[0]) is not None:
322
+ e["confirm"] = _stored_confirm(self._rescaled(old[0]))
323
+ name = _h(e["loads"], e["model"], round(e["vf"], 12)) + ".npz"
324
+ e["file"] = name
325
+ try:
326
+ os.makedirs(self.folder, exist_ok=True)
327
+ np.savez_compressed(os.path.join(self.folder, name), x=np.asarray(x, np.float32),
328
+ xphys=np.asarray(xphys, np.float32))
329
+ except OSError:
330
+ return
331
+ self.index = [o for o in self.index if o not in old] + [e]
332
+ self.dirty = True
333
+
334
+ def confirmed(self, vf: float, rep: dict):
335
+ """Attach a fine-grid reading (verify_part's report, in this study's
336
+ units) to the outcome stored at `vf` by this run."""
337
+ for e in self.index:
338
+ if e["loads"] == self.keys["loads"] and e["model"] == self.keys["model"] \
339
+ and abs(e["vf"] - vf) <= 1e-9 and e["strength"] == self.study.material.strength \
340
+ and e["E"] == self.study.material.E and e["loadScale"] == self.scale:
341
+ e["confirm"] = _stored_confirm(rep)
342
+ self.dirty = True
343
+ return
344
+
345
+ def save(self):
346
+ if not self.dirty:
347
+ return
348
+ try:
349
+ # merge what other processes stored meanwhile (outcomes run in parallel)
350
+ with open(os.path.join(self.folder, "index.json")) as fh:
351
+ theirs = json.load(fh).get("entries", [])
352
+ except (OSError, ValueError):
353
+ theirs = []
354
+ mine = {e["file"] for e in self.index}
355
+ self.index += [e for e in theirs if e["file"] not in mine]
356
+ keep = sorted(self.index, key=lambda e: -e["time"])[:_MAX_ENTRIES]
357
+ live = {e["file"] for e in keep}
358
+ for e in self.index:
359
+ if e["file"] not in live:
360
+ try:
361
+ os.remove(os.path.join(self.folder, e["file"]))
362
+ except OSError:
363
+ pass
364
+ self.index = keep
365
+ try:
366
+ os.makedirs(self.folder, exist_ok=True)
367
+ tmp = os.path.join(self.folder, f"index.json.{os.getpid()}")
368
+ with open(tmp, "w") as fh:
369
+ json.dump({"entries": keep, "study": self.study.name}, fh)
370
+ os.replace(tmp, os.path.join(self.folder, "index.json"))
371
+ except OSError:
372
+ return
373
+ self.dirty = False
374
+ _prune(os.path.dirname(self.folder), keep=self.folder)
375
+
376
+
377
+ def _stored_confirm(rep: dict) -> dict:
378
+ return {"safetyFactor": rep["safetyFactor"],
379
+ "defl": max((c["deflection"] for c in rep["cases"]), default=None),
380
+ "analysisPitch": rep.get("analysisPitch"), "analysisCells": rep.get("analysisCells"),
381
+ "p5Member": rep.get("p5Member"), "minMember": rep.get("minMember"),
382
+ "cases": [{k: c[k] for k in ("name", "peakStress", "peakAt", "deflection", "safetyFactor") if k in c}
383
+ for c in rep["cases"]]}
384
+
385
+
386
+ def _prune(root: str, keep: str):
387
+ cap = float(os.environ.get("LOADPATH_RECALL_MB", "1000")) * 1e6
388
+ try:
389
+ dirs = [os.path.join(root, d) for d in os.listdir(root)]
390
+ except OSError:
391
+ return
392
+ sizes = []
393
+ for d in dirs:
394
+ try:
395
+ files = [os.path.join(d, f) for f in os.listdir(d)]
396
+ sizes.append((max(os.path.getmtime(f) for f in files) if files else 0.0,
397
+ sum(os.path.getsize(f) for f in files), d))
398
+ except OSError:
399
+ continue
400
+ total = sum(s for _, s, _ in sizes)
401
+ for _, s, d in sorted(sizes):
402
+ if total <= cap:
403
+ break
404
+ if d != keep:
405
+ shutil.rmtree(d, ignore_errors=True)
406
+ total -= s