Skip to content

Commit 8b41247

Browse files
authored
Solenoid stress calcs (#21)
## 2.5.0 2025-08-05 ### Added * Add `solenoid_stress` module * 1D finite-difference solver for solenoid stress given arbitrary B-field, current density, and surface load * Generalizes over both long-solenoid stress and ideal thick-walled pressure vessel stress * Tested against handcalcs for both ideal solenoid & thick-walled pressure vessel * 1D handcalc for solenoid stress under uniform current density and linear B-field profile * 1D handcalc for thick-walled pressure vessel
1 parent 5716fde commit 8b41247

18 files changed

+1026
-10
lines changed

.github/workflows/test_python.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ name: test_python
55

66
on:
77
# push: [] # Save time on actions
8-
pull_request: []
8+
pull_request:
9+
branches: [ "*" ]
10+
push:
11+
branches: [ "main", "develop", "release" ]
912
workflow_dispatch:
1013
tag: "Manual Run"
1114
workflow_call:

CHANGELOG_PYTHON.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## 2.5.0 2025-08-05
4+
5+
### Added
6+
7+
* Add `solenoid_stress` module
8+
* 1D finite-difference solver for solenoid stress given arbitrary B-field, current density, and surface load
9+
* Generalizes over both long-solenoid stress and ideal thick-walled pressure vessel stress
10+
* Tested against handcalcs for both ideal solenoid & thick-walled pressure vessel
11+
* 1D handcalc for solenoid stress under uniform current density and linear B-field profile
12+
* 1D handcalc for thick-walled pressure vessel
13+
314
## 2.4.3 2025-07-07
415

516
### Changed

CHANGELOG_RUST.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Changelog
22

3-
## 2.4.3 2025-07-07
3+
## 2.4.3 2025-07-07, 2.5.0 2025-08-05
44

55
### Changed
66

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cfsem"
3-
version = "2.4.3"
3+
version = "2.5.0"
44
edition = "2024"
55
authors = ["Commonwealth Fusion Systems <jlogan@cfs.energy>"]
66
license = "MIT"

cfsem/solenoid_stress/__init__.py

Whitespace-only changes.
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
"""
2+
Finite-difference solution to structural PDE for pancake or
3+
infinite-length solenoid coil, following Iwasa 2e section 3.6.
4+
5+
Assumes
6+
* Uniform current density within each r-section
7+
* Zero R-Z shear (deck-of-cards structure)
8+
* Zero Z-force,stress,strain (no axial compression accounted here; can be evaluated separately;
9+
usually relatively small)
10+
* Isotropic material
11+
* This method could be extended to handle orthotropic material, with some effort
12+
* Single material region
13+
* This method could be extended to handle multiple regions of isotropic materials
14+
15+
Supports
16+
* Non-uniform r-grid
17+
* Arbitrary current density and B-field defined on r-grid
18+
* Nonzero pressure on inner/outer wall (fluid pressure, bucking load, etc)
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from dataclasses import dataclass
24+
from functools import cached_property
25+
from logging import getLogger
26+
from pathlib import Path
27+
from typing import Callable, Literal
28+
29+
import findiff
30+
import numpy as np
31+
from numpy.typing import NDArray
32+
from pydantic import ConfigDict
33+
from pydantic_numpy.model import NumpyModel
34+
from pydantic_numpy.typing import NpNDArray # Array of any type or dimensionality
35+
from scipy import io, sparse
36+
from scipy.sparse import csc_matrix as CSC
37+
from scipy.sparse import csr_matrix as CSR
38+
from scipy.sparse.linalg import factorized
39+
40+
41+
def solenoid_1d_structural_factor(elasticity_modulus: float, poisson_ratio: float) -> float:
42+
"""Structural factor applied to RHS of solenoid stress solve"""
43+
c = (1.0 - poisson_ratio**2) / elasticity_modulus # [m/N]
44+
return c
45+
46+
47+
def solenoid_1d_structural_rhs(
48+
c: float,
49+
j: NDArray | list[float],
50+
bz: NDArray | list[float],
51+
pi: float = 0.0,
52+
po: float = 0.0,
53+
) -> NDArray:
54+
"""
55+
Right-hand-side for solenoid stress solve,
56+
including zero values at the BCs.
57+
58+
From Iwasa 2e eqn. 3.64a
59+
60+
Recommend padding the grid with a dummy value at either end
61+
to make room for the BCs without losing accounting of nonzero
62+
current density at the inner/outer radius.
63+
64+
Padding for BCs can be done like:
65+
`rgrid = np.array([r0 - 1e-6] + rgrid.tolist() + [r1 + 1e-6])`
66+
67+
Padding region is ultimately treated as structural material,
68+
so the padded region should be small to avoid introducing error,
69+
but not so small that it causes numerical error in the finite difference scheme.
70+
71+
Args:
72+
c: [m/N] scalar structural factor; see `solenoid_1d_structural_factor()`
73+
j: [A/m^2] with shape (n x 1), current density at each point in the r-grid
74+
bz: [T] with shape (n x 1), Z-axis B-field at each point in the r-grid
75+
pi: [Pa] scalar pressure on inner wall, defined in +r direction
76+
po: [Pa] scalar pressure on outer wall, defined in -r direction
77+
78+
Returns:
79+
-c * j * bz, [1/m^2] with shape (n x 1), the right-hand side of the solenoid stress PDE
80+
"""
81+
# Guarantee arrays
82+
j = np.array(j)
83+
bz = np.array(bz)
84+
85+
# RHS without BCs
86+
rhs = -c * j * bz
87+
88+
# BC for r-stress at inner and outer radius
89+
# is the fluid or mechanical pressure, which is usually going to be set to zero
90+
# to represent an unsupported system, but could be set to a nonzero value
91+
# to represent a surface load.
92+
rhs[0] = -pi # Sign convention: compression is negative stress
93+
rhs[-1] = -po
94+
95+
return rhs
96+
97+
98+
class SolenoidStress1D(NumpyModel):
99+
model_config = ConfigDict(validate_assignment=True, frozen=True, extra="forbid")
100+
101+
rgrid: NpNDArray
102+
"""[m] 1D grid of r-coordinates"""
103+
elasticity_modulus: float
104+
"""[Pa] diagonal terms in material property matrix"""
105+
poisson_ratio: float
106+
"""[dimensionless] factor determining off-diagonal terms in material property matrix"""
107+
order: Literal[2, 4] = 4
108+
"""Finite-difference stencil polynomial order.
109+
Higher order operators produce excessive numerical error under typical use."""
110+
direct_inverse: bool = False
111+
"""Whether to generate fully-dense direct inverse of the system, which
112+
can be useful as a linear operator. Alternatively, the system can be solved
113+
using an LU solver with reduced memory usage and better numerical conditioning."""
114+
115+
@cached_property
116+
def operators(self) -> SolenoidStress1DOperators:
117+
"""
118+
Linear operators for solving stress and strain in a pancake coil
119+
following Iwasa 2e section 3.6.
120+
"""
121+
return solenoid_1d_structural_operators(
122+
np.array(self.rgrid), self.elasticity_modulus, self.poisson_ratio, self.order, self.direct_inverse
123+
)
124+
125+
@cached_property
126+
def displacement_solver(self) -> Callable[[NDArray], NDArray]:
127+
"""LU solver for load-displacement relation (A_ub)
128+
as an alternative to taking a direct inverse of A_bu"""
129+
return factorized(self.operators.a_bu)
130+
131+
132+
@dataclass(frozen=True)
133+
class SolenoidStress1DOperators:
134+
"""
135+
Linear operators for solving stress and strain in a pancake coil
136+
following Iwasa 2e section 3.6.
137+
138+
A_bu, (n x n) sparse operator mapping displacement to the RHS like A @ u_r = -c * j * bz
139+
A_ub, (n x n) fully-dense direct inverse of A_bu mapping RHS to displacement
140+
A_eu (2n x n), A_eu_radial (n x n), A_eu_hoop (n x n), sparse operators mapping displacement to strain
141+
* First entry is combined operator producing both strain components
142+
* Second and third entries are split operators, which are equivalent because they are fully decoupled
143+
A_se (2n x 2n), sparse operator mapping strain to stress
144+
"""
145+
146+
a_bu: CSC
147+
"""(n x n) sparse operator mapping displacement to the RHS like A @ u_r = -c * j * bz"""
148+
a_ub: NDArray | None
149+
"""(n x n) fully-dense direct inverse of A_bu mapping RHS to displacement.
150+
Only generated if `direct_inverse` flag is set."""
151+
a_eu: CSR
152+
"""(2n x n), sparse operator mapping displacement to strain; contains both radial and hoop components"""
153+
a_eu_radial: CSR
154+
"""(n x n), sparse operators mapping displacement to strain; radial component only"""
155+
a_eu_hoop: CSR
156+
"""(n x n), sparse operators mapping displacement to strain; hoop component only"""
157+
a_se: CSR
158+
"""(2n x 2n), sparse operator mapping strain to stress"""
159+
160+
def write_mat(self, dst: str | Path) -> str:
161+
"""Write the collection of operators in .mat format.
162+
163+
Args:
164+
dst: Target directory to place the file named "stress_operators.mat"
165+
166+
Raises:
167+
IOError: If the directory does not exist
168+
"""
169+
# Check directory
170+
dst = Path(dst).absolute()
171+
fpath = dst / "stress_operators.mat"
172+
getLogger("cfsem").info(f"Saving stress operator data to {fpath}")
173+
174+
to_save = {
175+
"A_bu": self.a_bu,
176+
"A_ub": self.a_ub,
177+
"A_eu": self.a_eu,
178+
"A_eu_radial": self.a_eu_radial,
179+
"A_eu_hoop": self.a_eu_hoop,
180+
"A_se": self.a_se,
181+
}
182+
183+
if self.a_ub is None: # savemat fails on None value
184+
to_save.pop("A_ub")
185+
186+
# Note this will implicitly convert all CSR matrices to CSC, which is .mat's preferred I/O
187+
io.savemat(fpath, to_save)
188+
189+
return f"{fpath}"
190+
191+
192+
def solenoid_1d_structural_operators(
193+
rgrid: NDArray | list,
194+
elasticity_modulus: float,
195+
poisson_ratio: float,
196+
order: Literal[2, 4],
197+
direct_inverse: bool = False,
198+
) -> SolenoidStress1DOperators:
199+
"""
200+
Linear operators for solving stress and strain in a pancake coil
201+
following Iwasa 2e section 3.6.
202+
203+
Assumes
204+
* Uniform current density within each r-section
205+
* Zero R-Z shear (deck-of-cards structure)
206+
* Zero Z-force,stress,strain (no axial compression accounted here; can be evaluated separately;
207+
usually relatively small)
208+
* Isotropic material
209+
* This method could be extended to handle orthotropic material, with some effort
210+
* Single material region
211+
* This method could be extended to handle multiple regions of isotropic materials
212+
213+
Supports
214+
* Non-uniform r-grid
215+
* Arbitrary current density and B-field defined on r-grid
216+
217+
Args:
218+
rgrid: [m] with shape (n x 1), Grid of r-coordinates. Must be sorted ascending.
219+
elasticity_modulus: [N/m] Material property; Young's modulus
220+
poisson_ratio: [dimensionless] Material property; off-axis stress coupling term
221+
order: Finite-difference stencil polynomial order
222+
direct_inverse: Whether to generate fully-dense direct inverse of the system, which
223+
can be useful as a linear operator.
224+
Alternatively, the system can be solved using an LU solver with
225+
reduced memory usage and better numerical conditioning.
226+
227+
Returns:
228+
object containing linear operators
229+
"""
230+
# Guarantee arrays
231+
rgrid = np.array(rgrid)
232+
nr = len(rgrid)
233+
234+
#
235+
# Stencils / differential operators
236+
#
237+
238+
# Note these will include the 1/dr and 1/dr^2 scalings,
239+
# not just the normalized stencil
240+
ddr = findiff.Diff(0, rgrid, acc=order)
241+
d2dr2 = ddr**2
242+
243+
ddr = ddr.matrix((nr,))
244+
d2dr2 = d2dr2.matrix((nr,))
245+
246+
rinv = CSR(np.diag(1.0 / rgrid))
247+
rinv2 = CSR(np.diag(1.0 / rgrid**2))
248+
249+
#
250+
# Displacement-load relation
251+
#
252+
253+
# The first and last row of the u-b relation will be modified later
254+
# to include the boundary conditions
255+
#
256+
# Iwasa 2e eqn. 3.64a left hand side
257+
#
258+
# This one is converted to CSC because, while it's easiest to build it as CSR,
259+
# it is more useful for solving a system as CSC
260+
a_bu = CSC(d2dr2 + (rinv @ ddr) - rinv2) # Operator maps displacement TO rhs (-c*j*B)
261+
262+
# Stress-strain relation components
263+
# that are needed for BCs
264+
# Iwasa 2e eqns. 3.63g
265+
a_stress_strain = np.array(
266+
[
267+
[1.0 / elasticity_modulus, -poisson_ratio / elasticity_modulus],
268+
[-poisson_ratio / elasticity_modulus, 1.0 / elasticity_modulus],
269+
]
270+
)
271+
a_strain_stress = np.linalg.inv(a_stress_strain)
272+
diag_term = a_strain_stress[0, 0]
273+
off_diag_term = a_strain_stress[1, 0]
274+
275+
# We only need the first and last row of this matrix.
276+
# Actualizing the whole thing is easy but unnecessary
277+
# This is extracted by hand-calc starting from
278+
# Iwasa 2e eqns. 3.63a-d
279+
bcmat = diag_term * ddr + off_diag_term * rinv
280+
281+
# Apply BC to displacement operator
282+
a_bu[0, :] = bcmat[0, :]
283+
a_bu[-1, :] = bcmat[-1, :]
284+
285+
# Invert A_bu directly to get A_ub
286+
# This is fully dense, which may be prohibitive in some situations
287+
a_ub = (
288+
None if not direct_inverse else np.linalg.inv(a_bu.todense())
289+
) # Operator maps RHS=-c*j*bz to displacement
290+
291+
#
292+
# Strain-displacement operator(s)
293+
#
294+
# Iwasa 2e eqns. 3.63a-d
295+
a_eu_hoop = rinv # Just the hoop part
296+
a_eu_radial = ddr # Just the radial part
297+
# fmt: off
298+
a_eu = CSR(sparse.block_array(
299+
[[a_eu_radial],
300+
[a_eu_hoop]]
301+
)) # Operator for getting strain from displacement
302+
# fmt: on
303+
304+
#
305+
# Stress-strain operators
306+
#
307+
# In this case, we're manually assembling the inverse of the A_es matrix
308+
# because we know all the components from handcalc arithmetic
309+
eye = sparse.eye(nr, nr)
310+
# fmt: off
311+
a_se = CSR(sparse.block_array(
312+
[[ diag_term * eye, off_diag_term * eye],
313+
[off_diag_term * eye, diag_term * eye]]
314+
)) # Operator maps strain to stress
315+
# fmt: on
316+
317+
return SolenoidStress1DOperators(a_bu, a_ub, a_eu, a_eu_radial, a_eu_hoop, a_se)

0 commit comments

Comments
 (0)