├── aci_py ├── tests │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-313.pyc │ │ ├── test_batch.cpython-313-pytest-8.3.5.pyc │ │ ├── test_export.cpython-313-pytest-8.3.5.pyc │ │ ├── test_models.cpython-313-pytest-8.3.5.pyc │ │ ├── test_c3_fitting.cpython-313-pytest-8.3.5.pyc │ │ ├── test_c4_fitting.cpython-313-pytest-8.3.5.pyc │ │ ├── test_preprocessing.cpython-313-pytest-8.3.5.pyc │ │ ├── test_temperature.cpython-313-pytest-8.3.5.pyc │ │ ├── test_c3_calculations.cpython-313-pytest-8.3.5.pyc │ │ ├── test_c4_calculations.cpython-313-pytest-8.3.5.pyc │ │ ├── test_light_response.cpython-313-pytest-8.3.5.pyc │ │ ├── test_confidence_intervals.cpython-313-pytest-8.3.5.pyc │ │ └── test_temperature_response.cpython-313-pytest-8.3.5.pyc │ ├── test_confidence_intervals.py │ ├── test_c4_calculations.py │ ├── test_preprocessing.py │ ├── test_c4_fitting.py │ ├── test_light_response.py │ ├── test_temperature.py │ └── test_c3_calculations.py ├── .DS_Store ├── analysis │ ├── .DS_Store │ ├── __pycache__ │ │ ├── batch.cpython-313.pyc │ │ ├── __init__.cpython-313.pyc │ │ ├── plotting.cpython-313.pyc │ │ ├── c3_fitting.cpython-313.pyc │ │ ├── c4_fitting.cpython-313.pyc │ │ ├── initial_guess.cpython-313.pyc │ │ ├── optimization.cpython-313.pyc │ │ ├── light_response.cpython-313.pyc │ │ └── temperature_response.cpython-313.pyc │ ├── __init__.py │ ├── initial_guess.py │ ├── batch.py │ └── plotting.py ├── __pycache__ │ └── __init__.cpython-313.pyc ├── io │ ├── __pycache__ │ │ ├── export.cpython-313.pyc │ │ ├── licor.cpython-313.pyc │ │ ├── __init__.cpython-313.pyc │ │ └── licor_calculations.cpython-313.pyc │ └── __init__.py ├── core │ ├── __pycache__ │ │ ├── models.cpython-313.pyc │ │ ├── __init__.cpython-313.pyc │ │ ├── temperature.cpython-313.pyc │ │ ├── preprocessing.cpython-313.pyc │ │ ├── c3_calculations.cpython-313.pyc │ │ ├── c4_calculations.cpython-313.pyc │ │ └── data_structures.cpython-313.pyc │ ├── __init__.py │ ├── data_structures.py │ ├── c3_calculations.py │ ├── temperature.py │ └── models.py ├── gui │ ├── __pycache__ │ │ ├── __init__.cpython-313.pyc │ │ ├── smart_quality.cpython-313.pyc │ │ └── visualization_utils.cpython-313.pyc │ └── __init__.py └── __init__.py ├── .gitattributes ├── .DS_Store ├── requirements.txt ├── streamlit_app.py ├── .devcontainer └── devcontainer.json ├── README.md └── LICENSE /aci_py/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/.DS_Store -------------------------------------------------------------------------------- /aci_py/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/.DS_Store -------------------------------------------------------------------------------- /aci_py/analysis/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/.DS_Store -------------------------------------------------------------------------------- /aci_py/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/io/__pycache__/export.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/io/__pycache__/export.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/io/__pycache__/licor.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/io/__pycache__/licor.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/core/__pycache__/models.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/core/__pycache__/models.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/gui/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/gui/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/io/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/io/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/analysis/__pycache__/batch.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/__pycache__/batch.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/core/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/core/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/analysis/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/analysis/__pycache__/plotting.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/__pycache__/plotting.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/core/__pycache__/temperature.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/core/__pycache__/temperature.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/gui/__pycache__/smart_quality.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/gui/__pycache__/smart_quality.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/analysis/__pycache__/c3_fitting.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/__pycache__/c3_fitting.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/analysis/__pycache__/c4_fitting.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/__pycache__/c4_fitting.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/core/__pycache__/preprocessing.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/core/__pycache__/preprocessing.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/analysis/__pycache__/initial_guess.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/__pycache__/initial_guess.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/analysis/__pycache__/optimization.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/__pycache__/optimization.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/core/__pycache__/c3_calculations.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/core/__pycache__/c3_calculations.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/core/__pycache__/c4_calculations.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/core/__pycache__/c4_calculations.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/core/__pycache__/data_structures.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/core/__pycache__/data_structures.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/io/__pycache__/licor_calculations.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/io/__pycache__/licor_calculations.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/analysis/__pycache__/light_response.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/__pycache__/light_response.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/gui/__pycache__/visualization_utils.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/gui/__pycache__/visualization_utils.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/analysis/__pycache__/temperature_response.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/analysis/__pycache__/temperature_response.cpython-313.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_batch.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_batch.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_export.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_export.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_models.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_models.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_c3_fitting.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_c3_fitting.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_c4_fitting.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_c4_fitting.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_preprocessing.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_preprocessing.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_temperature.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_temperature.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_c3_calculations.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_c3_calculations.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_c4_calculations.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_c4_calculations.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_light_response.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_light_response.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_confidence_intervals.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_confidence_intervals.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /aci_py/tests/__pycache__/test_temperature_response.cpython-313-pytest-8.3.5.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zhujiedong/CO2-Response-Curve-Fitting-Tool-Ultra/main/aci_py/tests/__pycache__/test_temperature_response.cpython-313-pytest-8.3.5.pyc -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Core dependencies 2 | numpy>=1.20.0 3 | pandas>=1.3.0 4 | scipy>=1.7.0 5 | matplotlib>=3.4.0 6 | seaborn>=0.11.0 7 | lmfit>=1.0.0 8 | openpyxl>=3.0.0 9 | tqdm>=4.60.0 10 | 11 | # Interactive notebooks 12 | jupyter>=1.0.0 13 | ipywidgets>=8.0.0 14 | 15 | # GUI dependencies 16 | streamlit>=1.28.0 17 | plotly>=5.17.0 18 | 19 | # Development dependencies (install with pip install -r requirements-dev.txt) 20 | # pytest>=6.0 21 | # pytest-cov 22 | # black 23 | # flake8 24 | # mypy 25 | # sphinx -------------------------------------------------------------------------------- /streamlit_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | start the Streamlit interface. 4 | """ 5 | 6 | import subprocess 7 | import sys 8 | from pathlib import Path 9 | 10 | def main(): 11 | 12 | app_path = Path(__file__).parent / "aci_py" / "gui" / "app.py" 13 | 14 | if not app_path.exists(): 15 | print("Error: No GUI files found!") 16 | return 1 17 | 18 | try: 19 | # Launch streamlit 20 | subprocess.run([sys.executable, "-m", "streamlit", "run", str(app_path)]) 21 | except KeyboardInterrupt: 22 | print("\n\nGUI closed.") 23 | except Exception as e: 24 | print(f"\nError launching GUI: {e}") 25 | return 1 26 | 27 | return 0 28 | 29 | if __name__ == "__main__": 30 | sys.exit(main()) -------------------------------------------------------------------------------- /aci_py/io/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Input/Output utilities for ACI_py. 3 | 4 | This module provides functions for reading various gas exchange data formats. 5 | """ 6 | 7 | from aci_py.io.licor import ( 8 | read_licor_file, 9 | read_licor_6800_csv, 10 | read_licor_6800_excel, 11 | detect_licor_format, 12 | validate_aci_data, 13 | ) 14 | from aci_py.io.export import ( 15 | export_fitting_result, 16 | export_batch_results, 17 | create_analysis_report, 18 | save_for_photogea_compatibility, 19 | ) 20 | 21 | __all__ = [ 22 | "read_licor_file", 23 | "read_licor_6800_csv", 24 | "read_licor_6800_excel", 25 | "detect_licor_format", 26 | "validate_aci_data", 27 | "export_fitting_result", 28 | "export_batch_results", 29 | "create_analysis_report", 30 | "save_for_photogea_compatibility", 31 | ] -------------------------------------------------------------------------------- /aci_py/gui/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | GUI module for ACI_py - Graphical User Interface components. 3 | 4 | This module provides a web-based interface using Streamlit for 5 | interactive photosynthesis curve analysis. 6 | """ 7 | 8 | # Version info 9 | __version__ = '0.1.0' 10 | 11 | # GUI framework choice 12 | GUI_FRAMEWORK = 'streamlit' 13 | 14 | # Module info 15 | __all__ = ['GUI_FRAMEWORK', '__version__', 'launch_gui'] 16 | 17 | # Usage instructions 18 | def launch_gui(): 19 | """ 20 | Launch the ACI_py GUI application. 21 | 22 | This function provides instructions for running the Streamlit app. 23 | In future versions, it may directly launch the application. 24 | """ 25 | print("\n🌿 ACI_py GUI Launcher") 26 | print("=" * 50) 27 | print("\nTo launch the ACI_py GUI, run:") 28 | print(" streamlit run aci_py/gui/streamlit_app.py") 29 | print("\nFor the demo version:") 30 | print(" streamlit run aci_py/gui/streamlit_demo.py") 31 | print("\nThe app will open in your default web browser.") 32 | print("=" * 50) -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 4 | "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": [ 8 | "README.md", 9 | "streamlit_app.py" 10 | ] 11 | }, 12 | "vscode": { 13 | "settings": {}, 14 | "extensions": [ 15 | "ms-python.python", 16 | "ms-python.vscode-pylance" 17 | ] 18 | } 19 | }, 20 | "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y best_params[0] 143 | 144 | def test_different_confidence_levels(self): 145 | """Test CI calculation with different confidence levels.""" 146 | def error_func(params): 147 | return (params[0] - 3)**2 148 | 149 | best_params = np.array([3.0]) 150 | param_names = ['x'] 151 | bounds = [(0, 6)] 152 | 153 | # Calculate CIs at different levels 154 | ci_68 = calculate_confidence_intervals_profile( 155 | error_func, best_params, param_names, bounds, 156 | confidence_level=0.68, n_points=30 157 | ) 158 | 159 | ci_95 = calculate_confidence_intervals_profile( 160 | error_func, best_params, param_names, bounds, 161 | confidence_level=0.95, n_points=30 162 | ) 163 | 164 | # 95% CI should be wider than 68% CI 165 | width_68 = ci_68['x'][1] - ci_68['x'][0] 166 | width_95 = ci_95['x'][1] - ci_95['x'][0] 167 | assert width_95 > width_68 -------------------------------------------------------------------------------- /aci_py/core/data_structures.py: -------------------------------------------------------------------------------- 1 | """ 2 | Data structures for ACI_py, including the ExtendedDataFrame class. 3 | 4 | This module provides enhanced data structures that track units and metadata 5 | similar to PhotoGEA's exdf objects. 6 | """ 7 | 8 | from typing import Dict, List, Optional, Union, Any 9 | import pandas as pd 10 | import numpy as np 11 | from copy import deepcopy 12 | 13 | 14 | class ExtendedDataFrame: 15 | """ 16 | Enhanced DataFrame with units and metadata tracking. 17 | 18 | Similar to PhotoGEA's exdf structure, this class wraps a pandas DataFrame 19 | with additional metadata about units and data categories/sources. 20 | 21 | Attributes: 22 | data: The main pandas DataFrame containing the data 23 | units: Dictionary mapping column names to their units 24 | categories: Dictionary mapping column names to their categories/sources 25 | """ 26 | 27 | def __init__( 28 | self, 29 | data: Union[pd.DataFrame, Dict, List], 30 | units: Optional[Dict[str, str]] = None, 31 | categories: Optional[Dict[str, str]] = None 32 | ): 33 | """ 34 | Initialize an ExtendedDataFrame. 35 | 36 | Args: 37 | data: Data to store (DataFrame, dict, or list) 38 | units: Dictionary of column names to unit strings 39 | categories: Dictionary of column names to category strings 40 | """ 41 | if isinstance(data, pd.DataFrame): 42 | self.data = data.copy() 43 | else: 44 | self.data = pd.DataFrame(data) 45 | 46 | self.units = units or {} 47 | self.categories = categories or {} 48 | 49 | # Ensure all columns have entries in units and categories 50 | for col in self.data.columns: 51 | if col not in self.units: 52 | self.units[col] = "dimensionless" 53 | if col not in self.categories: 54 | self.categories[col] = "unknown" 55 | 56 | def check_required_variables( 57 | self, 58 | required: List[str], 59 | raise_error: bool = True 60 | ) -> bool: 61 | """ 62 | Check if required columns exist in the data. 63 | 64 | Args: 65 | required: List of required column names 66 | raise_error: If True, raise ValueError if columns are missing 67 | 68 | Returns: 69 | True if all required columns exist, False otherwise 70 | 71 | Raises: 72 | ValueError: If raise_error=True and columns are missing 73 | """ 74 | missing = [col for col in required if col not in self.data.columns] 75 | 76 | if missing: 77 | msg = f"Missing required columns: {', '.join(missing)}" 78 | if raise_error: 79 | raise ValueError(msg) 80 | else: 81 | print(f"Warning: {msg}") 82 | return False 83 | return True 84 | 85 | def get_column_units(self, column: str) -> str: 86 | """Get units for a specific column.""" 87 | return self.units.get(column, "dimensionless") 88 | 89 | def get_column_category(self, column: str) -> str: 90 | """Get category for a specific column.""" 91 | return self.categories.get(column, "unknown") 92 | 93 | def set_variable( 94 | self, 95 | name: str, 96 | values: Union[np.ndarray, pd.Series, List, float], 97 | units: str = "dimensionless", 98 | category: str = "calculated" 99 | ) -> None: 100 | """ 101 | Add or update a variable in the ExtendedDataFrame. 102 | 103 | Args: 104 | name: Column name 105 | values: Values to set 106 | units: Units for the variable 107 | category: Category/source for the variable 108 | """ 109 | self.data[name] = values 110 | self.units[name] = units 111 | self.categories[name] = category 112 | 113 | def calculate_gas_properties( 114 | self, 115 | pressure_col: str = "Pa", 116 | temperature_col: str = "Tleaf" 117 | ) -> None: 118 | """ 119 | Calculate additional gas exchange properties. 120 | 121 | Adds calculated columns for partial pressures and other derived values 122 | commonly needed for photosynthesis calculations. 123 | 124 | Args: 125 | pressure_col: Column name for atmospheric pressure (kPa) 126 | temperature_col: Column name for leaf temperature (°C) 127 | """ 128 | # Check required columns 129 | self.check_required_variables([pressure_col, temperature_col]) 130 | 131 | # Convert temperature to Kelvin 132 | T_K = self.data[temperature_col] + 273.15 133 | self.set_variable("T_leaf_K", T_K, "K", "calculated") 134 | 135 | # Calculate partial pressures if CO2/O2 data exists 136 | if "Ca" in self.data.columns: 137 | # CO2 partial pressure in Pa 138 | PCa = self.data["Ca"] * self.data[pressure_col] * 0.1 # µbar 139 | self.set_variable("PCa", PCa, "µbar", "calculated") 140 | 141 | if "Ci" in self.data.columns: 142 | # Intercellular CO2 partial pressure 143 | PCi = self.data["Ci"] * self.data[pressure_col] * 0.1 # µbar 144 | self.set_variable("PCi", PCi, "µbar", "calculated") 145 | 146 | def copy(self) -> 'ExtendedDataFrame': 147 | """Create a deep copy of the ExtendedDataFrame.""" 148 | return ExtendedDataFrame( 149 | data=self.data.copy(), 150 | units=deepcopy(self.units), 151 | categories=deepcopy(self.categories) 152 | ) 153 | 154 | def subset_rows(self, indices: Union[pd.Index, np.ndarray, List]) -> 'ExtendedDataFrame': 155 | """ 156 | Create a subset of the ExtendedDataFrame with specified rows. 157 | 158 | Args: 159 | indices: Row indices or boolean mask 160 | 161 | Returns: 162 | New ExtendedDataFrame with subset of rows 163 | """ 164 | return ExtendedDataFrame( 165 | data=self.data.loc[indices].copy(), 166 | units=deepcopy(self.units), 167 | categories=deepcopy(self.categories) 168 | ) 169 | 170 | def subset_columns(self, columns: List[str]) -> 'ExtendedDataFrame': 171 | """ 172 | Create a subset of the ExtendedDataFrame with specified columns. 173 | 174 | Args: 175 | columns: List of column names to keep 176 | 177 | Returns: 178 | New ExtendedDataFrame with subset of columns 179 | """ 180 | subset_units = {col: self.units[col] for col in columns if col in self.units} 181 | subset_categories = {col: self.categories[col] for col in columns if col in self.categories} 182 | 183 | return ExtendedDataFrame( 184 | data=self.data[columns].copy(), 185 | units=subset_units, 186 | categories=subset_categories 187 | ) 188 | 189 | def to_dict(self) -> Dict[str, Any]: 190 | """Convert to dictionary format for serialization.""" 191 | return { 192 | "data": self.data.to_dict(), 193 | "units": self.units, 194 | "categories": self.categories 195 | } 196 | 197 | @classmethod 198 | def from_dict(cls, data_dict: Dict[str, Any]) -> 'ExtendedDataFrame': 199 | """Create ExtendedDataFrame from dictionary.""" 200 | return cls( 201 | data=pd.DataFrame(data_dict["data"]), 202 | units=data_dict.get("units", {}), 203 | categories=data_dict.get("categories", {}) 204 | ) 205 | 206 | def __repr__(self) -> str: 207 | """String representation of ExtendedDataFrame.""" 208 | n_rows, n_cols = self.data.shape 209 | cols_with_units = [ 210 | f"{col} [{self.units.get(col, '?')}]" 211 | for col in self.data.columns[:5] 212 | ] 213 | if n_cols > 5: 214 | cols_with_units.append("...") 215 | 216 | return ( 217 | f"ExtendedDataFrame with {n_rows} rows and {n_cols} columns:\n" 218 | f"Columns: {', '.join(cols_with_units)}\n" 219 | f"Categories: {len(set(self.categories.values()))} unique" 220 | ) 221 | 222 | def __len__(self) -> int: 223 | """Return number of rows.""" 224 | return len(self.data) 225 | 226 | def __getitem__(self, key: str) -> pd.Series: 227 | """Allow direct column access like a DataFrame.""" 228 | return self.data[key] 229 | 230 | def __setitem__(self, key: str, value: Any) -> None: 231 | """Allow direct column setting with default units/category.""" 232 | self.set_variable(key, value) 233 | 234 | 235 | def identify_common_columns( 236 | exdf_list: List[ExtendedDataFrame], 237 | require_all: bool = True 238 | ) -> List[str]: 239 | """ 240 | Identify columns that are common across multiple ExtendedDataFrames. 241 | 242 | Args: 243 | exdf_list: List of ExtendedDataFrames to compare 244 | require_all: If True, return only columns present in ALL DataFrames 245 | If False, return columns present in ANY DataFrame 246 | 247 | Returns: 248 | List of common column names 249 | """ 250 | if not exdf_list: 251 | return [] 252 | 253 | if require_all: 254 | # Find intersection of all column sets 255 | common = set(exdf_list[0].data.columns) 256 | for exdf in exdf_list[1:]: 257 | common = common.intersection(set(exdf.data.columns)) 258 | return sorted(list(common)) 259 | else: 260 | # Find union of all column sets 261 | all_cols = set() 262 | for exdf in exdf_list: 263 | all_cols = all_cols.union(set(exdf.data.columns)) 264 | return sorted(list(all_cols)) -------------------------------------------------------------------------------- /aci_py/tests/test_c4_calculations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for C4 photosynthesis calculations. 3 | """ 4 | 5 | import pytest 6 | import numpy as np 7 | import pandas as pd 8 | from ..core.data_structures import ExtendedDataFrame 9 | from ..core.c4_calculations import ( 10 | calculate_c4_assimilation, 11 | identify_c4_limiting_processes, 12 | apply_gm_c4 13 | ) 14 | 15 | 16 | class TestC4Calculations: 17 | """Test C4 photosynthesis calculations.""" 18 | 19 | @pytest.fixture 20 | def sample_c4_data(self): 21 | """Create sample C4 data for testing.""" 22 | n_points = 10 23 | 24 | # Create CO2 gradient 25 | ci_values = np.linspace(50, 400, n_points) 26 | 27 | # Create sample data 28 | data = pd.DataFrame({ 29 | 'Ci': ci_values, 30 | 'PCi': ci_values * 0.04, # Approximate conversion to PCi 31 | 'PCm': ci_values * 0.04 * 0.9, # Slightly lower than PCi 32 | 'A': np.linspace(5, 35, n_points), # Dummy values 33 | 'Tleaf': 25.0, 34 | 'oxygen': 21.0, 35 | 'total_pressure': 1.0, 36 | # Kinetic parameters at 25°C 37 | 'ao': 0.21, 38 | 'gamma_star': 0.000193, # C4 value (much lower than C3) 39 | 'Kc': 650.0, 40 | 'Ko': 450.0, 41 | 'Kp': 80.0, 42 | # Temperature normalized values (all 1.0 at 25°C) 43 | 'Vcmax_norm': 1.0, 44 | 'Vpmax_norm': 1.0, 45 | 'RL_norm': 1.0, 46 | 'J_norm': 1.0, 47 | 'gmc_norm': 1.0 48 | }) 49 | 50 | return ExtendedDataFrame(data) 51 | 52 | def test_calculate_c4_assimilation_basic(self, sample_c4_data): 53 | """Test basic C4 assimilation calculation.""" 54 | result = calculate_c4_assimilation( 55 | sample_c4_data, 56 | Vcmax_at_25=60, 57 | Vpmax_at_25=120, 58 | J_at_25=200, 59 | RL_at_25=1.0, 60 | Vpr=80, 61 | return_extended=True 62 | ) 63 | 64 | # Check output structure 65 | assert isinstance(result, ExtendedDataFrame) 66 | assert 'An' in result.data.columns 67 | assert 'Ac' in result.data.columns 68 | assert 'Aj' in result.data.columns 69 | assert 'Ar' in result.data.columns 70 | assert 'Ap' in result.data.columns 71 | assert 'Apr' in result.data.columns 72 | 73 | # Check that assimilation is positive 74 | assert np.all(result.data['An'] >= 0) 75 | 76 | # Check that An is minimum of Ac and Aj 77 | assert np.allclose( 78 | result.data['An'], 79 | np.minimum(result.data['Ac'], result.data['Aj']), 80 | rtol=1e-10 81 | ) 82 | 83 | def test_c4_enzyme_limitations(self, sample_c4_data): 84 | """Test different enzyme limitations in C4.""" 85 | # Test Rubisco limitation (high Vpmax, low Vcmax) 86 | result = calculate_c4_assimilation( 87 | sample_c4_data, 88 | Vcmax_at_25=20, # Low 89 | Vpmax_at_25=200, # High 90 | J_at_25=400, # High 91 | Vpr=150, # High 92 | return_extended=True 93 | ) 94 | 95 | # At high CO2, should be Rubisco limited 96 | high_co2_mask = sample_c4_data.data['PCm'] > 10 97 | assert np.mean(np.abs(result.data['Ac'][high_co2_mask] - 98 | result.data['Ar'][high_co2_mask])) < 1.0 99 | 100 | def test_c4_pep_carboxylation_limitation(self, sample_c4_data): 101 | """Test PEP carboxylation limitation.""" 102 | # Test PEP carboxylation limitation (low Vpmax) 103 | result = calculate_c4_assimilation( 104 | sample_c4_data, 105 | Vcmax_at_25=100, # High 106 | Vpmax_at_25=30, # Low 107 | J_at_25=400, # High 108 | Vpr=150, # High 109 | return_extended=True 110 | ) 111 | 112 | # Should show PEP carboxylation limitation at low CO2 113 | low_co2_mask = sample_c4_data.data['PCm'] < 5 114 | if np.any(low_co2_mask): 115 | # Ap should be close to Apc (CO2 limited) 116 | assert np.mean(np.abs(result.data['Ap'][low_co2_mask] - 117 | result.data['Apc'][low_co2_mask])) < 1.0 118 | 119 | def test_c4_light_limitation(self, sample_c4_data): 120 | """Test light limitation in C4.""" 121 | # Test light limitation (low J) 122 | result = calculate_c4_assimilation( 123 | sample_c4_data, 124 | Vcmax_at_25=100, 125 | Vpmax_at_25=150, 126 | J_at_25=50, # Low 127 | Vpr=100, 128 | return_extended=True 129 | ) 130 | 131 | # Check that some points are light limited 132 | light_limited = np.abs(result.data['An'] - result.data['Aj']) < 1e-6 133 | assert np.any(light_limited) 134 | 135 | def test_c4_parameter_bounds(self, sample_c4_data): 136 | """Test parameter bound checking.""" 137 | # Test negative Vcmax 138 | with pytest.raises(ValueError, match="Vcmax must be >= 0"): 139 | calculate_c4_assimilation( 140 | sample_c4_data, 141 | Vcmax_at_25=-10, 142 | check_inputs=True 143 | ) 144 | 145 | # Test invalid alpha_psii 146 | with pytest.raises(ValueError, match="alpha_psii must be between 0 and 1"): 147 | calculate_c4_assimilation( 148 | sample_c4_data, 149 | alpha_psii=1.5, 150 | check_inputs=True 151 | ) 152 | 153 | # Test invalid Rm_frac 154 | with pytest.raises(ValueError, match="Rm_frac must be between 0 and 1"): 155 | calculate_c4_assimilation( 156 | sample_c4_data, 157 | Rm_frac=-0.1, 158 | check_inputs=True 159 | ) 160 | 161 | def test_c4_temperature_response(self, sample_c4_data): 162 | """Test temperature response integration.""" 163 | # Modify temperature 164 | sample_c4_data.data['Tleaf'] = 30.0 165 | 166 | # Adjust normalization factors for 30°C (approximate) 167 | sample_c4_data.data['Vcmax_norm'] = 1.5 168 | sample_c4_data.data['Vpmax_norm'] = 1.4 169 | sample_c4_data.data['J_norm'] = 1.3 170 | sample_c4_data.data['RL_norm'] = 1.8 171 | 172 | result = calculate_c4_assimilation( 173 | sample_c4_data, 174 | Vcmax_at_25=60, 175 | return_extended=True 176 | ) 177 | 178 | # Check temperature adjustments were applied 179 | assert np.allclose(result.data['Vcmax_tl'], 60 * 1.5) 180 | assert np.allclose(result.data['Vpmax_tl'], 150 * 1.4) # Using default Vpmax 181 | 182 | def test_identify_c4_limiting_processes(self, sample_c4_data): 183 | """Test identification of limiting processes.""" 184 | # Calculate with specific limitations 185 | result = calculate_c4_assimilation( 186 | sample_c4_data, 187 | Vcmax_at_25=30, # Low - force Rubisco limitation 188 | Vpmax_at_25=150, # High 189 | J_at_25=300, # High 190 | Vpr=100, 191 | return_extended=True 192 | ) 193 | 194 | # Identify limitations 195 | result = identify_c4_limiting_processes(result) 196 | 197 | assert 'limiting_process' in result.data.columns 198 | assert 'enzyme_limited_process' in result.data.columns 199 | 200 | # Check that processes are correctly identified 201 | assert set(result.data['limiting_process']) <= {'enzyme', 'light'} 202 | assert all(p in ['', 'rubisco', 'pep_carboxylation_co2', 'pep_regeneration'] 203 | for p in result.data['enzyme_limited_process']) 204 | 205 | def test_apply_gm_c4(self, sample_c4_data): 206 | """Test mesophyll conductance application for C4.""" 207 | # Remove PCm to test calculation 208 | sample_c4_data.data = sample_c4_data.data.drop('PCm', axis=1) 209 | 210 | # Apply mesophyll conductance 211 | result = apply_gm_c4(sample_c4_data, gmc_at_25=1.0) 212 | 213 | assert 'PCm' in result.data.columns 214 | assert 'gmc' in result.data.columns 215 | assert 'gmc_at_25' in result.data.columns 216 | 217 | # Check that PCm < PCi (due to resistance) 218 | assert np.all(result.data['PCm'] <= result.data['PCi']) 219 | 220 | # Check units 221 | assert result.units['PCm'] == 'microbar' 222 | assert result.units['gmc'] == 'mol m^(-2) s^(-1) bar^(-1)' 223 | 224 | def test_c4_vs_c3_gamma_star(self, sample_c4_data): 225 | """Test that C4 uses much lower gamma_star than C3.""" 226 | result = calculate_c4_assimilation( 227 | sample_c4_data, 228 | return_extended=True 229 | ) 230 | 231 | # C4 gamma_star should be much lower than C3 (around 37 µbar) 232 | gamma_star_value = sample_c4_data.data['gamma_star'].iloc[0] 233 | assert gamma_star_value < 0.001 # Much less than C3 value 234 | 235 | def test_c4_bundle_sheath_parameters(self, sample_c4_data): 236 | """Test bundle sheath specific parameters.""" 237 | # Test with PSII in bundle sheath 238 | result_with_psii = calculate_c4_assimilation( 239 | sample_c4_data, 240 | alpha_psii=0.15, # Some PSII in bundle sheath 241 | return_extended=True 242 | ) 243 | 244 | # Test without PSII in bundle sheath (default) 245 | result_no_psii = calculate_c4_assimilation( 246 | sample_c4_data, 247 | alpha_psii=0.0, 248 | return_extended=True 249 | ) 250 | 251 | # Results should be different 252 | assert not np.allclose(result_with_psii.data['An'], 253 | result_no_psii.data['An']) 254 | 255 | def test_c4_oxygen_sensitivity(self, sample_c4_data): 256 | """Test that C4 is less sensitive to oxygen than C3.""" 257 | # Calculate at normal oxygen 258 | result_normal = calculate_c4_assimilation( 259 | sample_c4_data, 260 | return_extended=True 261 | ) 262 | 263 | # Calculate at high oxygen 264 | sample_c4_data.data['oxygen'] = 30.0 265 | result_high_o2 = calculate_c4_assimilation( 266 | sample_c4_data, 267 | return_extended=True 268 | ) 269 | 270 | # C4 should show minimal oxygen effect 271 | relative_change = np.abs(result_high_o2.data['An'] - result_normal.data['An']) / result_normal.data['An'] 272 | assert np.mean(relative_change) < 0.1 # Less than 10% change on average -------------------------------------------------------------------------------- /aci_py/tests/test_preprocessing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for data preprocessing and quality control. 3 | """ 4 | 5 | import pytest 6 | import numpy as np 7 | import pandas as pd 8 | from ..core.data_structures import ExtendedDataFrame 9 | from ..core.preprocessing import ( 10 | detect_outliers_iqr, 11 | detect_outliers_zscore, 12 | detect_outliers_mad, 13 | check_environmental_stability, 14 | identify_aci_outliers, 15 | remove_outliers, 16 | check_aci_data_quality, 17 | preprocess_aci_data, 18 | flag_points_for_removal 19 | ) 20 | 21 | 22 | class TestOutlierDetection: 23 | """Test outlier detection methods.""" 24 | 25 | def test_detect_outliers_iqr(self): 26 | """Test IQR-based outlier detection.""" 27 | # Create data with clear outliers 28 | normal_data = np.random.normal(10, 2, 100) 29 | outliers = np.array([50, -20]) 30 | data = np.concatenate([normal_data, outliers]) 31 | 32 | # Detect outliers 33 | mask = detect_outliers_iqr(data, factor=1.5) 34 | 35 | # Check that outliers are detected 36 | assert mask[-2] # 50 should be outlier 37 | assert mask[-1] # -20 should be outlier 38 | 39 | # Most normal data should not be outliers 40 | assert np.sum(mask[:-2]) < 10 # Less than 10% false positives 41 | 42 | def test_detect_outliers_zscore(self): 43 | """Test z-score based outlier detection.""" 44 | # Create data with outliers 45 | data = np.array([1, 2, 3, 4, 5, 100, 2, 3, 4, -50]) 46 | 47 | mask = detect_outliers_zscore(data, threshold=2.0) 48 | 49 | assert mask[5] # 100 is outlier 50 | assert mask[9] # -50 is outlier 51 | assert not mask[0] # 1 is not outlier 52 | 53 | def test_detect_outliers_mad(self): 54 | """Test MAD-based outlier detection.""" 55 | # MAD is more robust to outliers than std 56 | data = np.array([1, 2, 3, 4, 5, 100, 2, 3, 4]) 57 | 58 | mask_mad = detect_outliers_mad(data, threshold=2.5) 59 | mask_zscore = detect_outliers_zscore(data, threshold=2.5) 60 | 61 | # MAD should detect the outlier 62 | assert mask_mad[5] 63 | 64 | # MAD should be more conservative than z-score 65 | assert np.sum(mask_mad) <= np.sum(mask_zscore) 66 | 67 | 68 | class TestEnvironmentalChecks: 69 | """Test environmental stability checks.""" 70 | 71 | @pytest.fixture 72 | def stable_data(self): 73 | """Create environmentally stable data.""" 74 | n = 10 75 | data = pd.DataFrame({ 76 | 'Tleaf': np.random.normal(25, 0.1, n), # Stable temperature 77 | 'RHcham': np.random.normal(60, 0.5, n), # Stable RH 78 | 'Qin': np.random.normal(1500, 5, n), # Stable PAR 79 | 'CO2_r': np.array([400]*3 + [600]*3 + [800]*4) # Step changes 80 | }) 81 | return ExtendedDataFrame(data) 82 | 83 | @pytest.fixture 84 | def unstable_data(self): 85 | """Create environmentally unstable data.""" 86 | n = 10 87 | data = pd.DataFrame({ 88 | 'Tleaf': np.linspace(20, 30, n), # Large temperature drift 89 | 'RHcham': np.linspace(40, 70, n), # Large RH drift 90 | 'Qin': np.linspace(1000, 1600, n), # Large PAR drift 91 | 'CO2_r': np.random.normal(400, 20, n) # Noisy CO2 92 | }) 93 | return ExtendedDataFrame(data) 94 | 95 | def test_stable_environment(self, stable_data): 96 | """Test detection of stable conditions.""" 97 | results = check_environmental_stability(stable_data) 98 | 99 | assert results['Tleaf_stable'] 100 | assert results['RH_stable'] 101 | assert results['PAR_stable'] 102 | assert results['Tleaf_range'] < 1.0 103 | 104 | def test_unstable_environment(self, unstable_data): 105 | """Test detection of unstable conditions.""" 106 | results = check_environmental_stability( 107 | unstable_data, 108 | temp_tolerance=2.0, 109 | rh_tolerance=5.0 110 | ) 111 | 112 | assert not results['Tleaf_stable'] 113 | assert not results['RH_stable'] 114 | assert not results['PAR_stable'] 115 | assert results['Tleaf_range'] > 5.0 116 | 117 | 118 | class TestACIOutliers: 119 | """Test ACI-specific outlier detection.""" 120 | 121 | @pytest.fixture 122 | def aci_data(self): 123 | """Create synthetic ACI data with outliers.""" 124 | # Normal ACI curve 125 | ci = np.array([50, 100, 150, 200, 300, 400, 600, 800, 1000]) 126 | a = np.array([5, 10, 15, 18, 22, 25, 28, 30, 31]) 127 | 128 | # Add outliers 129 | ci = np.append(ci, [500, 700]) 130 | a = np.append(a, [-10, 50]) # Negative and too high 131 | 132 | data = pd.DataFrame({'Ci': ci, 'A': a}) 133 | return ExtendedDataFrame(data) 134 | 135 | def test_identify_aci_outliers(self, aci_data): 136 | """Test ACI outlier identification.""" 137 | mask = identify_aci_outliers( 138 | aci_data, 139 | method='combined', 140 | check_negative_a=True 141 | ) 142 | 143 | # Should detect the outliers we added 144 | assert mask[-2] # Negative A 145 | assert mask[-1] # Too high A 146 | 147 | # Normal points should not be outliers 148 | assert np.sum(mask[:-2]) == 0 149 | 150 | def test_extreme_ci_detection(self): 151 | """Test detection of extreme Ci values.""" 152 | data = pd.DataFrame({ 153 | 'Ci': [-50, 10, 100, 500, 1000, 3000], 154 | 'A': [5, 8, 15, 25, 30, 32] 155 | }) 156 | exdf = ExtendedDataFrame(data) 157 | 158 | mask = identify_aci_outliers( 159 | exdf, 160 | check_extreme_ci=True, 161 | ci_min=0, 162 | ci_max=2000 163 | ) 164 | 165 | assert mask[0] # Negative Ci 166 | assert mask[5] # Ci > 2000 167 | assert not mask[2] # Normal Ci 168 | 169 | 170 | class TestDataQuality: 171 | """Test data quality checks.""" 172 | 173 | def test_check_aci_data_quality_good(self): 174 | """Test quality check on good data.""" 175 | data = pd.DataFrame({ 176 | 'Ci': np.linspace(50, 1000, 15), 177 | 'A': np.linspace(5, 35, 15) 178 | }) 179 | exdf = ExtendedDataFrame(data) 180 | 181 | results = check_aci_data_quality(exdf) 182 | 183 | assert results['quality_ok'] 184 | assert results['sufficient_points'] 185 | assert results['sufficient_ci_range'] 186 | assert results['has_low_ci'] 187 | assert results['has_high_ci'] 188 | assert len(results['quality_issues']) == 0 189 | 190 | def test_check_aci_data_quality_bad(self): 191 | """Test quality check on poor data.""" 192 | # Too few points, narrow range 193 | data = pd.DataFrame({ 194 | 'Ci': [400, 450, 500], 195 | 'A': [20, 22, 23] 196 | }) 197 | exdf = ExtendedDataFrame(data) 198 | 199 | results = check_aci_data_quality( 200 | exdf, 201 | min_points=5, 202 | require_low_ci=True, 203 | require_high_ci=True 204 | ) 205 | 206 | assert not results['quality_ok'] 207 | assert not results['sufficient_points'] 208 | assert not results['has_low_ci'] 209 | assert len(results['quality_issues']) > 0 210 | 211 | 212 | class TestPreprocessing: 213 | """Test complete preprocessing pipeline.""" 214 | 215 | @pytest.fixture 216 | def raw_aci_data(self): 217 | """Create raw ACI data with issues.""" 218 | # Base curve 219 | ci_base = np.array([50, 100, 200, 300, 400, 600, 800, 1000]) 220 | a_base = np.array([5, 10, 18, 22, 25, 28, 30, 31]) 221 | 222 | # Add outlier 223 | ci = np.append(ci_base, 500) 224 | a = np.append(a_base, -5) # Negative outlier 225 | 226 | # Add environmental data with drift 227 | n = len(ci) 228 | data = pd.DataFrame({ 229 | 'Ci': ci, 230 | 'A': a, 231 | 'Tleaf': np.linspace(24.5, 25.5, n), # 1°C drift 232 | 'RHcham': np.linspace(58, 62, n), # 4% drift 233 | 'Qin': np.full(n, 1500) # Stable 234 | }) 235 | 236 | return ExtendedDataFrame(data) 237 | 238 | def test_preprocess_aci_data_complete(self, raw_aci_data): 239 | """Test complete preprocessing pipeline.""" 240 | processed, report = preprocess_aci_data( 241 | raw_aci_data, 242 | remove_outliers_flag=True, 243 | check_environment=True, 244 | check_quality=True, 245 | verbose=False 246 | ) 247 | 248 | # Check that outlier was removed 249 | assert report['original_n_points'] == 9 250 | assert report['final_n_points'] == 8 251 | assert report['outliers_removed'] == 1 252 | 253 | # Check environmental stability was assessed 254 | assert 'environmental_stability' in report 255 | assert report['environmental_stability']['Tleaf_stable'] 256 | 257 | # Check quality was assessed 258 | assert 'quality_check' in report 259 | assert report['quality_check']['quality_ok'] 260 | 261 | def test_preprocess_preserve_minimum_points(self): 262 | """Test that preprocessing preserves minimum points.""" 263 | # Create data where most points would be outliers 264 | data = pd.DataFrame({ 265 | 'Ci': [100, 200, 300, 400, 500], 266 | 'A': [-10, 50, -5, 60, -15] # Mostly outliers 267 | }) 268 | exdf = ExtendedDataFrame(data) 269 | 270 | processed, report = preprocess_aci_data( 271 | exdf, 272 | remove_outliers_flag=True, 273 | min_points=4, 274 | verbose=False 275 | ) 276 | 277 | # Should keep at least min_points 278 | assert len(processed.data) >= 4 279 | 280 | 281 | class TestFlagging: 282 | """Test point flagging functionality.""" 283 | 284 | def test_flag_points_for_removal(self): 285 | """Test adding flags to data.""" 286 | data = pd.DataFrame({ 287 | 'Ci': np.arange(10), 288 | 'A': np.arange(10) 289 | }) 290 | exdf = ExtendedDataFrame(data) 291 | 292 | # Create some flags 293 | flags = { 294 | 'outlier': np.array([True] + [False]*8 + [True]), 295 | 'unstable': np.array([False]*5 + [True]*5) 296 | } 297 | 298 | result = flag_points_for_removal(exdf, flags) 299 | 300 | # Check flags were added 301 | assert 'flag_outlier' in result.data.columns 302 | assert 'flag_unstable' in result.data.columns 303 | assert 'flag_any' in result.data.columns 304 | 305 | # Check combined flag 306 | assert result.data['flag_any'].iloc[0] # Has outlier flag 307 | assert result.data['flag_any'].iloc[9] # Has both flags 308 | assert not result.data['flag_any'].iloc[2] # No flags 309 | 310 | # Check that 5 points have unstable flag 311 | assert np.sum(result.data['flag_unstable']) == 5 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /aci_py/analysis/initial_guess.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import numpy as np 4 | import pandas as pd 5 | from typing import Dict, Tuple, Optional, List 6 | from scipy import stats 7 | from ..core.data_structures import ExtendedDataFrame 8 | 9 | 10 | def estimate_c3_initial_parameters( 11 | exdf: ExtendedDataFrame, 12 | a_column: str = 'A', 13 | ci_column: str = 'Ci', 14 | temperature_response_params: Optional[Dict] = None, 15 | average_temperature: Optional[float] = None 16 | ) -> Dict[str, float]: 17 | """ 18 | Estimate initial C3 model parameters from A-Ci curve data. 19 | 20 | Based on PhotoGEA's initial_guess_c3_aci.R implementation. 21 | 22 | Args: 23 | exdf: Extended dataframe with A-Ci curve data 24 | a_column: Column name for net assimilation 25 | ci_column: Column name for intercellular CO2 26 | temperature_response_params: Temperature parameters for adjustments 27 | average_temperature: Average leaf temperature (°C) 28 | 29 | Returns: 30 | Dictionary of initial parameter estimates 31 | """ 32 | # Extract data 33 | A = exdf.data[a_column].values 34 | Ci = exdf.data[ci_column].values 35 | 36 | # Get temperature if available 37 | if average_temperature is None and 'Tleaf' in exdf.data.columns: 38 | average_temperature = exdf.data['Tleaf'].mean() 39 | if average_temperature is None: 40 | average_temperature = 25.0 # Default to 25°C 41 | 42 | # Sort by Ci for analysis 43 | sort_idx = np.argsort(Ci) 44 | Ci_sorted = Ci[sort_idx] 45 | A_sorted = A[sort_idx] 46 | 47 | # 1. Estimate Vcmax from Rubisco-limited region (low Ci) 48 | vcmax_guess = estimate_vcmax_from_initial_slope(Ci_sorted, A_sorted) 49 | 50 | # 2. Estimate J from RuBP-limited region (intermediate Ci) 51 | j_guess = estimate_j_from_plateau(Ci_sorted, A_sorted, vcmax_guess) 52 | 53 | # 3. Estimate Tp from TPU-limited region (high Ci) 54 | tp_guess = estimate_tp_from_high_ci(Ci_sorted, A_sorted) 55 | 56 | # 4. Estimate Rd from low-light intercept or minimum A 57 | rd_guess = estimate_rd(A_sorted) 58 | 59 | # 5. Default gm (mesophyll conductance) - often fixed 60 | gm_guess = 3.0 # mol m⁻² s⁻¹ bar⁻¹ 61 | 62 | # Apply reasonable bounds - use _at_25 suffix for parameters 63 | initial_params = { 64 | 'Vcmax_at_25': max(10.0, min(vcmax_guess, 300.0)), 65 | 'J_at_25': max(20.0, min(j_guess, 500.0)), 66 | 'Tp_at_25': max(5.0, min(tp_guess, 50.0)), 67 | 'RL_at_25': max(0.0, min(rd_guess, 10.0)), # RL instead of Rd 68 | 'gmc': gm_guess # gmc instead of gm 69 | } 70 | 71 | return initial_params 72 | 73 | 74 | def estimate_vcmax_from_initial_slope( 75 | ci: np.ndarray, 76 | a: np.ndarray, 77 | ci_threshold: float = 300.0 78 | ) -> float: 79 | """ 80 | Estimate Vcmax from the initial slope of the A-Ci curve. 81 | 82 | In the Rubisco-limited region (low Ci), the relationship is approximately linear. 83 | 84 | Args: 85 | ci: Sorted intercellular CO2 concentrations 86 | a: Sorted assimilation rates 87 | ci_threshold: Maximum Ci for Rubisco-limited region 88 | 89 | Returns: 90 | Estimated Vcmax value 91 | """ 92 | # Select points in Rubisco-limited region 93 | mask = ci < ci_threshold 94 | if np.sum(mask) < 3: 95 | # Not enough points, use all data 96 | mask = np.ones_like(ci, dtype=bool) 97 | 98 | ci_low = ci[mask] 99 | a_low = a[mask] 100 | 101 | # Fit linear regression to estimate slope 102 | if len(ci_low) >= 2: 103 | slope, intercept, _, _, _ = stats.linregress(ci_low, a_low) 104 | 105 | # Vcmax is approximately related to the slope 106 | # In the Rubisco-limited region: A ≈ Vcmax * (Ci - Γ*) / (Ci + Kc*(1 + O/Ko)) 107 | # At low Ci with typical kinetic constants, slope ≈ Vcmax / (Kc + typical_Ci) 108 | # With Kc ≈ 270 µmol/mol and typical low Ci ≈ 100, scaling factor ≈ 370/slope 109 | # But this is very approximate, so we use a more empirical approach 110 | 111 | # Use the maximum A value as additional information 112 | max_a = np.max(a) 113 | 114 | # Estimate Vcmax from both slope and max A 115 | vcmax_from_slope = slope * 25.0 # Increased empirical scaling 116 | vcmax_from_max = max_a * 4.0 # Vcmax typically 3-5x max A 117 | 118 | # Use weighted average favoring the slope estimate 119 | vcmax_guess = 0.7 * vcmax_from_slope + 0.3 * vcmax_from_max 120 | 121 | # Ensure positive and reasonable 122 | vcmax_guess = max(20.0, min(vcmax_guess, 300.0)) 123 | else: 124 | # Fallback estimate based on maximum A value 125 | max_a = np.max(a) 126 | vcmax_guess = max(50.0, min(max_a * 4.0, 200.0)) 127 | 128 | return vcmax_guess 129 | 130 | 131 | def estimate_j_from_plateau( 132 | ci: np.ndarray, 133 | a: np.ndarray, 134 | vcmax_est: float, 135 | ci_range: Tuple[float, float] = (300.0, 700.0) 136 | ) -> float: 137 | """ 138 | Estimate J from the RuBP-limited plateau region. 139 | 140 | Args: 141 | ci: Sorted intercellular CO2 concentrations 142 | a: Sorted assimilation rates 143 | vcmax_est: Estimated Vcmax value 144 | ci_range: Ci range for RuBP-limited region 145 | 146 | Returns: 147 | Estimated J value 148 | """ 149 | # Select points in RuBP-limited region 150 | mask = (ci >= ci_range[0]) & (ci <= ci_range[1]) 151 | if np.sum(mask) < 3: 152 | # Adjust range if not enough points 153 | mask = (ci >= 200.0) & (ci <= 800.0) 154 | 155 | if np.sum(mask) >= 2: 156 | a_plateau = a[mask] 157 | 158 | # In RuBP-limited region, A ≈ J/4 - Rd (simplified) 159 | # So J ≈ 4 * (A + Rd) 160 | a_mean = np.mean(a_plateau) 161 | j_guess = 4.0 * (a_mean + 1.0) # Assume Rd ≈ 1.0 162 | 163 | # J should be greater than Vcmax typically 164 | j_guess = max(j_guess, vcmax_est * 1.5) 165 | else: 166 | # Fallback: J is typically 2-2.5x Vcmax 167 | j_guess = vcmax_est * 2.0 168 | 169 | return j_guess 170 | 171 | 172 | def estimate_tp_from_high_ci( 173 | ci: np.ndarray, 174 | a: np.ndarray, 175 | ci_threshold: float = 700.0 176 | ) -> float: 177 | """ 178 | Estimate Tp from the TPU-limited region at high Ci. 179 | 180 | Args: 181 | ci: Sorted intercellular CO2 concentrations 182 | a: Sorted assimilation rates 183 | ci_threshold: Minimum Ci for TPU-limited region 184 | 185 | Returns: 186 | Estimated Tp value 187 | """ 188 | # Select points in potential TPU-limited region 189 | mask = ci > ci_threshold 190 | 191 | if np.sum(mask) >= 3: 192 | ci_high = ci[mask] 193 | a_high = a[mask] 194 | 195 | # Check if A decreases with increasing Ci (TPU limitation signature) 196 | correlation = np.corrcoef(ci_high, a_high)[0, 1] 197 | 198 | if correlation < -0.3: # Negative correlation suggests TPU limitation 199 | # Tp ≈ A_max / 3 (rough approximation) 200 | tp_guess = np.max(a_high) / 3.0 201 | else: 202 | # No clear TPU limitation 203 | tp_guess = 15.0 # Default moderate value 204 | else: 205 | # Not enough high Ci points 206 | tp_guess = 15.0 # Default 207 | 208 | return tp_guess 209 | 210 | 211 | def estimate_rd(a: np.ndarray) -> float: 212 | """ 213 | Estimate dark respiration (Rd) from assimilation data. 214 | 215 | Args: 216 | a: Assimilation rates 217 | 218 | Returns: 219 | Estimated Rd value 220 | """ 221 | # Method 1: Use minimum A value (if negative) 222 | a_min = np.min(a) 223 | if a_min < 0: 224 | rd_guess = abs(a_min) 225 | else: 226 | # Method 2: Estimate as small fraction of mean A 227 | rd_guess = 0.05 * np.mean(a[a > 0]) 228 | 229 | # Ensure reasonable range 230 | rd_guess = max(0.5, min(rd_guess, 5.0)) 231 | 232 | return rd_guess 233 | 234 | 235 | def estimate_c3_parameter_bounds( 236 | initial_params: Dict[str, float], 237 | fixed_params: Optional[Dict[str, float]] = None 238 | ) -> Dict[str, Tuple[float, float]]: 239 | """ 240 | Generate parameter bounds based on initial estimates. 241 | 242 | Args: 243 | initial_params: Initial parameter estimates 244 | fixed_params: Parameters to fix (not optimize) 245 | 246 | Returns: 247 | Dictionary of parameter bounds 248 | """ 249 | fixed_params = fixed_params or {} 250 | 251 | bounds = {} 252 | 253 | # Vcmax bounds: 0.2x to 5x initial estimate 254 | if 'Vcmax_at_25' not in fixed_params: 255 | vcmax_init = initial_params.get('Vcmax_at_25', 100.0) 256 | bounds['Vcmax_at_25'] = ( 257 | max(0.0, 0.2 * vcmax_init), 258 | min(500.0, 5.0 * vcmax_init) 259 | ) 260 | 261 | # J bounds: 0.5x to 3x initial estimate 262 | if 'J_at_25' not in fixed_params: 263 | j_init = initial_params.get('J_at_25', 200.0) 264 | bounds['J_at_25'] = ( 265 | max(0.0, 0.5 * j_init), 266 | min(600.0, 3.0 * j_init) 267 | ) 268 | 269 | # Tp bounds: 0.3x to 5x initial estimate 270 | if 'Tp_at_25' not in fixed_params: 271 | tp_init = initial_params.get('Tp_at_25', 15.0) 272 | bounds['Tp_at_25'] = ( 273 | max(0.0, 0.3 * tp_init), 274 | min(100.0, 5.0 * tp_init) 275 | ) 276 | 277 | # RL bounds: 0 to 3x initial estimate 278 | if 'RL_at_25' not in fixed_params: 279 | rd_init = initial_params.get('RL_at_25', 1.0) 280 | bounds['RL_at_25'] = ( 281 | 0.0, 282 | min(10.0, 3.0 * rd_init) 283 | ) 284 | 285 | # gmc bounds: wide range if not fixed 286 | if 'gmc' not in fixed_params: 287 | bounds['gmc'] = (0.1, 20.0) 288 | 289 | return bounds 290 | 291 | 292 | def identify_limiting_regions( 293 | exdf: ExtendedDataFrame, 294 | ci_column: str = 'Ci' 295 | ) -> Dict[str, np.ndarray]: 296 | """ 297 | Identify approximate regions where different processes limit photosynthesis. 298 | 299 | Args: 300 | exdf: Extended dataframe with A-Ci curve data 301 | ci_column: Column name for intercellular CO2 302 | 303 | Returns: 304 | Dictionary with boolean masks for each limiting region 305 | """ 306 | ci = exdf.data[ci_column].values 307 | 308 | # Define approximate Ci ranges for each limitation 309 | regions = { 310 | 'rubisco_limited': ci < 300.0, 311 | 'rubp_limited': (ci >= 300.0) & (ci <= 700.0), 312 | 'tpu_limited': ci > 700.0 313 | } 314 | 315 | return regions 316 | 317 | 318 | def validate_initial_guess( 319 | params: Dict[str, float], 320 | data_stats: Optional[Dict[str, float]] = None 321 | ) -> Tuple[bool, List[str]]: 322 | """ 323 | Validate initial parameter guesses. 324 | 325 | Args: 326 | params: Initial parameter estimates 327 | data_stats: Optional statistics from the data 328 | 329 | Returns: 330 | Tuple of (is_valid, list_of_warnings) 331 | """ 332 | warnings = [] 333 | 334 | # Check parameter relationships 335 | if 'Vcmax_at_25' in params and 'J_at_25' in params: 336 | j_vcmax_ratio = params['J_at_25'] / params['Vcmax_at_25'] 337 | if j_vcmax_ratio < 1.0: 338 | warnings.append(f"J/Vcmax ratio is low: {j_vcmax_ratio:.2f}") 339 | elif j_vcmax_ratio > 4.0: 340 | warnings.append(f"J/Vcmax ratio is high: {j_vcmax_ratio:.2f}") 341 | 342 | # Check individual parameters 343 | if params.get('Vcmax_at_25', 0) < 10: 344 | warnings.append("Vcmax seems too low") 345 | if params.get('J_at_25', 0) < 20: 346 | warnings.append("J seems too low") 347 | if params.get('RL_at_25', 0) > 5: 348 | warnings.append("RL seems too high") 349 | 350 | is_valid = len(warnings) == 0 351 | return is_valid, warnings -------------------------------------------------------------------------------- /aci_py/tests/test_c4_fitting.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for C4 ACI curve fitting. 3 | """ 4 | 5 | import pytest 6 | import numpy as np 7 | import pandas as pd 8 | from ..core.data_structures import ExtendedDataFrame 9 | from ..core.c4_calculations import calculate_c4_assimilation 10 | from ..analysis.c4_fitting import initial_guess_c4_aci, fit_c4_aci 11 | 12 | 13 | class TestC4Fitting: 14 | """Test C4 ACI curve fitting functions.""" 15 | 16 | @pytest.fixture 17 | def synthetic_c4_data(self): 18 | """Create synthetic C4 ACI curve data with known parameters.""" 19 | # True parameters 20 | true_params = { 21 | 'Vcmax_at_25': 60.0, 22 | 'Vpmax_at_25': 120.0, 23 | 'J_at_25': 200.0, 24 | 'RL_at_25': 1.5, 25 | 'Vpr': 80.0, 26 | 'alpha_psii': 0.0, 27 | 'gbs': 0.003, 28 | 'Rm_frac': 0.5 29 | } 30 | 31 | # Create CO2 gradient 32 | n_points = 15 33 | ci_values = np.array([50, 75, 100, 125, 150, 200, 250, 300, 34 | 350, 400, 500, 600, 800, 1000, 1200])[:n_points] 35 | 36 | # Create base data 37 | data = pd.DataFrame({ 38 | 'Ci': ci_values, 39 | 'Ca': ci_values * 1.2, # Approximate Ca from Ci 40 | 'PCi': ci_values * 0.04, # Convert to partial pressure 41 | 'PCm': ci_values * 0.04 * 0.95, # Slightly lower due to gm 42 | 'Tleaf': 25.0, 43 | 'Tleaf_K': 298.15, 44 | 'oxygen': 21.0, 45 | 'total_pressure': 1.0, 46 | # Kinetic parameters 47 | 'ao': 0.21, 48 | 'gamma_star': 0.000193, # C4 value 49 | 'Kc': 650.0, 50 | 'Ko': 450.0, 51 | 'Kp': 80.0, 52 | # Temperature responses (all 1.0 at 25°C) 53 | 'Vcmax_norm': 1.0, 54 | 'Vpmax_norm': 1.0, 55 | 'RL_norm': 1.0, 56 | 'J_norm': 1.0, 57 | 'gmc_norm': 1.0 58 | }) 59 | 60 | exdf = ExtendedDataFrame(data) 61 | 62 | # Calculate true assimilation values 63 | result = calculate_c4_assimilation( 64 | exdf, 65 | **true_params, 66 | return_extended=True 67 | ) 68 | 69 | # Add some noise 70 | np.random.seed(42) 71 | noise = np.random.normal(0, 0.5, n_points) 72 | result.data['A'] = result.data['An'] + noise 73 | result.data['A'] = np.maximum(result.data['A'], 0) # No negative values 74 | 75 | # Store true parameters 76 | result.true_params = true_params 77 | 78 | return result 79 | 80 | def test_initial_guess_c4_aci(self, synthetic_c4_data): 81 | """Test initial parameter guessing for C4.""" 82 | guesses = initial_guess_c4_aci(synthetic_c4_data) 83 | 84 | # Check that all required parameters are present 85 | required = ['RL_at_25', 'Vcmax_at_25', 'Vpmax_at_25', 'Vpr', 'J_at_25'] 86 | assert all(param in guesses for param in required) 87 | 88 | # Check that guesses are positive 89 | assert all(guesses[param] > 0 for param in required) 90 | 91 | # Check that guesses are in reasonable ranges 92 | assert 0.1 <= guesses['RL_at_25'] <= 10.0 93 | assert 10 <= guesses['Vcmax_at_25'] <= 500.0 94 | assert 10 <= guesses['Vpmax_at_25'] <= 500.0 95 | assert 10 <= guesses['Vpr'] <= 200.0 96 | assert 50 <= guesses['J_at_25'] <= 1000.0 97 | 98 | # For synthetic data, guesses should be somewhat close to true values 99 | true_params = synthetic_c4_data.true_params 100 | # Allow 100% error in initial guesses 101 | assert abs(guesses['Vcmax_at_25'] - true_params['Vcmax_at_25']) / true_params['Vcmax_at_25'] < 1.0 102 | 103 | def test_fit_c4_aci_basic(self, synthetic_c4_data): 104 | """Test basic C4 ACI fitting.""" 105 | result = fit_c4_aci( 106 | synthetic_c4_data, 107 | calculate_confidence=False # Speed up test 108 | ) 109 | 110 | # Check result structure 111 | assert hasattr(result, 'parameters') 112 | assert hasattr(result, 'rmse') 113 | assert hasattr(result, 'r_squared') 114 | assert hasattr(result, 'exdf') 115 | 116 | # Check that fitted parameters are present 117 | assert 'Vcmax_at_25' in result.parameters 118 | assert 'Vpmax_at_25' in result.parameters 119 | assert 'J_at_25' in result.parameters 120 | assert 'RL_at_25' in result.parameters 121 | assert 'Vpr' in result.parameters 122 | 123 | # Check fit quality 124 | assert result.rmse < 2.0 # Should fit well with small noise 125 | assert result.r_squared > 0.95 126 | 127 | # Check that parameters are reasonable 128 | assert 0 < result.parameters['RL_at_25'] < 10 129 | assert 10 < result.parameters['Vcmax_at_25'] < 200 130 | assert 10 < result.parameters['Vpmax_at_25'] < 300 131 | assert 10 < result.parameters['Vpr'] < 200 132 | assert 50 < result.parameters['J_at_25'] < 500 133 | 134 | def test_fit_c4_aci_fixed_parameters(self, synthetic_c4_data): 135 | """Test fitting with fixed parameters.""" 136 | # Fix some parameters 137 | fixed = { 138 | 'RL_at_25': 1.5, # Fix at true value 139 | 'Vpr': 80.0, # Fix at true value 140 | 'alpha_psii': 0.0, 141 | 'gbs': 0.003, 142 | 'Rm_frac': 0.5 143 | } 144 | 145 | result = fit_c4_aci( 146 | synthetic_c4_data, 147 | fixed_parameters=fixed, 148 | calculate_confidence=False 149 | ) 150 | 151 | # Check that fixed parameters weren't changed 152 | assert result.parameters['RL_at_25'] == 1.5 153 | assert result.parameters['Vpr'] == 80.0 154 | 155 | # Check that other parameters were fitted 156 | assert result.parameters['Vcmax_at_25'] != fixed.get('Vcmax_at_25', 0) 157 | assert result.parameters['Vpmax_at_25'] != fixed.get('Vpmax_at_25', 0) 158 | 159 | # Should still get good fit 160 | assert result.rmse < 2.0 161 | assert result.r_squared > 0.95 162 | 163 | def test_fit_c4_aci_custom_bounds(self, synthetic_c4_data): 164 | """Test fitting with custom parameter bounds.""" 165 | # Set tight bounds around true values 166 | true_params = synthetic_c4_data.true_params 167 | bounds = { 168 | 'Vcmax_at_25': (true_params['Vcmax_at_25'] * 0.8, 169 | true_params['Vcmax_at_25'] * 1.2), 170 | 'Vpmax_at_25': (true_params['Vpmax_at_25'] * 0.8, 171 | true_params['Vpmax_at_25'] * 1.2) 172 | } 173 | 174 | result = fit_c4_aci( 175 | synthetic_c4_data, 176 | bounds=bounds, 177 | calculate_confidence=False 178 | ) 179 | 180 | # Check that parameters respect bounds 181 | assert bounds['Vcmax_at_25'][0] <= result.parameters['Vcmax_at_25'] <= bounds['Vcmax_at_25'][1] 182 | assert bounds['Vpmax_at_25'][0] <= result.parameters['Vpmax_at_25'] <= bounds['Vpmax_at_25'][1] 183 | 184 | def test_fit_c4_aci_confidence_intervals(self, synthetic_c4_data): 185 | """Test confidence interval calculation.""" 186 | result = fit_c4_aci( 187 | synthetic_c4_data, 188 | calculate_confidence=True, 189 | confidence_level=0.95 190 | ) 191 | 192 | # Check that confidence intervals were calculated 193 | assert result.confidence_intervals is not None 194 | 195 | # Check structure 196 | for param in ['Vcmax_at_25', 'Vpmax_at_25', 'J_at_25', 'RL_at_25', 'Vpr']: 197 | if param in result.parameter_names: 198 | assert param in result.confidence_intervals 199 | ci = result.confidence_intervals[param] 200 | assert len(ci) == 2 201 | assert ci[0] < result.parameters[param] < ci[1] 202 | 203 | def test_fit_c4_aci_with_outliers(self, synthetic_c4_data): 204 | """Test fitting with outlier points.""" 205 | # Add some outliers 206 | synthetic_c4_data.data.loc[5, 'A'] = 50 # Unrealistically high 207 | synthetic_c4_data.data.loc[10, 'A'] = -5 # Negative 208 | 209 | result = fit_c4_aci( 210 | synthetic_c4_data, 211 | calculate_confidence=False 212 | ) 213 | 214 | # Should still converge despite outliers 215 | assert result.parameters is not None 216 | assert result.rmse < 10.0 # Higher due to outliers 217 | 218 | def test_fit_c4_aci_different_optimizers(self, synthetic_c4_data): 219 | """Test different optimization methods.""" 220 | # Test differential evolution (default) 221 | result_de = fit_c4_aci( 222 | synthetic_c4_data, 223 | optimizer='differential_evolution', 224 | calculate_confidence=False 225 | ) 226 | 227 | # Test Nelder-Mead 228 | result_nm = fit_c4_aci( 229 | synthetic_c4_data, 230 | optimizer='nelder-mead', 231 | calculate_confidence=False 232 | ) 233 | 234 | # Both should give reasonable results 235 | assert result_de.rmse < 2.0 236 | assert result_nm.rmse < 3.0 # NM might be slightly worse 237 | 238 | # Parameters should be similar 239 | for param in ['Vcmax_at_25', 'Vpmax_at_25']: 240 | assert abs(result_de.parameters[param] - result_nm.parameters[param]) / \ 241 | result_de.parameters[param] < 0.3 # Within 30% 242 | 243 | def test_fit_c4_aci_result_exdf(self, synthetic_c4_data): 244 | """Test the extended data frame in fitting results.""" 245 | result = fit_c4_aci( 246 | synthetic_c4_data, 247 | calculate_confidence=False 248 | ) 249 | 250 | # Check that model predictions are included 251 | assert 'An_model' in result.exdf.data.columns 252 | assert 'residuals' in result.exdf.data.columns 253 | 254 | # Check process rates 255 | assert 'Ac' in result.exdf.data.columns 256 | assert 'Aj' in result.exdf.data.columns 257 | assert 'Ar' in result.exdf.data.columns 258 | assert 'Ap' in result.exdf.data.columns 259 | 260 | # Check that residuals match 261 | expected_residuals = synthetic_c4_data.data['A'] - result.exdf.data['An_model'] 262 | assert np.allclose(result.exdf.data['residuals'], expected_residuals) 263 | 264 | def test_c4_limiting_processes_in_fit(self, synthetic_c4_data): 265 | """Test that we can identify limiting processes after fitting.""" 266 | from ..core.c4_calculations import identify_c4_limiting_processes 267 | 268 | result = fit_c4_aci( 269 | synthetic_c4_data, 270 | calculate_confidence=False 271 | ) 272 | 273 | # Identify limiting processes 274 | result_with_limits = identify_c4_limiting_processes(result.exdf) 275 | 276 | # Check that limitations make sense 277 | # At low CO2, expect PEP carboxylation or Rubisco limitation 278 | low_co2_mask = result_with_limits.data['PCm'] < 5 279 | if np.any(low_co2_mask): 280 | enzyme_limits = result_with_limits.data.loc[low_co2_mask, 'enzyme_limited_process'] 281 | assert all(lim in ['pep_carboxylation_co2', 'rubisco', ''] 282 | for lim in enzyme_limits) 283 | 284 | def test_c4_vs_c3_parameter_ranges(self, synthetic_c4_data): 285 | """Test that C4 parameters are in expected ranges compared to C3.""" 286 | result = fit_c4_aci( 287 | synthetic_c4_data, 288 | calculate_confidence=False 289 | ) 290 | 291 | # C4 specific expectations 292 | # Vpmax should be substantial (PEP carboxylase activity) 293 | assert result.parameters['Vpmax_at_25'] > 50 294 | 295 | # Vpmax often > Vcmax in C4 plants 296 | assert result.parameters['Vpmax_at_25'] > result.parameters['Vcmax_at_25'] * 0.5 297 | 298 | # Bundle sheath parameters 299 | assert result.parameters['gbs'] > 0 # Should have some conductance 300 | assert 0 <= result.parameters['alpha_psii'] <= 1 # Fraction bounds -------------------------------------------------------------------------------- /aci_py/tests/test_light_response.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for light response curve fitting module. 3 | """ 4 | 5 | import pytest 6 | import numpy as np 7 | import pandas as pd 8 | from aci_py.core.data_structures import ExtendedDataFrame 9 | from aci_py.analysis.light_response import ( 10 | non_rectangular_hyperbola, 11 | rectangular_hyperbola, 12 | exponential_model, 13 | initial_guess_light_response, 14 | fit_light_response, 15 | compare_light_models 16 | ) 17 | 18 | 19 | class TestLightResponseModels: 20 | """Test individual light response model functions.""" 21 | 22 | def test_non_rectangular_hyperbola(self): 23 | """Test non-rectangular hyperbola calculations.""" 24 | # Test single value 25 | I = 500 26 | phi = 0.05 27 | Amax = 30 28 | theta = 0.7 29 | Rd = 2 30 | 31 | A = non_rectangular_hyperbola(I, phi, Amax, theta, Rd) 32 | 33 | # Should be positive and less than Amax - Rd 34 | assert A > 0 35 | assert A < Amax - Rd 36 | 37 | # Test array 38 | I_array = np.array([0, 100, 500, 1000, 2000]) 39 | A_array = non_rectangular_hyperbola(I_array, phi, Amax, theta, Rd) 40 | 41 | # Check shape 42 | assert A_array.shape == I_array.shape 43 | 44 | # Check monotonic increase (after accounting for Rd) 45 | A_gross = A_array + Rd 46 | assert np.all(np.diff(A_gross) > 0) 47 | 48 | # Check limits 49 | assert A_array[0] == pytest.approx(-Rd) # A = -Rd at I = 0 50 | assert A_array[-1] < Amax # Approaches but doesn't exceed Amax 51 | 52 | def test_rectangular_hyperbola(self): 53 | """Test rectangular hyperbola (theta = 0).""" 54 | I = np.array([0, 100, 500, 1000, 2000]) 55 | phi = 0.05 56 | Amax = 30 57 | Rd = 2 58 | 59 | A = rectangular_hyperbola(I, phi, Amax, Rd) 60 | 61 | # Compare with non-rectangular hyperbola at theta = 0 62 | A_nrh = non_rectangular_hyperbola(I, phi, Amax, 0, Rd) 63 | 64 | # Should be very close (allowing for numerical precision) 65 | np.testing.assert_allclose(A, A_nrh, rtol=1e-10) 66 | 67 | # Check properties 68 | assert A[0] == pytest.approx(-Rd) 69 | assert np.all(np.diff(A) > 0) # Monotonic increase 70 | 71 | def test_exponential_model(self): 72 | """Test exponential light response model.""" 73 | I = np.array([0, 100, 500, 1000, 2000]) 74 | phi = 0.05 75 | Amax = 30 76 | Rd = 2 77 | 78 | A = exponential_model(I, phi, Amax, Rd) 79 | 80 | # Check properties 81 | assert A[0] == pytest.approx(-Rd) 82 | assert np.all(np.diff(A) > 0) # Monotonic increase 83 | assert np.all(A < Amax - Rd) # Never exceeds Amax - Rd 84 | 85 | # Check diminishing returns - first derivative should decrease 86 | dA = np.diff(A) 87 | # For exponential, diminishing returns means smaller increases 88 | assert dA[0] > dA[-1] # Early slope > late slope 89 | 90 | 91 | class TestInitialGuess: 92 | """Test initial parameter estimation for light response.""" 93 | 94 | def test_initial_guess_typical_data(self): 95 | """Test initial guess with typical light response data.""" 96 | # Generate synthetic data 97 | I = np.array([0, 20, 50, 100, 200, 400, 800, 1200, 1600, 2000]) 98 | A_true = non_rectangular_hyperbola(I, phi=0.05, Amax=30, theta=0.7, Rd=2) 99 | 100 | # Add small noise 101 | np.random.seed(42) 102 | A = A_true + np.random.normal(0, 0.5, size=len(A_true)) 103 | 104 | # Create ExtendedDataFrame 105 | data = pd.DataFrame({'Qin': I, 'A': A}) 106 | exdf = ExtendedDataFrame(data) 107 | 108 | # Get initial guess 109 | guess = initial_guess_light_response(exdf) 110 | 111 | # Check parameter ranges 112 | assert 0 < guess['phi'] < 0.2 113 | assert 0 < guess['Amax'] < 100 114 | assert 0 <= guess['theta'] <= 1 115 | assert 0 <= guess['Rd'] < 10 116 | 117 | # Should be reasonably close to true values 118 | assert guess['phi'] == pytest.approx(0.05, rel=0.5) 119 | assert guess['Amax'] == pytest.approx(32, rel=0.3) # Amax + Rd 120 | assert guess['Rd'] == pytest.approx(2, rel=0.5) 121 | 122 | def test_initial_guess_minimal_data(self): 123 | """Test initial guess with minimal data points.""" 124 | # Minimal data 125 | I = np.array([0, 500, 1500]) 126 | A = np.array([-1.5, 15, 25]) 127 | 128 | data = pd.DataFrame({'Qin': I, 'A': A}) 129 | exdf = ExtendedDataFrame(data) 130 | 131 | # Should still work 132 | guess = initial_guess_light_response(exdf) 133 | 134 | assert guess['phi'] > 0 135 | assert guess['Amax'] > 0 136 | assert guess['Rd'] >= 0 137 | 138 | 139 | class TestLightResponseFitting: 140 | """Test light response curve fitting.""" 141 | 142 | def test_fit_non_rectangular_hyperbola(self): 143 | """Test fitting non-rectangular hyperbola model.""" 144 | # Generate synthetic data with known parameters 145 | I = np.array([0, 20, 50, 100, 200, 400, 600, 800, 1000, 1200, 1500, 2000]) 146 | true_params = {'phi': 0.05, 'Amax': 30, 'theta': 0.7, 'Rd': 2} 147 | A_true = non_rectangular_hyperbola(I, **true_params) 148 | 149 | # Add noise 150 | np.random.seed(42) 151 | A = A_true + np.random.normal(0, 0.5, size=len(A_true)) 152 | 153 | # Create ExtendedDataFrame 154 | data = pd.DataFrame({'Qin': I, 'A': A}) 155 | exdf = ExtendedDataFrame(data) 156 | 157 | # Fit model 158 | result = fit_light_response(exdf, model_type='non_rectangular_hyperbola') 159 | 160 | # Check structure 161 | assert 'parameters' in result 162 | assert 'statistics' in result 163 | assert 'predicted' in result 164 | assert 'residuals' in result 165 | assert 'convergence' in result 166 | 167 | # Check fitted parameters are close to true values 168 | fitted = result['parameters'] 169 | assert fitted['phi'] == pytest.approx(true_params['phi'], rel=0.2) 170 | assert fitted['Amax'] == pytest.approx(true_params['Amax'], rel=0.1) 171 | assert fitted['theta'] == pytest.approx(true_params['theta'], rel=0.3) 172 | assert fitted['Rd'] == pytest.approx(true_params['Rd'], rel=0.3) 173 | 174 | # Check fit quality 175 | assert result['statistics']['r_squared'] > 0.95 176 | assert result['statistics']['rmse'] < 1.0 177 | 178 | # Check convergence 179 | assert result['convergence']['success'] 180 | 181 | def test_fit_with_fixed_parameters(self): 182 | """Test fitting with fixed parameters.""" 183 | # Generate data 184 | I = np.linspace(0, 2000, 15) 185 | A = non_rectangular_hyperbola(I, phi=0.06, Amax=28, theta=0.8, Rd=1.5) 186 | 187 | data = pd.DataFrame({'Qin': I, 'A': A}) 188 | exdf = ExtendedDataFrame(data) 189 | 190 | # Fix theta 191 | result = fit_light_response( 192 | exdf, 193 | model_type='non_rectangular_hyperbola', 194 | fixed_parameters={'theta': 0.8} 195 | ) 196 | 197 | # Check theta is fixed 198 | assert result['parameters']['theta'] == 0.8 199 | 200 | # Other parameters should still be fitted well 201 | assert result['parameters']['phi'] == pytest.approx(0.06, rel=0.1) 202 | assert result['parameters']['Amax'] == pytest.approx(28, rel=0.1) 203 | 204 | def test_fit_rectangular_hyperbola(self): 205 | """Test fitting rectangular hyperbola model.""" 206 | # Generate data with rectangular hyperbola (theta = 0) 207 | I = np.linspace(0, 2000, 20) 208 | A = rectangular_hyperbola(I, phi=0.05, Amax=25, Rd=1.8) 209 | 210 | # Add small noise 211 | np.random.seed(42) 212 | A += np.random.normal(0, 0.3, size=len(A)) 213 | 214 | data = pd.DataFrame({'Qin': I, 'A': A}) 215 | exdf = ExtendedDataFrame(data) 216 | 217 | # Fit model 218 | result = fit_light_response(exdf, model_type='rectangular_hyperbola') 219 | 220 | # Check parameters 221 | assert 'theta' not in result['parameters'] # No theta for rectangular 222 | assert result['parameters']['phi'] == pytest.approx(0.05, rel=0.1) 223 | assert result['parameters']['Amax'] == pytest.approx(25, rel=0.1) 224 | assert result['parameters']['Rd'] == pytest.approx(1.8, rel=0.2) 225 | 226 | def test_light_compensation_point(self): 227 | """Test calculation of light compensation point.""" 228 | # Generate data where we know LCP 229 | I = np.linspace(0, 2000, 30) 230 | phi = 0.05 231 | Amax = 30 232 | Rd = 2 233 | 234 | # LCP is where A = 0, so phi * I * Amax / (phi * I + Amax) = Rd 235 | # Solving: I = Rd * Amax / (phi * (Amax - Rd)) 236 | expected_lcp = Rd * Amax / (phi * (Amax - Rd)) 237 | 238 | A = rectangular_hyperbola(I, phi, Amax, Rd) 239 | 240 | data = pd.DataFrame({'Qin': I, 'A': A}) 241 | exdf = ExtendedDataFrame(data) 242 | 243 | result = fit_light_response(exdf, model_type='rectangular_hyperbola') 244 | 245 | # Check LCP calculation 246 | lcp = result['statistics']['light_compensation_point'] 247 | assert lcp is not None 248 | assert lcp == pytest.approx(expected_lcp, rel=0.1) 249 | 250 | def test_compare_models(self): 251 | """Test model comparison functionality.""" 252 | # Generate data that clearly favors non-rectangular hyperbola 253 | I = np.linspace(0, 2000, 25) 254 | A = non_rectangular_hyperbola(I, phi=0.05, Amax=30, theta=0.9, Rd=2) 255 | 256 | # Add noise 257 | np.random.seed(42) 258 | A += np.random.normal(0, 0.5, size=len(A)) 259 | 260 | data = pd.DataFrame({'Qin': I, 'A': A}) 261 | exdf = ExtendedDataFrame(data) 262 | 263 | # Compare models 264 | comparison = compare_light_models(exdf) 265 | 266 | # Check structure 267 | assert 'comparison_summary' in comparison 268 | assert 'non_rectangular_hyperbola' in comparison 269 | assert 'rectangular_hyperbola' in comparison 270 | assert 'exponential' in comparison 271 | 272 | # Check best model selection 273 | summary = comparison['comparison_summary'] 274 | assert 'best_model' in summary 275 | assert 'aic_values' in summary 276 | assert 'delta_aic' in summary 277 | 278 | # Best model should have delta_aic = 0 279 | best_model = summary['best_model'] 280 | assert summary['delta_aic'][best_model] == 0 281 | 282 | def test_missing_data_handling(self): 283 | """Test handling of missing data.""" 284 | # Data with NaN values 285 | I = np.array([0, 50, np.nan, 200, 400, 800, np.nan, 1500]) 286 | A = np.array([-2, 5, 10, np.nan, 18, 22, 25, np.nan]) 287 | 288 | data = pd.DataFrame({'Qin': I, 'A': A}) 289 | exdf = ExtendedDataFrame(data) 290 | 291 | # Should still fit with valid points 292 | result = fit_light_response(exdf) 293 | 294 | assert result['convergence']['success'] 295 | assert result['statistics']['n_points'] == 4 # Only valid points 296 | 297 | # Residuals should have NaN in same places 298 | assert np.isnan(result['residuals'][2]) 299 | assert np.isnan(result['residuals'][3]) 300 | assert np.isnan(result['residuals'][6]) 301 | assert np.isnan(result['residuals'][7]) 302 | 303 | def test_insufficient_data_error(self): 304 | """Test error with insufficient data.""" 305 | # Too few points 306 | data = pd.DataFrame({'Qin': [0, 500], 'A': [-1, 10]}) 307 | exdf = ExtendedDataFrame(data) 308 | 309 | with pytest.raises(ValueError, match="Insufficient valid data points"): 310 | fit_light_response(exdf) 311 | 312 | def test_custom_bounds(self): 313 | """Test fitting with custom parameter bounds.""" 314 | # Generate typical data 315 | I = np.linspace(0, 2000, 15) 316 | A = non_rectangular_hyperbola(I, phi=0.08, Amax=35, theta=0.6, Rd=3) 317 | 318 | data = pd.DataFrame({'Qin': I, 'A': A}) 319 | exdf = ExtendedDataFrame(data) 320 | 321 | # Set restrictive bounds 322 | bounds = { 323 | 'phi': (0.07, 0.09), 324 | 'Amax': (30, 40), 325 | 'theta': (0.5, 0.7), 326 | 'Rd': (2.5, 3.5) 327 | } 328 | 329 | result = fit_light_response( 330 | exdf, 331 | model_type='non_rectangular_hyperbola', 332 | bounds=bounds 333 | ) 334 | 335 | # Check parameters are within bounds 336 | params = result['parameters'] 337 | for param, (lower, upper) in bounds.items(): 338 | assert lower <= params[param] <= upper -------------------------------------------------------------------------------- /aci_py/core/c3_calculations.py: -------------------------------------------------------------------------------- 1 | """ 2 | C3 photosynthesis calculations using the Farquhar-von Caemmerer-Berry model. 3 | 4 | This module implements the FvCB model for C3 photosynthesis, including: 5 | - Rubisco-limited carboxylation (Wc) 6 | - RuBP-regeneration-limited carboxylation (Wj) 7 | - TPU-limited carboxylation (Wp) 8 | - Net CO2 assimilation calculation 9 | 10 | Based on PhotoGEA R package implementations. 11 | """ 12 | 13 | import numpy as np 14 | from typing import Dict, Union, Optional, Tuple 15 | from dataclasses import dataclass 16 | from .data_structures import ExtendedDataFrame 17 | from .temperature import apply_temperature_response, C3_TEMPERATURE_PARAM_BERNACCHI 18 | 19 | 20 | @dataclass 21 | class C3AssimilationResult: 22 | """Container for C3 assimilation calculation results.""" 23 | # Net assimilation rates 24 | An: np.ndarray # Net CO2 assimilation rate (µmol m⁻² s⁻¹) 25 | Ac: np.ndarray # Rubisco-limited net assimilation 26 | Aj: np.ndarray # RuBP-limited net assimilation 27 | Ap: np.ndarray # TPU-limited net assimilation 28 | 29 | # Gross carboxylation rates 30 | Vc: np.ndarray # Overall carboxylation rate 31 | Wc: np.ndarray # Rubisco-limited carboxylation 32 | Wj: np.ndarray # RuBP-limited carboxylation 33 | Wp: np.ndarray # TPU-limited carboxylation 34 | 35 | # Temperature-adjusted parameters 36 | Vcmax_tl: np.ndarray # Temperature-adjusted Vcmax 37 | J_tl: np.ndarray # Temperature-adjusted J 38 | Tp_tl: np.ndarray # Temperature-adjusted Tp 39 | RL_tl: np.ndarray # Temperature-adjusted RL 40 | Gamma_star_tl: np.ndarray # Temperature-adjusted Gamma_star 41 | Kc_tl: np.ndarray # Temperature-adjusted Kc 42 | Ko_tl: np.ndarray # Temperature-adjusted Ko 43 | 44 | # Effective CO2 compensation point 45 | Gamma_star_agt: np.ndarray # Effective Gamma_star with fractionation 46 | 47 | 48 | def calculate_c3_assimilation( 49 | exdf: ExtendedDataFrame, 50 | parameters: Dict[str, float], 51 | cc_column_name: str = 'Cc', 52 | temperature_response_params: Optional[Dict] = None, 53 | alpha_g: float = 0.0, 54 | alpha_old: float = 0.0, 55 | alpha_s: float = 0.0, 56 | alpha_t: float = 0.0, 57 | Wj_coef_C: float = 4.0, 58 | Wj_coef_Gamma_star: float = 8.0, 59 | TPU_threshold: Optional[float] = None, 60 | oxygen: float = 21.0, # O2 percentage 61 | use_legacy_alpha: bool = False, 62 | perform_checks: bool = True 63 | ) -> C3AssimilationResult: 64 | """ 65 | Calculate C3 assimilation using the Farquhar-von Caemmerer-Berry model. 66 | 67 | The FvCB model calculates photosynthesis as limited by: 68 | 1. Rubisco activity (Wc) 69 | 2. RuBP regeneration/electron transport (Wj) 70 | 3. Triose phosphate utilization (Wp) 71 | 72 | Args: 73 | exdf: ExtendedDataFrame with gas exchange data 74 | parameters: Dictionary with model parameters: 75 | - Vcmax_at_25: Maximum carboxylation rate at 25°C (µmol m⁻² s⁻¹) 76 | - J_at_25: Maximum electron transport rate at 25°C (µmol m⁻² s⁻¹) 77 | - Tp_at_25: Triose phosphate utilization rate at 25°C (µmol m⁻² s⁻¹) 78 | - RL_at_25: Day respiration rate at 25°C (µmol m⁻² s⁻¹) 79 | - Gamma_star_at_25: CO2 compensation point at 25°C (µmol mol⁻¹) 80 | - Kc_at_25: Michaelis constant for CO2 at 25°C (µmol mol⁻¹) 81 | - Ko_at_25: Michaelis constant for O2 at 25°C (mmol mol⁻¹) 82 | cc_column_name: Column name for mesophyll CO2 concentration 83 | temperature_response_params: Temperature response parameters (default: Bernacchi) 84 | alpha_g: Fractionation factor for gaseous diffusion 85 | alpha_old: Legacy fractionation factor (mutually exclusive with others) 86 | alpha_s: Fractionation factor for CO2 dissolution 87 | alpha_t: Fractionation factor during transport 88 | Wj_coef_C: Coefficient for Wj calculation (default 4.0) 89 | Wj_coef_Gamma_star: Coefficient for Wj calculation (default 8.0) 90 | TPU_threshold: Custom TPU threshold (uses biochemical threshold if None) 91 | oxygen: O2 concentration (percent) 92 | use_legacy_alpha: Use alpha_old instead of new fractionation factors 93 | perform_checks: Whether to perform input validation 94 | 95 | Returns: 96 | C3AssimilationResult with calculated values 97 | 98 | Raises: 99 | ValueError: If required columns are missing or parameters are invalid 100 | """ 101 | # Default temperature response if not provided 102 | if temperature_response_params is None: 103 | temperature_response_params = C3_TEMPERATURE_PARAM_BERNACCHI 104 | 105 | # Check for required columns 106 | required_columns = [cc_column_name, 'T_leaf_K', 'Pa'] 107 | if perform_checks: 108 | missing = [col for col in required_columns if col not in exdf.data.columns] 109 | if missing: 110 | raise ValueError(f"Missing required columns: {missing}") 111 | 112 | # Check parameter validity 113 | if perform_checks: 114 | _validate_c3_parameters(parameters, alpha_g, alpha_old, alpha_s, alpha_t, 115 | use_legacy_alpha, Wj_coef_C, Wj_coef_Gamma_star) 116 | 117 | # Extract data arrays 118 | Cc = exdf.data[cc_column_name].values # µmol/mol 119 | T_leaf_C = exdf.data['T_leaf_K'].values - 273.15 # Convert to °C 120 | pressure = exdf.data['Pa'].values / 100.0 # Convert Pa to bar 121 | 122 | # Calculate partial pressures 123 | PCc = Cc * pressure # µbar 124 | POc = oxygen * pressure * 1e4 # µbar (oxygen partial pressure) 125 | 126 | # Apply temperature corrections to parameters 127 | # Convert parameter names from '_at_25' format to base names 128 | base_params = { 129 | 'Vcmax': parameters['Vcmax_at_25'], 130 | 'J': parameters['J_at_25'], 131 | 'Tp': parameters['Tp_at_25'], 132 | 'RL': parameters['RL_at_25'], 133 | 'Gamma_star': parameters['Gamma_star_at_25'], 134 | 'Kc': parameters['Kc_at_25'], 135 | 'Ko': parameters['Ko_at_25'] 136 | } 137 | 138 | temp_adjusted = apply_temperature_response( 139 | base_params, temperature_response_params, T_leaf_C 140 | ) 141 | 142 | # Extract temperature-adjusted values 143 | # Handle case where temp_adjusted values might be scalars or arrays 144 | def get_array_value(key, default): 145 | val = temp_adjusted.get(key, default) 146 | if isinstance(val, (int, float)): 147 | return np.full_like(T_leaf_C, val, dtype=float) 148 | return val 149 | 150 | Vcmax_tl = get_array_value('Vcmax', parameters['Vcmax_at_25']) 151 | J_tl = get_array_value('J', parameters['J_at_25']) 152 | Tp_tl = get_array_value('Tp', parameters['Tp_at_25']) 153 | RL_tl = get_array_value('RL', parameters['RL_at_25']) 154 | Gamma_star_tl = get_array_value('Gamma_star', parameters['Gamma_star_at_25']) 155 | 156 | # Convert Kc and Ko to appropriate units with pressure 157 | Kc_base = get_array_value('Kc', parameters['Kc_at_25']) 158 | Ko_base = get_array_value('Ko', parameters['Ko_at_25']) 159 | Kc_tl = Kc_base * pressure # µbar 160 | Ko_tl = Ko_base * pressure * 1000 # µbar 161 | 162 | # Calculate effective CO2 compensation point with fractionation 163 | if use_legacy_alpha: 164 | Gamma_star_agt = (1 - alpha_old) * Gamma_star_tl * pressure 165 | else: 166 | Gamma_star_agt = (1 - alpha_g + 2 * alpha_t) * Gamma_star_tl * pressure # µbar 167 | 168 | # Calculate Rubisco-limited carboxylation rate (µmol m⁻² s⁻¹) 169 | Wc = PCc * Vcmax_tl / (PCc + Kc_tl * (1.0 + POc / Ko_tl)) 170 | 171 | # Calculate RuBP-regeneration-limited carboxylation rate 172 | if use_legacy_alpha: 173 | Wj_denominator = PCc * Wj_coef_C + Gamma_star_agt * Wj_coef_Gamma_star 174 | else: 175 | Wj_denominator = (PCc * Wj_coef_C + 176 | Gamma_star_agt * (Wj_coef_Gamma_star + 16 * alpha_g - 177 | 8 * alpha_t + 8 * alpha_s)) 178 | Wj = PCc * J_tl / Wj_denominator 179 | 180 | # Calculate TPU-limited carboxylation rate 181 | if use_legacy_alpha: 182 | Wp_denominator = PCc - Gamma_star_agt * (1 + 3 * alpha_old) 183 | else: 184 | Wp_denominator = (PCc - Gamma_star_agt * (1 + 3 * alpha_g + 185 | 6 * alpha_t + 4 * alpha_s)) 186 | 187 | # Initialize Wp with infinity 188 | Wp = np.full_like(PCc, np.inf, dtype=float) 189 | 190 | # Apply TPU threshold 191 | if TPU_threshold is None: 192 | # Use biochemical threshold 193 | if use_legacy_alpha: 194 | threshold = Gamma_star_agt * (1 + 3 * alpha_old) 195 | else: 196 | threshold = Gamma_star_agt * (1 + 3 * alpha_g + 6 * alpha_t + 4 * alpha_s) 197 | else: 198 | # Use custom threshold 199 | threshold = TPU_threshold 200 | 201 | # Calculate Wp only where PCc > threshold 202 | valid_idx = PCc > threshold 203 | if np.any(valid_idx): 204 | Wp[valid_idx] = PCc[valid_idx] * 3 * Tp_tl[valid_idx] / Wp_denominator[valid_idx] 205 | 206 | # Overall carboxylation rate (minimum of three limitations) 207 | Vc = np.minimum(np.minimum(Wc, Wj), Wp) 208 | 209 | # Calculate net CO2 assimilation rates 210 | photo_resp_factor = 1.0 - Gamma_star_agt / PCc # Photorespiration factor 211 | 212 | # Prevent division by zero 213 | photo_resp_factor = np.where(PCc > 0, photo_resp_factor, 0) 214 | 215 | # Net assimilation for each limitation 216 | Ac = photo_resp_factor * Wc - RL_tl 217 | Aj = photo_resp_factor * Wj - RL_tl 218 | Ap = photo_resp_factor * Wp - RL_tl 219 | 220 | # Overall net assimilation 221 | An = photo_resp_factor * Vc - RL_tl 222 | 223 | return C3AssimilationResult( 224 | An=An, Ac=Ac, Aj=Aj, Ap=Ap, 225 | Vc=Vc, Wc=Wc, Wj=Wj, Wp=Wp, 226 | Vcmax_tl=Vcmax_tl, J_tl=J_tl, Tp_tl=Tp_tl, RL_tl=RL_tl, 227 | Gamma_star_tl=Gamma_star_tl, Kc_tl=Kc_tl, Ko_tl=Ko_tl, 228 | Gamma_star_agt=Gamma_star_agt 229 | ) 230 | 231 | 232 | def _validate_c3_parameters( 233 | parameters: Dict[str, float], 234 | alpha_g: float, 235 | alpha_old: float, 236 | alpha_s: float, 237 | alpha_t: float, 238 | use_legacy_alpha: bool, 239 | Wj_coef_C: float, 240 | Wj_coef_Gamma_star: float 241 | ) -> None: 242 | """ 243 | Validate C3 model parameters. 244 | 245 | Raises: 246 | ValueError: If parameters are invalid or inconsistent 247 | """ 248 | # Check required parameters 249 | required_params = [ 250 | 'Vcmax_at_25', 'J_at_25', 'Tp_at_25', 'RL_at_25', 251 | 'Gamma_star_at_25', 'Kc_at_25', 'Ko_at_25' 252 | ] 253 | missing = [p for p in required_params if p not in parameters] 254 | if missing: 255 | raise ValueError(f"Missing required parameters: {missing}") 256 | 257 | # Check for mixing of alpha models 258 | if use_legacy_alpha and (alpha_g > 0 or alpha_s > 0 or alpha_t > 0): 259 | raise ValueError( 260 | "Cannot use legacy alpha_old with new fractionation factors " 261 | "(alpha_g, alpha_s, alpha_t)" 262 | ) 263 | 264 | if not use_legacy_alpha and alpha_old > 0: 265 | raise ValueError( 266 | "alpha_old > 0 but use_legacy_alpha is False. " 267 | "Set use_legacy_alpha=True to use alpha_old." 268 | ) 269 | 270 | # Check Wj coefficients when using new fractionation 271 | if (alpha_g > 0 or alpha_s > 0 or alpha_t > 0): 272 | if abs(Wj_coef_C - 4.0) > 1e-10 or abs(Wj_coef_Gamma_star - 8.0) > 1e-10: 273 | raise ValueError( 274 | "Wj_coef_C must be 4.0 and Wj_coef_Gamma_star must be 8.0 " 275 | "when using new fractionation factors" 276 | ) 277 | 278 | # Check parameter values 279 | for param_name, value in parameters.items(): 280 | if value < 0: 281 | raise ValueError(f"{param_name} must be >= 0, got {value}") 282 | 283 | # Check fractionation factors 284 | for name, value in [('alpha_g', alpha_g), ('alpha_old', alpha_old), 285 | ('alpha_s', alpha_s), ('alpha_t', alpha_t)]: 286 | if value < 0 or value > 1: 287 | raise ValueError(f"{name} must be between 0 and 1, got {value}") 288 | 289 | # Check combined fractionation constraint 290 | if alpha_g + 2 * alpha_t + 4 * alpha_s / 3 > 1: 291 | raise ValueError( 292 | "alpha_g + 2 * alpha_t + 4 * alpha_s / 3 must be <= 1" 293 | ) 294 | 295 | 296 | def identify_c3_limiting_process(result: C3AssimilationResult) -> np.ndarray: 297 | """ 298 | Identify which process limits photosynthesis at each point. 299 | 300 | Args: 301 | result: C3AssimilationResult from calculate_c3_assimilation 302 | 303 | Returns: 304 | Array of strings indicating limiting process for each point: 305 | - 'Rubisco': Rubisco-limited 306 | - 'RuBP': RuBP-regeneration-limited 307 | - 'TPU': TPU-limited 308 | """ 309 | n_points = len(result.An) 310 | limiting_process = np.empty(n_points, dtype=object) 311 | 312 | # Compare carboxylation rates to identify limitation 313 | for i in range(n_points): 314 | wc = result.Wc[i] 315 | wj = result.Wj[i] 316 | wp = result.Wp[i] 317 | 318 | if wc <= wj and wc <= wp: 319 | limiting_process[i] = 'Rubisco' 320 | elif wj <= wc and wj <= wp: 321 | limiting_process[i] = 'RuBP' 322 | else: 323 | limiting_process[i] = 'TPU' 324 | 325 | return limiting_process -------------------------------------------------------------------------------- /aci_py/tests/test_temperature.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for temperature response functions. 3 | 4 | Validates temperature response calculations against known values and 5 | ensures consistency with PhotoGEA R package. 6 | """ 7 | 8 | import numpy as np 9 | import pytest 10 | from aci_py.core.temperature import ( 11 | arrhenius_response, 12 | johnson_eyring_williams_response, 13 | gaussian_response, 14 | polynomial_response, 15 | calculate_temperature_response, 16 | apply_temperature_response, 17 | TemperatureParameter, 18 | C3_TEMPERATURE_PARAM_BERNACCHI, 19 | C3_TEMPERATURE_PARAM_SHARKEY, 20 | C3_TEMPERATURE_PARAM_FLAT, 21 | IDEAL_GAS_CONSTANT, 22 | F_CONST 23 | ) 24 | 25 | 26 | class TestArrheniusResponse: 27 | """Test Arrhenius temperature response function.""" 28 | 29 | def test_basic_calculation(self): 30 | """Test basic Arrhenius calculation.""" 31 | # At 25°C, should return exp(scaling) 32 | result = arrhenius_response(scaling=1.0, activation_energy=0, temperature_c=25.0) 33 | assert np.isclose(result, np.exp(1.0)) 34 | 35 | # Test with actual activation energy 36 | result = arrhenius_response(scaling=0, activation_energy=50, temperature_c=25.0) 37 | assert 0 < result < 1 38 | 39 | def test_temperature_array(self): 40 | """Test with array of temperatures.""" 41 | temps = np.array([15, 20, 25, 30, 35]) 42 | results = arrhenius_response(scaling=0, activation_energy=50, temperature_c=temps) 43 | 44 | # Should increase with temperature 45 | assert np.all(np.diff(results) > 0) 46 | assert results.shape == temps.shape 47 | 48 | def test_vcmax_temperature_response(self): 49 | """Test Vcmax temperature response using Bernacchi parameters.""" 50 | # Get Vcmax normalization at different temperatures 51 | temps = np.array([15, 20, 25, 30, 35]) 52 | vcmax_param = C3_TEMPERATURE_PARAM_BERNACCHI['Vcmax_norm'] 53 | 54 | results = arrhenius_response(vcmax_param.c, vcmax_param.Ea, temps) 55 | 56 | # At 25°C, normalization should be close to 1.0 57 | # Note: Vcmax uses c=26.35 (not c=Ea/f), so it's not exactly 1.0 58 | idx_25 = np.where(temps == 25)[0][0] 59 | assert np.isclose(results[idx_25], 0.9963, rtol=1e-3) 60 | 61 | # Should increase with temperature 62 | assert np.all(np.diff(results) > 0) 63 | 64 | 65 | class TestJohnsonResponse: 66 | """Test Johnson-Eyring-Williams temperature response function.""" 67 | 68 | def test_basic_calculation(self): 69 | """Test basic Johnson calculation.""" 70 | result = johnson_eyring_williams_response( 71 | scaling=20.01, 72 | activation_enthalpy=49.6, 73 | deactivation_enthalpy=437.4, 74 | entropy=1.4, 75 | temperature_c=25.0 76 | ) 77 | # At 25°C with Bernacchi gmc parameters, should be close to 1.0 78 | assert np.isclose(result, 1.0, rtol=0.01) 79 | 80 | def test_temperature_dependence(self): 81 | """Test temperature dependence shows optimum.""" 82 | temps = np.linspace(0, 50, 51) 83 | gmc_param = C3_TEMPERATURE_PARAM_BERNACCHI['gmc_norm'] 84 | 85 | results = johnson_eyring_williams_response( 86 | gmc_param.c, gmc_param.Ha, gmc_param.Hd, gmc_param.S, temps 87 | ) 88 | 89 | # Should have a maximum (optimum temperature) 90 | max_idx = np.argmax(results) 91 | assert 20 < temps[max_idx] < 40 # Optimum should be reasonable 92 | 93 | # Should decrease at very high temperatures 94 | assert results[-1] < results[max_idx] 95 | 96 | 97 | class TestGaussianResponse: 98 | """Test Gaussian temperature response function.""" 99 | 100 | def test_basic_calculation(self): 101 | """Test basic Gaussian calculation.""" 102 | # At optimum, should return optimum_rate 103 | result = gaussian_response( 104 | optimum_rate=100, 105 | t_opt=25, 106 | sigma=10, 107 | temperature_c=25 108 | ) 109 | assert result == 100 110 | 111 | # Away from optimum, should be less 112 | result = gaussian_response( 113 | optimum_rate=100, 114 | t_opt=25, 115 | sigma=10, 116 | temperature_c=35 117 | ) 118 | assert result < 100 119 | 120 | def test_symmetry(self): 121 | """Test Gaussian response is symmetric.""" 122 | optimum_rate = 100 123 | t_opt = 25 124 | sigma = 10 125 | 126 | # Same distance above and below optimum should give same result 127 | result_below = gaussian_response(optimum_rate, t_opt, sigma, 15) 128 | result_above = gaussian_response(optimum_rate, t_opt, sigma, 35) 129 | 130 | assert np.isclose(result_below, result_above) 131 | 132 | 133 | class TestPolynomialResponse: 134 | """Test polynomial temperature response function.""" 135 | 136 | def test_constant_polynomial(self): 137 | """Test constant (0th order) polynomial.""" 138 | result = polynomial_response(coefficients=42.0, temperature_c=25.0) 139 | assert result == 42.0 140 | 141 | # Should be same for any temperature 142 | temps = np.array([10, 20, 30]) 143 | results = polynomial_response(coefficients=42.0, temperature_c=temps) 144 | assert np.all(results == 42.0) 145 | 146 | def test_linear_polynomial(self): 147 | """Test linear polynomial.""" 148 | # y = 2 + 3*x 149 | coeffs = [2.0, 3.0] 150 | result = polynomial_response(coeffs, 10.0) 151 | assert result == 2.0 + 3.0 * 10.0 152 | 153 | def test_quadratic_polynomial(self): 154 | """Test quadratic polynomial.""" 155 | # y = 1 + 2*x + 3*x^2 156 | coeffs = [1.0, 2.0, 3.0] 157 | x = 5.0 158 | expected = 1.0 + 2.0 * x + 3.0 * x**2 159 | result = polynomial_response(coeffs, x) 160 | assert np.isclose(result, expected) 161 | 162 | 163 | class TestCalculateTemperatureResponse: 164 | """Test unified temperature response calculation.""" 165 | 166 | def test_arrhenius_type(self): 167 | """Test calculation with Arrhenius type.""" 168 | param = TemperatureParameter( 169 | type='arrhenius', 170 | c=26.35, 171 | Ea=65.33, 172 | units='normalized' 173 | ) 174 | result = calculate_temperature_response(param, 25.0) 175 | assert np.isclose(result, 1.0, rtol=0.01) 176 | 177 | def test_johnson_type(self): 178 | """Test calculation with Johnson type.""" 179 | param = TemperatureParameter( 180 | type='johnson', 181 | c=20.01, 182 | Ha=49.6, 183 | Hd=437.4, 184 | S=1.4, 185 | units='normalized' 186 | ) 187 | result = calculate_temperature_response(param, 25.0) 188 | assert np.isclose(result, 1.0, rtol=0.01) 189 | 190 | def test_gaussian_type(self): 191 | """Test calculation with Gaussian type.""" 192 | param = TemperatureParameter( 193 | type='gaussian', 194 | optimum_rate=100, 195 | t_opt=30, 196 | sigma=10, 197 | units='test units' 198 | ) 199 | result = calculate_temperature_response(param, 30.0) 200 | assert result == 100 201 | 202 | def test_polynomial_type(self): 203 | """Test calculation with polynomial type.""" 204 | param = TemperatureParameter( 205 | type='polynomial', 206 | coef=[10, 2], 207 | units='test units' 208 | ) 209 | result = calculate_temperature_response(param, 5.0) 210 | assert result == 10 + 2 * 5 211 | 212 | def test_invalid_type(self): 213 | """Test error with invalid type.""" 214 | param = TemperatureParameter( 215 | type='invalid', 216 | units='test' 217 | ) 218 | with pytest.raises(ValueError, match="Unknown temperature response type"): 219 | calculate_temperature_response(param, 25.0) 220 | 221 | def test_missing_parameters(self): 222 | """Test error with missing required parameters.""" 223 | # Missing Ea for Arrhenius 224 | param = TemperatureParameter( 225 | type='arrhenius', 226 | c=1.0, 227 | units='test' 228 | ) 229 | with pytest.raises(ValueError, match="Arrhenius parameters require"): 230 | calculate_temperature_response(param, 25.0) 231 | 232 | 233 | class TestApplyTemperatureResponse: 234 | """Test applying temperature responses to multiple parameters.""" 235 | 236 | def test_normalized_parameters(self): 237 | """Test application of normalized temperature responses.""" 238 | base_params = { 239 | 'Vcmax': 100.0, 240 | 'J': 200.0, 241 | 'RL': 2.0 242 | } 243 | 244 | temp_params = { 245 | 'Vcmax_norm': TemperatureParameter( 246 | type='arrhenius', c=26.35, Ea=65.33, units='normalized' 247 | ), 248 | 'J_norm': TemperatureParameter( 249 | type='arrhenius', c=17.57, Ea=43.5, units='normalized' 250 | ), 251 | 'RL_norm': TemperatureParameter( 252 | type='arrhenius', c=18.72, Ea=46.39, units='normalized' 253 | ) 254 | } 255 | 256 | # At 25°C, parameters should be close to base values 257 | # Note: Parameters don't normalize to exactly 1.0 due to their c values 258 | adjusted = apply_temperature_response(base_params, temp_params, 25.0) 259 | 260 | # Expected values based on actual normalization factors at 25°C 261 | assert np.isclose(adjusted['Vcmax'], 100.0 * 0.9963, rtol=1e-3) 262 | assert np.isclose(adjusted['J'], 200.0 * 1.0226, rtol=1e-3) 263 | assert np.isclose(adjusted['RL'], 2.0 * 1.0066, rtol=1e-3) 264 | 265 | # At higher temperature, values should increase 266 | adjusted_35 = apply_temperature_response(base_params, temp_params, 35.0) 267 | 268 | assert adjusted_35['Vcmax'] > adjusted['Vcmax'] 269 | assert adjusted_35['J'] > adjusted['J'] 270 | assert adjusted_35['RL'] > adjusted['RL'] 271 | 272 | def test_absolute_parameters(self): 273 | """Test application of absolute temperature responses.""" 274 | base_params = {} # No base values needed for absolute params 275 | 276 | temp_params = { 277 | 'Gamma_star_at_25': TemperatureParameter( 278 | type='polynomial', coef=42.93205, units='micromol mol^(-1)' 279 | ), 280 | 'Kc_at_25': TemperatureParameter( 281 | type='polynomial', coef=406.8494, units='micromol mol^(-1)' 282 | ) 283 | } 284 | 285 | adjusted = apply_temperature_response(base_params, temp_params, 25.0) 286 | 287 | assert 'Gamma_star' in adjusted 288 | assert 'Kc' in adjusted 289 | assert adjusted['Gamma_star'] == 42.93205 290 | assert adjusted['Kc'] == 406.8494 291 | 292 | def test_mixed_parameters(self): 293 | """Test mix of normalized and absolute parameters.""" 294 | base_params = {'Vcmax': 100.0} 295 | 296 | temp_params = { 297 | 'Vcmax_norm': TemperatureParameter( 298 | type='arrhenius', c=26.35, Ea=65.33, units='normalized' 299 | ), 300 | 'Gamma_star_at_25': TemperatureParameter( 301 | type='polynomial', coef=42.93205, units='micromol mol^(-1)' 302 | ) 303 | } 304 | 305 | adjusted = apply_temperature_response(base_params, temp_params, 30.0) 306 | 307 | assert 'Vcmax' in adjusted 308 | assert 'Gamma_star' in adjusted 309 | assert adjusted['Vcmax'] > 100.0 # Should increase with temperature 310 | assert adjusted['Gamma_star'] == 42.93205 # Constant polynomial 311 | 312 | 313 | class TestParameterSets: 314 | """Test predefined parameter sets.""" 315 | 316 | def test_bernacchi_parameters(self): 317 | """Test Bernacchi parameter set structure.""" 318 | assert 'Vcmax_norm' in C3_TEMPERATURE_PARAM_BERNACCHI 319 | assert 'Gamma_star_at_25' in C3_TEMPERATURE_PARAM_BERNACCHI 320 | 321 | vcmax_param = C3_TEMPERATURE_PARAM_BERNACCHI['Vcmax_norm'] 322 | assert vcmax_param.type == 'arrhenius' 323 | assert vcmax_param.Ea == 65.33 324 | 325 | gamma_param = C3_TEMPERATURE_PARAM_BERNACCHI['Gamma_star_at_25'] 326 | assert gamma_param.type == 'polynomial' 327 | assert gamma_param.coef == 42.93205 328 | 329 | def test_sharkey_parameters(self): 330 | """Test Sharkey parameter set structure.""" 331 | assert 'Vcmax_norm' in C3_TEMPERATURE_PARAM_SHARKEY 332 | 333 | # Should have different values than Bernacchi 334 | vcmax_bernacchi = C3_TEMPERATURE_PARAM_BERNACCHI['Gamma_star_norm'] 335 | vcmax_sharkey = C3_TEMPERATURE_PARAM_SHARKEY['Gamma_star_norm'] 336 | 337 | assert vcmax_bernacchi.Ea != vcmax_sharkey.Ea 338 | 339 | def test_flat_parameters(self): 340 | """Test flat (no temperature response) parameter set.""" 341 | # All normalized parameters should have Ea = 0 342 | for key, param in C3_TEMPERATURE_PARAM_FLAT.items(): 343 | if key.endswith('_norm'): 344 | assert param.Ea == 0 345 | assert param.c == 0 346 | 347 | def test_parameter_consistency(self): 348 | """Test consistency across parameter sets.""" 349 | # All sets should have the same parameter names 350 | bernacchi_keys = set(C3_TEMPERATURE_PARAM_BERNACCHI.keys()) 351 | sharkey_keys = set(C3_TEMPERATURE_PARAM_SHARKEY.keys()) 352 | flat_keys = set(C3_TEMPERATURE_PARAM_FLAT.keys()) 353 | 354 | # Sharkey has all Bernacchi parameters except Vomax_norm 355 | assert 'Vomax_norm' in bernacchi_keys 356 | assert 'Vomax_norm' not in sharkey_keys 357 | bernacchi_keys.remove('Vomax_norm') 358 | 359 | assert bernacchi_keys == sharkey_keys == flat_keys 360 | 361 | 362 | if __name__ == '__main__': 363 | pytest.main([__file__, '-v']) -------------------------------------------------------------------------------- /aci_py/tests/test_c3_calculations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for C3 photosynthesis calculations. 3 | 4 | Validates C3 assimilation calculations against expected values and 5 | ensures consistency with PhotoGEA R package. 6 | """ 7 | 8 | import numpy as np 9 | import pandas as pd 10 | import pytest 11 | from aci_py.core.data_structures import ExtendedDataFrame 12 | from aci_py.core.c3_calculations import ( 13 | calculate_c3_assimilation, 14 | identify_c3_limiting_process, 15 | C3AssimilationResult, 16 | _validate_c3_parameters 17 | ) 18 | from aci_py.core.temperature import C3_TEMPERATURE_PARAM_BERNACCHI 19 | 20 | 21 | class TestC3Calculations: 22 | """Test C3 assimilation calculations.""" 23 | 24 | @pytest.fixture 25 | def sample_data(self): 26 | """Create sample data for testing.""" 27 | # Create data at different CO2 levels 28 | ci_values = np.array([50, 100, 200, 300, 400, 600, 800, 1000, 1200]) 29 | 30 | df = pd.DataFrame({ 31 | 'Ci': ci_values, 32 | 'Cc': ci_values * 0.95, # Assume small gradient from Ci to Cc 33 | 'T_leaf_K': np.full_like(ci_values, 298.15), # 25°C 34 | 'Pa': np.full_like(ci_values, 101325.0), # 1 atm in Pa 35 | }) 36 | 37 | return ExtendedDataFrame( 38 | data=df, 39 | units={ 40 | 'Ci': 'micromol mol^(-1)', 41 | 'Cc': 'micromol mol^(-1)', 42 | 'T_leaf_K': 'K', 43 | 'Pa': 'Pa' 44 | } 45 | ) 46 | 47 | @pytest.fixture 48 | def typical_c3_params(self): 49 | """Typical C3 parameters for tobacco at 25°C.""" 50 | return { 51 | 'Vcmax_at_25': 100.0, # µmol m⁻² s⁻¹ 52 | 'J_at_25': 200.0, # µmol m⁻² s⁻¹ 53 | 'Tp_at_25': 12.0, # µmol m⁻² s⁻¹ 54 | 'RL_at_25': 1.5, # µmol m⁻² s⁻¹ 55 | 'Gamma_star_at_25': 42.93, # µmol mol⁻¹ 56 | 'Kc_at_25': 406.85, # µmol mol⁻¹ 57 | 'Ko_at_25': 277.14, # mmol mol⁻¹ 58 | } 59 | 60 | def test_basic_calculation(self, sample_data, typical_c3_params): 61 | """Test basic C3 assimilation calculation.""" 62 | result = calculate_c3_assimilation(sample_data, typical_c3_params) 63 | 64 | # Check result structure 65 | assert isinstance(result, C3AssimilationResult) 66 | assert hasattr(result, 'An') 67 | assert hasattr(result, 'Ac') 68 | assert hasattr(result, 'Aj') 69 | assert hasattr(result, 'Ap') 70 | 71 | # Check array shapes 72 | n_points = len(sample_data.data) 73 | assert len(result.An) == n_points 74 | assert len(result.Ac) == n_points 75 | assert len(result.Aj) == n_points 76 | assert len(result.Ap) == n_points 77 | 78 | # Check values are reasonable 79 | assert np.all(np.isfinite(result.An)) 80 | assert np.all(result.An[1:] > result.An[0]) # An should increase with Cc 81 | 82 | def test_rubisco_limitation(self, typical_c3_params): 83 | """Test Rubisco-limited region (low CO2).""" 84 | # Create data with very low CO2 where Rubisco should limit 85 | df = pd.DataFrame({ 86 | 'Cc': [50, 75, 100], 87 | 'T_leaf_K': [298.15, 298.15, 298.15], 88 | 'Pa': [101325.0, 101325.0, 101325.0] 89 | }) 90 | exdf = ExtendedDataFrame(df) 91 | 92 | result = calculate_c3_assimilation(exdf, typical_c3_params) 93 | limiting = identify_c3_limiting_process(result) 94 | 95 | # At low CO2, should be Rubisco-limited 96 | assert np.all(limiting == 'Rubisco') 97 | assert np.allclose(result.An, result.Ac) 98 | 99 | def test_rubp_limitation(self, typical_c3_params): 100 | """Test RuBP-limited region (intermediate CO2).""" 101 | # Create data with intermediate CO2 where RuBP should limit 102 | df = pd.DataFrame({ 103 | 'Cc': [200, 250, 300, 350, 400], 104 | 'T_leaf_K': [298.15, 298.15, 298.15, 298.15, 298.15], 105 | 'Pa': [101325.0, 101325.0, 101325.0, 101325.0, 101325.0] 106 | }) 107 | exdf = ExtendedDataFrame(df) 108 | 109 | # Adjust parameters to ensure RuBP limitation at these CO2 levels 110 | params = typical_c3_params.copy() 111 | params['J_at_25'] = 150.0 # Lower J to make RuBP limitation more likely 112 | 113 | result = calculate_c3_assimilation(exdf, params) 114 | limiting = identify_c3_limiting_process(result) 115 | 116 | # Should have at least some RuBP-limited points 117 | assert 'RuBP' in limiting 118 | # And RuBP limitation should occur at intermediate CO2 119 | rubp_idx = np.where(limiting == 'RuBP')[0] 120 | if len(rubp_idx) > 0: 121 | cc_at_rubp = df['Cc'].values[rubp_idx] 122 | assert np.any((cc_at_rubp > 150) & (cc_at_rubp < 500)) 123 | 124 | def test_tpu_limitation(self, typical_c3_params): 125 | """Test TPU-limited region (high CO2).""" 126 | # Create data with very high CO2 where TPU might limit 127 | df = pd.DataFrame({ 128 | 'Cc': [1000, 1200, 1500], 129 | 'T_leaf_K': [298.15, 298.15, 298.15], 130 | 'Pa': [101325.0, 101325.0, 101325.0] 131 | }) 132 | exdf = ExtendedDataFrame(df) 133 | 134 | # Reduce Tp to make TPU limitation more likely 135 | params = typical_c3_params.copy() 136 | params['Tp_at_25'] = 8.0 137 | 138 | result = calculate_c3_assimilation(exdf, params) 139 | 140 | # An should plateau at high CO2 if TPU-limited 141 | an_diff = np.diff(result.An) 142 | assert an_diff[-1] < an_diff[0] # Rate of increase should decline 143 | 144 | def test_temperature_response(self, typical_c3_params): 145 | """Test temperature effects on assimilation.""" 146 | # Create data at different temperatures 147 | temps_c = np.array([15, 20, 25, 30, 35]) 148 | df = pd.DataFrame({ 149 | 'Cc': np.full_like(temps_c, 300.0), 150 | 'T_leaf_K': temps_c + 273.15, 151 | 'Pa': np.full_like(temps_c, 101325.0) 152 | }) 153 | exdf = ExtendedDataFrame(df) 154 | 155 | result = calculate_c3_assimilation( 156 | exdf, typical_c3_params, 157 | temperature_response_params=C3_TEMPERATURE_PARAM_BERNACCHI 158 | ) 159 | 160 | # Check temperature-adjusted parameters increase with temperature 161 | assert np.all(np.diff(result.Vcmax_tl) > 0) 162 | assert np.all(np.diff(result.J_tl) > 0) 163 | 164 | # Assimilation should have an optimum 165 | # (not necessarily monotonic due to Gamma_star increase) 166 | assert result.An[2] > result.An[0] # Higher at 25°C than 15°C 167 | 168 | def test_fractionation_factors(self, sample_data, typical_c3_params): 169 | """Test different isotope fractionation scenarios.""" 170 | # Test with legacy alpha 171 | result_legacy = calculate_c3_assimilation( 172 | sample_data, typical_c3_params, 173 | alpha_old=0.01, use_legacy_alpha=True 174 | ) 175 | 176 | # Test with new fractionation factors 177 | result_new = calculate_c3_assimilation( 178 | sample_data, typical_c3_params, 179 | alpha_g=0.005, alpha_s=0.002, alpha_t=0.001 180 | ) 181 | 182 | # Results should be different but reasonable 183 | assert not np.allclose(result_legacy.An, result_new.An) 184 | assert np.all(np.isfinite(result_legacy.An)) 185 | assert np.all(np.isfinite(result_new.An)) 186 | 187 | def test_parameter_validation(self): 188 | """Test parameter validation.""" 189 | # Missing required parameter 190 | incomplete_params = { 191 | 'Vcmax_at_25': 100.0, 192 | 'J_at_25': 200.0, 193 | # Missing other required params 194 | } 195 | 196 | with pytest.raises(ValueError, match="Missing required parameters"): 197 | _validate_c3_parameters(incomplete_params, 0, 0, 0, 0, False, 4.0, 8.0) 198 | 199 | # Negative parameter value 200 | bad_params = { 201 | 'Vcmax_at_25': -100.0, # Negative! 202 | 'J_at_25': 200.0, 203 | 'Tp_at_25': 12.0, 204 | 'RL_at_25': 1.5, 205 | 'Gamma_star_at_25': 42.93, 206 | 'Kc_at_25': 406.85, 207 | 'Ko_at_25': 277.14, 208 | } 209 | 210 | with pytest.raises(ValueError, match="must be >= 0"): 211 | _validate_c3_parameters(bad_params, 0, 0, 0, 0, False, 4.0, 8.0) 212 | 213 | # Mixed alpha models 214 | good_params = { 215 | 'Vcmax_at_25': 100.0, 216 | 'J_at_25': 200.0, 217 | 'Tp_at_25': 12.0, 218 | 'RL_at_25': 1.5, 219 | 'Gamma_star_at_25': 42.93, 220 | 'Kc_at_25': 406.85, 221 | 'Ko_at_25': 277.14, 222 | } 223 | 224 | with pytest.raises(ValueError, match="Cannot use legacy alpha_old"): 225 | _validate_c3_parameters(good_params, 0.01, 0.01, 0, 0, True, 4.0, 8.0) 226 | 227 | def test_missing_columns(self, typical_c3_params): 228 | """Test error handling for missing columns.""" 229 | # Create data missing required columns 230 | df = pd.DataFrame({ 231 | 'Ci': [100, 200, 300] 232 | # Missing Cc, T_leaf_K, Pa 233 | }) 234 | exdf = ExtendedDataFrame(df) 235 | 236 | with pytest.raises(ValueError, match="Missing required columns"): 237 | calculate_c3_assimilation(exdf, typical_c3_params) 238 | 239 | def test_custom_column_names(self, typical_c3_params): 240 | """Test using custom column names.""" 241 | # Create data with non-standard column name 242 | df = pd.DataFrame({ 243 | 'Cc_mesophyll': [100, 200, 300], 244 | 'T_leaf_K': [298.15, 298.15, 298.15], 245 | 'Pa': [101325.0, 101325.0, 101325.0] 246 | }) 247 | exdf = ExtendedDataFrame(df) 248 | 249 | # Should work with custom column name 250 | result = calculate_c3_assimilation( 251 | exdf, typical_c3_params, 252 | cc_column_name='Cc_mesophyll' 253 | ) 254 | 255 | assert len(result.An) == 3 256 | assert np.all(np.isfinite(result.An)) 257 | 258 | def test_oxygen_concentration(self, sample_data, typical_c3_params): 259 | """Test effect of oxygen concentration.""" 260 | # Test with low oxygen (C4-like conditions) 261 | result_low_o2 = calculate_c3_assimilation( 262 | sample_data, typical_c3_params, 263 | oxygen=2.0 # 2% O2 264 | ) 265 | 266 | # Test with normal oxygen 267 | result_normal_o2 = calculate_c3_assimilation( 268 | sample_data, typical_c3_params, 269 | oxygen=21.0 # 21% O2 270 | ) 271 | 272 | # Low O2 should increase assimilation (less photorespiration) 273 | # But only where Rubisco limits - at high CO2, TPU may limit both 274 | rubisco_limited_idx = np.where(identify_c3_limiting_process(result_normal_o2) == 'Rubisco')[0] 275 | if len(rubisco_limited_idx) > 0: 276 | assert np.all(result_low_o2.An[rubisco_limited_idx] > result_normal_o2.An[rubisco_limited_idx]) 277 | 278 | def test_tpu_threshold(self, sample_data, typical_c3_params): 279 | """Test custom TPU threshold.""" 280 | # Set very high custom threshold 281 | result_high_threshold = calculate_c3_assimilation( 282 | sample_data, typical_c3_params, 283 | TPU_threshold=1000.0 # Very high threshold 284 | ) 285 | 286 | # Set low custom threshold 287 | result_low_threshold = calculate_c3_assimilation( 288 | sample_data, typical_c3_params, 289 | TPU_threshold=100.0 # Low threshold 290 | ) 291 | 292 | # High threshold should allow more TPU activity 293 | # Low threshold should limit TPU more 294 | # At points where PCc > low_threshold but < high_threshold, Wp should differ 295 | # Find points where thresholds might matter 296 | cc_values = sample_data.data['Cc'].values 297 | pressure = sample_data.data['Pa'].values / 100.0 298 | pcc_values = cc_values * pressure 299 | 300 | # Check if we have points in the affected range 301 | affected_range = (pcc_values > 100.0) & (pcc_values < 1000.0) 302 | if np.any(affected_range): 303 | # If we have points in the range where thresholds matter, Wp should differ 304 | assert not np.array_equal( 305 | result_high_threshold.Wp[affected_range], 306 | result_low_threshold.Wp[affected_range] 307 | ) 308 | 309 | 310 | class TestLimitingProcess: 311 | """Test limiting process identification.""" 312 | 313 | def test_identify_limiting_process(self): 314 | """Test identification of limiting processes.""" 315 | # Create mock result with clear limitations 316 | result = C3AssimilationResult( 317 | An=np.array([5, 10, 15, 20, 22, 23]), 318 | Ac=np.array([5, 12, 20, 25, 30, 35]), 319 | Aj=np.array([8, 10, 15, 20, 22, 25]), 320 | Ap=np.array([10, 15, 18, 22, 23, 23]), 321 | Wc=np.array([10, 20, 30, 40, 50, 60]), 322 | Wj=np.array([15, 18, 25, 35, 40, 45]), 323 | Wp=np.array([20, 25, 30, 40, 42, 42]), 324 | Vc=np.array([10, 18, 25, 35, 40, 42]), 325 | Vcmax_tl=np.array([100]*6), 326 | J_tl=np.array([200]*6), 327 | Tp_tl=np.array([12]*6), 328 | RL_tl=np.array([1.5]*6), 329 | Gamma_star_tl=np.array([42.93]*6), 330 | Kc_tl=np.array([406.85]*6), 331 | Ko_tl=np.array([277.14]*6), 332 | Gamma_star_agt=np.array([42.93]*6) 333 | ) 334 | 335 | limiting = identify_c3_limiting_process(result) 336 | 337 | # First point: Wc=10 < Wj=15 < Wp=20, so Rubisco-limited 338 | assert limiting[0] == 'Rubisco' 339 | 340 | # Check all values are valid 341 | assert all(l in ['Rubisco', 'RuBP', 'TPU'] for l in limiting) 342 | 343 | 344 | if __name__ == '__main__': 345 | pytest.main([__file__, '-v']) -------------------------------------------------------------------------------- /aci_py/core/temperature.py: -------------------------------------------------------------------------------- 1 | """ 2 | Temperature response functions for photosynthesis parameters. 3 | 4 | This module implements various temperature response functions used to adjust 5 | photosynthesis parameters based on leaf temperature. Includes Arrhenius, 6 | Johnson-Eyring-Williams, Gaussian, and polynomial response functions. 7 | 8 | Based on PhotoGEA R package implementations. 9 | """ 10 | 11 | import numpy as np 12 | from typing import Dict, Union, List, Optional 13 | from dataclasses import dataclass 14 | 15 | # Constants for temperature calculations 16 | IDEAL_GAS_CONSTANT = 8.3145e-3 # kJ / mol / K 17 | ABSOLUTE_ZERO = -273.15 # degrees C 18 | T_REF_K = 25.0 - ABSOLUTE_ZERO # Reference temperature (25°C) in Kelvin 19 | F_CONST = IDEAL_GAS_CONSTANT * T_REF_K # R * T_ref 20 | C_PA_TO_PPM = np.log(1e6 / 101325) 21 | 22 | 23 | @dataclass 24 | class TemperatureParameter: 25 | """Container for temperature response parameter data.""" 26 | type: str # 'arrhenius', 'gaussian', 'johnson', or 'polynomial' 27 | units: str 28 | # Arrhenius parameters 29 | c: Optional[float] = None 30 | Ea: Optional[float] = None # Activation energy (kJ/mol) 31 | # Johnson parameters 32 | Ha: Optional[float] = None # Activation enthalpy (kJ/mol) 33 | Hd: Optional[float] = None # Deactivation enthalpy (kJ/mol) 34 | S: Optional[float] = None # Entropy (kJ/K/mol) 35 | # Gaussian parameters 36 | optimum_rate: Optional[float] = None 37 | t_opt: Optional[float] = None # Optimum temperature (°C) 38 | sigma: Optional[float] = None # Standard deviation (°C) 39 | # Polynomial parameters 40 | coef: Optional[Union[float, List[float]]] = None 41 | 42 | 43 | def arrhenius_response( 44 | scaling: float, 45 | activation_energy: float, 46 | temperature_c: Union[float, np.ndarray] 47 | ) -> Union[float, np.ndarray]: 48 | """ 49 | Calculate Arrhenius temperature response. 50 | 51 | The Arrhenius equation describes the temperature dependence of reaction 52 | rates: 53 | 54 | response = exp(scaling - Ea / (R * T)) 55 | 56 | Args: 57 | scaling: Dimensionless scaling factor 58 | activation_energy: Activation energy (kJ/mol) 59 | temperature_c: Temperature in degrees Celsius 60 | 61 | Returns: 62 | Temperature response factor 63 | """ 64 | temperature_k = temperature_c - ABSOLUTE_ZERO 65 | return np.exp(scaling - activation_energy / (IDEAL_GAS_CONSTANT * temperature_k)) 66 | 67 | 68 | def johnson_eyring_williams_response( 69 | scaling: float, 70 | activation_enthalpy: float, 71 | deactivation_enthalpy: float, 72 | entropy: float, 73 | temperature_c: Union[float, np.ndarray] 74 | ) -> Union[float, np.ndarray]: 75 | """ 76 | Calculate Johnson-Eyring-Williams temperature response. 77 | 78 | This function describes temperature response with both activation and 79 | deactivation at high temperatures: 80 | 81 | response = arrhenius(c, Ha, T) / (1 + arrhenius(S/R, Hd, T)) 82 | 83 | Args: 84 | scaling: Dimensionless scaling factor 85 | activation_enthalpy: Activation enthalpy (kJ/mol) 86 | deactivation_enthalpy: Deactivation enthalpy (kJ/mol) 87 | entropy: Entropy term (kJ/K/mol) 88 | temperature_c: Temperature in degrees Celsius 89 | 90 | Returns: 91 | Temperature response factor 92 | """ 93 | top = arrhenius_response(scaling, activation_enthalpy, temperature_c) 94 | bot = 1.0 + arrhenius_response( 95 | entropy / IDEAL_GAS_CONSTANT, 96 | deactivation_enthalpy, 97 | temperature_c 98 | ) 99 | return top / bot 100 | 101 | 102 | def gaussian_response( 103 | optimum_rate: float, 104 | t_opt: float, 105 | sigma: float, 106 | temperature_c: Union[float, np.ndarray] 107 | ) -> Union[float, np.ndarray]: 108 | """ 109 | Calculate Gaussian temperature response. 110 | 111 | response = optimum_rate * exp(-(T - T_opt)^2 / sigma^2) 112 | 113 | Args: 114 | optimum_rate: Maximum rate at optimum temperature 115 | t_opt: Optimum temperature (°C) 116 | sigma: Standard deviation of response curve (°C) 117 | temperature_c: Temperature in degrees Celsius 118 | 119 | Returns: 120 | Temperature response in same units as optimum_rate 121 | """ 122 | return optimum_rate * np.exp(-(temperature_c - t_opt)**2 / sigma**2) 123 | 124 | 125 | def polynomial_response( 126 | coefficients: Union[float, List[float]], 127 | temperature_c: Union[float, np.ndarray] 128 | ) -> Union[float, np.ndarray]: 129 | """ 130 | Calculate polynomial temperature response. 131 | 132 | response = sum(coef[i] * T^i for i in range(len(coef))) 133 | 134 | Args: 135 | coefficients: Polynomial coefficients (constant term first) 136 | temperature_c: Temperature in degrees Celsius 137 | 138 | Returns: 139 | Polynomial value 140 | """ 141 | if isinstance(coefficients, (int, float)): 142 | coefficients = [coefficients] 143 | 144 | result = np.zeros_like(temperature_c, dtype=float) 145 | for i, coef in enumerate(coefficients): 146 | result += coef * temperature_c**i 147 | 148 | return result 149 | 150 | 151 | def calculate_temperature_response( 152 | parameter: TemperatureParameter, 153 | temperature_c: Union[float, np.ndarray] 154 | ) -> Union[float, np.ndarray]: 155 | """ 156 | Calculate temperature response for a single parameter. 157 | 158 | Args: 159 | parameter: Temperature parameter definition 160 | temperature_c: Temperature in degrees Celsius 161 | 162 | Returns: 163 | Temperature response value 164 | 165 | Raises: 166 | ValueError: If parameter type is unknown or required fields are missing 167 | """ 168 | param_type = parameter.type.lower() 169 | 170 | if param_type == 'arrhenius': 171 | if parameter.c is None or parameter.Ea is None: 172 | raise ValueError("Arrhenius parameters require 'c' and 'Ea' values") 173 | return arrhenius_response(parameter.c, parameter.Ea, temperature_c) 174 | 175 | elif param_type == 'johnson': 176 | if any(x is None for x in [parameter.c, parameter.Ha, parameter.Hd, parameter.S]): 177 | raise ValueError("Johnson parameters require 'c', 'Ha', 'Hd', and 'S' values") 178 | return johnson_eyring_williams_response( 179 | parameter.c, parameter.Ha, parameter.Hd, parameter.S, temperature_c 180 | ) 181 | 182 | elif param_type == 'gaussian': 183 | if any(x is None for x in [parameter.optimum_rate, parameter.t_opt, parameter.sigma]): 184 | raise ValueError("Gaussian parameters require 'optimum_rate', 't_opt', and 'sigma' values") 185 | return gaussian_response( 186 | parameter.optimum_rate, parameter.t_opt, parameter.sigma, temperature_c 187 | ) 188 | 189 | elif param_type == 'polynomial': 190 | if parameter.coef is None: 191 | raise ValueError("Polynomial parameters require 'coef' value(s)") 192 | return polynomial_response(parameter.coef, temperature_c) 193 | 194 | else: 195 | raise ValueError( 196 | f"Unknown temperature response type: '{param_type}'. " 197 | "Supported types are: arrhenius, johnson, gaussian, polynomial" 198 | ) 199 | 200 | 201 | def apply_temperature_response( 202 | parameters: Dict[str, float], 203 | temperature_params: Dict[str, TemperatureParameter], 204 | temperature_c: Union[float, np.ndarray] 205 | ) -> Dict[str, Union[float, np.ndarray]]: 206 | """ 207 | Apply temperature responses to multiple parameters. 208 | 209 | Args: 210 | parameters: Base parameter values at 25°C 211 | temperature_params: Temperature response definitions for each parameter 212 | temperature_c: Leaf temperature in degrees Celsius 213 | 214 | Returns: 215 | Dictionary of temperature-adjusted parameter values 216 | """ 217 | adjusted = {} 218 | 219 | for param_name, param_def in temperature_params.items(): 220 | temp_response = calculate_temperature_response(param_def, temperature_c) 221 | 222 | # Handle normalized vs absolute parameters 223 | if param_name.endswith('_norm'): 224 | # This is a normalized response, multiply by base value 225 | base_param = param_name.replace('_norm', '') 226 | if base_param in parameters: 227 | adjusted[base_param] = parameters[base_param] * temp_response 228 | else: 229 | # Store the normalization factor itself 230 | adjusted[param_name] = temp_response 231 | elif param_name.endswith('_at_25'): 232 | # This is an absolute value at 25°C, use directly 233 | base_param = param_name.replace('_at_25', '') 234 | adjusted[base_param] = temp_response 235 | else: 236 | # Direct temperature-dependent parameter 237 | adjusted[param_name] = temp_response 238 | 239 | return adjusted 240 | 241 | 242 | # Predefined parameter sets 243 | C3_TEMPERATURE_PARAM_BERNACCHI = { 244 | 'Gamma_star_norm': TemperatureParameter( 245 | type='arrhenius', c=37.83/F_CONST, Ea=37.83, 246 | units='normalized to Gamma_star at 25 degrees C' 247 | ), 248 | 'J_norm': TemperatureParameter( 249 | type='arrhenius', c=17.57, Ea=43.5, 250 | units='normalized to J at 25 degrees C' 251 | ), 252 | 'Kc_norm': TemperatureParameter( 253 | type='arrhenius', c=79.43/F_CONST, Ea=79.43, 254 | units='normalized to Kc at 25 degrees C' 255 | ), 256 | 'Ko_norm': TemperatureParameter( 257 | type='arrhenius', c=36.38/F_CONST, Ea=36.38, 258 | units='normalized to Ko at 25 degrees C' 259 | ), 260 | 'RL_norm': TemperatureParameter( 261 | type='arrhenius', c=18.72, Ea=46.39, 262 | units='normalized to RL at 25 degrees C' 263 | ), 264 | 'Vcmax_norm': TemperatureParameter( 265 | type='arrhenius', c=26.35, Ea=65.33, 266 | units='normalized to Vcmax at 25 degrees C' 267 | ), 268 | 'Vomax_norm': TemperatureParameter( 269 | type='arrhenius', c=22.98, Ea=60.11, 270 | units='normalized to Vomax at 25 degrees C' 271 | ), 272 | 'gmc_norm': TemperatureParameter( 273 | type='johnson', c=20.01, Ha=49.6, Hd=437.4, S=1.4, 274 | units='normalized to gmc at 25 degrees C' 275 | ), 276 | 'Tp_norm': TemperatureParameter( 277 | type='johnson', c=21.46, Ha=53.1, Hd=201.8, S=0.65, 278 | units='normalized to Tp at 25 degrees C' 279 | ), 280 | 'Gamma_star_at_25': TemperatureParameter( 281 | type='polynomial', coef=42.93205, 282 | units='micromol mol^(-1)' 283 | ), 284 | 'Kc_at_25': TemperatureParameter( 285 | type='polynomial', coef=406.8494, 286 | units='micromol mol^(-1)' 287 | ), 288 | 'Ko_at_25': TemperatureParameter( 289 | type='polynomial', coef=277.1446, 290 | units='mmol mol^(-1)' 291 | ), 292 | } 293 | 294 | C3_TEMPERATURE_PARAM_SHARKEY = { 295 | 'Gamma_star_norm': TemperatureParameter( 296 | type='arrhenius', c=24.46/F_CONST, Ea=24.46, 297 | units='normalized to Gamma_star at 25 degrees C' 298 | ), 299 | 'J_norm': TemperatureParameter( 300 | type='arrhenius', c=17.71, Ea=43.9, 301 | units='normalized to J at 25 degrees C' 302 | ), 303 | 'Kc_norm': TemperatureParameter( 304 | type='arrhenius', c=80.99/F_CONST, Ea=80.99, 305 | units='normalized to Kc at 25 degrees C' 306 | ), 307 | 'Ko_norm': TemperatureParameter( 308 | type='arrhenius', c=23.72/F_CONST, Ea=23.72, 309 | units='normalized to Ko at 25 degrees C' 310 | ), 311 | 'RL_norm': TemperatureParameter( 312 | type='arrhenius', c=18.7145, Ea=46.39, 313 | units='normalized to RL at 25 degrees C' 314 | ), 315 | 'Vcmax_norm': TemperatureParameter( 316 | type='arrhenius', c=26.355, Ea=65.33, 317 | units='normalized to Vcmax at 25 degrees C' 318 | ), 319 | 'gmc_norm': TemperatureParameter( 320 | type='johnson', c=20.01, Ha=49.6, Hd=437.4, S=1.4, 321 | units='normalized to gmc at 25 degrees C' 322 | ), 323 | 'Tp_norm': TemperatureParameter( 324 | type='johnson', c=21.46, Ha=53.1, Hd=201.8, S=0.65, 325 | units='normalized to Tp at 25 degrees C' 326 | ), 327 | 'Gamma_star_at_25': TemperatureParameter( 328 | type='polynomial', coef=36.94438, 329 | units='micromol mol^(-1)' 330 | ), 331 | 'Kc_at_25': TemperatureParameter( 332 | type='polynomial', coef=269.3391, 333 | units='micromol mol^(-1)' 334 | ), 335 | 'Ko_at_25': TemperatureParameter( 336 | type='polynomial', coef=163.7146, 337 | units='mmol mol^(-1)' 338 | ), 339 | } 340 | 341 | C3_TEMPERATURE_PARAM_FLAT = { 342 | 'Gamma_star_norm': TemperatureParameter( 343 | type='arrhenius', c=0, Ea=0, 344 | units='normalized to Gamma_star at 25 degrees C' 345 | ), 346 | 'gmc_norm': TemperatureParameter( 347 | type='arrhenius', c=0, Ea=0, 348 | units='normalized to gmc at 25 degrees C' 349 | ), 350 | 'J_norm': TemperatureParameter( 351 | type='arrhenius', c=0, Ea=0, 352 | units='normalized to J at 25 degrees C' 353 | ), 354 | 'Kc_norm': TemperatureParameter( 355 | type='arrhenius', c=0, Ea=0, 356 | units='normalized to Kc at 25 degrees C' 357 | ), 358 | 'Ko_norm': TemperatureParameter( 359 | type='arrhenius', c=0, Ea=0, 360 | units='normalized to Ko at 25 degrees C' 361 | ), 362 | 'RL_norm': TemperatureParameter( 363 | type='arrhenius', c=0, Ea=0, 364 | units='normalized to RL at 25 degrees C' 365 | ), 366 | 'Tp_norm': TemperatureParameter( 367 | type='arrhenius', c=0, Ea=0, 368 | units='normalized to Tp at 25 degrees C' 369 | ), 370 | 'Vcmax_norm': TemperatureParameter( 371 | type='arrhenius', c=0, Ea=0, 372 | units='normalized to Vcmax at 25 degrees C' 373 | ), 374 | 'Gamma_star_at_25': TemperatureParameter( 375 | type='polynomial', coef=36.94438, 376 | units='micromol mol^(-1)' 377 | ), 378 | 'Kc_at_25': TemperatureParameter( 379 | type='polynomial', coef=269.3391, 380 | units='micromol mol^(-1)' 381 | ), 382 | 'Ko_at_25': TemperatureParameter( 383 | type='polynomial', coef=163.7146, 384 | units='mmol mol^(-1)' 385 | ), 386 | } -------------------------------------------------------------------------------- /aci_py/analysis/batch.py: -------------------------------------------------------------------------------- 1 | 2 | import pandas as pd 3 | import numpy as np 4 | from typing import Dict, List, Optional, Union, Callable, Any, Tuple 5 | from concurrent.futures import ProcessPoolExecutor, as_completed 6 | from tqdm import tqdm 7 | import warnings 8 | from pathlib import Path 9 | 10 | from ..core.data_structures import ExtendedDataFrame 11 | from ..io.licor import read_licor_file 12 | from .c3_fitting import fit_c3_aci 13 | from .c4_fitting import fit_c4_aci 14 | from .optimization import FittingResult 15 | 16 | 17 | class BatchResult: 18 | """Container for batch processing results.""" 19 | 20 | def __init__(self): 21 | self.results: Dict[str, FittingResult] = {} 22 | self.summary_df: Optional[pd.DataFrame] = None 23 | self.failed_curves: List[str] = [] 24 | self.warnings: Dict[str, List[str]] = {} 25 | 26 | def add_result(self, curve_id: str, result: FittingResult): 27 | """ 28 | Add a fitting result for a curve. 29 | 30 | Args: 31 | curve_id: Unique identifier for the curve 32 | result: FittingResult object from fit_c3_aci or fit_c4_aci 33 | """ 34 | self.results[curve_id] = result 35 | 36 | def add_failure(self, curve_id: str, error_msg: str): 37 | """ 38 | Record a failed curve with error message. 39 | 40 | Args: 41 | curve_id: Unique identifier for the curve 42 | error_msg: Description of the failure 43 | 44 | Note: 45 | Failed curves are tracked separately and included in summary 46 | with success=False flag. 47 | """ 48 | self.failed_curves.append(curve_id) 49 | if curve_id not in self.warnings: 50 | self.warnings[curve_id] = [] 51 | self.warnings[curve_id].append(f"Fitting failed: {error_msg}") 52 | 53 | def add_warning(self, curve_id: str, warning_msg: str): 54 | """ 55 | Add a warning for a curve without marking it as failed. 56 | 57 | Args: 58 | curve_id: Unique identifier for the curve 59 | warning_msg: Warning message (e.g., "Low R-squared", "Convergence issues") 60 | 61 | Note: 62 | Warnings don't prevent a curve from being marked as successful, 63 | but are tracked for quality control purposes. 64 | """ 65 | if curve_id not in self.warnings: 66 | self.warnings[curve_id] = [] 67 | self.warnings[curve_id].append(warning_msg) 68 | 69 | def generate_summary(self): 70 | """Generate summary DataFrame from all results.""" 71 | summary_data = [] 72 | 73 | for curve_id, result in self.results.items(): 74 | row = {'curve_id': curve_id} 75 | 76 | # Add parameter values 77 | for param, value in result.parameters.items(): 78 | row[param] = value 79 | 80 | # Add confidence intervals if available 81 | if result.confidence_intervals: 82 | for param, (lower, upper) in result.confidence_intervals.items(): 83 | row[f'{param}_CI_lower'] = lower 84 | row[f'{param}_CI_upper'] = upper 85 | 86 | # Add fit statistics 87 | row['rmse'] = result.rmse 88 | row['r_squared'] = result.r_squared 89 | row['n_points'] = result.n_points 90 | # Check if optimization was successful 91 | # Result objects from fit_c3_aci and fit_c4_aci have 'success' attribute 92 | row['success'] = getattr(result, 'success', True) 93 | 94 | summary_data.append(row) 95 | 96 | # Add failed curves 97 | for curve_id in self.failed_curves: 98 | row = {'curve_id': curve_id, 'success': False} 99 | summary_data.append(row) 100 | 101 | self.summary_df = pd.DataFrame(summary_data) 102 | return self.summary_df 103 | 104 | 105 | def process_single_curve( 106 | curve_data: Union[ExtendedDataFrame, pd.DataFrame], 107 | curve_id: str, 108 | fit_function: Callable, 109 | fit_kwargs: Dict[str, Any] 110 | ) -> Tuple[str, Union[FittingResult, Exception]]: 111 | """ 112 | Process a single ACI curve. 113 | 114 | This function is designed to be used with parallel processing. 115 | 116 | Args: 117 | curve_data: Data for a single curve 118 | curve_id: Identifier for the curve 119 | fit_function: Function to use for fitting (fit_c3_aci or fit_c4_aci) 120 | fit_kwargs: Keyword arguments for the fitting function 121 | 122 | Returns: 123 | Tuple of (curve_id, result or exception) 124 | 125 | Raises: 126 | No exceptions are raised directly; all exceptions are caught and returned 127 | in the result tuple to support parallel processing error handling 128 | """ 129 | try: 130 | # Convert to ExtendedDataFrame if needed 131 | if isinstance(curve_data, pd.DataFrame): 132 | exdf = ExtendedDataFrame(curve_data) 133 | else: 134 | exdf = curve_data 135 | 136 | # Fit the curve 137 | result = fit_function(exdf, **fit_kwargs) 138 | return curve_id, result 139 | 140 | except Exception as e: 141 | return curve_id, e 142 | 143 | 144 | def batch_fit_aci( 145 | data: Union[str, pd.DataFrame, ExtendedDataFrame, Dict[str, Union[pd.DataFrame, ExtendedDataFrame]]], 146 | model_type: str = 'C3', 147 | groupby: Optional[List[str]] = None, 148 | n_jobs: int = 1, 149 | progress_bar: bool = True, 150 | **fit_kwargs 151 | ) -> BatchResult: 152 | """ 153 | Fit multiple ACI curves in batch. 154 | 155 | This function can process: 156 | 1. A file path containing multiple curves 157 | 2. A DataFrame with multiple curves (use groupby to separate) 158 | 3. A dictionary of DataFrames/ExtendedDataFrames 159 | 160 | Args: 161 | data: Input data in various formats 162 | model_type: 'C3' or 'C4' photosynthesis model 163 | groupby: Column names to group by (for DataFrame input) 164 | n_jobs: Number of parallel jobs (-1 for all CPUs) 165 | progress_bar: Show progress bar 166 | **fit_kwargs: Additional arguments passed to fit function 167 | 168 | Returns: 169 | BatchResult object containing all fitting results 170 | 171 | Examples: 172 | # From file with grouping 173 | results = batch_fit_aci('data.csv', groupby=['Genotype', 'Plant']) 174 | 175 | # From dictionary of curves 176 | curves = {'curve1': df1, 'curve2': df2} 177 | results = batch_fit_aci(curves, n_jobs=4) 178 | 179 | # With specific fitting options 180 | results = batch_fit_aci(data, fixed_parameters={'gmc': 0.5}) 181 | """ 182 | # Select fitting function 183 | if model_type.upper() == 'C3': 184 | fit_function = fit_c3_aci 185 | elif model_type.upper() == 'C4': 186 | fit_function = fit_c4_aci 187 | else: 188 | raise ValueError(f"Unknown model type: {model_type}") 189 | 190 | # Prepare data dictionary 191 | curves_dict = {} 192 | 193 | if isinstance(data, str): 194 | # Load from file 195 | if groupby: 196 | curves_dict = read_licor_file(data, groupby=groupby) 197 | else: 198 | # Single curve in file 199 | curves_dict = {'curve_1': read_licor_file(data)} 200 | 201 | elif isinstance(data, (pd.DataFrame, ExtendedDataFrame)): 202 | # DataFrame input 203 | if groupby: 204 | # Group the data 205 | df = data.data if isinstance(data, ExtendedDataFrame) else data 206 | grouped = df.groupby(groupby) 207 | 208 | for name, group in grouped: 209 | # Create curve ID from group names 210 | if isinstance(name, tuple): 211 | curve_id = "_".join(str(n) for n in name) 212 | else: 213 | curve_id = str(name) 214 | curves_dict[curve_id] = group.reset_index(drop=True) 215 | else: 216 | # Single curve 217 | curves_dict = {'curve_1': data} 218 | 219 | elif isinstance(data, dict): 220 | # Already a dictionary 221 | curves_dict = data 222 | 223 | else: 224 | raise ValueError("Data must be a file path, DataFrame, or dictionary") 225 | 226 | # Initialize results 227 | batch_result = BatchResult() 228 | 229 | # Determine number of jobs 230 | if n_jobs == -1: 231 | import multiprocessing 232 | n_jobs = multiprocessing.cpu_count() 233 | 234 | # Process curves 235 | if n_jobs == 1: 236 | # Sequential processing 237 | iterator = curves_dict.items() 238 | if progress_bar: 239 | iterator = tqdm(iterator, desc="Fitting curves", total=len(curves_dict)) 240 | 241 | for curve_id, curve_data in iterator: 242 | _, result = process_single_curve(curve_data, curve_id, fit_function, fit_kwargs) 243 | 244 | if isinstance(result, Exception): 245 | batch_result.add_failure(curve_id, str(result)) 246 | else: 247 | batch_result.add_result(curve_id, result) 248 | if hasattr(result, 'success') and not result.success: 249 | batch_result.add_warning(curve_id, "Optimization did not converge") 250 | else: 251 | # Parallel processing 252 | with ProcessPoolExecutor(max_workers=n_jobs) as executor: 253 | # Submit all tasks 254 | futures = { 255 | executor.submit(process_single_curve, curve_data, curve_id, fit_function, fit_kwargs): curve_id 256 | for curve_id, curve_data in curves_dict.items() 257 | } 258 | 259 | # Process completed tasks 260 | iterator = as_completed(futures) 261 | if progress_bar: 262 | iterator = tqdm(iterator, desc="Fitting curves", total=len(futures)) 263 | 264 | for future in iterator: 265 | curve_id = futures[future] 266 | try: 267 | _, result = future.result() 268 | 269 | if isinstance(result, Exception): 270 | batch_result.add_failure(curve_id, str(result)) 271 | else: 272 | batch_result.add_result(curve_id, result) 273 | if hasattr(result, 'success') and not result.success: 274 | batch_result.add_warning(curve_id, "Optimization did not converge") 275 | 276 | except Exception as e: 277 | batch_result.add_failure(curve_id, str(e)) 278 | 279 | # Generate summary 280 | batch_result.generate_summary() 281 | 282 | # Print summary statistics 283 | n_total = len(curves_dict) 284 | n_success = len(batch_result.results) 285 | n_failed = len(batch_result.failed_curves) 286 | 287 | print(f"\nBatch fitting complete:") 288 | print(f" Total curves: {n_total}") 289 | print(f" Successful: {n_success}") 290 | print(f" Failed: {n_failed}") 291 | 292 | if batch_result.warnings: 293 | print(f" Curves with warnings: {len(batch_result.warnings)}") 294 | 295 | return batch_result 296 | 297 | 298 | def compare_models( 299 | data: Union[pd.DataFrame, ExtendedDataFrame], 300 | models: List[str] = ['C3', 'C4'], 301 | **fit_kwargs 302 | ) -> Dict[str, FittingResult]: 303 | """ 304 | Compare different photosynthesis models on the same data. 305 | 306 | Args: 307 | data: ACI curve data 308 | models: List of model types to compare 309 | **fit_kwargs: Additional arguments for fitting 310 | 311 | Returns: 312 | Dictionary mapping model type to fitting results 313 | """ 314 | results = {} 315 | 316 | for model in models: 317 | if model.upper() == 'C3': 318 | results[model] = fit_c3_aci(data, **fit_kwargs) 319 | elif model.upper() == 'C4': 320 | results[model] = fit_c4_aci(data, **fit_kwargs) 321 | else: 322 | warnings.warn(f"Unknown model type: {model}") 323 | 324 | return results 325 | 326 | 327 | def analyze_parameter_variability( 328 | batch_result: BatchResult, 329 | parameters: Optional[List[str]] = None 330 | ) -> pd.DataFrame: 331 | """ 332 | Analyze parameter variability across batch results. 333 | 334 | Args: 335 | batch_result: Results from batch_fit_aci 336 | parameters: List of parameters to analyze (None for all) 337 | 338 | Returns: 339 | DataFrame with parameter statistics including mean, std, CV%, min, max, and n 340 | 341 | Example: 342 | >>> results = batch_fit_aci('data.csv', groupby=['Genotype']) 343 | >>> stats = analyze_parameter_variability(results, ['Vcmax_at_25', 'J_at_25']) 344 | >>> print(stats) 345 | # parameter mean std cv min max n_curves 346 | # 0 Vcmax_at_25 95.3 8.2 8.6% 82.1 108.5 12 347 | # 1 J_at_25 185.7 15.3 8.2% 162.3 210.1 12 348 | """ 349 | if batch_result.summary_df is None: 350 | batch_result.generate_summary() 351 | 352 | df = batch_result.summary_df[batch_result.summary_df['success'] == True].copy() 353 | 354 | if parameters is None: 355 | # Get all parameter columns (exclude metadata and CI columns) 356 | exclude_cols = ['curve_id', 'rmse', 'r_squared', 'n_points', 'success'] 357 | parameters = [col for col in df.columns 358 | if col not in exclude_cols and not col.endswith('_CI_lower') 359 | and not col.endswith('_CI_upper')] 360 | 361 | stats_data = [] 362 | for param in parameters: 363 | if param in df.columns: 364 | param_data = df[param].dropna() 365 | 366 | stats = { 367 | 'parameter': param, 368 | 'mean': param_data.mean(), 369 | 'std': param_data.std(), 370 | 'cv': param_data.std() / param_data.mean() * 100 if param_data.mean() != 0 else np.nan, 371 | 'min': param_data.min(), 372 | 'max': param_data.max(), 373 | 'n_curves': len(param_data) 374 | } 375 | stats_data.append(stats) 376 | 377 | return pd.DataFrame(stats_data) -------------------------------------------------------------------------------- /aci_py/analysis/plotting.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import matplotlib.pyplot as plt 3 | import seaborn as sns 4 | from typing import Dict, Optional, List, Tuple, Union 5 | import pandas as pd 6 | 7 | from ..core.data_structures import ExtendedDataFrame 8 | from .c3_fitting import C3FitResult 9 | 10 | 11 | def setup_plot_style(): 12 | try: 13 | plt.style.use('seaborn-v0_8-whitegrid') 14 | except: 15 | try: 16 | plt.style.use('seaborn-whitegrid') 17 | except: 18 | pass 19 | 20 | sns.set_palette("husl") 21 | plt.rcParams['figure.dpi'] = 100 22 | plt.rcParams['savefig.dpi'] = 300 23 | plt.rcParams['font.size'] = 12 24 | plt.rcParams['axes.labelsize'] = 14 25 | plt.rcParams['axes.titlesize'] = 16 26 | plt.rcParams['axes.labelweight'] = 'bold' 27 | plt.rcParams['axes.titleweight'] = 'bold' 28 | plt.rcParams['xtick.labelsize'] = 12 29 | plt.rcParams['ytick.labelsize'] = 12 30 | plt.rcParams['legend.fontsize'] = 12 31 | plt.rcParams['axes.linewidth'] = 1.5 32 | plt.rcParams['axes.edgecolor'] = 'black' 33 | 34 | 35 | def plot_aci_curve( 36 | exdf: ExtendedDataFrame, 37 | ci_column: str = 'Ci', 38 | a_column: str = 'A', 39 | title: Optional[str] = None, 40 | xlabel: Optional[str] = None, 41 | ylabel: Optional[str] = None, 42 | fig_size: Tuple[float, float] = (8, 6), 43 | color: str = 'black', 44 | marker: str = 'o', 45 | markersize: float = 8, 46 | alpha: float = 0.8, 47 | ax: Optional[plt.Axes] = None 48 | ) -> plt.Axes: 49 | 50 | if ax is None: 51 | fig, ax = plt.subplots(figsize=fig_size) 52 | 53 | # Extract data 54 | ci = exdf.data[ci_column].values 55 | a = exdf.data[a_column].values 56 | 57 | # Plot points 58 | ax.scatter(ci, a, color=color, marker=marker, s=markersize**2, 59 | alpha=alpha, label='Observed', zorder=3) 60 | 61 | # Set labels with italic formatting for parameters 62 | if xlabel is None: 63 | xlabel = f"$\\mathit{{{ci_column}}}$" # Italic parameter 64 | if ci_column in exdf.units: 65 | xlabel += f" ({exdf.units[ci_column]})" 66 | if ylabel is None: 67 | ylabel = f"$\\mathit{{{a_column}}}$" # Italic parameter 68 | if a_column in exdf.units: 69 | ylabel += f" ({exdf.units[a_column]})" 70 | 71 | ax.set_xlabel(xlabel) 72 | ax.set_ylabel(ylabel) 73 | 74 | # Remove title setting - no titles per request 75 | # if title: 76 | # ax.set_title(title) 77 | 78 | ax.grid(True, alpha=0.3) 79 | 80 | return ax 81 | 82 | 83 | def plot_c3_fit( 84 | exdf: ExtendedDataFrame, 85 | fit_result: C3FitResult, 86 | ci_column: str = 'Ci', 87 | a_column: str = 'A', 88 | title: Optional[str] = None, 89 | show_limiting_processes: bool = True, 90 | show_confidence_intervals: bool = True, 91 | show_parameters: bool = True, 92 | show_residuals: bool = True, 93 | fig_size: Tuple[float, float] = (10, 10), 94 | save_path: Optional[str] = None 95 | ) -> plt.Figure: 96 | """ 97 | Create comprehensive plot of C3 A-Ci curve fit. 98 | 99 | Args: 100 | exdf: ExtendedDataFrame with original data 101 | fit_result: C3FitResult object 102 | ci_column: Column name for Ci values 103 | a_column: Column name for A values 104 | title: Plot title 105 | show_limiting_processes: Color-code by limiting process 106 | show_confidence_intervals: Show parameter confidence intervals 107 | show_parameters: Show fitted parameter values 108 | show_residuals: Include residual subplot 109 | fig_size: Figure size 110 | save_path: Path to save figure (optional) 111 | 112 | Returns: 113 | Matplotlib figure object 114 | """ 115 | setup_plot_style() 116 | 117 | # Create figure with subplots 118 | if show_residuals: 119 | fig, (ax1, ax2) = plt.subplots(2, 1, figsize=fig_size, 120 | gridspec_kw={'height_ratios': [3, 1]}) 121 | else: 122 | fig, ax1 = plt.subplots(1, 1, figsize=(fig_size[0], fig_size[1] * 0.6)) 123 | 124 | # Extract data 125 | ci = exdf.data[ci_column].values 126 | a_obs = exdf.data[a_column].values 127 | a_fit = fit_result.fitted_A 128 | 129 | # Sort by Ci for smooth lines 130 | sort_idx = np.argsort(ci) 131 | ci_sorted = ci[sort_idx] 132 | a_obs_sorted = a_obs[sort_idx] 133 | a_fit_sorted = a_fit[sort_idx] 134 | 135 | # Plot observed data 136 | ax1.scatter(ci, a_obs, color='black', s=64, alpha=0.7, 137 | label='Observed', zorder=3, edgecolors='black', linewidth=0.5) 138 | 139 | if show_limiting_processes and hasattr(fit_result, 'limiting_process'): 140 | # Plot fitted curve colored by limiting process 141 | limiting_sorted = fit_result.limiting_process[sort_idx] 142 | 143 | colors = {'Wc': '#1f77b4', 'Wj': '#2ca02c', 'Wp': '#d62728'} 144 | labels = {'Wc': 'Rubisco-limited', 'Wj': 'RuBP-limited', 'Wp': 'TPU-limited'} 145 | 146 | # Plot each limiting region 147 | for process in ['Wc', 'Wj', 'Wp']: 148 | mask = limiting_sorted == process 149 | if np.any(mask): 150 | # Find continuous segments 151 | diff = np.diff(np.concatenate(([False], mask, [False])).astype(int)) 152 | starts = np.where(diff == 1)[0] 153 | ends = np.where(diff == -1)[0] 154 | 155 | for start, end in zip(starts, ends): 156 | label = labels[process] if start == starts[0] else None 157 | ax1.plot(ci_sorted[start:end], a_fit_sorted[start:end], 158 | color=colors[process], linewidth=3, label=label) 159 | else: 160 | # Simple fitted line 161 | ax1.plot(ci_sorted, a_fit_sorted, 'r-', linewidth=2, label='Fitted') 162 | 163 | # Labels and formatting with italic parameters 164 | xlabel = f"$\\mathit{{{ci_column}}}$" # Italic Ci 165 | if ci_column in exdf.units: 166 | xlabel += f" ({exdf.units[ci_column]})" 167 | ylabel = f"$\\mathit{{{a_column}}}$" # Italic A 168 | if a_column in exdf.units: 169 | ylabel += f" ({exdf.units[a_column]})" 170 | 171 | ax1.set_xlabel(xlabel) 172 | ax1.set_ylabel(ylabel) 173 | 174 | # No title per request 175 | # if title: 176 | # ax1.set_title(title) 177 | 178 | ax1.legend(loc='lower right') 179 | ax1.grid(True, alpha=0.3) 180 | 181 | # Add parameter text box 182 | if show_parameters: 183 | param_text = _format_parameters_text(fit_result, show_confidence_intervals) 184 | ax1.text(0.05, 0.95, param_text, transform=ax1.transAxes, 185 | verticalalignment='top', horizontalalignment='left', 186 | bbox=dict(boxstyle='round,pad=0.5', facecolor='white', 187 | edgecolor='gray', alpha=0.9)) 188 | 189 | # Residual plot 190 | if show_residuals: 191 | residuals_sorted = fit_result.residuals[sort_idx] 192 | ax2.scatter(ci_sorted, residuals_sorted, color='black', s=36, alpha=0.7) 193 | ax2.axhline(y=0, color='red', linestyle='--', alpha=0.5) 194 | ax2.set_xlabel(xlabel) 195 | ax2.set_ylabel('Residuals') 196 | ax2.grid(True, alpha=0.3) 197 | 198 | # Add RMSE text 199 | ax2.text(0.95, 0.95, f"RMSE = {fit_result.rmse:.2f}", 200 | transform=ax2.transAxes, 201 | verticalalignment='top', horizontalalignment='right', 202 | bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8)) 203 | 204 | plt.tight_layout() 205 | 206 | # Save if requested 207 | if save_path: 208 | fig.savefig(save_path, dpi=300, bbox_inches='tight') 209 | 210 | return fig 211 | 212 | 213 | def plot_parameter_distributions( 214 | results: List[C3FitResult], 215 | parameters: Optional[List[str]] = None, 216 | group_labels: Optional[List[str]] = None, 217 | fig_size: Tuple[float, float] = (12, 8), 218 | save_path: Optional[str] = None 219 | ) -> plt.Figure: 220 | """ 221 | Plot distributions of fitted parameters across multiple curves. 222 | 223 | Args: 224 | results: List of C3FitResult objects 225 | parameters: Parameters to plot (default: all) 226 | group_labels: Labels for each result 227 | fig_size: Figure size 228 | save_path: Path to save figure 229 | 230 | Returns: 231 | Matplotlib figure object 232 | """ 233 | setup_plot_style() 234 | 235 | # Determine parameters to plot 236 | if parameters is None: 237 | parameters = ['Vcmax_at_25', 'J_at_25', 'Tp_at_25', 'RL_at_25'] 238 | parameters = [p for p in parameters if p in results[0].parameters] 239 | 240 | n_params = len(parameters) 241 | n_cols = min(2, n_params) 242 | n_rows = (n_params + n_cols - 1) // n_cols 243 | 244 | fig, axes = plt.subplots(n_rows, n_cols, figsize=fig_size) 245 | if n_params == 1: 246 | axes = [axes] 247 | else: 248 | axes = axes.flatten() 249 | 250 | for idx, param in enumerate(parameters): 251 | ax = axes[idx] 252 | 253 | # Extract parameter values 254 | values = [r.parameters[param] for r in results if param in r.parameters] 255 | 256 | # Create DataFrame for easier plotting 257 | if group_labels: 258 | df = pd.DataFrame({ 259 | 'Value': values, 260 | 'Group': group_labels[:len(values)] 261 | }) 262 | 263 | # Box plot by group 264 | df.boxplot(column='Value', by='Group', ax=ax) 265 | ax.set_title(param) 266 | ax.set_xlabel('Group') 267 | else: 268 | # Histogram 269 | ax.hist(values, bins=20, edgecolor='black', alpha=0.7) 270 | ax.set_xlabel(param) 271 | ax.set_ylabel('Count') 272 | 273 | # Add statistics 274 | mean_val = np.mean(values) 275 | std_val = np.std(values) 276 | ax.axvline(mean_val, color='red', linestyle='--', 277 | label=f'Mean = {mean_val:.1f}') 278 | ax.text(0.95, 0.95, f'SD = {std_val:.1f}', 279 | transform=ax.transAxes, 280 | verticalalignment='top', horizontalalignment='right') 281 | 282 | ax.grid(True, alpha=0.3) 283 | 284 | # Hide extra subplots 285 | for idx in range(n_params, len(axes)): 286 | axes[idx].set_visible(False) 287 | 288 | plt.tight_layout() 289 | 290 | if save_path: 291 | fig.savefig(save_path, dpi=300, bbox_inches='tight') 292 | 293 | return fig 294 | 295 | 296 | def plot_limiting_process_analysis( 297 | exdf: ExtendedDataFrame, 298 | fit_result: C3FitResult, 299 | ci_column: str = 'Ci', 300 | fig_size: Tuple[float, float] = (10, 6), 301 | save_path: Optional[str] = None 302 | ) -> plt.Figure: 303 | """ 304 | Create detailed plot showing limiting processes and component rates. 305 | 306 | Args: 307 | exdf: ExtendedDataFrame with data 308 | fit_result: C3FitResult object 309 | ci_column: Column name for Ci 310 | fig_size: Figure size 311 | save_path: Path to save figure 312 | 313 | Returns: 314 | Matplotlib figure object 315 | """ 316 | setup_plot_style() 317 | 318 | fig, ax = plt.subplots(figsize=fig_size) 319 | 320 | # Get Ci values 321 | ci = exdf.data[ci_column].values 322 | sort_idx = np.argsort(ci) 323 | ci_sorted = ci[sort_idx] 324 | 325 | # Calculate component rates if available 326 | if hasattr(fit_result, 'temperature_adjusted_params'): 327 | # These would need to be calculated - placeholder for now 328 | a_obs = exdf.data['A'].values[sort_idx] 329 | a_fit = fit_result.fitted_A[sort_idx] 330 | 331 | ax.scatter(ci, exdf.data['A'].values, color='black', s=64, 332 | alpha=0.7, label='Observed', zorder=5) 333 | ax.plot(ci_sorted, a_fit, 'k-', linewidth=2, label='Net assimilation') 334 | 335 | # Add limiting regions as background colors 336 | if hasattr(fit_result, 'limiting_process'): 337 | limiting_sorted = fit_result.limiting_process[sort_idx] 338 | 339 | colors = {'Wc': '#1f77b4', 'Wj': '#2ca02c', 'Wp': '#d62728'} 340 | 341 | for process in ['Wc', 'Wj', 'Wp']: 342 | mask = limiting_sorted == process 343 | if np.any(mask): 344 | # Find continuous segments 345 | diff = np.diff(np.concatenate(([False], mask, [False])).astype(int)) 346 | starts = np.where(diff == 1)[0] 347 | ends = np.where(diff == -1)[0] 348 | 349 | for start, end in zip(starts, ends): 350 | ax.axvspan(ci_sorted[start], ci_sorted[end-1], 351 | alpha=0.2, color=colors[process]) 352 | 353 | ax.set_xlabel(f"$\\mathit{{{ci_column}}}$ ({exdf.units.get(ci_column, '')})") 354 | ax.set_ylabel(f"$\\mathit{{A}}$ ({exdf.units.get('A', '')})") 355 | # No title per request 356 | # ax.set_title("Limiting Process Analysis") 357 | ax.legend() 358 | ax.grid(True, alpha=0.3) 359 | 360 | plt.tight_layout() 361 | 362 | if save_path: 363 | fig.savefig(save_path, dpi=300, bbox_inches='tight') 364 | 365 | return fig 366 | 367 | 368 | def _format_parameters_text( 369 | fit_result: C3FitResult, 370 | show_confidence_intervals: bool = False 371 | ) -> str: 372 | """Format parameter values for display.""" 373 | lines = [] 374 | 375 | # Main parameters with italic formatting 376 | param_formats = { 377 | 'Vcmax_at_25': ('$V_{cmax}$', '.1f'), 378 | 'J_at_25': ('$J_{max}$', '.1f'), 379 | 'Tp_at_25': ('$T_p$', '.1f'), 380 | 'RL_at_25': ('$R_L$', '.2f'), 381 | 'gmc': ('$g_{mc}$', '.2f') 382 | } 383 | 384 | for param, (display_name, fmt) in param_formats.items(): 385 | if param in fit_result.parameters: 386 | value = fit_result.parameters[param] 387 | line = f"{display_name} = {value:{fmt}}" 388 | 389 | # Add confidence interval if available 390 | if show_confidence_intervals and fit_result.confidence_intervals: 391 | if param in fit_result.confidence_intervals: 392 | ci_lower, ci_upper = fit_result.confidence_intervals[param] 393 | line += f" [{ci_lower:{fmt}}, {ci_upper:{fmt}}]" 394 | 395 | lines.append(line) 396 | 397 | # Add statistics 398 | lines.append("") 399 | lines.append(f"R² = {fit_result.r_squared:.3f}") 400 | lines.append(f"RMSE = {fit_result.rmse:.2f}") 401 | 402 | if not np.isnan(fit_result.aic): 403 | lines.append(f"AIC = {fit_result.aic:.1f}") 404 | 405 | return '\n'.join(lines) -------------------------------------------------------------------------------- /aci_py/core/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base classes and interfaces for photosynthesis models. 3 | 4 | This module provides abstract base classes for implementing different 5 | photosynthesis models (C3, C4, CAM, etc.). 6 | """ 7 | 8 | from abc import ABC, abstractmethod 9 | from typing import Dict, Tuple, Optional, Union, List 10 | import numpy as np 11 | from aci_py.core.data_structures import ExtendedDataFrame 12 | 13 | 14 | class PhotosynthesisModel(ABC): 15 | """ 16 | Abstract base class for photosynthesis models. 17 | 18 | All photosynthesis models should inherit from this class and implement 19 | the required abstract methods. 20 | """ 21 | 22 | def __init__(self, name: str): 23 | """ 24 | Initialize the model. 25 | 26 | Args: 27 | name: Model name (e.g., "C3", "C4") 28 | """ 29 | self.name = name 30 | self._parameter_names: List[str] = [] 31 | self._parameter_bounds: Dict[str, Tuple[float, float]] = {} 32 | self._parameter_units: Dict[str, str] = {} 33 | 34 | @abstractmethod 35 | def calculate_assimilation( 36 | self, 37 | parameters: Dict[str, float], 38 | exdf: ExtendedDataFrame, 39 | return_diagnostics: bool = False 40 | ) -> Union[np.ndarray, Tuple[np.ndarray, Dict]]: 41 | """ 42 | Calculate assimilation rate given parameters and environmental data. 43 | 44 | Args: 45 | parameters: Dictionary of model parameters 46 | exdf: ExtendedDataFrame containing environmental data 47 | return_diagnostics: If True, return additional diagnostic information 48 | 49 | Returns: 50 | If return_diagnostics=False: Array of calculated assimilation rates 51 | If return_diagnostics=True: Tuple of (assimilation rates, diagnostics dict) 52 | """ 53 | pass 54 | 55 | @abstractmethod 56 | def get_parameter_bounds(self) -> Dict[str, Tuple[float, float]]: 57 | """ 58 | Get parameter bounds for optimization. 59 | 60 | Returns: 61 | Dictionary mapping parameter names to (lower, upper) bounds 62 | """ 63 | pass 64 | 65 | @abstractmethod 66 | def get_default_parameters(self, species: str = "tobacco") -> Dict[str, float]: 67 | """ 68 | Get default parameter values for a given species. 69 | 70 | Args: 71 | species: Species name (e.g., "tobacco", "soybean") 72 | 73 | Returns: 74 | Dictionary of default parameter values 75 | """ 76 | pass 77 | 78 | @abstractmethod 79 | def initial_guess( 80 | self, 81 | exdf: ExtendedDataFrame, 82 | fixed_parameters: Optional[Dict[str, float]] = None 83 | ) -> Dict[str, float]: 84 | """ 85 | Generate initial parameter guesses from data. 86 | 87 | Args: 88 | exdf: ExtendedDataFrame containing measurement data 89 | fixed_parameters: Parameters to fix (not estimate) 90 | 91 | Returns: 92 | Dictionary of initial parameter values 93 | """ 94 | pass 95 | 96 | @abstractmethod 97 | def validate_parameters(self, parameters: Dict[str, float]) -> Tuple[bool, str]: 98 | """ 99 | Validate parameter values for reasonableness. 100 | 101 | Args: 102 | parameters: Dictionary of parameter values to validate 103 | 104 | Returns: 105 | Tuple of (is_valid, error_message) 106 | """ 107 | pass 108 | 109 | def get_required_columns(self) -> List[str]: 110 | """ 111 | Get list of required data columns for this model. 112 | 113 | Returns: 114 | List of required column names 115 | """ 116 | # Base requirements common to all models 117 | return ["A", "Ci", "Tleaf", "Pa"] 118 | 119 | def get_parameter_units(self) -> Dict[str, str]: 120 | """ 121 | Get units for all parameters. 122 | 123 | Returns: 124 | Dictionary mapping parameter names to unit strings 125 | """ 126 | return self._parameter_units.copy() 127 | 128 | 129 | class C3Model(PhotosynthesisModel): 130 | """ 131 | C3 photosynthesis model (Farquhar-von Caemmerer-Berry). 132 | 133 | This is a placeholder - full implementation will follow. 134 | """ 135 | 136 | def __init__(self): 137 | super().__init__("C3") 138 | 139 | # Define C3 parameter names and bounds 140 | self._parameter_names = ["Vcmax", "J", "Tp", "Rd", "gm"] 141 | self._parameter_bounds = { 142 | "Vcmax": (0.0, 1000.0), # µmol m⁻² s⁻¹ 143 | "J": (0.0, 1000.0), # µmol m⁻² s⁻¹ 144 | "Tp": (0.0, 100.0), # µmol m⁻² s⁻¹ 145 | "Rd": (0.0, 50.0), # µmol m⁻² s⁻¹ 146 | "gm": (0.0, 10.0), # mol m⁻² s⁻¹ bar⁻¹ 147 | } 148 | self._parameter_units = { 149 | "Vcmax": "µmol m⁻² s⁻¹", 150 | "J": "µmol m⁻² s⁻¹", 151 | "Tp": "µmol m⁻² s⁻¹", 152 | "Rd": "µmol m⁻² s⁻¹", 153 | "gm": "mol m⁻² s⁻¹ bar⁻¹", 154 | } 155 | 156 | def calculate_assimilation( 157 | self, 158 | parameters: Dict[str, float], 159 | exdf: ExtendedDataFrame, 160 | return_diagnostics: bool = False 161 | ) -> Union[np.ndarray, Tuple[np.ndarray, Dict]]: 162 | """Calculate C3 assimilation using FvCB model.""" 163 | from aci_py.core.c3_calculations import calculate_c3_assimilation, identify_c3_limiting_process 164 | 165 | # Calculate assimilation 166 | result = calculate_c3_assimilation(exdf, parameters) 167 | 168 | if return_diagnostics: 169 | # Return assimilation and diagnostic info 170 | diagnostics = { 171 | 'Ac': result.Ac, 172 | 'Aj': result.Aj, 173 | 'Ap': result.Ap, 174 | 'Vcmax_tl': result.Vcmax_tl, 175 | 'J_tl': result.J_tl, 176 | 'Tp_tl': result.Tp_tl, 177 | 'RL_tl': result.RL_tl, 178 | 'limiting_process': identify_c3_limiting_process(result) 179 | } 180 | return result.An, diagnostics 181 | else: 182 | return result.An 183 | 184 | def get_parameter_bounds(self) -> Dict[str, Tuple[float, float]]: 185 | """Get C3 parameter bounds.""" 186 | return self._parameter_bounds.copy() 187 | 188 | def get_default_parameters(self, species: str = "tobacco") -> Dict[str, float]: 189 | """Get default C3 parameters for species.""" 190 | # Default tobacco parameters at 25°C 191 | if species == "tobacco": 192 | return { 193 | "Vcmax": 100.0, 194 | "J": 200.0, 195 | "Tp": 12.0, 196 | "Rd": 1.5, 197 | "gm": 0.5, 198 | } 199 | else: 200 | # Generic defaults 201 | return { 202 | "Vcmax": 80.0, 203 | "J": 160.0, 204 | "Tp": 10.0, 205 | "Rd": 1.0, 206 | "gm": 0.4, 207 | } 208 | 209 | def initial_guess( 210 | self, 211 | exdf: ExtendedDataFrame, 212 | fixed_parameters: Optional[Dict[str, float]] = None 213 | ) -> Dict[str, float]: 214 | """Generate initial C3 parameter guesses.""" 215 | from aci_py.analysis.initial_guess import estimate_c3_initial_parameters 216 | 217 | # Get initial estimates from data 218 | initial_params = estimate_c3_initial_parameters(exdf) 219 | 220 | # Override with any fixed parameters 221 | if fixed_parameters: 222 | initial_params.update(fixed_parameters) 223 | 224 | return initial_params 225 | 226 | def validate_parameters(self, parameters: Dict[str, float]) -> Tuple[bool, str]: 227 | """Validate C3 parameters.""" 228 | # Check all required parameters are present 229 | for param in self._parameter_names: 230 | if param not in parameters: 231 | return False, f"Missing required parameter: {param}" 232 | 233 | # Check bounds 234 | for param, value in parameters.items(): 235 | if param in self._parameter_bounds: 236 | lower, upper = self._parameter_bounds[param] 237 | if not lower <= value <= upper: 238 | return False, f"{param} = {value} is outside bounds [{lower}, {upper}]" 239 | 240 | # Check parameter relationships 241 | if parameters["J"] < parameters["Vcmax"]: 242 | return False, "J should typically be greater than Vcmax" 243 | 244 | return True, "" 245 | 246 | 247 | class C4Model(PhotosynthesisModel): 248 | """ 249 | C4 photosynthesis model (von Caemmerer). 250 | 251 | This is a placeholder - full implementation will follow. 252 | """ 253 | 254 | def __init__(self): 255 | super().__init__("C4") 256 | 257 | # Define C4 parameter names and bounds 258 | self._parameter_names = ["Vcmax", "Vpmax", "J", "Rd", "gbs", "Rm_frac"] 259 | self._parameter_bounds = { 260 | "Vcmax": (0.0, 200.0), # µmol m⁻² s⁻¹ 261 | "Vpmax": (0.0, 400.0), # µmol m⁻² s⁻¹ 262 | "J": (0.0, 1000.0), # µmol m⁻² s⁻¹ 263 | "Rd": (0.0, 10.0), # µmol m⁻² s⁻¹ 264 | "gbs": (0.0, 100.0), # mmol m⁻² s⁻¹ 265 | "Rm_frac": (0.0, 1.0), # dimensionless 266 | } 267 | self._parameter_units = { 268 | "Vcmax": "µmol m⁻² s⁻¹", 269 | "Vpmax": "µmol m⁻² s⁻¹", 270 | "J": "µmol m⁻² s⁻¹", 271 | "Rd": "µmol m⁻² s⁻¹", 272 | "gbs": "mmol m⁻² s⁻¹", 273 | "Rm_frac": "dimensionless", 274 | } 275 | 276 | def calculate_assimilation( 277 | self, 278 | parameters: Dict[str, float], 279 | exdf: ExtendedDataFrame, 280 | return_diagnostics: bool = False 281 | ) -> Union[np.ndarray, Tuple[np.ndarray, Dict]]: 282 | """Calculate C4 assimilation using von Caemmerer model.""" 283 | from aci_py.core.c4_calculations import calculate_c4_assimilation 284 | 285 | # Map parameters to expected names 286 | mapped_params = { 287 | 'Vcmax_at_25': parameters.get('Vcmax', parameters.get('Vcmax_at_25', 50)), 288 | 'Vpmax_at_25': parameters.get('Vpmax', parameters.get('Vpmax_at_25', 150)), 289 | 'J_at_25': parameters.get('J', parameters.get('J_at_25', 400)), 290 | 'RL_at_25': parameters.get('Rd', parameters.get('RL_at_25', 1.0)), 291 | 'gbs': parameters.get('gbs', 0.003), 292 | 'Rm_frac': parameters.get('Rm_frac', 0.5), 293 | 'Vpr': parameters.get('Vpr', parameters.get('Vpmax', 150) * 0.8) # Default to 80% of Vpmax 294 | } 295 | 296 | # Calculate assimilation 297 | result = calculate_c4_assimilation( 298 | exdf, 299 | **mapped_params, 300 | return_extended=return_diagnostics 301 | ) 302 | 303 | if return_diagnostics: 304 | # For extended output, result is an ExtendedDataFrame 305 | An = result.data['An'].values 306 | diagnostics = { 307 | 'Ac': result.data['Ac'].values, 308 | 'Aj': result.data['Aj'].values, 309 | 'Vpc': result.data['Vpc'].values if 'Vpc' in result.data else None, 310 | 'Vp': result.data['Vp'].values if 'Vp' in result.data else None, 311 | 'limiting_process': result.data['C4_limiting_process'].values if 'C4_limiting_process' in result.data else None 312 | } 313 | return An, diagnostics 314 | else: 315 | # For simple output, result is just the An array 316 | return result 317 | 318 | def get_parameter_bounds(self) -> Dict[str, Tuple[float, float]]: 319 | """Get C4 parameter bounds.""" 320 | return self._parameter_bounds.copy() 321 | 322 | def get_default_parameters(self, species: str = "maize") -> Dict[str, float]: 323 | """Get default C4 parameters for species.""" 324 | # Default maize parameters at 25°C 325 | if species == "maize": 326 | return { 327 | "Vcmax": 50.0, 328 | "Vpmax": 120.0, 329 | "J": 400.0, 330 | "Rd": 2.0, 331 | "gbs": 3.0, 332 | "Rm_frac": 0.5, 333 | } 334 | else: 335 | # Generic defaults 336 | return { 337 | "Vcmax": 40.0, 338 | "Vpmax": 100.0, 339 | "J": 300.0, 340 | "Rd": 1.5, 341 | "gbs": 2.5, 342 | "Rm_frac": 0.5, 343 | } 344 | 345 | def initial_guess( 346 | self, 347 | exdf: ExtendedDataFrame, 348 | fixed_parameters: Optional[Dict[str, float]] = None 349 | ) -> Dict[str, float]: 350 | """Generate initial C4 parameter guesses.""" 351 | from aci_py.analysis.c4_fitting import initial_guess_c4_aci 352 | 353 | # Get initial estimates from data 354 | initial_params = initial_guess_c4_aci(exdf) 355 | 356 | # Map to our parameter names 357 | mapped_params = { 358 | 'Vcmax': initial_params.get('Vcmax_at_25', 50), 359 | 'Vpmax': initial_params.get('Vpmax_at_25', 150), 360 | 'J': initial_params.get('J_at_25', 400), 361 | 'Rd': initial_params.get('RL_at_25', 1.0), 362 | 'gbs': 3.0, # Default value 363 | 'Rm_frac': 0.5 # Default value 364 | } 365 | 366 | # Add Vpr if present 367 | if 'Vpr' in initial_params: 368 | mapped_params['Vpr'] = initial_params['Vpr'] 369 | 370 | # Override with any fixed parameters 371 | if fixed_parameters: 372 | mapped_params.update(fixed_parameters) 373 | 374 | return mapped_params 375 | 376 | def validate_parameters(self, parameters: Dict[str, float]) -> Tuple[bool, str]: 377 | """Validate C4 parameters.""" 378 | # Check all required parameters are present 379 | for param in self._parameter_names: 380 | if param not in parameters: 381 | return False, f"Missing required parameter: {param}" 382 | 383 | # Check bounds 384 | for param, value in parameters.items(): 385 | if param in self._parameter_bounds: 386 | lower, upper = self._parameter_bounds[param] 387 | if not lower <= value <= upper: 388 | return False, f"{param} = {value} is outside bounds [{lower}, {upper}]" 389 | 390 | # Check parameter relationships 391 | if parameters["Vpmax"] < parameters["Vcmax"]: 392 | return False, "Vpmax should typically be greater than Vcmax" 393 | 394 | return True, "" --------------------------------------------------------------------------------