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/thermal.py ADDED
@@ -0,0 +1,1691 @@
1
+ """
2
+ Steady heat conduction on the voxel grid, and topology optimization with it:
3
+ conductors and heat sinks on their own, and parts that have to carry load
4
+ and get rid of heat at once (a NEMA 17 bracket next to a 60 C motor).
5
+
6
+ The analysis is the scalar twin of fem.py: 8-node hexahedra, all the same
7
+ cube, one element conduction matrix scaled per cell by its conductivity.
8
+ Boundary conditions are the three a printed part sees:
9
+
10
+ - fixed temperature (Dirichlet) on nodes: a sink bolted to a frame, a motor
11
+ face held at its case temperature;
12
+ - heat input in W, spread over a region's nodes or cells: a die, a coil, a
13
+ contact patch;
14
+ - convection h (T - T_fluid) on cell faces, with a per-face coefficient and
15
+ fluid temperature. The faces that convect are the part's own surface, so
16
+ under optimization they move with the design: every face between two
17
+ cells convects in proportion to how different their densities are, and a
18
+ face on the grid's outside in proportion to its cell's density. A crisp
19
+ solid part therefore convects exactly on its skin, and the optimizer
20
+ sees what adding a fin buys (Yoon & Kim's smeared-interface idea, with a
21
+ smooth |.| so the gradient exists).
22
+
23
+ Temperatures are solved relative to `ambient`, so ambient-temperature sinks
24
+ and fluids add nothing to the right-hand side.
25
+
26
+ Optimization is SIMP on conductivity, k(x) = kmin + x^3 (1 - kmin), with the
27
+ density filter, Heaviside continuation and robust erosion of simp.py, and
28
+ MMA (mma.py) because with convection the sensitivities change sign: a fin
29
+ in the wrong place hides surface that was convecting. Objectives are the
30
+ mean temperature rise of a region (default: power-weighted over the heat
31
+ inputs, i.e. thermal compliance divided by the power), so the adjoint is
32
+ one extra solve, or none when it equals the state.
33
+
34
+ Thermo-mechanical coupling (`optimize_thermomechanical`) is one-way:
35
+ temperature softens the polymer. Each cell's modulus is derated by the
36
+ material's retention curve at its temperature (PETG keeps about 60% of its
37
+ stiffness at 65 C), the design's own temperature field decides where, and
38
+ the compliance gradient carries the thermal adjoint, so the optimizer knows
39
+ that material near the motor is both hot and soft, and that material which
40
+ conducts heat into the structure softens it. A weighted temperature term
41
+ can be added to the objective as well.
42
+
43
+ Thermal expansion (`alpha=` on optimize_thermomechanical, and
44
+ `thermoelastic` to rate a design): each cell wants to grow by
45
+ alpha (T - t_ref), fem.thermal_load turns what stops it into nodal forces,
46
+ and the stress comes off the elastic part of the strain. The objective is
47
+ then twice the elastic strain energy, which is the compliance without
48
+ expansion: f.u would count the work of free growth and pay the optimizer
49
+ to cut whatever hot material moves most. The load depends on density and
50
+ on the design's temperature, so the gradient takes a mechanical adjoint
51
+ besides the thermal one. Checked against a bar held between rollers
52
+ (sigma = -E alpha dT exactly) and Timoshenko's bimetal strip (within 2.5%
53
+ at 4 cells per layer); bench/thermal_expansion.py runs the motor mount.
54
+
55
+ A study's `thermal` block (spec.Thermal) comes in through StudyHeat:
56
+ build_domain hangs one on the Domain, simp's loop re-solves the design's
57
+ temperature every iteration and folds softening, expansion and their
58
+ gradients (mechanical and thermal adjoints, as above) into the ordinary
59
+ optimizer, and verify_part solves the delivered part's temperature, derates
60
+ moduli and strength by it and takes the free expansion off the stress.
61
+
62
+ Heat-up and cool-down (`transient`, `solve_cycle`): backward Euler on the
63
+ same conduction system with a lumped capacity (density times the card's
64
+ specific heat, SPECIFIC_HEAT). A duty cycle (Cycle: heat on for `on` s of
65
+ every `period`) is solved to its repeating state from the steady state at
66
+ the mean power, which takes a few periods rather than the part's warm-up.
67
+ Checked against the Fourier series for a bar heated at one end (0.3% at 50
68
+ steps, first order) and the closed form for a lumped block under a square
69
+ wave (0.2%).
70
+
71
+ Units: mm, W, K (or C) throughout; conductivity is given in W/(m K) and
72
+ convection in W/(m^2 K) at the edges, as datasheets quote them, and
73
+ converted here.
74
+ """
75
+ from __future__ import annotations
76
+
77
+ import time
78
+ from dataclasses import dataclass, field
79
+
80
+ import numpy as np
81
+ import scipy.sparse as sp
82
+ from scipy.sparse.linalg import cg
83
+
84
+ from .fem import CORNERS, HexMesh
85
+ from .material_library import LIBRARY as _LIBRARY
86
+ from .mma import MMA
87
+
88
+ __all__ = [
89
+ "CONDUCTIVITY", "MODULUS_RETENTION", "conductivity", "retention", "hex8_kt", "ThermalGrid",
90
+ "thermal_grid", "grid_from_domain", "Faces", "Conduction", "ScalarSolver", "ThermalBC",
91
+ "Convection", "nodes_in", "cells_in", "thermal_bc", "solve_temperature", "ThermalResult",
92
+ "ThermalOptions", "ThermalOptResult", "optimize_thermal", "optimize_thermomechanical",
93
+ "EXPANSION", "expansion", "thermoelastic", "StudyHeat", "study_heat", "study_thermal_bc",
94
+ "SPECIFIC_HEAT", "specific_heat", "node_capacity", "transient", "Cycle", "CycleResult", "solve_cycle",
95
+ ]
96
+
97
+ # W/(m K), dense printed parts, in plane, from the material library's cards
98
+ # (material_library.py cites each: Tychanicz-Kwiecien et al. 2025 for PLA
99
+ # and PETG, the AddUp sheets for AlSi10Mg and 316L, estimates otherwise).
100
+ # FDM conducts 10-30% worse across layers.
101
+ CONDUCTIVITY = {k: float(c["values"]["conductivity"][0]) for k, c in _LIBRARY.items()
102
+ if "conductivity" in c["values"]}
103
+
104
+ # Fraction of room-temperature modulus kept at temperature (C), piecewise
105
+ # linear, from the cards' `retention` (read off typical DMA curves and
106
+ # checked against each card's heat deflection temperature; approximate,
107
+ # and the cliff at the glass transition is where sources disagree most).
108
+ MODULUS_RETENTION = {k: [tuple(p) for p in c["values"]["retention"][0]] for k, c in _LIBRARY.items()
109
+ if "retention" in c["values"]}
110
+
111
+ # Linear thermal expansion, 1/K, near room temperature, from the cards'
112
+ # `cte` (um/(m K); Formlabs and AddUp sheets where they give it, estimates
113
+ # for the filaments, whose datasheets don't). FDM parts in plane, PA-CF
114
+ # along the fibres it is laid with. Printed polymers grow 5-10x more than
115
+ # aluminium, which is what loads a plastic part bolted to a metal frame
116
+ # when the motor beside it warms up.
117
+ EXPANSION = {k: float(c["values"]["cte"][0]) / 1e6 for k, c in _LIBRARY.items() if "cte" in c["values"]}
118
+
119
+ # J/(kg K) near room temperature, from the cards' `specificHeat` (polymer
120
+ # handbook values for the families, the alloy handbook for metals): with
121
+ # density it sets how fast a part heats up and cools down.
122
+ SPECIFIC_HEAT = {k: float(c["values"]["specificHeat"][0]) for k, c in _LIBRARY.items()
123
+ if "specificHeat" in c["values"]}
124
+
125
+ K_MIN = 1e-3 # void conductivity, relative to the material's
126
+ C_MIN = 1e-3 # void heat capacity, relative to the material's
127
+ PENALTY = 3.0
128
+ _SMOOTH = 0.05 # smoothing of |rho_i - rho_j| on interface faces
129
+
130
+
131
+ def _material_key(material) -> str:
132
+ name = getattr(material, "name", material)
133
+ from .materials import _ALIASES
134
+ key = str(name).strip().lower()
135
+ return _ALIASES.get(key, key)
136
+
137
+
138
+ def conductivity(material) -> float:
139
+ """W/(m K) for a material name (or Material), from CONDUCTIVITY."""
140
+ key = _material_key(material)
141
+ if key not in CONDUCTIVITY:
142
+ raise ValueError(f"no conductivity for {material!r}; known: {sorted(CONDUCTIVITY)} (or pass k)")
143
+ return CONDUCTIVITY[key]
144
+
145
+
146
+ def expansion(material) -> float:
147
+ """1/K for a material name (or Material), from EXPANSION."""
148
+ key = _material_key(material)
149
+ if key not in EXPANSION:
150
+ raise ValueError(f"no expansion coefficient for {material!r}; known: {sorted(EXPANSION)} (or pass a number)")
151
+ return EXPANSION[key]
152
+
153
+
154
+ def specific_heat(material) -> float:
155
+ """J/(kg K) for a material name (or Material), from SPECIFIC_HEAT."""
156
+ key = _material_key(material)
157
+ if key not in SPECIFIC_HEAT:
158
+ raise ValueError(f"no specific heat for {material!r}; known: {sorted(SPECIFIC_HEAT)} (or pass one)")
159
+ return SPECIFIC_HEAT[key]
160
+
161
+
162
+ def retention(material, t: np.ndarray):
163
+ """(modulus retention, its derivative per K) at temperatures t (C)."""
164
+ pts = MODULUS_RETENTION[_material_key(material)] if not isinstance(material, (list, tuple)) else material
165
+ tt = np.array([p[0] for p in pts], float)
166
+ gg = np.array([p[1] for p in pts], float)
167
+ t = np.asarray(t, float)
168
+ g = np.interp(t, tt, gg)
169
+ seg = np.clip(np.searchsorted(tt, t) - 1, 0, len(tt) - 2)
170
+ slope = (gg[seg + 1] - gg[seg]) / (tt[seg + 1] - tt[seg])
171
+ dg = np.where((t > tt[0]) & (t < tt[-1]), slope, 0.0)
172
+ return g, dg
173
+
174
+
175
+ def hex8_kt() -> np.ndarray:
176
+ """Conduction matrix (8x8) of a unit cube with k = 1, 2x2x2 Gauss. For side
177
+ h and conductivity k the element matrix is k * h * this."""
178
+ s = 2.0 * CORNERS - 1.0
179
+ g = 1.0 / np.sqrt(3.0)
180
+ kt = np.zeros((8, 8))
181
+ for xi in (-g, g):
182
+ for eta in (-g, g):
183
+ for zeta in (-g, g):
184
+ dn = np.empty((8, 3))
185
+ dn[:, 0] = 0.25 * s[:, 0] * (1 + eta * s[:, 1]) * (1 + zeta * s[:, 2])
186
+ dn[:, 1] = 0.25 * s[:, 1] * (1 + xi * s[:, 0]) * (1 + zeta * s[:, 2])
187
+ dn[:, 2] = 0.25 * s[:, 2] * (1 + xi * s[:, 0]) * (1 + eta * s[:, 1])
188
+ kt += dn @ dn.T * 0.125
189
+ return 0.5 * (kt + kt.T)
190
+
191
+
192
+ # Consistent face matrix of a bilinear square of area 1, corners in cyclic
193
+ # order: int N_i N_j dA.
194
+ _FACE_M = np.array([[4, 2, 1, 2], [2, 4, 2, 1], [1, 2, 4, 2], [2, 1, 2, 4]], float) / 36.0
195
+
196
+
197
+ def _face_corners(axis: int, side: int) -> list:
198
+ b, c = [a for a in range(3) if a != axis]
199
+ out = []
200
+ for u, v in ((0, 0), (1, 0), (1, 1), (0, 1)):
201
+ out.append((side << axis) | (u << b) | (v << c))
202
+ return out
203
+
204
+
205
+ # ---------------------------------------------------------------- the grid
206
+
207
+
208
+ @dataclass
209
+ class ThermalGrid:
210
+ """What the thermal solver and optimizer need of a voxel domain. Duck-
211
+ types simp.Domain for simp.density_filter (mesh, pitch, dims)."""
212
+ pitch: float
213
+ origin: np.ndarray
214
+ dims: tuple
215
+ mesh: HexMesh
216
+ preserve: np.ndarray # bool per element
217
+ mirror: dict = field(default_factory=dict)
218
+
219
+ @property
220
+ def n_elem(self):
221
+ return self.mesh.n_elem
222
+
223
+ def full_grid(self, values, fill=0.0):
224
+ g = np.full(self.dims, fill, float)
225
+ g[tuple(self.mesh.elem_ijk.T)] = values
226
+ return g
227
+
228
+
229
+ def thermal_grid(design_space, pitch: float, preserve=(), keep_out=(), symmetry=()) -> ThermalGrid:
230
+ """Voxelize shapes (lists of shapes.Shape) the way domain.build_domain
231
+ does, without the supports a mechanical domain needs."""
232
+ from .domain import _fraction
233
+
234
+ design_space = list(design_space) if isinstance(design_space, (list, tuple)) else [design_space]
235
+ lo = np.min([s.bounds()[0] for s in design_space], axis=0)
236
+ hi = np.max([s.bounds()[1] for s in design_space], axis=0)
237
+ dims = np.maximum(np.ceil((hi - lo) / pitch - 1e-9).astype(int), 1)
238
+ origin = 0.5 * (lo + hi) - 0.5 * dims * pitch
239
+ ii, jj, kk = np.meshgrid(*[np.arange(d) for d in dims], indexing="ij")
240
+ ijk = np.stack([ii.ravel(), jj.ravel(), kk.ravel()], axis=1)
241
+ centers = origin + (ijk + 0.5) * pitch
242
+ design_f = _fraction(design_space, centers, pitch)
243
+ pres_f = _fraction(list(preserve), centers, pitch)
244
+ keep_f = _fraction(list(keep_out), centers, pitch)
245
+ pres = (pres_f >= 0.5) & (keep_f < 0.5)
246
+ active = pres | ((design_f >= 0.5) & (keep_f == 0))
247
+ if not active.any():
248
+ raise ValueError("the design space is empty after removing keep-outs")
249
+ mesh = HexMesh(ijk[active], pitch, origin)
250
+ mirror = {}
251
+ for axis in symmetry:
252
+ a = "xyz".index(axis)
253
+ m_ijk = mesh.elem_ijk.copy()
254
+ m_ijk[:, a] = dims[a] - 1 - m_ijk[:, a]
255
+ lut = np.full(dims, -1, np.int64)
256
+ lut[tuple(mesh.elem_ijk.T)] = np.arange(mesh.n_elem)
257
+ mirror[axis] = lut[tuple(m_ijk.T)]
258
+ return ThermalGrid(float(pitch), origin, tuple(int(d) for d in dims), mesh, pres[active], mirror)
259
+
260
+
261
+ def grid_from_domain(domain) -> ThermalGrid:
262
+ """The thermal view of a mechanical simp.Domain: same cells, same nodes."""
263
+ return ThermalGrid(domain.pitch, domain.origin, domain.dims, domain.mesh, domain.preserve.copy(),
264
+ dict(domain.mirror))
265
+
266
+
267
+ def nodes_in(mesh: HexMesh, shape, tol: float | None = None) -> np.ndarray:
268
+ """Bool per node: inside `shape` or within `tol` (default half a cell)."""
269
+ tol = 0.5 * mesh.pitch if tol is None else tol
270
+ return shape.sdf(mesh.node_xyz) <= tol
271
+
272
+
273
+ def cells_in(mesh: HexMesh, shape) -> np.ndarray:
274
+ return shape.sdf(mesh.elem_centers()) <= 0
275
+
276
+
277
+ # ------------------------------------------------------------- the faces
278
+
279
+
280
+ class Faces:
281
+ """Every cell face that can convect: faces between two active cells
282
+ (`b` >= 0) and faces on the outside of the active set (`b` == -1).
283
+
284
+ a, b: the cells on either side (normal points from a to b); nodes: (nf, 4)
285
+ in cyclic order; center, normal: (nf, 3)."""
286
+
287
+ def __init__(self, mesh: HexMesh):
288
+ ijk = mesh.elem_ijk
289
+ dims = mesh.node_dims - 1
290
+ lut = np.full(tuple(dims), -1, np.int64)
291
+ lut[tuple(ijk.T)] = np.arange(mesh.n_elem)
292
+ a_l, b_l, n_l, c_l, nrm_l = [], [], [], [], []
293
+ for axis in range(3):
294
+ for side in (0, 1):
295
+ nb = ijk.copy()
296
+ nb[:, axis] += 2 * side - 1
297
+ ok = (nb[:, axis] >= 0) & (nb[:, axis] < dims[axis])
298
+ j = np.full(mesh.n_elem, -1, np.int64)
299
+ j[ok] = lut[tuple(nb[ok].T)]
300
+ # internal faces once (from the lower cell), boundary faces always
301
+ take = (j < 0) | ((j >= 0) & (side == 1))
302
+ ii = np.nonzero(take)[0]
303
+ corners = _face_corners(axis, side)
304
+ a_l.append(ii)
305
+ b_l.append(j[ii])
306
+ n_l.append(mesh.enode[ii][:, corners])
307
+ cen = mesh.origin + (ijk[ii] + 0.5) * mesh.pitch
308
+ cen[:, axis] += (side - 0.5) * mesh.pitch
309
+ c_l.append(cen)
310
+ nrm = np.zeros((len(ii), 3))
311
+ nrm[:, axis] = 2 * side - 1
312
+ nrm_l.append(nrm)
313
+ self.a = np.concatenate(a_l)
314
+ self.b = np.concatenate(b_l)
315
+ self.nodes = np.concatenate(n_l)
316
+ self.center = np.concatenate(c_l)
317
+ self.normal = np.concatenate(nrm_l)
318
+ self.area = mesh.pitch ** 2
319
+ self.boundary = self.b < 0
320
+
321
+ def __len__(self):
322
+ return len(self.a)
323
+
324
+ def exposure(self, rho: np.ndarray):
325
+ """Per face: how much of it is solid-against-void (0..1) at densities
326
+ rho, and its derivatives with respect to rho_a and rho_b."""
327
+ ra = rho[self.a]
328
+ rb = np.where(self.boundary, 0.0, rho[np.maximum(self.b, 0)])
329
+ d = ra - rb
330
+ e = _SMOOTH
331
+ norm = np.sqrt(1 + e * e) - e
332
+ r = np.sqrt(d * d + e * e)
333
+ s = (r - e) / norm
334
+ ds = d / r / norm
335
+ s = np.where(self.boundary, ra, s)
336
+ ds_a = np.where(self.boundary, 1.0, ds)
337
+ ds_b = np.where(self.boundary, 0.0, -ds)
338
+ return s, ds_a, ds_b
339
+
340
+
341
+ @dataclass
342
+ class Convection:
343
+ """Convection to a fluid at `fluid_t` with coefficient `h` W/(m^2 K) on the
344
+ faces `where` selects: None (all), a Shape (face centers within half a cell
345
+ of it), or a callable (centers, normals) -> bool. `boundary` / `internal`
346
+ restrict it to the grid's outside or to faces between cells (the surface
347
+ the design grows)."""
348
+ h: float
349
+ fluid_t: float | None = None # None: ambient
350
+ where: object = None
351
+ boundary: bool = True
352
+ internal: bool = True
353
+
354
+
355
+ @dataclass
356
+ class ThermalBC:
357
+ ambient: float # C; temperatures are solved relative to it
358
+ fixed: np.ndarray # bool per node
359
+ fixed_t: np.ndarray # C per node (used where fixed)
360
+ heat: np.ndarray # W per node
361
+ face_h: np.ndarray # W/(mm^2 K) per face (0: none)
362
+ face_t: np.ndarray # fluid C per face
363
+ faces: Faces
364
+ heat_nodes: np.ndarray # bool per node: where heat goes in
365
+
366
+ @property
367
+ def power(self):
368
+ return float(self.heat.sum())
369
+
370
+
371
+ def thermal_bc(mesh: HexMesh, *, ambient: float = 25.0, temperatures=(), heat=(), convection=(),
372
+ faces: Faces | None = None) -> ThermalBC:
373
+ """Assemble boundary conditions.
374
+
375
+ temperatures: [(Shape or node mask, C)]; heat: [(Shape / node mask, W)] or
376
+ [(Shape / cell mask, W, "volume")] spread over the region's cells by
377
+ volume; convection: [Convection]."""
378
+ faces = faces or Faces(mesh)
379
+ nn = mesh.n_nodes
380
+ fixed = np.zeros(nn, bool)
381
+ fixed_t = np.full(nn, float(ambient))
382
+ for region, t in temperatures:
383
+ m = region if isinstance(region, np.ndarray) else nodes_in(mesh, region)
384
+ if not m.any():
385
+ raise ValueError("a fixed temperature touches no node of the grid")
386
+ fixed |= m
387
+ fixed_t[m] = float(t)
388
+ q = np.zeros(nn)
389
+ heat_nodes = np.zeros(nn, bool)
390
+ for item in heat:
391
+ region, watts = item[0], float(item[1])
392
+ if len(item) > 2 and item[2] == "volume":
393
+ cm = region if isinstance(region, np.ndarray) else cells_in(mesh, region)
394
+ if not cm.any():
395
+ raise ValueError("a volume heat source covers no cell of the grid")
396
+ per = watts / cm.sum() / 8.0
397
+ np.add.at(q, mesh.enode[cm].ravel(), per)
398
+ heat_nodes[mesh.enode[cm].ravel()] = True
399
+ else:
400
+ m = region if isinstance(region, np.ndarray) else nodes_in(mesh, region)
401
+ if not m.any():
402
+ raise ValueError("a heat input touches no node of the grid")
403
+ # weighted by the cells each node touches, so a flux over a face
404
+ # is spread as the face's area is (corners a quarter, edges a half)
405
+ w = np.bincount(mesh.enode.ravel(), minlength=nn).astype(float) * m
406
+ q += watts * w / w.sum()
407
+ heat_nodes |= m
408
+ face_h = np.zeros(len(faces))
409
+ face_t = np.full(len(faces), float(ambient))
410
+ for cv in convection:
411
+ sel = np.ones(len(faces), bool)
412
+ if cv.where is not None:
413
+ if callable(cv.where) and not hasattr(cv.where, "sdf"):
414
+ sel = np.asarray(cv.where(faces.center, faces.normal), bool)
415
+ else:
416
+ sel = cv.where.sdf(faces.center) <= 0.5 * mesh.pitch
417
+ if not cv.boundary:
418
+ sel &= ~faces.boundary
419
+ if not cv.internal:
420
+ sel &= faces.boundary
421
+ face_h[sel] = float(cv.h) * 1e-6
422
+ face_t[sel] = float(ambient if cv.fluid_t is None else cv.fluid_t)
423
+ return ThermalBC(float(ambient), fixed, fixed_t, q, face_h, face_t, faces, heat_nodes)
424
+
425
+
426
+ # ---------------------------------------------------------- the assembly
427
+
428
+
429
+ class Conduction:
430
+ """CSR layout for a HexMesh's scalar conduction matrix. `assemble(k, rho)`
431
+ takes per-element conductivity k (W/(mm K)) and densities rho (which set
432
+ the convecting surface) and returns (matrix with Dirichlet rows, rhs).
433
+
434
+ `porous`: while a design is still grey, a uniform field has no interfaces
435
+ and so no convecting surface, and the only faces that convect are on the
436
+ grid's outside: the optimizer then pushes everything there (a ring of pins
437
+ round the edge of a heat sink). A grey cell is treated as a porous medium
438
+ instead, with 4 rho (1 - rho) * `porous` faces' worth of wetted area,
439
+ which is gone once the design is crisp. Set by the optimizer; 0 for
440
+ analysis."""
441
+
442
+ def __init__(self, mesh: HexMesh, bc: ThermalBC, porous: float = 0.0):
443
+ self.mesh = mesh
444
+ self.bc = bc
445
+ self.kt = hex8_kt() * mesh.pitch
446
+ nn = mesh.n_nodes
447
+ en = mesh.enode
448
+ rows = np.repeat(en, 8, axis=1).ravel()
449
+ cols = np.tile(en, (1, 8)).ravel()
450
+ keys = rows * nn + cols
451
+ uniq, self._inv = np.unique(keys, return_inverse=True)
452
+ self._inv = self._inv.astype(np.int64)
453
+ self.indices = (uniq % nn).astype(np.int32)
454
+ r = uniq // nn
455
+ self.indptr = np.concatenate([[0], np.cumsum(np.bincount(r, minlength=nn))]).astype(np.int32)
456
+ self.nnz = len(uniq)
457
+ self._kflat = self.kt.ravel()
458
+ # face node pairs -> slots (always within one element, so present)
459
+ faces = bc.faces
460
+ fn = faces.nodes
461
+ conv = np.nonzero(bc.face_h > 0)[0]
462
+ self.conv = conv
463
+ frow = np.repeat(fn[conv], 4, axis=1).ravel()
464
+ fcol = np.tile(fn[conv], (1, 4)).ravel()
465
+ self._fslot = np.searchsorted(uniq, frow * nn + fcol)
466
+ self._fm = _FACE_M.ravel() * faces.area
467
+ # porous convection: per cell, the strongest internal convection of
468
+ # its faces (lumped onto its corners' diagonal)
469
+ inner = conv[~faces.boundary[conv]]
470
+ self.cell_h = np.zeros(mesh.n_elem)
471
+ self.cell_dt = np.zeros(mesh.n_elem)
472
+ for side in (faces.a[inner], faces.b[inner]):
473
+ take = bc.face_h[inner] > self.cell_h[side]
474
+ self.cell_h[side[take]] = bc.face_h[inner][take]
475
+ self.cell_dt[side[take]] = bc.face_t[inner][take] - bc.ambient
476
+ self._dslot = np.searchsorted(uniq, en.ravel() * nn + en.ravel()).reshape(-1, 8)
477
+ self.porous = float(porous)
478
+ fixed = bc.fixed
479
+ self._keep = ~(fixed[r] | fixed[self.indices])
480
+ self._diag_fixed = np.nonzero((r == self.indices) & fixed[r])[0]
481
+ self.theta_fixed = np.where(fixed, bc.fixed_t - bc.ambient, 0.0)
482
+
483
+ def _porous(self, rho):
484
+ a = self.porous * self.mesh.pitch ** 2
485
+ return a * 4 * rho * (1 - rho), a * 4 * (1 - 2 * rho)
486
+
487
+ def _data(self, k, rho):
488
+ data = np.bincount(self._inv, weights=(np.asarray(k, float)[:, None] * self._kflat).ravel(),
489
+ minlength=self.nnz)
490
+ if len(self.conv):
491
+ s = self.bc.faces.exposure(rho)[0]
492
+ w = (self.bc.face_h[self.conv] * s[self.conv])[:, None] * self._fm
493
+ data += np.bincount(self._fslot, weights=w.ravel(), minlength=self.nnz)
494
+ if self.porous > 0:
495
+ sv = self._porous(rho)[0] * self.cell_h / 8.0
496
+ data += np.bincount(self._dslot.ravel(), weights=np.repeat(sv, 8), minlength=self.nnz)
497
+ return data
498
+
499
+ def load(self, rho):
500
+ """Right-hand side before Dirichlet lifting: heat in plus convection
501
+ from fluids that are not at ambient."""
502
+ f = self.bc.heat.copy()
503
+ c = self.conv
504
+ if len(c):
505
+ s = self.bc.faces.exposure(rho)[0]
506
+ dt = self.bc.face_t[c] - self.bc.ambient
507
+ w = self.bc.face_h[c] * s[c] * dt * self.bc.faces.area / 4.0
508
+ if np.any(w):
509
+ np.add.at(f, self.bc.faces.nodes[c].ravel(), np.repeat(w, 4))
510
+ if self.porous > 0 and np.any(self.cell_dt):
511
+ w = self._porous(rho)[0] * self.cell_h * self.cell_dt / 8.0
512
+ np.add.at(f, self.mesh.enode.ravel(), np.repeat(w, 8))
513
+ return f
514
+
515
+ def has_sink(self, rho):
516
+ if self.bc.fixed.any():
517
+ return True
518
+ s = self.bc.faces.exposure(rho)[0]
519
+ return bool(np.any(self.bc.face_h * s > 0)) or bool(self.porous > 0 and np.any(self.cell_h > 0))
520
+
521
+ def assemble(self, k, rho):
522
+ nn = self.mesh.n_nodes
523
+ data = self._data(k, rho)
524
+ f = self.load(rho)
525
+ if self.bc.fixed.any():
526
+ full = sp.csr_matrix((data, self.indices, self.indptr), shape=(nn, nn))
527
+ f = f - full @ self.theta_fixed
528
+ # Identity rows scaled like the rest of the matrix (k h is ~1e-4
529
+ # W/K for a polymer at 1 mm): unscaled, the fixed values dominate
530
+ # the residual norm and CG stops before the free rows converge.
531
+ dval = float(np.mean(full.diagonal()))
532
+ f[self.bc.fixed] = dval * self.theta_fixed[self.bc.fixed]
533
+ data = data * self._keep
534
+ data[self._diag_fixed] = dval
535
+ mat = sp.csr_matrix((data, self.indices, self.indptr), shape=(nn, nn))
536
+ mat.has_sorted_indices = True
537
+ return mat, f
538
+
539
+ def residual(self, k, rho, theta):
540
+ """K theta - f without Dirichlet rows: at fixed nodes, minus the heat
541
+ they inject."""
542
+ nn = self.mesh.n_nodes
543
+ full = sp.csr_matrix((self._data(k, rho), self.indices, self.indptr), shape=(nn, nn))
544
+ return full @ theta - self.load(rho)
545
+
546
+ def elem_energy(self, lam, theta):
547
+ """Per element lam_e^T Kt theta_e at unit conductivity."""
548
+ en = self.mesh.enode
549
+ return np.einsum("ij,jk,ik->i", lam[en], self.kt, theta[en])
550
+
551
+ def convection_gradient(self, rho, lam, theta):
552
+ """Per element d(lam^T R)/d rho from the convection terms."""
553
+ g = np.zeros(len(rho))
554
+ c = self.conv
555
+ if len(c):
556
+ faces = self.bc.faces
557
+ _, ds_a, ds_b = faces.exposure(rho)
558
+ fn = faces.nodes[c]
559
+ a = faces.area
560
+ fe = np.einsum("ij,jk,ik->i", lam[fn], _FACE_M * a, theta[fn])
561
+ fe -= lam[fn].sum(axis=1) * (self.bc.face_t[c] - self.bc.ambient) * a / 4.0
562
+ fe *= self.bc.face_h[c]
563
+ g += np.bincount(faces.a[c], weights=fe * ds_a[c], minlength=len(rho))
564
+ inner = faces.b[c] >= 0
565
+ g += np.bincount(faces.b[c][inner], weights=(fe * ds_b[c])[inner], minlength=len(rho))
566
+ if self.porous > 0:
567
+ en = self.mesh.enode
568
+ dsv = self._porous(rho)[1]
569
+ pe = (lam[en] * theta[en]).sum(axis=1) - lam[en].sum(axis=1) * self.cell_dt
570
+ g += dsv * self.cell_h / 8.0 * pe
571
+ return g
572
+
573
+
574
+ class ScalarSolver:
575
+ """CG + smoothed-aggregation AMG for a scalar diffusion matrix, keeping
576
+ the hierarchy across solves while it still works (as fem.Solver)."""
577
+
578
+ def __init__(self, rtol=1e-8, maxiter=2000, rebuild_after_iters=40):
579
+ self.rtol, self.maxiter, self.rebuild_after_iters = rtol, maxiter, rebuild_after_iters
580
+ self._ml = None
581
+ self.last_iters = 0
582
+
583
+ def invalidate(self):
584
+ self._ml = None
585
+
586
+ def _precond(self, mat):
587
+ if self._ml is None:
588
+ from .fem import amg_solver
589
+ self._ml = amg_solver(mat, symmetry="hermitian", max_coarse=400, coarse_solver="splu")
590
+ return self._ml.aspreconditioner(cycle="V")
591
+
592
+ def solve(self, mat, f, x0=None):
593
+ count = [0]
594
+
595
+ def cb(_):
596
+ count[0] += 1
597
+
598
+ # the system is scaled by the conductivity, which can be tiny (W/mm K)
599
+ scale = float(np.abs(f).max()) or 1.0
600
+ t, info = cg(mat, f / scale, x0=None if x0 is None else x0 / scale, rtol=self.rtol,
601
+ maxiter=self.maxiter, M=self._precond(mat), callback=cb)
602
+ if info != 0:
603
+ self._ml = None
604
+ count[0] = 0
605
+ t, info = cg(mat, f / scale, x0=t, rtol=self.rtol, maxiter=self.maxiter,
606
+ M=self._precond(mat), callback=cb)
607
+ self.last_iters = count[0]
608
+ if count[0] > self.rebuild_after_iters:
609
+ self._ml = None
610
+ if info != 0:
611
+ raise RuntimeError(f"thermal solve did not converge ({count[0]} iterations); is any heat "
612
+ "path to a fixed temperature or a convecting face?")
613
+ return t * scale
614
+
615
+
616
+ @dataclass
617
+ class ThermalResult:
618
+ temperature: np.ndarray # C per node
619
+ elem_temperature: np.ndarray # C per element (mean of its nodes)
620
+ max_t: float
621
+ heat_in: float # W
622
+ heat_out_fixed: float # W leaving through fixed temperatures (negative: entering)
623
+ heat_out_convection: float # W leaving by convection
624
+ resistance: float # K/W: mean rise over the heat inputs per W (nan without heat)
625
+
626
+ def mean_t(self, nodes):
627
+ return float(self.temperature[nodes].mean())
628
+
629
+
630
+ def solve_temperature(mesh: HexMesh, k, bc: ThermalBC, density=None, solver: ScalarSolver | None = None,
631
+ conduction: Conduction | None = None) -> ThermalResult:
632
+ """Steady temperatures on `mesh`. k: conductivity in W/(m K), scalar or per
633
+ element; density (per element, default solid) scales it SIMP-wise and
634
+ sets which faces convect."""
635
+ rho = np.ones(mesh.n_elem) if density is None else np.asarray(density, float)
636
+ kk = np.broadcast_to(np.asarray(k, float) * 1e-3, (mesh.n_elem,))
637
+ ke = kk * (K_MIN + rho ** PENALTY * (1 - K_MIN))
638
+ cond = conduction or Conduction(mesh, bc)
639
+ if not cond.has_sink(rho):
640
+ raise ValueError("no heat path out: add a fixed temperature or convection")
641
+ mat, f = cond.assemble(ke, rho)
642
+ theta = (solver or ScalarSolver()).solve(mat, f)
643
+ t = theta + bc.ambient
644
+ # heat balance: reaction at the fixed nodes, convection integral
645
+ out_fixed = -float(cond.residual(ke, rho, theta)[bc.fixed].sum())
646
+ c = cond.conv
647
+ s = bc.faces.exposure(rho)[0]
648
+ th_face = theta[bc.faces.nodes[c]].mean(axis=1)
649
+ out_conv = float(np.sum(bc.face_h[c] * s[c] * bc.faces.area * (th_face - (bc.face_t[c] - bc.ambient))))
650
+ q = bc.heat
651
+ res = float((q @ theta) / q.sum() ** 2) if q.sum() > 0 else float("nan")
652
+ return ThermalResult(t, t[mesh.enode].mean(axis=1), float(t.max()), float(q.sum()), out_fixed, out_conv, res)
653
+
654
+
655
+ # ------------------------------------------------------------ transient
656
+
657
+
658
+ def node_capacity(mesh: HexMesh, volumetric, density=None) -> np.ndarray:
659
+ """Lumped heat capacity per node, J/K: volumetric heat capacity
660
+ (J/(mm^3 K), rho c_p; scalar or per element) times each cell's volume,
661
+ a corner holding an eighth, linear in density down to C_MIN."""
662
+ rho = np.ones(mesh.n_elem) if density is None else np.asarray(density, float)
663
+ ce = np.broadcast_to(np.asarray(volumetric, float), (mesh.n_elem,)) * mesh.pitch ** 3 \
664
+ * (C_MIN + rho * (1 - C_MIN))
665
+ return np.bincount(mesh.enode.ravel(), weights=np.repeat(ce / 8.0, 8), minlength=mesh.n_nodes)
666
+
667
+
668
+ class _Stepper:
669
+ """Backward Euler on the conduction system with a lumped capacity:
670
+ (K + C/dt) theta_n+1 = C/dt theta_n + f, with the heat inputs scaled
671
+ (switched on and off by a duty cycle). Temperatures relative to ambient;
672
+ fixed temperatures held. One CG solver per step size, warm-started from
673
+ the last step."""
674
+
675
+ def __init__(self, mesh, k, bc, capacity, density=None, conduction=None):
676
+ rho = np.ones(mesh.n_elem) if density is None else np.asarray(density, float)
677
+ kk = np.broadcast_to(np.asarray(k, float) * 1e-3, (mesh.n_elem,))
678
+ ke = kk * (K_MIN + rho ** PENALTY * (1 - K_MIN))
679
+ cond = conduction or Conduction(mesh, bc)
680
+ self.mat, self.f = cond.assemble(ke, rho)
681
+ self.free = ~bc.fixed
682
+ self.heat = np.where(bc.fixed, 0.0, bc.heat)
683
+ self.c = np.where(self.free, np.asarray(capacity, float), 0.0)
684
+ self.sink = cond.has_sink(rho)
685
+ self._sys = {}
686
+
687
+ def rhs(self, scale):
688
+ return self.f - (1.0 - scale) * self.heat
689
+
690
+ def steady(self, scale=1.0, x0=None):
691
+ if not self.sink:
692
+ raise ValueError("no heat path out: add a fixed temperature or convection")
693
+ if "steady" not in self._sys:
694
+ self._sys["steady"] = ScalarSolver(rtol=1e-9)
695
+ return self._sys["steady"].solve(self.mat, self.rhs(scale), x0)
696
+
697
+ def step(self, theta, dt, scale=1.0):
698
+ key = round(float(dt), 12)
699
+ if key not in self._sys:
700
+ self._sys[key] = (self.mat + sp.diags(self.c / dt)).tocsr(), ScalarSolver(rtol=1e-9)
701
+ a, solver = self._sys[key]
702
+ return solver.solve(a, self.rhs(scale) + self.c / dt * theta, theta)
703
+
704
+
705
+ def transient(mesh: HexMesh, k, bc: ThermalBC, capacity, dts, theta0=None, scales=1.0, density=None):
706
+ """Temperatures (C per node) after each step of sizes `dts` (s), from
707
+ theta0 (C per node; default ambient, with fixed temperatures applied at
708
+ once), heat inputs scaled by `scales` (one per step, or one number).
709
+ `capacity` is per node (node_capacity). Backward Euler: first order in
710
+ time and unconditionally stable."""
711
+ st = _Stepper(mesh, k, bc, capacity, density)
712
+ theta = np.zeros(mesh.n_nodes) if theta0 is None else np.asarray(theta0, float) - bc.ambient
713
+ scales = np.broadcast_to(np.asarray(scales, float), (len(dts),))
714
+ out = []
715
+ for dt, sc in zip(dts, scales):
716
+ theta = st.step(theta, dt, sc)
717
+ out.append(theta + bc.ambient)
718
+ return out
719
+
720
+
721
+ @dataclass
722
+ class Cycle:
723
+ """A duty cycle: every `period` seconds the heat inputs are on for the
724
+ first `on` seconds and off for the rest (fixed temperatures and fluids
725
+ hold). `cycles`: how many the part sees over its life (thermal-cycling
726
+ fatigue), or None. `steps` per period."""
727
+ period: float
728
+ on: float
729
+ cycles: float | None = None
730
+ steps: int = 48
731
+ # weight of the hot-to-cold swing in the optimizer's objective, relative
732
+ # to the hot moment's at the start (StudyHeat.sensitivities); 0: design
733
+ # for the hot moment alone
734
+ fatigue_weight: float = 0.0
735
+ # or instead: hold the swing's stress under the fatigue strength at
736
+ # `cycles` over this safety factor, a constraint (MMA in simp.optimize)
737
+ fatigue_sf: float | None = None
738
+
739
+
740
+ @dataclass
741
+ class CycleResult:
742
+ times: np.ndarray # s, the steps of one period (0 = heat switching on)
743
+ hot: np.ndarray # C per node at the hottest moment (highest mean over the part)
744
+ cold: np.ndarray # C per node at the coldest moment
745
+ t_hot: float # s into the period
746
+ t_cold: float
747
+ node_max: np.ndarray # C per node, highest over the period
748
+ node_min: np.ndarray
749
+ mean: np.ndarray # C, mean over the part's nodes at each step
750
+ periods: int # periods simulated to reach the repeating cycle
751
+ converged: bool
752
+ change: float # K: largest change over the last period
753
+
754
+
755
+ def solve_cycle(mesh: HexMesh, k, bc: ThermalBC, capacity, cycle: Cycle, density=None, tol=0.05,
756
+ max_periods=60) -> CycleResult:
757
+ """The repeating (periodic steady) temperature cycle under a duty cycle.
758
+
759
+ Starts from the steady state at the mean power (duty times the heat):
760
+ modes much slower than a period sit at their mean there already, and
761
+ the faster ones settle within a few periods, so the cycle repeats to
762
+ `tol` K after a handful of periods instead of the part's full warm-up
763
+ (hours for a plastic bracket)."""
764
+ if not 0 < cycle.on < cycle.period:
765
+ raise ValueError("a duty cycle's heat is on for part of each period: 0 < on < period")
766
+ st = _Stepper(mesh, k, bc, capacity, density)
767
+ duty = cycle.on / cycle.period
768
+ theta = st.steady(duty)
769
+ n_on = int(np.clip(round(cycle.steps * duty), 2, cycle.steps - 2))
770
+ n_off = max(2, cycle.steps - n_on)
771
+ dts = [cycle.on / n_on] * n_on + [(cycle.period - cycle.on) / n_off] * n_off
772
+ sc = [1.0] * n_on + [0.0] * n_off
773
+ rho = np.ones(mesh.n_elem) if density is None else np.asarray(density, float)
774
+ part = np.zeros(mesh.n_nodes, bool)
775
+ part[mesh.enode[rho > 0.5].ravel()] = True
776
+ if not part.any():
777
+ part[:] = True
778
+ change, conv, p = np.inf, False, 0
779
+ for p in range(1, max_periods + 1):
780
+ hist = [theta]
781
+ for dt, s_ in zip(dts, sc):
782
+ theta = st.step(theta, dt, s_)
783
+ hist.append(theta)
784
+ change = float(np.abs(theta - hist[0]).max())
785
+ if change < tol:
786
+ conv = True
787
+ break
788
+ h = np.array(hist) + bc.ambient
789
+ mean = h[:, part].mean(axis=1)
790
+ times = np.concatenate([[0.0], np.cumsum(dts)])
791
+ ih, ic = int(np.argmax(mean)), int(np.argmin(mean))
792
+ return CycleResult(times, h[ih], h[ic], float(times[ih]), float(times[ic]), h.max(axis=0), h.min(axis=0),
793
+ mean, p, conv, change)
794
+
795
+
796
+ # --------------------------------------------------------- optimization
797
+
798
+
799
+ @dataclass
800
+ class ThermalOptions:
801
+ volume_fraction: float
802
+ filter_radius: float # mm (simp.filter_radius gives one from a min member)
803
+ k: float = 0.2 # W/(m K) of the solid
804
+ max_iterations: int = 100
805
+ beta_max: float = 16.0
806
+ beta_every: int = 12
807
+ eta: float = 0.5
808
+ # Robust erosion as simp.SimpOptions (physics on the eroded design, the
809
+ # opening delivered). Off by default here: on a 40 mm convective sink it
810
+ # left the die 35-75% hotter than the same run without it (36 vs 24 K,
811
+ # 37 vs 21 K with porous grey), since the optimizer is paid for surface
812
+ # and erosion takes surface away; the filter still sets the member size.
813
+ # Use 0.15 for load-bearing parts.
814
+ erode: float = 0.0
815
+ move: float = 0.1
816
+ tol: float = 0.01
817
+ # objective: mean temperature rise over these nodes, or None for the
818
+ # power-weighted rise over the heat inputs (thermal compliance / power)
819
+ target: np.ndarray | None = None
820
+ # pin cells touching fixed temperatures and heat inputs solid
821
+ anchor: bool = True
822
+ x0: np.ndarray | None = None
823
+ # Axis index the part is extruded along (2.5D: a spreader cut from plate),
824
+ # or None. Also turns off the filter's boundary padding, which would
825
+ # otherwise thin a plate a few cells thick to nothing.
826
+ extrusion: int | None = None
827
+ # grey cells convect as a porous medium with this many faces' worth of
828
+ # wetted area at rho = 0.5 (see Conduction); 0 turns it off
829
+ # (on the same sink: 21.0 K with it, 23.7 K without; a hand-laid forest
830
+ # of 2 mm pins at the same mass reads 23.0 K)
831
+ porous: float = 1.0
832
+
833
+
834
+ @dataclass
835
+ class ThermalOptResult:
836
+ x: np.ndarray
837
+ xphys: np.ndarray # delivered design: 0/1, connected to a heat path
838
+ objective: float # mean temperature rise, K (thermal), or the weighted sum
839
+ temperature: ThermalResult # of the delivered design
840
+ volume_fraction: float
841
+ iterations: int
842
+ converged: bool
843
+ seconds: float
844
+ compliance: float = float("nan") # thermo-mechanical: weighted compliance at temperature, N mm
845
+ case_compliance: list = field(default_factory=list)
846
+ history: list = field(default_factory=list)
847
+
848
+
849
+ class _Thermal:
850
+ """State, objective and gradient (with respect to the eroded density) of
851
+ the thermal problem inside an optimization loop."""
852
+
853
+ def __init__(self, grid: ThermalGrid, bc: ThermalBC, k: float, target, porous: float = 0.0):
854
+ self.grid, self.bc = grid, bc
855
+ self.mesh = grid.mesh
856
+ self.k = float(k) * 1e-3
857
+ self.cond = Conduction(grid.mesh, bc, porous)
858
+ self.solver = ScalarSolver(rtol=1e-7)
859
+ if target is None:
860
+ if bc.power <= 0:
861
+ raise ValueError("no heat input: pass a target region to measure")
862
+ self.l = bc.heat / bc.power
863
+ else:
864
+ tg = np.asarray(target, bool)
865
+ self.l = tg / tg.sum()
866
+ self.l = np.where(bc.fixed, 0.0, self.l)
867
+ # self-adjoint when the objective weights are the loads and nothing
868
+ # is lifted: then lambda = theta / power
869
+ self.self_adjoint = target is None and not np.any(self.cond.theta_fixed) and \
870
+ not np.any((bc.face_h > 0) & (bc.face_t != bc.ambient)) and not np.any(self.cond.cell_dt)
871
+ self.theta = None
872
+ self.lam = None
873
+
874
+ def solve(self, rho):
875
+ ke = self.k * (K_MIN + rho ** PENALTY * (1 - K_MIN))
876
+ if not self.cond.has_sink(rho):
877
+ raise ValueError("no heat path out: add a fixed temperature or convection")
878
+ mat, f = self.cond.assemble(ke, rho)
879
+ self.theta = self.solver.solve(mat, f, self.theta)
880
+ self._mat = mat
881
+ return self.theta
882
+
883
+ def objective(self):
884
+ return float(self.l @ self.theta)
885
+
886
+ def adjoint(self, rhs, x0=None):
887
+ rhs = np.where(self.bc.fixed, 0.0, rhs)
888
+ return self.solver.solve(self._mat, rhs, x0)
889
+
890
+ def gradient(self, rho, lam):
891
+ """d(lam-weighted residual)/d rho: -lam^T dR/drho, i.e. the gradient of
892
+ a functional whose adjoint is lam."""
893
+ dk = self.k * PENALTY * rho ** (PENALTY - 1) * (1 - K_MIN)
894
+ g = -dk * self.cond.elem_energy(lam, self.theta)
895
+ return g - self.cond.convection_gradient(rho, lam, self.theta)
896
+
897
+ def objective_gradient(self, rho):
898
+ if self.self_adjoint:
899
+ lam = self.theta / self.bc.power
900
+ else:
901
+ self.lam = self.adjoint(self.l, self.lam)
902
+ lam = self.lam
903
+ return self.gradient(rho, lam)
904
+
905
+
906
+ def _anchor_cells(grid, bc):
907
+ """Cells touching a fixed temperature or a heat input, pinned solid when
908
+ that is a small patch (a sink pad, a die): the boundary-padded filter
909
+ would otherwise make them too expensive to fill. A distributed source
910
+ (heat generated everywhere) is left to the optimizer."""
911
+ out = np.zeros(grid.mesh.n_elem, bool)
912
+ for nodes in (bc.fixed, bc.heat_nodes):
913
+ touch = nodes[grid.mesh.enode].any(axis=1)
914
+ if touch.sum() <= 0.03 * grid.mesh.n_elem:
915
+ out |= touch
916
+ return out
917
+
918
+
919
+ class _Design:
920
+ """Filter, projection, erosion and symmetry shared by both optimizers."""
921
+
922
+ def __init__(self, grid, opts, pres):
923
+ from .simp import density_filter
924
+ self.h, self.hs = density_filter(grid, opts.filter_radius, pad=opts.extrusion is None)
925
+ self.ht = self.h.T.tocsr()
926
+ self.opts = opts
927
+ self.pres = pres
928
+ self.design = ~pres
929
+ self.mirror = grid.mirror
930
+ self.columns = None
931
+ if opts.extrusion is not None:
932
+ others = [i for i in range(3) if i != opts.extrusion]
933
+ ij = grid.mesh.elem_ijk[:, others]
934
+ _, self.columns = np.unique(ij[:, 0] * (grid.dims[others[1]] + 1) + ij[:, 1], return_inverse=True)
935
+
936
+ def tie(self, v):
937
+ """Symmetry and extrusion applied to a design-variable field."""
938
+ from .simp import _extrude, _symmetrize
939
+ if self.mirror:
940
+ v = _symmetrize(v, self.mirror)
941
+ return _extrude(v, self.columns, self.design)
942
+
943
+ def fields(self, x, beta):
944
+ from .simp import _dproject, _project
945
+ o = self.opts
946
+ xt = (self.h @ x) / self.hs
947
+ xp = _project(xt, beta, o.eta)
948
+ xe = _project(xt, beta, o.eta + o.erode)
949
+ dpb = _dproject(xt, beta, o.eta)
950
+ dpe = _dproject(xt, beta, o.eta + o.erode)
951
+ for v in (xp, xe):
952
+ v[self.pres] = 1.0
953
+ for v in (dpb, dpe):
954
+ v[self.pres] = 0.0
955
+ return xp, xe, dpb, dpe
956
+
957
+ def back(self, g, dp):
958
+ return self.tie(self.ht @ (g * dp / self.hs))
959
+
960
+ def deliver(self, x, beta):
961
+ from .simp import _project
962
+ o = self.opts
963
+ xt = (self.h @ x) / self.hs
964
+ if o.erode > 0:
965
+ xe = _project(xt, beta, o.eta + o.erode)
966
+ xe[self.pres] = 1.0
967
+ xp = _project((self.h @ xe) / self.hs, beta, o.eta - o.erode)
968
+ else:
969
+ xp = _project(xt, beta, o.eta)
970
+ xp[self.pres] = 1.0
971
+ return xp
972
+
973
+
974
+ def _crisp_connected(grid, xp, bc, pres):
975
+ """The delivered part: cells above half density, minus pieces that touch
976
+ no heat input, fixed temperature or preserved cell. Convective designs
977
+ keep grey shells the projection never quite clears (40% of the non-void
978
+ cells on the 40 mm sink at beta 16), and floating specks; analysed crisp
979
+ and connected, the same sinks read 1-2 K different and lighter."""
980
+ from scipy import ndimage
981
+ mesh = grid.mesh
982
+ solid = (xp > 0.5) | pres
983
+ lbl, _ = ndimage.label(grid.full_grid(solid) > 0.5, structure=np.ones((3, 3, 3), bool))
984
+ el = lbl[tuple(mesh.elem_ijk.T)]
985
+ anchor = solid & ((bc.fixed | bc.heat_nodes)[mesh.enode].any(axis=1) | pres)
986
+ keep = np.isin(el, np.unique(el[anchor]))
987
+ return (solid & keep & (el > 0)).astype(float)
988
+
989
+
990
+ def _mma_loop(grid, opts, pres, evaluate, on_iter=None, should_stop=None):
991
+ """Volume-constrained MMA with projection continuation. evaluate(xe) ->
992
+ (objective, d objective / d xe, record)."""
993
+ t0 = time.time()
994
+ n = grid.mesh.n_elem
995
+ design = ~pres
996
+ free = np.flatnonzero(design)
997
+ if len(free) == 0:
998
+ raise ValueError("nothing to optimize: every cell is preserved")
999
+ dz = _Design(grid, opts, pres)
1000
+ x = np.full(n, opts.volume_fraction)
1001
+ if opts.x0 is not None:
1002
+ x = np.clip(np.asarray(opts.x0, float), 0.0, 1.0)
1003
+ x[pres] = 1.0
1004
+ x = dz.tie(x)
1005
+ mma = MMA(len(free), 1, move=opts.move)
1006
+ beta, since_beta = 1.0, 0
1007
+ f_ref = None
1008
+ history = []
1009
+ converged = False
1010
+ it = 0
1011
+ for it in range(1, opts.max_iterations + 1):
1012
+ if should_stop is not None and should_stop():
1013
+ break
1014
+ xp, xe, dpb, dpe = dz.fields(x, beta)
1015
+ obj, g_xe, rec = evaluate(xe)
1016
+ if f_ref is None:
1017
+ f_ref = abs(obj) or 1.0
1018
+ f0 = 10.0 * obj / f_ref
1019
+ df0 = dz.back(10.0 * g_xe / f_ref, dpe)
1020
+ vol = float(xp[design].mean())
1021
+ con = vol / opts.volume_fraction - 1.0
1022
+ dcon = dz.back(design / design.sum() / opts.volume_fraction, dpb)
1023
+ xn = mma.update(x[free], f0, df0[free], np.array([con]), dcon[free][None, :])
1024
+ x_new = x.copy()
1025
+ x_new[free] = xn
1026
+ x_new = dz.tie(x_new)
1027
+ change = float(np.abs(x_new - x)[design].max())
1028
+ x = x_new
1029
+ since_beta += 1
1030
+ rec = {"iteration": it, "objective": obj, "volume": vol, "beta": beta, "change": change,
1031
+ "seconds": time.time() - t0, **rec}
1032
+ history.append(rec)
1033
+ if on_iter is not None:
1034
+ on_iter(rec, xp)
1035
+ if beta < opts.beta_max and (since_beta >= opts.beta_every or change < 2 * opts.tol):
1036
+ beta = min(2 * beta, opts.beta_max)
1037
+ since_beta = 0
1038
+ elif beta >= opts.beta_max and since_beta >= 5 and con < 0.02:
1039
+ if change < opts.tol or (len(history) > 6 and
1040
+ abs(history[-1]["objective"] - history[-7]["objective"])
1041
+ < 2e-3 * abs(history[-7]["objective"])):
1042
+ converged = True
1043
+ break
1044
+ return x, dz.deliver(x, beta), it, converged, history, time.time() - t0
1045
+
1046
+
1047
+ def optimize_thermal(grid: ThermalGrid, bc: ThermalBC, opts: ThermalOptions, on_iter=None,
1048
+ should_stop=None) -> ThermalOptResult:
1049
+ """Lay out `volume_fraction` of the design cells to minimize the mean
1050
+ temperature rise (of opts.target, or over the heat inputs)."""
1051
+ pres = grid.preserve.copy()
1052
+ if opts.anchor:
1053
+ pres |= _anchor_cells(grid, bc)
1054
+ th = _Thermal(grid, bc, opts.k, opts.target, opts.porous)
1055
+
1056
+ def evaluate(xe):
1057
+ th.solve(xe)
1058
+ return th.objective(), th.objective_gradient(xe), {"cgIterations": th.solver.last_iters}
1059
+
1060
+ x, xp, it, conv, hist, secs = _mma_loop(grid, opts, pres, evaluate, on_iter, should_stop)
1061
+ xp = _crisp_connected(grid, xp, bc, pres)
1062
+ res = solve_temperature(grid.mesh, opts.k, bc, density=xp)
1063
+ obj = float(th.l @ (res.temperature - bc.ambient))
1064
+ return ThermalOptResult(x=x, xphys=xp, objective=obj, temperature=res,
1065
+ volume_fraction=float(xp[~pres].mean()), iterations=it, converged=conv,
1066
+ seconds=secs, history=hist)
1067
+
1068
+
1069
+ def optimize_thermomechanical(domain, nu: float, bc: ThermalBC, opts: ThermalOptions, material="petg",
1070
+ thermal_weight: float = 0.0, on_iter=None, should_stop=None, alpha=None,
1071
+ t_ref=None, modulus=None) -> ThermalOptResult:
1072
+ """Minimize the weighted compliance of a mechanical simp.Domain whose
1073
+ moduli are derated by temperature (`material`'s MODULUS_RETENTION, or a
1074
+ list of (C, retention) points), plus `thermal_weight` times the mean
1075
+ temperature rise (normalized by its starting value), for a volume.
1076
+
1077
+ The temperature field is the design's own: solved on the same cells with
1078
+ `bc` every iteration, and the compliance gradient includes its adjoint.
1079
+
1080
+ `alpha` adds thermal expansion: True for the material's EXPANSION, a
1081
+ number (1/K), or one per cell (a metal insert in a polymer part: the
1082
+ mismatch warps it). Each cell then wants to grow by alpha (T - t_ref)
1083
+ (t_ref, the stress-free temperature, defaults to bc.ambient), and
1084
+ wherever the supports, the loads' fixtures or its neighbours stop it,
1085
+ that is a load. It is applied in every load case, scales with the
1086
+ cell's (derated) stiffness, so it depends on the design, and its
1087
+ gradient (through density and through temperature) is in the
1088
+ sensitivities. `modulus` (MPa) defaults to the material's E."""
1089
+ from .simp import E_MIN
1090
+
1091
+ grid = grid_from_domain(domain)
1092
+ mesh = domain.mesh
1093
+ pres = grid.preserve.copy()
1094
+ if opts.anchor:
1095
+ pres |= _anchor_cells(grid, bc)
1096
+ evaluate, fe, th = _thermomech_evaluator(domain, nu, bc, opts, material, thermal_weight, alpha=alpha,
1097
+ t_ref=t_ref, modulus=modulus)
1098
+ weights = np.asarray(domain.case_weights, float)
1099
+
1100
+ x, xp, it, conv, hist, secs = _mma_loop(grid, opts, pres, evaluate, on_iter, should_stop)
1101
+ xp = _crisp_connected(grid, xp, bc, pres | (domain.support_nodes | domain.load_nodes)[mesh.enode].any(axis=1))
1102
+ res = solve_temperature(mesh, opts.k, bc, density=xp)
1103
+ ev = thermoelastic(domain, nu, xp, res.elem_temperature, material, alpha=alpha,
1104
+ t_ref=bc.ambient if t_ref is None else t_ref, modulus=modulus, fe=fe)
1105
+ comp = float(np.dot(weights, ev["caseCompliance"]))
1106
+ tobj = float(th.l @ (res.temperature - bc.ambient))
1107
+ out = ThermalOptResult(x=x, xphys=xp, objective=tobj, temperature=res,
1108
+ volume_fraction=float(xp[~pres].mean()), iterations=it, converged=conv,
1109
+ seconds=secs, compliance=comp, case_compliance=ev["caseCompliance"], history=hist)
1110
+ out.thermoelastic = ev
1111
+ return out
1112
+
1113
+
1114
+ def _alpha_cells(alpha, material, n):
1115
+ if alpha is None or alpha is False:
1116
+ return None
1117
+ a = expansion(material) if alpha is True else alpha
1118
+ return np.asarray(a, float) * np.ones(n)
1119
+
1120
+
1121
+ def _modulus(material, modulus):
1122
+ if modulus is not None:
1123
+ return float(modulus)
1124
+ from .materials import material_from_spec
1125
+
1126
+ return float(material_from_spec(material).E)
1127
+
1128
+
1129
+ def thermoelastic(domain, nu, xp, elem_temperature, material="petg", alpha=True, t_ref=25.0, modulus=None,
1130
+ fe=None, rtol=1e-6):
1131
+ """Solve a design (densities xp) at a temperature field (per cell, C):
1132
+ the load cases plus the thermal expansion load (alpha as in
1133
+ optimize_thermomechanical; None for none), moduli derated by
1134
+ temperature. Returns per-case displacements (mm), compliance (N mm),
1135
+ twice the elastic strain energy (N mm: the optimizer's
1136
+ objective with expansion, the compliance without), von Mises stress per cell (MPa, the expansion strain taken off) and the
1137
+ largest displacement."""
1138
+ from .fem import elastic_c, hex8_b_center, thermal_load, von_mises
1139
+ from .simp import E_MIN, _Analysis
1140
+
1141
+ mesh = domain.mesh
1142
+ fe = fe or _Analysis(domain, nu, rtol)
1143
+ E0 = _modulus(material, modulus)
1144
+ te = np.asarray(elem_temperature, float)
1145
+ g, _ = retention(material, te)
1146
+ e = (E_MIN + xp ** 3 * (1 - E_MIN)) * g
1147
+ a = _alpha_cells(alpha, material, mesh.n_elem)
1148
+ loads = list(domain.loads)
1149
+ if a is not None:
1150
+ # in fe's units (stiffness at E = 1, so u scaled by E0): E0 E_e eps
1151
+ eps = a * (te - t_ref)
1152
+ fth = thermal_load(mesh, nu, E0 * xp ** 3 * (1 - E_MIN) * g, eps, domain.fixed)
1153
+ loads = [f + fth for f in loads] if loads else [fth]
1154
+ us = fe.solve(e, xp, [None] * len(loads), rtol=rtol, loads=loads)
1155
+ b = hex8_b_center() / mesh.pitch
1156
+ c = elastic_c(1.0, nu)
1157
+ vm = np.zeros(mesh.n_elem)
1158
+ for u in us:
1159
+ strain = u[mesh.edof] @ b.T / E0
1160
+ if a is not None:
1161
+ strain[:, :3] -= eps[:, None]
1162
+ sig = (strain @ c.T) * (E0 * e)[:, None]
1163
+ vm = np.maximum(vm, von_mises(sig))
1164
+ # twice the elastic strain energy, exactly: u.(f_m - f_th) + sum E eps^2 m^T C m V
1165
+ energy = [float(u @ f) / E0 for f, u in zip(loads, us)]
1166
+ if a is not None:
1167
+ cm = float(np.array([1, 1, 1, 0, 0, 0]) @ c @ np.array([1, 1, 1, 0, 0, 0])) * mesh.pitch ** 3
1168
+ self_e = float(np.sum(E0 * xp ** 3 * (1 - E_MIN) * g * eps * eps)) * cm
1169
+ energy = [float(u @ (f - 2 * fth)) / E0 + self_e for f, u in zip(loads, us)]
1170
+ solid = xp > 0.5
1171
+ disp = [np.linalg.norm(u.reshape(-1, 3), axis=1) / E0 for u in us]
1172
+ return {"displacement": [u / E0 for u in us], "caseCompliance": [float(f @ u) / E0 for f, u in zip(loads, us)],
1173
+ "strainEnergy2": energy, "vonMises": vm, "maxVonMises": float(vm[solid].max()) if solid.any() else 0.0,
1174
+ "maxDisplacement": float(max(d.max() for d in disp)), "thermal": a is not None}
1175
+
1176
+
1177
+ def _thermomech_evaluator(domain, nu, bc, opts, material, thermal_weight, rtol=1e-4, alpha=None, t_ref=None,
1178
+ modulus=None):
1179
+ """evaluate(xe) -> (objective, gradient w.r.t. xe, record) for
1180
+ optimize_thermomechanical; normalized by the first evaluation."""
1181
+ from .fem import elastic_c, hex8_fth
1182
+ from .simp import ALIVE, E_MIN, _Analysis
1183
+
1184
+ mesh = domain.mesh
1185
+ fe = _Analysis(domain, nu, rtol)
1186
+ th = _Thermal(grid_from_domain(domain), bc, opts.k, opts.target, opts.porous)
1187
+ weights = np.asarray(domain.case_weights, float)
1188
+ a = _alpha_cells(alpha, material, mesh.n_elem)
1189
+ tr = bc.ambient if t_ref is None else float(t_ref)
1190
+ E0 = _modulus(material, modulus) if a is not None else 1.0
1191
+ vth = hex8_fth(nu) * mesh.pitch ** 2
1192
+ # m^T C0 m V: a free-growth strain's energy density at E = 1, times the cell
1193
+ cm = float(np.array([1, 1, 1, 0, 0, 0]) @ elastic_c(1.0, nu) @ np.array([1, 1, 1, 0, 0, 0])) * mesh.pitch ** 3
1194
+ ncase = max(1, len(domain.loads)) if a is not None else len(domain.loads)
1195
+ state = {"us": [None] * ncase}
1196
+ refs = {}
1197
+
1198
+ def evaluate(xe):
1199
+ theta = th.solve(xe)
1200
+ te = (theta + bc.ambient)[mesh.enode].mean(axis=1)
1201
+ g, dg = retention(material, te)
1202
+ estiff = E_MIN + xe ** 3 * (1 - E_MIN)
1203
+ loads = list(domain.loads)
1204
+ if a is not None:
1205
+ # expansion load per cell: E0 p(x) g(T) alpha (T - t_ref) vth,
1206
+ # p = x^3 without the void floor, and none from cells the
1207
+ # solve leaves out (their share is ~x^3, nothing)
1208
+ live = xe > ALIVE
1209
+ pth = np.where(live, xe ** 3 * (1 - E_MIN), 0.0)
1210
+ eps = a * (te - tr)
1211
+ w = E0 * pth * g * eps
1212
+ fth = np.bincount(mesh.edof.ravel(), weights=(w[:, None] * vth[None, :]).ravel(), minlength=mesh.n_dof)
1213
+ fth[domain.fixed] = 0.0
1214
+ loads = [f + fth for f in loads] if loads else [fth]
1215
+ us = fe.solve(estiff * g, xe, state["us"], rtol=rtol, loads=loads)
1216
+ state["us"] = us
1217
+ cw = weights if len(weights) == len(loads) else np.ones(len(loads))
1218
+ if a is None:
1219
+ comp = 0.0
1220
+ w_e = np.zeros(mesh.n_elem) # sum_c w_c u_e^T K0 u_e
1221
+ for ci, f in enumerate(loads):
1222
+ comp += cw[ci] * float(f @ us[ci])
1223
+ w_e += cw[ci] * fe.strain_energy(us[ci])
1224
+ refs.setdefault("c", comp)
1225
+ # explicit part, at fixed temperature
1226
+ g_xe = -(3 * xe ** 2 * (1 - E_MIN)) * g * w_e / refs["c"]
1227
+ # through temperature: dC/dT_e = -E_e g'(T_e) w_e, a node holds 1/8
1228
+ # of each of its cells' temperature
1229
+ dc_dte = -estiff * dg * w_e / refs["c"]
1230
+ else:
1231
+ # With expansion the objective is twice the elastic strain energy,
1232
+ # sum_e E_e (eps - eps_th)^T C0 (eps - eps_th) V, not f.u: free
1233
+ # growth does work on its load without stressing anything, and
1234
+ # f.u would pay the optimizer to cut the hot material that moves
1235
+ # most rather than the material that is fought over. Without
1236
+ # expansion it is the compliance. In fe's units:
1237
+ # J = u.f_m - u.f_th + E0^2 sum_e p_e g_e eps_e^2 cm V, adjoint
1238
+ # K lam = f_m - f_th.
1239
+ # (loads already carry f_th: f_m - f_th = f - 2 f_th)
1240
+ lams = fe.resolve([f - 2 * fth for f in loads], state.get("lams"))
1241
+ state["lams"] = lams
1242
+ comp = 0.0
1243
+ m_e = np.zeros(mesh.n_elem) # sum_c w_c lam_e^T K0 u_e
1244
+ q_e = np.zeros(mesh.n_elem) # sum_c w_c (lam_e - u_e) . vth
1245
+ self_e = E0 * E0 * eps * eps * cm
1246
+ for ci, f in enumerate(loads):
1247
+ u, lam = us[ci], lams[ci]
1248
+ comp += cw[ci] * (float(u @ (f - 2 * fth)) + float(np.sum(pth * g * self_e)))
1249
+ m_e += cw[ci] * 0.25 * (fe.strain_energy(lam + u) - fe.strain_energy(lam - u))
1250
+ q_e += cw[ci] * ((lam - u)[mesh.edof] @ vth)
1251
+ refs.setdefault("c", comp)
1252
+ dp = np.where(live, 3 * xe ** 2 * (1 - E_MIN), 0.0)
1253
+ sw = cw.sum()
1254
+ g_xe = (-(3 * xe ** 2 * (1 - E_MIN)) * g * m_e + E0 * dp * g * eps * q_e
1255
+ + sw * dp * g * self_e) / refs["c"]
1256
+ dc_dte = (-estiff * dg * m_e + E0 * pth * (dg * eps + g * a) * q_e
1257
+ + sw * E0 * E0 * pth * cm * (dg * eps * eps + 2 * g * eps * a)) / refs["c"]
1258
+ rhs = np.bincount(mesh.enode.ravel(), weights=np.repeat(dc_dte / 8.0, 8), minlength=mesh.n_nodes)
1259
+ obj = comp / refs["c"]
1260
+ if thermal_weight > 0:
1261
+ tj = th.objective()
1262
+ refs.setdefault("t", abs(tj) or 1.0)
1263
+ obj += thermal_weight * tj / refs["t"]
1264
+ rhs = rhs + thermal_weight * th.l / refs["t"]
1265
+ if np.any(rhs):
1266
+ th.lam = th.adjoint(rhs, th.lam)
1267
+ g_xe = g_xe + th.gradient(xe, th.lam)
1268
+ rec = {"compliance" if a is None else "strainEnergy2": comp / E0, "maxT": float(theta.max() + bc.ambient),
1269
+ "minRetention": float(g[xe > 0.5].min()) if np.any(xe > 0.5) else 1.0}
1270
+ return obj, g_xe, rec
1271
+
1272
+ return evaluate, fe, th
1273
+
1274
+
1275
+ # ------------------------------------------------- a study's thermal block
1276
+
1277
+
1278
+ def study_thermal_bc(study, mesh: HexMesh) -> ThermalBC:
1279
+ """ThermalBC on `mesh` from a study's `thermal` block (spec.Thermal):
1280
+ region ids resolve to the study's preserve / keepOut shapes."""
1281
+ th = study.thermal
1282
+
1283
+ def shape(region, sh):
1284
+ return sh if sh is not None else study.region_shape(region)[0]
1285
+
1286
+ temps = [(shape(r, s), t) for r, s, t in th.temperatures]
1287
+ heat = [(shape(r, s), w, "volume") if spread == "volume" else (shape(r, s), w)
1288
+ for r, s, w, spread in th.heat]
1289
+ conv = [Convection(h, fluid, None if r is None and s is None else shape(r, s))
1290
+ for r, s, h, fluid in th.convection if h > 0]
1291
+ if heat:
1292
+ for (r, s, w, spread), (sh, *_) in zip(th.heat, heat):
1293
+ if spread == "volume" and not cells_in(mesh, sh).any():
1294
+ raise ValueError(f"thermal heat into {r or 'a shape'}: no cell of the grid is inside it")
1295
+ return thermal_bc(mesh, ambient=th.ambient, temperatures=temps, heat=heat, convection=conv)
1296
+
1297
+
1298
+ class StudyHeat:
1299
+ """A study's thermal block on a mechanical mesh, for the optimizer
1300
+ (simp) and for verification: solve the temperature at a density, then
1301
+ each cell's retention g(T), expansion strain eps = alpha (T - t_ref)
1302
+ and expansion load.
1303
+
1304
+ In the optimizer the objective with expansion is twice the elastic
1305
+ strain energy (see optimize_thermomechanical) and the gradient carries
1306
+ the mechanical adjoint and the thermal one, so material that conducts
1307
+ heat into the structure is charged for softening and growing it.
1308
+
1309
+ With a duty cycle (spec.Thermal.cycle) the temperature is the repeating
1310
+ cycle's hottest moment (solve_cycle) and `cold` its coldest. A cycle is
1311
+ dozens of solves, too many for every iteration, and holding its
1312
+ temperature fixed between solves hid from the optimizer that cutting
1313
+ the material round the heat makes the rest hotter (on the motor mount
1314
+ that design ran 6 K hotter and lost 13% of its hot safety factor to the
1315
+ one designed cold). So the hot moment is carried as a node-by-node
1316
+ share r of the steady temperature rise at full power, hot = r * steady,
1317
+ with r (in [0, 1]: a duty cycle never outruns full power) refreshed from
1318
+ a cycle solve every REFRESH calls; the steady solve and its adjoint run
1319
+ every iteration, so the gradient follows the heat path with r held.
1320
+ Verification solves the cycle on the delivered part."""
1321
+
1322
+ REFRESH = 5
1323
+
1324
+ def __init__(self, study, mesh: HexMesh, nu, fixed, modulus: float | None = None):
1325
+ th = study.thermal
1326
+ mat = study.material
1327
+ self.mesh, self.nu, self.fixed = mesh, nu, fixed
1328
+ self.t_ref = float(th.t_ref)
1329
+ self.k = th.conductivity if th.conductivity is not None else _card(conductivity, mat, "conductivity")
1330
+ self.curve = _card(lambda m: MODULUS_RETENTION[_material_key(m)], mat, "retention") if th.soften else None
1331
+ a = th.expansion
1332
+ self.alpha = (_card(expansion, mat, "expansion coefficient") if a is True else float(a)) if a else None
1333
+ self.E0 = float(mat.E if modulus is None else modulus)
1334
+ self.bc = study_thermal_bc(study, mesh)
1335
+ self.cycle = th.cycle
1336
+ if self.cycle is not None:
1337
+ cp = th.specific_heat if th.specific_heat is not None else _card(specific_heat, mat, "specific heat")
1338
+ self.rho_cp = mat.density * 1e-6 * cp # J/(mm^3 K)
1339
+ self.vth = None
1340
+ self.th = None
1341
+ self.te = None
1342
+ self.cold = None
1343
+ self.cycle_result = None
1344
+ self.r = None
1345
+ self.r_cold = None
1346
+ # cells left out of the swing's stress p-norm: those by supports and
1347
+ # loads, whose stress verification leaves out of the peak too (a
1348
+ # bolted face restraining expansion is a singular corner)
1349
+ self.near = None
1350
+ self._calls = 0
1351
+ # weight of the thermal-cycling swing in the optimizer's objective
1352
+ # (relative to the hot objective at the first iteration)
1353
+ self.swing_weight = float(th.cycle.fatigue_weight) if th.cycle is not None else 0.0
1354
+ # or the swing as a constraint: the largest swing stress range over
1355
+ # retention (MPa) allowed, from the fatigue strength at the cycle
1356
+ # count over the safety factor. Fatigue SF S means damage
1357
+ # n/N(sigma) <= S^-k, so sigma <= fatigue strength at n S^k
1358
+ # cycles; durability reads the cycle stress as range / 0.7.
1359
+ self.swing_limit = None
1360
+ if th.cycle is not None and th.cycle.fatigue_sf is not None:
1361
+ from .durability import durability_data, fatigue_strength
1362
+
1363
+ dur = getattr(study, "durability", None)
1364
+ data = durability_data(mat, dur.overrides if dur is not None else None)
1365
+ n_eq = float(th.cycle.cycles) * float(th.cycle.fatigue_sf) ** data.fatigue_slope
1366
+ # / SWING_ALLOWANCE: verification reads the surface of the meshed
1367
+ # part, about 1.2-1.3x what the cells' corners read on the grid
1368
+ # (motor mount, first pass: 1.24 at 12k cells, 1.19-1.26 at 45k)
1369
+ self.swing_limit = 0.7 * fatigue_strength(data, mat.strength, n_eq) / self.SWING_ALLOWANCE
1370
+
1371
+ @property
1372
+ def expands(self):
1373
+ return self.alpha is not None and self.alpha > 0
1374
+
1375
+ def temperature(self, rho=None, reuse=False):
1376
+ """Solve at densities rho (default solid): per-element C, and sets
1377
+ g, dg, eps. With a duty cycle: the cycle's hottest moment (and
1378
+ self.cold, its coldest), held over REFRESH calls when `reuse`."""
1379
+ rho = np.ones(self.mesh.n_elem) if rho is None else np.asarray(rho, float)
1380
+ if self.th is None:
1381
+ grid = ThermalGrid(self.mesh.pitch, self.mesh.origin, (0, 0, 0), self.mesh,
1382
+ np.zeros(self.mesh.n_elem, bool))
1383
+ target = None if self.bc.power > 0 else np.ones(self.mesh.n_nodes, bool)
1384
+ self.th = _Thermal(grid, self.bc, self.k, target)
1385
+ if self.bc.fixed.all():
1386
+ # every node held: nothing to solve, and nothing depends on rho
1387
+ theta = self.bc.fixed_t - self.bc.ambient
1388
+ else:
1389
+ theta = self.th.solve(rho)
1390
+ if self.cycle is not None:
1391
+ self._calls += 1
1392
+ if not (reuse and self.r is not None and (self._calls - 1) % self.REFRESH):
1393
+ res = solve_cycle(self.mesh, self.k, self.bc, node_capacity(self.mesh, self.rho_cp, rho),
1394
+ self.cycle, density=rho)
1395
+ self.cycle_result, self.cold = res, res.cold
1396
+ big = np.abs(theta) > 1e-9 * max(np.abs(theta).max(), 1e-300)
1397
+ den = np.where(big, theta, 1.0)
1398
+ self.r = np.where(big, np.clip((res.hot - self.bc.ambient) / den, 0, 1), 1.0)
1399
+ self.r_cold = np.where(big, np.clip((res.cold - self.bc.ambient) / den, 0, 1), 1.0)
1400
+ self.theta_s = theta
1401
+ theta = self.r * theta
1402
+ return self.use(theta + self.bc.ambient)
1403
+
1404
+ def use(self, t_node):
1405
+ """Take a temperature field (C per node): sets te, g, dg, eps."""
1406
+ self.t_node = np.asarray(t_node, float)
1407
+ self.te = self.t_node[self.mesh.enode].mean(axis=1)
1408
+ if self.curve is not None:
1409
+ self.g, self.dg = retention(self.curve, self.te)
1410
+ else:
1411
+ self.g, self.dg = np.ones(self.mesh.n_elem), np.zeros(self.mesh.n_elem)
1412
+ self.eps = self.alpha * (self.te - self.t_ref) if self.expands else np.zeros(self.mesh.n_elem)
1413
+ return self.te
1414
+
1415
+ def load(self, p, scale=1.0):
1416
+ """Expansion load at stiffness interpolation p per cell (no void
1417
+ floor), times scale (the modulus: E0 in the optimizer's units)."""
1418
+ from .fem import hex8_fth
1419
+
1420
+ if not self.expands:
1421
+ return np.zeros(self.mesh.n_dof)
1422
+ if self.vth is None:
1423
+ self.vth = hex8_fth(self.nu) * self.mesh.pitch ** 2
1424
+ w = scale * p * self.g * self.eps
1425
+ f = np.bincount(self.mesh.edof.ravel(), weights=(w[:, None] * self.vth[None, :]).ravel(),
1426
+ minlength=self.mesh.n_dof)
1427
+ f[self.fixed] = 0.0
1428
+ return f
1429
+
1430
+ def sensitivities(self, fe, xe, rhs, us, fth, weights, gravity=None, state=None, layout=None,
1431
+ spreads=None, smooth=None):
1432
+ """(objective, per-case objective, gradient w.r.t. xe, per-case worst
1433
+ direction or None) of the weighted cases at the last temperature(xe),
1434
+ in fe's units (forces in N, u scaled by E0). `rhs` (directions.expand,
1435
+ with `layout` and `spreads`; one per case without them) already carry
1436
+ fth and self-weight. Without expansion the objective is the
1437
+ compliance; with it, twice the elastic strain energy:
1438
+ J = u.(f - 2 fth) + E0^2 sum p g eps^2 cm V, adjoint
1439
+ K lam = f - 2 fth. Self-weight (gravity, N/mm^3 linear in xe) adds
1440
+ (lam + u) . df/dx.
1441
+
1442
+ A force of unknown direction (directions.py) keeps J quadratic in the
1443
+ direction d: with the case's fixed part f0 (fth in it) and the
1444
+ uncertain force b_j along each axis, J(d) = J0 + 2 g.d + d'Ad with
1445
+ g_j = b_j.(u0 + lam0) / 2 and A_ij = b_i.u_j, since the basis loads
1446
+ carry no expansion (their adjoints are their displacements). The
1447
+ worst d is the same 3x3 problem as for compliance, and the gradient
1448
+ is the soft maximum over directions that directions.collapse uses,
1449
+ with lam = lam0 + U d beside u = u0 + U d."""
1450
+ from .directions import SMOOTH, _cap_samples, worst_direction
1451
+ from .fem import elastic_c
1452
+ from .simp import ALIVE, E_MIN, PENALTY
1453
+
1454
+ mesh = self.mesh
1455
+ state = state if state is not None else {}
1456
+ smooth = SMOOTH if smooth is None else smooth
1457
+ layout = layout if layout is not None else [(i,) for i in range(len(rhs))]
1458
+ spreads = spreads or [None] * len(layout)
1459
+ g, dg, eps = self.g, self.dg, self.eps
1460
+ live = xe > ALIVE
1461
+ pth = np.where(live, xe ** PENALTY * (1 - E_MIN), 0.0)
1462
+ dp = np.where(live, PENALTY * xe ** (PENALTY - 1) * (1 - E_MIN), 0.0)
1463
+ estiff = E_MIN + xe ** PENALTY * (1 - E_MIN)
1464
+ dE = PENALTY * xe ** (PENALTY - 1) * (1 - E_MIN)
1465
+ S = 0.0
1466
+ if self.expands:
1467
+ if self.vth is None:
1468
+ self.load(pth)
1469
+ # adjoints of the right-hand sides that carry the expansion: every
1470
+ # plain case, and each spread case's fixed part
1471
+ fixed_part = [idx[0] for idx in layout if idx[0] is not None]
1472
+ sol = fe.resolve([rhs[i] - 2 * fth for i in fixed_part], state.get("lams"))
1473
+ state["lams"] = sol
1474
+ lams = list(us)
1475
+ for i, lam in zip(fixed_part, sol):
1476
+ lams[i] = lam
1477
+ cm = float(np.array([1, 1, 1, 0, 0, 0]) @ elastic_c(1.0, self.nu)
1478
+ @ np.array([1, 1, 1, 0, 0, 0])) * mesh.pitch ** 3
1479
+ self_e = self.E0 ** 2 * eps * eps * cm
1480
+ S = float(np.sum(pth * g * self_e))
1481
+ else:
1482
+ lams = us
1483
+ rmod = [r - 2 * fth if self.expands else r for r in rhs]
1484
+ case_j, dirs, j = [], [], 0.0
1485
+ m_e = np.zeros(mesh.n_elem) # sum_c w_c lam_e^T K0 u_e
1486
+ q_e = np.zeros(mesh.n_elem) # sum_c w_c (lam_e - u_e) . vth
1487
+ grav = np.zeros(mesh.n_elem)
1488
+ for ci, idx in enumerate(layout):
1489
+ if len(idx) == 1:
1490
+ i = idx[0]
1491
+ jc = float(us[i] @ rmod[i]) + S
1492
+ pairs = [(1.0, lams[i], us[i])]
1493
+ lam_m, u_m, d = lams[i], us[i], None
1494
+ else:
1495
+ i0, ib = idx[0], idx[1:]
1496
+ ub = [us[i] for i in ib]
1497
+ bb = [rhs[i] for i in ib]
1498
+ a = np.array([[bb[i] @ ub[k] for k in range(3)] for i in range(3)])
1499
+ a = 0.5 * (a + a.T)
1500
+ if i0 is None:
1501
+ u0 = lam0 = np.zeros_like(ub[0])
1502
+ c00, gv = S, np.zeros(3)
1503
+ else:
1504
+ u0, lam0 = us[i0], lams[i0]
1505
+ c00 = float(u0 @ rmod[i0]) + S
1506
+ gv = 0.5 * np.array([bk @ (u0 + lam0) for bk in bb])
1507
+ sp = spreads[ci]
1508
+ d, jc = worst_direction(c00, gv, a, sp.nominal, sp.cos_half)
1509
+ cand = np.vstack([_cap_samples(sp), d[None]])
1510
+ vals = c00 + 2.0 * cand @ gv + np.einsum("ij,jk,ik->i", cand, a, cand)
1511
+ w = np.exp(smooth * (vals - jc) / max(abs(jc), 1e-300))
1512
+ w /= w.sum()
1513
+ dd = np.column_stack([np.ones(len(cand)), cand])
1514
+ mu, q = np.linalg.eigh((dd * w[:, None]).T @ dd)
1515
+ uu, ll = [u0] + ub, [lam0] + ub
1516
+ pairs = [(float(m), sum(q[k, n] * ll[k] for k in range(4)), sum(q[k, n] * uu[k] for k in range(4)))
1517
+ for n, m in enumerate(mu) if m > 1e-9 * mu[-1]]
1518
+ mean = dd.T @ w
1519
+ lam_m = sum(mean[k] * ll[k] for k in range(4))
1520
+ u_m = sum(mean[k] * uu[k] for k in range(4))
1521
+ for m, lam, u in pairs:
1522
+ if lam is u:
1523
+ m_e += weights[ci] * m * fe.strain_energy(u)
1524
+ else:
1525
+ m_e += weights[ci] * m * 0.25 * (fe.strain_energy(lam + u) - fe.strain_energy(lam - u))
1526
+ if self.expands:
1527
+ q_e += weights[ci] * ((lam_m - u_m)[mesh.edof] @ self.vth)
1528
+ if gravity is not None:
1529
+ s = (lam_m + u_m).reshape(-1, 3)[mesh.enode].sum(axis=1)
1530
+ grav += weights[ci] * (s @ gravity) * mesh.pitch ** 3 / 8.0
1531
+ case_j.append(jc)
1532
+ dirs.append(d)
1533
+ j += weights[ci] * jc
1534
+ sw = float(np.sum(weights[:len(layout)]))
1535
+ dx = -dE * g * m_e + grav
1536
+ # temperature derivatives per cell, split by path: through the
1537
+ # retention g (the hot moment's temperature) and through the
1538
+ # expansion strain (whose nodal temperature may be another field)
1539
+ dte_g = -estiff * dg * m_e
1540
+ dte_e = np.zeros(mesh.n_elem)
1541
+ r_e = self.r
1542
+ if self.expands:
1543
+ dx += self.E0 * dp * g * eps * q_e + sw * dp * g * self_e
1544
+ dte_g += self.E0 * pth * dg * eps * q_e + sw * self.E0 ** 2 * pth * cm * dg * eps * eps
1545
+ dte_e += self.E0 * pth * g * self.alpha * q_e + sw * self.E0 ** 2 * pth * cm * 2 * g * eps * self.alpha
1546
+ if self.swing_weight > 0 and self.expands and self.r is not None:
1547
+ # Thermal cycling: the swing from the cold moment to the hot one
1548
+ # as an expansion problem of its own, eps_s = alpha (T_hot -
1549
+ # T_cold) at the hot moduli with no mechanical load, measured by
1550
+ # a p-norm of its von Mises stress over strength retention (what
1551
+ # the fatigue check reads, durability.py). Twice its strain energy
1552
+ # was tried first and made parts worse: the energy of a thermal
1553
+ # load falls as material near the heat goes, which concentrates
1554
+ # the stress fatigue cares about (motor mount, 12k cells: fatigue
1555
+ # SF 2.48 -> 1.29 at weight 0.1). Weighted so that at the first
1556
+ # iteration it is swing_weight times the hot objective.
1557
+ j_s, dx_s, dte_gs, dte_s = self._swing_pnorm(fe, xe, state, pth, dp, estiff, dE, g, dg)
1558
+ if "swing_scale" not in state:
1559
+ state["swing_scale"] = self.swing_weight * abs(j) / max(abs(j_s), 1e-300)
1560
+ k_s = state["swing_scale"]
1561
+ state["swing"] = j_s
1562
+ j += k_s * j_s
1563
+ dx += k_s * dx_s
1564
+ dte_g += k_s * dte_gs
1565
+ dte_s = k_s * dte_s
1566
+ elif self.swing_limit is not None and self.expands and self.r is not None:
1567
+ # Thermal cycling as a constraint (simp.optimize runs MMA): the
1568
+ # swing's p-norm and its full gradient, temperature path
1569
+ # included through an adjoint of its own, kept apart from the
1570
+ # objective
1571
+ j_s, dx_s, dte_gs, dte_s1 = self._swing_pnorm(fe, xe, state, pth, dp, estiff, dE, g, dg)
1572
+ if not self.bc.fixed.all():
1573
+ def nodes1(v):
1574
+ return np.bincount(mesh.enode.ravel(), weights=np.repeat(v / 8.0, 8), minlength=mesh.n_nodes)
1575
+ rhs_s = nodes1(dte_gs) * self.r + nodes1(dte_s1) * (self.r - self.r_cold)
1576
+ state["lam_swing"] = self.th.adjoint(rhs_s, state.get("lam_swing"))
1577
+ dx_s = dx_s + self.th.gradient(xe, state["lam_swing"])
1578
+ state["swing"] = j_s
1579
+ state["swing_grad"] = dx_s
1580
+ dte_s = None
1581
+ else:
1582
+ dte_s = None
1583
+ # through temperature: a node holds 1/8 of each of its cells' T, and
1584
+ # a duty cycle's hot moment is r * the steady rise (its cold one
1585
+ # r_cold * it)
1586
+ if (np.any(dte_g) or np.any(dte_e) or (dte_s is not None and np.any(dte_s))) and not self.bc.fixed.all():
1587
+ def nodes(v):
1588
+ return np.bincount(mesh.enode.ravel(), weights=np.repeat(v / 8.0, 8), minlength=mesh.n_nodes)
1589
+ rhs_t = nodes(dte_g + dte_e)
1590
+ if r_e is not None:
1591
+ rhs_t = rhs_t * r_e
1592
+ if dte_s is not None:
1593
+ rhs_t = rhs_t + nodes(dte_s) * (self.r - self.r_cold)
1594
+ self.th.lam = self.th.adjoint(rhs_t, self.th.lam)
1595
+ dx = dx + self.th.gradient(xe, self.th.lam)
1596
+ return j, case_j, dx, dirs
1597
+
1598
+ SWING_P = 8.0 # p of the swing's stress p-norm
1599
+ SWING_ALLOWANCE = 1.25 # verified / optimizer swing peak, for the constraint's limit
1600
+ SWING_POINTS = "corners" # where the swing's stress is sampled: "corners" or "center"
1601
+ SWING_Q = 0.5 # grey cells' stress relaxation, x^q (Bruggi's qp)
1602
+
1603
+ def _swing_pnorm(self, fe, xe, state, pth, dp, estiff, dE, g, dg):
1604
+ """(P, dP/dx, dP/dT through g per cell, dP/deps_s-weighted per cell for
1605
+ the swing's nodal temperature) of the p-norm of the thermal swing's
1606
+ cell-centre von Mises stress over retention,
1607
+ s_e = x^q C0 (B u_e - E0 eps_e m), K u = f_s (fe's units), with its
1608
+ adjoint K mu = dP/du."""
1609
+ from .directions import _VM
1610
+ from .fem import elastic_c, hex8_b_center
1611
+
1612
+ mesh = self.mesh
1613
+ p, q = self.SWING_P, self.SWING_Q
1614
+ eps = self.alpha * ((self.r - self.r_cold) * self.theta_s)[mesh.enode].mean(axis=1)
1615
+ w = pth * g * eps * self.E0
1616
+ f = np.bincount(mesh.edof.ravel(), weights=(w[:, None] * self.vth[None, :]).ravel(), minlength=mesh.n_dof)
1617
+ f[self.fixed] = 0.0
1618
+ u = fe.resolve([f], state.get("u_swing"))[0]
1619
+ state["u_swing"] = [u]
1620
+ c0 = elastic_c(1.0, self.nu)
1621
+ m = np.array([1.0, 1, 1, 0, 0, 0])
1622
+ cm = c0 @ m
1623
+ eta = np.maximum(xe, 1e-9) ** q
1624
+ keep = (~self.near) if self.near is not None else np.ones(mesh.n_elem, bool)
1625
+ # sample points: the cell centre, or its 8 corners (SWING_POINTS):
1626
+ # a member's bending puts its peak at the surface, which the centre
1627
+ # of a cell spanning it reads low
1628
+ if self.SWING_POINTS == "corners":
1629
+ # (hex8_b: with the incompatible-mode element part of the
1630
+ # bending strain lives in its condensed bubbles, which the plain
1631
+ # trilinear B misses at the corners)
1632
+ from .fem import hex8_b
1633
+ pts = [(a, b_, c) for a in (-1.0, 1.0) for b_ in (-1.0, 1.0) for c in (-1.0, 1.0)]
1634
+ cbs = [c0 @ (hex8_b(*pt, self.nu) / mesh.pitch) for pt in pts]
1635
+ else:
1636
+ cbs = [c0 @ (hex8_b_center() / mesh.pitch)]
1637
+ ue = u[mesh.edof]
1638
+ sigs, vss, vms = [], [], []
1639
+ for cb in cbs:
1640
+ sig = ue @ cb.T - (self.E0 * eps)[:, None] * cm[None, :]
1641
+ sk = eta[:, None] * sig * keep[:, None]
1642
+ vs = sk @ _VM
1643
+ sigs.append(sig)
1644
+ vss.append(vs)
1645
+ vms.append(np.sqrt(np.maximum(np.einsum("ij,ij->i", sk, vs), 1e-300)))
1646
+ big = max(float(v.max()) for v in vms)
1647
+ state["swing_max"] = big
1648
+ sp = float(sum(np.sum((v / big) ** p) for v in vms)) # scaled against overflow
1649
+ P = big * sp ** (1.0 / p)
1650
+ gu = np.zeros(mesh.n_dof)
1651
+ dxq = np.zeros(mesh.n_elem)
1652
+ dsc = np.zeros(mesh.n_elem)
1653
+ for cb, sig, vs, vm in zip(cbs, sigs, vss, vms):
1654
+ dpv = sp ** (1.0 / p - 1.0) * (vm / big) ** (p - 1.0) # dP/dvm
1655
+ ds = (dpv / vm)[:, None] * vs # dP/ds
1656
+ dsig = eta[:, None] * ds # dP/dsig
1657
+ gu += np.bincount(mesh.edof.ravel(), weights=(dsig @ cb).ravel(), minlength=mesh.n_dof)
1658
+ dxq += np.einsum("ij,ij->i", ds, sig)
1659
+ dsc += dsig @ cm
1660
+ gu[self.fixed] = 0.0
1661
+ mu = fe.resolve([gu], state.get("mu_swing"))[0]
1662
+ state["mu_swing"] = [mu]
1663
+ mk = 0.25 * (fe.strain_energy(mu + u) - fe.strain_energy(mu - u)) # mu_e^T K0 u_e
1664
+ mv = mu[mesh.edof] @ self.vth # mu_e . vth
1665
+ # P(x, u(x)): explicit + mu^T (df/dx - dK/dx u)
1666
+ dx = q * np.maximum(xe, 1e-9) ** (q - 1) * dxq \
1667
+ + self.E0 * dp * g * eps * mv - dE * g * mk
1668
+ # through the swing strain: explicit, and mu . df/deps
1669
+ de = -self.E0 * dsc + self.E0 * pth * g * mv
1670
+ # through the hot moment's retention: K and f_s both carry g
1671
+ dgv = dg * (self.E0 * pth * eps * mv - estiff * mk)
1672
+ return P, dx, dgv, de * self.alpha
1673
+
1674
+
1675
+ def _card(get, material, what):
1676
+ try:
1677
+ return get(material)
1678
+ except (KeyError, ValueError):
1679
+ raise ValueError(f"thermal: no {what} for {getattr(material, 'name', material)!r}; give it in the "
1680
+ "thermal block (conductivity, expansion) or set soften false") from None
1681
+
1682
+
1683
+ def study_heat(study, mesh: HexMesh, nu, fixed, modulus: float | None = None, near=None):
1684
+ """StudyHeat for a study with a thermal block, else None. `near`: cells
1685
+ by supports and loads, left out of the swing's stress (StudyHeat.near)."""
1686
+ if getattr(study, "thermal", None) is None:
1687
+ return None
1688
+ heat = StudyHeat(study, mesh, nu, fixed, modulus)
1689
+ if near is not None:
1690
+ heat.near = np.asarray(near, bool)
1691
+ return heat