PythonicDISORT 1.6__tar.gz → 1.8__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PythonicDISORT
3
- Version: 1.6
3
+ Version: 1.8
4
4
  Summary: Discrete Ordinates Solver for the (1D) Radiative Transfer Equation in a single or multi-layer atmosphere.
5
5
  Author-email: Dion HO Jia Xu <dh3065@columbia.edu>
6
6
  Project-URL: Homepage, https://github.com/LDEO-CREW/Pythonic-DISORT
@@ -26,15 +26,15 @@ Requires-Dist: matplotlib>=3.6.0; extra == "notebook-dependencies"
26
26
  # Introduction
27
27
  The PythonicDISORT package is a Discrete Ordinates Solver for the (1D) Radiative Transfer Equation
28
28
  in a plane-parallel, horizontally homogeneous atmosphere.
29
- It is coded entirely in Python 3 and is a reimplementation instead of a wrapper.
30
- While PythonicDISORT has been optimized for speed, it will naturally be slower than similar FORTRAN algorithms.
31
- On the other hand, PythonicDISORT should be easier to install, use, and modify than FORTRAN-based Discrete Ordinates Solvers.
32
-
33
- PythonicDISORT is based on Stamnes' FORTRAN DISORT (see References, in particular [2, 3, 8]) and has its main features: multi-layer solver,
34
- delta-_M_ scaling, Nakajima-Tanaka (NT) corrections, only flux option, direct beam source, isotropic internal source (blackbody emission),
35
- Dirichlet boundary conditions (diffuse flux boundary sources), Bi-Directional Reflectance Function (BDRF) for surface reflection,
36
- and interpolation with respect to polar angle.
37
- In addition, we added a subroutine to compute actinic fluxes to satisfy a user request, and integration with respect to optical depth was also added.
29
+ It is coded entirely in Python 3 and is not a wrapper.
30
+ PythonicDISORT has been optimized for speed and **as of v1.8 is one-third the speed of FORTRAN DISORT while being NumPy and Scipy only**.
31
+ PythonicDISORT is intended to be easier to install, use, and modify than FORTRAN-based Discrete Ordinates Solvers.
32
+
33
+ PythonicDISORT is based on Stamnes' FORTRAN DISORT (see References, in particular [2, 3, 8]),
34
+ as of v1.8 the only **missing features** are $\delta$-M+ scaling (PythonicDISORT instead implements NT corrections),
35
+ the Chapman function for near-horizon solar angles, and pseudo-spherical corrections.
36
+ On the other hand, we added a subroutine to compute actinic fluxes to satisfy a user request,
37
+ added analytic integration with respect to optical depth, and improved the handling of singularities.
38
38
  Further feature requests as well as feedback are welcome.
39
39
 
40
40
  You may contact me, Dion, through dh3065@columbia.edu.
@@ -70,8 +70,11 @@ These notebooks double up as examples of how to use PythonicDISORT. The tests wh
70
70
  * Test Problem 7: Absorption + Scattering + All Possible Sources, Lambertian and Hapke Surface Reflectivities (one layer)
71
71
  * Test Problem 8: Absorbing / Isotropic-Scattering Medium (multiple layers)
72
72
  * Test Problem 9: General Emitting / Absorbing / Scattering Medium (multiple layers)
73
- * Test Problem 11: Single-Layer vs. Multiple Layers (no corresponding Jupyter Notebook)
74
- * Test Problem I: Antiderivative / integration functionality (no corresponding Jupyter Notebook)
73
+
74
+ (no corresponding Jupyter Notebook)
75
+ * Test Problem 11: Single-Layer vs. Multiple Layers
76
+ * Test Problem I: Antiderivative / integration functionality
77
+ * Test Problem Interp: Interpolation + NT Correction functionality
75
78
 
76
79
  # Installation
77
80
 
@@ -4,15 +4,15 @@
4
4
  # Introduction
5
5
  The PythonicDISORT package is a Discrete Ordinates Solver for the (1D) Radiative Transfer Equation
6
6
  in a plane-parallel, horizontally homogeneous atmosphere.
7
- It is coded entirely in Python 3 and is a reimplementation instead of a wrapper.
8
- While PythonicDISORT has been optimized for speed, it will naturally be slower than similar FORTRAN algorithms.
9
- On the other hand, PythonicDISORT should be easier to install, use, and modify than FORTRAN-based Discrete Ordinates Solvers.
10
-
11
- PythonicDISORT is based on Stamnes' FORTRAN DISORT (see References, in particular [2, 3, 8]) and has its main features: multi-layer solver,
12
- delta-_M_ scaling, Nakajima-Tanaka (NT) corrections, only flux option, direct beam source, isotropic internal source (blackbody emission),
13
- Dirichlet boundary conditions (diffuse flux boundary sources), Bi-Directional Reflectance Function (BDRF) for surface reflection,
14
- and interpolation with respect to polar angle.
15
- In addition, we added a subroutine to compute actinic fluxes to satisfy a user request, and integration with respect to optical depth was also added.
7
+ It is coded entirely in Python 3 and is not a wrapper.
8
+ PythonicDISORT has been optimized for speed and **as of v1.8 is one-third the speed of FORTRAN DISORT while being NumPy and Scipy only**.
9
+ PythonicDISORT is intended to be easier to install, use, and modify than FORTRAN-based Discrete Ordinates Solvers.
10
+
11
+ PythonicDISORT is based on Stamnes' FORTRAN DISORT (see References, in particular [2, 3, 8]),
12
+ as of v1.8 the only **missing features** are $\delta$-M+ scaling (PythonicDISORT instead implements NT corrections),
13
+ the Chapman function for near-horizon solar angles, and pseudo-spherical corrections.
14
+ On the other hand, we added a subroutine to compute actinic fluxes to satisfy a user request,
15
+ added analytic integration with respect to optical depth, and improved the handling of singularities.
16
16
  Further feature requests as well as feedback are welcome.
17
17
 
18
18
  You may contact me, Dion, through dh3065@columbia.edu.
@@ -48,8 +48,11 @@ These notebooks double up as examples of how to use PythonicDISORT. The tests wh
48
48
  * Test Problem 7: Absorption + Scattering + All Possible Sources, Lambertian and Hapke Surface Reflectivities (one layer)
49
49
  * Test Problem 8: Absorbing / Isotropic-Scattering Medium (multiple layers)
50
50
  * Test Problem 9: General Emitting / Absorbing / Scattering Medium (multiple layers)
51
- * Test Problem 11: Single-Layer vs. Multiple Layers (no corresponding Jupyter Notebook)
52
- * Test Problem I: Antiderivative / integration functionality (no corresponding Jupyter Notebook)
51
+
52
+ (no corresponding Jupyter Notebook)
53
+ * Test Problem 11: Single-Layer vs. Multiple Layers
54
+ * Test Problem I: Antiderivative / integration functionality
55
+ * Test Problem Interp: Interpolation + NT Correction functionality
53
56
 
54
57
  # Installation
55
58
 
@@ -8,7 +8,7 @@ dependencies = [
8
8
  "scipy>=1.8.0",
9
9
  ]
10
10
  name = "PythonicDISORT"
11
- version = "1.6"
11
+ version = "1.8"
12
12
  authors = [
13
13
  { name="Dion HO Jia Xu", email="dh3065@columbia.edu" },
14
14
  ]
@@ -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
  )