Weather-driven Phenology

This tutorial develops a thermal-time model from an equation, connects it to daily weather, and stops the simulation at maturity. It introduces the pattern used by larger crop models: process components are written independently, then composed with environment and controller systems.

The model

For daily mean temperature $T$, base temperature $T_b$, and optimum temperature $T_{opt}$, define effective temperature and accumulated thermal time as

\[\Delta T = \max(\min(T, T_{opt}) - T_b, 0), \qquad TT_{i+1} = TT_i + \Delta T_i \Delta t.\]

The crop is mature when $TT$ reaches its requirement.

Prepare weather data

The example is self-contained. A real analysis would normally read the same columns from a CSV file. The Beltsville, Maryland 2002 weather.csv used by the original tutorial remains available as a larger unit-annotated sample.

using Cropbox
using DataFrames
using Dates

dates = Date(2025, 4, 1):Day(1):Date(2025, 4, 20)
weather = DataFrame(
    date = collect(dates),
    Tavg = [10, 11, 12, 14, 15, 16, 17, 16, 15, 14,
            13, 12, 14, 16, 18, 19, 17, 15, 13, 12],
)
20×2 DataFrame
RowdateTavg
DateInt64
12025-04-0110
22025-04-0211
32025-04-0312
42025-04-0414
52025-04-0515
62025-04-0616
72025-04-0717
82025-04-0816
92025-04-0915
102025-04-1014
112025-04-1113
122025-04-1212
132025-04-1314
142025-04-1416
152025-04-1518
162025-04-1619
172025-04-1717
182025-04-1815
192025-04-1913
202025-04-2012

Keep units at the model boundary. Here the table contains plain numbers and the drive declaration supplies degrees Celsius.

Declare the weather component

@system Weather begin
    calendar(context)   ~ ::Calendar
    date(calendar.date) ~ track::date

    data                ~ provide(parameter, index = :date, init = date)
    T: temperature      ~ drive(from = data, by = :Tavg, u"°C")
end
Main.Weather

provide stores an indexed table. drive reads the row corresponding to the current calendar date. The model equations depend on T, not on DataFrame operations.

Declare thermal time

@system ThermalTime begin
    Tb: base_temperature                    => 5                 ~ preserve(parameter, u"°C")
    Topt: optimum_temperature               => 30                ~ preserve(parameter, u"°C")
    requirement                             => 75                ~ preserve(parameter, u"K*d")

    Tbounded(T, Topt)                       => T                 ~ track(max = Topt, u"°C")
    ΔT(Tbounded, Tb): effective_temperature => Tbounded - Tb     ~ track(min = 0, u"K")
    TT(ΔT): thermal_time                                         ~ accumulate(u"K*d")
    mature(TT, requirement)                 => TT >= requirement ~ flag
end
Main.ThermalTime

The max = Topt tag caps the stored temperature and min = 0 implements the lower bound of effective temperature. The equation therefore contains no hidden clamping function. accumulate combines effective temperature with the daily clock step and stores kelvin-days.

Compose the executable model

@system PhenologyModel(ThermalTime, Weather, Controller)
Main.PhenologyModel

Cropbox.hierarchy shows how the reusable process, environment, controller, and child systems are assembled:

h = Cropbox.hierarchy(PhenologyModel; skipcontext = true)
println(repr(MIME("text/plain"), h))
nothing
{PhenologyModel, ThermalTime, Weather, Calendar, Controller}

PhenologyModel system hierarchy

Direct writeimage output from hierarchy(PhenologyModel; skipcontext = true). Dashed arrows identify mixins; solid arrows identify child-system relationships. The runtime context is omitted while the Calendar used by Weather remains visible.

The final system contains the process, environment, and root controller. The same ThermalTime component can be reused with another weather implementation.

Configure the scenario

config = @config (
    Clock => :step => 1u"d",
    Calendar => :init => ZonedDateTime(2025, 4, 1, tz"UTC"),
    Weather => :data => weather,
)

Config for 3 systems:

Clock
step=24 hr
Calendar
init=ZonedDateTime(2025, 4, 1, tz"UTC")
Weather
data=20×2 DataFrame…

Run to maturity

