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,810 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core class for particle size distribution data.
|
|
3
|
+
|
|
4
|
+
This module provides the SizeDist class, which encapsulates particle
|
|
5
|
+
size distribution data and provides convenient properties for accessing
|
|
6
|
+
particle diameters, logarithmic bin widths, and distribution state information.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from pandas import DataFrame
|
|
13
|
+
|
|
14
|
+
__all__ = ['SizeDist', 'get_required_format']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SizeDist:
|
|
18
|
+
"""
|
|
19
|
+
A class representing particle size distribution data.
|
|
20
|
+
|
|
21
|
+
This class encapsulates particle size distribution data and provides
|
|
22
|
+
convenient properties for accessing particle diameters, logarithmic
|
|
23
|
+
bin widths, and distribution state information.
|
|
24
|
+
|
|
25
|
+
Attributes
|
|
26
|
+
----------
|
|
27
|
+
_data : DataFrame
|
|
28
|
+
The processed PSD data stored as a pandas DataFrame.
|
|
29
|
+
_dp : ndarray
|
|
30
|
+
The array of particle diameters from the PSD data.
|
|
31
|
+
_dlogdp : ndarray
|
|
32
|
+
The array of logarithmic particle diameter bin widths.
|
|
33
|
+
_index : DatetimeIndex
|
|
34
|
+
The index of the DataFrame representing time.
|
|
35
|
+
_state : str
|
|
36
|
+
The state of particle size distribution data ('dN', 'ddp', 'dlogdp').
|
|
37
|
+
_weighting : str
|
|
38
|
+
The weighting type for distribution calculations.
|
|
39
|
+
|
|
40
|
+
Methods
|
|
41
|
+
-------
|
|
42
|
+
data
|
|
43
|
+
Returns the size distribution DataFrame.
|
|
44
|
+
dp
|
|
45
|
+
Returns the particle diameter array.
|
|
46
|
+
dlogdp
|
|
47
|
+
Returns the logarithmic bin width array.
|
|
48
|
+
index
|
|
49
|
+
Returns the time index.
|
|
50
|
+
state
|
|
51
|
+
Returns the distribution state.
|
|
52
|
+
weighting
|
|
53
|
+
Returns the weighting type.
|
|
54
|
+
|
|
55
|
+
Examples
|
|
56
|
+
--------
|
|
57
|
+
>>> from pandas import read_csv
|
|
58
|
+
>>> df = read_csv('PNSD_dNdlogdp.csv', parse_dates=['Time'], index_col='Time')
|
|
59
|
+
>>> psd = SizeDist(df, state='dlogdp', weighting='n')
|
|
60
|
+
>>> print(psd.dp)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self,
|
|
64
|
+
data: DataFrame,
|
|
65
|
+
state: Literal['dN', 'ddp', 'dlogdp'] = 'dlogdp',
|
|
66
|
+
weighting: Literal['n', 's', 'v', 'ext_in', 'ext_ex'] = 'n'
|
|
67
|
+
):
|
|
68
|
+
"""
|
|
69
|
+
Initialize a SizeDist object.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
data : DataFrame
|
|
74
|
+
The particle size distribution data with particle diameters as columns.
|
|
75
|
+
Column names must be numeric diameter values in nm.
|
|
76
|
+
state : {'dN', 'ddp', 'dlogdp'}, default='dlogdp'
|
|
77
|
+
The state of the distribution data:
|
|
78
|
+
- 'dN': Raw number concentration
|
|
79
|
+
- 'ddp': dN/ddp normalized
|
|
80
|
+
- 'dlogdp': dN/dlogdp normalized
|
|
81
|
+
weighting : {'n', 's', 'v', 'ext_in', 'ext_ex'}, default='n'
|
|
82
|
+
The weighting type for property calculations:
|
|
83
|
+
- 'n': Number weighting
|
|
84
|
+
- 's': Surface weighting
|
|
85
|
+
- 'v': Volume weighting
|
|
86
|
+
- 'ext_in': Internal extinction weighting
|
|
87
|
+
- 'ext_ex': External extinction weighting
|
|
88
|
+
|
|
89
|
+
Raises
|
|
90
|
+
------
|
|
91
|
+
ValueError
|
|
92
|
+
If data is None or empty, or column names are not numeric.
|
|
93
|
+
TypeError
|
|
94
|
+
If data is not a DataFrame.
|
|
95
|
+
"""
|
|
96
|
+
# Validate input data
|
|
97
|
+
if data is None:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"\nSizeDist 需要 DataFrame 資料!\n"
|
|
100
|
+
" 格式要求: 欄位名稱為粒徑值 (nm)\n"
|
|
101
|
+
" 例如: df.columns = [10.0, 20.0, 50.0, ...]"
|
|
102
|
+
)
|
|
103
|
+
if not isinstance(data, DataFrame):
|
|
104
|
+
raise TypeError(
|
|
105
|
+
f"\nSizeDist 需要 pandas DataFrame!\n"
|
|
106
|
+
f" 收到類型: {type(data).__name__}"
|
|
107
|
+
)
|
|
108
|
+
if data.empty:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"\nSizeDist 收到空的 DataFrame!\n"
|
|
111
|
+
" 請確認資料已正確讀取"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Validate column names are numeric (particle diameters)
|
|
115
|
+
try:
|
|
116
|
+
_ = np.array(data.columns, dtype=float)
|
|
117
|
+
except (ValueError, TypeError):
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"\nSizeDist 欄位名稱必須為數值 (粒徑 nm)!\n"
|
|
120
|
+
f" 收到欄位: {list(data.columns[:5])}{'...' if len(data.columns) > 5 else ''}\n"
|
|
121
|
+
f" 正確格式: [10.0, 20.0, 50.0, 100.0, ...]"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Validate state parameter
|
|
125
|
+
if state not in ['dN', 'dlogdp', 'ddp']:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"\nSizeDist 無效的 state 參數!\n"
|
|
128
|
+
f" 收到: '{state}'\n"
|
|
129
|
+
f" 有效選項: ['dN', 'ddp', 'dlogdp']"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Validate weighting parameter
|
|
133
|
+
if weighting not in ['n', 's', 'v', 'ext_in', 'ext_ex']:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"\nSizeDist 無效的 weighting 參數!\n"
|
|
136
|
+
f" 收到: '{weighting}'\n"
|
|
137
|
+
f" 有效選項: ['n', 's', 'v', 'ext_in', 'ext_ex']"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
self._data = data
|
|
141
|
+
self._dp = np.array(self._data.columns, dtype=float)
|
|
142
|
+
self._dlogdp = np.full_like(self._dp, 0.014)
|
|
143
|
+
self._index = self._data.index.copy()
|
|
144
|
+
self._state = state
|
|
145
|
+
self._weighting = weighting
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def data(self) -> DataFrame:
|
|
149
|
+
"""Return the size distribution DataFrame."""
|
|
150
|
+
return self._data
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def dp(self) -> np.ndarray:
|
|
154
|
+
"""Return the particle diameter array in nm."""
|
|
155
|
+
return self._dp
|
|
156
|
+
|
|
157
|
+
@dp.setter
|
|
158
|
+
def dp(self, new_dp: np.ndarray):
|
|
159
|
+
"""Set the particle diameter array."""
|
|
160
|
+
self._dp = new_dp
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def dlogdp(self) -> np.ndarray:
|
|
164
|
+
"""Return the logarithmic bin width array."""
|
|
165
|
+
return self._dlogdp
|
|
166
|
+
|
|
167
|
+
@dlogdp.setter
|
|
168
|
+
def dlogdp(self, new_dlogdp: np.ndarray):
|
|
169
|
+
"""Set the logarithmic bin width array."""
|
|
170
|
+
self._dlogdp = new_dlogdp
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def index(self):
|
|
174
|
+
"""Return the time index of the distribution data."""
|
|
175
|
+
return self._index
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def state(self):
|
|
179
|
+
"""Return the distribution state."""
|
|
180
|
+
return self._state
|
|
181
|
+
|
|
182
|
+
@state.setter
|
|
183
|
+
def state(self, value):
|
|
184
|
+
"""Set the distribution state."""
|
|
185
|
+
if value not in ['dN', 'dlogdp', 'ddp']:
|
|
186
|
+
raise ValueError("state must be 'dN', 'dlogdp', or 'ddp'")
|
|
187
|
+
self._state = value
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def weighting(self):
|
|
191
|
+
"""Return the weighting type."""
|
|
192
|
+
return self._weighting
|
|
193
|
+
|
|
194
|
+
@weighting.setter
|
|
195
|
+
def weighting(self, value):
|
|
196
|
+
"""Set the weighting type."""
|
|
197
|
+
if value not in ['n', 's', 'v', 'ext_in', 'ext_ex']:
|
|
198
|
+
raise ValueError("weighting must be 'n', 's', 'v', 'ext_in', or 'ext_ex'")
|
|
199
|
+
self._weighting = value
|
|
200
|
+
|
|
201
|
+
# =========================================================================
|
|
202
|
+
# Distribution Calculations
|
|
203
|
+
# =========================================================================
|
|
204
|
+
|
|
205
|
+
def to_surface(self) -> DataFrame:
|
|
206
|
+
"""
|
|
207
|
+
Convert to surface area distribution.
|
|
208
|
+
|
|
209
|
+
Formula: dS/dlogDp = π * dp² * dN/dlogDp
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
DataFrame
|
|
214
|
+
Surface area distribution (nm² / cm³).
|
|
215
|
+
|
|
216
|
+
Examples
|
|
217
|
+
--------
|
|
218
|
+
>>> psd = SizeDist(df)
|
|
219
|
+
>>> surface = psd.to_surface()
|
|
220
|
+
"""
|
|
221
|
+
return self._data.dropna().apply(
|
|
222
|
+
lambda col: np.pi * self._dp ** 2 * np.array(col),
|
|
223
|
+
axis=1, result_type='broadcast'
|
|
224
|
+
).reindex(self._index)
|
|
225
|
+
|
|
226
|
+
def to_volume(self) -> DataFrame:
|
|
227
|
+
"""
|
|
228
|
+
Convert to volume distribution.
|
|
229
|
+
|
|
230
|
+
Formula: dV/dlogDp = (π/6) * dp³ * dN/dlogDp
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
DataFrame
|
|
235
|
+
Volume distribution (nm³ / cm³).
|
|
236
|
+
|
|
237
|
+
Examples
|
|
238
|
+
--------
|
|
239
|
+
>>> psd = SizeDist(df)
|
|
240
|
+
>>> volume = psd.to_volume()
|
|
241
|
+
"""
|
|
242
|
+
return self._data.dropna().apply(
|
|
243
|
+
lambda col: np.pi / 6 * self._dp ** 3 * np.array(col),
|
|
244
|
+
axis=1, result_type='broadcast'
|
|
245
|
+
).reindex(self._index)
|
|
246
|
+
|
|
247
|
+
def properties(self) -> DataFrame:
|
|
248
|
+
"""
|
|
249
|
+
Calculate statistical properties of the distribution.
|
|
250
|
+
|
|
251
|
+
Returns
|
|
252
|
+
-------
|
|
253
|
+
DataFrame
|
|
254
|
+
Properties including GMD, GSD, mode, and mode contributions.
|
|
255
|
+
|
|
256
|
+
Examples
|
|
257
|
+
--------
|
|
258
|
+
>>> psd = SizeDist(df)
|
|
259
|
+
>>> props = psd.properties()
|
|
260
|
+
"""
|
|
261
|
+
from functools import partial
|
|
262
|
+
from .prop import properties as calc_props
|
|
263
|
+
|
|
264
|
+
return self._data.dropna().apply(
|
|
265
|
+
partial(calc_props, dp=self._dp, dlogdp=self._dlogdp, weighting=self._weighting),
|
|
266
|
+
axis=1, result_type='expand'
|
|
267
|
+
).reindex(self._index)
|
|
268
|
+
|
|
269
|
+
def to_extinction(self,
|
|
270
|
+
RI: DataFrame,
|
|
271
|
+
method: str = 'internal',
|
|
272
|
+
result_type: str = 'extinction') -> DataFrame:
|
|
273
|
+
"""
|
|
274
|
+
Calculate extinction distribution using Mie theory.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
RI : DataFrame
|
|
279
|
+
Refractive index data with n and k columns.
|
|
280
|
+
method : {'internal', 'external', 'core_shell', 'sensitivity'}, default='internal'
|
|
281
|
+
Mixing method for Mie calculation.
|
|
282
|
+
result_type : {'extinction', 'scattering', 'absorption'}, default='extinction'
|
|
283
|
+
Type of optical result.
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
DataFrame
|
|
288
|
+
Extinction distribution (Mm⁻¹).
|
|
289
|
+
|
|
290
|
+
Examples
|
|
291
|
+
--------
|
|
292
|
+
>>> psd = SizeDist(df)
|
|
293
|
+
>>> ext = psd.to_extinction(df_RI, method='internal')
|
|
294
|
+
"""
|
|
295
|
+
from functools import partial
|
|
296
|
+
from pandas import concat
|
|
297
|
+
from ..Optical.mie_theory import internal, external, core_shell, sensitivity
|
|
298
|
+
|
|
299
|
+
method_mapping = {
|
|
300
|
+
'internal': internal,
|
|
301
|
+
'external': external,
|
|
302
|
+
'core_shell': core_shell,
|
|
303
|
+
'sensitivity': sensitivity
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if RI is None or (hasattr(RI, 'empty') and RI.empty):
|
|
307
|
+
raise ValueError(
|
|
308
|
+
"\nto_extinction() 需要折射率資料!\n"
|
|
309
|
+
" 必要輸入: RI (DataFrame)\n"
|
|
310
|
+
" 需包含欄位: n (real), k (imaginary)"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if method not in method_mapping:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"\n無效的計算方法: '{method}'\n"
|
|
316
|
+
f" 有效方法: {list(method_mapping.keys())}"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
mie_func = method_mapping[method]
|
|
320
|
+
combined = concat([self._data, RI], axis=1).dropna()
|
|
321
|
+
|
|
322
|
+
return combined.apply(
|
|
323
|
+
partial(mie_func, dp=self._dp, result_type=result_type),
|
|
324
|
+
axis=1, result_type='expand'
|
|
325
|
+
).reindex(self._index).set_axis(self._dp, axis=1)
|
|
326
|
+
|
|
327
|
+
def mode_statistics(self, unit: str = 'nm') -> dict:
|
|
328
|
+
"""
|
|
329
|
+
Calculate statistics for different size modes.
|
|
330
|
+
|
|
331
|
+
Computes number, surface, and volume distributions along with
|
|
332
|
+
GMD, GSD, total, and mode for each size range.
|
|
333
|
+
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
unit : {'nm', 'um'}, default='nm'
|
|
337
|
+
Unit of particle diameter in the data.
|
|
338
|
+
|
|
339
|
+
Returns
|
|
340
|
+
-------
|
|
341
|
+
dict
|
|
342
|
+
- 'number': Number distribution (dN)
|
|
343
|
+
- 'number_norm': Normalized number distribution (dN/dlogDp)
|
|
344
|
+
- 'surface': Surface area distribution
|
|
345
|
+
- 'surface_norm': Normalized surface distribution
|
|
346
|
+
- 'volume': Volume distribution
|
|
347
|
+
- 'volume_norm': Normalized volume distribution
|
|
348
|
+
- 'statistics': DataFrame with GMD, GSD, total, mode per size mode
|
|
349
|
+
|
|
350
|
+
Examples
|
|
351
|
+
--------
|
|
352
|
+
>>> psd = SizeDist(df)
|
|
353
|
+
>>> stats = psd.mode_statistics()
|
|
354
|
+
>>> stats['statistics'] # GMD, GSD for each mode
|
|
355
|
+
"""
|
|
356
|
+
# Size mode boundaries in nm
|
|
357
|
+
mode_bounds = {
|
|
358
|
+
'Nucleation': (10, 25),
|
|
359
|
+
'Aitken': (25, 100),
|
|
360
|
+
'Accumulation': (100, 1000),
|
|
361
|
+
'Coarse': (1000, 2500),
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# Prepare distributions
|
|
365
|
+
number_norm = self._data
|
|
366
|
+
number = (self._data * self._dlogdp).copy()
|
|
367
|
+
surface_norm = self.to_surface()
|
|
368
|
+
surface = (surface_norm * self._dlogdp).copy()
|
|
369
|
+
volume_norm = self.to_volume()
|
|
370
|
+
volume = (volume_norm * self._dlogdp).copy()
|
|
371
|
+
|
|
372
|
+
out = {
|
|
373
|
+
'number': number,
|
|
374
|
+
'number_norm': number_norm,
|
|
375
|
+
'surface': surface,
|
|
376
|
+
'surface_norm': surface_norm,
|
|
377
|
+
'volume': volume,
|
|
378
|
+
'volume_norm': volume_norm,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
# Calculate statistics for each mode
|
|
382
|
+
df_stats = DataFrame(index=self._index)
|
|
383
|
+
|
|
384
|
+
bounds = [('all', (self._dp.min(), self._dp.max() + 1))]
|
|
385
|
+
for mode_name, (lb, ub) in mode_bounds.items():
|
|
386
|
+
if unit == 'um':
|
|
387
|
+
lb, ub = lb / 1e3, ub / 1e3
|
|
388
|
+
bounds.append((mode_name, (lb, ub)))
|
|
389
|
+
|
|
390
|
+
dist_types = [
|
|
391
|
+
('num', number),
|
|
392
|
+
('surf', surface),
|
|
393
|
+
('vol', volume)
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
for type_name, dist_data in dist_types:
|
|
397
|
+
for mode_name, (lb, ub) in bounds:
|
|
398
|
+
mode_dp = self._dp[(self._dp >= lb) & (self._dp < ub)]
|
|
399
|
+
if not mode_dp.any():
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
mode_dist = dist_data[mode_dp].copy()
|
|
403
|
+
|
|
404
|
+
# Calculate GMD, GSD, total
|
|
405
|
+
total, gmd, gsd = _geometric_statistics(mode_dp, mode_dist)
|
|
406
|
+
|
|
407
|
+
df_stats[f'total_{type_name}_{mode_name}'] = total
|
|
408
|
+
df_stats[f'GMD_{type_name}_{mode_name}'] = gmd
|
|
409
|
+
df_stats[f'GSD_{type_name}_{mode_name}'] = gsd
|
|
410
|
+
|
|
411
|
+
# Calculate mode (peak diameter)
|
|
412
|
+
mask = mode_dist.notna().any(axis=1)
|
|
413
|
+
df_stats.loc[mask, f'mode_{type_name}_{mode_name}'] = mode_dist.loc[mask].idxmax(axis=1)
|
|
414
|
+
df_stats.loc[~mask, f'mode_{type_name}_{mode_name}'] = np.nan
|
|
415
|
+
|
|
416
|
+
out['statistics'] = df_stats
|
|
417
|
+
|
|
418
|
+
return out
|
|
419
|
+
|
|
420
|
+
def to_dry(self, df_gRH: DataFrame, uniform: bool = True) -> DataFrame:
|
|
421
|
+
"""
|
|
422
|
+
Convert ambient (wet) PSD to dry PSD.
|
|
423
|
+
|
|
424
|
+
Shrinks particles according to hygroscopic growth factor and
|
|
425
|
+
redistributes concentrations to appropriate smaller diameter bins.
|
|
426
|
+
|
|
427
|
+
Parameters
|
|
428
|
+
----------
|
|
429
|
+
df_gRH : DataFrame
|
|
430
|
+
DataFrame with 'gRH' column (growth factor = Dp_wet / Dp_dry).
|
|
431
|
+
uniform : bool, default=True
|
|
432
|
+
If True, apply uniform gRH across all sizes.
|
|
433
|
+
If False, apply size-dependent gRH based on lognormal distribution.
|
|
434
|
+
|
|
435
|
+
Returns
|
|
436
|
+
-------
|
|
437
|
+
DataFrame
|
|
438
|
+
Dry particle size distribution.
|
|
439
|
+
|
|
440
|
+
Examples
|
|
441
|
+
--------
|
|
442
|
+
>>> psd = SizeDist(df_pnsd)
|
|
443
|
+
>>> dry_psd = psd.to_dry(df_chem[['gRH']])
|
|
444
|
+
"""
|
|
445
|
+
from pandas import concat
|
|
446
|
+
|
|
447
|
+
if df_gRH is None or (hasattr(df_gRH, 'empty') and df_gRH.empty):
|
|
448
|
+
raise ValueError(
|
|
449
|
+
"\nto_dry() 需要成長因子資料!\n"
|
|
450
|
+
" 必要輸入: df_gRH (DataFrame)\n"
|
|
451
|
+
" 需包含欄位: gRH (Dp_wet / Dp_dry)"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if 'gRH' not in df_gRH.columns:
|
|
455
|
+
raise ValueError(
|
|
456
|
+
f"\nto_dry() 需要 'gRH' 欄位!\n"
|
|
457
|
+
f" 收到欄位: {list(df_gRH.columns)}"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
combined = concat([self._data, df_gRH[['gRH']]], axis=1).dropna()
|
|
461
|
+
|
|
462
|
+
if combined.empty:
|
|
463
|
+
return DataFrame(columns=self._dp, index=self._index)
|
|
464
|
+
|
|
465
|
+
result = combined.apply(
|
|
466
|
+
lambda row: _dry_pnsd_process(
|
|
467
|
+
row[self._data.columns].values,
|
|
468
|
+
self._dp,
|
|
469
|
+
row['gRH'],
|
|
470
|
+
uniform=uniform
|
|
471
|
+
),
|
|
472
|
+
axis=1,
|
|
473
|
+
result_type='expand'
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if len(result.columns) < len(self._dp):
|
|
477
|
+
result = result.reindex(columns=range(len(self._dp)))
|
|
478
|
+
|
|
479
|
+
result.columns = self._dp[:len(result.columns)]
|
|
480
|
+
|
|
481
|
+
return result.reindex(self._index)
|
|
482
|
+
|
|
483
|
+
def lung_deposition(self, activity: str = 'light') -> dict:
|
|
484
|
+
"""
|
|
485
|
+
Calculate lung deposition using ICRP 66 model.
|
|
486
|
+
|
|
487
|
+
Based on the ICRP (International Commission on Radiological Protection)
|
|
488
|
+
Human Respiratory Tract Model for particle deposition.
|
|
489
|
+
|
|
490
|
+
Parameters
|
|
491
|
+
----------
|
|
492
|
+
activity : {'sleep', 'sitting', 'light', 'heavy'}, default='light'
|
|
493
|
+
Activity level affecting breathing pattern:
|
|
494
|
+
- 'sleep': Sleeping (nasal, 7.5 L/min)
|
|
495
|
+
- 'sitting': Sitting awake (nasal, 9 L/min)
|
|
496
|
+
- 'light': Light exercise (nasal+oral, 25 L/min)
|
|
497
|
+
- 'heavy': Heavy exercise (oral, 50 L/min)
|
|
498
|
+
|
|
499
|
+
Returns
|
|
500
|
+
-------
|
|
501
|
+
dict
|
|
502
|
+
- 'DF': Deposition fraction DataFrame (HA, TB, AL, Total)
|
|
503
|
+
- 'deposited': Deposited number distribution
|
|
504
|
+
- 'dose': Regional deposited dose (particles/cm³)
|
|
505
|
+
- 'total_dose': Total deposited particles
|
|
506
|
+
|
|
507
|
+
Notes
|
|
508
|
+
-----
|
|
509
|
+
Deposition regions:
|
|
510
|
+
- HA (Head Airways): 頭部氣道 (鼻、咽、喉)
|
|
511
|
+
- TB (Tracheobronchial): 氣管支氣管區
|
|
512
|
+
- AL (Alveolar): 肺泡區
|
|
513
|
+
|
|
514
|
+
References
|
|
515
|
+
----------
|
|
516
|
+
- ICRP Publication 66 (1994)
|
|
517
|
+
- Hinds, W.C. (1999) Aerosol Technology
|
|
518
|
+
|
|
519
|
+
Examples
|
|
520
|
+
--------
|
|
521
|
+
>>> psd = SizeDist(df)
|
|
522
|
+
>>> lung = psd.lung_deposition(activity='light')
|
|
523
|
+
>>> lung['DF'] # Deposition fractions
|
|
524
|
+
>>> lung['dose'] # Regional dose
|
|
525
|
+
"""
|
|
526
|
+
# Deposition fraction functions based on ICRP 66 / Hinds (1999)
|
|
527
|
+
dp_um = self._dp / 1000 # Convert nm to μm
|
|
528
|
+
|
|
529
|
+
# Calculate deposition fractions for each region
|
|
530
|
+
DF_HA, DF_TB, DF_AL = _calc_deposition_fractions(dp_um, activity)
|
|
531
|
+
DF_total = DF_HA + DF_TB + DF_AL
|
|
532
|
+
|
|
533
|
+
# Create deposition fraction DataFrame
|
|
534
|
+
df_DF = DataFrame({
|
|
535
|
+
'HA': DF_HA,
|
|
536
|
+
'TB': DF_TB,
|
|
537
|
+
'AL': DF_AL,
|
|
538
|
+
'Total': DF_total
|
|
539
|
+
}, index=self._dp)
|
|
540
|
+
|
|
541
|
+
# Calculate deposited distribution for each time point
|
|
542
|
+
deposited_HA = self._data.dropna().apply(
|
|
543
|
+
lambda row: np.array(row) * DF_HA, axis=1, result_type='broadcast'
|
|
544
|
+
).reindex(self._index)
|
|
545
|
+
|
|
546
|
+
deposited_TB = self._data.dropna().apply(
|
|
547
|
+
lambda row: np.array(row) * DF_TB, axis=1, result_type='broadcast'
|
|
548
|
+
).reindex(self._index)
|
|
549
|
+
|
|
550
|
+
deposited_AL = self._data.dropna().apply(
|
|
551
|
+
lambda row: np.array(row) * DF_AL, axis=1, result_type='broadcast'
|
|
552
|
+
).reindex(self._index)
|
|
553
|
+
|
|
554
|
+
deposited_total = self._data.dropna().apply(
|
|
555
|
+
lambda row: np.array(row) * DF_total, axis=1, result_type='broadcast'
|
|
556
|
+
).reindex(self._index)
|
|
557
|
+
|
|
558
|
+
# Calculate total dose (integrated over size)
|
|
559
|
+
dlogdp = self._dlogdp[0] if len(self._dlogdp) > 0 else 0.014
|
|
560
|
+
|
|
561
|
+
dose_HA = deposited_HA.sum(axis=1) * dlogdp
|
|
562
|
+
dose_TB = deposited_TB.sum(axis=1) * dlogdp
|
|
563
|
+
dose_AL = deposited_AL.sum(axis=1) * dlogdp
|
|
564
|
+
dose_total = deposited_total.sum(axis=1) * dlogdp
|
|
565
|
+
|
|
566
|
+
from pandas import concat
|
|
567
|
+
dose = concat([dose_HA, dose_TB, dose_AL, dose_total], axis=1)
|
|
568
|
+
dose.columns = ['HA', 'TB', 'AL', 'Total']
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
'DF': df_DF,
|
|
572
|
+
'deposited': {
|
|
573
|
+
'HA': deposited_HA,
|
|
574
|
+
'TB': deposited_TB,
|
|
575
|
+
'AL': deposited_AL,
|
|
576
|
+
'Total': deposited_total
|
|
577
|
+
},
|
|
578
|
+
'dose': dose,
|
|
579
|
+
'total_dose': dose_total
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _calc_deposition_fractions(dp_um: np.ndarray, activity: str = 'light') -> tuple:
|
|
584
|
+
"""
|
|
585
|
+
Calculate regional deposition fractions based on ICRP 66 model.
|
|
586
|
+
|
|
587
|
+
Parameters
|
|
588
|
+
----------
|
|
589
|
+
dp_um : np.ndarray
|
|
590
|
+
Particle diameter in micrometers.
|
|
591
|
+
activity : str
|
|
592
|
+
Activity level.
|
|
593
|
+
|
|
594
|
+
Returns
|
|
595
|
+
-------
|
|
596
|
+
tuple
|
|
597
|
+
(DF_HA, DF_TB, DF_AL) deposition fractions.
|
|
598
|
+
"""
|
|
599
|
+
# Breathing parameters by activity level
|
|
600
|
+
# (nasal fraction, tidal volume L, breathing frequency /min)
|
|
601
|
+
activity_params = {
|
|
602
|
+
'sleep': (1.0, 0.625, 12), # 7.5 L/min, nasal
|
|
603
|
+
'sitting': (1.0, 0.75, 12), # 9 L/min, nasal
|
|
604
|
+
'light': (0.5, 1.25, 20), # 25 L/min, mixed
|
|
605
|
+
'heavy': (0.0, 1.92, 26), # 50 L/min, oral
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if activity not in activity_params:
|
|
609
|
+
raise ValueError(f"Invalid activity: {activity}. Choose from {list(activity_params.keys())}")
|
|
610
|
+
|
|
611
|
+
f_nasal, Vt, f_breath = activity_params[activity]
|
|
612
|
+
|
|
613
|
+
# Inhalability (fraction that enters the respiratory system)
|
|
614
|
+
# Based on ICRP 66
|
|
615
|
+
IF = 1 - 0.5 * (1 - 1 / (1 + 0.00076 * dp_um ** 2.8))
|
|
616
|
+
|
|
617
|
+
# Head Airways (HA) deposition - empirical fit
|
|
618
|
+
# Nasal deposition
|
|
619
|
+
DF_HA_nasal = IF * (1 / (1 + np.exp(6.84 + 1.183 * np.log(dp_um))) +
|
|
620
|
+
1 / (1 + np.exp(0.924 - 1.885 * np.log(dp_um))))
|
|
621
|
+
|
|
622
|
+
# Oral deposition (lower for larger particles)
|
|
623
|
+
DF_HA_oral = IF * (1 / (1 + np.exp(6.84 + 1.183 * np.log(dp_um))) * 0.5)
|
|
624
|
+
|
|
625
|
+
# Weighted HA deposition
|
|
626
|
+
DF_HA = f_nasal * DF_HA_nasal + (1 - f_nasal) * DF_HA_oral
|
|
627
|
+
|
|
628
|
+
# Fraction reaching thoracic region
|
|
629
|
+
F_thoracic = IF - DF_HA
|
|
630
|
+
|
|
631
|
+
# Tracheobronchial (TB) deposition
|
|
632
|
+
# Based on impaction and sedimentation
|
|
633
|
+
DF_TB = F_thoracic * (0.00352 / dp_um * (np.exp(-0.234 * (np.log(dp_um) + 3.40) ** 2) +
|
|
634
|
+
63.9 * np.exp(-0.819 * (np.log(dp_um) - 1.61) ** 2)))
|
|
635
|
+
|
|
636
|
+
# Ensure non-negative
|
|
637
|
+
DF_TB = np.maximum(DF_TB, 0)
|
|
638
|
+
|
|
639
|
+
# Alveolar (AL) deposition
|
|
640
|
+
# Diffusion-dominated for ultrafine, sedimentation for larger
|
|
641
|
+
F_alveolar = F_thoracic - DF_TB
|
|
642
|
+
|
|
643
|
+
DF_AL = F_alveolar * (0.0155 / dp_um * (np.exp(-0.416 * (np.log(dp_um) + 2.84) ** 2) +
|
|
644
|
+
19.11 * np.exp(-0.482 * (np.log(dp_um) - 1.362) ** 2)))
|
|
645
|
+
|
|
646
|
+
# Ensure non-negative and bounded
|
|
647
|
+
DF_AL = np.maximum(DF_AL, 0)
|
|
648
|
+
DF_AL = np.minimum(DF_AL, F_alveolar)
|
|
649
|
+
|
|
650
|
+
# Ensure total doesn't exceed IF
|
|
651
|
+
DF_total = DF_HA + DF_TB + DF_AL
|
|
652
|
+
scale = np.where(DF_total > IF, IF / DF_total, 1.0)
|
|
653
|
+
DF_HA *= scale
|
|
654
|
+
DF_TB *= scale
|
|
655
|
+
DF_AL *= scale
|
|
656
|
+
|
|
657
|
+
return DF_HA, DF_TB, DF_AL
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _resolved_gRH(dp: np.ndarray, gRH: float = 1.31, uniform: bool = True) -> np.ndarray:
|
|
661
|
+
"""
|
|
662
|
+
Calculate the growth factor for each particle diameter bin.
|
|
663
|
+
|
|
664
|
+
Parameters
|
|
665
|
+
----------
|
|
666
|
+
dp : np.ndarray
|
|
667
|
+
Array of particle diameters in nm.
|
|
668
|
+
gRH : float, default=1.31
|
|
669
|
+
The uniform growth factor to apply if uniform=True.
|
|
670
|
+
uniform : bool, default=True
|
|
671
|
+
If True, apply uniform gRH across all sizes.
|
|
672
|
+
If False, apply size-dependent gRH based on lognormal distribution.
|
|
673
|
+
|
|
674
|
+
Returns
|
|
675
|
+
-------
|
|
676
|
+
np.ndarray
|
|
677
|
+
Growth factor for each diameter bin.
|
|
678
|
+
"""
|
|
679
|
+
if uniform:
|
|
680
|
+
return np.full(dp.size, gRH)
|
|
681
|
+
else:
|
|
682
|
+
def lognorm_dist(x, geoMean, geoStd):
|
|
683
|
+
return (gRH / (np.log10(geoStd) * np.sqrt(2 * np.pi))) * np.exp(
|
|
684
|
+
-(x - np.log10(geoMean)) ** 2 / (2 * np.log10(geoStd) ** 2))
|
|
685
|
+
|
|
686
|
+
result = lognorm_dist(np.log10(dp), 200, 2.0)
|
|
687
|
+
return np.where(result < 1, 1, result)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _dry_pnsd_process(dist: np.ndarray,
|
|
691
|
+
dp: np.ndarray,
|
|
692
|
+
gRH: float,
|
|
693
|
+
uniform: bool = True) -> np.ndarray:
|
|
694
|
+
"""
|
|
695
|
+
Convert ambient PSD to dry PSD by shrinking particles.
|
|
696
|
+
|
|
697
|
+
Parameters
|
|
698
|
+
----------
|
|
699
|
+
dist : np.ndarray
|
|
700
|
+
The ambient particle number distribution.
|
|
701
|
+
dp : np.ndarray
|
|
702
|
+
Array of particle diameters in nm.
|
|
703
|
+
gRH : float
|
|
704
|
+
The growth factor (Dp_wet / Dp_dry).
|
|
705
|
+
uniform : bool, default=True
|
|
706
|
+
If True, apply uniform gRH across all sizes.
|
|
707
|
+
|
|
708
|
+
Returns
|
|
709
|
+
-------
|
|
710
|
+
np.ndarray
|
|
711
|
+
The dry particle number distribution.
|
|
712
|
+
"""
|
|
713
|
+
ndp = np.array(dist[:np.size(dp)])
|
|
714
|
+
growth_factors = _resolved_gRH(dp, gRH, uniform=uniform)
|
|
715
|
+
|
|
716
|
+
# Calculate dry diameters
|
|
717
|
+
dry_dp = dp / growth_factors
|
|
718
|
+
|
|
719
|
+
# Find which bin each dry diameter belongs to
|
|
720
|
+
belong_which_ibin = np.digitize(dry_dp, dp) - 1
|
|
721
|
+
|
|
722
|
+
# Redistribute particles to appropriate bins
|
|
723
|
+
result = {}
|
|
724
|
+
for i, (ibin, dn) in enumerate(zip(belong_which_ibin, ndp)):
|
|
725
|
+
if ibin < 0 or ibin >= len(dp):
|
|
726
|
+
continue
|
|
727
|
+
if dp[ibin] not in result:
|
|
728
|
+
result[dp[ibin]] = []
|
|
729
|
+
result[dp[ibin]].append(ndp[i])
|
|
730
|
+
|
|
731
|
+
# Average concentrations in each bin
|
|
732
|
+
dry_ndp = []
|
|
733
|
+
for key in sorted(result.keys()):
|
|
734
|
+
val = result[key]
|
|
735
|
+
dry_ndp.append(sum(val) / len(val))
|
|
736
|
+
|
|
737
|
+
return np.array(dry_ndp)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _geometric_statistics(dp: np.ndarray, dist: DataFrame) -> tuple:
|
|
741
|
+
"""
|
|
742
|
+
Calculate geometric mean diameter and standard deviation.
|
|
743
|
+
|
|
744
|
+
Parameters
|
|
745
|
+
----------
|
|
746
|
+
dp : ndarray
|
|
747
|
+
Particle diameters.
|
|
748
|
+
dist : DataFrame
|
|
749
|
+
Distribution data.
|
|
750
|
+
|
|
751
|
+
Returns
|
|
752
|
+
-------
|
|
753
|
+
tuple
|
|
754
|
+
(total, GMD, GSD)
|
|
755
|
+
"""
|
|
756
|
+
total = dist.sum(axis=1)
|
|
757
|
+
total = total.where(total > 0).copy()
|
|
758
|
+
|
|
759
|
+
log_dp = np.log(dp)
|
|
760
|
+
gmd = ((dist * log_dp).sum(axis=1)) / total
|
|
761
|
+
|
|
762
|
+
dp_mesh, gmd_mesh = np.meshgrid(log_dp, gmd)
|
|
763
|
+
gsd = ((((dp_mesh - gmd_mesh) ** 2) * dist).sum(axis=1) / total) ** 0.5
|
|
764
|
+
|
|
765
|
+
return total, gmd.apply(np.exp), gsd.apply(np.exp)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def get_required_format():
|
|
769
|
+
"""
|
|
770
|
+
Get required format for SizeDist input data.
|
|
771
|
+
|
|
772
|
+
Returns
|
|
773
|
+
-------
|
|
774
|
+
dict
|
|
775
|
+
Dictionary describing the required format for SizeDist.
|
|
776
|
+
|
|
777
|
+
Examples
|
|
778
|
+
--------
|
|
779
|
+
>>> fmt = get_required_format()
|
|
780
|
+
>>> print(fmt['data'])
|
|
781
|
+
"""
|
|
782
|
+
return {
|
|
783
|
+
'data': {
|
|
784
|
+
'type': 'pandas DataFrame',
|
|
785
|
+
'columns': '粒徑值作為欄位名稱 (nm),例如: 10.0, 20.0, 50.0, ...',
|
|
786
|
+
'values': '各粒徑的數目濃度 (dN/dlogDp 或 dN/ddp 或 dN)',
|
|
787
|
+
'index': 'DatetimeIndex (時間索引)'
|
|
788
|
+
},
|
|
789
|
+
'state': {
|
|
790
|
+
'options': ['dN', 'ddp', 'dlogdp'],
|
|
791
|
+
'default': 'dlogdp',
|
|
792
|
+
'description': {
|
|
793
|
+
'dN': '原始數目濃度',
|
|
794
|
+
'ddp': 'dN/ddp 正規化',
|
|
795
|
+
'dlogdp': 'dN/dlogDp 正規化'
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
'weighting': {
|
|
799
|
+
'options': ['n', 's', 'v', 'ext_in', 'ext_ex'],
|
|
800
|
+
'default': 'n',
|
|
801
|
+
'description': {
|
|
802
|
+
'n': 'Number weighting 數目加權',
|
|
803
|
+
's': 'Surface weighting 表面積加權',
|
|
804
|
+
'v': 'Volume weighting 體積加權',
|
|
805
|
+
'ext_in': 'Internal extinction weighting 內混合消光加權',
|
|
806
|
+
'ext_ex': 'External extinction weighting 外混合消光加權'
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
'usage_example': "psd = SizeDist(df, state='dlogdp', weighting='n')"
|
|
810
|
+
}
|