pyfuga 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.
pyfuga/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ import functools
2
+ import inspect
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from . import _fuga
6
+
7
+ try:
8
+ __version__ = version("pyfuga")
9
+ except PackageNotFoundError:
10
+ __version__ = "0+unknown"
11
+
12
+ __all__ = ["get_luts", "__version__"]
13
+
14
+
15
+ @functools.wraps(_fuga.get_luts)
16
+ def get_luts(*args, **kwargs):
17
+ return _fuga.get_luts(*args, **kwargs)
18
+
19
+
20
+ get_luts.__signature__ = inspect.signature(_fuga.get_luts)
pyfuga/_fuga.py ADDED
@@ -0,0 +1,230 @@
1
+ """_fuga.py
2
+ ==========
3
+ Main API entry point for PyFuga — generate wake look-up tables.
4
+
5
+ This module orchestrates the full three-stage PyFuga pipeline:
6
+
7
+ 1. **Preliminary LUTs (preLUTs)** — via :mod:`pyfuga.preluts_generator`
8
+ Integrates atmospheric boundary layer equations between vertical stations.
9
+
10
+ 2. **Fourier LUTs** — via :mod:`pyfuga.flut`
11
+ Solves for spectral wake response to all forcing directions.
12
+
13
+ 3. **Physical-space LUTs** — via :mod:`pyfuga.trafalgar`
14
+ Inverse Fourier transform to obtain wake fields in physical x-y-z space.
15
+
16
+ The entry point is :func:`get_luts`, which orchestrates all three stages,
17
+ handling file caching and data I/O.
18
+ """
19
+
20
+ import os
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+ import xarray as xr
25
+
26
+ from pyfuga import utils
27
+ from pyfuga.constants import UVW_LT
28
+ from pyfuga.flut import FourierLUTGenerator
29
+ from pyfuga.preluts import PreLUTs
30
+ from pyfuga.trafalgar import Trafalgar
31
+ from pyfuga.utils import ComplexXRDataset, get_beta_lst, get_kz0_lst
32
+
33
+
34
+ def get_luts(
35
+ folder,
36
+ zeta0,
37
+ nkz0,
38
+ nbeta,
39
+ diameter,
40
+ zhub,
41
+ z0,
42
+ zi,
43
+ zlow,
44
+ zhigh,
45
+ lut_vars=UVW_LT,
46
+ nx=2048,
47
+ ny=512,
48
+ dx=None,
49
+ dy=None,
50
+ jit=True,
51
+ n_cpu=1,
52
+ ): # pragma: no cover
53
+ """Generate physical-space wake look-up tables via the full PyFuga pipeline.
54
+
55
+ This function orchestrates the three-stage PyFuga pipeline:
56
+ 1. Preliminary LUTs (preLUTs) via pyfuga.preluts_generator
57
+ 2. Fourier LUTs via pyfuga.flut
58
+ 3. Physical-space LUTs via pyfuga.trafalgar
59
+
60
+ Caches intermediate results (preLUTs and Fourier LUTs) as NetCDF files in
61
+ the specified folder, skipping recomputation if files exist.
62
+
63
+ Parameters
64
+ ----------
65
+ folder : str or Path
66
+ Output folder for all generated files (preLUTs, Fourier LUTs, LUTs).
67
+ zeta0 : float
68
+ Monin-Obukhov stability parameter (z0/L). Positive=stable, negative=unstable,
69
+ zero=neutral.
70
+ nkz0 : int
71
+ Spectral density: number of wavenumbers per log decade.
72
+ Total count = 9*nkz0 - (nkz0-1); typically 8--16.
73
+ nbeta : int
74
+ Angular resolution for azimuthal averaging. Total angle count is nbeta + 1.
75
+ diameter : float
76
+ Wind turbine rotor diameter (m).
77
+ zhub : float
78
+ Wind turbine hub height (m).
79
+ z0 : float
80
+ Aerodynamic roughness length (m); defines vertical grid via s = log(z/z0).
81
+ zi : float
82
+ Atmospheric inversion height (m); upper domain boundary.
83
+ zlow : float
84
+ Lowest output height (m). If zlow == zhigh == zhub, only hub-height solution.
85
+ zhigh : float
86
+ Highest output height (m). If zlow == zhigh == zhub, only hub-height solution.
87
+ lut_vars : sequence of str, optional
88
+ Variable names to compute. Default is all 8: 'UL', 'UT', 'VL', 'VT', 'WL',
89
+ 'WT', 'PL', 'PT' (L=longitudinal, T=transverse forcing).
90
+ nx : int, optional
91
+ Streamwise grid points, default 2048. Turbine at x = nx/4 * dx.
92
+ ny : int, optional
93
+ Cross-stream half-grid size, default 512.
94
+ dx : float or None, optional
95
+ Streamwise grid spacing (m). If None (default), set to diameter / 4.
96
+ dy : float or None, optional
97
+ Cross-stream grid spacing (m). If None (default), set to diameter / 16.
98
+ jit : bool, optional
99
+ Enable JIT compilation (Numba). Default is True.
100
+ n_cpu : int or None, optional
101
+ CPU cores for parallel computation. None uses all; 1 disables parallelisation.
102
+
103
+ Returns
104
+ -------
105
+ xarray.Dataset
106
+ Physical-space wake fields with dimensions (z, x, y).
107
+
108
+ See Also
109
+ --------
110
+ pyfuga.preluts_generator.PreLUTGenerator.make_prelut : First pipeline stage.
111
+ pyfuga.flut.FourierLUTGenerator.make_lut : Second pipeline stage.
112
+ pyfuga.trafalgar.Trafalgar.make_luts : Third pipeline stage.
113
+ """
114
+ utils.compile(jit)
115
+ folder = Path(folder)
116
+ os.makedirs(folder, exist_ok=True)
117
+ dx = dx or diameter / 4
118
+ dy = dy or diameter / 16
119
+
120
+ preluts_id = f"Zeta0={zeta0:3.2e}_{nkz0}_{nbeta}"
121
+
122
+ L_vars = [v[0] for v in lut_vars if v[1] == "L"]
123
+ T_vars = [v[0] for v in lut_vars if v[1] == "T"]
124
+ lut_vars_id = ""
125
+ if L_vars:
126
+ lut_vars_id += f"_{''.join(L_vars)}L"
127
+ if T_vars:
128
+ lut_vars_id += f"_{''.join(T_vars)}T"
129
+
130
+ ds = 0.05
131
+ if zlow == zhigh == zhub:
132
+ low_level_out = high_level_out = 9999
133
+ z_id = f"z{zhub:.1f}"
134
+ else:
135
+ low_level_out = int(np.floor(np.log(zlow / z0) / ds))
136
+ high_level_out = int(np.ceil(np.log(zhigh / z0) / ds))
137
+ zlow = z0 * np.exp(low_level_out * ds)
138
+ zhigh = z0 * np.exp(high_level_out * ds)
139
+ z_id = f"z{zlow:.1f}-{zhigh:.1f}"
140
+
141
+ fluts_id = preluts_id + f"_D{diameter}_zhub{zhub}_zi{zi}_z0={z0:.8f}_{z_id}{lut_vars_id}"
142
+
143
+ luts_id = fluts_id + f"_nx{nx}_ny{ny}_dx{dx}_dy{dy}"
144
+
145
+ luts_path = folder / f"LUTs_{luts_id}.nc"
146
+ if not luts_path.exists():
147
+ # LUTs are missing (run Trafalgar)
148
+ fluts_path = folder / f"fLUTs_{fluts_id}.nc"
149
+ if not fluts_path.exists():
150
+ # FourierLUTs are missing (run lut)
151
+
152
+ preluts_path = folder / f"preLUTs_{preluts_id}.nc"
153
+ if not preluts_path.exists():
154
+ # Preluts are missing (run prelut)
155
+ preluts = PreLUTs.make_preluts(
156
+ zeta0=zeta0,
157
+ kz0_lst=get_kz0_lst(nkz0, 1e-9, 1e-1),
158
+ beta_lst=get_beta_lst(nbeta),
159
+ kzmax=300,
160
+ ds=ds,
161
+ accgoal=0.0001,
162
+ jit=jit,
163
+ n_cpu=n_cpu,
164
+ )
165
+ preluts.attrs["nkz0"] = nkz0
166
+ preluts.attrs["nbeta"] = nbeta
167
+ preluts.save(preluts_path)
168
+ else:
169
+ preluts = PreLUTs.from_netcdf(preluts_path)
170
+
171
+ # preluts loaded make fourier luts
172
+ flut_generator = FourierLUTGenerator(preluts, zhub, diameter, zi)
173
+ if low_level_out == high_level_out == 9999:
174
+ fluts = flut_generator.make_hubheight_luts(z0, lut_vars, n_cpu=n_cpu)
175
+ else:
176
+ fluts = flut_generator.make_lut(z0, low_level_out, high_level_out, lut_vars, n_cpu=n_cpu)
177
+
178
+ fluts.save(fluts_path)
179
+ else:
180
+ fluts = ComplexXRDataset.from_netcdf(fluts_path)
181
+
182
+ # fourier luts loaded make luts
183
+ luts = Trafalgar(fluts, nx, ny, dx, dy).make_luts(3)
184
+ luts.to_netcdf(luts_path)
185
+ else:
186
+ luts = xr.load_dataset(luts_path)
187
+ luts.attrs["name"] = luts_path.stem
188
+ return luts
189
+
190
+
191
+ def main():
192
+ if __name__ == "__main__": # pragma: no cover
193
+ luts = get_luts(
194
+ folder=Path(__file__).parent.parent,
195
+ zeta0=0,
196
+ nkz0=16,
197
+ nbeta=32,
198
+ diameter=80,
199
+ zhub=70,
200
+ z0=0.00001,
201
+ zi=400,
202
+ zlow=70,
203
+ zhigh=70,
204
+ lut_vars=["UL"],
205
+ )
206
+ import matplotlib.pyplot as plt
207
+
208
+ luts.UL.sel(z=70)[502:542, :40].plot(x="x")
209
+ plt.axis("equal")
210
+ plt.figure()
211
+ luts = get_luts(
212
+ folder=Path(__file__).parent.parent,
213
+ zeta0=0,
214
+ nkz0=16,
215
+ nbeta=32,
216
+ diameter=80,
217
+ zhub=70,
218
+ z0=0.00001,
219
+ zi=400,
220
+ zlow=30,
221
+ zhigh=110,
222
+ lut_vars=["UL"],
223
+ )
224
+
225
+ luts.UL.sel(x=500)[:, :50].plot(x="y")
226
+ plt.axis("equal")
227
+ plt.show()
228
+
229
+
230
+ main()
pyfuga/common.py ADDED
@@ -0,0 +1,81 @@
1
+ r"""common.py
2
+ ===========
3
+ Numerical helper functions for atmospheric stability relations in PyFuga.
4
+
5
+ This module provides JIT-accelerated utilities used by the preLUT and Fourier
6
+ LUT stages.
7
+
8
+ Key components:
9
+
10
+ - ``get_cdivkL(zeta0, kz0)``: Stability parameter :math:`c/(kL)`.
11
+ - ``get_phi_inverse(kz, cdivkL)``: Inverse of :math:`\phi_m` for unstable flow.
12
+ - ``get_psi(zeta0, kz, cdivkL)``: Stability correction function :math:`-\psi_m`.
13
+ - ``get_phi(zeta0, kz, cdivkL)``: Stability function :math:`\phi_m`.
14
+ - ``complex_norm(arr)``: Euclidean norm for complex arrays.
15
+ - ``get_new_h2(h, acc, Yerr, Y)``: Adaptive integration step-size update.
16
+ """
17
+
18
+ import numpy as np
19
+
20
+ from pyfuga.utils import jit
21
+
22
+ from .constants import CM_STABLE, CM_UNSTABLE
23
+
24
+
25
+ @jit("double(double,double)")
26
+ def get_cdivkL(zeta0, kz0):
27
+ """c/(k*L), c is a stability constant: depending on the stability (0, Cm1, Cm2)"""
28
+ if abs(zeta0) < 1e-10: # Neutral
29
+ return 0.0
30
+ else:
31
+ if zeta0 < 0: # Unstable
32
+ return zeta0 / kz0 * CM_UNSTABLE
33
+ else: # Stable
34
+ return zeta0 / kz0 * CM_STABLE
35
+
36
+
37
+ @jit("double(double,double)")
38
+ def get_phi_inverse(kz, cdivkl):
39
+ """
40
+ Inverse of stability function phi_m
41
+ for unstable conditions
42
+ phi_m=(1+Cm1*z/L)**(1/4)
43
+ """
44
+ return (1.0 + cdivkl * kz) ** 0.25
45
+
46
+
47
+ @jit("double(double,double,double)")
48
+ def get_psi(zeta0, kz, cdivkl):
49
+ """Stability function -psi_m"""
50
+ if zeta0 < 0:
51
+ # Unstable: -psi_m=-ln(1/8*(1+phi_m**-2)*(1+phi_m**-1)**2)+2*atan(phi_m**-1)-pi/2
52
+
53
+ aux = get_phi_inverse(kz, cdivkl)
54
+ aux2 = (1.0 + aux) ** 2 * (1 + aux**2)
55
+ return np.log(8.0 / aux2) + 2.0 * np.arctan(cdivkl * kz / aux2)
56
+ else: # Stable: -psi_m=Cm2*z/L
57
+ return cdivkl * kz
58
+
59
+
60
+ @jit("double(double,double,double)")
61
+ def get_phi(zeta0, kz, cdivkl):
62
+ """Stability function phi_m"""
63
+ if zeta0 < 0: # Unstable: phi_m=(1+Cm1*z/L)**(-1/4)
64
+ return (1.0 + cdivkl * kz) ** (-0.25)
65
+ else: # Stable: phi_m=1+Cm2*z/L
66
+ return 1.0 + cdivkl * kz
67
+
68
+
69
+ @jit("double[:](complex128[:,:],int32)")
70
+ def complex_norm(arr, axis=0):
71
+ """Compute the norm of a complex array along the first axis"""
72
+ return np.sqrt((np.abs(arr) ** 2).sum(0))
73
+
74
+
75
+ @jit("double(double,double,complex128[:,:],complex128[:,:])")
76
+ def get_new_h2(h, acc, Yerr, Y):
77
+ """Get new step size based on error between integration estimates"""
78
+ err = np.max(complex_norm(Yerr, axis=0) / complex_norm(Y, axis=0))
79
+ return np.maximum(np.minimum(0.9 * h * (acc / err) ** (1 / 3), 4.0e-1), 1.0e-4)
80
+
81
+ # return np.clip(0.9 * h * (acc / err)**(1 / 3), 1.0E-4, 4.0E-1)
pyfuga/constants.py ADDED
@@ -0,0 +1,32 @@
1
+ """constants.py
2
+ ==============
3
+ Global constants for atmospheric stability, numerical algorithms, and data structure sizes.
4
+
5
+ Key constants:
6
+ - **Stability (MO)**: CM_STABLE, CM_UNSTABLE for Monin-Obukhov relations
7
+ - **Numerics**: KAPPA (von Kármán), MAX_NODES (max preLUT nodes),
8
+ Y_NORM_THRESHOLD (threshold to trigger QR decomposition and substations
9
+ between nodes)
10
+ - **Data**: N_EQ (number of differential equations), UVW_LT (variable names)
11
+ - **Coordinate systems**: COORD_T (log profile), COORD_S (kz)
12
+ """
13
+
14
+ # PreLUT constants
15
+ CM_STABLE = 5.0 # MO Stability constant in phi_m (stable)
16
+ CM_UNSTABLE = -19.0 # MO Stability constant in phi_m (unstable) Hogstrom (1988)
17
+ N_EQ = 6 # ! Number of equations
18
+ KAPPA = 0.4 # Von Karman constant
19
+ KAPPA_SQUARED = 0.16 # Square of kappa
20
+ P_SCALE = 1 # Pressure normalisation variable
21
+ MAX_NODES = 400
22
+ Y_NORM_THRESHOLD = 100 # Max value of Ynorm before a sublevel is made
23
+
24
+ # LUT constant
25
+ FIRST_Z_OUTPUT = 1.0 # First height level in output
26
+
27
+ # Other constants
28
+ UVW_LT = ["UL", "UT", "VL", "VT", "WL", "WT", "PL", "PT"]
29
+
30
+ # Coordinate system indicators
31
+ COORD_T = 1 # t = u0 * KAPPA
32
+ COORD_S = 2 # s = kz
pyfuga/file_readers.py ADDED
@@ -0,0 +1,141 @@
1
+ """file_readers.py
2
+ ================
3
+ Read Fortran binary output files from the original FUGA code.
4
+
5
+ This module provides readers for legacy .bin output files containing
6
+ parameters, case data, and preLUT tables saved by the Fortran FUGA implementation.
7
+ Used for initialising preLUTs from existing FUGA runs and for compatibility
8
+ with legacy simulation data.
9
+ """
10
+
11
+ import os
12
+ import struct
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import xarray as xr
17
+ from numpy import newaxis as na
18
+
19
+
20
+ class BinaryReader:
21
+ def s(self, n):
22
+ return self.fid.read(n).decode().strip()
23
+
24
+ def i(self):
25
+ return struct.unpack("i", self.fid.read(4))[0]
26
+
27
+ def d(self):
28
+ return struct.unpack("d", self.fid.read(8))[0]
29
+
30
+
31
+ class Parameters(BinaryReader):
32
+ def __init__(self, folder):
33
+ i, d, s = self.i, self.d, self.s
34
+ with open(Path(folder) / "parameters.bin", "rb") as self.fid:
35
+ self.prelutname = s(127)
36
+ self.closure = i()
37
+ self.kz0min = d()
38
+ self.kz0max = d()
39
+ self.nkz0 = i()
40
+ self.jmin = i()
41
+ self.jmax = i()
42
+ self.nbeta = i()
43
+ self.mbeta = i()
44
+ self.dummy2 = d()
45
+ self.ds = d()
46
+ self.kzmax = d()
47
+ self.accgoal = d()
48
+ self.beta_lst = np.fromfile(self.fid, float, self.nbeta + self.mbeta + 1)
49
+ self.kz0_lst = np.fromfile(self.fid, float, self.jmax - self.jmin + 1)
50
+
51
+
52
+ class CaseData(BinaryReader):
53
+ def __init__(self, folder):
54
+ i, d, s = self.i, self.d, self.s
55
+
56
+ with open(Path(folder) / "CaseData.bin", "rb") as self.fid:
57
+ self.case_name = s(127)
58
+ self.radius = d()
59
+ self.zhub = d()
60
+ self.low_level_out = i()
61
+ self.high_level_out = i()
62
+ self.z0 = d()
63
+ self.zi = d()
64
+ self.ds = d()
65
+ self.closure = i()
66
+
67
+
68
+ def eof(fid):
69
+ fid.seek(-1, 2) # go to the file end.
70
+ eof = fid.tell() + 1 # get the end of file location
71
+ fid.seek(0, 0) # go back to file beginning
72
+ return eof
73
+
74
+
75
+ def read_prelut_list(folder, dict=True):
76
+
77
+ folder = Path(folder)
78
+ with open(folder / "prelut_list.lst", "rb") as fid:
79
+ fid.seek(18)
80
+ n = 24 if fid.read(6) == b"\x00\x00\x00\x00\x00\x00" else 18
81
+
82
+ N = eof(fid) // (n + 48)
83
+
84
+ # list of filename (18 or 18+6xNull), ds, smaxx, kz0, beta, kzmax, accgoal
85
+ if dict:
86
+ return {fid.read(n)[:18].decode(): struct.unpack("d" * 6, fid.read(8 * 6)) for _ in range(N)}
87
+
88
+ else:
89
+ return [(fid.read(n)[:18].decode(),) + struct.unpack("d" * 6, fid.read(8 * 6)) for _ in range(N)]
90
+
91
+
92
+ def read_pre_file(filename):
93
+ with open(filename, "rb") as fid:
94
+ d = np.fromfile(fid, float, -1).reshape((-1, 291))
95
+ Yleft, Rleft, Rright, dy = np.moveaxis((d[:, :288:2] + d[:, 1:288:2] * 1j).reshape((-1, 4, 6, 6)), 1, 0)
96
+ sleft, sright = d[:, 288:290].T
97
+ fid.seek(0, 0)
98
+ level = np.fromfile(fid, np.int32, -1).reshape((-1, 291 * 2))[:, -2]
99
+ return {
100
+ "Yleft": (["i", "j", "k"], Yleft),
101
+ "Rleft": (["i", "j", "k"], Rleft),
102
+ "Rright": (["i", "j", "k"], Rright),
103
+ "dyxu0": (["i", "j"], dy[:, 0]),
104
+ "dyxu1": (["i", "j"], dy[:, 1]),
105
+ "dyxv0": (["i", "j"], dy[:, 2]),
106
+ "dyxv1": (["i", "j"], dy[:, 3]),
107
+ "dyxw0": (["i", "j"], dy[:, 4]),
108
+ "dyxw1": (["i", "j"], dy[:, 5]),
109
+ "sleft": (["i"], sleft),
110
+ "sright": (["i"], sright),
111
+ "level": (["i"], level),
112
+ }
113
+
114
+
115
+ def read_lut_file(filename, prelut_folder):
116
+ parameters = Parameters(prelut_folder)
117
+ casedata = CaseData(os.path.dirname(filename))
118
+ var = os.path.basename(filename)[:2]
119
+ level = int(filename[-8:-4])
120
+ z = casedata.zhub if level == 9999 else casedata.z0 * np.exp(casedata.ds * level)
121
+
122
+ lut = np.fromfile(filename, complex, -1).reshape((len(parameters.kz0_lst) + 1, len(parameters.beta_lst)))
123
+ lut = lut[1:].T
124
+ return xr.Dataset(
125
+ {
126
+ var: (("beta", "kz0", "level"), lut[:, :, na]),
127
+ "z": (("level"), [z]),
128
+ "diameter": casedata.radius * 2,
129
+ "hubheight": casedata.zhub,
130
+ "z0": casedata.z0,
131
+ },
132
+ coords={"kz0": parameters.kz0_lst, "beta": parameters.beta_lst, "level": [level]},
133
+ )
134
+
135
+
136
+ def read_lut_dat_file(filename, nx, ny, dx, dy):
137
+ dat = np.fromfile(filename, np.dtype("<f"), -1).astype(float).reshape((ny // 2, nx))
138
+ x_lst = np.arange(-nx // 4, nx * 3 / 4) * dx # rotor is located 1/4 downstream
139
+
140
+ y_lst = np.arange(ny // 2) * dy
141
+ return xr.DataArray(dat, dims=("y", "x"), coords={"x": x_lst, "y": y_lst})