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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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)
|