Skip to content

Flow model chain - #56

Draft
bjarketol wants to merge 53 commits into
EUFLOW:mainfrom
bjarketol:flow-model-chain
Draft

Flow model chain#56
bjarketol wants to merge 53 commits into
EUFLOW:mainfrom
bjarketol:flow-model-chain

Conversation

@bjarketol

@bjarketol bjarketol commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

Persistent branch tracking the FLOW validation model-chain. Changes from here are cherry-picked into stand-alone PRs when needed.

bjarketol and others added 9 commits March 27, 2026 09:33
… deps, and NOJLocalDeficit alias

- Make all wrapped tools (floris, pywake, wayve, foxes) optional dependencies
- Add case-insensitive submodel name lookup across deficit, deflection, turbulence,
  superposition, rotor averaging, and blockage models
- Add NOJLocalDeficit as a deficit model name alias alongside Jensen
- Fix CI: skip floris on Python <3.10, fix numpy 2.x compat in wayve
- Add comprehensive parametrized tests for all submodel configuration functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
run_api() was loading the full YAML+netCDF 4 times per simulation:
validate_yaml (load 1, result discarded), load_yaml (load 2), then
the downstream model runner called validate_yaml (load 3) and
load_yaml (load 4) again internally.

Now validate_yaml is called once (its return value is the loaded dict),
and the dict is passed to model runners — which skip their own
validate+load when they receive a dict. Reduces peak transient memory
from ~800 MB to ~200 MB for time-series wind resources.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…y correction

Switch from the default DensityScale (scales power/CT after lookup) to
DensityCompensation (corrects wind speed before power curve lookup) to
match foxes' air density handling approach: ws *= (rho/rho_ref)^(1/3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix _construct_weibull_site() Speedup axis bug: use dim-name lookup
  instead of hardcoded axis=0, and set Speedup dims from input data's
  actual ordering. Previously, flow_model_chain's (wind_direction,
  wind_turbine) ordering caused Speedup to be silently ignored by
  PyWake, removing terrain-induced wind speed inhomogeneity and
  inflating wake losses from ~10% to ~39%.

- Add automatic flow case computation when wind_speed is absent from
  the windIO dict:
  - WS range: 0 to Speedup-adjusted 99.9% Weibull CDF point, 0.5 m/s steps
  - WD sub-sectors: 5 per sector (matching pywasp), letting PyWake's
    probability partitioning handle sector probability distribution

- Handle missing turbulence_intensity gracefully (default 0.06)

- Add test_weibull_speedup_dim_ordering regression test verifying both
  (wind_direction, wind_turbine) and (wind_turbine, wind_direction)
  orderings produce identical AEP

- Update test_heterogeneous_wind_rose_grid baseline to match new
  auto-computed ws range and wd sub-sectors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SimpleYawModel(exp=2) was removed in PyWake 2.6 — the exp=2 behavior
is now the default. Use try/except to support both old and new versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
bjarketol and others added 20 commits March 27, 2026 09:41
… deps, and NOJLocalDeficit alias

- Make all wrapped tools (floris, pywake, wayve, foxes) optional dependencies
- Add case-insensitive submodel name lookup across deficit, deflection, turbulence,
  superposition, rotor averaging, and blockage models
- Add NOJLocalDeficit as a deficit model name alias alongside Jensen
- Fix CI: skip floris on Python <3.10, fix numpy 2.x compat in wayve
- Add comprehensive parametrized tests for all submodel configuration functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…uards

Follow windIO's analysis-schema changes (engine-neutral rotor averaging,
single TI flag) on the pyWake path.

- TI reference: read the nested wake_expansion_coefficient.free_stream_ti
  (foxes-compatible) and set use_effective_ti = not free_stream_ti. Lift the
  handling out of the GAUSSIAN_MODELS branch into a TI_CAPABLE set so it also
  covers SuperGaussian/SuperGaussian2023 and TurbOPark (previously ignored).
  The removed top-level use_effective_ti key is no longer read.
- rotor_averaging: add the new windIO names grid -> GridRotorAvg,
  gaussian_overlap -> GaussianOverlapAvgModel, area_overlap ->
  AreaOverlapAvgModel; keep avg_deficit as a deprecated alias.
- Guard: WeightedSum/CumulativeWakeSum with a non-node rotor-averaging model
  now raises a clear ValueError instead of PyWake's deep AssertionError.
