Coverage for pySDC/implementations/sweeper_classes/boris_2nd_order.py: 100%
125 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 12:30 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 12:30 +0000
1import numpy as np
3from pySDC.core.errors import ParameterError
4from pySDC.core.sweeper import Sweeper
7class boris_2nd_order(Sweeper):
8 """
9 SDC sweeper for charged particles in electromagnetic fields, with velocity-Verlet and a Boris push as base.
11 Second-order sweeper using velocity-Verlet with Boris scheme as base integrator
13 Attributes:
14 S: node-to-node collocation matrix (first order)
15 SQ: node-to-node collocation matrix (second order)
16 ST: node-to-node trapezoidal matrix
17 Sx: node-to-node Euler half-step for position update
18 """
20 def __init__(self, params, level):
21 """
22 Initialization routine for the custom sweeper
24 Args:
25 params: parameters for the sweeper
26 level (pySDC.Level.level): the level that uses this sweeper
27 """
29 if "QI" not in params:
30 params["QI"] = "IE"
31 if "QE" not in params:
32 params["QE"] = "EE"
34 super(boris_2nd_order, self).__init__(params, level)
36 # S- and SQ-matrices (derived from Q) and Sx- and ST-matrices for the integrator
37 [
38 self.S,
39 self.ST,
40 self.SQ,
41 self.Sx,
42 self.QQ,
43 self.QI,
44 self.QT,
45 self.Qx,
46 self.Q,
47 ] = self.__get_Qd()
49 # the Boris step adds back the trapezoid dt*d/2*(f[m]+f[m+1]) with d = diag(QI), so ST must be exactly that,
50 # otherwise the sweep converges to a wrong fixed point
51 M = self.coll.num_nodes
52 d = np.diag(self.QI)[1:]
53 T = np.zeros_like(self.ST)
54 T[range(1, M + 1), range(1, M + 1)] = d / 2
55 T[range(1, M + 1), range(M)] += d / 2
56 # For a working pair ST == T in exact arithmetic. In floating point, QI and QE round the same node distances
57 # independently (at most 1, so up to eps apart, eps / 2 after halving), and forming QT = (QI + QE) / 2 and its
58 # row differences rounds twice more (eps / 2 each): |ST - T| <= 2 eps. The bar is twice that; a wrong pair is
59 # off by O(0.1).
60 if not np.allclose(self.ST[1:], T[1:], rtol=0, atol=4 * np.finfo(float).eps):
61 raise ParameterError(
62 f'boris_2nd_order converges to a wrong solution with QI={self.params.QI!r} and QE={self.params.QE!r}: '
63 'the velocity update needs QE to supply the trapezoid partner of the diagonal of QI. Use QI="IE" '
64 'with QE="EE" (the default), or QI="PIC" with QE="PIC".'
65 )
67 self.qQ = np.dot(self.coll.weights, self.coll.Qmat[1:, 1:])
69 def __get_Qd(self):
70 """
71 Get integration matrices for 2nd-order SDC
73 Returns:
74 S: node-to-node collocation matrix (first order)
75 SQ: node-to-node collocation matrix (second order)
76 ST: node-to-node trapezoidal matrix
77 Sx: node-to-node Euler half-step for position update
78 """
80 # set implicit and explicit Euler matrices (default, but can be changed)
81 QI = self.get_Qdelta_implicit(qd_type=self.params.QI)
82 QE = self.get_Qdelta_explicit(qd_type=self.params.QE)
84 # trapezoidal rule
85 QT = 1 / 2 * (QI + QE)
87 # Qx as in the paper
88 Qx = np.dot(QE, QT) + 1 / 2 * QE * QE
90 Sx = np.zeros(np.shape(self.coll.Qmat))
91 ST = np.zeros(np.shape(self.coll.Qmat))
92 S = np.zeros(np.shape(self.coll.Qmat))
94 # fill-in node-to-node matrices
95 Sx[0, :] = Qx[0, :]
96 ST[0, :] = QT[0, :]
97 S[0, :] = self.coll.Qmat[0, :]
98 for m in range(self.coll.num_nodes):
99 Sx[m + 1, :] = Qx[m + 1, :] - Qx[m, :]
100 ST[m + 1, :] = QT[m + 1, :] - QT[m, :]
101 S[m + 1, :] = self.coll.Qmat[m + 1, :] - self.coll.Qmat[m, :]
102 # SQ via dot-product, could also be done via QQ
103 SQ = np.dot(S, self.coll.Qmat)
105 # QQ-matrix via product of Q
106 QQ = np.dot(self.coll.Qmat, self.coll.Qmat)
108 return [S, ST, SQ, Sx, QQ, QI, QT, Qx, self.coll.Qmat]
110 def update_nodes(self):
111 """
112 Update the u- and f-values at the collocation nodes -> corresponds to a single sweep over all nodes
114 Returns:
115 None
116 """
118 # get current level and problem description
119 L = self.level
120 P = L.prob
122 # only if the level has been touched before
123 assert L.status.unlocked
125 # get number of collocation nodes for easier access
126 M = self.coll.num_nodes
128 # initialize integral terms with zeros, will add stuff later
129 integral = [P.dtype_u(P.init, val=0.0) for l in range(M)]
131 # gather all terms which are known already (e.g. from the previous iteration)
132 # this corresponds to SF(u^k) - SdF(u^k) + tau (note: have integrals in pos and vel!)
133 for m in range(M):
134 for j in range(M + 1):
135 # build RHS from f-terms (containing the E field) and the B field
136 f = P.build_f(L.f[j], L.u[j], L.time + L.dt * self.coll.nodes[j - 1])
137 # add SQF(u^k) - SxF(u^k) for the position
138 integral[m].pos += L.dt * (L.dt * (self.SQ[m + 1, j] - self.Sx[m + 1, j]) * f)
139 # add SF(u^k) - STF(u^k) for the velocity
140 integral[m].vel += L.dt * (self.S[m + 1, j] - self.ST[m + 1, j]) * f
141 # add tau if associated
142 if L.tau[m] is not None:
143 integral[m] += L.tau[m]
144 # tau is 0-to-node, need to change it to node-to-node here
145 if m > 0:
146 integral[m] -= L.tau[m - 1]
148 # do the sweep
149 for m in range(0, M):
150 # build rhs, consisting of the known values from above and new values from previous nodes (at k+1)
151 tmp = P.dtype_u(integral[m])
152 for j in range(m + 1):
153 # build RHS from f-terms (containing the E field) and the B field
154 f = P.build_f(L.f[j], L.u[j], L.time + L.dt * self.coll.nodes[j - 1])
155 # add SxF(u^{k+1})
156 tmp.pos += L.dt * (L.dt * self.Sx[m + 1, j] * f)
157 # add pos at previous node + dt*v0
158 tmp.pos += L.u[m].pos + L.dt * self.coll.delta_m[m] * L.u[0].vel
159 # set new position, is explicit
160 L.u[m + 1].pos = tmp.pos
162 # get E field with new positions and compute mean
163 L.f[m + 1] = P.eval_f(L.u[m + 1], L.time + L.dt * self.coll.nodes[m])
165 ck = tmp.vel
167 # do the boris scheme
168 L.u[m + 1].vel = P.boris_solver(ck, L.dt * np.diag(self.QI)[m + 1], L.f[m], L.f[m + 1], L.u[m])
170 # indicate presence of new values at this level
171 L.status.updated = True
173 return None
175 def integrate(self):
176 """
177 Integrates the right-hand side
179 Returns:
180 list of dtype_u: containing the integral as values
181 """
183 # get current level and problem description
184 L = self.level
185 P = L.prob
187 # create new instance of dtype_u, initialize values with 0
188 p = []
190 for m in range(1, self.coll.num_nodes + 1):
191 p.append(P.dtype_u(P.init, val=0.0))
193 # integrate RHS over all collocation nodes, RHS is here only f(x,v)!
194 for j in range(1, self.coll.num_nodes + 1):
195 f = P.build_f(L.f[j], L.u[j], L.time + L.dt * self.coll.nodes[j - 1])
196 p[-1].pos += L.dt * (L.dt * self.QQ[m, j] * f) + L.dt * self.coll.Qmat[m, j] * L.u[0].vel
197 p[-1].vel += L.dt * self.coll.Qmat[m, j] * f
199 return p
201 def compute_end_point(self):
202 """
203 Compute u at the right point of the interval
205 The value uend computed here is a full evaluation of the Picard formulation (always!)
207 Returns:
208 None
209 """
211 # get current level and problem description
212 L = self.level
213 P = L.prob
215 # start with u0 and add integral over the full interval (using coll.weights)
216 L.uend = P.dtype_u(L.u[0])
217 for m in range(self.coll.num_nodes):
218 f = P.build_f(L.f[m + 1], L.u[m + 1], L.time + L.dt * self.coll.nodes[m])
219 L.uend.pos += L.dt * (L.dt * self.qQ[m] * f) + L.dt * self.coll.weights[m] * L.u[0].vel
220 L.uend.vel += L.dt * self.coll.weights[m] * f
221 # add up tau correction of the full interval (last entry)
222 if L.tau[-1] is not None:
223 L.uend += L.tau[-1]
225 return None
227 def get_sweeper_mats(self):
228 """
229 Returns the matrices Q, QQ, Qx, QT which define the sweeper.
230 """
232 Q = self.Q[1:, 1:]
233 QQ = self.QQ[1:, 1:]
234 Qx = self.Qx[1:, 1:]
235 QT = self.QT[1:, 1:]
237 return Q, QQ, Qx, QT
239 def get_scalar_problems_sweeper_mats(self, lambdas=None):
240 """
241 This function returns the corresponding matrices of an SDC sweep matrix formulation
243 Args:
244 lambdas (numpy.narray): the first entry in lambdas is k-spring constant and the second is mu friction.
245 """
247 Q, QQ, Qx, QT = self.get_sweeper_mats()
249 if lambdas is None:
250 pass
251 # should use lambdas from attached problem and make sure it is scalar SDC
252 raise NotImplementedError("At the moment, the values for the lambda have to be provided")
253 else:
254 k = lambdas[0]
255 mu = lambdas[1]
257 nnodes = self.coll.num_nodes
258 dt = self.level.dt
260 F = np.block(
261 [
262 [-k * np.eye(nnodes), -mu * np.eye(nnodes)],
263 [-k * np.eye(nnodes), -mu * np.eye(nnodes)],
264 ]
265 )
267 C_coll = np.block([[np.eye(nnodes), dt * Q], [np.zeros([nnodes, nnodes]), np.eye(nnodes)]])
268 Q_coll = np.block(
269 [
270 [dt**2 * QQ, np.zeros([nnodes, nnodes])],
271 [np.zeros([nnodes, nnodes]), dt * Q],
272 ]
273 )
274 Q_vv = np.block(
275 [
276 [dt**2 * Qx, np.zeros([nnodes, nnodes])],
277 [np.zeros([nnodes, nnodes]), dt * QT],
278 ]
279 )
280 M_vv = np.eye(2 * nnodes) - np.dot(Q_vv, F)
282 return C_coll, Q_coll, Q_vv, M_vv, F
284 def get_scalar_problems_manysweep_mats(self, nsweeps, lambdas=None):
285 """
286 For a scalar problem, K sweeps of SDC can be written in matrix form.
288 Args:
289 nsweeps (int): number of sweeps
290 lambdas (numpy.ndarray): the first entry in lambdas is k-spring constant and the second is mu friction.
291 """
292 nnodes = self.coll.num_nodes
294 C_coll, Q_coll, Q_vv, M_vv, F = self.get_scalar_problems_sweeper_mats(lambdas=lambdas)
296 K_sdc = np.dot(np.linalg.inv(M_vv), Q_coll - Q_vv) @ F
298 Keig, Kvec = np.linalg.eig(K_sdc)
300 Kp_sdc = np.linalg.matrix_power(K_sdc, nsweeps)
301 Kinv_sdc = np.linalg.inv(np.eye(2 * nnodes) - K_sdc)
303 Kdot_sdc = np.dot(np.eye(2 * nnodes) - Kp_sdc, Kinv_sdc)
304 MC = np.dot(np.linalg.inv(M_vv), C_coll)
306 Mat_sweep = Kp_sdc + np.dot(Kdot_sdc, MC)
308 return Mat_sweep, np.max(np.abs(Keig))
310 def get_scalar_problems_picardsweep_mats(self, nsweeps, lambdas=None):
311 """
312 For a scalar problem, K sweeps of SDC can be written in matrix form.
314 Args:
315 nsweeps (int): number of sweeps
316 lambdas (numpy.ndarray): the first entry in lambdas is k-spring constant and the second is mu friction.
317 """
318 nnodes = self.coll.num_nodes
320 C_coll, Q_coll, Q_vv, M_vv, F = self.get_scalar_problems_sweeper_mats(lambdas=lambdas)
322 K_sdc = np.dot(Q_coll, F)
324 Keig, Kvec = np.linalg.eig(K_sdc)
326 Kp_sdc = np.linalg.matrix_power(K_sdc, nsweeps)
327 Kinv_sdc = np.linalg.inv(np.eye(2 * nnodes) - K_sdc)
329 Kdot_sdc = np.dot(np.eye(2 * nnodes) - Kp_sdc, Kinv_sdc)
331 Mat_sweep = Kp_sdc + np.dot(Kdot_sdc, C_coll)
333 return Mat_sweep, np.max(np.abs(Keig))