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