PythonicDISORT 1.6__py3-none-any.whl → 1.8__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.
@@ -29,7 +29,8 @@ def _assemble_intensity_and_fluxes(
29
29
  only_flux, # Only compute fluxes?
30
30
  use_banded_solver_NLayers, # Number of layers above or equal which to use `scipy.linalg.solve_banded`
31
31
  autograd_compatible, # Should the output functions be compatible with autograd?
32
- ):
32
+ cache_asso_leg, # Assoc. Legendre table cache mode: None, "no_mu0" or "mu0"
33
+ ):
33
34
  """Assembles the solution functions: intensity `u`, upward flux `flux_up`, downward flux `flux_down`
34
35
  from the previously solved eigenpairs and coefficients from the boundary conditions.
35
36
  Returns these solution functions.
@@ -78,7 +79,8 @@ def _assemble_intensity_and_fluxes(
78
79
  | `only_flux` | boolean |
79
80
  | `use_banded_solver_NLayers` | scalar |
80
81
  | `autograd_compatible` | boolean |
81
-
82
+ | `cache_asso_leg` | None, "no_mu0" or "mu0" |
83
+
82
84
  Notable internal variables of _assemble_intensity_and_fluxes
83
85
  | Variable | Type / Shape |
84
86
  | ----------------- | -------------------------------------- |
@@ -117,6 +119,7 @@ def _assemble_intensity_and_fluxes(
117
119
  mu0, I0_div_4pi,
118
120
  there_is_beam_source,
119
121
  there_is_iso_source,
122
+ cache_asso_leg,
120
123
  )
121
124
  if there_is_beam_source and there_is_iso_source:
122
125
  G_collect, K_collect, B_collect, G_inv_collect_0 = outputs
@@ -161,8 +164,66 @@ def _assemble_intensity_and_fluxes(
161
164
  G_collect_0 = G_collect[0, :, :, :]
162
165
  K_collect_0 = K_collect[0, :, :]
163
166
  GC_collect_0 = GC_collect[0, :, :, :]
164
- # --------------------------------------------------------------------------------------------------------------------------
165
-
167
+ # --------------------------------------------------------------------------------------------------------------------------
168
+
169
+ # Value-keyed cache for the most recent `tau` evaluation grid, shared across the output
170
+ # functions `u`, `u0`, `flux_up` and `flux_down` (evaluating several outputs on the same
171
+ # grid, e.g. `flux_up(tau); flux_down(tau)`, then recomputes nothing).
172
+ # Stores a copy of `tau` (compared by value like the cache in `subroutines.interpolate`)
173
+ # with the derived layer indices and delta-scaled `tau`, and, lazily, the exponentials of
174
+ # the homogeneous-solution exponents for the 0th mode ("exp0") and for all modes ("expm").
175
+ # The cache is bypassed when `autograd_compatible` as it would break gradient tracing.
176
+ # --------------------------------------------------------------------------------------------------------------------------
177
+ is_delta_scaled = np.any(scale_tau != 1)
178
+ _tau_cache = {"tau": None, "l": None, "scaled_tau": None, "exp0": None, "expm": None}
179
+
180
+ def _process_tau(tau):
181
+ """Layer indices and delta-scaled `tau` for the evaluation grid `tau`, cached."""
182
+ if (not autograd_compatible and _tau_cache["tau"] is not None
183
+ and np.array_equal(tau, _tau_cache["tau"])):
184
+ return _tau_cache["l"], _tau_cache["scaled_tau"]
185
+ # Atmospheric layer indices: smallest l with tau <= tau_arr[l]
186
+ l = np.searchsorted(tau_arr, tau, side="left")
187
+ if is_delta_scaled:
188
+ scaled_tau = scaled_tau_arr_with_0[1:][l] - (tau_arr[l] - tau) * scale_tau[l]
189
+ else:
190
+ scaled_tau = tau
191
+ if not autograd_compatible:
192
+ _tau_cache.update(
193
+ tau=np.array(tau, copy=True), l=l, scaled_tau=scaled_tau, exp0=None, expm=None
194
+ )
195
+ return l, scaled_tau
196
+
197
+ def _exp_exponent(l, scaled_tau, all_modes):
198
+ """exp of the homogeneous-solution exponents for the (cached) evaluation grid.
199
+ Returns shape (Ntau, NQuad) for the 0th mode, or (NFourier, Ntau, NQuad) for all modes."""
200
+ key = "expm" if all_modes else "exp0"
201
+ if not autograd_compatible and _tau_cache[key] is not None:
202
+ return _tau_cache[key]
203
+ scaled_tau_arr_l = scaled_tau_arr_with_0[1:][l]
204
+ scaled_tau_arr_lm1 = scaled_tau_arr_with_0[l]
205
+ if all_modes:
206
+ exponent = np.concatenate(
207
+ [
208
+ K_collect[:, l, :N] * (scaled_tau - scaled_tau_arr_lm1)[None, :, None],
209
+ K_collect[:, l, N:] * (scaled_tau - scaled_tau_arr_l)[None, :, None],
210
+ ],
211
+ axis=-1,
212
+ )
213
+ else:
214
+ exponent = np.concatenate(
215
+ [
216
+ K_collect_0[l, :N] * (scaled_tau - scaled_tau_arr_lm1)[:, None],
217
+ K_collect_0[l, N:] * (scaled_tau - scaled_tau_arr_l)[:, None],
218
+ ],
219
+ axis=-1,
220
+ )
221
+ result = np.exp(exponent)
222
+ if not autograd_compatible:
223
+ _tau_cache[key] = result
224
+ return result
225
+ # --------------------------------------------------------------------------------------------------------------------------
226
+
166
227
  if not only_flux:
167
228
 
168
229
  # Construct the intensity function (refer to section 3.7 of the Comprehensive Documentation)
@@ -181,27 +242,10 @@ def _assemble_intensity_and_fluxes(
181
242
  if np.any(tau < 0) or np.any(tau > tau_arr[-1]):
182
243
  raise ValueError("tau input outside the tau range specified for the atmosphere (check `tau_arr`).")
183
244
 
184
- # Atmospheric layer indices
185
- l = np.argmax(tau[:, None] <= tau_arr[None, :], axis=1)
186
- scaled_tau_arr_l = scaled_tau_arr_with_0[1:][l]
187
- scaled_tau_arr_lm1 = scaled_tau_arr_with_0[l]
245
+ # Atmospheric layer indices, delta-M scaling and exponentials (cached)
246
+ l, scaled_tau = _process_tau(tau)
247
+ exp_exponent = _exp_exponent(l, scaled_tau, all_modes=True)
188
248
 
189
- # Delta-M scaling
190
- if np.any(scale_tau != np.ones(NLayers)):
191
- tau_dist_from_top = tau_arr[l] - tau
192
- scaled_tau_dist_from_top = tau_dist_from_top * scale_tau[l]
193
- scaled_tau = scaled_tau_arr_l - scaled_tau_dist_from_top
194
- else:
195
- scaled_tau = tau
196
-
197
- exponent = np.concatenate(
198
- [
199
- K_collect[:, l, :N] * (scaled_tau - scaled_tau_arr_lm1)[None, :, None],
200
- K_collect[:, l, N:] * (scaled_tau - scaled_tau_arr_l)[None, :, None],
201
- ],
202
- axis=-1,
203
- )
204
-
205
249
  direct_beam_contribution = 0
206
250
  if is_antiderivative_wrt_tau:
207
251
  if there_is_beam_source:
@@ -213,7 +257,7 @@ def _assemble_intensity_and_fluxes(
213
257
  np.einsum(
214
258
  "mtij, mtj -> mit",
215
259
  GC_collect[:, l, :, :],
216
- np.exp(exponent) / (scale_tau[None, :, None] * K_collect)[:, l, :],
260
+ exp_exponent / (scale_tau[None, :, None] * K_collect)[:, l, :],
217
261
  optimize=True,
218
262
  )
219
263
  + direct_beam_contribution
@@ -227,7 +271,7 @@ def _assemble_intensity_and_fluxes(
227
271
 
228
272
  um = (
229
273
  np.einsum(
230
- "mtij, mtj -> mit", GC_collect[:, l, :, :], np.exp(exponent), optimize=True
274
+ "mtij, mtj -> mit", GC_collect[:, l, :, :], exp_exponent, optimize=True
231
275
  )
232
276
  + direct_beam_contribution
233
277
  )
@@ -236,7 +280,8 @@ def _assemble_intensity_and_fluxes(
236
280
  if there_is_iso_source:
237
281
  l_uniq, l_inv = np.unique(l, return_inverse=True)
238
282
  _mathscr_v_contribution = _mathscr_v(
239
- scaled_tau, scale_tau, l_inv,
283
+ scaled_tau, scale_tau[l_uniq], # `scale_tau` is only required to determine the antiderivative
284
+ l_inv,
240
285
  Nscoeffs,
241
286
  scaled_s_poly_coeffs[l_uniq],
242
287
  G_collect_0[l_uniq],
@@ -253,49 +298,52 @@ def _assemble_intensity_and_fluxes(
253
298
  else:
254
299
  um[0, :, :] += _mathscr_v_contribution
255
300
 
256
- u = np.sum(
257
- um[:, :, :, None]
258
- * np.cos(np.arange(NFourier)[:, None] * (phi0 - phi)[None, :])[:, None, None, :],
259
- axis=0,
301
+ # Contract over the Fourier modes directly: materializing the
302
+ # (NFourier, NQuad, Ntau, Nphi) product only to sum over its first axis
303
+ # is substantially slower and heavier for large NFourier * Nphi
304
+ u = np.einsum(
305
+ "mit, mp -> itp",
306
+ um,
307
+ np.cos(np.arange(NFourier)[:, None] * (phi0 - phi)[None, :]),
308
+ optimize=True,
260
309
  )
261
310
 
262
311
  outputs = (rescale_factor * np.squeeze(u),)
263
312
  output_is_tuple = False
264
313
 
265
314
  if return_Fourier_error:
266
- exponent = np.concatenate(
267
- [
268
- K_collect[-1, l, :N] * (scaled_tau - scaled_tau_arr_lm1)[:, None],
269
- K_collect[-1, l, N:] * (scaled_tau - scaled_tau_arr_l)[:, None],
270
- ],
271
- axis=-1,
272
- )
273
-
315
+ # The last Fourier mode's exponentials are the last slice of the cached tensor
316
+ exp_exponent_last = exp_exponent[-1]
317
+
274
318
  direct_beam_contribution = 0
275
319
  if is_antiderivative_wrt_tau:
276
320
  if there_is_beam_source:
321
+ # `B_collect[-1].T` has shape (NQuad, NLayers): the last Fourier mode's
322
+ # particular-solution coefficients (`B_collect.T[-1]` would wrongly
323
+ # index the last quadrature node)
277
324
  direct_beam_contribution = (
278
- B_collect.T[-1, :, :] / (-scale_tau / mu0)[None, :]
325
+ B_collect[-1].T / (-scale_tau / mu0)[None, :]
279
326
  )[:, l] * np.exp(-scaled_tau / mu0)[None, :]
280
327
 
281
328
  ulast = (
282
329
  np.einsum(
283
330
  "tij, tj -> it",
284
331
  GC_collect[-1, l, :, :],
285
- np.exp(exponent) / (scale_tau[:, None] * K_collect[-1, :, :])[l, :],
332
+ exp_exponent_last / (scale_tau[:, None] * K_collect[-1, :, :])[l, :],
286
333
  optimize=True,
287
334
  )
288
335
  + direct_beam_contribution
289
336
  )
290
337
  else:
291
338
  if there_is_beam_source:
339
+ # See the shape note in the `is_antiderivative_wrt_tau` branch above
292
340
  direct_beam_contribution = (
293
- B_collect.T[-1, :, l] * np.exp(-scaled_tau / mu0)[None, :]
341
+ B_collect[-1].T[:, l] * np.exp(-scaled_tau / mu0)[None, :]
294
342
  )
295
343
 
296
344
  ulast = (
297
345
  np.einsum(
298
- "tij, tj -> it", GC_collect[-1, l, :, :], np.exp(exponent), optimize=True
346
+ "tij, tj -> it", GC_collect[-1, l, :, :], exp_exponent_last, optimize=True
299
347
  )
300
348
  + direct_beam_contribution
301
349
  )
@@ -338,41 +386,25 @@ def _assemble_intensity_and_fluxes(
338
386
  if np.any(tau < 0) or np.any(tau > tau_arr[-1]):
339
387
  raise ValueError("tau input outside the tau range given for the atmosphere (check `tau_arr`).")
340
388
 
341
- # Atmospheric layer indices
342
- l = np.argmax(tau[:, None] <= tau_arr[None, :], axis=1)
343
- scaled_tau_arr_l = scaled_tau_arr_with_0[1:][l]
344
- scaled_tau_arr_lm1 = scaled_tau_arr_with_0[l]
389
+ # Atmospheric layer indices, delta-M scaling and exponentials (cached)
390
+ l, scaled_tau = _process_tau(tau)
391
+ exp_exponent = _exp_exponent(l, scaled_tau, all_modes=False)
345
392
 
346
- # Delta-M scaling
347
- if np.any(scale_tau != np.ones(NLayers)):
348
- tau_dist_from_top = tau_arr[l] - tau
349
- scaled_tau_dist_from_top = tau_dist_from_top * scale_tau[l]
350
- scaled_tau = scaled_tau_arr_l - scaled_tau_dist_from_top
351
- # The following complements the function `subroutines.generate_diff_act_flux_funcs`
352
- # in performing the reclassification of delta-scaled actinic flux
353
- if _return_act_dscale_for_reclass:
354
- if is_antiderivative_wrt_tau:
355
- act_dscale_reclassification = (
356
- I0 * np.exp(-scaled_tau / mu0) / (-scale_tau[l] / mu0)
357
- - I0 * np.exp(-tau / mu0) * -mu0
358
- )
359
- else:
360
- act_dscale_reclassification = I0 * np.exp(-scaled_tau / mu0) - I0 * np.exp(-tau / mu0)
361
- else:
362
- scaled_tau = tau
363
- if _return_act_dscale_for_reclass:
393
+ # The following complements the function `subroutines.generate_diff_act_flux_funcs`
394
+ # in performing the reclassification of delta-scaled actinic flux
395
+ if _return_act_dscale_for_reclass:
396
+ if not is_delta_scaled:
364
397
  act_dscale_reclassification = 0
398
+ elif is_antiderivative_wrt_tau:
399
+ act_dscale_reclassification = (
400
+ I0 * np.exp(-scaled_tau / mu0) / (-scale_tau[l] / mu0)
401
+ - I0 * np.exp(-tau / mu0) * -mu0
402
+ )
403
+ else:
404
+ act_dscale_reclassification = I0 * np.exp(-scaled_tau / mu0) - I0 * np.exp(-tau / mu0)
365
405
 
366
- exponent = np.concatenate(
367
- [
368
- K_collect_0[l, :N] * (scaled_tau - scaled_tau_arr_lm1)[:, None],
369
- K_collect_0[l, N:] * (scaled_tau - scaled_tau_arr_l)[:, None],
370
- ],
371
- axis=-1,
372
- )
373
-
374
406
  direct_beam_contribution = 0
375
- if is_antiderivative_wrt_tau and there_is_beam_source:
407
+ if is_antiderivative_wrt_tau:
376
408
  if there_is_beam_source:
377
409
  direct_beam_contribution = (B_collect_0.T / (-scale_tau / mu0)[None, :])[
378
410
  :, l
@@ -382,7 +414,7 @@ def _assemble_intensity_and_fluxes(
382
414
  np.einsum(
383
415
  "tij, tj -> it",
384
416
  GC_collect_0[l, :, :],
385
- np.exp(exponent) / (scale_tau[:, None] * K_collect_0)[l, :],
417
+ exp_exponent / (scale_tau[:, None] * K_collect_0)[l, :],
386
418
  optimize=True,
387
419
  )
388
420
  + direct_beam_contribution
@@ -395,7 +427,7 @@ def _assemble_intensity_and_fluxes(
395
427
 
396
428
  u0 = (
397
429
  np.einsum(
398
- "tij, tj -> it", GC_collect_0[l, :, :], np.exp(exponent), optimize=True
430
+ "tij, tj -> it", GC_collect_0[l, :, :], exp_exponent, optimize=True
399
431
  )
400
432
  + direct_beam_contribution
401
433
  )
@@ -404,7 +436,8 @@ def _assemble_intensity_and_fluxes(
404
436
  if there_is_iso_source:
405
437
  l_uniq, l_inv = np.unique(l, return_inverse=True)
406
438
  _mathscr_v_contribution = _mathscr_v(
407
- scaled_tau, scale_tau, l_inv,
439
+ scaled_tau, scale_tau[l_uniq], # `scale_tau` is only required to determine the antiderivative
440
+ l_inv,
408
441
  Nscoeffs,
409
442
  scaled_s_poly_coeffs[l_uniq],
410
443
  G_collect_0[l_uniq],
@@ -447,23 +480,15 @@ def _assemble_intensity_and_fluxes(
447
480
  if np.any(tau < 0) or np.any(tau > tau_arr[-1]):
448
481
  raise ValueError("tau input outside the tau range given for the atmosphere (check `tau_arr`).")
449
482
 
450
- # Atmospheric layer indices
451
- l = np.argmax(tau[:, None] <= tau_arr[None, :], axis=1)
452
- scaled_tau_arr_l = scaled_tau_arr_with_0[1:][l]
453
- scaled_tau_arr_lm1 = scaled_tau_arr_with_0[l]
454
-
455
- # Delta-M scaling
456
- if np.any(scale_tau != np.ones(NLayers)):
457
- tau_dist_from_top = tau_arr[l] - tau
458
- scaled_tau_dist_from_top = tau_dist_from_top * scale_tau[l]
459
- scaled_tau = scaled_tau_arr_l - scaled_tau_dist_from_top
460
- else:
461
- scaled_tau = tau
483
+ # Atmospheric layer indices, delta-M scaling and exponentials (cached)
484
+ l, scaled_tau = _process_tau(tau)
485
+ exp_exponent = _exp_exponent(l, scaled_tau, all_modes=False)
462
486
 
463
487
  if there_is_iso_source:
464
488
  l_uniq, l_inv = np.unique(l, return_inverse=True)
465
489
  _mathscr_v_contribution = _mathscr_v(
466
- scaled_tau, scale_tau, l_inv,
490
+ scaled_tau, scale_tau[l_uniq], # `scale_tau` is only required to determine the antiderivative
491
+ l_inv,
467
492
  Nscoeffs,
468
493
  scaled_s_poly_coeffs[l_uniq],
469
494
  G_collect_0[l_uniq, :N, :],
@@ -475,14 +500,6 @@ def _assemble_intensity_and_fluxes(
475
500
  else:
476
501
  _mathscr_v_contribution = 0
477
502
 
478
- exponent = np.concatenate(
479
- [
480
- K_collect_0[l, :N] * (scaled_tau - scaled_tau_arr_lm1)[:, None],
481
- K_collect_0[l, N:] * (scaled_tau - scaled_tau_arr_l)[:, None],
482
- ],
483
- axis=-1,
484
- )
485
-
486
503
  direct_beam_contribution = 0
487
504
  if is_antiderivative_wrt_tau:
488
505
  if there_is_beam_source:
@@ -494,7 +511,7 @@ def _assemble_intensity_and_fluxes(
494
511
  np.einsum(
495
512
  "tij, tj -> it",
496
513
  GC_pos[l, :, :],
497
- np.exp(exponent) / (scale_tau[:, None] * K_collect_0)[l, :],
514
+ exp_exponent / (scale_tau[:, None] * K_collect_0)[l, :],
498
515
  )
499
516
  + direct_beam_contribution
500
517
  + _mathscr_v_contribution
@@ -504,7 +521,7 @@ def _assemble_intensity_and_fluxes(
504
521
  direct_beam_contribution = B_pos[:, l] * np.exp(-scaled_tau / mu0)[None, :]
505
522
 
506
523
  u0_pos = (
507
- np.einsum("tij, tj -> it", GC_pos[l, :, :], np.exp(exponent))
524
+ np.einsum("tij, tj -> it", GC_pos[l, :, :], exp_exponent)
508
525
  + direct_beam_contribution
509
526
  + _mathscr_v_contribution
510
527
  )
@@ -529,23 +546,15 @@ def _assemble_intensity_and_fluxes(
529
546
  if np.any(tau < 0) or np.any(tau > tau_arr[-1]):
530
547
  raise ValueError("tau input outside the tau range given for the atmosphere (check `tau_arr`).")
531
548
 
532
- # Atmospheric layer indices
533
- l = np.argmax(tau[:, None] <= tau_arr[None, :], axis=1)
534
- scaled_tau_arr_l = scaled_tau_arr_with_0[1:][l]
535
- scaled_tau_arr_lm1 = scaled_tau_arr_with_0[l]
536
-
537
- # Delta-M scaling
538
- if np.any(scale_tau != np.ones(NLayers)):
539
- tau_dist_from_top = tau_arr[l] - tau
540
- scaled_tau_dist_from_top = tau_dist_from_top * scale_tau[l]
541
- scaled_tau = scaled_tau_arr_l - scaled_tau_dist_from_top
542
- else:
543
- scaled_tau = tau
549
+ # Atmospheric layer indices, delta-M scaling and exponentials (cached)
550
+ l, scaled_tau = _process_tau(tau)
551
+ exp_exponent = _exp_exponent(l, scaled_tau, all_modes=False)
544
552
 
545
553
  if there_is_iso_source:
546
554
  l_uniq, l_inv = np.unique(l, return_inverse=True)
547
555
  _mathscr_v_contribution = _mathscr_v(
548
- scaled_tau, scale_tau, l_inv,
556
+ scaled_tau, scale_tau[l_uniq], # `scale_tau` is only required to determine the antiderivative
557
+ l_inv,
549
558
  Nscoeffs,
550
559
  scaled_s_poly_coeffs[l_uniq],
551
560
  G_collect_0[l_uniq, N:, :],
@@ -557,25 +566,17 @@ def _assemble_intensity_and_fluxes(
557
566
  else:
558
567
  _mathscr_v_contribution = 0
559
568
 
560
- exponent = np.concatenate(
561
- [
562
- K_collect_0[l, :N] * (scaled_tau - scaled_tau_arr_lm1)[:, None],
563
- K_collect_0[l, N:] * (scaled_tau - scaled_tau_arr_l)[:, None],
564
- ],
565
- axis=-1,
566
- )
567
-
568
569
  direct_beam_contribution = 0
569
570
  direct_beam = 0
570
571
  direct_beam_scaled = 0
571
572
  if is_antiderivative_wrt_tau:
572
573
  if there_is_beam_source:
573
- direct_beam_contribution = (B_neg / (-scale_tau / mu0)[None, l])[:, l] * np.exp(-scaled_tau / mu0)[None, :]
574
+ direct_beam_contribution = (B_neg / (-scale_tau / mu0)[None, :])[:, l] * np.exp(-scaled_tau / mu0)[None, :]
574
575
  direct_beam = I0 * mu0 * np.exp(-tau / mu0) * -mu0
575
- direct_beam_scaled = I0 * mu0 * np.exp(-scaled_tau / mu0) / (-scale_tau / mu0)[None, l]
576
+ direct_beam_scaled = I0 * mu0 * np.exp(-scaled_tau / mu0) / (-scale_tau[l] / mu0)
576
577
 
577
578
  u0_neg = (
578
- np.einsum("tij, tj -> it", GC_neg[l, :, :], np.exp(exponent) / (scale_tau[:, None] * K_collect_0)[l, :])
579
+ np.einsum("tij, tj -> it", GC_neg[l, :, :], exp_exponent / (scale_tau[:, None] * K_collect_0)[l, :])
579
580
  + direct_beam_contribution
580
581
  + _mathscr_v_contribution
581
582
  )
@@ -586,7 +587,7 @@ def _assemble_intensity_and_fluxes(
586
587
  direct_beam_scaled = I0 * mu0 * np.exp(-scaled_tau / mu0)
587
588
 
588
589
  u0_neg = (
589
- np.einsum("tij, tj -> it", GC_neg[l, :, :], np.exp(exponent))
590
+ np.einsum("tij, tj -> it", GC_neg[l, :, :], exp_exponent)
590
591
  + direct_beam_contribution
591
592
  + _mathscr_v_contribution
592
593
  )
@@ -79,7 +79,8 @@ def _solve_for_coeffs(
79
79
  use_banded_solver = (NLayers >= use_banded_solver_NLayers)
80
80
  dim = NLayers * NQuad
81
81
  C_collect = np.empty((NFourier, dim))
82
-
82
+ thickness = np.diff(scaled_tau_arr_with_0) # (NLayers,)
83
+
83
84
  RHS = np.empty(dim)
84
85
  i_bot = dim - N # Equals `j_bot_right`
85
86
  j_bot_left = dim - NQuad
@@ -89,23 +90,52 @@ def _solve_for_coeffs(
89
90
  LHS_dof = np.zeros((6 * N - 1, dim), order="F") # Diagonal ordered form
90
91
  s0, s1 = LHS_dof.strides
91
92
  col_stride = s1 - s0 # Stride when moving +1 col and -1 row
92
-
93
+
93
94
  def _view_slanted(i0, j0, nrows):
94
- """
95
- Return a view into `LHS_dof` using slanted mapping.
96
- The function uses `numpy.lib.stride_tricks.as_strided` which is performant but dangerous,
97
- which is why this function is not exposed in `PythonicDISORT.subroutines`.
95
+ """Single slanted view into `LHS_dof` anchored at conceptual LHS coords (i0, j0).
96
+ Returns a 2D view of shape (nrows, N). Used for top/bottom boundary-condition rows.
97
+
98
+ Uses `numpy.lib.stride_tricks.as_strided` (zero-copy but unsafe), which is why
99
+ this helper is not exposed in `PythonicDISORT.subroutines`.
98
100
  See https://numpy.org/devdocs/reference/generated/numpy.lib.stride_tricks.as_strided.html.
99
-
101
+
100
102
  Assume the following exist in the outer scope:
101
- `LHS_dof`, `Nsupsubdiags`, `s0`, `col_stride`
103
+ `LHS_dof`, `Nsupsubdiags`, `N`, `s0`, `col_stride`.
102
104
  """
