codex/flat-plate-velocity-traverse-apparatus.py

flat-plate-velocity-traverse-apparatus.py
"""Generate flat-plate-velocity-traverse-apparatus.png in the working directory.

Tested with Python 3.14, matplotlib 3.10.7, numpy 2.3.5.
This is an illustrative pressure-averaging model, not measured laboratory data.
The assumed 0.50 mm sensing footprint is not a reported probe bore diameter.
"""
from pathlib import Path
import importlib.util
import json
import sys
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import numpy as np

sys.dont_write_bytecode = True

spec = importlib.util.spec_from_file_location(
    "laminar_profile", Path(__file__).with_name("flat-plate-laminar-velocity-profiles.py"))
profile = importlib.util.module_from_spec(spec)
spec.loader.exec_module(profile)


def sample_model(eta, states, centres, footprint, U, nu, x, rho):
    y = eta * np.sqrt(nu * x / U)
    offsets = np.linspace(-footprint / 2, footprint / 2, 301)
    locations = centres[:, None] + offsets[None, :]
    assert np.min(locations) >= 0
    speeds = U * np.interp(locations, y, states[:, 1])
    q_samples = np.trapezoid(0.5 * rho * speeds**2, offsets, axis=1) / footprint
    mean_speed = np.trapezoid(speeds, offsets, axis=1) / footprint
    inferred = np.sqrt(2 * q_samples / rho)
    return q_samples, inferred, mean_speed


def main():
    eta, states = profile.blasius_profile()
    U, nu, x, rho, footprint = 10.0, 1.5e-5, 0.3048, 1.2, 0.00050
    centres = np.array([0.25, 0.45, 0.7, 1.0, 1.35, 1.7, 2.1, 2.6, 3.3, 4.2]) * 1e-3
    q, inferred, mean = sample_model(eta, states, centres, footprint, U, nu, x, rho)
    assert np.all(inferred >= mean - 1e-12)
    plt.rcParams.update({"font.size": 11, "figure.facecolor": "white"})
    fig, (sketch, readout) = plt.subplots(1, 2, figsize=(10.6, 5.3),
                    gridspec_kw={"width_ratios": [1.55, 1]}, layout="constrained")
    fig.suptitle("Sampling a flat-plate velocity profile with pressure", fontsize=15)
    sketch.set(xlim=(0, 10), ylim=(-1.1, 7.5))
    sketch.axis("off")
    sketch.plot([0.1, 9.7], [0.3, 0.3], color="gray", lw=1)
    sketch.plot([0.1, 9.7], [6.8, 6.8], color="gray", lw=1)
    sketch.add_patch(Rectangle((1, 1.1), 6.0, 0.13, color="#424b55"))
    sketch.text(1.1, 0.7, "Stationary smooth plate", fontsize=11)
    positions = np.linspace(1, 7, 150)
    edge = 1.23 + 0.9 * np.sqrt((positions - 1) / 6)
    sketch.fill_between(positions, 1.23, edge, color="#e0edf4")
    sketch.plot(positions, edge, ls="--", color="#1f6f9b")
    sketch.text(1.2, 2.35, "Thin velocity deficit\n(normal scale exaggerated)", fontsize=10)
    for yy in [3.6, 4.6]:
        sketch.annotate("", (2, yy), (0.25, yy), arrowprops={"arrowstyle": "->", "lw": 2,
                                                                      "color": "#1f6f9b"})
    sketch.text(0.25, 5.1, "Incoming U", fontsize=11, color="#1f6f9b")
    # Upstream-facing two-port probe, carried by a normal traverse.
    sketch.plot([5.0, 6.5, 6.5], [1.78, 1.78, 5.4], lw=3, color="#313d48")
    sketch.scatter([5.0], [1.78], s=30, color="#b84145", zorder=4)
    sketch.scatter([5.55], [1.78], s=22, color="#437741", zorder=4)
    sketch.annotate("", (6.15, 4.6), (6.15, 2.5), arrowprops={"arrowstyle": "<->", "lw": 1.5})
    sketch.text(4.5, 6.15, "Micrometer traverse\nrecords inlet-centre y", fontsize=11)
    sketch.annotate("Upstream opening: p₀(y)", (5, 1.78), (3.8, 3.45), fontsize=10,
                    arrowprops={"arrowstyle": "->", "color": "#b84145"})
    sketch.text(6.75, 2.6, "Static port: p(y)", fontsize=10, color="#437741")
    sketch.plot([6.5, 7.45, 7.45], [5.3, 5.3, 4.1], color="#b84145", lw=1.5)
    sketch.plot([6.6, 8.75, 8.75], [4.8, 4.8, 4.1], color="#437741", lw=1.5)
    sketch.add_patch(Rectangle((7.15, 3.2), 2.2, 0.85, facecolor="white", edgecolor="#424b55"))
    sketch.text(8.25, 3.63, "Manometer\nq = p₀ − p", ha="center", va="center", fontsize=10)
    sketch.text(7.45, 5.5, "Pressure lines", fontsize=10)
    sketch.text(0.4, -0.25, "Model x = 0.3048 m  •  U = 10 m/s\nρ = 1.2 kg/m³  •  ν = 1.5 × 10⁻⁵ m²/s", fontsize=10, va="top")
    yy = eta * np.sqrt(nu * x / U) * 1000
    readout.plot(states[:, 1], yy, lw=2, color="#1f6f9b", label="Ideal point profile")
    readout.scatter(inferred / U, centres * 1000, s=45, edgecolor="#b84145", facecolor="white",
                    linewidth=1.5, label="Pressure-averaged model", zorder=4)
    readout.axhline(0, color="#424b55", lw=2)
    readout.set(xlim=(0, 1.1), ylim=(0, 4.8), xlabel=r"Inferred speed $\sqrt{2q/\rho}/U$",
                ylabel="Inlet-centre y (mm)")
    readout.spines[["top", "right"]].set_visible(False)
    readout.grid(alpha=0.17)
    readout.legend(loc="upper left", fontsize=9)
    readout.text(0.06, 3.5, "Synthetic readings only\nAssumed footprint: 0.50 mm\nProbe bore is unspecified", fontsize=9,
                 bbox={"facecolor": "white", "alpha": 0.9, "edgecolor": "none"})
    readout.text(0.45, 0.3, "Wall is unsampled", fontsize=10)
    fig.savefig(Path(__file__).stem + ".png", dpi=130, facecolor="white", transparent=False)
    plt.close(fig)
    print(json.dumps({"sample_count": len(centres), "min_centre_mm": float(centres.min() * 1000),
                      "footprint_mm": footprint * 1000, "pressure_range_pa": [float(q.min()), float(q.max())],
                      "max_rms_minus_mean_ms": float((inferred - mean).max())}, sort_keys=True))


if __name__ == "__main__":
    main()