class VaporPressure(System): # Campbell and Norman (1998), p 41 Saturation vapor pressure in kPa a = parameter(0.611) # kPa b = parameter(17.502) # C c = parameter(240.97) # C @derive(alias='es') def saturation(self, T, *, a, b, c): return a * np.exp((b * T) / (c + T)) @derive(alias='ea') def ambient(self, T, RH): return self.es(T) * RH @derive(alias='vpd') def deficit(self, T, RH): return self.es(T) * (1 - RH) @derive(alias='rh') def relative_humidity(self, T, VPD): return 1 - VPD / self.es(T) # slope of the sat vapor pressure curve: first order derivative of Es with respect to T @derive(alias='cs') def curve_slope(self, T, P, *, b, c): return self.es(T) * (b * c) / (c + T)**2 / P
class VaporPressure(System): # Campbell and Norman (1998), p 41 Saturation vapor pressure in kPa a = parameter(0.611) # kPa b = parameter(17.502) # C c = parameter(240.97) # C @derive(alias='es', unit='kPa', nounit='T') def saturation(self, T, *, a, b, c): return a*exp((b*T)/(c+T)) @derive(alias='ea', unit='kPa') def ambient(self, T, RH): return self.es(T) * RH @derive(alias='D', unit='kPa') def deficit(self, T, RH): return self.es(T) * (1 - RH) @derive(alias='rh', unit='') def relative_humidity(self, T, VPD): return 1 - VPD / self.es(T) # slope of the sat vapor pressure curve: first order derivative of Es with respect to T @derive(alias='Delta', unit='kPa/degC', nounit='T') def _saturation_slope(self, T, *, b, c): return self.es(T) * (b*c)/(c+T)**2 / U(1, 'degC') @derive(alias='s', unit='1/degC') def saturation_slope(self, T, P): return self.Delta(T) / P
class S(System): a = parameter(1, unit='m') b = parameter(1, unit='m') c = parameter(1, unit='m') d = parameter(1, unit='m') e = parameter(1) @parameter(unit='m') def f(self, ff='1m'): return self.a + ff
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 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 Location(System): latitude = parameter(36, unit='deg') longitude = parameter(128, unit='deg') altitude = parameter(20, unit='m')
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 Stomata(System): @system def leaf(self): return System # Ball-Berry model parameters from Miner and Bauerle 2017, used to be 0.04 and 4.0, respectively (2018-09-04: KDY) g0 = parameter(0.017, unit='mmol/m^2/s H2O') g1 = parameter(4.53) A_net = proxy('leaf.A_net') CO2 = proxy('leaf.weather.CO2') RH = proxy('leaf.weather.RH') @parameter(alias='drb', unit='H2O/CO2') def diffusivity_ratio_boundary_layer(self): return 1.37 @parameter(alias='dra', unit='H2O/CO2') def diffusivity_ratio_air(self): return 1.6 @derive(alias='gb', unit='mol/m^2/s H2O', nounit='lw,ww') # def update_boundary_layer(self, wind): def boundary_layer_conductance(self, lw='leaf.width', ww='leaf.weather.wind'): # maize is an amphistomatous species, assume 1:1 (adaxial:abaxial) ratio. #sr = 1.0 # switchgrass adaxial : abaxial (Awada 2002) # https://doi.org/10.4141/P01-031 sr = 1.28 ratio = (sr + 1)**2 / (sr**2 + 1) # characteristic dimension of a leaf, leaf width in m d = lw * 0.72 #return 1.42 # total BLC (both sides) for LI6400 leaf chamber gb = 1.4 * 0.147 * (clip(ww, lower=0.1) / d)**0.5 * ratio #gb = (1.4 * 1.1 * 6.62 * (wind / d)**0.5 * (P_air / (R * (273.15 + T_air)))) # this is an alternative form including a multiplier for conversion from mm s-1 to mol m-2 s-1 # 1.1 is the factor to convert from heat conductance to water vapor conductance, an avarage between still air and laminar flow (see Table 3.2, HG Jones 2014) # 6.62 is for laminar forced convection of air over flat plates on projected area basis # when all conversion is done for each surface it becomes close to 0.147 as given in Norman and Campbell # multiply by 1.4 for outdoor condition, Campbell and Norman (1998), p109, also see Jones 2014, pg 59 which suggest using 1.5 as this factor. # multiply by ratio to get the effective blc (per projected area basis), licor 6400 manual p 1-9 return gb # stomatal conductance for water vapor in mol m-2 s-1 # gamma: 10.0 for C4 maize #FIXME T_leaf not used @derive(alias='gs', init='g0', unit='mol/m^2/s H2O') # def update_stomata(self, LWP, CO2, A_net, RH, T_leaf): #def stomatal_conductance(self, g0, g1, gb, m, A_net='leaf.A_net', CO2='leaf.weather.CO2', RH='leaf.weather.RH', gamma=10): def stomatal_conductance(self, g0, g1, gb, m, A_net, CO2, RH, drb, gamma=U(10, 'umol/mol')): Cs = CO2 - (drb * A_net / gb) # surface CO2 in mole fraction Cs = clip(Cs, lower=gamma) a = m * g1 * A_net / Cs b = g0 + gb - a c = (-RH * gb) - g0 #hs = max(np.roots([a, b, c])) #hs = scipy.optimize.brentq(lambda x: np.polyval([a, b, c], x), 0, 1) #hs = scipy.optimize.fsolve(lambda x: np.polyval([a, b, c], x), 0) hs = quadratic_solve_upper(a, b, c) #hs = clip(hs, 0.1, 1.0) # preventing bifurcation: used to be (0.3, 1.0) for C4 maize #FIXME unused? #T_leaf = l.temperature #es = w.vp.saturation(T_leaf) #Ds = (1 - hs) * es # VPD at leaf surface #Ds = w.vp.deficit(T_leaf, hs) gs = g0 + (g1 * m * (A_net * hs / Cs)) gs = clip(gs, lower=g0) return gs @derive(alias='m') def leafp_effect(self, LWP='leaf.soil.WP_leaf', sf=U(2.3, '1/MPa'), phyf=U(-2.0, 'MPa')): return (1 + np.exp(sf * phyf)) / (1 + np.exp(sf * (phyf - LWP))) @derive(alias='gv', unit='mmol/m^2/s H2O') def total_conductance_h2o(self, gs, gb): return gs * gb / (gs + gb) @derive(alias='rbc') def boundary_layer_resistance_co2(self, gb, drb): return drb / gb @derive(alias='rsc') def stomatal_resistance_co2(self, gs, dra): return dra / gs @derive(alias='rvc') def total_resistance_co2(self, rbc, rsc): return rbc + rsc