Download src/models/canopy_photosynthesis.py from SolarWine/api: direct link, hf CLI and curl.
- Browser
- Download file 5.71 kB
-
https://huggingface.co/spaces/SolarWine/api/resolve/main/src/models/canopy_photosynthesis.py
- Command line
-
hf download hf://spaces/SolarWine/api/src/models/canopy_photosynthesis.py
-
curl -L -o canopy_photosynthesis.py https://huggingface.co/spaces/SolarWine/api/resolve/main/src/models/canopy_photosynthesis.py
5.71 kB
| """ | |
| CanopyPhotosynthesisModel: integrate shadow geometry with Farquhar model | |
| to compute vine-level photosynthesis from zone-level PAR distribution. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import pandas as pd | |
| from config.settings import FRUITING_ZONE_INDEX | |
| from src.farquhar_model import FarquharModel | |
| from src.solar_geometry import ShadowModel | |
| class CanopyPhotosynthesisModel: | |
| """Compute vine-level A by running Farquhar on each canopy zone.""" | |
| def __init__( | |
| self, | |
| shadow_model: ShadowModel | None = None, | |
| farquhar_model: FarquharModel | None = None, | |
| lai: float = 2.5, | |
| shade_temp_offset: float = -1.5, | |
| diffuse_fraction: float = 0.15, | |
| ): | |
| self.shadow = shadow_model or ShadowModel() | |
| self.farquhar = farquhar_model or FarquharModel() | |
| self.lai = lai | |
| self.shade_temp_offset = shade_temp_offset | |
| self.diffuse_fraction = diffuse_fraction | |
| # Zone weights from LAI distribution (bottom to top) | |
| nv = self.shadow.n_vertical | |
| nh = self.shadow.n_horizontal | |
| # Distribute LAI weights across zones | |
| vert_weights = self.shadow.lai_weights # shape (n_vertical,) | |
| # Each horizontal zone within a row gets equal share | |
| self._zone_weights = np.outer(vert_weights, np.ones(nh) / nh) | |
| # Normalize so total = 1 | |
| self._zone_weights /= self._zone_weights.sum() | |
| def compute_vine_A( | |
| self, | |
| par: float, | |
| Tleaf: float, | |
| CO2: float, | |
| VPD: float, | |
| Tair: float, | |
| shadow_mask: np.ndarray, | |
| solar_elevation: float | None = None, | |
| solar_azimuth: float | None = None, | |
| tracker_tilt: float | None = None, | |
| ) -> dict: | |
| """ | |
| Compute vine-level A for a single timestep. | |
| Returns dict with: | |
| A_vine: weighted vine-level A (umol CO2 m-2 s-1) | |
| A_zones: array of A per zone (n_vertical x n_horizontal) | |
| sunlit_fraction: fraction of zones in sun | |
| par_zones: PAR per zone | |
| """ | |
| par_zones = self.shadow.compute_par_distribution( | |
| par, shadow_mask, self.diffuse_fraction, | |
| solar_elevation=solar_elevation, solar_azimuth=solar_azimuth, | |
| tracker_tilt=tracker_tilt, | |
| ) | |
| A_zones = np.zeros_like(par_zones) | |
| for iz in range(self.shadow.n_vertical): | |
| for ix in range(self.shadow.n_horizontal): | |
| zone_par = par_zones[iz, ix] | |
| # Shaded zones are slightly cooler | |
| zone_tleaf = Tleaf + (self.shade_temp_offset if shadow_mask[iz, ix] else 0.0) | |
| zone_tair = Tair + (self.shade_temp_offset * 0.5 if shadow_mask[iz, ix] else 0.0) | |
| if zone_par > 0: | |
| A_zones[iz, ix] = self.farquhar.calc_photosynthesis( | |
| PAR=zone_par, Tleaf=zone_tleaf, CO2=CO2, | |
| VPD=VPD, Tair=zone_tair, | |
| ) | |
| A_vine = float(np.sum(A_zones * self._zone_weights)) * self.lai | |
| sunlit_frac = self.shadow.sunlit_fraction(shadow_mask) | |
| # Extract fruiting zone (zone 1) and top canopy (zone 2) summaries | |
| fz = FRUITING_ZONE_INDEX # default 1 | |
| top = min(self.shadow.n_vertical - 1, 2) # zone 2 = apical | |
| fruiting_zone_A = float(A_zones[fz, :].mean()) if A_zones.shape[0] > fz else 0.0 | |
| fruiting_zone_par = float(par_zones[fz, :].mean()) if par_zones.shape[0] > fz else 0.0 | |
| top_canopy_A = float(A_zones[top, :].mean()) if A_zones.shape[0] > top else 0.0 | |
| top_canopy_par = float(par_zones[top, :].mean()) if par_zones.shape[0] > top else 0.0 | |
| return { | |
| "A_vine": A_vine, | |
| "A_zones": A_zones, | |
| "sunlit_fraction": sunlit_frac, | |
| "par_zones": par_zones, | |
| "fruiting_zone_A": fruiting_zone_A, | |
| "fruiting_zone_par": fruiting_zone_par, | |
| "top_canopy_A": top_canopy_A, | |
| "top_canopy_par": top_canopy_par, | |
| } | |
| def compute_timeseries( | |
| self, | |
| df: pd.DataFrame, | |
| shadow_masks: np.ndarray, | |
| par_col: str = "Air1_PAR_ref", | |
| tleaf_col: str = "Air1_leafTemperature_ref", | |
| co2_col: str = "Air1_CO2_ref", | |
| vpd_col: str = "Air1_VPD_ref", | |
| tair_col: str = "Air1_airTemperature_ref", | |
| ) -> pd.DataFrame: | |
| """ | |
| Compute vine-level A for each row in df using pre-computed shadow masks. | |
| shadow_masks: array of shape (len(df), n_vertical, n_horizontal). | |
| """ | |
| records = [] | |
| for i, (_, row) in enumerate(df.iterrows()): | |
| par = float(row[par_col]) if pd.notna(row[par_col]) else 0.0 | |
| tleaf = float(row[tleaf_col]) if pd.notna(row[tleaf_col]) else 25.0 | |
| co2 = float(row[co2_col]) if pd.notna(row[co2_col]) else 400.0 | |
| vpd = float(row[vpd_col]) if pd.notna(row[vpd_col]) else 1.5 | |
| tair = float(row[tair_col]) if pd.notna(row[tair_col]) else 25.0 | |
| mask = shadow_masks[i] | |
| result = self.compute_vine_A(par, tleaf, co2, vpd, tair, mask) | |
| # Also compute reference (no panel = no shadow) | |
| no_shadow = np.zeros_like(mask, dtype=bool) | |
| ref_result = self.compute_vine_A(par, tleaf, co2, vpd, tair, no_shadow) | |
| records.append({ | |
| "A_vine_panel": result["A_vine"], | |
| "A_vine_ref": ref_result["A_vine"], | |
| "sunlit_fraction": result["sunlit_fraction"], | |
| "par_mean_panel": result["par_zones"].mean(), | |
| "par_mean_ref": ref_result["par_zones"].mean(), | |
| }) | |
| return pd.DataFrame(records, index=df.index) | |