[docs]defmaybe_time_like(cls):""" Wrapper to add temporal properties and methods to a class. """@wraps(cls,updated=())@dataclassclasstemporal(cls):# start: Optional[datetime.datetime] = datetime.datetime(2020, 1, 1, 0, 0)start:Optional[datetime.datetime]=Noneend:Optional[datetime.datetime]=None@propertydefyear_planted(self):returnself.start.yeardefage(self,current_time=datetime.datetime.today())->datetime.timedelta:ifnotself.start:raiseValueError("Start time undefined, age indeterminable.")returncurrent_time-self.startdefyears(self,current_time=datetime.datetime.today())->int:returnint(timedelta.Timedelta(self.age(current_time)).total.days/365.25)defdays(self,current_time=datetime.datetime.today())->int:returntimedelta.Timedelta(self.age(current_time)).total.daysdefmins(self,current_time=datetime.datetime.today())->int:returntimedelta.Timedelta(self.age(current_time)).total.minutesreturntemporal
[docs]@dataclass(frozen=True)classConfig:""" Stores information about crop yields per species / varietal name """species:strname:str=""output_per_crop:float=1.0unit:str="cuerdas"def__eq__(self,other_cls):ifself.nameandother_cls.name:return(self.name==other_cls.nameandself.species==other_cls.speciesandself.unit==other_cls.unit)returnself.species==other_cls.speciesandself.unit==other_cls.unit
[docs]@maybe_time_like@dataclassclassPlot:""" Stores information about a farmer's plot. What is planted, how much, where, to whom does it belong? etc. """num:int=1# number of cropsarea:float=1.0plot_id:int=0species:str=field(default_factory=str)unit:str="cuerdas"@property# TODO deprecate / change tests?defsize(self)->float:returnself.area@staticmethoddefto_datetime(time)->datetime.datetime:ifisinstance(time,Number):# assumes `time` = yearreturndatetime.datetime(round(time),1,1,0,0)elifisinstance(time,datetime.datetime):returntimeelse:raiseValueError(f"Please specify a valid time. Given {type(time)}")@classmethoddeffrom_series(cls,series:pd.Series,**kwargs):""" Instantiates class from a ``pandas.Series`` object. This method primarily exists for backwards compatibility. Args: data (dict): Plot density (trees/unit area) **kwargs: Arbitrary keyword arguments. Returns: plot (Plot): ``Plot`` object. """returncls.from_density(density=1.0,plot_id=series.plotID,species=series.treeType,area=series.numCuerdas,unit="cuerdas",start=cls.to_datetime(series.yearPlanted),)@classmethoddeffrom_dict(cls,data:Dict,**kwargs):""" Instantiates class using a ``dict`` object. This method primarily exists for backwards compatibility. Args: data (dict): Plot density (trees/unit area) **kwargs: Arbitrary keyword arguments. Returns: plot (Plot): ``Plot`` object. """returncls.from_series(pd.Series(data))@classmethoddeffrom_density(cls,density:float=1.0,**kwargs):""" Instantiates class using a ``density`` argument instead of the number of trees. Args: density (float): Plot density (trees/unit area) **kwargs: Arbitrary keyword arguments. Returns: plot (Plot): ``Plot`` object. """plot=cls(**kwargs)plot.num=np.floor(plot.area*density)returnplot
[docs]@dataclassclassFarm:""" Container class for a collection of ``Plot`` objects. """plot_list:List[Plot]=field(default_factory=List)
[docs]@maybe_time_like@dataclassclassEvent:""" Stores information about events which impact harvest expectations. """name:strimpact:Optional[Union[float,Callable]]=1.0scope:Optional[Union[bool,Dict]]=field(default_factory=dict)defis_active(self,current_time:datetime.datetime=datetime.datetime.today(),plot:Optional[Plot]=None,)->bool:""" Performs checks on scope of event to determine whether or not this event will have an impact on the harvest. """time_check=self._check_time_window(current_time)# TODO: add more checks involving scopescope_check=self._check_scope(plot)returntime_checkandscope_checkdef_check_time_window(self,current_time=datetime.datetime.today())->bool:""" Determines if event is occurring at a specified time. """ifnotself.start:returnTrueifnotself.end:returnTrueage_in_mins=self.mins(current_time)returnage_in_mins>0andcurrent_time<=self.enddef_check_scope(self,plot:Optional[Plot]=None):""" Checks whether event is relevant / active with respect to the event scope. This determination can be based on species and location data of a plot. """ifisinstance(self.scope,bool):returnself.scopeifnotself.scope:_logger.warning("Scope definition missing, assuming inactive.")returnFalseifnotplot:_logger.warning("Plot definition missing, assuming inactive.")returnFalseifself.scope["type"]=="species":returnself.scope["def"]==plot.speciesifself.scope["type"]in("geo","gps","area"):# TODO: naming choicesraiseNotImplementedError("Geographic scope not yet implemented.")returnFalsedefeval(self,*args,**kwargs):ifisinstance(self.impact,Callable):returnself.impact(*args,**kwargs,**self.__dict__)returnself.impact
[docs]@lru_cache(maxsize=128)deffind_config(name:str,configs:Tuple[Config])->Config:""" Looks up a varietal by name in a collection of `Configs`. If name not found, will return config for the species instead, but throw an error if missing. Args: name (str): Name of varietal or species to look up configs (Tuple[Config]): registry of configs Returns: config (Config): relevant config entry Raises: KeyError: `name` could not be found in `configs` """# first check for name (to look for strategy)forcinconfigs:ifc.name==name:returnc# if none found, seek species default# TODO print warning about this behaviorwarnings.warn(("Could not find canonical match"f"for species=`{name}`, searching for""match against species instead."))forcinconfigs:ifc.species==name:returncraiseValueError(f"Could not find desired config for species=`{name}`")
[docs]defguate_harvest_function(lifespan:float=30,mature:float=5,retire:float=None)->Callable:""" Defines piecewise-linear function which approximates the growth patterns of coffee trees according to the coffee cooperative for which this simulation was first written. """ifnotretire:retire=lifespan-2assertretire<lifespanassertmature<retiredefgrowth(time:Union[datetime.datetime,float],plot:Plot,**kwargs):birth_year=plot.year_plantedcurrent_year=timeifisinstance(time,(float,int))elsetime.yearage=current_year-birth_yearifage<mature-1:return0ifage==mature-1:return0.2ifage<retire:return1.0ifage<=lifespan:return1.0-0.25*(age-retire)_logger.info("Replanting same species.")returngrowth(current_year-lifespan-1,plot)returngrowth
[docs]deftotal_impact(plot:Plot,time:datetime.datetime,events:List[Event])->float:""" Determines which events are relevant and multiplies all the associated impacts together in order to define the "total impact" of these events on a particular plot. """# - is it relevant to this plot? can check species, geography, etc.# - if so, what will be the impact to pass to the prediction function?# - is a strategy being applied? it has an impact too, is an eventifnotevents:_logger.warning("Events empty")return1.0relevant_events=[]foreinevents:# TODO more checks to determine this conditionife.is_active(plot=plot,current_time=time):relevant_events.append(e)_logger.debug(f"Found relevant event {e}")impact=np.prod([e.eval(time=time,plot=plot)foreinrelevant_events])returnimpact
[docs]defpredict_yield_for_plot(plot:Plot,config:Config,events:Optional[List[Event]]=None,time:datetime.datetime=datetime.datetime(2020,1,1),)->float:""" Predicts yields for a given plot, set of events, and collection of configs at a specified time. """# yield = area * crops/area * weight / crop# messy to compare two different classes but duck typing allows it...ifplot.species==config.speciesandplot.unit==config.unit:impact=total_impact(plot=plot,time=time,events=events)returnplot.num*config.output_per_crop*impactraiseValueError(f"Species mismatch, {plot}, {config}")
[docs]defpredict_yield_for_farm(farm:Farm,configs:List[Config],events:Optional[List[Event]]=None,time:datetime.datetime=datetime.datetime(2020,1,1),)->List[float]:ifeventsandnotisinstance(events,list):# TODO: add tests for thisevents=list(events)harvests=[]forpinfarm.plots:# print(f"Processing Plot {p.plot_id}")name=p.speciestry:c=find_config(name,configs)harvests.append(predict_yield_for_plot(plot=p,config=c,events=events,time=time))exceptValueErrorasv:_logger.warn(f"Caught {v}, skipping yield prediction for plot {p.plot_id}. Yield will be 0")harvests.append(0)returnharvests