103
105
  r0 = Nsupsubdiags + i0 - j0
104
106
  base = LHS_dof[r0 : r0 + nrows, j0 : j0 + N]
105
107
  return as_strided(base, shape=(nrows, N), strides=(s0, col_stride))
106
-
108
+
109
+ def _view_slanted_batched(i0, j0, nrows):
110
+ """Batched (NLayers-1, nrows, N) slanted view across interlayer continuity rows.
111
+ Anchored at conceptual LHS coords (i0, j0) for layer 0; layer l advances both
112
+ i and j by `NQuad` along the diagonal.
113
+
114
+ Same `(i0, j0)` convention as `_view_slanted` (slant arithmetic done internally).
115
+ Assume the following exist in the outer scope:
116
+ `LHS_dof`, `Nsupsubdiags`, `N`, `NQuad`, `NLayers`, `s0`, `s1`, `col_stride`.
117
+ """
118
+ r0 = Nsupsubdiags + i0 - j0 # matches `_view_slanted`'s internal slant
119
+ base = LHS_dof[r0 : r0 + nrows, j0 : j0 + N + (NLayers - 2) * NQuad]
120
+ return as_strided(base, shape=(NLayers - 1, nrows, N),
121
+ strides=(NQuad * s1, s0, col_stride))
122
+
107
123
  else:
