Source code for turbo_moc.plotting_plotly

"""
turbo_moc.plotting_plotly -- same functions as turbo_moc.plotting_mpl, returning
go.Figure instead of matplotlib axes. Meant for the Dash app (turbodash's
own app.py consumes plotly.graph_objects figures the same way, built from
its plotting_plotly.py) -- a callback calls turbo_moc.design_nozzle(...) once,
then these functions to build the figures it returns.

Same inputs as plotting_mpl: the `data` dict from design_nozzle() /
MOCSolver.build_result_data(). No solver/UI objects, no state.

Every function also accepts an optional `reference=<second data dict>` to
overlay a second design for comparison (same color, dashed/lighter) -- see
plotting_mpl's module docstring. Used by turbo_moc.app's "pin as reference"
workflow, but standalone too.
"""

import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import jaxprop as jxp

from turbo_moc._phase_diagram import get_saturation_dome
from turbo_moc.geometry.annular import wrap_blade_annular

# Curated palette (not matplotlib's default tab10 rainbow): a dark
# navy/slate for the solved geometry itself, a cool blue vs. warm coral
# pairing for the two characteristic families (and the pressure/Mach
# property plots, kept on the same cool/warm split for visual consistency
# across all five Phase 1 views), and a muted teal for the sonic line so it
# reads as a third, distinct quantity rather than competing with either
# characteristic family.
COLOR_WALL = "#1b1b1b"
COLOR_PRESSURE = "#ee6c4d"
COLOR_MACH = "#3d5a80"
COLOR_C_PLUS = "#3d5a80"
COLOR_C_MINUS = "#ee6c4d"
COLOR_SONIC = "#2a9d8f"
COLOR_KERNEL = "#6c757d"
COLOR_CP_LINE = "#293241"  # dark navy -- blade control-point connector
COLOR_CP_FACE = "#ee6c4d"  # coral -- blade control-point marker fill

__all__ = [
    "plot_nozzle_contour", "plot_characteristics_mesh",
    "plot_axis_properties", "plot_wall_properties", "plot_ts_diagram",
    "plot_blade", "plot_rotor_vortex_blade", "plot_radial_cascade",
    "plot_meridional_view", "plot_annular_blade_3d",
]


def _apply_journal_style(fig):
    """Applied at the end of every plot function here, so the interactive
    Plotly figure (used for the app's zoom/pan-able dcc.Graph display)
    actually reproduces jaxprop.set_plot_options()'s rcParams -- the same
    call turbo_moc.plotting_mpl makes for the matplotlib/download half of this
    split -- rather than Plotly's own default template. Values below are
    copied straight out of jxp.set_plot_options()'s rcParams dict (serif/
    Times New Roman, inward major+minor ticks, opaque mid-gray grid,
    1.25px axis border) so the two renderers of the same data read as one
    visual language, not just "both roughly boxed with gridlines"."""
    fig.update_layout(
        template="plotly_white",
        font=dict(family="Times New Roman, Times, serif", size=14),
        legend=dict(bordercolor="black", borderwidth=1, bgcolor="white", font_size=12),
        margin=dict(l=60, r=30, t=30, b=50),
        plot_bgcolor="white",
        paper_bgcolor="white",
    )
    axis_style = dict(
        showline=True, linewidth=1.25, linecolor="black", mirror=True,
        gridcolor="#808080", gridwidth=0.5,
        zeroline=False,
        ticks="inside", tickwidth=1.25, ticklen=6,
        minor=dict(ticks="inside", ticklen=3, tickwidth=0.75, showgrid=False),
    )
    fig.update_xaxes(**axis_style)
    fig.update_yaxes(**axis_style)
    return fig


def _mirror(y):
    return [-v for v in y]


