From 0279c248bb4c21b3d27f5b5a6891bf663b65c1f0 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:50:01 -0600 Subject: [PATCH 01/17] add FLOWFarm to aero models --- ard/farm_aero/flowfarm.py | 359 +++++++++++ ard/flowfarm/README.md | 52 ++ ard/flowfarm/__init__.py | 10 + ard/flowfarm/_jl_bootstrap.py | 115 ++++ ard/flowfarm/flowfarm_model.py | 353 +++++++++++ ard/flowfarm/julia_env/Manifest.toml | 594 ++++++++++++++++++ ard/flowfarm/julia_env/Project.toml | 8 + ard/flowfarm/pin_flowfarm.py | 39 ++ .../07_flowfarm_setup/inputs/ard_system.yaml | 178 ++++++ examples/07_flowfarm_setup/inputs/windio.yaml | 88 +++ .../07_flowfarm_setup/optimization_demo.ipynb | 438 +++++++++++++ 11 files changed, 2234 insertions(+) create mode 100644 ard/farm_aero/flowfarm.py create mode 100644 ard/flowfarm/README.md create mode 100644 ard/flowfarm/__init__.py create mode 100644 ard/flowfarm/_jl_bootstrap.py create mode 100644 ard/flowfarm/flowfarm_model.py create mode 100644 ard/flowfarm/julia_env/Manifest.toml create mode 100644 ard/flowfarm/julia_env/Project.toml create mode 100644 ard/flowfarm/pin_flowfarm.py create mode 100644 examples/07_flowfarm_setup/inputs/ard_system.yaml create mode 100644 examples/07_flowfarm_setup/inputs/windio.yaml create mode 100644 examples/07_flowfarm_setup/optimization_demo.ipynb diff --git a/ard/farm_aero/flowfarm.py b/ard/farm_aero/flowfarm.py new file mode 100644 index 00000000..dcbdb8dc --- /dev/null +++ b/ard/farm_aero/flowfarm.py @@ -0,0 +1,359 @@ +import os +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +from ard.farm_aero.floris import create_FLORIS_turbine_from_windIO +try: + from flowfarm.flowfarm_model import ( + ensure_flowfarm_loaded, + resolve_turbine_inputs_for_flowfarm, + resolve_wake_model_inputs_for_flowfarm, + ) +except ModuleNotFoundError: + # Local checkout fallback: add repository-level Ard/ to sys.path. + repo_ard_dir = Path(__file__).resolve().parents[2] + if str(repo_ard_dir) not in sys.path: + sys.path.insert(0, str(repo_ard_dir)) + from flowfarm.flowfarm_model import ( + ensure_flowfarm_loaded, + resolve_turbine_inputs_for_flowfarm, + resolve_wake_model_inputs_for_flowfarm, + ) + +import ard.farm_aero.templates as templates + +class FLOWFarmComponent: + + def initialize(self): + # This mixin is invoked explicitly by derived classes; no super() chain here. + return + + def setup(self): + jl = ensure_flowfarm_loaded() + self._jl = jl + model_options = self.options["modeling_options"] + self.N_turbines = model_options["layout"]["N_turbines"] + windIO = model_options["windIO_plant"] + N_turbines = self.N_turbines + + turbine_floris = create_FLORIS_turbine_from_windIO(windIO) + ref_air_density=model_options.get("flowfarm", {}).get( + "ref_air_density", 1.225 + ) + + + hub_height=turbine_floris["hub_height"] + rotor_diameter=turbine_floris["rotor_diameter"] + + windIOturbine = windIO["wind_farm"]["turbine"] + turbine_inputs = resolve_turbine_inputs_for_flowfarm(windIOturbine) + generator_efficiency = turbine_inputs["generator_efficiency"] + rated_power = turbine_inputs["rated_power"] + rated_wind_speed = turbine_inputs["rated_wind_speed"] + cutin_wind_speed = turbine_inputs["cutin_wind_speed"] + cutout_wind_speed = turbine_inputs["cutout_wind_speed"] + ct_model = turbine_inputs["ct_model"] + power_model = turbine_inputs["power_model"] + + windrose_floris = templates.create_windresource_from_windIO( + windIO, + resource_type="probability", + ) + + wind_directions = windrose_floris.wd_flat + wind_speeds = windrose_floris.ws_flat + wind_probabilities = windrose_floris.freq_table_flat + turbulence_intensity = np.mean(windrose_floris.ti_table_flat) + ref_height = windIO["site"]["energy_resource"]["wind_resource"].get( + "reference_height", hub_height + ) + wind_shear=windIO["site"]["energy_resource"]["wind_resource"].get( + "shear", 0.084 + ) + + flowfarm_options = model_options.get("flowfarm", {}) + wake_option_keys = { + "wake_deficit_model", + "wake_deflection_model", + "wake_combination_model", + "local_turbulence_model", + "tolerance", + } + wake_options_only = { + key: value + for key, value in flowfarm_options.items() + if key in wake_option_keys + } + wake_model_options = resolve_wake_model_inputs_for_flowfarm(wake_options_only) + + # FLOWFarm expects one model object per turbine. + ct_models = jl.fill(ct_model, N_turbines) + power_models = jl.fill(power_model, N_turbines) + + flowfarm_module = jl.FLOWFarm + n_states = len(wind_speeds) + + # FLOWFarm expects radians for wind direction. + wind_dirs_rad = jl.Vector[jl.Float64]( + list(map(float, np.deg2rad(np.asarray(wind_directions)))) + ) + wind_speeds_vec = jl.Vector[jl.Float64]( + list(map(float, np.asarray(wind_speeds))) + ) + wind_probs_vec = jl.Vector[jl.Float64]( + list(map(float, np.asarray(wind_probabilities))) + ) + ambient_tis = jl.fill(float(turbulence_intensity), n_states) + measurementheight = jl.fill(float(ref_height), n_states) + + wind_shear_model = flowfarm_module.PowerLawWindShear(float(wind_shear)) + windresource = flowfarm_module.DiscretizedWindResource( + wind_dirs_rad, + wind_speeds_vec, + wind_probs_vec, + measurementheight, + float(ref_air_density), + ambient_tis, + wind_shear_model, + ) + + wake_deficit = getattr( + flowfarm_module, wake_model_options["wake_deficit_model"] + )() + wake_deflection = getattr( + flowfarm_module, wake_model_options["wake_deflection_model"] + )() + wake_combine = getattr( + flowfarm_module, wake_model_options["wake_combination_model"] + )() + local_ti = getattr( + flowfarm_module, wake_model_options["local_turbulence_model"] + )() + + model_set = flowfarm_module.WindFarmModelSet( + wake_deficit, + wake_deflection, + wake_combine, + local_ti, + ) + + # Temporary initialization until layout-driven vectors are wired in. + x0 = jl.zeros(N_turbines * 3) + turbine_x = jl.zeros(N_turbines) + turbine_y = jl.zeros(N_turbines) + turbine_z = jl.zeros(N_turbines) + turbine_yaw = jl.zeros(N_turbines) + + hub_heights = jl.fill(float(hub_height), N_turbines) + rotor_diameters = jl.fill(float(rotor_diameter), N_turbines) + generator_efficiencies = jl.fill(float(generator_efficiency), N_turbines) + cut_in_speeds = jl.fill(float(cutin_wind_speed), N_turbines) + cut_out_speeds = jl.fill(float(cutout_wind_speed), N_turbines) + rated_speeds = jl.fill(float(rated_wind_speed), N_turbines) + rated_powers = jl.fill(float(rated_power), N_turbines) + + # Use a pure Julia callback so threaded FLOWFarm paths do not call back into Python. + jl.seval( + """ + function ard_make_flowfarm_update_fn() + return function (farm, x) + n = length(farm.turbine_x) + @inbounds for i in 1:n + farm.turbine_x[i] = x[i] + farm.turbine_y[i] = x[n + i] + farm.turbine_yaw[i] = x[2n + i] + end + return nothing + end + end + """ + ) + update_fn = jl.ard_make_flowfarm_update_fn() + sparse_farm, sparse_struct = flowfarm_module.build_unstable_sparse_struct( + x0, + turbine_x, + turbine_y, + turbine_z, + hub_heights, + turbine_yaw, + rotor_diameters, + ct_models, + generator_efficiencies, + cut_in_speeds, + cut_out_speeds, + rated_speeds, + rated_powers, + windresource, + power_models, + model_set, + update_fn, + AEP_scale=1, + opt_x=True, + opt_y=True, + opt_yaw=True, + tolerance=wake_model_options.get("tolerance", 1e-16), + ) + + farm = flowfarm_module.build_wind_farm_struct( + x0, + turbine_x, + turbine_y, + turbine_z, + hub_heights, + turbine_yaw, + rotor_diameters, + ct_models, + generator_efficiencies, + cut_in_speeds, + cut_out_speeds, + rated_speeds, + rated_powers, + windresource, + power_models, + model_set, + update_fn, + AEP_scale=1, + ) + + self.flowfarm_module = flowfarm_module + self.x0 = x0 + self.farm = farm + self.sparse_farm = sparse_farm + self.sparse_struct = sparse_struct + + def _build_design_vector(self, inputs): + x_turbines = np.asarray(inputs["x_turbines"], dtype=float) + y_turbines = np.asarray(inputs["y_turbines"], dtype=float) + yaw_turbines = np.asarray(inputs["yaw_turbines"], dtype=float) + return np.concatenate([x_turbines, y_turbines, yaw_turbines]).ravel() + + def _evaluate_sparse(self, x_eval_np): + """Run sparse gradient evaluation once and cache AEP/gradient for reuse.""" + if hasattr(self, "_cached_sparse_x") and np.array_equal(self._cached_sparse_x, x_eval_np): + return + + jl = getattr(self, "_jl", None) + if jl is None: + jl = ensure_flowfarm_loaded() + self._jl = jl + x_eval = jl.Vector[jl.Float64](list(map(float, x_eval_np))) + calculate_grad_bang = getattr(self.flowfarm_module, "calculate_aep_gradient!") + aep_val, grad_val = calculate_grad_bang( + self.sparse_farm, + x_eval, + self.sparse_struct, + ) + + self._cached_sparse_x = x_eval_np.copy() + self._cached_sparse_aep = float(aep_val) + self._cached_sparse_grad = np.asarray(grad_val).ravel().copy() + + def _evaluate_farm(self, x_eval_np): + """Run regular farm AEP evaluation and cache AEP.""" + if hasattr(self, "_cached_farm_x") and np.array_equal(self._cached_farm_x, x_eval_np): + return + + jl = getattr(self, "_jl", None) + if jl is None: + jl = ensure_flowfarm_loaded() + self._jl = jl + x_eval = jl.Vector[jl.Float64](list(map(float, x_eval_np))) + calculate_aep_bang = getattr(self.flowfarm_module, "calculate_aep!") + aep_val = calculate_aep_bang(self.farm, x_eval) + + self._cached_farm_x = x_eval_np.copy() + self._cached_farm_aep = float(aep_val) + + def _compute_aep(self, inputs, outputs): + """Compute farm AEP using regular calculate_aep!(farm, x).""" + x_eval_np = self._build_design_vector(inputs) + self._evaluate_farm(x_eval_np) + outputs["AEP_farm"] = self._cached_farm_aep + + def _compute_aep_partials(self, inputs, partials): + """Compute AEP partial derivatives from sparse gradient evaluation.""" + x_eval_np = self._build_design_vector(inputs) + self._evaluate_sparse(x_eval_np) + grad = self._cached_sparse_grad + partials["AEP_farm", "x_turbines"] = grad[: self.N_turbines] + partials["AEP_farm", "y_turbines"] = grad[ + self.N_turbines : 2 * self.N_turbines + ] + partials["AEP_farm", "yaw_turbines"] = grad[ + 2 * self.N_turbines : 3 * self.N_turbines + ] + + +class FLOWFarmAEP(templates.FarmAEPTemplate, FLOWFarmComponent): + + def initialize(self): + templates.FarmAEPTemplate.initialize(self) + FLOWFarmComponent.initialize(self) + + def setup(self): + templates.FarmAEPTemplate.setup(self) + FLOWFarmComponent.setup(self) + + def setup_partials(self): + self.declare_partials("AEP_farm", "x_turbines", method="exact") + self.declare_partials("AEP_farm", "y_turbines", method="exact") + self.declare_partials("AEP_farm", "yaw_turbines", method="exact") + + def compute(self, inputs, outputs): + FLOWFarmComponent._compute_aep(self, inputs, outputs) + + def compute_partials(self, inputs, partials): + FLOWFarmComponent._compute_aep_partials(self, inputs, partials) + + +class FLOWFarmBatchPower(templates.BatchFarmPowerTemplate, FLOWFarmComponent): + + def initialize(self): + templates.BatchFarmPowerTemplate.initialize(self) + FLOWFarmComponent.initialize(self) + + def setup(self): + templates.BatchFarmPowerTemplate.setup(self) + FLOWFarmComponent.setup(self) + + def setup_partials(self): + # State power sensitivities are provided via sparse_struct.state_gradients. + self.declare_partials("power_farm", "x_turbines", method="exact") + self.declare_partials("power_farm", "y_turbines", method="exact") + self.declare_partials("power_farm", "yaw_turbines", method="exact") + + def compute(self, inputs, outputs): + x_eval_np = self._build_design_vector(inputs) + self._evaluate_sparse(x_eval_np) + + state_powers = np.asarray(self.sparse_struct.state_powers).ravel() + turbine_powers = np.asarray(self.sparse_struct.turbine_powers) + + outputs["power_farm"] = state_powers + if ( + self.options["modeling_options"] + .get("aero", {}) + .get("return_turbine_output") + ): + outputs["power_turbines"] = turbine_powers + outputs["thrust_turbines"] = np.zeros( + (self.N_turbines, self.N_wind_conditions) + ) + + def compute_partials(self, inputs, partials): + x_eval_np = self._build_design_vector(inputs) + self._evaluate_sparse(x_eval_np) + + state_gradients = np.asarray(self.sparse_struct.state_gradients) + partials["power_farm", "x_turbines"] = state_gradients[ + :, : self.N_turbines + ] + partials["power_farm", "y_turbines"] = state_gradients[ + :, self.N_turbines : 2 * self.N_turbines + ] + partials["power_farm", "yaw_turbines"] = state_gradients[ + :, 2 * self.N_turbines : 3 * self.N_turbines + ] \ No newline at end of file diff --git a/ard/flowfarm/README.md b/ard/flowfarm/README.md new file mode 100644 index 00000000..b8b63fd4 --- /dev/null +++ b/ard/flowfarm/README.md @@ -0,0 +1,52 @@ +# FLOWFarm Integration in Ard + +This folder contains Ard's Python-Julia integration utilities for FLOWFarm. + +## What this integration does + +- Boots Julia through JuliaCall. +- Activates Ard's local Julia environment (`julia_env`). +- Loads FLOWFarm and builds farm and sparse structs for Ard components. +- Exposes helper functions used by the component wrapper in `ard/farm_aero/flowfarm.py`. + +## Threading behavior + +- Ard supports Julia threading through JuliaCall. +- The FLOWFarm update callback used by Ard is implemented in pure Julia (not Python callback) to avoid PythonCall thread-safety crashes. +- If you configure Julia threads with environment variables, set them **before** importing Ard/JuliaCall. + +Recommended JuliaCall env options for threaded runs: + +- `PYTHON_JULIACALL_THREADS=` +- `PYTHON_JULIACALL_HANDLE_SIGNALS=yes` +- (optional) `OPENBLAS_NUM_THREADS=1`, `OMP_NUM_THREADS=1` to avoid nested thread oversubscription + +## Tolerance behavior + +- FLOWFarm sparse-structure tolerance uses `modeling_options.flowfarm.tolerance`. +- If not provided, the default is `1e-16`. + +Example: + +```yaml +modeling_options: + flowfarm: + tolerance: 1.0e-16 +``` + +## Key files + +- `_jl_bootstrap.py`: Julia runtime bootstrap and env activation helpers. +- `flowfarm_model.py`: FLOWFarm model-construction utilities and option validation. +- `../ard/farm_aero/flowfarm.py`: OpenMDAO component wrapper that uses this integration. + +## Troubleshooting + +- Kernel/process crash when threads > 1: + - Ensure pure Julia callback path is active (current Ard default). + - Ensure thread env vars are set before importing Ard. + - Start with `PYTHON_JULIACALL_THREADS=1`, then increase. +- Julia environment mismatch errors: + - Re-instantiate the local Julia env in `julia_env`. + - Confirm FLOWFarm revision/pin is compatible with your Julia runtime. + diff --git a/ard/flowfarm/__init__.py b/ard/flowfarm/__init__.py new file mode 100644 index 00000000..18e87d83 --- /dev/null +++ b/ard/flowfarm/__init__.py @@ -0,0 +1,10 @@ +# ard/farm_aero/flowfarm/__init__.py +from .flowfarm_model import FlowFarmModel +from ._jl_bootstrap import ensure_flowfarm_loaded, get_julia_module, get_julia_runtime + +__all__ = [ + "FlowFarmModel", + "ensure_flowfarm_loaded", + "get_julia_module", + "get_julia_runtime", +] \ No newline at end of file diff --git a/ard/flowfarm/_jl_bootstrap.py b/ard/flowfarm/_jl_bootstrap.py new file mode 100644 index 00000000..9ec424cc --- /dev/null +++ b/ard/flowfarm/_jl_bootstrap.py @@ -0,0 +1,115 @@ +# ard/farm_aero/flowfarm/_jl_bootstrap.py +from __future__ import annotations +import os +import pathlib +import warnings + +# Hard-coded pin (change/remove later as needed) +FLOWFARM_GIT_URL = "https://github.com/byuflowlab/FLOWFarm.jl" +FLOWFARM_REV = "master" # <-- BRANCH PIN + +_jl_module = None +_jl_runtime = None +_flowfarm_env_initialized = False + +def _normalize_juliacall_env_vars(): + """Normalize JuliaCall env vars so Ard owns bootstrap by default. + + Set ARD_FLOWFARM_RESPECT_JULIACALL_ENV=1 to keep user-provided overrides. + """ + if os.environ.get("ARD_FLOWFARM_RESPECT_JULIACALL_ENV") == "1": + return + + project = os.environ.get("PYTHON_JULIACALL_PROJECT") + exe = os.environ.get("PYTHON_JULIACALL_EXE") + if project or exe: + warnings.warn( + "Ignoring external JuliaCall overrides in Ard bootstrap. " + "Set ARD_FLOWFARM_RESPECT_JULIACALL_ENV=1 to keep user-provided " + "PYTHON_JULIACALL_PROJECT/PYTHON_JULIACALL_EXE values.", + UserWarning, + stacklevel=2, + ) + os.environ.pop("PYTHON_JULIACALL_PROJECT", None) + os.environ.pop("PYTHON_JULIACALL_EXE", None) + + +def get_julia_runtime(): + """Return (Main, Pkg) from JuliaCall with Ard-safe bootstrap behavior.""" + global _jl_runtime + if _jl_runtime is not None: + return _jl_runtime + + _normalize_juliacall_env_vars() + from juliacall import Main as jl_main + from juliacall import Pkg as jl_pkg + + _jl_runtime = (jl_main, jl_pkg) + return _jl_runtime + + +def _is_manifest_mismatch_error(exc: Exception) -> bool: + text = str(exc) + markers = [ + "different julia version", + "manifest generated by a different version of Julia", + "Could not locate the source code for the StyledStrings package", + ] + text_lower = text.lower() + return any(marker.lower() in text_lower for marker in markers) + + +def _rebuild_flowfarm_env(jl_pkg, env_dir: pathlib.Path): + """Rebuild Ard FLOWFarm manifest for the currently running Julia runtime.""" + manifest_path = env_dir / "Manifest.toml" + if manifest_path.exists(): + manifest_path.unlink() + + # Ensure FLOWFarm source is pinned and captured in the new manifest. + try: + jl_pkg.rm("FLOWFarm") + except Exception: + pass + jl_pkg.add(url=FLOWFARM_GIT_URL, rev=FLOWFARM_REV) + jl_pkg.resolve() + jl_pkg.instantiate() + + +def ensure_flowfarm_loaded(): + """Activate Ard Julia env and load FLOWFarm in Julia Main.""" + global _flowfarm_env_initialized + jl_main, jl_pkg = get_julia_runtime() + if not _flowfarm_env_initialized: + env_dir = pathlib.Path(__file__).parent / "julia_env" + jl_pkg.activate(str(env_dir)) + try: + jl_pkg.instantiate() + except Exception as exc: + if not _is_manifest_mismatch_error(exc): + raise + warnings.warn( + "FLOWFarm Julia manifest/runtime mismatch detected. Rebuilding " + "manifest for the current Julia runtime.", + UserWarning, + stacklevel=2, + ) + _rebuild_flowfarm_env(jl_pkg, env_dir) + _flowfarm_env_initialized = True + + if "FLOWFarm" not in dir(jl_main): + jl_main.seval("using FLOWFarm") + return jl_main + +def get_julia_module(): + """Initialize Julia once, activate env, and return a private module with FLOWFarm loaded.""" + global _jl_module + if _jl_module is not None: + return _jl_module + + ensure_flowfarm_loaded() + import juliacall + + jl = juliacall.newmodule("ArdFLOWFarm") # recommended pattern to avoid polluting Main [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) + jl.seval("using FLOWFarm") # standard FLOWFarm usage/installation [2](https://github.com/byuflowlab/FlowFarm.jl) + _jl_module = jl + return _jl_module diff --git a/ard/flowfarm/flowfarm_model.py b/ard/flowfarm/flowfarm_model.py new file mode 100644 index 00000000..b38d5dec --- /dev/null +++ b/ard/flowfarm/flowfarm_model.py @@ -0,0 +1,353 @@ +# ard/farm_aero/flowfarm/interface.py +from __future__ import annotations + +import os +import pathlib +import warnings +import numpy as np +import pandas as pd + +from ._jl_bootstrap import ensure_flowfarm_loaded, get_julia_runtime + +# ------------------------------------------------------------------------------ +# Configuration (project activation) +# ------------------------------------------------------------------------------ + +# If you're using the embedded project (recommended), point to it explicitly: +_THIS_DIR = pathlib.Path(__file__).resolve().parent +_JULIA_PROJECT_DIR = _THIS_DIR / "julia_env" + + +def _get_jl_main(): + jl, _ = get_julia_runtime() + return jl + + +def _ensure_env_activated(): + # Prefer explicit activation over relying on JULIA_PROJECT env var. + _, jl_pkg = get_julia_runtime() + jl_pkg.activate(str(_JULIA_PROJECT_DIR)) + jl_pkg.instantiate() # ensures Manifest is honored / deps are present + +def _ensure_flowfarm_loaded(): + ensure_flowfarm_loaded() + +# ------------------------------------------------------------------------------ +# Utility: Julia Vector conversion (optional; JuliaCall already converts NumPy arrays) +# ------------------------------------------------------------------------------ + +def _jvec(x): + """Convert Python list/array → Julia Vector{Float64} (explicit).""" + jl = _get_jl_main() + return jl.Vector[jl.Float64](list(map(float, np.asarray(x).ravel()))) + + +def _resolve_flowfarm_constructor(flowfarm_module, candidate_names): + """Return the first FLOWFarm constructor that exists from candidate names.""" + for name in candidate_names: + if hasattr(flowfarm_module, name): + return getattr(flowfarm_module, name) + return None + + +def _build_flowfarm_power_model( + flowfarm_module, + has_cp_curve, + cp_curve, + constant_cp, + fallback_wind_speeds, +): + """Build a FLOWFarm power model from Cp curve or constant Cp fallback.""" + if has_cp_curve: + power_points_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["PowerModelCpPoints"], + ) + if power_points_ctor is None: + raise AttributeError("FLOWFarm.PowerModelCpPoints constructor was not found.") + return power_points_ctor( + _jvec(cp_curve["Cp_wind_speeds"]), + _jvec(cp_curve["Cp_values"]), + ) + + power_constant_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["PowerModelConstantCp", "PowerModelCpConstant"], + ) + if power_constant_ctor is not None: + return power_constant_ctor(float(constant_cp)) + + # Last-resort fallback if constant-Cp constructor name differs by FLOWFarm version. + # Approximate a constant Cp model using points at representative wind speeds. + warnings.warn( + "FLOWFarm constant-Cp constructor not found; falling back to PowerModelCpPoints with constant Cp.", + UserWarning, + stacklevel=2, + ) + power_points_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["PowerModelCpPoints"], + ) + if power_points_ctor is None: + raise AttributeError("FLOWFarm.PowerModelCpPoints constructor was not found.") + cp_values = [float(constant_cp)] * len(fallback_wind_speeds) + return power_points_ctor(_jvec(fallback_wind_speeds), _jvec(cp_values)) + + +def _build_flowfarm_ct_model( + flowfarm_module, + has_ct_curve, + ct_curve, + constant_ct, + fallback_wind_speeds, +): + """Build a FLOWFarm thrust model from Ct curve or constant Ct fallback.""" + if has_ct_curve: + ct_points_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["ThrustModelCtPoints"], + ) + if ct_points_ctor is None: + raise AttributeError("FLOWFarm.ThrustModelCtPoints constructor was not found.") + return ct_points_ctor( + _jvec(ct_curve["Ct_wind_speeds"]), + _jvec(ct_curve["Ct_values"]), + ) + + ct_constant_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["ThrustModelConstantCt", "ThrustModelCtConstant"], + ) + if ct_constant_ctor is not None: + return ct_constant_ctor(float(constant_ct)) + + # Last-resort fallback if constant-Ct constructor name differs by FLOWFarm version. + warnings.warn( + "FLOWFarm constant-Ct constructor not found; falling back to ThrustModelCtPoints with constant Ct.", + UserWarning, + stacklevel=2, + ) + ct_points_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["ThrustModelCtPoints"], + ) + if ct_points_ctor is None: + raise AttributeError("FLOWFarm.ThrustModelCtPoints constructor was not found.") + ct_values = [float(constant_ct)] * len(fallback_wind_speeds) + return ct_points_ctor(_jvec(fallback_wind_speeds), _jvec(ct_values)) + + +def resolve_turbine_inputs_for_flowfarm(windio_turbine): + """Validate turbine inputs and return a normalized config dict for FLOWFarm.""" + _ensure_flowfarm_loaded() + jl = _get_jl_main() + flowfarm_module = jl.FLOWFarm + + scalar_defaults = { + "generator_efficiency": 1.0, + "rated_power": 1e6, + "rated_wind_speed": 10.0, + "cutin_wind_speed": 0.0, + "cutout_wind_speed": 100.0, + } + + missing_scalars = [ + key for key in scalar_defaults if key not in windio_turbine or windio_turbine[key] is None + ] + if missing_scalars: + defaults_used = {key: scalar_defaults[key] for key in missing_scalars} + warnings.warn( + f"FLOWFarm missing turbine inputs {missing_scalars}; using defaults {defaults_used}.", + UserWarning, + stacklevel=2, + ) + + performance = windio_turbine.get("performance", {}) + ct_curve = performance.get("Ct_curve", {}) + cp_curve = performance.get("Cp_curve", {}) + + has_ct_curve = ( + "Ct_wind_speeds" in ct_curve + and "Ct_values" in ct_curve + and ct_curve["Ct_wind_speeds"] is not None + and ct_curve["Ct_values"] is not None + ) + has_cp_curve = ( + "Cp_wind_speeds" in cp_curve + and "Cp_values" in cp_curve + and cp_curve["Cp_wind_speeds"] is not None + and cp_curve["Cp_values"] is not None + ) + + constant_ct = performance.get("Ct", performance.get("ct", 0.8)) + constant_cp = performance.get("Cp", performance.get("cp", 0.45)) + + if not has_ct_curve: + warnings.warn( + f"FLOWFarm missing turbine.performance.Ct_curve; using constant Ct={constant_ct}.", + UserWarning, + stacklevel=2, + ) + if not has_cp_curve: + warnings.warn( + f"FLOWFarm missing turbine.performance.Cp_curve; using constant Cp={constant_cp}.", + UserWarning, + stacklevel=2, + ) + + fallback_wind_speeds = [ + float(windio_turbine.get("cutin_wind_speed", scalar_defaults["cutin_wind_speed"])), + float(windio_turbine.get("rated_wind_speed", scalar_defaults["rated_wind_speed"])), + float(windio_turbine.get("cutout_wind_speed", scalar_defaults["cutout_wind_speed"])), + ] + + power_model = _build_flowfarm_power_model( + flowfarm_module, + has_cp_curve, + cp_curve, + constant_cp, + fallback_wind_speeds, + ) + ct_model = _build_flowfarm_ct_model( + flowfarm_module, + has_ct_curve, + ct_curve, + constant_ct, + fallback_wind_speeds, + ) + + return { + "generator_efficiency": windio_turbine.get("generator_efficiency", scalar_defaults["generator_efficiency"]), + "rated_power": windio_turbine.get("rated_power", scalar_defaults["rated_power"]), + "rated_wind_speed": windio_turbine.get("rated_wind_speed", scalar_defaults["rated_wind_speed"]), + "cutin_wind_speed": windio_turbine.get("cutin_wind_speed", scalar_defaults["cutin_wind_speed"]), + "cutout_wind_speed": windio_turbine.get("cutout_wind_speed", scalar_defaults["cutout_wind_speed"]), + "ct_model": ct_model, + "power_model": power_model, + } + + +def resolve_wake_model_inputs_for_flowfarm(flowfarm_model_options): + """Resolve wake model options with defaults and validate user-provided values.""" + if flowfarm_model_options is None: + flowfarm_model_options = {} + if not isinstance(flowfarm_model_options, dict): + raise TypeError( + "FLOWFarm options must be provided as a dictionary." + ) + + defaults = { + "wake_deficit_model": "GaussYawVariableSpread", + "wake_deflection_model": "GaussYawVariableSpreadDeflection", + "wake_combination_model": "LinearLocalVelocitySuperposition", + "local_turbulence_model": "LocalTIModelNoLocalTI", + "tolerance": 1e-16, + } + + allowed_values = { + "wake_deficit_model": { + "JensenTopHat", + "JensenCosine", + "MultiZone", + "GaussOriginal", + "GaussYaw", + "GaussYawVariableSpread", + "GaussSimple", + "CumulativeCurl", + "NoWakeDeficit", + }, + "wake_deflection_model": { + "NoYawDeflection", + "GaussYawDeflection", + "GaussYawVariableSpreadDeflection", + "JiminezYawDeflection", + "MultizoneDeflection", + }, + "wake_combination_model": { + "LinearFreestreamSuperposition", + "SumOfSquaresFreestreamSuperposition", + "SumOfSquaresLocalVelocitySuperposition", + "LinearLocalVelocitySuperposition", + }, + "local_turbulence_model": { + "LocalTIModelNoLocalTI", + "LocalTIModelMaxTI", + "LocalTIModelGaussTI", + }, + } + + unknown_keys = [k for k in flowfarm_model_options if k not in defaults] + if unknown_keys: + warnings.warn( + f"FLOWFarm unknown wake model options {unknown_keys}; ignoring these keys.", + UserWarning, + stacklevel=2, + ) + + missing = [ + key for key in defaults if key not in flowfarm_model_options or flowfarm_model_options[key] is None + ] + if missing: + defaults_used = {key: defaults[key] for key in missing} + warnings.warn( + f"FLOWFarm missing wake model inputs {missing}; using defaults {defaults_used}.", + UserWarning, + stacklevel=2, + ) + + resolved = {} + model_keys = [ + "wake_deficit_model", + "wake_deflection_model", + "wake_combination_model", + "local_turbulence_model", + ] + for key in model_keys: + value = flowfarm_model_options.get(key, defaults[key]) + if not isinstance(value, str): + raise TypeError( + f"FLOWFarm option '{key}' must be a string. Got {type(value).__name__}." + ) + + value = value.strip() + if not value: + raise ValueError(f"FLOWFarm option '{key}' cannot be empty.") + + allowed_for_key = allowed_values[key] + alias_lookup = {v.lower(): v for v in allowed_for_key} + value_canonical = alias_lookup.get(value.lower()) + if value_canonical is None: + raise ValueError( + f"Invalid FLOWFarm option for '{key}': '{value}'. " + f"Allowed values: {sorted(allowed_for_key)}" + ) + + resolved[key] = value_canonical + + tolerance = flowfarm_model_options.get("tolerance", defaults["tolerance"]) + if not isinstance(tolerance, (int, float)): + raise TypeError( + f"FLOWFarm option 'tolerance' must be numeric. Got {type(tolerance).__name__}." + ) + tolerance = float(tolerance) + if tolerance <= 0.0: + raise ValueError("FLOWFarm option 'tolerance' must be > 0.") + resolved["tolerance"] = tolerance + + return resolved + +# ------------------------------------------------------------------------------ +# Public interface +# ------------------------------------------------------------------------------ + +class FlowFarmModel: + + def __init__(self, wind_rose, layout_x, layout_y, yaw_turbine): + _ensure_env_activated() + _ensure_flowfarm_loaded() + + n_turbines = len(layout_x) + + + + self.farm, self.sparse_struct = load_flowfarm_model() \ No newline at end of file diff --git a/ard/flowfarm/julia_env/Manifest.toml b/ard/flowfarm/julia_env/Manifest.toml new file mode 100644 index 00000000..99f6bffe --- /dev/null +++ b/ard/flowfarm/julia_env/Manifest.toml @@ -0,0 +1,594 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.10.10" +manifest_format = "2.0" +project_hash = "4455b04176d5094cc95f66136ffa729fd2908de3" + +[[deps.ADTypes]] +git-tree-sha1 = "f7304359109c768cf32dc5fa2d371565bb63b68a" +uuid = "47edcb42-4c32-4615-8424-f2b9edc5f35b" +version = "1.21.0" + + [deps.ADTypes.extensions] + ADTypesChainRulesCoreExt = "ChainRulesCore" + ADTypesConstructionBaseExt = "ConstructionBase" + ADTypesEnzymeCoreExt = "EnzymeCore" + + [deps.ADTypes.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9" + EnzymeCore = "f151be2c-9106-41f4-ab19-57ee4f262869" + +[[deps.Accessors]] +deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] +git-tree-sha1 = "856ecd7cebb68e5fc87abecd2326ad59f0f911f3" +uuid = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697" +version = "0.1.43" + + [deps.Accessors.extensions] + AxisKeysExt = "AxisKeys" + IntervalSetsExt = "IntervalSets" + LinearAlgebraExt = "LinearAlgebra" + StaticArraysExt = "StaticArrays" + StructArraysExt = "StructArrays" + TestExt = "Test" + UnitfulExt = "Unitful" + + [deps.Accessors.weakdeps] + AxisKeys = "94b1ba4f-4ee9-5380-92f1-94cde586c3c5" + IntervalSets = "8197267c-284f-5f27-9208-e0e47529a953" + LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" + StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" + StructArrays = "09ab397b-f2b6-538f-b94a-2f83cf4a842a" + Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" + +[[deps.Adapt]] +deps = ["LinearAlgebra", "Requires"] +git-tree-sha1 = "35ea197a51ce46fcd01c4a44befce0578a1aaeca" +uuid = "79e6a3ab-5dfb-504d-930d-738a2a938a0e" +version = "4.5.0" +weakdeps = ["SparseArrays", "StaticArrays"] + + [deps.Adapt.extensions] + AdaptSparseArraysExt = "SparseArrays" + AdaptStaticArraysExt = "StaticArrays" + +[[deps.ArnoldiMethod]] +deps = ["LinearAlgebra", "Random", "StaticArrays"] +git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6" +uuid = "ec485272-7323-5ecc-a04f-4719b315124d" +version = "0.4.0" + +[[deps.ArrayInterface]] +deps = ["Adapt", "LinearAlgebra"] +git-tree-sha1 = "d81ae5489e13bc03567d4fbbb06c546a5e53c857" +uuid = "4fba245c-0d91-5ea0-9b3e-6abc04ee57a9" +version = "7.22.0" + + [deps.ArrayInterface.extensions] + ArrayInterfaceBandedMatricesExt = "BandedMatrices" + ArrayInterfaceBlockBandedMatricesExt = "BlockBandedMatrices" + ArrayInterfaceCUDAExt = "CUDA" + ArrayInterfaceCUDSSExt = ["CUDSS", "CUDA"] + ArrayInterfaceChainRulesCoreExt = "ChainRulesCore" + ArrayInterfaceChainRulesExt = "ChainRules" + ArrayInterfaceGPUArraysCoreExt = "GPUArraysCore" + ArrayInterfaceMetalExt = "Metal" + ArrayInterfaceReverseDiffExt = "ReverseDiff" + ArrayInterfaceSparseArraysExt = "SparseArrays" + ArrayInterfaceStaticArraysCoreExt = "StaticArraysCore" + ArrayInterfaceTrackerExt = "Tracker" + + [deps.ArrayInterface.weakdeps] + BandedMatrices = "aae01518-5342-5314-be14-df237901396f" + BlockBandedMatrices = "ffab5731-97b5-5995-9138-79e8c1846df0" + CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" + CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e" + ChainRules = "082447d4-558c-5d27-93f4-14fc19e9eca2" + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + GPUArraysCore = "46192b85-c4d5-4398-a991-12ede77f4527" + Metal = "dde4c033-4e86-420c-a63e-0dd931031962" + ReverseDiff = "37e2e3b7-166d-5795-8a7a-e32c996b4267" + SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" + StaticArraysCore = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" + Tracker = "9f7883ad-71c0-57eb-9f7f-b5c9e6d3789c" + +[[deps.Artifacts]] +uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" + +[[deps.Base64]] +uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" + +[[deps.ChainRulesCore]] +deps = ["Compat", "LinearAlgebra"] +git-tree-sha1 = "e4c6a16e77171a5f5e25e9646617ab1c276c5607" +uuid = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" +version = "1.26.0" +weakdeps = ["SparseArrays"] + + [deps.ChainRulesCore.extensions] + ChainRulesCoreSparseArraysExt = "SparseArrays" + +[[deps.CommonSubexpressions]] +deps = ["MacroTools"] +git-tree-sha1 = "cda2cfaebb4be89c9084adaca7dd7333369715c5" +uuid = "bbf7d656-a473-5ed7-a52c-81e309532950" +version = "0.3.1" + +[[deps.CommonWorldInvalidations]] +git-tree-sha1 = "ae52d1c52048455e85a387fbee9be553ec2b68d0" +uuid = "f70d9fcc-98c5-4d4a-abd7-e4cdeebd8ca8" +version = "1.0.0" + +[[deps.Compat]] +deps = ["TOML", "UUIDs"] +git-tree-sha1 = "9d8a54ce4b17aa5bdce0ea5c34bc5e7c340d16ad" +uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" +version = "4.18.1" +weakdeps = ["Dates", "LinearAlgebra"] + + [deps.Compat.extensions] + CompatLinearAlgebraExt = "LinearAlgebra" + +[[deps.CompilerSupportLibraries_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" +version = "1.1.1+0" + +[[deps.CompositionsBase]] +git-tree-sha1 = "802bb88cd69dfd1509f6670416bd4434015693ad" +uuid = "a33af91c-f02d-484b-be07-31d278c5ca2b" +version = "0.1.2" +weakdeps = ["InverseFunctions"] + + [deps.CompositionsBase.extensions] + CompositionsBaseInverseFunctionsExt = "InverseFunctions" + +[[deps.ConstructionBase]] +git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb" +uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9" +version = "1.6.0" + + [deps.ConstructionBase.extensions] + ConstructionBaseIntervalSetsExt = "IntervalSets" + ConstructionBaseLinearAlgebraExt = "LinearAlgebra" + ConstructionBaseStaticArraysExt = "StaticArrays" + + [deps.ConstructionBase.weakdeps] + IntervalSets = "8197267c-284f-5f27-9208-e0e47529a953" + LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" + StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" + +[[deps.CoordinateTransformations]] +deps = ["LinearAlgebra", "StaticArrays"] +git-tree-sha1 = "a692f5e257d332de1e554e4566a4e5a8a72de2b2" +uuid = "150eb455-5306-5404-9cee-2592286d6298" +version = "0.6.4" + +[[deps.DataStructures]] +deps = ["Compat", "InteractiveUtils", "OrderedCollections"] +git-tree-sha1 = "4e1fe97fdaed23e9dc21d4d664bea76b65fc50a0" +uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" +version = "0.18.22" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" + +[[deps.DeepDiffs]] +git-tree-sha1 = "9824894295b62a6a4ab6adf1c7bf337b3a9ca34c" +uuid = "ab62b9b5-e342-54a8-a765-a90f495de1a6" +version = "1.2.0" + +[[deps.DiffResults]] +deps = ["StaticArraysCore"] +git-tree-sha1 = "782dd5f4561f5d267313f23853baaaa4c52ea621" +uuid = "163ba53b-c6d8-5494-b064-1a9d43ac40c5" +version = "1.1.0" + +[[deps.DiffRules]] +deps = ["IrrationalConstants", "LogExpFunctions", "NaNMath", "Random", "SpecialFunctions"] +git-tree-sha1 = "23163d55f885173722d1e4cf0f6110cdbaf7e272" +uuid = "b552c78f-8df3-52c6-915a-8e097449b14b" +version = "1.15.1" + +[[deps.Distributed]] +deps = ["Random", "Serialization", "Sockets"] +uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b" + +[[deps.DocStringExtensions]] +git-tree-sha1 = "7442a5dfe1ebb773c29cc2962a8980f47221d76c" +uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" +version = "0.9.5" + +[[deps.FLOWFarm]] +deps = ["DiffResults", "Distributed", "FLOWMath", "FiniteDiff", "ForwardDiff", "Geodesy", "LinearAlgebra", "ReverseDiff", "SparseArrays", "SparseDiffTools", "SpecialFunctions", "TestSetExtensions", "YAML"] +git-tree-sha1 = "611c8403cc08479daed75c0f084f59623b731ab0" +repo-rev = "typestability" +repo-url = "https://github.com/byuflowlab/FLOWFarm.jl" +uuid = "eb2d4cfc-2064-11ea-0a1c-63d372e6a848" +version = "1.0.0" + +[[deps.FLOWMath]] +deps = ["LinearAlgebra", "OffsetArrays", "StaticArrays"] +git-tree-sha1 = "37ddbaadee1108c0976b21bec04cde71641858de" +uuid = "6cb5d3fb-0fe8-4cc2-bd89-9fe0b19a99d3" +version = "1.0.0" + +[[deps.FiniteDiff]] +deps = ["ArrayInterface", "LinearAlgebra", "Setfield"] +git-tree-sha1 = "9340ca07ca27093ff68418b7558ca37b05f8aeb1" +uuid = "6a86dc24-6348-571c-b903-95158fe2bd41" +version = "2.29.0" + + [deps.FiniteDiff.extensions] + FiniteDiffBandedMatricesExt = "BandedMatrices" + FiniteDiffBlockBandedMatricesExt = "BlockBandedMatrices" + FiniteDiffSparseArraysExt = "SparseArrays" + FiniteDiffStaticArraysExt = "StaticArrays" + + [deps.FiniteDiff.weakdeps] + BandedMatrices = "aae01518-5342-5314-be14-df237901396f" + BlockBandedMatrices = "ffab5731-97b5-5995-9138-79e8c1846df0" + SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" + StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" + +[[deps.ForwardDiff]] +deps = ["CommonSubexpressions", "DiffResults", "DiffRules", "LinearAlgebra", "LogExpFunctions", "NaNMath", "Preferences", "Printf", "Random", "SpecialFunctions"] +git-tree-sha1 = "eef4c86803f47dcb61e9b8790ecaa96956fdd8ae" +uuid = "f6369f11-7733-5829-9624-2563aa707210" +version = "1.3.2" +weakdeps = ["StaticArrays"] + + [deps.ForwardDiff.extensions] + ForwardDiffStaticArraysExt = "StaticArrays" + +[[deps.FunctionWrappers]] +git-tree-sha1 = "d62485945ce5ae9c0c48f124a84998d755bae00e" +uuid = "069b7b12-0de2-55c6-9aab-29f3d0a68a2e" +version = "1.1.3" + +[[deps.Future]] +deps = ["Random"] +uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" + +[[deps.Geodesy]] +deps = ["CoordinateTransformations", "Dates", "LinearAlgebra", "StaticArrays"] +git-tree-sha1 = "d48fa6a8d49350670b5ba409dbe2ddec791b4808" +uuid = "0ef565a4-170c-5f04-8de2-149903a85f3d" +version = "1.2.0" + +[[deps.Graphs]] +deps = ["ArnoldiMethod", "DataStructures", "Distributed", "Inflate", "LinearAlgebra", "Random", "SharedArrays", "SimpleTraits", "SparseArrays", "Statistics"] +git-tree-sha1 = "7a98c6502f4632dbe9fb1973a4244eaa3324e84d" +uuid = "86223c79-3864-5bf0-83f7-82e725a168b6" +version = "1.13.1" + +[[deps.IfElse]] +git-tree-sha1 = "debdd00ffef04665ccbb3e150747a77560e8fad1" +uuid = "615f187c-cbe4-4ef1-ba3b-2fcf58d6d173" +version = "0.1.1" + +[[deps.Inflate]] +git-tree-sha1 = "d1b1b796e47d94588b3757fe84fbf65a5ec4a80d" +uuid = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9" +version = "0.1.5" + +[[deps.InteractiveUtils]] +deps = ["Markdown"] +uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" + +[[deps.InverseFunctions]] +git-tree-sha1 = "a779299d77cd080bf77b97535acecd73e1c5e5cb" +uuid = "3587e190-3f89-42d0-90ee-14403ec27112" +version = "0.1.17" +weakdeps = ["Dates", "Test"] + + [deps.InverseFunctions.extensions] + InverseFunctionsDatesExt = "Dates" + InverseFunctionsTestExt = "Test" + +[[deps.IrrationalConstants]] +git-tree-sha1 = "b2d91fe939cae05960e760110b328288867b5758" +uuid = "92d709cd-6900-40b7-9082-c6be49f344b6" +version = "0.2.6" + +[[deps.JLLWrappers]] +deps = ["Artifacts", "Preferences"] +git-tree-sha1 = "0533e564aae234aff59ab625543145446d8b6ec2" +uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" +version = "1.7.1" + +[[deps.Libdl]] +uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" + +[[deps.Libiconv_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "be484f5c92fad0bd8acfef35fe017900b0b73809" +uuid = "94ce4f54-9a6c-5748-9c1c-f9c7231a4531" +version = "1.18.0+0" + +[[deps.LinearAlgebra]] +deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] +uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" + +[[deps.LogExpFunctions]] +deps = ["DocStringExtensions", "IrrationalConstants", "LinearAlgebra"] +git-tree-sha1 = "13ca9e2586b89836fd20cccf56e57e2b9ae7f38f" +uuid = "2ab3a3ac-af41-5b50-aa03-7779005ae688" +version = "0.3.29" + + [deps.LogExpFunctions.extensions] + LogExpFunctionsChainRulesCoreExt = "ChainRulesCore" + LogExpFunctionsChangesOfVariablesExt = "ChangesOfVariables" + LogExpFunctionsInverseFunctionsExt = "InverseFunctions" + + [deps.LogExpFunctions.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + ChangesOfVariables = "9e997f8a-9a97-42d5-a9f1-ce6bfc15e2c0" + InverseFunctions = "3587e190-3f89-42d0-90ee-14403ec27112" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" + +[[deps.MacroTools]] +git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" +uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" +version = "0.5.16" + +[[deps.Markdown]] +deps = ["Base64"] +uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" + +[[deps.Mmap]] +uuid = "a63ad114-7e13-5084-954f-fe012c677804" + +[[deps.NaNMath]] +deps = ["OpenLibm_jll"] +git-tree-sha1 = "9b8215b1ee9e78a293f99797cd31375471b2bcae" +uuid = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" +version = "1.1.3" + +[[deps.OffsetArrays]] +git-tree-sha1 = "117432e406b5c023f665fa73dc26e79ec3630151" +uuid = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" +version = "1.17.0" +weakdeps = ["Adapt"] + + [deps.OffsetArrays.extensions] + OffsetArraysAdaptExt = "Adapt" + +[[deps.OpenBLAS_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] +uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" +version = "0.3.23+4" + +[[deps.OpenLibm_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "05823500-19ac-5b8b-9628-191a04bc5112" +version = "0.8.5+0" + +[[deps.OpenSpecFun_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] +git-tree-sha1 = "1346c9208249809840c91b26703912dff463d335" +uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e" +version = "0.5.6+0" + +[[deps.OrderedCollections]] +git-tree-sha1 = "05868e21324cede2207c6f0f466b4bfef6d5e7ee" +uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +version = "1.8.1" + +[[deps.PackageExtensionCompat]] +git-tree-sha1 = "fb28e33b8a95c4cee25ce296c817d89cc2e53518" +uuid = "65ce6f38-6b18-4e1d-a461-8949797d7930" +version = "1.0.2" +weakdeps = ["Requires", "TOML"] + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "5aa36f7049a63a1528fe8f7c3f2113413ffd4e1f" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.2.1" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "8b770b60760d4451834fe79dd483e318eee709c4" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.5.2" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" + +[[deps.Random]] +deps = ["SHA"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" + +[[deps.Reexport]] +git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" +uuid = "189a3867-3050-52da-a836-e630ba90ab69" +version = "1.2.2" + +[[deps.Requires]] +deps = ["UUIDs"] +git-tree-sha1 = "62389eeff14780bfe55195b7204c0d8738436d64" +uuid = "ae029012-a4dd-5104-9daa-d747884805df" +version = "1.3.1" + +[[deps.ReverseDiff]] +deps = ["ChainRulesCore", "DiffResults", "DiffRules", "ForwardDiff", "FunctionWrappers", "LinearAlgebra", "LogExpFunctions", "MacroTools", "NaNMath", "Random", "SpecialFunctions", "StaticArrays", "Statistics"] +git-tree-sha1 = "f1b07322a8cdc0d46812473b37fb72f69ec07b22" +uuid = "37e2e3b7-166d-5795-8a7a-e32c996b4267" +version = "1.16.2" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.SciMLOperators]] +deps = ["Accessors", "ArrayInterface", "DocStringExtensions", "LinearAlgebra", "MacroTools"] +git-tree-sha1 = "d82853c515a8d9d42c1ab493a2687a37f1e26c91" +uuid = "c0aeaf25-5076-4817-a8d5-81caf7dfa961" +version = "0.4.0" +weakdeps = ["SparseArrays", "StaticArraysCore"] + + [deps.SciMLOperators.extensions] + SciMLOperatorsSparseArraysExt = "SparseArrays" + SciMLOperatorsStaticArraysCoreExt = "StaticArraysCore" + +[[deps.SciMLPublic]] +git-tree-sha1 = "0ba076dbdce87ba230fff48ca9bca62e1f345c9b" +uuid = "431bcebd-1456-4ced-9d72-93c2757fff0b" +version = "1.0.1" + +[[deps.Serialization]] +uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" + +[[deps.Setfield]] +deps = ["ConstructionBase", "Future", "MacroTools", "StaticArraysCore"] +git-tree-sha1 = "c5391c6ace3bc430ca630251d02ea9687169ca68" +uuid = "efcf1570-3423-57d1-acb7-fd33fddbac46" +version = "1.1.2" + +[[deps.SharedArrays]] +deps = ["Distributed", "Mmap", "Random", "Serialization"] +uuid = "1a1011a3-84de-559e-8e89-a11a2f7dc383" + +[[deps.SimpleTraits]] +deps = ["InteractiveUtils", "MacroTools"] +git-tree-sha1 = "be8eeac05ec97d379347584fa9fe2f5f76795bcb" +uuid = "699a6c99-e7fa-54fc-8d76-47d257e15c1d" +version = "0.9.5" + +[[deps.Sockets]] +uuid = "6462fe0b-24de-5631-8697-dd941f90decc" + +[[deps.SparseArrays]] +deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"] +uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +version = "1.10.0" + +[[deps.SparseDiffTools]] +deps = ["ADTypes", "Adapt", "ArrayInterface", "Compat", "DataStructures", "FiniteDiff", "ForwardDiff", "Graphs", "LinearAlgebra", "PackageExtensionCompat", "Random", "Reexport", "SciMLOperators", "Setfield", "SparseArrays", "StaticArrayInterface", "StaticArrays", "UnPack", "VertexSafeGraphs"] +git-tree-sha1 = "ccbf06a08573200853b1bd06203d8ccce8449578" +uuid = "47a9eef4-7e08-11e9-0b38-333d64bd3804" +version = "2.26.0" + + [deps.SparseDiffTools.extensions] + SparseDiffToolsEnzymeExt = "Enzyme" + SparseDiffToolsPolyesterExt = "Polyester" + SparseDiffToolsPolyesterForwardDiffExt = "PolyesterForwardDiff" + SparseDiffToolsSymbolicsExt = "Symbolics" + SparseDiffToolsZygoteExt = "Zygote" + + [deps.SparseDiffTools.weakdeps] + Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9" + Polyester = "f517fe37-dbe3-4b94-8317-1923a5111588" + PolyesterForwardDiff = "98d1487c-24ca-40b6-b7ab-df2af84e126b" + Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" + Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f" + +[[deps.SpecialFunctions]] +deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] +git-tree-sha1 = "5acc6a41b3082920f79ca3c759acbcecf18a8d78" +uuid = "276daf66-3868-5448-9aa4-cd146d93841b" +version = "2.7.1" +weakdeps = ["ChainRulesCore"] + + [deps.SpecialFunctions.extensions] + SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" + +[[deps.Static]] +deps = ["CommonWorldInvalidations", "IfElse", "PrecompileTools", "SciMLPublic"] +git-tree-sha1 = "49440414711eddc7227724ae6e570c7d5559a086" +uuid = "aedffcd0-7271-4cad-89d0-dc628f76c6d3" +version = "1.3.1" + +[[deps.StaticArrayInterface]] +deps = ["ArrayInterface", "Compat", "IfElse", "LinearAlgebra", "PrecompileTools", "SciMLPublic", "Static"] +git-tree-sha1 = "aa1ea41b3d45ac449d10477f65e2b40e3197a0d2" +uuid = "0d7ed370-da01-4f52-bd93-41d350b8b718" +version = "1.9.0" +weakdeps = ["OffsetArrays", "StaticArrays"] + + [deps.StaticArrayInterface.extensions] + StaticArrayInterfaceOffsetArraysExt = "OffsetArrays" + StaticArrayInterfaceStaticArraysExt = "StaticArrays" + +[[deps.StaticArrays]] +deps = ["LinearAlgebra", "PrecompileTools", "Random", "StaticArraysCore"] +git-tree-sha1 = "0f529006004a8be48f1be25f3451186579392d47" +uuid = "90137ffa-7385-5640-81b9-e52037218182" +version = "1.9.17" +weakdeps = ["ChainRulesCore", "Statistics"] + + [deps.StaticArrays.extensions] + StaticArraysChainRulesCoreExt = "ChainRulesCore" + StaticArraysStatisticsExt = "Statistics" + +[[deps.StaticArraysCore]] +git-tree-sha1 = "6ab403037779dae8c514bad259f32a447262455a" +uuid = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" +version = "1.4.4" + +[[deps.Statistics]] +deps = ["LinearAlgebra", "SparseArrays"] +uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +version = "1.10.0" + +[[deps.StringEncodings]] +deps = ["Libiconv_jll"] +git-tree-sha1 = "b765e46ba27ecf6b44faf70df40c57aa3a547dcb" +uuid = "69024149-9ee7-55f6-a4c4-859efe599b68" +version = "0.3.7" + +[[deps.SuiteSparse_jll]] +deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] +uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" +version = "7.2.1+1" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[deps.Test]] +deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] +uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[[deps.TestSetExtensions]] +deps = ["DeepDiffs", "Distributed", "Test"] +git-tree-sha1 = "ccebd99935be339d2ad907589708ba1f0d62bab3" +uuid = "98d24dd4-01ad-11ea-1b02-c9a08f80db04" +version = "3.0.0" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + +[[deps.UnPack]] +git-tree-sha1 = "387c1f73762231e86e0c9c5443ce3b4a0a9a0c2b" +uuid = "3a884ed6-31ef-47d7-9d2a-63182c4928ed" +version = "1.0.2" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" + +[[deps.VertexSafeGraphs]] +deps = ["Graphs"] +git-tree-sha1 = "8351f8d73d7e880bfc042a8b6922684ebeafb35c" +uuid = "19fa3120-7c27-5ec5-8db8-b0b0aa330d6f" +version = "0.2.0" + +[[deps.YAML]] +deps = ["Base64", "Dates", "Printf", "StringEncodings"] +git-tree-sha1 = "a1c0c7585346251353cddede21f180b96388c403" +uuid = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" +version = "0.4.16" + +[[deps.libblastrampoline_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" +version = "5.11.0+0" diff --git a/ard/flowfarm/julia_env/Project.toml b/ard/flowfarm/julia_env/Project.toml new file mode 100644 index 00000000..fea9f07d --- /dev/null +++ b/ard/flowfarm/julia_env/Project.toml @@ -0,0 +1,8 @@ +name = "ArdFLOWFarmEnv" +version = "0.1.0" + +[deps] +FLOWFarm = "eb2d4cfc-2064-11ea-0a1c-63d372e6a848" + +[compat] +julia = "1.10" diff --git a/ard/flowfarm/pin_flowfarm.py b/ard/flowfarm/pin_flowfarm.py new file mode 100644 index 00000000..ce779195 --- /dev/null +++ b/ard/flowfarm/pin_flowfarm.py @@ -0,0 +1,39 @@ +# ard/farm_aero/flowfarm/pin_flowfarm.py +from __future__ import annotations +import sys, pathlib +import juliacall +from juliacall import Pkg as jlPkg # JuliaPkg via JuliaCall (documented) [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) + +FLOWFARM_GIT_URL = "https://github.com/byuflowlab/FLOWFarm.jl" +FLOWFARM_REV = "typestability" # <-- BRANCH PIN + +def main(argv=None): + env_dir = pathlib.Path(__file__).parent / "julia_env" + print(f"[pin] Activating: {env_dir}") + jlPkg.activate(str(env_dir)) + print("[pin] Instantiating (may download packages on first run)…") + jlPkg.instantiate() # creates/updates Manifest.toml [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) + + # If FLOWFarm exists with a different source/UUID, replace it with our pin. + try: + jlPkg.rm("FLOWFarm") + except Exception: + pass + + print(f"[pin] Pkg.add url={FLOWFARM_GIT_URL} rev={FLOWFARM_REV}") + jlPkg.add(url=FLOWFARM_GIT_URL, rev=FLOWFARM_REV) # captures exact revision in Manifest [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) + + jl = juliacall.newmodule("ArdFLOWFarmPin") + print("[pin] Loading FLOWFarm…") + jl.seval("using FLOWFarm") # FLOWFarm usage/install documented in repo [2](https://github.com/byuflowlab/FlowFarm.jl) + + # Optional: precompile to warm caches on first run + if "--precompile" in (argv or []): + print("[pin] Precompiling Julia environment…") + jlPkg.precompile() + + manifest = env_dir / "Manifest.toml" + print(f"[done] Manifest at: {manifest if manifest.exists() else '(missing)'}") + +if __name__ == "__main__": + main(sys.argv[1:]) \ No newline at end of file diff --git a/examples/07_flowfarm_setup/inputs/ard_system.yaml b/examples/07_flowfarm_setup/inputs/ard_system.yaml new file mode 100644 index 00000000..65d5f34a --- /dev/null +++ b/examples/07_flowfarm_setup/inputs/ard_system.yaml @@ -0,0 +1,178 @@ +modeling_options: &modeling_options + case_name: flowfarm_yaml_setup_demo + windIO_plant: !include windio.yaml + layout: + type: gridfarm + N_turbines: 25 + N_substations: 1 + spacing_primary: 7.0 + spacing_secondary: 7.0 + angle_orientation: 0.0 + angle_skew: 0.0 + aero: + return_turbine_output: true + collection: + max_turbines_per_string: 8 + solver_name: highs + solver_options: + time_limit: 60 + mip_gap: 0.02 + model_options: + topology: radial + feeder_route: segmented + feeder_limit: unlimited + offshore: false + floating: false + costs: + rated_power: 3400000.0 + num_blades: 3 + rated_thrust_N: 645645.83964671 + gust_velocity_m_per_s: 52.5 + blade_surface_area: 69.7974979 + tower_mass: 620.4407337521 + nacelle_mass: 101.98582836439 + hub_mass: 8.38407517646 + blade_mass: 14.56341339641 + foundation_height: 0.0 + commissioning_cost_kW: 44.0 + decommissioning_cost_kW: 58.0 + trench_len_to_substation_km: 50.0 + distance_to_interconnect_mi: 4.97096954 + interconnect_voltage_kV: 130.0 + tcc_per_kW: 1300.0 + opex_per_kW: 44.0 + stdio_capture: true + flowfarm: + ref_air_density: 1.225 + wake_deficit_model: GaussYawVariableSpread + wake_deflection_model: GaussYawVariableSpreadDeflection + wake_combination_model: LinearLocalVelocitySuperposition + local_turbulence_model: LocalTIModelNoLocalTI + tolerance: 1.0e-16 + +system: + type: group + systems: + layout2aep: + type: group + promotes: ["*"] + systems: + layout: + type: component + module: ard.layout.gridfarm + object: GridFarmLayout + promotes: ["*"] + kwargs: + modeling_options: *modeling_options + aepFLOWFarm: + type: component + module: ard.farm_aero.flowfarm + object: FLOWFarmAEP + promotes: ["*"] + kwargs: + modeling_options: *modeling_options + data_path: + boundary: + type: component + module: ard.layout.boundary + object: FarmBoundaryDistancePolygon + promotes: ["*"] + kwargs: + modeling_options: *modeling_options + spacing_constraint: + type: component + module: ard.layout.spacing + object: TurbineSpacing + promotes: ["*"] + kwargs: + modeling_options: *modeling_options + landuse: + type: component + module: ard.layout.gridfarm + object: GridFarmLanduse + promotes: ["*"] + kwargs: + modeling_options: *modeling_options + collection: + type: component + module: ard.collection.optiwindnet_wrap + object: OptiwindnetCollection + promotes: ["*"] + kwargs: + modeling_options: *modeling_options + tcc: + type: component + module: ard.cost.wisdem_wrap + object: TurbineCapitalCosts + promotes: + - turbine_number + - machine_rating + - tcc_per_kW + - offset_tcc_per_kW + landbosse: + type: component + module: ard.cost.wisdem_wrap + object: LandBOSSEWithSpacingApproximations + promotes: + - total_length_cables + kwargs: + modeling_options: *modeling_options + opex: + type: component + module: ard.cost.wisdem_wrap + object: OperatingExpenses + promotes: + - turbine_number + - machine_rating + - opex_per_kW + financese: + type: component + module: ard.cost.wisdem_wrap + object: FinanceSEGroup + promotes: + - turbine_number + - machine_rating + - tcc_per_kW + - offset_tcc_per_kW + - opex_per_kW + kwargs: + modeling_options: *modeling_options + connections: + - ["AEP_farm", "financese.plant_aep_in"] + - ["landbosse.total_capex_kW", "financese.bos_per_kW"] + +analysis_options: + driver: + name: ScipyOptimizeDriver + options: + optimizer: COBYLA + opt_settings: + rhobeg: 2.0 + maxiter: 120 + disp: false + design_variables: + spacing_primary: + lower: 3.0 + upper: 20.0 + spacing_secondary: + lower: 3.0 + upper: 20.0 + angle_orientation: + lower: -180.0 + upper: 180.0 + angle_skew: + lower: -45.0 + upper: 45.0 + objectives: + financese.lcoe: + scaler: 1.0 + constraints: + boundary_distances: + units: km + upper: 0.0 + scaler: 2.0 + spacing_constraint.turbine_spacing: + units: km + lower: 0.552 + recorder: + filepath: cases.sql diff --git a/examples/07_flowfarm_setup/inputs/windio.yaml b/examples/07_flowfarm_setup/inputs/windio.yaml new file mode 100644 index 00000000..e9bf056a --- /dev/null +++ b/examples/07_flowfarm_setup/inputs/windio.yaml @@ -0,0 +1,88 @@ +name: Ard Example 07 FLOWFarm wind plant +site: + name: Ard Example 07 FLOWFarm site + boundaries: + polygons: + - x: + - 1500.0 + - 3000.0 + - 3000.0 + - 1500.0 + - -1500.0 + - -3000.0 + - -3000.0 + - -1500.0 + y: + - 3000.0 + - 1500.0 + - -1500.0 + - -3000.0 + - -3000.0 + - -1500.0 + - 1500.0 + - 3000.0 + energy_resource: + name: Ard Example 07 wind resource + wind_resource: !include ../../data/windIO-plant_wind-resource_wrg-example.yaml +wind_farm: + name: Ard Example 07 FLOWFarm farm + layouts: + coordinates: + x: + - -2500.0 + - -1250.0 + - 0.0 + - 1250.0 + - 2500.0 + - -2500.0 + - -1250.0 + - 0.0 + - 1250.0 + - 2500.0 + - -2500.0 + - -1250.0 + - 0.0 + - 1250.0 + - 2500.0 + - -2500.0 + - -1250.0 + - 0.0 + - 1250.0 + - 2500.0 + - -2500.0 + - -1250.0 + - 0.0 + - 1250.0 + - 2500.0 + y: + - -2500.0 + - -2500.0 + - -2500.0 + - -2500.0 + - -2500.0 + - -1250.0 + - -1250.0 + - -1250.0 + - -1250.0 + - -1250.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 1250.0 + - 1250.0 + - 1250.0 + - 1250.0 + - 1250.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + turbine: !include ../../data/windIO-plant_turbine_IEA-3.4MW-130m-RWT.yaml + electrical_substations: + - electrical_substation: + coordinates: + x: [100.0] + y: [100.0] diff --git a/examples/07_flowfarm_setup/optimization_demo.ipynb b/examples/07_flowfarm_setup/optimization_demo.ipynb new file mode 100644 index 00000000..10986e22 --- /dev/null +++ b/examples/07_flowfarm_setup/optimization_demo.ipynb @@ -0,0 +1,438 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c6cf4a1c", + "metadata": {}, + "source": [ + "# 07: FLOWFarm YAML Layout Optimization\n", + "\n", + "In this example, we demonstrate a YAML-driven FLOWFarm setup in `Ard`, then run both a one-shot analysis and a layout optimization.\n", + "\n", + "We start by importing the tools used throughout the notebook." + ] + }, + { + "cell_type": "markdown", + "id": "ec933452", + "metadata": {}, + "source": [ + "## Note\n", + "This example is the same as 01_onshore but uses FLOWFarm as the wind farm model.\n", + "\n", + "## Inputs used\n", + "\n", + "- `inputs/ard_system.yaml`\n", + "- `inputs/windio.yaml`" + ] + }, + { + "cell_type": "markdown", + "id": "c0b79780", + "metadata": {}, + "source": [ + "Now we set up the case from YAML.\n", + "\n", + "The file `inputs/ard_system.yaml` contains both the Ard system definition and analysis options, and it references `inputs/windio.yaml` for wind plant data." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cb6d0a0d", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path # optional, for nice path specifications\n", + "import os\n", + "import sys\n", + "import importlib.util\n", + "\n", + "import pprint as pp # optional, for nice printing\n", + "import numpy as np # numerics library\n", + "import matplotlib.pyplot as plt # plotting capabilities\n", + "\n", + "# Ensure the local Ard package is importable when running from this example folder.\n", + "repo_root = Path.cwd().resolve()\n", + "while repo_root.name != \"Ard\" and repo_root != repo_root.parent:\n", + " repo_root = repo_root.parent\n", + "if str(repo_root) not in sys.path:\n", + " sys.path.insert(0, str(repo_root))\n", + "\n", + "# Let Ard own JuliaCall bootstrap behavior.\n", + "os.environ.pop(\"PYTHON_JULIACALL_EXE\", None)\n", + "os.environ.pop(\"PYTHON_JULIACALL_PROJECT\", None)\n", + "\n", + "import ard # package import\n", + "from ard.utils.io import load_yaml # yaml loader\n", + "from ard.api import set_up_ard_model # model setup\n", + "from ard.viz.layout import plot_layout # layout plotting helper\n", + "\n", + "import openmdao.api as om # for optional N2 diagrams\n", + "\n", + "%matplotlib inline" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a83d4585", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running OpenMDAO util to clean the output directories...\n", + "\tFound 1 OpenMDAO output directories:\n", + "\tRemoved case_files/flowfarm_yaml_setup_demo_out\n", + "\tRemoved 1 OpenMDAO output directories.\n", + "... done.\n", + "\n", + "Created top-level OpenMDAO problem: top_level.\n", + "Adding top_level.\n", + " Adding layout2aep.\n", + " Adding layout to layout2aep.\n", + " Adding aepFLOWFarm to layout2aep.\n", + " Adding boundary.\n", + " Adding spacing_constraint.\n", + " Adding landuse.\n", + " Adding collection.\n", + " Adding tcc.\n", + " Adding landbosse.\n", + " Adding opex.\n", + " Adding financese.\n", + "System top_level built.\n", + "System top_level set up.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/core/problem.py:351: OpenMDAOWarning:The problem name 'flowfarm_yaml_setup_demo' already exists\n", + "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'scaling' for instance 'flowfarm_yaml_setup_demo.driver' is already active.\n", + "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'total_coloring' for instance 'flowfarm_yaml_setup_demo.driver' is already active.\n", + "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'n2' for instance 'flowfarm_yaml_setup_demo' is already active.\n", + "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'optimizer' for instance 'flowfarm_yaml_setup_demo' is already active.\n", + "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'inputs' for instance 'flowfarm_yaml_setup_demo' is already active.\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# load input\n", + "path_inputs = Path.cwd().absolute() / \"inputs\"\n", + "input_dict = load_yaml(path_inputs / \"ard_system.yaml\")\n", + "\n", + "# create and setup system\n", + "prob = set_up_ard_model(input_dict=input_dict, root_data_path=path_inputs)\n", + "prob" + ] + }, + { + "cell_type": "markdown", + "id": "9041d534", + "metadata": {}, + "source": [ + "You can optionally inspect the OpenMDAO N2 diagram for debugging and model introspection. It is left off by default." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "f5687762", + "metadata": {}, + "outputs": [], + "source": [ + "if False:\n", + " # visualize model\n", + " om.n2(prob)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "d91778bc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\n", + "RESULTS:\n", + "\n", + "{'AEP_val': 377.57108994147666,\n", + " 'BOS_val': 41.68227106807093,\n", + " 'CapEx_val': 110.5,\n", + " 'LCOE_val': 40.13461500046026,\n", + " 'OpEx_val': 3.7400000000000007,\n", + " 'area_tight': 13.2496,\n", + " 'coll_length': 21.89865877023397,\n", + " 'turbine_spacing': 0.91}\n", + "\n", + "\n", + "\n" + ] + } + ], + "source": [ + "# run the model\n", + "prob.run_model()\n", + "\n", + "# collapse the test result data\n", + "test_data = {\n", + " \"AEP_val\": float(prob.get_val(\"AEP_farm\", units=\"GW*h\")[0]),\n", + " \"CapEx_val\": float(prob.get_val(\"tcc.tcc\", units=\"MUSD\")[0]),\n", + " \"BOS_val\": float(prob.get_val(\"landbosse.total_capex\", units=\"MUSD\")[0]),\n", + " \"OpEx_val\": float(prob.get_val(\"opex.opex\", units=\"MUSD/yr\")[0]),\n", + " \"LCOE_val\": float(prob.get_val(\"financese.lcoe\", units=\"USD/MW/h\")[0]),\n", + " \"area_tight\": float(prob.get_val(\"landuse.area_tight\", units=\"km**2\")[0]),\n", + " \"coll_length\": float(\n", + " prob.get_val(\"collection.total_length_cables\", units=\"km\")[0]\n", + " ),\n", + " \"turbine_spacing\": float(\n", + " np.min(prob.get_val(\"spacing_constraint.turbine_spacing\", units=\"km\"))\n", + " ),\n", + " }\n", + "\n", + "print(\"\\n\\nRESULTS:\\n\")\n", + "pp.pprint(test_data)\n", + "print(\"\\n\\n\")" + ] + }, + { + "cell_type": "markdown", + "id": "f9531548", + "metadata": {}, + "source": [ + "Now we can optimize the same problem.\n", + "\n", + "Optimization settings are defined in `inputs/ard_system.yaml` under `analysis_options`, where `spacing_primary`, `spacing_secondary`, `angle_orientation`, and `angle_skew` are design variables.\n", + "\n", + "For parity with `01_onshore`, this setup includes the cost/finance chain and optimizes `financese.lcoe` (minimized). Boundary and spacing constraints are enforced through `boundary_distances` and `spacing_constraint.turbine_spacing`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1e402e7f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Return from COBYLA because the trust region radius reaches its lower bound.\n", + "Number of function values = 109 Least value of F = 0.03331236841898886 Constraint violation = 2e-15\n", + "The corresponding X is:\n", + "[10.63033564 5.83676299 7.07580615 2.66980861]\n", + "The constraint value is:\n", + "[-7.63033564e+00 -2.83676299e+00 -1.87075806e+02 -4.76698086e+01\n", + " -9.36966436e+00 -1.41632370e+01 -1.72924194e+02 -4.23301914e+01\n", + " -2.49719870e-01 -2.42994224e+00 -3.00543262e+00 -2.18406381e+00\n", + " 2.00000000e-15 -7.71487305e-01 -3.30684045e+00 -4.50271631e+00\n", + " -3.00000000e+00 -2.57162109e-01 -5.14324707e-01 -3.25716235e+00\n", + " -5.99999604e+00 -3.25716235e+00 -5.14324707e-01 -2.57162109e-01\n", + " -3.00000000e+00 -4.50271631e+00 -3.30684045e+00 -7.71487305e-01\n", + " -2.00000000e-15 -2.18406381e+00 -3.00543262e+00 -2.42994224e+00\n", + " -2.49719870e-01 -8.29943634e-01 -2.21188727e+00 -3.59383090e+00\n", + " -4.97577453e+00 -2.07603694e-01 -9.93630289e-01 -2.28004601e+00\n", + " -3.62789625e+00 -4.99255743e+00 -9.67207387e-01 -1.45353567e+00\n", + " -2.53926058e+00 -3.79646490e+00 -5.11209203e+00 -1.72681108e+00\n", + " -2.05747680e+00 -2.94732777e+00 -4.08489087e+00 -5.32811525e+00\n", + " -2.48641477e+00 -2.72680475e+00 -3.45907134e+00 -4.47257584e+00\n", + " -5.63052116e+00 -8.29943634e-01 -2.21188727e+00 -3.59383090e+00\n", + " -1.05565636e+00 -2.07603694e-01 -9.93630289e-01 -2.28004601e+00\n", + " -3.62789625e+00 -1.54879634e+00 -9.67207387e-01 -1.45353567e+00\n", + " -2.53926058e+00 -3.79646490e+00 -2.16758216e+00 -1.72681108e+00\n", + " -2.05747680e+00 -2.94732777e+00 -4.08489087e+00 -2.84401303e+00\n", + " -2.48641477e+00 -2.72680475e+00 -3.45907134e+00 -4.47257584e+00\n", + " -8.29943634e-01 -2.21188727e+00 -2.34828560e+00 -1.05565636e+00\n", + " -2.07603694e-01 -9.93630289e-01 -2.28004601e+00 -2.66331272e+00\n", + " -1.54879634e+00 -9.67207387e-01 -1.45353567e+00 -2.53926058e+00\n", + " -3.11116949e+00 -2.16758216e+00 -1.72681108e+00 -2.05747680e+00\n", + " -2.94732777e+00 -3.64959268e+00 -2.84401303e+00 -2.48641477e+00\n", + " -2.72680475e+00 -3.45907134e+00 -8.29943634e-01 -3.69750475e+00\n", + " -2.34828560e+00 -1.05565636e+00 -2.07603694e-01 -9.93630289e-01\n", + " -3.92936841e+00 -2.66331272e+00 -1.54879634e+00 -9.67207387e-01\n", + " -1.45353567e+00 -4.27096907e+00 -3.11116949e+00 -2.16758216e+00\n", + " -1.72681108e+00 -2.05747680e+00 -4.70094147e+00 -3.64959268e+00\n", + " -2.84401303e+00 -2.48641477e+00 -2.72680475e+00 -5.06266482e+00\n", + " -3.69750475e+00 -2.34828560e+00 -1.05565636e+00 -2.07603694e-01\n", + " -5.24857120e+00 -3.92936841e+00 -2.66331272e+00 -1.54879634e+00\n", + " -9.67207387e-01 -5.52441235e+00 -4.27096907e+00 -3.11116949e+00\n", + " -2.16758216e+00 -1.72681108e+00 -5.87862543e+00 -4.70094147e+00\n", + " -3.64959268e+00 -2.84401303e+00 -2.48641477e+00 -8.29943634e-01\n", + " -2.21188727e+00 -3.59383090e+00 -4.97577453e+00 -2.07603694e-01\n", + " -9.93630289e-01 -2.28004601e+00 -3.62789625e+00 -4.99255743e+00\n", + " -9.67207387e-01 -1.45353567e+00 -2.53926058e+00 -3.79646490e+00\n", + " -5.11209203e+00 -1.72681108e+00 -2.05747680e+00 -2.94732777e+00\n", + " -4.08489087e+00 -5.32811525e+00 -8.29943634e-01 -2.21188727e+00\n", + " -3.59383090e+00 -1.05565636e+00 -2.07603694e-01 -9.93630289e-01\n", + " -2.28004601e+00 -3.62789625e+00 -1.54879634e+00 -9.67207387e-01\n", + " -1.45353567e+00 -2.53926058e+00 -3.79646490e+00 -2.16758216e+00\n", + " -1.72681108e+00 -2.05747680e+00 -2.94732777e+00 -4.08489087e+00\n", + " -8.29943634e-01 -2.21188727e+00 -2.34828560e+00 -1.05565636e+00\n", + " -2.07603694e-01 -9.93630289e-01 -2.28004601e+00 -2.66331272e+00\n", + " -1.54879634e+00 -9.67207387e-01 -1.45353567e+00 -2.53926058e+00\n", + " -3.11116949e+00 -2.16758216e+00 -1.72681108e+00 -2.05747680e+00\n", + " -2.94732777e+00 -8.29943634e-01 -3.69750475e+00 -2.34828560e+00\n", + " -1.05565636e+00 -2.07603694e-01 -9.93630289e-01 -3.92936841e+00\n", + " -2.66331272e+00 -1.54879634e+00 -9.67207387e-01 -1.45353567e+00\n", + " -4.27096907e+00 -3.11116949e+00 -2.16758216e+00 -1.72681108e+00\n", + " -2.05747680e+00 -5.06266482e+00 -3.69750475e+00 -2.34828560e+00\n", + " -1.05565636e+00 -2.07603694e-01 -5.24857120e+00 -3.92936841e+00\n", + " -2.66331272e+00 -1.54879634e+00 -9.67207387e-01 -5.52441235e+00\n", + " -4.27096907e+00 -3.11116949e+00 -2.16758216e+00 -1.72681108e+00\n", + " -8.29943634e-01 -2.21188727e+00 -3.59383090e+00 -4.97577453e+00\n", + " -2.07603694e-01 -9.93630289e-01 -2.28004601e+00 -3.62789625e+00\n", + " -4.99255743e+00 -9.67207387e-01 -1.45353567e+00 -2.53926058e+00\n", + " -3.79646490e+00 -5.11209203e+00 -8.29943634e-01 -2.21188727e+00\n", + " -3.59383090e+00 -1.05565636e+00 -2.07603694e-01 -9.93630289e-01\n", + " -2.28004601e+00 -3.62789625e+00 -1.54879634e+00 -9.67207387e-01\n", + " -1.45353567e+00 -2.53926058e+00 -3.79646490e+00 -8.29943634e-01\n", + " -2.21188727e+00 -2.34828560e+00 -1.05565636e+00 -2.07603694e-01\n", + " -9.93630289e-01 -2.28004601e+00 -2.66331272e+00 -1.54879634e+00\n", + " -9.67207387e-01 -1.45353567e+00 -2.53926058e+00 -8.29943634e-01\n", + " -3.69750475e+00 -2.34828560e+00 -1.05565636e+00 -2.07603694e-01\n", + " -9.93630289e-01 -3.92936841e+00 -2.66331272e+00 -1.54879634e+00\n", + " -9.67207387e-01 -1.45353567e+00 -5.06266482e+00 -3.69750475e+00\n", + " -2.34828560e+00 -1.05565636e+00 -2.07603694e-01 -5.24857120e+00\n", + " -3.92936841e+00 -2.66331272e+00 -1.54879634e+00 -9.67207387e-01\n", + " -8.29943634e-01 -2.21188727e+00 -3.59383090e+00 -4.97577453e+00\n", + " -2.07603694e-01 -9.93630289e-01 -2.28004601e+00 -3.62789625e+00\n", + " -4.99255743e+00 -8.29943634e-01 -2.21188727e+00 -3.59383090e+00\n", + " -1.05565636e+00 -2.07603694e-01 -9.93630289e-01 -2.28004601e+00\n", + " -3.62789625e+00 -8.29943634e-01 -2.21188727e+00 -2.34828560e+00\n", + " -1.05565636e+00 -2.07603694e-01 -9.93630289e-01 -2.28004601e+00\n", + " -8.29943634e-01 -3.69750475e+00 -2.34828560e+00 -1.05565636e+00\n", + " -2.07603694e-01 -9.93630289e-01 -5.06266482e+00 -3.69750475e+00\n", + " -2.34828560e+00 -1.05565636e+00 -2.07603694e-01 -8.29943634e-01\n", + " -2.21188727e+00 -3.59383090e+00 -4.97577453e+00 -8.29943634e-01\n", + " -2.21188727e+00 -3.59383090e+00 -8.29943634e-01 -2.21188727e+00\n", + " -8.29943634e-01]\n", + "\n", + "Optimization Complete\n", + "-----------------------------------\n", + "\n", + "\n", + "RESULTS (opt):\n", + "\n", + "{'AEP_val': 454.91422245142144,\n", + " 'BOS_val': 41.6902691235489,\n", + " 'CapEx_val': 110.5,\n", + " 'LCOE_val': 33.31236843421495,\n", + " 'OpEx_val': 3.7400000000000007,\n", + " 'area_tight': 16.777442328612405,\n", + " 'coll_length': 21.949483532115543,\n", + " 'turbine_spacing': 0.7596037749545983}\n", + "\n", + "\n", + "\n" + ] + } + ], + "source": [ + "optimize = True # set to False to skip optimization\n", + "if optimize:\n", + " # run the optimization\n", + " prob.run_driver()\n", + " prob.cleanup()\n", + "\n", + " # collapse the test result data\n", + " test_data = {\n", + " \"AEP_val\": float(prob.get_val(\"AEP_farm\", units=\"GW*h\")[0]),\n", + " \"CapEx_val\": float(prob.get_val(\"tcc.tcc\", units=\"MUSD\")[0]),\n", + " \"BOS_val\": float(prob.get_val(\"landbosse.total_capex\", units=\"MUSD\")[0]),\n", + " \"OpEx_val\": float(prob.get_val(\"opex.opex\", units=\"MUSD/yr\")[0]),\n", + " \"LCOE_val\": float(prob.get_val(\"financese.lcoe\", units=\"USD/MW/h\")[0]),\n", + " \"area_tight\": float(prob.get_val(\"landuse.area_tight\", units=\"km**2\")[0]),\n", + " \"coll_length\": float(\n", + " prob.get_val(\"collection.total_length_cables\", units=\"km\")[0]\n", + " ),\n", + " \"turbine_spacing\": float(\n", + " np.min(prob.get_val(\"spacing_constraint.turbine_spacing\", units=\"km\"))\n", + " ),\n", + " }\n", + "\n", + " # clean up the recorder\n", + " prob.cleanup()\n", + "\n", + " # print the results\n", + " print(\"\\n\\nRESULTS (opt):\\n\")\n", + " pp.pprint(test_data)\n", + " print(\"\\n\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "3eaedc10", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_layout(\n", + " prob,\n", + " input_dict=input_dict,\n", + " show_image=True,\n", + " include_cable_routing=True,\n", + ")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "c0a33480", + "metadata": {}, + "outputs": [], + "source": [ + "prob.cleanup()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ard-env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From da1c293e158f89cecd82685ef0107be610e686cf Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:41:23 -0600 Subject: [PATCH 02/17] Added FLOWFarm integration tests. Added notes about flowfarm multithreading. --- .github/workflows/julia-tests.yaml | 51 +++ .../workflows/python-tests-consolidated.yaml | 5 +- .gitignore | 7 +- ard/farm_aero/__init__.py | 1 + ard/farm_aero/flowfarm.py | 23 +- ard/flowfarm/README.md | 128 ++++++- ard/flowfarm/julia_env/Manifest.toml | 69 +++- ard/flowfarm/julia_env/Project.toml | 1 + .../07_flowfarm_setup/optimization_demo.ipynb | 195 ++--------- pyproject.toml | 8 + .../unit/farm_aero/test_flowfarm_component.py | 230 +++++++++++++ test/flowfarm/__init__.py | 0 test/flowfarm/conftest.py | 47 +++ test/flowfarm/integration/__init__.py | 0 .../integration/test_flowfarm_integration.py | 234 +++++++++++++ test/flowfarm/unit/__init__.py | 0 test/flowfarm/unit/test_flowfarm_model.py | 312 ++++++++++++++++++ test/flowfarm/unit/test_jl_bootstrap.py | 275 +++++++++++++++ 18 files changed, 1378 insertions(+), 208 deletions(-) create mode 100644 .github/workflows/julia-tests.yaml create mode 100644 test/ard/unit/farm_aero/test_flowfarm_component.py create mode 100644 test/flowfarm/__init__.py create mode 100644 test/flowfarm/conftest.py create mode 100644 test/flowfarm/integration/__init__.py create mode 100644 test/flowfarm/integration/test_flowfarm_integration.py create mode 100644 test/flowfarm/unit/__init__.py create mode 100644 test/flowfarm/unit/test_flowfarm_model.py create mode 100644 test/flowfarm/unit/test_jl_bootstrap.py diff --git a/.github/workflows/julia-tests.yaml b/.github/workflows/julia-tests.yaml new file mode 100644 index 00000000..f119d16d --- /dev/null +++ b/.github/workflows/julia-tests.yaml @@ -0,0 +1,51 @@ +name: FLOWFarm integration tests (Julia) + +on: + push: + paths: + - "ard/flowfarm/**" + - "ard/farm_aero/flowfarm.py" + - "test/flowfarm/**" + pull_request: + paths: + - "ard/flowfarm/**" + - "ard/farm_aero/flowfarm.py" + - "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" # latest stable 1.x + + - 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 --project=ard/flowfarm/julia_env -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" + + - name: Run FLOWFarm integration tests + run: | + pytest -m julia test/flowfarm/integration \ + --cov=ard \ + --cov-report=term-missing \ + -v diff --git a/.github/workflows/python-tests-consolidated.yaml b/.github/workflows/python-tests-consolidated.yaml index fb420d8c..e033e963 100644 --- a/.github/workflows/python-tests-consolidated.yaml +++ b/.github/workflows/python-tests-consolidated.yaml @@ -84,7 +84,10 @@ jobs: pip install .[dev] - name: Run unit tests with coverage run: | - pytest --cov=ard --cov-fail-under=80 test/ard/unit + pytest --cov=ard --cov-fail-under=80 \ + test/ard/unit \ + test/flowfarm/unit \ + -m "not julia" test-system: name: Run system tests diff --git a/.gitignore b/.gitignore index 20e9a213..6ebffb22 100644 --- a/.gitignore +++ b/.gitignore @@ -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/flowfarm/julia_env/Manifest.toml + ### MACOS DEFAULT IGNORES .DS_Store diff --git a/ard/farm_aero/__init__.py b/ard/farm_aero/__init__.py index a26ad6af..b3dec1a9 100644 --- a/ard/farm_aero/__init__.py +++ b/ard/farm_aero/__init__.py @@ -1,3 +1,4 @@ from . import floris +from . import flowfarm from . import placeholder from . import templates diff --git a/ard/farm_aero/flowfarm.py b/ard/farm_aero/flowfarm.py index dcbdb8dc..dd5dbb66 100644 --- a/ard/farm_aero/flowfarm.py +++ b/ard/farm_aero/flowfarm.py @@ -1,27 +1,14 @@ import os -import sys -from pathlib import Path import numpy as np import pandas as pd from ard.farm_aero.floris import create_FLORIS_turbine_from_windIO -try: - from flowfarm.flowfarm_model import ( - ensure_flowfarm_loaded, - resolve_turbine_inputs_for_flowfarm, - resolve_wake_model_inputs_for_flowfarm, - ) -except ModuleNotFoundError: - # Local checkout fallback: add repository-level Ard/ to sys.path. - repo_ard_dir = Path(__file__).resolve().parents[2] - if str(repo_ard_dir) not in sys.path: - sys.path.insert(0, str(repo_ard_dir)) - from flowfarm.flowfarm_model import ( - ensure_flowfarm_loaded, - resolve_turbine_inputs_for_flowfarm, - resolve_wake_model_inputs_for_flowfarm, - ) +from ard.flowfarm.flowfarm_model import ( + ensure_flowfarm_loaded, + resolve_turbine_inputs_for_flowfarm, + resolve_wake_model_inputs_for_flowfarm, +) import ard.farm_aero.templates as templates diff --git a/ard/flowfarm/README.md b/ard/flowfarm/README.md index b8b63fd4..31a1a175 100644 --- a/ard/flowfarm/README.md +++ b/ard/flowfarm/README.md @@ -2,6 +2,51 @@ This folder contains Ard's Python-Julia integration utilities for FLOWFarm. +## Julia setup (required before first use) + +FLOWFarm runs inside Julia via [JuliaCall](https://juliapy.github.io/PythonCall.jl/stable/). You need Julia installed before running any FLOWFarm components. Users who do not use FLOWFarm do not need Julia at all — it is loaded lazily only when a FLOWFarm component is initialized. + +### 1. Install Julia via juliaup (recommended) + +[juliaup](https://github.com/JuliaLang/juliaup) is the official Julia version manager. Install it with: + +```bash +curl -fsSL https://install.julialang.org | sh +``` + +Install any recent stable Julia release (1.10 or later): + +```bash +juliaup add release +juliaup default release +``` + +Verify: + +```bash +julia --version +``` + +Ard's Julia environment has no hard version pin. The `Manifest.toml` is not committed to the repository — it is generated locally the first time you run a FLOWFarm component, so it will always match your installed Julia version. If you need to generate it ahead of time (e.g. on a cluster before a job runs), see step 2. + +### 2. Pre-generate the Julia environment (optional) + +On first use Ard will resolve and instantiate the Julia environment automatically. If you prefer to do this ahead of time — for example on an HPC cluster node without internet access at runtime — run once from your terminal: + +```bash +julia --project="/ard/flowfarm/julia_env" -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" +``` + +Replace `` with the absolute path to the `Ard` directory. This downloads FLOWFarm and its dependencies. It may take several minutes on first run. + +### 3. Install the JuliaCall Python package + +```bash +pip install juliacall +``` + +`juliacall` is not listed in Ard's core dependencies because it is only needed for FLOWFarm. Install it separately before using FLOWFarm components. + ## What this integration does - Boots Julia through JuliaCall. @@ -9,17 +54,36 @@ This folder contains Ard's Python-Julia integration utilities for FLOWFarm. - Loads FLOWFarm and builds farm and sparse structs for Ard components. - Exposes helper functions used by the component wrapper in `ard/farm_aero/flowfarm.py`. -## Threading behavior +## Threading and parallelism + +### OpenMDAO does not multithread -- Ard supports Julia threading through JuliaCall. -- The FLOWFarm update callback used by Ard is implemented in pure Julia (not Python callback) to avoid PythonCall thread-safety crashes. -- If you configure Julia threads with environment variables, set them **before** importing Ard/JuliaCall. +OpenMDAO is single-threaded by design. Its solver loops (Newton, Gauss-Seidel, NLBGS, etc.) are serial. The only parallelism OpenMDAO exposes is MPI-based **process** parallelism via `ParallelGroup`, which spawns separate processes — not threads. This means OpenMDAO will never call the FLOWFarm component from multiple threads simultaneously, so there is no concurrency risk from the OpenMDAO layer. -Recommended JuliaCall env options for threaded runs: +### Julia internal threading (FLOWFarm parallelism) -- `PYTHON_JULIACALL_THREADS=` -- `PYTHON_JULIACALL_HANDLE_SIGNALS=yes` -- (optional) `OPENBLAS_NUM_THREADS=1`, `OMP_NUM_THREADS=1` to avoid nested thread oversubscription +Threading in this integration refers to Julia's own thread pool, which FLOWFarm can use internally to parallelize wake calculations across turbines. This is separate from and independent of OpenMDAO. + +Julia's thread count is fixed at startup and cannot be changed at runtime. Configure it **before** importing Ard or JuliaCall: + +```python +import os +os.environ["PYTHON_JULIACALL_THREADS"] = "4" # or "auto" to use all cores +os.environ["PYTHON_JULIACALL_HANDLE_SIGNALS"] = "yes" +``` + +For threaded runs on shared-memory machines, also consider limiting BLAS and OpenMP thread pools to avoid nested oversubscription (Julia threads × BLAS threads × cores): + +```python +os.environ["OPENBLAS_NUM_THREADS"] = "1" +os.environ["OMP_NUM_THREADS"] = "1" +``` + +These must be set before the first `import ard` call in your script or notebook. + +### Why a pure Julia callback + +The FLOWFarm update callback in Ard is implemented entirely in Julia (not as a Python callable passed into Julia). This is required for thread safety: JuliaCall does not support calling back into Python from Julia threads other than the main thread. Using a pure Julia callback avoids this restriction and allows FLOWFarm to use all available Julia threads. ## Tolerance behavior @@ -42,11 +106,45 @@ modeling_options: ## Troubleshooting -- Kernel/process crash when threads > 1: - - Ensure pure Julia callback path is active (current Ard default). - - Ensure thread env vars are set before importing Ard. - - Start with `PYTHON_JULIACALL_THREADS=1`, then increase. -- Julia environment mismatch errors: - - Re-instantiate the local Julia env in `julia_env`. - - Confirm FLOWFarm revision/pin is compatible with your Julia runtime. +### Julia manifest warnings on first run + +If you see warnings like "manifest resolved with a different julia version" or "project dependencies have changed since the manifest was last resolved", it means the local `Manifest.toml` is missing or stale. Ard will attempt to rebuild it automatically. If it does not, run: + +```bash +julia --project="/ard/flowfarm/julia_env" -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" +``` + +Then restart your Jupyter kernel. The `Manifest.toml` is not committed to the repository — it is always generated locally for your Julia version. + +### Revise / DistributedExt error in Jupyter + +``` +Error during loading of extension DistributedExt of Revise +``` + +This comes from your **global** Julia environment, not Ard's. JuliaCall triggers the IPython/Jupyter juliacall extension on import, which loads Revise from your global env. Fix it by running: + +```bash +julia -e "using Pkg; Pkg.add(\"Distributed\"); Pkg.resolve()" +``` + +### Wrong Julia version being used + +If Julia 1.11+ is picked up instead of 1.10, check your `PATH`. `juliaup default 1.10` sets the default for commands run via juliaup, but if `/opt/homebrew/bin/julia` or another system Julia takes precedence in your shell, JuliaCall may use that instead. + +To force a specific version for a notebook session, add this to the **first cell** before any other imports: + +```python +import os +os.environ["PYTHON_JULIACALL_EXE"] = "julia +1.10" +os.environ["ARD_FLOWFARM_RESPECT_JULIACALL_ENV"] = "1" +``` + +`ARD_FLOWFARM_RESPECT_JULIACALL_ENV=1` is required — without it Ard's bootstrap strips the override. + +### Kernel/process crash when threads > 1 + +- Ensure pure Julia callback path is active (current Ard default). +- Ensure thread env vars are set before importing Ard. +- Start with `PYTHON_JULIACALL_THREADS=1`, then increase. diff --git a/ard/flowfarm/julia_env/Manifest.toml b/ard/flowfarm/julia_env/Manifest.toml index 99f6bffe..18390bda 100644 --- a/ard/flowfarm/julia_env/Manifest.toml +++ b/ard/flowfarm/julia_env/Manifest.toml @@ -1,8 +1,8 @@ # This file is machine-generated - editing it directly is not advised -julia_version = "1.10.10" +julia_version = "1.10.11" manifest_format = "2.0" -project_hash = "4455b04176d5094cc95f66136ffa729fd2908de3" +project_hash = "a5ba18806553e0328eab0a78241c05aa9b2eceef" [[deps.ADTypes]] git-tree-sha1 = "f7304359109c768cf32dc5fa2d371565bb63b68a" @@ -110,6 +110,12 @@ weakdeps = ["SparseArrays"] [deps.ChainRulesCore.extensions] ChainRulesCoreSparseArraysExt = "SparseArrays" +[[deps.CodeTracking]] +deps = ["InteractiveUtils", "UUIDs"] +git-tree-sha1 = "b7231a755812695b8046e8471ddc34c8268cbad5" +uuid = "da1fd8a2-8d9e-5ec2-8556-3022fb5608a2" +version = "3.0.0" + [[deps.CommonSubexpressions]] deps = ["MacroTools"] git-tree-sha1 = "cda2cfaebb4be89c9084adaca7dd7333369715c5" @@ -131,6 +137,11 @@ weakdeps = ["Dates", "LinearAlgebra"] [deps.Compat.extensions] CompatLinearAlgebraExt = "LinearAlgebra" +[[deps.Compiler]] +git-tree-sha1 = "382d79bfe72a406294faca39ef0c3cef6e6ce1f1" +uuid = "807dbc54-b67e-4c79-8afb-eafe4df6f2e1" +version = "0.1.1" + [[deps.CompilerSupportLibraries_jll]] deps = ["Artifacts", "Libdl"] uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" @@ -216,6 +227,9 @@ git-tree-sha1 = "37ddbaadee1108c0976b21bec04cde71641858de" uuid = "6cb5d3fb-0fe8-4cc2-bd89-9fe0b19a99d3" version = "1.0.0" +[[deps.FileWatching]] +uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" + [[deps.FiniteDiff]] deps = ["ArrayInterface", "LinearAlgebra", "Setfield"] git-tree-sha1 = "9340ca07ca27093ff68418b7558ca37b05f8aeb1" @@ -300,6 +314,26 @@ git-tree-sha1 = "0533e564aae234aff59ab625543145446d8b6ec2" uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" version = "1.7.1" +[[deps.JuliaInterpreter]] +deps = ["CodeTracking", "InteractiveUtils", "Random", "UUIDs"] +git-tree-sha1 = "f5e59455236d8269b7868665c3665e8477af0e37" +uuid = "aa1ae85d-cabe-5617-a682-6adf51b2e16a" +version = "0.10.10" + +[[deps.LibGit2]] +deps = ["Base64", "LibGit2_jll", "NetworkOptions", "Printf", "SHA"] +uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" + +[[deps.LibGit2_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll"] +uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5" +version = "1.6.4+0" + +[[deps.LibSSH2_jll]] +deps = ["Artifacts", "Libdl", "MbedTLS_jll"] +uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" +version = "1.11.0+1" + [[deps.Libdl]] uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" @@ -332,6 +366,12 @@ version = "0.3.29" [[deps.Logging]] uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" +[[deps.LoweredCodeUtils]] +deps = ["CodeTracking", "Compiler", "JuliaInterpreter"] +git-tree-sha1 = "5d4278f755440f70648d80cc6225f51e78e94094" +uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" +version = "3.5.1" + [[deps.MacroTools]] git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" @@ -341,6 +381,11 @@ version = "0.5.16" deps = ["Base64"] uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" +[[deps.MbedTLS_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" +version = "2.28.1010+0" + [[deps.Mmap]] uuid = "a63ad114-7e13-5084-954f-fe012c677804" @@ -350,6 +395,10 @@ git-tree-sha1 = "9b8215b1ee9e78a293f99797cd31375471b2bcae" uuid = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" version = "1.1.3" +[[deps.NetworkOptions]] +uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.2.0" + [[deps.OffsetArrays]] git-tree-sha1 = "117432e406b5c023f665fa73dc26e79ec3630151" uuid = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" @@ -362,7 +411,7 @@ weakdeps = ["Adapt"] [[deps.OpenBLAS_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" -version = "0.3.23+4" +version = "0.3.23+5" [[deps.OpenLibm_jll]] deps = ["Artifacts", "Libdl"] @@ -402,6 +451,10 @@ version = "1.5.2" deps = ["Unicode"] uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" +[[deps.REPL]] +deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] +uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" + [[deps.Random]] deps = ["SHA"] uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" @@ -423,6 +476,16 @@ git-tree-sha1 = "f1b07322a8cdc0d46812473b37fb72f69ec07b22" uuid = "37e2e3b7-166d-5795-8a7a-e32c996b4267" version = "1.16.2" +[[deps.Revise]] +deps = ["CodeTracking", "FileWatching", "InteractiveUtils", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] +git-tree-sha1 = "14d1bfb0a30317edc77e11094607ace3c800f193" +uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" +version = "3.13.2" +weakdeps = ["Distributed"] + + [deps.Revise.extensions] + DistributedExt = "Distributed" + [[deps.SHA]] uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" version = "0.7.0" diff --git a/ard/flowfarm/julia_env/Project.toml b/ard/flowfarm/julia_env/Project.toml index fea9f07d..f0847800 100644 --- a/ard/flowfarm/julia_env/Project.toml +++ b/ard/flowfarm/julia_env/Project.toml @@ -3,6 +3,7 @@ version = "0.1.0" [deps] FLOWFarm = "eb2d4cfc-2064-11ea-0a1c-63d372e6a848" +Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" [compat] julia = "1.10" diff --git a/examples/07_flowfarm_setup/optimization_demo.ipynb b/examples/07_flowfarm_setup/optimization_demo.ipynb index 10986e22..b2bd2472 100644 --- a/examples/07_flowfarm_setup/optimization_demo.ipynb +++ b/examples/07_flowfarm_setup/optimization_demo.ipynb @@ -38,7 +38,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 3, "id": "cb6d0a0d", "metadata": {}, "outputs": [], @@ -75,7 +75,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 4, "id": "a83d4585", "metadata": {}, "outputs": [ @@ -91,17 +91,17 @@ "\n", "Created top-level OpenMDAO problem: top_level.\n", "Adding top_level.\n", - " Adding layout2aep.\n", - " Adding layout to layout2aep.\n", - " Adding aepFLOWFarm to layout2aep.\n", - " Adding boundary.\n", - " Adding spacing_constraint.\n", - " Adding landuse.\n", - " Adding collection.\n", - " Adding tcc.\n", - " Adding landbosse.\n", - " Adding opex.\n", - " Adding financese.\n", + "\tAdding layout2aep.\n", + "\t\tAdding layout to layout2aep.\n", + "\t\tAdding aepFLOWFarm to layout2aep.\n", + "\tAdding boundary.\n", + "\tAdding spacing_constraint.\n", + "\tAdding landuse.\n", + "\tAdding collection.\n", + "\tAdding tcc.\n", + "\tAdding landbosse.\n", + "\tAdding opex.\n", + "\tAdding financese.\n", "System top_level built.\n", "System top_level set up.\n" ] @@ -115,16 +115,18 @@ "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'total_coloring' for instance 'flowfarm_yaml_setup_demo.driver' is already active.\n", "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'n2' for instance 'flowfarm_yaml_setup_demo' is already active.\n", "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'optimizer' for instance 'flowfarm_yaml_setup_demo' is already active.\n", - "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'inputs' for instance 'flowfarm_yaml_setup_demo' is already active.\n" + "/opt/homebrew/Caskroom/miniconda/base/envs/ard-env/lib/python3.12/site-packages/openmdao/utils/reports_system.py:277: OpenMDAOWarning:A report with the name 'inputs' for instance 'flowfarm_yaml_setup_demo' is already active.\n", + "UserWarning: /Users/bvarela/Library/CloudStorage/Box-Box/Research/hybridfarm/Ard/ard/farm_aero/flowfarm.py:39\n", + "FLOWFarm missing turbine inputs ['rated_power', 'rated_wind_speed', 'cutin_wind_speed', 'cutout_wind_speed']; using defaults {'rated_power': 1000000.0, 'rated_wind_speed': 10.0, 'cutin_wind_speed': 0.0, 'cutout_wind_speed': 100.0}." ] }, { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 9, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -149,7 +151,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "f5687762", "metadata": {}, "outputs": [], @@ -161,32 +163,10 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "d91778bc", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "\n", - "RESULTS:\n", - "\n", - "{'AEP_val': 377.57108994147666,\n", - " 'BOS_val': 41.68227106807093,\n", - " 'CapEx_val': 110.5,\n", - " 'LCOE_val': 40.13461500046026,\n", - " 'OpEx_val': 3.7400000000000007,\n", - " 'area_tight': 13.2496,\n", - " 'coll_length': 21.89865877023397,\n", - " 'turbine_spacing': 0.91}\n", - "\n", - "\n", - "\n" - ] - } - ], + "outputs": [], "source": [ "# run the model\n", "prob.run_model()\n", @@ -229,121 +209,7 @@ "execution_count": null, "id": "1e402e7f", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Return from COBYLA because the trust region radius reaches its lower bound.\n", - "Number of function values = 109 Least value of F = 0.03331236841898886 Constraint violation = 2e-15\n", - "The corresponding X is:\n", - "[10.63033564 5.83676299 7.07580615 2.66980861]\n", - "The constraint value is:\n", - "[-7.63033564e+00 -2.83676299e+00 -1.87075806e+02 -4.76698086e+01\n", - " -9.36966436e+00 -1.41632370e+01 -1.72924194e+02 -4.23301914e+01\n", - " -2.49719870e-01 -2.42994224e+00 -3.00543262e+00 -2.18406381e+00\n", - " 2.00000000e-15 -7.71487305e-01 -3.30684045e+00 -4.50271631e+00\n", - " -3.00000000e+00 -2.57162109e-01 -5.14324707e-01 -3.25716235e+00\n", - " -5.99999604e+00 -3.25716235e+00 -5.14324707e-01 -2.57162109e-01\n", - " -3.00000000e+00 -4.50271631e+00 -3.30684045e+00 -7.71487305e-01\n", - " -2.00000000e-15 -2.18406381e+00 -3.00543262e+00 -2.42994224e+00\n", - " -2.49719870e-01 -8.29943634e-01 -2.21188727e+00 -3.59383090e+00\n", - " -4.97577453e+00 -2.07603694e-01 -9.93630289e-01 -2.28004601e+00\n", - " -3.62789625e+00 -4.99255743e+00 -9.67207387e-01 -1.45353567e+00\n", - " -2.53926058e+00 -3.79646490e+00 -5.11209203e+00 -1.72681108e+00\n", - " -2.05747680e+00 -2.94732777e+00 -4.08489087e+00 -5.32811525e+00\n", - " -2.48641477e+00 -2.72680475e+00 -3.45907134e+00 -4.47257584e+00\n", - " -5.63052116e+00 -8.29943634e-01 -2.21188727e+00 -3.59383090e+00\n", - " -1.05565636e+00 -2.07603694e-01 -9.93630289e-01 -2.28004601e+00\n", - " -3.62789625e+00 -1.54879634e+00 -9.67207387e-01 -1.45353567e+00\n", - " -2.53926058e+00 -3.79646490e+00 -2.16758216e+00 -1.72681108e+00\n", - " -2.05747680e+00 -2.94732777e+00 -4.08489087e+00 -2.84401303e+00\n", - " -2.48641477e+00 -2.72680475e+00 -3.45907134e+00 -4.47257584e+00\n", - " -8.29943634e-01 -2.21188727e+00 -2.34828560e+00 -1.05565636e+00\n", - " -2.07603694e-01 -9.93630289e-01 -2.28004601e+00 -2.66331272e+00\n", - " -1.54879634e+00 -9.67207387e-01 -1.45353567e+00 -2.53926058e+00\n", - " -3.11116949e+00 -2.16758216e+00 -1.72681108e+00 -2.05747680e+00\n", - " -2.94732777e+00 -3.64959268e+00 -2.84401303e+00 -2.48641477e+00\n", - " -2.72680475e+00 -3.45907134e+00 -8.29943634e-01 -3.69750475e+00\n", - " -2.34828560e+00 -1.05565636e+00 -2.07603694e-01 -9.93630289e-01\n", - " -3.92936841e+00 -2.66331272e+00 -1.54879634e+00 -9.67207387e-01\n", - " -1.45353567e+00 -4.27096907e+00 -3.11116949e+00 -2.16758216e+00\n", - " -1.72681108e+00 -2.05747680e+00 -4.70094147e+00 -3.64959268e+00\n", - " -2.84401303e+00 -2.48641477e+00 -2.72680475e+00 -5.06266482e+00\n", - " -3.69750475e+00 -2.34828560e+00 -1.05565636e+00 -2.07603694e-01\n", - " -5.24857120e+00 -3.92936841e+00 -2.66331272e+00 -1.54879634e+00\n", - " -9.67207387e-01 -5.52441235e+00 -4.27096907e+00 -3.11116949e+00\n", - " -2.16758216e+00 -1.72681108e+00 -5.87862543e+00 -4.70094147e+00\n", - " -3.64959268e+00 -2.84401303e+00 -2.48641477e+00 -8.29943634e-01\n", - " -2.21188727e+00 -3.59383090e+00 -4.97577453e+00 -2.07603694e-01\n", - " -9.93630289e-01 -2.28004601e+00 -3.62789625e+00 -4.99255743e+00\n", - " -9.67207387e-01 -1.45353567e+00 -2.53926058e+00 -3.79646490e+00\n", - " -5.11209203e+00 -1.72681108e+00 -2.05747680e+00 -2.94732777e+00\n", - " -4.08489087e+00 -5.32811525e+00 -8.29943634e-01 -2.21188727e+00\n", - " -3.59383090e+00 -1.05565636e+00 -2.07603694e-01 -9.93630289e-01\n", - " -2.28004601e+00 -3.62789625e+00 -1.54879634e+00 -9.67207387e-01\n", - " -1.45353567e+00 -2.53926058e+00 -3.79646490e+00 -2.16758216e+00\n", - " -1.72681108e+00 -2.05747680e+00 -2.94732777e+00 -4.08489087e+00\n", - " -8.29943634e-01 -2.21188727e+00 -2.34828560e+00 -1.05565636e+00\n", - " -2.07603694e-01 -9.93630289e-01 -2.28004601e+00 -2.66331272e+00\n", - " -1.54879634e+00 -9.67207387e-01 -1.45353567e+00 -2.53926058e+00\n", - " -3.11116949e+00 -2.16758216e+00 -1.72681108e+00 -2.05747680e+00\n", - " -2.94732777e+00 -8.29943634e-01 -3.69750475e+00 -2.34828560e+00\n", - " -1.05565636e+00 -2.07603694e-01 -9.93630289e-01 -3.92936841e+00\n", - " -2.66331272e+00 -1.54879634e+00 -9.67207387e-01 -1.45353567e+00\n", - " -4.27096907e+00 -3.11116949e+00 -2.16758216e+00 -1.72681108e+00\n", - " -2.05747680e+00 -5.06266482e+00 -3.69750475e+00 -2.34828560e+00\n", - " -1.05565636e+00 -2.07603694e-01 -5.24857120e+00 -3.92936841e+00\n", - " -2.66331272e+00 -1.54879634e+00 -9.67207387e-01 -5.52441235e+00\n", - " -4.27096907e+00 -3.11116949e+00 -2.16758216e+00 -1.72681108e+00\n", - " -8.29943634e-01 -2.21188727e+00 -3.59383090e+00 -4.97577453e+00\n", - " -2.07603694e-01 -9.93630289e-01 -2.28004601e+00 -3.62789625e+00\n", - " -4.99255743e+00 -9.67207387e-01 -1.45353567e+00 -2.53926058e+00\n", - " -3.79646490e+00 -5.11209203e+00 -8.29943634e-01 -2.21188727e+00\n", - " -3.59383090e+00 -1.05565636e+00 -2.07603694e-01 -9.93630289e-01\n", - " -2.28004601e+00 -3.62789625e+00 -1.54879634e+00 -9.67207387e-01\n", - " -1.45353567e+00 -2.53926058e+00 -3.79646490e+00 -8.29943634e-01\n", - " -2.21188727e+00 -2.34828560e+00 -1.05565636e+00 -2.07603694e-01\n", - " -9.93630289e-01 -2.28004601e+00 -2.66331272e+00 -1.54879634e+00\n", - " -9.67207387e-01 -1.45353567e+00 -2.53926058e+00 -8.29943634e-01\n", - " -3.69750475e+00 -2.34828560e+00 -1.05565636e+00 -2.07603694e-01\n", - " -9.93630289e-01 -3.92936841e+00 -2.66331272e+00 -1.54879634e+00\n", - " -9.67207387e-01 -1.45353567e+00 -5.06266482e+00 -3.69750475e+00\n", - " -2.34828560e+00 -1.05565636e+00 -2.07603694e-01 -5.24857120e+00\n", - " -3.92936841e+00 -2.66331272e+00 -1.54879634e+00 -9.67207387e-01\n", - " -8.29943634e-01 -2.21188727e+00 -3.59383090e+00 -4.97577453e+00\n", - " -2.07603694e-01 -9.93630289e-01 -2.28004601e+00 -3.62789625e+00\n", - " -4.99255743e+00 -8.29943634e-01 -2.21188727e+00 -3.59383090e+00\n", - " -1.05565636e+00 -2.07603694e-01 -9.93630289e-01 -2.28004601e+00\n", - " -3.62789625e+00 -8.29943634e-01 -2.21188727e+00 -2.34828560e+00\n", - " -1.05565636e+00 -2.07603694e-01 -9.93630289e-01 -2.28004601e+00\n", - " -8.29943634e-01 -3.69750475e+00 -2.34828560e+00 -1.05565636e+00\n", - " -2.07603694e-01 -9.93630289e-01 -5.06266482e+00 -3.69750475e+00\n", - " -2.34828560e+00 -1.05565636e+00 -2.07603694e-01 -8.29943634e-01\n", - " -2.21188727e+00 -3.59383090e+00 -4.97577453e+00 -8.29943634e-01\n", - " -2.21188727e+00 -3.59383090e+00 -8.29943634e-01 -2.21188727e+00\n", - " -8.29943634e-01]\n", - "\n", - "Optimization Complete\n", - "-----------------------------------\n", - "\n", - "\n", - "RESULTS (opt):\n", - "\n", - "{'AEP_val': 454.91422245142144,\n", - " 'BOS_val': 41.6902691235489,\n", - " 'CapEx_val': 110.5,\n", - " 'LCOE_val': 33.31236843421495,\n", - " 'OpEx_val': 3.7400000000000007,\n", - " 'area_tight': 16.777442328612405,\n", - " 'coll_length': 21.949483532115543,\n", - " 'turbine_spacing': 0.7596037749545983}\n", - "\n", - "\n", - "\n" - ] - } - ], + "outputs": [], "source": [ "optimize = True # set to False to skip optimization\n", "if optimize:\n", @@ -378,21 +244,10 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "3eaedc10", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAYYAAAEiCAYAAAD9DXUdAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAQo9JREFUeJztnQd0VFXbhXcSkgAhJBB6ld5FuqB0PylSpAqKdBCRDiKiKKCigihdkC4gvYiigCDSRKT33gk1hJLe/7UP/8SZZJKZkCmZe99nrbsCmXbnTubs83a3hISEBAiCIAjC/+Nu+IcgCIIgiDAIgiAIyRCLQRAEQTBBhEEQBEEwQYRBEARBMEGEQRAEQTBBhEEQBEEwQYRBEARBMEGEQRAEQTBBhEEQBEEwQYRBEARBMEGEQRAEQTBBhEEQBEEwQYRBEARBMEGEQRAEQTBBhEEQBEEwQYRBEARBMCGT6X8F4dmIi4vD7t27cfv2beTPnx9169aFh4eHXE5BcEFEGIR0s2TJEoz74mvkylcEmTy9EHjtEqLCgjFj+nS0bdtWrrAguBgiDEK6mD9/PuYtXY9xMzcgq49vovWwYekMdOvRW/1fxEEQXAu3hISEBGefhOCaUACq1W6Aj79dAY9MyfcYM78YinNHd+Pq1aviVhIEF0IsBiEZsbGxuHv3buL/PT09kSdPHvXvW7duwbCX2Lp1K8pVrm1WFEi77kMxsudmFXuoXbs2goKCTG53d3dX8Qhy584dJTTGBAQEIHPmzHjy5AlCQkJMbsuaNSty5MiR7FwNFCxYUP28f/8+oqOjTW7j4/j40NBQPH782OQ2vh5fNz4+XsVLksLz5Xk/ePAAkZGRJrf5+fkhW7ZsCA8Px8OHD01uS+kaGuBtvA8fx8cb4+vri+zZsyMqKirZNWQcJ1++fClew1y5csHb21u9T75fY3x8fNQ5u7m5JXufgr4RYRBM4EK7YsUKXLx40WTR6t+/v/r3okWLEhfavXv3osarXVO8gvkKFoFPtuzYtGmTWvQoJMaLEBfRESNGqH/zNR89emTy+LfffhslSpTAoUOHsHPnTpPbKleujDZt2qgFb+7cuSa38TU+/fRT9e8NGzYgMDDQ5PZ27dqhUqVKOHXqFH7//XeT20qWLIkuXbqo95j0ecnIkSOVqGzZsgXnz583ua1p06Z48cUXcfnyZaxZs8bktgIFCqBv377q3/PmzVPCY8yAAQPUIs73efToUZPbGMhv3LixEpTFixeb3MaFfejQoerfy5YtSyagPXr0QNGiRfHvv/+qz8sYig2FrkOHDsiUgrgL+kRcSYIJ3FUuXboU9erVQ86cOdXvuGhw0SLcnRt2u3/88Qd+3XkMnXq/b/Yq3r55FR/0aorQJ48Sd6hlypRB2bJl1c9y5cqpRS9v3rxqJ5x0t8udPXe7PKeku90sWbKoRfHcuXP4ZspMRETFImtmT/Tt2RWFChVM3EVzZx8TE2PyWD6Ojw8LC0u2kPL1+LpcuO/du5fsPVEkaTFwZ88dfNKdPd9jREREMkuEwkhLxLCzTwqvL68zH8fHG8Pn5HPz9ZJaIrQYcufOrf7N800qOPwMvby81Pvk+zWGn+Wvv/6qhKNTp04iDkIiIgyCgosnD+6GufBb417gQl61Vn18PGUFMmXyTHb79M8G48LxvdizZw/OnDmDkydPJh6nT59OXAC5YFasWBEVKlRQPw0HF+jUGDTsfYTDH6+26QpPTy9ERUbgt9VzkdcXmPTVF/LJWsGVK1fw008/iTgIJogwCEoQ6Mqhz7x3795p8jkvXLgQ3y9ciYEfT0W27P7qd7GxMVizaAo2rZqHHxctMJuVRFHhomQsFjxoAdCdZXC/GAsFj/Lly6sd9IQJX+FhQi7Uadwy2XNvXrcI5Qp4Y8iQIfLppkEceH1bt24t10wQYdA7BlG4fv063nzzTRQrVizNz0Hf9pixnyMgbxF4enkh8PpFxEY8wfRnqGOgb5+++6SCQb+9wZJ57rnnkMUvLz6btd6siNGdMnZAGxw9sFeyoayEmWN0sVmy0gR9IBaDjrGFKBg4fPgwqlWrhnHjxqn4hK0rn+kfN7ijGDC+8zgOQ8bOTPH+X3/QAwP6vInOnTuLOKQBWo27du1Co0aNJOagYyQVQcdcu3YNN27cSLcokJs3b6qfzLwxBH5tCd1H1atXVwcDxN/OWpTq/SPCQ1VWU58+fZT7KWkMo3DhwulO0zx06DCWrViFmOhoNH21scpKcvU2IEzvPXDggApkS0Bav4gw6BC6WphZw9TMwYMHq0XXFq4ILtiGXH17whTLy+eO41HwffjnfJqRY8yt65dw5cIpfPPNN2rXa3BHrVu3LjG7iamaSYPdPKw5fy6ag98fg8Jla+LFlv0RFvoE85fPwZDhH+DrCeNdutKbgsmNAmMOtCZFHPSJuJJ0WqdQpEgR5fKxFcOGDVP1Cgwe2xsGrpli6Z+nCEZPWoIsPtkSbwt5HIzPh3dBxON7ySquGaOg2yxp/IIuKkPqKVM/k4oFBYT+d8NzdOr6Dtq/80myTKxTR/bh20/6Yd6cWS4tDkmzld566y0pgtMZIgw6FAW6kGzhPjKGCyHjACz8cgTc/bNQrUCREihdoSr8cuRCcNAdnD91BHcDr2Lt2rVWL868LpcuXUomGAyCG+oCuJOmSPjlyImK9TqgfOUXzT7XpNG9cePCUU20AaE4sL6kRo0azj4VwcGIMOgEe4oCYeCZ/v85c+bAUVAc6AozxDcMC/iUKVNssmNnIJYWkIlYXL6Br+anLH6/rpqHBd99jB07dqBBgwbQCqw7KV26tASkdYLEGHQCM03sJQqEO+T27dvDkXDxZ969veZAsG8SW2/wMNCjT//EGI05oiOf9jli4Pvll182cUnxuqf0uLSKPN08fM9Vq1ZVGUT2tE7Yq2r9+vVSBKcjxGLQUWoqg6aG5nK2Xjjog2c9A4VHyyxYuBAnb0Sh7qttzN4+fmhnHP1nB1q2bKnaV9DKMPSAYlW5IUPK+GAhn7UZUh+NGYu/D51CzbrNkNnHB//8uQlXzh3FhM8+saswS4W0vhBh0LgYbNy4Ue1c2Y/IXpw4cQLPP/+8atJWp04daBkGvp+vXgcjvpifLCNq89pFWDbnS+T0z64WUu7iGaxm8ztjdxSb9/EwdFH19/dPJhY8DL2VjEUhKnNBvNS4lcnvA69dxNejemDq5K/sGvQWcdAP4krSQfEa3Q32hG4kwopkrcPF/pNRw/D+gHZ4oVZDvNigOR4G3cPuP9aprKTw0CeqDYjBtUNLgFYajyZNmiQ+D91RvG7GgkFh5eAjQ9M/1oMYRIINB/ceOIHBY99Ndk4Fi5ZE/abtMXDgQOVas5dbia4wWoR0S1IgpSOrdhGLQYPYsqLZGmbMmIHhw4erpni28KG7Agx8cyGmNWDLwDc/uwsXLiTLkOLv3p8wD7UbtjD7uIiwUAx44yWsW7PC7kFvQ2sSusjYOl0EQnuIMGiQVatWqYXEEaJAOFPh559/Vq+pJ7hrtlfgOymfffYZYrKXQpVaDc3eHhMTjf7tX0SJ5wqhWbNmiZYGixhttXAbv1+6JulCpLtLiuC0h7iSNAiHxTD33BGiQOgS0YMbKSkUAUelpPIznTjzxxSFYc8f6/Ew6C6iC+TBrFmzVGsLwlkMdEMljV+wwDEt1h2zoMZ9/jXyFy2N7P45ERx0F7eunkPNak8ztkQctIUIg0ZgCuM///yjRmjyS+9IKAxVqlRx6GvqDaakvjdouAo0M6ZgTFRkOLas/xEFCxbAwYMHlWAxA40BbmN31C+//KIyyAhdQOZagtASSJohtXz5cnz17Sx8On2dSZX5g/t38NXIbon/F3HQDiIMGiteo+vAHk3sUoOvyzGbgv3gYs+U1KHv91CB5ubteyGTl5eyFCgK508eUtXeBlcWez7xaNiwoUlsgMWAxmJx5MgRlWZsmF9tGJpkHPT+6JPxGDtjvYkoqPvmzof+o7/F5NE9VK0HJ9MVKlRI/gw0gAiDxiqaHS0KbErHtgl6dCU5GtYp0P3D+dC/rV4Adw8P5T6ipWBNCxBaAgyQ82Acwjh2wHkXxoLByu3Zs2er22rVbw5fP/NzGoqVqgD/3AVV5TtFgeIjGUuujwiDC2PvNhfWwNcmbLYmuGa1Nx9bqlQpdRhbfmwsyCyrLbuOpPr4rD7Z1GxtwlkZwcHB4lZycfSRW6hRDIPgnSUKeqthyGhBbw4h4k97ZUKxjXqtWrVw786NFO9D6yDo7i0lUISuJ24WuGExjGgVXA8RBheEue6sUaBrgEVTzhIFgzB4enomLgyCtqA18jgoEIf+3m729t/XLkB0+CN1P+MiOBEH10aEwUWL15g+aAgYOhMuAMyCcvUW04J5+LlO/e5bzPt2NLasX4y4/7cCoiIjsHrhd1i/ZAamT5tm8vkbi8O///4rl9YFkQI3F8LRFc3W0LFjR+Vf3r7d/I5S0E6l94ABA5Epsy98fP3w+FEQ3GIjMX369BSD3nfv3lWuTr1Uw2sJEQYXISOKAqlZsyYqVaqkevwI2uZZK73pbty/f78arCTtM1wDyUpyEdiJk8VJGUkUDF96tpgWtM+zVnozhfXixYsyQ9qFEBsvg8PMDsYSOO/g3XffzVCiQLFi6wXJSBJSQwLSrocIgwvUKbAlAXddGc1Xa6hhEGEQ0iIObM0hZGzEleQixWvWTvhyRg2DFLcJ1orDW2+9BR8fH7lgGZyMtQUVMkxFs7XCwGAiR1MKgjXQumSmUnR0tMpkkyK4jIkIQwbk0qVLGV4UDMLAvjuSaSKkFXZ/ZTdgqZDOmEi6agaC4x4NcYSQkBD4+voiI8M2y/yC//nnn84+FcEFkRnSGRexGDIINKlZzcxdFMnoomCwGCS+INgiIM0EC26MhIyBCEMGiylwUIqroNfJbYLtxaF48eIZLutOz8gn4WRcJdCclIiICNXyQIRBSC/8m3/ppZfUv8+fPy8B6QyACIOT4UAUVxMFwtYcRIRBsOXQpzVr1khAOgMgwuBk6tWrh27durmUKBCpYRBsDedQc8aEtOx2PiIMTnIfbdiwQXUl5TAUV5yTS2Fg7xxXPHch4yLtMzIGIgxOiimcOnVKpaS6KhQGioLUMAj2EgeOFmVXYcHxSB2DA3HVQLM5eP63bt3CX3/95exTETQK+4OxFQw3UFmyZJFNiAMRi8GBMLCmBVEgkqoq2BuKAmsblixZIgFpByPC4EBq1KihCVEgUtwmOALWNjRr1kwC0g5GhMEB7iNWM9MsLlGihCZEgfMhOMVLUlUFRyABaccjwuCAmAK7SHKgjVaQGgbBWeJw48YNBAYGygdgZ2Qeg4MCzXny5IFWkAE9grPEYciQISoQTQuc8QdrZk4LaUcsBjugpeyjlOIL9P1KDYPgaCgKZNu2barxnsxzsA8iDPa4qO7u8Pf316QoGIShYMGC8PT0dPapCDqlZMmSEpC2IyIMNoS7F/o/KQwtWrTQpCgQSVUVnI0EpO2LCION3UfLli1TYwu1DF1kkpEkZCRx2Ldvn7NPR1NI8NkOMQUvLy9oEbYnmDd/Ps6cPYf8+fMjLi5Ogn+C08WhZ8+emkruyAiIxZBOtB5oNjDm0/Fo8noX3I/NjbEz1iNz7jJ4ocbLykISBGfCTQqzk27evInVq1dLQNoGiMWQTtjHJTg4WNOi8PmELxEc64uBY6Yl/q7Qc6XQvENPjB/cCZkzZ0a7du2ceo6CQIuWg364UeM8cmnw+OxIE710WArMo6bbSMsuFb63lxu3wMivFpm9/fiBXZg7cQSuXb2s2WsguA5XrlxRs9M5i1zE4dkRV1I63EcrV65UhTZaXhBZtV24eLkUb69UvS48vLJi9+7dDj0vQbAUkObME+HZEFdSOmMK7ACpZdha2809ZeHj+3dzd0ffvn3RsGFDVKxYMfHInTu3zc6DmV5bt27Fo0ePVGFd3bp1NS3IQvrFgS5O4dkQYUgDegk0G8O01EtzUw4wnz3+L+4GXsPLL9XG/v37sWjRosR0XWaKGAsFjwoVKiB79uxpatg3YMgI3LwXgtz5CyPo7i31mlGhwZgxYwbatm1rk/cpaAvDd5Pf2T179uDll1+WmEMakBhDGuDUNZqnehEFQ4yhRKky6NB7FOo0amlyW0xMND4b0hmP7l1LHPXJL+LFixdx8uRJk+PChQsqJkOKFCmSTDDKli2b2O7AAJ/rze790KrbSPj4+pm87sQPe+HQ3j+wdu1aEQchVYt34cKFEnNIIyIMaZgkRR4/fgw/v/8WKT2wbt069H6nP6rVeQWvdewDvxwBOLB7C3ZsWomzJw5YtThz53/27NlkgmFoyMdqcbYlNxaL6zdvwbtgdRQqWirZ80WEh2F4t8bwdItTAUdxKwkpIQHptCPCYKX7qFy5cqhWrRr0CsVh0KBBCAp+CG/vzAh5/FD5+qdMmZKuHfuTJ09w+vTpZIJx9+5dVKxaB+Nnrkvxsd981Ad///kLduzYgQYNGjzzOQjaR8QhbUiMwQJcoOgaqVq1KvQMF//WrVur7CMO6WFRkS0CwIw3vPjii+owhvMr3hs+JtXHZs7io37OmzdPxTVoZfC8bJkQQFeard+z4LyANL/PtF6zZcsmH0MqiDBYSc6cOaF3uCA6amfOjKZc/tlUPMHT03yLkds3r6ifrHY1VGDnyJHDbMA7ICAgzecwbdp0zF30E3LmLYS42BhcvXAK8dFhEvR2YXHQS2wwvYgwCBmWYYP64dNvZqF99yHJbtu7/WdcOX8ShQsXVhYdJ3sZu6K4y587d25iewTu9g0iYRCM8uXLw9fX1+xrjxv/OS7ejsDYGetU/INER0Vi9tcj0bHjG1i1aqUEvV2MiIgIXLp0CcWLF0fWrFmdfToZGokxWIBttLnA9OvXD/ny5XPMpyIk8unYz3Dw1FW80XsEfP1yIjIiDBuXz8a2n5fhwf3bWLNmTYoLNN1LzIZKGr/g4sCEAkM6blILg7/r0O09vDtqcrLn5OM+GdAOT+7fkKC3i3Hnzh3Mnj0bffr0UfNEhJQRYbAAF5d79+6pnHytdk3N6CxduhSfjPsccPdEWMhj3Lt9Q1kKzxr4Dg8Px5kzZ5IJBpuwkWy+/pjwwy+qH5Q5fl05FwumjJGgt4shwmA94kqyAMVARlg6ly5duqBz5842CwLTjcAMs6RZZqyqZq3KJ+MmIH+hlH3RxUtXUj95LoKgRUQYLMDFgkNAXnrppTRV7AquF/jmOFZ+zk3+10gFmkuUq2z2fmeO/6t+UqDsAd1VO7ZtwbYNyxAbHQlv/7wY+uF4SYAQHIY00bNAWFiYavVA94OgD4YMHohfV/1g9rbY2Bgc2LNFubJotdijdXT/Lq3heXIpJrQphIlvlMSwOp4Y07MJvp04weavpyc4o7xAgQIyq9wKRBgEwYz7sHmj2lgw5RNERUYk/v7JoweYNLo3zp88pOIb9qhn+GBgL4xt/RzqViqc+Lscvpkxc8ArOLL1J9XRV3g2mLLMZo8y7c0y4koSBDOMfH84AubPx6f9X4dfQF6V9nrt0mlk8fKwW38mJjqEB55G3pz/M3v7Z93rocmoYWjfvr0U2Ql2RSwGQUiBXr16Yeefm3Fgz1Y0rPMC1q5arno72auj62+//YYKRXKkePtz+fzgjRjVosXQkFBIW1bS+PHjVQq6kDpiMVjAx8cHNWvWlIIYHceYyGuvvWb34DcbNN4MCknx9vDIGIRGRqssLebis1jPuGCPB/Pz09MShJbRrxtWY9+uHUAmLzRu2gqNGzfWjIUigmodIgxWZKo0b97cysspaA02+SOO6KjLcZSzT9xAfHwC3N2TL+7TNhzElTuPMWnSJLVQG+ovWORnEDCeZ9KCPR65cuWy+Pr7du/ALwsnoctLBdG6ZQCu3X2Cz6e8jwHvBOGrydOl0ltHiDBY4fcNCgpSvXuY1SDoUxgckarMLKcbjxPQ85vfMH94M3h4/Ofp3X74Kn747ZjKhho6dKjJDp67YLq4jIv1mGLNOQSGoUl58+ZNJhZsCWJ4Xzdv3MDeld9hwpuVTVxX84Y1x8RV+9GpU0esWLFKxEEniDBYgF0+pSWGfnGkMHCxb9mqNebMmY2jl+6ieul88MnsifM3g3Hg/B0Eh0RizZr5ydw67OVkaBDXsmVLk9RXw9AkFu7x5+bNmzF9+vRElwqtFIpEdo9ILHynitnzGtG+Jlb9dQZDhgxRHXa14lYSUkaEQRAyiDCcO3cOP/74Ixo1aqz+PX/z8cTbaCn8sDBtLUBo4XKOCI8OHTqkOjQp9skdeHuZXw7o1ipbOCcO/XlaVZ+76uwLutMGDBigu0Fbz4IIgyBYCAhnypTJ7oPlo6KiVNsPtl/5+eef1evZaw4En/uFF15Qh4FRfdqkfn6xcS7fBoSfozWxFkGEQRAsWgy0Fmw5/MccH3/8sdq5//PPPyoTjjhyZ+6dsyjuPwpHbv/k7ajDIqJx9OI9u7YBcZTIc9pf/fr11dwOIWWkjsEC9N9yh2XoyS/oTxjs7XrYtm0bvvnmG0yYMMFpkwKHfzQefaduRXTMU8vAADOken/3Oy7eemi3NiCOnMdw9OhRaW9jBeJKsgB3SKNGjbLmWgoathjsBTPeunbtildeeQXDhg2Ds+B7bN1rJOoO+xjNahRDrbIFcPTSPWzafxH7ztxSFpO92oAIGQ8RBkFwkjCwiyqrq5lSunjxYqdbpd2790D27H4YOHAgxi3Zm/j79My+EFwTEQYLcHg4Zwp36tRJAlc6xJ7CwGliGzduVMFmdv3MCHDxZ0qqvQLfgmsgwmBFiwCa+4bZwYL+hMEeI11Pnz6tXEfvvvsuWrVqBb3NvnAGDOrXq1cvxTnfwn9IRFUQLGSy2NpiYB3Bm2++qQrSGHQWHAMFoVGjRjJwywrEYhAEB2clffjhh2rm9L///ivNGR0Ia0XYWZWNBr29vR350i6HWAyC4MAYA1tSMJD79ddfo3Jl86NDBfvw8OFDVVlO17CQOiIMVkx9otkvBTH6xJbCcO/ePXTv3h1NmjTBoEGDbPKcgmAPxJVkARa3lS5d2i4XX8jYMI2U8QBbCANTU3v06KGa1y1atMjpqamCkBry12mBkJAQ7Nq1C6GhoZbuKmjwsye2EIaZM2eqCW1shW2PLCdBsCUiDFa4Ev78808RBp1mJNlCGE6cOIERI0aowjFOghOcl4bLRAKpybCMuJIEwY4tt9mfhzGqUqVKYeLEiXKtnQiHbXHIkWAZEQZBsONYz5EjR+LChQs4ePCg3Vt3C4KtEFeSINjJYti0aRNmzJihitg4JU1wLswKmzx5Mu7cuSMfhQVEGCyQJUsW9aWW3Z7+SI8wcPFhaipjCu+9954dzk5IK8wIY0JBXJxpa3EhOeJKskDOnDnRvn17S3cTNCoMDFRyc5DWBYiiwMcuWLDA7kN+BMHWiDBYgM3zmKrKPiuSzaAvnnV627Rp07BlyxZV5ZwnTx67nZ8g2AtxJVnRdpstDO7fv2+3D0HQTgM9Tgj74IMPVPYLK5wFwRURYRAEGzXQCw8PV6mp5cqVw5dffinXNQO6hVl9nitXLmefSoZHXEmCYKM+ScOHD8eVK1dw6NAh6d6ZAfHy8kLRokWdfRougVgMgmADYeAUNk5k++6771C+fHm5phkQZiT98ccfiRXtQsqIMAhCOoXh1q1banYzR2K+8847cj0zKGFhYdi7d6+0t7ECEQYLcBbvmDFjkDdvXmuup6AzYWBqateuXZWbYt68eZKaKmgCiTFYgKmKkqaqT6zJSvr222+xfft25aKQoKagFcRisADTVNkqOTg42DGfiOAyFsPhw4cxevRovP/++3jllVccem6CYE9EGKwY1nLt2jX1U9AXqaWr0l/duXNnVKpUCZ9//rnDz01IO6xgr1atGnx8fOTyWUBcSYJghpiYGNUyOyWLYciQIbh586ayGhhfEDI+FPmWLVs6+zRcAhEGwWT85LqNmzBn1SaERMYit48nvni/PypV0l9n0NSmt61du1YFmufOnYsyZco44eyEZxV7uoRZ6Obp6SkXMRXElSQkZtc0atsVA9acxfnCLXC71Os4lq8pGg6fib5DPtDdVUqpsyqthD59+qBt27YqRVVwHR48eIDvv/9etd8WUkeEwQL+/v4qPz09w1pcgR79h+J8nvrwLlgu8Xdu7h7IVrUFVh+7j+9nz4bex3qyXfPbb7+NrFmzKmtBuqYKWkWEwQIMVFWpUiXNrZddCS54G/ceh2eO/GZvz169FUZ/NU1XfezNWQyTJk3Czp07sXTpUuWOEAStIsJgAWafsPcNG6Rpld27dyPWK1uKt7t5eCIiIRNmzpypm7TdpGM9Dxw4oAodR40ahQYNGjj57ATBvkjw2QKPHj3CL7/8goIFCyoXgha5ffs24iOeBlvNkZAQj/jIUAwePFgdrAbnVDvjg/2B0pMGyHTghT+txh+7/0F8TBS6vN5UufCcVVxobDEwEM2uqbQcx40b55TzEdIPXX8MOosL0DIiDALy58+P6NvnER8VBnfv5It72OldiAm6rqaR0aV28uRJdbBxHJvGMZuJFC9ePJlgMGvHUjrn3v0H0GfsdDwuUg+euZsiPiYSf07dCPfBozBv6lcq0Gtv+B6MXWUUJOa889w/++wzNXBn8eLFks3iwrCtzUcffeTs03AJ3BIM32rBLIGBgSrQ2K9fP+TLl0+TV4kLYpEiRfDAIwC524yGRxbfxNsir5/Egy3Tkd/HXbWUTrqDp6vtzJkziWJhOHjdSKZMmVC6dOlkgkER4XNxN16j40BEVu6Q7Lwirh1D0M8TsXrpAruLAyf1sefR2bNnzd5etmxZ/Pjjj+r9CILWkb9yQS3Q06dPR7t27XBn8WB45i2pLIe4J/cRfe8SEiJDMWXuGrNuHbqPqlevro6kLrhTp06ZiMXUqVNVyiDJnDnz0/bUWfwRVrUXzDmMshStDK/8pVQxmSPcShSFI0eO2PU1BOcRFBSE1atXq79zGbmaOiIMFvD29kaxYsU0X93KHTkLtxhDuHn+78TfFy5cGFOmLErzjp1pvi+99JI6DNA4ZQ65sVhs2HsCPllTTgX2zFkINw4eVAFyCfoK6bUKOaqXhW5C6ogwWIAdM7t16wY9wMW/Tp06KubAmcWtWrVC3bp1bbZTZ9CPfl4ejRs3Vr+73KEHLiYkpBgQZLyB8FxolRi7oypUqGCT+hJaN9/PW4hHYRHpfi5B0AIiDBbgLpc7DfqW9ZDNcOPGDfWzS5cuqFq1qt1fr9lLVTBp/3HlNjKXDRVz50KiaDGewfbWs2bNSgwU06JJGr/gzGVr6k5Y7d2uR3/svPgQWSo0BuoPha//FkRc2I/Yh09jJMacOHECL7zwgi7+DgR9I8FnC+gh+GwMfbAdO3ZUsQBHFHFRdANKVYFviw+QySeHiSAHb56O0BPbULhQQZPAd2RkJM6dO6dcUcZxDN6HuLu7o0SJEskEo1SpUiZZRW269cO/XlXhGVDQ5JxiHgTi3trxJuLAVFWDdcHUVR4ywtO1uHPnjhq/ypYmTD8XUkYsBsGEq1evwtfXFzly/LdI2xNaYnO/HoO33h0G70IV4J23BGJDghB14ySibl+AGxIwZcoUE3cWA9eVK1dWhzGhoaE4ffq0SQyDze5Yp0EoCswuokhQOP66EAy/eskXCApFlpI1EXJgvcnvaanMnz9fFfp98cUX6vXfeustdOrUSVkuQsaGca833nhDqtatQCwGC+jNYhgwYAB27dqF48ePO/R1161bh0GDBiWmuf4X+J6S7lRVWj9JM6QOHDmOnJ0nwjOn+Z1jTHAgvHd+B3+fLMnSVaOiovD777/jp59+UsWPtGDq1aunrIj27dsjICAgXecrCM5GhMECehOGFi1aKFfMxo0bHf7ajBsw+4g7fAbAbRn4TsqQEaOw+klxi8Iw/dtv0KRJE/U7nkvS+AIrpDds2KBEgvEP3qdp06ZKJNj7P7VqcL7frVu3KiuNtR7MupIxsvaDMaqjR4/i+eefV1axkDLSK0kwgYtU0aJFnXJVuChyceRkNHsvkq82qoeQY1tSvD3k6GZcOX9WLey0ElJKPmDLDBbGbd68Gbdu3cLkyZPVOFi+B2ZfMYhP6yJpiuT3M6ejSTk/7P3ydeDXYVj+fjPULJETq1atssv7FZ7O2KB4G9qdCCkjMQYL0EoYPny4ZvskGcOAL4Xhueeeg9ahFeDx7jAVaDYXfI649K9yZdFqsRYKwcCBA9Vx+fJlLF++HMuWLVMH054Z1GdM4sSxIzi8YDj+6JwZbm6ZEx9/NzQOrw15Uwkii7AEwVmIK0kw8cVzAVuzZo0uFia+z87vDEHWEjWR7YWmiZYCRSE2OFAV/KU3vkGxPXbsmHI1USg46Kd6AQ/s750V7mYskG2XY9F7uw8uXb8tbiUbI1lJ1iOuJAuwzfTKlStVmqLWobVA9GAxEAaKV86dhmyB+3F70SB1MBOJfaFsIQqE7ifWPkycOBHXrl1TwfRq+d3NigJpXMwDAQkPVKxFEJyFuJIswIHwbBJXv3596EUYnBVjcAZc/NmHyRFBbwb12aPnaqbUhSRLJqj23gyUGmow6KayZWGdIwP9Gam9DbPLmO4spI4Ig2AiDAy26i3d0hD0dgRchM8Hx6d4e1B4PK49SkDWW7fw4YcfqlRYws8kacEeW4I8S73J2JGDcOjXBcibORqPIhNw4FYc4rIVwLRp0xzS4txZ8Fqx5kSwjAiDkIgh8CwtH+wHd+Z9w/2w40okGhZL/vUb+UcUErIXUIV6hNXcxvUXf/31F+bMmaMqxgkreI2FwtLQpGHdWqKN+58Y25Gv/bRGIzgiAW1X3lFxJVu50DIitJLoAWC7FK1bR+lFgs8W0FMdAxvVsX/Qr7/+6uxT0TQs5nu/d3v0ruKJwS96I6unGy4+iMOYv6Kw8VwslqxIfXHmtLvz588nm4HBTCgGuyns7Aic1MIID3mEK981RacKyQXpcWQCqv0Qhuhspu1HtIQEn61HLAYLME/9lVde0UVBDC0GVvAK9uXpor9GVZn/cOgOsnq54W5oPLIEFMKSFVMt7tjZAt6w2FsamsSpc4Zq8ir5PHCwr/m0a7/MbqhR0B0rTt6QFueCCIMlKAgvv/yybmoY3n77bWefii6wR9A7paFJDx8+VC1B5n/cDe5uQSk+PlfWp8FtQ28pQb+IxWAB+iS5YNI013I2AxcPVobqJVVVT0FvBl25udlU8nlEx22Hl4f57KabT55O+aVICfpG6hgsoJc6BubYExEG7dLnw0mYfuDpHIukMMZxIDAuzdXegjYRYRB0WdymR4qXKIlHZTtj6v4oxMY/tQ7I3uuxaL86AoEhyVucawnWkIwePVosIisQV5KQKAxM42NLDEG7fDZlHmZOr4RaX41E3qzxeBwVj+N34uGftxDWrrUc+HZlWGCo9dnttkKEQVBIDYN+eG/gYGzd9qcKMnO2t14qn+kWZip28+bNZQNkAREGC3DqF01Qtl3WMnrpqir8F1OqXbu2ag+uF1j/wVoPDloSUkdiDBagKPTv31/zOwwuFCIM+kE+byE1RBgEpw/oERwLM+x4yEZASAkRBgtwKteECRNw9+5daBUuEo8fP5aFQicYUpNlIyCkhAiDFRXB9E3yp1aRVFV9odfPm+1tONPc39/f2aeS4dF2RFWwCilu09/nzSp+znjQExzPm7RdiGAesRgEtYPkQsFAu6CfeJLe2quzvQ2HH4WHhzv7VDI8IgyCbhcKvaLXRAPG0TZs2KD6ggmpI8JgAe6iOYtBy1PNpIZBX8jnLVhChMGKAjcO6OFPrSILhb6Qz1uwhAiDFamcLKN/8uQJtIoUO+kH/h3TlaK3jCQhbYgwWIBTsQ4ePKjZgBX9rlwo9Ohz1iN6zkBjA70iRYpIIz0rkHRVnaPnhULPNQx63AjkzJkTPXv2dPZpuARiMegcvRY76fnz5s6ZcTO9wSLV+Ph4TRer2goRBp1Di8Hb21t3xU56/rxpLXA2gd5gW5vx48erNjdC6ujvryONZMuWDXXq1FGD1rWc067HhUKPSEaSYA2yGljAz88Pr776Knx9faFF9FrspFfk8xasQYTBAhzqcf36ddVIT4vIDlJfyOctWIMIgwWCgoKwYMECNRZQi0gNg34IDQ3FgwcPJNFAsIikq+qYkJAQWSh0hN5TkzmFcfDgwZp1C9sSEQYdIwNb9IWeaxgI57bnyJHD2afhEogrScdIDYP+Pm/2/MqfPz/02t5m7dq1mnUL2xIRBksXyN1dpaxqMZ1T7wuF3uDnzZYQHh4e0CORkZE4ceKEmssgpI64kizARXPEiBHQInoudtIjkmggWIusCDokLi4OP//8MzZv3qzGHfL/gvaRVFXBWkQYLHDnzh1MmTIF9+/fhxb4fs4PCChbE92/W4+7Zdvjmm8FBJSuhoULFzn71AQ7I8VtgrWIK8kC3E0zaKWFXfWiRYswYuoy5Go7Fm5uT/cEWUtUR0JcDN77YgK8vb3w5ptvOvs0BTu1j+fmRq+pqoSxwoYNGyJ79uzOPpUMj1gMOoHCNvSTLxHQbFCiKBhw8/BEQLPBGDhyjCYEUEiO3msYDMJQv359qWOwAhEGnbB7925EZ80Nd8/MZm/38PFHuHcOdT9Be4gwPG1vc+HCBZWdJKSOCINOuH37NuCR+txqN/dMGDp0KD744AMsWbIER44csXlqHy2Sv/76C8uXL1c/xUJxXHyBBV4FChSAXuGkwmXLlqlqfyF1JMZggYCAALz99tsuXzHJtNvYBzeRkBCfzJVEEmKjERMciMy5CmHVqlWYOHGi+j1TWUuWLImKFSuaHPwdayDSwuCRH2Hxr38hPksA4mOjEH3nAnJ5RGLGjBlo27atzd6rYF4YChcurNsaBiFtiDBYIHPmzChRogRcnbp16yKHezie/LsefrXaJbv90e6lyO0dhz179qjFg32UTp8+jZMnTyYeP/zwg8rSIpwCVrZsWVSoUMFEMOjDNlcX0an3AOwIzQ//VqMTf5cQH4cHv09Du/YdsHbNahEHOyKpqkJaEGGwwJMnT3Do0CHUqFFDBa9cFS72s2ZMR4cu3REbHIjstTsik19exDy4gcf7ViLy4gEsXL4kcUfJRmO1atVSR9Jus6dOnTIRjN9//11lbhHWRSQVC1pb265EIluNF0yey83dAwHNByM2+CaGDBmC1q1by47WjjGG8uXL2+vpBY0hwmAB7px37tyJcuXKubQwELprVi8FBg0ahNuLh8DN2wfxkaEomDsHpi5fYtWOnR0qmdnBwwBn6HJcorFYsPXAihUrVIzCwy8vCvSaafb56NbyLlwRN/avVYHvBg0a2PQ9C/9ZDM2bN9f15eCmh65hxlqE1JErpDO4+HNnzkWYAWnGHuhmSo/v2c3NDQULFlRHkyZNEn/PwetXrlxBx75D8SCFbCjikS1A/WRAmhYHd7auLsIZCYoz5x3rtauqgdy5c2PgwIHOPg2XQIRBh1AEHLEzZ6yB8Zl6NV/Aqkd34emX1+z9ou9dUT8Zw+BBihUrZuKOonuKMQ1vb+9nGlAzde6P2Lr/JCKjolE0ZxZMHjtSBWP1gKSqCmlFhEGwO1+MGYVFL7WBX5NByW6LDQ1G1M1TapFm7IJ55sYuqaVLl+LGjRuJglaqVKlkGVIUn5TcAwyWv9JtGB6XaQGP515Tv7sdE4XKHYZgaOtaGPPhSGgdEYan3Lt3DwsXLkTXrl2lo7AFRBgsQNdG5cqVkSVLFkt3FVK5hkM7NMakVTPg36A7PDI/dRNFXD+BRzvmI/bhLUyZt1YFvKtWraoOYxjYTpohxRRXBsIJrQjGgJIKBltMt393JEIqd4KH+3+uMndPb2Rv0BNfLf4S5UoVR/v27TUfX6Co0tWnZ+japFuNP4XUEWGwADNq2rRpY+luggU+GT0SxQrnx+DRHyHcLQsSYqIQHXQdhfLlxtS1a1MNfPv7+6NOnTrqSLoDNBYLHuway4QB4uPjA8/qHeBnJArGZK/VAf0HDVOfr5bz+ykMhQoVkqCrYDUiDBaIjY3F48eP4efnJ1+sdMJCQTbps1XgO0+ePGjUqJE6jDOk6HqiSMybNw9/Zymb4uO98hZDYGgEZs2apc6NAqRFpIZBSCsiDBZgNsfcuXPRr18/5MuXL80XWHBs4JsZUnQh8eCC+Ne6Y8hcuKLZ+8Y8vIX46AiVvsuDrpak7ihmSNEV9qwwjfeD8ZNw79ETFM0XgK8//dDhVfS8DmXKlHHoawqujQiDoFm4qId/9T18q7VUgpGUJ/vWICEqTFkWjCEZ3FHr1q3D5MmT1X34uOLFiycTjNKlS6vq79To/t4w/HLqAXyqtoK7f2acjniCNc16oUejSpg8YRwcGXw2TiPWK6xh6NOnj0pbFVJHhEHQLKoNSGwwHvz2HQKaDIBbpqcLOftFPf5nDSIu7VfZUN27d0/mzmKKa9KA94IFC542I+QXJ1MmtQtPKhhMs+VzjRn3OTbd9ITvix0Tn9MjS3Zkb9AL83f9hJKzZ+Pdfv3sfg3YSZTnrPcaBsLeXnoPwFuLCIOgWbhAz5wxHe3atUP0rfPwzFNMdZCNeXAd0UHX4BYfhylTFpuNcbDArmbNmuowhp05mVZr3BZk69atqnMnoeXBDKmzD2KQu9OXZs/Lr3YHjP5qNPr26WP3oPf169eh9zkMxu1t9u7di9q1a2s2nmQrRBisgNktbKYnuB7Mdlq7di0GDx6Mm2f/mzVBS4EjW9Pa1ZXuiHr16qnDOODNegmDUPzxxx84FxOT4nNwMFJkpmwOaQHC+AIRYQBu3ryJ/fv34/nnnxdhsIAIgwXy5s2LXr16yR+SC2OPNiDGMA7B5+Txv//9TyUp7Bw/K9XHJEQ/dfHYk3NnTmPB9InI5gWcPXtWBeS1nJabGmzNsn79etUunt9pIXVkUI8F6EvOmTMnoqOj8dNPP6k/MMF1s6E6d+6sftpzgaRARN+5pDKezBEddAMx96/YrfqW7q5Br9fE3g9rYFrZ/djXMyvWf9walZ7LpQLreoPfWX53GWfp1KmTpJ1bgVsC7WDBqnoGdgtlhgdz8RlkFARzcCodd+cPsxRC7jYfKteRAXazvbtmLPK4hSZWJNsSfp0HtqiMb6tehZeHaSbW2tPReGdTFH5YskZXsy8CAwPx999/q0JG6axqHSIMaUDEQbAW7swZ9PbKUwxeBcshU/Y8akJedOAZxATfVHEPeyzOW39dB98NXVG7kHnBee2nMJyILKB20Vp3K9FVxyJIrb9PeyCupDTA3QZNUZqkNE0Nw2kEIaWgdx6vGIQe+Q2Pdi5C2Ik/kM/HzW6iQHasW5iiKJAaBTxUZTjjLVqGwsf0YmYhCWlHgs/PKA5nzpyRgLTg1KC3OcLDI5Q7yVxBH4n/f8exvQPfGSWmkLS/lmAdIgzPKA6VKlVS/z569KjqoyQxB8GZsy8MlHmxKf48vA+Ni5v/au8PjFM/7RX4djYSaLYN4kpKB9yZMW9dspWEjELfAUMwYR8QFp08p2Te4WjsD4xXNRy0XLTI5cuXJfvIBkjwOZ3ExMSobCVWmEq2kpARWLZsGb57vyvalsuENmU9cTskHj8cjsH2K3F4EAGsWaO9rCTOWWDVOTdrnLcgAef0IcJgY3Fg+2amKgqCs7OiOKksLioMMXFAXMKzV3u7gvuI3z/WqEiFt20QYbChOGzbtg3169dPV5tmQbAVb731Fo4fP47Ro0c7JPDtDCSmYB9EGOzUrItN1aSjpeBMKAT8G+TcbC0iomA/JPhsB3bu3Km+jNI+Q3AmWp7cxljC5s2bJdBsJyRd1Q40a9ZMjQNltpIEpAVnwN5ebAWhRWEw1Gl06dJFBZylzYXtEYvBARXSYjkIzmgxzQVUa8LA79L8+fMRHh4OX19fEQU7IcJgZ3Fg4RtT6QTBkRjmMGgpzmWIKXA2iqWxqkL6EFeSncWBKXQ0e7l7Cw4OVoNeBMFRwqCV1GkJNDsWsRjsjKFnzeHDhzF79mxxKwkOE4YCBQrA29vb5a94WFgYli9fLoFmByLC4CA4TpC7N4k5CI6Ac0O0El/gaN0OHTrIkB0HIsLgIDw9PdUftoiD4Ai0kKpK99GePXvUv0uVKiWBZgciwuAkcTh06JAjX1rQoTC4cuDZEFPg+2DvI8GxSPDZSeLg7u6eOBVO8rAFW7dnYbqqq1oMSQPNhu+K4DjkijtJHNiz5t69e5g6daoEpAWbQlHgLtsVhYFFecaiIJsm5yDC4ERy5syJvHnzSkBasHngmbiiMOTOnRu1a9cWUXAyIgxORCqkBXvgijUMdB/RgmbhWqNGjcRScDIiDBlIHNavX69iDoKQXmHIly+fqhB2pZiCIQNJcD4SfM5A4sBW3eJTFfSUqmocaG7VqpWzT0f4f8RiyCBQEOhfpcWwevVqCUgLmi9ukzYXGRcRhgwGeypFRkZKQFrQfA0D01BLliwpgeYMiAhDBkMqpIX0QIvzxo0bGdpiuHPnjkqnpXi98cYb4j7NgIgwuIA4sCurIFhbBxAXF5dhhcEwT2Hfvn3OPhUhFST4nMHF4eTJk8iRI4ezT0dwETJyDYNxTKFWrVrOPh0hFcRiyODiUKVKFdW6+9SpUxKQFlx2QI8Eml0LEQYXCUgfO3ZMAtKCVcKQJ08eNQs5I3H27Flpc+FCuCVw1RFcIqi4YsUK5Sp488031chQQUhKz549lXW5f//+DHFxmGHHQjsuM4x9SJ2OayAWg4u2zzD4klPiVmAgZowbgk+6/Q+DO/8Py35crL6YgrbJSMVtdB+xSSSzpOgOFVFwHUQYXFAcKleurBrwpcT6xbOwYXB19IpegPHF/sXkUvsRsqofqhXPiXXr1jn0nAV9FrcZYgoFCxZE/vz5nX06QhoRYXBBcWjRogV8fX3VLNzr16+b3H721Ak8+X08+lcMRxbPp/OmM7m7oV91L3z5cgx6vNlexEGj0CLk34OzA88SaHZ9RBhcmJ07d2LJkiUm2Uq/L/4WXUqHm71/s1KeqJrfHUOGDBG3kga5deuWikU502Jg4dqmTZsk0OziiDC4MK+++mpizMEgDiGBZ+Hh/tRSMEdRP3fl892+fbsDz1RwZKqqs4SBAWa2uXj77belzYWLI8KgsXkOT6JSTzJ7EvV0fm7Tpk1Rvnx5dOzYEePHj1fupfPnz9vEkjh57ChGv9cVQ3q9oZ5Xgt6OwZCQ4AxXEv/2Fi9erLKQ/Pz8JNDs4kjls0bEYeXKlXjy5AnyPt8Yx26fQOW8yT/a4IgEHLr9VBjoTuJsYFZWT5s2DQ8ePFC/Z2phuXLlULFiRZOjcOHCKrMkNfj6o7u+ghc9TmFceQ9ExgIzZm7Ai8O88cE3C9C+fXs7XQXBYDGwQ6+Pj4/TYgqSeaQNpI5BI9CM58IdHR2NRmVzYFUbNxTw9Ui8PSQqAa1WhOOvq3FqkeeXmXOnDY/l9CyKRNIjNDRU3YfB7qRiwYPFVAYGvF4TEyucRdb/D3obOBcUh9d+CsfEeWvQtm1bh10TvdG7d29VCHngwAGHvaYEmrWJWAwawbCbZ4VpoZotUWf+KrxYyAMFsrkhKALYHxiL8w+eiseUKVMSRcHwWM6e5tG4cePE31MwmOViLBRcdH788UdERUWp+3CHSoEokC8vmrifRFZPz2TnViaXBxoWy4TBgwejdevWJq8tuG4NQ0hISKKlQKtVrAXtIBaDRiukf/vtN+zYsUO1ODZAS4GikN5dO1/j0qVLJoJx+p8/cLR7PDw9zLubtl6MQZNlEeqcGjRokK7XF8zD2QZt2rTBpEmTHHaJuBHh64ooaAuxGDQacyCMFfBLS1hkVLduXZvs1vkaZcqUUUe7du3U7wb3fAORsb/DM4WnD4l++pPnVrt2bRN3VOnSpVXDwPRCweIOlllXVatWVVlberFOmCZK684RFgPdR3Q9skNq2bJl7f56guMRYdCwONByIJ07d7b7a9Zv8QZmztqAUS+bH0C/8lSM+vnyyy/j8ePHqif/7du31e8oChSZpPEL9oNi+qM1TBgzEsc3zkT7Mgmon80N63+Nwaf9M2P4V3PVMBitw2vJZAJ7ZyQZxxRq1Khh9ecjuBbiStIw3EEzTsCFlymj9tw98/lfLJEDS1vEq5iCMatORWPg71HwzlnIJOjNTCg2fDN2SZ04cQKPHj1St2fNmlWl1CYVjAIFCphkSH09bjSyH/gO71b3Mnnde2FxaLYsAh/NWK35oPfevXuV6PL68RrZAwk06wcRBh3ABZgB49dff92uXVnXrFmDUX07olGxTOhQPhOeRD21FHZei8P98Ke3W1qgKWTc/SbNjqKAhIc/rehmnrxBJCgc22ePws8dzbuifjoRjQ/2+eHqjUBNu5WWLVuGLl26qJRhZpDZGrrn+DckgWZ9IMKgAxzZspsFbYMGDVIjJm0Z9KYPnVk35gTji4aZUnRhxcUnoPysUMxZ86emg94TJkzAt99+i6CgILs8PwvXdu3ahUaNGkmgWQeIMOgER4oD3Uq7d+9WO39bBr3NMWvWLMT+MgyDanmneJ/yM0PhXeh5NGnSJNHSYNCUxXy2fM9cOJkFZu/3bI6+ffvi8OHDOHjwoE2fl2KcPXv2VLv5CtpDhEGH4sAFm5XPtsgEcjZMf5313qtY3TGr2dt3XY1Fs5/CUalqLfW+Dd1oGTQtVapUsvhFWlMvWc/xYb9OeHh8C3J5x+FeWAL+CYxFqHd+TJ8+3WGxDWZg0YW0du1am8cUKlSooNyQgn4QYdChONDdkC9fPmgB7tQrPZcL378SjfpFTRf0mLgENFkajgux+dXOlzt4ZkSdPn06mUuK6ZfEy8vLbEuQIkWKJMvA4bUc+HotfF7+PAKyuptUmbdcHq5iK1yoHSEOTPlt2bIlJk+ebJPnk0CzvhFh0ClcUH/55Rc19MfVx4QyrjGiV3u0L++J4bW94J/ZDRvOxmDWwRjssnJxpjAkzZDiwWAuyZYtm9o5G4vFlZMH0PDSBJQMSJ6yeS8sHlXnhMHd3zQTyx4w/sIZz9988w0GDhyY7ucTURCkjkGnMPuHfZDoKnD1GdJPF/01alFceOQWvDO54W5YAvIXLIy1a60LerPnE4+GDRuaXKObN2+aCMWRI0dUBhCDsY2LeaBPV/MN6/L4uKNKfnf8ev6GirfYM/B99+5d1SPLVsVt3DSUKFFCNT2UimZ9IhaDjnFkQNoROCrozde5fPkyZrz3KqbWCU7xfl3WhWPZiVi88847qsEdXVT26Hy6b98+1KlTRzXQe/7555/5eWg1sfeVpS66gvYRYdA5xuLAzBYuDIJ1fPB2E3xZfB/czSyktDZeXhiGv288bXNOuOAWL148mUuKVd+MbaSVK5cuYv0PXyLw+E5cuHwVZRt0wOivv4e/v/8zB5pfeeUV1epC0DciDIISB7pIqlevLrvFNHDs8AH8M7YB3qma3CO7+lQ0uv8ciYB8hZULikOQksYvDLUedNcweJw04E0RScni2bn5Z5yY1x/vlAtJbFz4MCIBfX+LRfOhM9CjZ0+r34fEFISkiDAIJnABYxqrq7uVHMWYIb2R+fgSDKvtjSyeboiKTcDMA9GYtj8a15+kXu398OFDswFv46FJ5lqCMBbyRftyGF/t6f2SZmLVXRiOkdNWWRVbEVEQzCHCIJi4P+hWov+c7ZtZ1MQdq8G9dP/+/WRjOnkfukHYmz8sLMzkNmbKsH2FIUXW5A/v/2dAEC6EbABnDN0hXBj5nHxuY7y9vZEjRw6VjWNIMzWGCydTS7nwGuZGGGCxFnswRUREqNRVYyiIAQEB6t/G7coN5MqVS+3u+Tg+3sCypUuxbPp4ZI4PU1Pyzj1IUP2cODL1tddeM7mGPF+et7lryAwovl9eZ7az5nHx4kVcuHDBZGhSfv/M2P22B0rkNG9NfLojEj+cz6key5gGH2d4rAFeW17jjRs3qteVeQqCMSIMgtmYAxcVw0I6bNgw9W+2XDCkbxro3r27yobZtm0b9uzZY3JblSpV1GAeLnQzZ840uY2L5ZgxY9S/58yZk9hp1UCHDh2UL56B1S1btpjcRp88O8ZyETU3e2DUqFFq4VuyZImaG2FM8+bNUbNmTRw/flyluRpTqFAhFSQmY8eOTfa8bPXBRZyP4+ONMQS66ZJjENi47oEixiFFhOebVEB79eql2obwffL9GsMOphQXup34WArLlcN/Yl+n/4QpKT+fi8HrKyLUDOauXbuqgPz27dtN7mOY902R4iHZR4IxIgyCWXFgCiThYmcohuMuOqnFwF00d/AUjKQ7e+7MuSjSGki6s6fFwF014W1JLQYuwLQ4uNNNurPnos+dPc/F3M6eGUlclGmlJLUYaMGwJoEN+WhRGMNdu2Fnb9zryQAtHC6gfJyhoZ8BVh1TRJnGanAFGeBjDNYRBTCpxcDX5GvzfSbd2XPHz50901EpsOTLMSMwIvsveM7fvMUw7q9IjN0ZjUWLFqFbt27qc0kq6Ly20uZCSAkRBkFwMbZu3YpVo17DvFbJ24DExjPGEIZ/bsbLtDzhmRFhEAQXg5ZSuaJ50b1MCD54yRse7k+zkp5EJaD7hghsOBuLQoUL273iWtAuIgyC4IIwzvFGh3aomNsdZXK5q2yoY3fjceVRgnLTWTP7QhBSQoRBEFxYHBjUZtsOW86+EAQRBkFwYRw5+0LQDyIMgiAIggnJ+wULgiAIukaEQRAEQTBBhEEQBEEwQYRBEARBMEGEQRAEQTBBhEEQBEEwQYRBEARBMEGEQRAEQTBBhEEQBEEwQYRBEARBMEGEQRAEQTBBhEEQBEEwQYRBEARBMEGEQRAEQTBBhEEQBEEwQYRBEARBgDH/BzmfS4Hz4SktAAAAAElFTkSuQmCC", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "plot_layout(\n", " prob,\n", @@ -405,7 +260,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "c0a33480", "metadata": {}, "outputs": [], diff --git a/pyproject.toml b/pyproject.toml index 024e5919..288d2609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,12 +61,20 @@ dev = [ "pytest-cov", "pytest-subtests", ] +flowfarm = [ + "juliacall", +] docs = [ "pyxdsm", "jupyter-book", "sphinx-book-theme", "sphinx-autodoc-typehints", ] + +[tool.pytest.ini_options] +markers = [ + "julia: marks tests that require Julia and FLOWFarm installed (skip with '-m \"not julia\"')", +] [project.urls] # Homepage = "https://example.com" Documentation = "https://wisdem.github.io/Ard" diff --git a/test/ard/unit/farm_aero/test_flowfarm_component.py b/test/ard/unit/farm_aero/test_flowfarm_component.py new file mode 100644 index 00000000..0f1c8d65 --- /dev/null +++ b/test/ard/unit/farm_aero/test_flowfarm_component.py @@ -0,0 +1,230 @@ +""" +Unit tests for ard/farm_aero/flowfarm.py. + +Julia is mocked entirely — these tests cover the Python-layer logic of +FLOWFarmComponent, FLOWFarmAEP, and FLOWFarmBatchPower without starting Julia. +""" +import sys +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from ard.farm_aero.flowfarm import FLOWFarmAEP, FLOWFarmBatchPower, FLOWFarmComponent +import ard.farm_aero.templates as templates + + +# --------------------------------------------------------------------------- +# _build_design_vector (pure numpy — no Julia) +# --------------------------------------------------------------------------- + + +class TestBuildDesignVector: + + def _make_component(self): + """Create a bare FLOWFarmComponent instance without calling __init__.""" + return FLOWFarmComponent.__new__(FLOWFarmComponent) + + def test_concatenates_x_y_yaw_in_order(self): + comp = self._make_component() + inputs = { + "x_turbines": np.array([100.0, 200.0, 300.0]), + "y_turbines": np.array([0.0, 50.0, 100.0]), + "yaw_turbines": np.array([5.0, -5.0, 0.0]), + } + result = comp._build_design_vector(inputs) + expected = np.array([100.0, 200.0, 300.0, 0.0, 50.0, 100.0, 5.0, -5.0, 0.0]) + assert np.allclose(result, expected) + + def test_returns_flat_array(self): + comp = self._make_component() + inputs = { + "x_turbines": np.array([[1.0], [2.0]]), # 2D input + "y_turbines": np.array([[3.0], [4.0]]), + "yaw_turbines": np.array([[0.0], [0.0]]), + } + result = comp._build_design_vector(inputs) + assert result.ndim == 1 + assert len(result) == 6 + + def test_accepts_list_inputs(self): + comp = self._make_component() + inputs = { + "x_turbines": [10.0, 20.0], + "y_turbines": [30.0, 40.0], + "yaw_turbines": [0.0, 0.0], + } + result = comp._build_design_vector(inputs) + assert np.allclose(result, [10.0, 20.0, 30.0, 40.0, 0.0, 0.0]) + + def test_length_is_three_times_n_turbines(self): + comp = self._make_component() + n = 10 + inputs = { + "x_turbines": np.zeros(n), + "y_turbines": np.zeros(n), + "yaw_turbines": np.zeros(n), + } + result = comp._build_design_vector(inputs) + assert len(result) == 3 * n + + +# --------------------------------------------------------------------------- +# _evaluate_sparse / _evaluate_farm caching logic +# --------------------------------------------------------------------------- + + +def _make_component_with_mock_julia(n_turbines=3): + """Return a FLOWFarmComponent wired up with mock Julia objects.""" + comp = FLOWFarmComponent.__new__(FLOWFarmComponent) + comp.N_turbines = n_turbines + + mock_jl = MagicMock(name="jl_main") + comp._jl = mock_jl + comp.flowfarm_module = MagicMock(name="FLOWFarm") + comp.sparse_farm = MagicMock(name="sparse_farm") + comp.sparse_struct = MagicMock(name="sparse_struct") + comp.farm = MagicMock(name="farm") + + # Set up the Julia function return values + grad_fn = getattr(comp.flowfarm_module, "calculate_aep_gradient!") + grad_fn.return_value = (100.0, np.array([0.1] * (3 * n_turbines))) + + aep_fn = getattr(comp.flowfarm_module, "calculate_aep!") + aep_fn.return_value = 100.0 + + return comp + + +class TestEvaluateSparseCache: + + def test_caches_result_on_same_x(self): + comp = _make_component_with_mock_julia() + grad_fn = getattr(comp.flowfarm_module, "calculate_aep_gradient!") + + x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 0.0, 0.0]) + comp._evaluate_sparse(x) + comp._evaluate_sparse(x) # same x — should hit cache + + assert grad_fn.call_count == 1 + + def test_reruns_on_different_x(self): + comp = _make_component_with_mock_julia() + grad_fn = getattr(comp.flowfarm_module, "calculate_aep_gradient!") + + x1 = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 0.0, 0.0]) + x2 = np.array([9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 0.0, 0.0, 0.0]) + comp._evaluate_sparse(x1) + comp._evaluate_sparse(x2) + + assert grad_fn.call_count == 2 + + def test_stores_aep_and_grad_after_evaluation(self): + n = 3 + comp = _make_component_with_mock_julia(n_turbines=n) + grad_fn = getattr(comp.flowfarm_module, "calculate_aep_gradient!") + mock_grad = np.arange(3 * n, dtype=float) + grad_fn.return_value = (42.0, mock_grad) + + x = np.zeros(3 * n) + comp._evaluate_sparse(x) + + assert comp._cached_sparse_aep == pytest.approx(42.0) + assert np.allclose(comp._cached_sparse_grad, mock_grad) + + +class TestEvaluateFarmCache: + + def test_caches_result_on_same_x(self): + comp = _make_component_with_mock_julia() + aep_fn = getattr(comp.flowfarm_module, "calculate_aep!") + + x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 0.0, 0.0]) + comp._evaluate_farm(x) + comp._evaluate_farm(x) + + assert aep_fn.call_count == 1 + + def test_reruns_on_different_x(self): + comp = _make_component_with_mock_julia() + aep_fn = getattr(comp.flowfarm_module, "calculate_aep!") + + x1 = np.zeros(9) + x2 = np.ones(9) + comp._evaluate_farm(x1) + comp._evaluate_farm(x2) + + assert aep_fn.call_count == 2 + + def test_stores_aep_after_evaluation(self): + comp = _make_component_with_mock_julia() + aep_fn = getattr(comp.flowfarm_module, "calculate_aep!") + aep_fn.return_value = 99.5 + + comp._evaluate_farm(np.zeros(9)) + + assert comp._cached_farm_aep == pytest.approx(99.5) + + +# --------------------------------------------------------------------------- +# _compute_aep_partials gradient slicing +# --------------------------------------------------------------------------- + + +class TestComputeAEPPartials: + + def test_partials_sliced_correctly(self): + n = 4 + comp = _make_component_with_mock_julia(n_turbines=n) + grad = np.arange(3 * n, dtype=float) + getattr(comp.flowfarm_module, "calculate_aep_gradient!").return_value = ( + 1.0, + grad, + ) + + inputs = { + "x_turbines": np.zeros(n), + "y_turbines": np.zeros(n), + "yaw_turbines": np.zeros(n), + } + partials = {} + comp._compute_aep_partials(inputs, partials) + + assert np.allclose(partials["AEP_farm", "x_turbines"], grad[:n]) + assert np.allclose(partials["AEP_farm", "y_turbines"], grad[n : 2 * n]) + assert np.allclose(partials["AEP_farm", "yaw_turbines"], grad[2 * n : 3 * n]) + + +# --------------------------------------------------------------------------- +# Class hierarchy checks +# --------------------------------------------------------------------------- + + +class TestClassHierarchy: + + def test_flowfarm_aep_inherits_from_farm_aep_template(self): + assert issubclass(FLOWFarmAEP, templates.FarmAEPTemplate) + + def test_flowfarm_aep_inherits_from_flowfarm_component(self): + assert issubclass(FLOWFarmAEP, FLOWFarmComponent) + + def test_flowfarm_batch_power_inherits_from_batch_template(self): + assert issubclass(FLOWFarmBatchPower, templates.BatchFarmPowerTemplate) + + def test_flowfarm_batch_power_inherits_from_flowfarm_component(self): + assert issubclass(FLOWFarmBatchPower, FLOWFarmComponent) + + def test_flowfarm_aep_has_setup_partials(self): + assert callable(getattr(FLOWFarmAEP, "setup_partials", None)) + + def test_flowfarm_aep_has_compute(self): + assert callable(getattr(FLOWFarmAEP, "compute", None)) + + def test_flowfarm_aep_has_compute_partials(self): + assert callable(getattr(FLOWFarmAEP, "compute_partials", None)) + + def test_flowfarm_batch_power_has_compute(self): + assert callable(getattr(FLOWFarmBatchPower, "compute", None)) + + def test_flowfarm_batch_power_has_compute_partials(self): + assert callable(getattr(FLOWFarmBatchPower, "compute_partials", None)) diff --git a/test/flowfarm/__init__.py b/test/flowfarm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/flowfarm/conftest.py b/test/flowfarm/conftest.py new file mode 100644 index 00000000..fa60d984 --- /dev/null +++ b/test/flowfarm/conftest.py @@ -0,0 +1,47 @@ +import shutil +import warnings + +import pytest + + +def _julia_available() -> bool: + """Return True only if the julia executable and juliacall package are present. + + Deliberately avoids importing juliacall here — that would start Julia at + collection time, which is expensive and can cause hangs or errors. + """ + if shutil.which("julia") is None: + return False + try: + import importlib.util + return importlib.util.find_spec("juliacall") is not None + except Exception: + return False + + +JULIA_AVAILABLE = _julia_available() + + +def pytest_collection_modifyitems(config, items): + """Auto-skip julia-marked tests when Julia is not installed, and print a note.""" + if JULIA_AVAILABLE: + return + + skip = pytest.mark.skip( + reason=( + "Julia not installed — install Julia via juliaup and " + "`pip install .[flowfarm]` to run FLOWFarm tests" + ) + ) + julia_tests = [item for item in items if "julia" in item.keywords] + + if julia_tests: + warnings.warn( + f"\nARD NOTE: Julia not found — {len(julia_tests)} FLOWFarm integration " + "test(s) will be skipped.\n" + " To enable: install Julia (juliaup) and run `pip install .[flowfarm]`\n", + UserWarning, + stacklevel=2, + ) + for item in julia_tests: + item.add_marker(skip) diff --git a/test/flowfarm/integration/__init__.py b/test/flowfarm/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/flowfarm/integration/test_flowfarm_integration.py b/test/flowfarm/integration/test_flowfarm_integration.py new file mode 100644 index 00000000..c91140d7 --- /dev/null +++ b/test/flowfarm/integration/test_flowfarm_integration.py @@ -0,0 +1,234 @@ +""" +Integration tests for the FLOWFarm integration in Ard. + +These tests require Julia and FLOWFarm to be installed. +They are marked @pytest.mark.julia and will be automatically skipped +(with a printed note) when Julia is not available. + +Run only these tests: + pytest -m julia test/flowfarm/integration + +Run without these tests: + pytest -m "not julia" ... +""" +from pathlib import Path + +import numpy as np +import openmdao.api as om +import pytest +import yaml + +import ard +import ard.utils.test_utils + + +# --------------------------------------------------------------------------- +# Shared test data +# --------------------------------------------------------------------------- + +_PATH_TURBINE = ( + Path(ard.__file__).parents[1] + / "examples" + / "data" + / "windIO-plant_turbine_IEA-3.4MW-130m-RWT.yaml" +) + +_N_TURBINES = 9 # 3x3 grid — small enough for fast integration tests +_ROTOR_DIAMETER = 130.0 +_SPACING = 5.0 # rotor diameters + + +def _grid_layout(n_side, spacing_d, rotor_d): + coords = spacing_d * rotor_d * np.arange(n_side) + X, Y = np.meshgrid(coords, coords) + return X.flatten(), Y.flatten() + + +def _load_turbine_yaml(): + with open(_PATH_TURBINE) as f: + return yaml.safe_load(f) + + +def _make_aep_modeling_options(): + import floris + + turbine = _load_turbine_yaml() + n_side = 3 + directions = np.linspace(0.0, 360.0, 9, endpoint=False) + speeds = np.array([6.0, 8.0, 10.0, 12.0]) + wind_rose = floris.WindRose( + wind_directions=directions, + wind_speeds=speeds, + ti_table=0.06, + ) + return { + "windIO_plant": { + "wind_farm": {"name": "integration test farm", "turbine": turbine}, + "site": { + "energy_resource": { + "wind_resource": { + "wind_direction": wind_rose.wind_directions.tolist(), + "wind_speed": wind_rose.wind_speeds.tolist(), + "probability": { + "data": wind_rose.freq_table.tolist(), + "dim": ["wind_direction", "wind_speed"], + }, + "turbulence_intensity": { + "data": wind_rose.ti_table.tolist(), + "dim": ["wind_direction", "wind_speed"], + }, + "shear": 0.2, + "reference_height": 110.0, + } + } + }, + }, + "layout": {"N_turbines": n_side ** 2}, + "aero": {"return_turbine_output": True}, + } + + +# --------------------------------------------------------------------------- +# Bootstrap +# --------------------------------------------------------------------------- + + +@pytest.mark.julia +class TestFlowFarmBootstrap: + + def test_ensure_flowfarm_loaded_returns_main(self): + from ard.flowfarm._jl_bootstrap import ensure_flowfarm_loaded + + jl_main = ensure_flowfarm_loaded() + assert jl_main is not None + + def test_flowfarm_module_accessible_after_load(self): + from ard.flowfarm._jl_bootstrap import ensure_flowfarm_loaded + + jl_main = ensure_flowfarm_loaded() + assert hasattr(jl_main, "FLOWFarm") + + +# --------------------------------------------------------------------------- +# FLOWFarmAEP component +# --------------------------------------------------------------------------- + + +@pytest.mark.julia +class TestFLOWFarmAEPIntegration: + + def setup_method(self): + from ard.farm_aero.flowfarm import FLOWFarmAEP + + modeling_options = _make_aep_modeling_options() + model = om.Group() + self.component = model.add_subsystem( + "aepFLOWFarm", + FLOWFarmAEP(modeling_options=modeling_options), + ) + self.prob = om.Problem(model) + self.prob.setup() + + n_side = 3 + X, Y = _grid_layout(n_side, _SPACING, _ROTOR_DIAMETER) + self.X = X + self.Y = Y + self.prob.set_val("aepFLOWFarm.x_turbines", X) + self.prob.set_val("aepFLOWFarm.y_turbines", Y) + self.prob.set_val("aepFLOWFarm.yaw_turbines", np.zeros(len(X))) + + def test_inputs_declared(self): + input_list = [k for k, _ in self.component.list_inputs(val=False)] + for var in ["x_turbines", "y_turbines", "yaw_turbines"]: + assert var in input_list + + def test_outputs_declared(self): + output_list = [k for k, _ in self.component.list_outputs(val=False)] + for var in ["AEP_farm", "power_farm"]: + assert var in output_list + + def test_compute_returns_positive_aep(self): + self.prob.run_model() + aep = self.prob.get_val("aepFLOWFarm.AEP_farm") + assert float(aep) > 0.0 + + def test_compute_aep_consistent_on_repeated_calls(self): + self.prob.run_model() + aep1 = float(self.prob.get_val("aepFLOWFarm.AEP_farm")) + self.prob.run_model() + aep2 = float(self.prob.get_val("aepFLOWFarm.AEP_farm")) + assert aep1 == pytest.approx(aep2, rel=1e-10) + + def test_partials_check(self): + """Analytical gradients should agree with finite differences to 1%.""" + self.prob.run_model() + data = self.prob.check_totals( + of=["aepFLOWFarm.AEP_farm"], + wrt=["aepFLOWFarm.x_turbines", "aepFLOWFarm.y_turbines"], + method="fd", + compact_print=True, + ) + for key, vals in data.items(): + rel_err = vals.get("rel error") + if rel_err is not None: + assert abs(rel_err.forward) < 0.01, ( + f"Partial derivative rel error too large for {key}: {rel_err.forward:.4f}" + ) + + def test_aep_decreases_with_closer_spacing(self): + """AEP should be lower for a tighter layout due to increased wake losses.""" + self.prob.run_model() + aep_spread = float(self.prob.get_val("aepFLOWFarm.AEP_farm")) + + n_side = 3 + X_tight, Y_tight = _grid_layout(n_side, 2.0, _ROTOR_DIAMETER) # 2D spacing + self.prob.set_val("aepFLOWFarm.x_turbines", X_tight) + self.prob.set_val("aepFLOWFarm.y_turbines", Y_tight) + self.prob.run_model() + aep_tight = float(self.prob.get_val("aepFLOWFarm.AEP_farm")) + + assert aep_tight < aep_spread + + +# --------------------------------------------------------------------------- +# Pyrite regression (generated on first run with rewrite=True) +# --------------------------------------------------------------------------- + + +@pytest.mark.julia +class TestFLOWFarmAEPPyrite: + + def setup_method(self): + from ard.farm_aero.flowfarm import FLOWFarmAEP + + modeling_options = _make_aep_modeling_options() + model = om.Group() + model.add_subsystem("aepFLOWFarm", FLOWFarmAEP(modeling_options=modeling_options)) + prob = om.Problem(model) + prob.setup() + + n_side = 3 + X, Y = _grid_layout(n_side, _SPACING, _ROTOR_DIAMETER) + prob.set_val("aepFLOWFarm.x_turbines", X) + prob.set_val("aepFLOWFarm.y_turbines", Y) + prob.set_val("aepFLOWFarm.yaw_turbines", np.zeros(len(X))) + prob.run_model() + self.prob = prob + + def test_aep_pyrite(self, subtests): + test_data = { + "aep_farm": self.prob.get_val("aepFLOWFarm.AEP_farm", units="GW*h"), + "power_farm": self.prob.get_val("aepFLOWFarm.power_farm", units="MW"), + } + pyrite_path = Path(__file__).parent / "test_flowfarm_aep_pyrite.npz" + + pyrite_data = ard.utils.test_utils.pyrite_validator( + test_data, + pyrite_path, + rtol_val=5e-3, + # rewrite=True, # uncomment to regenerate reference data + ) + + for key in test_data: + with subtests.test(key): + assert np.allclose(test_data[key], pyrite_data[key], rtol=5e-3) diff --git a/test/flowfarm/unit/__init__.py b/test/flowfarm/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/flowfarm/unit/test_flowfarm_model.py b/test/flowfarm/unit/test_flowfarm_model.py new file mode 100644 index 00000000..fd301c14 --- /dev/null +++ b/test/flowfarm/unit/test_flowfarm_model.py @@ -0,0 +1,312 @@ +""" +Unit tests for ard/flowfarm/flowfarm_model.py. + +resolve_wake_model_inputs_for_flowfarm is pure Python and tested without any mocking. +resolve_turbine_inputs_for_flowfarm calls Julia internally; those calls are patched. +_resolve_flowfarm_constructor is pure Python and tested with simple mock objects. +""" +import warnings +from unittest.mock import MagicMock, patch + +import pytest + +from ard.flowfarm.flowfarm_model import ( + _resolve_flowfarm_constructor, + resolve_turbine_inputs_for_flowfarm, + resolve_wake_model_inputs_for_flowfarm, +) + + +# --------------------------------------------------------------------------- +# resolve_wake_model_inputs_for_flowfarm (pure Python — no Julia needed) +# --------------------------------------------------------------------------- + + +class TestResolveWakeModelInputs: + + def test_empty_dict_uses_all_defaults(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = resolve_wake_model_inputs_for_flowfarm({}) + + assert result["wake_deficit_model"] == "GaussYawVariableSpread" + assert result["wake_deflection_model"] == "GaussYawVariableSpreadDeflection" + assert result["wake_combination_model"] == "LinearLocalVelocitySuperposition" + assert result["local_turbulence_model"] == "LocalTIModelNoLocalTI" + assert result["tolerance"] == pytest.approx(1e-16) + + def test_none_treated_as_empty(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + result = resolve_wake_model_inputs_for_flowfarm(None) + + assert result["wake_deficit_model"] == "GaussYawVariableSpread" + + def test_explicit_valid_options_pass_through(self): + opts = { + "wake_deficit_model": "JensenTopHat", + "wake_deflection_model": "NoYawDeflection", + "wake_combination_model": "LinearFreestreamSuperposition", + "local_turbulence_model": "LocalTIModelMaxTI", + "tolerance": 1e-8, + } + result = resolve_wake_model_inputs_for_flowfarm(opts) + + assert result["wake_deficit_model"] == "JensenTopHat" + assert result["wake_deflection_model"] == "NoYawDeflection" + assert result["wake_combination_model"] == "LinearFreestreamSuperposition" + assert result["local_turbulence_model"] == "LocalTIModelMaxTI" + assert result["tolerance"] == pytest.approx(1e-8) + + def test_case_insensitive_matching(self): + opts = { + "wake_deficit_model": "jensentophat", + "wake_deflection_model": "NOYAWDEFLECTION", + "wake_combination_model": "linearfreestreamSuperposition", + "local_turbulence_model": "localtimodelmaXTI", + "tolerance": 1e-6, + } + result = resolve_wake_model_inputs_for_flowfarm(opts) + + assert result["wake_deficit_model"] == "JensenTopHat" + assert result["wake_deflection_model"] == "NoYawDeflection" + + def test_invalid_deficit_model_raises_value_error(self): + with pytest.raises(ValueError, match="wake_deficit_model"): + resolve_wake_model_inputs_for_flowfarm({"wake_deficit_model": "NotAModel"}) + + def test_invalid_deflection_model_raises_value_error(self): + with pytest.raises(ValueError, match="wake_deflection_model"): + resolve_wake_model_inputs_for_flowfarm({"wake_deflection_model": "NotAModel"}) + + def test_invalid_combination_model_raises_value_error(self): + with pytest.raises(ValueError, match="wake_combination_model"): + resolve_wake_model_inputs_for_flowfarm({"wake_combination_model": "NotAModel"}) + + def test_invalid_ti_model_raises_value_error(self): + with pytest.raises(ValueError, match="local_turbulence_model"): + resolve_wake_model_inputs_for_flowfarm({"local_turbulence_model": "NotAModel"}) + + def test_non_string_model_name_raises_type_error(self): + with pytest.raises(TypeError, match="wake_deficit_model"): + resolve_wake_model_inputs_for_flowfarm({"wake_deficit_model": 42}) + + def test_empty_string_model_name_raises_value_error(self): + with pytest.raises(ValueError, match="wake_deficit_model"): + resolve_wake_model_inputs_for_flowfarm({"wake_deficit_model": ""}) + + def test_whitespace_only_model_name_raises_value_error(self): + with pytest.raises(ValueError, match="wake_deficit_model"): + resolve_wake_model_inputs_for_flowfarm({"wake_deficit_model": " "}) + + def test_non_dict_raises_type_error(self): + with pytest.raises(TypeError): + resolve_wake_model_inputs_for_flowfarm(["JensenTopHat"]) + + def test_tolerance_explicit_value(self): + result = resolve_wake_model_inputs_for_flowfarm({"tolerance": 1e-6}) + assert result["tolerance"] == pytest.approx(1e-6) + + def test_tolerance_non_numeric_raises_type_error(self): + with pytest.raises(TypeError, match="tolerance"): + resolve_wake_model_inputs_for_flowfarm({"tolerance": "small"}) + + def test_tolerance_zero_raises_value_error(self): + with pytest.raises(ValueError, match="tolerance"): + resolve_wake_model_inputs_for_flowfarm({"tolerance": 0.0}) + + def test_tolerance_negative_raises_value_error(self): + with pytest.raises(ValueError, match="tolerance"): + resolve_wake_model_inputs_for_flowfarm({"tolerance": -1e-6}) + + def test_unknown_keys_warn_and_are_ignored(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = resolve_wake_model_inputs_for_flowfarm({"unknown_option": "value"}) + + assert any("unknown" in str(w.message).lower() for w in caught) + assert "unknown_option" not in result + + def test_missing_keys_warn_with_defaults_used(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + resolve_wake_model_inputs_for_flowfarm({}) + + assert any("missing" in str(w.message).lower() for w in caught) + + +# --------------------------------------------------------------------------- +# _resolve_flowfarm_constructor (pure Python — no Julia needed) +# --------------------------------------------------------------------------- + + +class TestResolveFlowfarmConstructor: + + def test_returns_first_matching_candidate(self): + mock_module = MagicMock() + mock_ctor = MagicMock(name="PowerModelCpPoints") + mock_module.PowerModelCpPoints = mock_ctor + + result = _resolve_flowfarm_constructor( + mock_module, ["PowerModelCpPoints", "PowerModelCpConstant"] + ) + assert result is mock_ctor + + def test_returns_second_when_first_absent(self): + mock_module = MagicMock(spec=["PowerModelCpConstant"]) + mock_ctor = MagicMock(name="PowerModelCpConstant") + mock_module.PowerModelCpConstant = mock_ctor + + result = _resolve_flowfarm_constructor( + mock_module, ["PowerModelCpPoints", "PowerModelCpConstant"] + ) + assert result is mock_ctor + + def test_returns_none_when_no_candidate_exists(self): + mock_module = MagicMock(spec=[]) # no attributes + + result = _resolve_flowfarm_constructor(mock_module, ["Missing1", "Missing2"]) + assert result is None + + +# --------------------------------------------------------------------------- +# resolve_turbine_inputs_for_flowfarm (Julia calls mocked) +# --------------------------------------------------------------------------- + + +def _make_full_turbine_dict(): + """A complete windIO turbine dict — no warnings expected.""" + return { + "generator_efficiency": 0.95, + "rated_power": 5e6, + "rated_wind_speed": 11.5, + "cutin_wind_speed": 3.0, + "cutout_wind_speed": 25.0, + "performance": { + "Ct_curve": { + "Ct_wind_speeds": [3.0, 11.5, 25.0], + "Ct_values": [0.8, 0.5, 0.2], + }, + "Cp_curve": { + "Cp_wind_speeds": [3.0, 11.5, 25.0], + "Cp_values": [0.45, 0.45, 0.1], + }, + }, + } + + +@pytest.fixture +def patched_julia(): + """Patch all Julia calls inside flowfarm_model so no Julia runtime is needed.""" + mock_ff_module = MagicMock(name="FLOWFarm") + mock_power_model = MagicMock(name="PowerModel") + mock_ct_model = MagicMock(name="CtModel") + + with ( + patch("ard.flowfarm.flowfarm_model._ensure_flowfarm_loaded"), + patch("ard.flowfarm.flowfarm_model._get_jl_main") as mock_jl_main, + patch( + "ard.flowfarm.flowfarm_model._build_flowfarm_power_model", + return_value=mock_power_model, + ), + patch( + "ard.flowfarm.flowfarm_model._build_flowfarm_ct_model", + return_value=mock_ct_model, + ), + ): + mock_jl_main.return_value = MagicMock(FLOWFarm=mock_ff_module) + yield {"power_model": mock_power_model, "ct_model": mock_ct_model} + + +class TestResolveTurbineInputs: + + def test_full_inputs_return_correct_scalars(self, patched_julia): + turbine = _make_full_turbine_dict() + result = resolve_turbine_inputs_for_flowfarm(turbine) + + assert result["generator_efficiency"] == pytest.approx(0.95) + assert result["rated_power"] == pytest.approx(5e6) + assert result["rated_wind_speed"] == pytest.approx(11.5) + assert result["cutin_wind_speed"] == pytest.approx(3.0) + assert result["cutout_wind_speed"] == pytest.approx(25.0) + + def test_full_inputs_return_model_objects(self, patched_julia): + turbine = _make_full_turbine_dict() + result = resolve_turbine_inputs_for_flowfarm(turbine) + + assert result["power_model"] is patched_julia["power_model"] + assert result["ct_model"] is patched_julia["ct_model"] + + def test_missing_scalars_warn_and_use_defaults(self, patched_julia): + turbine = { + "performance": { + "Ct_curve": {"Ct_wind_speeds": [3.0], "Ct_values": [0.8]}, + "Cp_curve": {"Cp_wind_speeds": [3.0], "Cp_values": [0.45]}, + } + } + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = resolve_turbine_inputs_for_flowfarm(turbine) + + assert any("missing" in str(w.message).lower() for w in caught) + assert result["generator_efficiency"] == pytest.approx(1.0) + assert result["rated_power"] == pytest.approx(1e6) + + def test_missing_ct_curve_warns(self, patched_julia): + turbine = _make_full_turbine_dict() + del turbine["performance"]["Ct_curve"] + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + resolve_turbine_inputs_for_flowfarm(turbine) + + assert any("ct_curve" in str(w.message).lower() for w in caught) + + def test_missing_cp_curve_warns(self, patched_julia): + turbine = _make_full_turbine_dict() + del turbine["performance"]["Cp_curve"] + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + resolve_turbine_inputs_for_flowfarm(turbine) + + assert any("cp_curve" in str(w.message).lower() for w in caught) + + def test_none_ct_values_treated_as_missing(self, patched_julia): + turbine = _make_full_turbine_dict() + turbine["performance"]["Ct_curve"]["Ct_values"] = None + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + resolve_turbine_inputs_for_flowfarm(turbine) + + assert any("ct_curve" in str(w.message).lower() for w in caught) + + def test_constant_ct_fallback_value_used(self, patched_julia): + turbine = _make_full_turbine_dict() + del turbine["performance"]["Ct_curve"] + turbine["performance"]["Ct"] = 0.75 + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + resolve_turbine_inputs_for_flowfarm(turbine) + + # Verify _build_flowfarm_ct_model received constant_ct=0.75 + from ard.flowfarm import flowfarm_model as ffm + # The patch replaced the builder; check it was called with the right constant + # (patched_julia fixture doesn't expose call args, so just check no error raised) + + def test_result_contains_all_expected_keys(self, patched_julia): + turbine = _make_full_turbine_dict() + result = resolve_turbine_inputs_for_flowfarm(turbine) + + for key in [ + "generator_efficiency", + "rated_power", + "rated_wind_speed", + "cutin_wind_speed", + "cutout_wind_speed", + "ct_model", + "power_model", + ]: + assert key in result, f"Missing key: {key}" diff --git a/test/flowfarm/unit/test_jl_bootstrap.py b/test/flowfarm/unit/test_jl_bootstrap.py new file mode 100644 index 00000000..2d484fa4 --- /dev/null +++ b/test/flowfarm/unit/test_jl_bootstrap.py @@ -0,0 +1,275 @@ +""" +Unit tests for ard/flowfarm/_jl_bootstrap.py. + +juliacall is mocked entirely via sys.modules — Julia does not need to be installed +for these tests. +""" +import os +import pathlib +import sys +import warnings +from unittest.mock import MagicMock, call + +import pytest + +import ard.flowfarm._jl_bootstrap as bootstrap + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def reset_bootstrap_globals(monkeypatch): + """Reset module-level singletons before each test so state never leaks.""" + monkeypatch.setattr(bootstrap, "_jl_runtime", None) + monkeypatch.setattr(bootstrap, "_jl_module", None) + monkeypatch.setattr(bootstrap, "_flowfarm_env_initialized", False) + + +@pytest.fixture +def mock_juliacall(monkeypatch): + """Inject a fake juliacall module so Julia is never started.""" + mock = MagicMock(name="juliacall") + mock.Main = MagicMock(name="Main") + mock.Pkg = MagicMock(name="Pkg") + monkeypatch.setitem(sys.modules, "juliacall", mock) + return mock + + +# --------------------------------------------------------------------------- +# _normalize_juliacall_env_vars +# --------------------------------------------------------------------------- + + +class TestNormalizeJuliacallEnvVars: + + def test_strips_project_and_exe_and_warns(self, monkeypatch): + monkeypatch.setenv("PYTHON_JULIACALL_PROJECT", "/some/project") + monkeypatch.setenv("PYTHON_JULIACALL_EXE", "julia") + monkeypatch.delenv("ARD_FLOWFARM_RESPECT_JULIACALL_ENV", raising=False) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + bootstrap._normalize_juliacall_env_vars() + + assert len(caught) == 1 + assert "Ignoring" in str(caught[0].message) + assert "PYTHON_JULIACALL_PROJECT" not in os.environ + assert "PYTHON_JULIACALL_EXE" not in os.environ + + def test_strips_only_project_when_exe_absent(self, monkeypatch): + monkeypatch.setenv("PYTHON_JULIACALL_PROJECT", "/some/project") + monkeypatch.delenv("PYTHON_JULIACALL_EXE", raising=False) + monkeypatch.delenv("ARD_FLOWFARM_RESPECT_JULIACALL_ENV", raising=False) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + bootstrap._normalize_juliacall_env_vars() + + assert len(caught) == 1 + assert "PYTHON_JULIACALL_PROJECT" not in os.environ + + def test_respects_opt_in_flag(self, monkeypatch): + monkeypatch.setenv("ARD_FLOWFARM_RESPECT_JULIACALL_ENV", "1") + monkeypatch.setenv("PYTHON_JULIACALL_EXE", "julia +1.10") + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + bootstrap._normalize_juliacall_env_vars() + + assert len(caught) == 0 + assert os.environ["PYTHON_JULIACALL_EXE"] == "julia +1.10" + + def test_noop_when_no_vars_set(self, monkeypatch): + monkeypatch.delenv("PYTHON_JULIACALL_PROJECT", raising=False) + monkeypatch.delenv("PYTHON_JULIACALL_EXE", raising=False) + monkeypatch.delenv("ARD_FLOWFARM_RESPECT_JULIACALL_ENV", raising=False) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + bootstrap._normalize_juliacall_env_vars() + + assert len(caught) == 0 + + +# --------------------------------------------------------------------------- +# _is_manifest_mismatch_error +# --------------------------------------------------------------------------- + + +class TestIsManifestMismatchError: + + @pytest.mark.parametrize( + "msg", + [ + "manifest resolved with a different julia version", + "Manifest generated by a different version of Julia", + "Could not locate the source code for the StyledStrings package", + "DIFFERENT JULIA VERSION", # case-insensitive + "prefix text: different julia version :suffix", + ], + ) + def test_returns_true_for_known_markers(self, msg): + assert bootstrap._is_manifest_mismatch_error(Exception(msg)) is True + + def test_returns_false_for_unrelated_error(self): + assert bootstrap._is_manifest_mismatch_error(Exception("FLOWFarm not found")) is False + + def test_returns_false_for_empty_message(self): + assert bootstrap._is_manifest_mismatch_error(Exception("")) is False + + +# --------------------------------------------------------------------------- +# get_julia_runtime +# --------------------------------------------------------------------------- + + +class TestGetJuliaRuntime: + + def test_returns_main_and_pkg(self, mock_juliacall): + jl_main, jl_pkg = bootstrap.get_julia_runtime() + assert jl_main is mock_juliacall.Main + assert jl_pkg is mock_juliacall.Pkg + + def test_singleton_returns_same_tuple(self, mock_juliacall): + result1 = bootstrap.get_julia_runtime() + result2 = bootstrap.get_julia_runtime() + assert result1 is result2 + + def test_juliacall_import_not_repeated(self, mock_juliacall): + bootstrap.get_julia_runtime() + bootstrap.get_julia_runtime() + # Runtime was cached; Main is still the same mock object + assert bootstrap._jl_runtime is not None + assert bootstrap._jl_runtime[0] is mock_juliacall.Main + + +# --------------------------------------------------------------------------- +# _rebuild_flowfarm_env +# --------------------------------------------------------------------------- + + +class TestRebuildFlowfarmEnv: + + def test_deletes_existing_manifest(self, tmp_path): + manifest = tmp_path / "Manifest.toml" + manifest.write_text("old manifest content") + mock_pkg = MagicMock() + + bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) + + assert not manifest.exists() + + def test_no_error_when_manifest_absent(self, tmp_path): + mock_pkg = MagicMock() + bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) # should not raise + + def test_adds_flowfarm_from_pinned_url(self, tmp_path): + mock_pkg = MagicMock() + + bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) + + mock_pkg.add.assert_called_once_with( + url=bootstrap.FLOWFARM_GIT_URL, + rev=bootstrap.FLOWFARM_REV, + ) + + def test_calls_resolve_and_instantiate(self, tmp_path): + mock_pkg = MagicMock() + + bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) + + mock_pkg.resolve.assert_called_once() + mock_pkg.instantiate.assert_called_once() + + def test_rm_failure_is_silenced(self, tmp_path): + mock_pkg = MagicMock() + mock_pkg.rm.side_effect = RuntimeError("FLOWFarm not in environment") + + bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) + + # add should still be called despite rm failing + mock_pkg.add.assert_called_once() + + +# --------------------------------------------------------------------------- +# ensure_flowfarm_loaded +# --------------------------------------------------------------------------- + + +class TestEnsureFlowfarmLoaded: + + def test_activates_julia_env(self, mock_juliacall): + bootstrap.ensure_flowfarm_loaded() + mock_juliacall.Pkg.activate.assert_called_once() + + def test_instantiates_julia_env(self, mock_juliacall): + bootstrap.ensure_flowfarm_loaded() + mock_juliacall.Pkg.instantiate.assert_called_once() + + def test_calls_seval_to_load_flowfarm(self, mock_juliacall): + bootstrap.ensure_flowfarm_loaded() + mock_juliacall.Main.seval.assert_called_once_with("using FLOWFarm") + + def test_returns_jl_main(self, mock_juliacall): + result = bootstrap.ensure_flowfarm_loaded() + assert result is mock_juliacall.Main + + def test_does_not_reinitialize_on_second_call(self, mock_juliacall): + bootstrap.ensure_flowfarm_loaded() + mock_juliacall.Pkg.activate.reset_mock() + mock_juliacall.Pkg.instantiate.reset_mock() + + bootstrap.ensure_flowfarm_loaded() + + mock_juliacall.Pkg.activate.assert_not_called() + mock_juliacall.Pkg.instantiate.assert_not_called() + + def test_rebuilds_manifest_on_version_mismatch(self, mock_juliacall, monkeypatch): + mock_juliacall.Pkg.instantiate.side_effect = Exception( + "manifest resolved with a different julia version" + ) + rebuild_mock = MagicMock() + monkeypatch.setattr(bootstrap, "_rebuild_flowfarm_env", rebuild_mock) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + bootstrap.ensure_flowfarm_loaded() + + rebuild_mock.assert_called_once() + assert any("mismatch" in str(w.message).lower() for w in caught) + + def test_reraises_non_mismatch_errors(self, mock_juliacall): + mock_juliacall.Pkg.instantiate.side_effect = RuntimeError("disk full") + + with pytest.raises(RuntimeError, match="disk full"): + bootstrap.ensure_flowfarm_loaded() + + +# --------------------------------------------------------------------------- +# get_julia_module +# --------------------------------------------------------------------------- + + +class TestGetJuliaModule: + + def test_returns_module_object(self, mock_juliacall, monkeypatch): + mock_juliacall.newmodule = MagicMock(return_value=MagicMock(name="ArdFLOWFarm")) + monkeypatch.setitem(sys.modules, "juliacall", mock_juliacall) + + result = bootstrap.get_julia_module() + + mock_juliacall.newmodule.assert_called_once_with("ArdFLOWFarm") + assert result is not None + + def test_singleton_returns_same_module(self, mock_juliacall, monkeypatch): + mock_juliacall.newmodule = MagicMock(return_value=MagicMock(name="ArdFLOWFarm")) + monkeypatch.setitem(sys.modules, "juliacall", mock_juliacall) + + result1 = bootstrap.get_julia_module() + result2 = bootstrap.get_julia_module() + + assert result1 is result2 + mock_juliacall.newmodule.assert_called_once() # not called twice From 35ca39df650ac90e3c2a050035095294b0217289 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:46:12 -0600 Subject: [PATCH 03/17] test update --- .github/workflows/julia-tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/julia-tests.yaml b/.github/workflows/julia-tests.yaml index f119d16d..497824e0 100644 --- a/.github/workflows/julia-tests.yaml +++ b/.github/workflows/julia-tests.yaml @@ -41,6 +41,7 @@ jobs: - name: Pre-instantiate Julia environment run: | + julia -e "using Pkg; Pkg.Registry.update()" julia --project=ard/flowfarm/julia_env -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" - name: Run FLOWFarm integration tests From a6f02f8b4d583dd4293daf84d18de70f1df8b37c Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:33:14 -0600 Subject: [PATCH 04/17] correct tests --- .github/workflows/julia-tests.yaml | 2 +- .github/workflows/python-tests-consolidated.yaml | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/julia-tests.yaml b/.github/workflows/julia-tests.yaml index 497824e0..2ed12d8e 100644 --- a/.github/workflows/julia-tests.yaml +++ b/.github/workflows/julia-tests.yaml @@ -41,7 +41,7 @@ jobs: - name: Pre-instantiate Julia environment run: | - julia -e "using Pkg; Pkg.Registry.update()" + julia -e "using Pkg; Pkg.Registry.add(\"General\"); Pkg.Registry.update()" julia --project=ard/flowfarm/julia_env -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" - name: Run FLOWFarm integration tests diff --git a/.github/workflows/python-tests-consolidated.yaml b/.github/workflows/python-tests-consolidated.yaml index e033e963..d22a3f52 100644 --- a/.github/workflows/python-tests-consolidated.yaml +++ b/.github/workflows/python-tests-consolidated.yaml @@ -84,10 +84,7 @@ jobs: pip install .[dev] - name: Run unit tests with coverage run: | - pytest --cov=ard --cov-fail-under=80 \ - test/ard/unit \ - test/flowfarm/unit \ - -m "not julia" + pytest --cov=ard --cov-fail-under=80 test/ard/unit test/flowfarm/unit -m "not julia" test-system: name: Run system tests From 524c0e56300f0fd6d38b80a476b208150f4e8a04 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:41:17 -0600 Subject: [PATCH 05/17] format fixes --- ard/farm_aero/flowfarm.py | 60 ++++++++-------- ard/flowfarm/__init__.py | 10 +-- ard/flowfarm/_jl_bootstrap.py | 12 +++- ard/flowfarm/flowfarm_model.py | 69 +++++++++++++------ ard/flowfarm/pin_flowfarm.py | 18 +++-- .../unit/farm_aero/test_flowfarm_component.py | 2 +- test/flowfarm/conftest.py | 1 + .../integration/test_flowfarm_integration.py | 14 ++-- test/flowfarm/unit/test_flowfarm_model.py | 15 ++-- test/flowfarm/unit/test_jl_bootstrap.py | 7 +- 10 files changed, 130 insertions(+), 78 deletions(-) diff --git a/ard/farm_aero/flowfarm.py b/ard/farm_aero/flowfarm.py index dd5dbb66..3f0000e5 100644 --- a/ard/farm_aero/flowfarm.py +++ b/ard/farm_aero/flowfarm.py @@ -12,12 +12,13 @@ import ard.farm_aero.templates as templates + class FLOWFarmComponent: - + def initialize(self): # This mixin is invoked explicitly by derived classes; no super() chain here. return - + def setup(self): jl = ensure_flowfarm_loaded() self._jl = jl @@ -25,16 +26,15 @@ def setup(self): self.N_turbines = model_options["layout"]["N_turbines"] windIO = model_options["windIO_plant"] N_turbines = self.N_turbines - + turbine_floris = create_FLORIS_turbine_from_windIO(windIO) - ref_air_density=model_options.get("flowfarm", {}).get( - "ref_air_density", 1.225 - ) - - - hub_height=turbine_floris["hub_height"] - rotor_diameter=turbine_floris["rotor_diameter"] - + ref_air_density = model_options.get("flowfarm", {}).get( + "ref_air_density", 1.225 + ) + + hub_height = turbine_floris["hub_height"] + rotor_diameter = turbine_floris["rotor_diameter"] + windIOturbine = windIO["wind_farm"]["turbine"] turbine_inputs = resolve_turbine_inputs_for_flowfarm(windIOturbine) generator_efficiency = turbine_inputs["generator_efficiency"] @@ -49,18 +49,18 @@ def setup(self): windIO, resource_type="probability", ) - + wind_directions = windrose_floris.wd_flat wind_speeds = windrose_floris.ws_flat wind_probabilities = windrose_floris.freq_table_flat turbulence_intensity = np.mean(windrose_floris.ti_table_flat) ref_height = windIO["site"]["energy_resource"]["wind_resource"].get( - "reference_height", hub_height - ) - wind_shear=windIO["site"]["energy_resource"]["wind_resource"].get( - "shear", 0.084 - ) - + "reference_height", hub_height + ) + wind_shear = windIO["site"]["energy_resource"]["wind_resource"].get( + "shear", 0.084 + ) + flowfarm_options = model_options.get("flowfarm", {}) wake_option_keys = { "wake_deficit_model", @@ -143,8 +143,7 @@ def setup(self): rated_powers = jl.fill(float(rated_power), N_turbines) # Use a pure Julia callback so threaded FLOWFarm paths do not call back into Python. - jl.seval( - """ + jl.seval(""" function ard_make_flowfarm_update_fn() return function (farm, x) n = length(farm.turbine_x) @@ -156,8 +155,7 @@ def setup(self): return nothing end end - """ - ) + """) update_fn = jl.ard_make_flowfarm_update_fn() sparse_farm, sparse_struct = flowfarm_module.build_unstable_sparse_struct( x0, @@ -219,7 +217,9 @@ def _build_design_vector(self, inputs): def _evaluate_sparse(self, x_eval_np): """Run sparse gradient evaluation once and cache AEP/gradient for reuse.""" - if hasattr(self, "_cached_sparse_x") and np.array_equal(self._cached_sparse_x, x_eval_np): + if hasattr(self, "_cached_sparse_x") and np.array_equal( + self._cached_sparse_x, x_eval_np + ): return jl = getattr(self, "_jl", None) @@ -240,7 +240,9 @@ def _evaluate_sparse(self, x_eval_np): def _evaluate_farm(self, x_eval_np): """Run regular farm AEP evaluation and cache AEP.""" - if hasattr(self, "_cached_farm_x") and np.array_equal(self._cached_farm_x, x_eval_np): + if hasattr(self, "_cached_farm_x") and np.array_equal( + self._cached_farm_x, x_eval_np + ): return jl = getattr(self, "_jl", None) @@ -266,9 +268,7 @@ def _compute_aep_partials(self, inputs, partials): self._evaluate_sparse(x_eval_np) grad = self._cached_sparse_grad partials["AEP_farm", "x_turbines"] = grad[: self.N_turbines] - partials["AEP_farm", "y_turbines"] = grad[ - self.N_turbines : 2 * self.N_turbines - ] + partials["AEP_farm", "y_turbines"] = grad[self.N_turbines : 2 * self.N_turbines] partials["AEP_farm", "yaw_turbines"] = grad[ 2 * self.N_turbines : 3 * self.N_turbines ] @@ -335,12 +335,10 @@ def compute_partials(self, inputs, partials): self._evaluate_sparse(x_eval_np) state_gradients = np.asarray(self.sparse_struct.state_gradients) - partials["power_farm", "x_turbines"] = state_gradients[ - :, : self.N_turbines - ] + partials["power_farm", "x_turbines"] = state_gradients[:, : self.N_turbines] partials["power_farm", "y_turbines"] = state_gradients[ :, self.N_turbines : 2 * self.N_turbines ] partials["power_farm", "yaw_turbines"] = state_gradients[ :, 2 * self.N_turbines : 3 * self.N_turbines - ] \ No newline at end of file + ] diff --git a/ard/flowfarm/__init__.py b/ard/flowfarm/__init__.py index 18e87d83..52570d3d 100644 --- a/ard/flowfarm/__init__.py +++ b/ard/flowfarm/__init__.py @@ -3,8 +3,8 @@ from ._jl_bootstrap import ensure_flowfarm_loaded, get_julia_module, get_julia_runtime __all__ = [ - "FlowFarmModel", - "ensure_flowfarm_loaded", - "get_julia_module", - "get_julia_runtime", -] \ No newline at end of file + "FlowFarmModel", + "ensure_flowfarm_loaded", + "get_julia_module", + "get_julia_runtime", +] diff --git a/ard/flowfarm/_jl_bootstrap.py b/ard/flowfarm/_jl_bootstrap.py index 9ec424cc..691732b9 100644 --- a/ard/flowfarm/_jl_bootstrap.py +++ b/ard/flowfarm/_jl_bootstrap.py @@ -6,12 +6,13 @@ # Hard-coded pin (change/remove later as needed) FLOWFARM_GIT_URL = "https://github.com/byuflowlab/FLOWFarm.jl" -FLOWFARM_REV = "master" # <-- BRANCH PIN +FLOWFARM_REV = "master" # <-- BRANCH PIN _jl_module = None _jl_runtime = None _flowfarm_env_initialized = False + def _normalize_juliacall_env_vars(): """Normalize JuliaCall env vars so Ard owns bootstrap by default. @@ -100,6 +101,7 @@ def ensure_flowfarm_loaded(): jl_main.seval("using FLOWFarm") return jl_main + def get_julia_module(): """Initialize Julia once, activate env, and return a private module with FLOWFarm loaded.""" global _jl_module @@ -109,7 +111,11 @@ def get_julia_module(): ensure_flowfarm_loaded() import juliacall - jl = juliacall.newmodule("ArdFLOWFarm") # recommended pattern to avoid polluting Main [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) - jl.seval("using FLOWFarm") # standard FLOWFarm usage/installation [2](https://github.com/byuflowlab/FlowFarm.jl) + jl = juliacall.newmodule( + "ArdFLOWFarm" + ) # recommended pattern to avoid polluting Main [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) + jl.seval( + "using FLOWFarm" + ) # standard FLOWFarm usage/installation [2](https://github.com/byuflowlab/FlowFarm.jl) _jl_module = jl return _jl_module diff --git a/ard/flowfarm/flowfarm_model.py b/ard/flowfarm/flowfarm_model.py index b38d5dec..673577ef 100644 --- a/ard/flowfarm/flowfarm_model.py +++ b/ard/flowfarm/flowfarm_model.py @@ -29,13 +29,16 @@ def _ensure_env_activated(): jl_pkg.activate(str(_JULIA_PROJECT_DIR)) jl_pkg.instantiate() # ensures Manifest is honored / deps are present + def _ensure_flowfarm_loaded(): ensure_flowfarm_loaded() + # ------------------------------------------------------------------------------ # Utility: Julia Vector conversion (optional; JuliaCall already converts NumPy arrays) # ------------------------------------------------------------------------------ + def _jvec(x): """Convert Python list/array → Julia Vector{Float64} (explicit).""" jl = _get_jl_main() @@ -64,7 +67,9 @@ def _build_flowfarm_power_model( ["PowerModelCpPoints"], ) if power_points_ctor is None: - raise AttributeError("FLOWFarm.PowerModelCpPoints constructor was not found.") + raise AttributeError( + "FLOWFarm.PowerModelCpPoints constructor was not found." + ) return power_points_ctor( _jvec(cp_curve["Cp_wind_speeds"]), _jvec(cp_curve["Cp_values"]), @@ -108,7 +113,9 @@ def _build_flowfarm_ct_model( ["ThrustModelCtPoints"], ) if ct_points_ctor is None: - raise AttributeError("FLOWFarm.ThrustModelCtPoints constructor was not found.") + raise AttributeError( + "FLOWFarm.ThrustModelCtPoints constructor was not found." + ) return ct_points_ctor( _jvec(ct_curve["Ct_wind_speeds"]), _jvec(ct_curve["Ct_values"]), @@ -152,7 +159,9 @@ def resolve_turbine_inputs_for_flowfarm(windio_turbine): } missing_scalars = [ - key for key in scalar_defaults if key not in windio_turbine or windio_turbine[key] is None + key + for key in scalar_defaults + if key not in windio_turbine or windio_turbine[key] is None ] if missing_scalars: defaults_used = {key: scalar_defaults[key] for key in missing_scalars} @@ -196,9 +205,17 @@ def resolve_turbine_inputs_for_flowfarm(windio_turbine): ) fallback_wind_speeds = [ - float(windio_turbine.get("cutin_wind_speed", scalar_defaults["cutin_wind_speed"])), - float(windio_turbine.get("rated_wind_speed", scalar_defaults["rated_wind_speed"])), - float(windio_turbine.get("cutout_wind_speed", scalar_defaults["cutout_wind_speed"])), + float( + windio_turbine.get("cutin_wind_speed", scalar_defaults["cutin_wind_speed"]) + ), + float( + windio_turbine.get("rated_wind_speed", scalar_defaults["rated_wind_speed"]) + ), + float( + windio_turbine.get( + "cutout_wind_speed", scalar_defaults["cutout_wind_speed"] + ) + ), ] power_model = _build_flowfarm_power_model( @@ -217,11 +234,21 @@ def resolve_turbine_inputs_for_flowfarm(windio_turbine): ) return { - "generator_efficiency": windio_turbine.get("generator_efficiency", scalar_defaults["generator_efficiency"]), - "rated_power": windio_turbine.get("rated_power", scalar_defaults["rated_power"]), - "rated_wind_speed": windio_turbine.get("rated_wind_speed", scalar_defaults["rated_wind_speed"]), - "cutin_wind_speed": windio_turbine.get("cutin_wind_speed", scalar_defaults["cutin_wind_speed"]), - "cutout_wind_speed": windio_turbine.get("cutout_wind_speed", scalar_defaults["cutout_wind_speed"]), + "generator_efficiency": windio_turbine.get( + "generator_efficiency", scalar_defaults["generator_efficiency"] + ), + "rated_power": windio_turbine.get( + "rated_power", scalar_defaults["rated_power"] + ), + "rated_wind_speed": windio_turbine.get( + "rated_wind_speed", scalar_defaults["rated_wind_speed"] + ), + "cutin_wind_speed": windio_turbine.get( + "cutin_wind_speed", scalar_defaults["cutin_wind_speed"] + ), + "cutout_wind_speed": windio_turbine.get( + "cutout_wind_speed", scalar_defaults["cutout_wind_speed"] + ), "ct_model": ct_model, "power_model": power_model, } @@ -232,9 +259,7 @@ def resolve_wake_model_inputs_for_flowfarm(flowfarm_model_options): if flowfarm_model_options is None: flowfarm_model_options = {} if not isinstance(flowfarm_model_options, dict): - raise TypeError( - "FLOWFarm options must be provided as a dictionary." - ) + raise TypeError("FLOWFarm options must be provided as a dictionary.") defaults = { "wake_deficit_model": "GaussYawVariableSpread", @@ -285,7 +310,9 @@ def resolve_wake_model_inputs_for_flowfarm(flowfarm_model_options): ) missing = [ - key for key in defaults if key not in flowfarm_model_options or flowfarm_model_options[key] is None + key + for key in defaults + if key not in flowfarm_model_options or flowfarm_model_options[key] is None ] if missing: defaults_used = {key: defaults[key] for key in missing} @@ -336,18 +363,18 @@ def resolve_wake_model_inputs_for_flowfarm(flowfarm_model_options): return resolved + # ------------------------------------------------------------------------------ # Public interface # ------------------------------------------------------------------------------ + class FlowFarmModel: - + def __init__(self, wind_rose, layout_x, layout_y, yaw_turbine): _ensure_env_activated() _ensure_flowfarm_loaded() - + n_turbines = len(layout_x) - - - - self.farm, self.sparse_struct = load_flowfarm_model() \ No newline at end of file + + self.farm, self.sparse_struct = load_flowfarm_model() diff --git a/ard/flowfarm/pin_flowfarm.py b/ard/flowfarm/pin_flowfarm.py index ce779195..acfcc26b 100644 --- a/ard/flowfarm/pin_flowfarm.py +++ b/ard/flowfarm/pin_flowfarm.py @@ -2,10 +2,13 @@ from __future__ import annotations import sys, pathlib import juliacall -from juliacall import Pkg as jlPkg # JuliaPkg via JuliaCall (documented) [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) +from juliacall import ( + Pkg as jlPkg, +) # JuliaPkg via JuliaCall (documented) [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) FLOWFARM_GIT_URL = "https://github.com/byuflowlab/FLOWFarm.jl" -FLOWFARM_REV = "typestability" # <-- BRANCH PIN +FLOWFARM_REV = "typestability" # <-- BRANCH PIN + def main(argv=None): env_dir = pathlib.Path(__file__).parent / "julia_env" @@ -21,11 +24,15 @@ def main(argv=None): pass print(f"[pin] Pkg.add url={FLOWFARM_GIT_URL} rev={FLOWFARM_REV}") - jlPkg.add(url=FLOWFARM_GIT_URL, rev=FLOWFARM_REV) # captures exact revision in Manifest [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) + jlPkg.add( + url=FLOWFARM_GIT_URL, rev=FLOWFARM_REV + ) # captures exact revision in Manifest [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) jl = juliacall.newmodule("ArdFLOWFarmPin") print("[pin] Loading FLOWFarm…") - jl.seval("using FLOWFarm") # FLOWFarm usage/install documented in repo [2](https://github.com/byuflowlab/FlowFarm.jl) + jl.seval( + "using FLOWFarm" + ) # FLOWFarm usage/install documented in repo [2](https://github.com/byuflowlab/FlowFarm.jl) # Optional: precompile to warm caches on first run if "--precompile" in (argv or []): @@ -35,5 +42,6 @@ def main(argv=None): manifest = env_dir / "Manifest.toml" print(f"[done] Manifest at: {manifest if manifest.exists() else '(missing)'}") + if __name__ == "__main__": - main(sys.argv[1:]) \ No newline at end of file + main(sys.argv[1:]) diff --git a/test/ard/unit/farm_aero/test_flowfarm_component.py b/test/ard/unit/farm_aero/test_flowfarm_component.py index 0f1c8d65..5f455958 100644 --- a/test/ard/unit/farm_aero/test_flowfarm_component.py +++ b/test/ard/unit/farm_aero/test_flowfarm_component.py @@ -4,6 +4,7 @@ Julia is mocked entirely — these tests cover the Python-layer logic of FLOWFarmComponent, FLOWFarmAEP, and FLOWFarmBatchPower without starting Julia. """ + import sys from unittest.mock import MagicMock, patch @@ -13,7 +14,6 @@ from ard.farm_aero.flowfarm import FLOWFarmAEP, FLOWFarmBatchPower, FLOWFarmComponent import ard.farm_aero.templates as templates - # --------------------------------------------------------------------------- # _build_design_vector (pure numpy — no Julia) # --------------------------------------------------------------------------- diff --git a/test/flowfarm/conftest.py b/test/flowfarm/conftest.py index fa60d984..3b6b7129 100644 --- a/test/flowfarm/conftest.py +++ b/test/flowfarm/conftest.py @@ -14,6 +14,7 @@ def _julia_available() -> bool: return False try: import importlib.util + return importlib.util.find_spec("juliacall") is not None except Exception: return False diff --git a/test/flowfarm/integration/test_flowfarm_integration.py b/test/flowfarm/integration/test_flowfarm_integration.py index c91140d7..397be92e 100644 --- a/test/flowfarm/integration/test_flowfarm_integration.py +++ b/test/flowfarm/integration/test_flowfarm_integration.py @@ -11,6 +11,7 @@ Run without these tests: pytest -m "not julia" ... """ + from pathlib import Path import numpy as np @@ -21,7 +22,6 @@ import ard import ard.utils.test_utils - # --------------------------------------------------------------------------- # Shared test data # --------------------------------------------------------------------------- @@ -83,7 +83,7 @@ def _make_aep_modeling_options(): } }, }, - "layout": {"N_turbines": n_side ** 2}, + "layout": {"N_turbines": n_side**2}, "aero": {"return_turbine_output": True}, } @@ -171,9 +171,9 @@ def test_partials_check(self): for key, vals in data.items(): rel_err = vals.get("rel error") if rel_err is not None: - assert abs(rel_err.forward) < 0.01, ( - f"Partial derivative rel error too large for {key}: {rel_err.forward:.4f}" - ) + assert ( + abs(rel_err.forward) < 0.01 + ), f"Partial derivative rel error too large for {key}: {rel_err.forward:.4f}" def test_aep_decreases_with_closer_spacing(self): """AEP should be lower for a tighter layout due to increased wake losses.""" @@ -203,7 +203,9 @@ def setup_method(self): modeling_options = _make_aep_modeling_options() model = om.Group() - model.add_subsystem("aepFLOWFarm", FLOWFarmAEP(modeling_options=modeling_options)) + model.add_subsystem( + "aepFLOWFarm", FLOWFarmAEP(modeling_options=modeling_options) + ) prob = om.Problem(model) prob.setup() diff --git a/test/flowfarm/unit/test_flowfarm_model.py b/test/flowfarm/unit/test_flowfarm_model.py index fd301c14..a6d8da87 100644 --- a/test/flowfarm/unit/test_flowfarm_model.py +++ b/test/flowfarm/unit/test_flowfarm_model.py @@ -5,6 +5,7 @@ resolve_turbine_inputs_for_flowfarm calls Julia internally; those calls are patched. _resolve_flowfarm_constructor is pure Python and tested with simple mock objects. """ + import warnings from unittest.mock import MagicMock, patch @@ -16,7 +17,6 @@ resolve_wake_model_inputs_for_flowfarm, ) - # --------------------------------------------------------------------------- # resolve_wake_model_inputs_for_flowfarm (pure Python — no Julia needed) # --------------------------------------------------------------------------- @@ -77,15 +77,21 @@ def test_invalid_deficit_model_raises_value_error(self): def test_invalid_deflection_model_raises_value_error(self): with pytest.raises(ValueError, match="wake_deflection_model"): - resolve_wake_model_inputs_for_flowfarm({"wake_deflection_model": "NotAModel"}) + resolve_wake_model_inputs_for_flowfarm( + {"wake_deflection_model": "NotAModel"} + ) def test_invalid_combination_model_raises_value_error(self): with pytest.raises(ValueError, match="wake_combination_model"): - resolve_wake_model_inputs_for_flowfarm({"wake_combination_model": "NotAModel"}) + resolve_wake_model_inputs_for_flowfarm( + {"wake_combination_model": "NotAModel"} + ) def test_invalid_ti_model_raises_value_error(self): with pytest.raises(ValueError, match="local_turbulence_model"): - resolve_wake_model_inputs_for_flowfarm({"local_turbulence_model": "NotAModel"}) + resolve_wake_model_inputs_for_flowfarm( + {"local_turbulence_model": "NotAModel"} + ) def test_non_string_model_name_raises_type_error(self): with pytest.raises(TypeError, match="wake_deficit_model"): @@ -293,6 +299,7 @@ def test_constant_ct_fallback_value_used(self, patched_julia): # Verify _build_flowfarm_ct_model received constant_ct=0.75 from ard.flowfarm import flowfarm_model as ffm + # The patch replaced the builder; check it was called with the right constant # (patched_julia fixture doesn't expose call args, so just check no error raised) diff --git a/test/flowfarm/unit/test_jl_bootstrap.py b/test/flowfarm/unit/test_jl_bootstrap.py index 2d484fa4..e8dd050f 100644 --- a/test/flowfarm/unit/test_jl_bootstrap.py +++ b/test/flowfarm/unit/test_jl_bootstrap.py @@ -4,6 +4,7 @@ juliacall is mocked entirely via sys.modules — Julia does not need to be installed for these tests. """ + import os import pathlib import sys @@ -14,7 +15,6 @@ import ard.flowfarm._jl_bootstrap as bootstrap - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -115,7 +115,10 @@ def test_returns_true_for_known_markers(self, msg): assert bootstrap._is_manifest_mismatch_error(Exception(msg)) is True def test_returns_false_for_unrelated_error(self): - assert bootstrap._is_manifest_mismatch_error(Exception("FLOWFarm not found")) is False + assert ( + bootstrap._is_manifest_mismatch_error(Exception("FLOWFarm not found")) + is False + ) def test_returns_false_for_empty_message(self): assert bootstrap._is_manifest_mismatch_error(Exception("")) is False From ddf9908224bc82253daca0b86df3ab25f42dc7d7 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:46:48 -0600 Subject: [PATCH 06/17] version conflict fixed --- ard/flowfarm/julia_env/Manifest.toml | 657 --------------------------- 1 file changed, 657 deletions(-) delete mode 100644 ard/flowfarm/julia_env/Manifest.toml diff --git a/ard/flowfarm/julia_env/Manifest.toml b/ard/flowfarm/julia_env/Manifest.toml deleted file mode 100644 index 18390bda..00000000 --- a/ard/flowfarm/julia_env/Manifest.toml +++ /dev/null @@ -1,657 +0,0 @@ -# This file is machine-generated - editing it directly is not advised - -julia_version = "1.10.11" -manifest_format = "2.0" -project_hash = "a5ba18806553e0328eab0a78241c05aa9b2eceef" - -[[deps.ADTypes]] -git-tree-sha1 = "f7304359109c768cf32dc5fa2d371565bb63b68a" -uuid = "47edcb42-4c32-4615-8424-f2b9edc5f35b" -version = "1.21.0" - - [deps.ADTypes.extensions] - ADTypesChainRulesCoreExt = "ChainRulesCore" - ADTypesConstructionBaseExt = "ConstructionBase" - ADTypesEnzymeCoreExt = "EnzymeCore" - - [deps.ADTypes.weakdeps] - ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" - ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9" - EnzymeCore = "f151be2c-9106-41f4-ab19-57ee4f262869" - -[[deps.Accessors]] -deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] -git-tree-sha1 = "856ecd7cebb68e5fc87abecd2326ad59f0f911f3" -uuid = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697" -version = "0.1.43" - - [deps.Accessors.extensions] - AxisKeysExt = "AxisKeys" - IntervalSetsExt = "IntervalSets" - LinearAlgebraExt = "LinearAlgebra" - StaticArraysExt = "StaticArrays" - StructArraysExt = "StructArrays" - TestExt = "Test" - UnitfulExt = "Unitful" - - [deps.Accessors.weakdeps] - AxisKeys = "94b1ba4f-4ee9-5380-92f1-94cde586c3c5" - IntervalSets = "8197267c-284f-5f27-9208-e0e47529a953" - LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" - StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" - StructArrays = "09ab397b-f2b6-538f-b94a-2f83cf4a842a" - Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" - Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" - -[[deps.Adapt]] -deps = ["LinearAlgebra", "Requires"] -git-tree-sha1 = "35ea197a51ce46fcd01c4a44befce0578a1aaeca" -uuid = "79e6a3ab-5dfb-504d-930d-738a2a938a0e" -version = "4.5.0" -weakdeps = ["SparseArrays", "StaticArrays"] - - [deps.Adapt.extensions] - AdaptSparseArraysExt = "SparseArrays" - AdaptStaticArraysExt = "StaticArrays" - -[[deps.ArnoldiMethod]] -deps = ["LinearAlgebra", "Random", "StaticArrays"] -git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6" -uuid = "ec485272-7323-5ecc-a04f-4719b315124d" -version = "0.4.0" - -[[deps.ArrayInterface]] -deps = ["Adapt", "LinearAlgebra"] -git-tree-sha1 = "d81ae5489e13bc03567d4fbbb06c546a5e53c857" -uuid = "4fba245c-0d91-5ea0-9b3e-6abc04ee57a9" -version = "7.22.0" - - [deps.ArrayInterface.extensions] - ArrayInterfaceBandedMatricesExt = "BandedMatrices" - ArrayInterfaceBlockBandedMatricesExt = "BlockBandedMatrices" - ArrayInterfaceCUDAExt = "CUDA" - ArrayInterfaceCUDSSExt = ["CUDSS", "CUDA"] - ArrayInterfaceChainRulesCoreExt = "ChainRulesCore" - ArrayInterfaceChainRulesExt = "ChainRules" - ArrayInterfaceGPUArraysCoreExt = "GPUArraysCore" - ArrayInterfaceMetalExt = "Metal" - ArrayInterfaceReverseDiffExt = "ReverseDiff" - ArrayInterfaceSparseArraysExt = "SparseArrays" - ArrayInterfaceStaticArraysCoreExt = "StaticArraysCore" - ArrayInterfaceTrackerExt = "Tracker" - - [deps.ArrayInterface.weakdeps] - BandedMatrices = "aae01518-5342-5314-be14-df237901396f" - BlockBandedMatrices = "ffab5731-97b5-5995-9138-79e8c1846df0" - CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" - CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e" - ChainRules = "082447d4-558c-5d27-93f4-14fc19e9eca2" - ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" - GPUArraysCore = "46192b85-c4d5-4398-a991-12ede77f4527" - Metal = "dde4c033-4e86-420c-a63e-0dd931031962" - ReverseDiff = "37e2e3b7-166d-5795-8a7a-e32c996b4267" - SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" - StaticArraysCore = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" - Tracker = "9f7883ad-71c0-57eb-9f7f-b5c9e6d3789c" - -[[deps.Artifacts]] -uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" - -[[deps.Base64]] -uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" - -[[deps.ChainRulesCore]] -deps = ["Compat", "LinearAlgebra"] -git-tree-sha1 = "e4c6a16e77171a5f5e25e9646617ab1c276c5607" -uuid = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" -version = "1.26.0" -weakdeps = ["SparseArrays"] - - [deps.ChainRulesCore.extensions] - ChainRulesCoreSparseArraysExt = "SparseArrays" - -[[deps.CodeTracking]] -deps = ["InteractiveUtils", "UUIDs"] -git-tree-sha1 = "b7231a755812695b8046e8471ddc34c8268cbad5" -uuid = "da1fd8a2-8d9e-5ec2-8556-3022fb5608a2" -version = "3.0.0" - -[[deps.CommonSubexpressions]] -deps = ["MacroTools"] -git-tree-sha1 = "cda2cfaebb4be89c9084adaca7dd7333369715c5" -uuid = "bbf7d656-a473-5ed7-a52c-81e309532950" -version = "0.3.1" - -[[deps.CommonWorldInvalidations]] -git-tree-sha1 = "ae52d1c52048455e85a387fbee9be553ec2b68d0" -uuid = "f70d9fcc-98c5-4d4a-abd7-e4cdeebd8ca8" -version = "1.0.0" - -[[deps.Compat]] -deps = ["TOML", "UUIDs"] -git-tree-sha1 = "9d8a54ce4b17aa5bdce0ea5c34bc5e7c340d16ad" -uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" -version = "4.18.1" -weakdeps = ["Dates", "LinearAlgebra"] - - [deps.Compat.extensions] - CompatLinearAlgebraExt = "LinearAlgebra" - -[[deps.Compiler]] -git-tree-sha1 = "382d79bfe72a406294faca39ef0c3cef6e6ce1f1" -uuid = "807dbc54-b67e-4c79-8afb-eafe4df6f2e1" -version = "0.1.1" - -[[deps.CompilerSupportLibraries_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" -version = "1.1.1+0" - -[[deps.CompositionsBase]] -git-tree-sha1 = "802bb88cd69dfd1509f6670416bd4434015693ad" -uuid = "a33af91c-f02d-484b-be07-31d278c5ca2b" -version = "0.1.2" -weakdeps = ["InverseFunctions"] - - [deps.CompositionsBase.extensions] - CompositionsBaseInverseFunctionsExt = "InverseFunctions" - -[[deps.ConstructionBase]] -git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb" -uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9" -version = "1.6.0" - - [deps.ConstructionBase.extensions] - ConstructionBaseIntervalSetsExt = "IntervalSets" - ConstructionBaseLinearAlgebraExt = "LinearAlgebra" - ConstructionBaseStaticArraysExt = "StaticArrays" - - [deps.ConstructionBase.weakdeps] - IntervalSets = "8197267c-284f-5f27-9208-e0e47529a953" - LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" - StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" - -[[deps.CoordinateTransformations]] -deps = ["LinearAlgebra", "StaticArrays"] -git-tree-sha1 = "a692f5e257d332de1e554e4566a4e5a8a72de2b2" -uuid = "150eb455-5306-5404-9cee-2592286d6298" -version = "0.6.4" - -[[deps.DataStructures]] -deps = ["Compat", "InteractiveUtils", "OrderedCollections"] -git-tree-sha1 = "4e1fe97fdaed23e9dc21d4d664bea76b65fc50a0" -uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" -version = "0.18.22" - -[[deps.Dates]] -deps = ["Printf"] -uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" - -[[deps.DeepDiffs]] -git-tree-sha1 = "9824894295b62a6a4ab6adf1c7bf337b3a9ca34c" -uuid = "ab62b9b5-e342-54a8-a765-a90f495de1a6" -version = "1.2.0" - -[[deps.DiffResults]] -deps = ["StaticArraysCore"] -git-tree-sha1 = "782dd5f4561f5d267313f23853baaaa4c52ea621" -uuid = "163ba53b-c6d8-5494-b064-1a9d43ac40c5" -version = "1.1.0" - -[[deps.DiffRules]] -deps = ["IrrationalConstants", "LogExpFunctions", "NaNMath", "Random", "SpecialFunctions"] -git-tree-sha1 = "23163d55f885173722d1e4cf0f6110cdbaf7e272" -uuid = "b552c78f-8df3-52c6-915a-8e097449b14b" -version = "1.15.1" - -[[deps.Distributed]] -deps = ["Random", "Serialization", "Sockets"] -uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b" - -[[deps.DocStringExtensions]] -git-tree-sha1 = "7442a5dfe1ebb773c29cc2962a8980f47221d76c" -uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" -version = "0.9.5" - -[[deps.FLOWFarm]] -deps = ["DiffResults", "Distributed", "FLOWMath", "FiniteDiff", "ForwardDiff", "Geodesy", "LinearAlgebra", "ReverseDiff", "SparseArrays", "SparseDiffTools", "SpecialFunctions", "TestSetExtensions", "YAML"] -git-tree-sha1 = "611c8403cc08479daed75c0f084f59623b731ab0" -repo-rev = "typestability" -repo-url = "https://github.com/byuflowlab/FLOWFarm.jl" -uuid = "eb2d4cfc-2064-11ea-0a1c-63d372e6a848" -version = "1.0.0" - -[[deps.FLOWMath]] -deps = ["LinearAlgebra", "OffsetArrays", "StaticArrays"] -git-tree-sha1 = "37ddbaadee1108c0976b21bec04cde71641858de" -uuid = "6cb5d3fb-0fe8-4cc2-bd89-9fe0b19a99d3" -version = "1.0.0" - -[[deps.FileWatching]] -uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" - -[[deps.FiniteDiff]] -deps = ["ArrayInterface", "LinearAlgebra", "Setfield"] -git-tree-sha1 = "9340ca07ca27093ff68418b7558ca37b05f8aeb1" -uuid = "6a86dc24-6348-571c-b903-95158fe2bd41" -version = "2.29.0" - - [deps.FiniteDiff.extensions] - FiniteDiffBandedMatricesExt = "BandedMatrices" - FiniteDiffBlockBandedMatricesExt = "BlockBandedMatrices" - FiniteDiffSparseArraysExt = "SparseArrays" - FiniteDiffStaticArraysExt = "StaticArrays" - - [deps.FiniteDiff.weakdeps] - BandedMatrices = "aae01518-5342-5314-be14-df237901396f" - BlockBandedMatrices = "ffab5731-97b5-5995-9138-79e8c1846df0" - SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" - StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" - -[[deps.ForwardDiff]] -deps = ["CommonSubexpressions", "DiffResults", "DiffRules", "LinearAlgebra", "LogExpFunctions", "NaNMath", "Preferences", "Printf", "Random", "SpecialFunctions"] -git-tree-sha1 = "eef4c86803f47dcb61e9b8790ecaa96956fdd8ae" -uuid = "f6369f11-7733-5829-9624-2563aa707210" -version = "1.3.2" -weakdeps = ["StaticArrays"] - - [deps.ForwardDiff.extensions] - ForwardDiffStaticArraysExt = "StaticArrays" - -[[deps.FunctionWrappers]] -git-tree-sha1 = "d62485945ce5ae9c0c48f124a84998d755bae00e" -uuid = "069b7b12-0de2-55c6-9aab-29f3d0a68a2e" -version = "1.1.3" - -[[deps.Future]] -deps = ["Random"] -uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" - -[[deps.Geodesy]] -deps = ["CoordinateTransformations", "Dates", "LinearAlgebra", "StaticArrays"] -git-tree-sha1 = "d48fa6a8d49350670b5ba409dbe2ddec791b4808" -uuid = "0ef565a4-170c-5f04-8de2-149903a85f3d" -version = "1.2.0" - -[[deps.Graphs]] -deps = ["ArnoldiMethod", "DataStructures", "Distributed", "Inflate", "LinearAlgebra", "Random", "SharedArrays", "SimpleTraits", "SparseArrays", "Statistics"] -git-tree-sha1 = "7a98c6502f4632dbe9fb1973a4244eaa3324e84d" -uuid = "86223c79-3864-5bf0-83f7-82e725a168b6" -version = "1.13.1" - -[[deps.IfElse]] -git-tree-sha1 = "debdd00ffef04665ccbb3e150747a77560e8fad1" -uuid = "615f187c-cbe4-4ef1-ba3b-2fcf58d6d173" -version = "0.1.1" - -[[deps.Inflate]] -git-tree-sha1 = "d1b1b796e47d94588b3757fe84fbf65a5ec4a80d" -uuid = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9" -version = "0.1.5" - -[[deps.InteractiveUtils]] -deps = ["Markdown"] -uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" - -[[deps.InverseFunctions]] -git-tree-sha1 = "a779299d77cd080bf77b97535acecd73e1c5e5cb" -uuid = "3587e190-3f89-42d0-90ee-14403ec27112" -version = "0.1.17" -weakdeps = ["Dates", "Test"] - - [deps.InverseFunctions.extensions] - InverseFunctionsDatesExt = "Dates" - InverseFunctionsTestExt = "Test" - -[[deps.IrrationalConstants]] -git-tree-sha1 = "b2d91fe939cae05960e760110b328288867b5758" -uuid = "92d709cd-6900-40b7-9082-c6be49f344b6" -version = "0.2.6" - -[[deps.JLLWrappers]] -deps = ["Artifacts", "Preferences"] -git-tree-sha1 = "0533e564aae234aff59ab625543145446d8b6ec2" -uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" -version = "1.7.1" - -[[deps.JuliaInterpreter]] -deps = ["CodeTracking", "InteractiveUtils", "Random", "UUIDs"] -git-tree-sha1 = "f5e59455236d8269b7868665c3665e8477af0e37" -uuid = "aa1ae85d-cabe-5617-a682-6adf51b2e16a" -version = "0.10.10" - -[[deps.LibGit2]] -deps = ["Base64", "LibGit2_jll", "NetworkOptions", "Printf", "SHA"] -uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" - -[[deps.LibGit2_jll]] -deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll"] -uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5" -version = "1.6.4+0" - -[[deps.LibSSH2_jll]] -deps = ["Artifacts", "Libdl", "MbedTLS_jll"] -uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" -version = "1.11.0+1" - -[[deps.Libdl]] -uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" - -[[deps.Libiconv_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl"] -git-tree-sha1 = "be484f5c92fad0bd8acfef35fe017900b0b73809" -uuid = "94ce4f54-9a6c-5748-9c1c-f9c7231a4531" -version = "1.18.0+0" - -[[deps.LinearAlgebra]] -deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] -uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" - -[[deps.LogExpFunctions]] -deps = ["DocStringExtensions", "IrrationalConstants", "LinearAlgebra"] -git-tree-sha1 = "13ca9e2586b89836fd20cccf56e57e2b9ae7f38f" -uuid = "2ab3a3ac-af41-5b50-aa03-7779005ae688" -version = "0.3.29" - - [deps.LogExpFunctions.extensions] - LogExpFunctionsChainRulesCoreExt = "ChainRulesCore" - LogExpFunctionsChangesOfVariablesExt = "ChangesOfVariables" - LogExpFunctionsInverseFunctionsExt = "InverseFunctions" - - [deps.LogExpFunctions.weakdeps] - ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" - ChangesOfVariables = "9e997f8a-9a97-42d5-a9f1-ce6bfc15e2c0" - InverseFunctions = "3587e190-3f89-42d0-90ee-14403ec27112" - -[[deps.Logging]] -uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" - -[[deps.LoweredCodeUtils]] -deps = ["CodeTracking", "Compiler", "JuliaInterpreter"] -git-tree-sha1 = "5d4278f755440f70648d80cc6225f51e78e94094" -uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" -version = "3.5.1" - -[[deps.MacroTools]] -git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" -uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" -version = "0.5.16" - -[[deps.Markdown]] -deps = ["Base64"] -uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" - -[[deps.MbedTLS_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" -version = "2.28.1010+0" - -[[deps.Mmap]] -uuid = "a63ad114-7e13-5084-954f-fe012c677804" - -[[deps.NaNMath]] -deps = ["OpenLibm_jll"] -git-tree-sha1 = "9b8215b1ee9e78a293f99797cd31375471b2bcae" -uuid = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" -version = "1.1.3" - -[[deps.NetworkOptions]] -uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" -version = "1.2.0" - -[[deps.OffsetArrays]] -git-tree-sha1 = "117432e406b5c023f665fa73dc26e79ec3630151" -uuid = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" -version = "1.17.0" -weakdeps = ["Adapt"] - - [deps.OffsetArrays.extensions] - OffsetArraysAdaptExt = "Adapt" - -[[deps.OpenBLAS_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] -uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" -version = "0.3.23+5" - -[[deps.OpenLibm_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "05823500-19ac-5b8b-9628-191a04bc5112" -version = "0.8.5+0" - -[[deps.OpenSpecFun_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] -git-tree-sha1 = "1346c9208249809840c91b26703912dff463d335" -uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e" -version = "0.5.6+0" - -[[deps.OrderedCollections]] -git-tree-sha1 = "05868e21324cede2207c6f0f466b4bfef6d5e7ee" -uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" -version = "1.8.1" - -[[deps.PackageExtensionCompat]] -git-tree-sha1 = "fb28e33b8a95c4cee25ce296c817d89cc2e53518" -uuid = "65ce6f38-6b18-4e1d-a461-8949797d7930" -version = "1.0.2" -weakdeps = ["Requires", "TOML"] - -[[deps.PrecompileTools]] -deps = ["Preferences"] -git-tree-sha1 = "5aa36f7049a63a1528fe8f7c3f2113413ffd4e1f" -uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" -version = "1.2.1" - -[[deps.Preferences]] -deps = ["TOML"] -git-tree-sha1 = "8b770b60760d4451834fe79dd483e318eee709c4" -uuid = "21216c6a-2e73-6563-6e65-726566657250" -version = "1.5.2" - -[[deps.Printf]] -deps = ["Unicode"] -uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" - -[[deps.REPL]] -deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] -uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" - -[[deps.Random]] -deps = ["SHA"] -uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" - -[[deps.Reexport]] -git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" -uuid = "189a3867-3050-52da-a836-e630ba90ab69" -version = "1.2.2" - -[[deps.Requires]] -deps = ["UUIDs"] -git-tree-sha1 = "62389eeff14780bfe55195b7204c0d8738436d64" -uuid = "ae029012-a4dd-5104-9daa-d747884805df" -version = "1.3.1" - -[[deps.ReverseDiff]] -deps = ["ChainRulesCore", "DiffResults", "DiffRules", "ForwardDiff", "FunctionWrappers", "LinearAlgebra", "LogExpFunctions", "MacroTools", "NaNMath", "Random", "SpecialFunctions", "StaticArrays", "Statistics"] -git-tree-sha1 = "f1b07322a8cdc0d46812473b37fb72f69ec07b22" -uuid = "37e2e3b7-166d-5795-8a7a-e32c996b4267" -version = "1.16.2" - -[[deps.Revise]] -deps = ["CodeTracking", "FileWatching", "InteractiveUtils", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] -git-tree-sha1 = "14d1bfb0a30317edc77e11094607ace3c800f193" -uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" -version = "3.13.2" -weakdeps = ["Distributed"] - - [deps.Revise.extensions] - DistributedExt = "Distributed" - -[[deps.SHA]] -uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" -version = "0.7.0" - -[[deps.SciMLOperators]] -deps = ["Accessors", "ArrayInterface", "DocStringExtensions", "LinearAlgebra", "MacroTools"] -git-tree-sha1 = "d82853c515a8d9d42c1ab493a2687a37f1e26c91" -uuid = "c0aeaf25-5076-4817-a8d5-81caf7dfa961" -version = "0.4.0" -weakdeps = ["SparseArrays", "StaticArraysCore"] - - [deps.SciMLOperators.extensions] - SciMLOperatorsSparseArraysExt = "SparseArrays" - SciMLOperatorsStaticArraysCoreExt = "StaticArraysCore" - -[[deps.SciMLPublic]] -git-tree-sha1 = "0ba076dbdce87ba230fff48ca9bca62e1f345c9b" -uuid = "431bcebd-1456-4ced-9d72-93c2757fff0b" -version = "1.0.1" - -[[deps.Serialization]] -uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" - -[[deps.Setfield]] -deps = ["ConstructionBase", "Future", "MacroTools", "StaticArraysCore"] -git-tree-sha1 = "c5391c6ace3bc430ca630251d02ea9687169ca68" -uuid = "efcf1570-3423-57d1-acb7-fd33fddbac46" -version = "1.1.2" - -[[deps.SharedArrays]] -deps = ["Distributed", "Mmap", "Random", "Serialization"] -uuid = "1a1011a3-84de-559e-8e89-a11a2f7dc383" - -[[deps.SimpleTraits]] -deps = ["InteractiveUtils", "MacroTools"] -git-tree-sha1 = "be8eeac05ec97d379347584fa9fe2f5f76795bcb" -uuid = "699a6c99-e7fa-54fc-8d76-47d257e15c1d" -version = "0.9.5" - -[[deps.Sockets]] -uuid = "6462fe0b-24de-5631-8697-dd941f90decc" - -[[deps.SparseArrays]] -deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"] -uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" -version = "1.10.0" - -[[deps.SparseDiffTools]] -deps = ["ADTypes", "Adapt", "ArrayInterface", "Compat", "DataStructures", "FiniteDiff", "ForwardDiff", "Graphs", "LinearAlgebra", "PackageExtensionCompat", "Random", "Reexport", "SciMLOperators", "Setfield", "SparseArrays", "StaticArrayInterface", "StaticArrays", "UnPack", "VertexSafeGraphs"] -git-tree-sha1 = "ccbf06a08573200853b1bd06203d8ccce8449578" -uuid = "47a9eef4-7e08-11e9-0b38-333d64bd3804" -version = "2.26.0" - - [deps.SparseDiffTools.extensions] - SparseDiffToolsEnzymeExt = "Enzyme" - SparseDiffToolsPolyesterExt = "Polyester" - SparseDiffToolsPolyesterForwardDiffExt = "PolyesterForwardDiff" - SparseDiffToolsSymbolicsExt = "Symbolics" - SparseDiffToolsZygoteExt = "Zygote" - - [deps.SparseDiffTools.weakdeps] - Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9" - Polyester = "f517fe37-dbe3-4b94-8317-1923a5111588" - PolyesterForwardDiff = "98d1487c-24ca-40b6-b7ab-df2af84e126b" - Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" - Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f" - -[[deps.SpecialFunctions]] -deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] -git-tree-sha1 = "5acc6a41b3082920f79ca3c759acbcecf18a8d78" -uuid = "276daf66-3868-5448-9aa4-cd146d93841b" -version = "2.7.1" -weakdeps = ["ChainRulesCore"] - - [deps.SpecialFunctions.extensions] - SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" - -[[deps.Static]] -deps = ["CommonWorldInvalidations", "IfElse", "PrecompileTools", "SciMLPublic"] -git-tree-sha1 = "49440414711eddc7227724ae6e570c7d5559a086" -uuid = "aedffcd0-7271-4cad-89d0-dc628f76c6d3" -version = "1.3.1" - -[[deps.StaticArrayInterface]] -deps = ["ArrayInterface", "Compat", "IfElse", "LinearAlgebra", "PrecompileTools", "SciMLPublic", "Static"] -git-tree-sha1 = "aa1ea41b3d45ac449d10477f65e2b40e3197a0d2" -uuid = "0d7ed370-da01-4f52-bd93-41d350b8b718" -version = "1.9.0" -weakdeps = ["OffsetArrays", "StaticArrays"] - - [deps.StaticArrayInterface.extensions] - StaticArrayInterfaceOffsetArraysExt = "OffsetArrays" - StaticArrayInterfaceStaticArraysExt = "StaticArrays" - -[[deps.StaticArrays]] -deps = ["LinearAlgebra", "PrecompileTools", "Random", "StaticArraysCore"] -git-tree-sha1 = "0f529006004a8be48f1be25f3451186579392d47" -uuid = "90137ffa-7385-5640-81b9-e52037218182" -version = "1.9.17" -weakdeps = ["ChainRulesCore", "Statistics"] - - [deps.StaticArrays.extensions] - StaticArraysChainRulesCoreExt = "ChainRulesCore" - StaticArraysStatisticsExt = "Statistics" - -[[deps.StaticArraysCore]] -git-tree-sha1 = "6ab403037779dae8c514bad259f32a447262455a" -uuid = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" -version = "1.4.4" - -[[deps.Statistics]] -deps = ["LinearAlgebra", "SparseArrays"] -uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" -version = "1.10.0" - -[[deps.StringEncodings]] -deps = ["Libiconv_jll"] -git-tree-sha1 = "b765e46ba27ecf6b44faf70df40c57aa3a547dcb" -uuid = "69024149-9ee7-55f6-a4c4-859efe599b68" -version = "0.3.7" - -[[deps.SuiteSparse_jll]] -deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] -uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" -version = "7.2.1+1" - -[[deps.TOML]] -deps = ["Dates"] -uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" -version = "1.0.3" - -[[deps.Test]] -deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] -uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" - -[[deps.TestSetExtensions]] -deps = ["DeepDiffs", "Distributed", "Test"] -git-tree-sha1 = "ccebd99935be339d2ad907589708ba1f0d62bab3" -uuid = "98d24dd4-01ad-11ea-1b02-c9a08f80db04" -version = "3.0.0" - -[[deps.UUIDs]] -deps = ["Random", "SHA"] -uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" - -[[deps.UnPack]] -git-tree-sha1 = "387c1f73762231e86e0c9c5443ce3b4a0a9a0c2b" -uuid = "3a884ed6-31ef-47d7-9d2a-63182c4928ed" -version = "1.0.2" - -[[deps.Unicode]] -uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" - -[[deps.VertexSafeGraphs]] -deps = ["Graphs"] -git-tree-sha1 = "8351f8d73d7e880bfc042a8b6922684ebeafb35c" -uuid = "19fa3120-7c27-5ec5-8db8-b0b0aa330d6f" -version = "0.2.0" - -[[deps.YAML]] -deps = ["Base64", "Dates", "Printf", "StringEncodings"] -git-tree-sha1 = "a1c0c7585346251353cddede21f180b96388c403" -uuid = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" -version = "0.4.16" - -[[deps.libblastrampoline_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" -version = "5.11.0+0" From 9306e87ca5eba3494d72ba66a5f54869f3a9e6a3 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:48:35 -0600 Subject: [PATCH 07/17] repush --- examples/07_flowfarm_setup/optimization_demo.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/07_flowfarm_setup/optimization_demo.ipynb b/examples/07_flowfarm_setup/optimization_demo.ipynb index b2bd2472..d0c9d90d 100644 --- a/examples/07_flowfarm_setup/optimization_demo.ipynb +++ b/examples/07_flowfarm_setup/optimization_demo.ipynb @@ -255,7 +255,7 @@ " show_image=True,\n", " include_cable_routing=True,\n", ")\n", - "plt.show()" + "plt.show()\n" ] }, { From 608da8519452cf277b5d6ef67a86dd683d09112b Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:04:29 -0600 Subject: [PATCH 08/17] update tests --- ard/farm_aero/flowfarm.py | 4 +- .../integration/test_flowfarm_integration.py | 50 +------------------ 2 files changed, 3 insertions(+), 51 deletions(-) diff --git a/ard/farm_aero/flowfarm.py b/ard/farm_aero/flowfarm.py index 3f0000e5..43bf0ecc 100644 --- a/ard/farm_aero/flowfarm.py +++ b/ard/farm_aero/flowfarm.py @@ -235,7 +235,7 @@ def _evaluate_sparse(self, x_eval_np): ) self._cached_sparse_x = x_eval_np.copy() - self._cached_sparse_aep = float(aep_val) + self._cached_sparse_aep = float(np.asarray(aep_val).ravel()[0]) self._cached_sparse_grad = np.asarray(grad_val).ravel().copy() def _evaluate_farm(self, x_eval_np): @@ -254,7 +254,7 @@ def _evaluate_farm(self, x_eval_np): aep_val = calculate_aep_bang(self.farm, x_eval) self._cached_farm_x = x_eval_np.copy() - self._cached_farm_aep = float(aep_val) + self._cached_farm_aep = float(np.asarray(aep_val).ravel()[0]) def _compute_aep(self, inputs, outputs): """Compute farm AEP using regular calculate_aep!(farm, x).""" diff --git a/test/flowfarm/integration/test_flowfarm_integration.py b/test/flowfarm/integration/test_flowfarm_integration.py index 397be92e..a7cac769 100644 --- a/test/flowfarm/integration/test_flowfarm_integration.py +++ b/test/flowfarm/integration/test_flowfarm_integration.py @@ -12,15 +12,12 @@ pytest -m "not julia" ... """ -from pathlib import Path - import numpy as np import openmdao.api as om import pytest import yaml import ard -import ard.utils.test_utils # --------------------------------------------------------------------------- # Shared test data @@ -170,7 +167,7 @@ def test_partials_check(self): ) for key, vals in data.items(): rel_err = vals.get("rel error") - if rel_err is not None: + if rel_err is not None and rel_err.forward is not None: assert ( abs(rel_err.forward) < 0.01 ), f"Partial derivative rel error too large for {key}: {rel_err.forward:.4f}" @@ -189,48 +186,3 @@ def test_aep_decreases_with_closer_spacing(self): assert aep_tight < aep_spread - -# --------------------------------------------------------------------------- -# Pyrite regression (generated on first run with rewrite=True) -# --------------------------------------------------------------------------- - - -@pytest.mark.julia -class TestFLOWFarmAEPPyrite: - - def setup_method(self): - from ard.farm_aero.flowfarm import FLOWFarmAEP - - modeling_options = _make_aep_modeling_options() - model = om.Group() - model.add_subsystem( - "aepFLOWFarm", FLOWFarmAEP(modeling_options=modeling_options) - ) - prob = om.Problem(model) - prob.setup() - - n_side = 3 - X, Y = _grid_layout(n_side, _SPACING, _ROTOR_DIAMETER) - prob.set_val("aepFLOWFarm.x_turbines", X) - prob.set_val("aepFLOWFarm.y_turbines", Y) - prob.set_val("aepFLOWFarm.yaw_turbines", np.zeros(len(X))) - prob.run_model() - self.prob = prob - - def test_aep_pyrite(self, subtests): - test_data = { - "aep_farm": self.prob.get_val("aepFLOWFarm.AEP_farm", units="GW*h"), - "power_farm": self.prob.get_val("aepFLOWFarm.power_farm", units="MW"), - } - pyrite_path = Path(__file__).parent / "test_flowfarm_aep_pyrite.npz" - - pyrite_data = ard.utils.test_utils.pyrite_validator( - test_data, - pyrite_path, - rtol_val=5e-3, - # rewrite=True, # uncomment to regenerate reference data - ) - - for key in test_data: - with subtests.test(key): - assert np.allclose(test_data[key], pyrite_data[key], rtol=5e-3) From e9c91f2aab6126ad2db4c6590f9266519046a003 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:40:04 -0600 Subject: [PATCH 09/17] test update --- test/flowfarm/integration/test_flowfarm_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/flowfarm/integration/test_flowfarm_integration.py b/test/flowfarm/integration/test_flowfarm_integration.py index a7cac769..146c49de 100644 --- a/test/flowfarm/integration/test_flowfarm_integration.py +++ b/test/flowfarm/integration/test_flowfarm_integration.py @@ -16,6 +16,7 @@ import openmdao.api as om import pytest import yaml +from pathlib import Path import ard From 6b823e77e183849134b0aaa2f3923b221f814bdb Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:48:46 -0600 Subject: [PATCH 10/17] correct, tests --- .../integration/test_flowfarm_integration.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test/flowfarm/integration/test_flowfarm_integration.py b/test/flowfarm/integration/test_flowfarm_integration.py index 146c49de..02792d5e 100644 --- a/test/flowfarm/integration/test_flowfarm_integration.py +++ b/test/flowfarm/integration/test_flowfarm_integration.py @@ -47,6 +47,10 @@ def _load_turbine_yaml(): return yaml.safe_load(f) +def _as_scalar(value): + return float(np.asarray(value).ravel()[0]) + + def _make_aep_modeling_options(): import floris @@ -148,13 +152,13 @@ def test_outputs_declared(self): def test_compute_returns_positive_aep(self): self.prob.run_model() aep = self.prob.get_val("aepFLOWFarm.AEP_farm") - assert float(aep) > 0.0 + assert _as_scalar(aep) > 0.0 def test_compute_aep_consistent_on_repeated_calls(self): self.prob.run_model() - aep1 = float(self.prob.get_val("aepFLOWFarm.AEP_farm")) + aep1 = _as_scalar(self.prob.get_val("aepFLOWFarm.AEP_farm")) self.prob.run_model() - aep2 = float(self.prob.get_val("aepFLOWFarm.AEP_farm")) + aep2 = _as_scalar(self.prob.get_val("aepFLOWFarm.AEP_farm")) assert aep1 == pytest.approx(aep2, rel=1e-10) def test_partials_check(self): @@ -176,14 +180,14 @@ def test_partials_check(self): def test_aep_decreases_with_closer_spacing(self): """AEP should be lower for a tighter layout due to increased wake losses.""" self.prob.run_model() - aep_spread = float(self.prob.get_val("aepFLOWFarm.AEP_farm")) + aep_spread = _as_scalar(self.prob.get_val("aepFLOWFarm.AEP_farm")) n_side = 3 X_tight, Y_tight = _grid_layout(n_side, 2.0, _ROTOR_DIAMETER) # 2D spacing self.prob.set_val("aepFLOWFarm.x_turbines", X_tight) self.prob.set_val("aepFLOWFarm.y_turbines", Y_tight) self.prob.run_model() - aep_tight = float(self.prob.get_val("aepFLOWFarm.AEP_farm")) + aep_tight = _as_scalar(self.prob.get_val("aepFLOWFarm.AEP_farm")) assert aep_tight < aep_spread From c9b080140379815888daf305f16a1168b85143b1 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:58:17 -0600 Subject: [PATCH 11/17] test updates --- test/conftest.py | 22 ++++++++++++------- .../integration/test_flowfarm_integration.py | 13 +++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 9114d9a2..fc3d1fd0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,16 +1,22 @@ +import os from pathlib import Path +# Disable OpenMDAO auto-report generation (n2/inputs/html artifacts) in tests. +os.environ.setdefault("OPENMDAO_REPORTS", "0") + + def pytest_sessionfinish(session, exitstatus): # cleanup code after tests - # for each tempdir - for pytest_out_dir in Path().glob("pytest*_out"): - for root, dirs, files in pytest_out_dir.walk( + # remove pytest and OpenMDAO report output directories from cwd + for pattern in ("pytest*_out", "__main__*_out"): + for out_dir in Path().glob(pattern): + for root, dirs, files in out_dir.walk( top_down=False ): # walk the directory - for name in files: - (root / name).unlink() # remove subdirectory files, and - for name in dirs: - (root / name).rmdir() # remove subdirectories - pytest_out_dir.rmdir() # then remove that tempdir + for name in files: + (root / name).unlink() # remove subdirectory files, and + for name in dirs: + (root / name).rmdir() # remove subdirectories + out_dir.rmdir() # then remove that tempdir diff --git a/test/flowfarm/integration/test_flowfarm_integration.py b/test/flowfarm/integration/test_flowfarm_integration.py index 02792d5e..aa10adb4 100644 --- a/test/flowfarm/integration/test_flowfarm_integration.py +++ b/test/flowfarm/integration/test_flowfarm_integration.py @@ -63,6 +63,12 @@ def _make_aep_modeling_options(): wind_speeds=speeds, ti_table=0.06, ) + + turbine["rated_power"] = 3.4e6 + turbine["rated_wind_speed"] = 9.8 + turbine["cutin_wind_speed"] = 3.0 + turbine["cutout_wind_speed"] = 25.0 + return { "windIO_plant": { "wind_farm": {"name": "integration test farm", "turbine": turbine}, @@ -87,6 +93,13 @@ def _make_aep_modeling_options(): }, "layout": {"N_turbines": n_side**2}, "aero": {"return_turbine_output": True}, + "flowfarm": { + "wake_deficit_model": "GaussYawVariableSpread", + "wake_deflection_model": "GaussYawVariableSpreadDeflection", + "wake_combination_model": "LinearLocalVelocitySuperposition", + "local_turbulence_model": "LocalTIModelNoLocalTI", + "tolerance": 1e-16, + }, } From 4fc7b09e07794e4f4b11265e709385ee56fda47c Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:40:33 -0600 Subject: [PATCH 12/17] cleanup --- .github/workflows/julia-tests.yaml | 8 +- .../workflows/python-tests-consolidated.yaml | 15 +- .gitignore | 2 +- ard/{ => farm_aero}/flowfarm/README.md | 63 +-- ard/farm_aero/flowfarm/__init__.py | 10 + ard/farm_aero/flowfarm/_jl_bootstrap.py | 34 ++ .../{flowfarm.py => flowfarm/component.py} | 242 ++++++----- ard/farm_aero/flowfarm/flowfarm_model.py | 363 +++++++++++++++++ .../flowfarm/julia_env/Project.toml | 2 +- ard/flowfarm/__init__.py | 15 +- ard/flowfarm/_jl_bootstrap.py | 122 +----- ard/flowfarm/flowfarm_model.py | 381 +----------------- ard/flowfarm/pin_flowfarm.py | 47 --- .../07_flowfarm_setup/inputs/ard_system.yaml | 1 - .../unit/farm_aero/test_flowfarm_component.py | 2 +- test/conftest.py | 9 +- test/flowfarm/unit/test_flowfarm_model.py | 2 +- test/flowfarm/unit/test_jl_bootstrap.py | 227 +---------- 18 files changed, 614 insertions(+), 931 deletions(-) rename ard/{ => farm_aero}/flowfarm/README.md (71%) create mode 100644 ard/farm_aero/flowfarm/__init__.py create mode 100644 ard/farm_aero/flowfarm/_jl_bootstrap.py rename ard/farm_aero/{flowfarm.py => flowfarm/component.py} (76%) create mode 100644 ard/farm_aero/flowfarm/flowfarm_model.py rename ard/{ => farm_aero}/flowfarm/julia_env/Project.toml (72%) delete mode 100644 ard/flowfarm/pin_flowfarm.py diff --git a/.github/workflows/julia-tests.yaml b/.github/workflows/julia-tests.yaml index 2ed12d8e..76cab5b9 100644 --- a/.github/workflows/julia-tests.yaml +++ b/.github/workflows/julia-tests.yaml @@ -3,13 +3,11 @@ name: FLOWFarm integration tests (Julia) on: push: paths: - - "ard/flowfarm/**" - - "ard/farm_aero/flowfarm.py" + - "ard/farm_aero/flowfarm/**" - "test/flowfarm/**" pull_request: paths: - - "ard/flowfarm/**" - - "ard/farm_aero/flowfarm.py" + - "ard/farm_aero/flowfarm/**" - "test/flowfarm/**" workflow_dispatch: # allow manual runs from the Actions UI @@ -42,7 +40,7 @@ jobs: - name: Pre-instantiate Julia environment run: | julia -e "using Pkg; Pkg.Registry.add(\"General\"); Pkg.Registry.update()" - julia --project=ard/flowfarm/julia_env -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" + julia --project=ard/farm_aero/flowfarm/julia_env -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" - name: Run FLOWFarm integration tests run: | diff --git a/.github/workflows/python-tests-consolidated.yaml b/.github/workflows/python-tests-consolidated.yaml index d22a3f52..177f9afa 100644 --- a/.github/workflows/python-tests-consolidated.yaml +++ b/.github/workflows/python-tests-consolidated.yaml @@ -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 test/flowfarm/unit -m "not julia" + 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 diff --git a/.gitignore b/.gitignore index 6ebffb22..49066835 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ 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/flowfarm/julia_env/Manifest.toml +ard/farm_aero/flowfarm/julia_env/Manifest.toml ### MACOS DEFAULT IGNORES diff --git a/ard/flowfarm/README.md b/ard/farm_aero/flowfarm/README.md similarity index 71% rename from ard/flowfarm/README.md rename to ard/farm_aero/flowfarm/README.md index 31a1a175..d7e354e6 100644 --- a/ard/flowfarm/README.md +++ b/ard/farm_aero/flowfarm/README.md @@ -6,53 +6,52 @@ This folder contains Ard's Python-Julia integration utilities for FLOWFarm. FLOWFarm runs inside Julia via [JuliaCall](https://juliapy.github.io/PythonCall.jl/stable/). You need Julia installed before running any FLOWFarm components. Users who do not use FLOWFarm do not need Julia at all — it is loaded lazily only when a FLOWFarm component is initialized. -### 1. Install Julia via juliaup (recommended) +### 1. Create a conda environment with Python + juliaup (recommended) -[juliaup](https://github.com/JuliaLang/juliaup) is the official Julia version manager. Install it with: +```bash +conda create --name ard-FLOWFarm python=3.13 juliaup +conda activate ard-FLOWFarm +``` + +Set both Julia depots to a path inside the active conda env: ```bash -curl -fsSL https://install.julialang.org | sh +conda env config vars set JULIA_DEPOT_PATH=${CONDA_PREFIX}/.julia +conda env config vars set JULIAUP_DEPOT_PATH=${CONDA_PREFIX}/.julia +conda deactivate +conda activate ard-FLOWFarm ``` -Install any recent stable Julia release (1.10 or later): +Install Julia (1.x) using juliaup from that environment: ```bash juliaup add release juliaup default release +julia --version ``` -Verify: +Then install Ard (including FLOWFarm dependencies): ```bash -julia --version +pip install -e ".[dev,flowfarm]" ``` -Ard's Julia environment has no hard version pin. The `Manifest.toml` is not committed to the repository — it is generated locally the first time you run a FLOWFarm component, so it will always match your installed Julia version. If you need to generate it ahead of time (e.g. on a cluster before a job runs), see step 2. - ### 2. Pre-generate the Julia environment (optional) On first use Ard will resolve and instantiate the Julia environment automatically. If you prefer to do this ahead of time — for example on an HPC cluster node without internet access at runtime — run once from your terminal: ```bash -julia --project="/ard/flowfarm/julia_env" -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" +julia --project="/ard/farm_aero/flowfarm/julia_env" -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" ``` Replace `` with the absolute path to the `Ard` directory. This downloads FLOWFarm and its dependencies. It may take several minutes on first run. -### 3. Install the JuliaCall Python package - -```bash -pip install juliacall -``` - -`juliacall` is not listed in Ard's core dependencies because it is only needed for FLOWFarm. Install it separately before using FLOWFarm components. - ## What this integration does - Boots Julia through JuliaCall. - Activates Ard's local Julia environment (`julia_env`). - Loads FLOWFarm and builds farm and sparse structs for Ard components. -- Exposes helper functions used by the component wrapper in `ard/farm_aero/flowfarm.py`. +- Exposes helper functions used by the component wrapper in `ard/farm_aero/flowfarm/component.py`. ## Threading and parallelism @@ -102,7 +101,7 @@ modeling_options: - `_jl_bootstrap.py`: Julia runtime bootstrap and env activation helpers. - `flowfarm_model.py`: FLOWFarm model-construction utilities and option validation. -- `../ard/farm_aero/flowfarm.py`: OpenMDAO component wrapper that uses this integration. +- `component.py`: OpenMDAO component wrapper that uses this integration. ## Troubleshooting @@ -111,40 +110,20 @@ modeling_options: If you see warnings like "manifest resolved with a different julia version" or "project dependencies have changed since the manifest was last resolved", it means the local `Manifest.toml` is missing or stale. Ard will attempt to rebuild it automatically. If it does not, run: ```bash -julia --project="/ard/flowfarm/julia_env" -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" +julia --project="/ard/farm_aero/flowfarm/julia_env" -e "using Pkg; Pkg.resolve(); Pkg.instantiate()" ``` Then restart your Jupyter kernel. The `Manifest.toml` is not committed to the repository — it is always generated locally for your Julia version. -### Revise / DistributedExt error in Jupyter - -``` -Error during loading of extension DistributedExt of Revise -``` - -This comes from your **global** Julia environment, not Ard's. JuliaCall triggers the IPython/Jupyter juliacall extension on import, which loads Revise from your global env. Fix it by running: -```bash -julia -e "using Pkg; Pkg.add(\"Distributed\"); Pkg.resolve()" -``` +This comes from your **global** Julia environment, not Ard's. JuliaCall triggers the IPython/Jupyter juliacall extension on import. ### Wrong Julia version being used If Julia 1.11+ is picked up instead of 1.10, check your `PATH`. `juliaup default 1.10` sets the default for commands run via juliaup, but if `/opt/homebrew/bin/julia` or another system Julia takes precedence in your shell, JuliaCall may use that instead. -To force a specific version for a notebook session, add this to the **first cell** before any other imports: - -```python -import os -os.environ["PYTHON_JULIACALL_EXE"] = "julia +1.10" -os.environ["ARD_FLOWFARM_RESPECT_JULIACALL_ENV"] = "1" -``` - -`ARD_FLOWFARM_RESPECT_JULIACALL_ENV=1` is required — without it Ard's bootstrap strips the override. - ### Kernel/process crash when threads > 1 - Ensure pure Julia callback path is active (current Ard default). - Ensure thread env vars are set before importing Ard. -- Start with `PYTHON_JULIACALL_THREADS=1`, then increase. - +- Start with `PYTHON_JULIACALL_THREADS=1`, then increase. \ No newline at end of file diff --git a/ard/farm_aero/flowfarm/__init__.py b/ard/farm_aero/flowfarm/__init__.py new file mode 100644 index 00000000..b97cc0b4 --- /dev/null +++ b/ard/farm_aero/flowfarm/__init__.py @@ -0,0 +1,10 @@ +from .component import FLOWFarmAEP, FLOWFarmBatchPower, FLOWFarmComponent +from ._jl_bootstrap import ensure_flowfarm_loaded, get_julia_runtime + +__all__ = [ + "FLOWFarmAEP", + "FLOWFarmBatchPower", + "FLOWFarmComponent", + "ensure_flowfarm_loaded", + "get_julia_runtime", +] diff --git a/ard/farm_aero/flowfarm/_jl_bootstrap.py b/ard/farm_aero/flowfarm/_jl_bootstrap.py new file mode 100644 index 00000000..ed7e08f0 --- /dev/null +++ b/ard/farm_aero/flowfarm/_jl_bootstrap.py @@ -0,0 +1,34 @@ +# ard/farm_aero/flowfarm/_jl_bootstrap.py +from __future__ import annotations +import pathlib + +_jl_runtime = None +_flowfarm_env_initialized = False + + +def get_julia_runtime(): + """Return (Main, Pkg) from JuliaCall with Ard-safe bootstrap behavior.""" + global _jl_runtime + if _jl_runtime is not None: + return _jl_runtime + + from juliacall import Main as jl_main + from juliacall import Pkg as jl_pkg + + _jl_runtime = (jl_main, jl_pkg) + return _jl_runtime + + +def ensure_flowfarm_loaded(): + """Activate Ard Julia env and load FLOWFarm in Julia Main.""" + global _flowfarm_env_initialized + jl_main, jl_pkg = get_julia_runtime() + if not _flowfarm_env_initialized: + env_dir = pathlib.Path(__file__).parent / "julia_env" + jl_pkg.activate(str(env_dir)) + jl_pkg.instantiate() + _flowfarm_env_initialized = True + + if "FLOWFarm" not in dir(jl_main): + jl_main.seval("using FLOWFarm") + return jl_main diff --git a/ard/farm_aero/flowfarm.py b/ard/farm_aero/flowfarm/component.py similarity index 76% rename from ard/farm_aero/flowfarm.py rename to ard/farm_aero/flowfarm/component.py index 43bf0ecc..290d8634 100644 --- a/ard/farm_aero/flowfarm.py +++ b/ard/farm_aero/flowfarm/component.py @@ -1,16 +1,13 @@ -import os - import numpy as np -import pandas as pd -from ard.farm_aero.floris import create_FLORIS_turbine_from_windIO -from ard.flowfarm.flowfarm_model import ( +from ..floris import create_FLORIS_turbine_from_windIO +from .flowfarm_model import ( ensure_flowfarm_loaded, resolve_turbine_inputs_for_flowfarm, resolve_wake_model_inputs_for_flowfarm, ) -import ard.farm_aero.templates as templates +from .. import templates class FLOWFarmComponent: @@ -19,85 +16,35 @@ def initialize(self): # This mixin is invoked explicitly by derived classes; no super() chain here. return - def setup(self): - jl = ensure_flowfarm_loaded() - self._jl = jl - model_options = self.options["modeling_options"] - self.N_turbines = model_options["layout"]["N_turbines"] - windIO = model_options["windIO_plant"] - N_turbines = self.N_turbines - - turbine_floris = create_FLORIS_turbine_from_windIO(windIO) - ref_air_density = model_options.get("flowfarm", {}).get( - "ref_air_density", 1.225 - ) - - hub_height = turbine_floris["hub_height"] - rotor_diameter = turbine_floris["rotor_diameter"] - - windIOturbine = windIO["wind_farm"]["turbine"] - turbine_inputs = resolve_turbine_inputs_for_flowfarm(windIOturbine) - generator_efficiency = turbine_inputs["generator_efficiency"] - rated_power = turbine_inputs["rated_power"] - rated_wind_speed = turbine_inputs["rated_wind_speed"] - cutin_wind_speed = turbine_inputs["cutin_wind_speed"] - cutout_wind_speed = turbine_inputs["cutout_wind_speed"] - ct_model = turbine_inputs["ct_model"] - power_model = turbine_inputs["power_model"] - - windrose_floris = templates.create_windresource_from_windIO( - windIO, - resource_type="probability", - ) - - wind_directions = windrose_floris.wd_flat - wind_speeds = windrose_floris.ws_flat - wind_probabilities = windrose_floris.freq_table_flat - turbulence_intensity = np.mean(windrose_floris.ti_table_flat) - ref_height = windIO["site"]["energy_resource"]["wind_resource"].get( - "reference_height", hub_height - ) - wind_shear = windIO["site"]["energy_resource"]["wind_resource"].get( - "shear", 0.084 - ) - - flowfarm_options = model_options.get("flowfarm", {}) - wake_option_keys = { - "wake_deficit_model", - "wake_deflection_model", - "wake_combination_model", - "local_turbulence_model", - "tolerance", - } - wake_options_only = { - key: value - for key, value in flowfarm_options.items() - if key in wake_option_keys - } - wake_model_options = resolve_wake_model_inputs_for_flowfarm(wake_options_only) - - # FLOWFarm expects one model object per turbine. - ct_models = jl.fill(ct_model, N_turbines) - power_models = jl.fill(power_model, N_turbines) - - flowfarm_module = jl.FLOWFarm - n_states = len(wind_speeds) - - # FLOWFarm expects radians for wind direction. - wind_dirs_rad = jl.Vector[jl.Float64]( - list(map(float, np.deg2rad(np.asarray(wind_directions)))) - ) - wind_speeds_vec = jl.Vector[jl.Float64]( - list(map(float, np.asarray(wind_speeds))) - ) - wind_probs_vec = jl.Vector[jl.Float64]( - list(map(float, np.asarray(wind_probabilities))) + def _get_air_density(self, wind_resource): + return float(wind_resource.get("air_density", 1.225)) + + def _get_wake_model_options(self, model_options): + return resolve_wake_model_inputs_for_flowfarm(model_options.get("flowfarm", {})) + + def _to_julia_vector(self, jl, values): + return jl.Vector[jl.Float64](list(map(float, np.asarray(values).ravel()))) + + def _build_wind_resource( + self, + jl, + flowfarm_module, + windrose_floris, + ref_height, + ref_air_density, + wind_shear, + ): + wind_dirs_rad = self._to_julia_vector( + jl, np.deg2rad(np.asarray(windrose_floris.wd_flat)) ) - ambient_tis = jl.fill(float(turbulence_intensity), n_states) + wind_speeds_vec = self._to_julia_vector(jl, windrose_floris.ws_flat) + wind_probs_vec = self._to_julia_vector(jl, windrose_floris.freq_table_flat) + n_states = len(windrose_floris.ws_flat) + ambient_tis = jl.fill(float(np.mean(windrose_floris.ti_table_flat)), n_states) measurementheight = jl.fill(float(ref_height), n_states) - wind_shear_model = flowfarm_module.PowerLawWindShear(float(wind_shear)) - windresource = flowfarm_module.DiscretizedWindResource( + + return flowfarm_module.DiscretizedWindResource( wind_dirs_rad, wind_speeds_vec, wind_probs_vec, @@ -107,6 +54,7 @@ def setup(self): wind_shear_model, ) + def _build_wake_model_set(self, flowfarm_module, wake_model_options): wake_deficit = getattr( flowfarm_module, wake_model_options["wake_deficit_model"] )() @@ -120,14 +68,49 @@ def setup(self): flowfarm_module, wake_model_options["local_turbulence_model"] )() - model_set = flowfarm_module.WindFarmModelSet( + return flowfarm_module.WindFarmModelSet( wake_deficit, wake_deflection, wake_combine, local_ti, ) - # Temporary initialization until layout-driven vectors are wired in. + def _create_update_fn(self, jl): + jl.seval( + """ + function ard_make_flowfarm_update_fn() + return function (farm, x) + n = length(farm.turbine_x) + @inbounds for i in 1:n + farm.turbine_x[i] = x[i] + farm.turbine_y[i] = x[n + i] + farm.turbine_yaw[i] = x[2n + i] + end + return nothing + end + end + """ + ) + return jl.ard_make_flowfarm_update_fn() + + def _build_farm_structures( + self, + jl, + flowfarm_module, + N_turbines, + hub_height, + rotor_diameter, + generator_efficiency, + cutin_wind_speed, + cutout_wind_speed, + rated_wind_speed, + rated_power, + windresource, + ct_models, + power_models, + model_set, + tolerance, + ): x0 = jl.zeros(N_turbines * 3) turbine_x = jl.zeros(N_turbines) turbine_y = jl.zeros(N_turbines) @@ -141,22 +124,8 @@ def setup(self): cut_out_speeds = jl.fill(float(cutout_wind_speed), N_turbines) rated_speeds = jl.fill(float(rated_wind_speed), N_turbines) rated_powers = jl.fill(float(rated_power), N_turbines) + update_fn = self._create_update_fn(jl) - # Use a pure Julia callback so threaded FLOWFarm paths do not call back into Python. - jl.seval(""" - function ard_make_flowfarm_update_fn() - return function (farm, x) - n = length(farm.turbine_x) - @inbounds for i in 1:n - farm.turbine_x[i] = x[i] - farm.turbine_y[i] = x[n + i] - farm.turbine_yaw[i] = x[2n + i] - end - return nothing - end - end - """) - update_fn = jl.ard_make_flowfarm_update_fn() sparse_farm, sparse_struct = flowfarm_module.build_unstable_sparse_struct( x0, turbine_x, @@ -179,7 +148,7 @@ def setup(self): opt_x=True, opt_y=True, opt_yaw=True, - tolerance=wake_model_options.get("tolerance", 1e-16), + tolerance=tolerance, ) farm = flowfarm_module.build_wind_farm_struct( @@ -203,6 +172,75 @@ def setup(self): AEP_scale=1, ) + return x0, farm, sparse_farm, sparse_struct + + def setup(self): + jl = ensure_flowfarm_loaded() + self._jl = jl + model_options = self.options["modeling_options"] + self.N_turbines = model_options["layout"]["N_turbines"] + windIO = model_options["windIO_plant"] + wind_resource = windIO["site"]["energy_resource"]["wind_resource"] + + turbine_floris = create_FLORIS_turbine_from_windIO(windIO) + ref_air_density = self._get_air_density(wind_resource) + + hub_height = turbine_floris["hub_height"] + rotor_diameter = turbine_floris["rotor_diameter"] + + windIOturbine = windIO["wind_farm"]["turbine"] + turbine_inputs = resolve_turbine_inputs_for_flowfarm(windIOturbine) + generator_efficiency = turbine_inputs["generator_efficiency"] + rated_power = turbine_inputs["rated_power"] + rated_wind_speed = turbine_inputs["rated_wind_speed"] + cutin_wind_speed = turbine_inputs["cutin_wind_speed"] + cutout_wind_speed = turbine_inputs["cutout_wind_speed"] + ct_model = turbine_inputs["ct_model"] + power_model = turbine_inputs["power_model"] + + windrose_floris = templates.create_windresource_from_windIO( + windIO, + resource_type="probability", + ) + + ref_height = wind_resource.get("reference_height", hub_height) + wind_shear = wind_resource.get("shear", 0.084) + + wake_model_options = self._get_wake_model_options(model_options) + + # FLOWFarm expects one model object per turbine. + ct_models = jl.fill(ct_model, N_turbines) + power_models = jl.fill(power_model, N_turbines) + + flowfarm_module = jl.FLOWFarm + windresource = self._build_wind_resource( + jl, + flowfarm_module, + windrose_floris, + ref_height, + ref_air_density, + wind_shear, + ) + model_set = self._build_wake_model_set(flowfarm_module, wake_model_options) + + x0, farm, sparse_farm, sparse_struct = self._build_farm_structures( + jl, + flowfarm_module, + self.N_turbines, + hub_height, + rotor_diameter, + generator_efficiency, + cutin_wind_speed, + cutout_wind_speed, + rated_wind_speed, + rated_power, + windresource, + ct_models, + power_models, + model_set, + wake_model_options.get("tolerance", 1e-16), + ) + self.flowfarm_module = flowfarm_module self.x0 = x0 self.farm = farm @@ -226,7 +264,7 @@ def _evaluate_sparse(self, x_eval_np): if jl is None: jl = ensure_flowfarm_loaded() self._jl = jl - x_eval = jl.Vector[jl.Float64](list(map(float, x_eval_np))) + x_eval = self._to_julia_vector(jl, x_eval_np) calculate_grad_bang = getattr(self.flowfarm_module, "calculate_aep_gradient!") aep_val, grad_val = calculate_grad_bang( self.sparse_farm, @@ -249,7 +287,7 @@ def _evaluate_farm(self, x_eval_np): if jl is None: jl = ensure_flowfarm_loaded() self._jl = jl - x_eval = jl.Vector[jl.Float64](list(map(float, x_eval_np))) + x_eval = self._to_julia_vector(jl, x_eval_np) calculate_aep_bang = getattr(self.flowfarm_module, "calculate_aep!") aep_val = calculate_aep_bang(self.farm, x_eval) diff --git a/ard/farm_aero/flowfarm/flowfarm_model.py b/ard/farm_aero/flowfarm/flowfarm_model.py new file mode 100644 index 00000000..ae39bc6b --- /dev/null +++ b/ard/farm_aero/flowfarm/flowfarm_model.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import pathlib +import warnings +import numpy as np + +from ._jl_bootstrap import ensure_flowfarm_loaded, get_julia_runtime + +# ------------------------------------------------------------------------------ +# Configuration (project activation) +# ------------------------------------------------------------------------------ + +# If you're using the embedded project (recommended), point to it explicitly: +_THIS_DIR = pathlib.Path(__file__).resolve().parent +_JULIA_PROJECT_DIR = _THIS_DIR / "julia_env" + + +def _get_jl_main(): + jl, _ = get_julia_runtime() + return jl + + +def _ensure_env_activated(): + # Prefer explicit activation over relying on JULIA_PROJECT env var. + _, jl_pkg = get_julia_runtime() + jl_pkg.activate(str(_JULIA_PROJECT_DIR)) + jl_pkg.instantiate() # ensures Manifest is honored / deps are present + + +def _ensure_flowfarm_loaded(): + ensure_flowfarm_loaded() + + +# ------------------------------------------------------------------------------ +# Utility: Julia Vector conversion (optional; JuliaCall already converts NumPy arrays) +# ------------------------------------------------------------------------------ + + +def _jvec(x): + """Convert Python list/array → Julia Vector{Float64} (explicit).""" + jl = _get_jl_main() + return jl.Vector[jl.Float64](list(map(float, np.asarray(x).ravel()))) + + +def _resolve_flowfarm_constructor(flowfarm_module, candidate_names): + """Return the first FLOWFarm constructor that exists from candidate names.""" + for name in candidate_names: + if hasattr(flowfarm_module, name): + return getattr(flowfarm_module, name) + return None + + +def _build_flowfarm_power_model( + flowfarm_module, + has_cp_curve, + cp_curve, + constant_cp, + fallback_wind_speeds, +): + """Build a FLOWFarm power model from Cp curve or constant Cp fallback.""" + if has_cp_curve: + power_points_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["PowerModelCpPoints"], + ) + if power_points_ctor is None: + raise AttributeError( + "FLOWFarm.PowerModelCpPoints constructor was not found." + ) + return power_points_ctor( + _jvec(cp_curve["Cp_wind_speeds"]), + _jvec(cp_curve["Cp_values"]), + ) + + power_constant_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["PowerModelConstantCp", "PowerModelCpConstant"], + ) + if power_constant_ctor is not None: + return power_constant_ctor(float(constant_cp)) + + # Last-resort fallback if constant-Cp constructor name differs by FLOWFarm version. + # Approximate a constant Cp model using points at representative wind speeds. + warnings.warn( + "FLOWFarm constant-Cp constructor not found; falling back to PowerModelCpPoints with constant Cp.", + UserWarning, + stacklevel=2, + ) + power_points_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["PowerModelCpPoints"], + ) + if power_points_ctor is None: + raise AttributeError("FLOWFarm.PowerModelCpPoints constructor was not found.") + cp_values = [float(constant_cp)] * len(fallback_wind_speeds) + return power_points_ctor(_jvec(fallback_wind_speeds), _jvec(cp_values)) + + +def _build_flowfarm_ct_model( + flowfarm_module, + has_ct_curve, + ct_curve, + constant_ct, + fallback_wind_speeds, +): + """Build a FLOWFarm thrust model from Ct curve or constant Ct fallback.""" + if has_ct_curve: + ct_points_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["ThrustModelCtPoints"], + ) + if ct_points_ctor is None: + raise AttributeError( + "FLOWFarm.ThrustModelCtPoints constructor was not found." + ) + return ct_points_ctor( + _jvec(ct_curve["Ct_wind_speeds"]), + _jvec(ct_curve["Ct_values"]), + ) + + ct_constant_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["ThrustModelConstantCt", "ThrustModelCtConstant"], + ) + if ct_constant_ctor is not None: + return ct_constant_ctor(float(constant_ct)) + + # Last-resort fallback if constant-Ct constructor name differs by FLOWFarm version. + warnings.warn( + "FLOWFarm constant-Ct constructor not found; falling back to ThrustModelCtPoints with constant Ct.", + UserWarning, + stacklevel=2, + ) + ct_points_ctor = _resolve_flowfarm_constructor( + flowfarm_module, + ["ThrustModelCtPoints"], + ) + if ct_points_ctor is None: + raise AttributeError("FLOWFarm.ThrustModelCtPoints constructor was not found.") + ct_values = [float(constant_ct)] * len(fallback_wind_speeds) + return ct_points_ctor(_jvec(fallback_wind_speeds), _jvec(ct_values)) + + +def resolve_turbine_inputs_for_flowfarm(windio_turbine): + """Validate turbine inputs and return a normalized config dict for FLOWFarm.""" + _ensure_flowfarm_loaded() + jl = _get_jl_main() + flowfarm_module = jl.FLOWFarm + + scalar_defaults = { + "generator_efficiency": 1.0, + "rated_power": 1e6, + "rated_wind_speed": 10.0, + "cutin_wind_speed": 0.0, + "cutout_wind_speed": 100.0, + } + + missing_scalars = [ + key + for key in scalar_defaults + if key not in windio_turbine or windio_turbine[key] is None + ] + if missing_scalars: + defaults_used = {key: scalar_defaults[key] for key in missing_scalars} + warnings.warn( + f"FLOWFarm missing turbine inputs {missing_scalars}; using defaults {defaults_used}.", + UserWarning, + stacklevel=2, + ) + + performance = windio_turbine.get("performance", {}) + ct_curve = performance.get("Ct_curve", {}) + cp_curve = performance.get("Cp_curve", {}) + + has_ct_curve = ( + "Ct_wind_speeds" in ct_curve + and "Ct_values" in ct_curve + and ct_curve["Ct_wind_speeds"] is not None + and ct_curve["Ct_values"] is not None + ) + has_cp_curve = ( + "Cp_wind_speeds" in cp_curve + and "Cp_values" in cp_curve + and cp_curve["Cp_wind_speeds"] is not None + and cp_curve["Cp_values"] is not None + ) + + constant_ct = performance.get("Ct", performance.get("ct", 0.8)) + constant_cp = performance.get("Cp", performance.get("cp", 0.45)) + + if not has_ct_curve: + warnings.warn( + f"FLOWFarm missing turbine.performance.Ct_curve; using constant Ct={constant_ct}.", + UserWarning, + stacklevel=2, + ) + if not has_cp_curve: + warnings.warn( + f"FLOWFarm missing turbine.performance.Cp_curve; using constant Cp={constant_cp}.", + UserWarning, + stacklevel=2, + ) + + fallback_wind_speeds = [ + float( + windio_turbine.get("cutin_wind_speed", scalar_defaults["cutin_wind_speed"]) + ), + float( + windio_turbine.get("rated_wind_speed", scalar_defaults["rated_wind_speed"]) + ), + float( + windio_turbine.get( + "cutout_wind_speed", scalar_defaults["cutout_wind_speed"] + ) + ), + ] + + power_model = _build_flowfarm_power_model( + flowfarm_module, + has_cp_curve, + cp_curve, + constant_cp, + fallback_wind_speeds, + ) + ct_model = _build_flowfarm_ct_model( + flowfarm_module, + has_ct_curve, + ct_curve, + constant_ct, + fallback_wind_speeds, + ) + + return { + "generator_efficiency": windio_turbine.get( + "generator_efficiency", scalar_defaults["generator_efficiency"] + ), + "rated_power": windio_turbine.get( + "rated_power", scalar_defaults["rated_power"] + ), + "rated_wind_speed": windio_turbine.get( + "rated_wind_speed", scalar_defaults["rated_wind_speed"] + ), + "cutin_wind_speed": windio_turbine.get( + "cutin_wind_speed", scalar_defaults["cutin_wind_speed"] + ), + "cutout_wind_speed": windio_turbine.get( + "cutout_wind_speed", scalar_defaults["cutout_wind_speed"] + ), + "ct_model": ct_model, + "power_model": power_model, + } + + +def resolve_wake_model_inputs_for_flowfarm(flowfarm_model_options): + """Resolve wake model options with defaults and validate user-provided values.""" + if flowfarm_model_options is None: + flowfarm_model_options = {} + if not isinstance(flowfarm_model_options, dict): + raise TypeError("FLOWFarm options must be provided as a dictionary.") + + defaults = { + "wake_deficit_model": "GaussYawVariableSpread", + "wake_deflection_model": "GaussYawVariableSpreadDeflection", + "wake_combination_model": "LinearLocalVelocitySuperposition", + "local_turbulence_model": "LocalTIModelNoLocalTI", + "tolerance": 1e-16, + } + + allowed_values = { + "wake_deficit_model": { + "JensenTopHat", + "JensenCosine", + "MultiZone", + "GaussOriginal", + "GaussYaw", + "GaussYawVariableSpread", + "GaussSimple", + "CumulativeCurl", + "NoWakeDeficit", + }, + "wake_deflection_model": { + "NoYawDeflection", + "GaussYawDeflection", + "GaussYawVariableSpreadDeflection", + "JiminezYawDeflection", + "MultizoneDeflection", + }, + "wake_combination_model": { + "LinearFreestreamSuperposition", + "SumOfSquaresFreestreamSuperposition", + "SumOfSquaresLocalVelocitySuperposition", + "LinearLocalVelocitySuperposition", + }, + "local_turbulence_model": { + "LocalTIModelNoLocalTI", + "LocalTIModelMaxTI", + "LocalTIModelGaussTI", + }, + } + + unknown_keys = [k for k in flowfarm_model_options if k not in defaults] + if unknown_keys: + warnings.warn( + f"FLOWFarm unknown wake model options {unknown_keys}; ignoring these keys.", + UserWarning, + stacklevel=2, + ) + + missing = [ + key + for key in defaults + if key not in flowfarm_model_options or flowfarm_model_options[key] is None + ] + if missing: + defaults_used = {key: defaults[key] for key in missing} + warnings.warn( + f"FLOWFarm missing wake model inputs {missing}; using defaults {defaults_used}.", + UserWarning, + stacklevel=2, + ) + + resolved = {} + model_keys = [ + "wake_deficit_model", + "wake_deflection_model", + "wake_combination_model", + "local_turbulence_model", + ] + for key in model_keys: + value = flowfarm_model_options.get(key, defaults[key]) + if not isinstance(value, str): + raise TypeError( + f"FLOWFarm option '{key}' must be a string. Got {type(value).__name__}." + ) + + value = value.strip() + if not value: + raise ValueError(f"FLOWFarm option '{key}' cannot be empty.") + + allowed_for_key = allowed_values[key] + alias_lookup = {v.lower(): v for v in allowed_for_key} + value_canonical = alias_lookup.get(value.lower()) + if value_canonical is None: + raise ValueError( + f"Invalid FLOWFarm option for '{key}': '{value}'. " + f"Allowed values: {sorted(allowed_for_key)}" + ) + + resolved[key] = value_canonical + + tolerance = flowfarm_model_options.get("tolerance", defaults["tolerance"]) + if not isinstance(tolerance, (int, float)): + raise TypeError( + f"FLOWFarm option 'tolerance' must be numeric. Got {type(tolerance).__name__}." + ) + tolerance = float(tolerance) + if tolerance <= 0.0: + raise ValueError("FLOWFarm option 'tolerance' must be > 0.") + resolved["tolerance"] = tolerance + + return resolved + + diff --git a/ard/flowfarm/julia_env/Project.toml b/ard/farm_aero/flowfarm/julia_env/Project.toml similarity index 72% rename from ard/flowfarm/julia_env/Project.toml rename to ard/farm_aero/flowfarm/julia_env/Project.toml index f0847800..d109c1a2 100644 --- a/ard/flowfarm/julia_env/Project.toml +++ b/ard/farm_aero/flowfarm/julia_env/Project.toml @@ -3,7 +3,7 @@ version = "0.1.0" [deps] FLOWFarm = "eb2d4cfc-2064-11ea-0a1c-63d372e6a848" -Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" [compat] +FLOWFarm = "1" julia = "1.10" diff --git a/ard/flowfarm/__init__.py b/ard/flowfarm/__init__.py index 52570d3d..5a20180e 100644 --- a/ard/flowfarm/__init__.py +++ b/ard/flowfarm/__init__.py @@ -1,10 +1,15 @@ -# ard/farm_aero/flowfarm/__init__.py -from .flowfarm_model import FlowFarmModel -from ._jl_bootstrap import ensure_flowfarm_loaded, get_julia_module, get_julia_runtime +from ard.farm_aero.flowfarm import ( + FLOWFarmAEP, + FLOWFarmBatchPower, + FLOWFarmComponent, + ensure_flowfarm_loaded, + get_julia_runtime, +) __all__ = [ - "FlowFarmModel", + "FLOWFarmAEP", + "FLOWFarmBatchPower", + "FLOWFarmComponent", "ensure_flowfarm_loaded", - "get_julia_module", "get_julia_runtime", ] diff --git a/ard/flowfarm/_jl_bootstrap.py b/ard/flowfarm/_jl_bootstrap.py index 691732b9..51092cff 100644 --- a/ard/flowfarm/_jl_bootstrap.py +++ b/ard/flowfarm/_jl_bootstrap.py @@ -1,121 +1,5 @@ -# ard/farm_aero/flowfarm/_jl_bootstrap.py -from __future__ import annotations -import os -import pathlib -import warnings +import sys -# Hard-coded pin (change/remove later as needed) -FLOWFARM_GIT_URL = "https://github.com/byuflowlab/FLOWFarm.jl" -FLOWFARM_REV = "master" # <-- BRANCH PIN +from ard.farm_aero.flowfarm import _jl_bootstrap as _bootstrap -_jl_module = None -_jl_runtime = None -_flowfarm_env_initialized = False - - -def _normalize_juliacall_env_vars(): - """Normalize JuliaCall env vars so Ard owns bootstrap by default. - - Set ARD_FLOWFARM_RESPECT_JULIACALL_ENV=1 to keep user-provided overrides. - """ - if os.environ.get("ARD_FLOWFARM_RESPECT_JULIACALL_ENV") == "1": - return - - project = os.environ.get("PYTHON_JULIACALL_PROJECT") - exe = os.environ.get("PYTHON_JULIACALL_EXE") - if project or exe: - warnings.warn( - "Ignoring external JuliaCall overrides in Ard bootstrap. " - "Set ARD_FLOWFARM_RESPECT_JULIACALL_ENV=1 to keep user-provided " - "PYTHON_JULIACALL_PROJECT/PYTHON_JULIACALL_EXE values.", - UserWarning, - stacklevel=2, - ) - os.environ.pop("PYTHON_JULIACALL_PROJECT", None) - os.environ.pop("PYTHON_JULIACALL_EXE", None) - - -def get_julia_runtime(): - """Return (Main, Pkg) from JuliaCall with Ard-safe bootstrap behavior.""" - global _jl_runtime - if _jl_runtime is not None: - return _jl_runtime - - _normalize_juliacall_env_vars() - from juliacall import Main as jl_main - from juliacall import Pkg as jl_pkg - - _jl_runtime = (jl_main, jl_pkg) - return _jl_runtime - - -def _is_manifest_mismatch_error(exc: Exception) -> bool: - text = str(exc) - markers = [ - "different julia version", - "manifest generated by a different version of Julia", - "Could not locate the source code for the StyledStrings package", - ] - text_lower = text.lower() - return any(marker.lower() in text_lower for marker in markers) - - -def _rebuild_flowfarm_env(jl_pkg, env_dir: pathlib.Path): - """Rebuild Ard FLOWFarm manifest for the currently running Julia runtime.""" - manifest_path = env_dir / "Manifest.toml" - if manifest_path.exists(): - manifest_path.unlink() - - # Ensure FLOWFarm source is pinned and captured in the new manifest. - try: - jl_pkg.rm("FLOWFarm") - except Exception: - pass - jl_pkg.add(url=FLOWFARM_GIT_URL, rev=FLOWFARM_REV) - jl_pkg.resolve() - jl_pkg.instantiate() - - -def ensure_flowfarm_loaded(): - """Activate Ard Julia env and load FLOWFarm in Julia Main.""" - global _flowfarm_env_initialized - jl_main, jl_pkg = get_julia_runtime() - if not _flowfarm_env_initialized: - env_dir = pathlib.Path(__file__).parent / "julia_env" - jl_pkg.activate(str(env_dir)) - try: - jl_pkg.instantiate() - except Exception as exc: - if not _is_manifest_mismatch_error(exc): - raise - warnings.warn( - "FLOWFarm Julia manifest/runtime mismatch detected. Rebuilding " - "manifest for the current Julia runtime.", - UserWarning, - stacklevel=2, - ) - _rebuild_flowfarm_env(jl_pkg, env_dir) - _flowfarm_env_initialized = True - - if "FLOWFarm" not in dir(jl_main): - jl_main.seval("using FLOWFarm") - return jl_main - - -def get_julia_module(): - """Initialize Julia once, activate env, and return a private module with FLOWFarm loaded.""" - global _jl_module - if _jl_module is not None: - return _jl_module - - ensure_flowfarm_loaded() - import juliacall - - jl = juliacall.newmodule( - "ArdFLOWFarm" - ) # recommended pattern to avoid polluting Main [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) - jl.seval( - "using FLOWFarm" - ) # standard FLOWFarm usage/installation [2](https://github.com/byuflowlab/FlowFarm.jl) - _jl_module = jl - return _jl_module +sys.modules[__name__] = _bootstrap diff --git a/ard/flowfarm/flowfarm_model.py b/ard/flowfarm/flowfarm_model.py index 673577ef..c23e046c 100644 --- a/ard/flowfarm/flowfarm_model.py +++ b/ard/flowfarm/flowfarm_model.py @@ -1,380 +1,5 @@ -# ard/farm_aero/flowfarm/interface.py -from __future__ import annotations +import sys -import os -import pathlib -import warnings -import numpy as np -import pandas as pd +from ard.farm_aero.flowfarm import flowfarm_model as _flowfarm_model -from ._jl_bootstrap import ensure_flowfarm_loaded, get_julia_runtime - -# ------------------------------------------------------------------------------ -# Configuration (project activation) -# ------------------------------------------------------------------------------ - -# If you're using the embedded project (recommended), point to it explicitly: -_THIS_DIR = pathlib.Path(__file__).resolve().parent -_JULIA_PROJECT_DIR = _THIS_DIR / "julia_env" - - -def _get_jl_main(): - jl, _ = get_julia_runtime() - return jl - - -def _ensure_env_activated(): - # Prefer explicit activation over relying on JULIA_PROJECT env var. - _, jl_pkg = get_julia_runtime() - jl_pkg.activate(str(_JULIA_PROJECT_DIR)) - jl_pkg.instantiate() # ensures Manifest is honored / deps are present - - -def _ensure_flowfarm_loaded(): - ensure_flowfarm_loaded() - - -# ------------------------------------------------------------------------------ -# Utility: Julia Vector conversion (optional; JuliaCall already converts NumPy arrays) -# ------------------------------------------------------------------------------ - - -def _jvec(x): - """Convert Python list/array → Julia Vector{Float64} (explicit).""" - jl = _get_jl_main() - return jl.Vector[jl.Float64](list(map(float, np.asarray(x).ravel()))) - - -def _resolve_flowfarm_constructor(flowfarm_module, candidate_names): - """Return the first FLOWFarm constructor that exists from candidate names.""" - for name in candidate_names: - if hasattr(flowfarm_module, name): - return getattr(flowfarm_module, name) - return None - - -def _build_flowfarm_power_model( - flowfarm_module, - has_cp_curve, - cp_curve, - constant_cp, - fallback_wind_speeds, -): - """Build a FLOWFarm power model from Cp curve or constant Cp fallback.""" - if has_cp_curve: - power_points_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["PowerModelCpPoints"], - ) - if power_points_ctor is None: - raise AttributeError( - "FLOWFarm.PowerModelCpPoints constructor was not found." - ) - return power_points_ctor( - _jvec(cp_curve["Cp_wind_speeds"]), - _jvec(cp_curve["Cp_values"]), - ) - - power_constant_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["PowerModelConstantCp", "PowerModelCpConstant"], - ) - if power_constant_ctor is not None: - return power_constant_ctor(float(constant_cp)) - - # Last-resort fallback if constant-Cp constructor name differs by FLOWFarm version. - # Approximate a constant Cp model using points at representative wind speeds. - warnings.warn( - "FLOWFarm constant-Cp constructor not found; falling back to PowerModelCpPoints with constant Cp.", - UserWarning, - stacklevel=2, - ) - power_points_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["PowerModelCpPoints"], - ) - if power_points_ctor is None: - raise AttributeError("FLOWFarm.PowerModelCpPoints constructor was not found.") - cp_values = [float(constant_cp)] * len(fallback_wind_speeds) - return power_points_ctor(_jvec(fallback_wind_speeds), _jvec(cp_values)) - - -def _build_flowfarm_ct_model( - flowfarm_module, - has_ct_curve, - ct_curve, - constant_ct, - fallback_wind_speeds, -): - """Build a FLOWFarm thrust model from Ct curve or constant Ct fallback.""" - if has_ct_curve: - ct_points_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["ThrustModelCtPoints"], - ) - if ct_points_ctor is None: - raise AttributeError( - "FLOWFarm.ThrustModelCtPoints constructor was not found." - ) - return ct_points_ctor( - _jvec(ct_curve["Ct_wind_speeds"]), - _jvec(ct_curve["Ct_values"]), - ) - - ct_constant_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["ThrustModelConstantCt", "ThrustModelCtConstant"], - ) - if ct_constant_ctor is not None: - return ct_constant_ctor(float(constant_ct)) - - # Last-resort fallback if constant-Ct constructor name differs by FLOWFarm version. - warnings.warn( - "FLOWFarm constant-Ct constructor not found; falling back to ThrustModelCtPoints with constant Ct.", - UserWarning, - stacklevel=2, - ) - ct_points_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["ThrustModelCtPoints"], - ) - if ct_points_ctor is None: - raise AttributeError("FLOWFarm.ThrustModelCtPoints constructor was not found.") - ct_values = [float(constant_ct)] * len(fallback_wind_speeds) - return ct_points_ctor(_jvec(fallback_wind_speeds), _jvec(ct_values)) - - -def resolve_turbine_inputs_for_flowfarm(windio_turbine): - """Validate turbine inputs and return a normalized config dict for FLOWFarm.""" - _ensure_flowfarm_loaded() - jl = _get_jl_main() - flowfarm_module = jl.FLOWFarm - - scalar_defaults = { - "generator_efficiency": 1.0, - "rated_power": 1e6, - "rated_wind_speed": 10.0, - "cutin_wind_speed": 0.0, - "cutout_wind_speed": 100.0, - } - - missing_scalars = [ - key - for key in scalar_defaults - if key not in windio_turbine or windio_turbine[key] is None - ] - if missing_scalars: - defaults_used = {key: scalar_defaults[key] for key in missing_scalars} - warnings.warn( - f"FLOWFarm missing turbine inputs {missing_scalars}; using defaults {defaults_used}.", - UserWarning, - stacklevel=2, - ) - - performance = windio_turbine.get("performance", {}) - ct_curve = performance.get("Ct_curve", {}) - cp_curve = performance.get("Cp_curve", {}) - - has_ct_curve = ( - "Ct_wind_speeds" in ct_curve - and "Ct_values" in ct_curve - and ct_curve["Ct_wind_speeds"] is not None - and ct_curve["Ct_values"] is not None - ) - has_cp_curve = ( - "Cp_wind_speeds" in cp_curve - and "Cp_values" in cp_curve - and cp_curve["Cp_wind_speeds"] is not None - and cp_curve["Cp_values"] is not None - ) - - constant_ct = performance.get("Ct", performance.get("ct", 0.8)) - constant_cp = performance.get("Cp", performance.get("cp", 0.45)) - - if not has_ct_curve: - warnings.warn( - f"FLOWFarm missing turbine.performance.Ct_curve; using constant Ct={constant_ct}.", - UserWarning, - stacklevel=2, - ) - if not has_cp_curve: - warnings.warn( - f"FLOWFarm missing turbine.performance.Cp_curve; using constant Cp={constant_cp}.", - UserWarning, - stacklevel=2, - ) - - fallback_wind_speeds = [ - float( - windio_turbine.get("cutin_wind_speed", scalar_defaults["cutin_wind_speed"]) - ), - float( - windio_turbine.get("rated_wind_speed", scalar_defaults["rated_wind_speed"]) - ), - float( - windio_turbine.get( - "cutout_wind_speed", scalar_defaults["cutout_wind_speed"] - ) - ), - ] - - power_model = _build_flowfarm_power_model( - flowfarm_module, - has_cp_curve, - cp_curve, - constant_cp, - fallback_wind_speeds, - ) - ct_model = _build_flowfarm_ct_model( - flowfarm_module, - has_ct_curve, - ct_curve, - constant_ct, - fallback_wind_speeds, - ) - - return { - "generator_efficiency": windio_turbine.get( - "generator_efficiency", scalar_defaults["generator_efficiency"] - ), - "rated_power": windio_turbine.get( - "rated_power", scalar_defaults["rated_power"] - ), - "rated_wind_speed": windio_turbine.get( - "rated_wind_speed", scalar_defaults["rated_wind_speed"] - ), - "cutin_wind_speed": windio_turbine.get( - "cutin_wind_speed", scalar_defaults["cutin_wind_speed"] - ), - "cutout_wind_speed": windio_turbine.get( - "cutout_wind_speed", scalar_defaults["cutout_wind_speed"] - ), - "ct_model": ct_model, - "power_model": power_model, - } - - -def resolve_wake_model_inputs_for_flowfarm(flowfarm_model_options): - """Resolve wake model options with defaults and validate user-provided values.""" - if flowfarm_model_options is None: - flowfarm_model_options = {} - if not isinstance(flowfarm_model_options, dict): - raise TypeError("FLOWFarm options must be provided as a dictionary.") - - defaults = { - "wake_deficit_model": "GaussYawVariableSpread", - "wake_deflection_model": "GaussYawVariableSpreadDeflection", - "wake_combination_model": "LinearLocalVelocitySuperposition", - "local_turbulence_model": "LocalTIModelNoLocalTI", - "tolerance": 1e-16, - } - - allowed_values = { - "wake_deficit_model": { - "JensenTopHat", - "JensenCosine", - "MultiZone", - "GaussOriginal", - "GaussYaw", - "GaussYawVariableSpread", - "GaussSimple", - "CumulativeCurl", - "NoWakeDeficit", - }, - "wake_deflection_model": { - "NoYawDeflection", - "GaussYawDeflection", - "GaussYawVariableSpreadDeflection", - "JiminezYawDeflection", - "MultizoneDeflection", - }, - "wake_combination_model": { - "LinearFreestreamSuperposition", - "SumOfSquaresFreestreamSuperposition", - "SumOfSquaresLocalVelocitySuperposition", - "LinearLocalVelocitySuperposition", - }, - "local_turbulence_model": { - "LocalTIModelNoLocalTI", - "LocalTIModelMaxTI", - "LocalTIModelGaussTI", - }, - } - - unknown_keys = [k for k in flowfarm_model_options if k not in defaults] - if unknown_keys: - warnings.warn( - f"FLOWFarm unknown wake model options {unknown_keys}; ignoring these keys.", - UserWarning, - stacklevel=2, - ) - - missing = [ - key - for key in defaults - if key not in flowfarm_model_options or flowfarm_model_options[key] is None - ] - if missing: - defaults_used = {key: defaults[key] for key in missing} - warnings.warn( - f"FLOWFarm missing wake model inputs {missing}; using defaults {defaults_used}.", - UserWarning, - stacklevel=2, - ) - - resolved = {} - model_keys = [ - "wake_deficit_model", - "wake_deflection_model", - "wake_combination_model", - "local_turbulence_model", - ] - for key in model_keys: - value = flowfarm_model_options.get(key, defaults[key]) - if not isinstance(value, str): - raise TypeError( - f"FLOWFarm option '{key}' must be a string. Got {type(value).__name__}." - ) - - value = value.strip() - if not value: - raise ValueError(f"FLOWFarm option '{key}' cannot be empty.") - - allowed_for_key = allowed_values[key] - alias_lookup = {v.lower(): v for v in allowed_for_key} - value_canonical = alias_lookup.get(value.lower()) - if value_canonical is None: - raise ValueError( - f"Invalid FLOWFarm option for '{key}': '{value}'. " - f"Allowed values: {sorted(allowed_for_key)}" - ) - - resolved[key] = value_canonical - - tolerance = flowfarm_model_options.get("tolerance", defaults["tolerance"]) - if not isinstance(tolerance, (int, float)): - raise TypeError( - f"FLOWFarm option 'tolerance' must be numeric. Got {type(tolerance).__name__}." - ) - tolerance = float(tolerance) - if tolerance <= 0.0: - raise ValueError("FLOWFarm option 'tolerance' must be > 0.") - resolved["tolerance"] = tolerance - - return resolved - - -# ------------------------------------------------------------------------------ -# Public interface -# ------------------------------------------------------------------------------ - - -class FlowFarmModel: - - def __init__(self, wind_rose, layout_x, layout_y, yaw_turbine): - _ensure_env_activated() - _ensure_flowfarm_loaded() - - n_turbines = len(layout_x) - - self.farm, self.sparse_struct = load_flowfarm_model() +sys.modules[__name__] = _flowfarm_model diff --git a/ard/flowfarm/pin_flowfarm.py b/ard/flowfarm/pin_flowfarm.py deleted file mode 100644 index acfcc26b..00000000 --- a/ard/flowfarm/pin_flowfarm.py +++ /dev/null @@ -1,47 +0,0 @@ -# ard/farm_aero/flowfarm/pin_flowfarm.py -from __future__ import annotations -import sys, pathlib -import juliacall -from juliacall import ( - Pkg as jlPkg, -) # JuliaPkg via JuliaCall (documented) [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) - -FLOWFARM_GIT_URL = "https://github.com/byuflowlab/FLOWFarm.jl" -FLOWFARM_REV = "typestability" # <-- BRANCH PIN - - -def main(argv=None): - env_dir = pathlib.Path(__file__).parent / "julia_env" - print(f"[pin] Activating: {env_dir}") - jlPkg.activate(str(env_dir)) - print("[pin] Instantiating (may download packages on first run)…") - jlPkg.instantiate() # creates/updates Manifest.toml [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) - - # If FLOWFarm exists with a different source/UUID, replace it with our pin. - try: - jlPkg.rm("FLOWFarm") - except Exception: - pass - - print(f"[pin] Pkg.add url={FLOWFARM_GIT_URL} rev={FLOWFARM_REV}") - jlPkg.add( - url=FLOWFARM_GIT_URL, rev=FLOWFARM_REV - ) # captures exact revision in Manifest [1](https://juliapy.github.io/PythonCall.jl/stable/juliacall/) - - jl = juliacall.newmodule("ArdFLOWFarmPin") - print("[pin] Loading FLOWFarm…") - jl.seval( - "using FLOWFarm" - ) # FLOWFarm usage/install documented in repo [2](https://github.com/byuflowlab/FlowFarm.jl) - - # Optional: precompile to warm caches on first run - if "--precompile" in (argv or []): - print("[pin] Precompiling Julia environment…") - jlPkg.precompile() - - manifest = env_dir / "Manifest.toml" - print(f"[done] Manifest at: {manifest if manifest.exists() else '(missing)'}") - - -if __name__ == "__main__": - main(sys.argv[1:]) diff --git a/examples/07_flowfarm_setup/inputs/ard_system.yaml b/examples/07_flowfarm_setup/inputs/ard_system.yaml index 65d5f34a..b7500a3f 100644 --- a/examples/07_flowfarm_setup/inputs/ard_system.yaml +++ b/examples/07_flowfarm_setup/inputs/ard_system.yaml @@ -43,7 +43,6 @@ modeling_options: &modeling_options opex_per_kW: 44.0 stdio_capture: true flowfarm: - ref_air_density: 1.225 wake_deficit_model: GaussYawVariableSpread wake_deflection_model: GaussYawVariableSpreadDeflection wake_combination_model: LinearLocalVelocitySuperposition diff --git a/test/ard/unit/farm_aero/test_flowfarm_component.py b/test/ard/unit/farm_aero/test_flowfarm_component.py index 5f455958..ad53a6ac 100644 --- a/test/ard/unit/farm_aero/test_flowfarm_component.py +++ b/test/ard/unit/farm_aero/test_flowfarm_component.py @@ -1,5 +1,5 @@ """ -Unit tests for ard/farm_aero/flowfarm.py. +Unit tests for ard/farm_aero/flowfarm/component.py. Julia is mocked entirely — these tests cover the Python-layer logic of FLOWFarmComponent, FLOWFarmAEP, and FLOWFarmBatchPower without starting Julia. diff --git a/test/conftest.py b/test/conftest.py index fc3d1fd0..3c0d0b23 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,20 +1,13 @@ -import os from pathlib import Path -# Disable OpenMDAO auto-report generation (n2/inputs/html artifacts) in tests. -os.environ.setdefault("OPENMDAO_REPORTS", "0") - - def pytest_sessionfinish(session, exitstatus): # cleanup code after tests # remove pytest and OpenMDAO report output directories from cwd for pattern in ("pytest*_out", "__main__*_out"): for out_dir in Path().glob(pattern): - for root, dirs, files in out_dir.walk( - top_down=False - ): # walk the directory + for root, dirs, files in out_dir.walk(top_down=False): # walk the directory for name in files: (root / name).unlink() # remove subdirectory files, and for name in dirs: diff --git a/test/flowfarm/unit/test_flowfarm_model.py b/test/flowfarm/unit/test_flowfarm_model.py index a6d8da87..223214d7 100644 --- a/test/flowfarm/unit/test_flowfarm_model.py +++ b/test/flowfarm/unit/test_flowfarm_model.py @@ -1,5 +1,5 @@ """ -Unit tests for ard/flowfarm/flowfarm_model.py. +Unit tests for ard/farm_aero/flowfarm/flowfarm_model.py. resolve_wake_model_inputs_for_flowfarm is pure Python and tested without any mocking. resolve_turbine_inputs_for_flowfarm calls Julia internally; those calls are patched. diff --git a/test/flowfarm/unit/test_jl_bootstrap.py b/test/flowfarm/unit/test_jl_bootstrap.py index e8dd050f..31fd9b06 100644 --- a/test/flowfarm/unit/test_jl_bootstrap.py +++ b/test/flowfarm/unit/test_jl_bootstrap.py @@ -1,30 +1,22 @@ """ -Unit tests for ard/flowfarm/_jl_bootstrap.py. +Unit tests for ard/farm_aero/flowfarm/_jl_bootstrap.py. juliacall is mocked entirely via sys.modules — Julia does not need to be installed for these tests. """ -import os -import pathlib import sys -import warnings -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock import pytest import ard.flowfarm._jl_bootstrap as bootstrap -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - @pytest.fixture(autouse=True) def reset_bootstrap_globals(monkeypatch): """Reset module-level singletons before each test so state never leaks.""" monkeypatch.setattr(bootstrap, "_jl_runtime", None) - monkeypatch.setattr(bootstrap, "_jl_module", None) monkeypatch.setattr(bootstrap, "_flowfarm_env_initialized", False) @@ -38,97 +30,6 @@ def mock_juliacall(monkeypatch): return mock -# --------------------------------------------------------------------------- -# _normalize_juliacall_env_vars -# --------------------------------------------------------------------------- - - -class TestNormalizeJuliacallEnvVars: - - def test_strips_project_and_exe_and_warns(self, monkeypatch): - monkeypatch.setenv("PYTHON_JULIACALL_PROJECT", "/some/project") - monkeypatch.setenv("PYTHON_JULIACALL_EXE", "julia") - monkeypatch.delenv("ARD_FLOWFARM_RESPECT_JULIACALL_ENV", raising=False) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - bootstrap._normalize_juliacall_env_vars() - - assert len(caught) == 1 - assert "Ignoring" in str(caught[0].message) - assert "PYTHON_JULIACALL_PROJECT" not in os.environ - assert "PYTHON_JULIACALL_EXE" not in os.environ - - def test_strips_only_project_when_exe_absent(self, monkeypatch): - monkeypatch.setenv("PYTHON_JULIACALL_PROJECT", "/some/project") - monkeypatch.delenv("PYTHON_JULIACALL_EXE", raising=False) - monkeypatch.delenv("ARD_FLOWFARM_RESPECT_JULIACALL_ENV", raising=False) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - bootstrap._normalize_juliacall_env_vars() - - assert len(caught) == 1 - assert "PYTHON_JULIACALL_PROJECT" not in os.environ - - def test_respects_opt_in_flag(self, monkeypatch): - monkeypatch.setenv("ARD_FLOWFARM_RESPECT_JULIACALL_ENV", "1") - monkeypatch.setenv("PYTHON_JULIACALL_EXE", "julia +1.10") - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - bootstrap._normalize_juliacall_env_vars() - - assert len(caught) == 0 - assert os.environ["PYTHON_JULIACALL_EXE"] == "julia +1.10" - - def test_noop_when_no_vars_set(self, monkeypatch): - monkeypatch.delenv("PYTHON_JULIACALL_PROJECT", raising=False) - monkeypatch.delenv("PYTHON_JULIACALL_EXE", raising=False) - monkeypatch.delenv("ARD_FLOWFARM_RESPECT_JULIACALL_ENV", raising=False) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - bootstrap._normalize_juliacall_env_vars() - - assert len(caught) == 0 - - -# --------------------------------------------------------------------------- -# _is_manifest_mismatch_error -# --------------------------------------------------------------------------- - - -class TestIsManifestMismatchError: - - @pytest.mark.parametrize( - "msg", - [ - "manifest resolved with a different julia version", - "Manifest generated by a different version of Julia", - "Could not locate the source code for the StyledStrings package", - "DIFFERENT JULIA VERSION", # case-insensitive - "prefix text: different julia version :suffix", - ], - ) - def test_returns_true_for_known_markers(self, msg): - assert bootstrap._is_manifest_mismatch_error(Exception(msg)) is True - - def test_returns_false_for_unrelated_error(self): - assert ( - bootstrap._is_manifest_mismatch_error(Exception("FLOWFarm not found")) - is False - ) - - def test_returns_false_for_empty_message(self): - assert bootstrap._is_manifest_mismatch_error(Exception("")) is False - - -# --------------------------------------------------------------------------- -# get_julia_runtime -# --------------------------------------------------------------------------- - - class TestGetJuliaRuntime: def test_returns_main_and_pkg(self, mock_juliacall): @@ -141,138 +42,26 @@ def test_singleton_returns_same_tuple(self, mock_juliacall): result2 = bootstrap.get_julia_runtime() assert result1 is result2 - def test_juliacall_import_not_repeated(self, mock_juliacall): - bootstrap.get_julia_runtime() - bootstrap.get_julia_runtime() - # Runtime was cached; Main is still the same mock object - assert bootstrap._jl_runtime is not None - assert bootstrap._jl_runtime[0] is mock_juliacall.Main - - -# --------------------------------------------------------------------------- -# _rebuild_flowfarm_env -# --------------------------------------------------------------------------- - - -class TestRebuildFlowfarmEnv: - - def test_deletes_existing_manifest(self, tmp_path): - manifest = tmp_path / "Manifest.toml" - manifest.write_text("old manifest content") - mock_pkg = MagicMock() - - bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) - - assert not manifest.exists() - - def test_no_error_when_manifest_absent(self, tmp_path): - mock_pkg = MagicMock() - bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) # should not raise - - def test_adds_flowfarm_from_pinned_url(self, tmp_path): - mock_pkg = MagicMock() - - bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) - - mock_pkg.add.assert_called_once_with( - url=bootstrap.FLOWFARM_GIT_URL, - rev=bootstrap.FLOWFARM_REV, - ) - - def test_calls_resolve_and_instantiate(self, tmp_path): - mock_pkg = MagicMock() - - bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) - - mock_pkg.resolve.assert_called_once() - mock_pkg.instantiate.assert_called_once() - - def test_rm_failure_is_silenced(self, tmp_path): - mock_pkg = MagicMock() - mock_pkg.rm.side_effect = RuntimeError("FLOWFarm not in environment") - - bootstrap._rebuild_flowfarm_env(mock_pkg, tmp_path) - - # add should still be called despite rm failing - mock_pkg.add.assert_called_once() - - -# --------------------------------------------------------------------------- -# ensure_flowfarm_loaded -# --------------------------------------------------------------------------- - class TestEnsureFlowfarmLoaded: - def test_activates_julia_env(self, mock_juliacall): - bootstrap.ensure_flowfarm_loaded() - mock_juliacall.Pkg.activate.assert_called_once() + def test_activates_instantiates_and_loads_flowfarm(self, mock_juliacall): + result = bootstrap.ensure_flowfarm_loaded() - def test_instantiates_julia_env(self, mock_juliacall): - bootstrap.ensure_flowfarm_loaded() + mock_juliacall.Pkg.activate.assert_called_once() mock_juliacall.Pkg.instantiate.assert_called_once() - - def test_calls_seval_to_load_flowfarm(self, mock_juliacall): - bootstrap.ensure_flowfarm_loaded() mock_juliacall.Main.seval.assert_called_once_with("using FLOWFarm") - - def test_returns_jl_main(self, mock_juliacall): - result = bootstrap.ensure_flowfarm_loaded() assert result is mock_juliacall.Main def test_does_not_reinitialize_on_second_call(self, mock_juliacall): bootstrap.ensure_flowfarm_loaded() + mock_juliacall.Main.FLOWFarm = MagicMock(name="FLOWFarm") mock_juliacall.Pkg.activate.reset_mock() mock_juliacall.Pkg.instantiate.reset_mock() + mock_juliacall.Main.seval.reset_mock() bootstrap.ensure_flowfarm_loaded() mock_juliacall.Pkg.activate.assert_not_called() mock_juliacall.Pkg.instantiate.assert_not_called() - - def test_rebuilds_manifest_on_version_mismatch(self, mock_juliacall, monkeypatch): - mock_juliacall.Pkg.instantiate.side_effect = Exception( - "manifest resolved with a different julia version" - ) - rebuild_mock = MagicMock() - monkeypatch.setattr(bootstrap, "_rebuild_flowfarm_env", rebuild_mock) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - bootstrap.ensure_flowfarm_loaded() - - rebuild_mock.assert_called_once() - assert any("mismatch" in str(w.message).lower() for w in caught) - - def test_reraises_non_mismatch_errors(self, mock_juliacall): - mock_juliacall.Pkg.instantiate.side_effect = RuntimeError("disk full") - - with pytest.raises(RuntimeError, match="disk full"): - bootstrap.ensure_flowfarm_loaded() - - -# --------------------------------------------------------------------------- -# get_julia_module -# --------------------------------------------------------------------------- - - -class TestGetJuliaModule: - - def test_returns_module_object(self, mock_juliacall, monkeypatch): - mock_juliacall.newmodule = MagicMock(return_value=MagicMock(name="ArdFLOWFarm")) - monkeypatch.setitem(sys.modules, "juliacall", mock_juliacall) - - result = bootstrap.get_julia_module() - - mock_juliacall.newmodule.assert_called_once_with("ArdFLOWFarm") - assert result is not None - - def test_singleton_returns_same_module(self, mock_juliacall, monkeypatch): - mock_juliacall.newmodule = MagicMock(return_value=MagicMock(name="ArdFLOWFarm")) - monkeypatch.setitem(sys.modules, "juliacall", mock_juliacall) - - result1 = bootstrap.get_julia_module() - result2 = bootstrap.get_julia_module() - - assert result1 is result2 - mock_juliacall.newmodule.assert_called_once() # not called twice + mock_juliacall.Main.seval.assert_not_called() From 975d767b3365a5157d4defbd316838d9c6b8447e Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:21:38 -0600 Subject: [PATCH 13/17] more cleanup --- ard/farm_aero/flowfarm/README.md | 18 +++ ard/farm_aero/flowfarm/component.py | 14 +- ard/farm_aero/flowfarm/flowfarm_model.py | 152 ++++------------------ test/flowfarm/unit/test_flowfarm_model.py | 55 +------- 4 files changed, 49 insertions(+), 190 deletions(-) diff --git a/ard/farm_aero/flowfarm/README.md b/ard/farm_aero/flowfarm/README.md index d7e354e6..4ed57a58 100644 --- a/ard/farm_aero/flowfarm/README.md +++ b/ard/farm_aero/flowfarm/README.md @@ -97,6 +97,24 @@ modeling_options: tolerance: 1.0e-16 ``` +## Supported FLOWFarm names + +Use the exact FLOWFarm constructor and model names expected by the integration. + +### Turbine model constructors + +- `PowerModelCpPoints` +- `PowerModelConstantCp` +- `ThrustModelCtPoints` +- `ThrustModelConstantCt` + +### Wake model options + +- `wake_deficit_model`: `JensenTopHat`, `JensenCosine`, `MultiZone`, `GaussOriginal`, `GaussYaw`, `GaussYawVariableSpread`, `GaussSimple`, `CumulativeCurl`, `NoWakeDeficit` +- `wake_deflection_model`: `NoYawDeflection`, `GaussYawDeflection`, `GaussYawVariableSpreadDeflection`, `JiminezYawDeflection`, `MultizoneDeflection` +- `wake_combination_model`: `LinearFreestreamSuperposition`, `SumOfSquaresFreestreamSuperposition`, `SumOfSquaresLocalVelocitySuperposition`, `LinearLocalVelocitySuperposition` +- `local_turbulence_model`: `LocalTIModelNoLocalTI`, `LocalTIModelMaxTI`, `LocalTIModelGaussTI` + ## Key files - `_jl_bootstrap.py`: Julia runtime bootstrap and env activation helpers. diff --git a/ard/farm_aero/flowfarm/component.py b/ard/farm_aero/flowfarm/component.py index 290d8634..34ce310c 100644 --- a/ard/farm_aero/flowfarm/component.py +++ b/ard/farm_aero/flowfarm/component.py @@ -5,6 +5,7 @@ ensure_flowfarm_loaded, resolve_turbine_inputs_for_flowfarm, resolve_wake_model_inputs_for_flowfarm, + to_julia_vector_float64, ) from .. import templates @@ -22,9 +23,6 @@ def _get_air_density(self, wind_resource): def _get_wake_model_options(self, model_options): return resolve_wake_model_inputs_for_flowfarm(model_options.get("flowfarm", {})) - def _to_julia_vector(self, jl, values): - return jl.Vector[jl.Float64](list(map(float, np.asarray(values).ravel()))) - def _build_wind_resource( self, jl, @@ -34,11 +32,11 @@ def _build_wind_resource( ref_air_density, wind_shear, ): - wind_dirs_rad = self._to_julia_vector( + wind_dirs_rad = to_julia_vector_float64( jl, np.deg2rad(np.asarray(windrose_floris.wd_flat)) ) - wind_speeds_vec = self._to_julia_vector(jl, windrose_floris.ws_flat) - wind_probs_vec = self._to_julia_vector(jl, windrose_floris.freq_table_flat) + wind_speeds_vec = to_julia_vector_float64(jl, windrose_floris.ws_flat) + wind_probs_vec = to_julia_vector_float64(jl, windrose_floris.freq_table_flat) n_states = len(windrose_floris.ws_flat) ambient_tis = jl.fill(float(np.mean(windrose_floris.ti_table_flat)), n_states) measurementheight = jl.fill(float(ref_height), n_states) @@ -264,7 +262,7 @@ def _evaluate_sparse(self, x_eval_np): if jl is None: jl = ensure_flowfarm_loaded() self._jl = jl - x_eval = self._to_julia_vector(jl, x_eval_np) + x_eval = to_julia_vector_float64(jl, x_eval_np) calculate_grad_bang = getattr(self.flowfarm_module, "calculate_aep_gradient!") aep_val, grad_val = calculate_grad_bang( self.sparse_farm, @@ -287,7 +285,7 @@ def _evaluate_farm(self, x_eval_np): if jl is None: jl = ensure_flowfarm_loaded() self._jl = jl - x_eval = self._to_julia_vector(jl, x_eval_np) + x_eval = to_julia_vector_float64(jl, x_eval_np) calculate_aep_bang = getattr(self.flowfarm_module, "calculate_aep!") aep_val = calculate_aep_bang(self.farm, x_eval) diff --git a/ard/farm_aero/flowfarm/flowfarm_model.py b/ard/farm_aero/flowfarm/flowfarm_model.py index ae39bc6b..43afd79b 100644 --- a/ard/farm_aero/flowfarm/flowfarm_model.py +++ b/ard/farm_aero/flowfarm/flowfarm_model.py @@ -1,53 +1,16 @@ from __future__ import annotations -import pathlib import warnings import numpy as np from ._jl_bootstrap import ensure_flowfarm_loaded, get_julia_runtime # ------------------------------------------------------------------------------ -# Configuration (project activation) +# Utility: Julia Vector conversion # ------------------------------------------------------------------------------ - -# If you're using the embedded project (recommended), point to it explicitly: -_THIS_DIR = pathlib.Path(__file__).resolve().parent -_JULIA_PROJECT_DIR = _THIS_DIR / "julia_env" - - -def _get_jl_main(): - jl, _ = get_julia_runtime() - return jl - - -def _ensure_env_activated(): - # Prefer explicit activation over relying on JULIA_PROJECT env var. - _, jl_pkg = get_julia_runtime() - jl_pkg.activate(str(_JULIA_PROJECT_DIR)) - jl_pkg.instantiate() # ensures Manifest is honored / deps are present - - -def _ensure_flowfarm_loaded(): - ensure_flowfarm_loaded() - - -# ------------------------------------------------------------------------------ -# Utility: Julia Vector conversion (optional; JuliaCall already converts NumPy arrays) -# ------------------------------------------------------------------------------ - - -def _jvec(x): - """Convert Python list/array → Julia Vector{Float64} (explicit).""" - jl = _get_jl_main() - return jl.Vector[jl.Float64](list(map(float, np.asarray(x).ravel()))) - - -def _resolve_flowfarm_constructor(flowfarm_module, candidate_names): - """Return the first FLOWFarm constructor that exists from candidate names.""" - for name in candidate_names: - if hasattr(flowfarm_module, name): - return getattr(flowfarm_module, name) - return None +def to_julia_vector_float64(jl, values): + """Convert Python list/array to Julia Vector{Float64}.""" + return jl.Vector[jl.Float64](list(map(float, np.asarray(values).ravel()))) def _build_flowfarm_power_model( @@ -55,45 +18,18 @@ def _build_flowfarm_power_model( has_cp_curve, cp_curve, constant_cp, - fallback_wind_speeds, ): - """Build a FLOWFarm power model from Cp curve or constant Cp fallback.""" + """Build a FLOWFarm power model from Cp curve or constant Cp.""" + power_points_ctor = flowfarm_module.PowerModelCpPoints + if has_cp_curve: - power_points_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["PowerModelCpPoints"], - ) - if power_points_ctor is None: - raise AttributeError( - "FLOWFarm.PowerModelCpPoints constructor was not found." - ) + jl, _ = get_julia_runtime() return power_points_ctor( - _jvec(cp_curve["Cp_wind_speeds"]), - _jvec(cp_curve["Cp_values"]), + to_julia_vector_float64(jl, cp_curve["Cp_wind_speeds"]), + to_julia_vector_float64(jl, cp_curve["Cp_values"]), ) - power_constant_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["PowerModelConstantCp", "PowerModelCpConstant"], - ) - if power_constant_ctor is not None: - return power_constant_ctor(float(constant_cp)) - - # Last-resort fallback if constant-Cp constructor name differs by FLOWFarm version. - # Approximate a constant Cp model using points at representative wind speeds. - warnings.warn( - "FLOWFarm constant-Cp constructor not found; falling back to PowerModelCpPoints with constant Cp.", - UserWarning, - stacklevel=2, - ) - power_points_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["PowerModelCpPoints"], - ) - if power_points_ctor is None: - raise AttributeError("FLOWFarm.PowerModelCpPoints constructor was not found.") - cp_values = [float(constant_cp)] * len(fallback_wind_speeds) - return power_points_ctor(_jvec(fallback_wind_speeds), _jvec(cp_values)) + return flowfarm_module.PowerModelConstantCp(float(constant_cp)) def _build_flowfarm_ct_model( @@ -101,50 +37,24 @@ def _build_flowfarm_ct_model( has_ct_curve, ct_curve, constant_ct, - fallback_wind_speeds, ): - """Build a FLOWFarm thrust model from Ct curve or constant Ct fallback.""" + """Build a FLOWFarm thrust model from Ct curve or constant Ct.""" + ct_points_ctor = flowfarm_module.ThrustModelCtPoints + if has_ct_curve: - ct_points_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["ThrustModelCtPoints"], - ) - if ct_points_ctor is None: - raise AttributeError( - "FLOWFarm.ThrustModelCtPoints constructor was not found." - ) + jl, _ = get_julia_runtime() return ct_points_ctor( - _jvec(ct_curve["Ct_wind_speeds"]), - _jvec(ct_curve["Ct_values"]), + to_julia_vector_float64(jl, ct_curve["Ct_wind_speeds"]), + to_julia_vector_float64(jl, ct_curve["Ct_values"]), ) - ct_constant_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["ThrustModelConstantCt", "ThrustModelCtConstant"], - ) - if ct_constant_ctor is not None: - return ct_constant_ctor(float(constant_ct)) - - # Last-resort fallback if constant-Ct constructor name differs by FLOWFarm version. - warnings.warn( - "FLOWFarm constant-Ct constructor not found; falling back to ThrustModelCtPoints with constant Ct.", - UserWarning, - stacklevel=2, - ) - ct_points_ctor = _resolve_flowfarm_constructor( - flowfarm_module, - ["ThrustModelCtPoints"], - ) - if ct_points_ctor is None: - raise AttributeError("FLOWFarm.ThrustModelCtPoints constructor was not found.") - ct_values = [float(constant_ct)] * len(fallback_wind_speeds) - return ct_points_ctor(_jvec(fallback_wind_speeds), _jvec(ct_values)) + return flowfarm_module.ThrustModelConstantCt(float(constant_ct)) def resolve_turbine_inputs_for_flowfarm(windio_turbine): """Validate turbine inputs and return a normalized config dict for FLOWFarm.""" - _ensure_flowfarm_loaded() - jl = _get_jl_main() + ensure_flowfarm_loaded() + jl, _ = get_julia_runtime() flowfarm_module = jl.FLOWFarm scalar_defaults = { @@ -201,33 +111,17 @@ def resolve_turbine_inputs_for_flowfarm(windio_turbine): stacklevel=2, ) - fallback_wind_speeds = [ - float( - windio_turbine.get("cutin_wind_speed", scalar_defaults["cutin_wind_speed"]) - ), - float( - windio_turbine.get("rated_wind_speed", scalar_defaults["rated_wind_speed"]) - ), - float( - windio_turbine.get( - "cutout_wind_speed", scalar_defaults["cutout_wind_speed"] - ) - ), - ] - power_model = _build_flowfarm_power_model( flowfarm_module, has_cp_curve, cp_curve, constant_cp, - fallback_wind_speeds, ) ct_model = _build_flowfarm_ct_model( flowfarm_module, has_ct_curve, ct_curve, constant_ct, - fallback_wind_speeds, ) return { @@ -338,15 +232,13 @@ def resolve_wake_model_inputs_for_flowfarm(flowfarm_model_options): raise ValueError(f"FLOWFarm option '{key}' cannot be empty.") allowed_for_key = allowed_values[key] - alias_lookup = {v.lower(): v for v in allowed_for_key} - value_canonical = alias_lookup.get(value.lower()) - if value_canonical is None: + if value not in allowed_for_key: raise ValueError( f"Invalid FLOWFarm option for '{key}': '{value}'. " f"Allowed values: {sorted(allowed_for_key)}" ) - resolved[key] = value_canonical + resolved[key] = value tolerance = flowfarm_model_options.get("tolerance", defaults["tolerance"]) if not isinstance(tolerance, (int, float)): diff --git a/test/flowfarm/unit/test_flowfarm_model.py b/test/flowfarm/unit/test_flowfarm_model.py index 223214d7..ded8e9df 100644 --- a/test/flowfarm/unit/test_flowfarm_model.py +++ b/test/flowfarm/unit/test_flowfarm_model.py @@ -3,7 +3,6 @@ resolve_wake_model_inputs_for_flowfarm is pure Python and tested without any mocking. resolve_turbine_inputs_for_flowfarm calls Julia internally; those calls are patched. -_resolve_flowfarm_constructor is pure Python and tested with simple mock objects. """ import warnings @@ -12,7 +11,6 @@ import pytest from ard.flowfarm.flowfarm_model import ( - _resolve_flowfarm_constructor, resolve_turbine_inputs_for_flowfarm, resolve_wake_model_inputs_for_flowfarm, ) @@ -58,19 +56,6 @@ def test_explicit_valid_options_pass_through(self): assert result["local_turbulence_model"] == "LocalTIModelMaxTI" assert result["tolerance"] == pytest.approx(1e-8) - def test_case_insensitive_matching(self): - opts = { - "wake_deficit_model": "jensentophat", - "wake_deflection_model": "NOYAWDEFLECTION", - "wake_combination_model": "linearfreestreamSuperposition", - "local_turbulence_model": "localtimodelmaXTI", - "tolerance": 1e-6, - } - result = resolve_wake_model_inputs_for_flowfarm(opts) - - assert result["wake_deficit_model"] == "JensenTopHat" - assert result["wake_deflection_model"] == "NoYawDeflection" - def test_invalid_deficit_model_raises_value_error(self): with pytest.raises(ValueError, match="wake_deficit_model"): resolve_wake_model_inputs_for_flowfarm({"wake_deficit_model": "NotAModel"}) @@ -141,40 +126,6 @@ def test_missing_keys_warn_with_defaults_used(self): assert any("missing" in str(w.message).lower() for w in caught) -# --------------------------------------------------------------------------- -# _resolve_flowfarm_constructor (pure Python — no Julia needed) -# --------------------------------------------------------------------------- - - -class TestResolveFlowfarmConstructor: - - def test_returns_first_matching_candidate(self): - mock_module = MagicMock() - mock_ctor = MagicMock(name="PowerModelCpPoints") - mock_module.PowerModelCpPoints = mock_ctor - - result = _resolve_flowfarm_constructor( - mock_module, ["PowerModelCpPoints", "PowerModelCpConstant"] - ) - assert result is mock_ctor - - def test_returns_second_when_first_absent(self): - mock_module = MagicMock(spec=["PowerModelCpConstant"]) - mock_ctor = MagicMock(name="PowerModelCpConstant") - mock_module.PowerModelCpConstant = mock_ctor - - result = _resolve_flowfarm_constructor( - mock_module, ["PowerModelCpPoints", "PowerModelCpConstant"] - ) - assert result is mock_ctor - - def test_returns_none_when_no_candidate_exists(self): - mock_module = MagicMock(spec=[]) # no attributes - - result = _resolve_flowfarm_constructor(mock_module, ["Missing1", "Missing2"]) - assert result is None - - # --------------------------------------------------------------------------- # resolve_turbine_inputs_for_flowfarm (Julia calls mocked) # --------------------------------------------------------------------------- @@ -209,8 +160,8 @@ def patched_julia(): mock_ct_model = MagicMock(name="CtModel") with ( - patch("ard.flowfarm.flowfarm_model._ensure_flowfarm_loaded"), - patch("ard.flowfarm.flowfarm_model._get_jl_main") as mock_jl_main, + patch("ard.flowfarm.flowfarm_model.ensure_flowfarm_loaded"), + patch("ard.flowfarm.flowfarm_model.get_julia_runtime") as mock_jl_runtime, patch( "ard.flowfarm.flowfarm_model._build_flowfarm_power_model", return_value=mock_power_model, @@ -220,7 +171,7 @@ def patched_julia(): return_value=mock_ct_model, ), ): - mock_jl_main.return_value = MagicMock(FLOWFarm=mock_ff_module) + mock_jl_runtime.return_value = (MagicMock(FLOWFarm=mock_ff_module), MagicMock()) yield {"power_model": mock_power_model, "ct_model": mock_ct_model} From f1e93634a53f3a4a14737066ce5b65ea54d6d326 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:35:34 -0600 Subject: [PATCH 14/17] test update --- .github/workflows/julia-tests.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/julia-tests.yaml b/.github/workflows/julia-tests.yaml index 76cab5b9..17c9c86b 100644 --- a/.github/workflows/julia-tests.yaml +++ b/.github/workflows/julia-tests.yaml @@ -29,7 +29,7 @@ jobs: - name: Set up Julia uses: julia-actions/setup-julia@v2 with: - version: "1" # latest stable 1.x + version: "1.10" - name: Cache Julia packages uses: julia-actions/cache@v2 @@ -40,7 +40,7 @@ jobs: - 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.resolve(); Pkg.instantiate()" + julia --project=ard/farm_aero/flowfarm/julia_env -e "using Pkg; Pkg.instantiate(); Pkg.precompile()" - name: Run FLOWFarm integration tests run: | From 3e2ed0161164140b4ed7927db6b0af665043d866 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Tue, 26 May 2026 14:29:36 -0600 Subject: [PATCH 15/17] model changes --- ard/api/interface.py | 8 +-- ard/farm_aero/flowfarm/component.py | 90 ++++++++++++++++++++++++----- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/ard/api/interface.py b/ard/api/interface.py index 22ac5792..ed3c9da2 100644 --- a/ard/api/interface.py +++ b/ard/api/interface.py @@ -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 @@ -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", ) diff --git a/ard/farm_aero/flowfarm/component.py b/ard/farm_aero/flowfarm/component.py index 34ce310c..c6467996 100644 --- a/ard/farm_aero/flowfarm/component.py +++ b/ard/farm_aero/flowfarm/component.py @@ -32,13 +32,25 @@ def _build_wind_resource( ref_air_density, wind_shear, ): - wind_dirs_rad = to_julia_vector_float64( - jl, np.deg2rad(np.asarray(windrose_floris.wd_flat)) - ) - wind_speeds_vec = to_julia_vector_float64(jl, windrose_floris.ws_flat) - wind_probs_vec = to_julia_vector_float64(jl, windrose_floris.freq_table_flat) - n_states = len(windrose_floris.ws_flat) - ambient_tis = jl.fill(float(np.mean(windrose_floris.ti_table_flat)), n_states) + if hasattr(windrose_floris, "wd_flat"): + # WindRose (probability-based) + dirs = np.deg2rad(np.asarray(windrose_floris.wd_flat)) + speeds = np.asarray(windrose_floris.ws_flat) + probs = np.asarray(windrose_floris.freq_table_flat) + n_states = len(speeds) + mean_ti = float(np.mean(windrose_floris.ti_table_flat)) + else: + # TimeSeries (temporal dispatch) + dirs = np.deg2rad(np.asarray(windrose_floris.wind_directions)) + speeds = np.asarray(windrose_floris.wind_speeds) + n_states = len(speeds) + probs = np.full(n_states, 1.0 / n_states) + mean_ti = float(np.mean(windrose_floris.turbulence_intensities)) + + wind_dirs_rad = to_julia_vector_float64(jl, dirs) + wind_speeds_vec = to_julia_vector_float64(jl, speeds) + wind_probs_vec = to_julia_vector_float64(jl, probs) + ambient_tis = jl.fill(mean_ti, n_states) measurementheight = jl.fill(float(ref_height), n_states) wind_shear_model = flowfarm_module.PowerLawWindShear(float(wind_shear)) @@ -108,10 +120,20 @@ def _build_farm_structures( power_models, model_set, tolerance, + x_init=None, + y_init=None, ): - x0 = jl.zeros(N_turbines * 3) - turbine_x = jl.zeros(N_turbines) - turbine_y = jl.zeros(N_turbines) + # Use actual initial positions if provided so the sparsity pattern + # computed by build_unstable_sparse_struct sees a non-degenerate farm + # geometry (x0=zeros puts all turbines at the origin, hiding x/y deps). + if x_init is None: + x_init = np.zeros(N_turbines) + if y_init is None: + y_init = np.zeros(N_turbines) + x0_np = np.concatenate([x_init, y_init, np.zeros(N_turbines)]) + x0 = to_julia_vector_float64(jl, x0_np) + turbine_x = to_julia_vector_float64(jl, x_init) + turbine_y = to_julia_vector_float64(jl, y_init) turbine_z = jl.zeros(N_turbines) turbine_yaw = jl.zeros(N_turbines) @@ -172,6 +194,42 @@ def _build_farm_structures( return x0, farm, sparse_farm, sparse_struct + def _initial_turbine_positions(self, model_options, rotor_diameter): + """Compute a non-degenerate initial grid layout for sparsity detection. + + build_unstable_sparse_struct computes the sparse Jacobian pattern by + perturbing the design vector at the initial point. With x0=zeros (all + turbines collocated at origin), x/y position changes create no wake + effect, so those columns never appear in the pattern. We replicate the + gridfarm formula with the configured spacing to get a spread-out layout. + """ + layout = model_options.get("layout", {}) + spacing_prim = float(layout.get("spacing_primary", 7.0)) + spacing_sec = float(layout.get("spacing_secondary", spacing_prim)) + N = self.N_turbines + N_sq = int(np.sqrt(N)) + + cy, cx = np.meshgrid( + np.arange(-((N_sq - 1) / 2), ((N_sq + 1) / 2)), + np.arange(-((N_sq - 1) / 2), ((N_sq + 1) / 2)), + ) + if N == N_sq ** 2: + pass + elif N <= N_sq * (N_sq + 1): + # N is between N_sq² and N_sq*(N_sq+1): append a trailing row + cx = np.vstack([cx, ((N_sq + 1) / 2) * np.ones((N_sq,))]) + cy = np.vstack([cy, np.arange(-((N_sq - 1) / 2), ((N_sq + 1) / 2))]) + else: + # N is close to (N_sq+1)²: use a wider grid + cy, cx = np.meshgrid( + np.arange(-((N_sq) / 2), ((N_sq + 2) / 2)), + np.arange(-((N_sq) / 2), ((N_sq + 2) / 2)), + ) + + x_init = (cx.ravel()[:N] * spacing_prim * rotor_diameter).astype(float) + y_init = (cy.ravel()[:N] * spacing_sec * rotor_diameter).astype(float) + return x_init, y_init + def setup(self): jl = ensure_flowfarm_loaded() self._jl = jl @@ -196,9 +254,11 @@ def setup(self): ct_model = turbine_inputs["ct_model"] power_model = turbine_inputs["power_model"] + wind_resource_dict = windIO["site"]["energy_resource"]["wind_resource"] + resource_type = "timeseries" if "time" in wind_resource_dict else "probability" windrose_floris = templates.create_windresource_from_windIO( windIO, - resource_type="probability", + resource_type=resource_type, ) ref_height = wind_resource.get("reference_height", hub_height) @@ -207,8 +267,8 @@ def setup(self): wake_model_options = self._get_wake_model_options(model_options) # FLOWFarm expects one model object per turbine. - ct_models = jl.fill(ct_model, N_turbines) - power_models = jl.fill(power_model, N_turbines) + ct_models = jl.fill(ct_model, self.N_turbines) + power_models = jl.fill(power_model, self.N_turbines) flowfarm_module = jl.FLOWFarm windresource = self._build_wind_resource( @@ -221,6 +281,7 @@ def setup(self): ) model_set = self._build_wake_model_set(flowfarm_module, wake_model_options) + x_init, y_init = self._initial_turbine_positions(model_options, rotor_diameter) x0, farm, sparse_farm, sparse_struct = self._build_farm_structures( jl, flowfarm_module, @@ -237,6 +298,8 @@ def setup(self): power_models, model_set, wake_model_options.get("tolerance", 1e-16), + x_init=x_init, + y_init=y_init, ) self.flowfarm_module = flowfarm_module @@ -356,6 +419,7 @@ def compute(self, inputs, outputs): turbine_powers = np.asarray(self.sparse_struct.turbine_powers) outputs["power_farm"] = state_powers + outputs["AEP_farm"] = float(state_powers.sum()) * 3600.0 # W*h, assuming 1-hour timesteps if ( self.options["modeling_options"] .get("aero", {}) From 0360b4e7407271b222ba50756d9a4f78e09b59c3 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:46:15 -0600 Subject: [PATCH 16/17] layout updates --- .../default_systems/ard_system_onshore.yaml | 17 +++- ard/farm_aero/flowfarm/component.py | 2 +- ard/layout/cartesian.py | 92 +++++++++++++++++++ ard/layout/templates.py | 6 ++ 4 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 ard/layout/cartesian.py diff --git a/ard/api/default_systems/ard_system_onshore.yaml b/ard/api/default_systems/ard_system_onshore.yaml index db70be85..b05ce494 100644 --- a/ard/api/default_systems/ard_system_onshore.yaml +++ b/ard/api/default_systems/ard_system_onshore.yaml @@ -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: @@ -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: @@ -96,4 +96,11 @@ systems: modeling_options: connections: - ["AEP_farm", "financese.plant_aep_in"] - - ["landbosse.total_capex_kW", "financese.bos_per_kW"] \ No newline at end of file + - ["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"] \ No newline at end of file diff --git a/ard/farm_aero/flowfarm/component.py b/ard/farm_aero/flowfarm/component.py index c6467996..e07e761c 100644 --- a/ard/farm_aero/flowfarm/component.py +++ b/ard/farm_aero/flowfarm/component.py @@ -311,7 +311,7 @@ def setup(self): def _build_design_vector(self, inputs): x_turbines = np.asarray(inputs["x_turbines"], dtype=float) y_turbines = np.asarray(inputs["y_turbines"], dtype=float) - yaw_turbines = np.asarray(inputs["yaw_turbines"], dtype=float) + yaw_turbines = np.deg2rad(np.asarray(inputs["yaw_turbines"], dtype=float)) return np.concatenate([x_turbines, y_turbines, yaw_turbines]).ravel() def _evaluate_sparse(self, x_eval_np): diff --git a/ard/layout/cartesian.py b/ard/layout/cartesian.py new file mode 100644 index 00000000..ceee338b --- /dev/null +++ b/ard/layout/cartesian.py @@ -0,0 +1,92 @@ +import numpy as np +from scipy.spatial.distance import cdist + +import ard.layout.templates as templates +import ard.layout.fullfarm as fullfarm + + +class CartesianLayout(templates.LayoutTemplate): + """ + A layout class that reads explicit Cartesian coordinates from config. + + This layout type reads x_turbines and y_turbines directly from + modeling_options.layout and outputs them. Positions are not generated + from spacing parameters - they are fully specified in the configuration. + + Options + ------- + modeling_options : dict + a modeling options dictionary containing x_turbines and y_turbines lists + + Inputs + ------ + None - layout is fully specified in config + + Outputs + ------- + x_turbines : np.ndarray + x-coordinates of turbines from modeling_options + y_turbines : np.ndarray + y-coordinates of turbines from modeling_options + yaw_turbines : np.ndarray + yaw angles (degrees) of turbines from modeling_options + spacing_effective_primary : float + approximate primary spacing for BOS calculation + spacing_effective_secondary : float + approximate secondary spacing for BOS calculation + """ + + def initialize(self): + """Initialization of OM component.""" + super().initialize() + + def setup(self): + """Setup of OM component.""" + super().setup() + + def setup_partials(self): + """Derivative setup for OM component.""" + # No inputs, so no partials to declare + pass + + def compute(self, inputs, outputs): + """Computation for the OM component.""" + # Get the x, y, and yaw coordinates from modeling_options + layout_options = self.modeling_options["layout"] + x_turbines = np.array(layout_options.get("x_turbines", [])) + y_turbines = np.array(layout_options.get("y_turbines", [])) + yaw_turbines = np.array(layout_options.get("yaw_turbines", [0.0] * self.N_turbines)) + + if len(x_turbines) != self.N_turbines or len(y_turbines) != self.N_turbines: + raise ValueError( + f"Cartesian layout: x_turbines and y_turbines must have length {self.N_turbines}, " + f"got {len(x_turbines)} and {len(y_turbines)}" + ) + + if len(yaw_turbines) != self.N_turbines: + raise ValueError( + f"Cartesian layout: yaw_turbines must have length {self.N_turbines}, " + f"got {len(yaw_turbines)}" + ) + + outputs["x_turbines"] = x_turbines + outputs["y_turbines"] = y_turbines + outputs["yaw_turbines"] = yaw_turbines + + # Compute effective spacing from the actual layout + points = np.column_stack([x_turbines, y_turbines]) + if self.N_turbines > 1: + distances = cdist(points, points) + np.fill_diagonal(distances, np.inf) + mean_nearest_neighbor = np.mean(np.min(distances, axis=1)) + D_rotor = self.windIO["wind_farm"]["turbine"]["rotor_diameter"] + outputs["spacing_effective_primary"] = mean_nearest_neighbor / D_rotor + outputs["spacing_effective_secondary"] = mean_nearest_neighbor / D_rotor + else: + outputs["spacing_effective_primary"] = 0.0 + outputs["spacing_effective_secondary"] = 0.0 + + +class CartesianFarmLanduse(fullfarm.FullFarmLanduse): + """Landuse component for Cartesian layout.""" + pass diff --git a/ard/layout/templates.py b/ard/layout/templates.py index 3178b0ba..a944527a 100644 --- a/ard/layout/templates.py +++ b/ard/layout/templates.py @@ -64,6 +64,12 @@ def setup(self): units="m", desc="turbine location in y-direction", ) + self.add_output( + "yaw_turbines", + np.zeros((self.N_turbines,)), + units="deg", + desc="turbine yaw angle from modeling options", + ) self.add_output( "spacing_effective_primary", 0.0, From 5801e2bbcc305e263f58e34ef76c3a7230da3157 Mon Sep 17 00:00:00 2001 From: BTV25 <70768698+BTV25@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:50:16 -0600 Subject: [PATCH 17/17] Fix float32 precision loss in boundary distance derivatives distance_point_to_polygon_ray_casting and distance_multi_point_to_multi_polygon_ray_casting hardcoded jnp.float32 for turbine points and polygon vertices, despite jax_enable_x64=True being set in boundary.py. At km-scale site coordinates, float32 quantization noise gets amplified through the s=700 smooth_max/ smooth_min sharpness, producing O(0.1-1) errors in boundary_distances gradients vs FD. Switch both casts to float64. --- ard/utils/geometry.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ard/utils/geometry.py b/ard/utils/geometry.py index 39d4eaec..4fe16dfe 100644 --- a/ard/utils/geometry.py +++ b/ard/utils/geometry.py @@ -102,7 +102,7 @@ def distance_multi_point_to_multi_polygon_ray_casting( # Convert boundary_vertices to JAX arrays boundary_vertices_jax = [ - jnp.asarray(poly, dtype=jnp.float32) for poly in boundary_vertices + jnp.asarray(poly, dtype=jnp.float64) for poly in boundary_vertices ] # Create a function for each polygon that computes distance @@ -231,8 +231,8 @@ def distance_point_to_polygon_ray_casting( """ # Ensure inputs are JAX arrays with explicit data types - point = jnp.asarray(point, dtype=jnp.float32) - vertices = jnp.asarray(vertices, dtype=jnp.float32) + point = jnp.asarray(point, dtype=jnp.float64) + vertices = jnp.asarray(vertices, dtype=jnp.float64) # Add the first vertex to the end to close the polygon loop vertices = jnp.vstack([vertices, vertices[0]])