108
124
  LHS = np.zeros((dim, dim), order="F")
125
+ sR, sC = LHS.strides
126
+ # Stacks for a single batched `np.linalg.solve` over all Fourier modes after the
127
+ # assembly loop; this removes the per-mode LAPACK dispatch overhead. The banded
128
+ # path keeps its per-mode loop as `scipy.linalg.solve_banded` cannot batch.
129
+ LHS_batch = np.empty((NFourier, dim, dim))
130
+ RHS_batch = np.empty((NFourier, dim))
131
+ def _dense_slab(r0, c0, nrows, ncols):
132
+ """Batched (NLayers-1, nrows, ncols) view across interlayer blocks of `LHS`.
133
+ Anchored at LHS coords (r0, c0) for layer 0; layer l advances both row and column
134
+ anchors by `NQuad` along the diagonal. Each slab has shape (nrows, ncols)."""
135
+ base = LHS[r0 : r0 + nrows + (NLayers - 2) * NQuad,
136
+ c0 : c0 + ncols + (NLayers - 2) * NQuad]
137
+ return as_strided(base, shape=(NLayers - 1, nrows, ncols),
138
+ strides=(NQuad * (sR + sC), sR, sC))
109
139
 
110
140
  # The following loops can easily be parallelized, but the speed-up is unlikely to be worth the overhead
