2929DEFAULT_END_OF_SEQUENCE = "eos"
3030DEFAULT_MIN_YEAR = 2000
3131DEFAULT_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
3437def 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+
49117def _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
116205class 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 ()
0 commit comments