Skip to content

Commit 36f72e5

Browse files
pimp twso
1 parent 1917ef9 commit 36f72e5

3 files changed

Lines changed: 170 additions & 7 deletions

File tree

cybench/conf/model/twso_bc.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,8 @@ scale: 0.001
1717
min_location_years: 5
1818
min_std_mod: 0.1
1919

20+
# Skip location-years when the last in-season TWSO is farther than this from EOS.
21+
max_days_before_eos: 14
22+
2023
# Optional override for TWSO CSV root (default: cybench.config.PATH_DATA_DIR)
2124
# data_dir: /path/to/cybench/data

cybench/models/twso_model.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
DEFAULT_END_OF_SEQUENCE = "eos"
3030
DEFAULT_MIN_YEAR = 2000
3131
DEFAULT_MAX_YEAR = 2024
32+
# Skip loc-years whose last in-season TWSO is more than this many days before EOS
33+
# (truncated files, e.g. US 2023 ending weeks before harvest).
34+
DEFAULT_MAX_DAYS_BEFORE_EOS = 14
3235

3336

3437
def twso_csv_path(crop: str, country: str, data_dir: str | Path = PATH_DATA_DIR) -> Path:
@@ -46,6 +49,71 @@ def _aggregate_max_twso(df: pd.DataFrame, *, scale: float) -> pd.Series:
4649
return (grouped * scale).sort_index()
4750

4851

