Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
almass.jl 35.02 KiB
### Persefone.jl - a model of agricultural landscapes and ecosystems in Europe.
###
### This file is responsible for managing the crop growth modules.
###

# Some functions translated from ALMaSS for use in Persefone.jl
#
# ALMaSS is licensed as follows:
#
# Copyright (c) 2011, Christopher John Topping, Aarhus University
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided
# that the following conditions are met:
#
# Redistributions of source code must retain the above copyright notice, this list of conditions and the
# following disclaimer.
# Redistributions in binary form must reproduce the above copyright notice, this list of conditions and
# the following disclaimer in the documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
# FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


#FIXME Looking at fields.pdf in the Persefone output, crop maturity is not recognised
# properly, so most crops (except maize) are never planted. At the same time, maize never
# seems to start growing.

#TODO write tests to compare our output to the output of the original ALMaSS algorithm

module ALMaSS

## The two input files required by ALMaSS. Must be present in the crop directory.
const CROPFILE = "crop_data_general.csv"
const GROWTHFILE = "crop_growth_curves.csv"

using Persefone:
    @rand,
    @u_str,
    AnnualDate,
    Management,
    cm,
    SimulationModel,
    fertiliser,
    maxtemp,
    mintemp,
    year,
    dayofyear

import Persefone:
    stepagent!,
    croptype,
    cropname,
    cropheight,
    cropcover,
    cropyield,
    sow!,
    harvest!,
    isharvestable,
    bounds

using Dates: Date, month, monthday
using CSV: CSV
using Unitful: unit

# We can't use Length{Float64} as that is a Union type
const Tlength = typeof(1.0cm)

"""
    GrowthPhase

ALMaSS crop growth curves are split into five phases, triggered by
seasonal dates or agricultural events.
"""
@enum GrowthPhase janfirst sow marchfirst harvest1 harvest2

"""
    CropCurveParams

The values in this struct define one crop growth curve.
"""
struct CropCurveParams
    #TODO add Unitful
    curveID::Int
    highnutrients::Bool
    GDD::Dict{GrowthPhase,Vector{Float64}}
    LAItotal::Dict{GrowthPhase, Vector{Float64}}
    LAIgreen::Dict{GrowthPhase, Vector{Float64}}
    height::Dict{GrowthPhase, Vector{typeof(1.0cm)}}
end

"""
    CropType

The type struct for all crops. Currently follows the crop growth model as
implemented in ALMaSS.
"""
struct CropType
    #FIXME this needs thinking about. The sowing and harvest dates belong in the farm model,
    # not here. Also, we need to harmonise crops across the crop growth models.
    name::String
    group::String
    is_c4_plant::Bool  # false means it is a C3 plant
    minsowdate::Union{Missing,AnnualDate}
    maxsowdate::Union{Missing,AnnualDate}
    minharvestdate::Union{Missing,AnnualDate}
    maxharvestdate::Union{Missing,AnnualDate}
    mingrowthtemp::Union{Missing,Float64}
    highnutrientgrowth::Union{Missing,CropCurveParams}
    lownutrientgrowth::Union{Missing,CropCurveParams}
end

cropname(ct::CropType) = ct.name

"""
    CropState

The state data for an ALMaSS vegetation point calculation.  Usually
part of a `FarmPlot`.
"""
@kwdef mutable struct CropState
    # Class in original ALMaSS code:
    #     `VegElement` from `Landscape/Elements.h`, line 601
    croptype::CropType
    events::Vector{Management} = Management[]
    mature::Bool = false  #TODO how do we determine this?

    # biomass::Float64 #XXX I need to figure out how to calculate this
    # height::Length{Float64}
    # growingdegreedays::Float64

    phase::GrowthPhase
    vegddegs::Float64 = 0   # Vegetation growing degree days since sowing
    ddegs::Float64 = 0      # Growing degree days in current phase
    yddegs::Float64 = 0     # Growing degree days in current phase for previous day
    LAgreen::Float64 = 0
    LAtotal::Float64 = 0
    oldLAtotal::Float64 = 0
    veg_height::Tlength = 0.0cm
    newgrowth::Float64 = 0
    veg_cover::Float64 = 0
    veg_biomass::Float64 = 0
    green_biomass::Float64 = 0
    dead_biomass::Float64 = 0

    growth_scaler::Float64 = 1.0      # TODO: where is this set?
    forced_phase_shift::Bool = false  # TODO: what does this do?

    # TODO: meaning of force_* fields ?
    force_growth::Bool = false
    force_LAgreen::Float64 = 0
    force_LAtotal::Float64 = 0
    force_veg_height::Tlength = 0.0cm
end

croptype(cs::CropState) = cs.croptype
cropname(cs::CropState) = cropname(croptype(cs))
cropheight(cs::CropState) = cs.veg_height
cropcover(cs::CropState) = cs.veg_cover
cropyield(cs::CropState) = cs.veg_biomass  # TODO: correct? units? dry or wet?
isharvestable(cs::CropState) = cs.mature

