Skip to content

Commit 55299f6

Browse files
authored
Merge pull request #23 from dfeen87/copilot/generate-stage5-figures
feat(stage5): add figure generation script for ΔΦ(t) and regime segmentation
2 parents 1e19292 + d4c666c commit 55299f6

3 files changed

Lines changed: 245 additions & 0 deletions

File tree

137 KB
Loading
596 KB
Loading
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""
2+
Stage 5 Figure Generation – Regime-Transition Detection
3+
========================================================
4+
5+
Generates two figures using the Stage 5 operators:
6+
7+
1. figures/stage5_regime_transitions/delta_phi_thresholds.png
8+
- ΔΦ(t) curve over time
9+
- Horizontal dashed lines for θ_low and θ_high thresholds
10+
11+
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
15+
16+
Reproducibility settings:
17+
seed = 0, DPI = 150, figure sizes as specified per figure below.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import os
23+
import sys
24+
25+
import numpy as np
26+
import pandas as pd
27+
import matplotlib
28+
matplotlib.use("Agg")
29+
import matplotlib.pyplot as plt
30+
from matplotlib.patches import Patch
31+
from matplotlib.lines import Line2D
32+
33+
# ── Allow running from repository root or from this file's directory ──────────
34+
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
35+
sys.path.insert(0, _REPO_ROOT)
36+
37+
from src.core.models import ICEStateSeries
38+
from src.stage5_regime_transitions.compute_delta_phi import compute_delta_phi
39+
from src.stage5_regime_transitions.detect_regimes import detect_regimes
40+
from src.stage5_regime_transitions.segmentation_utils import (
41+
regime_shading_intervals,
42+
extract_transition_events,
43+
)
44+
45+
# ── Reproducibility ──────────────────────────────────────────────────────────
46+
SEED = 0
47+
DPI = 150
48+
np.random.seed(SEED)
49+
50+
# ── Consistent regime colour palette ─────────────────────────────────────────
51+
REGIME_COLORS = {
52+
"stable": "#2196F3", # blue
53+
"pre-instability": "#FF9800", # orange
54+
"instability": "#F44336", # red
55+
"recovery": "#4CAF50", # green
56+
}
57+
58+
# ── Paths ─────────────────────────────────────────────────────────────────────
59+
_DATA_PATH = os.path.join(_REPO_ROOT, "data", "synthetic", "eic_timeseries.csv")
60+
_FIG_DIR = os.path.join(_REPO_ROOT, "figures", "stage5_regime_transitions")
61+
62+
63+
# ── Data loading ──────────────────────────────────────────────────────────────
64+
65+
def _load_series(path: str) -> tuple[ICEStateSeries, np.ndarray]:
66+
"""Load eic_timeseries.csv and return (ICEStateSeries, time array)."""
67+
df = pd.read_csv(path)
68+
t = df["time"].to_numpy(dtype=float)
69+
series = ICEStateSeries(
70+
delta_E=df["delta_E"].to_numpy(dtype=float),
71+
delta_I=df["delta_I"].to_numpy(dtype=float),
72+
delta_C=df["delta_C"].to_numpy(dtype=float),
73+
)
74+
return series, t
75+
76+
77+
# ── Figure 1: ΔΦ(t) with threshold lines ─────────────────────────────────────
78+
79+
def plot_delta_phi_thresholds(
80+
t: np.ndarray,
81+
delta_phi: np.ndarray,
82+
theta_low: float,
83+
theta_high: float,
84+
out_path: str,
85+
) -> None:
86+
"""Plot ΔΦ(t) with θ_low and θ_high threshold lines and save."""
87+
fig, ax = plt.subplots(figsize=(12, 4))
88+
89+
ax.plot(t, delta_phi, color="#1565C0", linewidth=1.0, label=r"$\Delta\Phi(t)$")
90+
ax.axhline(
91+
theta_low,
92+
color="#FF9800",
93+
linewidth=1.2,
94+
linestyle="--",
95+
label=rf"$\theta_{{\mathrm{{low}}}}$ = {theta_low:.3f}",
96+
)
97+
ax.axhline(
98+
theta_high,
99+
color="#F44336",
100+
linewidth=1.2,
101+
linestyle="--",
102+
label=rf"$\theta_{{\mathrm{{high}}}}$ = {theta_high:.3f}",
103+
)
104+
105+
ax.set_xlabel("Time (s)", fontsize=11)
106+
ax.set_ylabel(r"$\Delta\Phi(t)$", fontsize=11)
107+
ax.set_title(
108+
r"Stage 5: Instability Magnitude $\Delta\Phi(t)$ with Regime Thresholds",
109+
fontsize=12,
110+
)
111+
ax.legend(loc="upper right", fontsize=9)
112+
ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5)
113+
114+
fig.tight_layout()
115+
fig.savefig(out_path, dpi=DPI, bbox_inches="tight")
116+
plt.close(fig)
117+
print(f"Saved: {out_path}")
118+
119+
120+
# ── Figure 2: Regime segmentation ────────────────────────────────────────────
121+
122+
def plot_regime_segmentation(
123+
t: np.ndarray,
124+
delta_phi: np.ndarray,
125+
regime_labels,
126+
out_path: str,
127+
) -> None:
128+
"""Plot ΔΦ(t) with shaded regime regions and transition markers and save."""
129+
intervals = regime_shading_intervals(regime_labels)
130+
transitions = extract_transition_events(regime_labels)
131+
132+
fig, ax = plt.subplots(figsize=(14, 5))
133+
134+
# ── Shaded regime spans ───────────────────────────────────────────────────
135+
for iv in intervals:
136+
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+
ax.axvspan(
140+
t_start,
141+
t_end,
142+
alpha=0.18,
143+
color=REGIME_COLORS.get(iv.regime, "#CCCCCC"),
144+
linewidth=0,
145+
)
146+
147+
# ── ΔΦ(t) curve ──────────────────────────────────────────────────────────
148+
ax.plot(
149+
t, delta_phi,
150+
color="#212121",
151+
linewidth=0.9,
152+
zorder=3,
153+
label=r"$\Delta\Phi(t)$",
154+
)
155+
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+
168+
ax.set_xlabel("Time (s)", fontsize=11)
169+
ax.set_ylabel(r"$\Delta\Phi(t)$", fontsize=11)
170+
ax.set_title(
171+
r"Stage 5: Regime Segmentation of $\Delta\Phi(t)$",
172+
fontsize=12,
173+
)
174+
175+
# ── Legend ────────────────────────────────────────────────────────────────
176+
regime_patches = [
177+
Patch(
178+
facecolor=REGIME_COLORS[r],
179+
alpha=0.5,
180+
label=r.capitalize(),
181+
)
182+
for r in ["stable", "pre-instability", "instability", "recovery"]
183+
if r in set(regime_labels)
184+
]
185+
transition_handle = Line2D(
186+
[0], [0],
187+
color="#9C27B0",
188+
linewidth=0.9,
189+
linestyle=":",
190+
alpha=0.8,
191+
label="Transition",
192+
)
193+
delta_phi_handle = Line2D(
194+
[0], [0],
195+
color="#212121",
196+
linewidth=0.9,
197+
label=r"$\Delta\Phi(t)$",
198+
)
199+
ax.legend(
200+
handles=[delta_phi_handle] + regime_patches + [transition_handle],
201+
loc="upper right",
202+
fontsize=8,
203+
)
204+
ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.4)
205+
206+
fig.tight_layout()
207+
fig.savefig(out_path, dpi=DPI, bbox_inches="tight")
208+
plt.close(fig)
209+
print(f"Saved: {out_path}")
210+
211+
212+
# ── Entry point ───────────────────────────────────────────────────────────────
213+
214+
def main() -> None:
215+
os.makedirs(_FIG_DIR, exist_ok=True)
216+
217+
# ── 1. Load data ──────────────────────────────────────────────────────────
218+
series, t = _load_series(_DATA_PATH)
219+
220+
# ── 2. Compute ΔΦ(t) ─────────────────────────────────────────────────────
221+
delta_phi = compute_delta_phi(series)
222+
223+
# ── 3. Detect regimes ─────────────────────────────────────────────────────
224+
regime_result = detect_regimes(delta_phi)
225+
226+
# ── 4. Plot ΔΦ(t) with threshold lines ───────────────────────────────────
227+
plot_delta_phi_thresholds(
228+
t,
229+
delta_phi,
230+
theta_low=regime_result.theta_low,
231+
theta_high=regime_result.theta_high,
232+
out_path=os.path.join(_FIG_DIR, "delta_phi_thresholds.png"),
233+
)
234+
235+
# ── 5. Plot regime segmentation ───────────────────────────────────────────
236+
plot_regime_segmentation(
237+
t,
238+
delta_phi,
239+
regime_labels=regime_result.labels,
240+
out_path=os.path.join(_FIG_DIR, "regime_segmentation.png"),
241+
)
242+
243+
244+
if __name__ == "__main__":
245+
main()

0 commit comments

Comments
 (0)