"""Conformal mapping from an unrolled blade to an annular passage.
The log-spiral mapping wraps either a stator blade produced by
``parametrize_stator_blade`` or a rotor blade produced by
``turbo_moc.rotor.design_rotor_vortex_blade`` between two radii. Its reference
point and axial-chord scale are derived from the blade's own point cloud, so
the mapping does not require geometry-specific constants.
The mapping is::
r = r1 * exp(log(r2/r1) * (chordwise - x1) / c_axial)
theta = theta0 + log(r2/r1) / c_axial * (pitchwise - y1)
The source axes used for the chordwise and pitchwise coordinates differ:
* A stator's chordwise extent runs mainly along its local y axis and its
pitchwise extent along x after the final profile rotation.
* A rotor's chordwise direction runs along local x and its pitch runs along y.
Moving along the chord sweeps the radius from ``r1`` to ``r2``; a pitch
offset between adjacent copies becomes the annular blade spacing. The mapping
is angle-preserving by construction.
"""
import numpy as np
__all__ = [
"apply_conformal_mapping", "rotate_radial_points",
"wrap_curve_radial", "wrap_blade_radial", "wrap_rotor_blade_radial", "consistent_radial_wrap",
"conformal_length_scale", "mapped_throat_width",
]
[docs]
def rotate_radial_points(x, y, angle_rad):
"""Rigid rotation by angle_rad about the origin -- lays out the
n_blades copies around the annulus after mapping."""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
cos_a, sin_a = np.cos(angle_rad), np.sin(angle_rad)
return x * cos_a - y * sin_a, x * sin_a + y * cos_a
def _reference_point_and_scale(chord_vals, pitch_vals):
"""(x1, y1, c_axial) derived from the curve's own point cloud -- the
point with the largest chordwise coordinate anchors r=r1/theta=theta0,
and the chordwise span sets c_axial. Needs no per-case tuning, unlike
the original script's hardcoded version."""
chord_vals = np.asarray(chord_vals, dtype=float)
pitch_vals = np.asarray(pitch_vals, dtype=float)
c_axial = float(np.min(chord_vals) - np.max(chord_vals))
idx = int(np.argmax(chord_vals))
y1 = float(pitch_vals[idx])
x1 = float(chord_vals[idx])
return x1, y1, c_axial
[docs]
def wrap_curve_radial(x, y, r1, r2, n_blades, theta0=0.0, chordwise_axis="y"):
"""
Wrap ONE closed (or open) curve (x, y) onto an annular passage between
radii r1/r2, laid out as n_blades copies spaced 2*pi/n_blades apart.
`chordwise_axis` picks which of the curve's own axes runs chordwise
(the one that maps to radius) -- "y" for a stator-convention curve,
"x" for a rotor-convention one; see module docstring.
Returns a list of n_blades {"x", "y"} dicts (the mapped curve, one per
copy) plus the reference (x1, y1, c_axial, d_theta) used, for reuse
when wrapping a second curve (e.g. a trailing edge) with the SAME
reference frame as this one.
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
if chordwise_axis == "y":
chord_vals, pitch_vals = y, x
elif chordwise_axis == "x":
chord_vals, pitch_vals = x, y
else:
raise ValueError(f"chordwise_axis must be 'x' or 'y', got {chordwise_axis!r}")
x1, y1, c_axial = _reference_point_and_scale(chord_vals, pitch_vals)
x_mapped, y_mapped = apply_conformal_mapping(chord_vals, pitch_vals, x1, y1, r1, r2, c_axial, theta0)
d_theta = 2.0 * np.pi / n_blades
copies = []
for i in range(n_blades):
xi, yi = rotate_radial_points(x_mapped, y_mapped, i * d_theta)
copies.append({"x": xi.tolist(), "y": yi.tolist()})
return copies, dict(x1=x1, y1=y1, c_axial=c_axial, d_theta=d_theta)
def _wrap_with_shared_reference(x, y, x1, y1, c_axial, r1, r2, n_blades, theta0, chordwise_axis):
"""Like wrap_curve_radial, but reusing a reference (x1, y1, c_axial)
computed from a DIFFERENT curve (e.g. the main blade contour) -- used
to map a trailing-edge arc consistently with its own blade's wrap."""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
chord_vals, pitch_vals = (y, x) if chordwise_axis == "y" else (x, y)
x_mapped, y_mapped = apply_conformal_mapping(chord_vals, pitch_vals, x1, y1, r1, r2, c_axial, theta0)
d_theta = 2.0 * np.pi / n_blades
copies = []
for i in range(n_blades):
xi, yi = rotate_radial_points(x_mapped, y_mapped, i * d_theta)
copies.append({"x": xi.tolist(), "y": yi.tolist()})
return copies
def _eval_reference(blade_data, r1, r2, source):
"""Shared by mapped_throat_width/mapped_pitch_width/
implied_n_blades_radial: the (x1, c_axial) the actual wrap derives
from the main contour (so results stay consistent with the rendered/
exported geometry), the EVALUATION point's own chordwise coordinate,
and the radius the map puts it at.
The evaluation point is the natural "critical section" to check pitch/
throat consistency at: for a stator, its own throat_point (the
narrowest passage section). The rotor method has no equivalent
critical-section concept, so x1 itself (the hub reference point,
r=r1 by construction -- see _reference_point_and_scale) is used
instead, a reasonable single representative location in the absence
of a better one."""
if source == "stator":
curve = blade_data["blade_curve"]
chord_vals, pitch_vals = np.asarray(curve["y"], dtype=float), np.asarray(curve["x"], dtype=float)
x1, y1, c_axial = _reference_point_and_scale(chord_vals, pitch_vals)
eval_chord = blade_data["throat_point"]["y"]
elif source == "rotor":
blade = blade_data["blade"]
chord_vals, pitch_vals = np.asarray(blade["x"], dtype=float), np.asarray(blade["y"], dtype=float)
x1, y1, c_axial = _reference_point_and_scale(chord_vals, pitch_vals)
eval_chord = x1 # hub reference point (r=r1) -- no throat concept for the rotor method
else:
raise ValueError(f"source must be 'stator' or 'rotor', got {source!r}")
a = np.log(r2 / r1) / c_axial
r_eval = r1 * np.exp(a * (eval_chord - x1))
return x1, c_axial, eval_chord, float(r_eval)
[docs]
def mapped_throat_width(blade_data, r1, r2, source="stator"):
"""The 2D-plane throat_opening, rescaled by the EXACT local conformal
length factor at the throat's own (chordwise, pitchwise) location --
see conformal_length_scale. Uses the SAME reference (x1, c_axial) the
actual wrap (wrap_blade_radial/wrap_rotor_blade_radial) derives from
the main blade contour, so the result is consistent with the geometry
that actually gets rendered/exported.
blade_data : dict
Stator: needs "blade_curve" ({"x","y"}) and "throat_point"
({"x","y"}, see parametrize_stator_blade/_semi) and
"throat_opening". Rotor: this tool's rotor method has no
throat_opening/throat_point -- not supported here (its sizing is
pitch-consistency only to begin with, see
turbo_moc.geometry.sizing.size_rotor_from_pitch).
"""
if source != "stator":
raise ValueError(f"mapped_throat_width only supports source='stator', got {source!r}")
x1, c_axial, eval_chord, _ = _eval_reference(blade_data, r1, r2, source)
scale = conformal_length_scale(eval_chord, x1, r1, r2, c_axial)
return float(blade_data["throat_opening"] * scale)
[docs]
def mapped_pitch_width(blade_data, r1, r2, source="stator"):
"""The 2D-plane pitch, rescaled by the exact local conformal length
factor at the evaluation point's chordwise location (the stator's own
throat, or the rotor's hub reference point -- see _eval_reference) --
i.e. the actual circumferential footprint ONE mapped blade copy
occupies there. Unlike the axial wrap, nothing here guarantees this
matches 2*pi*r/n_blades for whatever n_blades you pick (see this
module's docstring and implied_n_blades_radial) -- adjacent copies
will overlap if n_blades is too large for this footprint."""
x1, c_axial, eval_chord, _ = _eval_reference(blade_data, r1, r2, source)
scale = conformal_length_scale(eval_chord, x1, r1, r2, c_axial)
return float(blade_data["pitch"] * scale)
[docs]
def implied_n_blades_radial(blade_data, r1, r2, source="stator"):
"""The blade count that makes the copy-to-copy spacing
(2*pi*r_eval/n_blades) match this blade's own mapped pitch at the
evaluation point (stator: its throat; rotor: its hub reference point,
see _eval_reference) -- i.e. the n_blades that actually tiles the
annulus without overlap or gaps, AT THAT RADIUS (pitch still varies
with span under this map, so this is a single representative value,
same role as the axial wrap's own pitch-consistency check, not an
exact guarantee everywhere along the blade). A default N_blades
chosen far from this will visibly overlap (too large) or leave gaps
(too small) in the 3D preview."""
x1, c_axial, eval_chord, r_eval = _eval_reference(blade_data, r1, r2, source)
mapped_pitch = mapped_pitch_width(blade_data, r1, r2, source=source)
return float(2.0 * np.pi * r_eval / mapped_pitch)
[docs]
def consistent_radial_wrap(blade_data, r1, r2_target, source="stator"):
"""Blade count and outer radius that keep the designed 2D passage.
Under the log-spiral map one 2D pitch p turns by ``|a| p``, a = ln(r2/r1) /
c_axial, at every radius; the copies are laid out 2*pi/N apart. Both
agree only for ``N = 2*pi / (|a| p)`` (implied_n_blades_radial, which is
exactly this, independent of where it is evaluated). So N is the
nearest integer to that count at (r1, r2_target), and r2 is then moved
to the radius where it is exact::
|ln(r2/r1)| = 2*pi |c_axial| / (N p), same side of r1 as r2_target
Returns n_blades, r2, and the target's non-integer implied count.
"""
if r1 <= 0 or r2_target <= 0 or r1 == r2_target:
raise ValueError("r1 and r2 must be positive and different.")
n_target = implied_n_blades_radial(blade_data, r1, r2_target, source=source)
n_blades = max(2, int(round(n_target)))
curve = blade_data["blade_curve"] if source == "stator" else blade_data["blade"]
chord_vals = np.asarray(curve["y"] if source == "stator" else curve["x"], dtype=float)
pitch_vals = np.asarray(curve["x"] if source == "stator" else curve["y"], dtype=float)
_, _, c_axial = _reference_point_and_scale(chord_vals, pitch_vals)
log_ratio = np.sign(np.log(r2_target / r1)) * 2.0 * np.pi * abs(c_axial) / (n_blades * float(blade_data["pitch"]))
return {"n_blades": n_blades, "r2": float(r1 * np.exp(log_ratio)), "n_implied_at_target": float(n_target)}
[docs]
def wrap_blade_radial(blade_data, r1, r2, n_blades, theta0=0.0):
"""
Wrap a Phase-2-style stator blade (parametrize_stator_blade/_semi's
output dict) onto an annular passage between radii r1 and r2.
Parameters
----------
blade_data : dict
Needs "blade_curve" and "trailing_edge" ({"x", "y"} each, mm).
r1, r2 : float
Inner/outer radii of the annular passage (same length units as
blade_data, mm), reached at the blade's own chordwise extremes.
n_blades : int
Number of blade copies laid out around the full annulus.
theta0 : float
Angular offset [rad] of the first (index-0) blade copy.
Returns
-------
dict
"blades": list of n_blades {"x", "y"} closed-contour dicts;
"trailing_edges": matching list for the trailing-edge arc;
"r1"/"r2"/"n_blades"/"d_theta" echoed back; "units".
"""
curve = blade_data["blade_curve"]
te = blade_data["trailing_edge"]
blades, ref = wrap_curve_radial(curve["x"], curve["y"], r1, r2, n_blades, theta0, chordwise_axis="y")
trailing_edges = _wrap_with_shared_reference(
te["x"], te["y"], ref["x1"], ref["y1"], ref["c_axial"], r1, r2, n_blades, theta0, "y"
)
return {
"blades": blades,
"trailing_edges": trailing_edges,
"r1": float(r1), "r2": float(r2),
"n_blades": int(n_blades), "d_theta": ref["d_theta"],
"units": blade_data.get("units", "mm"),
}
[docs]
def wrap_rotor_blade_radial(rotor_data, r1, r2, n_blades, theta0=0.0):
"""
Wrap a Phase-3-style rotor blade (turbo_moc.rotor.design_rotor_vortex_blade's
output dict) onto an annular passage between radii r1 and r2.
Parameters
----------
rotor_data : dict
Needs "blade" ({"x", "y"}) -- the closed LE/TE-rounded blade
contour. Its trailing edge is already part of this closed curve
(unlike the stator's, which keeps a separate arc), so there is no
separate trailing-edge overlay here. Its units don't matter (it
may well be nondimensional, r*): the map normalises the contour by
its own chordwise extent, so a uniformly rescaled blade wraps to
exactly the same result.
r1, r2 : float
Radii [mm] the blade's chordwise extremes map to; the result is in
the same units.
n_blades : int
Number of blade copies laid out around the full annulus.
theta0 : float
Angular offset [rad] of the first (index-0) blade copy.
Returns
-------
dict
Same shape as wrap_blade_radial's return, with "trailing_edges"
as a list of empty {"x": [], "y": []} placeholders (nothing extra
to draw -- kept for a uniform shape with the stator's result, so
plot_radial_cascade works unchanged for either source).
"""
# Pitch is mirrored so both rows come out with the same handedness. The
# map treats chord + i*pitch as a holomorphic coordinate: for the
# rotor's (x=chord, y=pitch) frame that preserves orientation, for the
# stator's (x=pitch, y=chord) frame it reverses it. Unmirrored, the
# rotor's inlet swirl came out opposite to the stator's exit swirl
# (leading edge facing against the incoming flow) for both inward and
# outward stages.
blade = rotor_data["blade"]
blades, ref = wrap_curve_radial(blade["x"], [-v for v in blade["y"]], r1, r2, n_blades, theta0,
chordwise_axis="x")
return {
"blades": blades,
"trailing_edges": [{"x": [], "y": []} for _ in range(n_blades)],
"r1": float(r1), "r2": float(r2),
"n_blades": int(n_blades), "d_theta": ref["d_theta"],
"units": "mm",
}