# Constant in original ALMaSS code:
#     `EL_VEG_START_LAIT` from `Landscape/Elements.cpp`, line 238-239
const VEG_START_LAIT::Float64 = 1.08

# Constant in original ALMaSS code:
#     `EL_VEG_HEIGHTSCALE` from `Landscape/Elements.cpp`, line 242-243
const VEG_HEIGHTSCALE::Tlength = 16.0cm

# Constant in original ALMaSS code:
#     `cfg_beer_law_extinction_coef` from `Landscape/Elements.cpp`, line 139
# TODO: units
const BEER_LAW_EXTINCTION_COEF::Float64 = 0.6

# TODO: units
"Temperature to solar conversion factor for C3 plants."
const temperature_to_solar_conversion_c3 = [
    0,   0,    0,    0,    0,    0,    0,    0,    0,    0,     # -30°C to -21°C
    0,   0,    0,    0,    0,    0,    0,    0,    0,    0,     # -20°C to -11°C
    0,   0,    0,    0,    0,    0,    0,    0,    0,    0,     # -10°C to  -1°C
    0,   0,    0,    0,    0,    0.28, 0.56, 0.84, 1.12, 1.4,   #   0°C to   9°C
    1.4, 1.4,  1.4,  1.4,  1.4,  1.4,  1.4,  1.4,  1.4,  1.4,   #  10°C to  19°C
    1.4, 1.4,  1.4,  1.4,  1.4,  1.4,  1.26, 1.12, 0.98, 0.84,  #  20°C to  29°C
    0.7, 0.56, 0.42, 0.28, 0.14, 0,    0,    0,    0,    0,     #  30°C to  39°C
    0,   0,    0,    0,    0,    0,    0,    0,    0,    0,     #  40°C to  49°C
    0                                                           #  50°C
]

# TODO: units
"Temperature to solar conversion factor for C4 plants."
const temperature_to_solar_conversion_c4 = [
    0,        0,        0,        0,        0,    0,   0,    0,    0,        0,         # -30°C to -21°C
    0,        0,        0,        0,        0,    0,   0,    0,    0,        0,         # -20°C to -11°C
    0,        0,        0,        0,        0,    0,   0,    0,    0,        0,         # -10°C to  -1°C
    0,        0,        0,        0,        0,    0,   0,    0,    0.242857, 0.485714,  #   0°C to   9°C
    0.728571, 0.971429, 1.214286, 1.457143, 1.7,  1.7, 1.7,  1.7,  1.7,      1.7,       #  10°C to  19°C
    1.7,      1.7,      1.7,      1.7,      1.7,  1.7, 1.7,  1.7,  1.7,      1.7,       #  20°C to  29°C
    1.7,      1.7,      1.7,      1.7,      1.7,  1.7, 1.53, 1.36, 1.19,     1.02,      #  30°C to  39°C
    0.85,     0.68,     0.51,     0.34,     0.17, 0,   0,    0,    0,        0,         #  40°C to  49°C
    0                                                                                   #  50°C
]

"""
    solar_conversion_c3(temperature)

Solar conversion factor (no units) for C3 plants.
"""
function solar_conversion_c3(temperature)
    if temperature < -30 || temperature > 50
        return zero(eltype(temperature_to_solar_conversion_c3))
    end
    idx = Int(floor(0.5 + temperature)) + 30 + 1
    return temperature_to_solar_conversion_c3[idx]
end

"""
    solar_conversion_c3(temperature)

Solar conversion factor (no units) for C4 plants.
"""
function solar_conversion_c4(temperature)
    if temperature < -30 || temperature > 50
        return zero(eltype(temperature_to_solar_conversion_c4))
    end
    idx = Int(floor(0.5 + temperature)) + 30 + 1
    return temperature_to_solar_conversion_c4[idx]
end

