@@ -71,7 +71,6 @@ class WarmupStudyConfig:
7171
7272 # Equilibration detection parameters
7373 equilibration_window : int = 10 # Rolling window size
74- equilibration_threshold : float = 0.02 # CV threshold for equilibrium
7574
7675 # Simulation parameters (near critical point)
7776 prey_birth : float = 0.22
@@ -93,14 +92,17 @@ def estimate_equilibration_trend(
9392 time_series : np .ndarray ,
9493 sample_interval : int ,
9594 window : int = 10 ,
96- threshold : float = 0.02 ,
95+ n_check : int = 8 ,
96+ min_alternation_rate : float = 0.35 ,
9797) -> int :
9898 """
99- Estimate equilibration step by detecting when the mean stops trending .
99+ Estimate equilibration by detecting when directional trend disappears .
100100
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.
101+ This method is grid-size independent because it checks the DIRECTION
102+ of changes (positive/negative), not their MAGNITUDE.
103+
104+ - During warmup: Changes are consistently in one direction (trending)
105+ - At equilibrium: Changes alternate randomly (no trend)
104106
105107 Parameters
106108 ----------
@@ -109,47 +111,73 @@ def estimate_equilibration_trend(
109111 sample_interval : int
110112 Number of simulation steps between samples.
111113 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.
114+ Size of rolling window for smoothing (reduces noise in direction detection).
115+ n_check : int
116+ Number of consecutive changes to check for alternation pattern.
117+ min_alternation_rate : float
118+ Minimum fraction of sign changes required to declare equilibrium.
119+ At equilibrium (random walk), expect ~50% alternations.
120+ Default 0.35 allows some tolerance for correlated fluctuations.
116121
117122 Returns
118123 -------
119124 int
120125 Estimated equilibration step.
121126 """
122- if len (time_series ) < window * 3 :
127+ if len (time_series ) < window + n_check + 10 :
123128 return len (time_series ) * sample_interval
124129
125- # Skip initial transient (first 10% of data)
126- start_idx = max (window * 2 , len (time_series ) // 10 )
130+ # Compute rolling means to smooth out high-frequency noise
131+ n_means = len (time_series ) - window + 1
132+ rolling_means = np .array ([
133+ np .mean (time_series [i :i + window ])
134+ for i in range (n_means )
135+ ])
136+
137+ # Compute step-to-step changes in rolling mean
138+ changes = np .diff (rolling_means )
127139
128- # Number of consecutive stable windows required
129- required_stable = 3
130- stable_count = 0
140+ # Skip initial transient (first 20% of data to ensure we're past initial chaos)
141+ start_idx = max (n_check , len (changes ) // 5 )
131142
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 ]
143+ # Slide through and check for loss of directional consistency
144+ for i in range (start_idx , len (changes ) - n_check ):
145+ recent_changes = changes [i :i + n_check ]
146+
147+ # Get signs of changes (+1, -1, or 0)
148+ signs = np .sign (recent_changes )
136149
137- mean1 = np .mean (window1 )
138- mean2 = np .mean (window2 )
150+ # Count sign alternations (how often direction flips)
151+ # A flip is when sign[i] != sign[i+1] (and neither is 0)
152+ nonzero_mask = signs != 0
153+ if np .sum (nonzero_mask ) < n_check // 2 :
154+ # Too many zero changes (population might be dead/static)
155+ continue
139156
140- if mean1 > 0 and mean2 > 0 :
141- # Relative change in mean
142- relative_change = abs ( mean2 - mean1 ) / mean1
157+ nonzero_signs = signs [ nonzero_mask ]
158+ if len ( nonzero_signs ) < 2 :
159+ continue
143160
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
161+ sign_flips = np .sum (nonzero_signs [:- 1 ] != nonzero_signs [1 :])
162+ alternation_rate = sign_flips / (len (nonzero_signs ) - 1 )
163+
164+ # If changes alternate frequently, we're at equilibrium
165+ # (no consistent directional trend)
166+ if alternation_rate >= min_alternation_rate :
167+ # Verify this persists for a bit longer
168+ if i + n_check * 2 < len (changes ):
169+ future_changes = changes [i + n_check :i + n_check * 2 ]
170+ future_signs = np .sign (future_changes )
171+ future_nonzero = future_signs [future_signs != 0 ]
172+ if len (future_nonzero ) >= 2 :
173+ future_flips = np .sum (future_nonzero [:- 1 ] != future_nonzero [1 :])
174+ future_rate = future_flips / (len (future_nonzero ) - 1 )
175+ if future_rate >= min_alternation_rate * 0.8 :
176+ # Confirmed: direction is random, we're at equilibrium
177+ return (i + window ) * sample_interval
149178 else :
150- stable_count = 0 # Reset if trend detected
151- else :
152- stable_count = 0 # Reset if population crashed
179+ # Near end of data, accept current detection
180+ return (i + window ) * sample_interval
153181
154182 return len (time_series ) * sample_interval
155183
@@ -242,7 +270,6 @@ def set_numba_seed(seed): pass
242270 prey_densities ,
243271 cfg .sample_interval ,
244272 window = cfg .equilibration_window ,
245- threshold = cfg .equilibration_threshold ,
246273 )
247274
248275 size_results ['time_per_step' ].append (time_per_step )
0 commit comments