AeroViz 0.1.21__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.
Files changed (180) hide show
  1. AeroViz/__init__.py +13 -0
  2. AeroViz/__pycache__/__init__.cpython-312.pyc +0 -0
  3. AeroViz/data/DEFAULT_DATA.csv +1417 -0
  4. AeroViz/data/DEFAULT_PNSD_DATA.csv +1417 -0
  5. AeroViz/data/hysplit_example_data.txt +101 -0
  6. AeroViz/dataProcess/Chemistry/__init__.py +149 -0
  7. AeroViz/dataProcess/Chemistry/__pycache__/__init__.cpython-312.pyc +0 -0
  8. AeroViz/dataProcess/Chemistry/_calculate.py +557 -0
  9. AeroViz/dataProcess/Chemistry/_isoropia.py +150 -0
  10. AeroViz/dataProcess/Chemistry/_mass_volume.py +487 -0
  11. AeroViz/dataProcess/Chemistry/_ocec.py +172 -0
  12. AeroViz/dataProcess/Chemistry/isrpia.cnf +21 -0
  13. AeroViz/dataProcess/Chemistry/isrpia2.exe +0 -0
  14. AeroViz/dataProcess/Optical/PyMieScatt_update.py +577 -0
  15. AeroViz/dataProcess/Optical/_IMPROVE.py +452 -0
  16. AeroViz/dataProcess/Optical/__init__.py +281 -0
  17. AeroViz/dataProcess/Optical/__pycache__/PyMieScatt_update.cpython-312.pyc +0 -0
  18. AeroViz/dataProcess/Optical/__pycache__/__init__.cpython-312.pyc +0 -0
  19. AeroViz/dataProcess/Optical/__pycache__/mie_theory.cpython-312.pyc +0 -0
  20. AeroViz/dataProcess/Optical/_derived.py +518 -0
  21. AeroViz/dataProcess/Optical/_extinction.py +123 -0
  22. AeroViz/dataProcess/Optical/_mie_sd.py +912 -0
  23. AeroViz/dataProcess/Optical/_retrieve_RI.py +243 -0
  24. AeroViz/dataProcess/Optical/coefficient.py +72 -0
  25. AeroViz/dataProcess/Optical/fRH.pkl +0 -0
  26. AeroViz/dataProcess/Optical/mie_theory.py +260 -0
  27. AeroViz/dataProcess/README.md +271 -0
  28. AeroViz/dataProcess/SizeDistr/__init__.py +245 -0
  29. AeroViz/dataProcess/SizeDistr/__pycache__/__init__.cpython-312.pyc +0 -0
  30. AeroViz/dataProcess/SizeDistr/__pycache__/_size_dist.cpython-312.pyc +0 -0
  31. AeroViz/dataProcess/SizeDistr/_size_dist.py +810 -0
  32. AeroViz/dataProcess/SizeDistr/merge/README.md +93 -0
  33. AeroViz/dataProcess/SizeDistr/merge/__init__.py +20 -0
  34. AeroViz/dataProcess/SizeDistr/merge/_merge_v0.py +251 -0
  35. AeroViz/dataProcess/SizeDistr/merge/_merge_v0_1.py +246 -0
  36. AeroViz/dataProcess/SizeDistr/merge/_merge_v1.py +255 -0
  37. AeroViz/dataProcess/SizeDistr/merge/_merge_v2.py +244 -0
  38. AeroViz/dataProcess/SizeDistr/merge/_merge_v3.py +518 -0
  39. AeroViz/dataProcess/SizeDistr/merge/_merge_v4.py +422 -0
  40. AeroViz/dataProcess/SizeDistr/prop.py +62 -0
  41. AeroViz/dataProcess/VOC/__init__.py +14 -0
  42. AeroViz/dataProcess/VOC/__pycache__/__init__.cpython-312.pyc +0 -0
  43. AeroViz/dataProcess/VOC/_potential_par.py +108 -0
  44. AeroViz/dataProcess/VOC/support_voc.json +446 -0
  45. AeroViz/dataProcess/__init__.py +66 -0
  46. AeroViz/dataProcess/__pycache__/__init__.cpython-312.pyc +0 -0
  47. AeroViz/dataProcess/core/__init__.py +272 -0
  48. AeroViz/dataProcess/core/__pycache__/__init__.cpython-312.pyc +0 -0
  49. AeroViz/mcp_server.py +352 -0
  50. AeroViz/plot/__init__.py +13 -0
  51. AeroViz/plot/__pycache__/__init__.cpython-312.pyc +0 -0
  52. AeroViz/plot/__pycache__/bar.cpython-312.pyc +0 -0
  53. AeroViz/plot/__pycache__/box.cpython-312.pyc +0 -0
  54. AeroViz/plot/__pycache__/pie.cpython-312.pyc +0 -0
  55. AeroViz/plot/__pycache__/radar.cpython-312.pyc +0 -0
  56. AeroViz/plot/__pycache__/regression.cpython-312.pyc +0 -0
  57. AeroViz/plot/__pycache__/scatter.cpython-312.pyc +0 -0
  58. AeroViz/plot/__pycache__/violin.cpython-312.pyc +0 -0
  59. AeroViz/plot/bar.py +126 -0
  60. AeroViz/plot/box.py +69 -0
  61. AeroViz/plot/distribution/__init__.py +1 -0
  62. AeroViz/plot/distribution/__pycache__/__init__.cpython-312.pyc +0 -0
  63. AeroViz/plot/distribution/__pycache__/distribution.cpython-312.pyc +0 -0
  64. AeroViz/plot/distribution/distribution.py +576 -0
  65. AeroViz/plot/meteorology/CBPF.py +295 -0
  66. AeroViz/plot/meteorology/__init__.py +3 -0
  67. AeroViz/plot/meteorology/__pycache__/CBPF.cpython-312.pyc +0 -0
  68. AeroViz/plot/meteorology/__pycache__/__init__.cpython-312.pyc +0 -0
  69. AeroViz/plot/meteorology/__pycache__/hysplit.cpython-312.pyc +0 -0
  70. AeroViz/plot/meteorology/__pycache__/wind_rose.cpython-312.pyc +0 -0
  71. AeroViz/plot/meteorology/hysplit.py +93 -0
  72. AeroViz/plot/meteorology/wind_rose.py +77 -0
  73. AeroViz/plot/optical/__init__.py +1 -0
  74. AeroViz/plot/optical/__pycache__/__init__.cpython-312.pyc +0 -0
  75. AeroViz/plot/optical/__pycache__/optical.cpython-312.pyc +0 -0
  76. AeroViz/plot/optical/optical.py +388 -0
  77. AeroViz/plot/pie.py +210 -0
  78. AeroViz/plot/radar.py +184 -0
  79. AeroViz/plot/regression.py +200 -0
  80. AeroViz/plot/scatter.py +174 -0
  81. AeroViz/plot/templates/__init__.py +6 -0
  82. AeroViz/plot/templates/__pycache__/__init__.cpython-312.pyc +0 -0
  83. AeroViz/plot/templates/__pycache__/ammonium_rich.cpython-312.pyc +0 -0
  84. AeroViz/plot/templates/__pycache__/contour.cpython-312.pyc +0 -0
  85. AeroViz/plot/templates/__pycache__/corr_matrix.cpython-312.pyc +0 -0
  86. AeroViz/plot/templates/__pycache__/diurnal_pattern.cpython-312.pyc +0 -0
  87. AeroViz/plot/templates/__pycache__/koschmieder.cpython-312.pyc +0 -0
  88. AeroViz/plot/templates/__pycache__/metal_heatmap.cpython-312.pyc +0 -0
  89. AeroViz/plot/templates/ammonium_rich.py +34 -0
  90. AeroViz/plot/templates/contour.py +47 -0
  91. AeroViz/plot/templates/corr_matrix.py +267 -0
  92. AeroViz/plot/templates/diurnal_pattern.py +61 -0
  93. AeroViz/plot/templates/koschmieder.py +95 -0
  94. AeroViz/plot/templates/metal_heatmap.py +164 -0
  95. AeroViz/plot/timeseries/__init__.py +2 -0
  96. AeroViz/plot/timeseries/__pycache__/__init__.cpython-312.pyc +0 -0
  97. AeroViz/plot/timeseries/__pycache__/template.cpython-312.pyc +0 -0
  98. AeroViz/plot/timeseries/__pycache__/timeseries.cpython-312.pyc +0 -0
  99. AeroViz/plot/timeseries/template.py +47 -0
  100. AeroViz/plot/timeseries/timeseries.py +446 -0
  101. AeroViz/plot/utils/__init__.py +4 -0
  102. AeroViz/plot/utils/__pycache__/__init__.cpython-312.pyc +0 -0
  103. AeroViz/plot/utils/__pycache__/_color.cpython-312.pyc +0 -0
  104. AeroViz/plot/utils/__pycache__/_unit.cpython-312.pyc +0 -0
  105. AeroViz/plot/utils/__pycache__/plt_utils.cpython-312.pyc +0 -0
  106. AeroViz/plot/utils/__pycache__/sklearn_utils.cpython-312.pyc +0 -0
  107. AeroViz/plot/utils/_color.py +71 -0
  108. AeroViz/plot/utils/_unit.py +55 -0
  109. AeroViz/plot/utils/fRH.json +390 -0
  110. AeroViz/plot/utils/plt_utils.py +92 -0
  111. AeroViz/plot/utils/sklearn_utils.py +49 -0
  112. AeroViz/plot/utils/units.json +89 -0
  113. AeroViz/plot/violin.py +80 -0
  114. AeroViz/rawDataReader/FLOW.md +138 -0
  115. AeroViz/rawDataReader/__init__.py +220 -0
  116. AeroViz/rawDataReader/__pycache__/__init__.cpython-312.pyc +0 -0
  117. AeroViz/rawDataReader/config/__init__.py +0 -0
  118. AeroViz/rawDataReader/config/__pycache__/__init__.cpython-312.pyc +0 -0
  119. AeroViz/rawDataReader/config/__pycache__/supported_instruments.cpython-312.pyc +0 -0
  120. AeroViz/rawDataReader/config/supported_instruments.py +135 -0
  121. AeroViz/rawDataReader/core/__init__.py +658 -0
  122. AeroViz/rawDataReader/core/__pycache__/__init__.cpython-312.pyc +0 -0
  123. AeroViz/rawDataReader/core/__pycache__/logger.cpython-312.pyc +0 -0
  124. AeroViz/rawDataReader/core/__pycache__/pre_process.cpython-312.pyc +0 -0
  125. AeroViz/rawDataReader/core/__pycache__/qc.cpython-312.pyc +0 -0
  126. AeroViz/rawDataReader/core/__pycache__/report.cpython-312.pyc +0 -0
  127. AeroViz/rawDataReader/core/logger.py +171 -0
  128. AeroViz/rawDataReader/core/pre_process.py +308 -0
  129. AeroViz/rawDataReader/core/qc.py +961 -0
  130. AeroViz/rawDataReader/core/report.py +579 -0
  131. AeroViz/rawDataReader/script/AE33.py +173 -0
  132. AeroViz/rawDataReader/script/AE43.py +151 -0
  133. AeroViz/rawDataReader/script/APS.py +339 -0
  134. AeroViz/rawDataReader/script/Aurora.py +191 -0
  135. AeroViz/rawDataReader/script/BAM1020.py +90 -0
  136. AeroViz/rawDataReader/script/BC1054.py +161 -0
  137. AeroViz/rawDataReader/script/EPA.py +79 -0
  138. AeroViz/rawDataReader/script/GRIMM.py +68 -0
  139. AeroViz/rawDataReader/script/IGAC.py +140 -0
  140. AeroViz/rawDataReader/script/MA350.py +179 -0
  141. AeroViz/rawDataReader/script/Minion.py +218 -0
  142. AeroViz/rawDataReader/script/NEPH.py +199 -0
  143. AeroViz/rawDataReader/script/OCEC.py +173 -0
  144. AeroViz/rawDataReader/script/Q-ACSM.py +12 -0
  145. AeroViz/rawDataReader/script/SMPS.py +389 -0
  146. AeroViz/rawDataReader/script/TEOM.py +181 -0
  147. AeroViz/rawDataReader/script/VOC.py +106 -0
  148. AeroViz/rawDataReader/script/Xact.py +244 -0
  149. AeroViz/rawDataReader/script/__init__.py +28 -0
  150. AeroViz/rawDataReader/script/__pycache__/AE33.cpython-312.pyc +0 -0
  151. AeroViz/rawDataReader/script/__pycache__/AE43.cpython-312.pyc +0 -0
  152. AeroViz/rawDataReader/script/__pycache__/APS.cpython-312.pyc +0 -0
  153. AeroViz/rawDataReader/script/__pycache__/Aurora.cpython-312.pyc +0 -0
  154. AeroViz/rawDataReader/script/__pycache__/BAM1020.cpython-312.pyc +0 -0
  155. AeroViz/rawDataReader/script/__pycache__/BC1054.cpython-312.pyc +0 -0
  156. AeroViz/rawDataReader/script/__pycache__/EPA.cpython-312.pyc +0 -0
  157. AeroViz/rawDataReader/script/__pycache__/GRIMM.cpython-312.pyc +0 -0
  158. AeroViz/rawDataReader/script/__pycache__/IGAC.cpython-312.pyc +0 -0
  159. AeroViz/rawDataReader/script/__pycache__/MA350.cpython-312.pyc +0 -0
  160. AeroViz/rawDataReader/script/__pycache__/Minion.cpython-312.pyc +0 -0
  161. AeroViz/rawDataReader/script/__pycache__/NEPH.cpython-312.pyc +0 -0
  162. AeroViz/rawDataReader/script/__pycache__/OCEC.cpython-312.pyc +0 -0
  163. AeroViz/rawDataReader/script/__pycache__/Q-ACSM.cpython-312.pyc +0 -0
  164. AeroViz/rawDataReader/script/__pycache__/SMPS.cpython-312.pyc +0 -0
  165. AeroViz/rawDataReader/script/__pycache__/TEOM.cpython-312.pyc +0 -0
  166. AeroViz/rawDataReader/script/__pycache__/VOC.cpython-312.pyc +0 -0
  167. AeroViz/rawDataReader/script/__pycache__/Xact.cpython-312.pyc +0 -0
  168. AeroViz/rawDataReader/script/__pycache__/__init__.cpython-312.pyc +0 -0
  169. AeroViz/tools/__init__.py +2 -0
  170. AeroViz/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  171. AeroViz/tools/__pycache__/database.cpython-312.pyc +0 -0
  172. AeroViz/tools/__pycache__/dataclassifier.cpython-312.pyc +0 -0
  173. AeroViz/tools/database.py +95 -0
  174. AeroViz/tools/dataclassifier.py +117 -0
  175. AeroViz/tools/dataprinter.py +58 -0
  176. aeroviz-0.1.21.dist-info/METADATA +294 -0
  177. aeroviz-0.1.21.dist-info/RECORD +180 -0
  178. aeroviz-0.1.21.dist-info/WHEEL +5 -0
  179. aeroviz-0.1.21.dist-info/licenses/LICENSE +21 -0
  180. aeroviz-0.1.21.dist-info/top_level.txt +1 -0
