"""Physical planar cascades using the same blade-to-blade mesh as annular rows.
The numerical plane retains pitchwise q_x and downstream-negative q_y.
Unlike the annular conformal chart, both coordinates are physical lengths:
X = -q_y, Y = q_x, Z = eta * blade_height. No reference radius is needed.
"""
from dataclasses import dataclass
import numpy as np
from . import geometry_2d as planar
from .meridional_channel import scale_blade, scale_flow_domain, validate_machine
[docs]
@dataclass(frozen=True)
class LinearCascadeDefinition:
meridional_chord: float
blade_height: float
pitch: float
[docs]
def to_dict(self):
return {
"type": "linear",
"meridional_chord_mm": self.meridional_chord,
"blade_height_mm": self.blade_height,
"pitch_mm": self.pitch,
}
[docs]
class LinearCascadeMapping:
"""Identity blade sections extruded between two parallel span endwalls."""
is_linear = True
# (q_x,q_y) -> (Y,-X) preserves planar winding; increasing eta adds +Z.
orientation = 1
def __init__(self, definition):
if not isinstance(definition, LinearCascadeDefinition):
raise TypeError("LinearCascadeMapping requires a LinearCascadeDefinition.")
lengths = np.asarray(
[definition.meridional_chord, definition.blade_height, definition.pitch]
)
if not np.isfinite(lengths).all() or np.any(lengths <= 0):
raise ValueError("Linear cascade chord, height and derived pitch must be positive.")
self.definition = definition
self.reference_chord = float(definition.meridional_chord)
self.blade_height = float(definition.blade_height)
self.pitch = float(definition.pitch)
self.characteristic_length = float(lengths.max())
[docs]
def section_scale(self, eta):
"""Every span station has the same physical profile and pitch."""
return 1.0
[docs]
def map_points(self, q, eta):
q = np.asarray(q, dtype=float)
if not np.isfinite(q).all() or not np.isfinite(eta):
raise ValueError("Linear cascade mapping requires finite coordinates.")
span = np.full(q.shape[:-1], eta * self.blade_height)
return np.stack((-q[..., 1], q[..., 0], span), axis=-1)
[docs]
def meridional_points(self, u, eta, *, derivative=False):
"""Return (span height, streamwise distance) for the meridional plot."""
u = np.asarray(u, dtype=float)
if derivative:
tangent = np.full_like(u, self.reference_chord)
return np.stack((np.zeros_like(u), tangent), axis=-1)
span = np.full_like(u, eta * self.blade_height)
return np.stack((span, u * self.reference_chord), axis=-1)
[docs]
def build_linear_section(nozzle, config, template):
"""Scale the fitted blade and its MoC-derived pitch together to the chord.
Preserving pitch/chord avoids introducing an independent, unstated solidity
choice. The sole automatic passage-placement search remains in 2D.
"""
dimensions = config["machine"]
validate_machine(dimensions)
contour = planar.blade_contour(template)
origin = contour[np.argmax(contour[:, 1])]
scale = dimensions["meridional_chord"] / np.ptp(contour[:, 1])
passage = config["passage"]
domain = planar.generate_flow_domain(
template,
nozzle["wall_final"],
inlet_chords=passage["inlet_chords"],
outlet_chords=passage["outlet_chords"],
smooth=passage.get("smooth", {}),
domain_shift_mode=passage.get("domain_shift_mode", "auto"),
domain_shift=passage.get("domain_shift", 0),
)
blade = scale_blade(template, scale, origin)
curves = scale_flow_domain(domain, scale, origin)
curves["blade_curve"] = planar.xy(blade["blade_curve"])
curves["trailing_edge"] = planar.xy(blade["trailing_edge"])
mapping = LinearCascadeMapping(LinearCascadeDefinition(
float(dimensions["meridional_chord"]),
float(dimensions["blade_height"]),
float(blade["pitch"]),
))
return mapping, blade, curves