From 290411d83801f8ca89d2120f41bbfe7b6116e906 Mon Sep 17 00:00:00 2001
From: Marco Matthies <71844+marcom@users.noreply.github.com>
Date: Mon, 24 Feb 2025 21:39:07 +0100
Subject: [PATCH] Make AquaCrop usable with weather and soil data and crop
 types

---
 data/crops/aquacrop/crop_data.csv  |   9 ++
 src/crop/almass.jl                 |   2 +-
 src/crop/aquacrop.jl               | 161 ++++++++++++++++++++++++-----
 src/crop/cropmodels.jl             |  37 ++++---
 src/parameters.toml                |   6 +-
 test/crop_tests.jl                 |  13 ++-
 test/test_parameters_aquacrop.toml |   3 +-
 7 files changed, 181 insertions(+), 50 deletions(-)
 create mode 100644 data/crops/aquacrop/crop_data.csv

diff --git a/data/crops/aquacrop/crop_data.csv b/data/crops/aquacrop/crop_data.csv
new file mode 100644
index 0000000..c9a871f
--- /dev/null
+++ b/data/crops/aquacrop/crop_data.csv
@@ -0,0 +1,9 @@
+persefone_name,aquacrop_name,crop_group,min_sow_date,max_sow_date
+natural grass,alfalfaGDD,semi-natural,NA,NA
+permanent grassland (grazed),alfalfaGDD,grass,NA,NA
+permanent set-aside,alfalfaGDD,grass,NA,NA
+no growth,alfalfaGDD,semi-natural,NA,NA
+winter rape,rapeseed,grain,20 August,25 August
+winter wheat,wheatGDD,grain,15 October,31 October
+maize,maize,grain,15 April,30 April
+winter barley,barleyGDD,grain,15 September,30 September
\ No newline at end of file
diff --git a/src/crop/almass.jl b/src/crop/almass.jl
index 59b94b9..8edc318 100644
--- a/src/crop/almass.jl
+++ b/src/crop/almass.jl
@@ -398,7 +398,7 @@ 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"
+    @debug "Reading crop parameters for ALMaSS crop model"
     # TODO: the second-last column (sowingdensity) uses `String` as
     # type.  This is because the entry for "peas/beans" has a value of
     # "35/80" for the sowingdensity.
diff --git a/src/crop/aquacrop.jl b/src/crop/aquacrop.jl
index 7e09b2c..205078d 100644
--- a/src/crop/aquacrop.jl
+++ b/src/crop/aquacrop.jl
@@ -1,13 +1,19 @@
-module WrapAquaCrop
-
 # Persefone.jl wrapper for AquaCrop.jl
-# Use different name from `AquaCrop` to avoid name clash
+
+module AquaCropWrapper
+
+const CROPFILE = "crop_data.csv"
 
 import AquaCrop
+import CSV
+using Dates: Date
+using DataFrames: DataFrame
 
 using Persefone:
     AnnualDate,
     cm,
+    daynumber,
+    SoilType,
     SimulationModel
 
 import Persefone:
@@ -26,48 +32,146 @@ using Unitful: @u_str
 # We can't use Length{Float64} as that is a Union type
 const Tlength = typeof(1.0cm)
 
-struct AquaCropType
+# TODO: read crop names directly from AquaCrop.jl
+# names extracted by running this command in the AquaCrop.jl src dir:
+#     grep -nir 'crop_type ==' src/ | cut -d '=' -f 3 | sed 's/$/,/' | sort | uniq
+const AQUACROP_CROPNAMES = [
+    "alfalfaGDD",
+    "barley",
+    "barleyGDD",
+    "cotton",
+    "cottonGDD",
+    "drybean",
+    "drybeanGDD",
+    "maize",
+    "maizeGDD",
+    "oat",
+    "paddyrice",
+    "paddyriceGDD",
+    "potato",
+    "potatoGDD",
+    "quinoa",
+    "rapeseed",
+    "sorghum",
+    "sorghumGDD",
+    "soybean",
+    "soybeanGDD",
+    "sugarbeet",
+    "sugarbeetGDD",
+    "sugarcane",
+    "sunflower",
+    "sunflowerGDD",
+    "tef",
+    "tomato",
+    "tomatoGDD",
+    "wheat",
+    "wheatGDD",
+]
+
+@kwdef struct CropType
     name::String
+    aquacrop_name::String
     group::String
     minsowdate::Union{Missing,AnnualDate}
     maxsowdate::Union{Missing,AnnualDate}
 end
 
-cropname(ct::AquaCropType) = ct.name
+cropname(ct::CropType) = ct.name
 