# Below is the data for insolation in MJ per m2 for Ødum fitted to 1961-1990
# by Olesen (1991)
# TODO: unit is u"MJ/m^2"
const global_insolation = [
     0.91,      0.921585,  0.935138,  0.950669,  0.968188,
     0.987706,  1.009231,  1.032774,  1.058346,  1.085956,
     1.115614,  1.14733,   1.181113,  1.216974,  1.254921,
     1.294964,  1.337112,  1.381373,  1.427757,  1.476271,
     1.526923,  1.57972,   1.634671,  1.691781,  1.751057,
     1.812504,  1.876128,  1.941934,  2.009926,  2.080107,
     2.15248,   2.227048,  2.303812,  2.382774,  2.463933,
     2.547289,  2.63284,   2.720585,  2.81052,   2.902642,
     2.996945,  3.093425,  3.192073,  3.292884,  3.395847,
     3.500954,  3.608194,  3.717555,  3.829024,  3.942587,
     4.05823,   4.175935,  4.295687,  4.417465,  4.541252,
     4.667024,  4.794762,  4.924441,  5.056036,  5.189522,
     5.324873,  5.462059,  5.601051,  5.741819,  5.884329,
     6.02855,   6.174446,  6.321981,  6.471118,  6.621819,
     6.774044,  6.927753,  7.082902,  7.239449,  7.397349,
     7.556555,  7.717022,  7.8787,    8.041541,  8.205493,
     8.370505,  8.536524,  8.703496,  8.871365,  9.040077,
     9.209573,  9.379796,  9.550686,  9.722183,  9.894227,
    10.06675,  10.2397,   10.41301,  10.58661,  10.76044,
    10.93443,  11.10852,  11.28264,  11.45671,  11.63068,
    11.80448,  11.97802,  12.15125,  12.3241,   12.49648,
    12.66834,  12.8396,   13.01019,  13.18004,  13.34907,
    13.51721,  13.6844,   13.85056,  14.01561,  14.17949,
    14.34212,  14.50344,  14.66337,  14.82184,  14.97877,
    15.13411,  15.28778,  15.43971,  15.58983,  15.73807,
    15.88437,  16.02866,  16.17087,  16.31093,  16.44879,
    16.58439,  16.71764,  16.8485,   16.97691,  17.1028,
    17.22612,  17.3468,   17.46479,  17.58005,  17.6925,
    17.80211,  17.90881,  18.01256,  18.11332,  18.21102,
    18.30564,  18.39712,  18.48542,  18.5705,   18.65233,
    18.73086,  18.80605,  18.87788,  18.94632,  19.01132,
    19.07286,  19.13092,  19.18546,  19.23647,  19.28393,
    19.3278,   19.36809,  19.40475,  19.4378,   19.4672,
    19.49296,  19.51506,  19.53349,  19.54825,  19.55934,
    19.56676,  19.5705,   19.57058,  19.56698,  19.55973,
    19.54883,  19.53428,  19.51611,  19.49432,  19.46893,
    19.43996,  19.40743,  19.37136,  19.33177,  19.28868,
    19.24213,  19.19214,  19.13873,  19.08195,  19.02183,
    18.95839,  18.89168,  18.82173,  18.74858,  18.67228,
    18.59286,  18.51036,  18.42484,  18.33634,  18.2449,
    18.15058,  18.05342,  17.95348,  17.8508,   17.74545,
    17.63746,  17.52691,  17.41384,  17.29832,  17.18039,
    17.06012,  16.93757,  16.8128,   16.68586,  16.55683,
    16.42576,  16.29271,  16.15775,  16.02094,  15.88235,
    15.74203,  15.60006,  15.4565,   15.3114,   15.16485,
    15.0169,   14.86762,  14.71707,  14.56532,  14.41243,
    14.25848,  14.10352,  13.94762,  13.79084,  13.63325,
    13.47491,  13.31588,  13.15624,  12.99604,  12.83534,
    12.6742,   12.5127,   12.35088,  12.18881,  12.02654,
    11.86415,  11.70167,  11.53918,  11.37673,  11.21437,
    11.05216,  10.89016,  10.72841,  10.56697,  10.40589,
    10.24522,  10.08502,   9.925323,  9.766186,  9.607653,
     9.449772,  9.292586,  9.13614,   8.980476,  8.825635,
     8.67166,   8.518588,  8.366459,  8.215311,  8.06518,
     7.916101,  7.768108,  7.621236,  7.475515,  7.330978,
     7.187655,  7.045573,  6.904763,  6.765249,  6.62706,
     6.490218,  6.35475,   6.220676,  6.088021,  5.956804,
     5.827045,  5.698765,  5.571981,  5.44671,   5.32297,
     5.200776,  5.080142,  4.961083,  4.843613,  4.727743,
     4.613485,  4.50085,   4.38985,   4.280492,  4.172788,
     4.066743,  3.962368,  3.859668,  3.758651,  3.659322,
     3.561687,  3.465753,  3.371522,  3.279,     3.18819,
     3.099097,  3.011723,  2.926071,  2.842145,  2.759946,
     2.679477,  2.600739,  2.523735,  2.448466,  2.374933,
     2.303139,  2.233084,  2.16477,   2.098198,  2.033369,
     1.970285,  1.908946,  1.849355,  1.791512,  1.735419,
     1.681077,  1.628489,  1.577656,  1.528581,  1.481264,
     1.43571,   1.391919,  1.349896,  1.309643,  1.271164,
     1.234461,  1.199539,  1.166401,  1.135053,  1.105497,
     1.07774,   1.051786,  1.02764,   1.005309,  0.984798,
     0.966113,  0.94926,   0.934248,  0.921081,  0.909769,
     0.900317,  0.892735,  0.88703,   0.88321,   0.881284,
     0.881261,  0.883149,  0.886958,  0.892696,  0.900374,
     0.900374,  # duplicated last datapoint in case the year has 366 days
]

