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/tasks.py ADDED
@@ -0,0 +1,822 @@
1
+ """
2
+ The jobs behind the CLI and the MCP server, besides the study itself: a
3
+ lattice infill of a finished part, a heat exchanger, and sizing one. Each
4
+ takes plain JSON and an output folder and returns a JSON-able report, so the
5
+ command line, the MCP server and Python callers share one code path.
6
+
7
+ python -m loadpath lattice study.json part.stl --out out/
8
+ python -m loadpath heatx exchanger.json --out out/
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+
15
+ import numpy as np
16
+
17
+ __all__ = ["jsonable", "check_study", "check_document", "check_heat_exchanger", "lattice_part", "heat_exchanger",
18
+ "size_heat_exchanger", "examples", "example"]
19
+
20
+ _HERE = os.path.dirname(os.path.abspath(__file__))
21
+ _EXAMPLES = os.path.join(_HERE, "examples")
22
+
23
+
24
+ def jsonable(o):
25
+ if isinstance(o, dict):
26
+ return {str(k): jsonable(v) for k, v in o.items()}
27
+ if isinstance(o, (list, tuple)):
28
+ return [jsonable(v) for v in o]
29
+ if isinstance(o, np.ndarray):
30
+ return jsonable(o.tolist())
31
+ if isinstance(o, np.bool_):
32
+ return bool(o)
33
+ if isinstance(o, np.floating):
34
+ o = float(o)
35
+ if isinstance(o, np.integer):
36
+ return int(o)
37
+ if isinstance(o, float) and (o != o or o in (float("inf"), float("-inf"))):
38
+ return None
39
+ return o
40
+
41
+
42
+ def _emit(on_progress, **ev):
43
+ if on_progress is not None:
44
+ on_progress(ev)
45
+
46
+
47
+ _HX_KEYS = ("core", "envelope", "portDiameter", "fluids", "layout", "zones", "cleanouts")
48
+ _LATTICE_KEYS = ("minWall", "maxWall", "solidAbove", "collar", "fillPockets", "drainDiameter")
49
+
50
+
51
+ def document_kind(raw) -> str:
52
+ """Which kind of document this is: "study", "heatExchanger", "lattice"
53
+ (lattice options) or "field" (a field graph), from its keys."""
54
+ if not isinstance(raw, dict):
55
+ return "study"
56
+ if "designSpace" in raw or "loadCases" in raw or "loads" in raw:
57
+ return "study"
58
+ if "nodes" in raw:
59
+ return "field"
60
+ if any(k in raw for k in _HX_KEYS) or isinstance(raw.get("duty"), dict):
61
+ return "heatExchanger"
62
+ if any(k in raw for k in _LATTICE_KEYS) or raw.get("sizing") in ("homogenized", "solid-stress"):
63
+ return "lattice"
64
+ return "study"
65
+
66
+
67
+ def check_document(raw, base_dir: str | None = None) -> dict:
68
+ """`loadpath check` on any document: a study (check_study), a heat
69
+ exchanger (check_heat_exchanger), lattice options or a field graph
70
+ (schema lint)."""
71
+ if isinstance(raw, str):
72
+ try:
73
+ raw = json.loads(raw)
74
+ except json.JSONDecodeError as exc:
75
+ return {"ok": False, "error": f"not valid JSON: {exc}"}
76
+ kind = document_kind(raw)
77
+ if kind == "heatExchanger":
78
+ return check_heat_exchanger(raw, base_dir)
79
+ if kind in ("lattice", "field"):
80
+ from .schema import format_issues, lint
81
+ issues = lint(raw, kind)
82
+ errors = [i for i in issues if i["level"] == "error"]
83
+ out = {"ok": not errors, "kind": kind, "issues": issues, "schema": f"loadpath schema {kind}"}
84
+ if errors:
85
+ out["error"] = format_issues(errors)
86
+ return out
87
+ return {"kind": "study", **check_study(raw, base_dir)}
88
+
89
+
90
+ def check_heat_exchanger(spec: dict, base_dir: str | None = None) -> dict:
91
+ """Lint and build a heat exchanger spec without designing it
92
+ (milliseconds): the design grid's step, point count and estimated peak
93
+ memory against what is free, the zones, and what the process allows.
94
+ An envelope spec is checked for sizing (loadpath heatx --size)."""
95
+ from .heatx import grid_plan, zones_for
96
+ from .schema import format_issues, lint
97
+ from .sizing import PROCESS_LIMITS
98
+ from .study import _available_bytes
99
+
100
+ issues = lint(spec, "heatExchanger")
101
+ errors = [i for i in issues if i["level"] == "error"]
102
+ if errors:
103
+ return {"ok": False, "kind": "heatExchanger", "error": format_issues(errors), "issues": issues,
104
+ "schema": "loadpath schema heatExchanger"}
105
+ warnings = [f"{i['path']}: {i['message']}" for i in issues]
106
+ notes = []
107
+ names = list(spec.get("fluids") or ["A", "B"])
108
+ duty = spec.get("duty")
109
+ if "envelope" in spec and "core" not in spec:
110
+ if not duty or any(n not in duty for n in names):
111
+ return {"ok": False, "kind": "heatExchanger", "error": f"an envelope spec is sized for its duty: "
112
+ f"\"duty\" needs an entry for each fluid {names}", "issues": issues}
113
+ sz = spec.get("sizing") or {}
114
+ proc = sz.get("process", spec.get("process", "fdm"))
115
+ return jsonable({"ok": True, "kind": "heatExchanger", "mode": "envelope", "envelope": spec["envelope"],
116
+ "fluids": names, "process": proc, "processLimits": PROCESS_LIMITS.get(proc),
117
+ "warnings": warnings,
118
+ "next": "loadpath heatx SPEC --size --out DIR: ranks cores that fit the envelope (each "
119
+ "candidate's spec designs with loadpath heatx)"})
120
+ try:
121
+ hx = _hx_spec(spec, base_dir)
122
+ duty = _duty(spec, hx)
123
+ zones = zones_for(hx)
124
+ except (ValueError, KeyError, TypeError, IndexError) as exc:
125
+ return {"ok": False, "kind": "heatExchanger", "error": str(exc) or type(exc).__name__, "issues": issues}
126
+ from . import quick
127
+ quick.heatx(hx, None)
128
+ plan = grid_plan(hx)
129
+ lim = PROCESS_LIMITS.get(hx.process, PROCESS_LIMITS["fdm"])
130
+ if hx.wall < lim["minWall"]:
131
+ warnings.append(f"wall {hx.wall:g} mm is under {hx.process}'s {lim['minWall']:g} mm minimum")
132
+ if hx.shell < lim["minWall"]:
133
+ warnings.append(f"shell {hx.shell:g} mm is under {hx.process}'s {lim['minWall']:g} mm minimum")
134
+ avail = _available_bytes()
135
+ if avail and plan["fits"] and plan["peakMemoryGB"] * 1e9 > 0.9 * avail:
136
+ warnings.append(f"the design needs about {plan['peakMemoryGB']:.1f} GB at its peak and "
137
+ f"{avail / 1e9:.1f} GB is free: loadpath heatx refuses it (--force runs anyway); set a "
138
+ f"coarser step (at most wall / 2 = {0.5 * hx.wall:.2f} mm) or --max-points")
139
+ if duty is None:
140
+ notes.append("no duty: the design is not rated (add one entry per fluid and \"wall\")")
141
+ elif not isinstance(duty.get("wall"), str) or _wall_density(duty.get("wall")) is None:
142
+ notes.append("duty.wall is not a known material name: the report carries no mass")
143
+ lo, hi = (np.asarray(b, float) for b in hx.core.bounds())
144
+ out = {"ok": bool(plan["fits"]), "kind": "heatExchanger", "name": spec.get("name"),
145
+ "type": hx.kind, "layout": hx.layout, "fluids": list(hx.names), "cell": hx.cell, "wall": hx.wall,
146
+ "process": hx.process, "core": {"min": lo.round(3).tolist(), "max": hi.round(3).tolist()},
147
+ "zones": [f"{hx.names[z.fluid]}-{z.end}" for z in zones],
148
+ "gridStep": plan["step"], "gridPoints": plan["points"], "maxPoints": plan["maxPoints"],
149
+ "peakMemoryGB": plan["peakMemoryGB"], "availableGB": round(avail / 1e9, 2) if avail else None,
150
+ "processLimits": lim, "warnings": warnings, "notes": notes}
151
+ if not plan["fits"]:
152
+ out["error"] = plan["error"]
153
+ out["maxPointsNeeded"] = plan["maxPointsNeeded"]
154
+ return jsonable(out)
155
+
156
+
157
+ def check_study(raw, base_dir: str | None = None) -> dict:
158
+ """Lint against the schema (every problem at once), then parse and build
159
+ the voxel problem. {"ok": False, "error", "issues"} or {"ok": True,
160
+ "pitch", "cells", ..., "warnings"}."""
161
+ from .domain import build_domain
162
+ from .schema import lint
163
+ from .simp import filter_radius
164
+ from .spec import study_from_dict
165
+
166
+ if isinstance(raw, str):
167
+ try:
168
+ raw = json.loads(raw)
169
+ except json.JSONDecodeError as exc:
170
+ return {"ok": False, "error": f"study is not valid JSON: {exc}"}
171
+ issues = lint(raw, "study")
172
+ errors = [i for i in issues if i["level"] == "error"]
173
+ if errors:
174
+ return {"ok": False, "error": "; ".join(f"{i['path']}: {i['message']}" for i in errors),
175
+ "issues": issues, "schema": "loadpath schema study (MCP: loadpath_schema)"}
176
+ try:
177
+ s = study_from_dict(raw, base_dir=base_dir or os.getcwd())
178
+ d = build_domain(s)
179
+ except (ValueError, KeyError, TypeError, IndexError) as exc:
180
+ return {"ok": False, "error": str(exc) or type(exc).__name__, "issues": issues}
181
+ warnings = [f"{i['path']}: {i['message']}" for i in issues] + list(d.warnings)
182
+ notes = []
183
+ warnings += _preserve_cut(s)
184
+ notes += _resting_supports(s)
185
+ notes += _glued_faces(s)
186
+ w, n = _blind_bores(s)
187
+ warnings += w
188
+ notes += n
189
+ # only moments the study wrote: one loadpath derived (mass or force 'at'
190
+ # an offset) is in N·mm by construction
191
+ typed = set()
192
+ for rc in (raw.get("loadCases") or []) if isinstance(raw, dict) else []:
193
+ for li, rl in enumerate((rc.get("loads") or []) if isinstance(rc, dict) else []):
194
+ if isinstance(rl, dict) and ("moment" in rl or "torque" in rl):
195
+ typed.add((str(rc.get("name")), li))
196
+ for ci, c in enumerate(s.load_cases):
197
+ for li, ld in enumerate(c.loads):
198
+ mm = float((ld.moment ** 2).sum() ** 0.5)
199
+ if 0 < mm < 50 and (str(c.name).split("@")[0], li) in typed:
200
+ warnings.append(f"loadCases[{ci}].loads[{li}].moment: {mm:g} N·mm is tiny; moments are in N·mm, "
201
+ f"so {mm:g} N·m is {mm * 1000:g}")
202
+ dur = raw.get("durability") if isinstance(raw, dict) else None
203
+ if isinstance(dur, dict) and not dur.get("constrain"):
204
+ notes.append("durability is checked on the delivered part, not designed for: the search picks the "
205
+ "lightest static pass, whose fatigue/creep safety factor can be under target. Set "
206
+ "durability.constrain: true to size the part for it"
207
+ + ("" if "safetyFactor" in dur else
208
+ f" (durability.safetyFactor defaults to 1.5, not the goal's {s.goal.safety_factor:g})"
209
+ if s.goal.safety_factor else ""))
210
+ if not s.pitch and s.min_member and abs(d.pitch - 0.65 * s.min_member) <= 1e-3 * d.pitch:
211
+ notes.append(f"pitch {d.pitch:.2f} mm is set by minMember {s.min_member:g} mm (two cells per filter "
212
+ f"radius; finer cells only cost time), so the grid has {d.n_elem} cells, fewer than "
213
+ f"resolution.maxElements {s.max_elements}")
214
+ planar = [c.name for c in s.load_cases if "@" in c.name]
215
+ if planar:
216
+ src = len({c.name.split("@")[0] for c in s.load_cases if "@" in c.name})
217
+ notes.append(f"{len(s.load_cases)} load cases ({len(planar)} of them are {src} in-plane load case"
218
+ f"{'s' if src > 1 else ''} expanded into one case per direction, 8 or 16 each): every "
219
+ "optimizer step solves each one, so the run takes about that many times as long as one case")
220
+ from .study import _available_bytes, _confirm_cells, _run_bytes
221
+ g = s.goal
222
+ peak = max(d.n_elem, _confirm_cells(s) if (g.safety_factor or g.max_deflection) else 0)
223
+ mem = _run_bytes(peak) / 1e9
224
+ avail = _available_bytes()
225
+ if mem > 4 and (g.safety_factor or g.max_deflection) and not os.environ.get("LOADPATH_CONFIRM_CELLS"):
226
+ notes.append(f"the fine re-check sets the peak ({mem:.1f} GB at full size); the run shrinks it to fit "
227
+ "the free memory by itself, so nothing needs setting. To leave room for other jobs on a "
228
+ "shared machine, LOADPATH_CONFIRM_CELLS caps it (e.g. 50000 for about a third)")
229
+ if avail and mem * 1e9 > 0.8 * avail:
230
+ warnings.append(f"this run needs about {mem:.1f} GB at its peak and {avail / 1e9:.1f} GB is free: "
231
+ "lower resolution.maxElements, or set LOADPATH_CONFIRM_CELLS for the confirm stage")
232
+ return jsonable({
233
+ "ok": True, "name": s.name, "pitch": d.pitch, "peakMemoryGB": round(mem, 1), "cells": d.n_elem, "dims": list(d.dims),
234
+ "preservedCells": int(d.preserve.sum()), "filterRadius": filter_radius(d, s.length_scale, s.min_hole),
235
+ "supportNodes": int(d.support_nodes.sum()), "loadNodes": int(d.load_nodes.sum()),
236
+ "regions": [r.id for r in s.preserve] + [r.id for r in s.keep_out],
237
+ "loadCases": [c.name for c in s.load_cases], "material": s.material.name,
238
+ # what each case resolves to (mass, 'at', fasteners and in-plane
239
+ # directions expanded): to check before a half-hour run
240
+ "resolvedLoads": [{"case": c.name, "loads": [
241
+ {"region": ld.region, "force": np.round(ld.force, 3).tolist(),
242
+ **({"moment": np.round(ld.moment, 1).tolist()} if np.any(ld.moment) else {}),
243
+ **({"direction": ld.direction} if ld.direction else {}),
244
+ **({"distribution": ld.distribution} if ld.distribution != "uniform" else {})}
245
+ for ld in c.loads if not ld.pressure]} for c in s.load_cases],
246
+ "minMember": s.min_member, "warnings": warnings, "notes": notes,
247
+ })
248
+
249
+
250
+ def verify_mesh(raw, path: str, base_dir: str | None = None, max_cells: int = 60_000) -> dict:
251
+ """Check any mesh (an earlier outcome, a hand-edited STL, a part from
252
+ elsewhere) against a study: mass, safety factor and deflection per load
253
+ case, keep-out intrusion, preserve loss, bodies, and pass/fail against
254
+ the study's goal. The same analysis `run` gives its own outcomes, on the
255
+ voxel grid (no tet cross-check)."""
256
+ import trimesh
257
+
258
+ from .delivery import attention
259
+ from .spec import study_from_dict
260
+ from .study import _margin
261
+ from .verify import verify_part
262
+
263
+ checked = check_study(raw, base_dir)
264
+ if not checked["ok"]:
265
+ return checked
266
+ if isinstance(raw, str):
267
+ raw = json.loads(raw)
268
+ full = path if base_dir is None or os.path.isabs(path) else os.path.join(base_dir, path)
269
+ if not os.path.isfile(full):
270
+ return {"ok": False, "error": f"no mesh at {full}"}
271
+ study = study_from_dict(raw, base_dir=base_dir or os.getcwd())
272
+ rep = verify_part(study, trimesh.load(full, force="mesh"), max_cells=max_cells)
273
+ margin = _margin(study, rep)
274
+ out = {k: rep.get(k) for k in ("mass", "volume", "massReduction", "safetyFactor", "keepOutIntrusion",
275
+ "preserveMissing", "outsideDesignSpace", "bodies", "enclosedCavityVolume",
276
+ "minMember", "p5Member", "closedBores", "analysisCells")}
277
+ out["cases"] = [{k: c.get(k) for k in ("name", "safetyFactor", "peakStress", "peakAt", "deflection",
278
+ "worstDirection", "interlayerStress") if c.get(k) is not None}
279
+ for c in rep["cases"]]
280
+ if rep.get("durability"):
281
+ out["durability"] = {k: rep["durability"].get(k) for k in ("safetyFactor", "target", "warnings")}
282
+ out["margin"] = margin
283
+ out["passes"] = bool(margin is not None and margin >= 1.0 and rep.get("bodies") == 1
284
+ and (rep.get("keepOutIntrusion") or 0) <= 1.0 and (rep.get("preserveMissing") or 0) <= 1.0)
285
+ out["attention"] = attention({**rep, "passes": out["passes"]}, raw)
286
+ return jsonable({"ok": True, "stl": full, "safetyFactorMethod": "voxel", **out})
287
+
288
+
289
+ def _leaves(shape):
290
+ return [x for c in shape.shapes for x in _leaves(c)] if hasattr(shape, "shapes") else [shape]
291
+
292
+
293
+ def _bore_through(p, k) -> bool:
294
+ """A keep-out cylinder whose axis runs through the preserved piece: a
295
+ bore or bolt hole through its boss or plate, the idiom, not a mistake.
296
+ A driver path clipping the side of a boss has its axis outside it."""
297
+ import numpy as np
298
+
299
+ if getattr(k, "kind", None) != "cylinder":
300
+ return False
301
+ t = np.linspace(0.0, 1.0, 33)[:, None]
302
+ axis = k.p0 + t * (k.p1 - k.p0)
303
+ return bool((p.sdf(axis) < 0).any())
304
+
305
+
306
+ def _resting_supports(s) -> list[str]:
307
+ """A support on a whole round hole (a rod, a peg, a pin the part hangs
308
+ on) holds it all round, bonded. A part that only rests on it bears on a
309
+ narrow line where the load pushes it; holding the rest of the hole
310
+ hides the bending at the hook's mouth (agents under-read such parts by
311
+ up to 3x). Say so; a bolted or clamped bore is the user's call."""
312
+ import numpy as np
313
+
314
+ out = []
315
+ try:
316
+ ko = {str(k.id): k for k in s.keep_out}
317
+ for sp in s.supports:
318
+ k = ko.get(str(sp.region)) if sp.region else None
319
+ if k is None or sp.pin or str(k.id).endswith("-bore") or not all(sp.fix):
320
+ continue
321
+ leaves = [c for c in _leaves(k.shape) if getattr(c, "kind", None) == "cylinder"]
322
+ if len(leaves) != 1 or leaves[0].r < 4:
323
+ continue
324
+ c = leaves[0]
325
+ net = [np.sum([ld.force for ld in lc.loads], axis=0) for lc in s.load_cases]
326
+ radial = [f - c.axis * (c.axis @ f) for f in net]
327
+ if not any(np.linalg.norm(f) > 1e-9 for f in radial):
328
+ continue
329
+ out.append(f"supports: {k.id!r} is held all round, bonded. If the part only rests on it (a hook on a "
330
+ "rod, a cradle, a peg), it bears on a narrow line where the load pushes it: support a thin "
331
+ "box over that line instead (e.g. ±0.2 r wide, the hole's top), or the bending at the "
332
+ "opening is hidden")
333
+ except Exception: # noqa: BLE001 - a lint
334
+ return out
335
+ return out
336
+
337
+
338
+ def _glued_faces(s) -> list[str]:
339
+ """A support on a thin slab under a whole face, holding the face's normal,
340
+ in a study with bolts through that face: the part is held down
341
+ everywhere, as if glued to the plate, while a bolted foot is held only
342
+ where the bolts clamp it (the plate pushes, it does not pull). A load
343
+ that lifts or tips the part then reads far too stiff and strong: a hot
344
+ stepper bracket with its whole base fixed in z read SF 3.5, and 0.98
345
+ held under its two bolts (brief evals, 2026-11-01)."""
346
+ import numpy as np
347
+
348
+ out = []
349
+ try:
350
+ if not s.joints:
351
+ return out
352
+ lo, hi = s.bounds()
353
+ ext = np.maximum(hi - lo, 1e-9)
354
+ for sp in s.supports:
355
+ sh = sp.shape
356
+ if getattr(sh, "kind", None) != "box" or sp.pin:
357
+ continue
358
+ size = np.minimum(sh.hi, hi) - np.maximum(sh.lo, lo)
359
+ thin = int(np.argmin(size))
360
+ if size[thin] > 1.5 or not sp.fix[thin]:
361
+ continue
362
+ others = [a for a in range(3) if a != thin]
363
+ if np.prod(np.clip(size[others], 0, None)) < 0.4 * np.prod(ext[others]):
364
+ continue
365
+ axis = "xyz"[thin]
366
+ if not any(abs(np.asarray(j.get("axis", (0, 0, 0)), float)[thin]) > 0.9 for j in s.joints):
367
+ continue
368
+ out.append(f"supports: a slab over the whole {axis} face holds it in {axis} everywhere, as if glued "
369
+ f"to what it sits on. A part bolted down is held only under its bolts (the plate pushes, "
370
+ f"it cannot pull), so a load that lifts or tips it reads too strong: hold {axis} on a "
371
+ "washer-sized disc under each bolt instead, unless the face really is bonded")
372
+ except Exception: # noqa: BLE001 - a lint
373
+ return out
374
+ return out
375
+
376
+
377
+ def _blind_bores(s) -> tuple[list[str], list[str]]:
378
+ """A bore keep-out (a shaft, pin or bearing seat) that ends inside the
379
+ design space: the optimizer may close material over its end, and then the
380
+ shaft cannot go in. Sealed at both ends is almost always a mistake; at
381
+ one end it is a blind pocket, which is right only if nothing passes
382
+ through."""
383
+ import numpy as np
384
+
385
+ warn, note = [], []
386
+ try:
387
+ ds = list(s.design_space)
388
+ others = [k for k in s.keep_out]
389
+
390
+ def end(pt, own):
391
+ pt = np.asarray(pt, float)[None, :]
392
+ if min(float(d.sdf(pt)[0]) for d in ds) >= 0:
393
+ return "open" # outside the design space
394
+ if any(float(k.shape.sdf(pt)[0]) < 0 for k in others if k is not own):
395
+ return "pocket" # into another keep-out
396
+ return "closed"
397
+
398
+ for k in s.keep_out:
399
+ # a fastener's head and driver path: `driver` sizes it, and its
400
+ # far end against material is the user's call (a side tool)
401
+ # (and its tap: blind by design, the screw comes from the mating part)
402
+ if not getattr(k, "bore", True) or str(k.id).endswith(("-head", "-tap")):
403
+ continue
404
+ for c in _leaves(k.shape):
405
+ if getattr(c, "kind", None) != "cylinder" or c.r_inner > 0:
406
+ continue
407
+ e = max(1.0, 0.5 * c.r)
408
+ ends = [end(c.p0 - e * c.axis, k), end(c.p1 + e * c.axis, k)]
409
+ ax = "[" + ", ".join(f"{v:g}" for v in np.round(c.axis, 3)) + "]"
410
+ if "closed" not in ends:
411
+ continue
412
+ if "open" not in ends:
413
+ how = "at both ends" if ends == ["closed", "closed"] else "at one end and into another keep-out at the other"
414
+ warn.append(f"keepOut {k.id!r}: the cylinder along {ax} ends inside the design space {how}, "
415
+ "so the part can close over it and a shaft or pin may have no way in. Extend "
416
+ "it past the design space on the side it is inserted from (or set "
417
+ "\"bore\": false if that is on purpose)")
418
+ elif c.r >= 2.5: # a blind tapped or insert hole is normal; a blind shaft seat is worth a look
419
+ note.append(f"keepOut {k.id!r}: the cylinder along {ax} ends inside the design space at "
420
+ "one end, a blind pocket. If a shaft, pin or bearing passes through, extend "
421
+ "it past the design space")
422
+ break
423
+ except Exception: # noqa: BLE001 - a lint
424
+ pass
425
+ return list(dict.fromkeys(warn)), list(dict.fromkeys(note))
426
+
427
+
428
+ def _preserve_cut(s) -> list[str]:
429
+ """Keep-outs win over preserves, silently: a fastener's driver path
430
+ through a preserved pad, or a clearance box over a boss, deletes what the
431
+ study asked to keep. Say so when one takes a real share of a preserved
432
+ piece (bores through their own bosses excepted)."""
433
+ from .verify import _union
434
+
435
+ out = []
436
+ try:
437
+ for r in s.preserve:
438
+ for p in _leaves(r.shape):
439
+ pm = _union([p])
440
+ vol = float(pm.volume())
441
+ for k in s.keep_out:
442
+ kid, rid = str(k.id), str(r.id)
443
+ if "-" in kid and "-" in rid and kid.rsplit("-", 1)[0] == rid.rsplit("-", 1)[0]:
444
+ continue # one fastener's own tap through its own seat or sleeve
445
+ # a head and driver path is not a bore: it cuts what it passes through
446
+ head = kid.endswith("-head")
447
+ cut = 0.0
448
+ for kl in _leaves(k.shape):
449
+ if head or not _bore_through(p, kl):
450
+ cut += float((pm ^ _union([kl])).volume())
451
+ if vol > 0 and cut > 1.0 and cut > 0.05 * vol:
452
+ out.append(f"keepOut {k.id!r} removes {cut:.0f} mm^3 ({cut / vol:.0%}) of preserve {r.id!r}: "
453
+ "keep-outs win, so that material will not be there. Move or shrink one of "
454
+ "them (a fastener's head and driver path are keep-outs too: check its "
455
+ "direction and `driver` length)")
456
+ break
457
+ except Exception: # noqa: BLE001 - a lint, never a reason to refuse the study
458
+ return out
459
+ return list(dict.fromkeys(out))
460
+
461
+
462
+ # --- lattice infill ---------------------------------------------------------
463
+
464
+ def _lattice_options(spec, study):
465
+ from .lattice import LatticeOptions
466
+ from .schema import format_issues, lint
467
+
468
+ spec = dict(spec or {})
469
+ errors = [i for i in lint(spec, "lattice") if i["level"] == "error"]
470
+ if errors:
471
+ raise ValueError("lattice options: " + format_issues(errors))
472
+ return LatticeOptions.from_spec(spec, study.process, study.goal.safety_factor)
473
+
474
+
475
+ def lattice_part(study, mesh, options: dict | None, out_dir: str, stem: str, render: bool = True,
476
+ on_progress=None) -> dict:
477
+ """Fill `mesh` (a finished part for `study`) with a TPMS lattice graded by
478
+ the part's own stress, verify the result and write <stem>-lattice.stl
479
+ (and .png). Returns the report."""
480
+ from .lattice import lattice_infill
481
+ from .verify import verify_part
482
+
483
+ o = _lattice_options(options, study)
484
+ _emit(on_progress, stage="lattice-stress", note="analyzing the solid part")
485
+ solid = verify_part(study, mesh, refine=0, fields=o.target_sf is None)
486
+ _emit(on_progress, stage="lattice-build", type=o.kind, cell=o.cell,
487
+ sizing="homogenized" if o.target_sf else "solid-stress")
488
+ lat, info = lattice_infill(study, mesh, solid.get("fields"), o)
489
+ _emit(on_progress, stage="lattice-verify")
490
+ rep = verify_part(study, lat, refine=0)
491
+ os.makedirs(out_dir, exist_ok=True)
492
+ stl = os.path.join(out_dir, stem + "-lattice.stl")
493
+ lat.export(stl)
494
+ out = {"stl": stl, "lattice": info, "massGrams": info["mass"],
495
+ "solidMassGrams": solid["mass"], "safetyFactor": rep.get("safetyFactor"),
496
+ "solidSafetyFactor": solid.get("safetyFactor"), "bodies": rep.get("bodies"),
497
+ "keepOutIntrusion": rep.get("keepOutIntrusion"), "preserveMissing": rep.get("preserveMissing"),
498
+ "enclosedCavityVolume": rep.get("enclosedCavityVolume"),
499
+ "cases": [{"name": c["name"], "peakStressMPa": c["peakStress"], "safetyFactor": c["safetyFactor"],
500
+ "deflectionMm": c["deflection"]} for c in rep.get("cases") or []]}
501
+ attention = []
502
+ target = study.goal.safety_factor
503
+ sf = out["safetyFactor"]
504
+ if target and sf is not None and sf < target:
505
+ attention.append(f"safety factor {sf:.2f} is under the goal's {target:g}: raise safetyFactor, minWall "
506
+ "or maxWall, or use a smaller cell")
507
+ if (out["bodies"] or 1) > 1:
508
+ attention.append(f"{out['bodies']} separate bodies")
509
+ out["attention"] = attention
510
+ out["printability"] = info.get("printability")
511
+ if info.get("enclosedVoid", 0) > 1.0 and study.process is not None and study.process.kind != "fdm":
512
+ out["warning"] = (f"{info['enclosedVoid']:.0f} mm^3 of lattice is still walled in, which traps "
513
+ f"{study.process.kind} powder or resin; set drains to 'yes', or skin to 0")
514
+ if render:
515
+ from .render import render_views
516
+ png = os.path.join(out_dir, stem + "-lattice.png")
517
+ sf = out["safetyFactor"]
518
+ render_views(lat, png, study, title=f"{stem} lattice {o.kind} {o.cell:g}mm {info['mass']:.1f} g"
519
+ + (f" SF {sf:.2f}" if sf is not None else ""))
520
+ out["render"] = png
521
+ return jsonable(out)
522
+
523
+
524
+ # --- heat exchangers --------------------------------------------------------
525
+
526
+ _HX_OWN = ("name", "description", "step", "duty", "expect", "envelope", "sizing", "simulate")
527
+
528
+
529
+ def _hx_spec(spec, base_dir):
530
+ from .heatx import heat_exchanger_from_spec
531
+ from .schema import format_issues, lint
532
+
533
+ errors = [i for i in lint(spec, "heatExchanger") if i["level"] == "error"]
534
+ if errors:
535
+ raise ValueError(format_issues(errors))
536
+ hx = heat_exchanger_from_spec({k: v for k, v in spec.items() if k not in _HX_OWN}, base_dir)
537
+ if "step" in spec:
538
+ hx.step = float(spec["step"])
539
+ return hx
540
+
541
+
542
+ def _duty(spec, hx):
543
+ duty = spec.get("duty")
544
+ if not duty:
545
+ return None
546
+ missing = [n for n in hx.names if n not in duty]
547
+ if missing:
548
+ raise ValueError(f"duty needs an entry for each fluid {list(hx.names)}; missing {missing}")
549
+ return duty
550
+
551
+
552
+ def _wall_density(wall):
553
+ """g/cm^3 of the duty's wall material (a name), or None."""
554
+ from .heatx_thermal import WALL_DENSITY
555
+
556
+ if not isinstance(wall, str):
557
+ return None
558
+ if wall in WALL_DENSITY:
559
+ return WALL_DENSITY[wall]
560
+ try:
561
+ from .materials import material_from_spec
562
+ return float(material_from_spec(wall).density)
563
+ except (ValueError, KeyError, TypeError):
564
+ return None
565
+
566
+
567
+ def _hx_memory(hx, force=False) -> dict:
568
+ """The design grid's plan (heatx.grid_plan), refused up front when the
569
+ point cap leaves too coarse a grid for the wall, or its peak memory is
570
+ over what this machine has free (unless `force`)."""
571
+ from .heatx import grid_plan
572
+ from .study import _available_bytes
573
+
574
+ plan = grid_plan(hx)
575
+ if not plan["fits"]:
576
+ raise ValueError(plan["error"])
577
+ avail = _available_bytes()
578
+ if avail:
579
+ plan["availableGB"] = round(avail / 1e9, 2)
580
+ if plan["peakMemoryGB"] * 1e9 > 0.9 * avail and not force:
581
+ raise ValueError(f"the design grid is {plan['points'] / 1e6:.1f}M points at {plan['step']:.3f} mm, about "
582
+ f"{plan['peakMemoryGB']:.1f} GB at its peak, and {avail / 1e9:.1f} GB is free: set a "
583
+ f"coarser \"step\" (at most wall / 2 = {0.5 * hx.wall:.2f} mm), a lower --max-points, "
584
+ "or a smaller core; or --force to run anyway")
585
+ return plan
586
+
587
+
588
+ def heat_exchanger(spec: dict, out_dir: str, base_dir: str | None = None, render: bool = True,
589
+ on_progress=None, viewer: bool = True, max_points: int | None = None,
590
+ force: bool = False) -> dict:
591
+ """Design a heat exchanger, check it, rate it if `duty` is given (and
592
+ simulate it whole with "simulate": true; porous.py), and write <name>.stl, <name>.png, the fluid spaces (<name>-<fluid>.stl) and
593
+ an interactive viewer (<name>.html). `max_points` caps the design grid
594
+ (default 16M; about 400 bytes a point at the peak); a design whose
595
+ estimated peak is over the free memory is refused unless `force`."""
596
+ from .heatx import design_heat_exchanger
597
+ from .heatx_thermal import rate_design
598
+
599
+ hx = _hx_spec(spec, base_dir)
600
+ if max_points:
601
+ hx.max_points = int(max_points)
602
+ duty = _duty(spec, hx)
603
+ name = str(spec.get("name") or "heat-exchanger")
604
+ sim_opts = spec.get("simulate")
605
+ sim_opts = ({} if sim_opts is True else dict(sim_opts)) if sim_opts and duty is not None else None
606
+ from . import quick
607
+ sim_opts = quick.heatx(hx, sim_opts)
608
+ plan = _hx_memory(hx, force)
609
+ _emit(on_progress, stage="design", type=hx.kind, cell=hx.cell, wall=hx.wall, layout=hx.layout,
610
+ gridStep=plan["step"], gridPoints=plan["points"], peakMemoryGB=plan["peakMemoryGB"])
611
+ mesh, report, spaces, grids = design_heat_exchanger(hx, fluid_meshes=True, grids=True)
612
+ rho = _wall_density((duty or {}).get("wall"))
613
+ if rho is not None:
614
+ # the whole part (core, shell, plenums, ports), as printed dense
615
+ report["mass"] = float(report["solidVolume"]) * rho / 1000.0
616
+ report["massMaterial"] = {"name": duty["wall"], "density": rho}
617
+ if sim_opts is None:
618
+ del grids
619
+ os.makedirs(out_dir, exist_ok=True)
620
+ stl = os.path.join(out_dir, name + ".stl")
621
+ mesh.export(stl)
622
+ out = {"name": name, "stl": stl, "report": report, "fluidSpaces": {}}
623
+ for fl, m in spaces.items():
624
+ p = os.path.join(out_dir, f"{name}-{fl}.stl")
625
+ m.export(p)
626
+ out["fluidSpaces"][fl] = p
627
+ ok = bool(report.get("isolated")) and all((report.get("connected") or {}).values())
628
+ out["passes"] = ok and bool(report.get("watertight"))
629
+ if duty is not None:
630
+ _emit(on_progress, stage="rate")
631
+ try:
632
+ out["rating"] = rate_design(hx, report, duty)
633
+ except ValueError as exc:
634
+ out["ratingError"] = str(exc)
635
+ if sim_opts is not None:
636
+ _emit(on_progress, stage="simulate")
637
+ from .porous import plot_fields, simulate_design
638
+ try:
639
+ sim = simulate_design(hx, report, grids, duty, sim_opts.get("voxel"), sim_opts.get("closures", "design"),
640
+ fields=render)
641
+ f = sim.pop("_fields", None)
642
+ if f is not None:
643
+ png = os.path.join(out_dir, name + "-simulation.png")
644
+ # the plane both fluids flow in (a counterflow: A end to end, B across)
645
+ plot_fields(f, png, axis=({0, 1, 2} - {int(a) for a in hx.axes}).pop(),
646
+ title=f"{name}: whole-exchanger simulation, middle of the core")
647
+ sim["render"] = png
648
+ out["simulation"] = sim
649
+ except ValueError as exc:
650
+ out["simulationError"] = str(exc)
651
+ del grids
652
+ if render:
653
+ _emit(on_progress, stage="render")
654
+ from .render import render_heat_exchanger
655
+ png = os.path.join(out_dir, name + ".png")
656
+ render_heat_exchanger(mesh, spaces, hx.core, png, title=f"{name} {hx.kind} {hx.cell:g}/{hx.wall:g}mm "
657
+ f"{hx.layout}")
658
+ out["render"] = png
659
+ if viewer:
660
+ from .viewer import heat_exchanger_viewer
661
+ html = os.path.join(out_dir, name + ".html")
662
+ heat_exchanger_viewer(mesh, spaces, report, html, title=name)
663
+ out["viewer"] = html
664
+ return jsonable(out)
665
+
666
+
667
+ def _size_in_envelope(spec, max_pressure_drop, objective, min_effectiveness, min_wall=None, splits=None,
668
+ axes=None, max_points=None):
669
+ """Size core, cell, wall, split, layout and ports to fit `envelope` (sizing.py)."""
670
+ from .schema import format_issues, lint
671
+ from .sizing import size_exchanger
672
+
673
+ errors = [i for i in lint(spec, "heatExchanger") if i["level"] == "error"]
674
+ if errors:
675
+ raise ValueError(format_issues(errors))
676
+ duty = spec.get("duty")
677
+ names = spec.get("fluids", ["A", "B"])
678
+ if not duty or any(n not in duty for n in names):
679
+ raise ValueError(f"sizing needs a duty with an entry for each fluid {names}")
680
+ sz = spec.get("sizing") or {}
681
+ # what the caller passes wins over the spec's sizing block
682
+ obj = objective or sz.get("objective") or "effectiveness"
683
+ kw = {"splits": tuple(float(x) for x in splits)} if splits is not None else {}
684
+ res = size_exchanger({**{n: duty[n] for n in names}, **({"wall": duty["wall"]} if "wall" in duty else {})},
685
+ spec["envelope"], process=sz.get("process", spec.get("process", "fdm")),
686
+ max_pressure_drop=max_pressure_drop if max_pressure_drop is not None
687
+ else sz.get("maxPressureDrop"),
688
+ min_effectiveness=min_effectiveness if min_effectiveness is not None
689
+ else sz.get("minEffectiveness"),
690
+ target_duty=sz.get("targetDuty"), objective=obj, kind=spec.get("type", "gyroid"),
691
+ layout=spec.get("layout", "auto"), min_wall=min_wall, axes=axes or spec.get("axes"),
692
+ max_points=max_points, **kw)
693
+ for c in res["candidates"]:
694
+ c["spec"]["name"] = f"{spec.get('name', 'sized')}-{c['layout']}-{c['cell']:g}-{c['wall']:g}"
695
+ res["next"] = ("Design the best one: write its \"spec\" to a file and run loadpath heatx on it (add "
696
+ "\"simulate\": true to check flow sharing and outlet temperatures whole).")
697
+ return res
698
+
699
+
700
+ def size_heat_exchanger(spec: dict, base_dir: str | None = None, max_pressure_drop: dict | None = None,
701
+ min_wall: float | None = None, objective: str | None = None,
702
+ min_effectiveness: float | None = None, splits=None, axes: str | None = None,
703
+ max_points: int | None = None, out_dir: str | None = None) -> dict:
704
+ """Sweep cell and wall for the core and duty in `spec` before designing
705
+ it (seconds, not minutes): the candidates that meet the pressure-drop
706
+ limits ({fluid: Pa}) and `min_effectiveness`, ranked by `objective`:
707
+ "effectiveness" (default; best first), "mass" (least metal first;
708
+ "metal" is the same) or "pressure" (least of the worst fluid's share of
709
+ its limit). `splits` (the first fluid's share of the void) sweeps the
710
+ sheet's offset too; default the spec's split. `min_wall` floors the
711
+ walls (default the process's minimum). Flow lengths are the core's
712
+ extent along each fluid's axis. With an `envelope` instead of a core,
713
+ sizing.py sizes the core too (`axes` pins which way the first fluid
714
+ runs). Only walls the design step can mesh within `max_points` are
715
+ kept. With `out_dir`, writes sizing.json (the ranked result) and
716
+ best.json (the best candidate's spec, ready for loadpath heatx)."""
717
+ from .shapes import manifold_from_trimesh # noqa: F401 (manifold3d present)
718
+
719
+ if "envelope" in spec:
720
+ res = jsonable(_size_in_envelope(spec, max_pressure_drop, objective, min_effectiveness, min_wall, splits,
721
+ axes, max_points))
722
+ return _write_sizing(res, out_dir, (res.get("best") or {}).get("spec"))
723
+ res = _size_core_spec(spec, base_dir, max_pressure_drop, min_wall, objective, min_effectiveness, splits,
724
+ max_points)
725
+ best = None
726
+ if res.get("best"):
727
+ b = res["best"]
728
+ best = {k: v for k, v in spec.items() if k not in ("expect",)}
729
+ best.update(cell=b["cell"], wall=b["wall"], split=b["split"])
730
+ if "step" in best:
731
+ best["step"] = min(float(best["step"]), 0.5 * b["wall"])
732
+ return _write_sizing(res, out_dir, best)
733
+
734
+
735
+ def _write_sizing(res, out_dir, best_spec):
736
+ if out_dir:
737
+ os.makedirs(out_dir, exist_ok=True)
738
+ path = os.path.join(out_dir, "sizing.json")
739
+ res["files"] = {"sizing": path}
740
+ if best_spec is not None:
741
+ bp = os.path.join(out_dir, "best.json")
742
+ with open(bp, "w") as fh:
743
+ json.dump(best_spec, fh, indent=2)
744
+ res["files"]["best"] = bp
745
+ with open(path, "w") as fh:
746
+ json.dump(res, fh, indent=2)
747
+ return res
748
+
749
+
750
+ def _size_core_spec(spec, base_dir, max_pressure_drop, min_wall, objective, min_effectiveness, splits, max_points):
751
+ from .heatx import grid_plan
752
+ from .heatx_thermal import size_core
753
+ from .sizing import PROCESS_LIMITS
754
+
755
+ hx = _hx_spec(spec, base_dir)
756
+ if max_points:
757
+ hx.max_points = int(max_points)
758
+ if min_wall is None:
759
+ min_wall = PROCESS_LIMITS.get(hx.process, PROCESS_LIMITS["fdm"])["minWall"]
760
+ objective = objective or (spec.get("sizing") or {}).get("objective") or "effectiveness"
761
+ duty = _duty(spec, hx)
762
+ if duty is None:
763
+ raise ValueError("sizing needs a duty: {\"A\": {\"fluid\": \"water\", \"lPerMin\": 2, \"tIn\": 80}, "
764
+ "\"B\": {...}, \"wall\": \"petg\"}")
765
+ core_volume = float(hx.core.to_manifold().volume())
766
+ lo, hi = (np.asarray(b, float) for b in hx.core.bounds())
767
+ ext = hi - lo
768
+ a0, a1 = (a if isinstance(a, (int, np.integer)) else "xyz".index(a) for a in hx.axes)
769
+ lengths = [float(ext[a0]), float(ext[a0] if hx.layout == "counter" else ext[a1])]
770
+ duty = {**{n: duty[n] for n in hx.names}, **({"wall": duty["wall"]} if "wall" in duty else {})}
771
+ res = size_core(hx.kind, core_volume, lengths, duty, "counter" if hx.layout == "counter" else "cross",
772
+ hx.port_diameter, min_wall=min_wall, max_pressure_drop=max_pressure_drop,
773
+ split=splits if splits is not None else hx.split, objective=objective,
774
+ min_effectiveness=min_effectiveness)
775
+ # only walls the design step can mesh within its point cap (a grid of at most wall / 2)
776
+ keep, dropped, nearest = [], 0, None
777
+ step0 = hx.step
778
+ for c in res["candidates"]:
779
+ hx.wall = c["wall"]
780
+ # a spec's own step, but never past what the candidate's wall allows
781
+ hx.step = min(step0, 0.5 * c["wall"]) if step0 else None
782
+ plan = grid_plan(hx)
783
+ if plan["fits"]:
784
+ c["grid"] = {k: plan[k] for k in ("step", "points", "peakMemoryGB")}
785
+ keep.append(c)
786
+ else:
787
+ dropped += 1
788
+ nearest = plan if nearest is None or plan["maxPointsNeeded"] < nearest["maxPointsNeeded"] else nearest
789
+ res["candidates"], res["best"] = keep, keep[0] if keep else None
790
+ res["unmeshable"], res["maxPoints"] = dropped, int(hx.max_points)
791
+ if dropped:
792
+ res["note"] = (f"{dropped} candidate(s) dropped: their walls are too thin for the design step to mesh "
793
+ f"this core within {hx.max_points / 1e6:g}M grid points (the nearest needs "
794
+ f"{nearest['maxPointsNeeded'] / 1e6:.0f}M, about "
795
+ f"{nearest['memoryNeededGB']:.1f} GB): raise --max-points or "
796
+ "shrink the core")
797
+ res.update(coreVolume=core_volume, flowLengths=dict(zip(hx.names, lengths)),
798
+ next="Put the best cell, wall and split in the spec and run it "
799
+ "(loadpath heatx / loadpath_heat_exchanger).")
800
+ return jsonable(res)
801
+
802
+
803
+ # --- examples ---------------------------------------------------------------
804
+
805
+ def examples() -> list[dict]:
806
+ """The packaged example documents: name, kind and description."""
807
+ out = []
808
+ for f in sorted(os.listdir(_EXAMPLES)):
809
+ if f.endswith(".json"):
810
+ with open(os.path.join(_EXAMPLES, f)) as fh:
811
+ d = json.load(fh)
812
+ out.append({"name": f[:-5], "kind": "heatExchanger" if "core" in d else "study",
813
+ "description": d.get("description", "")})
814
+ return out
815
+
816
+
817
+ def example(name: str) -> dict:
818
+ path = os.path.join(_EXAMPLES, os.path.basename(name).removesuffix(".json") + ".json")
819
+ if not os.path.exists(path):
820
+ raise ValueError(f"no example {name!r}; known: {[e['name'] for e in examples()]}")
821
+ with open(path) as fh:
822
+ return json.load(fh)