-mutable struct AquaCropState
-    croptype::AquaCropType
+"""
+    readcropparameters(cropdirectory)
+
+Parse a CSV file containing some parameters required to map AquaCrop
+crop names to Persefone crop names as well as additional crop data
+needed for Persefone (cropgroup, minsowdate, maxsowdate).
+"""
+function readcropparameters(cropdirectory::String)
+    @debug "Reading extra crop parameters for AquaCrop crop model"
+    croptypes = Dict{String,CropType}()
+    df = CSV.read(joinpath(cropdirectory, CROPFILE), DataFrame;
+                  missingstring="NA", types=[String, String, String, AnnualDate, AnnualDate])
+    for row in eachrow(df)
+        croptypes[row.persefone_name] =
+            CropType(name = row.persefone_name,
+                     aquacrop_name = row.aquacrop_name,
+                     group = row.crop_group,
+                     minsowdate = row.min_sow_date,
+                     maxsowdate = row.max_sow_date)
+    end
+    return croptypes
+end
+
+mutable struct CropState
+    croptype::CropType
     height::Tlength  # TODO: remove height field, supply from cropstate
+    soiltype::SoilType
     cropstate::AquaCrop.AquaCropField
 
-    function AquaCropState(croptype::AquaCropType, height::Tlength=0.0cm)
-        runtype = AquaCrop.TomlFileRun()
-        parentdir = AquaCrop.test_toml_dir  # TODO: hardcoded croptype
-        ac_cropfield, all_ok = AquaCrop.start_cropfield(; parentdir, runtype)
-        if ! all_ok.logi
-            error("AquaCrop.start_cropfield() failed, status = $all_ok")
-        end
-        AquaCrop.setup_cropfield!(ac_cropfield, all_ok; runtype=runtype)
+    function CropState(crop_type::CropType, soiltype::SoilType,
+                       model::SimulationModel, height::Tlength=0.0cm)
+        # TODO: get this mapping for soil types from a CSV file?
+        soiltype_to_aquacrop = Dict(soiltype => replace(string(soiltype), "_" => " ")
+                                    for soiltype in instances(SoilType))
+        aquacrop_soiltype = soiltype_to_aquacrop[soiltype]
+
+        start_date = model.date            # TODO: maybe `today(model)`
+        end_date = model.weather.lastdate  # TODO: maybe `lastdate(model)`
+        start_daynum = daynumber(model.weather, start_date)
+        end_daynum = daynumber(model.weather, end_date)
+        dayrange = start_daynum:end_daynum
+        input_Tmin = @view model.weather.mintemp[dayrange]
+        input_Tmax = @view model.weather.maxtemp[dayrange]
+        input_ETo = @view model.weather.evapotranspiration[dayrange]
+        input_Rain = @view model.weather.precipitation[dayrange]
+
+        # Generate the keyword object for the AquaCrop simulation
+        kwargs = (
+            ## Necessary keywords
+            # runtype
+            runtype = AquaCrop.NoFileRun(),
+            # project input
+            Simulation_DayNr1 = start_date,
+            Simulation_DayNrN = end_date,
+            Crop_Day1 = start_date,    # TODO: originally start_date + Week(1)
+            Crop_DayN = end_date,
+            # soil
+            soil_type = aquacrop_soiltype,
+            # crop
+            crop_type = crop_type.aquacrop_name,
+            # Climate
+            InitialClimDate = start_date,
+            ## Optional keyworkds
+            # Climate
+            Tmin = input_Tmin,
+            Tmax = input_Tmax,
+            ETo = input_ETo,
+            Rain = input_Rain,
+            # change soil properties
+            soil_layers = Dict("Thickness" => 5.0)
+        )
+
+        aquacrop_cropfield, all_ok = AquaCrop.start_cropfield(; kwargs...)
         if ! all_ok.logi
-            error("AquaCrop.setup_cropfield!() failed, status = $all_ok")
+            @error "Failed calling AquaCrop.start_cropfield()\nAquaCrop error: $(all_ok.msg)"
         end
-        return new(croptype, height, ac_cropfield)
+
+        return new(crop_type, height, soiltype, aquacrop_cropfield)
     end
 end
 