52+
def _twso_loc_year_complete_mask(
53+
aligned: pd.DataFrame,
54+
crop_season_df: pd.DataFrame,
55+
*,
56+
max_days_before_eos: int,
57+
) -> pd.Series:
58+
"""True when the last in-season TWSO date is within ``max_days_before_eos`` of EOS."""
59+
if aligned.empty:
60+
return pd.Series(dtype=bool)
61+
62+
last = aligned.groupby([KEY_LOC, KEY_YEAR], observed=True)["date"].max()
63+
eos = crop_season_df.set_index([KEY_LOC, KEY_YEAR])["end_of_sequence_date"]
64+
complete: dict[tuple[str, int], bool] = {}
65+
for key, last_dt in last.items():
66+
eos_dt = eos.get(key)
67+
if eos_dt is None or pd.isna(eos_dt):
68+
complete[key] = False
69+
continue
70+
gap_days = int((pd.Timestamp(eos_dt) - pd.Timestamp(last_dt)).days)
71+
complete[key] = gap_days <= max_days_before_eos
72+
return pd.Series(complete, dtype=bool)
73+
74+
75+
def twso_coverage_table(
76+
crop: str,
77+
country: str,
78+
*,
79+
data_dir: str | Path = PATH_DATA_DIR,
80+
start_of_sequence: str = DEFAULT_START_OF_SEQUENCE,
81+
end_of_sequence: str = DEFAULT_END_OF_SEQUENCE,
82+
min_year: int = DEFAULT_MIN_YEAR,
83+
max_year: int = DEFAULT_MAX_YEAR,
84+
max_days_before_eos: int = DEFAULT_MAX_DAYS_BEFORE_EOS,
85+
) -> pd.DataFrame:
86+
"""Per location-year TWSO season coverage relative to crop-calendar EOS."""
87+
path = twso_csv_path(crop, country, data_dir=data_dir)
88+
cal_path = crop_calendar_csv_path(crop, country, data_dir=data_dir)
89+
if not path.is_file() or not cal_path.is_file():
90+
return pd.DataFrame()
91+
92+
df = pd.read_csv(path)
93+
df["date"] = pd.to_datetime(df["date"].astype(str), format="%Y%m%d")
94+
df[KEY_YEAR] = df["date"].dt.year
95+
df = df[[KEY_LOC, KEY_YEAR, "date", TWSO_COL]].copy()
96+
97+
crop_cal = pd.read_csv(cal_path)
98+
crop_season_df = compute_crop_season_window(
99+
crop_cal,
100+
min_year=min_year,
101+
max_year=max_year,
102+
start_of_sequence=start_of_sequence,
103+
end_of_sequence=end_of_sequence,
104+
)
105+
aligned = _align_twso_to_season(df, crop_season_df)
106+
if aligned.empty:
107+
return pd.DataFrame()
108+
109+
last = aligned.groupby([KEY_LOC, KEY_YEAR], observed=True)["date"].max().rename("last_twso_date")
110+
eos = crop_season_df.set_index([KEY_LOC, KEY_YEAR])["end_of_sequence_date"].rename("eos_date")
111+
out = last.to_frame().join(eos, how="left")
112+
out["days_before_eos"] = (out["eos_date"] - out["last_twso_date"]).dt.days
113+
out["complete"] = out["days_before_eos"] <= max_days_before_eos
114+
return out.reset_index()
115+
116+
49117
def _align_twso_to_season(df: pd.DataFrame, crop_season_df: pd.DataFrame) -> pd.DataFrame:
50118
"""Keep TWSO observations that fall inside each location-year season window.
51119
@@ -80,12 +148,16 @@ def load_twso_yields(
80148
min_year: int = DEFAULT_MIN_YEAR,
81149
max_year: int = DEFAULT_MAX_YEAR,
82150
scale: float = TWSO_SCALE,
151+
max_days_before_eos: int = DEFAULT_MAX_DAYS_BEFORE_EOS,
83152
) -> pd.Series:
84153
"""Load season-aligned max TWSO as a Series indexed by (adm_id, year).
85154
86155
Daily TWSO values are aligned to the crop-season window (same defaults as
87-
the benchmark dataset config), then aggregated with ``max`` per location-year
156+
the benchmark dataset config), then aggregated with ``max`` per location-year
88157
and scaled to t/ha-compatible units (× ``scale``, default 0.001).
158+
159+
Location-years whose last in-season observation falls more than
160+
``max_days_before_eos`` days before EOS are returned as NaN (skipped).
89161
"""
90162
path = twso_csv_path(crop, country, data_dir=data_dir)
91163
if not path.is_file():
@@ -108,9 +180,26 @@ def load_twso_yields(
108180
start_of_sequence=start_of_sequence,
109181
end_of_sequence=end_of_sequence,
110182
)
111-
df = _align_twso_to_season(df, crop_season_df)
112-
113-
return _aggregate_max_twso(df, scale=scale)
183+
aligned = _align_twso_to_season(df, crop_season_df)
184+
yields = _aggregate_max_twso(aligned, scale=scale)
185+
complete = _twso_loc_year_complete_mask(
186+
aligned, crop_season_df, max_days_before_eos=max_days_before_eos
187+
)
188+
n_incomplete = 0
189+
for key in yields.index:
190+
if key not in complete.index or not bool(complete[key]):
191+
yields[key] = np.nan
192+
n_incomplete += 1
193+
if n_incomplete:
194+
log.info(
195+
"TWSO %s/%s: skipped %d location-year(s) with last observation "
196+
"more than %d day(s) before EOS",
197+
crop,
198+
country,
199+
n_incomplete,
200+
max_days_before_eos,
201+
)
202+
return yields
114203

115204

116205
class TwsoBiasCorrectedModel(BaseModel):
@@ -132,6 +221,7 @@ def __init__(
132221
scale: float = TWSO_SCALE,
133222
min_location_years: int = 5,
134223
min_std_mod: float = 0.1,
224+
max_days_before_eos: int = DEFAULT_MAX_DAYS_BEFORE_EOS,
135225
**_ignored,
136226
):
137227
self.name = name
@@ -143,6 +233,7 @@ def __init__(
143233
self._scale = float(scale)
144234
self._min_location_years = int(min_location_years)
145235
self._min_std_mod = float(min_std_mod)
236+
self._max_days_before_eos = int(max_days_before_eos)
146237
self._crop: str | None = None
147238
self._country: str | None = None
148239
self._twso_yields: pd.Series | None = None
@@ -164,6 +255,7 @@ def fit( # pyright: ignore[reportIncompatibleMethodOverride]
164255
min_year=self._min_year,
165256
max_year=self._max_year,
166257
scale=self._scale,
258+
max_days_before_eos=self._max_days_before_eos,
167259
)
168260

169261
y = dataset.y.reset_index()

tests/models/test_twso_model.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import numpy as np
12
import pandas as pd
23
import pytest
34

@@ -46,7 +47,7 @@ def test_load_twso_yields_max_per_season(tmp_path):
4647
},
4748
)
4849

49-
series = load_twso_yields("maize", "NL", data_dir=tmp_path)
50+
series = load_twso_yields("maize", "NL", data_dir=tmp_path, max_days_before_eos=999)
5051
assert series[("NL11", 2001)] == pytest.approx(4.0)
5152
assert series[("NL11", 2002)] == pytest.approx(8.0)
5253

@@ -104,7 +105,7 @@ def test_twso_model_fit_predict(tmp_path):
104105
train_ds = PandasDataset(cfg=cfg, y=train_y, x=pd.DataFrame(index=train_index))
105106

106107
model = TwsoBiasCorrectedModel(
107-
data_dir=str(tmp_path), min_location_years=2
108+
data_dir=str(tmp_path), min_location_years=2, max_days_before_eos=999
108109
)
109110
model.fit(train_ds)
110111

@@ -228,7 +229,7 @@ def test_twso_global_fallback_for_tiny_std_mod(tmp_path):
228229
cfg = {"crop": {"name": "maize"}, "country": "US"}
229230
train_ds = PandasDataset(cfg=cfg, y=train_y, x=pd.DataFrame(index=train_index))
230231

231-
model = TwsoBiasCorrectedModel(data_dir=str(tmp_path))
232+
model = TwsoBiasCorrectedModel(data_dir=str(tmp_path), max_days_before_eos=999)
232233
model.fit(train_ds)
233234

234235
assert model._global_calibration is not None
@@ -245,3 +246,70 @@ def test_twso_global_fallback_for_tiny_std_mod(tmp_path):
245246

246247
assert preds[0] == pytest.approx(expected)
247248
assert preds[0] < 25.0
249+
250+
251+
def test_load_twso_yields_skips_truncated_season(tmp_path):
252+
"""Loc-years whose last TWSO is far before EOS are returned as NaN."""
253+
country_dir = _season_setup(tmp_path)
254+
# Only early-season obs; EOS for NL test calendar is ~day 280 (October).
255+
_write_twso_csv(
256+
country_dir / "twso_maize_NL.csv",
257+
{
258+
"crop_name": ["maize"] * 4,
259+
KEY_LOC: ["NL11"] * 4,
260+
"date": [20010301, 20010401, 20010501, 20010601],
261+
TWSO_COL: [1000.0, 3000.0, 2000.0, 4000.0],
262+
},
263+
)
264+
265+
strict = load_twso_yields("maize", "NL", data_dir=tmp_path, max_days_before_eos=14)
266+
assert pd.isna(strict[("NL11", 2001)])
267+
268+
relaxed = load_twso_yields("maize", "NL", data_dir=tmp_path, max_days_before_eos=999)
269+
assert relaxed[("NL11", 2001)] == pytest.approx(4.0)
270+
271+
272+
def test_twso_model_predict_nan_for_truncated_season(tmp_path):
273+
country_dir = _season_setup(tmp_path, locs=["NL11", "NL12"])
274+
# NL12 includes late-season obs (complete); NL11 only early-season (truncated).
275+
_write_twso_csv(
276+
country_dir / "twso_maize_NL.csv",
277+
{
278+
"crop_name": ["maize"] * 10,
279+
KEY_LOC: ["NL12"] * 5 + ["NL11"] * 5,
280+
"date": [
281+
20010301,
282+
20010401,
283+
20010501,
284+
20010601,
285+
20011005,
286+
20030301,
287+
20030401,
288+
20030501,
289+
20030601,
290+
20030701,
291+
],
292+
TWSO_COL: [4000.0, 5000.0, 6000.0, 7000.0, 8000.0] * 2,
293+
},
294+
)
295+
296+
train_index = pd.MultiIndex.from_tuples(
297+
[("NL12", 2001), ("NL12", 2002)], names=[KEY_LOC, KEY_YEAR]
298+
)
299+
train_y = pd.DataFrame({KEY_TARGET: [10.0, 12.0]}, index=train_index)
300+
cfg = {"crop": {"name": "maize"}, "country": "NL"}
301+
train_ds = PandasDataset(cfg=cfg, y=train_y, x=pd.DataFrame(index=train_index))
302+
303+
model = TwsoBiasCorrectedModel(
304+
data_dir=str(tmp_path), min_location_years=1, max_days_before_eos=14
305+
)
306+
model.fit(train_ds)
307+
308+
test_index = pd.MultiIndex.from_tuples([("NL11", 2003)], names=[KEY_LOC, KEY_YEAR])
309+
test_ds = PandasDataset(
310+
cfg=cfg,
311+
y=pd.DataFrame({KEY_TARGET: [0.0]}, index=test_index),
312+
x=pd.DataFrame(index=test_index),
313+
)
314+
preds, _ = model.predict(test_ds)
315+
assert np.isnan(preds[0])

0 commit comments

Comments
 (0)