Plot results
# %load_ext autoreload
# %autoreload 2
from pathlib import Path
import math
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import simpest.models.simplace as simplace
import simpest.models.franchestyn as franchestyn
franchestyn_simulated_path = "./data/New/franchestyn_crop_model_data.csv"
simpest_simulated_path = "./data/simpest_outputs/SimulationExperimentTemplate/merged_simulation_data.csv"
reference_path = "./data/New/reference_indiana.csv"
franchestyn_df = pd.read_csv(franchestyn_simulated_path)
simpest_df = pd.read_csv(simpest_simulated_path)
reference_df = pd.read_csv(reference_path)
Disease Severity¶
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
# Model writes DiseaseSeverity as a 0-1 fraction; reference Disease is 0-100 percent.
TO_PCT = 100.0
franchestyn_clean = franchestyn_df.copy()
franchestyn_clean['Date'] = pd.to_datetime(franchestyn_clean['Date'], format='mixed', dayfirst=True, errors='coerce')
franchestyn_clean['Year'] = franchestyn_clean['Date'].dt.year
franchestyn_clean['DiseaseSeverity'] = pd.to_numeric(franchestyn_clean['DiseaseSeverity'], errors='coerce') * TO_PCT
franchestyn_clean = franchestyn_clean[['Date', 'Year', 'DiseaseSeverity']].dropna(subset=['Date', 'Year'])
simpest_clean = simpest_df.copy()
simpest_clean['Date'] = pd.to_datetime(simpest_clean['Date_F'], format='mixed', dayfirst=True, errors='coerce')
simpest_clean['Year'] = simpest_clean['Date'].dt.year
simpest_clean['DiseaseSeverity'] = pd.to_numeric(simpest_clean['DiseaseSeverity_F'], errors='coerce') * TO_PCT
simpest_clean = simpest_clean[['Date', 'Year', 'DiseaseSeverity']].dropna(subset=['Date', 'Year'])
gt_clean = reference_df.copy()
gt_clean.columns = gt_clean.columns.str.lower()
gt_clean = gt_clean.dropna(subset=['year', 'doy'])
gt_clean['Date'] = pd.to_datetime(
gt_clean['year'].astype(int).astype(str) + '-' + gt_clean['doy'].astype(int).astype(str),
format='%Y-%j', errors='coerce'
)
gt_clean['Year'] = gt_clean['year'].astype(int)
gt_clean = gt_clean.rename(columns={'disease': 'DiseaseSeverity'})
gt_clean = gt_clean[['Date', 'Year', 'DiseaseSeverity']].dropna(subset=['Date', 'Year'])
franchestyn_clean['Source'] = 'FraNchEstYN'
simpest_clean['Source'] = 'SIMPEST'
gt_clean['Source'] = 'Reference'
master_df = pd.concat([franchestyn_clean, simpest_clean, gt_clean], ignore_index=True)
master_df.to_csv('master_disease_severity_data.csv', index=False)
# Drop the partial seasons clipped by the 1971-1992 window: the crop cycle runs
# roughly October -> July, so 1972 has no spring epidemic and 1991 is cut off
# before the season completes. Set to () to show every year again.
# (Inlined rather than using EXCLUDE_YEARS because that constant is defined in a
# later cell; keep the two in sync if you change one.)
exclude_years = {1972, 1991}
years = sorted(y for y in master_df['Year'].dropna().unique() if int(y) not in exclude_years)
cols = 3
rows = (len(years) + cols - 1) // cols
fig, axes = plt.subplots(rows, cols, figsize=(18, 3 * rows), sharey=False)
axes = axes.flatten()
for i, year in enumerate(years):
ax = axes[i]
year_data = master_df[master_df['Year'] == year]
ax.set_xlim(pd.Timestamp(f"{int(year)}-01-01"), pd.Timestamp(f"{int(year)}-12-31"))
ax.grid(False)
for source, color, style in zip(['FraNchEstYN', 'SIMPEST', 'Reference'],
['dodgerblue', 'forestgreen', 'red'],
['line', 'line', 'scatter']):
subset = year_data[year_data['Source'] == source]
if subset.empty:
continue
if style == 'line':
ax.plot(subset['Date'], subset['DiseaseSeverity'],
label=source, color=color, linewidth=1.5, zorder=1)
else:
scatter_subset = subset.dropna(subset=['DiseaseSeverity'])
ax.scatter(scatter_subset['Date'], scatter_subset['DiseaseSeverity'],
label=source, color=color, s=30, edgecolor='black',
linewidth=0.5, alpha=1.0, zorder=3)
ax.set_title(f"Year: {int(year)}", fontsize=10, fontweight='bold')
ax.set_ylabel('Disease severity (%)', fontsize=8)
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=2))
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.set_ylim(-5, 105)
for j in range(len(years), len(axes)):
fig.delaxes(axes[j])
legend_dict = {}
for ax in axes:
handles, labels = ax.get_legend_handles_labels()
for handle, label in zip(handles, labels):
legend_dict.setdefault(label, handle)
fig.legend(legend_dict.values(), legend_dict.keys(), loc='lower center', ncol=3, bbox_to_anchor=(0.5, 0))
plt.suptitle("Disease Severity Comparison: Full Season Dynamics (Jan - Dec)", fontsize=14, y=1.02)
plt.tight_layout()
plt.show()
####
def _to_datetime(s):
"""Parse the mixed day-first date formats used across these outputs."""
return pd.to_datetime(s, format='mixed', dayfirst=True, errors='coerce')
def tidy(df, date_col, value_col, label, scale=1.0):
"""Return a tidy Date/Value/Label frame for one series."""
out = pd.DataFrame({
'Date': _to_datetime(df[date_col]),
'Value': pd.to_numeric(df[value_col], errors='coerce') * scale,
}).dropna(subset=['Date'])
out['Year'] = out['Date'].dt.year
out['Label'] = label
return out
def tidy_reference(ref_df, value_col, label):
"""Reference observations are keyed by year + DOY rather than a date."""
r = ref_df.copy()
r.columns = r.columns.str.lower()
r = r.dropna(subset=['year', 'doy'])
out = pd.DataFrame({
'Date': pd.to_datetime(
r['year'].astype(int).astype(str) + '-' + r['doy'].astype(int).astype(str),
format='%Y-%j', errors='coerce'),
'Value': pd.to_numeric(r[value_col], errors='coerce'),
}).dropna(subset=['Date', 'Value'])
out['Year'] = out['Date'].dt.year
out['Label'] = label
return out
# Identity colours, shared with the disease-severity figure so the notebook reads
# as one system. Validated for colourblind separation (worst adjacent pair
# deltaE 10.0 deutan, 30.9 normal vision) against a light surface.
COLORS = {'FraNchEstYN': 'dodgerblue', 'SIMPEST': 'forestgreen', 'Reference': 'red'}
# Partial seasons clipped by the 1971-1992 simulation window: the crop cycle runs
# roughly October -> July, so 1972 has no spring epidemic and 1991 is cut off before
# the season completes. Neither is a like-for-like comparison, so they are dropped.
# Pass exclude_years=() to any plot call to show them again.
EXCLUDE_YEARS = (1972, 1991)
def plot_seasonal_comparison(series, ylabel, title, ylim=None, cols=3,
exclude_years=EXCLUDE_YEARS):
"""Facet a set of series by calendar year, one panel per season.
Args:
series: list of dicts with keys ``frame`` (tidy Date/Value/Year),
``label``, ``kind`` ('line' or 'scatter') and optional ``linestyle``.
ylabel: y-axis label, including units.
title: figure title.
ylim: optional (lo, hi) tuple applied to every panel.
cols: number of facet columns.
exclude_years: years to drop entirely (defaults to the partial edge
seasons); pass ``()`` to keep every year.
"""
excluded = set(exclude_years or ())
all_years = pd.concat([s['frame'] for s in series])['Year'].dropna().unique()
years = sorted(int(y) for y in all_years if int(y) not in excluded)
rows = (len(years) + cols - 1) // cols
fig, axes = plt.subplots(rows, cols, figsize=(18, 3 * rows))
axes = np.atleast_1d(axes).flatten()
for i, year in enumerate(years):
ax = axes[i]
ax.set_xlim(pd.Timestamp(f"{year}-01-01"), pd.Timestamp(f"{year}-12-31"))
ax.grid(True, alpha=0.15, linewidth=0.6) # recessive grid
ax.set_axisbelow(True)
for s in series:
sub = s['frame']
sub = sub[sub['Year'] == year].sort_values('Date')
if sub.empty:
continue
color = COLORS[s['label']]
if s['kind'] == 'line':
ax.plot(sub['Date'], sub['Value'], label=s['label'], color=color,
linewidth=2, linestyle=s.get('linestyle', '-'),
alpha=s.get('alpha', 1.0), zorder=2)
else:
ax.scatter(sub['Date'], sub['Value'], label=s['label'], color=color,
s=45, edgecolor='white', linewidth=1.0, zorder=3)
ax.set_title(f"Year: {year}", fontsize=10, fontweight='bold')
ax.set_ylabel(ylabel, fontsize=8)
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=2))
ax.xaxis.set_major_formatter(mdates.DateFormatter('%b'))
ax.tick_params(axis='x', rotation=45, labelsize=8)
if ylim:
ax.set_ylim(*ylim)
for j in range(len(years), len(axes)):
fig.delaxes(axes[j])
handles = {}
for ax in axes:
for h, l in zip(*ax.get_legend_handles_labels()):
handles.setdefault(l, h)
fig.legend(handles.values(), handles.keys(), loc='lower center',
ncol=len(handles), bbox_to_anchor=(0.5, -0.01), frameon=False)
plt.suptitle(title, fontsize=14, y=1.01)
plt.tight_layout()
plt.show()
Above-ground biomass (AGB)¶
Both engines report AGB in kg ha⁻¹, so no conversion is applied. Solid lines are attainable biomass (disease-free potential); dashed lines are actual biomass after disease damage — the gap between them is the yield penalty the epidemic causes.
Note that in this pipeline SIMPEST's crop-model input is the FraNchEstYN output file, so the two attainable curves should overlay essentially exactly. Treat that as a handoff sanity check: any visible gap there means the crop-model transfer is losing something. The actual curves are computed independently by each disease model, so that is where a genuine dynamics difference would show up.
# AGB: SIMPEST vs FraNchEstYN. Both sides are kg ha-1 -> no scaling.
agb_series = [
{'frame': tidy(franchestyn_df, 'Date', 'AGBattainable', 'FraNchEstYN'),
'label': 'FraNchEstYN', 'kind': 'line', 'linestyle': '-'},
{'frame': tidy(franchestyn_df, 'Date', 'AGBactual', 'FraNchEstYN'),
'label': 'FraNchEstYN', 'kind': 'line', 'linestyle': '--', 'alpha': 0.75},
{'frame': tidy(simpest_df, 'Date_F', 'AGBattainable_F', 'SIMPEST'),
'label': 'SIMPEST', 'kind': 'line', 'linestyle': '-'},
{'frame': tidy(simpest_df, 'Date_F', 'AGBactual_F', 'SIMPEST'),
'label': 'SIMPEST', 'kind': 'line', 'linestyle': '--', 'alpha': 0.75},
]
plot_seasonal_comparison(
agb_series,
ylabel='AGB (kg ha$^{-1}$)',
title='Above-ground biomass: SIMPEST vs FraNchEstYN (solid = attainable, dashed = actual)',
)
# Numeric parity check on the attainable series, which should match by construction.
_fra = tidy(franchestyn_df, 'Date', 'AGBattainable', 'FraNchEstYN').set_index('Date')['Value']
_sim = tidy(simpest_df, 'Date_F', 'AGBattainable_F', 'SIMPEST').set_index('Date')['Value']
_j = pd.concat([_fra.rename('fra'), _sim.rename('sim')], axis=1).dropna()
if not _j.empty:
_d = (_j['sim'] - _j['fra']).abs()
print(f"AGBattainable overlap: {len(_j)} days | max |diff| = {_d.max():.4f} kg/ha | mean = {_d.mean():.4f}")
else:
print("No overlapping dates between the two AGBattainable series - check date parsing.")
AGBattainable overlap: 5216 days | max |diff| = 0.0000 kg/ha | mean = 0.0000
Fractional light interception (fInt)¶
All three sources are on the same 0–1 fraction scale, so no rescaling is needed (unlike disease severity, where the model writes a fraction and the reference is a percentage).
- Solid — total canopy interception (
LightInterception), the attainable canopy. - Dashed — healthy canopy only (
LightIntHealthy); the gap to the solid line is canopy lost to disease. - Red points — observed
FINTfromreference_indiana.csv. These are the same observations the calibration scores against, so they are the arbiter of which engine is actually right, not just which two agree.
# fInt: SIMPEST vs FraNchEstYN vs observed reference. All are 0-1 fractions.
fint_series = [
{'frame': tidy(franchestyn_df, 'Date', 'LightInterception', 'FraNchEstYN'),
'label': 'FraNchEstYN', 'kind': 'line', 'linestyle': '-'},
{'frame': tidy(franchestyn_df, 'Date', 'LightIntHealthy', 'FraNchEstYN'),
'label': 'FraNchEstYN', 'kind': 'line', 'linestyle': '--', 'alpha': 0.75},
{'frame': tidy(simpest_df, 'Date_F', 'LightInterception_F', 'SIMPEST'),
'label': 'SIMPEST', 'kind': 'line', 'linestyle': '-'},
{'frame': tidy(simpest_df, 'Date_F', 'LightIntHealthy_F', 'SIMPEST'),
'label': 'SIMPEST', 'kind': 'line', 'linestyle': '--', 'alpha': 0.75},
{'frame': tidy_reference(reference_df, 'fint', 'Reference'),
'label': 'Reference', 'kind': 'scatter'},
]
plot_seasonal_comparison(
fint_series,
ylabel='fInt (fraction)',
title='Fractional light interception: SIMPEST vs FraNchEstYN vs reference '
'(solid = total, dashed = healthy)',
ylim=(-0.05, 1.05),
)
_n_ref = len(tidy_reference(reference_df, 'fint', 'Reference'))
print(f"Observed fInt points plotted: {_n_ref}")
Observed fInt points plotted: 38