View Javadoc
1   // ******************************************************************************
2   //
3   // Title:       Force Field X.
4   // Description: Force Field X - Software for Molecular Biophysics.
5   // Copyright:   Copyright (c) Michael J. Schnieders 2001-2025.
6   //
7   // This file is part of Force Field X.
8   //
9   // Force Field X is free software; you can redistribute it and/or modify it
10  // under the terms of the GNU General Public License version 3 as published by
11  // the Free Software Foundation.
12  //
13  // Force Field X is distributed in the hope that it will be useful, but WITHOUT
14  // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
15  // FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
16  // details.
17  //
18  // You should have received a copy of the GNU General Public License along with
19  // Force Field X; if not, write to the Free Software Foundation, Inc., 59 Temple
20  // Place, Suite 330, Boston, MA 02111-1307 USA
21  //
22  // Linking this library statically or dynamically with other modules is making a
23  // combined work based on this library. Thus, the terms and conditions of the
24  // GNU General Public License cover the whole combination.
25  //
26  // As a special exception, the copyright holders of this library give you
27  // permission to link this library with independent modules to produce an
28  // executable, regardless of the license terms of these independent modules, and
29  // to copy and distribute the resulting executable under terms of your choice,
30  // provided that you also meet, for each linked independent module, the terms
31  // and conditions of the license of that module. An independent module is a
32  // module which is not derived from or based on this library. If you modify this
33  // library, you may extend this exception to your version of the library, but
34  // you are not obligated to do so. If you do not wish to do so, delete this
35  // exception statement from your version.
36  //
37  // ******************************************************************************
38  package ffx.numerics.multipole;
39  
40  import ffx.utilities.FFXTest;
41  import org.junit.Test;
42  import org.junit.runner.RunWith;
43  import org.junit.runners.Parameterized;
44  import org.junit.runners.Parameterized.Parameters;
45  
46  import java.util.Arrays;
47  import java.util.Collection;
48  import java.util.logging.Logger;
49  
50  import static ffx.numerics.math.DoubleMath.length;
51  import static ffx.numerics.multipole.MultipoleTensorTest.Qi;
52  import static ffx.numerics.multipole.MultipoleTensorTest.Qk;
53  import static ffx.numerics.multipole.MultipoleTensorTest.Ui;
54  import static ffx.numerics.multipole.MultipoleTensorTest.UiEwald;
55  import static ffx.numerics.multipole.MultipoleTensorTest.Uk;
56  import static ffx.numerics.multipole.MultipoleTensorTest.UkEwald;
57  import static ffx.numerics.multipole.MultipoleTensorTest.permTorqueI;
58  import static ffx.numerics.multipole.MultipoleTensorTest.permTorqueIEwald;
59  import static ffx.numerics.multipole.MultipoleTensorTest.permTorqueK;
60  import static ffx.numerics.multipole.MultipoleTensorTest.permTorqueKEwald;
61  import static ffx.numerics.multipole.MultipoleTensorTest.permanentEnergy;
62  import static ffx.numerics.multipole.MultipoleTensorTest.permanentEnergyEwald;
63  import static ffx.numerics.multipole.MultipoleTensorTest.polarGradICoulomb;
64  import static ffx.numerics.multipole.MultipoleTensorTest.polarGradIEwald;
65  import static ffx.numerics.multipole.MultipoleTensorTest.polarGradIThole;
66  import static ffx.numerics.multipole.MultipoleTensorTest.polarTorqueICoulomb;
67  import static ffx.numerics.multipole.MultipoleTensorTest.polarTorqueIEwald;
68  import static ffx.numerics.multipole.MultipoleTensorTest.polarTorqueIThole;
69  import static ffx.numerics.multipole.MultipoleTensorTest.polarTorqueKCoulomb;
70  import static ffx.numerics.multipole.MultipoleTensorTest.polarTorqueKEwald;
71  import static ffx.numerics.multipole.MultipoleTensorTest.polarTorqueKThole;
72  import static ffx.numerics.multipole.MultipoleTensorTest.polarizationEnergyCoulomb;
73  import static ffx.numerics.multipole.MultipoleTensorTest.polarizationEnergyEwald;
74  import static ffx.numerics.multipole.MultipoleTensorTest.polarizationEnergyThole;
75  import static ffx.numerics.multipole.MultipoleTensorTest.scaleMutual;
76  import static ffx.numerics.multipole.MultipoleTensorTest.xyz;
77  import static java.lang.String.format;
78  import static java.util.Arrays.fill;
79  import static org.junit.Assert.assertEquals;
80  
81  /**
82   * Parameterized Test of the MultipoleTensor class.
83   *
84   * @author Michael J. Schnieders
85   * @since 1.0
86   */
87  @RunWith(Parameterized.class)
88  public class MultipoleTensorQITest extends FFXTest {
89  
90    /**
91     * Logger for the MultipoleTensor class.
92     */
93    private static final Logger logger = Logger.getLogger(MultipoleTensorQITest.class.getName());
94  
95    private final double tolerance = 1.0e-13;
96    private final double fdTolerance = 1.0e-6;
97    private final double[] r = new double[3];
98    private final double[] tensor;
99    private final double[] noStorageTensor;
100   private final double[] fastTensor;
101   private final int order;
102   private final int tensorCount;
103   private final String info;
104   private final Operator operator;
105   private final double beta = 0.545;
106   private final double thole = 0.39;
107   private final double AiAk = 1.061104559485911;
108 
109   public MultipoleTensorQITest(
110       String info,
111       int order,
112       Operator operator) {
113     this.info = info;
114     this.order = order;
115     r[0] = xyz[0];
116     r[1] = xyz[1];
117     r[2] = xyz[2];
118     this.tensorCount = MultipoleUtilities.tensorCount(order);
119     tensor = new double[tensorCount];
120     noStorageTensor = new double[tensorCount];
121     fastTensor = new double[tensorCount];
122     this.operator = operator;
123   }
124 
125   @Parameters
126   public static Collection<Object[]> data() {
127     return Arrays.asList(
128         new Object[][]{
129             {
130                 "Order 5 Coulomb",
131                 5,      // Order
132                 Operator.COULOMB
133             },
134             {
135                 "Order 5 Screened Coulomb",
136                 5,
137                 Operator.SCREENED_COULOMB
138             },
139             {
140                 "Order 4 Thole Field",
141                 4,
142                 Operator.THOLE_FIELD
143             }
144         });
145   }
146 
147   @Test
148   public void codeGenerationTest() {
149     // Only run code generation for the Coulomb operator.
150     if (operator != Operator.COULOMB) {
151       return;
152     }
153 
154     double[] Qi = {0.11, 0.22, 0.33, 0.44, 0.11, 0.22, -0.33, 0.12, 0.13, 0.14};
155     double[] r = {2.11, 2.12, 2.13};
156 
157     double[] tensor = new double[tensorCount];
158     CoulombTensorQI multipoleTensor = new CoulombTensorQI(order);
159     logger.info(format(" Writing QI Order %d tensor recursion code:", order));
160 
161     r[2] = length(r);
162     r[0] = 0.0;
163     r[1] = 0.0;
164     String code = multipoleTensor.codeTensorRecursion(r, tensor);
165     logger.info(format("\n%s", code));
166 
167     logger.info(format(" Writing QI Order %d vectorized tensor recursion code:", order));
168     code = multipoleTensor.codeVectorTensorRecursion(r, tensor);
169     logger.info(format("\n%s", code));
170 
171     PolarizableMultipole polarizableMultipole = new PolarizableMultipole(Qi, Ui, Ui);
172     QIFrame qiFrame = new QIFrame(r);
173     qiFrame.rotatePolarizableMultipole(polarizableMultipole);
174     StringBuilder sb = new StringBuilder();
175     logger.info(" Writing QI potential code due to multipole I:");
176     multipoleTensor.codePotentialMultipoleI(polarizableMultipole, tensor, 0, 0, 0, sb);
177     logger.info("\n\n" + sb);
178 
179     sb = new StringBuilder();
180     logger.info(" Writing QI potential vectorized code due to multipole I:");
181     multipoleTensor.codePotentialMultipoleISIMD(polarizableMultipole, tensor, 0, 0, 0, sb);
182     logger.info("\n\n" + sb);
183 
184     sb = new StringBuilder();
185     logger.info(" Writing QI potential code due to multipole K:");
186     multipoleTensor.codePotentialMultipoleK(polarizableMultipole, tensor, 0, 0, 0, sb);
187     logger.info("\n\n" + sb);
188 
189     sb = new StringBuilder();
190     logger.info(" Writing QI potential vectorized code due to multipole K:");
191     multipoleTensor.codePotentialMultipoleKSIMD(polarizableMultipole, tensor, 0, 0, 0, sb);
192     logger.info("\n\n" + sb);
193 
194   }
195 
196   @Test
197   public void permanentMultipoleEnergyAndGradTest() {
198     // Thole damping is not used for permanent AMOEBA electrostatics.
199     if (operator == Operator.THOLE_FIELD) {
200       return;
201     }
202 
203     MultipoleTensor multipoleTensor = new CoulombTensorQI(order);
204     if (operator == Operator.SCREENED_COULOMB) {
205       multipoleTensor = new EwaldTensorQI(order, beta);
206     }
207 
208     double delta = 1.0e-5;
209     double delta2 = 2.0 * 1.0e-5;
210     double[] Gi = new double[3];
211     double[] Gk = new double[3];
212     double[] Ti = new double[3];
213     double[] Tk = new double[3];
214 
215     PolarizableMultipole mI = new PolarizableMultipole(Qi, Ui, Ui);
216     PolarizableMultipole mK = new PolarizableMultipole(Qk, Uk, Uk);
217     QIFrame qiFrame = new QIFrame(r[0], r[1], r[2]);
218     qiFrame.rotatePolarizableMultipole(mI);
219     qiFrame.rotatePolarizableMultipole(mK);
220 
221     multipoleTensor.generateTensor(r);
222     double e = multipoleTensor.multipoleEnergyAndGradient(mI, mK, Gi, Gk, Ti, Tk);
223 
224     qiFrame.toGlobal(Gk);
225     qiFrame.toGlobal(Ti);
226     qiFrame.toGlobal(Tk);
227 
228     if (operator == Operator.COULOMB) {
229       assertEquals(info + " QI Permanent Energy", permanentEnergy, e, tolerance);
230       assertEquals(info + " QI Multipole Torque I", permTorqueI[1], Ti[1], tolerance);
231       assertEquals(info + " QI Multipole Torque K", permTorqueK[1], Tk[1], tolerance);
232     } else if (operator == Operator.SCREENED_COULOMB) {
233       assertEquals(info + " QI Ewald Permanent Energy", permanentEnergyEwald, e, tolerance);
234       assertEquals(info + " QI Ewald Multipole Torque I", permTorqueIEwald[1], Ti[1], tolerance);
235       assertEquals(info + " QI Ewald Multipole Torque K", permTorqueKEwald[1], Tk[1], tolerance);
236     }
237 
238     // Analytic gradient on Atom K.
239     double aX = Gk[0];
240     double aY = Gk[1];
241     double aZ = Gk[2];
242 
243     r[0] += delta;
244     mI.set(Qi, Ui, Ui);
245     mK.set(Qk, Uk, Uk);
246     qiFrame.setAndRotate(r, mI, mK);
247     multipoleTensor.generateTensor(r);
248     double posX = multipoleTensor.multipoleEnergy(mI, mK);
249 
250     r[0] -= delta2;
251     mI.set(Qi, Ui, Ui);
252     mK.set(Qk, Uk, Uk);
253     qiFrame.setAndRotate(r, mI, mK);
254     multipoleTensor.generateTensor(r);
255     double negX = multipoleTensor.multipoleEnergy(mI, mK);
256     r[0] += delta;
257 
258     r[1] += delta;
259     mI.set(Qi, Ui, Ui);
260     mK.set(Qk, Uk, Uk);
261     qiFrame.setAndRotate(r, mI, mK);
262     multipoleTensor.generateTensor(r);
263     double posY = multipoleTensor.multipoleEnergy(mI, mK);
264     r[1] -= delta2;
265     mI.set(Qi, Ui, Ui);
266     mK.set(Qk, Uk, Uk);
267     qiFrame.setAndRotate(r, mI, mK);
268     multipoleTensor.generateTensor(r);
269     double negY = multipoleTensor.multipoleEnergy(mI, mK);
270     r[1] += delta;
271 
272     r[2] += delta;
273     mI.set(Qi, Ui, Ui);
274     mK.set(Qk, Uk, Uk);
275     qiFrame.setAndRotate(r, mI, mK);
276     multipoleTensor.generateTensor(r);
277     double posZ = multipoleTensor.multipoleEnergy(mI, mK);
278     r[2] -= delta2;
279     mI.set(Qi, Ui, Ui);
280     mK.set(Qk, Uk, Uk);
281     qiFrame.setAndRotate(r, mI, mK);
282     multipoleTensor.generateTensor(r);
283     double negZ = multipoleTensor.multipoleEnergy(mI, mK);
284     r[2] += delta;
285 
286     double expect = aX;
287     double actual = (posX - negX) / delta2;
288     assertEquals(info + " QI Force X", expect, actual, fdTolerance);
289 
290     expect = aY;
291     actual = (posY - negY) / delta2;
292     assertEquals(info + " QI Force Y", expect, actual, fdTolerance);
293 
294     expect = aZ;
295     actual = (posZ - negZ) / delta2;
296     assertEquals(info + " QI Force Z", expect, actual, fdTolerance);
297   }
298 
299   @Test
300   public void polarizationEnergyAndGradTest() {
301 
302     MultipoleTensor multipoleTensor = new CoulombTensorQI(order);
303     if (operator == Operator.THOLE_FIELD) {
304       multipoleTensor = new TholeTensorQI(order, thole, AiAk);
305     } else if (operator == Operator.SCREENED_COULOMB) {
306       multipoleTensor = new EwaldTensorQI(order, beta);
307     }
308 
309     double delta = 1.0e-5;
310     double delta2 = 2.0 * 1.0e-5;
311     double[] Gi = new double[3];
312     double[] Ti = new double[3];
313     double[] Tk = new double[3];
314 
315     // Apply the softcore damping to the Z-axis
316     // r[2] += lambdaFunction;
317 
318     PolarizableMultipole mI = new PolarizableMultipole(Qi, Ui, Ui);
319     PolarizableMultipole mK = new PolarizableMultipole(Qk, Uk, Uk);
320     if (operator == Operator.SCREENED_COULOMB) {
321       mI.setInducedDipole(UiEwald, UiEwald);
322       mK.setInducedDipole(UkEwald, UkEwald);
323     }
324 
325     QIFrame qiFrame = new QIFrame(r[0], r[1], r[2]);
326     qiFrame.rotatePolarizableMultipole(mI);
327     qiFrame.rotatePolarizableMultipole(mK);
328 
329     multipoleTensor.generateTensor(r);
330     double e = multipoleTensor.polarizationEnergyAndGradient(mI, mK, 1.0, 1.0, scaleMutual, Gi, Ti,
331         Tk);
332 
333     qiFrame.toGlobal(Gi);
334     qiFrame.toGlobal(Ti);
335     qiFrame.toGlobal(Tk);
336 
337     // Analytic gradient on Atom I.
338     double aX = Gi[0];
339     double aY = Gi[1];
340     double aZ = Gi[2];
341 
342     double energy = polarizationEnergyCoulomb;
343     double[] gradI = polarGradICoulomb;
344     double[] torqueI = polarTorqueICoulomb;
345     double[] torqueK = polarTorqueKCoulomb;
346     if (operator == Operator.THOLE_FIELD) {
347       energy = polarizationEnergyThole;
348       gradI = polarGradIThole;
349       torqueI = polarTorqueIThole;
350       torqueK = polarTorqueKThole;
351     } else if (operator == Operator.SCREENED_COULOMB) {
352       energy = polarizationEnergyEwald;
353       gradI = polarGradIEwald;
354       torqueI = polarTorqueIEwald;
355       torqueK = polarTorqueKEwald;
356     }
357     assertEquals(info + " Polarization Energy", energy, e, tolerance);
358     assertEquals(info + " Polarization GradX", gradI[0], aX, tolerance);
359     assertEquals(info + " Polarization GradY", gradI[1], aY, tolerance);
360     assertEquals(info + " Polarization GradZ", gradI[2], aZ, tolerance);
361     assertEquals(info + " Polarization TorqueX I", torqueI[0], Ti[0], tolerance);
362     assertEquals(info + " Polarization TorqueY I", torqueI[1], Ti[1], tolerance);
363     assertEquals(info + " Polarization TorqueZ I", torqueI[2], Ti[2], tolerance);
364     assertEquals(info + " Polarization TorqueX K", torqueK[0], Tk[0], tolerance);
365     assertEquals(info + " Polarization TorqueY K", torqueK[1], Tk[1], tolerance);
366     assertEquals(info + " Polarization TorqueZ K", torqueK[2], Tk[2], tolerance);
367 
368     if (scaleMutual == 0.0) {
369       // Compute the gradient on Atom K.
370       r[0] += delta;
371       mI.set(Qi, Ui, Ui);
372       mK.set(Qk, Uk, Uk);
373       qiFrame.setAndRotate(r, mI, mK);
374       multipoleTensor.generateTensor(r);
375       double posX = multipoleTensor.polarizationEnergy(mI, mK, 1.0);
376 
377       r[0] -= delta2;
378       mI.set(Qi, Ui, Ui);
379       mK.set(Qk, Uk, Uk);
380       qiFrame.setAndRotate(r, mI, mK);
381       multipoleTensor.generateTensor(r);
382       double negX = multipoleTensor.polarizationEnergy(mI, mK, 1.0);
383       r[0] += delta;
384 
385       r[1] += delta;
386       mI.set(Qi, Ui, Ui);
387       mK.set(Qk, Uk, Uk);
388       qiFrame.setAndRotate(r, mI, mK);
389       multipoleTensor.generateTensor(r);
390       double posY = multipoleTensor.polarizationEnergy(mI, mK, 1.0);
391       r[1] -= delta2;
392       mI.set(Qi, Ui, Ui);
393       mK.set(Qk, Uk, Uk);
394       qiFrame.setAndRotate(r, mI, mK);
395       multipoleTensor.generateTensor(r);
396       double negY = multipoleTensor.polarizationEnergy(mI, mK, 1.0);
397       r[1] += delta;
398 
399       r[2] += delta;
400       mI.set(Qi, Ui, Ui);
401       mK.set(Qk, Uk, Uk);
402       qiFrame.setAndRotate(r, mI, mK);
403       multipoleTensor.generateTensor(r);
404       double posZ = multipoleTensor.polarizationEnergy(mI, mK, 1.0);
405       r[2] -= delta2;
406       mI.set(Qi, Ui, Ui);
407       mK.set(Qk, Uk, Uk);
408       qiFrame.setAndRotate(r, mI, mK);
409       multipoleTensor.generateTensor(r);
410       double negZ = multipoleTensor.polarizationEnergy(mI, mK, 1.0);
411       r[2] += delta;
412 
413       // Note that the factor of 2 below accounts for the induced dipoles not being updated (i.e. the analytic gradient is for
414       // induced dipoles, but the finite-difference is effectively for permanent dipoles due to lack of re-induction).
415       double expect = -aX;
416       double actual = 2.0 * (posX - negX) / delta2;
417       assertEquals(info + " QI Polarization Force X", expect, actual, fdTolerance);
418 
419       expect = -aY;
420       actual = 2.0 * (posY - negY) / delta2;
421       assertEquals(info + " QI Polarization Force Y", expect, actual, fdTolerance);
422 
423       expect = -aZ;
424       actual = 2.0 * (posZ - negZ) / delta2;
425       assertEquals(info + " QI Polarization Force Z", expect, actual, fdTolerance);
426     }
427   }
428 
429   @Test
430   public void finiteDifferenceTest() {
431 
432     MultipoleTensor multipoleTensor = new CoulombTensorQI(order);
433     if (operator == Operator.THOLE_FIELD) {
434       multipoleTensor = new TholeTensorQI(order, thole, AiAk);
435     } else if (operator == Operator.SCREENED_COULOMB) {
436       multipoleTensor = new EwaldTensorQI(order, beta);
437     }
438 
439     r[0] = 0.0;
440     r[1] = 0.0;
441     r[2] = 2.1;
442 
443     multipoleTensor.noStorageRecursion(r, tensor);
444     double[] tensorsPz = new double[tensorCount];
445     double[] tensorsNz = new double[tensorCount];
446     double delta = 1.0e-5;
447     double delta2 = delta * 2;
448 
449     r[2] += delta;
450     multipoleTensor.noStorageRecursion(r, tensorsPz);
451     r[2] -= delta2;
452     multipoleTensor.noStorageRecursion(r, tensorsNz);
453     r[2] += delta;
454 
455     tensorFiniteDifference(multipoleTensor, delta2, tensorsPz, tensorsNz);
456 
457     multipoleTensor.recursion(r, tensor);
458     tensorFiniteDifference(multipoleTensor, delta2, tensorsPz, tensorsNz);
459 
460     multipoleTensor.generateTensor();
461     multipoleTensor.getTensor(tensor);
462     tensorFiniteDifference(multipoleTensor, delta2, tensorsPz, tensorsNz);
463   }
464 
465   @Test
466   public void multipoleTensorTest() {
467 
468     MultipoleTensor multipoleTensor = new CoulombTensorQI(order);
469     if (operator == Operator.THOLE_FIELD) {
470       multipoleTensor = new TholeTensorQI(order, thole, AiAk);
471     } else if (operator == Operator.SCREENED_COULOMB) {
472       multipoleTensor = new EwaldTensorQI(order, beta);
473     }
474 
475     // Check Cartesian Tensors in the QI frame.
476     multipoleTensor.noStorageRecursion(r, noStorageTensor);
477     multipoleTensor.recursion(r, tensor);
478     multipoleTensor.generateTensor();
479     multipoleTensor.getTensor(fastTensor);
480 
481     for (int i = 0; i < tensorCount; i++) {
482       double expect = noStorageTensor[i];
483       double actual = tensor[i];
484       assertEquals(info + " @ " + i, expect, actual, tolerance);
485       if (order == 4 || order == 5) {
486         expect = noStorageTensor[i];
487         actual = fastTensor[i];
488         assertEquals(info + " @ " + i, expect, actual, tolerance);
489       }
490     }
491 
492     // Check QI Tensors in a quasi-internal frame.
493     // Set x and y = 0.0
494     r[2] = length(r);
495     r[0] = 0.0;
496     r[1] = 0.0;
497     fill(noStorageTensor, 0.0);
498     fill(tensor, 0.0);
499     fill(fastTensor, 0.0);
500     multipoleTensor.noStorageRecursion(r, noStorageTensor);
501     multipoleTensor.recursion(r, tensor);
502     multipoleTensor.setTensor(fastTensor);
503     multipoleTensor.generateTensor();
504     multipoleTensor.getTensor(fastTensor);
505 
506     for (int i = 0; i < tensorCount; i++) {
507       double expect = noStorageTensor[i];
508       double actual = tensor[i];
509       assertEquals(info + " @ " + i, expect, actual, tolerance);
510       if (order == 4 || order == 5) {
511         expect = noStorageTensor[i];
512         actual = fastTensor[i];
513         assertEquals(info + " @ " + i, expect, actual, tolerance);
514       }
515     }
516   }
517 
518   private void tensorFiniteDifference(
519       MultipoleTensor multipoleTensor, double delta2, double[] tensorsPz, double[] tensorsNz) {
520 
521     int start = 0;
522 
523     // We do not calculate the zeroth term for Thole damping.
524     if (operator == Operator.THOLE_FIELD) {
525       start = 1;
526     }
527 
528     // Test the partial derivatives for all tensor components.
529     for (int l = start; l < order; l++) {
530       // Test Z derivative
531       double expect = tensor[multipoleTensor.ti(l, 0, 1)];
532       double actual =
533           (tensorsPz[multipoleTensor.ti(l, 0, 0)] - tensorsNz[multipoleTensor.ti(l, 0, 0)]) / delta2;
534       assertEquals(info + " (d/dx): " + l, expect, actual, fdTolerance);
535     }
536     for (int l = 0; l < order; l++) {
537       for (int m = 1; m < order - l; m++) {
538         // Test Z derivative
539         double expect = tensor[multipoleTensor.ti(l, m, 1)];
540         double actual =
541             (tensorsPz[multipoleTensor.ti(l, m, 0)] - tensorsNz[multipoleTensor.ti(l, m, 0)])
542                 / delta2;
543         assertEquals(info + " (d/dx): " + l + " (d/dy): " + m, expect, actual, fdTolerance);
544       }
545     }
546     // Find (d/dx)^l * (d/dy)^m * (d/dz)^n for l + m + n = 1..o (n >= 1)
547     for (int l = 0; l < order; l++) {
548       for (int m = 0; m < order - l; m++) {
549         for (int n = 1; n < order - l - m; n++) {
550           // Test Z derivative
551           double expect = tensor[multipoleTensor.ti(l, m, n + 1)];
552           double actual =
553               (tensorsPz[multipoleTensor.ti(l, m, n)] - tensorsNz[multipoleTensor.ti(l, m, n)])
554                   / delta2;
555           assertEquals(info + " (d/dx): " + l + " (d/dy): " + m + " (d/dz):" + n, expect, actual,
556               fdTolerance);
557         }
558       }
559     }
560   }
561 }