"""
    Base.tryparse(type, str)

Extend `tryparse` to allow parsing GrowthPhase values.
(Needed to read in the CSV parameter file.)
"""
function Base.tryparse(::Type{GrowthPhase}, str::String)
    str == "janfirst" ? janfirst :
        str == "sow" ? sow :
        str == "marchfirst" ? marchfirst :
        str == "harvest1" ? harvest1 :
        str == "harvest2" ? harvest2 :
        nothing
end

"""
    buildgrowthcurve(data)

Convert a list of rows from the crop growth data into a CropCurveParams object.
"""
function buildgrowthcurve(data::Vector{CSV.Row})
    isempty(data) && return missing
    GDD = Dict(janfirst=>Vector{Float64}(), sow=>Vector{Float64}(),
               marchfirst=>Vector{Float64}(), harvest1=>Vector{Float64}(),
               harvest2=>Vector{Float64}())
    LAItotal = Dict(janfirst=>Vector{Float64}(), sow=>Vector{Float64}(),
                    marchfirst=>Vector{Float64}(), harvest1=>Vector{Float64}(),
                    harvest2=>Vector{Float64}())
    LAIgreen = Dict(janfirst=>Vector{Float64}(), sow=>Vector{Float64}(),
                    marchfirst=>Vector{Float64}(), harvest1=>Vector{Float64}(),
                    harvest2=>Vector{Float64}())
    height = Dict(janfirst=>Vector{Tlength}(), sow=>Vector{Tlength}(),
                  marchfirst=>Vector{Tlength}(), harvest1=>Vector{Tlength}(),
                  harvest2=>Vector{Tlength}())
    for e in data        
        append!(GDD[e.growth_phase], e.GDD)
        append!(LAItotal[e.growth_phase], e.LAI_total)
        append!(LAIgreen[e.growth_phase], e.LAI_green)
        append!(height[e.growth_phase], e.height * cm)  # assumes `height` is in units of `cm`
    end
    CropCurveParams(data[1].curve_id, data[1].nutrient_status=="high",
                    GDD, LAItotal, LAIgreen, height)
end

"""
    readcropparameters(cropdirectory)

Parse a CSV file containing the required parameter values for each crop
(as produced from the original ALMaSS file by `convert_almass_data.py`).
"""
function readcropparameters(cropdirectory::String)
    @debug "Reading crop parameters"
    cropdata = CSV.File(joinpath(cropdirectory, CROPFILE), missingstring="NA",
                        types=[String,AnnualDate,AnnualDate,AnnualDate,AnnualDate,Float64,String])
    growthdata = CSV.File(joinpath(cropdirectory, GROWTHFILE), missingstring="NA",
                          types=[Int,String,String,GrowthPhase,String,
                                 Float64,Float64,Float64,Float64])
    croptypes = Dict{String,CropType}()
    for crop in cropdata
        cropgrowthdata = growthdata |> filter(x -> !ismissing(x.crop_name) &&
                                              x.crop_name == crop.name)
        highnuts = buildgrowthcurve(cropgrowthdata |>
                                    filter(x -> x.nutrient_status=="high"))
        lownuts = buildgrowthcurve(cropgrowthdata |>
                                   filter(x -> x.nutrient_status=="low"))
        # TODO: set crop group temporarily until there is a column in
        # the csv file
        crop_group = "CROP_GROUP_NOT_SET"
        # TODO: it would be better to save this in the parameter file
        # (Note that this matches the current ALMaSS code though,
        # which also only hardcodes maize as a C4 crop)
        is_c4_plant = occursin("maize", lowercase(crop.name))
        croptypes[crop.name] = CropType(crop.name, crop_group, is_c4_plant, crop.minsowdate,
                                        crop.maxsowdate, crop.minharvestdate, crop.maxharvestdate,
                                        crop.mingrowthtemp, highnuts, lownuts)
    end
    croptypes
end

