Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0279c24
add FLOWFarm to aero models
BTV25 Mar 12, 2026
da1c293
Added FLOWFarm integration tests. Added notes about flowfarm multithr…
BTV25 Mar 16, 2026
35ca39d
test update
BTV25 Mar 16, 2026
a6f02f8
correct tests
BTV25 Mar 16, 2026
524c0e5
format fixes
BTV25 Mar 16, 2026
ddf9908
version conflict fixed
BTV25 Mar 16, 2026
9306e87
repush
BTV25 Mar 23, 2026
608da85
update tests
BTV25 Mar 27, 2026
e9c91f2
test update
BTV25 Mar 27, 2026
6b823e7
correct, tests
BTV25 Mar 27, 2026
c9b0801
test updates
BTV25 Mar 27, 2026
4fc7b09
cleanup
BTV25 Apr 10, 2026
975d767
more cleanup
BTV25 Apr 10, 2026
8a5457e
Merge branch 'develop' into features/FLOWFarm
BTV25 Apr 10, 2026
f1e9363
test update
BTV25 Apr 10, 2026
3e2ed01
model changes
BTV25 May 26, 2026
0360b4e
layout updates
BTV25 Jun 23, 2026
5801e2b
Fix float32 precision loss in boundary distance derivatives
BTV25 Jul 3, 2026
6d2537e
Fix FLOWFarm sparsity-threshold calibration bug in _initial_turbine_p…
BTV25 Jul 11, 2026
6cc1f78
Add FreeLayout component for gradient-driven turbine positions
BTV25 Jul 14, 2026
f0f5306
Pin jax>=0.9.2 to avoid a jaxlib 0.9.0.1 VJP-transpose segfault
BTV25 Jul 17, 2026
d2e5195
Fix optiwindnet collection bugs from N_turbines surrogate sweep
BTV25 Aug 18, 2026
2fb5dea
Add N_turbines-only surrogate components for wind power/cost
BTV25 Aug 18, 2026
71cc90b
Update docstring paths for hybridfarm outputs/ reorg
BTV25 Aug 24, 2026
3065d84
Fix SurrogateFarmPower unpickling of PerStatePowerSurrogate
BTV25 Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/julia-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: FLOWFarm integration tests (Julia)

on:
push:
paths:
- "ard/farm_aero/flowfarm/**"
- "test/flowfarm/**"
pull_request:
paths:
- "ard/farm_aero/flowfarm/**"
- "test/flowfarm/**"
workflow_dispatch: # allow manual runs from the Actions UI

jobs:

test-julia:
name: Run FLOWFarm integration tests
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Set up Julia
uses: julia-actions/setup-julia@v2
with:
version: "1.10"

- name: Cache Julia packages
uses: julia-actions/cache@v2

- name: Install Ard with FLOWFarm extras
run: pip install ".[dev,flowfarm]"

- name: Pre-instantiate Julia environment
run: |
julia -e "using Pkg; Pkg.Registry.add(\"General\"); Pkg.Registry.update()"
julia --project=ard/farm_aero/flowfarm/julia_env -e "using Pkg; Pkg.instantiate(); Pkg.precompile()"

- name: Run FLOWFarm integration tests
run: |
pytest -m julia test/flowfarm/integration \
--cov=ard \
--cov-report=term-missing \
-v
15 changes: 14 additions & 1 deletion .github/workflows/python-tests-consolidated.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,20 @@ jobs:
pip install .[dev]
- name: Run unit tests with coverage
run: |
pytest --cov=ard --cov-fail-under=80 test/ard/unit
export MPLBACKEND=Agg
pytest --cov=ard --cov-fail-under=80 test/ard/unit -m "not julia"

# If no julia-marked tests are present in test/ard/unit, pytest exits 5.
# Treat that as success so CI remains stable across marker rollout.
set +e
pytest --cov=ard --cov-append test/ard/unit -m "julia"
rc=$?
set -e
if [ "$rc" -ne 0 ] && [ "$rc" -ne 5 ]; then
exit "$rc"
fi

pytest --cov=ard --cov-append test/flowfarm/unit -m "not julia"

test-system:
name: Run system tests
Expand Down
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@

### ARD DEVELOPMENT IGNORES
### ARD DEVELOPMENT IGNORES

.vscode
case_files
ard_prob_out

### JULIA
# Manifest.toml is user-generated (depends on local Julia version).
# Project.toml is committed; Manifest.toml is rebuilt automatically on first use.
ard/farm_aero/flowfarm/julia_env/Manifest.toml

### MACOS DEFAULT IGNORES

