Skip to content

fr_runner module

simpest.models.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