"""
    setphase!(cropstate, phase)

Set the growth `phase` of an ALMaSS `cropstate`.
"""
function setphase!(cs::CropState, phase::GrowthPhase, model::SimulationModel)
    # Function in original ALMaSS code:
    #     `void VegElement::SetGrowthPhase(int a_phase)` from `Landscape/Elements.cpp`, line 2098
    if phase == sow
        cs.vegddegs = 0
    elseif phase == harvest1
        cs.vegddegs = -1
    end
    if phase == janfirst
        cs.forced_phase_shift = false
        # TODO: Original comments from ALMaSS below, but do they make sense?
        #
        # If it is the first growth phase of the year then we might
        # cause some unnecessary hops if e.g. our biomass is 0 and we
        # suddenly jump up to 20 cm. To stop this from happening we
        # check here and if our settings are lower than the targets we
        # do nothing.
        if isvalidstart(cs, phase)
            if startvalue_veg_height(cs, phase) < cs.veg_height
                # we are better off with the numbers we have to start with
                cs.LAgreen = startvalue_LAgreen(cs, phase)
                cs.LAtotal = startvalue_LAtotal(cs, phase)
                cs.veg_height = startvalue_veg_height(cs, phase)
            end
        end
    elseif isvalidstart(cs, phase)
        cs.LAgreen = startvalue_LAgreen(cs, phase)
        cs.LAtotal = startvalue_LAtotal(cs, phase)
        cs.veg_height = startvalue_veg_height(cs, phase)
    end

    cs.phase = phase
    cs.yddegs = 0
    cs.ddegs = get_dddegs(model)
    cs.force_growth = false

    if cs.phase == janfirst
        # Original comment from ALMaSS:
        #
        # For some growth curves there is no growth in the first two
        # months of the year. This will more likely than not cause a
        # discontinuous jump in the growth curves come March
        # first. `force_growth_spring_test()` tries to avoid that by
        # checking for positive growth values for the January growth
        # phase. If none are found, then it initializes a forced
        # growth/transition to the March 1st starting values.
        #
        # Removal of this causes continuous increase in vegetation
        # growth year on year for any curve that does not have a hard
        # reset (e.g. harvest).
        force_growth_spring_test!(cs, model)
    end
end

# Function in original ALMaSS code:
#     `void VegElement::ForceGrowthSpringTest(void)` in `Landscape/Elements.cpp`, line 2162
function force_growth_spring_test!(cs::CropState, model::SimulationModel)
    # Original comments from ALMaSS:
    #
    # Check if there are any positive growth differentials in the
    # curve for the first two months of the year. Do nothing if there
    # is.  If we have any positive growth then no need to force either
    if (get_LAgreen_diff(cs, janfirst, 90000.0, 0.0) > 0.001 ||
        get_LAtotal_diff(cs, janfirst, 90000.0, 0.0) > 0.001 ||
        get_height_diff(cs, janfirst, 90000.0, 0.0) > 0.001cm)
        return
    end
    # No growth, force it.
    force_growth_initialize!(cs, model)
end

# Function in original ALMaSS code:
#     `void VegElement::ForceGrowthInitialize(void)` in `Landscape/Elements.cpp`, line 2177
function force_growth_initialize!(cs::CropState, model::SimulationModel)
    # Figure out what our target phase is.
    if monthday(model.date) < (1, 3)
        daysleft = (Date(year(model.date), 3, 1) - model.date).value
        next_phase = marchfirst
    elseif monthday(model.date) >= (11, 1)
        # Adjusted from 365 to prevent occaisional negative values
        daysleft = 366 - dayofyear(model.date)
        next_phase = janfirst
    else
        return
    end

    if daysleft <= 0
        @warn "Uh! Oh! This really shouldn't happen."
        return
    end
    if ! isvalidstart(cs, next_phase)
        # If no valid starting values for next phase, then
        # preinitialize the random starting values! Ie. make the
        # choice here and then do not choose another set come next
        # phase transition, but use the values we already got at that
        # point in time.
        LAtotal_target, LAgreen_target, veg_height_target = random_veg_start_values(model)
        # TODO: ignoring Weed_target
    else
        # add +/- 20% variation
        vari = (@rand() * 0.4) + 0.8
        # TODO: ignoring Weed_target
        LAgreen_target = startvalue_LAgreen(cs, next_phase) * vari
        LAtotal_target = startvalue_LAtotal(cs, next_phase) * vari
        veg_height_target = startvalue_veg_height(cs, next_phase) * vari
    end

    cs.force_growth = true
    # TODO ignoring: cs.force_Weed = (Weed_target - cs.weed_biomass) / daysleft
    cs.force_LAgreen = (LAgreen_target - cs.LAgreen) / daysleft
    cs.force_LAtotal = (LAtotal_target - cs.LAtotal) / daysleft
    cs.force_veg_height = (veg_height_target - cs.veg_height) / daysleft
end

# Function in original ALMaSS code:
#     `void VegElement::RandomVegStartValues(...)` in `Landscape/Elements.cpp`, line 2090
function random_veg_start_values(model)
    LAtotal = VEG_START_LAIT * ((@rand(-10:10) / 100.0) + 1.0)
    LAgreen = LAtotal / 4
    veg_height = LAgreen * VEG_HEIGHTSCALE
    # TODO ignoring `weed_biomass`
    return LAtotal, LAgreen, veg_height
end

function get_LAgreen_diff(cs::CropState, phase::GrowthPhase, ddegs::Float64, yddegs::Float64)
    curve = growthcurve(cs)
    dds = curve.GDD[phase]
    slopes = curve.LAIgreen[phase]
    return find_diff(dds, slopes, ddegs, yddegs)
end
get_LAgreen_diff(cs::CropState) =
    get_LAgreen_diff(cs, cs.phase, cs.ddegs, cs.yddegs)