@@ -0,0 +1,912 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Mie Scattering Calculation for Size Distribution Data
4
+
5
+ This module provides vectorized Mie scattering calculations optimized for
6
+ particle size distribution (PSD) data stored in pandas DataFrames.
7
+
8
+ Based on: http://pymiescatt.readthedocs.io/en/latest/forward.html
9
+
10
+ Theory:
11
+ Mie theory describes the scattering of electromagnetic radiation by
12
+ spherical particles. The key outputs are:
13
+ - Q_ext: Extinction efficiency (scattering + absorption)
14
+ - Q_sca: Scattering efficiency
15
+ - Q_abs: Absorption efficiency (Q_ext - Q_sca)
16
+ """
17
+
18
+ import warnings
19
+ import numpy as np
20
+ import pandas as pd
21
+ from scipy.integrate import trapezoid
22
+ from scipy.special import jv, yv # Bessel functions
23
+
24
+
25
+ # =============================================================================
26
+ # PSD Type Detection and Integration
27
+ # =============================================================================
28
+
29
+ def _detect_psd_type(values: np.ndarray, diameter: np.ndarray) -> tuple[str, str]:
30
+ """
31
+ Auto-detect whether PSD data is dN/dlogDp or dN.
32
+
33
+ Parameters
34
+ ----------
35
+ values : np.ndarray
36
+ PSD values, shape (n_bins,) or (n_times, n_bins)
37
+ diameter : np.ndarray
38
+ Particle diameters in nm
39
+
40
+ Returns
41
+ -------
42
+ psd_type : str
43
+ 'dNdlogDp' or 'dN'
44
+ confidence : str
45
+ 'high', 'medium', or 'low'
46
+ """
47
+ log_dp = np.log10(diameter)
48
+ dlogdp = np.diff(log_dp).mean()
49
+
50
+ # Use mean values if 2D
51
+ if values.ndim > 1:
52
+ values_1d = np.nanmean(values, axis=0)
53
+ else:
54
+ values_1d = values
55
+
56
+ # Calculate total N under both assumptions
57
+ N_as_dNdlogDp = trapezoid(values_1d, x=log_dp)
58
+ N_as_dN = np.nansum(values_1d)
59
+
60
+ # Typical total particle number: 1e2 - 1e7 #/cm³
61
+ typical_min, typical_max = 1e2, 1e7
62
+
63
+ dNdlogDp_ok = typical_min <= N_as_dNdlogDp <= typical_max
64
+ dN_ok = typical_min <= N_as_dN <= typical_max
65
+
66
+ # Calculate the ratio (should be ~1/dlogDp if dNdlogDp)
67
+ ratio = N_as_dN / N_as_dNdlogDp if N_as_dNdlogDp > 0 else float('inf')
68
+ expected_ratio = 1 / dlogdp
69
+
70
+ if dNdlogDp_ok and not dN_ok:
71
+ return 'dNdlogDp', 'high'
72
+ elif dN_ok and not dNdlogDp_ok:
73
+ return 'dN', 'high'
74
+ elif dNdlogDp_ok and dN_ok:
75
+ # Both reasonable - check if ratio matches expected
76
+ if 0.5 * expected_ratio < ratio < 2 * expected_ratio:
77
+ return 'dNdlogDp', 'medium'
78
+ else:
79
+ return 'dN', 'medium'
80
+ else:
81
+ # Neither reasonable - default to dNdlogDp with warning
82
+ return 'dNdlogDp', 'low'
83
+
84
+
85
+ def _integrate_psd(
86
+ values: np.ndarray,
87
+ diameter: np.ndarray,
88
+ psd_type: str = 'dNdlogDp'
89
+ ) -> np.ndarray:
90
+ """
91
+ Integrate over particle size distribution.
92
+
93
+ Parameters
94
+ ----------
95
+ values : np.ndarray
96
+ Integrand values, shape (n_times, n_bins)
97
+ diameter : np.ndarray
98
+ Particle diameters in nm
99
+ psd_type : str
100
+ 'dNdlogDp' or 'dN'
101
+
102
+ Returns
103
+ -------
104
+ result : np.ndarray
105
+ Integrated values, shape (n_times,)
106
+ """
107
+ if psd_type == 'dNdlogDp':
108
+ log_dp = np.log10(diameter)
109
+ return trapezoid(values, x=log_dp, axis=-1)
110
+ else: # dN
111
+ return np.sum(values, axis=-1)
112
+
113
+
114
+ def calculate_mie_coefficients(
115
+ refractive_index: np.ndarray,
116
+ size_parameter: np.ndarray,
117
+ n_max: np.ndarray,
118
+ n_terms: pd.DataFrame
119
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
120
+ """
121
+ Calculate Mie scattering coefficients (a_n, b_n) for multiple particles.
122
+
123
+ This implements the core Mie theory calculation using Bessel functions
124
+ and the logarithmic derivative method for numerical stability.
125
+
126
+ Parameters
127
+ ----------
128
+ refractive_index : np.ndarray
129
+ Complex refractive index (m = n + ik) for each time point.
130
+ Shape: (n_times,)
131
+ size_parameter : np.ndarray
132
+ Size parameter x = π * diameter / wavelength for each size bin.
133
+ Shape: (n_bins,)
134
+ n_max : np.ndarray
135
+ Maximum number of terms needed for each size bin.
136
+ Shape: (n_bins,)
137
+ n_terms : pd.DataFrame
138
+ Term indices for the series expansion.
139
+ Shape: (n_bins, max_terms)
140
+
141
+ Returns
142
+ -------
143
+ Q_ext : pd.DataFrame
144
+ Extinction efficiency for each (size_bin, time_point).
145
+ Q_sca : pd.DataFrame
146
+ Scattering efficiency for each (size_bin, time_point).
147
+
148
+ Notes
149
+ -----
150
+ The Mie coefficients a_n and b_n are calculated using:
151
+ - Riccati-Bessel functions (ψ, χ)
152
+ - Logarithmic derivative D_n(mx) computed via downward recurrence
153
+ """
154
+ m = refractive_index
155
+ x = size_parameter
156
+ n_bins = len(x)
157
+ n_times = len(m)
158
+
159
+ # Bessel function order: ν = n + 0.5
160
+ nu = n_terms.copy() + 0.5
161
+
162
+ # Coefficient for series summation: 2n + 1
163
+ coeff_2n_plus_1 = 2 * n_terms.copy() + 1
164
+
165
+ # === Calculate Riccati-Bessel functions ===
166
+ # ψ_n(x) = sqrt(πx/2) * J_{n+1/2}(x) [Bessel J]
167
+ # χ_n(x) = -sqrt(πx/2) * Y_{n+1/2}(x) [Bessel Y]
168
+ sqrt_factor = np.sqrt(0.5 * np.pi * x)
169
+
170
+ psi_n = sqrt_factor.reshape(-1, 1) * jv(nu, x.reshape(-1, 1))
171
+ chi_n = -sqrt_factor.reshape(-1, 1) * yv(nu, x.reshape(-1, 1))
172
+
173
+ # ψ_{n-1}(x) and χ_{n-1}(x) with boundary conditions
174
+ psi_n_minus_1 = pd.concat(
175
+ [pd.DataFrame(np.sin(x)), psi_n.mask(n_terms == n_max.reshape(-1, 1))],
176
+ axis=1
177
+ )
178
+ psi_n_minus_1.columns = np.arange(len(psi_n_minus_1.columns))
179
+ psi_n_minus_1 = psi_n_minus_1[n_terms.columns]
180
+
181
+ chi_n_minus_1 = pd.concat(
182
+ [pd.DataFrame(np.cos(x)), chi_n.mask(n_terms == n_max.reshape(-1, 1))],
183
+ axis=1
184
+ )
185
+ chi_n_minus_1.columns = np.arange(len(chi_n_minus_1.columns))
186
+ chi_n_minus_1 = chi_n_minus_1[n_terms.columns]
187
+
188
+ # Hankel function: ξ_n(x) = ψ_n(x) - i*χ_n(x)
189
+ xi_n = psi_n - 1j * chi_n
190
+ xi_n_minus_1 = psi_n_minus_1 - 1j * chi_n_minus_1
191
+
192
+ # === Calculate logarithmic derivative D_n(mx) ===
193
+ mx = m.reshape(-1, 1) * x # Complex argument
194
+
195
+ # Number of terms needed for downward recurrence
196
+ nmx_array = np.round(
197
+ np.max(
198
+ np.hstack([[n_max] * n_times, np.abs(mx)]).reshape(n_times, 2, -1),
199
+ axis=1
200
+ ) + 16
201
+ )
202
+
203
+ # Initialize output DataFrames
204
+ Q_ext = pd.DataFrame(columns=m.flatten(), index=n_terms.index)
205
+ Q_sca = pd.DataFrame(columns=m.flatten(), index=n_terms.index)
206
+
207
+ # Normalize n/x for later use
208
+ n_over_x = n_terms / x.reshape(-1, 1)
209
+
210
+ # === Main calculation loop over size bins ===
211
+ for bin_idx, (nmx_values, mx_values, nmax_bin) in enumerate(
212
+ zip(nmx_array.T, mx.T, n_max)
213
+ ):
214
+ # Logarithmic derivative D_n(mx) via downward recurrence
215
+ D_n = pd.DataFrame(
216
+ np.nan,
217
+ index=np.arange(n_times),
218
+ columns=n_terms.columns,
219
+ dtype=complex
220
+ )
221
+
222
+ # Group by nmx value for efficient computation
223
+ for nmx, time_indices in pd.DataFrame(nmx_values).groupby(0).groups.items():
224
+ inv_mx = 1 / mx_values[time_indices]
225
+ nmx_int = int(nmx)
226
+
227
+ # Downward recurrence: D_{n-1} = n/mx - 1/(D_n + n/mx)
228
+ D_recurrence = np.zeros((len(time_indices), nmx_int), dtype=complex)
229
+ for idx in range(nmx_int - 1, 1, -1):
230
+ D_recurrence[:, idx - 1] = (
231
+ idx * inv_mx - 1 / (D_recurrence[:, idx] + idx * inv_mx)
232
+ )
233
+
234
+ D_n.loc[time_indices, 0:int(nmax_bin) - 1] = D_recurrence[:, 1:int(nmax_bin) + 1]
235
+
236
+ # Get values for this size bin
237
+ n_x = n_over_x.loc[bin_idx]
238
+ psi = psi_n.loc[bin_idx]
239
+ psi_prev = psi_n_minus_1.loc[bin_idx]
240
+ xi = xi_n.loc[bin_idx]
241
+ xi_prev = xi_n_minus_1.loc[bin_idx]
242
+ coeff = coeff_2n_plus_1.loc[bin_idx].values
243
+
244
+ # === Calculate Mie coefficients a_n and b_n ===
245
+ # a_n = (D_n/m + n/x) * ψ_n - ψ_{n-1}
246
+ # ─────────────────────────────────
247
+ # (D_n/m + n/x) * ξ_n - ξ_{n-1}
248
+ numerator_a = D_n / m.reshape(-1, 1) + n_x
249
+ a_n = (numerator_a * psi - psi_prev) / (numerator_a * xi - xi_prev)
250
+
251
+ # b_n = (m*D_n + n/x) * ψ_n - ψ_{n-1}
252
+ # ─────────────────────────────────
253
+ # (m*D_n + n/x) * ξ_n - ξ_{n-1}
254
+ numerator_b = D_n * m.reshape(-1, 1) + n_x
255
+ b_n = (numerator_b * psi - psi_prev) / (numerator_b * xi - xi_prev)
256
+
257
+ # === Calculate efficiencies ===
258
+ # Q_ext = (2/x²) * Σ (2n+1) * Re(a_n + b_n)
259
+ # Q_sca = (2/x²) * Σ (2n+1) * (|a_n|² + |b_n|²)
260
+ real_a, real_b = np.real(a_n), np.real(b_n)
261
+ imag_a, imag_b = np.imag(a_n), np.imag(b_n)
262
+
263
+ Q_ext.loc[bin_idx] = np.nansum(coeff * (real_a + real_b), axis=1)
264
+ Q_sca.loc[bin_idx] = np.nansum(
265
+ coeff * (real_a**2 + real_b**2 + imag_a**2 + imag_b**2),
266
+ axis=1
267
+ )
268
+
269
+ return Q_ext, Q_sca
270
+
271
+
272
+ def calculate_mie_efficiencies(
273
+ refractive_index: np.ndarray,
274
+ wavelength: float,
275
+ diameter: np.ndarray
276
+ ) -> tuple[np.ndarray, np.ndarray]:
277
+ """
278
+ Calculate Mie extinction and scattering efficiencies (Q).
279
+
280
+ Parameters
281
+ ----------
282
+ refractive_index : np.ndarray
283
+ Complex refractive index for each time point. Shape: (n_times,)
284
+ wavelength : float
285
+ Wavelength of incident light in nm.
286
+ diameter : np.ndarray
287
+ Particle diameters in nm. Shape: (n_bins,)
288
+
289
+ Returns
290
+ -------
291
+ Q_ext : np.ndarray
292
+ Extinction efficiency. Shape: (n_times, n_bins)
293
+ Q_sca : np.ndarray
294
+ Scattering efficiency. Shape: (n_times, n_bins)
295
+
296
+ Notes
297
+ -----
298
+ Size parameter: x = π * d / λ
299
+ The number of terms needed scales as: n_max ≈ 2 + x + 4*x^(1/3)
300
+ """
301
+ # Size parameter: x = πd/λ
302
+ size_parameter = np.pi * diameter / wavelength
303
+
304
+ # Maximum number of terms in series expansion
305
+ n_max = np.round(2 + size_parameter + 4 * size_parameter**(1/3))
306
+
307
+ # Create term index matrix (masked where n > n_max for each bin)
308
+ max_terms = int(n_max.max())
309
+ n_terms = pd.DataFrame([np.arange(1, max_terms + 1)] * len(n_max))
310
+ n_terms = n_terms.mask(n_terms > n_max.reshape(-1, 1))
311
+
312
+ # Calculate Mie coefficients
313
+ Q_ext_raw, Q_sca_raw = calculate_mie_coefficients(
314
+ refractive_index, size_parameter, n_max, n_terms
315
+ )
316
+
317
+ # Apply normalization factor: 2/x²
318
+ norm_factor = (2 / size_parameter**2).reshape(-1, 1)
319
+ Q_ext = (norm_factor * Q_ext_raw).values.T.astype(float)
320
+ Q_sca = (norm_factor * Q_sca_raw).values.T.astype(float)
321
+
322
+ return Q_ext, Q_sca
323
+
324
+
325
+ def Mie_SD(
326
+ refractive_index: np.ndarray,
327
+ wavelength: float,
328
+ psd: pd.DataFrame,
329
+ psd_type: str = 'auto',
330
+ multi_ri_per_psd: bool = False,
331
+ precomputed_Q: tuple = None
332
+ ) -> pd.DataFrame | dict:
333
+ """
334
+ Calculate optical properties from particle size distribution using Mie theory.
335
+
336
+ This function integrates Mie efficiencies over the particle size distribution
337
+ to obtain bulk optical properties (extinction, scattering, absorption).
338
+
339
+ Parameters
340
+ ----------
341
+ refractive_index : np.ndarray
342
+ Complex refractive index (m = n + ik).
343
+ - If multi_ri_per_psd=False: Shape (n_times,), one m per time point
344
+ - If multi_ri_per_psd=True: Shape (n_ri,), multiple m tested per PSD
345
+ wavelength : float
346
+ Wavelength of incident light in nm.
347
+ psd : pd.DataFrame
348
+ Particle size distribution data.
349
+ - Columns: particle diameters (nm)
350
+ - Rows: time points
351
+ - Values: dN/dlogDp or dN depending on psd_type
352
+ psd_type : str, default='auto'
353
+ Type of PSD input:
354
+ - 'dNdlogDp': Number concentration per log bin width (#/cm³)
355
+ - 'dN': Number concentration per bin (#/cm³/bin)
356
+ - 'auto': Auto-detect with warning if uncertain
357
+ multi_ri_per_psd : bool, default=False
358
+ If True, calculate for multiple refractive indices per PSD row.
359
+ Useful for refractive index retrieval.
360
+ precomputed_Q : tuple, optional
361
+ Pre-computed (Q_ext, Q_sca) to avoid recalculation.
362
+
363
+ Returns
364
+ -------
365
+ pd.DataFrame or dict
366
+ If multi_ri_per_psd=False:
367
+ DataFrame with columns ['ext', 'sca', 'abs'] in Mm⁻¹
368
+ If multi_ri_per_psd=True:
369
+ dict with keys 'ext', 'sca', 'abs', each a DataFrame
370
+ with refractive indices as columns
371
+
372
+ Examples
373
+ --------
374
+ >>> import pandas as pd
375
+ >>> import numpy as np
376
+ >>>
377
+ >>> # Create sample PSD data (100 time points, 50 size bins)
378
+ >>> dp = np.logspace(1, 3, 50) # 10-1000 nm
379
+ >>> psd = pd.DataFrame(np.random.rand(100, 50) * 1000, columns=dp)
380
+ >>>
381
+ >>> # Refractive index for each time point
382
+ >>> m = np.array([complex(1.5, 0.02)] * 100)
383
+ >>>
384
+ >>> # Calculate optical properties (explicit dN/dlogDp input)
385
+ >>> result = Mie_SD(m, wavelength=550, psd=psd, psd_type='dNdlogDp')
386
+ >>> print(result[['ext', 'sca', 'abs']].head())
387
+
388
+ Notes
389
+ -----
390
+ The optical coefficients are calculated as:
391
+
392
+ For dN/dlogDp input:
393
+ b = ∫ Q(Dp) * π/4 * Dp² * (dN/dlogDp) * dlogDp
394
+
395
+ For dN input:
396
+ b = Σ Q(Dp) * π/4 * Dp² * dN
397
+
398
+ Where:
399
+ - Q: Mie efficiency (extinction, scattering, or absorption)
400
+ - Dp: particle diameter
401
+ - The factor 1e-6 converts from nm² to Mm⁻¹
402
+ """
403
+ # Ensure psd is a DataFrame
404
+ if not isinstance(psd, pd.DataFrame):
405
+ psd = pd.DataFrame(psd).T
406
+
407
+ # Validate input dimensions
408
+ if not multi_ri_per_psd and len(refractive_index) != len(psd):
409
+ raise ValueError(
410
+ f"Refractive index array length ({len(refractive_index)}) must match "
411
+ f"PSD row count ({len(psd)}). Set multi_ri_per_psd=True for RI retrieval."
412
+ )
413
+
414
+ # Extract diameter and number concentration
415
+ diameter = psd.columns.values.astype(float) # nm
416
+ number_conc = psd.values # dN/dlogDp or dN
417
+
418
+ # Auto-detect PSD type if needed
419
+ if psd_type == 'auto':
420
+ detected_type, confidence = _detect_psd_type(number_conc, diameter)
421
+ psd_type = detected_type
422
+
423
+ if confidence == 'low':
424
+ warnings.warn(
425
+ f"PSD type auto-detection has low confidence. "
426
+ f"Assuming '{detected_type}'. Please specify psd_type explicitly "
427
+ f"('dNdlogDp' or 'dN') to avoid incorrect results.",
428
+ UserWarning
429
+ )
430
+ elif confidence == 'medium':
431
+ warnings.warn(
432
+ f"PSD type auto-detected as '{detected_type}' with medium confidence. "
433
+ f"If results seem incorrect, try specifying psd_type explicitly.",
434
+ UserWarning
435
+ )
436
+ # High confidence: no warning
437
+
438
+ # Cross-sectional area × number concentration (scaled to Mm⁻¹)
439
+ # π/4 * Dp² * N * 1e-6 (nm² to Mm⁻¹ conversion)
440
+ cross_section_area = np.pi * (diameter / 2)**2 * number_conc * 1e-6
441
+
442
+ # Get or calculate Mie efficiencies
443
+ if precomputed_Q:
444
+ Q_ext, Q_sca = precomputed_Q
445
+ else:
446
+ Q_ext, Q_sca = calculate_mie_efficiencies(
447
+ refractive_index, wavelength, diameter
448
+ )
449
+
450
+ # === Integrate over size distribution ===
451
+ if multi_ri_per_psd:
452
+ # Multiple refractive indices per PSD (for RI retrieval)
453
+ n_times = len(psd)
454
+ n_ri = len(refractive_index)
455
+
456
+ # Expand arrays for broadcasting
457
+ area_expanded = np.repeat(
458
+ cross_section_area, n_ri, axis=0
459
+ ).reshape(n_times, n_ri, -1)
460
+
461
+ Q_ext_expanded = np.repeat(
462
+ Q_ext[np.newaxis, :, :], n_times, axis=0
463
+ ).reshape(n_times, n_ri, -1)
464
+
465
+ Q_sca_expanded = np.repeat(
466
+ Q_sca[np.newaxis, :, :], n_times, axis=0
467
+ ).reshape(n_times, n_ri, -1)
468
+
469
+ # Integrate based on psd_type
470
+ integrand_ext = area_expanded * Q_ext_expanded
471
+ integrand_sca = area_expanded * Q_sca_expanded
472
+
473
+ if psd_type == 'dNdlogDp':
474
+ log_dp = np.log10(diameter)
475
+ ext_values = trapezoid(integrand_ext, x=log_dp, axis=-1)
476
+ sca_values = trapezoid(integrand_sca, x=log_dp, axis=-1)
477
+ else: # dN
478
+ ext_values = np.sum(integrand_ext, axis=-1)
479
+ sca_values = np.sum(integrand_sca, axis=-1)
480
+
481
+ extinction = pd.DataFrame(
482
+ ext_values, columns=refractive_index, index=psd.index
483
+ ).astype(float)
484
+
485
+ scattering = pd.DataFrame(
486
+ sca_values, columns=refractive_index, index=psd.index
487
+ ).astype(float)
488
+
489
+ absorption = extinction - scattering
490
+
491
+ return {'ext': extinction, 'sca': scattering, 'abs': absorption}
492
+
493
+ else:
494
+ # Standard mode: one RI per time point
495
+ integrand_ext = Q_ext * cross_section_area
496
+ integrand_sca = Q_sca * cross_section_area
497
+
498
+ result = pd.DataFrame(index=psd.index)
499
+ result['ext'] = _integrate_psd(integrand_ext, diameter, psd_type).astype(float)
500
+ result['sca'] = _integrate_psd(integrand_sca, diameter, psd_type).astype(float)
501
+ result['abs'] = result['ext'] - result['sca']
502
+
503
+ return result
504
+
505
+
506
+ # =============================================================================
507
+ # Additional Functions: Distribution, Mass Efficiency, Mixing Models
508
+ # =============================================================================
509
+
510
+ def calculate_extinction_distribution(
511
+ refractive_index: complex | np.ndarray,
512
+ wavelength: float,
513
+ diameter: np.ndarray,
514
+ number_conc: np.ndarray,
515
+ ) -> dict[str, np.ndarray]:
516
+ """
517
+ Calculate extinction/scattering/absorption distribution per size bin.
518
+
519
+ Unlike Mie_SD which integrates over all sizes, this function returns
520
+ the contribution from each size bin (dExt/dlogDp).
521
+
522
+ Parameters
523
+ ----------
524
+ refractive_index : complex or np.ndarray
525
+ Complex refractive index. Can be:
526
+ - Single complex value (applied to all)
527
+ - Array of complex values (one per row of number_conc)
528
+ wavelength : float
529
+ Wavelength of incident light in nm.
530
+ diameter : np.ndarray
531
+ Particle diameters in nm. Shape: (n_bins,)
532
+ number_conc : np.ndarray
533
+ Number concentration (dN/dlogDp). Shape: (n_bins,) or (n_times, n_bins)
534
+
535
+ Returns
536
+ -------
537
+ dict
538
+ Dictionary with keys:
539
+ - 'ext': Extinction distribution (dExt/dlogDp) in Mm⁻¹
540
+ - 'sca': Scattering distribution (dSca/dlogDp) in Mm⁻¹
541
+ - 'abs': Absorption distribution (dAbs/dlogDp) in Mm⁻¹
542
+ - 'diameter': Particle diameters (nm)
543
+
544
+ Examples
545
+ --------
546
+ >>> dp = np.logspace(1, 3, 50)
547
+ >>> ndp = np.random.rand(50) * 1000
548
+ >>> m = complex(1.5, 0.02)
549
+ >>> dist = calculate_extinction_distribution(m, 550, dp, ndp)
550
+ >>> print(dist['ext'].shape) # (50,)
551
+
552
+ Notes
553
+ -----
554
+ Output is in dExt/dlogDp units. To get total extinction:
555
+ total_ext = np.trapz(dist['ext'], np.log10(diameter))
556
+ """
557
+ # Handle input dimensions
558
+ number_conc = np.atleast_2d(number_conc)
559
+ if number_conc.shape[1] != len(diameter):
560
+ number_conc = number_conc.T
561
+
562
+ n_times = number_conc.shape[0]
563
+
564
+ # Handle refractive index
565
+ if isinstance(refractive_index, complex):
566
+ ri_array = np.array([refractive_index] * n_times)
567
+ else:
568
+ ri_array = np.atleast_1d(refractive_index)
569
+ if len(ri_array) == 1:
570
+ ri_array = np.array([ri_array[0]] * n_times)
571
+
572
+ # Calculate Mie efficiencies
573
+ Q_ext, Q_sca = calculate_mie_efficiencies(ri_array, wavelength, diameter)
574
+
575
+ # Cross-sectional area (π/4 * Dp²) in nm², scaled to Mm⁻¹
576
+ cross_section = np.pi / 4 * diameter**2 * 1e-6
577
+
578
+ # Calculate distributions: dX/dlogDp = Q * (π/4 * Dp²) * dN/dlogDp
579
+ # Q_ext shape: (n_times, n_bins), cross_section shape: (n_bins,)
580
+ # number_conc shape: (n_times, n_bins)
581
+ ext_dist = Q_ext * cross_section * number_conc # (n_times, n_bins)
582
+ sca_dist = Q_sca * cross_section * number_conc
583
+
584
+ abs_dist = ext_dist - sca_dist
585
+
586
+ return {
587
+ 'ext': ext_dist.squeeze(),
588
+ 'sca': sca_dist.squeeze(),
589
+ 'abs': abs_dist.squeeze(),
590
+ 'diameter': diameter
591
+ }
592
+
593
+
594
+ def calculate_mass_efficiency(
595
+ refractive_index: complex,
596
+ wavelength: float,
597
+ diameter: np.ndarray,
598
+ density: float
599
+ ) -> dict[str, np.ndarray]:
600
+ """
601
+ Calculate mass extinction/scattering/absorption efficiency (MEE/MSE/MAE).
602
+
603
+ Parameters
604
+ ----------
605
+ refractive_index : complex
606
+ Complex refractive index (n + ik).
607
+ wavelength : float
608
+ Wavelength of incident light in nm.
609
+ diameter : np.ndarray
610
+ Particle diameters in nm.
611
+ density : float
612
+ Particle density in g/cm³.
613
+
614
+ Returns
615
+ -------
616
+ dict
617
+ Dictionary with keys:
618
+ - 'MEE': Mass extinction efficiency (m²/g)
619
+ - 'MSE': Mass scattering efficiency (m²/g)
620
+ - 'MAE': Mass absorption efficiency (m²/g)
621
+ - 'diameter': Particle diameters (nm)
622
+
623
+ Examples
624
+ --------
625
+ >>> dp = np.logspace(1, 3, 50)
626
+ >>> result = calculate_mass_efficiency(
627
+ ... complex(1.5, 0.02), wavelength=550, diameter=dp, density=1.5
628
+ ... )
629
+ >>> print(f"MEE at 100nm: {result['MEE'][25]:.2f} m²/g")
630
+
631
+ Notes
632
+ -----
633
+ MEE = (3/2) * Q / (ρ * Dp) * 1000
634
+
635
+ Where:
636
+ - Q: Mie efficiency
637
+ - ρ: particle density (g/cm³)
638
+ - Dp: particle diameter (nm)
639
+ - Factor 1000 converts to m²/g
640
+ """
641
+ # Calculate Q for single refractive index
642
+ ri_array = np.array([refractive_index])
643
+ Q_ext, Q_sca = calculate_mie_efficiencies(ri_array, wavelength, diameter)
644
+ # Q_ext shape: (1, n_bins), extract first row to get (n_bins,)
645
+
646
+ # MEE = 3Q / (2ρDp) * 1000
647
+ # Factor breakdown: 3/(2*ρ*Dp) where Dp in nm, ρ in g/cm³
648
+ # Multiply by 1000 to get m²/g
649
+ factor = 3 / (2 * density * diameter) * 1000
650
+
651
+ MEE = Q_ext[0, :] * factor # shape: (n_bins,)
652
+ MSE = Q_sca[0, :] * factor
653
+ MAE = MEE - MSE
654
+
655
+ return {
656
+ 'MEE': MEE,
657
+ 'MSE': MSE,
658
+ 'MAE': MAE,
659
+ 'diameter': diameter
660
+ }
661
+
662
+
663
+ # =============================================================================
664
+ # Mixing Models for Multi-Component Aerosols
665
+ # =============================================================================
666
+
667
+ # Default refractive indices for common aerosol species at 550 nm
668
+ DEFAULT_REFRACTIVE_INDICES = {
669
+ 'AS': complex(1.53, 0.00), # Ammonium Sulfate
670
+ 'AN': complex(1.55, 0.00), # Ammonium Nitrate
671
+ 'OM': complex(1.54, 0.00), # Organic Matter
672
+ 'Soil': complex(1.56, 0.01), # Soil/Dust
673
+ 'SS': complex(1.54, 0.00), # Sea Salt
674
+ 'EC': complex(1.80, 0.54), # Elemental Carbon
675
+ 'ALWC': complex(1.33, 0.00), # Aerosol Liquid Water Content
676
+ }
677
+
678
+
679
+ def internal_mixing(
680
+ psd: pd.DataFrame,
681
+ refractive_index: pd.DataFrame | pd.Series,
682
+ wavelength: float = 550,
683
+ psd_type: str = 'auto',
684
+ ) -> pd.DataFrame:
685
+ """
686
+ Calculate optical properties using internal mixing model.
687
+
688
+ In internal mixing, all species are homogeneously mixed within each
689
+ particle. The effective refractive index is the volume-weighted average.
690
+
691
+ Parameters
692
+ ----------
693
+ psd : pd.DataFrame
694
+ Particle size distribution.
695
+ Columns: particle diameters (nm)
696
+ Rows: time points
697
+ refractive_index : pd.DataFrame or pd.Series
698
+ Complex refractive index for each time point.
699
+ Should have columns 'n' and 'k', or be complex values directly.
700
+ wavelength : float, default=550
701
+ Wavelength of incident light in nm.
702
+ psd_type : str, default='auto'
703
+ Type of PSD input:
704
+ - 'dNdlogDp': Number concentration per log bin width (#/cm³)
705
+ - 'dN': Number concentration per bin (#/cm³/bin)
706
+ - 'auto': Auto-detect with warning if uncertain
707
+
708
+ Returns
709
+ -------
710
+ pd.DataFrame
711
+ Optical coefficients with columns: ext, sca, abs (Mm⁻¹)
712
+
713
+ Examples
714
+ --------
715
+ >>> # PSD data
716
+ >>> dp = np.logspace(1, 3, 50)
717
+ >>> psd = pd.DataFrame(np.random.rand(10, 50) * 1000, columns=dp)
718
+ >>>
719
+ >>> # Refractive index (volume-weighted average)
720
+ >>> ri = pd.DataFrame({'n': [1.52]*10, 'k': [0.01]*10})
721
+ >>> result = internal_mixing(psd, ri, psd_type='dNdlogDp')
722
+ """
723
+ # Convert RI to complex array
724
+ if isinstance(refractive_index, pd.DataFrame):
725
+ if 'n' in refractive_index.columns and 'k' in refractive_index.columns:
726
+ ri_array = (refractive_index['n'] + 1j * refractive_index['k']).values
727
+ else:
728
+ ri_array = refractive_index.iloc[:, 0].values
729
+ else:
730
+ ri_array = np.array(refractive_index)
731
+
732
+ # Use standard Mie_SD calculation
733
+ return Mie_SD(ri_array, wavelength, psd, psd_type=psd_type)
734
+
735
+
736
+ def external_mixing(
737
+ psd: pd.DataFrame,
738
+ volume_fractions: pd.DataFrame,
739
+ wavelength: float = 550,
740
+ refractive_indices: dict = None,
741
+ psd_type: str = 'auto',
742
+ ) -> pd.DataFrame:
743
+ """
744
+ Calculate optical properties using external mixing model.
745
+
746
+ In external mixing, each species exists as separate particles.
747
+ The total optical property is the sum of contributions from each species.
748
+
749
+ Parameters
750
+ ----------
751
+ psd : pd.DataFrame
752
+ Total particle size distribution.
753
+ Columns: particle diameters (nm)
754
+ Rows: time points
755
+ volume_fractions : pd.DataFrame
756
+ Volume fraction of each species. Columns should include:
757
+ AS, AN, OM, Soil, SS, EC, (optional: ALWC)
758
+ wavelength : float, default=550
759
+ Wavelength of incident light in nm.
760
+ refractive_indices : dict, optional
761
+ Custom refractive indices for species. Default uses standard values.
762
+ psd_type : str, default='auto'
763
+ Type of PSD input:
764
+ - 'dNdlogDp': Number concentration per log bin width (#/cm³)
765
+ - 'dN': Number concentration per bin (#/cm³/bin)
766
+ - 'auto': Auto-detect with warning if uncertain
767
+
768
+ Returns
769
+ -------
770
+ pd.DataFrame
771
+ Total optical coefficients with columns: ext, sca, abs (Mm⁻¹)
772
+
773
+ Examples
774
+ --------
775
+ >>> dp = np.logspace(1, 3, 50)
776
+ >>> psd = pd.DataFrame(np.random.rand(10, 50) * 1000, columns=dp)
777
+ >>> vol_frac = pd.DataFrame({
778
+ ... 'AS': [0.3]*10, 'AN': [0.2]*10, 'OM': [0.3]*10,
779
+ ... 'Soil': [0.05]*10, 'SS': [0.05]*10, 'EC': [0.1]*10
780
+ ... })
781
+ >>> result = external_mixing(psd, vol_frac, psd_type='dNdlogDp')
782
+ """
783
+ if refractive_indices is None:
784
+ refractive_indices = DEFAULT_REFRACTIVE_INDICES.copy()
785
+
786
+ diameter = psd.columns.values.astype(float)
787
+ n_times = len(psd)
788
+
789
+ # Auto-detect PSD type if needed
790
+ if psd_type == 'auto':
791
+ detected_type, confidence = _detect_psd_type(psd.values, diameter)
792
+ psd_type = detected_type
793
+
794
+ if confidence == 'low':
795
+ warnings.warn(
796
+ f"PSD type auto-detection has low confidence. "
797
+ f"Assuming '{detected_type}'. Please specify psd_type explicitly "
798
+ f"('dNdlogDp' or 'dN') to avoid incorrect results.",
799
+ UserWarning
800
+ )
801
+ elif confidence == 'medium':
802
+ warnings.warn(
803
+ f"PSD type auto-detected as '{detected_type}' with medium confidence. "
804
+ f"If results seem incorrect, try specifying psd_type explicitly.",
805
+ UserWarning
806
+ )
807
+
808
+ # Initialize result
809
+ total_ext = np.zeros(n_times)
810
+ total_sca = np.zeros(n_times)
811
+
812
+ # Check for ALWC correction
813
+ has_alwc = 'ALWC' in volume_fractions.columns
814
+ if has_alwc:
815
+ alwc_factor = 1 / (1 + volume_fractions['ALWC'].values)
816
+ else:
817
+ alwc_factor = np.ones(n_times)
818
+
819
+ # Calculate contribution from each species
820
+ for species, ri in refractive_indices.items():
821
+ if species not in volume_fractions.columns:
822
+ continue
823
+ if species == 'ALWC':
824
+ continue # ALWC is handled separately
825
+
826
+ vol_frac = volume_fractions[species].values
827
+
828
+ # Species PSD = total PSD × volume fraction (with ALWC correction)
829
+ species_psd = psd.values * (vol_frac * alwc_factor).reshape(-1, 1)
830
+
831
+ # Calculate Mie for this species (single RI for all times)
832
+ ri_array = np.array([ri] * n_times)
833
+ Q_ext, Q_sca = calculate_mie_efficiencies(ri_array, wavelength, diameter)
834
+ # Q_ext shape: (n_times, n_bins)
835
+
836
+ # Cross-sectional area
837
+ cross_section = np.pi * (diameter / 2)**2 * 1e-6 # shape: (n_bins,)
838
+
839
+ # Integrate over diameter bins
840
+ # species_psd shape: (n_times, n_bins)
841
+ integrand_ext = Q_ext * cross_section * species_psd # (n_times, n_bins)
842
+ integrand_sca = Q_sca * cross_section * species_psd
843
+ total_ext += _integrate_psd(integrand_ext, diameter, psd_type)
844
+ total_sca += _integrate_psd(integrand_sca, diameter, psd_type)
845
+
846
+ # Build result DataFrame
847
+ result = pd.DataFrame(index=psd.index)
848
+ result['ext'] = total_ext.astype(float)
849
+ result['sca'] = total_sca.astype(float)
850
+ result['abs'] = (total_ext - total_sca).astype(float)
851
+
852
+ return result
853
+
854
+
855
+ def generate_lognormal_psd(
856
+ geometric_mean: float = 200,
857
+ geometric_std: float = 2.0,
858
+ total_number: float = 1e6,
859
+ dp_range: tuple = (1, 2500),
860
+ n_bins: int = 167,
861
+ ) -> tuple[np.ndarray, np.ndarray]:
862
+ """
863
+ Generate a lognormal particle size distribution.
864
+
865
+ Parameters
866
+ ----------
867
+ geometric_mean : float, default=200
868
+ Geometric mean diameter in nm.
869
+ geometric_std : float, default=2.0
870
+ Geometric standard deviation.
871
+ total_number : float, default=1e6
872
+ Total number concentration (#/cm³).
873
+ dp_range : tuple, default=(1, 2500)
874
+ Diameter range (min, max) in nm.
875
+ n_bins : int, default=167
876
+ Number of size bins.
877
+
878
+ Returns
879
+ -------
880
+ diameter : np.ndarray
881
+ Particle diameters in nm.
882
+ ndp : np.ndarray
883
+ Number concentration (dN/dlogDp).
884
+
885
+ Examples
886
+ --------
887
+ >>> dp, ndp = generate_lognormal_psd(geometric_mean=100, geometric_std=1.8)
888
+ >>> print(f"Peak at {dp[np.argmax(ndp)]:.1f} nm")
889
+ """
890
+ diameter = np.logspace(np.log10(dp_range[0]), np.log10(dp_range[1]), n_bins)
891
+
892
+ # Lognormal distribution: dN/dlogDp
893
+ log_sigma = np.log(geometric_std)
894
+ log_dp = np.log(diameter)
895
+ log_mean = np.log(geometric_mean)
896
+
897
+ ndp = total_number * (
898
+ 1 / (log_sigma * np.sqrt(2 * np.pi)) *
899
+ np.exp(-(log_dp - log_mean)**2 / (2 * log_sigma**2))
900
+ )
901
+
902
+ return diameter, ndp
903
+
904
+
905
+ # =============================================================================
906
+ # Backward Compatibility Aliases
907
+ # =============================================================================
908
+
909
+ MieQ = calculate_mie_efficiencies
910
+ Mie_ab = calculate_mie_coefficients
911
+ Mie_PESD = calculate_extinction_distribution
912
+ Mie_MEE = calculate_mass_efficiency