- Vector superposition (foxes-only) raises an explicit NotImplementedError.
- Tests: free_stream_ti polarity across all TI-capable deficits + exclusions,
  the three rotor-averaging names, the Weighted/Cumulative node guard, and
  Vector rejection; update prior tests off the removed use_effective_ti key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…into flow-model-chain-submodels

# Conflicts:
#	pyproject.toml
#	tests/test_pywake_submodels.py
#	wifa/pywake_api.py
NOJLocalDeficit accepts use_effective_ti and, with a=[k_a, k_b], references
effective TI — so a no-turbulence Jensen/PARK config (turbulence_model=None)
hit PyWake's "TI_eff requires a turbulence model" assertion. Add jensen/
nojlocaldeficit to TI_CAPABLE so free_stream_ti=True selects ambient TI and
the config runs without an added-turbulence model. Tests updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These superpositions also require a ConvectionDeficitModel-based deficit
(PyWake asserts isinstance(deficit, ConvectionDeficitModel)). SuperGaussian,
SuperGaussian2023 and GCL are not convection models, so pairing them with
Weighted/Cumulative raised a bare AssertionError deep in a run. Extend the
existing node-rotor-avg guard to also check the deficit type and raise a
clear ValueError naming the offending model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
windIO's wake_expansion_coefficient has no scalar `k` field (validation
rejects it), so the free-stream NOJDeficit path now also reads k_b. Lets
jensen1983 use the faithful free-stream Jensen_1983 deficit with k via k_b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Use per-turbine inflow already at hub height (wind_turbine dim, no height
  profile) via dict_to_site instead of averaging across turbines
- Restore and fix vertical-profile support for mixed hub heights: build the
  height-indexed XRSite unconditionally (was only built when TI present,
  leaving site=None and crashing), use turbine-averaged reference inflow
  (was the last interpolated height only), and interpolate density profiles
- Add vector (sin/cos) wind-direction interpolation so the 0/360 wrap is
  handled correctly
- Raise on the ambiguous height-profile + wind_turbine combination
- Add golden-equivalence tests (per-turbine and vertical-profile) matching
  hand-built pyWake to rtol 1e-6, plus a guard test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The flow_model_chain integration branch tracks the persistent windIO
flow-model-chain branch so the transitive and direct windIO pins agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Skip the all-True [cases_idx] subset copy when no time subset is requested
- Use np.asarray so an array-backed resource is not re-copied (enables Phase 2)
- Reuse site.ds in the Weibull TI path instead of a second dict_to_netcdf build
- Add tests/mem_bench.py to measure site-construction memory

Measured (timeseries 4000x100): with today's dict-of-lists input the
construct_site peak is unchanged (~35 MB) because the removed copies are
transient; with an array-backed resource the same path drops to ~19 MB and
the input dict from ~59 MB to ~16 MB, previewing the Phase 2 win.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- pyWake + foxes readers call load_yaml(..., nc_data="array") and validate
  structure-only (array_data=True), keeping a large included wind_resource.nc
  as numpy arrays instead of nested Python lists
- guard the height-profile checks with an explicit has_heights boolean (an
  array-backed "height" has an ambiguous truth value)
- mem_bench: add the real-netCDF load comparison

Measured on a 16 MB wind_resource.nc: load peak 76 MB (lists) -> 19 MB
(arrays), ~3.9x lower; construct_site peak 35 MB -> 19 MB. Requires a windIO
build with the nc_data option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Asserts an included wind_resource.nc loaded with nc_data="array" peaks well
below the dict-of-lists default, and that the loader actually keeps ndarrays
while the default stays list-backed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two faithfulness fixes on the pyWake path so the configured models reproduce
py_wake.literature exactly:

- Fix A: map analysis.axial_induction_model to ct2a (1D -> ct2a_mom1d,
  Madsen -> ct2a_madsen) and set it on every deficit that accepts a ct2a
  parameter. Previously the field was ignored and every deficit kept its
  ct2a_madsen default, so a "1D" request was silently dropped. Closes the
  Bastankhah2014 gap vs Bastankhah_PorteAgel_2014.

- Fix B: build TurbOPark with the canonical Nygaard (2022) recipe — a Mirror
  ground model and ctlim=0.96 (constructor args) plus WS_key='WS_jlk' (set
  post-construction). run_simulation now takes groundModel from deficit_args
  instead of hardcoding None, and applies deficit_post_attrs. Closes the
  TurbOPark gap vs Nygaard_2022.

