denflux 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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.
denflux-0.1.0/PKG-INFO ADDED
@@ -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
+ [![PyPI Version](https://img.shields.io/pypi/v/denflux.svg)](https://pypi.org/project/denflux/)
27
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,113 @@
1
+ # DenFlux: A Process-Based Computational Framework for TDP Baseline Correction
2
+
3
+ [![PyPI Version](https://img.shields.io/pypi/v/denflux.svg)](https://pypi.org/project/denflux/)
4
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ **DenFlux** is an open-source Python framework designed to dynamically reconstruct the true zero-flow baseline for Granier thermal dissipation probes (TDP).
8
+
9
+ 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.
10
+
11
+ ---
12
+
13
+ ## 1. Installation
14
+
15
+ 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`).
16
+
17
+ ```bash
18
+ pip install denflux
19
+ ```
20
+
21
+ *Tip for upgrading:* To ensure you have the latest analytical algorithms and bug fixes, run `pip install denflux --upgrade`.
22
+
23
+ ## 2. Quick Start (Python API)
24
+
25
+ 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.
26
+
27
+ ```python
28
+ import pandas as pd
29
+ from denflux.core import process
30
+
31
+ # 1. Load your raw dataset
32
+ df_raw = pd.read_csv('data/example_data.csv')
33
+
34
+ # 2. Run the core processing engine
35
+ df_processed = process(
36
+ df=df_raw,
37
+ time_col='Time', # Specify your timestamp column
38
+ dayfirst=True, # Set to True if dates are DD/MM/YYYY
39
+ Radiation='PAR', # Solar radiation column for day/night split
40
+ growstart=5, # Start month of growing season
41
+ growend=9, # End month of growing season
42
+ night_threshold=10, # Radiation < 10 is considered night
43
+ diagnostic_plots=False # Set to True for troubleshooting & ML evaluation only
44
+ )
45
+
46
+ # 3. Export the shift-corrected sap flow data
47
+ df_processed.to_csv('data/corrected_sapflow.csv')
48
+ ```
49
+
50
+ **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.
51
+
52
+ ## 3. Required Data Format
53
+
54
+ 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:
55
+
56
+ | Variable category | Expected column name | Example unit | Description |
57
+ | :--- | :--- | :--- | :--- |
58
+ | **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*. |
59
+ | **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.* |
60
+ | **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.*|
61
+ | **VPD** | `VPD` | kPa | Vapor pressure deficit. If not provided, the engine will automatically calculate it using `Tair` and `RH`. |
62
+ | **Air Temperature** | `Tair` | °C | Mandatory *only if* VPD is not provided. |
63
+ | **Relative Humidity** | `RH` | % or decimal (0-1) | Mandatory *only if* VPD is not provided. |
64
+ | **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*. |
65
+
66
+ *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.*
67
+
68
+ ## 4. Interactive Tutorial
69
+
70
+ 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:
71
+
72
+ **[Open the DenFlux Tutorial (Jupyter Notebook)](https://github.com/Mianzhi-Wang/DenFlux/blob/main/DenFlux_Tutorial.ipynb)**
73
+
74
+ ## Bonus: Standalone Desktop App (GUI)
75
+
76
+ Not a programmer? No problem.
77
+ 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.
78
+
79
+ * **Download:** Go to the [GitHub Releases](https://github.com/Mianzhi-Wang/DenFlux/releases) tab to download the latest `DenFlux_GUI.zip`.
80
+ * **Usage:** Simply unzip and run the executable. No Python installation or coding required.
81
+
82
+ ## 5. The Science: Core Analytical Framework
83
+
84
+ The DenFlux pipeline processes data through three key phases to eliminate subjective baseline assumptions:
85
+
86
+ 1. **Component Partitioning (OLS Regression):**
87
+ 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.
88
+
89
+ 2. **Machine Learning Imputation:**
90
+ 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.
91
+
92
+ 3. **True Baseline Reconstruction:**
93
+ 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.
94
+
95
+ ## 6. Citation
96
+
97
+ If you use DenFlux in your research, please cite our manuscript. A permanent DOI will be updated upon publication.
98
+
99
+ > **[Wang M], et al. (In Review).** *[Paper Title]*. Submitted to [Journal Name].
100
+
101
+ ```bibtex
102
+ @article{denflux_under_review,
103
+ title={[Paper Title]},
104
+ author={[Author 1 and Author 2 and Author 3]},
105
+ journal={Submitted to [Journal Name]},
106
+ year={2026},
107
+ note={Under Review}
108
+ }
109
+ ```
110
+
111
+ ## License
112
+
113
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
File without changes
@@ -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
+ [![PyPI Version](https://img.shields.io/pypi/v/denflux.svg)](https://pypi.org/project/denflux/)
27
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ denflux/__init__.py
5
+ denflux/core.py
6
+ denflux.egg-info/PKG-INFO
7
+ denflux.egg-info/SOURCES.txt
8
+ denflux.egg-info/dependency_links.txt
9
+ denflux.egg-info/requires.txt
10
+ denflux.egg-info/top_level.txt
@@ -0,0 +1,6 @@
1
+ pandas>=1.3.0
2
+ numpy>=1.20.0
3
+ scikit-learn>=1.0.0
4
+ statsmodels>=0.13.0
5
+ matplotlib>=3.4.0
6
+ seaborn>=0.11.0
@@ -0,0 +1 @@
1
+ denflux
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "denflux"
7
+ version = "0.1.0"
8
+ authors = [
9
+ { name="Mianzhi Wang", email="mianzhi.wang@helsinki.fi" },
10
+ ]
11
+ description = "A process-based approach to correct TDP baseline shifts."
12
+ readme = "README.md"
13
+ requires-python = ">=3.8"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Intended Audience :: Science/Research",
19
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
20
+ ]
21
+ dependencies = [
22
+ "pandas>=1.3.0",
23
+ "numpy>=1.20.0",
24
+ "scikit-learn>=1.0.0",
25
+ "statsmodels>=0.13.0",
26
+ "matplotlib>=3.4.0",
27
+ "seaborn>=0.11.0"
28
+ ]
29
+
30
+ [project.urls]
31
+ "Homepage" = "https://github.com/Mianzhi-Wang/DenFlux"
32
+ "Bug Tracker" = "https://github.com/Mianzhi-Wang/DenFlux/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+