[docs] def plot_nozzle_contour(data, mirror=True, show_sauer=True, show_kernel=True, reference=None, label="Current", reference_label="Reference"): fig = go.Figure() def _draw(d, dash, opacity, lbl): wall = d["wall_final"] fig.add_trace(go.Scatter(x=wall["x"], y=wall["y"], mode="lines", line=dict(color=COLOR_WALL, width=2, dash=dash), opacity=opacity, name=lbl)) if mirror: fig.add_trace(go.Scatter(x=wall["x"], y=_mirror(wall["y"]), mode="lines", line=dict(color=COLOR_WALL, width=2, dash=dash), opacity=opacity, showlegend=False)) # Convergent inlet (see turbo_moc.core.classes.build_convergent_inlet): # geometry only, computed live in app.py and stashed onto a COPY # of `data` under this key -- not part of wall_final itself so # the wall-property plots (which need p/M/rho/T per point) are # unaffected. conv = d.get("convergent_wall") if conv and conv.get("x"): fig.add_trace(go.Scatter(x=conv["x"], y=conv["y"], mode="lines", line=dict(color=COLOR_WALL, width=2, dash=dash), opacity=opacity, showlegend=False)) if mirror: fig.add_trace(go.Scatter(x=conv["x"], y=_mirror(conv["y"]), mode="lines", line=dict(color=COLOR_WALL, width=2, dash=dash), opacity=opacity, showlegend=False)) if show_sauer and d.get("sauer", {}).get("x"): sauer = d["sauer"] fig.add_trace(go.Scatter(x=sauer["x"], y=sauer["y"], mode="markers", marker=dict(color=COLOR_SONIC, size=5), opacity=opacity, showlegend=False)) if mirror: fig.add_trace(go.Scatter(x=sauer["x"], y=_mirror(sauer["y"]), mode="markers", marker=dict(color=COLOR_SONIC, size=5), opacity=opacity, showlegend=False)) if show_kernel and d.get("kernel_wall", {}).get("x"): kernel = d["kernel_wall"] fig.add_trace(go.Scatter(x=kernel["x"], y=kernel["y"], mode="markers", marker=dict(color=COLOR_KERNEL, size=5), opacity=opacity, showlegend=False)) if mirror: fig.add_trace(go.Scatter(x=kernel["x"], y=_mirror(kernel["y"]), mode="markers", marker=dict(color=COLOR_KERNEL, size=5), opacity=opacity, showlegend=False)) if reference is not None: _draw(reference, "dash", 0.5, reference_label) _draw(data, "solid", 1.0, label if reference is not None else "Wall") if mirror: fig.add_hline(y=0, line=dict(color="gray", width=1, dash="dash")) fig.update_layout(xaxis_title="x (m)", yaxis_title="y (m)", yaxis=dict(scaleanchor="x", scaleratio=1)) return _apply_journal_style(fig)
[docs] def plot_characteristics_mesh(data, mirror=True, show_wall=True, show_kernel=True, show_fronts=True, show_sauer=True, show_reflex=True, reference=None, show_reference_mesh=False, label="Current", reference_label="Reference"): """The nozzle contour (wall + convergent inlet) together with the full characteristic grid -- merges what used to be two separate plots (plot_nozzle_contour + plot_characteristics_mesh) into one view, since the mesh is drawn directly on top of the geometry it was solved from anyway. See below for why stage*_fronts (not just stage*_c_plus/minus/stage3_mesh) are needed to actually look like a grid rather than a one-directional fan, and why the reflex-zone connectors are reconstructed from IVP/div_wall rather than read from mesh_data. reference=<data dict> overlays a second design's WALL only by default (dashed); its full characteristic mesh is skipped unless show_reference_mesh=True (busy fast otherwise).""" fig = go.Figure() def _draw_segments(segments, color, lbl, width=1, opacity=0.7, showlegend=True): first = True for xs, ys in segments: fig.add_trace(go.Scatter( x=xs, y=ys, mode="lines", line=dict(color=color, width=width), opacity=opacity, name=lbl, showlegend=(first and showlegend), legendgroup=lbl, )) if mirror: fig.add_trace(go.Scatter( x=xs, y=_mirror(ys), mode="lines", line=dict(color=color, width=width), opacity=opacity, showlegend=False, legendgroup=lbl, )) first = False def _draw_mesh(d, opacity_scale=1.0, showlegend=True): mesh = d.get("mesh_data", {}) _draw_segments(mesh.get("stage2_c_plus", []), COLOR_C_PLUS, "Stage 2 (C+)", opacity=0.7 * opacity_scale, showlegend=showlegend) _draw_segments(mesh.get("stage2_c_minus", []), COLOR_C_MINUS, "Stage 2 (C-)", opacity=0.7 * opacity_scale, showlegend=showlegend) _draw_segments(mesh.get("stage3_mesh", []), COLOR_C_PLUS, "Stage 3", opacity=0.7 * opacity_scale, showlegend=showlegend) if show_fronts: _draw_segments(mesh.get("stage2_fronts", []), COLOR_C_MINUS, "Stage 2 front", opacity=0.7 * opacity_scale, showlegend=showlegend) _draw_segments(mesh.get("stage3_fronts", []), COLOR_C_MINUS, "Stage 3 front", opacity=0.7 * opacity_scale, showlegend=showlegend) if show_reflex and d.get("IVP", {}).get("x") and d.get("div_wall", {}).get("x"): ivp, div_wall = d["IVP"], d["div_wall"] first = True for xi, yi, xd, yd in zip(ivp["x"], ivp["y"], div_wall["x"], div_wall["y"]): fig.add_trace(go.Scatter(x=[xi, xd], y=[yi, yd], mode="lines", line=dict(color=COLOR_C_MINUS, width=1), opacity=0.7 * opacity_scale, name="Reflex zone", showlegend=(first and showlegend), legendgroup="reflex")) if mirror: fig.add_trace(go.Scatter(x=[xi, xd], y=[-yi, -yd], mode="lines", line=dict(color=COLOR_C_MINUS, width=1), opacity=0.7 * opacity_scale, showlegend=False, legendgroup="reflex")) first = False fig.add_trace(go.Scatter(x=div_wall["x"], y=div_wall["y"], mode="markers", marker=dict(color=COLOR_WALL, size=5), opacity=opacity_scale, showlegend=False)) if mirror: fig.add_trace(go.Scatter(x=div_wall["x"], y=_mirror(div_wall["y"]), mode="markers", marker=dict(color=COLOR_WALL, size=5), opacity=opacity_scale, showlegend=False)) if show_sauer and d.get("sauer", {}).get("x"): sauer = d["sauer"] fig.add_trace(go.Scatter(x=sauer["x"], y=sauer["y"], mode="lines+markers", line=dict(color=COLOR_SONIC, width=2), marker=dict(size=4), opacity=opacity_scale, name="Sonic line", showlegend=showlegend)) if mirror: fig.add_trace(go.Scatter(x=sauer["x"], y=_mirror(sauer["y"]), mode="lines+markers", line=dict(color=COLOR_SONIC, width=2), marker=dict(size=4), opacity=opacity_scale, showlegend=False)) if show_kernel and d.get("kernel_wall", {}).get("x"): kernel = d["kernel_wall"] fig.add_trace(go.Scatter(x=kernel["x"], y=kernel["y"], mode="markers", marker=dict(color=COLOR_KERNEL, size=5), opacity=opacity_scale, showlegend=False)) if mirror: fig.add_trace(go.Scatter(x=kernel["x"], y=_mirror(kernel["y"]), mode="markers", marker=dict(color=COLOR_KERNEL, size=5), opacity=opacity_scale, showlegend=False)) def _draw_wall(d, dash, opacity, lbl): wall = d["wall_final"] fig.add_trace(go.Scatter(x=wall["x"], y=wall["y"], mode="lines", line=dict(color=COLOR_WALL, width=2, dash=dash), opacity=opacity, name=lbl)) if mirror: fig.add_trace(go.Scatter(x=wall["x"], y=_mirror(wall["y"]), mode="lines", line=dict(color=COLOR_WALL, width=2, dash=dash), opacity=opacity, showlegend=False)) # Convergent inlet (see turbo_moc.core.classes.build_convergent_inlet): # geometry only, computed live in app.py and stashed onto a COPY # of `data` under this key -- not part of wall_final itself so the # wall-property plots (which need p/M/rho/T per point) are # unaffected. conv = d.get("convergent_wall") if conv and conv.get("x"): fig.add_trace(go.Scatter(x=conv["x"], y=conv["y"], mode="lines", line=dict(color=COLOR_WALL, width=2, dash=dash), opacity=opacity, showlegend=False)) if mirror: fig.add_trace(go.Scatter(x=conv["x"], y=_mirror(conv["y"]), mode="lines", line=dict(color=COLOR_WALL, width=2, dash=dash), opacity=opacity, showlegend=False)) if reference is not None: if show_reference_mesh: _draw_mesh(reference, opacity_scale=0.4, showlegend=False) _draw_wall(reference, "dash", 0.5, reference_label) _draw_mesh(data) if show_wall: _draw_wall(data, "solid", 1.0, label if reference is not None else "Wall") if mirror: fig.add_hline(y=0, line=dict(color="gray", width=1, dash="dash")) fig.update_layout(xaxis_title="x (m)", yaxis_title="y (m)", yaxis=dict(scaleanchor="x", scaleratio=1)) return _apply_journal_style(fig)
def _property_vs_x(x, p, M, xlabel, p_label, m_label, ref_x=None, ref_p=None, ref_M=None, label="Current", reference_label="Reference"): fig = make_subplots(specs=[[{"secondary_y": True}]]) show_legend = ref_x is not None if show_legend: fig.add_trace(go.Scatter(x=ref_x, y=[v / 1e5 for v in ref_p], mode="lines", line=dict(color=COLOR_PRESSURE, width=2, dash="dash"), opacity=0.5, name=f"{reference_label} P", showlegend=False), secondary_y=False) fig.add_trace(go.Scatter(x=ref_x, y=ref_M, mode="lines", line=dict(color=COLOR_MACH, width=2, dash="dash"), opacity=0.5, name=f"{reference_label} M", showlegend=False), secondary_y=True) fig.add_trace(go.Scatter(x=[None], y=[None], mode="lines", line=dict(color="gray", width=2), name=label)) fig.add_trace(go.Scatter(x=[None], y=[None], mode="lines", line=dict(color="gray", width=2, dash="dash"), name=reference_label)) fig.add_trace(go.Scatter(x=x, y=[v / 1e5 for v in p], mode="lines", line=dict(color=COLOR_PRESSURE, width=2), name=p_label, showlegend=not show_legend), secondary_y=False) fig.add_trace(go.Scatter(x=x, y=M, mode="lines", line=dict(color=COLOR_MACH, width=2), name=m_label, showlegend=not show_legend), secondary_y=True) fig.update_xaxes(title_text=xlabel) fig.update_yaxes(title_text=p_label, color=COLOR_PRESSURE, secondary_y=False) fig.update_yaxes(title_text=m_label, color=COLOR_MACH, secondary_y=True) return _apply_journal_style(fig) def _extend_axis_to_exit(data): """axis_points_vec (and hence data['axis']) stops accumulating at the end of Stage 3 (the kernel region) -- Stage 4 (the reflex/turning region) never appends to it, because past the kernel no more expansion waves reach the centerline, so the axis P/M are already at their final (exit) values by then; the classical MOC construction doesn't need to track the axis any further. So the true P/M on the centerline really IS flat from the last kernel point to the nozzle exit -- this is exact physics, not a plotting approximation. Extends axis['x']/['p']/['M'] with one more point at the wall's own exit x, repeating the last axis p/M, so the plot's x-range visually matches plot_wall_properties (which does span the whole nozzle) instead of stopping short at the kernel/turning-region boundary.""" axis = data["axis"] exit_x = data["wall_final"]["x"][-1] if not axis["x"] or exit_x <= axis["x"][-1]: return axis["x"], axis["p"], axis["M"] return axis["x"] + [exit_x], axis["p"] + [axis["p"][-1]], axis["M"] + [axis["M"][-1]]
[docs] def plot_axis_properties(data, reference=None, label="Current", reference_label="Reference"): """Pressure (left, orange) + Mach (right, green) vs axial distance along the centerline -- data['axis'], extended with a flat line out to the nozzle exit (see _extend_axis_to_exit). reference=<data dict> overlays a second design's axis properties (dashed, same extension).""" x, p, M = _extend_axis_to_exit(data) if reference is not None: ref_x, ref_p, ref_M = _extend_axis_to_exit(reference) else: ref_x = ref_p = ref_M = None return _property_vs_x(x, p, M, "x (m)", "P (bar)", "M (-)", ref_x=ref_x, ref_p=ref_p, ref_M=ref_M, label=label, reference_label=reference_label)
[docs] def plot_wall_properties(data, reference=None, label="Current", reference_label="Reference"): """Pressure (left, orange) + Mach (right, green) vs axial distance along the wall -- data['wall_final']. reference=<data dict> overlays a second design's wall properties (dashed).""" wall = data["wall_final"] ref = reference["wall_final"] if reference is not None else None return _property_vs_x(wall["x"], wall["p"], wall["M"], "x (m)", "P (bar)", "M (-)", ref_x=ref["x"] if ref else None, ref_p=ref["p"] if ref else None, ref_M=ref["M"] if ref else None, label=label, reference_label=reference_label)
[docs] def plot_ts_diagram(data, show_dome=True, reference=None, label="Current", reference_label="Reference"): """T-s diagram -- see plotting_mpl.plot_ts_diagram for the exact meaning of the three highlighted points (Total, MoC start, Outlet) and why only MoC-start-to-Outlet gets a connecting line.""" fig = go.Figure() if show_dome: dome = get_saturation_dome(data["fluid_name"]) fig.add_trace(go.Scatter(x=dome["liq_s"], y=dome["liq_T"], mode="lines", line=dict(color="gray", width=1), showlegend=False)) fig.add_trace(go.Scatter(x=dome["vap_s"], y=dome["vap_T"], mode="lines", line=dict(color="gray", width=1), showlegend=False)) fig.add_trace(go.Scatter(x=[dome["crit_s"]], y=[dome["crit_T"]], mode="markers", marker=dict(color="gray", size=6), showlegend=False)) def _draw(d, opacity, dash, suffix): s_total, T_total = d["s0"], d["T0_stagnation"] s_start, T_start = d["axis"]["s"][0], d["axis"]["T"][0] s_out, T_out = d["axis"]["s"][-1], d["axis"]["T"][-1] fig.add_trace(go.Scatter(x=[s_start, s_out], y=[T_start, T_out], mode="lines", line=dict(color=COLOR_MACH, width=2, dash=dash), opacity=opacity, showlegend=False)) fig.add_trace(go.Scatter(x=[s_total], y=[T_total], mode="markers", marker=dict(color=COLOR_WALL, size=11, symbol="square"), opacity=opacity, name=f"Total{suffix}")) fig.add_trace(go.Scatter(x=[s_start], y=[T_start], mode="markers", marker=dict(color=COLOR_C_PLUS, size=11, symbol="circle"), opacity=opacity, name=f"MoC start{suffix}")) fig.add_trace(go.Scatter(x=[s_out], y=[T_out], mode="markers", marker=dict(color=COLOR_PRESSURE, size=11, symbol="triangle-up"), opacity=opacity, name=f"Outlet{suffix}")) if reference is not None: _draw(reference, 0.5, "dash", f" ({reference_label})") _draw(data, 1.0, "solid", f" ({label})" if reference is not None else "") fig.update_layout(xaxis_title="s (J/kg/K)", yaxis_title="T (K)") return _apply_journal_style(fig)
[docs] def plot_blade(blade_data, n_blades=2, show_control_points=True, show_cp_labels=True, show_trailing_edge=True, highlight_cp=None, reference=None, label="Current", reference_label="Reference"): """Cascade view of the fitted blade profile -- see plotting_mpl. plot_blade for the ported script's mm units, the reference-overlay semantics shared with the rest of this module, and what highlight_cp/ show_cp_labels are for.""" fig = go.Figure() all_x, all_y = [], [] def _draw(d, dash, opacity, lbl): pitch = d["pitch"] curve = d["blade_curve"] te = d["trailing_edge"] cps = d["control_points"] for i in range(n_blades): dx = i * pitch curve_x = [v + dx for v in curve["x"]] all_x.extend(curve_x) all_y.extend(curve["y"]) fig.add_trace(go.Scatter( x=curve_x, y=curve["y"], mode="lines", line=dict(color=COLOR_WALL, width=2, dash=dash), opacity=opacity, name=lbl, showlegend=(i == 0), )) if show_trailing_edge: te_x = [v + dx for v in te["x"]] all_x.extend(te_x) all_y.extend(te["y"]) fig.add_trace(go.Scatter( x=te_x, y=te["y"], mode="lines", line=dict(color=COLOR_C_PLUS, width=1.5), opacity=opacity, showlegend=False, )) if show_control_points and i == 0: cp_x = [p[0] + dx for p in cps] cp_y = [p[1] for p in cps] all_x.extend(cp_x) all_y.extend(cp_y) fig.add_trace(go.Scatter( x=cp_x, y=cp_y, mode="lines+markers", line=dict(color=COLOR_CP_LINE, width=1.2, dash="dash"), marker=dict(size=10, color=COLOR_CP_FACE, line=dict(color="black", width=1.2)), opacity=0.85 * opacity, name="Control points" if lbl != reference_label else None, showlegend=(lbl != reference_label), )) if reference is not None: _draw(reference, "dash", 0.5, reference_label) _draw(blade_data, "solid", 1.0, label if reference is not None else "Blade profile") if show_control_points and show_cp_labels: cps = blade_data["control_points"] fig.add_trace(go.Scatter( x=[p[0] for p in cps], y=[p[1] for p in cps], mode="text", text=[str(i) for i in range(len(cps))], textposition="top right", textfont=dict(size=11, color=COLOR_CP_LINE, family="DejaVu Sans, Arial, sans-serif"), showlegend=False, hoverinfo="skip", )) if show_control_points and highlight_cp is not None: cps = blade_data["control_points"] if 0 <= highlight_cp < len(cps): fig.add_trace(go.Scatter( x=[cps[highlight_cp][0]], y=[cps[highlight_cp][1]], mode="markers", marker=dict(size=22, color="#ffe600", symbol="star", line=dict(color="#d81b1b", width=2)), name=f"Selected CP {highlight_cp}", )) # Explicit, data-derived ranges on BOTH axes (with padding), plus a # fixed figure height -- leaving either axis on autorange while # scaleanchor="x"/scaleratio=1 is set means Plotly.js recomputes that # axis's range relative to whatever it was left at on the previous # Dash re-render (no full reset in between), so it compounds a little # every recompute instead of settling -- pinning both numerically, and # fixing the container height so it can't join the feedback loop, # makes every render fully self-contained. x_min, x_max = min(all_x), max(all_x) y_min, y_max = min(all_y), max(all_y) x_span = x_max - x_min if x_max > x_min else 1.0 y_span = y_max - y_min if y_max > y_min else 1.0 x_pad = 0.05 * x_span y_pad = 0.05 * y_span fig.update_layout( xaxis_title="x (mm)", yaxis_title="y (mm)", xaxis=dict(range=[x_min - x_pad, x_max + x_pad]), yaxis=dict(range=[y_min - y_pad, y_max + y_pad], scaleanchor="x", scaleratio=1), height=550, ) return _apply_journal_style(fig)
COLOR_BLADE_FILL = "#8c8c8c"
[docs] def plot_rotor_vortex_blade(data, show_surfaces=True, show_mach_lines=False, reference=None, label="Current", reference_label="Reference"): """Vortex-flow rotor blade (turbo_moc.rotor.design_rotor_vortex_blade, NASA TN D-4421/Goldman & Scullin 1968) -- see notes/rotor_vortex_blade.md for what `pressure`, `suction`, `blade`, and the Mach-line segments actually are; NOT the same construction as plot_blade (Phase 2's nozzle-derived stator blade) even though both return a `pitch`. show_surfaces overlays the raw pressure/suction transition-arc + circular-arc curves (thin, semi-transparent) on top of the filled blade polygon -- useful for sanity-checking the closure against the surfaces it was built from. show_mach_lines overlays the recorded characteristic segments from the marching recursion (thin, faint). """ fig = go.Figure() def _draw(d, dash, opacity, lbl): blade = d["blade"] fig.add_trace(go.Scatter( x=blade["x"], y=blade["y"], mode="lines", fill="toself", line=dict(color="black", width=1.5, dash=dash), fillcolor=COLOR_BLADE_FILL, opacity=0.55 * opacity if dash == "solid" else 0.25 * opacity, name=lbl or "Blade", showlegend=bool(lbl), )) if show_surfaces: for surf_key, color in (("pressure", COLOR_PRESSURE), ("suction", COLOR_MACH)): surf = d[surf_key] fig.add_trace(go.Scatter( x=surf["x"], y=surf["y"], mode="lines", line=dict(color=color, width=1.5, dash=dash), opacity=0.7 * opacity, name=f"{lbl + ' ' if lbl else ''}{surf_key}", showlegend=True, )) if show_mach_lines: for surf_key in ("pressure", "suction"): surf = d[surf_key] xs, ys = [], [] for x0, y0, x1, y1 in surf["mach_lines_inlet"] + surf["mach_lines_outlet"]: xs.extend([x0, x1, None]) ys.extend([y0, y1, None]) fig.add_trace(go.Scatter( x=xs, y=ys, mode="lines", line=dict(color="#999999", width=0.5), opacity=0.6 * opacity, showlegend=False, hoverinfo="skip", )) if reference is not None: _draw(reference, "dash", 0.6, reference_label) _draw(data, "solid", 1.0, label if reference is not None else "") units = data.get("units", "r*") fig.update_layout( xaxis_title=f"x ({units})", yaxis_title=f"y ({units})", yaxis=dict(scaleanchor="x", scaleratio=1), height=550, ) return _apply_journal_style(fig)
COLOR_RADIAL_STATOR = "#7e03a8" # plasma colormap, ~25% stop (purple) COLOR_RADIAL_ROTOR = "#f89441" # plasma colormap, ~75% stop (orange) def _draw_radial_set(fig, blades, trailing_edges, line_color, fillcolor, name): """One source's (stator's or rotor's) blade copies onto an existing annular-cascade figure. fillcolor=None draws outline-only (single- source mode); a fillcolor draws a translucent fill (combined mode, so the two sources read apart at a glance -- same convention as Phase 4's stator/rotor cascade overlay).""" for i, (blade, te) in enumerate(zip(blades, trailing_edges)): fig.add_trace(go.Scatter( x=blade["x"], y=blade["y"], mode="lines", fill="toself" if fillcolor else None, line=dict(color=line_color, width=1.5 if fillcolor is None else 1.2), fillcolor=fillcolor, opacity=0.85 if fillcolor is None else 0.35, name=name, showlegend=(i == 0), )) if te["x"]: fig.add_trace(go.Scatter( x=te["x"], y=te["y"], mode="lines", line=dict(color=COLOR_C_PLUS, width=1.2), opacity=0.85, showlegend=False, ))
[docs] def plot_radial_cascade(radial_data, show_radius_circles=True, clip_quadrant=True): """Full annular cascade view of turbo_moc.geometry.wrap_blade_radial(...) / wrap_rotor_blade_radial(...) results: every blade copy plus its trailing edge, laid out around the annulus, with the r1/r2 bounding circles as a visual reference. Also accepts a "combined" dict (radial_data["mode"]=="combined", with "stator"/"rotor" sub-dicts, as built by turbo_moc.app's Phase 5 "Both" source) to overlay the two rows the same way Phase 4 overlays the unrolled cascade.""" fig = go.Figure() combined = radial_data.get("mode") == "combined" if combined: stator, rotor = radial_data["stator"], radial_data["rotor"] _draw_radial_set(fig, stator["blades"], stator["trailing_edges"], COLOR_RADIAL_STATOR, COLOR_RADIAL_STATOR, "Stator") _draw_radial_set(fig, rotor["blades"], rotor["trailing_edges"], "black", COLOR_RADIAL_ROTOR, "Rotor") n_label = f"stator={stator['n_blades']}, rotor={rotor['n_blades']}" else: _draw_radial_set(fig, radial_data["blades"], radial_data["trailing_edges"], COLOR_WALL, None, "Blade") n_label = str(radial_data["n_blades"]) r1, r2 = radial_data["r1"], radial_data["r2"] if show_radius_circles: t = np.linspace(0, 2 * np.pi, 200) circles = [(r1, "r1")] if combined: # The interface/gap-start radii too, so the row-to-row gap # band is visible as the space between two of these circles # (r_stator_out and r_rotor_in coincide when gap=0). circles.append((radial_data["r_stator_out"], "r_interface (stator out)")) circles.append((radial_data["r_rotor_in"], "r_rotor_in")) circles.append((r2, "r2")) for r, lbl in circles: fig.add_trace(go.Scatter( x=r * np.cos(t), y=r * np.sin(t), mode="lines", line=dict(color="#999999", width=1, dash="dot"), name=lbl, showlegend=False, hoverinfo="skip", )) units = radial_data.get("units", "mm") axis_kwargs = {} if clip_quadrant: # Full annulus is drawn (all blade copies + full r1/r2 circles), # but the view is windowed to quadrant I (x, y >= 0) -- a single # blade passage says as much as the full ring without the wrap- # around clutter. r_pad = 1.05 * r2 axis_kwargs = dict(range=[0, r_pad]) # _apply_journal_style sets its own generic margin -- must run before # the layout below, not after, or it clobbers these values (see # plot_meridional_view's own note on the same issue). _apply_journal_style(fig) fig.update_layout( showlegend=False, xaxis=dict(title_text=f"x ({units})", **axis_kwargs), yaxis=dict(title_text=f"y ({units})", scaleanchor="x", scaleratio=1, **axis_kwargs), height=380, margin=dict(l=50, r=20, t=20, b=40), ) return fig
[docs] def plot_meridional_view(meridional_data, units="mm", z_range=None): """Meridional (axial-radial) sketch of a stator + rotor stage from turbo_moc.geometry.build_meridional_view(...): hub/tip lines for each row (straight, per the shared-axial-station flare convention -- see that function's docstring), with the passage between them filled. Coordinate convention (shared with the cascade view in app.py's update_cascade_plot): z = axial/flow direction, r = radius. Drawn rotated 90 deg from the raw (x=axial, y=radius) data -- r horizontal, z vertical -- by swapping which raw coordinate feeds which axis (same "rotate, then re-zero so it reads forward from 0" treatment as the cascade plot's own _rotate_traces_display, rather than a literal matrix rotation that would also flip the flow-reading direction).""" fig = go.Figure() def _draw_row(row, color, name): z, hub, tip = row["x"], row["hub"], row["tip"] poly_z, poly_r = z + z[::-1], hub + tip[::-1] fig.add_trace(go.Scatter( x=poly_r, y=poly_z, mode="lines", fill="toself", line=dict(color=color, width=0), fillcolor=color, opacity=0.25, name=name, showlegend=True, hoverinfo="skip", )) fig.add_trace(go.Scatter( x=hub, y=z, mode="lines+markers", line=dict(color=color, width=2), marker=dict(size=5), showlegend=False, )) fig.add_trace(go.Scatter( x=tip, y=z, mode="lines+markers", line=dict(color=color, width=2, dash="dash"), marker=dict(size=5), showlegend=False, )) _draw_row(meridional_data["stator"], COLOR_RADIAL_STATOR, "Stator") _draw_row(meridional_data["rotor"], COLOR_RADIAL_ROTOR, "Rotor") # A further 180 deg turn on top of the 90 deg already applied above # ((x, y) -> (-x, -y) on the r-horizontal/z-vertical layout drawn so # far) -- then re-zero both axes so the plot starts at 0 rather than # showing whitespace back to an arbitrary (now-negative) origin. for tr in fig.data: tr.x = [-xx for xx in tr.x] tr.y = [-yy for yy in tr.y] r_shift = -min(x for tr in fig.data for x in tr.x) z_shift = -min(y for tr in fig.data for y in tr.y) for tr in fig.data: tr.x = [xx + r_shift for xx in tr.x] tr.y = [yy + z_shift for yy in tr.y] yaxis = dict(title_text=f"z [{units}]") if z_range is not None: # Pinned to the cascade plot's own (already zero-based) z range -- # see app.py's cascade-zrange-store -- so the two figures share # the same z=0 reference and the same mm-per-pixel vertical scale # (same range + same container height = same scale) instead of # each autoscaling to its own data and drifting apart. yaxis["range"] = z_range yaxis["autorange"] = False # Fixed dtick (not autotick), matching the cascade plot's own # fixed z dtick, so gridlines land on the same values in both # figures rather than each picking its own step for the range. yaxis["dtick"] = 50 # _apply_journal_style sets its OWN generic margin -- must run before # the margin/height below, not after, otherwise it clobbers the exact # values matched to the cascade plot's own margin and breaks the # pixel-for-pixel alignment those two figures rely on. _apply_journal_style(fig) fig.update_layout( showlegend=False, # No scaleanchor/scaleratio lock here (unlike the cascade plot) -- # this view's r-span and z-span can differ a lot in magnitude, and # forcing a true 1:1 aspect would shrink the plotted area far below # its container (letterboxing), leaving it visibly shorter than # the cascade figure it sits beside. Autoscaling both axes to the # container instead keeps it the same height as its neighbor. xaxis=dict(title_text=f"r [{units}]"), yaxis=yaxis, height=380, margin=dict(l=50, r=20, t=20, b=40), ) return fig
def _quad_grid_triangles(n_span, n_pts, closed=True): """Triangle (i, j, k) index arrays for a structured (n_span, n_pts) point grid, flattened row-major (span-major). `closed` wraps the last profile point back to the first (the blade contour is a closed loop).""" i_idx, j_idx, k_idx = [], [], [] n_cols = n_pts if closed else n_pts - 1 for s in range(n_span - 1): for p in range(n_cols): p2 = (p + 1) % n_pts a, b = s * n_pts + p, s * n_pts + p2 c, d = (s + 1) * n_pts + p, (s + 1) * n_pts + p2 i_idx += [a, b] j_idx += [b, d] k_idx += [c, c] return i_idx, j_idx, k_idx def _add_annular_traces(fig, blade_data, r_hub, r_shroud, n_blades, flare, n_span, theta0, source, n_preview, z_offset, curve_color, te_color, legend_name): """Wraps ONE row (stator or rotor) via wrap_blade_annular(...) and adds its Mesh3d traces to fig -- factored out of plot_annular_blade_3d so a stator + rotor pair can share one figure/one scene, each its own color.""" wrapped = wrap_blade_annular(blade_data, r_hub, r_shroud, n_blades=n_blades, flare=flare, n_span=n_span, theta0=theta0, source=source, n_preview=n_preview) curve = wrapped["curve"] te = wrapped["trailing_edge"] n_span_actual = wrapped["n_span"] for b in range(wrapped["n_drawn"]): n_pts = curve["x"].shape[2] i_idx, j_idx, k_idx = _quad_grid_triangles(n_span_actual, n_pts, closed=(te is None)) fig.add_trace(go.Mesh3d( x=curve["x"][b].ravel(), y=curve["y"][b].ravel(), z=curve["z"][b].ravel() + z_offset, i=i_idx, j=j_idx, k=k_idx, color=curve_color, opacity=0.85, flatshading=False, name=legend_name if b == 0 else None, showlegend=(b == 0), lighting=dict(ambient=0.55, diffuse=0.7, specular=0.3, roughness=0.6), )) if te is not None: n_te = te["x"].shape[2] i_te, j_te, k_te = _quad_grid_triangles(n_span_actual, n_te, closed=False) fig.add_trace(go.Mesh3d( x=te["x"][b].ravel(), y=te["y"][b].ravel(), z=te["z"][b].ravel() + z_offset, i=i_te, j=j_te, k=k_te, color=te_color, opacity=0.85, flatshading=False, showlegend=False, )) return wrapped
[docs] def plot_annular_blade_3d(blade_data, r_hub, r_shroud, n_blades=1, flare="pitch_scale", n_span=13, theta0=0.0, source="stator", n_preview=None, rotor=None): """3D preview of turbo_moc.geometry.wrap_blade_annular(...): the blade profile genuinely wrapped circumferentially at every span station from r_hub to r_shroud (not the flat hub/tip loft of the "flared" STEP export), rendered as one go.Mesh3d surface per previewed blade copy. n_blades is the TRUE blade count (sets the angular spacing between copies); n_preview optionally caps how many of them are actually drawn, for a responsive preview, WITHOUT changing that spacing -- see wrap_blade_annular's docstring. rotor : dict or None Optional second row (source="rotor") added to the SAME figure/scene, e.g. from turbo_moc.app's rotor-store (already scaled to mm). Keys: "blade_data" (required), "r_hub"/"r_shroud" (required), "n_blades" (default 1), "flare" (default: same as the stator's own `flare`), "n_span"/"theta0"/"n_preview" (default: same as the stator's own), "z_offset" (default 0 -- the rotor's own axial start, e.g. the stator's axial chord plus a row-to-row gap, same convention as turbo_moc.geometry.build_meridional_view).""" fig = go.Figure() is_combined = rotor is not None _add_annular_traces( fig, blade_data, r_hub, r_shroud, n_blades, flare, n_span, theta0, source, n_preview, z_offset=0.0, curve_color=COLOR_RADIAL_STATOR if is_combined else COLOR_WALL, te_color=COLOR_C_PLUS, legend_name="Stator" if is_combined else "Blade", ) if is_combined: _add_annular_traces( fig, rotor["blade_data"], rotor["r_hub"], rotor["r_shroud"], rotor.get("n_blades", 1), rotor.get("flare", flare), rotor.get("n_span", n_span), rotor.get("theta0", theta0), "rotor", rotor.get("n_preview", n_preview), z_offset=rotor.get("z_offset", 0.0), curve_color=COLOR_RADIAL_ROTOR, te_color=COLOR_RADIAL_ROTOR, legend_name="Rotor", ) fig.update_layout( scene=dict( aspectmode="data", xaxis_title="X [mm]", yaxis_title="Y [mm]", zaxis_title="Z (axial) [mm]", ), margin=dict(l=0, r=0, t=30, b=0), showlegend=True, ) return fig