Skip to content

models package

simpest.models special

Model modules for SIMPLACE and FraNchEstYN workflows.

fr_crop_model

Daily crop growth step and disease damage mechanisms.

This module computes one day of crop growth together with the four disease damage mechanisms that couple the epidemiological state to crop performance:

  • light stealers reduce intercepted radiation,
  • RUE reducers lower radiation use efficiency,
  • assimilate sappers drain fixed carbon, and
  • senescence accelerators shorten the green-canopy duration.

Crop growth is computed through one of two branches:

  • Internal growth model. When no external crop-model series is supplied, the canopy, biomass, and yield are simulated from thermal time using logistic light-interception curves and a radiation-use-efficiency biomass model.
  • External growth model. When a daily crop-model series is supplied, the attainable light interception, biomass, and yield are taken from that series, and the damage mechanisms are applied to the daily increments to obtain the actual (disease-limited) trajectories.

In the external branch, day_after_sowing is incremented each simulated day so that the runner's maturity/safety stop and the calibration objective's "is-planted" logic operate correctly, and growing_degree_days is carried from the external series. These fields therefore reflect the simulated crop calendar rather than being left unset.

run(input_, parameters, output, output1)

Compute one daily crop growth step.

The four disease damage mechanisms are derived first from the current severity, then crop growth is advanced through either the internal growth model (logistic light interception plus a radiation-use-efficiency biomass model) or the external crop-model branch, depending on whether a daily crop-model series is attached to input_. In both branches the attainable (disease-free) and actual (disease-limited) light interception, biomass, and yield are written to output1.

Parameters:

Name Type Description Default
input_ InputsDaily

Today's daily inputs, including the optional external crop-model series.

required
parameters Parameters

Model parameters, including the crop and disease groups.

required
output Outputs

Previous day's output state, used as the starting state.

required
output1 Outputs

Today's output state, updated in place.

required

Returns:

Type Description
None

This function updates output1 in place.

Source code in simpest/models/fr_crop_model.py
def run(input_: InputsDaily, parameters: Parameters,
        output: Outputs, output1: Outputs) -> None:
    """Compute one daily crop growth step.

    The four disease damage mechanisms are derived first from the current
    severity, then crop growth is advanced through either the internal growth
    model (logistic light interception plus a radiation-use-efficiency biomass
    model) or the external crop-model branch, depending on whether a daily
    crop-model series is attached to ``input_``. In both branches the attainable
    (disease-free) and actual (disease-limited) light interception, biomass, and
    yield are written to ``output1``.

    Args:
        input_ (InputsDaily): Today's daily inputs, including the optional
            external crop-model series.
        parameters (Parameters): Model parameters, including the crop and disease
            groups.
        output (Outputs): Previous day's output state, used as the starting state.
        output1 (Outputs): Today's output state, updated in place.

    Returns:
        None: This function updates ``output1`` in place.
    """
    pc = parameters.par_crop
    pd = parameters.par_disease

    # -----------------------------------------------------------------------
    # Damage mechanisms – always computed before selecting model branch
    # -----------------------------------------------------------------------
    severity = output.disease.disease_severity

    output1.disease.damage_mechanisms.light_stealers = (
        1.0 - _light_stealers_fn(severity, pd.light_stealer_damage)
    )
    output1.disease.damage_mechanisms.rue_reducers = (
        1.0 - _rue_reduction_fn(severity, pd.rue_reducer_damage)
    )
    output1.disease.damage_mechanisms.assimilate_sappers = (
        _assimilate_sappers_fn(severity, pd.assimilate_sappers_damage)
    )
    output1.disease.damage_mechanisms.senescence_accelerators = (
        _senescence_accelerator_fn(severity, pd.senescence_accelerator_damage)
    )

    dm = output1.disease.damage_mechanisms
    cmd = input_.crop_model_data

    # -----------------------------------------------------------------------
    # Branch A: internal crop growth model (no external crop-model series)
    # -----------------------------------------------------------------------
    if cmd is None or len(cmd.f_int) == 0:
        t_ave = (input_.tmin + input_.tmax) / 2.0
        t_func = t_response(t_ave, pc.tbase_crop, pc.topt_crop, pc.tmax_crop)

        # Accumulate GDD
        output1.crop.growing_degree_days = (
            output.crop.growing_degree_days
            + t_func * (pc.topt_crop - pc.tbase_crop)
        )

        if output.crop.cycle_completion_percentage <= 100.0:
            gdd = output1.crop.growing_degree_days

            # Phenological code (1 = vegetative, 2 = reproductive)
            output1.crop.pheno_code = _pheno_code(
                gdd, pc.flowering_start / 100.0 * pc.cycle_length
            )

            # Attainable light interception
            f_int_att, senescence_started = _f_int_compute(
                pc.cycle_length, pc.slope_growth, pc.half_int_growth,
                pc.slope_senescence, pc.half_int_senescence, gdd
            )
            output1.crop.light_interception_attainable = f_int_att
            output1.crop.senescence_started = senescence_started

            # Save peak fInt at the onset of senescence
            if senescence_started and output1.crop.f_int_peak == 0.0:
                output1.crop.f_int_peak = output.crop.light_interception_attainable

            # Shift senescence half-point due to senescence accelerators
            half_int_sen_shifted = (
                pc.half_int_senescence - dm.senescence_accelerators * 100.0
            )

            # Actual light interception (with disease pressure)
            f_int_act, _ = _f_int_compute(
                pc.cycle_length, pc.slope_growth, pc.half_int_growth,
                pc.slope_senescence, half_int_sen_shifted, gdd
            )
            output1.crop.light_interception_actual = f_int_act

            # Apply light stealers
            output1.crop.light_interception_actual -= (
                output1.crop.light_interception_actual * dm.light_stealers
            )
            if output1.crop.light_interception_actual < 0.0:
                output1.crop.light_interception_actual = 0.0

            # Potential biomass accumulation
            carbon_rate_pot = _carbon_rate(
                pc.radiation_use_efficiency, input_.rad, t_func,
                output1.crop.light_interception_attainable
            ) * 10.0  # g m⁻² → kg ha⁻¹
            output1.crop.agb_attainable = output.crop.agb_attainable + carbon_rate_pot

            # Actual biomass accumulation
            carbon_rate_act = _carbon_rate(
                pc.radiation_use_efficiency
                - pc.radiation_use_efficiency * dm.rue_reducers,
                input_.rad, t_func,
                output1.crop.light_interception_actual
            ) * 10.0
            carbon_rate_act -= dm.assimilate_sappers
            output1.crop.agb_actual = output.crop.agb_actual + carbon_rate_act
            if output1.crop.agb_actual < 0.0:
                output1.crop.agb_actual = 0.0

            # Potential yield
            output1.crop.yield_attainable = output.crop.yield_attainable + _yield_rate(
                output1.crop.pheno_code, carbon_rate_pot, pc.partitioning_maximum
            )

            # Actual yield
            output1.crop.yield_actual = output.crop.yield_actual + _yield_rate(
                output1.crop.pheno_code, carbon_rate_act, pc.partitioning_maximum
            )
            if output1.crop.yield_actual < 0.0:
                output1.crop.yield_actual = 0.0

            # Days after sowing
            output1.crop.day_after_sowing = output.crop.day_after_sowing + 1

            # Cycle completion (clamped at 100 %)
            output1.crop.cycle_completion_percentage = min(
                gdd / pc.cycle_length * 100.0, 100.0
            )
        else:
            # Crop cycle complete → reset
            output1.crop = CropOutputs()
            output.crop = CropOutputs()

    # -----------------------------------------------------------------------
    # Branch B: External crop model supplies daily f_int, AGB, yield, GDD
    # -----------------------------------------------------------------------
    else:
        # Normalise date key (InputsDaily.date is datetime; dict keys are date)
        today: date = (
            input_.date.date() if isinstance(input_.date, datetime) else input_.date
        )

        if today in cmd.f_int:
            # --- Attainable values from external model ---
            output1.crop.light_interception_attainable = cmd.f_int[today]
            output1.crop.agb_attainable = cmd.agb[today]
            output1.crop.yield_attainable = cmd.yield_.get(today, 0.0)

            # --- Actual light interception ---
            f_int_att = output1.crop.light_interception_attainable
            f_int_act = (f_int_att - f_int_att * dm.light_stealers - dm.senescence_accelerators)
            if f_int_act < 0.0:
                f_int_act = 0.0
            output1.crop.light_interception_actual = f_int_act

            # Senescence flag: senescence starts when yield appears
            if cmd.yield_.get(today, 0.0) > 0.0:
                output1.crop.senescence_started = True

            # Peak fInt across entire season
            output1.crop.f_int_peak = max(cmd.f_int.values())

            # --- Daily rates (attainable) ---
            prev_day = today - timedelta(days=1)
            if prev_day in cmd.agb:
                pot_agb_rate = cmd.agb[today] - cmd.agb[prev_day]
                pot_yield_rate = (cmd.yield_.get(today, 0.0) - cmd.yield_.get(prev_day, 0.0))
            else:  # first day of season
                pot_agb_rate = cmd.agb[today]
                pot_yield_rate = cmd.yield_.get(today, 0.0)

            # --- Actual rates ---
            if f_int_att > 0.0:
                damage_ratio = (f_int_att - f_int_act) / f_int_att
                act_agb_rate = pot_agb_rate - pot_agb_rate * damage_ratio
                act_yield_rate = pot_yield_rate - pot_yield_rate * damage_ratio
            else:
                # First day, no light interception yet
                act_agb_rate = output.crop.agb_actual
                act_yield_rate = output.crop.yield_actual

            # Clamp negative rates before applying further reductions
            if act_agb_rate < 0.0:
                act_agb_rate = 0.0
            if act_yield_rate < 0.0:
                act_yield_rate = 0.0

            # Apply RUE reducers and assimilate sappers
            act_agb_rate = (
                act_agb_rate
                - act_agb_rate * dm.rue_reducers
                - dm.assimilate_sappers
            )
            act_yield_rate = (
                act_yield_rate
                - act_yield_rate * dm.rue_reducers
                - dm.assimilate_sappers
            )

            # Update state variables
            output1.crop.agb_actual = output.crop.agb_actual + act_agb_rate
            output1.crop.yield_actual = output.crop.yield_actual + act_yield_rate
            if output1.crop.agb_actual < 0.0:
                output1.crop.agb_actual = 0.0
            if output1.crop.yield_actual < 0.0:
                output1.crop.yield_actual = 0.0

            # Cycle completion from external model
            output1.crop.cycle_completion_percentage = cmd.cycle_percentage.get(today, 0.0)

            # Track the crop calendar: increment days-after-sowing so the
            # maturity/safety stop and the calibration objective's is-planted
            # logic operate correctly (see the module docstring).
            output1.crop.day_after_sowing = output.crop.day_after_sowing + 1

            # Carry thermal time from the external series. This is reporting-only
            # when use_gdd=False and drives cycle completion when use_gdd=True.
            output1.crop.growing_degree_days = cmd.gdd.get(today, 0.0)

        else:
            # Date not covered by external model → crop harvested, reset
            output1.crop = CropOutputs()
            output.crop = CropOutputs()

fr_data

Input, output, and parameter data structures for the FraNchEstYN model.

This module defines the typed containers that carry information through the simulation: hourly and daily weather inputs, external crop-model series, the crop, disease, and fungicide state written on each simulated day, the parameter groups that configure each sub-model, and the per-site simulation unit that binds sowing schedule and reference observations together.

All quantities follow the model's canonical units, which are noted inline beside each field (temperatures in °C, biomass and yield in kg ha⁻¹, radiation in MJ m⁻², light interception as a 0–1 fraction, and so on).

CropModelData dataclass

Output from an external crop model, used as input to damage mechanisms.

Keys are datetime.date objects (year, month, day).

Source code in simpest/models/fr_data.py
@dataclass
class CropModelData:
    """Output from an external crop model, used as input to damage mechanisms.

    Keys are datetime.date objects (year, month, day).
    """
    f_int: Dict[date, float] = field(default_factory=dict)          # fraction 0-1: light interception
    yield_: Dict[date, float] = field(default_factory=dict)         # kg ha⁻¹: dynamic crop yield
    agb: Dict[date, float] = field(default_factory=dict)            # kg ha⁻¹: above-ground biomass
    cycle_percentage: Dict[date, float] = field(default_factory=dict)  # %: crop cycle completion
    # Growing degree days (thermal time) carried alongside the crop series. It is
    # populated from the external crop model's thermal-time column and used to
    # derive cycle progress when GDD-based phenology is enabled.
    gdd: Dict[date, float] = field(default_factory=dict)            # °C·day: growing degree days

CropOutputs dataclass

Crop growth state variables output each day.

Source code in simpest/models/fr_data.py
@dataclass
class CropOutputs:
    """Crop growth state variables output each day."""
    growing_season: int = 0             # season identifier
    day_after_sowing: int = 0           # days since sowing
    pheno_code: float = 0.0             # phenological code (1 = vegetative, 2 = reproductive)
    growing_degree_days: float = 0.0    # °C·day accumulated GDD
    agb_attainable: float = 0.0         # kg ha⁻¹ attainable AGB
    agb_actual: float = 0.0             # kg ha⁻¹ actual AGB
    yield_attainable: float = 0.0       # kg ha⁻¹ attainable yield
    yield_actual: float = 0.0           # kg ha⁻¹ actual yield
    light_interception_attainable: float = 0.0  # fraction
    light_interception_actual: float = 0.0       # fraction
    cycle_completion_percentage: float = 0.0     # %
    senescence_started: bool = False
    f_int_peak: float = 0.0

DamageMechanisms dataclass

Damage outputs (one per day) that feed into crop loss calculations.

Source code in simpest/models/fr_data.py
@dataclass
class DamageMechanisms:
    """Damage outputs (one per day) that feed into crop loss calculations."""
    light_stealers: float = 0.0         # fraction: reduction in light interception
    senescence_accelerators: float = 0.0  # fraction: acceleration of tissue aging
    rue_reducers: float = 0.0           # fraction: reduction in radiation use efficiency
    assimilate_sappers: float = 0.0     # kg ha⁻¹: assimilate loss from disease

DiseaseOutputs dataclass

All disease state variables output each day.

Source code in simpest/models/fr_data.py
@dataclass
class DiseaseOutputs:
    """All disease state variables output each day."""
    counter_dry: float = 0.0            # hours since last wet period
    temp_function: float = 0.0          # temperature suitability (0-1)
    rh_function: float = 0.0            # RH suitability (0-1)
    hydro_thermal_time_state: float = 0.0   # accumulated hydro-thermal time (hydro-degree days)
    hydro_thermal_time_rate: float = 0.0    # daily hydro-thermal time rate
    hydro_thermal_time_infection: float = 0.0  # infection accumulation

    sporulation_efficiency: float = 0.0  # %

    tmax_daily: float = 0.0             # °C
    tmin_daily: float = 0.0             # °C
    rain_daily: float = 0.0             # mm
    rhmax_daily: float = 0.0            # %
    rhmin_daily: float = 0.0            # %
    lw_daily: float = 0.0               # % (leaf wetness fraction of day)

    # Tissue tracking is handled as a list of TissueState in disease_model.py
    # Summary aggregates written here each day:
    tissue_state: TissueState = field(default_factory=TissueState)
    latent_sum: float = 0.0             # % cumulative latent
    sporulating_sum: float = 0.0        # % cumulative sporulating
    dead_sum: float = 0.0               # % cumulative dead
    affected_sum: float = 0.0           # % cumulative affected
    disease_severity: float = 0.0       # % disease severity (sporulating + dead)
    susceptible_fraction: float = 1.0   # fraction of healthy susceptible tissue

    damage_mechanisms: DamageMechanisms = field(default_factory=DamageMechanisms)

    first_seasonal_infection: datetime = field(default_factory=lambda: datetime(1900, 1, 1))
    cycle_percentage_first_infection: float = 0.0
    is_primary_inoculum_started: bool = False
    outer_inoculum: float = 0.0

FungicideOutputs dataclass

Fungicide state variables output each day.

Source code in simpest/models/fr_data.py
@dataclass
class FungicideOutputs:
    """Fungicide state variables output each day."""
    concentration_factor: float = 0.0   # fraction - fungicide concentration
    tenacity_function: float = 0.0      # unitless - fungicide tenacity function
    tenacity: float = 0.0               # fraction - fungicide tenacity (potential efficacy after wash-off)     
    actual_degradation: float = 0.0     # fraction (1 = not degraded)
    potential_degradation: float = 0.0  # fraction
    efficacy: float = 0.0               # fraction

FungicideTreatmentSchedule dataclass

Holds all scheduled fungicide treatment dates for a simulation unit.

Source code in simpest/models/fr_data.py
@dataclass
class FungicideTreatmentSchedule:
    """Holds all scheduled fungicide treatment dates for a simulation unit."""
    treatments: List[datetime] = field(default_factory=list)

    def add_treatment(self, treatment_date: datetime) -> None:
        self.treatments.append(treatment_date)

InputsDaily dataclass

Daily input record (assembled from 24 hourly records each day).

Source code in simpest/models/fr_data.py
@dataclass
class InputsDaily:
    """Daily input record (assembled from 24 hourly records each day)."""
    date: datetime = datetime.min
    tmax: float = 0.0                   # °C
    tmin: float = 0.0                   # °C
    rad: float = 0.0                    # MJ m⁻² d⁻¹
    rhx: float = 0.0                    # % (max RH)
    rhn: float = 0.0                    # % (min RH)
    precipitation: float = 0.0          # mm
    leaf_wetness: float = 0.0           # hours
    dew_point: float = 0.0              # °C
    latitude: float = 0.0               # degrees
    date_treatment_last: datetime = datetime.min
    crop_model_data: "CropModelData" = None  # populated from external crop model CSV

InputsHourly dataclass

Hourly input record for the disease model.

Source code in simpest/models/fr_data.py
@dataclass
class InputsHourly:
    """Hourly input record for the disease model."""
    date: datetime = datetime.min
    air_temperature: float = 0.0        # °C
    precipitation: float = 0.0          # mm
    relative_humidity: float = 0.0      # %
    leaf_wetness: float = 0.0           # 0 or 1
    rad: float = 0.0                    # MJ m⁻² h⁻¹
    dis_ideotype_potential_rate: float = 0.0  # unitless
    latitude: float = 0.0               # degrees
    date_treatment_last: datetime = datetime.min

Outputs dataclass

Top-level daily output bundle.

Source code in simpest/models/fr_data.py
@dataclass
class Outputs:
    """Top-level daily output bundle."""
    disease: DiseaseOutputs = field(default_factory=DiseaseOutputs)
    crop: CropOutputs = field(default_factory=CropOutputs)
    inputs_daily: InputsDaily = field(default_factory=InputsDaily)
    fungicide: FungicideOutputs = field(default_factory=FungicideOutputs)

ParCrop dataclass

Crop model parameters.

Source code in simpest/models/fr_data.py
@dataclass
class ParCrop:
    """Crop model parameters."""
    tbase_crop: float = 0.0             # °C – base temperature for crop growth
    topt_crop: float = 0.0              # °C – optimum temperature
    tmax_crop: float = 0.0              # °C – maximum temperature
    cycle_length: float = 0.0           # degree-days – total crop cycle length
    flowering_start: float = 0.0        # % – crop cycle % when flowering starts
    half_int_growth: float = 0.0        # % – cycle % when light interception = 0.5 (growth phase)
    half_int_senescence: float = 0.0    # % – cycle % when light interception = 0.5 (senescence phase)
    slope_growth: float = 0.0           # unitless – slope of growth light interception curve
    slope_senescence: float = 0.0       # unitless – slope of senescence curve
    radiation_use_efficiency: float = 0.0  # g MJ⁻¹
    partitioning_maximum: float = 0.0   # unitless – max yield partitioning fraction
    varietal_resistance: float = 0.0    # 0–1 – varietal resistance to pathogen

ParDisease dataclass

Disease model parameters.

Source code in simpest/models/fr_data.py
@dataclass
class ParDisease:
    """Disease model parameters."""
    outer_inoculum_max: float = 0.0             # unitless – initial inoculum level
    outer_inoculum_shape_release: float = 0.0   # 0/1/2 – release shape
    outer_inoculum_shape_parameter: float = 0.0 # empirical parameter for shape 1 or 2
    pathogen_spread: float = 0.0                # dispersal potential
    wetness_duration_optimum: float = 0.0       # hours – optimal leaf wetness
    wetness_duration_minimum: float = 0.0       # hours – minimum required wetness
    dry_critical_interruption: float = 0.0      # hours – dry spell that disrupts infection
    rain50_detachment: float = 0.0              # mm – rain needed for spore detachment
    cycle_percentage_onset: float = 0.0         # % – crop cycle % when disease can start
    tmin: float = 0.0                           # °C – minimum temperature for pathogen
    topt: float = 0.0                           # °C – optimum temperature
    tmax: float = 0.0                           # °C – maximum temperature
    relative_humidity_critical: float = 0.0     # % – minimum RH for infection
    relative_humidity_not_limiting: float = 0.0 # % – RH above which it is not limiting
    hydro_thermal_time_onset: float = 0.0       # hydro-degree days – onset threshold
    latency_duration: float = 0.0               # days – latency period duration
    sporulation_duration: float = 0.0           # days – sporulation duration
    light_stealer_damage: float = 0.0           # unitless – lesion size representation
    rue_reducer_damage: float = 0.0             # fraction – RUE reduction
    senescence_accelerator_damage: float = 0.0  # fraction – senescence acceleration rate
    assimilate_sappers_damage: float = 0.0      # kg ha⁻¹ – max assimilate loss
    is_splash_borne: bool = False               # True if spore release requires rain

ParFungicide dataclass

Fungicide model parameters.

Source code in simpest/models/fr_data.py
@dataclass
class ParFungicide:
    """Fungicide model parameters."""
    a_shape_parameter: float = 0.0      # empirical degradation shape A
    b_shape_parameter: float = 0.0      # empirical degradation shape B
    degradation_rate: float = 0.0       # first-order degradation rate
    initial_dose: float = 0.0           # fraction – initial dose (1 = max dose)
    initial_efficacy: float = 0.0       # fraction – initial efficacy (1 = max)
    tenacity_factor: float = 0.0        # rain-driven degradation empirical factor

Parameter dataclass

A single parameter definition with calibration bounds.

Holds the numeric value, the lower and upper bounds used as the calibration search space, and metadata describing the parameter's class and whether it is boolean. Instances are produced by the parameter readers and consumed by both the model runner and the calibration optimizer.

Source code in simpest/models/fr_data.py
@dataclass
class Parameter:
    """A single parameter definition with calibration bounds.

    Holds the numeric value, the lower and upper bounds used as the calibration
    search space, and metadata describing the parameter's class and whether it is
    boolean. Instances are produced by the parameter readers and consumed by both
    the model runner and the calibration optimizer.
    """
    minimum: float = 0.0                # Lower bound (e.g., calibration search space)
    maximum: float = 0.0                # Upper bound (e.g., calibration search space)
    value: float = 0.0                  # Current numeric value (e.g., for simulation or optimization)
    calibration: str = ""               # calibration subset tag (e.g., "included", "excluded")
    param_class: str = ""               # class/category of the parameter (e.g., "disease", "crop")
    value_bool: bool = False            # For boolean parameters, the value is stored here instead of 'value'
    is_boolean: bool = False            # True if this parameter is boolean (e.g., "IsSplashBorne"), in which case 'value_bool' is used instead of 'value'

