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.
- AeroViz/__init__.py +13 -0
- AeroViz/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/data/DEFAULT_DATA.csv +1417 -0
- AeroViz/data/DEFAULT_PNSD_DATA.csv +1417 -0
- AeroViz/data/hysplit_example_data.txt +101 -0
- AeroViz/dataProcess/Chemistry/__init__.py +149 -0
- AeroViz/dataProcess/Chemistry/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/dataProcess/Chemistry/_calculate.py +557 -0
- AeroViz/dataProcess/Chemistry/_isoropia.py +150 -0
- AeroViz/dataProcess/Chemistry/_mass_volume.py +487 -0
- AeroViz/dataProcess/Chemistry/_ocec.py +172 -0
- AeroViz/dataProcess/Chemistry/isrpia.cnf +21 -0
- AeroViz/dataProcess/Chemistry/isrpia2.exe +0 -0
- AeroViz/dataProcess/Optical/PyMieScatt_update.py +577 -0
- AeroViz/dataProcess/Optical/_IMPROVE.py +452 -0
- AeroViz/dataProcess/Optical/__init__.py +281 -0
- AeroViz/dataProcess/Optical/__pycache__/PyMieScatt_update.cpython-312.pyc +0 -0
- AeroViz/dataProcess/Optical/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/dataProcess/Optical/__pycache__/mie_theory.cpython-312.pyc +0 -0
- AeroViz/dataProcess/Optical/_derived.py +518 -0
- AeroViz/dataProcess/Optical/_extinction.py +123 -0
- AeroViz/dataProcess/Optical/_mie_sd.py +912 -0
- AeroViz/dataProcess/Optical/_retrieve_RI.py +243 -0
- AeroViz/dataProcess/Optical/coefficient.py +72 -0
- AeroViz/dataProcess/Optical/fRH.pkl +0 -0
- AeroViz/dataProcess/Optical/mie_theory.py +260 -0
- AeroViz/dataProcess/README.md +271 -0
- AeroViz/dataProcess/SizeDistr/__init__.py +245 -0
- AeroViz/dataProcess/SizeDistr/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/dataProcess/SizeDistr/__pycache__/_size_dist.cpython-312.pyc +0 -0
- AeroViz/dataProcess/SizeDistr/_size_dist.py +810 -0
- AeroViz/dataProcess/SizeDistr/merge/README.md +93 -0
- AeroViz/dataProcess/SizeDistr/merge/__init__.py +20 -0
- AeroViz/dataProcess/SizeDistr/merge/_merge_v0.py +251 -0
- AeroViz/dataProcess/SizeDistr/merge/_merge_v0_1.py +246 -0
- AeroViz/dataProcess/SizeDistr/merge/_merge_v1.py +255 -0
- AeroViz/dataProcess/SizeDistr/merge/_merge_v2.py +244 -0
- AeroViz/dataProcess/SizeDistr/merge/_merge_v3.py +518 -0
- AeroViz/dataProcess/SizeDistr/merge/_merge_v4.py +422 -0
- AeroViz/dataProcess/SizeDistr/prop.py +62 -0
- AeroViz/dataProcess/VOC/__init__.py +14 -0
- AeroViz/dataProcess/VOC/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/dataProcess/VOC/_potential_par.py +108 -0
- AeroViz/dataProcess/VOC/support_voc.json +446 -0
- AeroViz/dataProcess/__init__.py +66 -0
- AeroViz/dataProcess/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/dataProcess/core/__init__.py +272 -0
- AeroViz/dataProcess/core/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/mcp_server.py +352 -0
- AeroViz/plot/__init__.py +13 -0
- AeroViz/plot/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/plot/__pycache__/bar.cpython-312.pyc +0 -0
- AeroViz/plot/__pycache__/box.cpython-312.pyc +0 -0
- AeroViz/plot/__pycache__/pie.cpython-312.pyc +0 -0
- AeroViz/plot/__pycache__/radar.cpython-312.pyc +0 -0
- AeroViz/plot/__pycache__/regression.cpython-312.pyc +0 -0
- AeroViz/plot/__pycache__/scatter.cpython-312.pyc +0 -0
- AeroViz/plot/__pycache__/violin.cpython-312.pyc +0 -0
- AeroViz/plot/bar.py +126 -0
- AeroViz/plot/box.py +69 -0
- AeroViz/plot/distribution/__init__.py +1 -0
- AeroViz/plot/distribution/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/plot/distribution/__pycache__/distribution.cpython-312.pyc +0 -0
- AeroViz/plot/distribution/distribution.py +576 -0
- AeroViz/plot/meteorology/CBPF.py +295 -0
- AeroViz/plot/meteorology/__init__.py +3 -0
- AeroViz/plot/meteorology/__pycache__/CBPF.cpython-312.pyc +0 -0
- AeroViz/plot/meteorology/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/plot/meteorology/__pycache__/hysplit.cpython-312.pyc +0 -0
- AeroViz/plot/meteorology/__pycache__/wind_rose.cpython-312.pyc +0 -0
- AeroViz/plot/meteorology/hysplit.py +93 -0
- AeroViz/plot/meteorology/wind_rose.py +77 -0
- AeroViz/plot/optical/__init__.py +1 -0
- AeroViz/plot/optical/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/plot/optical/__pycache__/optical.cpython-312.pyc +0 -0
- AeroViz/plot/optical/optical.py +388 -0
- AeroViz/plot/pie.py +210 -0
- AeroViz/plot/radar.py +184 -0
- AeroViz/plot/regression.py +200 -0
- AeroViz/plot/scatter.py +174 -0
- AeroViz/plot/templates/__init__.py +6 -0
- AeroViz/plot/templates/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/plot/templates/__pycache__/ammonium_rich.cpython-312.pyc +0 -0
- AeroViz/plot/templates/__pycache__/contour.cpython-312.pyc +0 -0
- AeroViz/plot/templates/__pycache__/corr_matrix.cpython-312.pyc +0 -0
- AeroViz/plot/templates/__pycache__/diurnal_pattern.cpython-312.pyc +0 -0
- AeroViz/plot/templates/__pycache__/koschmieder.cpython-312.pyc +0 -0
- AeroViz/plot/templates/__pycache__/metal_heatmap.cpython-312.pyc +0 -0
- AeroViz/plot/templates/ammonium_rich.py +34 -0
- AeroViz/plot/templates/contour.py +47 -0
- AeroViz/plot/templates/corr_matrix.py +267 -0
- AeroViz/plot/templates/diurnal_pattern.py +61 -0
- AeroViz/plot/templates/koschmieder.py +95 -0
- AeroViz/plot/templates/metal_heatmap.py +164 -0
- AeroViz/plot/timeseries/__init__.py +2 -0
- AeroViz/plot/timeseries/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/plot/timeseries/__pycache__/template.cpython-312.pyc +0 -0
- AeroViz/plot/timeseries/__pycache__/timeseries.cpython-312.pyc +0 -0
- AeroViz/plot/timeseries/template.py +47 -0
- AeroViz/plot/timeseries/timeseries.py +446 -0
- AeroViz/plot/utils/__init__.py +4 -0
- AeroViz/plot/utils/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/plot/utils/__pycache__/_color.cpython-312.pyc +0 -0
- AeroViz/plot/utils/__pycache__/_unit.cpython-312.pyc +0 -0
- AeroViz/plot/utils/__pycache__/plt_utils.cpython-312.pyc +0 -0
- AeroViz/plot/utils/__pycache__/sklearn_utils.cpython-312.pyc +0 -0
- AeroViz/plot/utils/_color.py +71 -0
- AeroViz/plot/utils/_unit.py +55 -0
- AeroViz/plot/utils/fRH.json +390 -0
- AeroViz/plot/utils/plt_utils.py +92 -0
- AeroViz/plot/utils/sklearn_utils.py +49 -0
- AeroViz/plot/utils/units.json +89 -0
- AeroViz/plot/violin.py +80 -0
- AeroViz/rawDataReader/FLOW.md +138 -0
- AeroViz/rawDataReader/__init__.py +220 -0
- AeroViz/rawDataReader/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/config/__init__.py +0 -0
- AeroViz/rawDataReader/config/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/config/__pycache__/supported_instruments.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/config/supported_instruments.py +135 -0
- AeroViz/rawDataReader/core/__init__.py +658 -0
- AeroViz/rawDataReader/core/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/core/__pycache__/logger.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/core/__pycache__/pre_process.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/core/__pycache__/qc.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/core/__pycache__/report.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/core/logger.py +171 -0
- AeroViz/rawDataReader/core/pre_process.py +308 -0
- AeroViz/rawDataReader/core/qc.py +961 -0
- AeroViz/rawDataReader/core/report.py +579 -0
- AeroViz/rawDataReader/script/AE33.py +173 -0
- AeroViz/rawDataReader/script/AE43.py +151 -0
- AeroViz/rawDataReader/script/APS.py +339 -0
- AeroViz/rawDataReader/script/Aurora.py +191 -0
- AeroViz/rawDataReader/script/BAM1020.py +90 -0
- AeroViz/rawDataReader/script/BC1054.py +161 -0
- AeroViz/rawDataReader/script/EPA.py +79 -0
- AeroViz/rawDataReader/script/GRIMM.py +68 -0
- AeroViz/rawDataReader/script/IGAC.py +140 -0
- AeroViz/rawDataReader/script/MA350.py +179 -0
- AeroViz/rawDataReader/script/Minion.py +218 -0
- AeroViz/rawDataReader/script/NEPH.py +199 -0
- AeroViz/rawDataReader/script/OCEC.py +173 -0
- AeroViz/rawDataReader/script/Q-ACSM.py +12 -0
- AeroViz/rawDataReader/script/SMPS.py +389 -0
- AeroViz/rawDataReader/script/TEOM.py +181 -0
- AeroViz/rawDataReader/script/VOC.py +106 -0
- AeroViz/rawDataReader/script/Xact.py +244 -0
- AeroViz/rawDataReader/script/__init__.py +28 -0
- AeroViz/rawDataReader/script/__pycache__/AE33.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/AE43.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/APS.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/Aurora.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/BAM1020.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/BC1054.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/EPA.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/GRIMM.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/IGAC.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/MA350.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/Minion.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/NEPH.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/OCEC.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/Q-ACSM.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/SMPS.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/TEOM.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/VOC.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/Xact.cpython-312.pyc +0 -0
- AeroViz/rawDataReader/script/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/tools/__init__.py +2 -0
- AeroViz/tools/__pycache__/__init__.cpython-312.pyc +0 -0
- AeroViz/tools/__pycache__/database.cpython-312.pyc +0 -0
- AeroViz/tools/__pycache__/dataclassifier.cpython-312.pyc +0 -0
- AeroViz/tools/database.py +95 -0
- AeroViz/tools/dataclassifier.py +117 -0
- AeroViz/tools/dataprinter.py +58 -0
- aeroviz-0.1.21.dist-info/METADATA +294 -0
- aeroviz-0.1.21.dist-info/RECORD +180 -0
- aeroviz-0.1.21.dist-info/WHEEL +5 -0
- aeroviz-0.1.21.dist-info/licenses/LICENSE +21 -0
- 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
|