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
@@ -0,0 +1,179 @@
1
+ """
2
+ Procedurally generated eval cases, so tuning stops fitting GRIP.
3
+
4
+ Every hand-written case is a GRIP-like part, and a fix tuned on them can win
5
+ there and lose everywhere else without anyone noticing. A generated case is
6
+ drawn from a seed: a family (link, bracket, tri-mount), random proportions,
7
+ bore sizes, axis directions, load directions, material and target. The same
8
+ seed always gives the same case, so a corpus is a fixed, reproducible
9
+ held-out set, and a fresh seed range is a part nobody has tuned on.
10
+
11
+ Loads are sized from a beam estimate so the solid design space would carry
12
+ them at several times the target safety factor: every case is feasible and
13
+ needs real material removal. Expectations are the generic ones every part
14
+ must meet (one body, nothing in keep-outs, preserved geometry kept, bores
15
+ open, SF met); the score that matters is the Pareto massAtTarget, compared
16
+ run to run.
17
+
18
+ python -m loadpath.evals.run --corpus 8 # seeds 0-7
19
+ python -m loadpath.evals.run --corpus 100:108 # a fresh range
20
+ python -m loadpath.evals.corpus 3 # print seed 3's study
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+
27
+ import numpy as np
28
+
29
+ FAMILIES = ("link", "bracket", "tri-mount")
30
+ MATERIALS = ("pla", "petg", "pa12", "asa")
31
+ _AX = {"x": 0, "y": 1, "z": 2}
32
+ # solid design space carries the load at this many times the target SF
33
+ _HEADROOM = (4.0, 8.0)
34
+
35
+
36
+ def _r(x, nd=1):
37
+ return float(round(float(x), nd))
38
+
39
+
40
+ def _bore(rid, center, axis, bore_r, boss_r, length, through=40.0):
41
+ """A preserved boss ring round a bore keep-out, both along `axis`."""
42
+ c = [_r(v) for v in center]
43
+ return ({"id": f"{rid}-boss", "shape": {"type": "cylinder", "center": c, "axis": axis,
44
+ "length": _r(length), "r": _r(boss_r)}},
45
+ {"id": f"{rid}-bore", "shape": {"type": "cylinder", "center": c, "axis": axis,
46
+ "length": _r(length + through), "r": _r(bore_r)}})
47
+
48
+
49
+ def _force(rng, size, mat, sf, span, section):
50
+ """N for a load whose cantilever bending stress on the solid section is
51
+ strength / (sf * headroom)."""
52
+ b, h = sorted(section)
53
+ k = rng.uniform(*_HEADROOM)
54
+ return mat.strength * b * h * h / (6.0 * span * sf * k)
55
+
56
+
57
+ def generate(seed: int, family: str | None = None, max_elements: int = 8000) -> dict:
58
+ from ..materials import material_from_spec
59
+
60
+ rng = np.random.default_rng(seed)
61
+ family = family or FAMILIES[seed % len(FAMILIES)]
62
+ mat_name = MATERIALS[int(rng.integers(len(MATERIALS)))]
63
+ mat = material_from_spec(mat_name)
64
+ sf = float(rng.choice([2.0, 2.5, 3.0]))
65
+ min_member = _r(rng.uniform(2.5, 3.5))
66
+ preserve, keep, supports, loads = [], [], [], []
67
+
68
+ if family == "link":
69
+ # two bosses on a bar; bore axes both z (a planar link) or crossed
70
+ L = rng.uniform(70, 150)
71
+ W = rng.uniform(26, 44)
72
+ T = rng.uniform(16, 28)
73
+ r1 = rng.uniform(3.5, 7.0)
74
+ r2 = rng.uniform(3.0, 6.5)
75
+ crossed = rng.random() < 0.35
76
+ lo, hi = [-W / 2 - 2, -W / 2, -T / 2], [L + W / 2 + 2, W / 2, T / 2]
77
+ a2 = "y" if crossed else "z"
78
+ len2 = W if crossed else T
79
+ for rid, c, ax, br, ln in (("a", [0, 0, 0], "z", r1, T), ("b", [L, 0, 0], a2, r2, len2)):
80
+ p, k_ = _bore(rid, c, ax, br, min(br + rng.uniform(3.5, 6.0), 0.5 * (W if ax == "z" else T)), ln)
81
+ preserve.append(p)
82
+ keep.append(k_)
83
+ supports.append({"region": "a-bore"})
84
+ d = rng.normal(size=3)
85
+ d[0] *= 0.3 # mostly across the link: bending, not tension
86
+ d /= np.linalg.norm(d)
87
+ F = _force(rng, None, mat, sf, L, (W, T))
88
+ loads.append({"region": "b-bore", "force": [_r(F * v) for v in d]})
89
+ symmetry = ["z"] if not crossed and abs(d[2]) < 0.15 else []
90
+ elif family == "bracket":
91
+ # a plate bolted to a wall at x=0 (2-4 bolts), loaded at a boss out at +x
92
+ L = rng.uniform(60, 120)
93
+ W = rng.uniform(50, 80)
94
+ H = rng.uniform(50, 80)
95
+ n = int(rng.integers(2, 5))
96
+ lo, hi = [0, -W / 2, -H / 2], [L, W / 2, H / 2]
97
+ br = rng.uniform(1.6, 2.75)
98
+ ax = "y" if rng.random() < 0.5 else "z" # the load boss's bore
99
+ ey, ez = W / 2 - 7, H / 2 - 7
100
+ if n == 2:
101
+ # two bolts either side of the load boss, not in line with it
102
+ holes = [(0.0, -ez), (0.0, ez)] if ax == "y" else [(-ey, 0.0), (ey, 0.0)]
103
+ else:
104
+ holes = [(-ey, -ez), (ey, ez), (-ey, ez), (ey, -ez)][:n]
105
+ for i, (y, z) in enumerate(holes):
106
+ p, k_ = _bore(f"bolt{i}", [3, y, z], "x", br, br + rng.uniform(2.5, 4.0), 6)
107
+ preserve.append(p)
108
+ keep.append(k_)
109
+ # head access out to the far side: nothing may cover the bolt head
110
+ keep.append({"id": f"bolt{i}-head", "shape": {"type": "cylinder", "p0": [6, _r(y), _r(z)],
111
+ "p1": [_r(L + 1), _r(y), _r(z)], "r": _r(2 * br + 1)}})
112
+ supports.append({"region": f"bolt{i}-bore"})
113
+ lr = rng.uniform(3.0, 5.0)
114
+ c = [L - lr - 5, 0, 0]
115
+ p, k_ = _bore("load", c, ax, lr, lr + rng.uniform(3.5, 4.5), W if ax == "y" else H)
116
+ preserve.append(p)
117
+ keep.append(k_)
118
+ d = rng.normal(size=3)
119
+ d[0] = -abs(d[0]) * 0.5
120
+ d /= np.linalg.norm(d)
121
+ # the bolts and the load boss carry it, not the whole section: a
122
+ # sixth of the beam estimate (seeds 1, 4 missed SF at 67% fill)
123
+ F = _force(rng, None, mat, sf, L, (W, H)) / 6.0
124
+ loads.append({"region": "load-bore", "force": [_r(F * v) for v in d]})
125
+ symmetry = []
126
+ elif family == "tri-mount":
127
+ # three bosses on a triangle in a plate: two hold, one is loaded
128
+ R = rng.uniform(35, 70)
129
+ T = rng.uniform(14, 26)
130
+ ang = np.sort(rng.uniform(0, 2 * np.pi, 3))
131
+ ang = ang[0] + np.array([0, 2.1, 4.2]) + rng.uniform(-0.3, 0.3, 3)
132
+ pts = [[R * np.cos(a), R * np.sin(a), 0.0] for a in ang]
133
+ lo = [min(p[0] for p in pts) - 14, min(p[1] for p in pts) - 14, -T / 2]
134
+ hi = [max(p[0] for p in pts) + 14, max(p[1] for p in pts) + 14, T / 2]
135
+ for i, c in enumerate(pts):
136
+ br = rng.uniform(2.5, 5.5)
137
+ p, k_ = _bore(f"m{i}", c, "z", br, br + rng.uniform(3.5, 6.0), T)
138
+ preserve.append(p)
139
+ keep.append(k_)
140
+ supports += [{"region": "m0-bore"}, {"region": "m1-bore"}]
141
+ d = rng.normal(size=3)
142
+ d /= np.linalg.norm(d)
143
+ span = float(np.linalg.norm(np.array(pts[2]) - 0.5 * (np.array(pts[0]) + np.array(pts[1]))))
144
+ F = _force(rng, None, mat, sf, max(span, 20.0), (hi[1] - lo[1], T)) / 3.0
145
+ loads.append({"region": "m2-bore", "force": [_r(F * v) for v in d]})
146
+ symmetry = []
147
+ else:
148
+ raise ValueError(f"unknown family {family!r}: {FAMILIES}")
149
+
150
+ name = f"corpus-{family}-{seed}"
151
+ return {
152
+ "name": name,
153
+ "description": f"Generated ({family}, seed {seed}): not tuned on. Load sized so the solid design "
154
+ f"space has several times the target SF.",
155
+ "designSpace": [{"type": "box", "min": [_r(v) for v in lo], "max": [_r(v) for v in hi]}],
156
+ "preserve": preserve, "keepOut": keep, "supports": supports,
157
+ "loadCases": [{"name": "load", "loads": loads}],
158
+ "material": mat_name,
159
+ "goal": {"safetyFactor": sf, "volumeFraction": 0.3},
160
+ "minMember": min_member,
161
+ **({"symmetry": symmetry} if symmetry else {}),
162
+ "resolution": {"maxElements": int(max_elements)},
163
+ "expect": {"minSafetyFactor": sf, "bodies": 1, "maxKeepOutIntrusion": 2.0,
164
+ "maxPreserveMissing": 2.0, "maxClosedBores": 0, "minMassReduction": 0.3},
165
+ }
166
+
167
+
168
+ def seeds(spec: str) -> list[int]:
169
+ """'8' -> 0..7, '100:108' -> 100..107, '3,5,9' -> those."""
170
+ if ":" in spec:
171
+ a, b = spec.split(":")
172
+ return list(range(int(a), int(b)))
173
+ if "," in spec:
174
+ return [int(s) for s in spec.split(",")]
175
+ return list(range(int(spec)))
176
+
177
+
178
+ if __name__ == "__main__":
179
+ print(json.dumps(generate(int(sys.argv[1]), *(sys.argv[2:3] or [None])), indent=1))
@@ -0,0 +1,90 @@
1
+ """
2
+ Pareto scoring: how good the mass / safety-factor trade-off is, not just
3
+ whether the pick cleared a line.
4
+
5
+ Pass/fail hides progress. A case that gets 15% lighter at the same safety
6
+ factor scores the same as before, and a case that misses its target by 2%
7
+ scores the same as one that misses by 60%. Every outcome a study tries is a
8
+ point (mass, SF); the lightest-for-its-SF points form a front, and the front
9
+ says how much mass the target costs whatever the search happened to pick.
10
+
11
+ massAtTarget g, the front interpolated to the goal's safety factor
12
+ (lower is better; the number to watch). Between the
13
+ heaviest failing and the lightest passing front point,
14
+ linear in SF. With no failing point below it, it is the
15
+ lightest passing mass (an upper bound: "bracketed" false).
16
+ hypervolume area the front dominates in (1 - mass / solid mass,
17
+ min(SF / target, 2)) from (0, 0): 0..2, higher is better.
18
+ Rewards the whole curve, so a search that explores well
19
+ and finds a better trade-off away from the target still
20
+ moves it.
21
+ front the non-dominated points, lightest first.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ SF_CAP = 2.0 # SF / target beyond this buys nothing
26
+
27
+
28
+ def front(points):
29
+ """Non-dominated (mass, sf) points: no other point is at most as heavy and
30
+ at least as safe (strictly better in one). Lightest first."""
31
+ pts = sorted({(float(m), float(s)) for m, s in points if m is not None and s is not None},
32
+ key=lambda p: (p[0], -p[1]))
33
+ out, best_sf = [], float("-inf")
34
+ for m, s in pts:
35
+ if s > best_sf:
36
+ out.append((m, s))
37
+ best_sf = s
38
+ return out
39
+
40
+
41
+ def mass_at(fr, target):
42
+ """(mass, bracketed) where the front reaches `target` SF, or (None, False)
43
+ when no front point reaches it."""
44
+ passing = [p for p in fr if p[1] >= target]
45
+ if not passing:
46
+ return None, False
47
+ hi = min(passing, key=lambda p: p[0])
48
+ below = [p for p in fr if p[1] < target and p[0] <= hi[0]]
49
+ if not below:
50
+ return hi[0], False
51
+ lo = max(below, key=lambda p: p[0])
52
+ if hi[1] - lo[1] <= 1e-12:
53
+ return hi[0], True
54
+ t = (target - lo[1]) / (hi[1] - lo[1])
55
+ return lo[0] + t * (hi[0] - lo[0]), True
56
+
57
+
58
+ def hypervolume(fr, solid_mass, target):
59
+ """Area dominated by the front in (saving, SF/target) from (0, 0), with
60
+ saving = 1 - mass / solid_mass and SF/target capped at SF_CAP."""
61
+ if not fr or not solid_mass or not target:
62
+ return None
63
+ # in (saving up, safety up) both are maximized; sort by saving descending
64
+ pts = sorted(((1.0 - m / solid_mass, min(s / target, SF_CAP)) for m, s in fr), key=lambda p: -p[0])
65
+ area, prev_y = 0.0, 0.0
66
+ for x, y in pts:
67
+ if y > prev_y and x > 0:
68
+ area += x * (y - prev_y)
69
+ prev_y = y
70
+ return area
71
+
72
+
73
+ def score(outcomes, target):
74
+ """Pareto summary of a study's outcomes (Outcome.summary() dicts or
75
+ anything with mass, safetyFactor, massReduction)."""
76
+ pts = [(o.get("mass"), o.get("safetyFactor")) for o in outcomes]
77
+ fr = front(pts)
78
+ solid = None
79
+ for o in outcomes:
80
+ m, r = o.get("mass"), o.get("massReduction")
81
+ if m and r is not None and r < 1.0:
82
+ solid = m / (1.0 - r)
83
+ break
84
+ out = {"points": len([p for p in pts if None not in p]), "front": [[m, s] for m, s in fr],
85
+ "solidMass": solid}
86
+ if target:
87
+ m, br = mass_at(fr, target)
88
+ out.update(target=target, massAtTarget=m, bracketed=br,
89
+ hypervolume=hypervolume(fr, solid, target))
90
+ return out