1 import numpy as np

2 from . import BaseAgent

3 from ..util import recursively_check_required_kwargs

4

5 class PlantAgent(BaseAgent):

6 """Plant agent with growth and reproduction."""

7

8 default_attributes = {

9 # Lifecycle

10 'delay_start': 0,

11 'grown': False,

12 # Growth weights

13 'daily_growth_factor': 1,

14 'par_factor': 1,

15 'growth_rate': 0,

16 'cu_factor': 1,

17 'te_factor': 1,

18 }

19

20 required_kwargs = {

21 'flows': {'in': {'co2': 0, 'par': 0},

22 'out': {'biomass': 0, 'inedible_biomass': 0}},

23 'capacity': {'biomass': 0},

24 'properties': {'photoperiod': {'value': 0},

25 'lifetime': {'value': 0},

26 'par_baseline': {'value': 0}}}

27

28 def __init__(self, *args, attributes=None, **kwargs):

29 recursively_check_required_kwargs(kwargs, self.required_kwargs)

30

31 attributes = {} if attributes is None else attributes

32 attributes = {**self.default_attributes, **attributes}

33 super().__init__(*args, attributes=attributes, **kwargs)

34 if self.attributes['delay_start'] > 0:

35 self.active = 0

36 # -- NON_SERIALIZED

37 self.daily_growth = []

38 self.max_growth = 0

39

40 def register(self, record_initial_state=False):

41 super().register(record_initial_state=record_initial_state)

42 # Create the `daily_growth` attribute:

43 # - Length is equal to the number of steps per day (e.g. 24)

44 # - Average value is always equal to 1

45 # - `photoperiod` is the number of hours per day of sunlight the plant

46 # requires, which is centered about 12:00 noon. Values outside this

47 # period are 0, and during this period are calculated such that the

48 # mean of all numbers is 1.

49 steps_per_day = 24

50 photoperiod = self.properties['photoperiod']['value']

51 photo_start = (steps_per_day - photoperiod) // 2

52 photo_end = photo_start + photoperiod

53 photo_rate = steps_per_day / photoperiod

54 self.daily_growth = np.zeros(steps_per_day)

55 self.daily_growth[photo_start:photo_end] = photo_rate

56

57 # Max Growth is used to determine the growth rate (% of ideal)

58 lifetime = self.properties['lifetime']['value']

59 mean_biomass = self.flows['out']['biomass']['value']

60 self.max_growth = mean_biomass * lifetime

61

62 # To avoid intra-step fluctuation, we cache the response values in

63 # the model each step. TODO: This is a hack, and should be fixed.

64 if not hasattr(self.model, '_co2_response_cache'):

65 self.model._co2_response_cache = {

66 'step_num': 0,

67 'cu_factor': 1,

68 'te_factor': 1,

69 }

70

71 def get_flow_value(self, dT, direction, currency, flow, influx):

72 is_grown = self.attributes['grown']

73 on_grown = ('criteria' in flow and

74 any(c['path'] == 'grown' for c in flow['criteria']))

75 if ((is_grown and not on_grown) or

76 (not is_grown and on_grown)):

77 return 0.

78 return super().get_flow_value(dT, direction, currency, flow, influx)

79

80 def _calculate_co2_response(self):

81 if self.model._co2_response_cache['step_num'] != self.model.step_num:

82 ref_agent_name = self.flows['in']['co2']['connections'][0]

83 ref_agent = self.model.agents[ref_agent_name]

84 ref_atm = ref_agent.view('atmosphere')

85 co2_ppm = ref_atm['co2'] / sum(ref_atm.values()) * 1e6

86 co2_actual = max(350, min(co2_ppm, 700))

87 # CO2 Uptake Factor: Decrease growth if actual < ideal

88 if ('carbon_fixation' not in self.properties or

89 self.properties['carbon_fixation']['value'] != 'c3'):

90 cu_ratio = 1

91 else:

92 # Standard equation found in research; gives *increase* in growth for eCO2

93 t_mean = 25 # Mean temperature for timestep.

94 tt = (163 - t_mean) / (5 - 0.1 * t_mean) # co2 compensation point

