Coverage for agent_model/agents/plant.py: 97%

105 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-05-04 13:14 +0700

1import numpy as np 

2from . import BaseAgent 

3from ..util import recursively_check_required_kwargs 

4 

5class 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)