Skip to content

Commit a63d22d

Browse files
committed
Detects Directional trend
1 parent 14d3ce9 commit a63d22d

1 file changed

Lines changed: 61 additions & 34 deletions

File tree

scripts/warmup_study.py

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)