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 @@
1
+ """Scorers for brief kinds other than one structural part (see briefs.kind)."""
@@ -0,0 +1,387 @@
1
+ """
2
+ Heat-exchanger briefs: the agent delivers a two-fluid exchanger (loadpath's
3
+ heatExchanger spec and the part it designs), not a structural part.
4
+
5
+ The scorer never takes the agent's numbers. It designs the delivered spec
6
+ again (heatx.design_heat_exchanger: the same geometry the agent's run wrote)
7
+ and rates it (heatx_thermal.rate_design) with the GOLD duty put in place of
8
+ the agent's: the brief's fluids, flows, inlet temperatures and wall
9
+ material, matched to the agent's fluid names. Then the brief's pass block:
10
+
11
+ "pass": {
12
+ "envelope": {"min": [x, y, z], "max": [x, y, z]}, # the whole part, ports included
13
+ "ports": {"oil-in": {"at": [x, y, z], "direction": "-x"}, # gold fluid names; "at"
14
+ "coolant-out": {"direction": "y"}, ...}, # optional, "y" = either way
15
+ "portTolerance": {"lateral": 3, "axial": 5}, # mm off the port's axis / short of "at"
16
+ "counterflow": ["oil", "coolant"], # optional: the second runs against the first
17
+ "minEffectiveness": 0.6, "minDuty": 400, # W (either or both)
18
+ "maxPressureDrop": {"oil": 50000, "coolant": 10000}, # Pa per gold fluid
19
+ "maxMass": 120, # g, solid volume x the wall's density
20
+ "maxTrappedVolume": 20, "maxInternalFlatArea": 300, # powder-bed printability (optional)
21
+ "process": "lpbf" # wall/channel limits (sizing.PROCESS_LIMITS);
22
+ } # default the gold spec's process
23
+
24
+ and always: the spec is a designed core (not a sizing request), the
25
+ process and build axis are the brief's, the two fluids are isolated and
26
+ each reaches its own two ports, the mesh is watertight, the walls between
27
+ the fluids and to the outside and the channels meet the process's limits,
28
+ and the delivered exchanger.stl is the part that spec designs (volume and
29
+ bounds), so a hand-made block or an edited mesh does not pass.
30
+
31
+ Cost: one design of the delivered spec at its own grid (about 20 s to 2
32
+ minutes for these briefs on two threads, up to the 16M-point cap heatx
33
+ itself works to). `quick=True` coarsens the grid like `loadpath heatx
34
+ --quick` (tests).
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import os
40
+ import time
41
+
42
+ import numpy as np
43
+
44
+ DELIVER = """ exchanger.json the heat exchanger you designed: loadpath's heatExchanger spec (a core, not
45
+ only an envelope), with its duty
46
+ exchanger.stl the part to print, as loadpath heatx wrote it for exchanger.json (keep the
47
+ fluid-space STLs it writes beside it)
48
+ notes.md two or three lines: its effectiveness or heat duty, each fluid's pressure drop,
49
+ its mass, and your assumptions
50
+ """
51
+
52
+ ENVELOPE_TOL = 0.5 # mm: marching cubes rounds the part's outside by up to a grid step
53
+ PORT_COS = np.cos(np.radians(15.0))
54
+ PORT_TOL = {"lateral": 3.0, "axial": 5.0}
55
+ WALL_SLACK = 0.85 # measured walls (on the design grid) against the process's minimum
56
+ CHANNEL_SLACK = 0.9
57
+ MESH_VOLUME_TOL = 0.05 # delivered STL against the redesign (a --quick run differs by a few %)
58
+ MESH_BOUNDS_TOL = 1.5 # mm
59
+
60
+
61
+ def _axis(v) -> tuple[np.ndarray, bool]:
62
+ """'-x' -> (unit vector, signed); 'x' -> (e_x, False): either way."""
63
+ s = str(v).strip()
64
+ signed = s[0] in "+-"
65
+ e = np.zeros(3)
66
+ e["xyz".index(s.lstrip("+-"))] = -1.0 if s.startswith("-") else 1.0
67
+ return e, signed
68
+
69
+
70
+ def _fluid_map(spec: dict, gold: dict) -> tuple[dict | None, str | None]:
71
+ """The agent's fluid name for each gold fluid. Same names map by name;
72
+ otherwise by each side's duty (fluid type, then inlet temperature).
73
+ Never by where the ports are: a swapped exchanger must not be mapped
74
+ back onto the brief."""
75
+ g_names = list(gold.get("fluids") or ["A", "B"])
76
+ a_names = list(spec.get("fluids") or ["A", "B"])
77
+ if set(a_names) == set(g_names):
78
+ return {g: g for g in g_names}, None
79
+ a_duty, g_duty = spec.get("duty") or {}, gold["duty"]
80
+ if not all(isinstance(a_duty.get(n), dict) for n in a_names):
81
+ return None, (f"exchanger.json's fluids {a_names} are not the brief's and it has no duty to tell "
82
+ "which is which")
83
+
84
+ def fit(g, a):
85
+ gd, ad = g_duty[g], a_duty[a]
86
+ s = 10.0 * (gd.get("fluid", "water") == ad.get("fluid", "water"))
87
+ if gd.get("tIn") is not None and ad.get("tIn") is not None:
88
+ s += 5.0 / (1.0 + abs(float(gd["tIn"]) - float(ad["tIn"])))
89
+ return s
90
+
91
+ straight = fit(g_names[0], a_names[0]) + fit(g_names[1], a_names[1])
92
+ crossed = fit(g_names[0], a_names[1]) + fit(g_names[1], a_names[0])
93
+ if abs(straight - crossed) < 1e-9:
94
+ return None, f"cannot tell which of exchanger.json's fluids {a_names} is which of the brief's"
95
+ pair = a_names if straight > crossed else a_names[::-1]
96
+ return dict(zip(g_names, pair)), None
97
+
98
+
99
+ def _density(wall) -> float | None:
100
+ from ...heatx_thermal import WALL_DENSITY
101
+ if isinstance(wall, str):
102
+ if wall in WALL_DENSITY:
103
+ return WALL_DENSITY[wall]
104
+ try:
105
+ from ...materials import material_from_spec
106
+ return float(material_from_spec(wall).density)
107
+ except (KeyError, ValueError):
108
+ return None
109
+ return None
110
+
111
+
112
+ def _at_mouth(hx, rep, at) -> bool:
113
+ """Whether a point lies at a port's open end, inside its teardrop."""
114
+ if at is None:
115
+ return False
116
+ at = np.asarray(at, float)
117
+ step = float(rep["step"])
118
+ for z in rep["zones"].values():
119
+ p1, ax = np.asarray(z["port"][0], float), np.asarray(z["port"][1], float)
120
+ d = at - p1
121
+ a = float(d @ ax)
122
+ lateral = float(np.linalg.norm(d - a * ax))
123
+ if -(hx.shell + 3 * step) <= a <= 3.0 and lateral <= 0.5 * z["portDiameter"] * np.sqrt(2) + hx.shell + 2 * step:
124
+ return True
125
+ return False
126
+
127
+
128
+ def _round(v, n=3):
129
+ return None if v is None else round(float(v), n)
130
+
131
+
132
+ def evaluate(brief: dict, spec: dict, base_dir: str | None = None, delivered=None, quick: bool = False) -> dict:
133
+ """Design `spec` (a heatExchanger document), rate it under the brief's
134
+ gold duty and check the pass block. `delivered`: the agent's mesh
135
+ (a path or trimesh), compared with the redesign. Returns the score row."""
136
+ from ... import quick as quick_mode
137
+ from ...heatx import design_heat_exchanger
138
+ from ...heatx_thermal import rate_design
139
+ from ...sizing import PROCESS_LIMITS
140
+ from ...tasks import _hx_spec
141
+
142
+ gold, want = brief["gold"], brief["pass"]
143
+ row = {"pass": False, "failures": []}
144
+ f = row["failures"]
145
+ if "core" not in spec:
146
+ f.append("exchanger.json has no core: an envelope is a sizing request, not a designed exchanger "
147
+ "(design the sized spec and deliver that)")
148
+ return row
149
+ spec = {k: v for k, v in spec.items() if k not in ("simulate", "expect")}
150
+ hx = _hx_spec(spec, base_dir)
151
+ names = list(hx.names)
152
+ fmap, err = _fluid_map(spec, gold)
153
+ if err:
154
+ f.append(err)
155
+ return row
156
+ row["fluids"] = fmap
157
+
158
+ # the brief's process and build direction
159
+ g_proc = gold.get("process", "fdm")
160
+ if hx.process != g_proc:
161
+ f.append(f"designed for process {hx.process!r}, the brief is {g_proc!r}")
162
+ g_build = gold.get("build", "z")
163
+ g_build = None if g_build in (None, False, "none") else "xyz".index(g_build)
164
+ if hx.build != g_build:
165
+ have = "none" if hx.build is None else "xyz"[hx.build]
166
+ f.append(f"built along {have}, the brief prints along {'none' if g_build is None else 'xyz'[g_build]}")
167
+ lim_key = want.get("process", g_proc)
168
+ lim = PROCESS_LIMITS[lim_key]
169
+ if hx.wall < lim["minWall"] - 1e-9:
170
+ f.append(f"{hx.wall:g} mm wall is under {lim_key}'s {lim['minWall']:g} mm")
171
+
172
+ if quick:
173
+ # as `loadpath heatx --quick` coarsens it (at most 0.45 wall: heatx refuses past half)
174
+ hx.step = min((hx.step or hx.wall / 3.0) * quick_mode.HX_STEP, hx.wall * 0.45)
175
+ t0 = time.time()
176
+ mesh, rep = design_heat_exchanger(hx)
177
+ row["designSeconds"] = round(time.time() - t0, 1)
178
+ row["step"] = _round(rep["step"])
179
+
180
+ # isolation and ports reached
181
+ row["isolated"] = bool(rep.get("isolated"))
182
+ row["connected"] = {g: bool((rep.get("connected") or {}).get(a)) for g, a in fmap.items()}
183
+ row["watertight"] = bool(rep.get("watertight"))
184
+ if not row["isolated"]:
185
+ f.append("the two fluids are not isolated (a port reaches the other fluid's space, or none)")
186
+ for g, ok in row["connected"].items():
187
+ if not ok:
188
+ f.append(f"{g} does not reach both its ports")
189
+ if not row["watertight"]:
190
+ f.append("the part's mesh is not watertight")
191
+
192
+ # envelope
193
+ lo, hi = (np.asarray(b, float) for b in rep["bounds"])
194
+ row["bounds"] = [lo.round(2).tolist(), hi.round(2).tolist()]
195
+ env = want.get("envelope")
196
+ if env:
197
+ elo, ehi = np.asarray(env["min"], float), np.asarray(env["max"], float)
198
+ over = np.maximum(elo - lo, hi - ehi).max()
199
+ row["outsideEnvelope"] = _round(max(over, 0.0), 2)
200
+ if over > ENVELOPE_TOL:
201
+ f.append(f"the part reaches {over:.1f} mm outside the envelope "
202
+ f"({row['bounds']} vs {[elo.tolist(), ehi.tolist()]})")
203
+
204
+ # ports where the brief puts them
205
+ tol = {**PORT_TOL, **(want.get("portTolerance") or {})}
206
+ row["ports"] = {}
207
+ for key, req in (want.get("ports") or {}).items():
208
+ g, end = key.rsplit("-", 1)
209
+ z = rep["zones"].get(f"{fmap[g]}-{end}")
210
+ if z is None:
211
+ f.append(f"no {key} port")
212
+ continue
213
+ mouth, ax = np.asarray(z["port"][0], float), np.asarray(z["port"][1], float)
214
+ e, signed = _axis(req["direction"])
215
+ c = float(ax @ e)
216
+ got = {"mouth": mouth.round(2).tolist(), "axis": ax.round(3).tolist(), "diameter": z["portDiameter"]}
217
+ if (c if signed else abs(c)) < PORT_COS:
218
+ f.append(f"{key} port points {np.round(ax, 2).tolist()}, the brief has it along {req['direction']}")
219
+ if "at" in req:
220
+ at = np.asarray(req["at"], float)
221
+ if not signed:
222
+ e = e * np.sign(c or 1.0)
223
+ d = at - mouth
224
+ axial = float(d @ e)
225
+ lateral = float(np.linalg.norm(d - axial * e))
226
+ got.update(lateral=round(lateral, 2), axial=round(axial, 2))
227
+ if lateral > tol["lateral"]:
228
+ f.append(f"{key} port is {lateral:.1f} mm off where the brief has it ({at.tolist()}; "
229
+ f"its mouth is at {mouth.round(1).tolist()})")
230
+ if axial > tol["axial"]:
231
+ f.append(f"{key} port ends {axial:.1f} mm short of {at.tolist()}")
232
+ if req.get("minDiameter") and z["portDiameter"] < req["minDiameter"]:
233
+ f.append(f"{key} port is {z['portDiameter']:g} mm, under {req['minDiameter']:g} mm")
234
+ row["ports"][key] = got
235
+ cf = want.get("counterflow")
236
+ if cf:
237
+ a, b = (fmap[n] for n in cf)
238
+ za = rep["zones"]
239
+ u = np.asarray(za[f"{a}-out"]["center"], float) - np.asarray(za[f"{a}-in"]["center"], float)
240
+ run = float((np.asarray(za[f"{b}-out"]["center"], float) - np.asarray(za[f"{b}-in"]["center"], float)) @ u)
241
+ row["counterflow"] = run < 0
242
+ if run >= 0:
243
+ f.append(f"{cf[1]} does not run against {cf[0]} (its inlet is not at {cf[0]}'s outlet end)")
244
+
245
+ # process limits, measured on the design grid
246
+ walls = {"betweenFluids": rep.get("minWallBetweenFluids"), "outer": rep.get("minOuterWall")}
247
+ if walls["outer"] is not None and _at_mouth(hx, rep, rep.get("minOuterWallAt")):
248
+ # heatx leaves a port's mouth out of the outer-wall check by a cylinder r + shell
249
+ # around it, but a sideways port's teardrop peaks at r * sqrt(2): on ports over
250
+ # ~11 mm the open lip of the teardrop reads as a one-voxel wall
251
+ row["outerWallAtMouth"] = _round(walls["outer"])
252
+ walls["outer"] = None
253
+ row["minWall"] = {k: _round(v) for k, v in walls.items()}
254
+ for k, v in walls.items():
255
+ if v is not None and v < WALL_SLACK * lim["minWall"]:
256
+ where = rep.get("minWallBetweenFluidsAt" if k == "betweenFluids" else "minOuterWallAt")
257
+ f.append(f"{'wall between the fluids' if k == 'betweenFluids' else 'outer wall'} is {v:.2f} mm "
258
+ f"at {where}, under {lim_key}'s {lim['minWall']:g} mm")
259
+ row["hydraulicDiameter"] = {}
260
+ for g, a in fmap.items():
261
+ dh = (rep.get(a) or {}).get("hydraulicDiameter")
262
+ row["hydraulicDiameter"][g] = _round(dh)
263
+ if dh is not None and dh < CHANNEL_SLACK * lim["minChannel"]:
264
+ f.append(f"{g}'s channels are {dh:.2f} mm across, under {lim_key}'s {lim['minChannel']:g} mm")
265
+ ic = rep.get("internalCeilings") or {}
266
+ row["trappedVolume"] = _round(rep.get("trappedVolume"), 1)
267
+ row["internalFlatArea"] = _round(ic.get("flatArea"), 1)
268
+ if want.get("maxTrappedVolume") is not None and (rep.get("trappedVolume") or 0) > want["maxTrappedVolume"]:
269
+ f.append(f"{rep['trappedVolume']:.1f} mm^3 of open space no port reaches (trapped powder), "
270
+ f"over {want['maxTrappedVolume']:g}")
271
+ if want.get("maxInternalFlatArea") is not None and (ic.get("flatArea") or 0) > want["maxInternalFlatArea"]:
272
+ f.append(f"{ic['flatArea']:.0f} mm^2 of flat ceiling inside (unsupportable), over "
273
+ f"{want['maxInternalFlatArea']:g}")
274
+
275
+ # rated under the gold duty
276
+ gd = gold["duty"]
277
+ duty = {fmap[g]: gd[g] for g in fmap}
278
+ if "wall" in gd:
279
+ duty["wall"] = gd["wall"]
280
+ try:
281
+ r = rate_design(hx, rep, duty)
282
+ except ValueError as exc:
283
+ f.append(f"cannot rate it: {exc}")
284
+ r = None
285
+ if r is not None:
286
+ row["effectiveness"] = _round(r["effectiveness"])
287
+ row["duty"] = _round(abs(r.get("duty") or 0.0), 1)
288
+ row["pressureDrop"] = {g: _round(r["fluids"][a]["pressureDrop"], 0) for g, a in fmap.items()}
289
+ row["tOut"] = {g: _round(r["tOut"][a], 1) for g, a in fmap.items()} if r.get("tOut") else None
290
+ if want.get("minEffectiveness") is not None and r["effectiveness"] < want["minEffectiveness"]:
291
+ f.append(f"effectiveness {r['effectiveness']:.3f} < {want['minEffectiveness']}")
292
+ if want.get("minDuty") is not None and row["duty"] < want["minDuty"]:
293
+ f.append(f"moves {row['duty']:.0f} W < {want['minDuty']} W")
294
+ for g, cap in (want.get("maxPressureDrop") or {}).items():
295
+ if row["pressureDrop"][g] > cap:
296
+ f.append(f"{g} pressure drop {row['pressureDrop'][g]:.0f} Pa > {cap} Pa")
297
+ rho = _density(gd.get("wall"))
298
+ row["solidVolume"] = _round(rep["solidVolume"], 0)
299
+ row["mass"] = _round(rep["solidVolume"] * rho / 1000.0, 1) if rho else None
300
+ if want.get("maxMass") is not None and row["mass"] is not None and row["mass"] > want["maxMass"]:
301
+ f.append(f"{row['mass']:.1f} g > {want['maxMass']} g")
302
+
303
+ # the delivered part is the one this spec designs
304
+ if delivered is not None:
305
+ import trimesh
306
+ m = trimesh.load(delivered, force="mesh") if isinstance(delivered, str) else delivered
307
+ vol = abs(float(m.volume))
308
+ dv = abs(vol - rep["solidVolume"]) / max(rep["solidVolume"], 1e-9)
309
+ db = float(np.abs(np.asarray(m.bounds) - np.stack([lo, hi])).max())
310
+ row["deliveredMesh"] = {"volume": round(vol, 0), "volumeDiff": round(dv, 3), "boundsDiff": round(db, 2)}
311
+ if dv > MESH_VOLUME_TOL * (2 if quick else 1) or db > MESH_BOUNDS_TOL:
312
+ f.append(f"exchanger.stl is not the part exchanger.json designs (volume {vol:.0f} vs "
313
+ f"{rep['solidVolume']:.0f} mm^3, bounds off by {db:.1f} mm)")
314
+ row["pass"] = not f
315
+ return row
316
+
317
+
318
+ def _find_mesh(folder: str, spec: dict) -> str | None:
319
+ for n in ("exchanger.stl", f"{spec.get('name') or 'heat-exchanger'}.stl"):
320
+ p = os.path.join(folder, n)
321
+ if os.path.exists(p):
322
+ return p
323
+ return None
324
+
325
+
326
+ def score(brief: dict, folder: str, quick: bool = False) -> dict:
327
+ """Score what the agent left in `folder` (exchanger.json, exchanger.stl)."""
328
+ path = os.path.join(folder, "exchanger.json")
329
+ if not os.path.exists(path):
330
+ return {"pass": False, "failures": ["no exchanger.json"]}
331
+ try:
332
+ with open(path) as fh:
333
+ spec = json.load(fh)
334
+ except (OSError, json.JSONDecodeError) as exc:
335
+ return {"pass": False, "failures": [f"exchanger.json does not read: {exc}"]}
336
+ if not isinstance(spec, dict):
337
+ return {"pass": False, "failures": ["exchanger.json is not a heatExchanger document"]}
338
+ mesh = _find_mesh(folder, spec)
339
+ row = evaluate(brief, spec, base_dir=folder, delivered=mesh, quick=quick)
340
+ if mesh is None:
341
+ row["failures"].append("no exchanger.stl")
342
+ row["pass"] = False
343
+ return row
344
+
345
+
346
+ def gold(brief: dict, quick: bool = False) -> dict:
347
+ """The gold spec's own check: it lints, its duty and the pass block name
348
+ its fluids, and it passes the scorer (warnings where a margin is thin)."""
349
+ t0 = time.time()
350
+ g, want = brief.get("gold") or {}, brief.get("pass") or {}
351
+ names = list(g.get("fluids") or ["A", "B"])
352
+ problems = []
353
+ if "core" not in g:
354
+ problems.append("gold needs a core")
355
+ if sorted(n for n in (g.get("duty") or {}) if n != "wall") != sorted(names):
356
+ problems.append(f"gold duty must name each of {names}")
357
+ for key in want.get("ports") or {}:
358
+ n, _, end = key.rpartition("-")
359
+ if n not in names or end not in ("in", "out"):
360
+ problems.append(f"pass port {key!r} is not <fluid>-in/out of {names}")
361
+ for n in (want.get("maxPressureDrop") or {}):
362
+ if n not in names:
363
+ problems.append(f"pass maxPressureDrop names {n!r}, not one of {names}")
364
+ if not any(k in want for k in ("minEffectiveness", "minDuty")):
365
+ problems.append("pass needs minEffectiveness or minDuty")
366
+ if problems:
367
+ return {"ok": False, "error": "; ".join(problems)}
368
+ try:
369
+ row = evaluate(brief, g, quick=quick)
370
+ except Exception as exc: # noqa: BLE001
371
+ return {"ok": False, "error": f"gold does not design: {str(exc).strip()[:300]}"}
372
+ warn = []
373
+ if row.get("effectiveness") is not None and want.get("minEffectiveness"):
374
+ if row["effectiveness"] < 1.05 * want["minEffectiveness"]:
375
+ warn.append(f"effectiveness margin thin: {row['effectiveness']} vs {want['minEffectiveness']}")
376
+ if row.get("duty") is not None and want.get("minDuty") and row["duty"] < 1.05 * want["minDuty"]:
377
+ warn.append(f"duty margin thin: {row['duty']} vs {want['minDuty']}")
378
+ for n, cap in (want.get("maxPressureDrop") or {}).items():
379
+ if (row.get("pressureDrop") or {}).get(n, 0) > 0.95 * cap:
380
+ warn.append(f"{n} pressure drop margin thin: {row['pressureDrop'][n]} vs {cap}")
381
+ if want.get("maxMass") and row.get("mass") and row["mass"] > 0.95 * want["maxMass"]:
382
+ warn.append(f"mass margin thin: {row['mass']} vs {want['maxMass']}")
383
+ out = {"ok": bool(row["pass"]), "score": row, "warnings": warn, "seconds": round(time.time() - t0, 1),
384
+ "cells": f"step {row.get('step')} mm"}
385
+ if not row["pass"]:
386
+ out["error"] = "; ".join(row["failures"])
387
+ return out