Skip to content

Opt-In Physics

CropForge v0.2.0 introduced Opt-In Physics — pre-built, mathematically verified solvers that plug into the simulation's negative phase execution slots. v0.5.0 extends this with two new advanced engines: Beer-Lambert Radiation Interception and Wind-driven Spatial Disease Spread.

The Philosophy

CropForge was built to let researchers write their own mathematical models in Python. However, many models require standard environmental, soil, or epidemiological calculations. Writing these from scratch for every experiment is tedious and error-prone.

Opt-In Physics provides standard, robust implementations. Crucially, they are gated — if you never call farm.use_physics(), CropForge behaves identically to v0.1.0: a blank canvas for your own math.


Enabling Physics

All engines are enabled through a single call before farm.run():

farm.use_physics(
    et0=True,               # FAO-56 Penman-Monteith ET0
    root_impedance=True,    # Soil penetration resistance
    water_balance=True,     # Soil water balance (requires et0=True)
    radiation=True,         # Beer-Lambert light interception (v0.5.0)
    disease=True,           # Wind-anisotropic SIR disease spread (v0.5.0)
    # --- disease configuration ---
    disease_foci=[(15, 15)],
    disease_wind_direction_deg=270.0,
    disease_spread_rate=0.20,
)

No decorator syntax is needed. Any combination of engines can be enabled independently.


Execution Order

Physics engines run at negative phases, guaranteed before any researcher @farm.step (which default to phase=0 or higher):

Phase Engine
-4 Lateral flow + nitrogen transport
-3 Soil water balance (FAO-56 hydrology)
-2 ET0 Penman-Monteith + Radiation Interception
-1 Root impedance + Spatial disease spread
0+ Researcher @farm.step functions

FAO-56 Penman-Monteith ET0 (et0=True)

Reads daily weather from EnvironmentState and computes reference evapotranspiration. Writes:

  • env.et0_mm — reference ET (mm/day)
  • env.vp_kpa, env.psychrometric_kpa, env.slope_svp, env.net_radiation_mj — FAO-56 intermediates

Root Impedance (root_impedance=True)

Maps each plant's root_depth_cm to the soil grid's penetration_resistance. Writes plant.root_growth_multiplier:

  • 1.0 — unrestricted growth
  • → 0.0 — hard-pan block when resistance ≥ 2.5 MPa

Soil Water Balance (water_balance=True)

Closes the ET₀ → soil moisture → plant stress loop automatically. Requires et0=True. Computes daily drainage, runoff, and writes Ks (water stress coefficient) to soil voxels.


Beer-Lambert Radiation Interception (radiation=True) — v0.5.0

Implements the standard canopy light interception equation for every living plant:

$$\text{PAR}_{\text{int}} = \text{solar_rad} \times 0.5 \times \left(1 - e^{-k \times \text{LAI}}\right)$$

where:

  • solar_rad = env.radiation_mj_m2 (MJ m⁻² day⁻¹)
  • 0.5 = PAR fraction of total solar radiation
  • k = extinction coefficient (default 0.45 for C3 crops; use 0.50 for C4/maize)
  • LAI = plant.lai

Output: plant.custom['intercepted_par_mj'] — readable by any plugin or @farm.step.

Enabling

farm.use_physics(radiation=True, k_extinction=0.45)

Reading the result in a plugin or step

@farm.step(interval="daily")
def use_par(state, env):
    for plant in state.plants:
        par = plant.custom.get("intercepted_par_mj", 0.0)
        # Use intercepted PAR for RUE-based biomass accumulation
        delta_biomass = par * 1.5  # example: RUE = 1.5 g/MJ
        plant.biomass_g += delta_biomass
    return state

Backward compatibility

When radiation=False (the default), plant.custom will never have an intercepted_par_mj key. Always use .get("intercepted_par_mj", 0.0) in any code that may run with or without this engine.


Wind-driven Anisotropic Disease Spread (disease=True) — v0.5.0

A spatially explicit SIR (Susceptible–Infected–Resistant) grid model that simulates disease or pest pressure propagating across the plant grid.

The model

Each plant has one of three states stored in plant.custom['disease_state']:

State Meaning
'S' Susceptible (default; healthy)
'I' Infected (spreading; accumulating stress)
'R' Resistant / removed

Each day, every infected plant attempts to infect its 4-connected neighbours. The probability is weighted by wind direction: downwind neighbours receive a much higher infection probability than upwind neighbours.

Wind direction convention

disease_wind_direction_deg follows the meteorological bearing: the direction from which the wind blows.

Value Wind from Spreads toward
North South
90° East West
180° South North
270° West East ← typical trial scenario

Enabling

farm.use_physics(
    disease=True,
    disease_foci=[(15, 15)],          # (row, col) infected on Day 1
    disease_spread_rate=0.15,          # base daily infection probability
    disease_latency_days=5,            # days before plant becomes contagious
    disease_stress_increment=0.04,     # daily stress_index increase per infected plant
    disease_wind_direction_deg=270.0,  # wind from West → spreads East
    disease_anisotropy=0.80,           # 0=isotropic, 1=fully directional
    disease_seed=42,                   # optional reproducibility seed
)

Or seed the outbreak on a specific day using the Event system:

from cropforge.events import Event

@farm.add_event(Event.custom(field="MyField", day=40))
def introduce_blight(field_state, env_state):
    center = next(p for p in field_state.plants if p.row == 15 and p.col == 15)
    center.custom["disease_state"] = "I"
    center.custom["days_infected"]  = 0
    center.custom["disease_stress"] = 0.0
    return field_state

Schema keys written (plant.custom)

Key Type Description
disease_state str 'S', 'I', or 'R'
disease_stress float Cumulative disease stress (0–1)
days_infected int Days since first infection

disease_stress integrates into plant.stress_index automatically (at 50% weight) to couple disease pressure with the growth model.

Backward compatibility

When disease=False (the default), no disease_state key is ever written. Always use .get("disease_state", "S") in portable code.


Combining Engines

All engines are fully composable. A complete v0.5.0 research setup:

farm.use_physics(
    et0=True,
    water_balance=True,
    root_impedance=True,
    radiation=True,
    k_extinction=0.45,
    disease=True,
    disease_foci=[(10, 10)],
    disease_wind_direction_deg=270.0,
    disease_spread_rate=0.20,
)

See examples/disease_outbreak_trial.py for a complete working script and the Disease Modeling Tutorial for a step-by-step walkthrough.