# -*- coding: utf-8 -*-
# Copyright (c) 2004-2015 Alterra, Wageningen-UR
# Allard de Wit (allard.dewit@wur.nl), Juli 2015
# from __future__ import print_function
"""Implementation of AgroManager and related classes for agromanagement actions in PCSE.
Available classes:
* CropCalendar: A class for handling cropping calendars
* TimedEventDispatcher: A class for handling timed events (e.g. events connected to a date)
* StateEventDispatcher: A class for handling state events (e.g. events that happen when a state variable reaches
a certain values.
* AgroManager: A class for handling all agromanagement events which encapsulates
the CropCalendar and Timed/State events.
"""
from datetime import date, timedelta
import logging
from collections import Counter
from .base import DispatcherObject, VariableKiosk, SimulationObject, ParameterProvider, AncillaryObject
from .traitlets import HasTraits, Float, Int, Instance, Enum, Bool, List, Dict, Unicode
from . import exceptions as exc
from .util import ConfigurationLoader
from . import signals
from . import exceptions as exc
def cmp2(x, y):
"""
Compare two values and return sign
Surrogate for cmp() function in Python2
"""
return (x > y) - (x < y)
def check_date_range(day, start, end):
"""returns True if start <= day < end
Optionally, end may be None. in that case return True if start <= day
:param day: the date that will be checked
:param start: the start date of the range
:param end: the end date of the range or None
:return: True/False
"""
if end is None:
return start <= day
else:
return start <= day < end
def take_first(iterator):
"""Return the first item of the given iterator.
"""
for item in iterator:
return item
[docs]class CropCalendar(HasTraits, DispatcherObject):
"""A crop calendar for managing the crop cycle.
A `CropCalendar` object is responsible for storing, checking, starting and ending
the crop cycle. The crop calendar is initialized by providing the parameters needed
for defining the crop cycle. At each time step the instance of `CropCalendar` is called
and at dates defined by its parameters it initiates the appropriate actions:
- sowing/emergence: A `crop_start` signal is dispatched including the parameters needed to
start the new crop simulation object
- maturity/harvest: the crop cycle is ended by dispatching a `crop_finish` signal with the
appropriate parameters.
:param kiosk: The PCSE VariableKiosk instance
:param crop_name: String identifying the crop
:param variety_name: String identifying the variety
:param crop_start_date: Start date of the crop simulation
:param crop_start_type: Start type of the crop simulation ('sowing', 'emergence')
:param crop_end_date: End date of the crop simulation
:param crop_end_type: End type of the crop simulation ('harvest', 'maturity', 'earliest')
:param max_duration: Integer describing the maximum duration of the crop cycle
:return: A CropCalendar Instance
"""
# Characteristics of the crop cycle
crop_name = Unicode()
variety_name = Unicode()
crop_start_date = Instance(date)
crop_start_type = Enum(["sowing", "emergence"])
crop_end_date = Instance(date)
crop_end_type = Enum(["maturity", "harvest", "earliest"])
max_duration = Int()
# system parameters
kiosk = Instance(VariableKiosk)
parameterprovider = Instance(ParameterProvider)
mconf = Instance(ConfigurationLoader)
logger = Instance(logging.Logger)
# Counter for duration of the crop cycle
duration = Int(0)
in_crop_cycle = Bool(False)
def __init__(self, kiosk, crop_name=None, variety_name=None, crop_start_date=None,
crop_start_type=None, crop_end_date=None, crop_end_type=None, max_duration=None):
# set up logging
loggername = "%s.%s" % (self.__class__.__module__,
self.__class__.__name__)
self.logger = logging.getLogger(loggername)
self.kiosk = kiosk
self.crop_name = crop_name
self.variety_name = variety_name
self.crop_start_date = crop_start_date
self.crop_start_type = crop_start_type
self.crop_end_date = crop_end_date
self.crop_end_type = crop_end_type
self.max_duration = max_duration
self._connect_signal(self._on_CROP_FINISH, signal=signals.crop_finish)
[docs] def validate(self, campaign_start_date, next_campaign_start_date):
"""Validate the crop calendar internally and against the interval for
the agricultural campaign.
:param campaign_start_date: start date of this campaign
:param next_campaign_start_date: start date of the next campaign
"""
# Check that crop_start_date is before crop_end_date
crop_end_date = self.crop_end_date
if self.crop_end_type == "maturity":
crop_end_date = self.crop_start_date + timedelta(days=self.max_duration)
if self.crop_start_date >= crop_end_date:
msg = "crop_end_date before or equal to crop_start_date for crop '%s'!"
raise exc.PCSEError(msg % (self.crop_start_date, self.crop_end_date))
# check that crop_start_date is within the campaign interval
r = check_date_range(self.crop_start_date, campaign_start_date, next_campaign_start_date)
if r is not True:
msg = "Start date (%s) for crop '%s' vareity '%s' not within campaign window (%s - %s)." % \
(self.crop_start_date, self.crop_name, self.variety_name,
campaign_start_date, next_campaign_start_date)
raise exc.PCSEError(msg)
def __call__(self, day):
"""Runs the crop calendar to determine if any actions are needed.
:param day: a date object for the current simulation day
:param drv: the driving variables at this day
:return: None
"""
if self.in_crop_cycle:
self.duration += 1
# Start of the crop cycle
if day == self.crop_start_date: # Start a new crop
self.duration = 0
self.in_crop_cycle = True
msg = "Starting crop (%s) with variety (%s) on day %s" % (self.crop_name, self.variety_name, day)
self.logger.info(msg)
self._send_signal(signal=signals.crop_start, day=day, crop_name=self.crop_name,
variety_name=self.variety_name, crop_start_type=self.crop_start_type,
crop_end_type=self.crop_end_type)
# end of the crop cycle
finish_type = None
if self.in_crop_cycle:
# Check if crop_end_date is reached for CROP_END_TYPE harvest/earliest
if self.crop_end_type in ["harvest", "earliest"]:
if day == self.crop_end_date:
finish_type = "harvest"
# Check for forced stop because maximum duration is reached
if self.in_crop_cycle and self.duration == self.max_duration:
finish_type = "max_duration"
# If finish condition is reached send a signal to finish the crop
if finish_type is not None:
self.in_crop_cycle = False
self._send_signal(signal=signals.crop_finish, day=day,
finish_type=finish_type, crop_delete=True)
def _on_CROP_FINISH(self):
"""Register that crop has reached the end of its cycle.
"""
self.in_crop_cycle = False
[docs] def get_end_date(self):
"""Return the end date of the crop cycle.
This is either given as the harvest date or calculated as
crop_start_date + max_duration
:return: a date object
"""
if self.crop_end_type in ["harvest", 'earliest']:
return self.crop_end_date
else:
return self.crop_start_date + timedelta(days=self.max_duration)
[docs] def get_start_date(self):
"""Returns the start date of the cycle. This is always self.crop_start_date
:return: the start date
"""
return self.crop_start_date
[docs]class TimedEventsDispatcher(HasTraits, DispatcherObject):
"""Takes care handling events that are connected to a date.
Events are handled by dispatching a signal (taken from the `signals` module)
and providing the relevant parameters with the signal. TimedEvents can be
most easily understood when looking at the definition in the agromanagement
file. The following section (in YAML) provides the definition of two instances
of TimedEventsDispatchers::
TimedEvents:
- event_signal: irrigate
name: Timed irrigation events
comment: All irrigation amounts in mm
events_table:
- 2000-01-01: {irrigation_amount: 20}
- 2000-01-21: {irrigation_amount: 50}
- 2000-03-18: {irrigation_amount: 30}
- 2000-03-19: {irrigation_amount: 25}
- event_signal: apply_npk
name: Timed N/P/K application table
comment: All fertilizer amounts in kg/ha
events_table:
- 2000-01-10: {N_amount : 10, P_amount: 5, K_amount: 2}
- 2000-01-31: {N_amount : 30, P_amount: 15, K_amount: 12}
- 2000-03-25: {N_amount : 50, P_amount: 25, K_amount: 22}
- 2000-04-05: {N_amount : 70, P_amount: 35, K_amount: 32}
Each TimedEventDispatcher is defined by an `event_signal`, an optional name,
an optional comment and the events_table. The events_table is list which provides
for each date the parameters that should be dispatched with the given
event_signal.
"""
event_signal = None
events_table = List()
days_with_events = Instance(Counter)
kiosk = Instance(VariableKiosk)
logger = Instance(logging.Logger)
name = Unicode()
comment = Unicode()
def __init__(self, kiosk, event_signal, name, comment, events_table):
"""Initialising a TimedEventDispatcher
:param kiosk: an instance of the VariableKiosk
:param event_signal: the signal to be dispatched when the event occurs (from pcse.signals)
:param name: the name of the event dispatcher
:param comment: A comment that will be used in log message
:param events_table: The events table, the structure here is a list of dicts, with each dict having only
one key/value with the key being the date of the event and the value a dict of parameter values
that should be dispatched with the signal.
"""
# set up logging
loggername = "%s.%s" % (self.__class__.__module__,
self.__class__.__name__)
self.logger = logging.getLogger(loggername)
self.kiosk = kiosk
self.events_table = events_table
self.name = name
self.comment = comment
# get signal from signals module
if not hasattr(signals, event_signal):
msg = "Signal '%s' not defined in pcse.signals module."
raise exc.PCSEError(msg % event_signal)
# self.event_signal = getattr(signals, event_signal)
self.event_signal = getattr(signals, event_signal)
# Build a counter for the days with events.
self.days_with_events = Counter()
for ev in self.events_table:
self.days_with_events.update(ev.keys())
# Check if there are days with two or more events under the
# same signal which is not allowed.
multi_days = []
for day, count in self.days_with_events.items():
if count > 1:
multi_days.append(day)
if multi_days:
msg = "Found days with more than 1 event for events table '%s' on days: %s"
raise exc.PCSEError(msg % (self.name, multi_days))
[docs] def validate(self, campaign_start_date, next_campaign_start_date):
"""Validates the timed events given the campaign window
:param campaign_start_date: Start date of the campaign
:param next_campaign_start_date: Start date of the next campaign, can be None
"""
for event in self.events_table:
day = list(event.keys())[0]
r = check_date_range(day, campaign_start_date, next_campaign_start_date)
if r is not True:
msg = "Timed event at day %s not in campaign interval (%s - %s)" %\
(day, campaign_start_date, next_campaign_start_date)
raise exc.PCSEError(msg)
def __call__(self, day):
"""Runs the TimedEventDispatcher to determine if any actions are needed.
:param day: a date object for the current simulation day
:return: None
"""
if day not in self.days_with_events:
return
for event in self.events_table:
if day in event:
msg = "Time event dispatched from '%s' at day %s" % (self.name, day)
self.logger.info(msg)
kwargs = event[day]
self._send_signal(signal=self.event_signal, **kwargs)
[docs] def get_end_date(self):
"""Returns the last date for which a timed event is given
"""
return max(self.days_with_events)
[docs]class StateEventsDispatcher(HasTraits, DispatcherObject):
"""Takes care handling events that are connected to a model state variable.
Events are handled by dispatching a signal (taken from the `signals` module)
and providing the relevant parameters with the signal. StateEvents can be
most easily understood when looking at the definition in the agromanagement
file. The following section (in YAML) provides the definition of two instances
of StateEventsDispatchers::
StateEvents:
- event_signal: apply_npk
event_state: DVS
zero_condition: rising
name: DVS-based N/P/K application table
comment: all fertilizer amounts in kg/ha
events_table:
- 0.3: {N_amount : 1, P_amount: 3, K_amount: 4}
- 0.6: {N_amount: 11, P_amount: 13, K_amount: 14}
- 1.12: {N_amount: 21, P_amount: 23, K_amount: 24}
- event_signal: irrigate
event_state: SM
zero_condition: falling
name: Soil moisture driven irrigation scheduling
comment: all irrigation amounts in cm of water
events_table:
- 0.15: {irrigation_amount: 20}
Each StateEventDispatcher is defined by an `event_signal`, an `event_state` (e.g. the model
state that triggers the event) and a `zero condition`. Moreover, an optional name and an
optional comment can be provided. Finally the events_table specifies at which model state values
the event occurs. The events_table is a list which provides for each state the parameters that
should be dispatched with the given event_signal.
For finding the time step at which a state event occurs PCSE uses the concept of `zero-crossing`.
This means that a state event is triggered when (`model_state` - `event_state`) equals or
crosses zero. The `zero_condition` defines how this crossing should take place. The value of `zero_condition`
can be:
* `rising`: the event is triggered when (`model_state` - `event_state`) goes from a negative value towards
zero or a positive value.
* `falling`: the event is triggered when (`model_state` - `event_state`) goes from a positive value towards
zero or a negative value.
* `either`: the event is triggered when (`model_state` - `event_state`) crosses or reaches zero from any
direction.
The impact of the zero_condition can be illustrated using the example definitions above.
The development stage of the crop (DVS) only increases from 0 at emergence to 2 at maturity. A StateEvent
set on the DVS (first example) will therefore logically have a zero_condition 'rising' although 'either'
could be used as well. A DVS-based event will not occur with zero_condition set to 'falling' as the value
of DVS will not decrease.
The soil moisture (SM) however can both increase and decrease. A StateEvent for applying irrigation (second
example) will therefore be specified with a zero_condition 'falling' because the event must be triggered
when the soil moisture level reaches or crosses the minimum level specified by the events_table. Note that
if we set the zero_condition to 'either' the event would probably occur again the next time-step because
the irrigation amount increase the soil moisture and (`model_state` - `event_state`) crosses zero again
but from the other direction.
"""
event_signal = None
event_state = Unicode()
zero_condition = Enum(['rising', 'falling', 'either'])
events_table = List()
kiosk = Instance(VariableKiosk)
logger = Instance(logging.Logger)
name = Unicode()
comment = Unicode()
previous_signs = List()
def __init__(self, kiosk, event_signal, event_state, zero_condition, name,
comment, events_table):
"""Initialising a StateEventDispatcher
:param kiosk: an instance of the VariableKiosk
:param event_signal: the signal to be dispatched when the event occurs (from pcse.signals)
:param event_state: the name of the state variable that should trigger the event
:param zero_condition: the zero_condition, one of 'rising'|'falling'|'either'
:param name: the name of the event dispatcher
:param comment: A comment that will be used in log message
:param events_table: The events table, the structure here is a list of dicts, with each dict having only
one key/value with the key being the value of the state that should trigger the event and the
value a dict of parameter values that should be dispatched with the signal.
"""
# set up logging
loggername = "%s.%s" % (self.__class__.__module__,
self.__class__.__name__)
self.logger = logging.getLogger(loggername)
self.kiosk = kiosk
self.events_table = events_table
self.zero_condition = zero_condition
self.event_state = event_state
self.name = name
self.comment = comment
# assign evaluation function for states
if self.zero_condition == 'falling':
self._evaluate_state = self._zero_condition_falling
elif self.zero_condition == 'rising':
self._evaluate_state = self._zero_condition_rising
elif self.zero_condition == 'either':
self._evaluate_state = self._zero_condition_either
# assign Nones to self.zero_condition_signs to signal
# that the sign have not yet been evaluated
self.previous_signs = [None]*len(self.events_table)
# get signal from signals module
if not hasattr(signals, event_signal):
msg = "Signal '%s' not defined in pcse.signals module."
raise exc.PCSEError(msg % event_signal)
self.event_signal = getattr(signals, event_signal)
# Build a counter for the state events.
self.states_with_events = Counter()
for ev in self.events_table:
self.states_with_events.update(ev.keys())
# Check if there are days with two or more events under the
# same signal which is not allowed.
multi_states = []
for state, count in self.states_with_events.items():
if count > 1:
multi_states.append(state)
if multi_states:
msg = "Found states with more than 1 event for events table '%s' for state: %s"
raise exc.PCSEError(msg % (self.name, multi_states))
def __call__(self, day):
"""Runs the TimedEventDispatcher to determine if any actions are needed.
:param day: a date object for the current simulation day
:return: None
"""
if not self.event_state in self.kiosk:
msg = "State variable '%s' not (yet) available in kiosk!" % self.event_state
self.logger.warning(msg)
return
# Determine if any event should be trigger based on the current state and
# the event_condition.
current_state = self.kiosk[self.event_state]
zero_condition_signs = []
for event, zero_condition_sign in zip(self.events_table, self.previous_signs):
state, keywords = take_first(event.items())
zcs = self._evaluate_state(current_state, state, keywords, zero_condition_sign)
zero_condition_signs.append(zcs)
self.previous_signs = zero_condition_signs
def _zero_condition_falling(self, current_state, state, keywords, zero_condition_sign):
sign = cmp2(current_state - state, 0)
# is None: e.g. called the first time and zero_condition_sign is not yet calculated
if zero_condition_sign is None:
return sign
if zero_condition_sign == 1 and sign in [-1, 0]:
msg = "State event dispatched from '%s' at event_state %s" % (self.name, state)
self.logger.info(msg)
self._send_signal(signal=self.event_signal, **keywords)
return sign
def _zero_condition_rising(self, current_state, state, kwargs, zero_condition_sign):
sign = cmp2(current_state - state, 0)
# is None: e.g. called the first time and zero_condition_sign is not yet calculated
if zero_condition_sign is None:
return sign
if zero_condition_sign == -1 and sign in [0, 1]:
msg = "State event dispatched from '%s' at model state %s" % (self.name, current_state)
self.logger.info(msg)
self._send_signal(signal=self.event_signal, **kwargs)
return sign
def _zero_condition_either(self, current_state, state, keywords, zero_condition_sign):
sign = cmp2(current_state - state, 0)
# is None: e.g. called the first time and zero_condition_sign is not yet calculated
if zero_condition_sign is None:
return sign
if (zero_condition_sign == 1 and sign in [-1, 0]) or \
(zero_condition_sign == -1 and sign in [0, 1]):
msg = "State event dispatched from %s at event_state %s" % (self.name, state)
self.logger.info(msg)
self._send_signal(signal=self.event_signal, **keywords)
return sign
[docs]class AgroManager(AncillaryObject):
"""Class for continuous AgroManagement actions including crop rotations and events.
See also the documentation for the classes `CropCalendar`, `TimedEventDispatcher` and `StateEventDispatcher`.
The AgroManager takes care of executing agromanagent actions that typically occur on agricultural
fields including planting and harvesting of the crop, as well as management actions such as fertilizer
application, irrigation, mowing and spraying.
The agromanagement during the simulation is implemented as a sequence of campaigns. Campaigns start on a
prescribed calendar date and finalize when the next campaign starts. The simulation ends either explicitly by
provided a trailing empty campaign or by deriving the end date from the crop calendar and timed events in the
last campaign. See also the section below on `end_date` property.
Each campaign is characterized by zero or one crop calendar, zero or more timed events and zero or more
state events.
The structure of the data needed as input for AgroManager is most easily understood with the example
(in YAML) below. The definition consists of three campaigns, the first starting on 1999-08-01, the second
starting on 2000-09-01 and the last campaign starting on 2001-03-01. The first campaign consists of a crop
calendar for winter-wheat starting with sowing at the given crop_start_date. During the campaign there are
timed events for irrigation at 2000-05-25 and 2000-06-30. Moreover, there are state events for fertilizer
application (event_signal: apply_npk) given by development stage (DVS) at DVS 0.3, 0.6 and 1.12.
The second campaign has no crop calendar, timed events or state events. This means that this is a period of
bare soil with only the water balance running. The third campaign is for fodder maize sown at 2001-04-15
with two series of timed events (one for irrigation and one for N/P/K application) and no state events.
The end date of the simulation in this case will be 2001-11-01 (2001-04-15 + 200 days).
An example of an agromanagement definition file::
AgroManagement:
- 1999-08-01:
CropCalendar:
crop_name: wheat
variety_name: winter-wheat
crop_start_date: 1999-09-15
crop_start_type: sowing
crop_end_date:
crop_end_type: maturity
max_duration: 300
TimedEvents:
- event_signal: irrigate
name: Timed irrigation events
comment: All irrigation amounts in cm
events_table:
- 2000-05-25: {irrigation_amount: 3.0}
- 2000-06-30: {irrigation_amount: 2.5}
StateEvents:
- event_signal: apply_npk
event_state: DVS
zero_condition: rising
name: DVS-based N/P/K application table
comment: all fertilizer amounts in kg/ha
events_table:
- 0.3: {N_amount : 1, P_amount: 3, K_amount: 4}
- 0.6: {N_amount: 11, P_amount: 13, K_amount: 14}
- 1.12: {N_amount: 21, P_amount: 23, K_amount: 24}
- 2000-09-01:
CropCalendar:
TimedEvents:
StateEvents
- 2001-03-01:
CropCalendar:
crop_name: maize
variety_name: fodder-maize
crop_start_date: 2001-04-15
crop_start_type: sowing
crop_end_date:
crop_end_type: maturity
max_duration: 200
TimedEvents:
- event_signal: irrigate
name: Timed irrigation events
comment: All irrigation amounts in cm
events_table:
- 2001-06-01: {irrigation_amount: 2.0}
- 2001-07-21: {irrigation_amount: 5.0}
- 2001-08-18: {irrigation_amount: 3.0}
- 2001-09-19: {irrigation_amount: 2.5}
- event_signal: apply_npk
name: Timed N/P/K application table
comment: All fertilizer amounts in kg/ha
events_table:
- 2001-05-25: {N_amount : 50, P_amount: 25, K_amount: 22}
- 2001-07-05: {N_amount : 70, P_amount: 35, K_amount: 32}
StateEvents:
"""
# campaign start dates
campaign_start_dates = List()
# Overall engine start date and end date
_start_date = Instance(date)
_end_date = Instance(date)
# campaign definitions
crop_calendars = List()
timed_event_dispatchers = List()
state_event_dispatchers = List()
_tmp_date = None # Helper variable
_icampaign = 0 # count the campaigns
[docs] def initialize(self, kiosk, agromanagement):
"""Initialize the AgroManager.
:param kiosk: A PCSE variable Kiosk
:param agromanagement: the agromanagement definition, see the example above in YAML.
"""
self.kiosk = kiosk
self.crop_calendars = []
self.timed_event_dispatchers = []
self.state_event_dispatchers = []
self.campaign_start_dates = []
# Connect CROP_FINISH signal with handler
self._connect_signal(self._on_CROP_FINISH, signals.crop_finish)
# If there is an "AgroManagement" item defined then we first need to get
# the contents defined within that item
if "AgroManagement" in agromanagement:
agromanagement = agromanagement["AgroManagement"]
# First get and validate the dates of the different campaigns
for campaign in agromanagement:
# Check if campaign start dates is in chronological order
campaign_start_date = take_first(campaign.keys())
self._check_campaign_date(campaign_start_date)
self.campaign_start_dates.append(campaign_start_date)
# Add None to the list of campaign dates to signal the end of the
# number of campaigns.
self.campaign_start_dates.append(None)
# Walk through the different campaigns and build crop calendars and
# timed/state event dispatchers
for campaign, campaign_start, next_campaign in \
zip(agromanagement, self.campaign_start_dates[:-1], self.campaign_start_dates[1:]):
# Get the campaign definition for the start date
campaign_def = campaign[campaign_start]
if self._is_empty_campaign(campaign_def): # no campaign definition for this campaign, e.g. fallow
self.crop_calendars.append(None)
self.timed_event_dispatchers.append(None)
self.state_event_dispatchers.append(None)
continue
# get crop calendar definition for this campaign
cc_def = campaign_def['CropCalendar']
if cc_def is not None:
cc = CropCalendar(kiosk, **cc_def)
cc.validate(campaign_start, next_campaign)
self.crop_calendars.append(cc)
else:
self.crop_calendars.append(None)
# Get definition of timed events and build TimedEventsDispatchers
te_def = campaign_def['TimedEvents']
if te_def is not None:
te_dsp = self._build_TimedEventDispatchers(kiosk, te_def)
for te in te_dsp:
te.validate(campaign_start, next_campaign)
self.timed_event_dispatchers.append(te_dsp)
else:
self.timed_event_dispatchers.append(None)
# Get definition of state events and build StateEventsDispatchers
se_def = campaign_def['StateEvents']
if se_def is not None:
se_dsp = self._build_StateEventDispatchers(kiosk, se_def)
self.state_event_dispatchers.append(se_dsp)
else:
self.state_event_dispatchers.append(None)
def _is_empty_campaign(self, campaign_def):
""""Check if the campaign definition is empty"""
if campaign_def is None:
return True
attrs = ["CropCalendar", "TimedEvents", "StateEvents"]
r = []
for attr in attrs:
if attr in campaign_def:
if campaign_def[attr] is None:
r.append(True)
else:
r.append(False)
if r == [True]*3:
return True
return False
@property
def start_date(self):
"""Retrieves the start date of the agromanagement sequence, e.g. the first simulation date
:return: a date object
"""
if self._start_date is None:
self._start_date = take_first(self.campaign_start_dates)
return self._start_date
@property
def end_date(self):
"""Retrieves the end date of the agromanagement sequence, e.g. the last simulation date.
:return: a date object
Getting the last simulation date is more complicated because there are two options.
**1. Adding an explicit trailing empty campaign**
The first option is to explicitly define the end date of the simulation by adding a
'trailing empty campaign' to the agromanagement definition.
An example of an agromanagement definition with a 'trailing empty campaigns' (YAML format) is
given below. This example will run the simulation until 2001-01-01::
Version: 1.0
AgroManagement:
- 1999-08-01:
CropCalendar:
crop_name: winter-wheat
variety_name: winter-wheat
crop_start_date: 1999-09-15
crop_start_type: sowing
crop_end_date:
crop_end_type: maturity
max_duration: 300
TimedEvents:
StateEvents:
- 2001-01-01:
Note that in configurations where the last campaign contains a definition for state events, a trailing
empty campaign *must* be provided because the end date cannot be determined. The following campaign
definition will therefore lead to an error::
Version: 1.0
AgroManagement:
- 2001-01-01:
CropCalendar:
crop_name: maize
variety_name: fodder-maize
crop_start_date: 2001-04-15
crop_start_type: sowing
crop_end_date:
crop_end_type: maturity
max_duration: 200
TimedEvents:
StateEvents:
- event_signal: apply_npk
event_state: DVS
zero_condition: rising
name: DVS-based N/P/K application table
comment: all fertilizer amounts in kg/ha
events_table:
- 0.3: {N_amount : 1, P_amount: 3, K_amount: 4}
- 0.6: {N_amount: 11, P_amount: 13, K_amount: 14}
- 1.12: {N_amount: 21, P_amount: 23, K_amount: 24}
**2. Without an explicit trailing campaign**
The second option is that there is no trailing empty campaign and in that case the end date of the simulation
is retrieved from the crop calendar and/or the timed events that are scheduled. In the example below, the
end date will be 2000-08-05 as this is the harvest date and there are no timed events scheduled after this
date::
Version: 1.0
AgroManagement:
- 1999-09-01:
CropCalendar:
crop_name: wheat
variety_name: winter-wheat
crop_start_date: 1999-10-01
crop_start_type: sowing
crop_end_date: 2000-08-05
crop_end_type: harvest
max_duration: 330
TimedEvents:
- event_signal: irrigate
name: Timed irrigation events
comment: All irrigation amounts in cm
events_table:
- 2000-05-01: {irrigation_amount: 2, efficiency: 0.7}
- 2000-06-21: {irrigation_amount: 5, efficiency: 0.7}
- 2000-07-18: {irrigation_amount: 3, efficiency: 0.7}
StateEvents:
In the case that there is no harvest date provided and the crop runs till maturity, the end date from
the crop calendar will be estimated as the crop_start_date plus the max_duration.
"""
if self._end_date is None:
# First check if the last campaign definition is an empty trailing campaign and use that date.
if self.crop_calendars[-1] is None and \
self.timed_event_dispatchers[-1] is None and \
self.state_event_dispatchers[-1] is None:
self._end_date = self.campaign_start_dates[-2] # use -2 here because None is
# appended to campaign_start_dates
return self._end_date
# Check if there are state events defined in the last campaign without specifying the end date
# explicitly with an trailing empty campaign
if self.state_event_dispatchers[-1] is not None:
msg = "In the AgroManagement definition, the last campaign with start date '%s' contains StateEvents. " \
"When specifying StateEvents, the end date of the campaign must be explicitly" \
"given by a trailing empty campaign."
raise exc.PCSEError(msg)
# Walk over the crop calendars and timed events to get the last date.
cc_dates = []
te_dates = []
for cc, teds in zip(self.crop_calendars, self.timed_event_dispatchers):
if cc is not None:
cc_dates.append(cc.get_end_date())
if teds is not None:
te_dates.extend([t.get_end_date() for t in teds])
# If no end dates can be found raise an error because the agromanagement sequence
# consists only of empty campaigns
if not cc_dates and not te_dates:
msg = "Empty agromanagement definition: no campaigns with crop calendars or timed events provided!"
raise exc.PCSEError(msg)
end_date = date(1, 1, 1)
if cc_dates:
end_date = max(max(cc_dates), end_date)
if te_dates:
end_date = max(max(te_dates), end_date)
self._end_date = end_date
return self._end_date
def _check_campaign_date(self, campaign_start_date):
"""
:param campaign_start_date: Start date of the agricultural campaign
:return: None
"""
if not isinstance(campaign_start_date, date):
msg = "Campaign start must be given as a date."
raise exc.PCSEError(msg)
if self._tmp_date is None:
self._tmp_date = campaign_start_date
else:
if campaign_start_date <= self._tmp_date:
msg = "The agricultural campaigns are not sequential " \
"in the agromanagement definition."
raise exc.PCSEError(msg)
def _build_TimedEventDispatchers(self, kiosk, event_definitions):
r = []
for ev_def in event_definitions:
ev_dispatcher = TimedEventsDispatcher(kiosk, **ev_def)
r.append(ev_dispatcher)
return r
def _build_StateEventDispatchers(self, kiosk, event_definitions):
r = []
for ev_def in event_definitions:
ev_dispatcher = StateEventsDispatcher(kiosk, **ev_def)
r.append(ev_dispatcher)
return r
def __call__(self, day, drv):
"""Calls the AgroManager to execute and crop calendar actions, timed or state events.
:param day: The current simulation date
:param drv: The driving variables for the current day
:return: None
"""
# Check if the agromanager should switch to a new campaign
if day == self.campaign_start_dates[self._icampaign+1]:
self._icampaign += 1
# if new campaign, throw out the previous campaign definition
self.crop_calendars.pop(0)
self.timed_event_dispatchers.pop(0)
self.state_event_dispatchers.pop(0)
# call handlers for the crop calendar, timed and state events
if self.crop_calendars[0] is not None:
self.crop_calendars[0](day)
if self.timed_event_dispatchers[0] is not None:
for ev_dsp in self.timed_event_dispatchers[0]:
ev_dsp(day)
if self.state_event_dispatchers[0] is not None:
for ev_dsp in self.state_event_dispatchers[0]:
ev_dsp(day)
def _on_CROP_FINISH(self, day):
"""Send signal to terminate after the crop cycle finishes.
The simulation will be terminated when the following conditions are met:
1. There are no campaigns defined after the current campaign
2. There are no StateEvents active
3. There are no TimedEvents scheduled after the current date.
"""
if self.campaign_start_dates[self._icampaign+1] is not None:
return # e.g. There is a next campaign defined
if self.state_event_dispatchers[0] is not None:
return # there are state events active that may trigger in the future
if self.timed_event_dispatchers[0] is not None:
end_dates = [t.get_end_date() for t in self.timed_event_dispatchers[0]]
if end_dates:
if max(end_dates) > day: # There is at least one scheduled event after the current day
return
self._send_signal(signal=signals.terminate)
@property
def ndays_in_crop_cycle(self):
"""Returns the number of days of the current cropping cycle.
Returns zero if no crop cycle is active.
"""
if self.crop_calendars[0] is None:
return 0
else:
return self.crop_calendars[0].duration