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/sizing.py ADDED
@@ -0,0 +1,312 @@
1
+ """
2
+ Size a two-fluid TPMS heat exchanger for a duty: the core's dimensions
3
+ inside an envelope, the cell, the wall, the split of the void between the
4
+ fluids and each fluid's ports, under a pressure-drop budget per fluid and
5
+ the print process's limits. Seconds: it sweeps the epsilon-NTU rating
6
+ (heatx_thermal.rate), corrected for what the whole-exchanger simulation
7
+ and the LBM showed the rating misses, and the best few can then be
8
+ designed and simulated whole (`verify`).
9
+
10
+ What the rating misses, and how it is put back:
11
+
12
+ - Edge bands. Outside its own zones each fluid is sealed within `cap` of
13
+ the core's surface, and partial channels next to that seal carry
14
+ almost nothing (porous.py, fitted to LBM of the 30 mm crossflow cube).
15
+ So each sealed face loses a band about `cap + cell / 4` deep from the
16
+ core that actually works. On a core a few cells across that is a third
17
+ of it; it is why small cells (more cells across) win more than the
18
+ rating alone says.
19
+ - Ports. A port that jets into its plenum loses more than the rating's
20
+ 1.5 velocity heads, and on the cube a 6 mm port at 1 L/min lost more
21
+ at the core's face than the porous medium can show. Each port is sized
22
+ so its velocity heads stay under a share of the fluid's budget
23
+ (`port_share`), as large as its face allows.
24
+ - Plenums. The port turns into its plenum through a ring pi d around and
25
+ `header` deep; a velocity head of that speed in and out is added. On
26
+ Connor's intercooler duty (0.143 kg/s charge air) a 6 mm plenum behind
27
+ a 47 mm port cost more than the core, so the header depth is swept too
28
+ (6-20 mm) and pays for itself out of the envelope.
29
+
30
+ Checked whole (porous.simulate_box, 3 mm voxels) on intercooler-sized
31
+ cores 200 x 70-100 x 62 mm: effectiveness within 5% (the sim reads
32
+ higher), core pressure drop within 10%.
33
+
34
+ The envelope is the part's bounding box. Each face with a zone gives up
35
+ header + shell + port length to its plenum and port; the other faces a
36
+ shell.
37
+
38
+ Process limits (walls that hold pressure and stay leak-tight, channels
39
+ that drain powder or resin): `PROCESS_LIMITS`.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import itertools
44
+
45
+ import numpy as np
46
+
47
+ from .heatx_thermal import WALL_DENSITY, _flow, _fluid, rate, tpms_geometry
48
+
49
+ __all__ = ["PROCESS_LIMITS", "size_exchanger", "verify"]
50
+
51
+ # per process: thinnest leak-tight wall (mm), narrowest channel (hydraulic diameter, mm,
52
+ # to clear powder or drain resin), and the heatx process that builds it
53
+ PROCESS_LIMITS = {
54
+ "fdm": {"minWall": 0.8, "minChannel": 1.5, "heatx": "fdm"},
55
+ "lpbf": {"minWall": 0.5, "minChannel": 2.0, "heatx": "lpbf"},
56
+ "sls": {"minWall": 0.8, "minChannel": 2.5, "heatx": "lpbf"},
57
+ "mjf": {"minWall": 0.8, "minChannel": 2.5, "heatx": "lpbf"},
58
+ "sla": {"minWall": 0.6, "minChannel": 1.5, "heatx": "lpbf"},
59
+ "dlp": {"minWall": 0.6, "minChannel": 1.5, "heatx": "lpbf"},
60
+ }
61
+
62
+ from .porous import _BAND_CELLS # noqa: E402 (the dead band under a sealed face, in cells, beyond the seal)
63
+
64
+
65
+ # port speed to aim for (m/s) when the face has room: whole-exchanger sims put 3-5
66
+ # velocity heads in the headers, so a port sized only to the budget leaves a
67
+ # liquid side over it (intercooler coolant: 11.5 mm port, 18 kPa of headers; 22 mm, 1.8)
68
+ _PORT_SPEED = {"liquid": 1.0, "gas": 25.0}
69
+
70
+
71
+ def _port(m, fl, dp_budget, share, d_max, d_min=4.0):
72
+ """The port (mm, 0.5 mm steps): wide enough that its 1.5 velocity heads
73
+ stay under `share` of the budget and its speed under _PORT_SPEED, as the
74
+ face allows. Returns (d, whether the face capped it below the budget's)."""
75
+ v_aim = _PORT_SPEED["liquid" if fl.rho > 100 else "gas"]
76
+ if dp_budget is None or not np.isfinite(dp_budget):
77
+ v = v_aim
78
+ else:
79
+ v = np.sqrt(share * dp_budget / (0.75 * fl.rho))
80
+ d_need = 2e3 * np.sqrt(m / (fl.rho * np.pi * v))
81
+ d = max(d_need, min(2e3 * np.sqrt(m / (fl.rho * np.pi * v_aim)), d_max))
82
+ d = max(d_min, np.ceil(d * 2.0) / 2.0)
83
+ return float(min(d, d_max)), d_need > d_max
84
+
85
+
86
+ def size_exchanger(duty: dict, envelope, *, process: str = "fdm", max_pressure_drop: dict | None = None,
87
+ layout: str = "auto", kind: str = "gyroid", cells=None, walls=None, splits=(0.4, 0.5, 0.6, 0.66),
88
+ min_effectiveness: float | None = None, target_duty: float | None = None,
89
+ objective: str = "mass", header: float | None = None, shell: float | None = None,
90
+ port_length: float = 8.0, cap: float | None = None, port_share: float = 0.25,
91
+ fractions=(0.4, 0.55, 0.7, 0.85, 1.0), keep: int = 8, min_wall: float | None = None,
92
+ axes: str | None = None, max_points: int | None = None) -> dict:
93
+ """Sweep the core's dimensions (fractions of what the envelope leaves),
94
+ cell, wall, split and layout for `duty` ({"A": {"fluid", "kgPerS" |
95
+ "lPerMin", "tIn"}, "B": {...}, "wall": material}). `envelope` (mm) is
96
+ the part's [x, y, z] extent; one fluid runs along x and the other
97
+ along y (cross), or back along the first with side ports on the other
98
+ (counter), both ways round; z carries no ports. Keeps designs within
99
+ `max_pressure_drop` ({fluid: Pa}) and, if given, `min_effectiveness`
100
+ or `target_duty` (W). `objective`: "mass" (lightest that meets them),
101
+ "effectiveness" (best), or "pressure" (least of the worst fluid's
102
+ share of its budget; "metal" is "mass"). Returns the best few, each
103
+ with a heatx spec ready to design ("spec"): its core centred in the
104
+ envelope's frame (the box from [0, 0, 0] to `envelope`), so ports and
105
+ plenums land inside it.
106
+
107
+ `min_wall` (mm) floors the walls swept (the default walls start at the
108
+ process's minimum);
109
+ `axes` ("xy" or "yx") pins which way fluid A runs (A along axes[0]);
110
+ without it both are tried. Only candidates the design step can mesh
111
+ within `max_points` grid points (heatx's cap, default 16M: a grid of at
112
+ most wall / 2) are returned; "unmeshable" counts those dropped.
113
+ "solidVolume" and "coreMass" are the core's sheet only: the shell,
114
+ plenums and ports add to it (the design's report.mass is the part)."""
115
+ from .heatx import HeatExchangerSpec, grid_plan, heat_exchanger_from_spec
116
+
117
+ if process not in PROCESS_LIMITS:
118
+ raise ValueError(f"process is one of {sorted(PROCESS_LIMITS)}")
119
+ objective = "mass" if objective == "metal" else objective
120
+ if objective not in ("mass", "effectiveness", "pressure"):
121
+ raise ValueError('objective is "mass", "effectiveness" or "pressure"')
122
+ if axes is not None and axes not in ("xy", "yx"):
123
+ raise ValueError(f"sizing puts the ports on the envelope's x and y faces (z, the build axis, carries "
124
+ f"none): axes is \"xy\" (the first fluid along x) or \"yx\", got {axes!r}; turn the "
125
+ "envelope so the ports face x and y")
126
+ max_points = int(max_points or HeatExchangerSpec.max_points)
127
+ lim = PROCESS_LIMITS[process]
128
+ names = [n for n in duty if n != "wall"]
129
+ if len(names) != 2:
130
+ raise ValueError("duty names two fluids")
131
+ fls = [_fluid(duty[n].get("fluid", "water")) for n in names]
132
+ ms = [_flow(duty[n], f) for n, f in zip(names, fls)]
133
+ t_in = [duty[n].get("tIn") for n in names]
134
+ caps = [m * f.cp for m, f in zip(ms, fls)]
135
+ if target_duty is not None:
136
+ if None in t_in:
137
+ raise ValueError("target_duty needs each fluid's tIn")
138
+ min_effectiveness = max(min_effectiveness or 0.0, target_duty / (min(caps) * abs(t_in[0] - t_in[1])))
139
+ budget = max_pressure_drop or {}
140
+ wall_mat = duty.get("wall", "petg")
141
+ density = WALL_DENSITY.get(wall_mat) if isinstance(wall_mat, str) else None
142
+ shell = shell if shell is not None else max(1.6, 2 * lim["minWall"])
143
+ env = np.asarray(envelope, float)
144
+ headers = [float(header)] if header is not None else [6.0, 9.0, 12.0, 16.0, 20.0]
145
+ # the process's minimum floors the default walls; min_wall floors any
146
+ floor = max(lim["minWall"] if walls is None else 0.0, float(min_wall or 0.0))
147
+ walls = [w for w in (walls if walls is not None else (0.5, 0.6, 0.8, 1.0, 1.2, 1.5)) if w >= floor - 1e-9]
148
+ if not walls:
149
+ raise ValueError(f"no wall to sweep at or over {floor:g} mm")
150
+ cells = cells if cells is not None else [4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 12.0, 14.0]
151
+ layouts = ["cross", "counter"] if layout == "auto" else [layout]
152
+ rows, rejected = [], 0
153
+ axes_tried = (axes,) if axes else ("xy", "yx")
154
+ for hd, lay, axes in itertools.product(headers, layouts, axes_tried):
155
+ allow = hd + shell + port_length
156
+ # what the envelope leaves the core: A's zones on its axis, B's on the other (both
157
+ # layouts); `room` is in A's frame (A along its first entry)
158
+ e = env if axes == "xy" else env[[1, 0, 2]]
159
+ room = np.array([e[0] - 2 * allow, e[1] - 2 * allow, e[2] - 2 * shell])
160
+ if (room <= 0).any():
161
+ continue
162
+ for fx, fy, fz in itertools.product(fractions, repeat=3):
163
+ dims = room * np.array([fx, fy, fz])
164
+ for a, t, sp in itertools.product(cells, walls, splits):
165
+ if a < 3 * t or min(dims) < a:
166
+ continue
167
+ cp_ = cap if cap is not None else t + 1.0
168
+ try:
169
+ geo = tpms_geometry(kind, a, t, sp)
170
+ except ValueError:
171
+ continue
172
+ if min(g["dh"] for g in geo["sides"]) < lim["minChannel"]:
173
+ continue
174
+ # the band each sealed face loses, and the core that works
175
+ band = cp_ + _BAND_CELLS * a
176
+ eff_dims = dims - 2 * band
177
+ if (eff_dims <= 0.25 * dims).any():
178
+ continue
179
+ v_eff = float(np.prod(eff_dims))
180
+ if lay == "cross":
181
+ lengths = [dims[0], dims[1]]
182
+ frontal = [eff_dims[1] * eff_dims[2], eff_dims[0] * eff_dims[2]]
183
+ faces = [(dims[1], dims[2]), (dims[0], dims[2])]
184
+ else:
185
+ lengths = [dims[0], dims[0]]
186
+ frontal = [eff_dims[1] * eff_dims[2], eff_dims[1] * eff_dims[2]]
187
+ faces = [(dims[1], dims[2]), (0.4 * dims[0], dims[2])]
188
+ ports, too_big = [], False
189
+ for i, n in enumerate(names):
190
+ d_max = min(faces[i]) - 2 * cp_ - 2.0
191
+ d, over = _port(ms[i], fls[i], budget.get(n, np.inf), port_share, d_max)
192
+ ports.append(d)
193
+ too_big |= over
194
+ # the port turns into the plenum through a ring pi d deep `header`: a
195
+ # velocity head of that speed (whole-exchanger sims put most of the
196
+ # header loss there; a shallow plenum on a big air port dominates)
197
+ if min(ports) < 3.0:
198
+ continue
199
+ # rate the working core: its volume, each fluid's frontal area through
200
+ # its full length (so the dead bands still cost pressure)
201
+ r = rate(kind, a, t, v_eff, [v_eff / f for f in frontal],
202
+ {**{n: duty[n] for n in names}, "wall": wall_mat},
203
+ "counter" if lay == "counter" else "cross", tuple(ports), split=sp)
204
+ dp, plen = {}, {}
205
+ for i, n in enumerate(names):
206
+ s = r["fluids"][n]
207
+ v_ring = ms[i] / (fls[i].rho * np.pi * ports[i] * 1e-3 * hd * 1e-3)
208
+ plen[n] = 2 * 0.5 * fls[i].rho * v_ring ** 2 # in and out
209
+ dp[n] = s["pressureDropCore"] * lengths[i] / (v_eff / frontal[i]) + \
210
+ s["pressureDropPorts"] + s["pressureDropMouths"] + plen[n]
211
+ solid = float(np.prod(dims)) * (1.0 - geo["phiSheet"])
212
+ world = dims if axes == "xy" else dims[[1, 0, 2]]
213
+ row = {"layout": lay, "axes": axes, "fluidAxes": dict(zip(names, axes)),
214
+ "core": np.round(world, 1).tolist(), "cell": float(a), "wall": float(t),
215
+ "split": float(sp), "cap": cp_, "header": hd,
216
+ "pressureDropPlenums": plen, "ports": dict(zip(names, ports)),
217
+ "effectiveness": r["effectiveness"], "effectivenessRange": r["effectivenessRange"],
218
+ "duty": r.get("duty"), "tOut": r.get("tOut"), "pressureDrop": dp,
219
+ "workingCore": round(v_eff / float(np.prod(dims)), 3),
220
+ "solidVolume": solid, "coreMass": solid * density / 1000.0 if density else None,
221
+ "portLimited": bool(too_big)}
222
+ ok = all(dp[n] <= budget.get(n, np.inf) for n in names) and \
223
+ (min_effectiveness is None or r["effectiveness"] >= min_effectiveness)
224
+ if ok:
225
+ rows.append(row)
226
+ else:
227
+ rejected += 1
228
+ if objective == "mass":
229
+ rows.sort(key=lambda x: (x["solidVolume"], -x["effectiveness"]))
230
+ elif objective == "effectiveness":
231
+ rows.sort(key=lambda x: -x["effectiveness"])
232
+ else:
233
+ rows.sort(key=lambda x: max(x["pressureDrop"][n] / budget.get(n, 1.0) for n in names))
234
+ best, unmeshable, nearest = [], 0, None
235
+ for x in rows:
236
+ # skip near-duplicates (same cell, wall and layout) so the list shows real options
237
+ if any(b["cell"] == x["cell"] and b["wall"] == x["wall"] and b["layout"] == x["layout"]
238
+ and b["axes"] == x["axes"] for b in best):
239
+ continue
240
+ spec = _spec(x, names, kind, x["header"], shell, port_length, lim["heatx"], duty, env)
241
+ # only what the design step can mesh within its point cap
242
+ hx = heat_exchanger_from_spec({k: v for k, v in spec.items() if k not in ("name", "duty")})
243
+ hx.max_points = max_points
244
+ plan = grid_plan(hx)
245
+ if not plan["fits"]:
246
+ unmeshable += 1
247
+ nearest = plan if nearest is None or plan["maxPointsNeeded"] < nearest["maxPointsNeeded"] else nearest
248
+ continue
249
+ x["spec"] = spec
250
+ x["grid"] = {k: plan[k] for k in ("step", "points", "peakMemoryGB")}
251
+ best.append(x)
252
+ if len(best) >= keep:
253
+ break
254
+ out = {"best": best[0] if best else None, "candidates": best, "objective": objective,
255
+ "considered": len(rows) + rejected, "feasible": len(rows), "process": process,
256
+ "minEffectiveness": min_effectiveness, "unmeshable": unmeshable, "maxPoints": max_points,
257
+ "envelopeFrame": {"min": [0.0, 0.0, 0.0], "max": env.tolist()}}
258
+ if unmeshable:
259
+ out["note"] = (f"{unmeshable} candidate(s) were dropped: the design step could not mesh them "
260
+ f"within {max_points / 1e6:g}M grid points (it needs a grid of at most wall / 2). The "
261
+ f"nearest needs {nearest['maxPointsNeeded'] / 1e6:.0f}M points, about "
262
+ f"{nearest['memoryNeededGB']:.1f} GB: raise the cap with "
263
+ "--max-points if the machine has that, or take a thicker wall")
264
+ return out
265
+
266
+
267
+ def _spec(row, names, kind, header, shell, port_length, heatx_process, duty, envelope=None):
268
+ c = np.asarray(row["core"], float)
269
+ # centred in the envelope's frame ([0, 0, 0] to envelope): ports and plenums inside it
270
+ lo = 0.5 * (np.asarray(envelope, float) - c) if envelope is not None else np.zeros(3)
271
+ return {"name": f"sized-{row['layout']}-{row['cell']:g}-{row['wall']:g}",
272
+ "core": {"type": "box", "min": np.round(lo, 2).tolist(), "max": np.round(lo + c, 2).tolist()},
273
+ "type": kind, "layout": row["layout"],
274
+ "axes": row["axes"], "fluids": list(names), "cell": row["cell"], "wall": row["wall"],
275
+ "split": row["split"], "cap": row["cap"], "header": header, "shell": shell,
276
+ "portLength": port_length, "portDiameter": row["ports"], "process": heatx_process,
277
+ "duty": duty}
278
+
279
+
280
+ def verify(candidate: dict, voxel=None, closures="design", progress=None, max_points=None, method="auto"):
281
+ """Simulate a sized candidate whole (porous.py): returns the
282
+ simulation's effectiveness, pressure drops and flow sharing next to the
283
+ sizing's own. `method` "design" designs it first (heatx, wall-resolving:
284
+ small cores); "box" builds the porous grid straight from the spec
285
+ (porous.box_domain, cross layout, any size); "auto" takes "box" for a
286
+ cross core over 1e5 mm3. The box sim's header losses read 3-5 port
287
+ velocity heads at 3 mm voxels where cell-resolved LBM of the tuned cube
288
+ measured ~1.4: take its header dp as an upper bound."""
289
+ from .heatx import heat_exchanger_from_spec
290
+ from .porous import simulate_box, simulate_exchanger
291
+
292
+ spec = dict(candidate["spec"])
293
+ duty = spec.pop("duty")
294
+ hx = heat_exchanger_from_spec(spec)
295
+ if max_points:
296
+ hx.max_points = int(max_points)
297
+ if method == "auto":
298
+ method = "box" if spec.get("layout") == "cross" and float(np.prod(candidate["core"])) > 1e5 else "design"
299
+ if method == "box":
300
+ sim = simulate_box(hx, duty, voxel=voxel, closures=closures, progress=progress)
301
+ rep = {}
302
+ else:
303
+ _, rep, sim = simulate_exchanger(hx, duty, voxel=voxel, closures=closures, progress=progress)
304
+ return {"sized": {k: candidate[k] for k in ("effectiveness", "pressureDrop", "tOut")},
305
+ "simulated": {"effectiveness": sim.get("effectiveness"),
306
+ "pressureDrop": {n: f["pressureDrop"] for n, f in sim["fluids"].items()},
307
+ "tOut": {n: f.get("tOut") for n, f in sim["fluids"].items()},
308
+ "midplaneCv": {n: f["midplane"]["cv"] for n, f in sim["fluids"].items()}},
309
+ "design": {"isolated": rep.get("isolated"), "connected": rep.get("connected"),
310
+ "minWallBetweenFluids": rep.get("minWallBetweenFluids"), "solidVolume": rep.get("solidVolume"),
311
+ "warnings": rep.get("warnings")},
312
+ "method": method, "seconds": sim.get("seconds")}
loadpath/slicer.py ADDED
@@ -0,0 +1,321 @@
1
+ """
2
+ A real slicer in the FDM loop: print time, filament and support material
3
+ measured by slicing the delivered part, not modelled.
4
+
5
+ For an FDM user the cost of a part is mostly hours on the printer, and grams
6
+ are only a proxy for that: two designs of the same mass can print in very
7
+ different times once one of them needs a forest of support. So every FDM
8
+ outcome is sliced headless (PrusaSlicer's command line, or a fork that keeps
9
+ it: SuperSlicer) standing the way it was designed to print, and the G-code
10
+ is read back for
11
+
12
+ - printTime (s), the slicer's own estimate under its machine limits;
13
+ - filament (mm, cm^3, g), and how much of it is support;
14
+ - the extruded volume of each feature type (perimeters, infill, support...).
15
+
16
+ `goal.minimize: "printTime"` then picks the fastest passing outcome instead
17
+ of the lightest, and `slicer.grade` (infill.py) turns the verified stress
18
+ into per-region infill and wall counts the slicer prints as modifiers.
19
+
20
+ The part is verified solid, so the default slice is solid too (100% infill):
21
+ what was analysed is what gets printed. Sparse infill is only safe where the
22
+ stress allows it, which is what grading works out.
23
+
24
+ The built-in profile is a generic 0.4 mm nozzle, 0.2 mm layer bed slinger at
25
+ moderate speeds (Prusa MK3S class); a CoreXY printer runs two to three times
26
+ faster. Pass your own PrusaSlicer config (`slicer.profile`, an .ini exported
27
+ from File > Export > Export Config) for absolute times; for comparing
28
+ outcomes against each other the generic one is enough.
29
+
30
+ Orca Slicer and Bambu Studio open the 3MF this writes (PrusaSlicer's
31
+ modifier metadata), but their command lines differ and are not driven here.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import re
37
+ import shutil
38
+ import subprocess
39
+ import tempfile
40
+ import time
41
+ from dataclasses import dataclass, field
42
+
43
+ import numpy as np
44
+
45
+ __all__ = ["SlicerOptions", "find_slicer", "slice_mesh", "read_gcode", "DEFAULT_PROFILE"]
46
+
47
+ # PrusaSlicer config keys. Bed 256 x 256 to match threemf's plate centre.
48
+ DEFAULT_PROFILE = {
49
+ "printer_technology": "FFF",
50
+ "gcode_flavor": "marlin2",
51
+ "bed_shape": "0x0,256x0,256x256,0x256",
52
+ "max_print_height": "256",
53
+ "nozzle_diameter": "0.4",
54
+ "filament_diameter": "1.75",
55
+ "layer_height": "0.2",
56
+ "first_layer_height": "0.2",
57
+ "extrusion_width": "0.45",
58
+ "perimeter_extrusion_width": "0.45",
59
+ "external_perimeter_extrusion_width": "0.45",
60
+ "infill_extrusion_width": "0.45",
61
+ "solid_infill_extrusion_width": "0.45",
62
+ "top_infill_extrusion_width": "0.4",
63
+ "support_material_extrusion_width": "0.35",
64
+ "perimeters": "3",
65
+ "top_solid_layers": "5",
66
+ "bottom_solid_layers": "4",
67
+ "top_solid_min_thickness": "0.7",
68
+ "bottom_solid_min_thickness": "0.5",
69
+ "fill_density": "100%",
70
+ "fill_pattern": "gyroid",
71
+ "infill_anchor": "2.5",
72
+ "perimeter_speed": "45",
73
+ "small_perimeter_speed": "25",
74
+ "external_perimeter_speed": "25",
75
+ "infill_speed": "80",
76
+ "solid_infill_speed": "80",
77
+ "top_solid_infill_speed": "40",
78
+ "support_material_speed": "50",
79
+ "support_material_interface_speed": "80%",
80
+ "bridge_speed": "30",
81
+ "gap_fill_speed": "40",
82
+ "travel_speed": "180",
83
+ "first_layer_speed": "20",
84
+ "max_volumetric_speed": "11.5",
85
+ "perimeter_acceleration": "800",
86
+ "infill_acceleration": "1000",
87
+ "bridge_acceleration": "1000",
88
+ "first_layer_acceleration": "800",
89
+ "default_acceleration": "1000",
90
+ "machine_limits_usage": "time_estimate_only",
91
+ "machine_max_acceleration_extruding": "1250",
92
+ "machine_max_acceleration_travel": "1250",
93
+ "machine_max_acceleration_x": "1000",
94
+ "machine_max_acceleration_y": "1000",
95
+ "machine_max_feedrate_x": "200",
96
+ "machine_max_feedrate_y": "200",
97
+ "support_material": "1",
98
+ "support_material_auto": "1",
99
+ # overhangs flatter than 45 degrees from horizontal get support: the
100
+ # angle loadpath's overhang control designs to, less printcheck's
101
+ # 5 degree tolerance
102
+ "support_material_threshold": "40",
103
+ "support_material_contact_distance": "0.2",
104
+ "support_material_spacing": "2",
105
+ "support_material_style": "grid",
106
+ "skirts": "1",
107
+ "brim_width": "0",
108
+ "use_relative_e_distances": "1",
109
+ "layer_gcode": "G92 E0",
110
+ "gcode_comments": "0",
111
+ }
112
+
113
+ # PrusaSlicer's ";TYPE:" feature names, by what they are
114
+ _SUPPORT_TYPES = ("Support material", "Support material interface")
115
+ _PART_TYPES = ("Perimeter", "External perimeter", "Overhang perimeter", "Internal infill", "Solid infill",
116
+ "Top solid infill", "Bridge infill", "Gap fill")
117
+
118
+ _NAMES = ("prusa-slicer", "PrusaSlicer", "prusaslicer", "superslicer", "SuperSlicer")
119
+
120
+
121
+ def find_slicer(path: str | None = None) -> str | None:
122
+ """The slicer executable: `path`, $LOADPATH_SLICER, or PrusaSlicer (or
123
+ SuperSlicer) on PATH or in its usual macOS place. None if there is none."""
124
+ for cand in (path, os.environ.get("LOADPATH_SLICER")):
125
+ if cand:
126
+ return cand if (os.path.isfile(cand) or shutil.which(cand)) else None
127
+ for n in _NAMES:
128
+ p = shutil.which(n)
129
+ if p:
130
+ return p
131
+ for p in ("/Applications/PrusaSlicer.app/Contents/MacOS/PrusaSlicer",
132
+ "/Applications/Original Prusa Drivers/PrusaSlicer.app/Contents/MacOS/PrusaSlicer"):
133
+ if os.path.isfile(p):
134
+ return p
135
+ return None
136
+
137
+
138
+ @dataclass
139
+ class SlicerOptions:
140
+ enabled: object = "auto" # "auto": when FDM and a slicer is installed; True: warn if none
141
+ executable: str | None = None
142
+ profile: str | None = None # PrusaSlicer .ini, applied before `settings`
143
+ settings: dict = field(default_factory=dict)
144
+ supports: object = "auto" # "auto"/True: slicer's own auto supports; False: none
145
+ infill: float = 100.0 # percent infill of the ungraded slice (100 = as verified)
146
+ timeout: float = 600.0
147
+ grade: dict | None = None # infill.GradeOptions spec, or None
148
+
149
+ @classmethod
150
+ def from_spec(cls, spec, base_dir: str | None = None) -> "SlicerOptions":
151
+ if spec is None:
152
+ return cls()
153
+ if isinstance(spec, bool):
154
+ return cls(enabled=spec)
155
+ if not isinstance(spec, dict):
156
+ raise ValueError("slicer is true/false or an object")
157
+ enabled = spec.get("enabled", "auto")
158
+ if enabled not in (True, False, "auto"):
159
+ raise ValueError("slicer.enabled is true, false or \"auto\"")
160
+ prof = spec.get("profile")
161
+ if prof is not None and base_dir and not os.path.isabs(prof):
162
+ prof = os.path.join(base_dir, prof)
163
+ infill = float(spec.get("infill", 100.0))
164
+ if not 0.0 <= infill <= 100.0:
165
+ raise ValueError("slicer.infill is a percentage, 0..100")
166
+ sup = spec.get("supports", "auto")
167
+ if sup not in (True, False, "auto"):
168
+ raise ValueError("slicer.supports is true, false or \"auto\"")
169
+ settings = spec.get("settings") or {}
170
+ if not isinstance(settings, dict):
171
+ raise ValueError("slicer.settings is an object of PrusaSlicer config keys")
172
+ grade = spec.get("grade")
173
+ if grade is True:
174
+ grade = {}
175
+ elif grade is False:
176
+ grade = None
177
+ elif grade is not None and not isinstance(grade, dict):
178
+ raise ValueError("slicer.grade is true or an object")
179
+ return cls(enabled=enabled, executable=spec.get("executable"), profile=prof,
180
+ settings={str(k): _fmt_setting(v) for k, v in settings.items()}, supports=sup,
181
+ infill=infill, timeout=float(spec.get("timeout", 600.0)), grade=grade)
182
+
183
+ def config(self) -> dict:
184
+ """The config keys the slice is run with (the profile file aside)."""
185
+ cfg = {} if self.profile else dict(DEFAULT_PROFILE)
186
+ cfg["fill_density"] = f"{self.infill:g}%"
187
+ if self.infill >= 100.0:
188
+ cfg["fill_pattern"] = "rectilinear" # PrusaSlicer refuses gyroid at 100%
189
+ cfg["support_material"] = "0" if self.supports is False else "1"
190
+ cfg["use_relative_e_distances"] = "1" # read_gcode sums E per feature
191
+ cfg.setdefault("layer_gcode", "G92 E0") # which PrusaSlicer requires with it
192
+ cfg.update(self.settings)
193
+ return cfg
194
+
195
+
196
+ def _fmt_setting(v) -> str:
197
+ if isinstance(v, bool):
198
+ return "1" if v else "0"
199
+ if isinstance(v, (list, tuple)):
200
+ return ",".join(str(x) for x in v)
201
+ return str(v)
202
+
203
+
204
+ def _version(exe: str) -> str:
205
+ try:
206
+ out = subprocess.run([exe, "--help"], capture_output=True, text=True, timeout=30).stdout
207
+ except (OSError, subprocess.SubprocessError):
208
+ return "unknown"
209
+ first = out.strip().splitlines()[0] if out.strip() else ""
210
+ return first.split(" based on")[0].strip() or "unknown"
211
+
212
+
213
+ _TIME_RE = re.compile(r"(\d+)\s*([dhms])")
214
+
215
+
216
+ def _seconds(text: str) -> float:
217
+ unit = {"d": 86400, "h": 3600, "m": 60, "s": 1}
218
+ return float(sum(int(n) * unit[u] for n, u in _TIME_RE.findall(text)))
219
+
220
+
221
+ def read_gcode(path: str, density: float | None = None) -> dict:
222
+ """Print time, filament and per-feature extruded volume from a
223
+ PrusaSlicer G-code (relative E). `density` g/cm^3 gives grams."""
224
+ feat: dict[str, float] = {}
225
+ cur = "Other"
226
+ meta: dict[str, str] = {}
227
+ dia = 1.75
228
+ relative = None
229
+ with open(path, "r", errors="replace") as fh:
230
+ for line in fh:
231
+ if line.startswith(";TYPE:"):
232
+ cur = line[6:].strip()
233
+ continue
234
+ if line.startswith("; ") and " = " in line:
235
+ k, v = line[2:].split(" = ", 1)
236
+ meta[k.strip()] = v.strip()
237
+ continue
238
+ if line.startswith("M83"):
239
+ relative = True
240
+ elif line.startswith("M82"):
241
+ relative = False
242
+ if line.startswith("G1 ") or line.startswith("G2 ") or line.startswith("G3 "):
243
+ i = line.find(" E")
244
+ if i >= 0:
245
+ j = i + 2
246
+ k = j
247
+ while k < len(line) and (line[k].isdigit() or line[k] in ".-"):
248
+ k += 1
249
+ try:
250
+ e = float(line[j:k])
251
+ except ValueError:
252
+ continue
253
+ if e > 0:
254
+ feat[cur] = feat.get(cur, 0.0) + e
255
+ if "filament_diameter" in meta:
256
+ dia = float(meta["filament_diameter"].split(",")[0])
257
+ if relative is False:
258
+ raise ValueError("read_gcode needs relative extrusion (use_relative_e_distances = 1)")
259
+ area = np.pi * (0.5 * dia) ** 2 / 1000.0 # cm^3 per mm of filament
260
+ vol = {k: v * area for k, v in feat.items()}
261
+ support = sum(vol.get(k, 0.0) for k in _SUPPORT_TYPES)
262
+ total_mm = float(meta.get("filament used [mm]", sum(feat.values())))
263
+ total_cm3 = float(meta.get("filament used [cm3]", total_mm * area))
264
+ t = meta.get("estimated printing time (normal mode)")
265
+ out = {
266
+ "printTime": _seconds(t) if t else None,
267
+ "printTimeText": t,
268
+ "filamentLength": total_mm,
269
+ "filamentVolume": total_cm3,
270
+ "supportVolume": float(support),
271
+ "supportShare": float(support / total_cm3) if total_cm3 > 0 else 0.0,
272
+ "features": {k: round(v, 4) for k, v in sorted(vol.items(), key=lambda kv: -kv[1])},
273
+ "layerHeight": float(meta["layer_height"]) if "layer_height" in meta else None,
274
+ }
275
+ if density is not None:
276
+ out["filamentMass"] = total_cm3 * density
277
+ out["supportMass"] = float(support) * density
278
+ return out
279
+
280
+
281
+ def slice_mesh(mesh, build=None, material=None, opts: SlicerOptions | None = None, *, name: str = "part",
282
+ object_settings: dict | None = None, modifiers: list | None = None,
283
+ keep: str | None = None) -> dict:
284
+ """Slice `mesh` (trimesh, mm) standing along `build` (as threemf takes
285
+ it) with `opts`; `object_settings` and `modifiers` (dicts with "mesh",
286
+ "settings", "name") are the per-region config graded infill prints
287
+ with. Returns read_gcode's report plus the slicer's name and version and
288
+ the slicing time. `keep` copies the G-code there."""
289
+ from .threemf import write_3mf
290
+
291
+ opts = opts or SlicerOptions()
292
+ exe = find_slicer(opts.executable)
293
+ if exe is None:
294
+ raise FileNotFoundError("no slicer found: install PrusaSlicer (or set LOADPATH_SLICER to its executable)")
295
+ t0 = time.time()
296
+ with tempfile.TemporaryDirectory(prefix="loadpath-slice-") as tmp:
297
+ tmf = os.path.join(tmp, "part.3mf")
298
+ write_3mf(tmf, mesh, build, name=name, material=material, object_settings=object_settings,
299
+ modifiers=modifiers)
300
+ ini = os.path.join(tmp, "loadpath.ini")
301
+ with open(ini, "w") as fh:
302
+ fh.write("".join(f"{k} = {v}\n" for k, v in opts.config().items()))
303
+ gcode = os.path.join(tmp, "part.gcode")
304
+ cmd = [exe]
305
+ if opts.profile:
306
+ cmd += ["--load", opts.profile]
307
+ cmd += ["--load", ini, "--export-gcode", "--output", gcode, tmf]
308
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=opts.timeout)
309
+ if proc.returncode != 0 or not os.path.isfile(gcode):
310
+ msg = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:]
311
+ raise RuntimeError(f"slicer failed ({proc.returncode}): {' / '.join(msg)}")
312
+ density = getattr(material, "density", None)
313
+ rep = read_gcode(gcode, density)
314
+ if keep:
315
+ shutil.copyfile(gcode, keep)
316
+ rep["gcode"] = keep
317
+ rep["slicer"] = _version(exe)
318
+ rep["profile"] = opts.profile or "loadpath generic (0.4 mm nozzle, 0.2 mm layers, MK3S-class speeds)"
319
+ rep["infill"] = opts.infill
320
+ rep["seconds"] = time.time() - t0
321
+ return rep