wake_real.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. """真实山地风场尾流图 (已脱敏)。
  2. 布局 = 我们做过的一个真实山地风场 90 台机位; 风向分布 = 该场 1,218,810 条实测 10min 记录。
  3. 脱敏: 经纬度转本地米制后做刚性变换(旋转+平移) —— 相对几何不变(尾流物理为真), 反查不出位置;
  4. 台号重编, 不出现场名/业主/厂商。
  5. 边界: 平坦地形假设(未建地形), 推力曲线为通用示意值 → 只看相对形态与排序, 不报绝对损失。
  6. 引擎: PyWake (DTU, MIT) · Bastankhah-Porte-Agel 2014。"""
  7. import os, math, numpy as np, matplotlib
  8. matplotlib.use("Agg")
  9. import matplotlib.pyplot as plt
  10. from matplotlib import font_manager as fm
  11. from py_wake.site import UniformSite
  12. from py_wake.wind_turbines import WindTurbine
  13. from py_wake.wind_turbines.power_ct_functions import PowerCtTabular
  14. from py_wake.literature.gaussian_models import Bastankhah_PorteAgel_2014
  15. for cand in ["PingFang SC", "Hiragino Sans GB", "Songti SC", "STHeiti"]:
  16. try:
  17. fm.findfont(fm.FontProperties(family=cand), fallback_to_default=False)
  18. matplotlib.rcParams["font.sans-serif"] = [cand, "DejaVu Sans"]; break
  19. except Exception: continue
  20. matplotlib.rcParams["axes.unicode_minus"] = False
  21. # ---- 浅蓝主题 (与观澜仿真台页面同一套 token) ----
  22. BG="#edf7f9"; PANEL="#ffffff"; INK="#133047"; INK2="#28536b"; MUT="#61788b"
  23. LINE="#cbe2eb"; ACC="#0e8898"; MEAS="#2077a8"; AMB="#d48818"; BAD="#d94b43"; GRN="#23875b"
  24. from matplotlib.colors import LinearSegmentedColormap
  25. # 自由来流 = 近乎页面底色, 尾流越深越蓝
  26. WAKE_CMAP = LinearSegmentedColormap.from_list("guanlan_wake",
  27. ["#0d4a63", "#2077a8", "#6cb6d4", "#b6e0ec", "#e8f6fa"])
  28. DEF_CMAP = LinearSegmentedColormap.from_list("guanlan_deficit",
  29. ["#e8f6fa", "#f0d9a8", "#d48818", "#d94b43", "#8c2420"])
  30. MAP="/Users/yuanying/wind-analytics/app_ETL/configs/contracts/mawang_machine_map.csv"
  31. # ★ 数据实况: 本表 90 台里混了两套坐标系 —— 50 台是经纬度(WGS84), 40 台(二期,上海电气)是 CGCS2000 3度带 CM108E 投影米制。
  32. # 直接当经纬度读会把比例尺拉爆 1e11 量级。此处逐行判别后统一到 WGS84。
  33. from pyproj import Transformer
  34. utm2ll = Transformer.from_crs("EPSG:4545", "EPSG:4326", always_xy=True) # CGCS2000 3度带 CM108E -> lon/lat
  35. lat=[]; lon=[]; dia=[]; n_ll=0; n_utm=0
  36. with open(MAP, encoding="utf-8-sig") as fh:
  37. import csv as _csv
  38. for r in _csv.DictReader(fh):
  39. try: a=float(r["lat"]); o=float(r["lon"])
  40. except (TypeError, ValueError): continue
  41. if 73 < o < 136 and 18 < a < 54:
  42. la_, lo_ = a, o; n_ll += 1
  43. elif 1e5 < o < 1e6 and 1e6 < a < 6e6: # 投影米制
  44. lo_, la_ = utm2ll.transform(o, a); n_utm += 1
  45. else:
  46. continue
  47. m = r.get("model","") or ""
  48. d = 121. if "121" in m else (116. if "116" in m else 118.)
  49. lat.append(la_); lon.append(lo_); dia.append(d)
  50. print(f"坐标解析: 经纬度(一期) {n_ll} 台 + 3度带投影(二期) {n_utm} 台 = {len(lat)} 台")
  51. lat=np.array(lat); lon=np.array(lon); dia=np.array(dia)
  52. R=6371000.; lat0=lat.mean(); lon0=lon.mean()
  53. x = R*np.radians(lon-lon0)*math.cos(math.radians(lat0))
  54. y = R*np.radians(lat-lat0)
  55. assert np.ptp(x) < 4e4 and np.ptp(y) < 4e4, f"场跨度异常: {np.ptp(x):.0f} x {np.ptp(y):.0f} m"
  56. print(f"场跨度: {np.ptp(x):.0f} m (东西) x {np.ptp(y):.0f} m (南北)")
  57. # --- 脱敏刚性变换: 坐标与风向同步旋转, 尾流几何完全不变 ---
  58. TH = math.radians(20.0) # 温和旋转脱敏(保持横构图); 风向必须同步 **减去** 该角
  59. # 已验: wd = wd_true - theta 时逐台亏损与不旋转基准逐位相同 (最大差 0.0000);
  60. # 若写成 + theta, 尾流会朝错误方向拖 (曾错, 已修)
  61. xr = x*math.cos(TH) - y*math.sin(TH)
  62. yr = x*math.sin(TH) + y*math.cos(TH)
  63. xr -= xr.min(); yr -= yr.min()
  64. WD_TRUE = 165.0 # 实测主导风向 (33.5%, n=1,218,810)
  65. WD = (WD_TRUE - math.degrees(TH)) % 360 # 已验: 逐台亏损与不旋转基准逐位相同
  66. D_MEAN = float(np.mean(dia))
  67. def mk_wt(D, H, kw):
  68. u=np.arange(0,26.); p=np.clip(((u-3)/(11-3))**3,0,1)*kw*1e3; p[u<3]=0; p[u>25]=0
  69. ct=np.where((u>=3)&(u<=25), np.clip(0.8*np.minimum(1,(11/np.maximum(u,3))**2),.05,.8),0)
  70. return WindTurbine(name="WT", diameter=D, hub_height=H, powerCtFunction=PowerCtTabular(u,p,'w',ct))
  71. site=UniformSite(p_wd=[1], ti=.11, ws=8.)
  72. wfm=Bastankhah_PorteAgel_2014(site, mk_wt(D_MEAN, 80., 2000.), k=0.0324555)
  73. sim=wfm(xr, yr, wd=WD, ws=8.)
  74. fmap=sim.flow_map(wd=WD, ws=8.)
  75. Z=fmap.WS_eff.squeeze().values; X=fmap.x.values; Y=fmap.y.values
  76. # flow_map 的 WS_eff 已是 (len(Y), len(X)); 不得转置。
  77. # (旧代码写 "if Z.shape[0]==len(X): Z=Z.T" —— 网格是 500x500 方阵时每次误触发, 把尾流画成竖向)
  78. assert Z.shape == (len(Y), len(X)), f"flow_map 方向变了: {Z.shape} vs {(len(Y), len(X))}"
  79. deficit = (8.0 - np.asarray(sim.WS_eff.squeeze().values, dtype=float))/8.0*100.
  80. fig=plt.figure(figsize=(14.4, 8.6), facecolor=BG)
  81. ax=fig.add_axes([.055,.085,.70,.735]); ax.set_facecolor(BG)
  82. c=ax.contourf(X, Y, Z, levels=np.linspace(5.6,8.15,64), cmap=WAKE_CMAP, extend="both")
  83. order=np.argsort(-deficit)
  84. sc=ax.scatter(xr, yr, s=34, c=deficit, cmap=DEF_CMAP, vmin=0, vmax=max(12,deficit.max()),
  85. edgecolors="#ffffff", linewidths=.7, zorder=6)
  86. offs = [(12,12), (12,-18), (-46,14)]
  87. for k, i in enumerate(order[:3]):
  88. ax.annotate(f"#{i+1}", (xr[i], yr[i]), textcoords="offset points", xytext=offs[k],
  89. color=AMB, fontsize=12, weight="bold", zorder=8,
  90. arrowprops=dict(arrowstyle="-", color=AMB, lw=.9, alpha=.75))
  91. # 风向箭头
  92. fu = -math.sin(math.radians(WD)); fv = -math.cos(math.radians(WD)) # 顺风单位向量
  93. L = .085
  94. ax.annotate("", xy=(.055+fu*L, .085+fv*L), xytext=(.055, .085), xycoords="axes fraction",
  95. arrowprops=dict(arrowstyle="-|>", color=ACC, lw=2.4))
  96. ax.text(.055+fu*L+.018, .085+fv*L, "来流方向", transform=ax.transAxes,
  97. color=ACC, fontsize=11, va="center")
  98. ax.set_aspect("equal"); ax.tick_params(colors=MUT, labelsize=8.5)
  99. for s in ax.spines.values(): s.set_color(LINE)
  100. ax.set_xlabel("场内相对坐标 (m) — 已做刚性变换脱敏", color=MUT, fontsize=9.5)
  101. ax.set_ylabel("场内相对坐标 (m)", color=MUT, fontsize=9.5)
  102. cax=fig.add_axes([.762,.42,.011,.40]); cb=fig.colorbar(c,cax=cax)
  103. cb.set_label("尾流后有效风速 (m/s)", color=INK2, fontsize=9.5)
  104. cb.ax.yaxis.set_tick_params(color=MUT, labelsize=8.5)
  105. plt.setp(plt.getp(cb.ax.axes,'yticklabels'), color=MUT); cb.outline.set_edgecolor(LINE)
  106. cax2=fig.add_axes([.845,.42,.011,.40]); cb2=fig.colorbar(sc,cax=cax2)
  107. cb2.set_label("该机位的风速亏损 (%)", color=INK2, fontsize=9.5)
  108. cb2.ax.yaxis.set_tick_params(color=MUT, labelsize=8.5)
  109. plt.setp(plt.getp(cb2.ax.axes,'yticklabels'), color=MUT); cb2.outline.set_edgecolor(LINE)
  110. # 风玫瑰 (实测)
  111. rose=[0.6,2.5,2.7,4.0,14.2,33.5,26.6,8.1,2.7,2.2,2.4,0.5]
  112. axr=fig.add_axes([.775,.085,.185,.245], projection="polar"); axr.set_facecolor(BG)
  113. th=np.radians(np.arange(0,360,30)+15)
  114. axr.bar(th, rose, width=np.radians(29), color=ACC, alpha=.72, edgecolor=LINE)
  115. axr.bar(np.radians(165), 33.5, width=np.radians(29), color=AMB, edgecolor="#8a5510")
  116. axr.set_theta_zero_location("N"); axr.set_theta_direction(-1)
  117. axr.set_yticklabels([]); axr.tick_params(colors=MUT, labelsize=7.5)
  118. axr.grid(color=LINE, lw=.6); axr.spines["polar"].set_color(LINE)
  119. axr.set_title("实测风向分布 · 主导 165°", color=INK2, fontsize=9.5, pad=6)
  120. fig.text(.055,.955,"一个真实山地风场的尾流图", color=INK, fontsize=20, weight="bold")
  121. fig.text(.055,.912,f"{len(xr)} 台机位为实际坐标(两期、三家整机厂混排);风向分布来自该场 1,218,810 条实测 10 分钟记录,主导风向占 33.5%。",
  122. color=INK2, fontsize=12.2)
  123. fig.text(.62,.955,"标注 = 该风向下尾流最深的 3 台", color=AMB, fontsize=10.5)
  124. fig.text(.055,.884,"已脱敏:坐标做刚性变换(相对几何不变,尾流物理为真),台号重编,不含场名与厂商。"
  125. "边界:平坦地形假设,推力曲线为通用示意值 → 只看相对形态与排序,不报绝对损失。",
  126. color=MUT, fontsize=9.3)
  127. fig.text(.055,.018,"引擎:PyWake(丹麦技术大学,MIT 许可)· Bastankhah–Porté-Agel 2014 高斯尾流模型",
  128. color=MUT, fontsize=8.8)
  129. out=os.path.join(os.path.dirname(__file__),"wake_real.png")
  130. fig.savefig(out, dpi=150, facecolor=BG); print("SAVED", out)
  131. print("n_turbines", len(xr), "| deficit max %.1f%% mean %.1f%%" % (deficit.max(), deficit.mean()))