95 numerator = (co2_actual - tt) * (350 + 2 * tt)

96 denominator = (co2_actual + 2 * tt) * (350 - tt)

97 cu_ratio = numerator/denominator

98 # Invert the above to give *decrease* in growth for less than ideal CO2

99 crf_ideal = 1.2426059597016264 # At 700ppm, the above equation gives this value

100 cu_ratio = cu_ratio / crf_ideal

101 # Transpiration Efficiency Factor: Increase water usage if actual < ideal

102 co2_range = [350, 700]

103 te_range = [1/1.37, 1] # Inverse of previously used

104 te_factor = np.interp(co2_actual, co2_range, te_range)

105 # Cache the values

106 self.model._co2_response_cache = {

107 'step_num': self.model.step_num,

108 'cu_factor': cu_ratio,

109 'te_factor': te_factor,

110 }

111 cached = self.model._co2_response_cache

112 return cached['cu_factor'], cached['te_factor']

113

114 def step(self, dT=1):

115 if not self.registered:

116 self.register()

117 # --- LIFECYCLE ---

118 # Delay start

119 if self.attributes['delay_start']:

120 super().step(dT)

121 self.attributes['delay_start'] -= dT

122 if self.attributes['delay_start'] <= 0:

123 self.active = self.amount

124 return

125 # Grown

126 if self.attributes['age'] >= self.properties['lifetime']['value']:

127 self.attributes['grown'] = True

128

129 # --- WEIGHTS ---

130 # Daily growth

131 hour_of_day = self.model.time.hour

132 self.attributes['daily_growth_factor'] = self.daily_growth[hour_of_day]

133 # Par Factor

134 # 12/22/22: Electric lamps and sunlight work differently.

135 # - Lamp.par is multiplied by the lamp amount (to scale kwh consumption)

136 # - Sun.par is not, because there's nothing to scale and plants can't

137 # compete over it. Sunlight also can't be incremented.

138 # TODO: Implement a grid layout system; add/take par from grid cells

139 par_ideal = self.properties['par_baseline']['value'] * self.attributes['daily_growth_factor']

140 light_type = self.flows['in']['par']['connections'][0]

141 light_agent = self.model.agents[light_type]

142 is_electric = ('sun' not in light_type)

143 if is_electric:

144 par_ideal *= self.active

145 exchange = light_agent.increment('par', -par_ideal)

146 par_available = abs(sum(exchange.values()))

147 else:

148 par_available = light_agent.storage['par']

149 self.attributes['par_factor'] = (0 if par_ideal == 0

150 else min(1, par_available / par_ideal))

151 # Growth Rate: *2, because expected to sigmoid so max=2 -> mean=1

152 if self.active == 0:

153 self.attributes['growth_rate'] = 0

154 else:

155 stored_biomass = sum(self.view('biomass').values())

156 fraction_of_max = stored_biomass / self.active / self.max_growth

157 self.attributes['growth_rate'] = fraction_of_max * 2

158 # CO2 response

159 cu_factor, te_factor = self._calculate_co2_response()

160 self.attributes['cu_factor'] = cu_factor

161 self.attributes['te_factor'] = te_factor

162

163 super().step(dT)

164

165 # Rproduction

166 if self.attributes['grown']:

167 self.storage['biomass'] = 0

168 if ('reproduce' not in self.properties or

169 not self.properties['reproduce']['value']):

170 self.kill(f'{self.agent_id} reached end of life')

171 else:

172 self.active = self.amount

173 self.attributes = {**self.attributes,

174 **self.default_attributes,

175 'age': 0}

176

177 def kill(self, reason, n_dead=None):

178 # Convert dead biomass to inedible biomass

179 if n_dead is None:

180 n_dead = self.active

181 dead_biomass = self.view('biomass')['biomass'] * n_dead / self.active

182 if dead_biomass:

183 self.storage['biomass'] -= dead_biomass

184 ined_bio_str_agent = self.flows['out']['inedible_biomass']['connections'][0]

185 ined_bio_str_agent = self.model.agents[ined_bio_str_agent]

186 ined_bio_str_agent.increment('inedible_biomass', dead_biomass)

187 super().kill(reason, n_dead=n_dead)