_configure_deficit_model now returns (class, args, post_attrs); configure_wake_model
exposes deficit_post_attrs. Adds unit tests for both fixes.

Verified against py_wake.literature: Bastankhah2014 and TurbOPark now match to
0.000% per-turbine power.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The flow-model-chain branch carried three test files (mem_bench.py,
test_memory.py, test_pywake.py) that predate the pinned pre-commit hooks and
failed the black/isort CI check. Reformat them with the repo-pinned versions so
the pipeline is green; no logic changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Honor axial_induction_model + TurbOPark literature recipe (faithfulness)
…edSum, Zong eps_coeff

Closes the Niayifar (2016) and Zong (2020) gaps vs py_wake.literature:

- Fix C: CrespoHernandez honors a `c` coefficient array from the turbulence
  config; when given it builds CrespoHernandez(c=..., ct2a=ct2a_mom1d,
  addedTurbulenceSuperpositionModel=SqrMaxSum()) — the literature recipe.
  Without `c`, the PyWake default is unchanged.
- Fix D: a "none" rotor-averaging option returns None, and the WeightedSum
  guard now accepts None (rotor centre, which PyWake allows) in addition to
  node models. Zong (2020) uses rotorAvgModel=None; forcing GridRotorAvg was a
  ~24% error.
- ceps now maps to Zong's `eps_coeff` (PyWake's name for its near-wake epsilon),
  instead of being dropped.

Verified against py_wake.literature: Niayifar2016 and Zong2020 now match to
0.000% per-turbine power. Adds unit tests for all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 faithfulness: CrespoHernandez c, None rotor for WeightedSum, Zong eps_coeff
- Honor the windIO use_effective_ws flag instead of hardcoding True. The flag
  was silently ignored on the pyWake path, so free-stream models (notably GCL's
  original Larsen variant) could not be expressed. NOJDeficit still pops it.
- Add GCL to TI_CAPABLE: GCLDeficit accepts use_effective_ti (GCLLocal sets it),
  so free_stream_ti is now honored for GCL.