function get_LAtotal_diff(cs::CropState, phase::GrowthPhase, ddegs::Float64, yddegs::Float64)
    curve = growthcurve(cs)
    dds = curve.GDD[phase]
    slopes = curve.LAItotal[phase]
    return find_diff(dds, slopes, ddegs, yddegs)
end
get_LAtotal_diff(cs::CropState) =
    get_LAtotal_diff(cs, cs.phase, cs.ddegs, cs.yddegs)

function get_height_diff(cs::CropState, phase::GrowthPhase, ddegs::Float64, yddegs::Float64)
    curve = growthcurve(cs)
    dds = curve.GDD[phase]
    slopes = curve.height[phase]
    return find_diff(dds, slopes, ddegs, yddegs)
end
get_height_diff(cs::CropState) =
    get_height_diff(cs, cs.phase, cs.ddegs, cs.yddegs)

# Function in original ALMaSS code:
#     `double PlantGrowthData::FindDiff(...)` in `Landscape/Plants.cpp`, line 45
function find_diff(dds::Vector{Float64}, slopes::Vector{T}, ddegs::Float64, yddegs::Float64) where {T}
    # Comment in ALMaSS code:
    #
    # This is broken for growth curves where one can risk passing
    # more than a single inflection point in the growth curve in a
    # single day...
    # Note: the code is slightly different to the original ALMaSS
    # code, which I think is caused by ALMaSS not storing the -1 entry
    # in the dds array.

    if first(dds) == 99999.0
        return 0.0 * oneunit(T)  # zero(T) does not work when slopes has units
    end

    function get_index(array::Tarr, threshold::eltype(Tarr)) where {Tarr <: DenseArray}
        idx = findfirst(x -> x > threshold, array)
        if isnothing(idx)
            idx = lastindex(array) + 1
        end
        idx -= 1
        if idx < firstindex(array)
            error("Index too small, idx = $idx")
        end
        return idx
    end

    newindex = get_index(dds, ddegs)
    oldindex = get_index(dds, yddegs)

    dds_newindex = dds[newindex] == -1.0 ? zero(eltype(dds)) : dds[newindex]
    slopes_oldindex = dds[oldindex] == -1.0 ? zero(eltype(slopes)) : slopes[oldindex]
    slopes_newindex = dds[newindex] == -1.0 ? zero(eltype(slopes)) : slopes[newindex]

    if newindex > oldindex
        # We have passed an inflection point between today and yesterday.
        # First add the increment from yesterdays day degree sum up to
        # the inflection point.
        dddif = dds_newindex - yddegs
        diff = slopes_oldindex * dddif

        # Then from the inflection point up to today.
        dddif = ddegs - dds_newindex
        diff += slopes_newindex * dddif
    else
        # No inflection point passed
        diff = slopes_newindex * (ddegs - yddegs)
    end
    return diff
end

# Function in original ALMaSS code:
#     `double Weather::GetDDDegs(long a_date)` in `Landscape/Weather.cpp`, line 205
# TODO: original ALMaSS code does not use a minimum growth temperature?
get_dddegs(model::SimulationModel) = bounds(supply_temperature(model))

# TODO efficiency: the fertiliser check is done in many places
function growthcurve(cs::CropState)
    if ismissing(cs.croptype.lownutrientgrowth) && ismissing(cs.croptype.highnutrientgrowth)
        error("No growth curves available for cropstate:\n $cs")
    end
    if fertiliser in cs.events
        curve = cs.croptype.highnutrientgrowth
        if ismissing(curve)
            @warn "fertiliser used, but highnutrientgrowth curve is missing. Using lownutrientgrowth curve."
            curve = cs.croptype.lownutrientgrowth
        end
    else
        curve = cs.croptype.lownutrientgrowth
        if ismissing(curve)
            # Note: This warning is commented out, as this is quite
            #       common for e.g. "permanent grassland"
            # @warn "fertiliser not used, but lownutrientgrowth curve is missing. Using highnutrientgrowth curve."
            curve = cs.croptype.highnutrientgrowth
        end
    end
    return curve
end

# Function in original ALMaSS code:
#     `double PlantGrowthData::GetStartValue(int a_veg_type, int a_phase, int a_type)` in `Landscape/Plants.h`, line 142
startvalue_veg_height(cs::CropState, phase::GrowthPhase) =
    first(growthcurve(cs).height[phase])
startvalue_LAgreen(cs::CropState, phase::GrowthPhase) =
    first(growthcurve(cs).LAIgreen[phase])
startvalue_LAtotal(cs::CropState, phase::GrowthPhase) =
    first(growthcurve(cs).LAItotal[phase])

# Function in original ALMaSS code:
#     `bool PlantGrowthData::StartValid(int a_veg_type, int a_phase)` in `Landscape/Plants.cpp`, line 835
# The original logic for this is in `Landscape/Plants.cpp`, line 213
# where `m_start_valid` is set.
isvalidstart(cs::CropState, phase::GrowthPhase) =
    growthcurve(cs).height[phase] == -1.0cm