Parameters dataclass

Container for all three parameter groups.

Source code in simpest/models/fr_data.py
@dataclass
class Parameters:
    """Container for all three parameter groups."""
    par_disease: ParDisease = field(default_factory=ParDisease)
    par_crop: ParCrop = field(default_factory=ParCrop)
    par_fungicide: ParFungicide = field(default_factory=ParFungicide)

ReferenceData dataclass

Observed / reference data for validation (field measurements).

Source code in simpest/models/fr_data.py
@dataclass
class ReferenceData:
    """Observed / reference data for validation (field measurements)."""
    date_fint: Dict[date, float] = field(default_factory=dict)
    date_agb: Dict[date, float] = field(default_factory=dict)
    date_yield_actual: Dict[date, float] = field(default_factory=dict)
    date_yield_attainable: Dict[date, float] = field(default_factory=dict)
    # disease_date_disease_sev[pathogen][date] = severity
    disease_date_disease_sev: Dict[str, Dict[date, float]] = field(default_factory=dict)

SimulationUnit dataclass

Everything needed to run one site × variety combination.

Source code in simpest/models/fr_data.py
@dataclass
class SimulationUnit:
    """Everything needed to run one site × variety combination."""
    site: str = ""                  # site identifier (e.g., location name)
    crop: str = ""                  # crop identifier (e.g., crop name)
    latitude: float = 0.0           # degrees - site latitude
    longitude: float = 0.0          # degrees - site longitude
    variety: str = ""               # variety identifier (e.g., variety name)
    pathogen: str = ""              # pathogen identifier
    variety_resistance: float = 0.0 # 0–1 – varietal resistance to pathogen

    year_sowing_doy: Dict[int, int] = field(default_factory=dict)           # Maps simulation year → sowing day-of-year

    reference_data: ReferenceData = field(default_factory=ReferenceData)    # observed or reference data for validation 
    fungicide_treatment_schedule: FungicideTreatmentSchedule = field(       # fungicide treatment scheduling
        default_factory=FungicideTreatmentSchedule
    )

TissueState dataclass

State of a single tissue cohort (SEIR disease model).

Source code in simpest/models/fr_data.py
@dataclass
class TissueState:
    """State of a single tissue cohort (SEIR disease model)."""
    latent_state: float = 0.0           # fraction currently in latent phase
    sporulating_state: float = 0.0      # fraction currently sporulating
    dead_state: float = 0.0             # fraction dead
    latent_counter: float = 0.0         # progress counter in latent phase (→ 1 = transitions to sporulating)
    sporulating_counter: float = 0.0    # progress counter in sporulating phase (→ 1 = transitions to dead)

fr_disease_model

Hourly infection accumulation and daily SEIR tissue progression.

This module implements the epidemiological core of the model. Infection pressure is accumulated at an hourly time step from temperature and leaf-wetness suitability; once per day the accumulated pressure drives new infections and the progression of existing lesions through a compartmental SEIR scheme (susceptible → latent → sporulating → dead).

The :class:DiseaseModel is stateful and must be instantiated once per growing season (the runner creates a fresh instance at sowing). Within a day, :meth:DiseaseModel.run_hourly is called for every hour; at the end of the day the runner advances the daily state and calls :meth:DiseaseModel.run_daily with the aggregated weather.

DiseaseModel

Stateful SEIR disease model.

Lifecycle

Create a fresh instance at the start of each growing season (sowing date). For each hour: call run_hourly(). After hour 23 (handled by the runner): call run_daily().

Source code in simpest/models/fr_disease_model.py
class DiseaseModel:
    """Stateful SEIR disease model.

    Lifecycle
    ---------
    Create a fresh instance at the start of each growing season (sowing date).
    For each hour: call run_hourly().
    After hour 23 (handled by the runner): call run_daily().
    """

    def __init__(self) -> None:
        # Hourly accumulators – cleared at end of each day by run_hourly()
        self._temp: List[float] = []
        self._rh:   List[float] = []
        self._rain: List[float] = []
        self._lw:   List[float] = []

        # Tissue cohort list – persists across the entire season
        self.tissue_tracking: List[TissueState] = []

    # -----------------------------------------------------------------------
    # Hourly step
    # -----------------------------------------------------------------------

    def run_hourly(
        self,
        input_: InputsHourly,
        parameters: Parameters,
        output: Outputs,
        output1: Outputs,
    ) -> None:
        """Process one hour of weather data.

        During hours 0–22 this accumulates the hydro-thermal time rate (the
        product of temperature and relative-humidity suitability) and tracks the
        dry-spell counter. At hour 23 it finalises the daily infection and
        sporulation efficiencies, normalises the accumulated rate, records the
        daily weather summary, and clears the hourly buffers.

        The same ``output1`` object is shared across all 24 hours of a day; the
        daily state advance happens only after the hour-23 call returns.

        Args:
            input_ (InputsHourly): The current hour's weather record.
            parameters (Parameters): Model parameters, including the disease group.
            output (Outputs): Previous day's output state, used to carry forward
                the accumulated hydro-thermal time state.
            output1 (Outputs): Current day's output state, updated in place.

        Returns:
            None: The function mutates ``output1`` in place.
        """
        par = parameters.par_disease

        # Accumulate hourly observations
        self._temp.append(input_.air_temperature)
        self._rh.append(input_.relative_humidity)
        self._rain.append(input_.precipitation)
        self._lw.append(input_.leaf_wetness)

        # Dry-spell counter (resets on any wet hour, otherwise increments)
        if input_.leaf_wetness == 1:
            output1.disease.counter_dry = 0.0
        else:
            output1.disease.counter_dry += 1.0

        # Temperature suitability (0–1)
        output1.disease.temp_function = t_response(
            input_.air_temperature, par.tmin, par.topt, par.tmax
        )

        hour = input_.date.hour

        if hour == 23:
            # ----------------------------------------------------------------
            # End-of-day: finalise infection metrics and daily weather summary
            # ----------------------------------------------------------------
            rate = output1.disease.hydro_thermal_time_rate  # accumulated hours 0-22

            # Sporulation efficiency = average hourly HTT per hour of the day
            output1.disease.sporulation_efficiency = rate / 24.0

            # Infection efficiency (before normalisation against WetnessDurationOptimum)
            if rate > par.wetness_duration_minimum:
                htt_inf = rate / par.wetness_duration_optimum
                if htt_inf >= 1.0:
                    htt_inf = 1.0
                output1.disease.hydro_thermal_time_infection = htt_inf
            else:
                output1.disease.hydro_thermal_time_infection = 0.0

            # Normalise daily rate (0–1)
            rate_norm = rate / par.wetness_duration_optimum
            if rate_norm >= 1.0:
                rate_norm = 1.0
            output1.disease.hydro_thermal_time_rate = rate_norm

            # Cumulate state (carried forward day to day via output)
            output1.disease.hydro_thermal_time_state = (
                output.disease.hydro_thermal_time_state + rate_norm
            )

            # Dry-spell interruption overrides infection for this day
            if output1.disease.counter_dry > par.dry_critical_interruption:
                output1.disease.hydro_thermal_time_infection = 0.0

            # Daily weather summary (for output only)
            output1.disease.tmax_daily  = max(self._temp)
            output1.disease.tmin_daily  = min(self._temp)
            output1.disease.rhmax_daily = max(self._rh)
            output1.disease.rhmin_daily = min(self._rh)
            output1.disease.rain_daily  = sum(self._rain)
            output1.disease.lw_daily    = sum(self._lw)

            # Clear buffers for the next day
            self._temp.clear()
            self._rh.clear()
            self._rain.clear()
            self._lw.clear()

        else:
            # ----------------------------------------------------------------
            # Within-day: accumulate HTT rate when within wet-period threshold
            # ----------------------------------------------------------------
            if output1.disease.counter_dry <= par.dry_critical_interruption:
                rh_fn = _rh_function(
                    input_.relative_humidity,
                    input_.leaf_wetness,
                    par.relative_humidity_not_limiting,
                    par.relative_humidity_critical,
                )
                output1.disease.rh_function = rh_fn
                output1.disease.hydro_thermal_time_rate += (
                    output1.disease.temp_function * rh_fn
                )
            else:
                output1.disease.rh_function = 0.0

    # -----------------------------------------------------------------------
    # Daily step (called AFTER the runner swaps output ↔ output1 at hour 23)
    # -----------------------------------------------------------------------

    def run_daily(
        self,
        input_: InputsDaily,
        parameters: Parameters,
        output: Outputs,
        output1: Outputs,
    ) -> None:
        """Run the daily SEIR tissue progression.

        Given the day's accumulated infection pressure, this step (1) creates a
        new latent tissue cohort when onset conditions are met, combining
        external (primary) and internal (secondary) infection sources scaled by
        susceptible tissue, host resistance, and fungicide efficacy; (2) advances
        every existing cohort through the latent → sporulating → dead transitions
        according to thermal time; and (3) aggregates the compartment fractions
        into the daily latent, sporulating, dead, affected, and severity outputs,
        and updates the susceptible fraction for the following day.

        At entry, ``output`` holds the hourly-accumulated state for the day and
        ``output1`` is a fresh daily output whose season-persistent fields
        (growing season, peak interception, and first-infection markers) have
        already been carried forward by the runner.

        Args:
            input_ (InputsDaily): The day's aggregated inputs.
            parameters (Parameters): Model parameters, including the disease and
                crop groups.
            output (Outputs): Previous (hourly-accumulated) state for the day.
            output1 (Outputs): Current day's output state, updated in place.

        Returns:
            None: The function mutates ``output1`` and the cohort list in place.
        """
        par = parameters.par_disease
        pc  = parameters.par_crop

        # ----------------------------------------------------------------
        # New infection – only once onset conditions are satisfied
        # ----------------------------------------------------------------
        if (output.disease.hydro_thermal_time_state >= par.hydro_thermal_time_onset
                and output.crop.cycle_completion_percentage >= par.cycle_percentage_onset):

            # Record first infection date (once per season)
            if not output1.disease.is_primary_inoculum_started:
                output1.disease.first_seasonal_infection = input_.date
                output1.disease.cycle_percentage_first_infection = (
                    output.crop.cycle_completion_percentage
                )
                output1.disease.is_primary_inoculum_started = True

            # Rescale existing tissue fractions for expanding green area
            # (growth phase only; skip during senescence)
            light_today = output1.crop.light_interception_attainable
            light_yesterday = output.crop.light_interception_attainable
            if light_today > 0.0:
                if (output.disease.affected_sum <= 1.0
                        and not output1.crop.senescence_started):
                    for tissue in self.tissue_tracking:
                        ratio = light_yesterday / light_today
                        tissue.latent_state      *= ratio
                        tissue.sporulating_state *= ratio
                        tissue.dead_state        *= ratio

            # Primary inoculum (reduced by fungicide efficacy)
            outer_ino = _inoculum_model(input_, parameters, output, output1) * (
                1.0 - output1.fungicide.efficacy
            )
            output1.disease.outer_inoculum = outer_ino

            # Splash-borne: spore detachment efficiency
            spo_detach = 0.0
            if par.is_splash_borne:
                spo_detach = 1.0 - rain_detachment(
                    input_.precipitation,
                    par.rain50_detachment,
                    output1.crop.light_interception_attainable,
                )

            # External infection (primary inoculum × HTT infection efficiency)
            ext_inf = output.disease.hydro_thermal_time_infection * outer_ino

            # Internal infection (secondary spread from sporulating tissue)
            int_inf = (
                output.disease.sporulating_sum
                * output.disease.sporulation_efficiency
                * par.pathogen_spread
                * (1.0 - spo_detach)
            )

            # New infection cohort size
            latent_value = (
                (ext_inf + int_inf)
                * output.disease.susceptible_fraction
                * output1.crop.light_interception_attainable
                * (1.0 - pc.varietal_resistance)
                * (1.0 - output1.fungicide.efficacy)
            )

            if latent_value > 0.0:
                self.tissue_tracking.append(TissueState(latent_state=latent_value))

        # ----------------------------------------------------------------
        # SEIR progression for all existing cohorts
        # ----------------------------------------------------------------
        t_ave = (input_.tmin + input_.tmax) / 2.0
        gdd_disease = t_response(t_ave, par.tmin, par.topt, par.tmax)

        lat_progress = (gdd_disease / par.latency_duration
                        if par.latency_duration > 0.0 else 0.0)
        spo_progress = (gdd_disease / par.sporulation_duration
                        if par.sporulation_duration > 0.0 else 0.0)

        efficacy = output1.fungicide.efficacy

        for tissue in self.tissue_tracking:
            if tissue.dead_state == 1.0:
                continue

            # Latent → sporulating
            tissue.latent_counter += lat_progress * (1.0 - efficacy)
            if tissue.latent_counter >= 1.0 and tissue.latent_state > 0.0:
                tissue.sporulating_state = tissue.latent_state
                tissue.latent_state = 0.0

            # Sporulating → dead
            if tissue.sporulating_state > 0.0:
                tissue.sporulating_counter += spo_progress * (1.0 - efficacy)
            if tissue.sporulating_counter >= 1.0 and tissue.sporulating_state > 0.0:
                tissue.dead_state = tissue.sporulating_state
                tissue.sporulating_state = 0.0

        # ----------------------------------------------------------------
        # Aggregate tissue states (cap each fraction at 1)
        # ----------------------------------------------------------------
        latent_sum = min(sum(t.latent_state for t in self.tissue_tracking), 1.0)
        spor_sum   = min(sum(t.sporulating_state for t in self.tissue_tracking), 1.0)
        dead_sum   = min(sum(t.dead_state for t in self.tissue_tracking), 1.0)

        affected_sum     = min(latent_sum + spor_sum + dead_sum, 1.0)
        disease_severity = min(spor_sum + dead_sum, 1.0)

        output1.disease.latent_sum       = latent_sum
        output1.disease.sporulating_sum  = spor_sum
        output1.disease.dead_sum         = dead_sum
        output1.disease.affected_sum     = affected_sum
        output1.disease.disease_severity = disease_severity

        # ----------------------------------------------------------------
        # Susceptible tissue fraction
        # ----------------------------------------------------------------
        tissue_availability = 1.0 - affected_sum
        susceptible = max(0.0, min(tissue_availability, 1.0))

        light_att_today = output1.crop.light_interception_attainable
        light_att_prev  = output.crop.light_interception_attainable

        if (light_att_today >= light_att_prev
                and not output1.crop.senescence_started):
            # Growth phase: susceptible fraction = unaffected tissue
            output1.disease.susceptible_fraction = susceptible
        else:
            # Senescence phase: also subtract senesced green area
            f_int_peak = output1.crop.f_int_peak
            if f_int_peak > 0.0:
                sen_loss = (f_int_peak - light_att_today) / f_int_peak
            else:
                sen_loss = 0.0
            output1.disease.susceptible_fraction = susceptible - sen_loss

        output1.disease.susceptible_fraction = max(
            0.0, min(output1.disease.susceptible_fraction, 1.0)
        )

        # ----------------------------------------------------------------
        # Reset aggregates when no green tissue remains
        # ----------------------------------------------------------------
        if light_att_today == 0.0:
            output1.disease.latent_sum      = 0.0
            output1.disease.sporulating_sum = 0.0
            output1.disease.dead_sum        = 0.0
            output1.disease.affected_sum    = 0.0
run_daily(self, input_, parameters, output, output1)

Run the daily SEIR tissue progression.

Given the day's accumulated infection pressure, this step (1) creates a new latent tissue cohort when onset conditions are met, combining external (primary) and internal (secondary) infection sources scaled by susceptible tissue, host resistance, and fungicide efficacy; (2) advances every existing cohort through the latent → sporulating → dead transitions according to thermal time; and (3) aggregates the compartment fractions into the daily latent, sporulating, dead, affected, and severity outputs, and updates the susceptible fraction for the following day.

At entry, output holds the hourly-accumulated state for the day and output1 is a fresh daily output whose season-persistent fields (growing season, peak interception, and first-infection markers) have already been carried forward by the runner.

Parameters:

Name Type Description Default
input_ InputsDaily

The day's aggregated inputs.

required
parameters Parameters

Model parameters, including the disease and crop groups.

required
output Outputs

Previous (hourly-accumulated) state for the day.

required
output1 Outputs

Current day's output state, updated in place.

required

Returns:

Type Description
None

The function mutates output1 and the cohort list in place.

Source code in simpest/models/fr_disease_model.py
def run_daily(
    self,
    input_: InputsDaily,
    parameters: Parameters,
    output: Outputs,
    output1: Outputs,
) -> None:
    """Run the daily SEIR tissue progression.

    Given the day's accumulated infection pressure, this step (1) creates a
    new latent tissue cohort when onset conditions are met, combining
    external (primary) and internal (secondary) infection sources scaled by
    susceptible tissue, host resistance, and fungicide efficacy; (2) advances
    every existing cohort through the latent → sporulating → dead transitions
    according to thermal time; and (3) aggregates the compartment fractions
    into the daily latent, sporulating, dead, affected, and severity outputs,
    and updates the susceptible fraction for the following day.

    At entry, ``output`` holds the hourly-accumulated state for the day and
    ``output1`` is a fresh daily output whose season-persistent fields
    (growing season, peak interception, and first-infection markers) have
    already been carried forward by the runner.

    Args:
        input_ (InputsDaily): The day's aggregated inputs.
        parameters (Parameters): Model parameters, including the disease and
            crop groups.
        output (Outputs): Previous (hourly-accumulated) state for the day.
        output1 (Outputs): Current day's output state, updated in place.

    Returns:
        None: The function mutates ``output1`` and the cohort list in place.
    """
    par = parameters.par_disease
    pc  = parameters.par_crop

    # ----------------------------------------------------------------
    # New infection – only once onset conditions are satisfied
    # ----------------------------------------------------------------
    if (output.disease.hydro_thermal_time_state >= par.hydro_thermal_time_onset
            and output.crop.cycle_completion_percentage >= par.cycle_percentage_onset):

        # Record first infection date (once per season)
        if not output1.disease.is_primary_inoculum_started:
            output1.disease.first_seasonal_infection = input_.date
            output1.disease.cycle_percentage_first_infection = (
                output.crop.cycle_completion_percentage
            )
            output1.disease.is_primary_inoculum_started = True

        # Rescale existing tissue fractions for expanding green area
        # (growth phase only; skip during senescence)
        light_today = output1.crop.light_interception_attainable
        light_yesterday = output.crop.light_interception_attainable
        if light_today > 0.0:
            if (output.disease.affected_sum <= 1.0
                    and not output1.crop.senescence_started):
                for tissue in self.tissue_tracking:
                    ratio = light_yesterday / light_today
                    tissue.latent_state      *= ratio
                    tissue.sporulating_state *= ratio
                    tissue.dead_state        *= ratio

        # Primary inoculum (reduced by fungicide efficacy)
        outer_ino = _inoculum_model(input_, parameters, output, output1) * (
            1.0 - output1.fungicide.efficacy
        )
        output1.disease.outer_inoculum = outer_ino

        # Splash-borne: spore detachment efficiency
        spo_detach = 0.0
        if par.is_splash_borne:
            spo_detach = 1.0 - rain_detachment(
                input_.precipitation,
                par.rain50_detachment,
                output1.crop.light_interception_attainable,
            )

        # External infection (primary inoculum × HTT infection efficiency)
        ext_inf = output.disease.hydro_thermal_time_infection * outer_ino

        # Internal infection (secondary spread from sporulating tissue)
        int_inf = (
            output.disease.sporulating_sum
            * output.disease.sporulation_efficiency
            * par.pathogen_spread
            * (1.0 - spo_detach)
        )

        # New infection cohort size
        latent_value = (
            (ext_inf + int_inf)
            * output.disease.susceptible_fraction
            * output1.crop.light_interception_attainable
            * (1.0 - pc.varietal_resistance)
            * (1.0 - output1.fungicide.efficacy)
        )

        if latent_value > 0.0:
            self.tissue_tracking.append(TissueState(latent_state=latent_value))

    # ----------------------------------------------------------------
    # SEIR progression for all existing cohorts
    # ----------------------------------------------------------------
    t_ave = (input_.tmin + input_.tmax) / 2.0
    gdd_disease = t_response(t_ave, par.tmin, par.topt, par.tmax)

    lat_progress = (gdd_disease / par.latency_duration
                    if par.latency_duration > 0.0 else 0.0)
    spo_progress = (gdd_disease / par.sporulation_duration
                    if par.sporulation_duration > 0.0 else 0.0)

    efficacy = output1.fungicide.efficacy

    for tissue in self.tissue_tracking:
        if tissue.dead_state == 1.0:
            continue

        # Latent → sporulating
        tissue.latent_counter += lat_progress * (1.0 - efficacy)
        if tissue.latent_counter >= 1.0 and tissue.latent_state > 0.0:
            tissue.sporulating_state = tissue.latent_state
            tissue.latent_state = 0.0

        # Sporulating → dead
        if tissue.sporulating_state > 0.0:
            tissue.sporulating_counter += spo_progress * (1.0 - efficacy)
        if tissue.sporulating_counter >= 1.0 and tissue.sporulating_state > 0.0:
            tissue.dead_state = tissue.sporulating_state
            tissue.sporulating_state = 0.0

    # ----------------------------------------------------------------
    # Aggregate tissue states (cap each fraction at 1)
    # ----------------------------------------------------------------
    latent_sum = min(sum(t.latent_state for t in self.tissue_tracking), 1.0)
    spor_sum   = min(sum(t.sporulating_state for t in self.tissue_tracking), 1.0)
    dead_sum   = min(sum(t.dead_state for t in self.tissue_tracking), 1.0)

    affected_sum     = min(latent_sum + spor_sum + dead_sum, 1.0)
    disease_severity = min(spor_sum + dead_sum, 1.0)

    output1.disease.latent_sum       = latent_sum
    output1.disease.sporulating_sum  = spor_sum
    output1.disease.dead_sum         = dead_sum
    output1.disease.affected_sum     = affected_sum
    output1.disease.disease_severity = disease_severity

    # ----------------------------------------------------------------
    # Susceptible tissue fraction
    # ----------------------------------------------------------------
    tissue_availability = 1.0 - affected_sum
    susceptible = max(0.0, min(tissue_availability, 1.0))

    light_att_today = output1.crop.light_interception_attainable
    light_att_prev  = output.crop.light_interception_attainable

    if (light_att_today >= light_att_prev
            and not output1.crop.senescence_started):
        # Growth phase: susceptible fraction = unaffected tissue
        output1.disease.susceptible_fraction = susceptible
    else:
        # Senescence phase: also subtract senesced green area
        f_int_peak = output1.crop.f_int_peak
        if f_int_peak > 0.0:
            sen_loss = (f_int_peak - light_att_today) / f_int_peak
        else:
            sen_loss = 0.0
        output1.disease.susceptible_fraction = susceptible - sen_loss

    output1.disease.susceptible_fraction = max(
        0.0, min(output1.disease.susceptible_fraction, 1.0)
    )

    # ----------------------------------------------------------------
    # Reset aggregates when no green tissue remains
    # ----------------------------------------------------------------
    if light_att_today == 0.0:
        output1.disease.latent_sum      = 0.0
        output1.disease.sporulating_sum = 0.0
        output1.disease.dead_sum        = 0.0
        output1.disease.affected_sum    = 0.0
