"""
turbo_moc.plotting_mpl -- matplotlib plots built from turbo_moc.design_nozzle's result
dict. Static/publication-quality output, matching the color/style conventions
already established across the NICFD_2026 postprocessing scripts (pressure
in orange, Mach in green, jaxprop.COLORS_PYTHON).
Mirrors turbodash's plotting_mpl.py/plotting_plotly.py split: this module
is the matplotlib half; turbo_moc.plotting_plotly provides the same functions
returning go.Figure instead, for the Dash app. Both take the SAME `data`
dict (design_nozzle's return value, or MOCSolver.build_result_data()) and
an optional existing axes/figure to draw into -- nothing here calls the
solver or knows about MOCSolver/FluidManager.
Every function also accepts an optional `reference=<second data dict>` to
overlay a second design for comparison (same color, dashed/lighter, to
distinguish it from the current one) -- e.g. pin one design, run another,
compare. Used by turbo_moc.app's "pin as reference" workflow, but standalone too.
"""
import matplotlib.pyplot as plt
import jaxprop as jxp
from turbo_moc._phase_diagram import get_fluid
# Publication-quality rcParams (fonts, boxed axes with ticks on all sides,
# gridlines, matlab color order) -- this is what plotting_plotly's
# _apply_journal_style is manually reproducing for the interactive figures,
# so both halves of the split read as one consistent visual language.
# Called explicitly here (not just relying on turbo_moc.solvers.conventional/mln
# already calling it as an import side effect) so this module's look is
# self-contained regardless of what else has been imported.
jxp.set_plot_options()
# Curated palette (not matplotlib's default tab10 rainbow) -- see
# turbo_moc.plotting_plotly's module-level comment for the rationale; both
# halves of the plotly/mpl split use the same colors so interactive display
# and downloaded figures read as one consistent visual language.
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_blade", "plot_rotor_vortex_blade", "plot_radial_cascade",
"plot_meridional_view",
]
def _mirror(y):
return [-v for v in y]
[docs]
def plot_nozzle_contour(data, ax=None, mirror=True, show_sauer=True, show_kernel=True,
reference=None, label="Current", reference_label="Reference"):
"""Wall contour (the actual nozzle shape). mirror=True reflects the
solved half across the axis for the full profile (this is a 2D
axisymmetric half-solve throughout turbo_moc, same convention as the rest of
the codebase). reference=<data dict> overlays a second design (dashed,
lighter) for comparison."""
if ax is None:
fig, ax = plt.subplots(figsize=(7, 4))
else:
fig = ax.figure
def _draw(d, ls, alpha, lbl):
wall = d["wall_final"]
ax.plot(wall["x"], wall["y"], ls, color=COLOR_WALL, lw=1.5, alpha=alpha, label=lbl)
if mirror:
ax.plot(wall["x"], _mirror(wall["y"]), ls, color=COLOR_WALL, lw=1.5, alpha=alpha)
conv = d.get("convergent_wall")
if conv and conv.get("x"):
ax.plot(conv["x"], conv["y"], ls, color=COLOR_WALL, lw=1.5, alpha=alpha)
if mirror:
ax.plot(conv["x"], _mirror(conv["y"]), ls, color=COLOR_WALL, lw=1.5, alpha=alpha)
if show_sauer and d.get("sauer", {}).get("x"):
sauer = d["sauer"]
ax.plot(sauer["x"], sauer["y"], "o", color=COLOR_SONIC, ms=3, alpha=alpha)
if mirror:
ax.plot(sauer["x"], _mirror(sauer["y"]), "o", color=COLOR_SONIC, ms=3, alpha=alpha)
if show_kernel and d.get("kernel_wall", {}).get("x"):
kernel = d["kernel_wall"]
ax.plot(kernel["x"], kernel["y"], "o", color=COLOR_KERNEL, ms=3, alpha=alpha)
if mirror:
ax.plot(kernel["x"], _mirror(kernel["y"]), "o", color=COLOR_KERNEL, ms=3, alpha=alpha)
if reference is not None:
_draw(reference, "--", 0.5, reference_label)
_draw(data, "-", 1.0, label if reference is not None else "Wall")
if mirror:
ax.axhline(0, color="gray", lw=0.8, ls="--", zorder=0)
ax.set_xlabel("x (m)")
ax.set_ylabel("y (m)")
ax.set_aspect("equal")
ax.legend(loc="best", fontsize=9)
return fig, ax
[docs]
def plot_characteristics_mesh(data, ax=None, 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. Both
line families, not just one.
data['mesh_data']'s stage2_c_plus/stage2_c_minus/stage3_mesh are the
individual characteristic SEGMENTS (radiating fan lines, one
direction); stage2_fronts/stage3_fronts are the FRONT polylines
connecting points across the fan at each marching step (the other
direction) -- together these two families are what makes it a grid
rather than a one-directional fan. Matches the composite figure
MOCSolver.plot_results()/MOCSolverMLN.plot_results() draw live during
a show_plot=True solve (Sauer line, reflex-zone connectors, and all),
reconstructed here from the saved data dict instead of needing the
live solver object.
reference=<data dict> overlays a second design's WALL only by default
(dashed) -- its full characteristic mesh is skipped unless
show_reference_mesh=True, since two overlaid meshes get visually busy
fast.
"""
if ax is None:
fig, ax = plt.subplots(figsize=(7, 4))
else:
fig = ax.figure
def _draw_segments(segments, color, lbl, lw=0.5, alpha=0.7):
first = True
for xs, ys in segments:
ax.plot(xs, ys, "-", color=color, lw=lw, alpha=alpha,
label=lbl if first else None)
if mirror:
ax.plot(xs, _mirror(ys), "-", color=color, lw=lw, alpha=alpha)
first = False
def _draw_mesh(d, alpha_scale=1.0):
mesh = d.get("mesh_data", {})
_draw_segments(mesh.get("stage2_c_plus", []), COLOR_C_PLUS, "Stage 2 (C+)", alpha=0.7 * alpha_scale)
_draw_segments(mesh.get("stage2_c_minus", []), COLOR_C_MINUS, "Stage 2 (C-)", alpha=0.7 * alpha_scale)
_draw_segments(mesh.get("stage3_mesh", []), COLOR_C_PLUS, "Stage 3", alpha=0.7 * alpha_scale)
if show_fronts:
_draw_segments(mesh.get("stage2_fronts", []), COLOR_C_MINUS, "Stage 2 front", alpha=0.7 * alpha_scale)
_draw_segments(mesh.get("stage3_fronts", []), COLOR_C_MINUS, "Stage 3 front", alpha=0.7 * alpha_scale)
if show_reflex and d.get("IVP", {}).get("x") and d.get("div_wall", {}).get("x"):
ivp, div_wall = d["IVP"], d["div_wall"]
for xi, yi, xd, yd in zip(ivp["x"], ivp["y"], div_wall["x"], div_wall["y"]):
ax.plot([xi, xd], [yi, yd], "-", color=COLOR_C_MINUS, lw=0.8, alpha=0.7 * alpha_scale)
if mirror:
ax.plot([xi, xd], [-yi, -yd], "-", color=COLOR_C_MINUS, lw=0.8, alpha=0.7 * alpha_scale)
ax.plot(div_wall["x"], div_wall["y"], "o", color=COLOR_WALL, ms=3, alpha=alpha_scale)
if mirror:
ax.plot(div_wall["x"], _mirror(div_wall["y"]), "o", color=COLOR_WALL, ms=3, alpha=alpha_scale)
if show_sauer and d.get("sauer", {}).get("x"):
sauer = d["sauer"]
ax.plot(sauer["x"], sauer["y"], "-o", color=COLOR_SONIC, lw=1.5, ms=3,
alpha=alpha_scale, label="Sonic line" if alpha_scale == 1.0 else None)
if mirror:
ax.plot(sauer["x"], _mirror(sauer["y"]), "-o", color=COLOR_SONIC, lw=1.5, ms=3, alpha=alpha_scale)
if show_kernel and d.get("kernel_wall", {}).get("x"):
kernel = d["kernel_wall"]
ax.plot(kernel["x"], kernel["y"], "o", color=COLOR_KERNEL, ms=3, alpha=alpha_scale)
if mirror:
ax.plot(kernel["x"], _mirror(kernel["y"]), "o", color=COLOR_KERNEL, ms=3, alpha=alpha_scale)
def _draw_wall(d, ls, alpha, lbl):
wall = d["wall_final"]
ax.plot(wall["x"], wall["y"], ls, color=COLOR_WALL, lw=1.5, alpha=alpha, label=lbl)
if mirror:
ax.plot(wall["x"], _mirror(wall["y"]), ls, color=COLOR_WALL, lw=1.5, alpha=alpha)
# 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"):
ax.plot(conv["x"], conv["y"], ls, color=COLOR_WALL, lw=1.5, alpha=alpha)
if mirror:
ax.plot(conv["x"], _mirror(conv["y"]), ls, color=COLOR_WALL, lw=1.5, alpha=alpha)
if reference is not None:
if show_reference_mesh:
_draw_mesh(reference, alpha_scale=0.4)
_draw_wall(reference, "--", 0.5, reference_label)
_draw_mesh(data)
if show_wall:
_draw_wall(data, "-", 1.0, label if reference is not None else "Wall")
if mirror:
ax.axhline(0, color="gray", lw=0.8, ls="--", zorder=0)
ax.set_xlabel("x (m)")
ax.set_ylabel("y (m)")
ax.set_aspect("equal")
ax.legend(loc="best", fontsize=9)
return fig, ax
def _property_vs_x(x, p, M, xlabel, p_label, m_label, ax_p=None,
ref_x=None, ref_p=None, ref_M=None,
label="Current", reference_label="Reference"):
if ax_p is None:
fig, ax_p = plt.subplots(figsize=(6, 4))
else:
fig = ax_p.figure
ax_M = ax_p.twinx()
show_legend = ref_x is not None
if show_legend:
ax_p.plot(ref_x, [v / 1e5 for v in ref_p], color=COLOR_PRESSURE, lw=2, ls="--", alpha=0.5)
ax_M.plot(ref_x, ref_M, color=COLOR_MACH, lw=2, ls="--", alpha=0.5)
# Dummy black-ish handles just to label current vs reference in one legend
ax_p.plot([], [], color="gray", lw=2, ls="-", label=label)
ax_p.plot([], [], color="gray", lw=2, ls="--", alpha=0.6, label=reference_label)
ax_p.plot(x, [v / 1e5 for v in p], color=COLOR_PRESSURE, lw=2)
ax_M.plot(x, M, color=COLOR_MACH, lw=2)
ax_p.set_xlabel(xlabel)
ax_p.set_ylabel(p_label, color=COLOR_PRESSURE)
ax_M.set_ylabel(m_label, color=COLOR_MACH)
ax_p.tick_params(axis="y", labelcolor=COLOR_PRESSURE)
ax_M.tick_params(axis="y", labelcolor=COLOR_MACH)
if show_legend:
ax_p.legend(loc="best", fontsize=9)
return fig, (ax_p, ax_M)
def _extend_axis_to_exit(data):
"""See plotting_plotly._extend_axis_to_exit's docstring: axis_points_
vec stops at the end of Stage 3 (the kernel region) since past it no
more expansion waves reach the centerline, so the axis P/M are
already at their final (exit) values -- the flat continuation to the
nozzle exit is exact physics, not an approximation."""
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, ax=None, 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 (-)", ax_p=ax,
ref_x=ref_x, ref_p=ref_p,
ref_M=ref_M, label=label, reference_label=reference_label)
[docs]
def plot_wall_properties(data, ax=None, 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 (-)", ax_p=ax,
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, ax=None, show_dome=True, show_spinodal=False, reference=None,
label="Current", reference_label="Reference"):
"""T-s diagram: the saturation dome (from data['fluid_name']) plus
three highlighted points of the expansion -- Total (stagnation,
data['s0']/['T0_stagnation']), MoC start (the first solved axis point,
~the sonic/critical state Stage 1 seeds from -- data['axis']['s'][0]/
['T'][0]), and Outlet (the last axis point). MoC start and Outlet are
connected with a straight line (the two endpoints of what the solver
actually marches, as opposed to Total, which sits upstream of where
the solve starts at ~zero velocity).
The dome itself is drawn with jaxprop's own Fluid.plot_phase_diagram
(the same T-s/phase-diagram plotter used across the rest of this
codebase's jaxprop-based scripts), not a hand-rolled line plot --
turbo_moc._phase_diagram.get_fluid caches the Fluid per fluid name so the
saturation line (and, if requested, the spinodal line) is computed
once and reused, not recomputed on every call. show_spinodal=True adds
the metastable-region spinodal line (relevant for the subcooled-liquid
flashing inlets this solver supports) -- off by default since it's a
real iterative optimization per point, not free like the saturation
line.
"""
if ax is None:
fig, ax = plt.subplots(figsize=(6, 5))
else:
fig = ax.figure
if show_dome:
fluid = get_fluid(data["fluid_name"])
fluid.plot_phase_diagram(
"s", "T", axes=ax,
plot_saturation_line=True, plot_critical_point=True,
plot_spinodal_line=show_spinodal,
)
def _draw(d, alpha, ls, 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]
ax.plot([s_start, s_out], [T_start, T_out], ls, color=COLOR_MACH, lw=1.5, alpha=alpha)
ax.plot(s_total, T_total, "s", color=COLOR_WALL, ms=8, alpha=alpha,
label=f"Total{suffix}")
ax.plot(s_start, T_start, "o", color=COLOR_C_PLUS, ms=8, alpha=alpha,
label=f"MoC start{suffix}")
ax.plot(s_out, T_out, "^", color=COLOR_PRESSURE, ms=8, alpha=alpha,
label=f"Outlet{suffix}")
if reference is not None:
_draw(reference, 0.5, "--", f" ({reference_label})")
_draw(data, 1.0, "-", f" ({label})" if reference is not None else "")
ax.set_xlabel("s (J/kg/K)")
ax.set_ylabel("T (K)")
ax.legend(loc="best", fontsize=8)
return fig, ax
[docs]
def plot_blade(blade_data, ax=None, 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 -- turbo_moc.geometry.
parametrize_stator_blade's ``blade_curve`` (closed B-spline contour)
repeated n_blades times at the design pitch, in mm (this module's
only plot not in SI, matching the ported blade-parametrization
script's own working units). reference=<second blade_data dict>
overlays a second design (dashed, lighter). highlight_cp=<int> marks
one control point of the CURRENT (not reference) blade with a
distinct marker -- used by turbo_moc.app's interactive control-point editor
(see turbo_moc.geometry.move_control_point_along_normal) to show which
point a nudge will move. show_cp_labels annotates each control point
of the CURRENT blade with its index (needed to pick a cp_index for
that editor) -- reference control points are never labeled, to keep
an overlaid comparison readable."""
if ax is None:
fig, ax = plt.subplots(figsize=(7, 6))
else:
fig = ax.figure
def _draw(d, ls, alpha, 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
ax.plot([v + dx for v in curve["x"]], curve["y"],
ls, color=COLOR_WALL, lw=2, alpha=alpha,
label=lbl if i == 0 else None)
if show_trailing_edge:
ax.plot([v + dx for v in te["x"]], te["y"], ls, color=COLOR_C_PLUS,
lw=1.5, alpha=alpha)
if show_control_points and i == 0:
cp_x = [p[0] + dx for p in cps]
cp_y = [p[1] for p in cps]
ax.plot(cp_x, cp_y, "--", color=COLOR_CP_LINE, lw=1.2, alpha=0.7 * alpha,
marker="o", mfc=COLOR_CP_FACE, mec="black", mew=0.8, ms=8, zorder=10)
if reference is not None:
_draw(reference, "--", 0.5, reference_label)
_draw(blade_data, "-", 1.0, label if reference is not None else "Blade profile")
if show_control_points and show_cp_labels:
for idx, (cx, cy) in enumerate(blade_data["control_points"]):
ax.annotate(str(idx), xy=(cx, cy), xytext=(4, 4), textcoords="offset points",
fontsize=8, fontweight="bold", color=COLOR_CP_LINE, zorder=12)
if show_control_points and highlight_cp is not None:
cps = blade_data["control_points"]
if 0 <= highlight_cp < len(cps):
ax.plot(cps[highlight_cp][0], cps[highlight_cp][1], marker="*", mfc="#ffe600",
mec="#d81b1b", mew=1.5, ms=22, linestyle="none", zorder=11,
label=f"Selected CP {highlight_cp}")
ax.set_xlabel("x (mm)")
ax.set_ylabel("y (mm)")
ax.set_aspect("equal")
ax.legend(loc="best", fontsize=9)
return fig, ax
[docs]
def plot_rotor_blade(data, ax=None, show_junctions=True,
reference=None, label="Current", reference_label="Reference"):
"""
Pressure/suction surfaces from turbo_moc.rotor.design_rotor_vortex_blade
(NASA TN D-4421/Goldman & Scullin 1968 vortex-flow method, validated
against the report's own worked example -- see vortex_blade.py's
module docstring), each plotted in ITS OWN local frame (own vortex
center at the origin) -- the two surfaces are NOT stitched into a
single closed blade contour here (that needs the leading/trailing-edge
treatment TN D-4421 covers separately, plus a rounding radius, neither
implemented yet); `data["pitch"]`/`data["chord"]` give the NATURAL
(not guessed) blade-to-blade spacing and the lower surface's own
inlet-to-outlet chord length, derived directly from the geometry.
show_junctions marks the transition-arc/circular-arc boundary on each
surface (small circle) -- purely informational; with the corrected
(real marching-based) construction these should NOT show a visible
kink. reference=<second data dict> overlays a second design
(dashed/lighter).
"""
if ax is None:
fig, ax = plt.subplots(figsize=(7, 6))
else:
fig = ax.figure
def _draw(d, ls, alpha, lbl):
for surf_key, color in (("pressure", COLOR_PRESSURE), ("suction", COLOR_MACH)):
surf = d[surf_key]
ax.plot(surf["x"], surf["y"], ls, color=color, lw=2, alpha=alpha,
label=f"{lbl} {surf_key}" if lbl else surf_key.capitalize())
if show_junctions:
n = len(surf["x"])
n_pts = d.get("num_points") or n // 3 # fallback if not echoed back
for idx in (n_pts - 1, n - n_pts):
if 0 <= idx < n:
ax.plot(surf["x"][idx], surf["y"][idx], "o", mfc="none",
mec=color, mew=1.3, ms=7, alpha=alpha, zorder=10)
if reference is not None:
_draw(reference, "--", 0.5, reference_label)
_draw(data, "-", 1.0, label if reference is not None else "")
units = data.get("units", "r*")
ax.set_xlabel(f"x ({units})")
ax.set_ylabel(f"y ({units})")
ax.set_aspect("equal")
ax.legend(loc="best", fontsize=9)
return fig, ax
COLOR_BLADE_FILL = "0.55"
[docs]
def plot_rotor_vortex_blade(data, ax=None, show_surfaces=True, show_mach_lines=False,
reference=None, label="Current", reference_label="Reference"):
"""Filled vortex-flow rotor blade (turbo_moc.rotor.design_rotor_vortex_blade's
"blade" key) -- see notes/rotor_vortex_blade.md for what this closure
actually is (pressure joined to suction shifted by +pitch, closed by
tangent segments at beta_inlet/beta_outlet -- NOT pressure+suction of
the same instance closed directly). show_surfaces overlays the raw
pressure/suction curves; show_mach_lines overlays the recorded
characteristic segments from the transition-arc marching.
"""
if ax is None:
fig, ax = plt.subplots(figsize=(7, 6))
else:
fig = ax.figure
def _draw(d, alpha, lbl):
blade = d["blade"]
ax.fill(blade["x"], blade["y"], color=COLOR_BLADE_FILL, edgecolor="0.15",
lw=1.5, alpha=0.85 * alpha, label=lbl or "Blade")
if show_surfaces:
for surf_key, color in (("pressure", COLOR_PRESSURE), ("suction", COLOR_MACH)):
surf = d[surf_key]
ax.plot(surf["x"], surf["y"], color=color, lw=1.2, alpha=0.7 * alpha,
label=f"{lbl + ' ' if lbl else ''}{surf_key}")
if show_mach_lines:
for surf_key in ("pressure", "suction"):
surf = d[surf_key]
for x0, y0, x1, y1 in surf["mach_lines_inlet"] + surf["mach_lines_outlet"]:
ax.plot([x0, x1], [y0, y1], color="0.6", lw=0.4, alpha=0.6 * alpha)
if reference is not None:
_draw(reference, 0.5, reference_label)
_draw(data, 1.0, label if reference is not None else "")
units = data.get("units", "r*")
ax.set_xlabel(f"x ({units})")
ax.set_ylabel(f"y ({units})")
ax.set_aspect("equal")
ax.legend(loc="best", fontsize=9)
return fig, ax
COLOR_RADIAL_STATOR = "#7e03a8" # plasma colormap, ~25% stop (purple)
COLOR_RADIAL_ROTOR = "#f89441" # plasma colormap, ~75% stop (orange)
def _draw_radial_set_mpl(ax, blades, trailing_edges, line_color, fillcolor, name):
"""One source's (stator's or rotor's) blade copies onto an existing
annular-cascade axes. fillcolor=None draws outline-only (single-source
mode); a fillcolor draws a translucent fill (combined mode), same
convention as Phase 4's stator/rotor cascade overlay."""
for i, (blade, te) in enumerate(zip(blades, trailing_edges)):
if fillcolor:
ax.fill(blade["x"], blade["y"], color=fillcolor, edgecolor=line_color,
lw=1.2, alpha=0.35, label=name if i == 0 else None)
else:
ax.plot(blade["x"], blade["y"], color=line_color, lw=1.2, alpha=0.85,
label=name if i == 0 else None)
if te["x"]:
ax.plot(te["x"], te["y"], color=COLOR_C_PLUS, lw=1.0, alpha=0.85)
[docs]
def plot_radial_cascade(radial_data, ax=None, show_radius_circles=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) to
overlay the two rows, same as plotting_plotly's version."""
import numpy as np
if ax is None:
fig, ax = plt.subplots(figsize=(7, 7))
else:
fig = ax.figure
combined = radial_data.get("mode") == "combined"
if combined:
stator, rotor = radial_data["stator"], radial_data["rotor"]
_draw_radial_set_mpl(ax, stator["blades"], stator["trailing_edges"],
COLOR_RADIAL_STATOR, COLOR_RADIAL_STATOR, "Stator")
_draw_radial_set_mpl(ax, 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_mpl(ax, 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:
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:
ax.plot(r * np.cos(t), r * np.sin(t), ":", color="0.6", lw=1, label=lbl)
units = radial_data.get("units", "mm")
ax.set_xlabel(f"x ({units})")
ax.set_ylabel(f"y ({units})")
ax.set_aspect("equal")
ax.set_title(f"Radial cascade ({n_label} blades, r1={r1:.2f}, r2={r2:.2f} {units})")
ax.legend(loc="best", fontsize=9)
return fig, ax
[docs]
def plot_meridional_view(meridional_data, ax=None, units="mm"):
"""Meridional (axial-radial, x-r) 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."""
if ax is None:
fig, ax = plt.subplots(figsize=(8, 5))
else:
fig = ax.figure
def _draw_row(row, color, name):
x, hub, tip = row["x"], row["hub"], row["tip"]
ax.fill(x + x[::-1], hub + tip[::-1], color=color, alpha=0.25, label=name)
ax.plot(x, hub, "-o", color=color, lw=2, ms=4)
ax.plot(x, tip, "--o", color=color, lw=2, ms=4)
_draw_row(meridional_data["stator"], COLOR_RADIAL_STATOR, "Stator")
_draw_row(meridional_data["rotor"], COLOR_RADIAL_ROTOR, "Rotor")
ax.set_xlabel(f"x ({units})")
ax.set_ylabel(f"r ({units})")
ax.set_aspect("equal")
ax.set_title(f"Meridional view (gap = {meridional_data['gap']:.2f} {units})")
ax.legend(loc="best", fontsize=9)
return fig, ax