"""
    stepagent!(cropstate, model)

Update a farm plot by one day.
"""
function stepagent!(cs::CropState, model::SimulationModel)
    # Function in original ALMaSS code:
    #     `void VegElement::DoDevelopment(void)` from `Landscape/Elements.cpp`, line 2254

    # This part happens in the ALMaSS function `void Landscape::Tick(void)`
    # in `Landscape/Landscape.cpp`, line 2272
    #
    # update the phase on key dates
    if monthday(model.date) == (1, 1)
        setphase!(cs, janfirst, model)
    elseif monthday(model.date) == (3, 1)
        setphase!(cs, marchfirst, model)
    end

    # TODO ignoring: cs.new_weed_growth = 0.0
    if ! cs.force_growth
        cs.yddegs = cs.ddegs
        pos_temp_today = get_dddegs(model)
        if cs.vegddegs != -1
            # Sum up the vegetation day degrees since sowing
            cs.vegddegs += pos_temp_today
        end
        # And sum up the phase ddegs
        cs.ddegs = pos_temp_today + cs.yddegs

        dLAG = get_LAgreen_diff(cs) * cs.growth_scaler
        dLAT = get_LAtotal_diff(cs) * cs.growth_scaler
        dHgt = get_height_diff(cs) * cs.growth_scaler

        cs.LAgreen += dLAG
        cs.LAtotal += dLAT
        cs.veg_height += dHgt
        if cs.LAgreen < 0.0
            cs.LAgreen = 0.0
        end
        if cs.LAtotal < 0.0
            cs.LAtotal = 0.0
        end
        if cs.veg_height < 0.0cm
            cs.veg_height = 0.0cm
        end

        # TODO ignored: calculation of cs.weedddegs, cs.new_weed_growth, cs.weed_biomass
    else
        force_growth_development!(cs)
    end

    if cs.LAtotal < cs.LAgreen
        @warn "Leaf Area Total smaller than Leaf Area Green (Veg Growth Model inconsistent). Performing hack correction."
        cs.LAtotal = 1.1 * cs.LAgreen
    end

    recalculate_bugs_n_stuff!(cs, model)

    # TODO ignored: ResetGeese()
    # TODO ignored: Deal with any possible unsprayed margin, transferring info as necessary
    # TODO ignored: cs.interested_green_biomass = cs.green_biomass * cs.interested_biomass_fraction
end

# Function in original ALMaSS code:
#     `void VegElement::ForceGrowthDevelopment(void)` from `Landscape/Elements.cpp`, line 2223
function force_growth_development!(cs::CropState)
    # TODO ignored: cs.weed_biomass += cs.force_Weed
    # TODO ignored: cs.new_weed_growth = cs.force_Weed
    cs.LAgreen += cs.force_LAgreen
    cs.LAtotal += cs.force_LAtotal
    cs.veg_height += cs.force_veg_height
    if cs.LAgreen < 0
        cs.LAgreen = 0
    end
    if cs.LAtotal < 0
        cs.LAtotal = 0
    end
    if cs.veg_height < 0.0cm
        cs.veg_height = 0.0cm
    end
end

# Function in original ALMaSS code:
#     `void VegElement::RecalculateBugsNStuff(void)` from `Landscape/Elements.cpp`, line 1704
function recalculate_bugs_n_stuff!(cs::CropState, model::SimulationModel)
    # This is the heart of the dynamics of vegetation elements. It
    # calculates vegetation cover and uses this to determine
    # vegetation biomass.  It also calculates spilled grain and goose
    # forage, as well a calculating insect biomass, vegetation density
    # and dead biomass.

    cs.newgrowth = 0

    # Beer's Law to give vegetation cover
    cs.veg_cover = 1.0 - exp(- BEER_LAW_EXTINCTION_COEF * cs.LAtotal)

    # This is used to calc growth rate
    # TODO: hardcoded extinction coefficient of 0.4
    useful_veg_cover = 1.0 - exp(cs.LAgreen * -0.4)

    # Need global radiation today
    glrad = supply_global_radiation(model)

    temperature = supply_temperature(model)
    if cs.croptype.is_c4_plant
        radconv = solar_conversion_c4(temperature)
    else
        radconv = solar_conversion_c3(temperature)
    end

    if cs.LAtotal >= cs.oldLAtotal
        # we are in positive growth so grow depending on our equation

        # TODO: biomass_scale
        biomass_scale = 1.0
        cs.newgrowth = useful_veg_cover * glrad * radconv * biomass_scale

        # TODO: ignoring farm intensity
        cs.veg_biomass += cs.newgrowth
    else
        # Negative growth - so shrink proportional to the loss in LAI Total
        if cs.oldLAtotal > 0
            temp_proportion = cs.LAtotal / cs.oldLAtotal
            cs.veg_biomass *= temp_proportion
        end
    end

    # Here we also want to know how much biomass we have on the field
    # in total. So we multiply the current biomass by area
    # cs.total_biomass = cs.veg_biomass * cs.area
    # cs.total_biomass_old = cs.total_biomass

    # NB The cs.weed_biomass is calculated directly from the curve in Curves.pre
    # rather than going through the rigmarole of converting leaf-area index
    # cs.veg_density = Int(floor(0.5 + (cs.veg_biomass / (1 + cs.veg_height))))

    # if cs.veg_density > 100
    #     cs.veg_density = 100  # to stop array bounds problems
    # end

    if cs.LAtotal == 0.0
        cs.green_biomass = 0.0
    else
        cs.green_biomass = cs.veg_biomass * (cs.LAgreen / (cs.LAtotal))
    end
    cs.dead_biomass = cs.veg_biomass - cs.green_biomass

    # TODO: species-specific calculations
    # Here we use our member function pointer to call the correct piece of code for our current species
    #(this->*(SpeciesSpecificCalculations))();
