class S(System): @system def t(self): return T @drive(alias='aa') def a(self): return self.t b = drive('t', alias='bb') c = derive('t.b')
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 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 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