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