end

# m_Landscape->SupplyTemp()
supply_temperature(model::SimulationModel) = (mintemp(model) + maxtemp(model) / 2)

# m_Landscape->SupplyGlobalRadiation()
supply_global_radiation(model::SimulationModel) =
    global_insolation[dayofyear(model.date)]


## CROP MANAGEMENT AND GROWTH FUNCTIONS

"""
    sow!(cropstate, model, cropname)

Change the cropstate to sow the specified crop.
"""
function sow!(cs::CropState, model::SimulationModel, cropname::String)
    !ismissing(cs.croptype.minsowdate) && model.date < cs.croptype.minsowdate &&
        @warn "$(model.date) is earlier than the minimum sowing date for $(cropname)."
    cs.croptype = model.crops[cropname]
    setphase!(cs, sow, model)
    cs.mature = false
    empty!(cs.events)

    # cs.vegddegs = 0.0
    # cs.ddegs = 0.0
    # cs.yddegs = 0.0
    # cs.veg_height = 0.0cm
    # cs.LAItotal = 0.0
    # cs.LAIgreen = 0.0
end

"""
    harvest!(cropstate, model)

Harvest the crop of this cropstate.
"""
function harvest!(cs::CropState, model::SimulationModel)
    phase = (cs.phase in (ALMaSS.harvest1, ALMaSS.harvest2) ? ALMaSS.harvest2 : ALMaSS.harvest1)
    setphase!(cs, phase, model)
    cs.mature = false
    # height & LAI will be automatically adjusted by the growth function
    #TODO calculate and return yield
end

#TODO fertilise!()
#TODO spray!()
#TODO till!()



# """
#     growcrop!(cropstate, gdd, model)

# Apply the relevant crop growth model to update the plants crop state
# on this farm plot.  Implements the ALMaSS crop growth model by Topping
# et al. (see `ALMaSS/Landscape/plant.cpp:PlantGrowthData::FindDiff()` and
# `ALMaSS/Landscape/elements.cpp:VegElement::DoDevelopment()`).
# """
# function growcrop!(cs::CropState, gdd::Float64, model::SimulationModel)
#     fertiliser in cs.events ?
#         curve = cs.croptype.lownutrientgrowth :
#         curve = cs.croptype.highnutrientgrowth
#     points = curve.GDD[cs.phase] #FIXME what if the curve is empty?
#     for p in 1:length(points)
#         if points[p] == 99999
#             !(cs.phase in (janfirst, sow)) && (cs.mature = true) #FIXME only in the last phase?
#             return # the marker that there is no further growth this phase
#         elseif points[p] == -1 # the marker to set all variables to specified values
#             cs.height = curve.height[cs.phase][p]
#             cs.LAItotal = curve.LAItotal[cs.phase][p]
#             cs.LAIgreen = curve.LAIgreen[cs.phase][p]
#             return
#         else
#             gdd = cs.growingdegreedays
#             # figure out which is the correct slope value to use for growth
#             if p == length(points) || gdd < points[p+1]
#                 #FIXME it appears to me from the ALMaSS source code that the curve value
#                 # for the day is multiplied with that day's growingdegreedays to give the
#                 # diff, which is then added to the previous values. However, this results
#                 # in values that are staggeringly too large. I'm not quite sure what the
#                 # issue is here...
#                 cs.height += bounds(curve.height[cs.phase][p]*gdd)
#                 cs.LAItotal += bounds(curve.LAItotal[cs.phase][p]*gdd)
#                 cs.LAIgreen += bounds(curve.LAIgreen[cs.phase][p]*gdd)
#                 return
#             end
#             #XXX To be precise, we ought to figure out if one or more inflection
#             # points have been passed between yesterday and today, and calculate the
#             # growth exactly up to the inflection point with the old slope, and onward
#             # with the new slope. Not doing so will introduce a small amount of error,
#             # although I think this is acceptable.
#         end
#     end
# end

end # module ALMaSS