111
141
  for m in range(NFourier):
@@ -256,13 +286,13 @@ def _solve_for_coeffs(
256
286
  # --------------------------------------------------------------------------------------------------------------------------
257
287
 
258
288
 
259
- # Assemble LHS and solve (much of this code is replicated in section 4 of the Comprehensive Documentation)
289
+ # Assemble LHS and solve (the old version of this code is replicated in section 4 of the Comprehensive Documentation)
260
290
  # --------------------------------------------------------------------------------------------------------------------------
261
291
  G_0 = G_collect_m[0]
262
292
  G_0_nn = G_0[N:, :N]
263
293
  G_0_np = G_0[N:, N:]
264
294
  E_01 = np.exp(K_collect_m[0, :N] * scaled_tau_arr_with_0[1])[None, :]
265
-
295
+
266
296
  G_L = G_collect_m[-1]
267
297
  G_L_pn = G_L[:N, :N]
268
298
  G_L_nn = G_L[N:, :N]
@@ -271,6 +301,9 @@ def _solve_for_coeffs(
271
301
  E_Lm1L = np.exp(
272
302
  K_collect_m[-1, :N] * (scaled_tau_arr_with_0[-1] - scaled_tau_arr_with_0[-2])
273
303
  )[None, :]
304
+ # Per-layer thickness exponentials, computed once per Fourier mode and reused below.
305
+ # If looping over `l in range(NLayers)`, the line below would be `E_pos_full[l] = exp(-thickness[l] * K_pos[l])`
306
+ E_pos_full = np.exp(-thickness[:, None] * K_collect_m[:, N:]) # (NLayers, N)
274
307
 
275
308
  ################################## Use `sc.linalg.solve_banded` ##################################
276
309
  if use_banded_solver:
@@ -292,44 +325,27 @@ def _solve_for_coeffs(
292
325
  _view_slanted(i_bot, j_bot_left, N)[:] = G_L_pn * E_Lm1L
293
326
  _view_slanted(i_bot, i_bot, N)[:] = G_L_pp
294
327
 
295
- # ---------------- Interlayer / continuity BCs ----------------
296
- for l in range(NLayers - 1):
297
- G_l = G_collect_m[l]
298
- G_l_pn = G_l[:N, :N] # (N,N)
299
- G_l_nn = G_l[N:, :N] # (N,N)
300
- G_l_ap = G_l[:, N:] # (2N,N)
301
-
302
- G_lp1 = G_collect_m[l + 1]
303
- G_lp1_an = G_lp1[:, :N] # (2N,N)
304
- G_lp1_pp = G_lp1[:N, N:] # (N,N)
305
- G_lp1_np = G_lp1[N:, N:] # (N,N)
306
-
307
- tau_lm1 = scaled_tau_arr_with_0[l]
308
- tau_l = scaled_tau_arr_with_0[l + 1]
309
- tau_lp1 = scaled_tau_arr_with_0[l + 2]
310
-
311
- E_lm1l = np.exp(K_collect_m[l, N:] * (tau_lm1 - tau_l))[None, :] # (N,)
312
- E_llp1 = np.exp(K_collect_m[l + 1, N:] * (tau_l - tau_lp1))[None, :] # (N,)
328
+ # ---------------- Interlayer / continuity BCs (batched across layers) ----------------
329
+ E_lm1l = E_pos_full[:-1, None, :] # (NLayers-1, 1, N)
330
+ E_llp1 = E_pos_full[1:, None, :] # (NLayers-1, 1, N)
331
+ G_lo = G_collect_m[:-1] # (NLayers-1, NQuad, NQuad)
332
+ G_hi = G_collect_m[1:] # (NLayers-1, NQuad, NQuad)
313
333
 
314
- start_row = N + l * NQuad
315
- start_col = l * NQuad
316
-
317
- _view_slanted(start_row, start_col, N)[:] = G_l_pn * E_lm1l
318
- _view_slanted(start_row + N, start_col, N)[:] = G_l_nn * E_lm1l
319
- _view_slanted(start_row, start_col + N, NQuad)[:] = G_l_ap
320
-
321
- _view_slanted(start_row, start_col + NQuad, NQuad)[:] = -G_lp1_an
322
- _view_slanted(start_row, start_col + 3 * N, N)[:] = -G_lp1_pp * E_llp1
323
- _view_slanted(start_row + N, start_col + 3 * N, N)[:] = -G_lp1_np * E_llp1
334
+ _view_slanted_batched(N, 0, N)[:] = G_lo[:, :N, :N] * E_lm1l
335
+ _view_slanted_batched(2 * N, 0, N)[:] = G_lo[:, N:, :N] * E_lm1l
336
+ _view_slanted_batched(N, N, NQuad)[:] = G_lo[:, :, N:]
337
+ _view_slanted_batched(N, NQuad, NQuad)[:] = -G_hi[:, :, :N]
338
+ _view_slanted_batched(N, 3 * N, N)[:] = -G_hi[:, :N, N:] * E_llp1
339
+ _view_slanted_batched(2 * N, 3 * N, N)[:] = -G_hi[:, N:, N:] * E_llp1
324
340
 
325
341
  # Solve the system
326
- C_m = sc.linalg.solve_banded(
342
+ C_collect[m, :] = sc.linalg.solve_banded(
327
343
  (Nsupsubdiags, Nsupsubdiags),
328
344
  LHS_dof,
329
345
  RHS,
330
346
  False,
331
347
  True,
332
- False,
348
+ True,
333
349
  )
334
350
 
335
351
  ##################################################################################################
@@ -348,43 +364,28 @@ def _solve_for_coeffs(
348
364
  LHS[-N:, -NQuad : -N] = G_L_pn * E_Lm1L
349
365
  LHS[-N:, -N:] = G_L_pp
350
366
 
351
- # ---------------- Interlayer / continuity BCs ----------------
352
- for l in range(NLayers - 1):
353
- G_l = G_collect_m[l]
354
- G_l_pn = G_l[:N, :N]
355
- G_l_nn = G_l[N:, :N]
356
- G_l_ap = G_l[:, N:]
357
-
358
- G_lp1 = G_collect_m[l + 1]
359
- G_lp1_an = G_lp1[:, :N]
360
- G_lp1_pp = G_lp1[:N, N:]
361
- G_lp1_np = G_lp1[N:, N:]
362
-
363
- scaled_tau_arr_lm1 = scaled_tau_arr_with_0[l]
364
- scaled_tau_arr_l = scaled_tau_arr_with_0[l + 1]
365
- scaled_tau_arr_lp1 = scaled_tau_arr_with_0[l + 2]
366
-
367
- # Postive eigenvalues
368
- E_lm1l = np.exp(K_collect_m[l, N:] * (scaled_tau_arr_lm1 - scaled_tau_arr_l))
369
- E_llp1 = np.exp(K_collect_m[l + 1, N:] * (scaled_tau_arr_l - scaled_tau_arr_lp1))
370
-
371
- start_row = N + l * NQuad
372
- start_col = l * NQuad
373
-
374
- LHS[start_row : N + start_row, start_col : N + start_col] = G_l_pn * E_lm1l
375
- LHS[N + start_row : NQuad + start_row, start_col : N + start_col] = G_l_nn * E_lm1l
376
- LHS[start_row : NQuad + start_row, N + start_col : NQuad + start_col] = G_l_ap
377
-
378
- LHS[start_row : NQuad + start_row, NQuad + start_col : 3 * N + start_col] = -G_lp1_an
379
- LHS[start_row : N + start_row, 3 * N + start_col : 4 * N + start_col] = -G_lp1_pp * E_llp1
380
- LHS[N + start_row : NQuad + start_row, 3 * N + start_col : 4 * N + start_col] = -G_lp1_np * E_llp1
381
-
382
- # Solve the system
383
- C_m = np.linalg.solve(LHS, RHS)
367
+ # ---------------- Interlayer / continuity BCs (batched across layers) ----------------
368
+ E_lm1l = E_pos_full[:-1, None, :] # (NLayers-1, 1, N)
369
+ E_llp1 = E_pos_full[1:, None, :] # (NLayers-1, 1, N)
370
+ G_lo = G_collect_m[:-1]
371
+ G_hi = G_collect_m[1:]
372
+
373
+ _dense_slab(N, 0, N, N)[:] = G_lo[:, :N, :N] * E_lm1l
374
+ _dense_slab(2 * N, 0, N, N)[:] = G_lo[:, N:, :N] * E_lm1l
375
+ _dense_slab(N, N, NQuad, N)[:] = G_lo[:, :, N:]
376
+ _dense_slab(N, NQuad, NQuad, N)[:] = -G_hi[:, :, :N]
377
+ _dense_slab(N, 3 * N, N, N)[:] = -G_hi[:, :N, N:] * E_llp1
378
+ _dense_slab(2 * N, 3 * N, N, N)[:] = -G_hi[:, N:, N:] * E_llp1
384
379
 
385
- ##################################################################################################
380
+ # Defer to the single batched solve over all Fourier modes below
381
+ LHS_batch[m] = LHS
382
+ RHS_batch[m] = RHS
383
+
384
+ ##################################################################################################
386
385
  # --------------------------------------------------------------------------------------------------------------------------
387
-
388
- C_collect[m, :] = C_m
389
-
386
+
387
+ if not use_banded_solver:
388
+ # One batched call for all Fourier modes
389
+ C_collect = np.linalg.solve(LHS_batch, RHS_batch[:, :, None])[:, :, 0]
390
+
390
391
  return G_collect * C_collect.reshape((NFourier, NLayers, 1, NQuad))