-croptype(cs::AquaCropState) = cs.croptype
-cropname(cs::AquaCropState) = cropname(croptype(cs))
-cropheight(cs::AquaCropState) = cs.height  # TODO: use AquaCrop state info
-cropcover(cs::AquaCropState) = AquaCrop.canopycover(cs.cropstate)
-cropyield(cs::AquaCropState) = AquaCrop.dryyield(cs.cropstate)  # TODO: there is also freshyield
-isharvestable(cs::AquaCropState) = true  # TODO: implement this correctly
+croptype(cs::CropState) = cs.croptype
+cropname(cs::CropState) = cropname(croptype(cs))
+cropheight(cs::CropState) = cs.height  # TODO: calculate from AquaCrop state info
+cropcover(cs::CropState) = AquaCrop.canopycover(cs.cropstate)
+cropyield(cs::CropState) = AquaCrop.dryyield(cs.cropstate)  # TODO: there is also freshyield
+isharvestable(cs::CropState) = true  # TODO: implement this correctly
 
 """
     stepagent!(cropstate, model)
 
 Update a crop state by one day.
 """
-function stepagent!(cs::AquaCropState, model::SimulationModel)
+function stepagent!(cs::CropState, model::SimulationModel)
     AquaCrop.dailyupdate!(cs.cropstate)
 end
 
@@ -76,7 +180,10 @@ end
 
 Change the cropstate to sow the specified crop.
 """
-function sow!(cs::AquaCropState, model::SimulationModel, cropname::String)
+function sow!(cs::CropState, model::SimulationModel, cropname::String)
+    if cropname ∉ keys(model.crops)
+        @error "cropname \"$cropname\" not found"
+    end
     cs.croptype = model.crops[cropname]
     cs.height = 0.0cm
     # TODO: other things needed for AquaCrop?
@@ -87,9 +194,9 @@ end
 
 Harvest the crop of this cropstate.
 """
-function harvest!(cs::AquaCropState, model::SimulationModel)
+function harvest!(cs::CropState, model::SimulationModel)
     # TODO: implement this
     return 0.0u"g/m^2"
 end
 
-end # module WrapAquaCrop
+end # module
diff --git a/src/crop/cropmodels.jl b/src/crop/cropmodels.jl
index 4a67eba..715e74b 100644
--- a/src/crop/cropmodels.jl
+++ b/src/crop/cropmodels.jl
@@ -23,12 +23,9 @@ function initcropmodel(cropmodel::AbstractString, cropdirectory::AbstractString)
         crops = Dict(name => SimpleCrop.CropType(ct.name, ct.group, ct.minsowdate, ct.maxsowdate)
                      for (name, ct) in crops_almass)
     elseif cropmodel == "aquacrop"
-        Tcroptype = WrapAquaCrop.AquaCropType
-        Tcropstate = WrapAquaCrop.AquaCropState
-        # TODO: temporarily using ALMaSS crop types
-        crops_almass = ALMaSS.readcropparameters(cropdirectory)
-        crops = Dict(name => WrapAquaCrop.AquaCropType(ct.name, ct.group, ct.minsowdate, ct.maxsowdate)
-                     for (name, ct) in crops_almass)
+        Tcroptype = AquaCropWrapper.CropType
+        Tcropstate = AquaCropWrapper.CropState
+        crops = AquaCropWrapper.readcropparameters(cropdirectory)
     else
         error("initcropmodel: no implementation for crop model '$cropmodel'")
     end
@@ -54,8 +51,8 @@ function initfields!(model::SimulationModel)
                 model.landscape[x,y].fieldid = objectid
                 push!(model.farmplots[objectid].pixels, (x,y))
             else
-                cropstate = makecropstate(model)
                 soiltype = model.landscape[x,y].soiltype
+                cropstate = makecropstate(model, soiltype)
                 fp = FarmPlot(length(model.farmplots) + 1, [(x, y)], -1, soiltype, cropstate)
                 push!(model.farmplots, fp)
                 model.landscape[x,y].fieldid = fp.id
@@ -64,20 +61,35 @@ function initfields!(model::SimulationModel)
         end
     end
 
-    # Adjust soil type to most common soil type of the pixels
-    # belonging to a farmplot
+    # Adjust farmplot soil type to most common soil type of its
+    # pixels, and set the cropstate soil type to this as well.
     for fp in model.farmplots
         fp.soiltype = mode(map(p -> model.landscape[p[1], p[2]].soiltype, fp.pixels))
+        set_cropstate_soiltype!(fp, model)
     end
+
     @info "Initialised $(length(model.farmplots)) farm plots."
 end
 
+# TODO: this function should be moved to the individual crop models,
+# and overloaded on the type of cropstate
+"""
+    set_cropstate_soiltype!(farmplot, model)
+"""
+function set_cropstate_soiltype!(farmplot::FarmPlot, model::SimulationModel)
+    if @param(crop.cropmodel) == "aquacrop"
+        farmplot.cropstate.soiltype = farmplot.soiltype
+    else
+        # do nothing for other cropmodels
+    end
+end
+
 """
-    makecropstate(model, cropmodel)
+    makecropstate(model, soiltype)
 
 An internal utility function to initialise one instance of the configured crop growth model.
 """
