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
« 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
5class PlantAgent(BaseAgent):
6 """Plant agent with growth and reproduction."""
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 }
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}}}
28 def __init__(self, *args, attributes=None, **kwargs):
29 recursively_check_required_kwargs(kwargs, self.required_kwargs)
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
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
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
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 }
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)
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']
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
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
163 super().step(dT)
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}
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)