denflux 0.1.0__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.
- denflux/__init__.py +0 -0
- denflux/core.py +812 -0
- denflux-0.1.0.dist-info/METADATA +136 -0
- denflux-0.1.0.dist-info/RECORD +7 -0
- denflux-0.1.0.dist-info/WHEEL +5 -0
- denflux-0.1.0.dist-info/licenses/LICENSE +10 -0
- denflux-0.1.0.dist-info/top_level.txt +1 -0
denflux/__init__.py
ADDED
|
File without changes
|
denflux/core.py
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
DenFlux Core Engine: A process-based approach to correct TDP baseline shifts.
|
|
4
|
+
|
|
5
|
+
This engine integrates ecophysiological theory with Random Forest
|
|
6
|
+
machine learning to partition nocturnal sap flux, estimate physiological
|
|
7
|
+
parameters, and dynamically reconstruct the true zero-flow baseline.
|
|
8
|
+
|
|
9
|
+
@author: Mianzhi Wang (mianzhi.wang@helsinki.fi)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
|
14
|
+
warnings.filterwarnings("ignore", category=UserWarning)
|
|
15
|
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
|
16
|
+
import pandas as pd
|
|
17
|
+
warnings.simplefilter(action="ignore")
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from sklearn.experimental import enable_iterative_imputer
|
|
21
|
+
from sklearn.impute import IterativeImputer
|
|
22
|
+
from sklearn.ensemble import RandomForestRegressor
|
|
23
|
+
from sklearn.model_selection import GroupShuffleSplit
|
|
24
|
+
from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error
|
|
25
|
+
import statsmodels.api as sm
|
|
26
|
+
import matplotlib.pyplot as plt
|
|
27
|
+
from sklearn.preprocessing import StandardScaler
|
|
28
|
+
import numpy as np
|
|
29
|
+
import seaborn as sns
|
|
30
|
+
|
|
31
|
+
def process(df,
|
|
32
|
+
time_col='Time',
|
|
33
|
+
dayfirst=False,
|
|
34
|
+
date_format=None,
|
|
35
|
+
alpha=0.0119,
|
|
36
|
+
beta=1.231,
|
|
37
|
+
growstart=5,
|
|
38
|
+
growend=9,
|
|
39
|
+
time_interval=5,
|
|
40
|
+
apply_rolling=True,
|
|
41
|
+
rolling_window=30,
|
|
42
|
+
night_threshold=10,
|
|
43
|
+
Radiation="PAR",
|
|
44
|
+
daycheck_start=10,
|
|
45
|
+
daycheck_end=16,
|
|
46
|
+
deci_tree_T=200, depth_T=12, min_leaf_T=5, max_fea_T='sqrt', random_state_T=42,
|
|
47
|
+
deci_tree_R=200, depth_R=12, min_leaf_R=5, max_fea_R='sqrt', random_state_R=42,
|
|
48
|
+
expected_r2=0.3,
|
|
49
|
+
diagnostic_plots=False):
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
Parameters:
|
|
53
|
+
-----------
|
|
54
|
+
[Data & Time Configuration]
|
|
55
|
+
df : pandas.DataFrame
|
|
56
|
+
Input dataframe containing timestamps and required environmental/sap flow variables.
|
|
57
|
+
time_col : str, default='Time'
|
|
58
|
+
The exact name of the column containing timestamps.
|
|
59
|
+
dayfirst : bool, default=False
|
|
60
|
+
Set to True if dates are formatted as DD/MM/YYYY to prevent parsing ambiguity.
|
|
61
|
+
date_format : str, default=None
|
|
62
|
+
Explicit datetime format string (e.g., '%Y-%m-%d %H:%M:%S'). If None, pandas auto-detects.
|
|
63
|
+
time_interval : int or float, default=5
|
|
64
|
+
The temporal resolution of the raw data in minutes.
|
|
65
|
+
|
|
66
|
+
[Ecophysiological Configuration]
|
|
67
|
+
alpha : float, default=0.0119
|
|
68
|
+
Calibration parameter alpha for the Granier formula.
|
|
69
|
+
beta : float, default=1.231
|
|
70
|
+
Calibration parameter beta for the Granier formula.
|
|
71
|
+
growstart : int, default=5
|
|
72
|
+
The starting month of the growing season (1-12).
|
|
73
|
+
growend : int, default=9
|
|
74
|
+
The ending month of the growing season (1-12).
|
|
75
|
+
|
|
76
|
+
[Day/Night Boundary & Filtering]
|
|
77
|
+
Radiation : str, default='PAR'
|
|
78
|
+
Column name for solar radiation, used to dynamically determine day and night.
|
|
79
|
+
night_threshold : float or int, default=10
|
|
80
|
+
Radiation value below which the environment is mathematically considered "nighttime".
|
|
81
|
+
daycheck_start : int, default=10
|
|
82
|
+
Start hour (0-23) for daytime leakage checking.
|
|
83
|
+
daycheck_end : int, default=16
|
|
84
|
+
End hour (0-23) for daytime leakage checking.
|
|
85
|
+
|
|
86
|
+
[Signal Processing]
|
|
87
|
+
apply_rolling : bool, default=True
|
|
88
|
+
Whether to apply a rolling average to smooth dendrometer and sap flux data.
|
|
89
|
+
rolling_window : int or float, default=30 (Unit: min)
|
|
90
|
+
The window size for the rolling average in minutes.
|
|
91
|
+
|
|
92
|
+
[Machine Learning Hyperparameters (Random Forest)]
|
|
93
|
+
expected_r2 : float, default=0.3
|
|
94
|
+
Validation R-squared threshold. Triggers a warning if models perform below this.
|
|
95
|
+
deci_tree_T, deci_tree_R : int, default=200
|
|
96
|
+
Number of trees in the forest for Transpiration (T) and Rehydration (R) models.
|
|
97
|
+
depth_T, depth_R : int, default=12
|
|
98
|
+
Maximum depth of the individual regression estimators.
|
|
99
|
+
min_leaf_T, min_leaf_R : int, default=5
|
|
100
|
+
Minimum number of samples required to be at a leaf node.
|
|
101
|
+
max_fea_T, max_fea_R : str, default='sqrt'
|
|
102
|
+
Number of features to consider when looking for the best split.
|
|
103
|
+
random_state_T, random_state_R : int, default=42
|
|
104
|
+
Seed for the random number generator to ensure reproducibility.
|
|
105
|
+
|
|
106
|
+
[Diagnostics]
|
|
107
|
+
diagnostic_plots : bool, default=False
|
|
108
|
+
If True, displays matplotlib figures for ML performance and baseline interpolation
|
|
109
|
+
to help troubleshoot data issues.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
--------
|
|
113
|
+
df : pandas.DataFrame
|
|
114
|
+
The fully processed dataframe enriched with corrected baseline and J values.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
df = df.copy()
|
|
118
|
+
|
|
119
|
+
resample_freq = f'{time_interval}min'
|
|
120
|
+
time_interval_s = time_interval * 60
|
|
121
|
+
max_gap = int(60 / time_interval)
|
|
122
|
+
rolling_window = int(rolling_window / time_interval)
|
|
123
|
+
|
|
124
|
+
print("\n" + "="*50)
|
|
125
|
+
print("INITIALIZING DENFLUX PROCESSING ENGINE...")
|
|
126
|
+
print("="*50)
|
|
127
|
+
|
|
128
|
+
# 1. set timestamp
|
|
129
|
+
if time_col in df.columns:
|
|
130
|
+
if date_format:
|
|
131
|
+
# If the user specifies an absolute format, the parsing will strictly adhere to that format.
|
|
132
|
+
df['Time'] = pd.to_datetime(df[time_col], format=date_format, errors='coerce')
|
|
133
|
+
else:
|
|
134
|
+
# Otherwise, let Pandas parse automatically, but follow the user-provided dayfirst instructions.
|
|
135
|
+
df['Time'] = pd.to_datetime(df[time_col], dayfirst=dayfirst, errors='coerce')
|
|
136
|
+
|
|
137
|
+
df.dropna(subset=['Time'], inplace=True)
|
|
138
|
+
df.set_index('Time', inplace=True)
|
|
139
|
+
elif not isinstance(df.index, pd.DatetimeIndex):
|
|
140
|
+
raise ValueError(f"The dataframe must have a column named '{time_col}' or a DatetimeIndex.")
|
|
141
|
+
|
|
142
|
+
df = df[(df.index.month >= growstart) & (df.index.month <= growend)]
|
|
143
|
+
|
|
144
|
+
# 2. get VPD (kPa)
|
|
145
|
+
def get_vpd(df, tair_col='Tair', rh_col='RH', vpd_col='VPD'):
|
|
146
|
+
df = df.copy()
|
|
147
|
+
|
|
148
|
+
# --- RH standardize ---
|
|
149
|
+
if rh_col not in df.columns:
|
|
150
|
+
raise KeyError(f"Missing column: {rh_col}")
|
|
151
|
+
|
|
152
|
+
if df[rh_col].max() > 5: #If the unit is kPa, it is impossible to have values greater than 5 kPa, so 5 is selected as the threshold.
|
|
153
|
+
df[rh_col] = df[rh_col] / 100.0 # convert to decimal
|
|
154
|
+
|
|
155
|
+
# --- VPD standardize ---
|
|
156
|
+
if vpd_col in df.columns:
|
|
157
|
+
if df[vpd_col].max() > 10:
|
|
158
|
+
df[vpd_col] = df[vpd_col] / 1000.0 # convert to kPa
|
|
159
|
+
else:
|
|
160
|
+
if tair_col not in df.columns:
|
|
161
|
+
raise KeyError(f"Missing column: {tair_col}")
|
|
162
|
+
|
|
163
|
+
T = df[tair_col]
|
|
164
|
+
RH = df[rh_col]
|
|
165
|
+
es = 0.611 * np.exp((17.502 * T) / (T + 240.97))
|
|
166
|
+
df[vpd_col] = es * (1 - RH)
|
|
167
|
+
|
|
168
|
+
return df
|
|
169
|
+
|
|
170
|
+
df = get_vpd(df)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
#####
|
|
174
|
+
|
|
175
|
+
# 3. adjust time resolution
|
|
176
|
+
df = df.resample(resample_freq).mean().interpolate(
|
|
177
|
+
method='linear',
|
|
178
|
+
limit=max_gap, # average data with high resolution, Interpolate data with low resolution
|
|
179
|
+
limit_direction='both'
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# 4. calculate dden
|
|
184
|
+
# 4.1 find col start with 'den'
|
|
185
|
+
den_cols = [col for col in df.columns if col.startswith('den')]
|
|
186
|
+
|
|
187
|
+
for col in den_cols:
|
|
188
|
+
# get den number, e.g., den2 = 2
|
|
189
|
+
suffix = col.replace('den', '')
|
|
190
|
+
new_col = f'dden{suffix}'
|
|
191
|
+
|
|
192
|
+
# calculate difference
|
|
193
|
+
df[new_col] = df[col].diff() / time_interval_s
|
|
194
|
+
|
|
195
|
+
# remove original col
|
|
196
|
+
df.drop(columns=col, inplace=True)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
df = df[(df.index.month >= growstart) & (df.index.month <= growend)]
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------
|
|
202
|
+
print("\n[Step 1/6] Validating Day/Night Split & Dendrometer Dynamics...")
|
|
203
|
+
print(" [INFO] Nocturnal stems are expected to expand (positive dden).")
|
|
204
|
+
print(" High negative ratios (>30%) may indicate improper night_threshold or daytime leakage.")
|
|
205
|
+
|
|
206
|
+
dden_cols = [col for col in df.columns if col.startswith('dden') and col != 'dden']
|
|
207
|
+
for col in dden_cols:
|
|
208
|
+
dden = df[[col, Radiation]].copy()
|
|
209
|
+
dden = dden[dden[Radiation] < night_threshold]
|
|
210
|
+
dden = dden[dden[col] < night_threshold]
|
|
211
|
+
|
|
212
|
+
neg_ratio = (dden[col] < 0).mean()
|
|
213
|
+
tree_id = col.replace('dden', '')
|
|
214
|
+
|
|
215
|
+
if neg_ratio > 0.3:
|
|
216
|
+
print(f" [WARNING] Tree {tree_id:<4} : {neg_ratio:>5.1%} negative values")
|
|
217
|
+
else:
|
|
218
|
+
print(f" [-] Tree {tree_id:<4} : {neg_ratio:>5.1%} negative values")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# 5. rolling average to dden and SFD (optional)
|
|
222
|
+
|
|
223
|
+
# 5.1 get dden and SFD cols names
|
|
224
|
+
dden_sfd_cols = [col for col in df.columns if col.startswith("dden") or col.startswith("SFD")]
|
|
225
|
+
|
|
226
|
+
# 5.2 apply rolling average (centered)
|
|
227
|
+
if apply_rolling and rolling_window > 1:
|
|
228
|
+
df[dden_sfd_cols] = df[dden_sfd_cols].rolling(window=rolling_window, center=True, min_periods=1).mean()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# 6. calculate NOY (night of year)
|
|
232
|
+
df['is_night'] = df[Radiation] < night_threshold
|
|
233
|
+
df['NOY'] = np.nan
|
|
234
|
+
|
|
235
|
+
# 6.1 get DOY (day of year)
|
|
236
|
+
df ['Time'] = df.index
|
|
237
|
+
df['DOY']=df['Time'].dt.strftime('%j').astype(int)
|
|
238
|
+
df=df.drop(['Time'],axis=1)
|
|
239
|
+
|
|
240
|
+
# 6.2 get NOY (night of year)
|
|
241
|
+
current_noy = np.nan
|
|
242
|
+
in_night = False
|
|
243
|
+
|
|
244
|
+
for i in range(1, len(df)):
|
|
245
|
+
curr = df.iloc[i]
|
|
246
|
+
|
|
247
|
+
if (not in_night) and (curr[Radiation] < night_threshold) and (curr.name.hour >= 12):
|
|
248
|
+
current_noy = curr['DOY']
|
|
249
|
+
in_night = True
|
|
250
|
+
df.iat[i, df.columns.get_loc('NOY')] = current_noy
|
|
251
|
+
elif in_night and curr[Radiation] < night_threshold:
|
|
252
|
+
df.iat[i, df.columns.get_loc('NOY')] = current_noy
|
|
253
|
+
elif in_night and curr[Radiation] >= night_threshold:
|
|
254
|
+
in_night = False
|
|
255
|
+
|
|
256
|
+
# 6.3 delete auxiliary column
|
|
257
|
+
df=df.drop(['is_night'],axis=1)
|
|
258
|
+
|
|
259
|
+
# 6.4 keep DOY only if Radiation is higher than night_threshold
|
|
260
|
+
df['DOY'] = df['DOY'].where(df[Radiation] >= night_threshold)
|
|
261
|
+
|
|
262
|
+
# 6.5 get whole_day
|
|
263
|
+
df['whole_day'] = df['NOY'].combine_first(df['DOY'])
|
|
264
|
+
|
|
265
|
+
# 6.6 add year prefix
|
|
266
|
+
year = df.index.year
|
|
267
|
+
for col in ['NOY', 'DOY', 'whole_day']:
|
|
268
|
+
df[col] = year * 1000 + df[col].astype('Int64')
|
|
269
|
+
|
|
270
|
+
# ==========================================================
|
|
271
|
+
# Daytime Leakage Filter
|
|
272
|
+
# ==========================================================
|
|
273
|
+
print("\n[Step 2/6] Running Internal Daytime Leakage Check...")
|
|
274
|
+
print(" [INFO] Checking if low-radiation daytime periods were incorrectly classified as night.")
|
|
275
|
+
|
|
276
|
+
df_temp_hour = df.index.hour
|
|
277
|
+
leaked_rows = df[(df['NOY'].notna()) &
|
|
278
|
+
(df_temp_hour >= daycheck_start) &
|
|
279
|
+
(df_temp_hour <= daycheck_end)]
|
|
280
|
+
|
|
281
|
+
if not leaked_rows.empty:
|
|
282
|
+
leak_counts_per_noy = leaked_rows.groupby('NOY').size()
|
|
283
|
+
threshold_rows = int(60 / time_interval)
|
|
284
|
+
bad_noys = leak_counts_per_noy[leak_counts_per_noy > threshold_rows].index.tolist()
|
|
285
|
+
|
|
286
|
+
if bad_noys:
|
|
287
|
+
total_noys_before = df['NOY'].nunique()
|
|
288
|
+
df.loc[df['NOY'].isin(bad_noys), 'NOY'] = np.nan
|
|
289
|
+
total_noys_after = df['NOY'].nunique()
|
|
290
|
+
removed_count = total_noys_before - total_noys_after
|
|
291
|
+
|
|
292
|
+
print(f" [WARNING] Found daytime leakage!")
|
|
293
|
+
print(f" - Corrupted NOYs (>{threshold_rows} points in daytime): {bad_noys}")
|
|
294
|
+
print(f" - Action: Removed {removed_count} corrupted NOYs.")
|
|
295
|
+
print(f" - Safe NOYs remaining: {total_noys_after}")
|
|
296
|
+
else:
|
|
297
|
+
print(" [-] Passed: Minor daytime leakage detected (under 60 mins). No nights removed.")
|
|
298
|
+
else:
|
|
299
|
+
print(" [-] Passed: No daytime leakage detected.")
|
|
300
|
+
# ==========================================================
|
|
301
|
+
|
|
302
|
+
# 7. calculate preliminary SFD, using dTmax of each day as baseline
|
|
303
|
+
|
|
304
|
+
# 7.1 find dTmax of each day as baseline
|
|
305
|
+
dT_cols = [col for col in df.columns if col.startswith('dT') and not col.startswith('dTmax')]
|
|
306
|
+
|
|
307
|
+
for col in dT_cols:
|
|
308
|
+
suffix = col.replace('dT', '')
|
|
309
|
+
new_col = f'dTmax{suffix}'
|
|
310
|
+
df[new_col] = df.groupby('whole_day')[col].transform('max')
|
|
311
|
+
|
|
312
|
+
# 7.2 calculate preliminary SFD
|
|
313
|
+
for col in df.columns:
|
|
314
|
+
if col.startswith('dT') and not col.startswith('dTmax'):
|
|
315
|
+
suffix = col.replace('dT', '') # e.g., '1' from 'dT1'
|
|
316
|
+
dT_col = col
|
|
317
|
+
dTmax_col = f'dTmax{suffix}'
|
|
318
|
+
sfd_col = f'SFD{suffix}'
|
|
319
|
+
|
|
320
|
+
if dTmax_col in df.columns:
|
|
321
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
322
|
+
# customize alpha and beta
|
|
323
|
+
df[sfd_col] = alpha * ((df[dTmax_col] - df[dT_col]) / df[dT_col]) ** beta
|
|
324
|
+
df[sfd_col] = df[sfd_col].replace([np.inf, -np.inf], np.nan)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# 8. multiple linear regression model to estimate gc and Frs
|
|
328
|
+
print("\n[Step 3/6] Building Night-by-Night Linear Models...")
|
|
329
|
+
print(" [INFO] Estimating physiological parameters (gt, Frs) to find baseline anchors.")
|
|
330
|
+
night = df[df['NOY'].notna()].copy()
|
|
331
|
+
|
|
332
|
+
# 8.1 read ddenX cols
|
|
333
|
+
dden_cols = [col for col in night.columns if col.startswith('dden')]
|
|
334
|
+
|
|
335
|
+
# 8.2 save results in dataframe
|
|
336
|
+
all_results = []
|
|
337
|
+
|
|
338
|
+
def get_outlier_mask(series, multiplier=3.0):
|
|
339
|
+
Q1 = series.quantile(0.25)
|
|
340
|
+
Q3 = series.quantile(0.75)
|
|
341
|
+
IQR = Q3 - Q1
|
|
342
|
+
lower_bound = Q1 - multiplier * IQR
|
|
343
|
+
upper_bound = Q3 + multiplier * IQR
|
|
344
|
+
return (series < lower_bound) | (series > upper_bound)
|
|
345
|
+
|
|
346
|
+
for dden_col in dden_cols:
|
|
347
|
+
suffix = dden_col.replace('dden', '')
|
|
348
|
+
sfd_col = f'SFD{suffix}'
|
|
349
|
+
|
|
350
|
+
if sfd_col not in night.columns:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
x_cols = ['VPD', dden_col]
|
|
354
|
+
y_col = sfd_col
|
|
355
|
+
|
|
356
|
+
# 8.3 remove NaN
|
|
357
|
+
df_fit = night.dropna(subset=[*x_cols, y_col]).copy()
|
|
358
|
+
df_fit['intercept'] = 1.0
|
|
359
|
+
|
|
360
|
+
# 8.4 group by NOY
|
|
361
|
+
grouped = df_fit.groupby('NOY')
|
|
362
|
+
results = []
|
|
363
|
+
|
|
364
|
+
for name, group in grouped:
|
|
365
|
+
try:
|
|
366
|
+
model = sm.OLS(group[y_col], group[x_cols + ['intercept']])
|
|
367
|
+
result = model.fit()
|
|
368
|
+
|
|
369
|
+
results.append({
|
|
370
|
+
'NOY': name,
|
|
371
|
+
'SFD': sfd_col,
|
|
372
|
+
'dden': dden_col,
|
|
373
|
+
'int': result.params['intercept'],
|
|
374
|
+
'Frs': result.params[dden_col],
|
|
375
|
+
'gc': result.params['VPD'],
|
|
376
|
+
'p_int': result.pvalues['intercept'],
|
|
377
|
+
'p_Frs': result.pvalues[dden_col],
|
|
378
|
+
'p_gc': result.pvalues['VPD'],
|
|
379
|
+
'R2': result.rsquared,
|
|
380
|
+
'R2_adj': result.rsquared_adj,
|
|
381
|
+
'AIC': result.aic
|
|
382
|
+
})
|
|
383
|
+
except Exception as e:
|
|
384
|
+
print(f"Skipping NOY={name} for {sfd_col}: {e}")
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
result_df = pd.DataFrame(results)
|
|
388
|
+
|
|
389
|
+
if result_df.empty:
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
required_cols = ['p_Frs', 'p_gc', 'Frs', 'gc', 'R2_adj']
|
|
393
|
+
if not all(col in result_df.columns for col in required_cols):
|
|
394
|
+
print(f"WARNING: Incomplete regression results for {sfd_col}. Missing required columns. Skipping.")
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
# 8.5 filter good nights
|
|
398
|
+
result_df['flag'] = (
|
|
399
|
+
(result_df['p_Frs'] < 0.05) &
|
|
400
|
+
(result_df['p_gc'] < 0.05) &
|
|
401
|
+
(result_df['Frs'] > 0) &
|
|
402
|
+
(result_df['gc'] > 0) &
|
|
403
|
+
(result_df['R2_adj'] > 0.5)
|
|
404
|
+
).astype(int)
|
|
405
|
+
|
|
406
|
+
# 8.6 print good night
|
|
407
|
+
good_rate = (result_df['flag'] == 1).mean()
|
|
408
|
+
print(f" [-] Tree {suffix:<4} : Valid nights for linear model: {good_rate:>5.1%}")
|
|
409
|
+
|
|
410
|
+
# 8.7 save only good ones
|
|
411
|
+
result_df = result_df[result_df['flag'] == 1]
|
|
412
|
+
|
|
413
|
+
if result_df.empty:
|
|
414
|
+
print(f" [WARNING] Tree {suffix:<4} : No valid nights found. Skipping this tree entirely.")
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
all_results.append(result_df)
|
|
418
|
+
|
|
419
|
+
# 9. calculate T and R
|
|
420
|
+
night[f'gc{suffix}'] = night['NOY'].map(result_df.set_index('NOY')['gc'])
|
|
421
|
+
night[f'Frs{suffix}'] = night['NOY'].map(result_df.set_index('NOY')['Frs'])
|
|
422
|
+
|
|
423
|
+
# 9.2 calculate
|
|
424
|
+
night[f'T{suffix}'] = night['VPD'] * night[f'gc{suffix}']
|
|
425
|
+
night[f'R{suffix}'] = night[dden_col] * night[f'Frs{suffix}']
|
|
426
|
+
|
|
427
|
+
# 9.3 outliers cleaning
|
|
428
|
+
mask_T = get_outlier_mask(night[f'T{suffix}'], multiplier=3.0)
|
|
429
|
+
mask_R = get_outlier_mask(night[f'R{suffix}'], multiplier=3.0)
|
|
430
|
+
|
|
431
|
+
outlier_mask = mask_T | mask_R
|
|
432
|
+
outliers_count = outlier_mask.sum()
|
|
433
|
+
|
|
434
|
+
if outliers_count > 0:
|
|
435
|
+
print(f"Tree {suffix}: Found {outliers_count} extreme outlier rows in T or R. Replacing with NaN for RF imputation.")
|
|
436
|
+
night.loc[outlier_mask, [f'T{suffix}', f'R{suffix}']] = np.nan
|
|
437
|
+
|
|
438
|
+
if all_results:
|
|
439
|
+
final_results = pd.concat(all_results, ignore_index=True)
|
|
440
|
+
|
|
441
|
+
# 9.4 After exiting the for loop, delete all model parameter columns at once
|
|
442
|
+
night.drop(columns=[col for col in night.columns if col.startswith(('gc', 'Frs', 'intercept', 'DOY', 'whole_day'))], errors='ignore', inplace=True)
|
|
443
|
+
|
|
444
|
+
# 10. Random Forest (train independently by sample number)
|
|
445
|
+
|
|
446
|
+
# Define the plotting function
|
|
447
|
+
def plot_academic_performance(df_plot, target_name):
|
|
448
|
+
if not diagnostic_plots:
|
|
449
|
+
return
|
|
450
|
+
sns.set_theme(style="ticks", context="paper", font_scale=1.2)
|
|
451
|
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5.5))
|
|
452
|
+
|
|
453
|
+
palette_colors = {'Training': '#4C72B0', 'Validation': '#C44E52'}
|
|
454
|
+
markers_style = {'Training': 'o', 'Validation': '^'}
|
|
455
|
+
|
|
456
|
+
sns.scatterplot(
|
|
457
|
+
data=df_plot, x='Actual', y='Predicted',
|
|
458
|
+
hue='Split', style='Split',
|
|
459
|
+
palette=palette_colors, markers=markers_style,
|
|
460
|
+
alpha=0.7, edgecolor='w', s=60, ax=axes[0]
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
min_val = min(df_plot['Actual'].min(), df_plot['Predicted'].min())
|
|
464
|
+
max_val = max(df_plot['Actual'].max(), df_plot['Predicted'].max())
|
|
465
|
+
|
|
466
|
+
axes[0].plot([min_val, max_val], [min_val, max_val],
|
|
467
|
+
color='black', linestyle='--', linewidth=1.5, label='1:1 Line')
|
|
468
|
+
|
|
469
|
+
axes[0].set_title(f'1:1 Scatter Plot for {target_name}', fontweight='bold')
|
|
470
|
+
axes[0].set_xlabel('Actual Value')
|
|
471
|
+
axes[0].set_ylabel('Predicted Value')
|
|
472
|
+
axes[0].legend(frameon=True, loc='best')
|
|
473
|
+
|
|
474
|
+
sns.scatterplot(
|
|
475
|
+
data=df_plot, x='Predicted', y='Residual',
|
|
476
|
+
hue='Split', style='Split',
|
|
477
|
+
palette=palette_colors, markers=markers_style,
|
|
478
|
+
alpha=0.7, edgecolor='w', s=60, ax=axes[1]
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
axes[1].axhline(0, color='black', linestyle='--', linewidth=1.5, label='Zero Error')
|
|
482
|
+
|
|
483
|
+
axes[1].set_title(f'Residual Plot for {target_name}', fontweight='bold')
|
|
484
|
+
axes[1].set_xlabel('Predicted Value')
|
|
485
|
+
axes[1].set_ylabel('Residual (Actual - Predicted)')
|
|
486
|
+
axes[1].legend(frameon=True, loc='best')
|
|
487
|
+
|
|
488
|
+
plt.tight_layout()
|
|
489
|
+
plt.show()
|
|
490
|
+
|
|
491
|
+
# ==========================================================
|
|
492
|
+
# Identify tree ID based on dden
|
|
493
|
+
# ==========================================================
|
|
494
|
+
orig_den_cols = [col for col in night.columns if re.match(r'^dden\d+$', col)]
|
|
495
|
+
sample_ids = [re.search(r'\d+', col).group() for col in orig_den_cols]
|
|
496
|
+
|
|
497
|
+
# save results
|
|
498
|
+
rf_results = []
|
|
499
|
+
all_plot_T = []
|
|
500
|
+
all_plot_R = []
|
|
501
|
+
|
|
502
|
+
print("\n[Step 4/6] Training Machine Learning Models for Non-linear Nights...")
|
|
503
|
+
|
|
504
|
+
for sid in sample_ids:
|
|
505
|
+
T_col = f"T{sid}"
|
|
506
|
+
R_col = f"R{sid}"
|
|
507
|
+
dden_col = f"dden{sid}"
|
|
508
|
+
sfd_col = f"SFD{sid}"
|
|
509
|
+
|
|
510
|
+
# ==========================================================
|
|
511
|
+
# If the tree fails completely during the OLS phase, the ML step is silently skipped.
|
|
512
|
+
# ==========================================================
|
|
513
|
+
if T_col not in night.columns or R_col not in night.columns:
|
|
514
|
+
print(f" [WARNING] Tree {sid:<4} : Skipping ML because it was dropped in OLS step due to poor quality.")
|
|
515
|
+
continue
|
|
516
|
+
|
|
517
|
+
exclude_keywords = [f'dden{i}' for i in sample_ids if i != sid] + \
|
|
518
|
+
[f'dT{i}' for i in sample_ids if i != sid] + \
|
|
519
|
+
[f'dTmax{i}' for i in sample_ids if i != sid] + \
|
|
520
|
+
[f'SFD{i}' for i in sample_ids if i != sid] + \
|
|
521
|
+
[f'T{i}' for i in sample_ids if i != sid] + \
|
|
522
|
+
[f'R{i}' for i in sample_ids if i != sid]
|
|
523
|
+
|
|
524
|
+
df_train = night.dropna(subset=[T_col, R_col]).copy()
|
|
525
|
+
df_predict = night[~(night[T_col].notna() & night[R_col].notna())].copy()
|
|
526
|
+
|
|
527
|
+
df_train = df_train[[col for col in df_train.columns if not any(kw in col for kw in exclude_keywords)]]
|
|
528
|
+
df_predict = df_predict[[col for col in df_predict.columns if not any(kw in col for kw in exclude_keywords)]]
|
|
529
|
+
|
|
530
|
+
y_train_T = df_train[T_col]
|
|
531
|
+
y_train_R = df_train[R_col]
|
|
532
|
+
|
|
533
|
+
groups = df_train['NOY']
|
|
534
|
+
X_train_raw = df_train.drop(columns=[T_col, R_col, 'NOY'])
|
|
535
|
+
feature_cols = X_train_raw.columns
|
|
536
|
+
|
|
537
|
+
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
|
|
538
|
+
train_idx, val_idx = next(gss.split(X_train_raw, y_train_T, groups=groups))
|
|
539
|
+
|
|
540
|
+
X_train_split_raw = X_train_raw.iloc[train_idx]
|
|
541
|
+
X_val_split_raw = X_train_raw.iloc[val_idx]
|
|
542
|
+
|
|
543
|
+
y_train_T_split = y_train_T.iloc[train_idx]
|
|
544
|
+
y_val_T_split = y_train_T.iloc[val_idx]
|
|
545
|
+
|
|
546
|
+
y_train_R_split = y_train_R.iloc[train_idx]
|
|
547
|
+
y_val_R_split = y_train_R.iloc[val_idx]
|
|
548
|
+
|
|
549
|
+
scaler = StandardScaler()
|
|
550
|
+
imputer = IterativeImputer(max_iter=10, random_state=42)
|
|
551
|
+
|
|
552
|
+
# Apply pd.DataFrame to prevent sklearn feature name warnings
|
|
553
|
+
X_train_scaled = pd.DataFrame(scaler.fit_transform(X_train_split_raw), columns=feature_cols)
|
|
554
|
+
X_train_imputed_scaled = pd.DataFrame(imputer.fit_transform(X_train_scaled), columns=feature_cols)
|
|
555
|
+
X_train_split = pd.DataFrame(scaler.inverse_transform(X_train_imputed_scaled), columns=feature_cols)
|
|
556
|
+
|
|
557
|
+
X_val_scaled = pd.DataFrame(scaler.transform(X_val_split_raw), columns=feature_cols)
|
|
558
|
+
X_val_imputed_scaled = pd.DataFrame(imputer.transform(X_val_scaled), columns=feature_cols)
|
|
559
|
+
X_val_split = pd.DataFrame(scaler.inverse_transform(X_val_imputed_scaled), columns=feature_cols)
|
|
560
|
+
|
|
561
|
+
df_predict_raw = df_predict[feature_cols].copy()
|
|
562
|
+
|
|
563
|
+
# 10.4 build ML model for T
|
|
564
|
+
model_T = RandomForestRegressor(
|
|
565
|
+
n_estimators=deci_tree_T,
|
|
566
|
+
max_depth=depth_T,
|
|
567
|
+
min_samples_leaf=min_leaf_T,
|
|
568
|
+
max_features=max_fea_T,
|
|
569
|
+
random_state=random_state_T
|
|
570
|
+
)
|
|
571
|
+
model_T.fit(X_train_split, y_train_T_split)
|
|
572
|
+
|
|
573
|
+
importances_T = model_T.feature_importances_
|
|
574
|
+
if diagnostic_plots:
|
|
575
|
+
plt.barh(feature_cols, importances_T)
|
|
576
|
+
plt.xlabel("Feature Importance")
|
|
577
|
+
plt.title(f"Feature Importance for T{sid} (VPD/RH/Tair/SFD should rank high)")
|
|
578
|
+
plt.tight_layout()
|
|
579
|
+
plt.show()
|
|
580
|
+
|
|
581
|
+
y_train_pred_T = model_T.predict(X_train_split)
|
|
582
|
+
y_val_pred_T = model_T.predict(X_val_split)
|
|
583
|
+
r2_val_t = r2_score(y_val_T_split, y_val_pred_T)
|
|
584
|
+
|
|
585
|
+
# === Retrain Model T on 100% of labeled data before final prediction ===
|
|
586
|
+
# 1. Refit scaler and imputer on the FULL valid dataset (100%)
|
|
587
|
+
X_full_scaled = pd.DataFrame(scaler.fit_transform(X_train_raw), columns=feature_cols)
|
|
588
|
+
X_full_imputed_scaled = pd.DataFrame(imputer.fit_transform(X_full_scaled), columns=feature_cols)
|
|
589
|
+
X_full = pd.DataFrame(scaler.inverse_transform(X_full_imputed_scaled), columns=feature_cols)
|
|
590
|
+
|
|
591
|
+
# 2. IMPORTANT: Re-transform the prediction set using the new 100%-fitted preprocessors!
|
|
592
|
+
df_predict_scaled_new = pd.DataFrame(scaler.transform(df_predict_raw), columns=feature_cols)
|
|
593
|
+
df_predict_imputed_scaled_new = pd.DataFrame(imputer.transform(df_predict_scaled_new), columns=feature_cols)
|
|
594
|
+
df_predict_filled = pd.DataFrame(scaler.inverse_transform(df_predict_imputed_scaled_new), columns=feature_cols)
|
|
595
|
+
|
|
596
|
+
# 3. Retrain model on full dataset
|
|
597
|
+
model_T.fit(X_full, y_train_T)
|
|
598
|
+
|
|
599
|
+
# 4. Predict with retrained model and correctly updated prediction features
|
|
600
|
+
df_predict[f'RF_T{sid}'] = model_T.predict(df_predict_filled)
|
|
601
|
+
# =====================================================================
|
|
602
|
+
|
|
603
|
+
df_plot_T_train = pd.DataFrame({'Actual': y_train_T_split, 'Predicted': y_train_pred_T, 'Split': 'Training', 'Tree_ID': sid})
|
|
604
|
+
df_plot_T_val = pd.DataFrame({'Actual': y_val_T_split, 'Predicted': y_val_pred_T, 'Split': 'Validation', 'Tree_ID': sid})
|
|
605
|
+
df_plot_T_current = pd.concat([df_plot_T_train, df_plot_T_val])
|
|
606
|
+
df_plot_T_current['Residual'] = df_plot_T_current['Actual'] - df_plot_T_current['Predicted']
|
|
607
|
+
|
|
608
|
+
plot_academic_performance(df_plot_T_current, f"T{sid}")
|
|
609
|
+
|
|
610
|
+
all_plot_T.append(df_plot_T_current)
|
|
611
|
+
|
|
612
|
+
# 10.5 build ML model for R
|
|
613
|
+
model_R = RandomForestRegressor(
|
|
614
|
+
n_estimators=deci_tree_R,
|
|
615
|
+
max_depth=depth_R,
|
|
616
|
+
min_samples_leaf=min_leaf_R,
|
|
617
|
+
max_features=max_fea_R,
|
|
618
|
+
random_state=random_state_R
|
|
619
|
+
)
|
|
620
|
+
model_R.fit(X_train_split, y_train_R_split)
|
|
621
|
+
|
|
622
|
+
importances_R = model_R.feature_importances_
|
|
623
|
+
if diagnostic_plots:
|
|
624
|
+
plt.barh(feature_cols, importances_R)
|
|
625
|
+
plt.xlabel("Feature Importance")
|
|
626
|
+
plt.title(f"Feature Importance for R{sid} (dden/SFD should rank high)")
|
|
627
|
+
plt.tight_layout()
|
|
628
|
+
plt.show()
|
|
629
|
+
|
|
630
|
+
y_train_pred_R = model_R.predict(X_train_split)
|
|
631
|
+
y_val_pred_R = model_R.predict(X_val_split)
|
|
632
|
+
r2_val_r = r2_score(y_val_R_split, y_val_pred_R)
|
|
633
|
+
|
|
634
|
+
print(f" [-] Tree {sid:<4} : Model T (Val R²: {r2_val_t:.2f}) | Model R (Val R²: {r2_val_r:.2f})")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
if r2_val_t < expected_r2 or r2_val_r < expected_r2:
|
|
638
|
+
print(f" [WARNING] Validation R² is below the expected threshold ({expected_r2}). Please check data quality and treat results for this tree with caution.")
|
|
639
|
+
|
|
640
|
+
# === Retrain Model R on 100% of labeled data before final prediction ===
|
|
641
|
+
# Retrain on full dataset (X_full and df_predict_filled were already updated in the T step)
|
|
642
|
+
model_R.fit(X_full, y_train_R)
|
|
643
|
+
# Predict with retrained model
|
|
644
|
+
df_predict[f'RF_R{sid}'] = model_R.predict(df_predict_filled)
|
|
645
|
+
# =====================================================================
|
|
646
|
+
|
|
647
|
+
df_predict[f'RF_Jn{sid}'] = df_predict[f'RF_T{sid}'] + df_predict[f'RF_R{sid}']
|
|
648
|
+
df_train[f'Jn{sid}'] = df_train[T_col] + df_train[R_col]
|
|
649
|
+
|
|
650
|
+
df_plot_R_train = pd.DataFrame({'Actual': y_train_R_split, 'Predicted': y_train_pred_R, 'Split': 'Training', 'Tree_ID': sid})
|
|
651
|
+
df_plot_R_val = pd.DataFrame({'Actual': y_val_R_split, 'Predicted': y_val_pred_R, 'Split': 'Validation', 'Tree_ID': sid})
|
|
652
|
+
df_plot_R_current = pd.concat([df_plot_R_train, df_plot_R_val])
|
|
653
|
+
df_plot_R_current['Residual'] = df_plot_R_current['Actual'] - df_plot_R_current['Predicted']
|
|
654
|
+
|
|
655
|
+
plot_academic_performance(df_plot_R_current, f"R{sid}")
|
|
656
|
+
|
|
657
|
+
all_plot_R.append(df_plot_R_current)
|
|
658
|
+
|
|
659
|
+
df_combined_sample = pd.concat([df_train, df_predict])
|
|
660
|
+
rf_results.append(df_combined_sample)
|
|
661
|
+
|
|
662
|
+
if not rf_results:
|
|
663
|
+
raise ValueError("All trees failed the OLS quality filter — no data available for machine learning. Check Night Threshold or data quality.")
|
|
664
|
+
|
|
665
|
+
# concat dataframe for output
|
|
666
|
+
df_combined = pd.concat(rf_results, axis=1)
|
|
667
|
+
df_combined = df_combined.loc[:, ~df_combined.columns.duplicated()]
|
|
668
|
+
|
|
669
|
+
# ==========================================
|
|
670
|
+
# 10.6 Aggregate plot data for EXPORT only (No global plotting)
|
|
671
|
+
# ==========================================
|
|
672
|
+
# Combine all individual tree data into master DataFrames
|
|
673
|
+
df_plot_T = pd.concat(all_plot_T, ignore_index=True)
|
|
674
|
+
df_plot_R = pd.concat(all_plot_R, ignore_index=True)
|
|
675
|
+
|
|
676
|
+
# 11. correct sap flow shift
|
|
677
|
+
|
|
678
|
+
# 11.1 combine Jn and RF_Jn to build model between SFD and Jn at one time
|
|
679
|
+
# find tree index,e.g., 1, 2, 3(get from cols name)
|
|
680
|
+
suffixes = sorted(set(col.replace("Jn", "") for col in df_combined.columns if col.startswith("Jn") and not col.startswith("RF")))
|
|
681
|
+
|
|
682
|
+
for suffix in suffixes:
|
|
683
|
+
Jn_col = f"Jn{suffix}"
|
|
684
|
+
RF_Jn_col = f"RF_Jn{suffix}"
|
|
685
|
+
Jn_all_col = f"Jn_all{suffix}"
|
|
686
|
+
|
|
687
|
+
if Jn_col in df_combined.columns and RF_Jn_col in df_combined.columns:
|
|
688
|
+
df_combined[Jn_all_col] = df_combined[Jn_col].combine_first(df_combined[RF_Jn_col])
|
|
689
|
+
|
|
690
|
+
# 11.2 build linear regression model to get intercept
|
|
691
|
+
shift_list = []
|
|
692
|
+
|
|
693
|
+
for suffix in suffixes:
|
|
694
|
+
sfd_col = f"SFD{suffix}"
|
|
695
|
+
jn_all_col = f"Jn_all{suffix}"
|
|
696
|
+
|
|
697
|
+
if sfd_col not in df_combined.columns or jn_all_col not in df_combined.columns:
|
|
698
|
+
continue
|
|
699
|
+
|
|
700
|
+
for noy, group in df_combined.groupby('NOY'):
|
|
701
|
+
sub = group[[sfd_col, jn_all_col]].dropna()
|
|
702
|
+
if len(sub) < 3:
|
|
703
|
+
continue
|
|
704
|
+
|
|
705
|
+
X = sm.add_constant(sub[jn_all_col])
|
|
706
|
+
y = sub[sfd_col]
|
|
707
|
+
|
|
708
|
+
model = sm.OLS(y, X).fit()
|
|
709
|
+
|
|
710
|
+
shift_list.append({
|
|
711
|
+
'x': suffix,
|
|
712
|
+
'NOY': noy,
|
|
713
|
+
'slope': model.params[jn_all_col],
|
|
714
|
+
'int': model.params['const'],
|
|
715
|
+
'p_slope': model.pvalues[jn_all_col],
|
|
716
|
+
'p_int': model.pvalues['const'],
|
|
717
|
+
'R2': model.rsquared
|
|
718
|
+
})
|
|
719
|
+
if not shift_list:
|
|
720
|
+
raise ValueError("No valid nights found for shift correction. Check your data or parameters.")
|
|
721
|
+
|
|
722
|
+
# convert into DataFrame
|
|
723
|
+
shift = pd.DataFrame(shift_list)
|
|
724
|
+
|
|
725
|
+
print("\n[Step 5/6] Deriving True Baseline Anchors...")
|
|
726
|
+
print(" [INFO] Identifying valid nights where baseline anchor points can be estimated.")
|
|
727
|
+
|
|
728
|
+
# 11.3 filter out good night
|
|
729
|
+
shift['flag'] = (
|
|
730
|
+
(shift['p_slope'] < 0.05) &
|
|
731
|
+
(shift['p_int'] < 0.05) &
|
|
732
|
+
(shift['R2'] > 0.5)
|
|
733
|
+
).astype(int)
|
|
734
|
+
|
|
735
|
+
for suffix in suffixes:
|
|
736
|
+
ratio = (shift[shift['x'] == suffix]['flag'] == 1).mean()
|
|
737
|
+
print(f" [-] Tree {suffix:<4} : Baseline anchor points estimated for {ratio:>5.1%} of nights")
|
|
738
|
+
|
|
739
|
+
shift_good = shift[shift['flag'] == 1]
|
|
740
|
+
|
|
741
|
+
# 11.4 mapping int from shift_good into df
|
|
742
|
+
for suffix in suffixes:
|
|
743
|
+
shift_sub = shift_good[shift_good['x'] == suffix][['NOY', 'int']].dropna()
|
|
744
|
+
|
|
745
|
+
if shift_sub.empty:
|
|
746
|
+
continue
|
|
747
|
+
|
|
748
|
+
shift_dict = dict(zip(shift_sub['NOY'], shift_sub['int']))
|
|
749
|
+
df[f'int{suffix}'] = df['NOY'].map(shift_dict)
|
|
750
|
+
|
|
751
|
+
# 11.5 calculate new baseline at dTmax (when preliminary SFD == 0)
|
|
752
|
+
for suffix in suffixes:
|
|
753
|
+
int_col = f'int{suffix}'
|
|
754
|
+
dTmax_col = f'dTmax{suffix}'
|
|
755
|
+
sfd_col = f'SFD{suffix}'
|
|
756
|
+
baseline_col = f'Baseline{suffix}'
|
|
757
|
+
|
|
758
|
+
if all(col in df.columns for col in [int_col, dTmax_col, sfd_col]):
|
|
759
|
+
with np.errstate(invalid='ignore', divide='ignore'):
|
|
760
|
+
mask = df[sfd_col] == 0
|
|
761
|
+
baseline_val = ((-df[int_col] / alpha) ** (1 / beta) + 1) * df[dTmax_col]
|
|
762
|
+
df[baseline_col] = np.where(mask, baseline_val, np.nan)
|
|
763
|
+
|
|
764
|
+
# 11.6 interpolate new baseline
|
|
765
|
+
# Calculate the maximum allowed number of interpolation points
|
|
766
|
+
max_interp_gap = int((7 * 24 * 60) / time_interval) # 7 days in data points
|
|
767
|
+
|
|
768
|
+
for suffix in suffixes:
|
|
769
|
+
baseline_col = f'Baseline{suffix}'
|
|
770
|
+
if baseline_col not in df.columns:
|
|
771
|
+
continue
|
|
772
|
+
|
|
773
|
+
df[baseline_col] = df[baseline_col].interpolate(
|
|
774
|
+
method='linear',
|
|
775
|
+
limit=max_interp_gap,
|
|
776
|
+
limit_direction='both'
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
# visualize baseline to evaluate
|
|
780
|
+
if diagnostic_plots:
|
|
781
|
+
for suffix in suffixes:
|
|
782
|
+
baseline_col = f'Baseline{suffix}'
|
|
783
|
+
if baseline_col not in df.columns:
|
|
784
|
+
continue
|
|
785
|
+
|
|
786
|
+
plt.figure(figsize=(10, 4))
|
|
787
|
+
plt.scatter(df.index, df[baseline_col], s=10, alpha=0.7, color='teal')
|
|
788
|
+
plt.title(f'Interpolated {baseline_col}')
|
|
789
|
+
plt.ylabel('Baseline')
|
|
790
|
+
plt.xlabel('Time')
|
|
791
|
+
plt.tight_layout()
|
|
792
|
+
plt.show()
|
|
793
|
+
|
|
794
|
+
print("\n[Step 6/6] Reconstructing Continuous Baseline & Finalizing Sap Flux...")
|
|
795
|
+
# 11.7 calculate shift-corrected SFD (J)
|
|
796
|
+
for suffix in suffixes:
|
|
797
|
+
baseline_col = f'Baseline{suffix}'
|
|
798
|
+
dT_col = f'dT{suffix}'
|
|
799
|
+
J_col = f'J{suffix}'
|
|
800
|
+
K_col = f'K{suffix}'
|
|
801
|
+
|
|
802
|
+
if baseline_col in df.columns and dT_col in df.columns:
|
|
803
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
804
|
+
ratio = (df[baseline_col] - df[dT_col]) / df[dT_col]
|
|
805
|
+
df[K_col] = ratio.replace([np.inf, -np.inf], np.nan) # K
|
|
806
|
+
df[J_col] = alpha * (ratio ** beta) # sap flux density
|
|
807
|
+
df[J_col] = df[J_col].replace([np.inf, -np.inf], np.nan)
|
|
808
|
+
|
|
809
|
+
print("\n" + "="*50)
|
|
810
|
+
print("[SUCCESS] Processing Complete! Data is ready for export.")
|
|
811
|
+
print("="*50 + "\n")
|
|
812
|
+
return df
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: denflux
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A process-based approach to correct TDP baseline shifts.
|
|
5
|
+
Author-email: Mianzhi Wang <mianzhi.wang@helsinki.fi>
|
|
6
|
+
Project-URL: Homepage, https://github.com/Mianzhi-Wang/DenFlux
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/Mianzhi-Wang/DenFlux/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: pandas>=1.3.0
|
|
17
|
+
Requires-Dist: numpy>=1.20.0
|
|
18
|
+
Requires-Dist: scikit-learn>=1.0.0
|
|
19
|
+
Requires-Dist: statsmodels>=0.13.0
|
|
20
|
+
Requires-Dist: matplotlib>=3.4.0
|
|
21
|
+
Requires-Dist: seaborn>=0.11.0
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# DenFlux: A Process-Based Computational Framework for TDP Baseline Correction
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/denflux/)
|
|
27
|
+
[](https://www.python.org/downloads/)
|
|
28
|
+
[](https://opensource.org/licenses/MIT)
|
|
29
|
+
|
|
30
|
+
**DenFlux** is an open-source Python framework designed to dynamically reconstruct the true zero-flow baseline for Granier thermal dissipation probes (TDP).
|
|
31
|
+
|
|
32
|
+
Conventional zero-flow assumption methods often do not account for nocturnal stem rehydration, leading to systematic baseline shifts and the underestimation of sap flux density. By integrating ecophysiological theory with Random Forest (RF) machine learning, DenFlux corrects these shifts to ensure higher accuracy sap flow estimations.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 1. Installation
|
|
37
|
+
|
|
38
|
+
DenFlux is hosted on the official Python Package Index (PyPI). You can install it instantly via `pip`. This will also automatically install all required dependencies (e.g., `pandas`, `scikit-learn`).
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install denflux
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
*Tip for upgrading:* To ensure you have the latest analytical algorithms and bug fixes, run `pip install denflux --upgrade`.
|
|
45
|
+
|
|
46
|
+
## 2. Quick Start (Python API)
|
|
47
|
+
|
|
48
|
+
DenFlux is built as a transparent, research-grade engine. It hides the complexity of machine learning hyperparameter tuning while keeping the ecological parameters fully accessible.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import pandas as pd
|
|
52
|
+
from denflux.core import process
|
|
53
|
+
|
|
54
|
+
# 1. Load your raw dataset
|
|
55
|
+
df_raw = pd.read_csv('data/example_data.csv')
|
|
56
|
+
|
|
57
|
+
# 2. Run the core processing engine
|
|
58
|
+
df_processed = process(
|
|
59
|
+
df=df_raw,
|
|
60
|
+
time_col='Time', # Specify your timestamp column
|
|
61
|
+
dayfirst=True, # Set to True if dates are DD/MM/YYYY
|
|
62
|
+
Radiation='PAR', # Solar radiation column for day/night split
|
|
63
|
+
growstart=5, # Start month of growing season
|
|
64
|
+
growend=9, # End month of growing season
|
|
65
|
+
night_threshold=10, # Radiation < 10 is considered night
|
|
66
|
+
diagnostic_plots=False # Set to True for troubleshooting & ML evaluation only
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# 3. Export the shift-corrected sap flow data
|
|
70
|
+
df_processed.to_csv('data/corrected_sapflow.csv')
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Tip:** To see the full list of all 20+ configurable parameters (including Granier's calibration coefficients $\alpha/\beta$ and advanced RF hyperparameters), run `help(process)` in your Python console.
|
|
74
|
+
|
|
75
|
+
## 3. Required Data Format
|
|
76
|
+
|
|
77
|
+
To ensure the physical formulas and machine learning models correctly pair the sap flow sensors with their corresponding dendrometers, `denflux` expects specific column naming conventions:
|
|
78
|
+
|
|
79
|
+
| Variable category | Expected column name | Example unit | Description |
|
|
80
|
+
| :--- | :--- | :--- | :--- |
|
|
81
|
+
| **Timestamp** | `Time` | `YYYY/MM/DD hh:mm:ss` | The column containing timestamps. *Date format strings can be auto-detected or explicitly configured in the engine*. |
|
|
82
|
+
| **TDP Sensor Data** | `dT1`, `dT2`, `dT3`... | °C | The temperature ($\Delta T$) or voltage ($\Delta V$) difference from TDP sensors. **A numerical suffix is strictly required** to pair it with the corresponding dendrometer. *Specific units are flexible because they cancel out mathematically.* |
|
|
83
|
+
| **Dendrometer Data** | `den1`, `den2`, `den3`... | μm | Cleaned stem radial displacement readings. **The numerical suffix must strictly match** its corresponding TDP sensor (e.g., `den1` pairs with `dT1`). *Specific units are flexible because they cancel out mathematically.*|
|
|
84
|
+
| **VPD** | `VPD` | kPa | Vapor pressure deficit. If not provided, the engine will automatically calculate it using `Tair` and `RH`. |
|
|
85
|
+
| **Air Temperature** | `Tair` | °C | Mandatory *only if* VPD is not provided. |
|
|
86
|
+
| **Relative Humidity** | `RH` | % or decimal (0-1) | Mandatory *only if* VPD is not provided. |
|
|
87
|
+
| **Radiation** | `PAR` | μmol m⁻² s⁻¹ | Solar radiation used to dynamically determine day/night periods based on a user-defined threshold. *Column name can be configured in the engine*. |
|
|
88
|
+
|
|
89
|
+
*Note: You do not need to alter your raw data files. You can effortlessly rename your columns using `pandas.DataFrame.rename()` immediately after loading your dataset in Python.*
|
|
90
|
+
|
|
91
|
+
## 4. Interactive Tutorial
|
|
92
|
+
|
|
93
|
+
For a deep dive into the methodology, including how to visualize the reconstructed baseline, evaluate model performance, and extract specific output variables (such as J, K, and the corrected Baseline), please see our highly detailed interactive tutorial on GitHub:
|
|
94
|
+
|
|
95
|
+
**[Open the DenFlux Tutorial (Jupyter Notebook)](https://github.com/Mianzhi-Wang/DenFlux/blob/main/DenFlux_Tutorial.ipynb)**
|
|
96
|
+
|
|
97
|
+
## Bonus: Standalone Desktop App (GUI)
|
|
98
|
+
|
|
99
|
+
Not a programmer? No problem.
|
|
100
|
+
We provide a user-friendly, standalone desktop executable (.exe) for Windows users. It encapsulates the identical rigorous Python engine behind a modern graphical interface—complete with progress tracking, diagnostic warnings, and one-click CSV exporting.
|
|
101
|
+
|
|
102
|
+
* **Download:** Go to the [GitHub Releases](https://github.com/Mianzhi-Wang/DenFlux/releases) tab to download the latest `DenFlux_GUI.zip`.
|
|
103
|
+
* **Usage:** Simply unzip and run the executable. No Python installation or coding required.
|
|
104
|
+
|
|
105
|
+
## 5. The Science: Core Analytical Framework
|
|
106
|
+
|
|
107
|
+
The DenFlux pipeline processes data through three key phases to eliminate subjective baseline assumptions:
|
|
108
|
+
|
|
109
|
+
1. **Component Partitioning (OLS Regression):**
|
|
110
|
+
Initial nocturnal sap flux (J_n0) is partitioned into transpiration (E, driven by VPD) and stem refilling (R, indicated by stem diameter changes), plus a constant intercept (c) representing the potential systematic sap flow shift. This multiple linear regression is applied night-by-night to extract valid physiological parameters.
|
|
111
|
+
|
|
112
|
+
2. **Machine Learning Imputation:**
|
|
113
|
+
For statistically complex nights where linear assumptions are unmet, DenFlux deploys a **Random Forest Regressor** to estimate E and R based on learned environmental features.
|
|
114
|
+
|
|
115
|
+
3. **True Baseline Reconstruction:**
|
|
116
|
+
By evaluating the night-specific sap flow difference (c'), DenFlux analytically derives the true unknown baseline mathematically linked to Granier's empirical coefficients ($\alpha$ and $\beta$). These calculated anchor points are then interpolated to generate a continuous baseline time series for the final sap flux (J) computation.
|
|
117
|
+
|
|
118
|
+
## 6. Citation
|
|
119
|
+
|
|
120
|
+
If you use DenFlux in your research, please cite our manuscript. A permanent DOI will be updated upon publication.
|
|
121
|
+
|
|
122
|
+
> **[Wang M], et al. (In Review).** *[Paper Title]*. Submitted to [Journal Name].
|
|
123
|
+
|
|
124
|
+
```bibtex
|
|
125
|
+
@article{denflux_under_review,
|
|
126
|
+
title={[Paper Title]},
|
|
127
|
+
author={[Author 1 and Author 2 and Author 3]},
|
|
128
|
+
journal={Submitted to [Journal Name]},
|
|
129
|
+
year={2026},
|
|
130
|
+
note={Under Review}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
denflux/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
denflux/core.py,sha256=dM3s6rSIkNhNkGAW8LzmAMbkRTEXnwbM20BNc17VKzw,34248
|
|
3
|
+
denflux-0.1.0.dist-info/licenses/LICENSE,sha256=7ecumowriwWLps20WmGDa4f7A3dfQrog2rb9a1n4g1Y,1077
|
|
4
|
+
denflux-0.1.0.dist-info/METADATA,sha256=tqfepZhBnmgwSVVSPcDfZtq1reS2toyC1HIk7mm7HOc,7885
|
|
5
|
+
denflux-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
denflux-0.1.0.dist-info/top_level.txt,sha256=1nQ3h1dIQby9-OsJhzS7fj3YCG_KpuJeowmmvApAYAo,8
|
|
7
|
+
denflux-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mianzhi Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
7
|
+
|
|
8
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
denflux
|