run_hourly(self, input_, parameters, output, output1)

Process one hour of weather data.

During hours 0–22 this accumulates the hydro-thermal time rate (the product of temperature and relative-humidity suitability) and tracks the dry-spell counter. At hour 23 it finalises the daily infection and sporulation efficiencies, normalises the accumulated rate, records the daily weather summary, and clears the hourly buffers.

The same output1 object is shared across all 24 hours of a day; the daily state advance happens only after the hour-23 call returns.

Parameters:

Name Type Description Default
input_ InputsHourly

The current hour's weather record.

required
parameters Parameters

Model parameters, including the disease group.

required
output Outputs

Previous day's output state, used to carry forward the accumulated hydro-thermal time state.

required
output1 Outputs

Current day's output state, updated in place.

required

Returns:

Type Description
None

The function mutates output1 in place.

Source code in simpest/models/fr_disease_model.py
def run_hourly(
    self,
    input_: InputsHourly,
    parameters: Parameters,
    output: Outputs,
    output1: Outputs,
) -> None:
    """Process one hour of weather data.

    During hours 0–22 this accumulates the hydro-thermal time rate (the
    product of temperature and relative-humidity suitability) and tracks the
    dry-spell counter. At hour 23 it finalises the daily infection and
    sporulation efficiencies, normalises the accumulated rate, records the
    daily weather summary, and clears the hourly buffers.

    The same ``output1`` object is shared across all 24 hours of a day; the
    daily state advance happens only after the hour-23 call returns.

    Args:
        input_ (InputsHourly): The current hour's weather record.
        parameters (Parameters): Model parameters, including the disease group.
        output (Outputs): Previous day's output state, used to carry forward
            the accumulated hydro-thermal time state.
        output1 (Outputs): Current day's output state, updated in place.

    Returns:
        None: The function mutates ``output1`` in place.
    """
    par = parameters.par_disease

    # Accumulate hourly observations
    self._temp.append(input_.air_temperature)
    self._rh.append(input_.relative_humidity)
    self._rain.append(input_.precipitation)
    self._lw.append(input_.leaf_wetness)

    # Dry-spell counter (resets on any wet hour, otherwise increments)
    if input_.leaf_wetness == 1:
        output1.disease.counter_dry = 0.0
    else:
        output1.disease.counter_dry += 1.0

    # Temperature suitability (0–1)
    output1.disease.temp_function = t_response(
        input_.air_temperature, par.tmin, par.topt, par.tmax
    )

    hour = input_.date.hour

    if hour == 23:
        # ----------------------------------------------------------------
        # End-of-day: finalise infection metrics and daily weather summary
        # ----------------------------------------------------------------
        rate = output1.disease.hydro_thermal_time_rate  # accumulated hours 0-22

        # Sporulation efficiency = average hourly HTT per hour of the day
        output1.disease.sporulation_efficiency = rate / 24.0

        # Infection efficiency (before normalisation against WetnessDurationOptimum)
        if rate > par.wetness_duration_minimum:
            htt_inf = rate / par.wetness_duration_optimum
            if htt_inf >= 1.0:
                htt_inf = 1.0
            output1.disease.hydro_thermal_time_infection = htt_inf
        else:
            output1.disease.hydro_thermal_time_infection = 0.0

        # Normalise daily rate (0–1)
        rate_norm = rate / par.wetness_duration_optimum
        if rate_norm >= 1.0:
            rate_norm = 1.0
        output1.disease.hydro_thermal_time_rate = rate_norm

        # Cumulate state (carried forward day to day via output)
        output1.disease.hydro_thermal_time_state = (
            output.disease.hydro_thermal_time_state + rate_norm
        )

        # Dry-spell interruption overrides infection for this day
        if output1.disease.counter_dry > par.dry_critical_interruption:
            output1.disease.hydro_thermal_time_infection = 0.0

        # Daily weather summary (for output only)
        output1.disease.tmax_daily  = max(self._temp)
        output1.disease.tmin_daily  = min(self._temp)
        output1.disease.rhmax_daily = max(self._rh)
        output1.disease.rhmin_daily = min(self._rh)
        output1.disease.rain_daily  = sum(self._rain)
        output1.disease.lw_daily    = sum(self._lw)

        # Clear buffers for the next day
        self._temp.clear()
        self._rh.clear()
        self._rain.clear()
        self._lw.clear()

    else:
        # ----------------------------------------------------------------
        # Within-day: accumulate HTT rate when within wet-period threshold
        # ----------------------------------------------------------------
        if output1.disease.counter_dry <= par.dry_critical_interruption:
            rh_fn = _rh_function(
                input_.relative_humidity,
                input_.leaf_wetness,
                par.relative_humidity_not_limiting,
                par.relative_humidity_critical,
            )
            output1.disease.rh_function = rh_fn
            output1.disease.hydro_thermal_time_rate += (
                output1.disease.temp_function * rh_fn
            )
        else:
            output1.disease.rh_function = 0.0

fr_fungicide_model

Daily fungicide degradation, tenacity, and efficacy.

This module advances the fungicide state by one day. After an application, the active ingredient decays through two mechanisms: first-order chemical degradation over time and rainfall-driven wash-off (tenacity). The resulting effective dose is mapped to a protective efficacy through a logistic response, and the treatment is retired after a fixed persistence window.

run(input_, parameters, output, output1)

Advance the fungicide state by one day.

Updates the concentration factor, tenacity, degradation, and protective efficacy for the current day. The calculation proceeds in stages:

  1. Concentration. On the application day the concentration factor is reset to 1; thereafter it decays exponentially with the previous day's effective degradation.
  2. Potential degradation. First-order decay of the initial dose, initial_dose · exp(-degradation_rate · days).
  3. Tenacity. Rainfall wash-off reduces the retained fraction by exp(-tenacity_factor · concentration · √precipitation), applied cumulatively across days.
  4. Effective degradation. The product of tenacity and potential degradation, i.e. the dose still active on the canopy.
  5. Efficacy. A logistic response of the effective degradation, scaled by the initial efficacy.

If no treatment has yet been applied (date_treatment_last.year <= 1) the call is a no-op. All state is cleared once the persistence window of _MAX_DAYS days has elapsed.

Parameters:

Name Type Description Default
input_ InputsDaily

Daily inputs, including date, precipitation, and the last treatment date.

required
parameters Parameters

Model parameters, including the fungicide group.

required
output Outputs

Previous day's output state.

required
output1 Outputs

Current day's output state, updated in place.

required

Returns:

Type Description
None

The function mutates output1 in place.

Source code in simpest/models/fr_fungicide_model.py
def run(
    input_: InputsDaily,
    parameters: Parameters,
    output: Outputs,
    output1: Outputs,
) -> None:
    """Advance the fungicide state by one day.

    Updates the concentration factor, tenacity, degradation, and protective
    efficacy for the current day. The calculation proceeds in stages:

    1. **Concentration.** On the application day the concentration factor is
       reset to 1; thereafter it decays exponentially with the previous day's
       effective degradation.
    2. **Potential degradation.** First-order decay of the initial dose,
       ``initial_dose · exp(-degradation_rate · days)``.
    3. **Tenacity.** Rainfall wash-off reduces the retained fraction by
       ``exp(-tenacity_factor · concentration · √precipitation)``, applied
       cumulatively across days.
    4. **Effective degradation.** The product of tenacity and potential
       degradation, i.e. the dose still active on the canopy.
    5. **Efficacy.** A logistic response of the effective degradation, scaled by
       the initial efficacy.

    If no treatment has yet been applied (``date_treatment_last.year <= 1``) the
    call is a no-op. All state is cleared once the persistence window of
    ``_MAX_DAYS`` days has elapsed.

    Args:
        input_ (InputsDaily): Daily inputs, including date, precipitation, and
            the last treatment date.
        parameters (Parameters): Model parameters, including the fungicide group.
        output (Outputs): Previous day's output state.
        output1 (Outputs): Current day's output state, updated in place.

    Returns:
        None: The function mutates ``output1`` in place.
    """
    pf = parameters.par_fungicide
    last = input_.date_treatment_last

    # Sentinel: DateTreatmentLast.year == 1 means no treatment yet
    if last.year <= 1:
        return

    # Days since last application
    days = (input_.date - last).total_seconds() / 86_400.0

    # -----------------------------------------------------------------------
    # Concentration factor
    # -----------------------------------------------------------------------
    if input_.date == last:
        # Application day: baseline values
        output1.fungicide.concentration_factor = 1.0
        output1.fungicide.tenacity_function = 1.0
        output.fungicide.tenacity = 1.0
    else:
        # After application: exponential decay based on yesterday's actual degradation
        output1.fungicide.concentration_factor = math.exp(
            (output.fungicide.actual_degradation - 1.0) * 3.0
        )

    # -----------------------------------------------------------------------
    # Potential degradation (first-order decay of initial dose)
    # -----------------------------------------------------------------------
    output1.fungicide.potential_degradation = pf.initial_dose * math.exp(
        -pf.degradation_rate * days
    )

    # -----------------------------------------------------------------------
    # Tenacity (rainfall-driven wash-off)
    # -----------------------------------------------------------------------
    output.fungicide.tenacity_function = math.exp(
        -pf.tenacity_factor
        * output1.fungicide.concentration_factor
        * math.sqrt(max(input_.precipitation, 0.0))
    )
    output1.fungicide.tenacity = (
        output.fungicide.tenacity * output.fungicide.tenacity_function
    )

    # -----------------------------------------------------------------------
    # Actual degradation
    # -----------------------------------------------------------------------
    output1.fungicide.actual_degradation = (
        output1.fungicide.tenacity * output1.fungicide.potential_degradation
    )

    # -----------------------------------------------------------------------
    # Efficacy (logistic response to actual degradation)
    # -----------------------------------------------------------------------
    output1.fungicide.efficacy = pf.initial_efficacy / (
        1.0 + math.exp(
            pf.a_shape_parameter
            - pf.b_shape_parameter * output1.fungicide.actual_degradation
        )
    )

    # -----------------------------------------------------------------------
    # Hard stop: zero everything after 30 days
    # -----------------------------------------------------------------------
    if days >= _MAX_DAYS:
        output1.fungicide.efficacy               = 0.0
        output1.fungicide.actual_degradation     = 0.0
        output1.fungicide.concentration_factor   = 0.0
        output1.fungicide.potential_degradation  = 0.0
        output1.fungicide.tenacity_function      = 0.0

fr_optimizer

Multi-start Nelder–Mead calibration for the FraNchEstYN model.

This module estimates model parameters by minimising the run's root-mean-square error against reference observations. It uses a self-contained, pure-Python multi-start Nelder–Mead (downhill simplex) search: several simplexes are each optimised from a random starting configuration and the best result across all restarts is returned. Multiple restarts reduce the chance of settling in a poor local minimum of the objective surface.

The objective is the runner's :meth:~simpest.models.fr_runner.FranchestynRunner.compute_rmse, and candidate parameter sets that fall outside their bounds receive a large penalty so the search remains in the feasible region. The search is controlled by three knobs: n_restarts (number of independent simplexes), max_iter (iterations per simplex), and ftol (objective-spread convergence tolerance).

Because the initial simplex is drawn at random, results vary from run to run unless a fixed seed is supplied. For deterministic, fixed-parameter validation, run the model directly rather than through calibration.

FranchestynOptimizer

Multi-start Nelder–Mead calibration for the FraNchEstYN model.

Wraps a configured runner and searches for the parameter set that minimises the run's RMSE against reference data. Only parameters flagged for calibration (and not explicitly disabled) within the requested scope are optimised; all others are held at their default values.

Parameters:

Name Type Description Default
runner FranchestynRunner

Fully configured runner instance.

required
calibration_variable str

Calibration target scope: "crop", "disease", or "all".

'all'
n_restarts int

Number of independent simplexes (random restarts).

5
max_iter int

Maximum iterations per simplex.

1000
ftol float

Convergence tolerance on the objective spread across the simplex vertices.

1e-12
disabled_by_class Optional[Dict[str, Set[str]]]

Parameter names to exclude from calibration, keyed by parameter class.

None
seed Optional[int]

Seed for the random number generator, giving reproducible restarts. None (default) is non-deterministic.

None
Source code in simpest/models/fr_optimizer.py
class FranchestynOptimizer:
    """Multi-start Nelder–Mead calibration for the FraNchEstYN model.

    Wraps a configured runner and searches for the parameter set that minimises
    the run's RMSE against reference data. Only parameters flagged for
    calibration (and not explicitly disabled) within the requested scope are
    optimised; all others are held at their default values.

    Args:
        runner (FranchestynRunner): Fully configured runner instance.
        calibration_variable (str): Calibration target scope: ``"crop"``,
            ``"disease"``, or ``"all"``.
        n_restarts (int): Number of independent simplexes (random restarts).
        max_iter (int): Maximum iterations per simplex.
        ftol (float): Convergence tolerance on the objective spread across the
            simplex vertices.
        disabled_by_class (Optional[Dict[str, Set[str]]]): Parameter names to
            exclude from calibration, keyed by parameter class.
        seed (Optional[int]): Seed for the random number generator, giving
            reproducible restarts. ``None`` (default) is non-deterministic.
    """

    def __init__(
        self,
        runner: FranchestynRunner,
        calibration_variable: str = "all",
        n_restarts: int = 5,
        max_iter: int = 1000,
        ftol: float = 1e-12,
        disabled_by_class: Optional[Dict[str, Set[str]]] = None,
        seed: Optional[int] = None,
    ) -> None:
        self.runner = runner
        self.calibration_variable = calibration_variable.lower()
        self.n_restarts = n_restarts
        self.max_iter = max_iter
        self.ftol = ftol
        self.disabled_by_class = disabled_by_class or {}
        # Optional RNG seed making the multi-start search reproducible.
        self.seed = seed

        # Select calibration parameters and record their bounds
        self.calib_keys, self.bounds = self._select_calib_params()

        self._n_eval = 0
        self._current_restart = 0
        self._iter_in_restart = 0
        self._last_rmse = math.inf

    # -----------------------------------------------------------------------
    # Public API
    # -----------------------------------------------------------------------

    def calibrate(self) -> Dict[str, float]:
        """
        Run multi-start simplex and return the best parameter set.

        Returns:
            Dict[str, float]: Best-fit parameter values keyed by
            ``class_ParamName``.
        """

        if not self.calib_keys:
            print("No calibration parameters found — returning defaults.")
            return {}

        best_rmse = math.inf
        best_params: Dict[str, float] = {}
        rng = np.random.default_rng(self.seed)

        print(f"- Calibrating {len(self.calib_keys)} using multi-start simplex method.\n"f"- Parameters:\n{self.calib_keys}")

        for restart in range(self.n_restarts):
            self._current_restart = restart + 1
            self._iter_in_restart = 0

            simplex, fvals, _nit = self._nelder_mead_single_restart(rng)
            best_idx = int(np.argmin(fvals))
            rmse = float(fvals[best_idx])

            if rmse < best_rmse:
                best_rmse = rmse
                best_params = dict(zip(self.calib_keys, simplex[best_idx]))

        print(f"\nBest RMSE: {best_rmse:.4f}")
        return best_params

    # -----------------------------------------------------------------------
    # Private helpers
    # -----------------------------------------------------------------------

    def _objective(self, x: np.ndarray) -> float:
        """Evaluate the calibration objective at parameter vector ``x``.

        Runs the model with the candidate parameters and returns the resulting
        RMSE. Candidates that violate the parameter bounds, or that raise during
        the run, return a large penalty (``1e300``) so the search avoids the
        infeasible region.
        """
        self._n_eval += 1

        # Penalise out-of-bounds candidates with a large finite objective value
        for val, (lo, hi) in zip(x, self.bounds):
            if val <= lo or val > hi:
                return 1e300

        param_values = dict(zip(self.calib_keys, x))
        try:
            date_outputs = self.runner.run(param_values)
        except Exception:
            return 1e300

        include_crop    = self.calibration_variable in ("crop", "all")
        include_disease = self.calibration_variable in ("disease", "all")

        rmse = self.runner.compute_rmse(
            date_outputs,
            include_crop=include_crop,
            include_disease=include_disease,
        )
        self._last_rmse = rmse
        return rmse

    def _on_iteration(self, _xk: np.ndarray) -> None:
        """Progress callback for each simplex iteration."""
        self._iter_in_restart += 1
        sys.stdout.write(
            f"\rRun {self._current_restart}/{self.n_restarts} Iteration {self._iter_in_restart}/{self.max_iter} CURR RMSE={self._last_rmse:.4f}"
        )
        sys.stdout.flush()

    def _select_calib_params(self) -> Tuple[List[str], List[Tuple[float, float]]]:
        """Return (keys, bounds) for parameters flagged for calibration."""
        calib_keys: List[str] = []
        bounds: List[Tuple[float, float]] = []

        for key, p in self.runner.name_param.items():
            # Skip non-calibrated params
            if not p.calibration.strip():
                continue

            # Restrict to the requested calibration variable
            param_class = key.split("_", 1)[0].lower()
            if self.calibration_variable not in ("all", param_class):
                continue

            # Explicitly exclude user-deactivated parameters by name.
            param_name = key.split("_", 1)[1] if "_" in key else key
            if param_name in self.disabled_by_class.get(param_class, set()):
                continue

            # Skip boolean parameters
            if p.is_boolean:
                continue

            calib_keys.append(key)
            bounds.append((p.minimum, p.maximum))

        return calib_keys, bounds

    def _random_simplex(self, rng: np.random.Generator) -> np.ndarray:
        """Create a random initial simplex fully contained in parameter bounds."""
        dim = len(self.bounds)
        simplex = np.empty((dim + 1, dim), dtype=float)

        # Anchor vertex
        simplex[0] = np.array([rng.uniform(lo, hi) for lo, hi in self.bounds], dtype=float)

        # One perturbed vertex per dimension
        for i in range(dim):
            v = simplex[0].copy()
            lo, hi = self.bounds[i]
            span = hi - lo
            delta = rng.uniform(0.05 * span, 0.25 * span)
            direction = -1.0 if rng.random() < 0.5 else 1.0
            v[i] = np.clip(v[i] + direction * delta, lo, hi)
            simplex[i + 1] = v

        return simplex

    def _nelder_mead_single_restart(
        self, rng: np.random.Generator
    ) -> Tuple[np.ndarray, np.ndarray, int]:
        """Run one Nelder-Mead restart using standard coefficients."""
        # Standard Nelder-Mead coefficients
        alpha = 1.0  # reflection
        gamma = 2.0  # expansion
        rho = 0.5    # contraction
        sigma = 0.5  # shrink

        simplex = self._random_simplex(rng)
        fvals = np.array([self._objective(v) for v in simplex], dtype=float)

        nit = 0
        while nit < self.max_iter:
            order = np.argsort(fvals)
            simplex = simplex[order]
            fvals = fvals[order]

            # Convergence check driven by the objective spread across vertices
            if np.max(np.abs(fvals - fvals[0])) <= self.ftol:
                break

            centroid = np.mean(simplex[:-1], axis=0)
            worst = simplex[-1]

            # Reflection
            xr = centroid + alpha * (centroid - worst)
            fr = self._objective(xr)

            if fvals[0] <= fr < fvals[-2]:
                simplex[-1] = xr
                fvals[-1] = fr
            elif fr < fvals[0]:
                # Expansion
                xe = centroid + gamma * (xr - centroid)
                fe = self._objective(xe)
                if fe < fr:
                    simplex[-1] = xe
                    fvals[-1] = fe
                else:
                    simplex[-1] = xr
                    fvals[-1] = fr
            else:
                # Contraction
                if fr < fvals[-1]:
                    # Outside contraction
                    xc = centroid + rho * (xr - centroid)
                    fc = self._objective(xc)
                    if fc <= fr:
                        simplex[-1] = xc
                        fvals[-1] = fc
                    else:
                        # Shrink
                        best = simplex[0].copy()
                        for i in range(1, len(simplex)):
                            simplex[i] = best + sigma * (simplex[i] - best)
                            fvals[i] = self._objective(simplex[i])
                else:
                    # Inside contraction
                    xc = centroid - rho * (centroid - worst)
                    fc = self._objective(xc)
                    if fc < fvals[-1]:
                        simplex[-1] = xc
                        fvals[-1] = fc
                    else:
                        # Shrink
                        best = simplex[0].copy()
                        for i in range(1, len(simplex)):
                            simplex[i] = best + sigma * (simplex[i] - best)
                            fvals[i] = self._objective(simplex[i])

            nit += 1
            self._on_iteration(simplex[0])

        return simplex, fvals, nit
calibrate(self)

Run multi-start simplex and return the best parameter set.

Returns:

Type Description
Dict[str, float]

Best-fit parameter values keyed by class_ParamName.

Source code in simpest/models/fr_optimizer.py
def calibrate(self) -> Dict[str, float]:
    """
    Run multi-start simplex and return the best parameter set.

    Returns:
        Dict[str, float]: Best-fit parameter values keyed by
        ``class_ParamName``.
    """

    if not self.calib_keys:
        print("No calibration parameters found — returning defaults.")
        return {}

    best_rmse = math.inf
    best_params: Dict[str, float] = {}
    rng = np.random.default_rng(self.seed)

    print(f"- Calibrating {len(self.calib_keys)} using multi-start simplex method.\n"f"- Parameters:\n{self.calib_keys}")

    for restart in range(self.n_restarts):
        self._current_restart = restart + 1
        self._iter_in_restart = 0

        simplex, fvals, _nit = self._nelder_mead_single_restart(rng)
        best_idx = int(np.argmin(fvals))
        rmse = float(fvals[best_idx])

        if rmse < best_rmse:
            best_rmse = rmse
            best_params = dict(zip(self.calib_keys, simplex[best_idx]))

    print(f"\nBest RMSE: {best_rmse:.4f}")
    return best_params

fr_param_reader

Readers for model parameter definitions.

Parameters can be supplied either as a flat CSV table or as modular JSON files organised by crop, disease, and fungicide type. Each reader returns a dictionary keyed by "Class_ParamName" (for example "crop_TbaseCrop") whose values are :class:~simpest.models.fr_data.Parameter objects carrying the numeric value together with the calibration bounds.

Parameter definition CSV columns:

Column Field Notes
0 Name Parameter class
1 Class Parameter name
2 Description Ignored
3 Unit Ignored
4 Min Lower calibration bound
5 Max Upper calibration bound
6 Value Default value
7 CalibrationSubset Calibration inclusion tag

Calibrated-output CSV columns are Name, Class, and Value.