-function makecropstate(model::SimulationModel)
+function makecropstate(model::SimulationModel, soiltype::SoilType)
     if @param(crop.cropmodel) == "almass"
         phase = (month(model.date) < 3 ? ALMaSS.janfirst : ALMaSS.marchfirst)
         cs = ALMaSS.CropState(
@@ -90,7 +102,8 @@ function makecropstate(model::SimulationModel)
             0.0m
         )
     elseif @param(crop.cropmodel) == "aquacrop"
-        cs = WrapAquaCrop.AquaCropState(model.crops["natural grass"], 0.0cm)
+        croptype = model.crops["natural grass"]
+        cs = AquaCropWrapper.CropState(croptype, soiltype, model)
     else
         Base.error("Unhandled crop model '$(@param(crop.cropmodel))' in makecropstate().")
     end
diff --git a/src/parameters.toml b/src/parameters.toml
index 743343a..c3d8aff 100644
--- a/src/parameters.toml
+++ b/src/parameters.toml
@@ -23,7 +23,7 @@ enddate = 2023-12-31 # last day of the simulation
 #enddate = 2022-01-02 # last day of the simulation (test value)
 
 [world]
-mapdirectory = "data/regions/jena" # the directory in which all geographic data are stored
+mapdirectory = "data/regions/jena/" # the directory in which all geographic data are stored
 mapresolution = 10 # map resolution in meters
 landcovermap = "landcover.tif" # name of the landcover map in the map directory
 farmfieldsmap = "fields.tif" # name of the field geometry map in the map directory
@@ -46,5 +46,5 @@ indoutfreq = "end" # output frequency individual-level data, daily/monthly/yearl
 insectmodel = ["season", "habitat", "pesticides", "weather"] # factors affecting insect growth
 	
 [crop]
-cropmodel = "almass" # crop growth model to use, "almass", "aquacrop", or "simple"
-cropdirectory = "data/crops/almass" # the directory storing all data files for the selected crop model
+cropmodel = "aquacrop" # crop growth model to use, "almass", "aquacrop", or "simple"
+cropdirectory = "data/crops/aquacrop/" # the directory storing all data files for the selected crop model
diff --git a/test/crop_tests.jl b/test/crop_tests.jl
index 17ec180..e80ed59 100644
--- a/test/crop_tests.jl
+++ b/test/crop_tests.jl
@@ -57,15 +57,18 @@ end
 end
 
 @testset "Submodule AquaCrop" begin
-    PsAC = Ps.WrapAquaCrop
-    ct = PsAC.AquaCropType("olive tree", "no_crop_group", AnnualDate(4, 1), AnnualDate(5, 1))
+    model = initialise(; configfile=TESTPARAM_AQUACROP)
+
+    PsAC = Ps.AquaCropWrapper
+    ct = PsAC.CropType("olive tree", "alfalfaGDD", "no_crop_group", AnnualDate(4, 1), AnnualDate(5, 1))
     id = 0
     pixels = [(0, 0)]
     farmer = 0
-    fp = FarmPlot(id, pixels, farmer, PsAC.AquaCropState(ct))
+    soiltype = Ps.sand
+    fp = FarmPlot(id, pixels, farmer, soiltype, PsAC.CropState(ct, soiltype, model))
     @test fp isa FarmPlot
-    @test fp isa FarmPlot{PsAC.AquaCropState}
-    @test croptype(fp) isa PsAC.AquaCropType
+    @test fp isa FarmPlot{PsAC.CropState}
+    @test croptype(fp) isa PsAC.CropType
     @test cropname(fp) isa String
     @test cropheight(fp) isa Length{Float64}
     @test cropcover(fp) isa Float64
diff --git a/test/test_parameters_aquacrop.toml b/test/test_parameters_aquacrop.toml
index 82bc805..9f29fdc 100644
--- a/test/test_parameters_aquacrop.toml
+++ b/test/test_parameters_aquacrop.toml
@@ -42,5 +42,4 @@ insectmodel = ["season", "habitat", "pesticides"] # which factors affect insect
 
 [crop]
 cropmodel = "aquacrop" # crop growth model to use, "almass", "aquacrop", or "simple"
-# TODO: dir is for ALMaSS parameters
-cropdirectory = "data/crops/almass/"
+cropdirectory = "data/crops/aquacrop/"
-- 
GitLab