.DS_Store
Expand Down
17 changes: 12 additions & 5 deletions ard/api/default_systems/ard_system_onshore.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ systems:
systems:
layout:
type: component
module: ard.layout.gridfarm
object: GridFarmLayout
module: ard.layout.cartesian
object: CartesianLayout
promotes: ["*"]
kwargs:
modeling_options:
Expand All @@ -34,8 +34,8 @@ systems:
modeling_options:
landuse:
type: component
module: ard.layout.gridfarm
object: GridFarmLanduse
module: ard.layout.cartesian
object: CartesianFarmLanduse
promotes: ["*"]
kwargs:
modeling_options:
Expand Down Expand Up @@ -96,4 +96,11 @@ systems:
modeling_options:
connections:
- ["AEP_farm", "financese.plant_aep_in"]
- ["landbosse.total_capex_kW", "financese.bos_per_kW"]
- ["landbosse.total_capex_kW", "financese.bos_per_kW"]
# Connect CartesianLayout outputs to root-level components that need them
- ["layout2aep.x_turbines", "boundary.x_turbines"]
- ["layout2aep.y_turbines", "boundary.y_turbines"]
- ["layout2aep.x_turbines", "collection.x_turbines"]
- ["layout2aep.y_turbines", "collection.y_turbines"]
- ["layout2aep.x_turbines", "spacing_constraint.x_turbines"]
- ["layout2aep.y_turbines", "spacing_constraint.y_turbines"]
8 changes: 4 additions & 4 deletions ard/api/interface.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import importlib
import numpy as np
import openmdao.api as om
from openmdao.drivers.doe_driver import DOEGenerator
from wisdem.optimization_drivers.nsga2_driver import NSGA2Driver
Expand Down Expand Up @@ -306,16 +307,15 @@ def set_up_system_recursive(
prob.add_recorder(recorder)
prob.driver.add_recorder(recorder)

# TODO! THIS IS NECESSARY FOR SOME REASON WHEN RUNNING FREE
# OPTIMIZATIONS. THIS SHOULDN'T BE NEEDED...
coords = modeling_options["windIO_plant"]["wind_farm"]["layouts"]["coordinates"]
prob.model.set_input_defaults(
"x_turbines",
# input_dict["modeling_options"]["windIO_plant"]["wind_farm"]["layouts"]["coordinates"]["x"],
val=np.array(coords["x"], dtype=float),
units="m",
)
prob.model.set_input_defaults(
"y_turbines",
# input_dict["modeling_options"]["windIO_plant"]["wind_farm"]["layouts"]["coordinates"]["y"],
val=np.array(coords["y"], dtype=float),
units="m",
)

Expand Down
77 changes: 65 additions & 12 deletions ard/collection/optiwindnet_wrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
from . import templates


def _own_L_from_inputs(inputs: dict, discrete_inputs: dict) -> nx.Graph:
def _own_L_from_inputs(
inputs: dict, discrete_inputs: dict, break_collinearity: bool = False
) -> nx.Graph:
# get the metadata and data for the OWN warm-starter from the inputs
T = len(inputs["x_turbines"])
R = len(inputs["x_substations"])
Expand Down Expand Up @@ -74,6 +76,27 @@ def _own_L_from_inputs(inputs: dict, discrete_inputs: dict) -> nx.Graph:
# store the adjustments
VertexCTR += adjustments

if break_collinearity:
# Nudge every vertex by a tiny (~1 micron), deterministic, direction-unique
# offset -- even without exact duplicates, an axis-aligned grid (e.g.
# make_basic_grid_turbine_layout's initial layout, or a mid-optimization SNOPT
# iterate that lands turbines back on a grid line) can leave 3+ points exactly
# collinear, which optiwindnet's constrained-Delaunay make_planar_embedding
# cannot handle (KeyError deep in its mesh code -- observed crashing the
# N_turbines=5 sweep run). Only applied on retry after that KeyError (see
# compute(), below) so the normal, non-degenerate case is untouched -- this
# perturbs every point, not just the ones actually causing the degeneracy, and
# would otherwise change reference cable lengths for every run. Golden-angle-
# spaced directions guarantee no two vertices' offsets point the same way, so no
# coincidental collinearity survives; magnitude matches the duplicate-
# perturbation epsilon above, far below any physically meaningful cable-routing
# distance.
golden_angle = np.pi * (3.0 - np.sqrt(5.0))
step = golden_angle * np.arange(len(VertexCTR))
VertexCTR = VertexCTR + perturbation_eps * np.stack(
[np.cos(step), np.sin(step)], axis=1
)

# apply the adjustments
VertexC[:T, :] = VertexCTR[:T, :]
VertexC[-R:, :] = VertexCTR[-R:, :]
Expand Down Expand Up @@ -183,8 +206,22 @@ def compute(
L = _own_L_from_inputs(inputs, discrete_inputs)
T = L.graph["T"]

# create planar embedding and set of available links
P, A = make_planar_embedding(L)
# create planar embedding and set of available links. optiwindnet's
# constrained-Delaunay triangulation can KeyError on an exactly-collinear (but
# not coincident) point configuration -- e.g. make_basic_grid_turbine_layout's
# axis-aligned initial grid, or a mid-optimization SNOPT iterate that lands back
# on one (observed crashing the N_turbines=5 sweep run). Retry once with a tiny
# collinearity-breaking nudge rather than failing the whole solve outright.
try:
P, A = make_planar_embedding(L)
except KeyError:
warn(
"optiwindnet's make_planar_embedding failed on the current turbine/"
"substation configuration (likely an exactly-collinear degeneracy); "
"retrying with a tiny collinearity-breaking perturbation."
)
L = _own_L_from_inputs(inputs, discrete_inputs, break_collinearity=True)
P, A = make_planar_embedding(L)

solver = solver_factory(solver_name)

Expand Down Expand Up @@ -242,17 +279,33 @@ def compute(
# feeder <u, v> has a straight route
length_cables[i] = d2roots[v, u]
else:
# feeder <u, v> is segmented (detoured route)
# feeder <u, v> is segmented (detoured route). v may have more than
# one neighboring detour hop with load == this feeder's load (two
# sibling branches of equal size, more likely at larger N) --
# matching on load alone picks whichever candidate networkx iterates
# to first, which can be the wrong branch and silently miscompute
# length_cables (only caught downstream by the sum-consistency assert
# below, e.g. the N_turbines=100 sweep run). Disambiguate by walking
# each candidate chain of detour (Steiner) hops to its end and keeping
# the one that actually terminates at substation u.
v_neighbors = G[v]
for cur_hop in v_neighbors:
if cur_hop >= T and v_neighbors[cur_hop]["load"] == load:
for candidate in v_neighbors:
if candidate < T or v_neighbors[candidate]["load"] != load:
continue
hop, prev_hop = candidate, v
chain_length = v_neighbors[candidate]["length"]
while hop >= T:
s, t = G[hop]
hop, prev_hop = (s if t == prev_hop else t), hop
chain_length += G[hop][prev_hop]["length"]
if hop == u:
length_cables[i] = chain_length
break
length_cables[i] = v_neighbors[cur_hop]["length"]
prev_hop = v
while cur_hop >= T:
s, t = G[cur_hop]
cur_hop, prev_hop = (s if t == prev_hop else t), cur_hop
length_cables[i] += G[cur_hop][prev_hop]["length"]
else:
raise RuntimeError(
f"OptiwindnetCollection: no detour chain from turbine {v} "
f"with load {load} reaches substation {u}"
)
else:
# link (u, v) is not a feeder, so A has length data
length_cables[i] = A[u][v]["length"]
Expand Down
67 changes: 67 additions & 0 deletions ard/cost/surrogate_cost.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
Surrogate wind CapEx/OpEx model: quadratic fits of cost vs. N_turbines built
by build_surrogate.py (hybridfarm repo root, outside Ard) from a sweep of
per-N SNOPT+FLOWFarm layout optimizations.
"""

import pickle

import numpy as np
import openmdao.api as om


class SurrogateWindCost(om.ExplicitComponent):
"""
Wind CapEx/OpEx from the g(N_turbines) polynomial cost surrogate.

Options
-------
surrogate_pkl_path : str
path to outputs/surrogates/surrogate_g_cost.pkl from build_surrogate.py

Inputs
------
N_turbines : float
number of turbines (continuous, for gradient-based sizing)

Outputs
-------
CapEx : float
wind CapEx, USD -- includes cabling cost (direct material cost plus
its effect on LandBOSSE's spacing-driven BOS cost); cable length
itself is not exposed here since it is not reliably fit standalone
(validated 14-28% error vs. held-out FLOWFarm/optiwindnet runs),
while total CapEx validated to ~1% error since cabling is only a
small (~4-6%) share of it.
OpEx : float
wind annual OpEx, USD/yr
"""

def initialize(self):
self.options.declare("surrogate_pkl_path", types=str)

def setup(self):
with open(self.options["surrogate_pkl_path"], "rb") as fh:
pkl = pickle.load(fh)
self._capex_coeffs = pkl["poly_coeffs"]["wind_capex_usd"]
self._opex_coeffs = pkl["poly_coeffs"]["wind_opex_usd_per_yr"]
self._dcapex_coeffs = np.polyder(self._capex_coeffs)
self._dopex_coeffs = np.polyder(self._opex_coeffs)

self.add_input("N_turbines", val=25.0)
self.add_output("CapEx", val=0.0, units="USD")
self.add_output("OpEx", val=0.0, units="USD/yr")

def setup_partials(self):
self.declare_partials("CapEx", "N_turbines")
self.declare_partials("OpEx", "N_turbines")

def compute(self, inputs, outputs):
n = inputs["N_turbines"].item()
outputs["CapEx"] = np.polyval(self._capex_coeffs, n)
outputs["OpEx"] = np.polyval(self._opex_coeffs, n)

def compute_partials(self, inputs, partials):
n = inputs["N_turbines"].item()
partials["CapEx", "N_turbines"] = np.polyval(self._dcapex_coeffs, n)
partials["OpEx", "N_turbines"] = np.polyval(self._dopex_coeffs, n)
2 changes: 2 additions & 0 deletions ard/farm_aero/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from . import floris
from . import flowfarm
from . import placeholder
from . import surrogate
from . import templates
Loading