calibrated_read(file)

Read a calibrated output CSV and return a dict keyed 'Name_Class'.

Parameters:

Name Type Description Default
file str | Path

Path to the calibrated parameters CSV. If the file does not exist, an empty dict is returned.

required

Returns:

Type Description
Dict[str, float]

Dictionary mapping 'ParamName_ClassName' → calibrated float value.

Source code in simpest/models/fr_param_reader.py
def calibrated_read(file: str | Path) -> Dict[str, float]:
    """Read a calibrated output CSV and return a dict keyed 'Name_Class'.

    Args:
        file: Path to the calibrated parameters CSV. If the file does not
              exist, an empty dict is returned.

    Returns:
        Dictionary mapping 'ParamName_ClassName' → calibrated float value.
    """
    result: Dict[str, float] = {}
    path = Path(file)

    if not path.exists():
        return result

    with path.open(newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        next(reader, None)  # skip header

        for row in reader:
            if not row or len(row) < 3:
                continue
            name = row[0].strip()
            cls = row[1].strip()
            try:
                value = float(row[2].strip())
            except ValueError:
                continue
            result[f"{name}_{cls}"] = value

    return result

read(file, calibration_variable='')

Read a parameter definition CSV and return a dict keyed 'Name_Class'.

Parameters:

Name Type Description Default
file str | Path

Path to the parameter CSV file.

required
calibration_variable str

Name of the variable being calibrated (currently reserved for future use; not applied as a filter here).

''

Returns:

Type Description
Dict[str, Parameter]

Dictionary mapping 'ParamName_ClassName' → Parameter.

Source code in simpest/models/fr_param_reader.py
def read(file: str | Path, calibration_variable: str = "") -> Dict[str, Parameter]:
    """Read a parameter definition CSV and return a dict keyed 'Name_Class'.

    Args:
        file:                 Path to the parameter CSV file.
        calibration_variable: Name of the variable being calibrated (currently
                              reserved for future use; not applied as a filter here).

    Returns:
        Dictionary mapping 'ParamName_ClassName' → Parameter.
    """
    result: Dict[str, Parameter] = {}
    path = Path(file)

    with path.open(newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        next(reader, None)  # skip header

        for row in reader:
            if not row or all(cell.strip() == "" for cell in row):
                continue
            # Ensure we have at least 8 columns
            while len(row) < 8:
                row.append("")

            model_class = row[0].strip()   # col 0: Model  (e.g., "crop", "disease")
            param_name = row[1].strip()    # col 1: Parameter (e.g., "TbaseCrop")
            raw_value = row[6].strip()

            param = Parameter(param_class=model_class)

            # IsSplashBorne is the only boolean parameter (values are "0" or "1")
            is_bool_param = param_name.lower() == "issplashborne" and raw_value.lower() in (
                "true", "false", "1", "0"
            )

            if is_bool_param:
                param.value_bool = raw_value.lower() in ("true", "1")
                param.is_boolean = True
            else:
                try:
                    param.value = float(raw_value)
                    param.minimum = float(row[4].strip())
                    param.maximum = float(row[5].strip())
                except ValueError:
                    # Skip malformed rows
                    continue

            param.calibration = row[7].strip() if len(row) > 7 else ""

            key = f"{model_class}_{param_name}"
            result[key] = param

    return result

read_by_crop(file, crop_type='wheat')

Read parameters from JSON file organized by crop type.

Parameters:

Name Type Description Default
file str | Path

Path to the parameters_by_crop.json file.

required
crop_type str

Crop type key (e.g., "wheat", "rice"). Defaults to "wheat".

'wheat'

Returns:

Type Description
Dict[str, Parameter]

Dictionary mapping 'ParamName_ClassName' → Parameter for the specified crop. Returns empty dict if crop_type not found.

Source code in simpest/models/fr_param_reader.py
def read_by_crop(file: str | Path, crop_type: str = "wheat") -> Dict[str, Parameter]:
    """Read parameters from JSON file organized by crop type.

    Args:
        file:      Path to the parameters_by_crop.json file.
        crop_type: Crop type key (e.g., "wheat", "rice"). Defaults to "wheat".

    Returns:
        Dictionary mapping 'ParamName_ClassName' → Parameter for the specified crop.
        Returns empty dict if crop_type not found.
    """
    result: Dict[str, Parameter] = {}
    path = Path(file)

    if not path.exists():
        raise FileNotFoundError(f"Parameter file not found: {path}")

    with path.open(encoding="utf-8") as fh:
        data = json.load(fh)

    # Get the crop section
    if crop_type not in data:
        raise ValueError(f"Crop type '{crop_type}' not found in parameter file. Available: {list(data.keys())}")

    crop_params = data[crop_type]

    # Iterate through model classes (crop, disease, fungicide)
    for model_class, params in crop_params.items():
        for param_name, param_dict in params.items():
            param = Parameter(param_class=model_class)

            # IsSplashBorne is the only boolean parameter
            if param_name.lower() == "issplashborne":
                param.value_bool = bool(param_dict.get("value", 0))
                param.is_boolean = True
            else:
                param.value = float(param_dict.get("value", 0.0))
                param.minimum = float(param_dict.get("min", 0.0))
                param.maximum = float(param_dict.get("max", 1.0))

            param.calibration = "x" if param_dict.get("calibration", False) else ""

            key = f"{model_class}_{param_name}"
            result[key] = param

    return result

read_crop_parameters(file, crop_type='wheat')

Read crop parameters from crop_parameters.json.

Parameters:

Name Type Description Default
file str | Path

Path to the crop_parameters.json file.

required
crop_type str

Crop type key (e.g., "wheat", "rice"). Defaults to "wheat".

'wheat'

Returns:

Type Description
Dict[str, Parameter]

Dictionary mapping 'crop_ParamName' → Parameter for the specified crop type. Raises ValueError if crop_type not found.

Source code in simpest/models/fr_param_reader.py
def read_crop_parameters(file: str | Path, crop_type: str = "wheat") -> Dict[str, Parameter]:
    """Read crop parameters from crop_parameters.json.

    Args:
        file:      Path to the crop_parameters.json file.
        crop_type: Crop type key (e.g., "wheat", "rice"). Defaults to "wheat".

    Returns:
        Dictionary mapping 'crop_ParamName' → Parameter for the specified crop type.
        Raises ValueError if crop_type not found.
    """
    result: Dict[str, Parameter] = {}
    path = Path(file)

    if not path.exists():
        raise FileNotFoundError(f"Crop parameter file not found: {path}")

    with path.open(encoding="utf-8") as fh:
        data = json.load(fh)

    if crop_type not in data:
        raise ValueError(f"Crop type '{crop_type}' not found. Available: {list(data.keys())}")

    crop_params = data[crop_type]

    for param_name, param_dict in crop_params.items():
        param = Parameter(param_class="crop")
        param.value = float(param_dict.get("value", 0.0))
        param.minimum = float(param_dict.get("min", 0.0))
        param.maximum = float(param_dict.get("max", 1.0))
        param.calibration = "x" if param_dict.get("calibration", False) else ""

        key = f"crop_{param_name}"
        result[key] = param

    return result

read_disease_parameters(file, disease_type)

Read disease parameters from disease_parameters.json.

Parameters:

Name Type Description Default
file str | Path

Path to the disease_parameters.json file.

required
disease_type str

Disease type key (e.g., "septoria", "brown_rust", "black_rust", "yellow_rust", "wheat_blast").

required

Returns:

Type Description
Dict[str, Parameter]

Dictionary mapping 'disease_ParamName' → Parameter for the specified disease type. Raises ValueError if disease_type not found.

Source code in simpest/models/fr_param_reader.py
def read_disease_parameters(file: str | Path, disease_type: str) -> Dict[str, Parameter]:
    """Read disease parameters from disease_parameters.json.

    Args:
        file:         Path to the disease_parameters.json file.
        disease_type: Disease type key (e.g., "septoria", "brown_rust", "black_rust",
                      "yellow_rust", "wheat_blast").

    Returns:
        Dictionary mapping 'disease_ParamName' → Parameter for the specified disease type.
        Raises ValueError if disease_type not found.
    """
    result: Dict[str, Parameter] = {}
    path = Path(file)

    if not path.exists():
        raise FileNotFoundError(f"Disease parameter file not found: {path}")

    with path.open(encoding="utf-8") as fh:
        data = json.load(fh)

    if disease_type not in data:
        raise ValueError(f"Disease type '{disease_type}' not found. Available: {list(data.keys())}")

    disease_params = data[disease_type]

    for param_name, param_dict in disease_params.items():
        param = Parameter(param_class="disease")

        # IsSplashBorne is a boolean parameter
        if param_name.lower() == "issplashborne":
            param.value_bool = bool(param_dict.get("value", 0))
            param.is_boolean = True
        else:
            param.value = float(param_dict.get("value", 0.0))
            param.minimum = float(param_dict.get("min", 0.0))
            param.maximum = float(param_dict.get("max", 1.0))

        param.calibration = "x" if param_dict.get("calibration", False) else ""

        key = f"disease_{param_name}"
        result[key] = param

    return result

read_fungicide_parameters(file, fungicide_type='protectant')

Read fungicide parameters from fungicide_parameters.json.

Parameters:

Name Type Description Default
file str | Path

Path to the fungicide_parameters.json file.

required
fungicide_type str

Fungicide type key (e.g., "protectant"). Defaults to "protectant".

'protectant'

Returns:

Type Description
Dict[str, Parameter]

Dictionary mapping 'fungicide_ParamName' → Parameter for the specified type. Raises ValueError if fungicide_type not found.

Source code in simpest/models/fr_param_reader.py
def read_fungicide_parameters(file: str | Path, fungicide_type: str = "protectant") -> Dict[str, Parameter]:
    """Read fungicide parameters from fungicide_parameters.json.

    Args:
        file:           Path to the fungicide_parameters.json file.
        fungicide_type: Fungicide type key (e.g., "protectant"). Defaults to "protectant".

    Returns:
        Dictionary mapping 'fungicide_ParamName' → Parameter for the specified type.
        Raises ValueError if fungicide_type not found.
    """
    result: Dict[str, Parameter] = {}
    path = Path(file)

    if not path.exists():
        raise FileNotFoundError(f"Fungicide parameter file not found: {path}")

    with path.open(encoding="utf-8") as fh:
        data = json.load(fh)

    if fungicide_type not in data:
        raise ValueError(f"Fungicide type '{fungicide_type}' not found. Available: {list(data.keys())}")

    fungicide_params = data[fungicide_type]

    for param_name, param_dict in fungicide_params.items():
        param = Parameter(param_class="fungicide")
        param.value = float(param_dict.get("value", 0.0))
        param.minimum = float(param_dict.get("min", 0.0))
        param.maximum = float(param_dict.get("max", 1.0))
        param.calibration = "x" if param_dict.get("calibration", False) else ""

        key = f"fungicide_{param_name}"
        result[key] = param

    return result

fr_reference_reader

Readers for sowing schedules, reference observations, and crop-model series.

This module loads the non-weather inputs that drive and constrain a run:

  • the per-site sowing schedule and fungicide treatment dates,
  • field reference observations used to score the model during calibration, and
  • a daily crop-model series (light interception, biomass, yield, and optional thermal time) supplied by an external crop growth model.

Each reader populates the appropriate fields of a :class:~simpest.models.fr_data.SimulationUnit or returns a :class:~simpest.models.fr_data.CropModelData container.

read_crop_model_data(crop_model_file, use_gdd=False)

Read an external crop-model series and compute cycle progress.

Loads the daily crop-model output (light interception, biomass, yield, and optionally thermal time) and segments it into growing cycles, computing the cycle-completion percentage for each day.

Expected CSV columns (matched case-insensitively against common aliases): year, doy, fint, agb, yield, and optionally gdd.

A new cycle is detected when the day-of-year steps backwards without a year boundary (a new sowing) or when yield resets from above to at or below the 100 kg ha⁻¹ harvest threshold.

Cycle completion is computed as follows:

  • When use_gdd is True and thermal time is available, progress within a cycle is gdd[date] / max_gdd_in_cycle * 100, which ties phenology to accumulated thermal time.
  • Otherwise, progress is interpolated linearly over calendar days between the first and last day of the cycle.

Parameters:

Name Type Description Default
crop_model_file str | Path

Path to the directory containing cropModelData.csv, or the path to the CSV file itself.

required
use_gdd bool

Whether to derive cycle completion from thermal time rather than calendar days.

False

Returns:

Type Description
Populated
Source code in simpest/models/fr_reference_reader.py
def read_crop_model_data(crop_model_file: str | Path, use_gdd: bool = False) -> CropModelData:
    """Read an external crop-model series and compute cycle progress.

    Loads the daily crop-model output (light interception, biomass, yield, and
    optionally thermal time) and segments it into growing cycles, computing the
    cycle-completion percentage for each day.

    Expected CSV columns (matched case-insensitively against common aliases):
    ``year``, ``doy``, ``fint``, ``agb``, ``yield``, and optionally ``gdd``.

    A new cycle is detected when the day-of-year steps backwards without a
    year boundary (a new sowing) or when yield resets from above to at or below
    the 100 kg ha⁻¹ harvest threshold.

    Cycle completion is computed as follows:

    - When ``use_gdd`` is ``True`` and thermal time is available, progress within
      a cycle is ``gdd[date] / max_gdd_in_cycle * 100``, which ties phenology to
      accumulated thermal time.
    - Otherwise, progress is interpolated linearly over calendar days between the
      first and last day of the cycle.

    Args:
        crop_model_file: Path to the directory containing ``cropModelData.csv``,
            or the path to the CSV file itself.
        use_gdd: Whether to derive cycle completion from thermal time rather than
            calendar days.

    Returns:
        Populated :class:`~simpest.models.fr_data.CropModelData`.
    """
    cmd = CropModelData()
    path = Path(crop_model_file)
    if path.is_dir():
        path = path / "cropModelData.csv"

    with path.open(newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        raw_headers = next(reader, [])
        header_map = {_norm(h): i for i, h in enumerate(raw_headers)}

        fint_col = _get_col(header_map, "fint", "f_int", "lightinterception", "lightint")
        agb_col = _get_col(header_map, "agb", "abovegroundbiomass", "biomass", "wtop")
        yield_col = _get_col(header_map, "yield", "yieldattainable", "yieldunlimited", "yieldpotential", "wgrn", "grainyieldpotential")
        year_col = _get_col(header_map, "year", "yr")
        doy_col = _get_col(header_map, "doy", "dayofyear", "dy", "d")
        gdd_col = _get_col(header_map, "gdd", "growingdegreedays", "tsum", "thermaltime")

        date_order: List[datetime] = []

        for row in reader:
            if not row or all(c.strip() == "" for c in row):
                continue

            if year_col < 0 or doy_col < 0:
                continue

            try:
                y = int(row[year_col].strip())
                doy = int(row[doy_col].strip())
            except (ValueError, IndexError):
                continue

            try:
                dt = datetime(y, 1, 1) + timedelta(days=doy - 1)
            except ValueError:
                continue

            d = dt.date()

            if fint_col >= 0 and fint_col < len(row):
                v = _pf(row[fint_col])
                if v is not None:
                    cmd.f_int[d] = v

            if agb_col >= 0 and agb_col < len(row):
                v = _pf(row[agb_col])
                if v is not None:
                    cmd.agb[d] = v

            if yield_col >= 0 and yield_col < len(row):
                v = _pf(row[yield_col])
                if v is not None:
                    cmd.yield_[d] = v

            if use_gdd and gdd_col >= 0 and gdd_col < len(row):
                v = _pf(row[gdd_col])
                if v is not None:
                    cmd.gdd[d] = v

            if d in cmd.f_int or d in cmd.agb or d in cmd.yield_:
                date_order.append(dt)

    if not date_order:
        return cmd

    # --- Cycle detection ---
    date_order_sorted = sorted(set(date_order))
    dates_d = [dt.date() for dt in date_order_sorted]

    cycles: List[Tuple[date, date]] = []
    cycle_start = dates_d[0]

    for i in range(1, len(dates_d)):
        prev = dates_d[i - 1]
        curr = dates_d[i]

        doy_backwards = curr.timetuple().tm_yday < prev.timetuple().tm_yday
        year_wrap = prev.month == 12 and curr.month == 1
        sowing_jump = doy_backwards and not year_wrap

        y_prev = cmd.yield_.get(prev, 0.0)
        y_curr = cmd.yield_.get(curr, 0.0)
        harvest_reset = (y_curr <= 100.0 and y_prev > 100.0)

        if sowing_jump or harvest_reset:
            cycles.append((cycle_start, prev))
            cycle_start = curr

    cycles.append((cycle_start, dates_d[-1]))

    # --- Compute cycle percentage ---
    for start, end in cycles:
        cycle_dates = [d for d in dates_d if start <= d <= end]
        if not cycle_dates:
            continue

        gdd_max = 0.0
        if use_gdd:
            gdd_values = [cmd.gdd.get(d, 0.0) for d in cycle_dates]
            gdd_max = max(gdd_values)

        if use_gdd and gdd_max > 0.0:
            # Thermal-time progression: scale by the cycle's maximum GDD
            for d in cycle_dates:
                gdd_d = cmd.gdd.get(d, 0.0)
                cmd.cycle_percentage[d] = min(100.0, gdd_d / gdd_max * 100.0)
        else:
            # Calendar-day interpolation between cycle start and end
            total_days = (end - start).days
            if total_days <= 0:
                continue
            for d in cycle_dates:
                frac = (d - start).days / total_days
                cmd.cycle_percentage[d] = frac * 100.0

    return cmd

read_reference(ref_dir, sowing_file, site, variety, start_year, end_year, sim_unit=None, disease='thisDisease')

Read referenceData.csv and populate the SimulationUnit's reference_data.

Parameters:

Name Type Description Default
ref_dir str | Path

Directory containing referenceData.csv.

required
sowing_file str | Path

Path to sowing.csv (loaded first if sim_unit is empty).

required
site str

Site identifier.

required
variety str

Variety identifier.

required
start_year int

First year.

required
end_year int

Last year.

required
sim_unit Optional[SimulationUnit]

Existing SimulationUnit to populate; created if None.

None
disease str

Disease column name to look for (e.g., "thisDisease", "stripe_rust").

'thisDisease'

Returns:

Type Description
SimulationUnit

Updated SimulationUnit with reference_data populated.

Source code in simpest/models/fr_reference_reader.py
def read_reference(
    ref_dir: str | Path,
    sowing_file: str | Path,
    site: str,
    variety: str,
    start_year: int,
    end_year: int,
    sim_unit: Optional[SimulationUnit] = None,
    disease: str = "thisDisease",
) -> SimulationUnit:
    """Read referenceData.csv and populate the SimulationUnit's reference_data.

    Args:
        ref_dir:    Directory containing referenceData.csv.
        sowing_file: Path to sowing.csv (loaded first if sim_unit is empty).
        site:       Site identifier.
        variety:    Variety identifier.
        start_year: First year.
        end_year:   Last year.
        sim_unit:   Existing SimulationUnit to populate; created if None.
        disease:    Disease column name to look for (e.g., "thisDisease", "stripe_rust").

    Returns:
        Updated SimulationUnit with reference_data populated.
    """
    if sim_unit is None or not sim_unit.year_sowing_doy:
        sim_unit = read_sowing(sowing_file, site, variety, start_year, end_year)

    sim_unit.site = site
    _rp = Path(ref_dir)
    path = _rp if _rp.is_file() else _rp / "referenceData.csv"

    if not path.exists():
        raise FileNotFoundError(f"referenceData.csv not found at '{path}'")
        # return sim_unit  # no reference data available

    with path.open(newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        raw_headers = next(reader, [])
        header_map = {_norm(h): i for i, h in enumerate(raw_headers)}

        fint_col = _get_col(header_map, "fint", "f_int", "lightinterception", "lightint")
        agb_col = _get_col(header_map, "agb", "abovegroundbiomass", "biomass", "wtop")
        year_col = _get_col(header_map, "year", "yr")
        doy_col = _get_col(header_map, "doy", "dayofyear", "dy", "d")
        variety_col = _get_col(header_map, "variety", "cultivar", "cv")
        dis_col = _get_col(header_map, disease, f"{disease}sev", f"{disease}severity")

        yield_att_col = _get_col(header_map, "yieldattainable", "yieldunlimited", "yieldpotential",
                                 "yield", "wgrn", "grainyieldpotential")
        yield_act_col = _get_col(header_map, "yieldactual", "yielddiseased", "yieldact",
                                 "yieldlimited", "grainyieldlimited")

        if dis_col < 0:
            warnings.warn(
                f"[read_reference] Column for disease '{disease}' not found in {path}. "
                f"Available columns: {', '.join(raw_headers)}",
                stacklevel=2,
            )

        for row in reader:
            if not row or all(c.strip() == "" for c in row):
                continue

            if variety_col >= 0:
                row_var = row[variety_col].strip().strip('"').lower() if variety_col < len(row) else ""
                if row_var != variety.lower():
                    continue

            # Parse date from year + doy
            obs_date = None
            if year_col >= 0 and doy_col >= 0 and year_col < len(row) and doy_col < len(row):
                try:
                    y = int(row[year_col].strip())
                    doy = int(row[doy_col].strip())
                    if 1 <= doy <= 366:
                        obs_date = (datetime(y, 1, 1) + timedelta(days=doy - 1)).date()
                except ValueError:
                    pass
            if obs_date is None:
                obs_date = datetime.min.date()  # sentinel date for undated observations

            # FINT
            if fint_col >= 0:
                v = _pf(row[fint_col]) if fint_col < len(row) else None
                if v is not None:
                    sim_unit.reference_data.date_fint[obs_date] = v
            # AGB
            if agb_col >= 0:
                v = _pf(row[agb_col]) if agb_col < len(row) else None
                if v is not None:
                    sim_unit.reference_data.date_agb[obs_date] = v
            # Yield attainable
            if yield_att_col >= 0:
                v = _pf(row[yield_att_col]) if yield_att_col < len(row) else None
                if v is not None:
                    sim_unit.reference_data.date_yield_attainable[obs_date] = v
            # Yield actual
            if yield_act_col >= 0:
                v = _pf(row[yield_act_col]) if yield_act_col < len(row) else None
                if v is not None:
                    sim_unit.reference_data.date_yield_actual[obs_date] = v
            # Disease severity
            if dis_col >= 0:
                v = _pf(row[dis_col]) if dis_col < len(row) else None
                if v is not None:
                    sim_unit.reference_data.disease_date_disease_sev.setdefault(disease, {})[obs_date] = v
    return sim_unit

read_sowing(sowing_file, site, variety, start_year, end_year, all_row_includes_end_year=False)

Read sowing.csv and build the SimulationUnit for one site × variety.

Expected CSV columns (case-insensitive): site, crop, variety, sowingDOY, year (+ optional treatment1, treatment2, ... columns for fungicide dates)

Parameters:

Name Type Description Default
sowing_file str | Path

Path to the sowing CSV.

required
site str

Site identifier to filter on (e.g., "indiana").

required
variety str

Variety identifier to filter on (e.g., "Generic").

required
start_year int

First simulation year (inclusive).

required
end_year int

Last simulation year (inclusive).

required
all_row_includes_end_year bool

How far an "All" sowing row is applied. When False (default) the "All" row is applied to the half-open range [start_year, end_year - 1] and therefore omits the final year; when True it is applied through end_year inclusive. This setting affects only "All" rows; explicit per-year rows are always honoured for every year, so it has no effect when sowing is specified per year (for example the CSV written by :func:simpest.models.simplace.build_management).

False

Returns:

Type Description
SimulationUnit

Populated SimulationUnit (year_sowing_doy, fungicide_treatment_schedule).

Source code in simpest/models/fr_reference_reader.py
def read_sowing(
    sowing_file: str | Path,
    site: str,
    variety: str,
    start_year: int,
    end_year: int,
    all_row_includes_end_year: bool = False,
) -> SimulationUnit:
    """Read sowing.csv and build the SimulationUnit for one site × variety.

    Expected CSV columns (case-insensitive):
        site, crop, variety, sowingDOY, year
        (+ optional treatment1, treatment2, ... columns for fungicide dates)

    Args:
        sowing_file: Path to the sowing CSV.
        site:        Site identifier to filter on (e.g., "indiana").
        variety:     Variety identifier to filter on (e.g., "Generic").
        start_year:  First simulation year (inclusive).
        end_year:    Last simulation year (inclusive).
        all_row_includes_end_year: How far an ``"All"`` sowing row is applied.
            When ``False`` (default) the ``"All"`` row is applied to the half-open
            range ``[start_year, end_year - 1]`` and therefore omits the final
            year; when ``True`` it is applied through ``end_year`` inclusive. This
            setting affects only ``"All"`` rows; explicit per-year rows are always
            honoured for every year, so it has no effect when sowing is specified
            per year (for example the CSV written by
            :func:`simpest.models.simplace.build_management`).

    Returns:
        Populated SimulationUnit (year_sowing_doy, fungicide_treatment_schedule).
    """
    sim = SimulationUnit()
    path = Path(sowing_file)
    # print(f'sowing_file: {sowing_file}')

    with path.open(newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        raw_headers = next(reader, [])
        headers = [h.strip().strip('"').lower().replace(" ", "").replace("_", "") for h in raw_headers]
        header_map = {h: i for i, h in enumerate(headers)}

        site_idx = _get_col(header_map, "site")
        crop_idx = _get_col(header_map, "crop")
        variety_idx = _get_col(header_map, "variety", "cultivar", "cv")
        sowing_idx = _get_col(header_map, "sowingdoy", "sowingday", "doy")
        year_idx = _get_col(header_map, "year", "yr")

        # Collect treatment columns (any column starting with "treatment")
        fung_indices = [i for i, h in enumerate(headers) if h.startswith("treatment")]

        # Store "All" row separately; per-year rows in a dict
        all_row: Optional[Tuple[int, List[int]]] = None
        per_year: Dict[int, Tuple[int, List[int]]] = {}

        for row in reader:
            if not row or all(c.strip() == "" for c in row):
                continue

            if site_idx >= 0:
                row_site = row[site_idx].strip().strip('"').lower()
                if row_site != site.lower():
                    continue

            if variety_idx >= 0:
                row_var = row[variety_idx].strip().strip('"').lower()
                if row_var != variety.lower():
                    continue

            if crop_idx >= 0 and sim.crop == "":
                sim.crop = row[crop_idx].strip().strip('"')
            if variety_idx >= 0 and sim.variety == "":
                sim.variety = row[variety_idx].strip().strip('"')

            try:
                sow_doy = int(row[sowing_idx].strip())
            except (ValueError, IndexError):
                continue

            fung_doys = []
            for fi in fung_indices:
                if fi < len(row):
                    try:
                        d = int(row[fi].strip())
                        if d > 0:
                            fung_doys.append(d)
                    except ValueError:
                        pass
            fung_doys = sorted(set(fung_doys))

            year_cell = row[year_idx].strip().strip('"') if year_idx >= 0 else ""
            print(f'Parsed row: site={row_site}, year={year_cell}, variety={row_var}, sow_doy={sow_doy}, fung_doys={fung_doys}')

            if year_cell.lower() == "all":
                all_row = (sow_doy, fung_doys)
            else:
                try:
                    y = int(year_cell)
                    per_year[y] = (sow_doy, fung_doys)
                except ValueError:
                    pass

    # Apply the "All" row to every year in range. By default the final year is
    # excluded (half-open range); see ``all_row_includes_end_year``.
    all_end = end_year + 1 if all_row_includes_end_year else end_year
    if all_row is not None:
        for y in range(start_year, all_end):
            _apply_row(sim, y, all_row[0], all_row[1])

    # Override with per-year entries
    for y, (sow_doy, fung_doys) in per_year.items():
        _apply_row(sim, y, sow_doy, fung_doys)

    return sim

fr_runner

Main simulation runner for the FraNchEstYN model.

The runner orchestrates a full simulation: it loads parameters, weather, the sowing schedule, reference observations, and any external crop-model series, then drives the hourly–daily model loop across the configured years. Each simulated day couples the crop, disease, and fungicide sub-models and records a complete output bundle. The runner also exposes the calibration objective used to score a run against reference data.

Examples:

1
2
3
4
from simpest.models.fr_runner import FranchestynRunner

runner = FranchestynRunner(...)
date_outputs = runner.run(param_values={})  # optional {param_key: value} overrides

FranchestynRunner

End-to-end FraNchEstYN simulation runner.

Parameters:

Name Type Description Default
weather_dir str

Directory containing weather data.

required
param_file str

Path to parameter file.

required
sowing_file str

Path to sowing or management CSV.

required
ref_dir str

Directory or file path for reference data.

required
crop_model_dir Optional[str]

Directory or file path for external crop model data. If None, the internal crop model is used.

required
site str

Site identifier.

required
variety str

Variety identifier.

required
disease str

Disease column/key name used in reference data.

required
start_year int

First simulation year (inclusive).

required
end_year int

Last simulation year (inclusive).

required
weather_time_step str

Weather frequency, daily or hourly.

'daily'
calibration_variable str

Calibration target scope: crop, disease, or all.

'all'
is_calibration bool

Whether the run is used for calibration.

False
latitude float

Site latitude in decimal degrees.

0.0
crop_type Optional[str]

Crop type for modular parameter loading.

None
crop_param_file Optional[str]

Crop parameter JSON path.

None
disease_param_file Optional[str]

Disease parameter JSON path.

None
disease_type Optional[str]

Disease type key for modular loading. Also acts as the on/off switch for the disease model: when None the SEIR disease step (hourly and daily) is skipped entirely, so disease severity stays 0 and there is no disease impact (a crop-only run). Provide a disease_type to enable the epidemiological model.

None
fungicide_param_file Optional[str]

Fungicide parameter JSON path.

None
fungicide_type Optional[str]

Fungicide type key for modular loading. Also the on/off switch for the fungicide model: when None the fungicide step is skipped. The step is a no-op unless a treatment has been scheduled, so it only needs to be enabled when fungicide treatments are present (otherwise they would be ignored).

None
use_gdd bool

How crop cycle completion is derived. Defaults to False (calendar-day interpolation of the external crop-model series); set True to use the thermal-time (GDD) based cycle percentage, which ties phenology more directly to accumulated heat.

False
use_prev_day_alignment bool

Day alignment used by the calibration objective. Defaults to True, which compares the previous day's simulated output to the current day's reference observation (sim[d-1] vs ref[d]). Set False for same-day alignment (sim[d] vs ref[d]), which matches the daily output table.

True
all_row_includes_end_year bool

How far an "All" sowing row is applied. Defaults to False (the final year is omitted); set True to apply it through end_year inclusive. Has no effect for per-year sowing CSVs.

False
Source code in simpest/models/fr_runner.py
class FranchestynRunner:
    """
    End-to-end FraNchEstYN simulation runner.

    Args:
        weather_dir (str): Directory containing weather data.
        param_file (str): Path to parameter file.
        sowing_file (str): Path to sowing or management CSV.
        ref_dir (str): Directory or file path for reference data.
        crop_model_dir (Optional[str]): Directory or file path for external
            crop model data. If ``None``, the internal crop model is used.
        site (str): Site identifier.
        variety (str): Variety identifier.
        disease (str): Disease column/key name used in reference data.
        start_year (int): First simulation year (inclusive).
        end_year (int): Last simulation year (inclusive).
        weather_time_step (str): Weather frequency, ``daily`` or ``hourly``.
        calibration_variable (str): Calibration target scope: ``crop``,
            ``disease``, or ``all``.
        is_calibration (bool): Whether the run is used for calibration.
        latitude (float): Site latitude in decimal degrees.
        crop_type (Optional[str]): Crop type for modular parameter loading.
        crop_param_file (Optional[str]): Crop parameter JSON path.
        disease_param_file (Optional[str]): Disease parameter JSON path.
        disease_type (Optional[str]): Disease type key for modular loading. Also
            acts as the on/off switch for the disease model: when ``None`` the
            SEIR disease step (hourly and daily) is skipped entirely, so disease
            severity stays 0 and there is no disease impact (a crop-only run).
            Provide a ``disease_type`` to enable the epidemiological model.
        fungicide_param_file (Optional[str]): Fungicide parameter JSON path.
        fungicide_type (Optional[str]): Fungicide type key for modular loading.
            Also the on/off switch for the fungicide model: when ``None`` the
            fungicide step is skipped. The step is a no-op unless a treatment has
            been scheduled, so it only needs to be enabled when fungicide
            treatments are present (otherwise they would be ignored).
        use_gdd (bool): How crop cycle completion is derived. Defaults to
            ``False`` (calendar-day interpolation of the external crop-model
            series); set ``True`` to use the thermal-time (GDD) based cycle
            percentage, which ties phenology more directly to accumulated heat.
        use_prev_day_alignment (bool): Day alignment used by the calibration
            objective. Defaults to ``True``, which compares the previous day's
            simulated output to the current day's reference observation
            (sim[d-1] vs ref[d]). Set ``False`` for same-day alignment
            (sim[d] vs ref[d]), which matches the daily output table.
        all_row_includes_end_year (bool): How far an ``"All"`` sowing row is
            applied. Defaults to ``False`` (the final year is omitted); set
            ``True`` to apply it through ``end_year`` inclusive. Has no effect
            for per-year sowing CSVs.
    """

    def __init__(
        self,
        weather_dir: str,
        param_file: str,
        sowing_file: str,
        ref_dir: str,
        crop_model_dir: Optional[str],
        site: str,
        variety: str,
        disease: str,
        start_year: int,
        end_year: int,
        weather_time_step: str = "daily",
        calibration_variable: str = "all",
        is_calibration: bool = False,
        latitude: float = 0.0,
        crop_type: Optional[str] = None,
        crop_param_file: Optional[str] = None,
        disease_param_file: Optional[str] = None,
        disease_type: Optional[str] = None,
        fungicide_param_file: Optional[str] = None,
        fungicide_type: Optional[str] = None,
        use_gdd: bool = False,
        use_prev_day_alignment: bool = True,
        all_row_includes_end_year: bool = False,
    ) -> None:
        self.weather_dir = weather_dir
        self.param_file = param_file
        self.sowing_file = sowing_file
        self.ref_dir = ref_dir
        self.crop_model_dir = crop_model_dir
        self.site = site
        self.variety = variety
        self.disease = disease
        self.start_year = start_year
        self.end_year = end_year
        self.weather_time_step = weather_time_step.lower()
        self.calibration_variable = calibration_variable
        self.is_calibration = is_calibration
        self.latitude = latitude
        self.disease_type = disease_type
        self.fungicide_type = fungicide_type
        self.use_gdd = use_gdd
        self.use_prev_day_alignment = use_prev_day_alignment
        self.all_row_includes_end_year = all_row_includes_end_year

        # Read parameter definitions (bounds, defaults)
        # Three modular loading scenarios:
        # 1. All three files provided → modular (crop + optional disease + optional fungicide)
        # 2. Only crop_type provided → legacy multi-crop JSON
        # 3. Only param_file → legacy CSV

        self.name_param: Dict[str, Parameter] = {}

        if crop_param_file and crop_type:
            # Modular loading: always load crop
            self.name_param.update(read_crop_parameters(crop_param_file, crop_type))

            # Optionally load disease if both file and type provided
            if disease_param_file and disease_type:
                self.name_param.update(read_disease_parameters(disease_param_file, disease_type))

            # Optionally load fungicide if both file and type provided
            if fungicide_param_file and fungicide_type:
                self.name_param.update(read_fungicide_parameters(fungicide_param_file, fungicide_type))
        elif crop_type:
            # Legacy multi-crop JSON (parameters_by_crop.json)
            self.name_param = read_by_crop(param_file, crop_type)
        else:
            # Legacy CSV loader
            self.name_param = param_read(param_file, calibration_variable)

        # Read calibrated values (override file, empty if not present)
        self.param_out_calibration: Dict[str, float] = {}

        # Read sowing + reference data
        self.sim_unit: SimulationUnit = read_sowing(
            sowing_file, site, variety, start_year, end_year,
            all_row_includes_end_year=all_row_includes_end_year,
        )
        self.sim_unit = read_reference(
            ref_dir, sowing_file, site, variety,
            start_year, end_year, self.sim_unit, disease
        )

        # Read external crop model data (None → internal model used)
        self.crop_model_data: Optional[CropModelData] = None
        if crop_model_dir:
            self.crop_model_data = read_crop_model_data(crop_model_dir, use_gdd=self.use_gdd)

    # -----------------------------------------------------------------------
    # Public API
    # -----------------------------------------------------------------------

    def run(
        self,
        param_values: Optional[Dict[str, float]] = None,
    ) -> Dict[datetime, Outputs]:
        """
        Run the model for all configured years and return daily outputs.

        Args:
            param_values (Optional[Dict[str, float]]): Optional override values
                keyed by ``class_ParamName``.

        Returns:
            Dict[datetime, Outputs]: Mapping of end-of-day timestamp to model
            outputs.
        """
        parameters = self._build_parameters(param_values or {})
        weather_data = self._load_weather()

        date_outputs: Dict[datetime, Outputs] = {}

        # Per-hour accumulators aggregated into the daily input each day
        hourly_temps:  List[float] = []
        hourly_rads:   List[float] = []
        hourly_precip: List[float] = []
        hourly_rhs:    List[float] = []
        hourly_lw:     List[float] = []

        output    = Outputs()
        output_t1 = Outputs()
        disease_model = DiseaseModel()

        is_planted = False
        last_treatment_date = datetime(1900, 1, 1)

        for hour_dt in sorted(weather_data.keys()):
            year = hour_dt.year
            if year < self.start_year or year > self.end_year:
                output = Outputs()
                output_t1 = Outputs()
                continue

            hourly_rec = weather_data[hour_dt]
            hourly_rec.date = hour_dt
            hourly_rec.latitude = self.latitude

            # Sowing: reset on the correct DOY
            sow_doy = self.sim_unit.year_sowing_doy.get(year)
            if sow_doy and hour_dt.timetuple().tm_yday == sow_doy:
                is_planted = True
                output = Outputs()
                output_t1 = Outputs()
                disease_model = DiseaseModel()
                output_t1.crop.growing_season = year

            if is_planted:
                # Track fungicide treatments
                sched = self.sim_unit.fungicide_treatment_schedule
                treatment_dates = {t.date() for t in sched.treatments}
                if hour_dt.date() in treatment_dates:
                    last_treatment_date = datetime.combine(
                        hour_dt.date(), datetime.min.time()
                    )
                hourly_rec.date_treatment_last = last_treatment_date

                # Accumulate hourly observations
                hourly_temps.append(hourly_rec.air_temperature)
                hourly_rads.append(hourly_rec.rad)
                hourly_precip.append(hourly_rec.precipitation)
                hourly_rhs.append(hourly_rec.relative_humidity)
                hourly_lw.append(hourly_rec.leaf_wetness)

                # Skip the hourly disease step when disease is disabled, and also
                # for crop-only calibration runs where disease plays no role.
                if self.disease_type and (self.calibration_variable != "crop" or not self.is_calibration):
                    disease_model.run_hourly(hourly_rec, parameters, output, output_t1)

                if hour_dt.hour == 23:
                    # ----------------------------------------------------------
                    # End of day: swap, build daily input, run daily models
                    # ----------------------------------------------------------
                    output = output_t1

                    # Fresh daily output with season-persistent fields carried over
                    output_t1 = Outputs()
                    output_t1.crop.growing_season = output.crop.growing_season
                    output_t1.crop.f_int_peak = output.crop.f_int_peak
                    output_t1.disease.is_primary_inoculum_started = (
                        output.disease.is_primary_inoculum_started
                    )
                    output_t1.disease.first_seasonal_infection = (
                        output.disease.first_seasonal_infection
                    )
                    output_t1.disease.cycle_percentage_first_infection = (
                        output.disease.cycle_percentage_first_infection
                    )

                    # Assemble daily input from hourly lists
                    input_daily = InputsDaily()
                    input_daily.tmax = max(hourly_temps)
                    input_daily.tmin = min(hourly_temps)
                    input_daily.rad  = sum(hourly_rads)
                    input_daily.precipitation = sum(hourly_precip)
                    input_daily.rhx = max(hourly_rhs)
                    input_daily.rhn = min(hourly_rhs)
                    input_daily.leaf_wetness = sum(hourly_lw)
                    # Date of this day = hour 23 minus 23 hours
                    input_daily.date = hour_dt - timedelta(hours=23)
                    input_daily.date_treatment_last = hourly_rec.date_treatment_last
                    input_daily.crop_model_data = self.crop_model_data

                    # Run the daily sub-models. The crop step always runs; the
                    # fungicide and disease steps are optional and gated on their
                    # respective *_type (None skips the step, giving a crop-only
                    # or no-fungicide run).
                    crop_run(input_daily, parameters, output, output_t1)
                    if self.fungicide_type:
                        fungicide_run(input_daily, parameters, output, output_t1)
                    if self.disease_type:
                        disease_model.run_daily(input_daily, parameters, output, output_t1)

                    # Store daily output
                    output_t1.inputs_daily = input_daily
                    date_outputs[hour_dt] = output_t1

                    # Clear hourly accumulators
                    hourly_temps.clear()
                    hourly_rads.clear()
                    hourly_precip.clear()
                    hourly_rhs.clear()
                    hourly_lw.clear()

                    # Safety stop or maturity check
                    if (output_t1.crop.cycle_completion_percentage >= 100
                            or output_t1.crop.day_after_sowing >= _MAX_DAS):
                        is_planted = False

            else:
                is_planted = False
                output = Outputs()
                output_t1 = Outputs()

        return date_outputs

    def compute_rmse(
        self,
        date_outputs: Dict[datetime, Outputs],
        include_crop: bool = True,
        include_disease: bool = True,
    ) -> float:
        """Compute the root-mean-square error against reference data.

        This is the calibration objective. For each simulated day that has a
        matching reference observation, it accumulates the squared, scaled
        errors of the selected variables — above-ground biomass, attainable and
        actual yield, light interception, and disease severity — and returns the
        root mean of those per-day error sums. Two penalty rules sharpen the
        objective: a zero simulated yield at maturity where the reference is
        positive, and a near-zero simulated light interception during the
        growing season, are both heavily penalised.

        Args:
            date_outputs (Dict[datetime, Outputs]): Daily outputs produced by
                :meth:`run`.
            include_crop (bool): Include the crop-related error terms (biomass,
                attainable yield, light interception).
            include_disease (bool): Include the disease-related error terms
                (severity and actual yield).

        Returns:
            float: The root-mean-square error, rounded to three decimals; ``0.0``
            when no reference observations overlap the simulated days.
        """
        errors: List[float] = []
        ref = self.sim_unit.reference_data

        from datetime import date as date_type
        for hour_dt, out in date_outputs.items():
            if hour_dt.hour != 23:
                continue
            # Reference keys are datetime.date; convert for lookup.
            sim_day: date_type = hour_dt.date()
            # Day alignment between simulated output and reference data.
            #   Previous-day alignment (default): compare this simulated day to
            #   the next day's reference, i.e. sim[d] vs ref[d+1], which is
            #   equivalent to sim[d-1] vs ref[d].
            #   Same-day alignment: sim[d] vs ref[d], matching the daily table.
            ref_key: date_type = (
                sim_day + timedelta(days=1)
                if self.use_prev_day_alignment else sim_day
            )
            total_err = 0.0
            has_ref = False

            # Infer crop state for the penalty multipliers below
            is_matured = out.crop.cycle_completion_percentage >= 100.0
            is_planted = out.crop.day_after_sowing > 0 and not is_matured

            if include_crop:
                agb_err = 0.0
                if ref_key in ref.date_agb:
                    sim_agb = out.crop.agb_attainable
                    agb_err = ((ref.date_agb[ref_key] - sim_agb) / 200.0) ** 2
                    has_ref = True

                yield_err = 0.0
                if ref_key in ref.date_yield_attainable:
                    sim_y = out.crop.yield_attainable
                    yield_ref = ref.date_yield_attainable[ref_key]
                    yield_err = ((yield_ref - sim_y) / 100.0) ** 2
                    # Penalty: heavily penalise zero yield at maturity when ref > 0
                    if sim_y == 0.0 and yield_ref > 0.0 and is_matured:
                        yield_err *= 1000.0
                    has_ref = True

                fint_err = 0.0
                if ref_key in ref.date_fint:
                    sim_fi = out.crop.light_interception_attainable * 100.0
                    fint_err = (ref.date_fint[ref_key] * 100.0 - sim_fi) ** 2
                    # Penalty: heavily penalise near-zero interception during the season
                    if sim_fi < 0.1 and is_planted:
                        fint_err *= 1000.0
                    has_ref = True

                total_err += agb_err + yield_err + fint_err

            if include_disease:
                dis_err = 0.0
                by_date = ref.disease_date_disease_sev.get(self.disease, {})
                if ref_key in by_date:
                    sim_ds = out.disease.disease_severity * 100.0
                    dis_err = (by_date[ref_key] - sim_ds) ** 2
                    has_ref = True

                yield_err2 = 0.0
                if ref_key in ref.date_yield_actual:
                    sim_ya = out.crop.yield_actual
                    yield_ref2 = ref.date_yield_actual[ref_key]
                    yield_err2 = ((yield_ref2 - sim_ya) / 100.0) ** 2
                    # Penalty: heavily penalise zero actual yield at maturity when ref > 0
                    if sim_ya == 0.0 and yield_ref2 > 0.0 and is_matured:
                        yield_err2 *= 1000.0
                    has_ref = True

                total_err += dis_err + yield_err2
            if has_ref:
                errors.append(total_err)

        if not errors:
            return 0.0
        import math
        return round(math.sqrt(sum(errors) / len(errors)), 3)

    # -----------------------------------------------------------------------
    # Private helpers
    # -----------------------------------------------------------------------

    def _build_parameters(
        self, param_values: Dict[str, float]
    ) -> Parameters:
        """Assemble a Parameters object from CSV defaults + overrides."""
        parameters = Parameters()

        for key, p in self.name_param.items():
            # key = "class_ParamName", e.g. "crop_TbaseCrop"
            parts = key.split("_", 1)
            if len(parts) != 2:
                continue
            param_class, param_name = parts

            # Use override value if supplied, else CSV default
            if key in param_values:
                value = param_values[key]
            elif key in self.param_out_calibration:
                value = self.param_out_calibration[key]
            else:
                value = p.value_bool if p.is_boolean else p.value

            _set_param(parameters, param_class, param_name, value)

        return parameters

    def _load_weather(self) -> Dict[datetime, InputsHourly]:
        """Load weather data for the configured site and time step.

        If ``self.weather_dir`` is itself an existing CSV file, it is used
        directly (i.e. the caller already resolved the full path).
        """
        wd = self.weather_dir
        if os.path.isfile(wd):
            weather_file = wd
        else:
            weather_file = os.path.join(
                wd, self.weather_time_step, f"{self.site}.csv"
            )
        if self.weather_time_step == "hourly":
            return read_hourly(
                weather_file, self.start_year, self.end_year,
                site=self.site, latitude=self.latitude
            )
        else:
            return read_daily(
                weather_file, self.start_year, self.end_year,
                latitude=self.latitude
            )
compute_rmse(self, date_outputs, include_crop=True, include_disease=True)

Compute the root-mean-square error against reference data.

This is the calibration objective. For each simulated day that has a matching reference observation, it accumulates the squared, scaled errors of the selected variables — above-ground biomass, attainable and actual yield, light interception, and disease severity — and returns the root mean of those per-day error sums. Two penalty rules sharpen the objective: a zero simulated yield at maturity where the reference is positive, and a near-zero simulated light interception during the growing season, are both heavily penalised.

Parameters:

Name Type Description Default
date_outputs Dict[datetime, Outputs]

Daily outputs produced by :meth:run.

required
include_crop bool

Include the crop-related error terms (biomass, attainable yield, light interception).

True
include_disease bool

Include the disease-related error terms (severity and actual yield).

True

Returns:

Type Description
float

The root-mean-square error, rounded to three decimals; 0.0 when no reference observations overlap the simulated days.

Source code in simpest/models/fr_runner.py
def compute_rmse(
    self,
    date_outputs: Dict[datetime, Outputs],
    include_crop: bool = True,
    include_disease: bool = True,
) -> float:
    """Compute the root-mean-square error against reference data.

    This is the calibration objective. For each simulated day that has a
    matching reference observation, it accumulates the squared, scaled
    errors of the selected variables — above-ground biomass, attainable and
    actual yield, light interception, and disease severity — and returns the
    root mean of those per-day error sums. Two penalty rules sharpen the
    objective: a zero simulated yield at maturity where the reference is
    positive, and a near-zero simulated light interception during the
    growing season, are both heavily penalised.

    Args:
        date_outputs (Dict[datetime, Outputs]): Daily outputs produced by
            :meth:`run`.
        include_crop (bool): Include the crop-related error terms (biomass,
            attainable yield, light interception).
        include_disease (bool): Include the disease-related error terms
            (severity and actual yield).

    Returns:
        float: The root-mean-square error, rounded to three decimals; ``0.0``
        when no reference observations overlap the simulated days.
    """
    errors: List[float] = []
    ref = self.sim_unit.reference_data

    from datetime import date as date_type
    for hour_dt, out in date_outputs.items():
        if hour_dt.hour != 23:
            continue
        # Reference keys are datetime.date; convert for lookup.
        sim_day: date_type = hour_dt.date()
        # Day alignment between simulated output and reference data.
        #   Previous-day alignment (default): compare this simulated day to
        #   the next day's reference, i.e. sim[d] vs ref[d+1], which is
        #   equivalent to sim[d-1] vs ref[d].
        #   Same-day alignment: sim[d] vs ref[d], matching the daily table.
        ref_key: date_type = (
            sim_day + timedelta(days=1)
            if self.use_prev_day_alignment else sim_day
        )
        total_err = 0.0
        has_ref = False

        # Infer crop state for the penalty multipliers below
        is_matured = out.crop.cycle_completion_percentage >= 100.0
        is_planted = out.crop.day_after_sowing > 0 and not is_matured

        if include_crop:
            agb_err = 0.0
            if ref_key in ref.date_agb:
                sim_agb = out.crop.agb_attainable
                agb_err = ((ref.date_agb[ref_key] - sim_agb) / 200.0) ** 2
                has_ref = True

            yield_err = 0.0
            if ref_key in ref.date_yield_attainable:
                sim_y = out.crop.yield_attainable
                yield_ref = ref.date_yield_attainable[ref_key]
                yield_err = ((yield_ref - sim_y) / 100.0) ** 2
                # Penalty: heavily penalise zero yield at maturity when ref > 0
                if sim_y == 0.0 and yield_ref > 0.0 and is_matured:
                    yield_err *= 1000.0
                has_ref = True

            fint_err = 0.0
            if ref_key in ref.date_fint:
                sim_fi = out.crop.light_interception_attainable * 100.0
                fint_err = (ref.date_fint[ref_key] * 100.0 - sim_fi) ** 2
                # Penalty: heavily penalise near-zero interception during the season
                if sim_fi < 0.1 and is_planted:
                    fint_err *= 1000.0
                has_ref = True

            total_err += agb_err + yield_err + fint_err

        if include_disease:
            dis_err = 0.0
            by_date = ref.disease_date_disease_sev.get(self.disease, {})
            if ref_key in by_date:
                sim_ds = out.disease.disease_severity * 100.0
                dis_err = (by_date[ref_key] - sim_ds) ** 2
                has_ref = True

            yield_err2 = 0.0
            if ref_key in ref.date_yield_actual:
                sim_ya = out.crop.yield_actual
                yield_ref2 = ref.date_yield_actual[ref_key]
                yield_err2 = ((yield_ref2 - sim_ya) / 100.0) ** 2
                # Penalty: heavily penalise zero actual yield at maturity when ref > 0
                if sim_ya == 0.0 and yield_ref2 > 0.0 and is_matured:
                    yield_err2 *= 1000.0
                has_ref = True

            total_err += dis_err + yield_err2
        if has_ref:
            errors.append(total_err)

    if not errors:
        return 0.0
    import math
    return round(math.sqrt(sum(errors) / len(errors)), 3)
run(self, param_values=None)

Run the model for all configured years and return daily outputs.

Parameters:

Name Type Description Default
param_values Optional[Dict[str, float]]

Optional override values keyed by class_ParamName.

None

Returns:

Type Description
Dict[datetime, Outputs]

Mapping of end-of-day timestamp to model outputs.

Source code in simpest/models/fr_runner.py
def run(
    self,
    param_values: Optional[Dict[str, float]] = None,
) -> Dict[datetime, Outputs]:
    """
    Run the model for all configured years and return daily outputs.

    Args:
        param_values (Optional[Dict[str, float]]): Optional override values
            keyed by ``class_ParamName``.

    Returns:
        Dict[datetime, Outputs]: Mapping of end-of-day timestamp to model
        outputs.
    """
    parameters = self._build_parameters(param_values or {})
    weather_data = self._load_weather()

    date_outputs: Dict[datetime, Outputs] = {}

    # Per-hour accumulators aggregated into the daily input each day
    hourly_temps:  List[float] = []
    hourly_rads:   List[float] = []
    hourly_precip: List[float] = []
    hourly_rhs:    List[float] = []
    hourly_lw:     List[float] = []

    output    = Outputs()
    output_t1 = Outputs()
    disease_model = DiseaseModel()

    is_planted = False
    last_treatment_date = datetime(1900, 1, 1)

    for hour_dt in sorted(weather_data.keys()):
        year = hour_dt.year
        if year < self.start_year or year > self.end_year:
            output = Outputs()
            output_t1 = Outputs()
            continue

        hourly_rec = weather_data[hour_dt]
        hourly_rec.date = hour_dt
        hourly_rec.latitude = self.latitude

        # Sowing: reset on the correct DOY
        sow_doy = self.sim_unit.year_sowing_doy.get(year)
        if sow_doy and hour_dt.timetuple().tm_yday == sow_doy:
            is_planted = True
            output = Outputs()
            output_t1 = Outputs()
            disease_model = DiseaseModel()
            output_t1.crop.growing_season = year

        if is_planted:
            # Track fungicide treatments
            sched = self.sim_unit.fungicide_treatment_schedule
            treatment_dates = {t.date() for t in sched.treatments}
            if hour_dt.date() in treatment_dates:
                last_treatment_date = datetime.combine(
                    hour_dt.date(), datetime.min.time()
                )
            hourly_rec.date_treatment_last = last_treatment_date

            # Accumulate hourly observations
            hourly_temps.append(hourly_rec.air_temperature)
            hourly_rads.append(hourly_rec.rad)
            hourly_precip.append(hourly_rec.precipitation)
            hourly_rhs.append(hourly_rec.relative_humidity)
            hourly_lw.append(hourly_rec.leaf_wetness)

            # Skip the hourly disease step when disease is disabled, and also
            # for crop-only calibration runs where disease plays no role.
            if self.disease_type and (self.calibration_variable != "crop" or not self.is_calibration):
                disease_model.run_hourly(hourly_rec, parameters, output, output_t1)

            if hour_dt.hour == 23:
                # ----------------------------------------------------------
                # End of day: swap, build daily input, run daily models
                # ----------------------------------------------------------
                output = output_t1

                # Fresh daily output with season-persistent fields carried over
                output_t1 = Outputs()
                output_t1.crop.growing_season = output.crop.growing_season
                output_t1.crop.f_int_peak = output.crop.f_int_peak
                output_t1.disease.is_primary_inoculum_started = (
                    output.disease.is_primary_inoculum_started
                )
                output_t1.disease.first_seasonal_infection = (
                    output.disease.first_seasonal_infection
                )
                output_t1.disease.cycle_percentage_first_infection = (
                    output.disease.cycle_percentage_first_infection
                )

                # Assemble daily input from hourly lists
                input_daily = InputsDaily()
                input_daily.tmax = max(hourly_temps)
                input_daily.tmin = min(hourly_temps)
                input_daily.rad  = sum(hourly_rads)
                input_daily.precipitation = sum(hourly_precip)
                input_daily.rhx = max(hourly_rhs)
                input_daily.rhn = min(hourly_rhs)
                input_daily.leaf_wetness = sum(hourly_lw)
                # Date of this day = hour 23 minus 23 hours
                input_daily.date = hour_dt - timedelta(hours=23)
                input_daily.date_treatment_last = hourly_rec.date_treatment_last
                input_daily.crop_model_data = self.crop_model_data

                # Run the daily sub-models. The crop step always runs; the
                # fungicide and disease steps are optional and gated on their
                # respective *_type (None skips the step, giving a crop-only
                # or no-fungicide run).
                crop_run(input_daily, parameters, output, output_t1)
                if self.fungicide_type:
                    fungicide_run(input_daily, parameters, output, output_t1)
                if self.disease_type:
                    disease_model.run_daily(input_daily, parameters, output, output_t1)

                # Store daily output
                output_t1.inputs_daily = input_daily
                date_outputs[hour_dt] = output_t1

                # Clear hourly accumulators
                hourly_temps.clear()
                hourly_rads.clear()
                hourly_precip.clear()
                hourly_rhs.clear()
                hourly_lw.clear()

                # Safety stop or maturity check
                if (output_t1.crop.cycle_completion_percentage >= 100
                        or output_t1.crop.day_after_sowing >= _MAX_DAS):
                    is_planted = False

        else:
            is_planted = False
            output = Outputs()
            output_t1 = Outputs()

    return date_outputs

fr_utilities

Biophysical helper functions shared across the model.

This module provides small, stateless functions that encode reusable biophysical relationships, such as the cardinal-temperature response used by both the crop and disease sub-models and the rainfall-driven spore detachment index used by the disease model.

rain_detachment(rainfall, rain50, f_int)

Rain-driven spore detachment index.

Returns a dimensionless index in [0, 1] that saturates as rainfall increases relative to the canopy-scaled half-saturation term:

\[ \text{detachment} = \frac{\text{rainfall}} {\text{rain50} \cdot f_{int} + \text{rainfall}} \]

Parameters:

Name Type Description Default
rainfall float

Precipitation over the time step (mm).

required
rain50 float

Half-saturation parameter (mm); the rainfall that yields an index of about 0.5 at full canopy (f_int = 1).

required
f_int float

Light interception fraction in [0, 1].

required

Returns:

Type Description
float

Detachment index in [0, 1]; 0.0 when rainfall is zero or the denominator is zero.

Source code in simpest/models/fr_utilities.py
def rain_detachment(rainfall: float, rain50: float, f_int: float) -> float:
    """Rain-driven spore detachment index.

    Returns a dimensionless index in ``[0, 1]`` that saturates as rainfall
    increases relative to the canopy-scaled half-saturation term:

    $$
    \\text{detachment} = \\frac{\\text{rainfall}}
                              {\\text{rain50} \\cdot f_{int} + \\text{rainfall}}
    $$

    Args:
        rainfall (float): Precipitation over the time step (mm).
        rain50 (float): Half-saturation parameter (mm); the rainfall that yields
            an index of about 0.5 at full canopy (``f_int = 1``).
        f_int (float): Light interception fraction in ``[0, 1]``.

    Returns:
        float: Detachment index in ``[0, 1]``; ``0.0`` when rainfall is zero or
        the denominator is zero.
    """
    denominator = (rain50 * f_int) + rainfall
    if denominator == 0.0:
        return 0.0
    return rainfall / denominator

t_response(t_ave, t_base, t_opt, t_max)

Beta-shaped cardinal-temperature response function.

Returns a dimensionless growth or development efficiency in the range [0, 1] that peaks at the optimum temperature and falls to zero at the base and maximum temperatures. For t_base < t_ave < t_max the response is

\[ f = \frac{t_{max} - t_{ave}}{t_{max} - t_{opt}} \left(\frac{t_{ave} - t_{base}}{t_{opt} - t_{base}}\right) ^{\frac{t_{opt} - t_{base}}{t_{max} - t_{opt}}} \]

and 0 outside the interval [t_base, t_max].

Parameters:

Name Type Description Default
t_ave float

Average temperature for the step (°C).

required
t_base float

Base (minimum) cardinal temperature (°C).

required
t_opt float

Optimum cardinal temperature (°C).

required
t_max float

Maximum cardinal temperature (°C).

required

Returns:

Type Description
float

Temperature response factor in [0, 1].

Source code in simpest/models/fr_utilities.py
def t_response(t_ave: float, t_base: float, t_opt: float, t_max: float) -> float:
    """Beta-shaped cardinal-temperature response function.

    Returns a dimensionless growth or development efficiency in the range
    ``[0, 1]`` that peaks at the optimum temperature and falls to zero at the
    base and maximum temperatures. For ``t_base < t_ave < t_max`` the response is

    $$
    f = \\frac{t_{max} - t_{ave}}{t_{max} - t_{opt}}
        \\left(\\frac{t_{ave} - t_{base}}{t_{opt} - t_{base}}\\right)
        ^{\\frac{t_{opt} - t_{base}}{t_{max} - t_{opt}}}
    $$

    and ``0`` outside the interval ``[t_base, t_max]``.

    Args:
        t_ave (float): Average temperature for the step (°C).
        t_base (float): Base (minimum) cardinal temperature (°C).
        t_opt (float): Optimum cardinal temperature (°C).
        t_max (float): Maximum cardinal temperature (°C).

    Returns:
        float: Temperature response factor in ``[0, 1]``.
    """
    if t_ave <= t_base or t_ave >= t_max:
        return 0.0

    first_term = (t_max - t_ave) / (t_max - t_opt)
    second_term = (t_ave - t_base) / (t_opt - t_base)
    exponent = (t_opt - t_base) / (t_max - t_opt)

    return first_term * math.pow(second_term, exponent)

fr_weather_reader

Weather readers that build an hourly forcing series for the model.

The disease sub-model operates on an hourly time step, so these readers ingest either daily or hourly weather CSV files and return a complete hourly series. When the input is daily, a physically based diurnal synthesis fills in the 24 hourly records for each day.

Key behaviour:

  • Header parsing. Column names are matched case-insensitively against a set of common aliases, so files from different sources are accepted without renaming.
  • Date columns. A single Date/Datetime column or separate Year/Month/Day (plus Hour for hourly files) are both accepted.
  • Radiation. Measured radiation is used when present; otherwise daily global solar radiation is estimated with the Hargreaves–Samani relationship from the daily temperature range and distributed over the day by clear-sky (extraterrestrial) fractions.
  • Humidity. Relative humidity is reconstructed from daily RHx/RHn extrema with a cosine diurnal curve, or estimated from a dew-point relation when extrema are absent.

read_daily(file, start_year, end_year, latitude=0.0)

Read a daily weather CSV and return synthesized hourly records.

Supports year/month/day columns OR a single date/datetime column. Radiation is estimated when absent (Hargreaves-Samani) if latitude is known.

Parameters:

Name Type Description Default
file str | Path

Path to the daily weather CSV.

required
start_year int

First year to include (inclusive).

required
end_year int

Last year to include (inclusive).

required
latitude float

Fallback latitude in decimal degrees when not in CSV.

0.0

Returns:

Type Description
Dict[datetime, InputsHourly]

Dict keyed by datetime(year, month, day, hour)InputsHourly.

Source code in simpest/models/fr_weather_reader.py
def read_daily(
    file: str | Path,
    start_year: int,
    end_year: int,
    latitude: float = 0.0,
) -> Dict[datetime, InputsHourly]:
    """Read a daily weather CSV and return synthesized hourly records.

    Supports year/month/day columns OR a single date/datetime column.
    Radiation is estimated when absent (Hargreaves-Samani) if latitude is known.

    Args:
        file:       Path to the daily weather CSV.
        start_year: First year to include (inclusive).
        end_year:   Last year to include (inclusive).
        latitude:   Fallback latitude in decimal degrees when not in CSV.

    Returns:
        Dict keyed by ``datetime(year, month, day, hour)`` → ``InputsHourly``.
    """
    result: Dict[datetime, InputsHourly] = {}
    path = Path(file)

    with path.open(newline="", encoding="utf-8-sig") as fh:
        sample = fh.read(2048)
        fh.seek(0)
        delimiter = "\t" if "\t" in sample.split("\n")[0] else ","

        reader = csv.reader(fh, delimiter=delimiter)
        headers = [_clean(h) for h in next(reader)]
        col = {name: i for i, name in enumerate(headers)}

        # Date columns
        date_idx = _idx(col, ["date", "datetime", "timestamp"], optional=True)
        year_idx = _idx(col, ["year"], optional=True)
        month_idx = _idx(col, ["month"], optional=True)
        day_idx = _idx(col, ["day"], optional=True)

        has_ymd = (year_idx >= 0 and month_idx >= 0 and day_idx >= 0)
        has_date = date_idx >= 0


        if not has_ymd and not has_date:
            raise ValueError(
                "Weather file must contain (year, month, day) OR a single date/datetime column."
            )

        # Meteorological columns
        rad_idx = _idx(col, ["rad", "radiation", "solar", "solarrad", "srad"], optional=True)
        lat_idx = _idx(col, ["lat", "latitude", "sitelat", "phi"], optional=True)
        tmax_idx = _idx(col, ["tmax", "t2mmax", "maxtemp", "tx"])
        tmin_idx = _idx(col, ["tmin", "t2mmin", "mintemp", "tn"])
        prec_idx = _idx(col, ["prec", "precip", "rain", "rainfall", "precipitation", "p"], optional=True)
        rhx_idx = _idx(col, ["rhmax", "humiditymax", "relativehumiditymax", "hummax", "rhx"], optional=True)
        rhn_idx = _idx(col, ["rhmin", "humiditymin", "relativehumiditymin", "hummin", "rhn"], optional=True)

        if rad_idx < 0 and lat_idx < 0 and latitude == 0.0:
            raise ValueError(
                "Weather file must contain a radiation column OR a latitude column "
                "(or pass latitude= to read_daily)."
            )

        for row in reader:
            if not row or all(c.strip() == "" for c in row):
                continue

            # --- Date ---
            try:
                if has_ymd:
                    yr = int(row[year_idx])
                    mo = int(row[month_idx])
                    dy = int(row[day_idx])
                    day_date = date(yr, mo, dy)
                else:
                    raw_date = row[date_idx].strip().strip('"')
                    day_date = datetime.fromisoformat(raw_date).date()
            except (ValueError, IndexError):
                continue

            if not (start_year <= day_date.year <= end_year):
                continue

            # --- Temperatures ---
            try:
                tmax = _pf(row, tmax_idx)
                tmin = _pf(row, tmin_idx)
            except (ValueError, IndexError):
                continue

            # --- Radiation ---
            rad = _pf(row, rad_idx) if rad_idx >= 0 else float("nan")
            rad_missing = not math.isfinite(rad) or rad <= 0.0

            # --- Latitude (for radiation estimation) ---
            lat = _pf(row, lat_idx) if lat_idx >= 0 else latitude

            if not math.isfinite(lat):
                lat = latitude

            if rad_missing:
                if lat == 0.0:
                    continue  # cannot estimate without latitude
                rd = _day_length(day_date, lat, tmax, tmin)
                rad = rd["gsr"]
                if not math.isfinite(rad) or rad <= 0.0:
                    continue

            # --- Build InputsDaily ---
            prec = _pf(row, prec_idx) if prec_idx >= 0 else 0.0
            if not math.isfinite(prec):
                prec = 0.0

            id_ = InputsDaily(
                date=datetime(day_date.year, day_date.month, day_date.day),
                tmax=tmax,
                tmin=tmin,
                rad=rad,
                precipitation=prec,
                latitude=lat,
            )
            id_.dew_point = _dew_point(id_.tmax, id_.tmin)

            if rhx_idx >= 0:
                v = _pf(row, rhx_idx)
                if math.isfinite(v):
                    id_.rhx = v
            if rhn_idx >= 0:
                v = _pf(row, rhn_idx)
                if math.isfinite(v):
                    id_.rhn = v

            # Calculate leaf wetness from RH and precipitation
            # High humidity (>80%) or significant precipitation (>0.5mm) indicate wet leaves
            avg_rh = (id_.rhx + id_.rhn) / 2.0 if id_.rhx > 0 else 0.0
            if prec >= 0.5 or avg_rh >= 80.0:
                # Estimate wetness hours: scale from 60% RH (minimal) to 100% RH (24 hours)
                if avg_rh >= 80.0:
                    id_.leaf_wetness = min(24.0, (avg_rh - 80.0) * 1.2)  # 80% -> 0h, 100% -> 24h
                else:
                    id_.leaf_wetness = 0.0
                # Add hours if precipitation present
                if prec >= 0.5:
                    id_.leaf_wetness = min(24.0, id_.leaf_wetness + prec * 2.0)  # Add ~2h per mm rainfall

            # --- Synthesize 24 hourly records ---
            hourly = _estimate_hourly(id_, day_date, lat)
            result.update(hourly)

    return result

read_hourly(file, start_year, end_year, site='', latitude=0.0)

Read an hourly weather CSV and return the hourly records.

Missing hourly radiation is filled per-day using the Hargreaves estimate distributed via clear-sky (ETR) fractions.

Parameters:

Name Type Description Default
file str | Path

Path to the hourly weather CSV.

required
start_year int

First year to include (inclusive).

required
end_year int

Last year to include (inclusive).

required
site str

Optional site filter (column 'site' or 'station').

''
latitude float

Fallback latitude when not in the file.

0.0

Returns:

Type Description
Dict[datetime, InputsHourly]

Dict keyed by datetime(year, month, day, hour)InputsHourly.

Source code in simpest/models/fr_weather_reader.py
def read_hourly(
    file: str | Path,
    start_year: int,
    end_year: int,
    site: str = "",
    latitude: float = 0.0,
) -> Dict[datetime, InputsHourly]:
    """Read an hourly weather CSV and return the hourly records.

    Missing hourly radiation is filled per-day using the Hargreaves estimate
    distributed via clear-sky (ETR) fractions.

    Args:
        file:       Path to the hourly weather CSV.
        start_year: First year to include (inclusive).
        end_year:   Last year to include (inclusive).
        site:       Optional site filter (column 'site' or 'station').
        latitude:   Fallback latitude when not in the file.

    Returns:
        Dict keyed by ``datetime(year, month, day, hour)`` → ``InputsHourly``.
    """
    result: Dict[datetime, InputsHourly] = {}
    daily_buckets: Dict[date, List[InputsHourly]] = {}

    path = Path(file)

    with path.open(newline="", encoding="utf-8-sig") as fh:
        sample = fh.read(2048)
        fh.seek(0)
        delimiter = "\t" if "\t" in sample.split("\n")[0] else ","
        reader = csv.reader(fh, delimiter=delimiter)
        headers = [_clean(h) for h in next(reader)]
        col = {name: i for i, name in enumerate(headers)}

        year_idx = _idx(col, ["year"], optional=True)
        month_idx = _idx(col, ["month", "mo"], optional=True)
        day_idx = _idx(col, ["day", "dd", "dy"], optional=True)
        hour_idx = _idx(col, ["hour", "hr", "h"], optional=True)
        date_idx = _idx(col, ["date", "datetime", "timestamp"], optional=True)

        has_ymdh = (year_idx >= 0 and month_idx >= 0 and day_idx >= 0 and hour_idx >= 0)
        has_dateh = (date_idx >= 0 and hour_idx >= 0)

        if not has_ymdh and not has_dateh:
            raise ValueError(
                "Hourly file must contain (year,month,day,hour) OR (date,hour)."
            )

        tmax_idx = _idx(col, ["tmax", "t2mmax", "maxtemp"], optional=True)
        tmin_idx = _idx(col, ["tmin", "t2mmin", "mintemp"], optional=True)
        temp_idx = _idx(col, ["temp", "temperature", "t2m"], optional=True)
        prec_idx = _idx(col, ["prec", "precip", "precipitation", "prectotcorr", "rain", "rainfall"], optional=True)
        rh_idx = _idx(col, ["rh", "humidity", "relhumidity", "relativehumidity"], optional=True)
        rad_idx = _idx(col, ["rad", "radiation", "solar", "solarrad"], optional=True)
        lat_idx = _idx(col, ["latitude", "lat"], optional=True)

        if rad_idx < 0 and lat_idx < 0 and latitude == 0.0:
            raise ValueError(
                "Hourly weather file must contain a radiation column OR a latitude column."
            )

        for row in reader:
            if not row or all(c.strip() == "" for c in row):
                continue

            # --- Timestamp ---
            try:
                if has_ymdh:
                    yr = int(row[year_idx])
                    mo = int(row[month_idx])
                    dy = int(row[day_idx])
                    hr = int(row[hour_idx])
                    ts = datetime(yr, mo, dy, hr)
                else:
                    raw = row[date_idx].strip().strip('"')
                    ts = datetime.fromisoformat(raw)
                    ts = ts.replace(minute=0, second=0, microsecond=0)
                    hr = int(row[hour_idx])
                    ts = ts.replace(hour=hr)
            except (ValueError, IndexError):
                continue

            if not (start_year <= ts.year <= end_year):
                continue

            gw = InputsHourly(date=ts)

            # Temperature
            if temp_idx >= 0:
                v = _pf(row, temp_idx)
                if math.isfinite(v):
                    gw.air_temperature = v
            elif tmax_idx >= 0 and tmin_idx >= 0:
                tx = _pf(row, tmax_idx)
                tn = _pf(row, tmin_idx)
                if math.isfinite(tx) and math.isfinite(tn):
                    gw.air_temperature = 0.5 * (tx + tn)

            # Precipitation
            if prec_idx >= 0:
                v = _pf(row, prec_idx)
                if math.isfinite(v) and v >= 0:
                    gw.precipitation = v

            # Relative humidity
            if rh_idx >= 0:
                v = _pf(row, rh_idx)
                if math.isfinite(v):
                    gw.relative_humidity = max(0.0, min(100.0, v))
            else:
                t = gw.air_temperature
                dp = _dew_point(t, t)
                es = 0.61121 * math.exp((17.502 * t) / (240.97 + t))
                ea = 0.61121 * math.exp((17.502 * dp) / (240.97 + dp))
                gw.relative_humidity = max(0.0, min(100.0, ea / es * 100.0)) if es > 0 else 0.0

            # Radiation
            if rad_idx >= 0:
                v = _pf(row, rad_idx)
                if math.isfinite(v) and v >= 0:
                    gw.rad = v

            # Latitude
            if lat_idx >= 0:
                v = _pf(row, lat_idx)
                if math.isfinite(v):
                    gw.latitude = v
            elif latitude != 0.0:
                gw.latitude = latitude

            # Leaf wetness
            gw.leaf_wetness = 1.0 if (gw.relative_humidity > 90.0 or gw.precipitation >= 0.2) else 0.0

            day_key = ts.date()
            daily_buckets.setdefault(day_key, []).append(gw)

    # --- Post-process: fill missing hourly radiation per day ---
    for day_key, records in daily_buckets.items():
        if not records:
            continue

        lat = records[0].latitude if records[0].latitude else latitude
        temps = [r.air_temperature for r in records]
        tmax_d = max(temps)
        tmin_d = min(temps)

        all_rad_zero = all(r.rad <= 0.0 for r in records)

        rd = None
        if all_rad_zero and lat:
            rd = _day_length(day_key, lat, tmax_d, tmin_d)

        for rec in records:
            hr = rec.date.hour
            if rec.rad <= 0.0 and rd is not None:
                rec.rad = rd["gsr_hourly"][hr]
            rec.leaf_wetness = 1.0 if (rec.relative_humidity > 90.0 or rec.precipitation >= 0.2) else 0.0
            result[rec.date] = rec

    return result

franchestyn

FranchestynConfig dataclass

Configuration for FraNchEstYN model runs.

Attributes:

Name Type Description
param_file str

Path to the main parameter file.

crop_param_file str

Path to crop parameter file.

disease_param_file str

Path to disease parameter file.

fungicide_param_file str

Path to fungicide parameter file.

reference_path str

Path to reference CSV.

crop_type str

Crop type (e.g., 'wheat').

disease_type str

Disease type (e.g., 'septoria'). Also the disease on/off switch — None skips the disease model entirely, giving a crop-only run.

fungicide_type str|None

Fungicide type (e.g., 'protectant'). Also the fungicide on/off switch — None skips the fungicide model. Set it whenever treatments are scheduled, or they will be ignored.

site str

Site name.

variety str

Variety name.

disease str

Disease name.

is_calibration bool

Whether to run calibration.

calibration_variable str

Calibration variable ('all', 'crop', 'disease').

use_gdd bool

Method for crop cycle completion. False (default) uses calendar-day interpolation of the external crop-model series; True uses the thermal-time (GDD) based cycle percentage.

use_prev_day_alignment bool

Day alignment used by the calibration objective. True (default) compares the previous simulated day to the current reference observation (sim[d-1] vs ref[d]); False uses same-day alignment (sim[d] vs ref[d]).

all_row_includes_end_year bool

Whether an "All" sowing row covers the final year. False (default) omits end_year; True includes it. Has no effect for per-year sowing CSVs.

n_restarts int

Number of calibration restarts.

max_iter int

Maximum calibration iterations.

crop_disabled_params frozenset[str]

Crop parameter names to hard-exclude from calibration.

disease_disabled_params frozenset[str]

Disease parameter names to hard-exclude from calibration.

Source code in simpest/models/franchestyn.py
@dataclass(frozen=True)
class FranchestynConfig:
    """
    Configuration for FraNchEstYN model runs.

    Attributes:
        param_file (str): Path to the main parameter file.
        crop_param_file (str): Path to crop parameter file.
        disease_param_file (str): Path to disease parameter file.
        fungicide_param_file (str): Path to fungicide parameter file.
        reference_path (str): Path to reference CSV.
        crop_type (str): Crop type (e.g., 'wheat').
        disease_type (str): Disease type (e.g., 'septoria'). Also the disease
            on/off switch — ``None`` skips the disease model entirely, giving a
            crop-only run.
        fungicide_type (str|None): Fungicide type (e.g., 'protectant'). Also the
            fungicide on/off switch — ``None`` skips the fungicide model. Set it
            whenever treatments are scheduled, or they will be ignored.
        site (str): Site name.
        variety (str): Variety name.
        disease (str): Disease name.
        is_calibration (bool): Whether to run calibration.
        calibration_variable (str): Calibration variable ('all', 'crop', 'disease').
        use_gdd (bool): Method for crop cycle completion. ``False`` (default)
            uses calendar-day interpolation of the external crop-model series;
            ``True`` uses the thermal-time (GDD) based cycle percentage.
        use_prev_day_alignment (bool): Day alignment used by the calibration
            objective. ``True`` (default) compares the previous simulated day to
            the current reference observation (sim[d-1] vs ref[d]); ``False``
            uses same-day alignment (sim[d] vs ref[d]).
        all_row_includes_end_year (bool): Whether an ``"All"`` sowing row covers
            the final year. ``False`` (default) omits ``end_year``; ``True``
            includes it. Has no effect for per-year sowing CSVs.
        n_restarts (int): Number of calibration restarts.
        max_iter (int): Maximum calibration iterations.
        crop_disabled_params (frozenset[str]): Crop parameter names to hard-exclude
            from calibration.
        disease_disabled_params (frozenset[str]): Disease parameter names to
            hard-exclude from calibration.
    """
    param_file: str = ""
    crop_param_file: str = field(default_factory=lambda: _resolve_local_model_file("fr_crop_parameters.json"))
    disease_param_file: str = field(default_factory=lambda: _resolve_local_model_file("fr_disease_parameters.json"))
    fungicide_param_file: str = field(default_factory=lambda: _resolve_local_model_file("fr_fungicide_parameters.json"))
    reference_path: str = _resolve_default_reference()
    crop_type: str = "wheat"
    disease_type: str = "septoria"
    fungicide_type: str | None = "protectant"
    site: str = "indiana"
    variety: str = "Generic"
    disease: str = "thisDisease"
    is_calibration: bool = True
    calibration_variable: str = "all"
    use_gdd: bool = False
    use_prev_day_alignment: bool = True
    all_row_includes_end_year: bool = False
    n_restarts: int = 1
    max_iter: int = 100
    crop_disabled_params: frozenset[str] = frozenset()
    disease_disabled_params: frozenset[str] = frozenset()

build_season_summary(df, site, variety)

Aggregate daily simulation results into a per-season summary.

Rows are grouped by growing season (sowing year) and, for each season, the function reports the epidemic and yield outcomes:

  • AUDPC (area under the disease progress curve) is the time integral of disease severity (expressed as a percentage) over the season, computed by the trapezoidal rule against the calendar date.
  • Yield loss is the gap between attainable and actual yield, reported both in absolute terms and as a percentage, (attainable − actual) / attainable × 100.
  • Peak attainable and actual yield and above-ground biomass, peak disease severity, and season-level weather aggregates (mean temperatures and humidity, total precipitation, radiation, and leaf wetness) are also included where the corresponding columns are present.

Parameters:

Name Type Description Default
df pd.DataFrame

Daily simulation results.

required
site str

Site name written to each summary row.

required
variety str

Variety name written to each summary row.

required

Returns:

Type Description
pd.DataFrame

One row per growing season; empty if the input has no valid post-sowing days.

Source code in simpest/models/franchestyn.py
def build_season_summary(df: pd.DataFrame, site: str, variety: str) -> pd.DataFrame:
    """Aggregate daily simulation results into a per-season summary.

    Rows are grouped by growing season (sowing year) and, for each season, the
    function reports the epidemic and yield outcomes:

    - **AUDPC** (area under the disease progress curve) is the time integral of
      disease severity (expressed as a percentage) over the season, computed by
      the trapezoidal rule against the calendar date.
    - **Yield loss** is the gap between attainable and actual yield, reported
      both in absolute terms and as a percentage,
      ``(attainable − actual) / attainable × 100``.
    - Peak attainable and actual yield and above-ground biomass, peak disease
      severity, and season-level weather aggregates (mean temperatures and
      humidity, total precipitation, radiation, and leaf wetness) are also
      included where the corresponding columns are present.

    Args:
        df (pd.DataFrame): Daily simulation results.
        site (str): Site name written to each summary row.
        variety (str): Variety name written to each summary row.

    Returns:
        pd.DataFrame: One row per growing season; empty if the input has no
        valid post-sowing days.
    """
    if df.empty:
        return pd.DataFrame()

    d = df.copy()
    d["Date"] = pd.to_datetime(d["Date"], dayfirst=True, errors="coerce")
    d = d.dropna(subset=["Date"])

    if "DaysAfterSowing" in d.columns:
        d = d[d["DaysAfterSowing"] > 0]
    if d.empty:
        return pd.DataFrame()

    # Group by the crop's growing season (= sowing year). Fall back to the
    # calendar year only where the season label is missing or unset (0), so
    # seasons that cross 1 January are not split across two summary rows.
    if "GrowingSeason" not in d.columns:
        d["GrowingSeason"] = d["Date"].dt.year
    else:
        season = pd.to_numeric(d["GrowingSeason"], errors="coerce")
        d["GrowingSeason"] = season.where(season > 0, d["Date"].dt.year)

    rows = []
    for season, g in d.groupby("GrowingSeason"):
        g = g.sort_values("Date")

        y = g["DiseaseSeverity"].fillna(0.0).to_numpy(dtype=float) * 100.0
        x = g["Date"].map(pd.Timestamp.toordinal).to_numpy(dtype=float)
        audpc = float(np.trapezoid(y, x)) if len(g) >= 2 else 0.0

        yield_att = float(g["YieldAttainable"].max()) if "YieldAttainable" in g.columns else 0.0
        yield_act = float(g["YieldActual"].max()) if "YieldActual" in g.columns else 0.0
        agb_att = float(g["AGBattainable"].max()) if "AGBattainable" in g.columns else 0.0
        agb_act = float(g["AGBactual"].max()) if "AGBactual" in g.columns else 0.0
        dis_sev = float(g["DiseaseSeverity"].max()) if "DiseaseSeverity" in g.columns else 0.0

        loss_raw = yield_att - yield_act
        loss_perc = (loss_raw / yield_att * 100.0) if yield_att > 0 else 0.0

        row = {
            "GrowingSeason": int(season),
            "Site": site,
            "Variety": variety,
            "AUDPC": audpc,
            "DiseaseSeverity": dis_sev,
            "YieldAttainable": yield_att,
            "YieldActual": yield_act,
            "YieldLossRaw": loss_raw,
            "YieldLossPerc": loss_perc,
            "AGBattainable": agb_att,
            "AGBactual": agb_act,
        }

        if "Tmax" in g.columns:
            row["AveTx"] = float(g["Tmax"].mean())
        if "Tmin" in g.columns:
            row["AveTn"] = float(g["Tmin"].mean())
        if "RHx" in g.columns:
            row["AveRHx"] = float(g["RHx"].mean())
        if "RHn" in g.columns:
            row["AveRHn"] = float(g["RHn"].mean())
        if "TotalPrec" in g.columns:
            row["TotalPrec"] = float(g["TotalPrec"].sum())
        if "TotalRad" in g.columns:
            row["TotalRad"] = float(g["TotalRad"].sum())
        if "TotalLW" in g.columns:
            row["TotalLW"] = float(g["TotalLW"].sum())

        rows.append(row)

    return pd.DataFrame(rows).sort_values("GrowingSeason").reset_index(drop=True)

deactivate_calibration(params, disable_list)

Disable calibration for selected parameter names in a parameter section.

Parameters:

Name Type Description Default
params dict

Mapping of parameter name to parameter specification dict.

required
disable_list Iterable[str]

Parameter names for which calibration should be forced to False.

required
Source code in simpest/models/franchestyn.py
def deactivate_calibration(params: dict, disable_list) -> None:
    """
    Disable calibration for selected parameter names in a parameter section.

    Args:
        params (dict): Mapping of parameter name to parameter specification dict.
        disable_list (Iterable[str]): Parameter names for which calibration
            should be forced to False.
    """
    for param_name in disable_list:
        if param_name in params:
            params[param_name]["calibration"] = False
    return params

run_franchestyn(weather_path, management_path, start_year, end_year, config, cropmodel_path=None, crop_param_file=None, disease_param_file=None, fungicide_param_file=None)

Run the FraNchEstYN model with the given configuration and input files.

Parameters:

Name Type Description Default
weather_path str

Path to weather input file.

required
management_path str

Path to management input file.

required
start_year int

Start year for simulation.

required
end_year int

End year for simulation.

required
config FranchestynConfig

FraNchEstYN configuration object.

required
cropmodel_path str|None

Path to crop model data file.

None
crop_param_file str|None

Path to crop parameter file.

None
disease_param_file str|None

Path to disease parameter file.

None
fungicide_param_file str|None

Path to fungicide parameter file.

None

Returns:

Type Description
dict

Dictionary with simulation outputs and summary.

Source code in simpest/models/franchestyn.py
def run_franchestyn(
    weather_path: str,
    management_path: str,
    start_year: int,
    end_year: int,
    config: FranchestynConfig,
    cropmodel_path: str | None = None,
    crop_param_file: str | None = None,
    disease_param_file: str | None = None,
    fungicide_param_file: str | None = None,
) -> dict:
    """
    Run the FraNchEstYN model with the given configuration and input files.

    Args:
        weather_path (str): Path to weather input file.
        management_path (str): Path to management input file.
        start_year (int): Start year for simulation.
        end_year (int): End year for simulation.
        config (FranchestynConfig): FraNchEstYN configuration object.
        cropmodel_path (str|None, optional): Path to crop model data file.
        crop_param_file (str|None, optional): Path to crop parameter file.
        disease_param_file (str|None, optional): Path to disease parameter file.
        fungicide_param_file (str|None, optional): Path to fungicide parameter file.

    Returns:
        dict: Dictionary with simulation outputs and summary.
    """
    crop_param_file = crop_param_file or config.crop_param_file
    disease_param_file = disease_param_file or config.disease_param_file
    fungicide_param_file = fungicide_param_file or config.fungicide_param_file

    runner = FranchestynRunner(
        weather_dir=weather_path,
        param_file=config.param_file,
        sowing_file=management_path,
        ref_dir=config.reference_path,
        crop_model_dir=cropmodel_path,
        site=config.site,
        variety=config.variety,
        disease=config.disease,
        start_year=start_year,
        end_year=end_year,
        weather_time_step="daily",
        calibration_variable=config.calibration_variable,
        is_calibration=config.is_calibration,
        use_gdd=config.use_gdd,
        use_prev_day_alignment=config.use_prev_day_alignment,
        all_row_includes_end_year=config.all_row_includes_end_year,
        crop_type=config.crop_type,
        crop_param_file=crop_param_file,
        disease_param_file=disease_param_file,
        disease_type=config.disease_type,
        fungicide_param_file=fungicide_param_file,
        fungicide_type=config.fungicide_type,
    )

    best_params = {}
    if config.is_calibration:
        disabled_by_class = {
            "crop": set(config.crop_disabled_params),
            "disease": set(config.disease_disabled_params),
        }
        optimizer = FranchestynOptimizer(
            runner=runner,
            calibration_variable=config.calibration_variable,
            n_restarts=config.n_restarts,
            max_iter=config.max_iter,
            disabled_by_class=disabled_by_class,
        )
        best_params = optimizer.calibrate()
        date_outputs = runner.run(param_values=best_params)
    else:
        date_outputs = runner.run()

    include_crop = config.calibration_variable in ("crop", "all")
    include_disease = config.calibration_variable in ("disease", "all")
    rmse = runner.compute_rmse(
        date_outputs,
        include_crop=include_crop,
        include_disease=include_disease,
    )

    records = _outputs_to_records(date_outputs)
    return {
        "outputs": {
            "simulation": records,
            "summary": {
                "rmse": rmse,
                "is_calibration": config.is_calibration,
                "calibration_variable": config.calibration_variable,
                "best_params": best_params,
            },
        }
    }

save_calibrated_parameters_csv(best_params, output_root, site, variety, filename=None, config=None, r_like=False)

Save calibration parameters to a CSV file.

Parameters:

Name Type Description Default
best_params dict

Best parameter values from calibration.

required
output_root Path

Output root directory.

required
site str

Site name.

required
variety str

Variety name.

required
filename str|None

Output filename. If None, uses a default pattern.

None
config FranchestynConfig|None

Configuration used to load parameter metadata when r_like=True.

None
r_like bool

If True, export an extended diagnostics table that includes each parameter's default, bounds, unit, calibration flag, and calibrated value, in addition to the calibrated values.

False

Returns:

Type Description
Path|None

Path to the saved CSV file, or None if best_params is empty.

Source code in simpest/models/franchestyn.py
def save_calibrated_parameters_csv(
    best_params: dict,
    output_root: Path,
    site: str,
    variety: str,
    filename: str | None = None,
    config: FranchestynConfig | None = None,
    r_like: bool = False,
) -> Path | None:
    """
    Save calibration parameters to a CSV file.

    Args:
        best_params (dict): Best parameter values from calibration.
        output_root (Path): Output root directory.
        site (str): Site name.
        variety (str): Variety name.
        filename (str|None, optional): Output filename. If None, uses a default pattern.
        config (FranchestynConfig|None, optional): Configuration used to load
            parameter metadata when ``r_like=True``.
        r_like (bool, optional): If True, export an extended diagnostics table
            that includes each parameter's default, bounds, unit, calibration
            flag, and calibrated value, in addition to the calibrated values.

    Returns:
        Path|None: Path to the saved CSV file, or None if best_params is empty.
    """
    out_dir = output_root / "SimulationExperimentTemplate" / "calibratedParameters"
    out_dir.mkdir(parents=True, exist_ok=True)
    if filename is None:
        filename = f"calibratedParameters_{site}_{variety}.csv"
    output_file = out_dir / filename

    if r_like:
        cfg = config or FranchestynConfig(site=site, variety=variety)

        rows = []
        parameter_file = f"parameters_{site}_{variety}.csv"

        def _load_param_defs(json_path: str, model: str, kind: str):
            path = Path(json_path)
            if not path.exists():
                return
            data = json.loads(path.read_text(encoding="utf-8"))
            section = data.get(kind, {})
            for param, spec in section.items():
                default_val = spec.get("value", "NA")
                min_val = spec.get("min", "NA")
                max_val = spec.get("max", "NA")
                unit = spec.get("unit", "unitless")
                calib_enabled = bool(spec.get("calibration", False))

                key = f"{model}_{param}"
                calibrated_val = best_params.get(key, default_val)
                value_col = calibrated_val if calib_enabled else "NA"
                file_col = parameter_file if calib_enabled else "NA"

                rows.append(
                    {
                        "Model": model,
                        "Parameter": param,
                        "unit": unit,
                        "min": min_val,
                        "max": max_val,
                        "default": default_val,
                        "calibration": "TRUE" if calib_enabled else "FALSE",
                        "calibrated": calibrated_val,
                        "value": value_col,
                        "file": file_col,
                        "facet_label": f"{param} ({unit})",
                    }
                )

        _load_param_defs(cfg.crop_param_file, "crop", cfg.crop_type)
        _load_param_defs(cfg.disease_param_file, "disease", cfg.disease_type)

        with output_file.open("w", newline="", encoding="utf-8") as f_out:
            fieldnames = [
                "Model",
                "Parameter",
                "unit",
                "min",
                "max",
                "default",
                "calibration",
                "calibrated",
                "value",
                "file",
                "facet_label",
            ]
            writer = csv.DictWriter(f_out, fieldnames=fieldnames, delimiter=",")
            writer.writeheader()
            for row in rows:
                writer.writerow(row)

        return output_file

    if not best_params:
        return None

    with output_file.open("w", newline="", encoding="utf-8") as f_out:
        writer = csv.DictWriter(f_out, fieldnames=["model", "param", "value"], delimiter=",")
        writer.writeheader()
        for key, value in sorted(best_params.items()):
            if "_" in key:
                model, param = key.split("_", 1)
            else:
                model, param = "", key
            writer.writerow({"model": model, "param": param, "value": value})

    return output_file

save_season_summary_csv(summary_df, output_root, filename='franchestyn_season_summary.csv')

Save season summary DataFrame to a CSV file.

Parameters:

Name Type Description Default
summary_df pd.DataFrame

Season summary DataFrame.

required
output_root Path

Output root directory.

required
filename str

Output filename. Defaults to 'franchestyn_season_summary.csv'.

'franchestyn_season_summary.csv'

Returns:

Type Description
Path|None

Path to the saved CSV file, or None if summary_df is empty.

Source code in simpest/models/franchestyn.py
def save_season_summary_csv(summary_df: pd.DataFrame, output_root: Path, filename: str = "franchestyn_season_summary.csv") -> Path | None:
    """
    Save season summary DataFrame to a CSV file.

    Args:
        summary_df (pd.DataFrame): Season summary DataFrame.
        output_root (Path): Output root directory.
        filename (str, optional): Output filename. Defaults to 'franchestyn_season_summary.csv'.

    Returns:
        Path|None: Path to the saved CSV file, or None if summary_df is empty.
    """
    if summary_df.empty:
        return None

    output_file = output_root / "SimulationExperimentTemplate" / filename
    summary_df.to_csv(output_file, index=False)
    return output_file

save_simulation_results_csv(res_ot_simulation, output_root, filename='franchestyn_simulation_results.csv')

Save FraNchEstYN simulation results to a CSV file.

Parameters:

Name Type Description Default
res_ot_simulation list of dict

Simulation output records.

required
output_root Path

Output root directory.

required
filename str

Output filename. Defaults to 'franchestyn_simulation_results.csv'.

'franchestyn_simulation_results.csv'

Returns:

Type Description
Path

Path to the saved CSV file.

Source code in simpest/models/franchestyn.py
def save_simulation_results_csv(res_ot_simulation, output_root: Path, filename: str = "franchestyn_simulation_results.csv") -> Path:
    """
    Save FraNchEstYN simulation results to a CSV file.

    Args:
        res_ot_simulation (list of dict): Simulation output records.
        output_root (Path): Output root directory.
        filename (str, optional): Output filename. Defaults to 'franchestyn_simulation_results.csv'.

    Returns:
        Path: Path to the saved CSV file.
    """
    output_file = output_root / "SimulationExperimentTemplate" / filename
    df = pd.DataFrame(res_ot_simulation)
    df.to_csv(output_file, index=False)
    return output_file

simplace

SimplaceConfig dataclass

Configuration for Simplace runs.

Attributes:

Name Type Description
install_dir str

Path to Simplace installation directory.

work_dir str

Path to Simplace working directory.

output_dir str

Path to Simplace output directory.

solution_path str

Path to Simplace solution XML file.

project_path str

Path to Simplace project XML file.

Source code in simpest/models/simplace.py
@dataclass(frozen=True)
class SimplaceConfig:
    """
    Configuration for Simplace runs.

    Attributes:
        install_dir (str): Path to Simplace installation directory.
        work_dir (str): Path to Simplace working directory.
        output_dir (str): Path to Simplace output directory.
        solution_path (str): Path to Simplace solution XML file.
        project_path (str): Path to Simplace project XML file.
    """
    install_dir: str = "C:/ParamVC/Research/simplace+/simplace_portable/workspace/"
    work_dir: str = "C:/ParamVC/Research/simplace+/simplace_portable/workspace/simplace_run/simulation/"
    output_dir: str = "C:/ParamVC/Research/simplace+/simplace_out/"
    solution_path: str = "SimulationExperimentTemplate/solution/Lintul5_indiana.sol.xml"
    project_path: str = "SimulationExperimentTemplate/project/Lintul5All_indiana.proj.xml"

build_management(output_root, project_row, crop='wheat', variety='generic', yearly_sowing_doy=None)

Build and save a management CSV file for FraNchEstYN.

Parameters:

Name Type Description Default
output_root Path

Output root directory.

required
project_row dict

Project row dictionary.

required
crop str

Crop name. Defaults to "wheat".

'wheat'
variety str

Variety name. Defaults to "generic".

'generic'
yearly_sowing_doy dict[int, int] | None

Mapping of simulation year to sowing DOY. If omitted, a single "All" row is written to preserve backward compatibility.

None

Returns:

Type Description
Path

Path to the management CSV file.

Source code in simpest/models/simplace.py
def build_management(
    output_root: Path,
    project_row: dict,
    crop: str = "wheat",
    variety: str = "generic",
    yearly_sowing_doy: Optional[dict[int, int]] = None,
) -> Path:
    """
    Build and save a management CSV file for FraNchEstYN.

    Args:
        output_root (Path): Output root directory.
        project_row (dict): Project row dictionary.
        crop (str, optional): Crop name. Defaults to "wheat".
        variety (str, optional): Variety name. Defaults to "generic".
        yearly_sowing_doy (dict[int, int] | None, optional): Mapping of
            simulation year to sowing DOY. If omitted, a single "All" row
            is written to preserve backward compatibility.

    Returns:
        Path: Path to the management CSV file.
    """
    sowing_doy = max(1, project_row["idem"] - 7)
    if yearly_sowing_doy is None:
        candidate = project_row.get("yearly_sowing_doy") if isinstance(project_row, dict) else None
        if isinstance(candidate, dict) and candidate:
            yearly_sowing_doy = {
                int(year): int(doy)
                for year, doy in candidate.items()
            }

    management_dst = output_root / "SimulationExperimentTemplate" / "management_franchestyn.csv"
    with management_dst.open("w", newline="", encoding="utf-8") as f_out:
        writer = csv.DictWriter(
            f_out,
            fieldnames=["site", "crop", "variety", "year", "sowingDOY"],
            delimiter=",",
        )
        writer.writeheader()
        if yearly_sowing_doy:
            for year in sorted(yearly_sowing_doy):
                writer.writerow(
                    {
                        "site": project_row["location"],
                        "crop": crop,
                        "variety": variety,
                        "year": year,
                        "sowingDOY": max(1, int(yearly_sowing_doy[year])),
                    }
                )
        else:
            writer.writerow(
                {
                    "site": project_row["location"],
                    "crop": crop,
                    "variety": variety,
                    "year": "All",
                    "sowingDOY": sowing_doy,
                }
            )

    return management_dst

convert_weather(work_root, output_root, location)

Convert Simplace weather file to FraNchEstYN-compatible CSV format.

Parameters:

Name Type Description Default
work_root Path

Simplace workspace root directory.

required
output_root Path

Output root directory.

required
location str

Location name (used for weather file).

required

Returns:

Type Description
Path

Path to the converted weather CSV file.

Source code in simpest/models/simplace.py
def convert_weather(work_root: Path, output_root: Path, location: str) -> Path:
    """
    Convert Simplace weather file to FraNchEstYN-compatible CSV format.

    Args:
        work_root (Path): Simplace workspace root directory.
        output_root (Path): Output root directory.
        location (str): Location name (used for weather file).

    Returns:
        Path: Path to the converted weather CSV file.
    """
    weather_src = work_root / "SimulationExperimentTemplate" / "data" / "weather" / f"{location}.txt"
    weather_dst = output_root / "SimulationExperimentTemplate" / "weather_franchestyn.csv"

    with weather_src.open(newline="", encoding="utf-8-sig") as f_in, weather_dst.open(
        "w", newline="", encoding="utf-8"
    ) as f_out:
        reader = csv.DictReader(f_in, delimiter="\t")
        reader.fieldnames = [h.lstrip("\ufeff").strip() for h in reader.fieldnames]

        writer = csv.DictWriter(
            f_out,
            fieldnames=["site", "year", "month", "day", "tx", "tn", "p", "rad", "vp", "rhx", "rhn"],
            delimiter=",",
        )
        writer.writeheader()

        for row in reader:
            d = datetime.strptime(row["CURRENTDAY"], "%d.%m.%Y")
            tmax = float(row["Tmax"])
            tmin = float(row["Tmin"])
            vp = float(row["VapourPressure"])

            es_tmax = _saturation_vapor_pressure(tmax)
            es_tmin = _saturation_vapor_pressure(tmin)
            rhx = min(100.0, max(0.0, 100.0 * vp / es_tmin)) if es_tmin > 0 else 0.0
            rhn = min(100.0, max(0.0, 100.0 * vp / es_tmax)) if es_tmax > 0 else 0.0

            writer.writerow(
                {
                    "site": location,
                    "year": d.year,
                    "month": d.month,
                    "day": d.day,
                    "tx": tmax,
                    "tn": tmin,
                    "p": row["Precipitation"],
                    "rad": float(row["Irradiation"]) / 1000.0,
                    "vp": vp,
                    "rhx": rhx,
                    "rhn": rhn,
                }
            )

    return weather_dst

export_crop_model_data(output_root, project_row)

Export crop model data from Simplace daily output to a CSV file for FraNchEstYN.

Parameters:

Name Type Description Default
output_root Path

Output root directory.

required
project_row dict

Project row dictionary.

required

Returns:

Type Description
Path

Path to the exported crop model CSV file.

Source code in simpest/models/simplace.py
def export_crop_model_data(output_root: Path, project_row: dict) -> Path:
    """
    Export crop model data from Simplace daily output to a CSV file for FraNchEstYN.

    Args:
        output_root (Path): Output root directory.
        project_row (dict): Project row dictionary.

    Returns:
        Path: Path to the exported crop model CSV file.
    """
    src = output_root / "SimulationExperimentTemplate" / f"{project_row['location']}{project_row['iopt']}_daily.csv"
    dst = output_root / "SimulationExperimentTemplate" / "cropModel_data.csv"

    with src.open(newline="", encoding="utf-8") as f_in, dst.open("w", newline="", encoding="utf-8") as f_out:
        reader = csv.DictReader(f_in, delimiter=";")
        writer = csv.DictWriter(
            f_out,
            fieldnames=["year", "doy", "agb", "yield", "fint", "lai", "gdd"],
            delimiter=",",
        )
        writer.writeheader()

        for row in reader:
            if all(float(row[name]) == 0.0 for name in ("TAGB", "WSO", "FINT", "LAI")):
                continue

            d = datetime.strptime(row["CURRENT.DATE"], "%d.%m.%Y")
            writer.writerow(
                {
                    "year": d.year,
                    "doy": d.timetuple().tm_yday,
                    "agb": float(row["TAGB"]) * 10,
                    "yield": float(row["WSO"]) * 10,
                    "fint": row["FINT"],
                    "lai": row["LAI"],
                    "gdd": row["TSUM"],
                }
            )
    return dst

extract_yearly_sowing_doy(work_root, project_row, start_year, end_year)

Extract per-year sowing DOY from projectdata CSV files.

The function prefers explicit ISOW values when present. If no per-year values are available, it falls back to a constant sowing DOY derived from IDEM - 7 to preserve current behavior.

Parameters:

Name Type Description Default
work_root Path

Simplace workspace root.

required
project_row dict

Selected project row metadata.

required
start_year int

Start year (inclusive).

required
end_year int

End year (inclusive).

required

Returns:

Type Description
dict[int, int]

Mapping year -> sowing DOY.

Source code in simpest/models/simplace.py
def extract_yearly_sowing_doy(
    work_root: Path,
    project_row: dict,
    start_year: int,
    end_year: int,
) -> dict[int, int]:
    """
    Extract per-year sowing DOY from projectdata CSV files.

    The function prefers explicit `ISOW` values when present. If no per-year
    values are available, it falls back to a constant sowing DOY derived from
    `IDEM - 7` to preserve current behavior.

    Args:
        work_root (Path): Simplace workspace root.
        project_row (dict): Selected project row metadata.
        start_year (int): Start year (inclusive).
        end_year (int): End year (inclusive).

    Returns:
        dict[int, int]: Mapping year -> sowing DOY.
    """
    projectdata_dir = work_root / "SimulationExperimentTemplate" / "data" / "projectdata"
    if not projectdata_dir.exists():
        return {}

    location = str(project_row.get("location", "")).strip().lower()
    iopt = _to_int(project_row.get("iopt"))
    idem = _to_int(project_row.get("idem"))
    default_sowing_doy = max(1, (idem or 8) - 7)

    rows: list[dict] = []
    for project_csv in sorted(projectdata_dir.glob("*.csv")):
        with project_csv.open(newline="", encoding="utf-8-sig") as f_proj:
            reader = csv.DictReader(f_proj, delimiter=";")
            if not reader.fieldnames:
                continue

            name_to_col = {name.strip().lower(): name for name in reader.fieldnames}
            col_location = name_to_col.get("location")
            col_iopt = name_to_col.get("iopt")
            col_start = name_to_col.get("startdate")
            col_end = name_to_col.get("enddate")
            col_isow = name_to_col.get("isow")
            col_idem = name_to_col.get("idem")

            for raw in reader:
                row_location = str(raw.get(col_location, "")).strip().lower() if col_location else ""
                if location and row_location and row_location != location:
                    continue

                row_iopt = _to_int(raw.get(col_iopt)) if col_iopt else None
                if iopt is not None and row_iopt is not None and row_iopt != iopt:
                    continue

                start_dt = _parse_project_date(raw.get(col_start, "")) if col_start else None
                end_dt = _parse_project_date(raw.get(col_end, "")) if col_end else None
                if start_dt is None and end_dt is None:
                    continue

                year = start_dt.year if start_dt is not None else end_dt.year
                if year < start_year or year > end_year:
                    continue

                sow_doy = _to_int(raw.get(col_isow)) if col_isow else None
                if sow_doy is None:
                    row_idem = _to_int(raw.get(col_idem)) if col_idem else idem
                    sow_doy = max(1, (row_idem or 8) - 7)

                rows.append(
                    {
                        "year": year,
                        "sowing_doy": sow_doy,
                        "span_days": ((end_dt - start_dt).days if start_dt and end_dt else 999999),
                    }
                )

    # Prefer the most specific row per year (shortest start-end span).
    yearly_sowing: dict[int, int] = {}
    for row in sorted(rows, key=lambda x: (x["year"], x["span_days"])):
        yearly_sowing[row["year"]] = row["sowing_doy"]

    if yearly_sowing:
        return yearly_sowing

    # Backward-compatible fallback: constant sowing day across all years.
    return {year: default_sowing_doy for year in range(start_year, end_year + 1)}

get_project_row(work_root, selected_line)

Get a row from the project CSV file by line index.

Parameters:

Name Type Description Default
work_root Path

Root directory for Simplace workspace.

required
selected_line int

Line number (1-based) to select from the project CSV.

required

Returns:

Type Description
dict

Dictionary with project row data.

Source code in simpest/models/simplace.py
def get_project_row(work_root: Path, selected_line: int) -> dict:
    """
    Get a row from the project CSV file by line index.

    Args:
        work_root (Path): Root directory for Simplace workspace.
        selected_line (int): Line number (1-based) to select from the project CSV.

    Returns:
        dict: Dictionary with project row data.
    """
    project_csv = (
        work_root
        / "SimulationExperimentTemplate"
        / "data"
        / "projectdata"
        / "lintul5all_indiana.csv"
    )
    with project_csv.open(newline="", encoding="utf-8-sig") as f_proj:
        proj_reader = csv.DictReader(f_proj, delimiter=";")
        project_rows = list(proj_reader)

    row = project_rows[selected_line - 1]
    key_map = {k.strip().lower(): k for k in row.keys()}

    def field(*aliases: str, default: str = "") -> str:
        for alias in aliases:
            key = key_map.get(alias.lower())
            if key is not None:
                return str(row.get(key, default)).strip()
        return default

    project_row = {
        "projectid": field("projectid"),
        "simulationid": field("simulationid"),
        "startdate": field("startdate"),
        "enddate": field("enddate"),
        "location": field("location"),
        "iopt": field("iopt"),
        "idem": _to_int(field("idem")) or 0,
        "irri": field("irri"),
    }

    start_dt = _parse_project_date(project_row["startdate"])
    end_dt = _parse_project_date(project_row["enddate"])
    if start_dt and end_dt:
        project_row["yearly_sowing_doy"] = extract_yearly_sowing_doy(
            work_root,
            project_row,
            start_dt.year,
            end_dt.year,
        )

    return project_row

get_simplace_directories(shell)

Wrapper for simplace.getSimplaceDirectories to avoid exposing simplace in notebooks.

Parameters:

Name Type Description Default
shell

Simplace shell instance.

required

Returns:

Type Description
dict

Simplace directories information.

Source code in simpest/models/simplace.py
def get_simplace_directories(shell):
    """
    Wrapper for simplace.getSimplaceDirectories to avoid exposing simplace in notebooks.

    Args:
        shell: Simplace shell instance.

    Returns:
        dict: Simplace directories information.
    """
    return simplace.getSimplaceDirectories(shell)

init_simplace(config)

Initialize and return a Simplace instance using the given configuration.

Parameters:

Name Type Description Default
config SimplaceConfig

Simplace configuration object.

required

Returns:

Type Description

Simplace instance.

Source code in simpest/models/simplace.py
def init_simplace(config: SimplaceConfig):
    """
    Initialize and return a Simplace instance using the given configuration.

    Args:
        config (SimplaceConfig): Simplace configuration object.

    Returns:
        Simplace instance.
    """
    global _SIMPLACE_INSTANCE

    if _SIMPLACE_INSTANCE is not None and jpype.isJVMStarted():
        return _SIMPLACE_INSTANCE

    if jpype.isJVMStarted():
        Wrapper = jpype.JClass('net.simplace.sim.wrapper.SimplaceWrapper')
        _SIMPLACE_INSTANCE = Wrapper(config.work_dir, config.output_dir, None, None)
        return _SIMPLACE_INSTANCE

    _SIMPLACE_INSTANCE = simplace.initSimplace(
        installDir=config.install_dir,
        workDir=config.work_dir,
        outputDir=config.output_dir,
    )
    return _SIMPLACE_INSTANCE

merge_simplace_and_franchestyn(output_root, project_row, franchestyn_df, out_name='merged_simulation_data.csv')

Merge Simplace and FraNchEstYN daily outputs into a single CSV file.

Parameters:

Name Type Description Default
output_root Path

Output root directory.

required
project_row dict

Project row dictionary.

required
franchestyn_df pd.DataFrame

FraNchEstYN daily output DataFrame.

required
out_name str

Output filename. Defaults to "merged_simulation_data.csv".

'merged_simulation_data.csv'

Returns:

Type Description
Path

Path to the merged CSV file.

Source code in simpest/models/simplace.py
def merge_simplace_and_franchestyn(
    output_root: Path,
    project_row: dict,
    franchestyn_df: pd.DataFrame,
    out_name: str = "merged_simulation_data.csv",
) -> Path:
    """
    Merge Simplace and FraNchEstYN daily outputs into a single CSV file.

    Args:
        output_root (Path): Output root directory.
        project_row (dict): Project row dictionary.
        franchestyn_df (pd.DataFrame): FraNchEstYN daily output DataFrame.
        out_name (str, optional): Output filename. Defaults to "merged_simulation_data.csv".

    Returns:
        Path: Path to the merged CSV file.
    """
    simplace_daily_path = output_root / "SimulationExperimentTemplate" / f"{project_row['location']}{project_row['iopt']}_daily.csv"
    simplace_df = pd.read_csv(simplace_daily_path, sep=";")

    simplace_df.columns = simplace_df.columns.str.strip()
    franchestyn_df.columns = franchestyn_df.columns.str.strip()

    simplace_df["date"] = pd.to_datetime(simplace_df["CURRENT.DATE"], format="%d.%m.%Y")
    franchestyn_df["date"] = pd.to_datetime(franchestyn_df["Date"], dayfirst=True)

    sim_df = simplace_df.rename(columns={c: f"{c}_S" for c in simplace_df.columns if c != "date"})
    fran_df = franchestyn_df.rename(columns={c: f"{c}_F" for c in franchestyn_df.columns if c != "date"})

    merged = pd.merge(sim_df, fran_df, on="date", how="left")
    merged = merged.rename(
        columns={
            "WSO_S": "WSO_S_g_m2",
            "YieldAttainable_F": "YieldAttainable_F_kg_ha",
            "YieldActual_F": "YieldActual_F_kg_ha",
        }
    )

    out_path = output_root / "SimulationExperimentTemplate" / out_name
    merged.to_csv(out_path, index=False)
    return out_path

run_simplace(shell, config, project_lines)

Run a Simplace project for the specified project lines.

Parameters:

Name Type Description Default
shell

Simplace shell instance.

required
config SimplaceConfig

Simplace configuration object.

required
project_lines list[int]

List of project line indices to run.

required
Source code in simpest/models/simplace.py
def run_simplace(shell, config: SimplaceConfig, project_lines: list[int]):
    """
    Run a Simplace project for the specified project lines.

    Args:
        shell: Simplace shell instance.
        config (SimplaceConfig): Simplace configuration object.
        project_lines (list[int]): List of project line indices to run.
    """
    simplace.openProject(shell, config.solution_path, config.project_path)
    simplace.setProjectLines(shell, project_lines)
    simplace.runProject(shell)