Together these let the GCL experiment reproduce py_wake.deficit_models.gcl.GCL
(use_effective_ws=False) — verified to 0.000%. Existing twins are unaffected
(they use use_effective_ws=True; TurbOPark's WS_key makes it moot). Adds tests;
moves GCL out of the "free_stream_ti ignored" test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3: honor use_effective_ws + enable GCL effective TI
bjarketol and others added 24 commits June 9, 2026 11:07
The Weibull/distributions path auto-generates a reference wind-speed grid
starting at 0 m/s. A ws=0 flow case carries zero energy for every model
but is degenerate for the WeightedSum superposition (Zong 2020), whose
convection-velocity iteration divides by the convection speed and is
undefined at zero wind speed. Including ws=0 silently corrupted the
WeightedSum AEP, collapsing the apparent wake loss: on the Twin Groves
distributions case Zong fell to 3.5% while the near-identical LinearSum
Niayifar stayed at 8.0% (and Zong's own time-series value was 8.2%).
LinearSum and the other superpositions were unaffected.

Start the auto ws grid at the first nonzero bin (0.5 m/s); dropping ws=0
is energy-neutral for all models and fixes WeightedSum. After the fix the
Zong distributions wake loss recovers to 8.2%, matching its time-series
value.

- _construct_weibull_site: ws grid now np.arange(0.5, ...).
- Add regression test test_weibull_ws_grid_excludes_zero_for_weightedsum
  (guards both ws[0] > 0 and Weighted-vs-Linear agreement).
- Update test_heterogeneous_wind_rose_grid's reference grid to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix WeightedSum collapse on the distributions path (drop ws=0 bin)
Replace the hardcoded Fuga LUT stub with per-farm LUT generation so Fuga
works for any validation case, not just the one offshore D80 turbine the
stub's hand-built filename happened to encode.

- The old branch hardcoded z0=1e-5, zi=500, zlow=zhigh=70 and constructed a
  LUT_path (z69.2-72.8...dx44.575) that could never match what get_luts
  actually writes (single hub level -> _z70.0_; dx=D/4) -> FileNotFoundError
  for any real farm.
- Derive the LUT atmosphere from the site: roughness z0 from the mean TI
  (Fuga has no TI input; z0 = zhub*exp(-1/TI), the inversion PyWake uses),
  inversion height zi from ABL_height, neutral stability. All overridable
  via wind_deficit_model.fuga.{z0,zi,zeta0,nkz0,nbeta,nx,ny,cache_dir}.
- Probe pyfuga.paths.get_luts_path before generating -> a persistent,
  content-addressed cache (~/.cache/wifa/fuga_luts or $WIFA_FUGA_LUT_DIR);
  pyfuga reuses the costly preLUT stage across geometries.
- Pop use_effective_ws for FugaDeficit (it doesn't accept it) and thread
  resource_dat through configure_wake_model/_configure_deficit_model as an
  optional trailing kwarg (existing callers/tests unaffected).

Validated on Twin Groves TS (V82, D82/hub78): pywake_fuga = 9.7% wake loss,
mid-pack and physically sane (auto-derived z0=0.033 ~ farmland roughness).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#8)

Make the Fuga LUT path TI-faithful. Fuga reads ambient turbulence off the LUT
roughness, so a single mean-TI LUT evaluates the wake at loss(mean TI) and
misses the low-TI tail that drives the deepest wakes (same convexity as the
GCL free-stream-TI gap).

- Generate a z0 SWEEP across the site TI distribution (n_z0=5 by default) and
  pass the list to FugaDeficit, which interpolates z0 = z0(TI) per flow case
  at run time -> the farm loss integrates over the TI distribution. All LUTs
  share the costly preLUT, so extra z0 values are cheap.
- Clamp the TI band to [0.03, 0.18] so the neutral inversion z0 stays physical
  (~[1e-5, 0.3] m); TI 0.30 would map to z0 ~2.8 m, outside Fuga's regime. The
  high-TI end saturates to shallow wakes and clamps via bounds='limit'.
- Build one LUT set per turbine geometry and thread turbine_geometries through
  configure_wake_model so mixed-turbine farms interpolate over d_h.
- Add fast unit tests for _fuga_atmosphere / _fuga_z0_sweep (no LUT gen).

Twin Groves TS: pywake_fuga 9.7% (single mean-TI LUT) -> 11.2% (TI-faithful
multi-LUT), now level with bastankhah2014.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
When wind_deficit_model.fuga.z0 is a list (an explicit z0 sweep, handled by
_fuga_z0_sweep), _fuga_atmosphere crashed on float(z0) while computing the
scalar fallback. Ignore a list z0 there (fall back to TI-derived/default), so a
configured z0 sweep works end to end. Add a regression test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- Span each LUT over all hub heights (zlow=min, zhigh=max) and pin the
  merged z axis to the hub heights via z_lst; merging single-height LUTs
  at different hub heights turned the FugaMultiLUTDeficit table all-NaN,
  zeroing production at every turbine waked by another turbine type
- Share one x/y grid (min diameter /4, /16) across mixed rotor diameters
  so LUTs merge without clipping
- Dedupe turbine geometries (duplicate d_h coordinates break the merge)
- Forward z_lst to the FUGA blockage deficit
- Forward fuga.n_cpu to pyfuga (its spawn pools re-JIT numba per worker;
  n_cpu=1 keeps small-LUT generation in-process)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
windIO defines k_a as the constant expansion and k_b as the coefficient
on TI; PyWake (k = a[0]*TI + a[1]) and wayve's Lanzilao (k = ka*TI + kb)
order their native parameters the other way around, so the readers must
swap. Matches the foxes eu_flow reader, which fixed the same inversion.

- pywake: a = [k_b, k_a]; scalar-k deficits (Jensen_1983,
  Bastankhah2014) take k_a and warn on a nonzero k_b
- wayve: Lanzilao(ka=k_b, kb=k_a); a scalar k is a constant -> kb
- Update tests and example analysis yamls to the windIO convention

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified end-to-end in a wayve env (foxes 1.8.4): the 4-turbine smoke
test converges 3/3 flow cases with the swapped Lanzilao mapping.

- Assert wake_model_setup maps ka <- k_b (TI multiplier) and
  kb <- k_a (constant) onto Lanzilao's kwake = ka*TI + kb
- Assert a scalar k lands in the constant kb with no TI term

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two independent defects made `wake_tool: foxes` impossible to use:

- `wake_tool` was read from `analysis.wake_tool`, but the windIO schema only
  allows it under `analysis.wm_coupling.wake_tool` and rejects the analysis-
  level key (the validator injects `additionalProperties: false`). No
  schema-valid windIO file could therefore select the foxes wake tool. It is
  now read from `wm_coupling`, falling back to the analysis level so the
  existing hand-written example yamls keep working.

- Once reachable, the foxes path raised `TypeError: Algorithm.__init__() got
  an unexpected keyword argument 'name'`. foxes' `Dict` takes its own label as
  `_name`; a plain `name=` kwarg becomes a data key and was forwarded into the
  foxes `Algorithm` constructor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ars)

Three defects in how run_wayve/flow_io_abl read the windIO input:

- `times_run.subset` simulated the wrong states. The subsetted timestamps were
  re-enumerated from zero, but the resulting index is used to look up the
  *full* wind-resource arrays in flow_io_abl(). So `subset: [10, 20]` built
  its ABL from rows 0 and 1 while labelling the output states 10 and 20 —
  silently wrong data with correct-looking coordinates. The positions into the
  full arrays are now carried alongside the timestamp labels.

- Turbulence intensity was divided by 100 in the single-point (non-profile)
  resource branch, and was read only when `z0` happened to also be present.
  windIO carries TI as a fraction everywhere else, including flow_io_abl's own
  vertical-profile branch and the `TI = 0.04` default two lines above. The
  effect was to collapse the TI-proportional wake expansion by two orders of
  magnitude.

- `turbine_outputs.output_variables` was ignored: the code read
  `turbine_output_variables`, while the schema and every other WIFA engine use
  `output_variables`. The old key is kept as a fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Attach a Mirror ground model to the analytic blockage models
  (SelfSimilarity/2020, Rathmann, RankineHalfBody, VortexCylinder,
  VortexDipole, HybridInduction) when ground_mirror is true; they are
  calibrated without ground effects and otherwise allow flow through
  the ground
- Warn and ignore the flag for FUGA blockage: the Fuga LUTs come from
  a linearized RANS solver that already includes the ground
- Default off (DEFAULTS entry), wake deficit models stay unmirrored
- Tests: mirror attached per model, off by default, FUGA warning, and
  a physics check that mirroring increases the upstream deficit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Analytic blockage models produce speed-up (negative deficit) regions;
pyWake defaults the blockage superposition to the wake superposition
model, so any Squared-superposition recipe (e.g. TurbOPark) hit
"SquaredSum only works for deficit - not speedups". Potential-flow
induction superposes linearly, so pin LinearSum on the blockage deficit
(the ground Mirror inherits it for the real+image sum).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
flow_io_abl hard-coded abl.rho = 1.225. The APM velocity solution does
not depend on density (wayve's power_turbines docstring), but abl.rho
linearly scales the reported turbine power and is forwarded to foxes as
FV.RHO — so a wind resource carrying per-state air_density now gets the
standard density correction. The Cp curve stays referenced to 1.225
(read_turbine_type), the density the windIO power curve is defined at.
Defaults to 1.225 when the resource has no air_density.

Note: air_density is not yet in the windIO wind-resource schema (the
wayve path skips validation); flagged alongside the capping-inversion
keys in EUFLOW#67.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- dict_to_site: build pyWake's joint P from windIO probability —
  multiplied by sector_probability when present (windkit/WAsP writes
  probability as the per-sector conditional over wind speed), bare
  probability taken as already joint (ieawind37 convention)
- construct_site: dispatch "probability" resources to a new
  _construct_histogram_site — ws verbatim from the histogram bin centers
  (pyWake cannot interpolate ws-dependent P), wd refined to sub-sectors
  via XRSite's circular P interpolation, operating=(n_wt, 1), TI
  defaulting to 0.06 when absent
- factor the Weibull path's sub-sector computation into
  _subsector_wind_directions (shared, no behavior change) and add
  _coord_values for dict-form windIO coordinates
- tests: joint-P construction (incl. windkit dim order), dispatch,
  sub-sector helper, analytic single-turbine AEP, two-turbine wake
  loss, and a full run_pywake histogram case

Enables eu-flow/wp5/flow_model_chain#15.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- EddyViscosity deficit: free-stream-referenced deficits by default
  (use_effective_ws=False, the MaxSum/WindFarmer convention), TI-capable
- QuartonAndAinslie / ModifiedQuartonAndAinslie turbulence -> the Hassan
  (1992) modified variant the EV bundle uses
- simplified_gaussian rotor averaging -> SimplifiedGaussianRotorAverageModel
- all imports lazy: released pyWake keeps working unless EV is requested;
  tests importorskip on the EV branch module

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wbMsoVker28aF72Xt5VC5
A height-resolved windIO wind resource carrying friction_velocity and
boundary_layer_height (mesoscale/reanalysis data such as ERA5) now sets
up the ABL via wayve's abl_setup.mesoscale_based() path instead of
requiring turbulence profiles:

- profiles reaching the stratosphere (>= 12 km) delegate to wayve's
  mesoscale_based(), including its tropopause two-line fit
- truncated profiles run the same core setup in-line (CI from an
  explicit windIO capping_inversion block or the SERZ fit, the
  stability-aware stress/eddy-viscosity closure, geostrophic wind from
  the profile) but skip the tropopause fit, whose unconditional run on
  truncated data would feed a garbage stratosphere into the ABL; the
  ABL keeps its defaults (h_strat=10 km, Uinf/Vinf=U3/V3, Ninf=N)
- new analysis.abl_setup block (Gmode, dh_max, serz) forwarded to
  flow_io_abl; Gmode "avg" falls back to "h2" with a warning when the
  profiles stop below 5 km
- turbulence_intensity in profile resources may now be a single value
  per state (interpolated profile still supported); profile resources
  without turbulence or mesoscale inputs get a clear error instead of
  a KeyError

Requested by flow_model_chain#27 (ERA5-profile-driven wayve states).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wayve's gravity-wave machinery and its grid conventions assume the mean
flow blows west->east (+x); the wrapper never rotated anything, so
non-westerly states solved with the farm footprint (and any anisotropic
APM grid) at the wrong angle to the flow.

- flow_io_abl builds the ABL in the solver frame by rotating the inputs
  (scalar branch: Nieuwstadt profile built westerly; profile branch:
  direction profile unwrapped and shifted so the hub-height wind is
  westerly, preserving veer; explicit tau_x/tau_y components rotated).
  Rotating inputs rather than calling ABL.rotate() sidesteps the broken
  momentum-flux rotation in wayve 2.0.0 and keeps everything derived
  downstream (Gmode geostrophic wind, stress alignment) consistent.
  Returns (abl, rotation).
- wf_setup rotates the layout by the same angle per state (and no longer
  mutates farm_dat); run_wayve rebuilds the wind farm inside the state
  loop.
- Flow-field output stays on the requested earth-frame grid: the query
  points are rotated into the solver frame, evaluated exactly there via
  the new _xy_plane_points (meshgrid-free mirror of wayve's xy_plane for
  both wake models), and the velocity vectors rotated back.
- Tests: solver-frame alignment, rotational invariance of per-turbine
  outputs (westerly row vs 90deg-rotated southerly row), earth-frame
  flow-field direction/wake position, flow-field equivalence under
  rotation, and exact _xy_plane_points == xy_plane equivalence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uPUjtEg3eRVtASSD5KGs7
number_of_fa_layers > 1 selects wayve's NonUniform gravity-wave closure,
the only component that consumes the vertical profile structure (N(z),
upper-level shear) beyond the two-layer averages. For truncated
reanalysis profiles (e.g. ERA5 levels 96-137 stopping near 6 km, or the
shipped 1.7 km Twin Groves files) the ABL kept its 10 km default
tropopause, so NonUniform would have spread most of its layers across a
no-data region where the profile splines hold constants.

- flow_io_abl caps the free atmosphere at the top of the profile data
  (h_strat = zs[-1], Uinf/Vinf from the top of the profile, Ninf = the
  fitted free-atmosphere N); above the cap sits the semi-infinite
  radiating layer.
- The pressure closure is built per state (_pressure_for_state):
  NonUniform only when the profile actually has points between the
  inversion top and the capped free-atmosphere top, otherwise a warned
  fallback to the bulk Uniform closure (scalar Nieuwstadt states always
  fall back - their synthetic profile stops at the inversion).
- Tests: h_strat capping + solver-frame profile branch, per-state
  closure selection/fallback, and a non-westerly NonUniform end-to-end
  run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uPUjtEg3eRVtASSD5KGs7
wayve@e87780a overrides background_flow_direction on the foxes coupling
with logic identical to the UniDirectionalSelfSimilar base class but a
broken lazy import (e_spanwise from wake_models.wake_model_tools instead
of forcing_tools), so every foxes-coupled solve crashes with an
ImportError. Rebind the base implementation while that import is broken;
the shim self-disables once upstream fixes the module path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uPUjtEg3eRVtASSD5KGs7
Findings from a four-angle review (rotation math, profile path, tests,
integration) of 20c890c..7424429. No correctness bugs were found; this
hardens the edges the review flagged:

- Tests: rotational invariance at a generic 37 deg angle (90 deg maps the
  square FFT grid onto itself, so the pre-rotation code already passed
  the 90 deg pair; 37 deg discriminates at ~4e-4), identical repeated
  states (guards the per-state wind-farm rebuild against cumulative
  layout mutation), a foxes twin of the _xy_plane_points drift guard
  (which also exercises the background_flow_direction shim in CI), an
  explicit tau_x/tau_y solver-frame rotation test, a no-silent-Uniform-
  fallback assertion in the NonUniform end-to-end test, and an Ninf
  assertion on the capped free atmosphere.
- flow_io_abl rejects non-ascending height arrays (np.interp would
  silently derive a wrong solver frame from them).
- Flow-field wind_direction is wrapped to [0, 360).
- turbine_data and flow_field appends happen together, so a flow-field
  failure cannot desync the two files' states axes.
- The foxes shim now probes the override's own source for the broken
  import instead of importing the misplaced symbol, so it stays inert
  if upstream rewrites the method rather than relocating e_spanwise.
- The NonUniform fallback warning is state-independent (dedupes on long
  series) and run_wayve prints a per-run NonUniform/Uniform tally.
- black/isort formatting per the repo pre-commit hooks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uPUjtEg3eRVtASSD5KGs7
WIFA's wayve adapter now reads the four capping-inversion scalars from the
flat windIO energy-resource keys (ABL_height, capping_inversion_thickness,
capping_inversion_strength, lapse_rate) in addition to the nested
thermal_stratification.capping_inversion block it read before. This is the
spelling the windIO schema declares and the one WIFA's own code_saturne
adapter already reads; it is also the only one a netCDF !include can carry,
since windIO flattens a netCDF's root group.

- The three duplicated nested reads (scalar, turbulence-profile and
  mesoscale branches) collapse into read_capping_inversion; a
  capping_inversion_spelling validator runs once before the state loop.
- Back-compatible: the nested block is still accepted. A file carrying both
  spellings, or an incomplete block in either, is rejected rather than
  silently resolved -- capping_inversion_strength alone sets the
  gravity-wave forcing amplitude, so a partial block is never completed from
  defaults (a deliberate departure from the code_saturne adapter's per-key
  fill). The structural check runs before the loop, whose except Exception
  would otherwise report a malformed file as every state crashing.
- tests: flat/nested equivalence, both-spellings and partial-block
  rejection, no-inversion profile-fit fallback; the _profile_wind_resource
  fixture gains a spelling= switch and defaults to flat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rofile

In the mesoscale branch the atmosphere is *fitted*, not supplied: flow_io_abl
fits the capping inversion when the resource states none, derives the
geostrophic wind through Gmode, and closes the stress profile from surface
scalars. None of that reached the outputs, so a run could not report the
atmosphere it had solved on, and the fitted inversion could not be checked
against an independent boundary-layer height. Write those scalars alongside
the turbine outputs.

Also let number_of_fa_layers <= 0 through to wayve's NonUniform closure, which
reads it as "one sublayer per level of the ABL's own vertical grid". A
reanalysis column then resolves the free atmosphere at the resolution it
actually has, instead of an arbitrary layer count over the same range. Only
exactly 1 now means the bulk Uniform closure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWxbyEe8CKC9Nj7BXfo63N
- Namespace them `abl_*`. They share a dataset with the turbine outputs, and
  `air_density` / `turbulence_intensity` are per-turbine field names in other
  engines and in windIO's turbine_data schema; a future collision would have
  silently overwritten a turbine result.
- Give every one a `units` attribute, and note that wayve's `utau` carries the
  surface stress rather than its root in the turbulence-profile branch.
- Never let reporting cost a state its physics: a non-scalar or missing value
  now becomes NaN instead of an exception, which the state loop's
  `except Exception` would have turned into a dropped state — discarding power
  that had already been computed.
- Take the mixed-layer temperature from the profile in the turbulence-profile
  branch's explicit-inversion path, as every other branch does. A hard-coded
  293.15 K made the buoyancy jump `g*dtheta/th0` disagree with the ABL's own
  theta profile by up to ~4% at realistic temperatures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWxbyEe8CKC9Nj7BXfo63N
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant