From 1e7e4c9d8b3c3fc6b30b5bfb07007c898baba104 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 10 Jun 2026 14:07:07 +0000 Subject: [PATCH 1/2] Add Python GUI for Francis runner design (Bovet method) Port the MATLAB francis-runner-design tool to Python with a Tkinter + Matplotlib interface. Users can adjust Q/H/rpm/N and instantly see the meridional plane (hub, shroud, streamlines, LE/TE) plus key dimensions. --- francis_runner_gui.py | 320 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 francis_runner_gui.py diff --git a/francis_runner_gui.py b/francis_runner_gui.py new file mode 100644 index 0000000..50b6b62 --- /dev/null +++ b/francis_runner_gui.py @@ -0,0 +1,320 @@ +"""Francis Turbine Runner Design GUI (Bovet Method, 1963). + +Python port of the MATLAB tool at https://github.com/.../francis-runner-design +with an interactive Tkinter + Matplotlib interface. + +Run: + python francis_runner_gui.py +""" + +from __future__ import annotations + +import math +import tkinter as tk +from tkinter import ttk, messagebox + +import numpy as np +import matplotlib + +matplotlib.use("TkAgg") +from matplotlib.backends.backend_tkagg import ( + FigureCanvasTkAgg, + NavigationToolbar2Tk, +) +from matplotlib.figure import Figure + + +# ---------- Bovet method core (ported from MATLAB) ---------- + +G = 9.81 +V2EO = 0.27 + + +def bovet_curve(x, l): + """Bovet shape function: y = 3.08*(1-x/l)^1.5 * (x/l)^0.5.""" + xn = np.asarray(x) / l + xn = np.clip(xn, 0.0, 1.0) + return 3.08 * (1.0 - xn) ** 1.5 * xn**0.5 + + +def r1e_poly(no): + return ( + -125.6 * no**5 + + 299.4 * no**4 + - 278.0 * no**3 + + 126.3 * no**2 + - 28.52 * no + + 3.674 + ) + + +def r2i_poly(no): + return 9.133 * no**4 - 16.61 * no**3 + 11.34 * no**2 - 3.707 * no + 0.974 + + +def meridional_dims(Vo, H, nr, N): + no = (nr * (Vo / math.pi) ** 0.5) / ((2 * G * H) ** 0.75) + bo = 0.8 * (2 - no) * no + roi = 0.7 + 0.16 / (no + 0.08) + ymi = roi + rli = 0.493 / no ** (2 / 3) + roe = 0.493 / no ** (2 / 3) if no < 0.275 else 1.255 - 0.3 * no + li = 3.2 + 3.2 * (2 - no) * no + le = 2.4 - 1.9 * (2 - no) * no + x2e = 0.5 + y2e = roe - 1 + y2ebyyme = float(bovet_curve(x2e, le)) + yme = y2e / y2ebyyme + rme = roe - yme + + R2e = ((Vo / math.pi) / (V2EO * nr)) ** (1 / 3) + return dict( + no=no, + R2e=R2e, + Bo=bo * R2e, + Roi=roi * R2e, + Ymi=ymi * R2e, + Rli=rli * R2e, + Roe=roe * R2e, + Li=li * R2e, + Le=le * R2e, + X2e=x2e * R2e, + Y2e=y2e * R2e, + Yme=yme * R2e, + Rme=rme * R2e, + ) + + +def _line_curve_intersections(y_const, curve_x, curve_y): + """Find x where a horizontal-in-(y,x) sweep meets the curve. + + In the MATLAB code the meridional plane is plotted as plot(y, x), + so axes are swapped. We mirror that: given y_const (radius), find + points (y_const, x) on the curve sampled at (curve_y, curve_x). + """ + pts = [] + for i in range(len(curve_x) - 1): + y0, y1 = curve_y[i], curve_y[i + 1] + if (y0 - y_const) * (y1 - y_const) <= 0 and y1 != y0: + t = (y_const - y0) / (y1 - y0) + x = curve_x[i] + t * (curve_x[i + 1] - curve_x[i]) + pts.append((y_const, x)) + return pts + + +def build_meridional(Vo, H, nrpm, N, n=80): + nr = 2 * math.pi * nrpm / 60.0 + d = meridional_dims(Vo, H, nr, N) + no = d["no"] + if not (0.1 <= no <= 0.8): + raise ValueError( + f"无量纲比转速 no={no:.3f} 超出 Bovet 法适用范围 [0.1, 0.8]" + ) + + Li, Le = d["Li"], d["Le"] + Bo, Roi, Roe = d["Bo"], d["Roi"], d["Roe"] + Ymi, Yme = d["Ymi"], d["Yme"] + R2e = d["R2e"] + + # Hub curve + x1_raw = np.linspace(0, Li / 4, n) + y1_raw = Ymi * bovet_curve(x1_raw, Li) + x1 = -x1_raw + (Le + Bo) + y1 = -y1_raw + Roi + + # Shroud curve + x2_raw = np.linspace(0, Le, n) + y2_raw = Yme * bovet_curve(x2_raw, Le) + x2 = -x2_raw + Le + y2 = -y2_raw + Roe + + # Streamlines + streams = [] + for i in range(1, N + 1): + xi = x1 + (x2 - x1) * i / (N + 1) + yi = y1 + (y2 - y1) * i / (N + 1) + streams.append((xi, yi)) + + # LE / TE intersections — in (y=radius, x=axial) + def first(pts): + return pts[0] if pts else None + + def max_x(pts): + return max(pts, key=lambda p: p[1]) if pts else None + + def min_x(pts): + return min(pts, key=lambda p: p[1]) if pts else None + + LE1 = first(_line_curve_intersections(d["Rli"], x1, y1)) + LE2 = max_x(_line_curve_intersections(R2e * r1e_poly(no), x2, y2)) + TE1 = first(_line_curve_intersections(R2e * r2i_poly(no), x1, y1)) + TE2 = min_x(_line_curve_intersections(R2e, x2, y2)) + + le_curve = te_curve = None + if None not in (LE1, LE2): + le_curve = _parabola(LE1, LE2, n) + if None not in (TE1, TE2): + te_curve = _parabola(TE1, TE2, n) + + return dict( + dims=d, + hub=(x1, y1), + shroud=(x2, y2), + streams=streams, + LE1=LE1, LE2=LE2, TE1=TE1, TE2=TE2, + le_curve=le_curve, te_curve=te_curve, + ) + + +def _parabola(P1, P2, n): + """y = a*(x - P2_x)^2 + P2_y passing through P1 (x is radius here).""" + y1, x1 = P1 # (radius, axial) + y2, x2 = P2 + if (y1 - y2) == 0: + xs = np.linspace(y1, y2, n) + return xs, np.linspace(x1, x2, n) + a = (x1 - x2) / ((y1 - y2) ** 2) + xs = np.linspace(y1, y2, n) + ys = a * (xs - y2) ** 2 + x2 + return xs, ys + + +# ---------- GUI ---------- + +class FrancisGUI: + def __init__(self, root): + self.root = root + root.title("Francis 转轮设计 — Bovet 法") + root.geometry("1100x720") + + left = ttk.Frame(root, padding=12) + left.pack(side=tk.LEFT, fill=tk.Y) + + ttk.Label(left, text="输入参数", font=("", 13, "bold")).pack(anchor=tk.W, pady=(0, 8)) + + self.vars = {} + for key, label, default in [ + ("Vo", "流量 Q (m³/s)", "0.225"), + ("H", "水头 H (m)", "12"), + ("nrpm", "转速 (rpm)", "750"), + ("N", "流线数 N", "3"), + ]: + row = ttk.Frame(left) + row.pack(fill=tk.X, pady=4) + ttk.Label(row, text=label, width=18).pack(side=tk.LEFT) + v = tk.StringVar(value=default) + ttk.Entry(row, textvariable=v, width=12).pack(side=tk.LEFT) + self.vars[key] = v + + ttk.Button(left, text="生成 / 刷新", command=self.regenerate).pack( + fill=tk.X, pady=(12, 4) + ) + ttk.Button(left, text="保存图片…", command=self.save_fig).pack(fill=tk.X, pady=4) + + ttk.Separator(left).pack(fill=tk.X, pady=10) + ttk.Label(left, text="计算结果", font=("", 12, "bold")).pack(anchor=tk.W) + + self.result = tk.Text(left, width=36, height=22, font=("Courier", 10)) + self.result.pack(fill=tk.BOTH, expand=True, pady=(6, 0)) + + # Right: plot + right = ttk.Frame(root) + right.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) + + self.fig = Figure(figsize=(7, 6), dpi=100) + self.ax = self.fig.add_subplot(111) + self.canvas = FigureCanvasTkAgg(self.fig, master=right) + self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + NavigationToolbar2Tk(self.canvas, right) + + self.regenerate() + + def _read_inputs(self): + return ( + float(self.vars["Vo"].get()), + float(self.vars["H"].get()), + float(self.vars["nrpm"].get()), + int(self.vars["N"].get()), + ) + + def regenerate(self): + try: + Vo, H, nrpm, N = self._read_inputs() + data = build_meridional(Vo, H, nrpm, N) + except Exception as e: + messagebox.showerror("错误", str(e)) + return + + d = data["dims"] + self.result.delete("1.0", tk.END) + lines = [ + f"无量纲比转速 no = {d['no']:.4f}", + f"出口外径 R2e = {d['R2e']*1000:.2f} mm", + f"进口高度 Bo = {d['Bo']*1000:.2f} mm", + f"轮毂半径 Roi = {d['Roi']*1000:.2f} mm", + f"外环半径 Roe = {d['Roe']*1000:.2f} mm", + f"轴向长 Li/Le = {d['Li']*1000:.1f} / {d['Le']*1000:.1f} mm", + f"LE 半径 Rli = {d['Rli']*1000:.2f} mm", + "", + "LE1 (hub): " + _fmt(data["LE1"]), + "LE2 (shroud): " + _fmt(data["LE2"]), + "TE1 (hub): " + _fmt(data["TE1"]), + "TE2 (shroud): " + _fmt(data["TE2"]), + ] + self.result.insert(tk.END, "\n".join(lines)) + + ax = self.ax + ax.clear() + ax.set_title(f"子午面 (no = {d['no']:.3f})") + ax.set_xlabel("径向 r (m)") + ax.set_ylabel("轴向 z (m)") + ax.grid(True, alpha=0.3) + + x1, y1 = data["hub"] + x2, y2 = data["shroud"] + ax.plot(y1, x1, "b-", lw=2, label="轮毂 hub") + ax.plot(y2, x2, "r-", lw=2, label="外环 shroud") + for i, (xi, yi) in enumerate(data["streams"]): + ax.plot(yi, xi, "g--", lw=1, label="流线" if i == 0 else None) + + if data["le_curve"]: + xs, ys = data["le_curve"] + ax.plot(xs, ys, "m-", lw=1.5, label="前缘 LE") + if data["te_curve"]: + xs, ys = data["te_curve"] + ax.plot(xs, ys, "c-", lw=1.5, label="尾缘 TE") + + for name in ("LE1", "LE2", "TE1", "TE2"): + p = data[name] + if p: + ax.plot(p[0], p[1], "k*", ms=10) + ax.annotate(name, (p[0], p[1]), textcoords="offset points", xytext=(6, 6)) + + ax.set_aspect("equal", adjustable="datalim") + ax.legend(loc="best", fontsize=9) + self.canvas.draw() + + def save_fig(self): + from tkinter import filedialog + + path = filedialog.asksaveasfilename( + defaultextension=".png", + filetypes=[("PNG", "*.png"), ("PDF", "*.pdf"), ("SVG", "*.svg")], + ) + if path: + self.fig.savefig(path, dpi=200, bbox_inches="tight") + messagebox.showinfo("已保存", path) + + +def _fmt(p): + return f"r={p[0]*1000:7.2f} mm, z={p[1]*1000:7.2f} mm" if p else "未找到" + + +def main(): + root = tk.Tk() + FrancisGUI(root) + root.mainloop() + + +if __name__ == "__main__": + main() From 3ae9f763e42021c488b3f33e0804cb77b2611b2a Mon Sep 17 00:00:00 2001 From: user Date: Wed, 10 Jun 2026 14:08:10 +0000 Subject: [PATCH 2/2] Add .gitignore for Python cache --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc