Skip to content

Commit 10121bf

Browse files
Copilotdfeen87
andauthored
Add min-dwell stability to detect_regimes and regenerate Stage 5 figures
Agent-Logs-Url: https://github.com/dfeen87/Cell-DNA-Dynamics/sessions/73a94cea-919c-44c1-9c6f-e89a944478de Co-authored-by: dfeen87 <158860247+dfeen87@users.noreply.github.com>
1 parent 79257a1 commit 10121bf

3 files changed

Lines changed: 122 additions & 33 deletions

File tree

-369 KB
Loading

src/stage5_regime_transitions/detect_regimes.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,77 @@
4848
from src.core.models import RegimeLabels
4949

5050

51+
def _enforce_min_dwell(labels: np.ndarray, min_dwell: int) -> np.ndarray:
52+
"""Merge regime intervals shorter than *min_dwell* samples into neighbours.
53+
54+
Iteratively finds the first contiguous run whose length is less than
55+
*min_dwell* and reassigns it to match the longer of its two adjacent runs
56+
(left neighbour wins a tie). The loop terminates once all runs meet the
57+
minimum length requirement.
58+
59+
Parameters
60+
----------
61+
labels : np.ndarray, shape (T,), dtype object
62+
Per-time-point regime label array to stabilise in-place copy.
63+
min_dwell : int
64+
Minimum number of consecutive samples required to keep a regime run.
65+
Values ≤ 1 disable the filter.
66+
67+
Returns
68+
-------
69+
np.ndarray
70+
A new label array with all runs at least *min_dwell* samples long.
71+
"""
72+
if min_dwell <= 1:
73+
return labels.copy()
74+
75+
result = labels.copy()
76+
77+
while True:
78+
# Build run-length encoding: list of [regime, start, stop].
79+
runs: list[list] = []
80+
cur = result[0]
81+
start = 0
82+
for i in range(1, len(result)):
83+
if result[i] != cur:
84+
runs.append([cur, start, i])
85+
cur = result[i]
86+
start = i
87+
runs.append([cur, start, len(result)])
88+
89+
# Find the first run that is too short.
90+
short_idx: int | None = None
91+
for k, (_, s, e) in enumerate(runs):
92+
if (e - s) < min_dwell:
93+
short_idx = k
94+
break
95+
96+
if short_idx is None:
97+
break # All runs satisfy the minimum dwell time.
98+
99+
k = short_idx
100+
left_len = (runs[k - 1][2] - runs[k - 1][1]) if k > 0 else -1
101+
right_len = (runs[k + 1][2] - runs[k + 1][1]) if k < len(runs) - 1 else -1
102+
103+
if left_len >= right_len and k > 0:
104+
# Absorb into the left neighbour.
105+
result[runs[k][1] : runs[k][2]] = runs[k - 1][0]
106+
elif k < len(runs) - 1:
107+
# Absorb into the right neighbour.
108+
result[runs[k][1] : runs[k][2]] = runs[k + 1][0]
109+
else:
110+
# Single-run array; nothing to merge.
111+
break
112+
113+
return result
114+
115+
51116
def detect_regimes(
52117
delta_phi: np.ndarray,
53118
q_low: float = 0.75,
54119
q_high: float = 0.90,
55120
detect_recovery: bool = True,
121+
min_dwell: int = 10,
56122
) -> RegimeLabels:
57123
"""Segment ΔΦ(t) into regime labels following the manuscript's rules.
58124
@@ -77,6 +143,14 @@ def detect_regimes(
77143
the system's return toward the reference stability region (Section 6:
78144
"cellular recovery trajectories").
79145
146+
Minimum dwell-time stabilisation (*min_dwell* > 1):
147+
After the initial per-sample labelling and optional recovery detection,
148+
any contiguous run of identical labels shorter than *min_dwell* samples
149+
is merged into its longer adjacent run. This prevents the classifier
150+
from switching regimes on single-sample noise and ensures the
151+
segmentation output contains broad, stable regime intervals rather than
152+
rapid oscillations around threshold boundaries.
153+
80154
Parameters
81155
----------
82156
delta_phi : array_like, shape (T,)
@@ -93,6 +167,10 @@ def detect_regimes(
93167
Whether to apply the optional recovery-labelling post-processing
94168
step. Set to ``False`` to obtain the three-regime version
95169
(stable / pre-instability / instability only).
170+
min_dwell : int, default 10
171+
Minimum number of consecutive samples required to retain a regime
172+
run. Any run shorter than this value is absorbed into its longer
173+
neighbour. Set to 1 to disable minimum-dwell-time stabilisation.
96174
97175
Returns
98176
-------
@@ -168,6 +246,15 @@ def detect_regimes(
168246
recovery_mask = labels[post_instability] == "stable"
169247
labels[post_instability[recovery_mask]] = "recovery"
170248

249+
# ------------------------------------------------------------------
250+
# 4. Minimum dwell-time stabilisation.
251+
#
252+
# Merge short regime runs to prevent the classifier from toggling
253+
# on single-sample threshold crossings due to measurement noise.
254+
# ------------------------------------------------------------------
255+
if min_dwell > 1:
256+
labels = _enforce_min_dwell(labels, min_dwell)
257+
171258
return RegimeLabels(
172259
labels=labels,
173260
theta_low=theta_low,

src/stage5_regime_transitions/generate_figures.py

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
- Horizontal dashed lines for θ_low and θ_high thresholds
1010
1111
2. figures/stage5_regime_transitions/regime_segmentation.png
12-
- ΔΦ(t) curve over time
13-
- Shaded regions for each regime (stable, pre-instability, instability, recovery)
14-
- Vertical markers at each regime transition
12+
- Broad colored regime bands (stable / pre-instability / instability / recovery)
13+
- Clean vertical boundary lines at regime transitions
14+
- ΔΦ(t) overlaid as a single curve
1515
1616
Reproducibility settings:
1717
seed = 0, DPI = 150, figure sizes as specified per figure below.
@@ -47,13 +47,14 @@
4747
DPI = 150
4848
np.random.seed(SEED)
4949

50-
# ── Consistent regime colour palette ─────────────────────────────────────────
50+
# ── Consistent regime colour palette (Stage 2 / Stage 3 compatible) ──────────
5151
REGIME_COLORS = {
5252
"stable": "#2196F3", # blue
5353
"pre-instability": "#FF9800", # orange
5454
"instability": "#F44336", # red
5555
"recovery": "#4CAF50", # green
5656
}
57+
REGIME_ALPHA = 0.18
5758

5859
# ── Paths ─────────────────────────────────────────────────────────────────────
5960
_DATA_PATH = os.path.join(_REPO_ROOT, "data", "synthetic", "eic_timeseries.csv")
@@ -125,46 +126,45 @@ def plot_regime_segmentation(
125126
regime_labels,
126127
out_path: str,
127128
) -> None:
128-
"""Plot ΔΦ(t) with shaded regime regions and transition markers and save."""
129+
"""Plot broad regime bands with ΔΦ(t) overlaid and transition markers."""
129130
intervals = regime_shading_intervals(regime_labels)
130131
transitions = extract_transition_events(regime_labels)
131132

132133
fig, ax = plt.subplots(figsize=(14, 5))
133134

134-
# ── Shaded regime spans ───────────────────────────────────────────────────
135+
# ── Broad colored regime bands ────────────────────────────────────────────
136+
dt_half = (t[1] - t[0]) / 2
135137
for iv in intervals:
136138
t_start = t[iv.start]
137-
# Use half-step extension at the right edge to avoid gaps
138-
t_end = t[iv.stop - 1] + (t[1] - t[0]) / 2 if iv.stop < len(t) else t[-1]
139+
t_end = t[iv.stop - 1] + dt_half if iv.stop < len(t) else t[-1] + dt_half
139140
ax.axvspan(
140141
t_start,
141142
t_end,
142-
alpha=0.18,
143+
alpha=REGIME_ALPHA,
143144
color=REGIME_COLORS.get(iv.regime, "#CCCCCC"),
144145
linewidth=0,
145146
)
146147

147-
# ── ΔΦ(t) curve ──────────────────────────────────────────────────────────
148+
# ── Clean boundary lines at transitions ──────────────────────────────────
149+
for ev in transitions:
150+
ax.axvline(
151+
t[ev.index],
152+
color="#616161",
153+
linewidth=0.8,
154+
linestyle="--",
155+
alpha=0.6,
156+
zorder=3,
157+
)
158+
159+
# ── ΔΦ(t) overlaid as a single curve ─────────────────────────────────────
148160
ax.plot(
149161
t, delta_phi,
150162
color="#212121",
151-
linewidth=0.9,
152-
zorder=3,
163+
linewidth=1.0,
164+
zorder=4,
153165
label=r"$\Delta\Phi(t)$",
154166
)
155167

156-
# ── Transition markers ────────────────────────────────────────────────────
157-
for ev in transitions:
158-
x_trans = t[ev.index]
159-
ax.axvline(
160-
x_trans,
161-
color="#9C27B0",
162-
linewidth=0.9,
163-
linestyle=":",
164-
alpha=0.8,
165-
zorder=4,
166-
)
167-
168168
ax.set_xlabel("Time (s)", fontsize=11)
169169
ax.set_ylabel(r"$\Delta\Phi(t)$", fontsize=11)
170170
ax.set_title(
@@ -173,33 +173,35 @@ def plot_regime_segmentation(
173173
)
174174

175175
# ── Legend ────────────────────────────────────────────────────────────────
176+
present_regimes = set(regime_labels)
176177
regime_patches = [
177178
Patch(
178179
facecolor=REGIME_COLORS[r],
179-
alpha=0.5,
180+
alpha=0.6,
180181
label=r.capitalize(),
181182
)
182183
for r in ["stable", "pre-instability", "instability", "recovery"]
183-
if r in set(regime_labels)
184+
if r in present_regimes
184185
]
185186
transition_handle = Line2D(
186187
[0], [0],
187-
color="#9C27B0",
188-
linewidth=0.9,
189-
linestyle=":",
190-
alpha=0.8,
188+
color="#616161",
189+
linewidth=0.8,
190+
linestyle="--",
191+
alpha=0.6,
191192
label="Transition",
192193
)
193194
delta_phi_handle = Line2D(
194195
[0], [0],
195196
color="#212121",
196-
linewidth=0.9,
197+
linewidth=1.0,
197198
label=r"$\Delta\Phi(t)$",
198199
)
199200
ax.legend(
200201
handles=[delta_phi_handle] + regime_patches + [transition_handle],
201202
loc="upper right",
202203
fontsize=8,
204+
framealpha=0.9,
203205
)
204206
ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.4)
205207

@@ -220,8 +222,8 @@ def main() -> None:
220222
# ── 2. Compute ΔΦ(t) ─────────────────────────────────────────────────────
221223
delta_phi = compute_delta_phi(series)
222224

223-
# ── 3. Detect regimes ─────────────────────────────────────────────────────
224-
regime_result = detect_regimes(delta_phi)
225+
# ── 3. Detect regimes with minimum dwell-time stability (10 samples) ──────
226+
regime_result = detect_regimes(delta_phi, min_dwell=10)
225227

226228
# ── 4. Plot ΔΦ(t) with threshold lines ───────────────────────────────────
227229
plot_delta_phi_thresholds(

0 commit comments

Comments
 (0)