@@ -89,80 +89,69 @@ class WarmupStudyConfig:
8989# EQUILIBRATION DETECTION
9090# =============================================================================
9191
92- def estimate_equilibration_cv (
92+ def estimate_equilibration_trend (
9393 time_series : np .ndarray ,
9494 sample_interval : int ,
9595 window : int = 10 ,
9696 threshold : float = 0.02 ,
9797) -> int :
9898 """
99- Estimate equilibration step using coefficient of variation (CV).
100-
101- Returns the step when rolling CV drops below threshold for a sustained period.
99+ Estimate equilibration step by detecting when the mean stops trending.
100+
101+ This method checks if consecutive rolling means are stable (not changing
102+ significantly). Unlike CV-based detection, this is robust to grid size
103+ because it measures actual trend, not fluctuation magnitude.
104+
105+ Parameters
106+ ----------
107+ time_series : np.ndarray
108+ Population density or count over time.
109+ sample_interval : int
110+ Number of simulation steps between samples.
111+ window : int
112+ Size of rolling window for computing means.
113+ threshold : float
114+ Maximum allowed relative change between consecutive window means.
115+ Default 0.02 means the mean can change by at most 2% between windows.
116+
117+ Returns
118+ -------
119+ int
120+ Estimated equilibration step.
102121 """
103- if len (time_series ) < window * 2 :
122+ if len (time_series ) < window * 3 :
104123 return len (time_series ) * sample_interval
105124
106125 # Skip initial transient (first 10% of data)
107- start_idx = max (window , len (time_series ) // 10 )
126+ start_idx = max (window * 2 , len (time_series ) // 10 )
108127
109- for i in range (start_idx , len (time_series )):
110- recent = time_series [i - window :i ]
111- mean_val = np .mean (recent )
112-
113- if mean_val > 0 :
114- cv = np .std (recent ) / mean_val
115- if cv < threshold :
116- # Check if it stays stable for another window
117- if i + window < len (time_series ):
118- next_window = time_series [i :i + window ]
119- next_cv = np .std (next_window ) / np .mean (next_window ) if np .mean (next_window ) > 0 else 1.0
120- if next_cv < threshold * 1.5 :
121- return i * sample_interval
122- else :
123- return i * sample_interval
128+ # Number of consecutive stable windows required
129+ required_stable = 3
130+ stable_count = 0
124131
125- return len (time_series ) * sample_interval
126-
127-
128- def estimate_equilibration_autocorr (
129- time_series : np .ndarray ,
130- sample_interval : int ,
131- lag_threshold : float = 0.1 ,
132- ) -> Tuple [int , float ]:
133- """
134- Estimate equilibration via autocorrelation decay.
135-
136- Returns (equilibration_step, integrated_autocorrelation_time).
137- """
138- n = len (time_series )
139- if n < 10 :
140- return n * sample_interval , 0.0
141-
142- mean = np .mean (time_series )
143- var = np .var (time_series )
144-
145- if var == 0 :
146- return 0 , 0.0
147-
148- # Compute autocorrelation function
149- acf = np .correlate (time_series - mean , time_series - mean , mode = 'full' )
150- acf = acf [n - 1 :] / (var * n )
151-
152- # Integrated autocorrelation time
153- tau_int = 1.0
154- for lag in range (1 , min (n // 4 , 100 )):
155- if acf [lag ] > 0 :
156- tau_int += 2 * acf [lag ]
132+ for i in range (start_idx , len (time_series ) - window ):
133+ # Compare means of two consecutive windows
134+ window1 = time_series [i - window :i ]
135+ window2 = time_series [i :i + window ]
136+
137+ mean1 = np .mean (window1 )
138+ mean2 = np .mean (window2 )
139+
140+ if mean1 > 0 and mean2 > 0 :
141+ # Relative change in mean
142+ relative_change = abs (mean2 - mean1 ) / mean1
143+
144+ if relative_change < threshold :
145+ stable_count += 1
146+ if stable_count >= required_stable :
147+ # Return the step where stability began
148+ return (i - required_stable * window // 2 ) * sample_interval
149+ else :
150+ stable_count = 0 # Reset if trend detected
157151 else :
158- break
152+ stable_count = 0 # Reset if population crashed
159153
160- # Find where ACF drops below threshold
161- for lag , corr in enumerate (acf ):
162- if corr < lag_threshold :
163- return lag * sample_interval , tau_int
164-
165- return len (acf ) * sample_interval , tau_int
154+ return len (time_series ) * sample_interval
166155
167156
168157# =============================================================================
@@ -200,9 +189,7 @@ def set_numba_seed(seed): pass
200189
201190 size_results = {
202191 'time_per_step' : [],
203- 'equilibration_steps_cv' : [],
204- 'equilibration_steps_acf' : [],
205- 'integrated_autocorr_time' : [],
192+ 'equilibration_steps' : [],
206193 'final_prey_density' : [],
207194 'final_pred_density' : [],
208195 }
@@ -250,43 +237,33 @@ def set_numba_seed(seed): pass
250237 prey_densities = np .array (prey_densities )
251238 pred_densities = np .array (pred_densities )
252239
253- # Estimate equilibration using both methods
254- eq_steps_cv = estimate_equilibration_cv (
240+ # Estimate equilibration (trend-based, robust to grid size)
241+ eq_steps = estimate_equilibration_trend (
255242 prey_densities ,
256243 cfg .sample_interval ,
257244 window = cfg .equilibration_window ,
258245 threshold = cfg .equilibration_threshold ,
259246 )
260247
261- eq_steps_acf , tau_int = estimate_equilibration_autocorr (
262- prey_densities ,
263- cfg .sample_interval ,
264- )
265-
266248 size_results ['time_per_step' ].append (time_per_step )
267- size_results ['equilibration_steps_cv' ].append (eq_steps_cv )
268- size_results ['equilibration_steps_acf' ].append (eq_steps_acf )
269- size_results ['integrated_autocorr_time' ].append (tau_int )
249+ size_results ['equilibration_steps' ].append (eq_steps )
270250 size_results ['final_prey_density' ].append (prey_densities [- 1 ])
271251 size_results ['final_pred_density' ].append (pred_densities [- 1 ])
272252
273253 if (rep + 1 ) % max (1 , cfg .n_replicates // 5 ) == 0 :
274254 logger .info (f" Replicate { rep + 1 } /{ cfg .n_replicates } : "
275- f"eq_steps={ eq_steps_cv } , time/step={ time_per_step * 1000 :.2f} ms" )
255+ f"eq_steps={ eq_steps } , time/step={ time_per_step * 1000 :.2f} ms" )
276256
277257 # Aggregate results
278258 results [L ] = {
279259 'grid_size' : L ,
280260 'grid_cells' : L * L ,
281261 'mean_time_per_step' : float (np .mean (size_results ['time_per_step' ])),
282262 'std_time_per_step' : float (np .std (size_results ['time_per_step' ])),
283- 'mean_eq_steps_cv' : float (np .mean (size_results ['equilibration_steps_cv' ])),
284- 'std_eq_steps_cv' : float (np .std (size_results ['equilibration_steps_cv' ])),
285- 'mean_eq_steps_acf' : float (np .mean (size_results ['equilibration_steps_acf' ])),
286- 'std_eq_steps_acf' : float (np .std (size_results ['equilibration_steps_acf' ])),
287- 'mean_tau_int' : float (np .mean (size_results ['integrated_autocorr_time' ])),
263+ 'mean_eq_steps' : float (np .mean (size_results ['equilibration_steps' ])),
264+ 'std_eq_steps' : float (np .std (size_results ['equilibration_steps' ])),
288265 'mean_total_warmup_time' : float (
289- np .mean (size_results ['equilibration_steps_cv ' ]) *
266+ np .mean (size_results ['equilibration_steps ' ]) *
290267 np .mean (size_results ['time_per_step' ])
291268 ),
292269 'mean_final_prey_density' : float (np .mean (size_results ['final_prey_density' ])),
@@ -297,8 +274,8 @@ def set_numba_seed(seed): pass
297274 logger .info (f"\n Summary for L={ L } :" )
298275 logger .info (f" Time per step: { results [L ]['mean_time_per_step' ]* 1000 :.2f} ± "
299276 f"{ results [L ]['std_time_per_step' ]* 1000 :.2f} ms" )
300- logger .info (f" Equilibration (CV) : { results [L ]['mean_eq_steps_cv ' ]:.0f} ± "
301- f"{ results [L ]['std_eq_steps_cv ' ]:.0f} steps " )
277+ logger .info (f" Equilibration steps : { results [L ]['mean_eq_steps ' ]:.0f} ± "
278+ f"{ results [L ]['std_eq_steps ' ]:.0f} " )
302279 logger .info (f" Total warmup time: { results [L ]['mean_total_warmup_time' ]:.2f} s" )
303280
304281 return results
@@ -342,17 +319,11 @@ def plot_warmup_scaling(
342319
343320 # Panel 2: Equilibration steps vs L (log-log)
344321 ax = axes [1 ]
345- eq_steps = [results [L ]['mean_eq_steps_cv' ] for L in sizes ]
346- eq_stds = [results [L ]['std_eq_steps_cv' ] for L in sizes ]
347322
323+ eq_steps = [results [L ]['mean_eq_steps' ] for L in sizes ]
324+ eq_stds = [results [L ]['std_eq_steps' ] for L in sizes ]
348325 ax .errorbar (sizes , eq_steps , yerr = eq_stds , fmt = 'o-' , capsize = 5 ,
349- linewidth = 2 , color = 'forestgreen' , markersize = 8 , label = 'CV method' )
350-
351- # Also plot ACF method
352- eq_steps_acf = [results [L ]['mean_eq_steps_acf' ] for L in sizes ]
353- eq_stds_acf = [results [L ]['std_eq_steps_acf' ] for L in sizes ]
354- ax .errorbar (sizes , eq_steps_acf , yerr = eq_stds_acf , fmt = 's--' , capsize = 5 ,
355- linewidth = 2 , color = 'darkorange' , markersize = 6 , alpha = 0.7 , label = 'ACF method' )
326+ linewidth = 2 , color = 'forestgreen' , markersize = 8 )
356327
357328 ax .set_xscale ('log' )
358329 ax .set_yscale ('log' )
@@ -426,9 +397,10 @@ def plot_scaling_summary(
426397 # Plot equilibration steps normalized by theoretical scaling
427398 # Try different z values
428399 for z , color , style in [(1.0 , 'green' , '--' ), (1.5 , 'orange' , '-.' ), (2.0 , 'red' , ':' )]:
429- eq_normalized = [results [L ]['mean_eq_steps_cv ' ] / (L ** z ) for L in sizes ]
400+ eq_normalized = [results [L ]['mean_eq_steps ' ] / (L ** z ) for L in sizes ]
430401 # Normalize to first point for comparison
431- eq_normalized = [x / eq_normalized [0 ] for x in eq_normalized ]
402+ if eq_normalized [0 ] > 0 :
403+ eq_normalized = [x / eq_normalized [0 ] for x in eq_normalized ]
432404 ax .plot (sizes , eq_normalized , style , color = color , linewidth = 2 , alpha = 0.7 ,
433405 label = f'Eq. steps / L^{ z :.1f} (normalized)' )
434406
@@ -554,15 +526,26 @@ def main():
554526
555527 # Compute scaling exponents
556528 if len (sizes ) >= 2 :
557- eq_steps = [results [L ]['mean_eq_steps_cv ' ] for L in sizes ]
529+ eq_steps = [results [L ]['mean_eq_steps ' ] for L in sizes ]
558530 total_times = [results [L ]['mean_total_warmup_time' ] for L in sizes ]
559531
560- log_L = np . log ( sizes )
561- log_eq = np . log ( eq_steps )
562- log_T = np . log ( total_times )
532+ # Filter out any zero or negative values for log
533+ valid_eq = [( L , eq ) for L , eq in zip ( sizes , eq_steps ) if eq > 0 ]
534+ valid_T = [( L , T ) for L , T in zip ( sizes , total_times ) if T > 0 ]
563535
564- z_eq , _ , r_eq , _ , _ = linregress (log_L , log_eq )
565- z_total , _ , r_total , _ , _ = linregress (log_L , log_T )
536+ if len (valid_eq ) >= 2 :
537+ log_L_eq = np .log ([x [0 ] for x in valid_eq ])
538+ log_eq = np .log ([x [1 ] for x in valid_eq ])
539+ z_eq , _ , r_eq , _ , _ = linregress (log_L_eq , log_eq )
540+ else :
541+ z_eq , r_eq = 0 , 0
542+
543+ if len (valid_T ) >= 2 :
544+ log_L_T = np .log ([x [0 ] for x in valid_T ])
545+ log_T = np .log ([x [1 ] for x in valid_T ])
546+ z_total , _ , r_total , _ , _ = linregress (log_L_T , log_T )
547+ else :
548+ z_total , r_total = 0 , 0
566549
567550 logger .info (f"Equilibration steps scaling: t_eq ~ L^{ z_eq :.2f} (R² = { r_eq ** 2 :.3f} )" )
568551 logger .info (f"Total warmup time scaling: T_warmup ~ L^{ z_total :.2f} (R² = { r_total ** 2 :.3f} )" )
0 commit comments