class GasExchange(System): weather = system(alias='w') soil = system() leaf = system(PhotosyntheticLeaf, weather='weather', soil='soil') @constant def name(self): return '' @derive def A_gross(self): return self.leaf.A_gross @derive def A_net(self): return self.leaf.A_net @derive def ET(self): return self.leaf.ET @derive def T_leaf(self): return self.leaf.temperature @derive def VPD(self): #TODO: use Weather directly, instead of through PhotosyntheticLeaf return self.weather.VPD @derive def gs(self): return self.leaf.stomata.stomatal_conductance
class NodalUnit(Organ): rank = constant() leaf = system(Leaf, plant='self.plant', nodal_unit='self') sheath = system(Sheath, plant='self.plant', nodal_unit='self') @derive def mass(self): return self.leaf.mass + self.sheath.mass
class Sheath(Organ): nodal_unit = system() @derive def rank(self): return self.nodal_unit.rank @derive def mass(self): #FIXME sheath biomass return 0
class GasExchange(System): #TODO: use externally initialized Weather / Soil @system(alias='w') def weather(self): return Weather @system def soil(self): return Soil # @system(weather='weather', soil='soil') # def leaf(self): # return PhotosyntheticLeaf leaf = system(PhotosyntheticLeaf, weather='weather', soil='soil') @derive def A_gross(self): return self.leaf.A_gross @derive def A_net(self): return self.leaf.A_net @derive def ET(self): return self.leaf.ET @derive def T_leaf(self): return self.leaf.temperature @derive def VPD(self): #TODO: use Weather directly, instead of through PhotosyntheticLeaf return self.weather.VPD @derive def gs(self): return self.leaf.stomata.stomatal_conductance
class Radiation(System): sun = system() #FIXME: chance to remove ref here? only LAI is used photosynthesis = system() # cumulative LAI at the layer @drive(alias='LAI', unit='cm^2 / m^2') def leaf_area_index(self): return self.photosynthesis @parameter def leaf_angle(self): return LeafAngle.ellipsoidal # ratio of horizontal to vertical axis of an ellipsoid @parameter(alias='LAF') def leaf_angle_factor(self): #return 1 # leaf angle factor for corn leaves, Campbell and Norman (1998) #return 1.37 # leaf angle factor for garlic canopy, from Rizzalli et al. (2002), X factor in Campbell and Norman (1998) return 0.7 @parameter def wave_band(self): return WaveBand.photothetically_active_radiation # scattering coefficient (reflectance + transmittance) @parameter(alias='s') def scattering(self): return 0.15 # clumping index @parameter def clumping(self): return 1 #r_h=0.05, #FIXME reflectance? # Forward from Sun @derive(unit='deg') def current_zenith_angle(self): return self.sun.zenith_angle @derive(alias='I0_dr', unit='umol/m^2/s Quanta') def directional_photosynthetic_radiation(self): return self.sun.directional_photosynthetic_radiation @derive(alias='I0_df', unit='umol/m^2/s Quanta') def diffusive_photosynthetic_radiation(self): return self.sun.diffusive_photosynthetic_radiation # Leaf angle stuff? #TODO better name? @derive def leaf_angle_coeff(self, zenith_angle): elevation_angle = U(90, 'deg') - zenith_angle #FIXME need to prevent zero like sin_beta / cot_beta? a = elevation_angle.to('rad') t = zenith_angle.to('rad') # leaf angle distribution parameter x = self.leaf_angle_factor return { # When Lt accounts for total path length, division by sin(elev) isn't necessary LeafAngle.spherical: 1 / (2 * sin(a)), LeafAngle.horizontal: 1, LeafAngle.vertical: 1 / (tan(a) * pi / 2), LeafAngle.empirical: 0.667, LeafAngle.diaheliotropic: 1 / sin(a), LeafAngle.ellipsoidal: sqrt(x**2 + tan(t)**2) / (x + 1.774 * (x + 1.182)**-0.733), }[self.leaf_angle] #TODO make it @property if arg is not needed # Kb: Campbell, p 253, Ratio of projected area to hemi-surface area for an ellisoid #TODO rename to extinction_coeff? # extiction coefficient assuming spherical leaf dist @derive def projection_ratio_at(self, zenith_angle): return self.leaf_angle_coeff(zenith_angle) * self.clumping @derive(alias='Kb') def projection_ratio(self, current_zenith_angle): return self.projection_ratio_at(current_zenith_angle) # diffused light ratio to ambient, itegrated over all incident angles from -90 to 90 @constant(unit='rad') def _angles(self): return array([(pi / 4) * (g + 1) for g in GAUSS3]) # diffused fraction (fdf) #FIXME name it @derive def _F(self, x, angles): # Why multiplied by 2? return ((pi / 4) * (2 * x * sin(angles) * cos(angles)) * WEIGHT3).sum() # Kd: K for diffuse light, the same literature as above @derive(alias='Kd') def diffusion_ratio(self, LAI): angles = self._angles coeffs = array([self.leaf_angle_coeff(a) for a in angles]) F = self._F(exp(-coeffs * LAI), angles) K = -log(F) / LAI return K * self.clumping ############################## # dePury and Farquhar (1997) # ############################## # Kb1: Kb prime in de Pury and Farquhar(1997) @derive(alias='Kb1') #TODO better name def projection_ratio_prime(self, Kb, s): return Kb * sqrt(1 - s) # Kd1: Kd prime in de Pury and Farquhar(1997) @derive(alias='Kd1') #TODO better name def diffusion_ratio_prime(self, Kd, s): return Kd * sqrt(1 - s) ################ # Reflectivity # ################ @derive #TODO better name? def reflectivity(self, rho_h, Kb, Kd): return rho_h * (2 * Kb / (Kb + Kd)) # canopy reflection coefficients for beam horizontal leaves, beam uniform leaves, and diffuse radiations # rho_h: canopy reflectance of beam irradiance on horizontal leaves, de Pury and Farquhar (1997) # also see Campbell and Norman (1998) p 255 for further info on potential problems @derive(alias='rho_h') def canopy_reflectivity_horizontal_leaf(self, s): return (1 - sqrt(1 - s)) / (1 + sqrt(1 - s)) #TODO make consistent interface with siblings # rho_cb: canopy reflectance of beam irradiance for uniform leaf angle distribution, de Pury and Farquhar (1997) @derive def canopy_reflectivity_uniform_leaf_at(self, zenith_angle): rho_h = self.canopy_reflectivity_horizontal_leaf Kb = self.projection_ratio_at(zenith_angle) rho_cb = 1 - exp(-2 * rho_h * Kb / (1 + Kb)) return rho_cb @derive(alias='rho_cb') def canopy_reflectivity_uniform_leaf(self, current_zenith_angle): return self.canopy_reflectivity_uniform_leaf_at(current_zenith_angle) # rho_cd: canopy reflectance of diffuse irradiance, de Pury and Farquhar (1997) Table A2 @derive(alias='rho_cd') def canopy_reflectivity_diffusion(self): if self.sun.diffusive_photosynthetic_radiation == 0: return 0 angles = self._angles rhos = array( [self.canopy_reflectivity_uniform_leaf_at(a) for a in angles]) # Probably the eqn A21 in de Pury is missing the integration terms of the angles?? return self._F(rhos, angles) # rho_soil: soil reflectivity for PAR band @parameter def soil_reflectivity(self): return 0.10 ####################### # I_l?: dePury (1997) # ####################### # I_lb: dePury (1997) eqn A3 @derive(alias='I_lb', unit='umol/m^2/s Quanta') def irradiance_lb(self, L): I0_dr = self.sun.directional_photosynthetic_radiation rho_cb = self.canopy_reflectivity_uniform_leaf Kb1 = self.projection_ratio_prime return I0_dr * (1 - rho_cb) * Kb1 * exp(-Kb1 * L) # I_ld: dePury (1997) eqn A5 @derive(alias='I_ld', unit='umol/m^2/s Quanta') def irradiance_ld(self, L): I0_df = self.sun.diffusive_photosynthetic_radiation rho_cb = self.canopy_reflectivity_uniform_leaf Kd1 = self.diffusion_ratio_prime return I0_df * (1 - rho_cb) * Kd1 * exp(-Kd1 * L) # I_l: dePury (1997) eqn A5 @derive(alias='I_l', unit='umol/m^2/s Quanta') def irradiance_l(self, L): return self.irradiance_lb(L) + self.irradiance_ld(L) # I_lbSun: dePury (1997) eqn A5 @derive(unit='umol/m^2/s Quanta') def irradiance_l_sunlit(self, L): I0_dr = self.sun.directional_photosynthetic_radiation s = self.scattering Kb = self.projection_ratio I_lb_sunlit = I0_dr * (1 - s) * Kb I_l_sunlit = I_lb_sunlit + self.irradiance_l_shaded(L) return I_l_sunlit # I_lSH: dePury (1997) eqn A5 @derive(unit='umol/m^2/s Quanta') def irradiance_l_shaded(self, L): return self.irradiance_ld(L) + self.irradiance_lbs(L) # I_lbs: dePury (1997) eqn A5 @derive(alias='I_lbs', unit='umol/m^2/s Quanta') def irradiance_lbs(self, L): I0_dr = self.sun.directional_photosynthetic_radiation rho_cb = self.canopy_reflectivity_uniform_leaf s = self.scattering Kb1 = self.projection_ratio_prime Kb = self.projection_ratio return I0_dr * ((1 - rho_cb) * Kb1 * exp(-Kb1 * L) - (1 - s) * Kb * exp(-Kb * L)) # I0tot: total irradiance at the top of the canopy, # passed over from either observed PAR or TSolar or TIrradiance @derive(unit='umol/m^2/s Quanta') def irradiance_I0_tot(self): I0_dr = self.sun.directional_photosynthetic_radiation I0_df = self.sun.diffusive_photosynthetic_radiation return I0_dr + I0_df ######## # I_c? # ######## # I_tot, I_sun, I_shade: absorved irradiance integrated over LAI per ground area #FIXME: not used, but seems producing very low values, need to check equations # I_c: Total irradiance absorbed by the canopy, de Pury and Farquhar (1997) @derive(unit='umol/m^2/s Quanta') def canopy_irradiance(self, rho_cb, I0_dr, I0_df, Kb1, Kd1, LAI): #I_c = self.canopy_sunlit_irradiance + self.canopy_shaded_irradiance def I(I0, K): return (1 - rho_cb) * I0 * (1 - exp(-K * LAI)) I_tot = I(I0_dr, Kb1) + I(I0_df, Kd1) return I_tot # I_cSun: The irradiance absorbed by the sunlit fraction, de Pury and Farquhar (1997) # should this be the same os Qsl? 03/02/08 SK @derive(unit='umol/m^2/s Quanta') def canopy_sunlit_irradiance(self, s, rho_cb, rho_cd, I0_dr, I0_df, Kb, Kb1, Kd1, LAI): I_c_sunlit = \ I0_dr * (1 - s) * (1 - exp(-Kb * LAI)) + \ I0_df * (1 - rho_cd) * (1 - exp(-(Kd1 + Kb) * LAI)) * Kd1 / (Kd1 + Kb) + \ I0_dr * ((1 - rho_cb) * (1 - exp(-(Kb1 + Kb) * LAI)) * Kb1 / (Kb1 + Kb) - (1 - s) * (1 - exp(-2*Kb * LAI)) / 2) return I_c_sunlit # I_cSh: The irradiance absorbed by the shaded fraction, de Pury and Farquhar (1997) @derive(unit='umol/m^2/s Quanta') def canopy_shaded_irradiance(self): I_c = self.canopy_irradiance I_c_sunlit = self.canopy_sunlit_irradiance I_c_shaded = I_c - I_c_sunlit return I_c_shaded ###### # Q? # ###### # @derive # def sunlit_photon_flux_density(self): # return self._sunlit_Q # # @derive # def shaded_photon_flux_density(self): # return self._shaded_Q # Qtot: total irradiance (dir + dif) at depth L, simple empirical approach @derive(unit='umol/m^2/s Quanta') def irradiance_Q_tot(self, L): I0_tot = self.irradiance_I0_tot s = self.scattering Kb = self.projection_ratio Kd = self.diffusion_ratio Q_tot = I0_tot * exp(-sqrt(1 - s) * ((Kb + Kd) / 2) * L) return Q_tot # Qbt: total beam radiation at depth L @derive(unit='umol/m^2/s Quanta') def irradiance_Q_bt(self, L): I0_dr = self.sun.directional_photosynthetic_radiation s = self.scattering Kb = self.projection_ratio Q_bt = I0_dr * exp(-sqrt(1 - s) * Kb * L) return Q_bt # net diffuse flux at depth of L within canopy @derive(unit='umol/m^2/s Quanta') def irradiance_Q_d(self, L): I0_df = self.sun.diffusive_photosynthetic_radiation s = self.scattering Kd = self.diffusion_ratio Q_d = I0_df * exp(-sqrt(1 - s) * Kd * L) return Q_d # weighted average absorved diffuse flux over depth of L within canopy # accounting for exponential decay, Campbell p261 @derive(unit='umol/m^2/s Quanta') def irradiance_Q_dm(self, LAI): if LAI > 0: # Integral Qd / Integral L I0_df = self.sun.diffusive_photosynthetic_radiation s = self.scattering Kd = self.diffusion_ratio Q_dm = I0_df * (1 - exp(-sqrt(1 - s) * Kd * LAI)) / (sqrt(1 - s) * Kd * LAI) else: Q_dm = 0 return Q_dm # unintercepted beam (direct beam) flux at depth of L within canopy @derive(unit='umol/m^2/s Quanta') def irradinace_Q_b(self, L): I0_dr = self.sun.directional_photosynthetic_radiation Kb = self.projection_ratio Q_b = I0_dr * exp(-Kb * L) return Q_b # mean flux density on sunlit leaves @derive(unit='umol/m^2/s Quanta') def irradiance_Q_sunlit(self, I0_dr, Kb): return I0_dr * Kb + self.irradiance_Q_shaded # flux density on sunlit leaves at delpth L @derive(unit='umol/m^2/s Quanta') def irradiance_Q_sunlit_at(self, L, *, I0_dr, Kb): return I0_dr * Kb + self.irradiance_Q_shaded(L) # mean flux density on shaded leaves over LAI @derive(unit='umol/m^2/s Quanta') def irradiance_Q_shaded(self): # It does not include soil reflection return self.irradiance_Q_dm + self.irradiance_Q_scm # diffuse flux density on shaded leaves at depth L @derive(unit='umol/m^2/s Quanta') def irradiance_Q_shaded_at(self, L): # It does not include soil reflection return self.irradiance_Q_d(L) + self.irradiance_Q_sc(L) # weighted average of Soil reflectance over canopy accounting for exponential decay @derive(unit='umol/m^2/s Quanta') def irradiance_Q_soilm(self, LAI): if LAI > 0: # Integral Qd / Integral L rho_soil = self.soil_reflectivity s = self.scattering Kd = self.diffusion_ratio Q_soil = self.irradiance_Q_soil Q_soilm = Q_soil * rho_soil * ( 1 - exp(-sqrt(1 - s) * Kd * LAI)) / (sqrt(1 - s) * Kd * LAI) else: Q_soilm = 0 return Q_soilm # weighted average scattered radiation within canopy @derive(unit='umol/m^2/s Quanta') def irradiance_Q_scm(self, LAI): if LAI > 0: # Integral Qd / Integral L rho_soil = self.soil_reflectivity s = self.scattering Kd = self.diffusion_ratio Q_soil = self.irradiance_Q_soil Q_soilm = Q_soil * rho_soil * ( 1 - exp(-sqrt(1 - s) * Kd * LAI)) / (sqrt(1 - s) * Kd * LAI) I0_dr = self.sun.directional_photosynthetic_radiation s = self.scattering Kb = self.projection_ratio # total beam including scattered absorbed by canopy #FIXME should the last part be multiplied by LAI like others? total_beam = I0_dr * (1 - exp(-sqrt(1 - s) * Kb * LAI)) / ( sqrt(1 - s) * Kb) # non scattered beam absorbed by canopy nonscattered_beam = I0_dr * (1 - exp(-Kb * LAI)) / Kb Q_scm = (total_beam - nonscattered_beam) / LAI # Campbell and Norman (1998) p 261, Average between top (where scattering is 0) and bottom. #Q_scm = (self.irradiance_Q_bt(LAI) - self.irradiance_Q_b(LAI)) / 2 else: Q_scm = 0 return Q_scm # scattered radiation at depth L in the canopy @derive(unit='umol/m^2/s Quanta') def irradiance_Q_sc(self, L): Q_bt = self.irradiance_Q_bt(L) Q_b = self.irradiance_Q_b(L) # total beam - nonscattered beam at depth L return Q_bt - Q_b # total PFD at the soil sufrace under the canopy @derive(unit='umol/m^2/s Quanta') def irradiance_Q_soil(self, LAI): return self.irradiance_Q_tot(LAI) ################### # Leaf Area Index # ################### @constant(unit='deg') def sunlit_minimum_elevation_angle(self): return 5 # sunlit LAI assuming closed canopy; thus not accurate for row or isolated canopy @derive(unit='cm^2 / m^2') def sunlit_leaf_area_index(self): if self.sun.elevation_angle <= self.sunlit_minimum_elevation_angle: return 0 else: Kb = self.projection_ratio LAI = self.leaf_area_index return (1 - exp(-Kb * LAI)) / Kb # shaded LAI assuming closed canopy @derive(unit='cm^2 / m^2') def shaded_leaf_area_index(self): return self.leaf_area_index - self.sunlit_leaf_area_index # sunlit fraction of current layer @derive def sunlit_fraction(self, L): if self.sun.elevation_angle <= self.sunlit_minimum_elevation_angle: return 0 else: Kb = self.projection_ratio return exp(-Kb * L) @derive def shaded_fraction(self, L): return 1 - self.sunlit_fraction(L)
class Sun(System): # conversion factor from W/m2 to PFD (umol m-2 s-1) for PAR waveband (median 550 nm of 400-700 nm) of solar radiation, # see Campbell and Norman (1994) p 149 # 4.55 is a conversion factor from W to photons for solar radiation, Goudriaan and van Laar (1994) # some use 4.6 i.e., Amthor 1994, McCree 1981, Challa 1995. PHOTON_UMOL_PER_J = constant(4.6, unit='umol/J Quanta') # solar constant, Iqbal (1983) #FIXME better to be 1361 or 1362 W/m-2? SOLAR_CONSTANT = parameter(1370.0, alias='SC', unit='W/m^2') #TODO make Location external location = system(Location) weather = system() # @derive time? -- takes account different Julian day conventions (03-01 vs. 01-01) @derive(alias='t', init=None) def datetime(self): #FIXME: how to drive time variable? return self.context.datetime @derive(alias='d', init=None) def day(self, t): #FIXME: properly drive time return t.day @derive(alias='h', init=None, unit='hr') def hour(self, t): #FIXME: properly drive time return t.hour latitude = drive('location', alias='lat', unit='deg') # DO NOT convert to radians for consistency longitude = drive( 'location', alias='long', unit='deg' ) # leave it as in degrees, used only once for solar noon calculation altitude = drive('location', alias='alt', unit='m') #TODO: fix inconsistent naming of PAR vs. PFD photosynthetic_active_radiation = drive( 'weather', alias='PAR', key='PFD', unit='umol/m^2/s Quanta') # unit='umol m-2 s-1' transmissivity = parameter( 0.5, alias='tau' ) # atmospheric transmissivity, Goudriaan and van Laar (1994) p 30 ##################### # Solar Coordinates # ##################### #HACK always use degrees for consistency and easy tracing @derive(alias='dec', unit='deg') def declination_angle(self): #FIXME pascal version of LightEnv uses iqbal() return self._declination_angle_spencer # Goudriaan 1977 @derive(unit='deg') def _declination_angle_goudriaan(self, d): g = 2 * pi * (d + 10) / 365 return -23.45 * cos(g) # Resenberg, blad, verma 1982 @derive(unit='deg') def _declination_angle_resenberg(self, d): g = 2 * pi * (d - 172) / 365 return 23.5 * cos(g) # Iqbal (1983) Pg 10 Eqn 1.3.3, and sundesign.com @derive(unit='deg') def _declination_angle_iqbal(self, d): g = 2 * pi * (d + 284) / 365 return 23.45 * sin(g) # Campbell and Norman, p168 @derive(unit='deg') def _declination_angle_campbell(self, d): a = radians(356.6 + 0.9856 * d) b = radians(278.97 + 0.9856 * d + 1.9165 * sin(a)) r = arcsin(0.39785 * sin(b)) return degrees(r) # Spencer equation, Iqbal (1983) Pg 7 Eqn 1.3.1. Most accurate among all @derive(unit='deg') def _declination_angle_spencer(self, d): # gamma: day angle g = 2 * pi * (d - 1) / 365 r = 0.006918 - 0.399912 * cos(g) + 0.070257 * sin(g) - 0.006758 * cos( 2 * g) + 0.000907 * sin(2 * g) - 0.002697 * cos( 3 * g) + 0.00148 * sin(3 * g) return degrees(r) @constant(alias='dph', unit='deg/hr') # (unit='degree') def degree_per_hour(self): return 360 / 24 # LC is longitude correction for Light noon, Wohlfart et al, 2000; Campbell & Norman 1998 @derive(alias='LC', unit='hr') def longitude_correction(self, long, dph): # standard meridian for pacific time zone is 120 W, Eastern Time zone : 75W # LC is positive if local meridian is east of standard meridian, i.e., 76E is east of 75E #standard_meridian = -120 meridian = round(long / dph) * dph #FIXME use standard longitude sign convention #return (long - meridian) / dph #HACK this assumes inverted longitude sign that MAIZSIM uses return (meridian - long) / dph @derive(alias='ET', unit='hr') def equation_of_time(self, d): f = radians(279.575 + 0.9856 * d) return (-104.7*sin(f) + 596.2*sin(2*f) + 4.3*sin(3*f) - 12.7*sin(4*f) \ -429.3*cos(f) - 2.0*cos(2*f) + 19.3*cos(3*f)) / (60 * 60) @derive(unit='hr') def solar_noon(self, LC, ET): return U(12, 'hr') - LC - ET @derive def _cos_hour_angle(self, angle, latitude, declination_angle): # this value should never become negative because -90 <= latitude <= 90 and -23.45 < decl < 23.45 #HACK is this really needed for crop models? # preventing division by zero for N and S poles #denom = fmax(denom, 0.0001) # sunrise/sunset hour angle #TODO need to deal with lat_bound to prevent tan(90)? #lat_bound = radians(68)? radians(85)? # cos(h0) at cos(theta_s) = 0 (solar zenith angle = 90 deg == elevation angle = 0 deg) #return -tan(latitude) * tan(declination_angle) w_s = angle.to('rad') # zenith angle p = latitude.to('rad') d = declination_angle.to('rad') return (cos(w_s) - sin(p) * sin(d)) / (cos(p) * cos(d)) @derive(unit='deg') def hour_angle_at_horizon(self): c = self._cos_hour_angle(angle=U(90, 'deg')) # in the polar region during the winter, sun does not rise if c > 1: return 0 # white nights during the summer in the polar region elif c < -1: return 180 else: return degrees(arccos(c)) @derive(unit='hr') def half_day_length(self): # from Iqbal (1983) p 16 return self.hour_angle_at_horizon / self.degree_per_hour @derive(unit='hr') def day_length(self): return self.half_day_length * 2 @derive(unit='hr') def sunrise(self): return self.solar_noon - self.half_day_length @derive(unit='hr') def sunset(self): return self.solar_noon + self.half_day_length @derive(unit='deg') def hour_angle(self): return (self.hour - self.solar_noon) * self.degree_per_hour @derive(unit='deg') def elevation_angle(self, hour_angle, declination_angle, latitude): #FIXME When time gets the same as solarnoon, this function fails. 3/11/01 ?? h = hour_angle.to('rad') p = latitude.to('rad') d = declination_angle.to('rad') r = arcsin(cos(h) * cos(d) * cos(p) + sin(d) * sin(p)) #return degrees(r) return r @derive(unit='deg') def zenith_angle(self): return 90 - self.elevation_angle # The solar azimuth angle is the angular distance between due South and the # projection of the line of sight to the sun on the ground. # View point from south, morning: +, afternoon: - # See An introduction to solar radiation by Iqbal (1983) p 15-16 # Also see http://www.susdesign.com/sunangle/index.html @derive(unit='deg') def azimuth_angle(self, elevation_angle, declination_angle, latitude): d = declination_angle.to('rad') t_s = elevation_angle.to('rad') p = latitude.to('rad') r = arccos((sin(d) - sin(t_s) * sin(p)) / (cos(t_s) * cos(p))) #return degrees(abs(r)) return abs(r) ################### # Solar Radiation # ################### # atmospheric pressure in kPa @derive(unit='kPa') def atmospheric_pressure(self, altitude): try: # campbell and Norman (1998), p 41 return 101.3 * exp(-U.magnitude(altitude, 'm') / 8200) except: return 100 @derive(unit='') def optical_air_mass_number(self, elevation_angle): t_s = clip(elevation_angle, lower=0, unit='rad') #FIXME need to do max(0.0001, sin(t_s))? try: #FIXME check 101.3 is indeed in kPa return self.atmospheric_pressure / (U(101.3, 'kPa') * sin(t_s)) except: return 0 @derive(unit='W/m^2') # Campbell and Norman's global solar radiation, this approach is used here #TODO rename to insolation? (W/m2) def solar_radiation(self, elevation_angle, day, SC): t_s = clip(elevation_angle, lower=0, unit='rad') g = 2 * pi * (day - 10) / 365 return SC * sin(t_s) * (1 + 0.033 * cos(g)) @derive(unit='W/m^2') def directional_solar_radiation(self): return self.directional_coeff * self.solar_radiation @derive(unit='W/m^2') def diffusive_solar_radiation(self): return self.diffusive_coeff * self.solar_radiation @derive(unit='') def directional_coeff(self, transmissivity, optical_air_mass_number): # Goudriaan and van Laar's global solar radiation def goudriaan(tau): #FIXME should be goudriaan() version return tau * (1 - self.diffusive_coeff) # Takakura (1993), p 5.11 def takakura(tau, m): return tau**m # Campbell and Norman (1998), p 173 def campbell(tau, m): return tau**m return campbell(transmissivity, optical_air_mass_number) # Fdif: Fraction of diffused light @derive(unit='') def diffusive_coeff(self, transmissivity, optical_air_mass_number): # Goudriaan and van Laar's global solar radiation def goudriaan(tau): # clear sky : 20% diffuse if tau >= 0.7: return 0.2 # cloudy sky: 100% diffuse elif tau <= 0.3: return 1 # inbetween else: return 1.6 - 2 * tau # Takakura (1993), p 5.11 def takakura(tau, m): return (1 - tau**m) / (1 - 1.4 * log(tau)) / 2 # Campbell and Norman (1998), p 173 def campbell(tau, m): return (1 - tau**m) * 0.3 return campbell(transmissivity, optical_air_mass_number) @derive(unit='') def directional_fraction(self): try: return 1 / (1 + self.diffusive_coeff / self.directional_coeff) except: return 0 @derive(unit='') def diffusive_fraction(self): try: return 1 / (1 + self.directional_coeff / self.diffusive_coeff) except: return 0 # PARfr @derive(unit='') #TODO better naming: extinction? transmitted_fraction? def photosynthetic_coeff(self, transmissivity): #if self.elevation_angle <= 0: # return 0 #TODO: implement Weiss and Norman (1985), 3/16/05 def weiss(): pass # Goudriaan and van Laar (1994) def goudriaan(tau): # clear sky (tau >= 0.7): 45% is PAR if tau >= 0.7: return 0.45 # cloudy sky (<= 0.3): 55% is PAR elif tau <= 0.3: return 0.55 else: return 0.625 - 0.25 * tau return goudriaan(transmissivity) # PARtot: total PAR (umol m-2 s-1) on horizontal surface (PFD) @derive(alias='PARtot', unit='umol/m^2/s Quanta') def photosynthetic_active_radiation_total(self, PAR): if self.PAR is not None: return self.PAR else: return self.solar_radiation * self.photosynthetic_coeff * self.PHOTON_UMOL_PER_J # PARdir @derive(unit='umol/m^2/s Quanta') def directional_photosynthetic_radiation(self, PARtot): return self.directional_fraction * PARtot # PARdif @derive(unit='umol/m^2/s Quanta') def diffusive_photosynthetic_radiation(self, PARtot): return self.diffusive_fraction * PARtot
class Trait(System): plant = system(alias='p')
class Phenology(System): weather = system(Weather) soil = system(Soil) @parameter def storage_days(self): pass @parameter def initial_leaf_number_at_harvest(self): return 4 @parameter(alias='R_max_LIR') def maximum_leaf_initiation_rate(self): pass @parameter(alias='R_max_LTAR') def maximum_leaf_tip_appearance_rate(self): pass @parameter(alias='T_opt') def optimal_temperature(self): return 22.28 @parameter(alias='T_ceil') def ceiling_temperature(self): return 34.23 @parameter def emergence_date(self): pass @parameter def critical_photoperiod(self): pass @parameter def scape_removal_date(self): pass # def setup(self): # # mean growing season temperature since germination, SK 1-19-12 # self.gst_recorder = gstr = GstRecorder(self) # self.gdd_recorder = gddr = GddRecorder(self) # self.gti_recorder = gtir = GtiRecorder(self) # self.germination = g = Germination(self) # self.emergence = e = Emergence(self, R_max=R_max_LTAR, T_opt=T_opt, T_ceil=T_ceil, emergence_date=emergence_date) # self.leaf_initiation = li = LeafInitiationWithStorage(self, initial_leaves_at_harvest=initial_leaf_number_at_harvest, R_max=R_max_LIR, T_opt=T_opt, T_ceil=T_ceil, storage_days=storage_days) # self.leaf_appearance = la = LeafAppearance(self, R_max=R_max_LTAR, T_opt=T_opt, T_ceil=T_ceil) # self.floral_initiation = fi = FloralInitiation(self, critical_photoperiod=critical_photoperiod) # self.bulbing = bi = Bulbing(self) # self.scape = s = Scape(self, R_max=R_max_LTAR, T_opt=T_opt, T_ceil=T_ceil) # self.scape_appearance = sa = ScapeAppearance(self, s) # self.scape_removal = sr = ScapeRemoval(self, s, scape_removal_date=scape_removal_date) # self.flowering = f = Flowering(self, s) # self.bulbiling = b = Bulbiling(self, s) # self.death = d = Death(self) # self.stages = [ # gstr, gddr, gtir, # g, e, li, la, fi, bi, s, sa, sr, f, b, d, # ] # def update(self, t): # #queue = self._queue() # [s.update(t) for s in self.stages if s.ready] # #FIXME remove finish() for simplicity # [s.finish() for s in self.stages if s.over] # self.stages = [s for s in self.stages if not s.over] # #TODO some methods for event records? or save them in Stage objects? # #def record(self, ...): # # pass germination = system(Germination, phenology='self') emergence = system(Emergence, phenology='self') leaf_initiation = system(LeafInitiationWithStorage, phenology='self') leaf_appearance = system(LeafAppearance, phenology='self') floral_initiation = system(FloralInitiation, phenology='self') bulbing = system(Bulbing, phenology='self') scape = system(Scape, phenology='self') scape_appearance = system(ScapeAppearance, phenology='self', scape='scape') scape_removal = system(ScapeRemoval, phenology='self', scape='scape') flowering = system(Flowering, phenology='self', scape='scape') bulbiling = system(Bulbiling, phenology='self', scape='scape') death = system(Death, phenology='self') ############ # Accessor # ############ @derive def leaves_potential(self): return max(self.leaves_generic, self.leaves_total) @parameter def leaves_generic(self): return 10 @derive def leaves_total(self): return self.leaves_initiated @derive def leaves_initiated(self): return self.leaf_initiation.leaves @derive def leaves_appeared(self): return self.leaf_appearance.leaves @derive(alias='T') def temperature(self): if self.leaves_appeared < 9: #FIXME soil module is not implemented yet #T = self.soil.T_soil #HACK garlic model does not use soil temperature T = self.weather.T_air else: T = self.weather.T_air #FIXME T_cur doesn't go below zero, but is it fair assumption? return T if T > 0 else 0 # @derive # def growing_temperature(self): # return self.gst_recorder.rate # common @flag def germinating(self): return self.germination.ing @flag def germinated(self): return self.germination.over @flag def emerging(self): return self.emergence.ing @flag def emerged(self): return self.emergence.over # garlic @flag def floral_initiated(self): return self.floral_initiation.over @flag def scaping(self): return self.scape.ing @flag def scape_appeared(self): return self.scape_appearance.over @flag def scape_removed(self): return self.scape_removal.over @flag def flowered(self): return self.flowering.over @flag def bulb_maturing(self): #FIXME clear definition of bulb maturing return self.scape_removed or self.bulbiling.over # common @flag def dead(self): return self.death.over
class PhotosyntheticLeaf(System): weather = system() soil = system() @system(leaf='self') def stomata(self): return Stomata photosynthesis = system(C4, leaf='self') # for maize #photosynthesis = system(C3, leaf='self') # for garlic #TODO organize leaf properties like water (LWP), nitrogen content? #TODO introduce a leaf geomtery class for leaf_width #TODO introduce a soil class for ET_supply ########### # Drivers # ########### # static properties nitrogen = parameter(2.0) # geometry width = parameter(10 / 100, unit='m') # meters # soil? ET_supply = parameter( 0, unit='mol/m^2/s H2O') # actual water uptake rate (mol H2O m-2 s-1) # dynamic properties # mesophyll CO2 partial pressure, ubar, one may use the same value as Ci assuming infinite mesohpyle conductance @derive(alias='Cm,Ci', unit='umol/mol CO2') def co2_mesophyll(self, A_net, w='weather', rvc='stomata.rvc'): P = w.P_air / U(100, 'kPa') Ca = w.CO2 * P # conversion to partial pressure Cm = Ca - A_net * rvc * P #print(f"+ Cm = {Cm}, Ca = {Ca}, A_net = {A_net}, gs = {self.stomata.gs}, gb = {self.stomata.gb}, rvc = {rvc}, P = {P}") return clip(Cm, 0, 2 * Ca) #return Cm #FIXME is it right place? maybe need coordination with geometry object in the future @derive(alias='I2', unit='umol/m^2/s Quanta') def light(self): #FIXME make scatt global parameter? scatt = 0.15 # leaf reflectance + transmittance f = 0.15 # spectral correction Ia = self.weather.PPFD * (1 - scatt) # absorbed irradiance I2 = Ia * (1 - f) / 2 # useful light absorbed by PSII return I2 @optimize(alias='A_net', unit='umol/m^2/s CO2') def net_photosynthesis(self): #I2 = self.light A_net0 = self.A_net #print(f"A_net0 = {A_net0}") #Cm0 = self.co2_mesophyll A_net1 = self.photosynthesis.net_photosynthesis #Cm1 = co2_mesophyll(A_net1) #print(f"- I2 = {I2}, Cm0 = {Cm0}, T_leaf = {T_leaf}, A_net0 = {A_net0}, A_net1 = {A_net1}, Cm1 = {Cm1}") # return A_net1 return (A_net1 - A_net0)**2 @derive(alias='Rd', unit='umol/m^2/s O2') def dark_respiration(self): return self.photosynthesis.dark_respiration @derive(alias='A_gross', unit='umol/m^2/s CO2') def gross_photosynthesis(self): return clip( self.A_net + self.Rd, lower=0 ) # gets negative when PFD = 0, Rd needs to be examined, 10/25/04, SK @derive(alias='gs', unit='umol/m^2/s H2O') def stomatal_conductance(self): return self.stomata.stomatal_conductance #TODO: use @optimize @derive(unit='delta_degC') def temperature_adjustment( self, w='weather', s='stomata', # see Campbell and Norman (1998) pp 224-225 # because Stefan-Boltzman constant is for unit surface area by denifition, # all terms including sbc are multilplied by 2 (i.e., gr, thermal radiation) lamda=U(44.0, 'kJ/mol'), # KJ mole-1 at 25oC Cp=U( 29.3, 'J/mol/degC' ), # thermodynamic psychrometer constant and specific heat of air (J mol-1 C-1) epsilon=0.97, sbc=U(5.6697e-8, 'J/m^2/s/degK^4'), # Stefan-Boltzmann constant (W m-2 K-4) ): T_air = w.T_air Tk = T_air.to('degK') PFD = w.PPFD P_air = w.P_air Jw = self.ET_supply gha = s.boundary_layer_conductance * ( 0.135 / 0.147 ) # heat conductance, gha = 1.4*.135*sqrt(u/d), u is the wind speed in m/s} Mol m-2 s-1 ? gv = s.total_conductance_h2o gr = 4 * epsilon * sbc * Tk**3 / Cp * 2 # radiative conductance, 2 account for both sides ghr = gha + gr thermal_air = epsilon * sbc * Tk**4 * 2 # emitted thermal radiation psc = Cp / lamda # psychrometric constant (C-1) psc1 = psc * ghr / gv # apparent psychrometer constant PAR = U(PFD.to('umol/m^2/s Quanta').magnitude / 4.55, 'J/m^2/s') # W m-2 # If total solar radiation unavailable, assume NIR the same energy as PAR waveband NIR = PAR scatt = 0.15 # shortwave radiation (PAR (=0.85) + NIR (=0.15) solar radiation absorptivity of leaves: =~ 0.5 # times 2 for projected area basis R_abs = (1 - scatt) * PAR + scatt * NIR + 2 * (epsilon * sbc * Tk**4) # debug dt I commented out the changes that yang made for leaf temperature for a test. I don't think they work if Jw == 0: # (R_abs - thermal_air - lamda * gv * w.VPD / P_air) / (Cp * ghr + lamda * w.saturation_slope * gv) # eqn 14.6a # eqn 14.6b linearized form using first order approximation of Taylor series return (psc1 / (w.saturation_slope + psc1)) * ((R_abs - thermal_air) / (ghr * Cp) - w.VPD / (psc1 * P_air)) else: return (R_abs - thermal_air - lamda * Jw) / (Cp * ghr) @derive(alias='T', unit='degC') def temperature(self): T_air = self.weather.T_air T_leaf = T_air + self.temperature_adjustment return T_leaf #TODO: expand @optimize decorator to support both cost function and variable definition # @temperature.optimize or minimize? # def temperature(self): # return (self.temperature - self.new_temperature)**2 @derive(alias='ET', unit='umol/m^2/s H2O') def evapotranspiration(self, vp='weather.vp'): gv = self.stomata.total_conductance_h2o ea = vp.ambient(self.weather.T_air, self.weather.RH) es_leaf = vp.saturation(self.temperature) ET = gv * ((es_leaf - ea) / self.weather.P_air) / (1 - (es_leaf + ea) / self.weather.P_air) return clip( ET, lower=0) # 04/27/2011 dt took out the 1000 everything is moles now
class C4(System): #TODO: more robust interface to connect Systems (i.e. type check, automatic prop defines) leaf = system() @derive(alias='Cm', unit='umol/mol CO2') def co2_mesophyll(self): Cm = self.leaf.co2_mesophyll return clip(Cm, lower=0) @derive(alias='I2', unit='umol/m^2/s Quanta') def light(self): I2 = self.leaf.light return clip(I2, lower=0) @drive(alias='T', unit='degC') def temperature(self): return self.leaf nitrogen = drive('leaf', alias='N') ############## # Parameters # ############## # FIXME are they even used? # self.beta_ABA = 1.48e2 # Tardieu-Davies beta, Dewar (2002) Need the references !? # self.delta = -1.0 # self.alpha_ABA = 1.0e-4 # self.lambda_r = 4.0e-12 # Dewar's email # self.lambda_l = 1.0e-12 # self.K_max = 6.67e-3 # max. xylem conductance (mol m-2 s-1 MPa-1) from root to leaf, Dewar (2002) gbs = parameter( 0.003, unit='mol/m^2/s CO2') # bundle sheath conductance to CO2, mol m-2 s-1 # gi = parameter(1.0) # conductance to CO2 from intercelluar to mesophyle, mol m-2 s-1, assumed # Arrhenius equation @derive(alias='T_dep') def temperature_dependence_rate(self, Ea, T, Tb=U(25, 'degC')): R = U(8.314, 'J/K/mol') # universal gas constant (J K-1 mol-1) #HACK handle too low temperature values during optimization Tk = clip(T, lower=0, unit='degK') Tbk = clip(Tb, lower=0, unit='degK') try: return np.exp(Ea * (T - Tb) / (Tbk * R * Tk)) except ZeroDivisionError: return 0 @derive(alias='N_dep') def nitrogen_limited_rate(self, N, s=2.9, N0=0.25): return 2 / (1 + np.exp(-s * (max(N0, N) - N0))) - 1 # Rd25: Values in Kim (2006) are for 31C, and the values here are normalized for 25C. SK @derive(alias='Rd') def dark_respiration(self, T_dep, Rd25=U(2, 'umol/m^2/s O2'), Ear=U(39800, 'J/mol')): return Rd25 * T_dep(Ear) @derive def Rm(self, Rd): return 0.5 * Rd @derive(alias='Jmax', unit='umol/m^2/s Electron') def maximum_electron_transport_rate(self, T, T_dep, N_dep, Jm25=U(300, 'umol/m^2/s Electron'), Eaj=U(32800, 'J/mol'), Sj=U(702.6, 'J/mol/degK'), Hj=U(220000, 'J/mol')): R = U(8.314, 'J/K/mol') Tb = U(25, 'degC') Tk = T.to('degK') Tbk = Tb.to('degK') r = Jm25 * N_dep \ * T_dep(Eaj) \ * (1 + np.exp((Sj*Tbk - Hj) / (R*Tbk))) \ / (1 + np.exp((Sj*Tk - Hj) / (R*Tk))) return clip(r, lower=0) @parameter(unit='mbar') def Om(self): # mesophyll O2 partial pressure O = 210 # gas units are mbar return O # Kp25: Michaelis constant for PEP caboxylase for CO2 @derive(unit='ubar') def Kp(self, Kp25=U(80, 'ubar')): return Kp25 # T dependence yet to be determined # Kc25: Michaelis constant of rubisco for CO2 of C4 plants (2.5 times that of tobacco), ubar, Von Caemmerer 2000 @derive(unit='ubar') def Kc(self, T_dep, Kc25=U(650, 'ubar'), Eac=U(59400, 'J/mol')): return Kc25 * T_dep(Eac) # Ko25: Michaelis constant of rubisco for O2 (2.5 times C3), mbar @derive(unit='mbar') def Ko(self, T_dep, Ko25=U(450, 'mbar'), Eao=U(36000, 'J/mol')): return Ko25 * T_dep(Eao) @derive(unit='ubar') def Km(self, Kc, Om, Ko): # effective M-M constant for Kc in the presence of O2 return Kc * (1 + Om / Ko) @derive(unit='umol/m^2/s CO2') def Vpmax(self, N_dep, T_dep, Vpm25=U(70, 'umol/m^2/s CO2'), EaVp=U(75100, 'J/mol')): return Vpm25 * N_dep * T_dep(EaVp) @derive(unit='umol/m^2/s CO2') def Vp(self, Vpmax, Cm, Kp): # PEP carboxylation rate, that is the rate of C4 acid generation Vp = (Cm * Vpmax) / (Cm + Kp / U(1, 'atm')) Vpr = U(80, 'umol/m^2/s CO2' ) # PEP regeneration limited Vp, value adopted from vC book Vp = clip(Vp, 0, Vpr) return Vp # EaVc: Sage (2002) JXB @derive(unit='umol/m^2/s CO2') def Vcmax(self, N_dep, T_dep, Vcm25=U(50, 'umol/m^2/s CO2'), EaVc=U(55900, 'J/mol')): return Vcm25 * N_dep * T_dep(EaVc) @derive(alias='Ac', unit='umol/m^2/s CO2') def enzyme_limited_photosynthesis_rate(self, Vp, gbs, Cm, Rm, Vcmax, Rd): # Enzyme limited A (Rubisco or PEP carboxylation) Ac1 = Vp + gbs * Cm - Rm #Ac1 = max(0, Ac1) # prevent Ac1 from being negative Yang 9/26/06 Ac2 = Vcmax - Rd #print(f'Ac1 = {Ac1}, Ac2 = {Ac2}') Ac = min(Ac1, Ac2) return Ac # Light and electron transport limited A mediated by J # theta: sharpness of transition from light limitation to light saturation # x: Partitioning factor of J, yield maximal J at this value @derive(alias='Aj', unit='umol/m^2/s CO2') def transport_limited_photosynthesis_rate(self, T, Jmax, Rd, Rm, I2, gbs, Cm, theta=0.5, x=0.4): J = quadratic_solve_lower(theta, -(I2 + Jmax), I2 * Jmax) #print(f'Jmax = {Jmax}, J = {J}') Aj1 = x * J / 2 - Rm + gbs * Cm Aj2 = (1 - x) * J / 3 - Rd Aj = min(Aj1, Aj2) return Aj @derive(alias='A_net', unit='umol/m^2/s CO2') def net_photosynthesis(self, Ac, Aj, beta=0.99): # smooting the transition between Ac and Aj A_net = ((Ac + Aj) - ((Ac + Aj)**2 - 4 * beta * Ac * Aj)**0.5) / (2 * beta) #print(f'Ac = {Ac}, Aj = {Aj}, A_net = {A_net}') return A_net #FIXME: currently not used variables # alpha: fraction of PSII activity in the bundle sheath cell, very low for NADP-ME types @derive(alias='Os', unit='mbar') def bundle_sheath_o2(self, A_net, gbs, Om, alpha=0.0001): return alpha * A_net / (0.047 * gbs) * U( 1, 'atm') + Om # Bundle sheath O2 partial pressure, mbar @derive(alias='Cbs', unit='ubar') def bundle_sheath_co2(self, A_net, Vp, Cm, Rm, gbs): return Cm + (Vp - A_net - Rm) / gbs # Bundle sheath CO2 partial pressure, ubar @derive(unit='ubar') def gamma(self, Rd, Km, Vcmax, Os): # half the reciprocal of rubisco specificity, to account for O2 dependence of CO2 comp point, # note that this become the same as that in C3 model when multiplied by [O2] gamma1 = 0.193 gamma_star = gamma1 * Os return (Rd * Km + Vcmax * gamma_star) / (Vcmax - Rd)
class Plant(System): weather = system(Weather) soil = system(Soil) phenology = system(Phenology, alias='pheno', plant='self') photosynthesis = system(Photosynthesis, plant='self') mass = system(Mass, plant='self') area = system(Area, plant='self') count = system(Count, plant='self') ratio = system(Ratio, plant='self') #carbon = system(Carbon, plant='self') #nitrogen = system(Nitrogen, plant='self') water = system(Water, plant='self') #TODO pass PRIMORDIA as initial_leaves primordia = parameter(5) bulb = system(None) scape = system(None) root = system(None) nodal_units = system([]) #TODO find a better place? @parameter(unit='1/m^2') def planting_density(self): return 55 @produce(target='nodal_units') def initiate_primordia(self): if len(self.nodal_units) == 0: return [(NodalUnit, { 'plant': self, 'rank': i + 1 }) for i in range(self.primordia)] @produce(target='root') def initiate_root(self): if not self.root: if self.pheno.emerging: #TODO import_carbohydrate(self.soil.total_root_weight) return (Root, {'plant': self}) @produce(target='nodal_units') def initiate_leaves(self): if not (self.pheno.germinating or self.pheno.dead): def f(i): try: self.nodal_units[i] except IndexError: return (NodalUnit, {'plant': self, 'rank': i + 1}) else: return None return [f(i) for i in range(self.pheno.leaves_initiated)]
class Leaf(Organ): nodal_unit = system() @derive def rank(self): return self.nodal_unit.rank # cm dd-1 Fournier and Andrieu 1998 Pg239. # This is the "potential" elongation rate with no water stress Yang # @property # def elongation_rate(self): # return 0.564 # max elongation rate (cm per day) at optipmal temperature # (Topt: 31C with Tbase = 9.8C using 0.564 cm/dd rate from Fournier 1998 paper above @parameter(alias='LER', unit='cm/day') def maximum_elongation_rate(self): return 12.0 @parameter(alias='LM_min', unit='cm') def minimum_length_of_longest_leaf(self): return 60.0 # leaf lamina width to length ratio @parameter def length_to_width_ratio(self): #return 0.106 # for maize return 0.05 # for garlic # leaf area coeff with respect to L*W (A_LW) @parameter def area_ratio(self): return 0.75 # staygreen trait of the hybrid # stay green for this value times growth period after peaking before senescence begins # An analogy for this is that with no other stresses involved, # it takes 15 years to grow up, stays active for 60 years, # and age the last 15 year if it were for a 90 year life span creature. # Once fully grown, the clock works differently so that the hotter it is quicker it ages @parameter def stay_green(self): return 3.5 @parameter(unit='cm/day') def aging_rate(self): return self.maximum_elongation_rate ############# # Variables # ############# #FIXME @derive def potential_leaves(self): return self.p.pheno.leaves_potential #FIXME @derive def extra_leaves(self): return self.p.pheno.leaves_potential - self.p.pheno.leaves_generic @derive(alias='maximum_length', unit='cm') def maximum_length_of_longest_leaf(self, LM_min, extra_leaves, k=0): # no length adjustment necessary for garlic, unlike MAIZE (KY, 2016-10-12) #k = 0 # 24.0 return sqrt(LM_min**2 + k * extra_leaves) @derive(unit='cm') def maximum_width(self): # Fournier and Andrieu(1998) Pg242 YY return self.maximum_length * self.length_to_width_ratio @derive(unit='cm^2') def maximum_area(self): # daughtry and hollinger (1984) Fournier and Andrieu(1998) Pg242 YY return self.maximum_length * self.maximum_width * self.area_ratio @derive(unit='cm^2', nounit='l') def area_from_length(self, l): #HACK ensure zero area for zero length if l == 0: return 0 else: # for garlic, see JH's thesis return 0.639945 + 0.954957 * l + 0.005920 * l**2 @derive(unit='cm^2', nounit='length') def area_increase_from_length(self, length): # for garlic, see JH's thesis return 0.954957 + 2 * 0.005920 * length #TODO better name, shared by growth_duration and pontential_area #TODO should be a plant parameter not leaf (?) @derive def rank_effect(self, potential_leaves, rank, weight=1): l = potential_leaves n_m = 5.93 + 0.33 * l # the rank of the largest leaf. YY a = (-10.61 + 0.25 * l) * weight b = (-5.99 + 0.27 * l) * weight # equation 7 in Fournier and Andrieu (1998). YY # equa 8(b)(Actually eqn 6? - eqn 8 deals with leaf age - DT) # in Fournier and Andrieu(1998). YY scale = rank / n_m - 1 return exp(a * scale**2 + b * scale**3) @derive(unit='cm') def potential_length(self): # for MAIZSIM #return self.maximum_length * self.rank_effect(weight=0.5) # for beta fn calibrated from JH's thesis for SP and KM varieties, 8/10/15, SK #FIXME: leaves_potential is already max(leaves_generic, leaves_total)? n = max(self.p.pheno.leaves_potential, self.p.pheno.leaves_generic) l_t = 1.64 * n l_pk = 0.88 * n R_max = self.maximum_length return R_max * beta_thermal_func(T=self.rank, T_opt=l_pk, T_max=l_t) # from CLeaf::calc_dimensions() # LM_min is a length characteristic of the longest leaf,in Fournier and Andrieu 1998, it was 90 cm # LA_max is a fn of leaf no (Birch et al, 1998 fig 4) with largest reported value near 1000cm2. This is implemented as lfno_effect below, SK # LM_min of 115cm gives LA of largest leaf 1050cm2 when totalLeaves are 25 and Nt=Ng, SK 1-20-12 # Without lfno_effect, it can be set to 97cm for the largest leaf area to be at 750 cm2 with Nt ~= Ng (Lmax*Wmax*0.75) based on Muchow, Sinclair, & Bennet (1990), SK 1-18-2012 # Eventually, this needs to be a cultivar parameter and included in input file, SK 1-18-12 # the unit of k is cm^2 (Fournier and Andrieu 1998 Pg239). YY # L_max is the length of the largest leaf when grown at T_peak. Here we assume LM_min is determined at growing Topt with minmal (generic) leaf no, SK 8/2011 # If this routine runs before TI, totalLeaves = genericLeafNo, and needs to be run with each update until TI and total leaves are finalized, SK @derive(unit='day') def growth_duration(self): # shortest possible linear phase duration in physiological time (days instead of GDD) modified days = self.potential_length / self.maximum_elongation_rate # for garlic return 1.5 * days @derive def phase1_delay(self, rank): # not used in MAIZSIM because LTAR is used to initiate leaf growth. # Fournier's value : -5.16+1.94*rank;equa 11 Fournier and Andrieu(1998) YY, This is in plastochron unit return clip(-5.16 + 1.94 * rank, lower=0) @derive def leaf_number_effect(self, potential_leaves): # Fig 4 of Birch et al. (1998) return clip(exp(-1.17 + 0.047 * potential_leaves), 0.5, 1.0) @derive(unit='cm^2') def potential_area(self): # for MAIZSIM # equa 6. Fournier and Andrieu(1998) multiplied by Birch et al. (1998) leaf no effect # LA_max the area of the largest leaf # PotentialArea potential final area of a leaf with rank "n". YY #return self.maximum_area * self.leaf_number_effect * self.rank_effect(weight=1) # for garlic return self.area_from_length(self.potential_length) @derive def green_ratio(self): return 1 - self.senescence_ratio @derive(unit='cm^2') def green_area(self): return self.green_ratio * self.area @accumulate(unit='day') def elongation_age(self): #TODO add td in the args #TODO implement Parent and Tardieu (2011, 2012) approach for leaf elongation in response to T and VPD, and normalized at 20C, SK, Nov 2012 # elongAge indicates where it is now along the elongation stage or duration. # duration is determined by totallengh/maxElongRate which gives the shortest duration to reach full elongation in the unit of days. #FIXME no need to check here, as it will be compared against duration later anyways #return min(self._elongation_tracker.rate, self.growth_duration) if self.appeared and not self.mature: R_max = 1.0 return R_max * beta_thermal_func( T=self.p.pheno.temperature, T_opt=self.p.pheno.optimal_temperature, T_max=self.p.pheno.ceiling_temperature) #TODO move to common module (i.e. Organ?) def _beta_growth(self, t, c_m, t_e, t_m=None, t_b=0, delta=1): #FIXME clipping necessary? #t = clip(t, 0., t_e) if not 0 <= t <= t_e: breakpoint() t_m = t_e / 2 if t_m is None else t_m t_et = t_e - t t_em = t_e - t_m t_tb = t - t_b t_mb = t_m - t_b return c_m * ((t_et / t_em) * (t_tb / t_mb)**(t_mb / t_em))**delta @derive(unit='cm/day') def potential_elongation_rate(self): if self.growing: #TODO proper integration with scipy.integrate return self._beta_growth( t=self.elongation_age, c_m=self.maximum_elongation_rate, t_e=self.growth_duration, ) else: return 0 @derive def _temperature_effect(self, T_grow, T_peak, T_base): # T_peak is the optimal growth temperature at which the potential leaf size determined in calc_mophology achieved. # Similar concept to fig 3 of Fournier and Andreiu (1998) # phyllochron corresponds to PHY in Lizaso (2003) # phyllochron needed for next leaf appearance in degree days (GDD8) - 08/16/11, SK. #phyllochron = (dv->get_T_Opt()- Tb)/(dv->get_Rmax_LTAR()); T_ratio = (T_grow - T_base) / (T_peak - T_base) # final leaf size is adjusted by growth temperature determining cell size during elongation return clip(T_ratio * exp(1 - T_ratio), lower=0) @derive def temperature_effect(self): #return self._temperature_effect(T_grow=self.p.pheno.growing_temperature, T_peak=18.7, T_base=8.0) # for MAIZSIM #FIXME garlic model uses current temperature, not average growing temperature #return self._temperature_effect(T_grow=self.p.pheno.temperature, T_peak=self.p.pheno.optimal_temperature, T_base=0) # for garlic #FIXME garlic model does not actually use tempeature effect on final leaf size calculation return 1.0 # for garlic @derive(unit='cm^2/day') def potential_expansion_rate(self): t = self.elongation_age t_e = self.growth_duration # 1.5 * w_max / c_m t = clip(t, upper=t_e) #FIXME can we introduce new w_max here when w_max in t_e (growth duration) supposed to be potential length? w_max = self.potential_area # c_m from Eq. 9, r (= dw/dt / c_m) from Eq. 7 of Yin (2003) #HACK can be more simplified #c_m = 1.5 / t_e * w_max #r = 4 * t * (t_e - t) / t_e**2 t_m = t_e / 2 c_m = (2 * t_e - t_m) / (t_e * (t_e - t_m)) * (t_m / t_e)**(t_m / (t_e - t_m)) * w_max r = (t_e - t) / (t_e - t_m) * (t / t_m)**(t_m / (t_e - t_m)) #FIXME dt here is physiological time, whereas timestep multiplied in potential_area_increase is chronological time return c_m * r # dw/dt @derive(unit='cm^2') def potential_area_increase(self): ##area = max(0, water_effect * T_effect * self.potential_area * (1 + (t_e - self.elongation_age) / (t_e - t_m)) * (self.elongation_age / t_e)**(t_e / (t_e - t_m))) #maximum_expansion_rate = T_effect * self.potential_area * (2*t_e - t_m) / (t_e * (t_e - t_m)) * (t_m / t_e)**(t_m / (t_e - t_m)) # potential leaf area increase without any limitations #return max(0, maximum_expansion_rate * max(0, (t_e - self.elongation_age) / (t_e - t_m) * (self.elongation_age / t_m)**(t_m / (t_e - t_m))) * self.timestep) if self.growing: # for MAIZSIM #return self.potential_expansion_rate * self.timestep # for garlic #TODO need common framework dealing with derivatives #return self.area_increase_from_length(self.actual_length_increase) return self.area_from_length( self.length + self.actual_length_increase) - self.area else: return 0 # create a function which simulates the reducing in leaf expansion rate # when predawn leaf water potential decreases. Parameterization of rf_psil # and rf_sensitivity are done with the data from Boyer (1970) and Tanguilig et al (1987) YY @derive def _water_potential_effect(self, psi_predawn, threshold): #psi_predawn = self.p.soil.WP_leaf_predawn psi_th = threshold # threshold wp below which stress effect shows up # DT Oct 10, 2012 changed this so it was not as sensitive to stress near -0.5 lwp # SK Sept 16, 2014 recalibrated/rescaled parameter estimates in Yang's paper. The scale of Boyer data wasn't set correctly # sensitivity = 1.92, LeafWPhalf = -1.86, the sensitivity parameter may be raised by 0.3 to 0.5 to make it less sensitivy at high LWP, SK s_f = 0.4258 # 0.5 psi_f = -1.4251 # -1.0 e = (1 + exp(s_f * psi_f)) / (1 + exp(s_f * (psi_f - (psi_predawn - psi_th)))) return clip(e, upper=1.0) @derive def water_potential_effect(self, threshold): # for MAIZSIM #return self._water_potential_effect(self.p.soil.WP_leaf_predawn, threshold) # for garlic return 1.0 @derive def carbon_effect(self): return 1.0 @accumulate(time='elongation_age', unit='cm') def length(self): #TODO: incorporate stress effects as done in actual_area_increase() return self.potential_elongation_rate @difference(time='elongation_age', unit='cm') def actual_length_increase(self): return self.potential_elongation_rate # actual area @derive(unit='cm^2') def area(self): # See Kim et al. (2012) Agro J. for more information on how this relationship has been derermined basned on multiple studies and is applicable across environments water_effect = self.water_potential_effect(-0.8657) # place holder carbon_effect = self.carbon_effect # growth temperature effect is now included here, outside of potential area increase calculation #TODO water and carbon effects are not multiplicative? return min( water_effect, carbon_effect) * self.temperature_effect * self.area_from_length( self.length) #TODO remove if unnecessary # @property # def actual_area_increase(self): # #FIXME area increase tracking should be done by some gobal state tracking manager # raise NotImplementedError("actual_area_increase") # # @property # def relative_area_increase(self): # #HACK meaning changed from 'relative to other leaves' (spatial) to 'relative to previous state' (temporal) # # adapted from CPlant::calcPerLeafRelativeAreaIncrease() # #return self.potential_area_increase / self.nodal_unit.plant.area.potential_leaf_increase # da = self.actual_area_increase # a = self.area - da # if a > 0: # return da / a # else: # return 0 @accumulate def stay_green_water_stress_duration(self, scale=0.5, threshold=-4.0): if self.mature: # One day of cumulative severe water stress (i.e., water_effect = 0.0 around -4MPa) would result in a reduction of leaf lifespan in relation staygreeness and growthDuration, SK # if scale is 1.0, one day of severe water stress shortens one day of stayGreenDuration #TODO remove WaterStress and use general Accumulator with a lambda function? return scale * (1 - self.water_potential_effect(threshold)) @derive def stay_green_duration(self): # SK 8/20/10: as in Sinclair and Horie, 1989 Crop sciences, N availability index scaled between 0 and 1 based on #nitrogen_index = max(0, (2 / (1 + exp(-2.9 * (self.g_content - 0.25))) - 1)) return clip(self.stay_green * self.growth_duration - self.stay_green_water_stress_duration, lower=0) @accumulate(unit='day') def active_age(self): # Assumes physiological time for senescence is the same as that for growth though this may be adjusted by stayGreen trait # a peaked fn like beta fn not used here because aging should accelerate with increasing T not slowing down at very high T like growth, # instead a q10 fn normalized to be 1 at T_opt is used, this means above Top aging accelerates. #TODO support clipping with @rate option or sub-decorator (i.e. @active_age.clip) #FIXME no need to check here, as it will be compared against duration later anyways #return min(self._aging_tracker.rate, self.stay_green_duration) if self.mature and not self.aging: #TODO only for MAIZSIM return q10_thermal_func(T=self.p.pheno.temperature, T_opt=self.p.pheno.optimal_temperature) @accumulate(unit='day') def senescence_water_stress_duration(self, scale=0.5, threshold=-4.0): if self.aging: # if scale is 0.5, one day of severe water stress at predawn shortens one half day of agingDuration return scale * (1 - self.water_potential_effect(threshold)) @derive(unit='day') def senescence_duration(self): # end of growth period, time to maturity return clip(self.growth_duration - self.senescence_water_stress_duration, lower=0) #TODO active_age and senescence_age could share a tracker with separate intervals @accumulate(unit='day') def senescence_age(self): #TODO support clipping with @rate option or sub-decorator (i.e. @active_age.clip) #FIXME no need to check here, as it will be compared against duration later anyways #return min(self._senescence_tracker.rate, self.senescence_duration) #FIXME need to remove dependency cycle? (senescence_age -> senescence_ratio -> dead -> senescence_age) if self.aging and not self.dead: return q10_thermal_func(T=self.p.pheno.temperature, T_opt=self.p.pheno.optimal_temperature) @derive #TODO confirm if it really means the senescence ratio, not rate def senescence_ratio(self): # for MAIZSIM # t = self.senescence_age # t_e = self.senescence_duration # if t >= t_e: # return 1 # else: # t_m = t_e / 2 # r = (1 + (t_e - t) / (t_e - t_m)) * (t / t_e)**(t_e / (t_e - t_m)) # return clip(r, 0., 1.) # for garlic #HACK prevents nan if self.length == 0: r = 0. else: r = self.aging_rate * self.senescence_age / self.length return clip(r, 0., 1.) @derive(unit='cm^2') def senescent_area(self): # Leaf senescence accelerates with drought and heat. see http://www.agry.purdue.edu/ext/corn/news/timeless/TopLeafDeath.html # rate = self._growth_rate(self.senescence_age, self.senescence_duration) # return rate * self.timestep * self.area return self.senescence_ratio * self.area @derive(unit='cm^2 / g') def specific_leaf_area(self): # temporary for now - it should vary by age. Value comes from some of Soo's work #return 200.0 try: return self.area / self.mass except: return 0 # Maturity @accumulate def maturity(self): #HACK: tracking should happen after plant emergence (due to implementation of original beginFromEmergence) if self.p.pheno.emerged and not self.mature: return growing_degree_days(T=self.p.pheno.temperature, T_base=4.0, T_max=40.0) # Nitrogen #FIXME avoid incorrect cycle detection (nitrogen member vs. module) - ? @derive def nitrogen(self): #TODO is this default value needed? # no N stress return 3.0 #TODO remove self.p.* referencing #FIXME enable Nitrogen trait #return self.p.nitrogen.leaf_content ########## # States # ########## @flag def initiated(self): # no explicit initialize() here return True @flag def appeared(self): return self.rank <= self.p.pheno.leaves_appeared @flag def growing(self): return self.appeared and not self.mature @flag def mature(self): return self.elongation_age >= self.growth_duration or self.area >= self.potential_area @flag def aging(self): # for MAIZSIM #return self.active_age >= self.stay_green_duration # for garlic return self.mature and self.physiological_age > self.stay_green * self.maturity @flag def dead(self): #return self.senescent_area >= self.area return self.senescence_ratio >= 1 #return self.senescence_age >= self.senescence_duration? @flag def dropped(self): return self.mature and self.dead
class Organ(System): plant = system(alias='p') # organ temperature, C temperature = drive('p.pheno', alias='T', unit='degC') # glucose, MW = 180.18 / 6 = 30.03 g @accumulate(alias='C', unit='g CH2O') def carbohydrate(self): return self.imported_carbohydrate # * self.respiration_adjustment # nitrogen content, mg nitrogen = accumulate('imported_nitrogen', alias='N', unit='g Nitrogen') # physiological age accounting for temperature effect (in reference to endGrowth and lifeSpan, days) @accumulate(unit='day') def physiological_age(self, T): #HACK: tracking should happen after plant emergence (due to implementation of original beginFromEmergence) if self.p.pheno.emerged: #TODO support species/cultivar specific temperature parameters #return growing_degree_days(T=self.p.pheno.temperature, T_base=8.0, T_max=43.3) return growing_degree_days(T=T, T_base=4.0, T_max=40.0) # chronological age of an organ, days # @physiological_age.period # def chronological_age(self): # pass # biomass, g # @derive # def mass(self): # #FIXME isn't it just the amount of carbohydrate? # #return self._carbohydrate / Weight.CH2O * Weight.C / Weight.C_to_CH2O_ratio # return self._carbohydrate #FIXME need unit conversion from CH2O? @derive(unit='g CH2O') def mass(self): return self.carbohydrate # #TODO remove set_mass() and directly access carbohydrate # def set_mass(self, mass): # #self._carbohydrate = mass * Weight.C_to_CH2O_ratio / Weight.C * Weight.CH2O # self._carbohydrate = mass # physiological days to reach the end of growth (both cell division and expansion) at optimal temperature, days @parameter(unit='day') def growth_duration(self): return 10 # life expectancy of an organ in days at optimal temperature (fastest growing temp), days #FIXME not used @parameter(unit='day') def longevity(self): return 50 # carbon allocation to roots or leaves for time increment #FIXME not used @derive(unit='g/hr CH2O') def potential_carbohydrate_increment(self): return 0 # carbon allocation to roots or leaves for time increment gr C for roots, gr carbo dt-1 #FIXME not used @derive(unit='g/hr CH2O') def actual_carbohydrate_increment(self): return 0 #TODO to be overridden @derive(unit='g/hr CH2O') def imported_carbohydrate(self): return 0 #TODO think about unit @derive def respiration_adjustment(self, Ka=0.1, Rm=0.02): # this needs to be worked on, currently not used at all # Ka: growth respiration # Rm: maintenance respiration return 1 - (Ka + Rm) #TODO to be overridden @derive(unit='g/hr Nitrogen') def imported_nitrogen(self): return 0