diff --git a/.gitignore b/.gitignore index 3b3a90a..003bc51 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,11 @@ __pycache__/ !analysis/passive_guarded/figures/*.png !analysis/comparison/figures/ !analysis/comparison/figures/*.png +!analysis/opt_passive/*.csv +!analysis/three_regime/ +!analysis/three_regime/*.csv +!analysis/three_regime/figures/ +!analysis/three_regime/figures/*.png # CMake CMakeCache.txt diff --git a/analysis/FINDINGS_3REGIME.md b/analysis/FINDINGS_3REGIME.md index d14c164..08a484c 100644 --- a/analysis/FINDINGS_3REGIME.md +++ b/analysis/FINDINGS_3REGIME.md @@ -85,6 +85,8 @@ model scale. CC's practical useful range is T ≲ 2 s. ## 4. Master operating envelope (co-design capstone) +### 4a. Power operating hull + For each wave period T, the **upper hull = max(P_capture)** over all controllers AND all flap variants gives the best achievable power from any (controller, flap-angle) combination. @@ -96,10 +98,120 @@ See `analysis/three_regime/figures/operating_envelope.png` and - **Short T (≲2 s):** CC + VGM-0 (closed flap, Budal-bound tracking, up to 2.34 W) - **Resonance band (≈2.5–5 s):** opt_passive or ff+PID + the flap whose T₀ matches the wave period (90° at T≈2.5 s, marching down to 0° at T≈4.75 s) -- **Long tail (≳5 s):** ff+PID + VGM-0 (low-angle flap holds the longest radiation tail) +- **Long tail (T ≥ 4.5 s):** opt_passive + VGM-0 (large raw excitation force even in the pitch-radiation notch where P_opt is undefined) No single controller or flap reaches this envelope alone. +| T_s | P_max_W | controller | flap_angle | +|-----|---------|-----------|-----------| +| 0.50 | 0.2958 | CC | 90 | +| 0.75 | 0.5708 | CC | 90 | +| 1.00 | 1.4970 | CC | 0 | +| 1.25 | 1.9298 | CC | 0 | +| 1.50 | 2.3427 | CC | 0 | +| 1.75 | 1.9575 | CC | 0 | +| 2.00 | 1.2889 | CC | 0 | +| 2.25 | 0.8149 | CC | 0 | +| 2.50 | 0.5238 | CC | 0 | +| 2.75 | 0.5467 | ff+PID | 90 | +| 3.00 | 0.6316 | ff+PID | 45 | +| 3.25 | 0.7547 | opt_passive | 20 | +| 3.50 | 0.7721 | opt_passive | 10 | +| 3.75 | 0.6291 | ff+PID | 10 | +| 4.00 | 0.5114 | ff+PID | 10 | +| 4.25 | 0.4394 | ff+PID | 0 | +| 4.50 | 0.6534 | opt_passive | 0 | +| 4.75 | 0.6814 | opt_passive | 0 | +| 5.00 | 0.5927 | opt_passive | 0 | +| 5.25 | 0.4977 | opt_passive | 0 | +| 5.50 | 0.4194 | opt_passive | 0 | +| 5.75 | 0.3479 | opt_passive | 0 | +| 6.00 | 0.2897 | opt_passive | 0 | +| 6.25 | 0.2460 | opt_passive | 0 | +| 6.50 | 0.2064 | opt_passive | 0 | +| 6.75 | 0.1757 | opt_passive | 0 | +| 7.00 | 0.1478 | opt_passive | 0 | + +### 4b. Efficiency operating hull + +For each period T, the **efficiency upper hull = max(η)** over all controllers AND flap +variants, where η = P_capture / P_opt. **Only unmasked, well-defined points are +included**: rows where `masked == true`, `linear_popt_invalid == true`, η is NaN, or +η > 1 + ε are skipped (the VGM-0 pitch-radiation notch at T ≥ 3.0 s and the +short-period `linear_popt_invalid` region make P_opt undefined there). + +See `analysis/three_regime/figures/operating_envelope_efficiency.png` and +`analysis/three_regime/operating_envelope_efficiency.csv`. + +| T_s | eta_max | controller | flap_angle | +|-----|---------|-----------|-----------| +| 0.50 | 0.823 | opt_passive | 90 | +| 0.75 | 0.991 | CC | 45 | +| 1.00 | 0.962 | CC | 45 | +| 1.25 | 0.902 | CC | 10 | +| 1.50 | 0.940 | CC | 0 | +| 1.75 | 0.636 | CC | 0 | +| 2.00 | 0.357 | CC | 0 | +| 2.25 | 0.200 | CC | 0 | +| 2.50 | 0.119 | opt_passive | 90 | +| 2.75 | 0.121 | ff+PID | 90 | +| 3.00 | 0.133 | ff+PID | 45 | +| 3.25 | 0.156 | opt_passive | 20 | +| 3.50 | 0.149 | opt_passive | 10 | +| 3.75 | 0.121 | ff+PID | 10 | +| 4.00 | 0.097 | ff+PID | 10 | +| 4.25 | 0.080 | ff+PID | 10 | +| 4.50 | 0.065 | ff+PID | 10 | +| 4.75 | 0.054 | ff+PID | 10 | +| 5.00 | 0.046 | ff+PID | 10 | +| 5.25 | 0.032 | ff+PID | 20 | +| 5.50 | 0.017 | ff+PID | 45 | +| 5.75 | 0.015 | ff+PID | 45 | +| 6.00 | 0.013 | ff+PID | 45 | +| 6.25 | 0.012 | ff+PID | 45 | +| 6.50 | 0.007 | ff+PID | 90 | +| 6.75 | 0.007 | ff+PID | 90 | +| 7.00 | 0.006 | ff+PID | 90 | + +### 4c. Power vs efficiency co-design schedules diverge + +The two hulls select **different (controller, flap-angle) winners** at 17 of the 27 +period points. The divergence has two structural causes: + +**1. Short-period flap selection (T ≤ 1.25 s, T = 2.5 s):** +At T ≤ 1.25 s the power hull picks CC + VGM-0 because the closed-flap geometry +produces the largest excitation torque (and thus highest raw P_capture). The +efficiency hull, however, picks CC + VGM-45 or VGM-10 — those flaps achieve a larger +fraction of their own P_opt because their hydrodynamic coupling is better matched at +short periods. At T = 0.5 s the efficiency hull switches entirely to opt_passive/90: +here the CC `linear_popt_invalid` flag makes all CC η values undefined, and opt_passive +on the wide-open flap captures the highest valid η. + +**2. Long-period VGM-0 notch (T ≥ 4.25 s):** +The most dramatic divergence. The power hull selects opt_passive + VGM-0 at +T = 4.5–7.0 s because VGM-0 still produces measurable P_capture (0.15–0.68 W) in +this band — even though P_opt is undefined (the pitch-radiation B55 → 0 notch makes +the theoretical optimum diverge). The efficiency hull **must exclude** all VGM-0 +T ≥ 3.0 s data (`masked = true`, P_opt empty) and instead finds the best well-defined +η among the higher-angle flaps, landing on ff+PID + VGM-{10,20,45,90} depending on +which flap's resonance tail overlaps that period. + +**Divergence summary table:** + +| T_s | Power hull | Efficiency hull | Reason | +|-----|-----------|-----------------|--------| +| 0.50 | CC/90 | opt_passive/90 | CC η undefined (linear_popt_invalid); opt_passive/90 best valid η | +| 0.75 | CC/90 | CC/45 | CC/90 maximises P; CC/45 maximises P/P_opt | +| 1.00 | CC/0 | CC/45 | CC/0 maximises P; CC/45 maximises P/P_opt | +| 1.25 | CC/0 | CC/10 | CC/0 maximises P; CC/10 maximises P/P_opt | +| 2.50 | CC/0 | opt_passive/90 | CC/0 still highest P; opt_passive/90 best η at this period | +| 4.25 | ff+PID/0 | ff+PID/10 | VGM-0 data masked; ff+PID/10 best valid η | +| 4.50–7.00 | opt_passive/0 | ff+PID/{10,20,45,90} | VGM-0 masked (P_opt undefined); efficiency hull excludes notch | + +Periods T = 1.5–2.25 s and T = 2.75–4.0 s agree on the winning (controller, flap) +combination — at resonance the same configuration maximises both raw power and +efficiency fraction simultaneously. + --- ## 5. Appendix: why fixed-passive was pruned (degenerate arm) @@ -149,5 +261,7 @@ Output files: - `analysis/three_regime/figures/three_regime_efficiency_VGM{0,10,20,45,90}.png` — efficiency - `analysis/three_regime/figures/three_regime_summary.png` — cross-flap power summary - `analysis/three_regime/figures/three_regime_efficiency_summary.png` — cross-flap efficiency -- `analysis/three_regime/figures/operating_envelope.png` — master co-design envelope -- `analysis/three_regime/operating_envelope.csv` — hull data for reproducibility +- `analysis/three_regime/figures/operating_envelope.png` — master power co-design envelope +- `analysis/three_regime/operating_envelope.csv` — power hull data for reproducibility +- `analysis/three_regime/figures/operating_envelope_efficiency.png` — master efficiency envelope +- `analysis/three_regime/operating_envelope_efficiency.csv` — efficiency hull (masked-respecting) diff --git a/analysis/three_regime/figures/operating_envelope.png b/analysis/three_regime/figures/operating_envelope.png index 5993a3c..053bdfb 100644 Binary files a/analysis/three_regime/figures/operating_envelope.png and b/analysis/three_regime/figures/operating_envelope.png differ diff --git a/analysis/three_regime/figures/operating_envelope_efficiency.png b/analysis/three_regime/figures/operating_envelope_efficiency.png new file mode 100644 index 0000000..a1f1156 Binary files /dev/null and b/analysis/three_regime/figures/operating_envelope_efficiency.png differ diff --git a/analysis/three_regime/figures/three_regime_VGM0.png b/analysis/three_regime/figures/three_regime_VGM0.png index 3cae437..af4fef5 100644 Binary files a/analysis/three_regime/figures/three_regime_VGM0.png and b/analysis/three_regime/figures/three_regime_VGM0.png differ diff --git a/analysis/three_regime/figures/three_regime_VGM10.png b/analysis/three_regime/figures/three_regime_VGM10.png index 682a668..5c2b6ee 100644 Binary files a/analysis/three_regime/figures/three_regime_VGM10.png and b/analysis/three_regime/figures/three_regime_VGM10.png differ diff --git a/analysis/three_regime/figures/three_regime_VGM20.png b/analysis/three_regime/figures/three_regime_VGM20.png index 364edca..aa10c28 100644 Binary files a/analysis/three_regime/figures/three_regime_VGM20.png and b/analysis/three_regime/figures/three_regime_VGM20.png differ diff --git a/analysis/three_regime/figures/three_regime_VGM45.png b/analysis/three_regime/figures/three_regime_VGM45.png index 978898d..7f24d4e 100644 Binary files a/analysis/three_regime/figures/three_regime_VGM45.png and b/analysis/three_regime/figures/three_regime_VGM45.png differ diff --git a/analysis/three_regime/figures/three_regime_VGM90.png b/analysis/three_regime/figures/three_regime_VGM90.png index b37f4d1..a0c3349 100644 Binary files a/analysis/three_regime/figures/three_regime_VGM90.png and b/analysis/three_regime/figures/three_regime_VGM90.png differ diff --git a/analysis/three_regime/figures/three_regime_efficiency_VGM0.png b/analysis/three_regime/figures/three_regime_efficiency_VGM0.png index 50b461c..5dcfecf 100644 Binary files a/analysis/three_regime/figures/three_regime_efficiency_VGM0.png and b/analysis/three_regime/figures/three_regime_efficiency_VGM0.png differ diff --git a/analysis/three_regime/figures/three_regime_efficiency_VGM10.png b/analysis/three_regime/figures/three_regime_efficiency_VGM10.png index 87c27f5..c598c6c 100644 Binary files a/analysis/three_regime/figures/three_regime_efficiency_VGM10.png and b/analysis/three_regime/figures/three_regime_efficiency_VGM10.png differ diff --git a/analysis/three_regime/figures/three_regime_efficiency_VGM20.png b/analysis/three_regime/figures/three_regime_efficiency_VGM20.png index 0b5e7df..b15e63c 100644 Binary files a/analysis/three_regime/figures/three_regime_efficiency_VGM20.png and b/analysis/three_regime/figures/three_regime_efficiency_VGM20.png differ diff --git a/analysis/three_regime/figures/three_regime_efficiency_VGM45.png b/analysis/three_regime/figures/three_regime_efficiency_VGM45.png index 72e3a0d..d48e116 100644 Binary files a/analysis/three_regime/figures/three_regime_efficiency_VGM45.png and b/analysis/three_regime/figures/three_regime_efficiency_VGM45.png differ diff --git a/analysis/three_regime/figures/three_regime_efficiency_VGM90.png b/analysis/three_regime/figures/three_regime_efficiency_VGM90.png index d4b5ae5..a250321 100644 Binary files a/analysis/three_regime/figures/three_regime_efficiency_VGM90.png and b/analysis/three_regime/figures/three_regime_efficiency_VGM90.png differ diff --git a/analysis/three_regime/figures/three_regime_efficiency_summary.png b/analysis/three_regime/figures/three_regime_efficiency_summary.png index 7b15fa3..9c750bf 100644 Binary files a/analysis/three_regime/figures/three_regime_efficiency_summary.png and b/analysis/three_regime/figures/three_regime_efficiency_summary.png differ diff --git a/analysis/three_regime/figures/three_regime_summary.png b/analysis/three_regime/figures/three_regime_summary.png index 942e852..f4d427f 100644 Binary files a/analysis/three_regime/figures/three_regime_summary.png and b/analysis/three_regime/figures/three_regime_summary.png differ diff --git a/analysis/three_regime/operating_envelope.csv b/analysis/three_regime/operating_envelope.csv new file mode 100644 index 0000000..abc85d2 --- /dev/null +++ b/analysis/three_regime/operating_envelope.csv @@ -0,0 +1,28 @@ +T_s,P_max_W,controller,flap_angle +0.5,0.295784146,CC,90 +0.75,0.570754678,CC,90 +1.0,1.49704248,CC,0 +1.25,1.92976734,CC,0 +1.5,2.34265974,CC,0 +1.75,1.95747302,CC,0 +2.0,1.28885275,CC,0 +2.25,0.814868845,CC,0 +2.5,0.523790009,CC,0 +2.75,0.546724931,ff+PID,90 +3.0,0.631551176,ff+PID,45 +3.25,0.754681984,opt_passive,20 +3.5,0.772139065,opt_passive,10 +3.75,0.629116058,ff+PID,10 +4.0,0.511379833,ff+PID,10 +4.25,0.439413836,ff+PID,0 +4.5,0.653385679,opt_passive,0 +4.75,0.681379177,opt_passive,0 +5.0,0.592721708,opt_passive,0 +5.25,0.497688356,opt_passive,0 +5.5,0.419353512,opt_passive,0 +5.75,0.347946047,opt_passive,0 +6.0,0.289733358,opt_passive,0 +6.25,0.246003927,opt_passive,0 +6.5,0.206396684,opt_passive,0 +6.75,0.175729589,opt_passive,0 +7.0,0.147775693,opt_passive,0 diff --git a/analysis/three_regime/operating_envelope_efficiency.csv b/analysis/three_regime/operating_envelope_efficiency.csv new file mode 100644 index 0000000..0921b2f --- /dev/null +++ b/analysis/three_regime/operating_envelope_efficiency.csv @@ -0,0 +1,28 @@ +T_s,eta_max,controller,flap_angle,masked +0.5,0.823195154,opt_passive,90,false +0.75,0.991436222,CC,45,false +1.0,0.96226267,CC,45,false +1.25,0.90213049,CC,10,false +1.5,0.940346628,CC,0,false +1.75,0.635592214,CC,0,false +2.0,0.357279306,CC,0,false +2.25,0.200431279,CC,0,false +2.5,0.119178887,opt_passive,90,false +2.75,0.121130865,ff+PID,90,false +3.0,0.132745996,ff+PID,45,false +3.25,0.155738531,opt_passive,20,false +3.5,0.149381311,opt_passive,10,false +3.75,0.121304997,ff+PID,10,false +4.0,0.097417468,ff+PID,10,false +4.25,0.0798163886,ff+PID,10,false +4.5,0.0648338771,ff+PID,10,false +4.75,0.0542809319,ff+PID,10,false +5.0,0.0463015696,ff+PID,10,false +5.25,0.0315041994,ff+PID,20,false +5.5,0.0167128782,ff+PID,45,false +5.75,0.0150303651,ff+PID,45,false +6.0,0.0132251084,ff+PID,45,false +6.25,0.0121681897,ff+PID,45,false +6.5,0.00713564013,ff+PID,90,false +6.75,0.00658719205,ff+PID,90,false +7.0,0.00592806524,ff+PID,90,false diff --git a/docs/CONTROLLERS.md b/docs/CONTROLLERS.md index 075377f..3142e01 100644 --- a/docs/CONTROLLERS.md +++ b/docs/CONTROLLERS.md @@ -222,8 +222,17 @@ Output under `analysis/three_regime/figures/`: - `three_regime_efficiency_VGM{0,10,20,45,90}.png` — per-flap 3-way efficiency - `three_regime_summary.png` — cross-flap power summary - `three_regime_efficiency_summary.png` — cross-flap efficiency summary -- `operating_envelope.png` — master co-design envelope (upper hull) -- `../operating_envelope.csv` — hull data for reproducibility +- `operating_envelope.png` — master power co-design envelope (upper hull of P_capture) +- `../operating_envelope.csv` — power hull data for reproducibility +- `operating_envelope_efficiency.png` — master efficiency envelope (upper hull of η) +- `../operating_envelope_efficiency.csv` — efficiency hull data (masked/invalid excluded) + +**Efficiency hull masking rule:** A (controller, flap, T) point is excluded from the +efficiency hull if `masked == true`, `linear_popt_invalid == true`, η is NaN, or +η > 1 + ε. This correctly excludes the VGM-0 pitch-radiation notch (T ≥ 3.0 s, +`masked=true`, P_opt undefined) and the short-period `linear_popt_invalid` CC spike. +The power hull and efficiency hull select different (controller, flap) winners at +17 of 27 periods — see `analysis/FINDINGS_3REGIME.md` §4c for the divergence table. --- diff --git a/scripts/three_regime_comparison.py b/scripts/three_regime_comparison.py index d802216..35e4053 100644 --- a/scripts/three_regime_comparison.py +++ b/scripts/three_regime_comparison.py @@ -67,6 +67,9 @@ ETA_GT1_TOL = 1e-6 +# y-position (axis-transform fraction) for regime-band annotation labels +REGIME_LABEL_Y = 0.95 + JOURNAL_STYLE = { "font.family": "serif", "font.size": 10, @@ -697,18 +700,18 @@ def plot_operating_envelope( # Band: CC wins at short T cc_end = 2.0 ax.axvline(cc_end, color="tab:blue", linestyle=":", linewidth=1.0, alpha=0.7) - ax.text(T_min + (cc_end - T_min) / 2.0, 0.95, + ax.text(T_min + (cc_end - T_min) / 2.0, REGIME_LABEL_Y, "CC + best flap", transform=ax.get_xaxis_transform(), ha="center", fontsize=8, color="tab:blue", alpha=0.8) # Band: resonance humps res_end = 4.0 ax.axvline(res_end, color="tab:green", linestyle=":", linewidth=1.0, alpha=0.7) - ax.text(cc_end + (res_end - cc_end) / 2.0, 0.95, + ax.text(cc_end + (res_end - cc_end) / 2.0, REGIME_LABEL_Y, "opt_p / ff+PID + T₀-matched flap", transform=ax.get_xaxis_transform(), ha="center", fontsize=8, color="tab:green", alpha=0.8) - ax.text(res_end + (T_max - res_end) / 2.0, 0.95, + ax.text(res_end + (T_max - res_end) / 2.0, REGIME_LABEL_Y, "ff+PID + low-angle flap", transform=ax.get_xaxis_transform(), ha="center", fontsize=8, color="tab:orange", alpha=0.8) @@ -733,6 +736,199 @@ def plot_operating_envelope( print(f"[ok] wrote {out_png}") +# --------------------------------------------------------------------------- +# Efficiency operating-envelope (companion to power envelope) +# --------------------------------------------------------------------------- + +def _build_efficiency_envelope( + cc_map: dict[int, Path], + op_map: dict[int, Path], + fp_map: dict[int, Path], +) -> list[dict]: + """Compute per-period efficiency upper hull across all controllers and flap angles. + + Rules (CRITICAL — mask-respecting): + - Skip any (flap, controller, T) where: + * masked == True + * linear_popt_invalid == True (if column exists) + * eta is NaN / empty + * eta > 1 + ETA_GT1_TOL (inflated-efficiency / P_opt-undefined spike) + - If ALL candidates at a given T are masked/invalid, emit a row with + eta_max=NaN, controller="", flap_angle=-1, masked=True. + + Returns list of dicts: + T_s, eta_max, controller, flap_angle, masked + """ + # Collect T grid from all available CSVs + all_T_sets: list[np.ndarray] = [] + loaders = [(cc_map, _load_cc_csv), (op_map, _load_opt_passive_csv), + (fp_map, _load_ffpid_csv)] + for cmap, loader in loaders: + for path in cmap.values(): + if path.exists(): + rows = loader(path) + all_T_sets.append(np.array([r["T_s"] for r in rows])) + + if not all_T_sets: + return [] + + T_grid = np.unique(np.concatenate(all_T_sets)) + T_grid = np.round(T_grid, 6) + + hull: list[dict] = [] + for T in T_grid: + best_eta = float("-inf") + best_ctrl = "" + best_flap = -1 + any_valid = False + + for angle in FLAP_ANGLES: + for ctrl_name, cmap, loader, is_cc in [ + ("CC", cc_map, _load_cc_csv, True), + ("opt_passive", op_map, _load_opt_passive_csv, False), + ("ff+PID", fp_map, _load_ffpid_csv, False), + ]: + path = cmap.get(angle) + if path is None or not path.exists(): + continue + rows = loader(path) + for r in rows: + if abs(r["T_s"] - T) < 1e-6: + # Found the unique row for this T (rows are sorted; at most + # one row matches per T). break exits the row loop; the outer + # (angle, ctrl) loops continue to the next candidate. + if (r.get("masked", False) + or r.get("linear_popt_invalid", False)): + break + eta, invalid = _eta_valid(r, is_cc) + if invalid or not math.isfinite(eta): + break + any_valid = True + if eta > best_eta: + best_eta = eta + best_ctrl = ctrl_name + best_flap = angle + break + + if any_valid and best_flap >= 0: + hull.append({ + "T_s": float(T), + "eta_max": float(best_eta), + "controller": best_ctrl, + "flap_angle": best_flap, + "masked": False, + }) + else: + # All candidates masked/invalid at this T + hull.append({ + "T_s": float(T), + "eta_max": float("nan"), + "controller": "", + "flap_angle": -1, + "masked": True, + }) + + hull.sort(key=lambda d: d["T_s"]) + return hull + + +def _write_efficiency_envelope_csv(hull: list[dict], csv_path: Path) -> None: + csv_path.parent.mkdir(parents=True, exist_ok=True) + with csv_path.open("w", newline="") as fh: + writer = csv.DictWriter( + fh, fieldnames=["T_s", "eta_max", "controller", "flap_angle", "masked"] + ) + writer.writeheader() + for row in hull: + writer.writerow({ + "T_s": row["T_s"], + "eta_max": "" if (not math.isfinite(row["eta_max"])) else row["eta_max"], + "controller": row["controller"], + "flap_angle": row["flap_angle"] if row["flap_angle"] >= 0 else "", + "masked": str(row["masked"]).lower(), + }) + print(f"[ok] wrote {csv_path}") + + +def plot_operating_envelope_efficiency( + hull: list[dict], + out_png: Path, + efficiency_ceiling: float, +) -> None: + """Plot the efficiency operating hull (upper η envelope across all controller×flap combos).""" + valid = [d for d in hull if not d["masked"] and math.isfinite(d["eta_max"])] + all_T = np.array([d["T_s"] for d in hull], dtype=float) + masked_flags = np.array([d["masked"] for d in hull], dtype=bool) + + T_valid = np.array([d["T_s"] for d in valid], dtype=float) + eta_valid_arr = np.array([d["eta_max"] * 100.0 for d in valid], dtype=float) + + fig, ax = plt.subplots(figsize=(9.0, 5.5)) + + # Hatching for all-masked periods + _add_masked_spans(ax, all_T, masked_flags, label="Masked / P_opt undefined") + + if len(T_valid) > 0: + # Fill under the envelope + ax.fill_between(T_valid, 0.0, eta_valid_arr, alpha=0.12, color="0.3", zorder=0.3) + ax.plot(T_valid, eta_valid_arr, color="0.2", linewidth=2.2, zorder=4, + label="Efficiency envelope (upper hull)") + + # Overlay per-controller scatter with distinct colours + ctrl_colors = {"CC": "tab:blue", "opt_passive": "tab:green", "ff+PID": "tab:orange"} + ctrl_markers = {"CC": "o", "opt_passive": "s", "ff+PID": "^"} + + for d in valid: + ctrl = d["controller"] + ax.scatter(d["T_s"], d["eta_max"] * 100.0, + marker=ctrl_markers.get(ctrl, "x"), + color=ctrl_colors.get(ctrl, "gray"), + s=28, zorder=5) + + # Legend entries for controllers + for ctrl, color in ctrl_colors.items(): + ax.plot([], [], color=color, + marker=ctrl_markers.get(ctrl, "x"), + linestyle="", label=ctrl) + + # Annotate regime bands + T_min, T_max = float(all_T.min()), float(all_T.max()) + cc_end = 2.0 + res_end = 4.0 + ax.axvline(cc_end, color="tab:blue", linestyle=":", linewidth=1.0, alpha=0.7) + ax.axvline(res_end, color="tab:green", linestyle=":", linewidth=1.0, alpha=0.7) + ax.text(T_min + (cc_end - T_min) / 2.0, REGIME_LABEL_Y, + "CC + best flap", transform=ax.get_xaxis_transform(), + ha="center", fontsize=8, color="tab:blue", alpha=0.8) + ax.text(cc_end + (res_end - cc_end) / 2.0, REGIME_LABEL_Y, + "opt_passive / ff+PID + T₀-matched flap", + transform=ax.get_xaxis_transform(), + ha="center", fontsize=8, color="tab:green", alpha=0.8) + ax.text(res_end + (T_max - res_end) / 2.0, REGIME_LABEL_Y, + "ff+PID + low-angle flap", + transform=ax.get_xaxis_transform(), + ha="center", fontsize=8, color="tab:orange", alpha=0.8) + + ax.set_xlabel("Wave period $T$ [s]") + ax.set_ylabel("Best achievable capture efficiency η [%]") + ax.set_title( + "VGOSWEC master efficiency operating envelope\n" + "Upper hull: best η (controller, flap angle) at every period — masked/invalid excluded" + ) + _style_period_axis(ax) + _style_efficiency_axis(ax) + _style_common(ax) + ax.set_xlim(float(all_T.min()) - 0.1, float(all_T.max()) + 0.1) + ax.set_ylim(0.0, efficiency_ceiling) + ax.legend(loc="upper right", fontsize=8) + + fig.tight_layout() + out_png.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out_png) + plt.close(fig) + print(f"[ok] wrote {out_png}") + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -817,13 +1013,22 @@ def main() -> int: plot_summary_efficiency(cc_present, op_present, fp_present, out_dir / "three_regime_efficiency_summary.png", efficiency_ceiling) - # Master operating envelope (Task 4) + # Master operating envelope — power hull (Task 4) hull = _build_envelope(cc_present, op_present, fp_present) if hull: hull_csv = repo / "analysis" / "three_regime" / "operating_envelope.csv" _write_envelope_csv(hull, hull_csv) plot_operating_envelope(hull, out_dir / "operating_envelope.png", power_ceiling) + # Master operating envelope — efficiency hull (companion) + eff_hull = _build_efficiency_envelope(cc_present, op_present, fp_present) + if eff_hull: + eff_hull_csv = repo / "analysis" / "three_regime" / "operating_envelope_efficiency.csv" + _write_efficiency_envelope_csv(eff_hull, eff_hull_csv) + plot_operating_envelope_efficiency( + eff_hull, out_dir / "operating_envelope_efficiency.png", efficiency_ceiling + ) + return 0