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,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
+ }