result = simulate(PhenologyModel;
    config,
    stop = :mature,
    index = :date,
    target = [:T, :ΔT, :TT, :mature],
)
10×5 DataFrame
RowdateTΔTTTmature
DateQuantity…Quantity…Quantity…Bool
12025-04-0110.0 °C5.0 K0.0 d Kfalse
22025-04-0211.0 °C6.0 K5.0 d Kfalse
32025-04-0312.0 °C7.0 K11.0 d Kfalse
42025-04-0414.0 °C9.0 K18.0 d Kfalse
52025-04-0515.0 °C10.0 K27.0 d Kfalse
62025-04-0616.0 °C11.0 K37.0 d Kfalse
72025-04-0717.0 °C12.0 K48.0 d Kfalse
82025-04-0816.0 °C11.0 K60.0 d Kfalse
92025-04-0915.0 °C10.0 K71.0 d Kfalse
102025-04-1014.0 °C9.0 K81.0 d Ktrue

Inspect the last row rather than assuming maturity occurs exactly on the requirement. A discrete daily model normally crosses the threshold.

result[end, [:date, :TT, :mature]]
DataFrameRow (3 columns)
RowdateTTmature
DateQuantity…Bool
102025-04-1081.0 d Ktrue

Plot temperature and thermal time

visualize(result, :date, :TT; kind = :line)
date Apr 1, 2025 2 3 4 5 6 7 8 9 10 TT 0 50 (d K)

Plot variables with incompatible dimensions in separate panels. A shared axis for temperature and thermal time would be visually convenient but physically misleading.

Compare base temperatures

configs = @config config + !(ThermalTime => :Tb => [0, 5, 10])

comparison = simulate(PhenologyModel;
    configs,
    stop = :mature,
    index = :date,
    target = [:TT, :mature],
    meta = :ThermalTime,
)
35×4 DataFrame
RowdateTTmatureTb
DateQuantity…BoolQuantity…
12025-04-010.0 d Kfalse0 °C
22025-04-0210.0 d Kfalse0 °C
32025-04-0321.0 d Kfalse0 °C
42025-04-0433.0 d Kfalse0 °C
52025-04-0547.0 d Kfalse0 °C
62025-04-0662.0 d Kfalse0 °C
72025-04-0778.0 d Ktrue0 °C
82025-04-010.0 d Kfalse5 °C
92025-04-025.0 d Kfalse5 °C
102025-04-0311.0 d Kfalse5 °C
112025-04-0418.0 d Kfalse5 °C
122025-04-0527.0 d Kfalse5 °C
132025-04-0637.0 d Kfalse5 °C
142025-04-0748.0 d Kfalse5 °C
152025-04-0860.0 d Kfalse5 °C
162025-04-0971.0 d Kfalse5 °C
172025-04-1081.0 d Ktrue5 °C
182025-04-010.0 d Kfalse10 °C
192025-04-020.0 d Kfalse10 °C
202025-04-031.0 d Kfalse10 °C
212025-04-043.0 d Kfalse10 °C
222025-04-057.0 d Kfalse10 °C
232025-04-0612.0 d Kfalse10 °C
242025-04-0718.0 d Kfalse10 °C
252025-04-0825.0 d Kfalse10 °C
262025-04-0931.0 d Kfalse10 °C
272025-04-1036.0 d Kfalse10 °C
282025-04-1140.0 d Kfalse10 °C
292025-04-1243.0 d Kfalse10 °C
302025-04-1345.0 d Kfalse10 °C
312025-04-1449.0 d Kfalse10 °C
322025-04-1555.0 d Kfalse10 °C
332025-04-1663.0 d Kfalse10 °C
342025-04-1772.0 d Kfalse10 °C
352025-04-1879.0 d Ktrue10 °C

Each configuration creates a fresh model. The metadata column records the base temperature used in each run.

Replace synthetic data

For a CSV file with date and Tavg columns:

using CSV, DataFrames

weather = CSV.read("weather.csv", DataFrame)
weather.date = Date.(weather.date)
config = @config config + (Weather => :data => weather)

Before simulation, verify that dates are ordered, unique, and cover the entire requested period. provide cannot invent missing weather observations.

Extend the model

Useful next exercises are:

  • add optimum and ceiling temperatures with a beta response;
  • replace mature with several stage flags;
  • use remember(when=mature) to capture the maturity date;
  • compare simulated dates with observations using evaluate;
  • calibrate Tb and requirement on training years, then evaluate held-out years.

The Evaluate and Calibrate Models workflow covers the last two steps.