wakemarch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wakemarch/__init__.py +9 -0
- wakemarch/_kernel.py +538 -0
- wakemarch/_progress.py +57 -0
- wakemarch/data/FLORIS_LICENSE.txt +26 -0
- wakemarch/data/iea_10MW.json +89 -0
- wakemarch/data/iea_15MW.json +179 -0
- wakemarch/data/nrel_5MW.json +179 -0
- wakemarch/model.py +219 -0
- wakemarch/turbine.py +260 -0
- wakemarch-0.1.0.dist-info/METADATA +197 -0
- wakemarch-0.1.0.dist-info/RECORD +15 -0
- wakemarch-0.1.0.dist-info/WHEEL +5 -0
- wakemarch-0.1.0.dist-info/licenses/LICENSE +28 -0
- wakemarch-0.1.0.dist-info/licenses/src/wakemarch/data/FLORIS_LICENSE.txt +26 -0
- wakemarch-0.1.0.dist-info/top_level.txt +1 -0
wakemarch/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""WakeMarch: steady wake model for a single yawed wind turbine with curled wake shape."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from .model import Numerics, WakeModel, WakeResult
|
|
6
|
+
from .turbine import Turbine, FixedTurbine, OperatingPoint, available_turbines
|
|
7
|
+
|
|
8
|
+
__version__ = version("wakemarch")
|
|
9
|
+
__all__ = ["Numerics", "WakeModel", "WakeResult", "Turbine", "FixedTurbine", "OperatingPoint", "available_turbines", "__version__"]
|
wakemarch/_kernel.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Numerical kernel copied from the authors' research solver (internal module).
|
|
2
|
+
|
|
3
|
+
The recommended configuration is preserved. This module retains historical
|
|
4
|
+
research switches for provenance, not as a supported public API. Use WakeModel.
|
|
5
|
+
The cross-plane conservative advection form does not establish exact circulation
|
|
6
|
+
conservation with variable-coefficient diffusion. Force-based initialization is
|
|
7
|
+
an engineering closure; the discrete circulation need not equal its target.
|
|
8
|
+
Power-law background shear is included; a resolved ground boundary layer and
|
|
9
|
+
wake pressure gradients are not. See README.md and PROVENANCE.md.
|
|
10
|
+
"""
|
|
11
|
+
import numpy as np
|
|
12
|
+
import scipy.sparse as sp
|
|
13
|
+
from scipy.sparse.linalg import splu
|
|
14
|
+
from scipy.ndimage import map_coordinates
|
|
15
|
+
from scipy.interpolate import RegularGridInterpolator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def lap1d(n, h):
|
|
19
|
+
return sp.diags([np.ones(n - 1), -2 * np.ones(n), np.ones(n - 1)], [-1, 0, 1]) / h**2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def lap1d_neumann_first(n, h):
|
|
23
|
+
"""Rows 0..n-1: Neumann (mirror ghost) at row 0, Dirichlet zero beyond row n-1."""
|
|
24
|
+
T = lap1d(n, h).tolil()
|
|
25
|
+
T[0, 1] = 2.0 / h**2
|
|
26
|
+
return T.tocsc()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def laplacian_2d(ny, nz, dy, dz):
|
|
30
|
+
"""Laplacian on a (nz, ny) C-ordered field (index = iz*ny + iy), zero Dirichlet outside."""
|
|
31
|
+
return sp.kron(sp.eye(nz), lap1d(ny, dy)) + sp.kron(lap1d(nz, dz), sp.eye(ny))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class YawedWakeSolver:
|
|
35
|
+
def __init__(self, D=126.0, H=90.0, U=8.0, CT=0.8, yaw_deg=25.0, TI=0.06,
|
|
36
|
+
Ly_D=8.0, ztop_D=3.5, h_D=1.0 / 30.0, sigma0_D=0.25, k_wake=None, ground=True,
|
|
37
|
+
gamma_scale=1.0, nu_omega_factor=1.0, theory="v3", entrainment=True, stretching=False, zbot_D=None, time_order=2, jacobian=True,
|
|
38
|
+
gamma_model="ref", nu_factor=1.0, freeze_vorticity=False, profile_q=0.0, shear_alpha=0.0, open_bc=True, thrust_ratio=None, swirl_CQ=0.0, swirl_sign=1.0, thrust_exp=None, hole_depth=0.0, hole_radius=0.25, hole_shape="gauss", hub_vortex_CQ=0.0, hub_vortex_sign=1.0, thrust_momentum=None, ct_cap=0.97, momentum_fraction=None, near_ramp=None, nu_near=None, edge_width=0.1, kappa_far=None, ground_mix=None, surface_visc=None):
|
|
39
|
+
self.D, self.H, self.U, self.CT, self.TI = D, H, U, CT, TI
|
|
40
|
+
self.yaw = np.radians(yaw_deg)
|
|
41
|
+
self.k_wake = 0.3837 * TI + 0.003678 if k_wake is None else k_wake # Niayifar-type growth rate
|
|
42
|
+
self.sigma0_D = sigma0_D # used by v1 only; v3 derives sigma0 = R_w/2
|
|
43
|
+
self.gamma_scale, self.nu_omega_factor = gamma_scale, nu_omega_factor
|
|
44
|
+
self.theory, self.ground = theory, ground
|
|
45
|
+
self.entrainment, self.stretching = entrainment, stretching # ablation switches (v3)
|
|
46
|
+
self.jacobian = jacobian # Jacobian factor exp(-dx div(v/u)) on omega advection (v3)
|
|
47
|
+
self.gamma_model = gamma_model # 'ref': Gamma0 = 0.5 D CT U sin(yaw) cos^2(yaw) (Shapiro 2018); 'impulse': lateral-impulse closed form
|
|
48
|
+
self.shear_alpha = shear_alpha # power-law background shear U(z) = U (z/H)^alpha (0 = uniform inflow); z floored at 0.05 H
|
|
49
|
+
self.open_bc = open_bc # open far field: Dirichlet data of psi and phi from a multipole expansion of the free-space (plus ground image) potential
|
|
50
|
+
if thrust_ratio is None and thrust_exp is not None:
|
|
51
|
+
thrust_ratio = float(np.cos(np.radians(yaw_deg)) ** thrust_exp) # T_x = T_x0 cos^p(yaw) with T_x0 the unyawed thrust; p = 3 reproduces the constant-C_T default
|
|
52
|
+
if thrust_momentum is None:
|
|
53
|
+
thrust_momentum = thrust_ratio is not None # default: when a thrust law is given, the initial momentum follows it as well as the circulation
|
|
54
|
+
_final = thrust_momentum and thrust_ratio is not None # 'final-model mode': a thrust law is given
|
|
55
|
+
if momentum_fraction is None:
|
|
56
|
+
momentum_fraction = 0.95 if _final else 1.0 # wake momentum / T_x downstream of the pressure recovery (LES: 0.91-0.95 at 3-5 D, tail-corrected)
|
|
57
|
+
if near_ramp is None and _final:
|
|
58
|
+
near_ramp = (0.5, 1.0) # display of the first diameter (LES disk-mean deficit 0.53, 0.80, 0.92, 1 of its 1 D value at 0, 0.25, 0.5, 1 D); False switches it off
|
|
59
|
+
if near_ramp is False:
|
|
60
|
+
near_ramp = None
|
|
61
|
+
self.edge_width = edge_width # width of the smoothed edge of the initial wake mask in units of the wake radius (tanh)
|
|
62
|
+
self.surface_visc = surface_visc # (zc, fmin, on_deficit): surface-layer viscosity f(z) = max(fmin, min(1, z_abs/(zc D))) on the vorticity (and optionally the deficit) diffusivity (None: off)
|
|
63
|
+
self.ground_mix = ground_mix # (a, zr, on_deficit): vortex (and optionally deficit) diffusivity multiplied by 1 + a max(0, 1 - z_abs/(zr D)) near the ground (None: off)
|
|
64
|
+
self.kappa_far = kappa_far # (k1, xa, xb): vortex-diffusivity factor ramps linearly from nu_omega_factor at xa to k1 at xb diameters (None: constant)
|
|
65
|
+
self.nu_near = nu_near # (f0, x_c): eddy viscosity (deficit and vorticity) reduced to f0 at the rotor and growing linearly to 1 at x_c diameters (thin shear layers of the near wake); None: off
|
|
66
|
+
self.near_ramp = near_ramp # (g0, x_r): near-wake display ramp, deficit multiplied by g(x_s) = 1 - (1 - g0)(1 - x_s/x_r)^2 for x_s < x_r (x_s: distance from the tilted rotor plane, in D); the marching is not affected
|
|
67
|
+
self.CT_eff = CT # thrust coefficient on the normal velocity that carries the initial streamwise momentum
|
|
68
|
+
if thrust_momentum and thrust_ratio is not None: # momentum-consistent with the adopted thrust law: T_x = T_x0 * ratio, i.e. C_T,eff = C_T ratio / cos^3(yaw), limited by momentum theory (C_T,eff <= ct_cap)
|
|
69
|
+
self.CT_eff = float(min(CT * thrust_ratio * momentum_fraction / np.cos(np.radians(yaw_deg)) ** 3, max(ct_cap, CT))) # momentum_fraction: share of T_x that the wake carries downstream of the pressure recovery (LES: 0.91-0.95 at 3-5 D); the circulation is not scaled
|
|
70
|
+
self.thrust_ratio = thrust_ratio # None: constant C_T on the normal velocity (Gamma0 = Gamma_ref). Else the measured ratio Theta_yawed/Theta_unyawed of the streamwise momentum deficit: Gamma0 = Gamma_ref thrust_ratio / cos^3(yaw), i.e. 4 F_y/(pi rho D U) with the actual lateral force
|
|
71
|
+
self.swirl_CQ = swirl_CQ # wake-rotation torque coefficient C_Q = Q/(0.5 rho A U^2 R) = C_P/lambda (0: no swirl)
|
|
72
|
+
self.swirl_sign = swirl_sign # +1: wake rotates counter-clockwise seen from upstream (clockwise rotor); -1: opposite
|
|
73
|
+
self.hole_shape = hole_shape # 'gauss': f = 1 - h exp(-(rho/rho_h)^2); 'ramp': f = 1 - h (1 - smoothstep(rho/rho_h)), a hub-to-ring ramp of radius rho_h
|
|
74
|
+
self.hole_depth, self.hole_radius = hole_depth, hole_radius # hub hole of the initial deficit: f = 1 - depth exp(-(rho/hole_radius)^2), rho = distance from the axis in units of the wake radius; mean over the disc kept (same mass flux)
|
|
75
|
+
self.hub_vortex_CQ, self.hub_vortex_sign = hub_vortex_CQ, hub_vortex_sign # optional rotation module: hub vortex Gamma_c = 2.25 C_Q U D (C_Q = unyawed torque coefficient, scaled by cos^1.5 yaw), radius 0.11 D, compensating ring of opposite circulation at 0.55 D; sign +1: clockwise rotor seen from upstream
|
|
76
|
+
self.profile_q = profile_q # initial deficit shape (1-rho^2)^q (q = 0: top-hat); same mass flux and, via the radius, same thrust
|
|
77
|
+
self.nu_factor = nu_factor # multiplier of the deficit eddy viscosity (closure parameter, default 1)
|
|
78
|
+
self.freeze_vorticity = freeze_vorticity # ablation: vortices fixed at the rotor axis, no transport, no decay (as in the 2021 curled wake model)
|
|
79
|
+
self.time_order = time_order # 2: time-centred (predictor-corrector) induced velocity; 1: frozen at the start of the step
|
|
80
|
+
h = h_D * D
|
|
81
|
+
zbot = -H if ground else (-2.5 * D if zbot_D is None else -zbot_D * D) # zbot_D: bottom of a no-ground domain
|
|
82
|
+
self.y = np.arange(-Ly_D / 2 * D, Ly_D / 2 * D + h / 2, h)
|
|
83
|
+
self.z = np.arange(zbot, ztop_D * D + h / 2, h)
|
|
84
|
+
self.ny, self.nz, self.h = len(self.y), len(self.z), h
|
|
85
|
+
self.Y, self.Z = np.meshgrid(self.y, self.z) # shape (nz, ny)
|
|
86
|
+
self._advection_grid = np.meshgrid(np.arange(self.ny, dtype=float),
|
|
87
|
+
np.arange(self.nz, dtype=float))
|
|
88
|
+
nyi, nzi = self.ny - 2, self.nz - 2
|
|
89
|
+
self._poisson = splu(laplacian_2d(nyi, nzi, h, h).tocsc())
|
|
90
|
+
self._L_int = laplacian_2d(nyi, nzi, h, h).tocsr()
|
|
91
|
+
self._ivec = sp.identity(nyi * nzi, format="csc")
|
|
92
|
+
# entrainment potential: Dirichlet far field, Neumann (impermeable) ground
|
|
93
|
+
if ground:
|
|
94
|
+
self._nzphi, self._phi_z0 = self.nz - 1, 0 # rows 0..nz-2, row 0 is the ground
|
|
95
|
+
Lz = lap1d_neumann_first(self._nzphi, h)
|
|
96
|
+
else:
|
|
97
|
+
self._nzphi, self._phi_z0 = self.nz - 2, 1
|
|
98
|
+
Lz = lap1d(self._nzphi, h)
|
|
99
|
+
zabs = np.maximum(self.Z + H, 0.05 * H)
|
|
100
|
+
self.Ubg = U * (zabs / H) ** shear_alpha
|
|
101
|
+
self.dUbg_dz = shear_alpha * self.Ubg / zabs * (self.Z + H > 0.05 * H)
|
|
102
|
+
# deficit diffusion: zero-flux (Neumann) ground, zero Dirichlet elsewhere; omega keeps Dirichlet (image vortex is antisymmetric)
|
|
103
|
+
if ground:
|
|
104
|
+
self._L_du = (sp.kron(sp.eye(self.nz - 1), lap1d(nyi, h)) + sp.kron(lap1d_neumann_first(self.nz - 1, h), sp.eye(nyi))).tocsr()
|
|
105
|
+
if self.open_bc:
|
|
106
|
+
self._build_far_field()
|
|
107
|
+
self._phi_solver = splu((sp.kron(sp.eye(self._nzphi), lap1d(nyi, h)) + sp.kron(Lz, sp.eye(nyi))).tocsc())
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def recommended(cls, D, H, U, CT, yaw_deg, TI, shear_alpha=0.0, **kw):
|
|
111
|
+
"""Minimal-parameter configuration of the methods report: inputs D, H, U (hub speed), C_T (unyawed thrust coefficient), yaw, TI, shear exponent. Constants: growth rate
|
|
112
|
+
k_w = 0.3837 TI + 0.003678, thrust-yaw law T_x = T_x0 cos^2(yaw) with Gamma_0 = 4 F_y / (pi rho D U), vortex diffusivity kappa_omega = 0.5 nu (trade-off: 0.25 for the kidney shape, 1 for the yaw-steering gain)."""
|
|
113
|
+
kw.setdefault("thrust_exp", 2.0); kw.setdefault("nu_omega_factor", 0.5) # defaults of the final model, overridable for sensitivity studies
|
|
114
|
+
return cls(D=D, H=H, U=U, CT=CT, yaw_deg=yaw_deg, TI=TI, shear_alpha=shear_alpha, **kw)
|
|
115
|
+
|
|
116
|
+
# ---- initial state -----------------------------------------------------
|
|
117
|
+
def initial_state_params(self):
|
|
118
|
+
"""Closed-form initial-state parameters of the accepted engineering model (theory.tex, 'Near-wake initial state')."""
|
|
119
|
+
U, CT, g = self.U, self.CT_eff, self.yaw
|
|
120
|
+
c, sn = np.cos(g), np.sin(g)
|
|
121
|
+
s = np.sqrt(1 - CT)
|
|
122
|
+
A = np.pi * self.D**2 / 4
|
|
123
|
+
dU0 = (1 - s) * U * c**2 # streamwise deficit
|
|
124
|
+
uw0 = U - dU0 # wake speed
|
|
125
|
+
mdot = 0.5 * A * U * c * (1 + s) # mass flux / rho through the yawed disk
|
|
126
|
+
Aw = mdot / uw0 # cross-plane wake area
|
|
127
|
+
Rw = np.sqrt(Aw / (np.pi * c)) # ellipse semi-axes (Rw c, Rw)
|
|
128
|
+
vj = (1 - s) * U * sn * c # lateral jump of wake fluid
|
|
129
|
+
Gamma_imp = vj * 2 * Rw * c # lateral-impulse closed form
|
|
130
|
+
Gamma_ref = 0.5 * self.D * U * self.CT * sn * c**2 # Shapiro (2018) / curl-model value
|
|
131
|
+
gscale = self.gamma_scale * (Gamma_ref / Gamma_imp if (self.gamma_model == "ref" and abs(Gamma_imp) > 1e-12) else 1.0)
|
|
132
|
+
if self.thrust_ratio is not None and self.gamma_model == "ref":
|
|
133
|
+
gscale *= self.thrust_ratio / c**3
|
|
134
|
+
Gamma0 = gscale * Gamma_imp
|
|
135
|
+
Fy = 0.5 * A * U**2 * CT * c**2 * sn # lateral force / rho
|
|
136
|
+
Tx = 0.5 * A * U**2 * CT * c**3 # streamwise thrust / rho
|
|
137
|
+
return dict(s=s, dU0=dU0, uw0=uw0, Aw=Aw, Rw=Rw, vj=vj, Gamma0=Gamma0, Gamma0_impulse=Gamma_imp, Gamma_ref=Gamma_ref, gscale=gscale, Fy=Fy, Tx=Tx, mdot=mdot)
|
|
138
|
+
|
|
139
|
+
def circulation_target(self):
|
|
140
|
+
if self.theory == "v1":
|
|
141
|
+
return self.gamma_scale * 0.5 * self.CT * self.U * self.D * np.sin(self.yaw) * np.cos(self.yaw) ** 2
|
|
142
|
+
return self.initial_state_params()["Gamma0"]
|
|
143
|
+
|
|
144
|
+
def initialize(self):
|
|
145
|
+
return self._initialize_v1() if self.theory == "v1" else self._initialize_v3()
|
|
146
|
+
|
|
147
|
+
def _initialize_v1(self):
|
|
148
|
+
R = self.D / 2
|
|
149
|
+
r = np.hypot(self.Y / (R * np.cos(self.yaw)), self.Z / R)
|
|
150
|
+
mask = 0.5 * (1 - np.tanh((r - 1.0) / 0.1))
|
|
151
|
+
a = 0.5 * (1 - np.sqrt(1 - self.CT))
|
|
152
|
+
du = 2 * a * self.U * mask
|
|
153
|
+
iz0 = np.argmin(np.abs(self.z))
|
|
154
|
+
width = np.sum(mask[iz0]) * self.h
|
|
155
|
+
A = self.circulation_target() / width
|
|
156
|
+
v0 = -A * mask
|
|
157
|
+
omega = -np.gradient(v0, self.h, axis=0)
|
|
158
|
+
self.sigma0 = self.sigma0_D * self.D
|
|
159
|
+
return du, omega
|
|
160
|
+
|
|
161
|
+
def _initialize_v3(self):
|
|
162
|
+
p = self.initial_state_params()
|
|
163
|
+
c = np.cos(self.yaw)
|
|
164
|
+
d0 = p["dU0"]
|
|
165
|
+
|
|
166
|
+
def build(R):
|
|
167
|
+
r = np.hypot(self.Y / (R * c), self.Z / R)
|
|
168
|
+
return 0.5 * (1 - np.tanh((r - 1.0) / self.edge_width))
|
|
169
|
+
|
|
170
|
+
q = self.profile_q
|
|
171
|
+
|
|
172
|
+
def prof(R):
|
|
173
|
+
m = build(R)
|
|
174
|
+
rho2 = (self.Y / (R * c)) ** 2 + (self.Z / R) ** 2
|
|
175
|
+
if self.hole_depth > 0.0:
|
|
176
|
+
if self.hole_shape == "ramp":
|
|
177
|
+
t = np.clip(np.sqrt(rho2) / self.hole_radius, 0.0, 1.0); hf = 1.0 - (3 * t**2 - 2 * t**3)
|
|
178
|
+
else:
|
|
179
|
+
hf = np.exp(-rho2 / self.hole_radius**2)
|
|
180
|
+
fm = m * (1.0 - self.hole_depth * hf)
|
|
181
|
+
return fm * (m.sum() / fm.sum()) # mean over the disc equals the top-hat value
|
|
182
|
+
if q <= 0:
|
|
183
|
+
return m
|
|
184
|
+
return m * (q + 1.0) * np.clip(1.0 - rho2, 0.0, None) ** q # mean over the disc equals the top-hat value
|
|
185
|
+
|
|
186
|
+
def theta(R):
|
|
187
|
+
m = prof(R)
|
|
188
|
+
return np.sum((self.U - d0 * m) * d0 * m) * self.h**2
|
|
189
|
+
|
|
190
|
+
# the smoothed mask edge adds momentum: adjust the mask radius (well conditioned) so the discrete Theta0 equals Tx
|
|
191
|
+
from scipy.optimize import brentq
|
|
192
|
+
Reff = brentq(lambda R: theta(R) - p["Tx"], 0.7 * p["Rw"], 1.3 * p["Rw"])
|
|
193
|
+
self.Reff0 = Reff
|
|
194
|
+
mask = build(Reff)
|
|
195
|
+
du = d0 * prof(Reff)
|
|
196
|
+
omega = p["gscale"] * p["vj"] * np.gradient(mask, self.h, axis=0) # omega = -dv/dz, v = -vj*mask
|
|
197
|
+
if self.swirl_CQ != 0.0: # solid-body wake rotation from the rotor torque: x-component of the angular momentum flux = Q cos(yaw)
|
|
198
|
+
R = self.D / 2
|
|
199
|
+
Qx = self.swirl_CQ * 0.5 * (np.pi * R**2) * self.U**2 * R * c
|
|
200
|
+
a_, b_ = Reff * c, Reff
|
|
201
|
+
Om = Qx / (p["uw0"] * np.pi * a_ * b_ * (a_**2 + b_**2) / 4)
|
|
202
|
+
omega = omega - self.swirl_sign * 2.0 * Om * mask
|
|
203
|
+
self.swirl_Omega = Om
|
|
204
|
+
if self.hub_vortex_CQ > 0.0: # hub vortex of the rotor rotation (LES: sigma_c = 0.11 D, Gamma_c = 2.25 C_Q U D, constant to 3 % over yaw 0-40), with the compensating tip-radius ring
|
|
205
|
+
Gc = self.hub_vortex_sign * 2.25 * self.hub_vortex_CQ * np.cos(self.yaw) ** 1.5 * self.U * self.D
|
|
206
|
+
r2 = self.Y**2 + self.Z**2; sc = 0.11 * self.D
|
|
207
|
+
gring = np.exp(-((np.sqrt(r2) - 0.55 * self.D) ** 2) / (2 * (0.05 * self.D) ** 2))
|
|
208
|
+
omega = omega + Gc / (2 * np.pi * sc**2) * np.exp(-r2 / (2 * sc**2)) - Gc * gring / (gring.sum() * self.h**2)
|
|
209
|
+
self.sigma0 = Reff / 2
|
|
210
|
+
self._p0 = p
|
|
211
|
+
return du, omega
|
|
212
|
+
|
|
213
|
+
# ---- open far field ----------------------------------------------------
|
|
214
|
+
NMULT = 14
|
|
215
|
+
|
|
216
|
+
def _build_far_field(self):
|
|
217
|
+
ny, nz = self.ny, self.nz
|
|
218
|
+
# psi: boundary nodes (left/right columns, top row, bottom row when there is no ground)
|
|
219
|
+
mask = np.zeros((nz, ny), bool)
|
|
220
|
+
mask[:, 0] = mask[:, -1] = True; mask[-1, :] = True
|
|
221
|
+
if not self.ground:
|
|
222
|
+
mask[0, :] = True
|
|
223
|
+
self._psi_bmask = mask
|
|
224
|
+
zt = (self.Y + 1j * self.Z)[mask]
|
|
225
|
+
self._psi_zt = zt
|
|
226
|
+
# phi: columns 0 and ny-1 (rows z0..nz-2), top row nz-1, bottom row 0 when there is no ground
|
|
227
|
+
pm = np.zeros((nz, ny), bool)
|
|
228
|
+
z0 = self._phi_z0
|
|
229
|
+
pm[z0:nz - 1, 0] = pm[z0:nz - 1, -1] = True; pm[-1, :] = True
|
|
230
|
+
if not self.ground:
|
|
231
|
+
pm[0, :] = True
|
|
232
|
+
self._phi_bmask = pm
|
|
233
|
+
self._phi_zt = (self.Y + 1j * self.Z)[pm]
|
|
234
|
+
self._zs = self.Y + 1j * self.Z
|
|
235
|
+
self._zs_img = self.Y + 1j * (-2 * self.H - self.Z)
|
|
236
|
+
w = np.zeros((nz, ny)); w[1:-1, 1:-1] = self.h**2
|
|
237
|
+
self._w_psi = w
|
|
238
|
+
wp = np.zeros((nz, ny)); wp[z0:nz - 1, 1:-1] = self.h**2
|
|
239
|
+
if self.ground:
|
|
240
|
+
wp[0, 1:-1] = 0.5 * self.h**2
|
|
241
|
+
self._w_phi = wp
|
|
242
|
+
# Geometry is fixed throughout marching. Preserve the original repeated
|
|
243
|
+
# multiplication and reduction order (no reassociation of the moments).
|
|
244
|
+
self._multipole_powers = []
|
|
245
|
+
zk = np.ones_like(self._zs.ravel())
|
|
246
|
+
zik = np.ones_like(self._zs_img.ravel())
|
|
247
|
+
for _ in range(self.NMULT):
|
|
248
|
+
zk = zk * self._zs.ravel()
|
|
249
|
+
if self.ground:
|
|
250
|
+
zik = zik * self._zs_img.ravel()
|
|
251
|
+
self._multipole_powers.append((zk, zik))
|
|
252
|
+
self._multipole_targets = {}
|
|
253
|
+
for targets in (self._psi_zt, self._phi_zt):
|
|
254
|
+
inv = 1.0 / targets
|
|
255
|
+
ik = np.ones_like(targets)
|
|
256
|
+
powers = []
|
|
257
|
+
for _ in range(self.NMULT):
|
|
258
|
+
ik = ik * inv
|
|
259
|
+
powers.append(ik)
|
|
260
|
+
self._multipole_targets[id(targets)] = (powers, np.log(np.abs(targets)))
|
|
261
|
+
|
|
262
|
+
def _far_field(self, f, w, zt, mask, even_image):
|
|
263
|
+
"""Dirichlet data at the boundary targets for lap(u) = -f in free space (plus the ground image: odd for psi, even for phi):
|
|
264
|
+
u = -(1/2 pi) S, S = sum_s w f [ln|zt - zs| + sg ln|zt - zs_image|], expanded in multipoles about the rotor axis:
|
|
265
|
+
ln|zt - zs| = ln|zt| - Re sum_k (zs/zt)^k / k."""
|
|
266
|
+
K = self.NMULT
|
|
267
|
+
out = np.zeros((self.nz, self.ny))
|
|
268
|
+
src = (w * f).ravel()
|
|
269
|
+
sg = (1.0 if even_image else -1.0) if self.ground else 0.0
|
|
270
|
+
powers, log_radius = self._multipole_targets[id(zt)]
|
|
271
|
+
val = np.zeros(len(zt), dtype=complex)
|
|
272
|
+
for k in range(1, K + 1):
|
|
273
|
+
zk, zik = self._multipole_powers[k - 1]
|
|
274
|
+
ik = powers[k - 1]
|
|
275
|
+
m = (src * zk).sum()
|
|
276
|
+
if self.ground:
|
|
277
|
+
m = m + sg * (src * zik).sum()
|
|
278
|
+
val += m * ik / k
|
|
279
|
+
S = src.sum() * (1.0 + sg) * log_radius - val.real
|
|
280
|
+
out[mask] = -S / (2 * np.pi)
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
# ---- operators ---------------------------------------------------------
|
|
284
|
+
def solve_psi(self, omega):
|
|
285
|
+
rhs = -omega[1:-1, 1:-1]
|
|
286
|
+
psi = np.zeros_like(omega)
|
|
287
|
+
if self.open_bc:
|
|
288
|
+
psi = self._far_field(omega, self._w_psi, self._psi_zt, self._psi_bmask, even_image=False)
|
|
289
|
+
B = np.zeros((self.nz - 2, self.ny - 2))
|
|
290
|
+
B[:, 0] += psi[1:-1, 0]; B[:, -1] += psi[1:-1, -1]; B[0, :] += psi[0, 1:-1]; B[-1, :] += psi[-1, 1:-1]
|
|
291
|
+
rhs = rhs - B / self.h**2
|
|
292
|
+
psi[1:-1, 1:-1] = self._poisson.solve(rhs.ravel()).reshape(self.nz - 2, self.ny - 2)
|
|
293
|
+
return psi
|
|
294
|
+
|
|
295
|
+
def solve_phi(self, dudx):
|
|
296
|
+
"""lap(phi) = -u_x (continuity); Dirichlet far field, Neumann (impermeable) ground."""
|
|
297
|
+
z0 = self._phi_z0
|
|
298
|
+
rhs = -dudx[z0:self.nz - 1, 1:-1]
|
|
299
|
+
phi = np.zeros((self.nz, self.ny))
|
|
300
|
+
if self.open_bc:
|
|
301
|
+
phi = self._far_field(dudx, self._w_phi, self._phi_zt, self._phi_bmask, even_image=True)
|
|
302
|
+
B = np.zeros((self._nzphi, self.ny - 2))
|
|
303
|
+
B[:, 0] += phi[z0:self.nz - 1, 0]; B[:, -1] += phi[z0:self.nz - 1, -1]; B[-1, :] += phi[self.nz - 1, 1:-1]
|
|
304
|
+
if not self.ground:
|
|
305
|
+
B[0, :] += phi[0, 1:-1]
|
|
306
|
+
rhs = rhs - B / self.h**2
|
|
307
|
+
sol = self._phi_solver.solve(rhs.ravel()).reshape(self._nzphi, self.ny - 2)
|
|
308
|
+
phi[z0:self.nz - 1, 1:-1] = sol
|
|
309
|
+
return phi
|
|
310
|
+
|
|
311
|
+
def velocities(self, psi):
|
|
312
|
+
dpsi_dz, dpsi_dy = np.gradient(psi, self.h, self.h, edge_order=2)
|
|
313
|
+
return dpsi_dz, -dpsi_dy # v, w (solenoidal part)
|
|
314
|
+
|
|
315
|
+
def vortex_velocity(self, om, dt):
|
|
316
|
+
"""Solenoidal velocity induced by om, time-centred over a step dt (scalar or array) when time_order == 2."""
|
|
317
|
+
v, w = self.velocities(self.solve_psi(om))
|
|
318
|
+
if self.time_order == 2:
|
|
319
|
+
om_half = self.advect([om], v, w, 0.5 * dt)[0]
|
|
320
|
+
v, w = self.velocities(self.solve_psi(om_half))
|
|
321
|
+
return v, w
|
|
322
|
+
|
|
323
|
+
def _sample(self, f, iy, iz, mirror_ground=False):
|
|
324
|
+
if mirror_ground: # deficit: reflect about the ground (zero flux), constant zero elsewhere
|
|
325
|
+
iz = np.abs(iz)
|
|
326
|
+
return map_coordinates(f, [iz, iy], order=3, mode="constant", cval=0.0)
|
|
327
|
+
|
|
328
|
+
def advect(self, fields, v, w, dt, mirror_first=False):
|
|
329
|
+
"""Semi-Lagrangian RK2 backward trace; v, w frozen over the step; dt scalar or per-point array."""
|
|
330
|
+
iy0, iz0 = self._advection_grid
|
|
331
|
+
iy_mid = iy0 - 0.5 * dt * v / self.h
|
|
332
|
+
iz_mid = iz0 - 0.5 * dt * w / self.h
|
|
333
|
+
v2, w2 = self._sample(v, iy_mid, iz_mid), self._sample(w, iy_mid, iz_mid)
|
|
334
|
+
iy_src = iy0 - dt * v2 / self.h
|
|
335
|
+
iz_src = iz0 - dt * w2 / self.h
|
|
336
|
+
return [self._sample(f, iy_src, iz_src, mirror_ground=(mirror_first and self.ground and i == 0)) for i, f in enumerate(fields)]
|
|
337
|
+
|
|
338
|
+
def diffuse(self, f, nu, dt, neumann_ground=False, fast=True):
|
|
339
|
+
"""Backward Euler on interior nodes, zero Dirichlet outside; coefficient dt*nu scalar or array.
|
|
340
|
+
neumann_ground: zero normal flux at the bottom row (used for the deficit when a ground is present).
|
|
341
|
+
fast: reuse a cached factorisation of (I - cbar L) with iterative refinement towards the exact variable-coefficient system
|
|
342
|
+
(relative residual 1e-9). After a stalled refinement, reuse the exact
|
|
343
|
+
factorization as the next preconditioner; refresh when the mean
|
|
344
|
+
coefficient moves by 12 per cent or refinement stalls again."""
|
|
345
|
+
ng = bool(neumann_ground and self.ground)
|
|
346
|
+
if ng:
|
|
347
|
+
c = np.broadcast_to(dt * nu, f.shape)[:-1, 1:-1]; rhs = f[:-1, 1:-1].ravel(); L = self._L_du; shp = (self.nz - 1, self.ny - 2)
|
|
348
|
+
else:
|
|
349
|
+
c = np.broadcast_to(dt * nu, f.shape)[1:-1, 1:-1]; rhs = f[1:-1, 1:-1].ravel(); L = self._L_int; shp = (self.nz - 2, self.ny - 2)
|
|
350
|
+
cv = np.ascontiguousarray(c).ravel(); n = len(cv)
|
|
351
|
+
out = np.zeros_like(f)
|
|
352
|
+
|
|
353
|
+
def exact_factor():
|
|
354
|
+
A = (sp.identity(n, format="csc") - sp.diags(cv) @ L).tocsc()
|
|
355
|
+
return splu(A)
|
|
356
|
+
|
|
357
|
+
if not fast:
|
|
358
|
+
sol = exact_factor().solve(rhs)
|
|
359
|
+
else:
|
|
360
|
+
cbar = float(cv.mean())
|
|
361
|
+
if cbar <= 0.0: # no diffusion (e.g. kappa_omega = 0): identity
|
|
362
|
+
out_ = np.zeros_like(f)
|
|
363
|
+
if ng:
|
|
364
|
+
out_[:-1, 1:-1] = rhs.reshape(shp)
|
|
365
|
+
else:
|
|
366
|
+
out_[1:-1, 1:-1] = rhs.reshape(shp)
|
|
367
|
+
return out_
|
|
368
|
+
cache = self.__dict__.setdefault("_dcache", {})
|
|
369
|
+
ent = cache.get(ng)
|
|
370
|
+
if ent is None or ent[0] <= 0.0 or abs(cbar / ent[0] - 1.0) > 0.12:
|
|
371
|
+
ent = (cbar, splu((sp.identity(n, format="csc") - cbar * L).tocsc()))
|
|
372
|
+
cache[ng] = ent
|
|
373
|
+
cb, lu = ent
|
|
374
|
+
x = lu.solve(rhs); bn = np.linalg.norm(rhs) + 1e-300
|
|
375
|
+
ok = False
|
|
376
|
+
for _ in range(14):
|
|
377
|
+
r = rhs - (x - cv * (L @ x))
|
|
378
|
+
if np.linalg.norm(r) < 1e-9 * bn:
|
|
379
|
+
ok = True; break
|
|
380
|
+
x = x + lu.solve(r)
|
|
381
|
+
if not ok: # keep the factorization we already paid for
|
|
382
|
+
lu = exact_factor()
|
|
383
|
+
x = lu.solve(rhs)
|
|
384
|
+
cache[ng] = (cbar, lu)
|
|
385
|
+
sol = x
|
|
386
|
+
if ng:
|
|
387
|
+
out[:-1, 1:-1] = sol.reshape(shp)
|
|
388
|
+
else:
|
|
389
|
+
out[1:-1, 1:-1] = sol.reshape(shp)
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
def laplacian(self, f, neumann_ground=False):
|
|
393
|
+
out = np.zeros_like(f)
|
|
394
|
+
out[1:-1, 1:-1] = (f[1:-1, 2:] + f[1:-1, :-2] + f[2:, 1:-1] + f[:-2, 1:-1] - 4 * f[1:-1, 1:-1]) / self.h**2
|
|
395
|
+
if neumann_ground and self.ground:
|
|
396
|
+
out[0, 1:-1] = (f[0, 2:] + f[0, :-2] + 2 * f[1, 1:-1] - 4 * f[0, 1:-1]) / self.h**2
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
# ---- marching ----------------------------------------------------------
|
|
400
|
+
def mix_field(self):
|
|
401
|
+
"""Near-ground enhancement of the diffusivity of `ground_mix` on the grid (1 where off)."""
|
|
402
|
+
f = 1.0
|
|
403
|
+
if self.ground_mix is not None:
|
|
404
|
+
a, zr = self.ground_mix[0], self.ground_mix[1]
|
|
405
|
+
f = f * (1.0 + a * np.maximum(0.0, 1.0 - np.maximum(self.Z + self.H, 0.0) / (zr * self.D)))
|
|
406
|
+
if self.surface_visc is not None:
|
|
407
|
+
zc, fmin = self.surface_visc[0], self.surface_visc[1]
|
|
408
|
+
f = f * np.maximum(fmin, np.minimum(1.0, np.maximum(self.Z + self.H, 0.0) / (zc * self.D)))
|
|
409
|
+
return f
|
|
410
|
+
|
|
411
|
+
def mix_deficit(self):
|
|
412
|
+
"""Same field for the deficit diffusivity when the options ask for it."""
|
|
413
|
+
on = (self.ground_mix is not None and self.ground_mix[2]) or (self.surface_visc is not None and self.surface_visc[2])
|
|
414
|
+
return self.mix_field() if on else 1.0
|
|
415
|
+
|
|
416
|
+
def kappa_at(self, x):
|
|
417
|
+
"""Vortex-diffusivity factor at distance x (m): constant nu_omega_factor, or the linear ramp of `kappa_far`."""
|
|
418
|
+
if self.kappa_far is None:
|
|
419
|
+
return self.nu_omega_factor
|
|
420
|
+
k1, xa, xb = self.kappa_far
|
|
421
|
+
return self.nu_omega_factor + (k1 - self.nu_omega_factor) * min(1.0, max(0.0, (x / self.D - xa) / (xb - xa)))
|
|
422
|
+
|
|
423
|
+
def solve(self, max_dist_D=10.0, dx_D=0.1, store_every_D=0.25, diffusion=True, init_fields=None, progress_callback=None):
|
|
424
|
+
buffer = (3.0 * np.tan(abs(self.yaw)) + 1.0) * self.D # room for the shear-mapped reconstruction
|
|
425
|
+
L = max_dist_D * self.D + buffer
|
|
426
|
+
if progress_callback is not None:
|
|
427
|
+
progress_callback(0.0, L / self.D)
|
|
428
|
+
du, om = self.initialize()
|
|
429
|
+
if init_fields is not None: # (du, om, sigma0) override, used by verification tests
|
|
430
|
+
du, om, self.sigma0 = init_fields[0].copy(), init_fields[1].copy(), init_fields[2]
|
|
431
|
+
xs, dus, oms, dudxs = [0.0], [du.copy()], [om.copy()], [np.zeros_like(du)]
|
|
432
|
+
x, dx = 0.0, dx_D * self.D
|
|
433
|
+
dudx = np.zeros_like(du)
|
|
434
|
+
while x < L:
|
|
435
|
+
if self.theory == "v1":
|
|
436
|
+
vs, ws = self.velocities(self.solve_psi(om))
|
|
437
|
+
else:
|
|
438
|
+
u0 = np.maximum(self.Ubg - du, 0.3 * self.Ubg)
|
|
439
|
+
vs, ws = self.vortex_velocity(om, dx / u0)
|
|
440
|
+
if self.theory == "v1":
|
|
441
|
+
u_loc = self.U * np.cos(self.yaw)
|
|
442
|
+
dt = dx / u_loc
|
|
443
|
+
du, om = self.advect([du, om], vs, ws, dt)
|
|
444
|
+
if diffusion:
|
|
445
|
+
nu = self.k_wake * u_loc * (self.sigma0 + self.k_wake * x)
|
|
446
|
+
du, om = self.diffuse(du, nu, dt), self.diffuse(om, self.nu_omega_factor * nu, dt)
|
|
447
|
+
else:
|
|
448
|
+
u = np.maximum(self.Ubg - du, 0.3 * self.Ubg)
|
|
449
|
+
sigma = self.sigma0 + self.k_wake * x
|
|
450
|
+
sum_du = du.sum()
|
|
451
|
+
u_bar = self.U - (du**2).sum() / sum_du if sum_du > 0 else self.U # deficit-weighted wake speed
|
|
452
|
+
nu = self.k_wake * sigma * u_bar if diffusion else 0.0 # uniform cross-plane eddy viscosity
|
|
453
|
+
if self.nu_near is not None and diffusion:
|
|
454
|
+
f0, xc = self.nu_near; nu = nu * (f0 + (1.0 - f0) * min(1.0, x / (xc * self.D)))
|
|
455
|
+
uz, uy = np.gradient(u, self.h, self.h, edge_order=2)
|
|
456
|
+
lap_u = self.laplacian(u - self.Ubg, neumann_ground=True)
|
|
457
|
+
v, w = vs, ws
|
|
458
|
+
for _ in range(3 if self.entrainment else 1): # u_x depends on (v,w), which depend on u_x via phi
|
|
459
|
+
dudx = (self.nu_factor * nu * lap_u - (v * uy + w * uz)) / u
|
|
460
|
+
if self.entrainment:
|
|
461
|
+
gz, gy = np.gradient(self.solve_phi(dudx), self.h, self.h, edge_order=2)
|
|
462
|
+
v, w = vs + gy, ws + gz
|
|
463
|
+
if self.ground:
|
|
464
|
+
w[0, :] = 0.0
|
|
465
|
+
dt = dx / u
|
|
466
|
+
if self.freeze_vorticity:
|
|
467
|
+
du = self.advect([du], v, w, dt, mirror_first=True)[0]
|
|
468
|
+
else:
|
|
469
|
+
du, om = self.advect([du, om], v, w, dt, mirror_first=True)
|
|
470
|
+
if self.shear_alpha != 0.0:
|
|
471
|
+
du = du + dt * w * self.dUbg_dz # vertical advection of the background shear by the induced flow
|
|
472
|
+
if self.jacobian and not self.freeze_vorticity: # approximate conservative advection update; not an exact discrete integral guarantee
|
|
473
|
+
div = np.gradient(v / u, self.h, axis=1, edge_order=2) + np.gradient(w / u, self.h, axis=0, edge_order=2)
|
|
474
|
+
om = om * np.exp(-dx * div)
|
|
475
|
+
if self.stretching and not self.freeze_vorticity:
|
|
476
|
+
om = om * np.exp(dx * dudx / u)
|
|
477
|
+
if diffusion:
|
|
478
|
+
du = self.diffuse(du, self.nu_factor * nu / u * self.mix_deficit(), dx, neumann_ground=True)
|
|
479
|
+
if not self.freeze_vorticity:
|
|
480
|
+
om = self.diffuse(om, self.kappa_at(x) * nu / u * self.mix_field(), dx)
|
|
481
|
+
x += dx
|
|
482
|
+
if progress_callback is not None:
|
|
483
|
+
progress_callback(min(x / self.D, L / self.D), L / self.D)
|
|
484
|
+
if x - xs[-1] >= store_every_D * self.D - 1e-9:
|
|
485
|
+
xs.append(x); dus.append(du.copy()); oms.append(om.copy()); dudxs.append(dudx.copy())
|
|
486
|
+
self.xs = np.array(xs)
|
|
487
|
+
self.du_hist = np.stack(dus)
|
|
488
|
+
self.om_hist = np.stack(oms)
|
|
489
|
+
self.dudx_hist = np.stack(dudxs)
|
|
490
|
+
self._interp = RegularGridInterpolator((self.xs, self.z, self.y), self.du_hist,
|
|
491
|
+
bounds_error=False, fill_value=0.0)
|
|
492
|
+
|
|
493
|
+
# ---- cross-plane velocities ---------------------------------------------
|
|
494
|
+
def build_velocity_history(self):
|
|
495
|
+
"""Cross-plane velocities (v lateral, w vertical) of every stored plane: solenoidal part from omega plus the entrainment flow."""
|
|
496
|
+
vs_, ws_ = [], []
|
|
497
|
+
for om, dudx in zip(self.om_hist, self.dudx_hist):
|
|
498
|
+
v, w = self.velocities(self.solve_psi(om))
|
|
499
|
+
if self.entrainment:
|
|
500
|
+
gz, gy = np.gradient(self.solve_phi(dudx), self.h, self.h, edge_order=2)
|
|
501
|
+
v, w = v + gy, w + gz
|
|
502
|
+
if self.ground:
|
|
503
|
+
w[0, :] = 0.0
|
|
504
|
+
vs_.append(v); ws_.append(w)
|
|
505
|
+
self.v_hist, self.w_hist = np.stack(vs_), np.stack(ws_)
|
|
506
|
+
self._vint = RegularGridInterpolator((self.xs, self.z, self.y), self.v_hist, bounds_error=False, fill_value=0.0)
|
|
507
|
+
self._wint = RegularGridInterpolator((self.xs, self.z, self.y), self.w_hist, bounds_error=False, fill_value=0.0)
|
|
508
|
+
|
|
509
|
+
def sample_velocity(self, X, Y, Z):
|
|
510
|
+
"""(v, w) at global (X, Y, Z) with the same shear-mapped reconstruction as the deficit."""
|
|
511
|
+
if not hasattr(self, "_vint"):
|
|
512
|
+
self.build_velocity_history()
|
|
513
|
+
xs = X + Y * np.tan(self.yaw)
|
|
514
|
+
pts = np.stack([xs.ravel(), (Z - self.H).ravel(), Y.ravel()], axis=1)
|
|
515
|
+
v, w = self._vint(pts).reshape(X.shape), self._wint(pts).reshape(X.shape)
|
|
516
|
+
v[xs < 0] = 0.0; w[xs < 0] = 0.0
|
|
517
|
+
return v, w
|
|
518
|
+
|
|
519
|
+
# ---- diagnostics / reconstruction -------------------------------------
|
|
520
|
+
def circulation_top(self, om):
|
|
521
|
+
"""Circulation of the vortex above hub height (z>0) and below (z<0)."""
|
|
522
|
+
up, dn = self.Z > 0, self.Z < 0
|
|
523
|
+
return np.sum(om[up]) * self.h**2, np.sum(om[dn]) * self.h**2
|
|
524
|
+
|
|
525
|
+
def momentum_deficit(self, du):
|
|
526
|
+
"""Theta = int u dU dA (per unit density)."""
|
|
527
|
+
return np.sum((self.U - du) * du) * self.h**2
|
|
528
|
+
|
|
529
|
+
def sample_global(self, X, Y, Z):
|
|
530
|
+
"""Shear-mapped reconstruction onto global (X, Y, Z); Z is height above ground."""
|
|
531
|
+
xs = X + Y * np.tan(self.yaw)
|
|
532
|
+
pts = np.stack([xs.ravel(), (Z - self.H).ravel(), Y.ravel()], axis=1)
|
|
533
|
+
out = self._interp(pts).reshape(X.shape)
|
|
534
|
+
out[xs < 0] = 0.0
|
|
535
|
+
if self.near_ramp is not None: # pressure recovery and wake expansion of the first diameter: the developed state is reached at x_r
|
|
536
|
+
g0, xr = self.near_ramp; t = np.clip(xs / (xr * self.D), 0.0, 1.0)
|
|
537
|
+
out = out * (1.0 - (1.0 - g0) * (1.0 - t) ** 2)
|
|
538
|
+
return out
|
wakemarch/_progress.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Small progress display: native notebook HTML or dependency-free terminal text."""
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProgressBar:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.started = time.monotonic()
|
|
9
|
+
self.last_update = 0.0
|
|
10
|
+
self.completed = 0.0
|
|
11
|
+
self.total = None
|
|
12
|
+
self.display = None
|
|
13
|
+
# IPython is optional and imported only if a notebook kernel is running.
|
|
14
|
+
if 'IPython' in sys.modules:
|
|
15
|
+
from IPython import get_ipython
|
|
16
|
+
if getattr(get_ipython(), 'kernel', None) is not None:
|
|
17
|
+
from IPython.display import HTML, display
|
|
18
|
+
self._html = HTML
|
|
19
|
+
self.display = display(HTML(self._markup('Preparing solver', None)), display_id=True)
|
|
20
|
+
if self.display is None:
|
|
21
|
+
self._text('Preparing solver', None)
|
|
22
|
+
|
|
23
|
+
def _markup(self, label, fraction):
|
|
24
|
+
value = '' if fraction is None else f' value="{fraction:.6f}"'
|
|
25
|
+
return (f'<div><strong>WakeMarch</strong> · {label}<br>'
|
|
26
|
+
f'<progress aria-label="WakeMarch solve progress" max="1"{value} '
|
|
27
|
+
'style="width: min(100%, 480px); height: 18px;"></progress></div>')
|
|
28
|
+
|
|
29
|
+
def _text(self, label, fraction):
|
|
30
|
+
bar = '' if fraction is None else '[' + '#' * int(24 * fraction) + '-' * (24 - int(24 * fraction)) + '] '
|
|
31
|
+
sys.stderr.write('\r' + f'WakeMarch {bar}{label}'.ljust(110))
|
|
32
|
+
sys.stderr.flush()
|
|
33
|
+
|
|
34
|
+
def _render(self, label, fraction):
|
|
35
|
+
if self.display is not None:
|
|
36
|
+
self.display.update(self._html(self._markup(label, fraction)))
|
|
37
|
+
else:
|
|
38
|
+
self._text(label, fraction)
|
|
39
|
+
|
|
40
|
+
def __call__(self, completed_D, total_D):
|
|
41
|
+
self.completed, self.total = completed_D, total_D
|
|
42
|
+
now = time.monotonic()
|
|
43
|
+
if now - self.last_update < 0.1 and completed_D < total_D:
|
|
44
|
+
return
|
|
45
|
+
self.last_update = now
|
|
46
|
+
fraction = completed_D / total_D
|
|
47
|
+
self._render(f'Marching {fraction:.0%} · {completed_D:.1f}/{total_D:.1f} D '
|
|
48
|
+
f'(including buffer) · {now-self.started:.1f}s', fraction)
|
|
49
|
+
|
|
50
|
+
def close(self, success):
|
|
51
|
+
fraction = None if self.total is None else self.completed / self.total
|
|
52
|
+
label = 'Complete' if success else 'Stopped before completion'
|
|
53
|
+
self._render(f'{label} · {time.monotonic()-self.started:.1f}s',
|
|
54
|
+
1.0 if success else fraction)
|
|
55
|
+
if self.display is None:
|
|
56
|
+
sys.stderr.write('\n')
|
|
57
|
+
sys.stderr.flush()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Alliance for Energy Innovation LLC, All rights reserved.
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted
|
|
6
|
+
provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
* Redistributions of source code must retain the above copyright notice, this list of conditions
|
|
9
|
+
and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
* Redistributions in binary form must reproduce the above copyright notice, this list of
|
|
12
|
+
conditions and the following disclaimer in the documentation and/or other materials provided
|
|
13
|
+
with the distribution.
|
|
14
|
+
|
|
15
|
+
* Neither the name of the copyright holder nor the names of its contributors may be used to
|
|
16
|
+
endorse or promote products derived from this software without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
|
|
19
|
+
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
20
|
+
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER
|
|
21
|
+
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
22
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
23
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
24
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
|
25
|
+
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
26
|
+
POSSIBILITY OF SUCH DAMAGE.
|