From 9966c91e91e68e4c42af5b179d31ec772780ffc9 Mon Sep 17 00:00:00 2001 From: dzalkind <65573423+dzalkind@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:45:06 -0600 Subject: [PATCH 1/3] WEIS v2.1.2 (#470) * Fully disable 2nd tower mode shapes if DOF is disabled * Fully disable 2nd tower mode shapes if DOF is disabled (#466) * Add moorpy version 1.2.1 to environment.yml * move some pypi packages to the conda forge * fix readme (#467) * Add Mean_PtfmPitch as an optimization constraint * trying updated wisdem to reduce package dependencies * unlock openmdao * Add some helper functions for platform design * Cd into notebooks for testing * Tidy platform optimization in RAFT * Remove plot_tradeoff * convert radians to degrees for heel constraints * seeing if openmpi speeds up installation * maybe without pyoptsparse for speedup? * one more combo * native mpi * found solution, restoring full test matrix * caught more rad to deg conversions --------- Co-authored-by: Garrett Barter Co-authored-by: Pietro Bortolotti Co-authored-by: Garrett Barter --- .github/workflows/CI_WEIS.yml | 18 +-- README.md | 2 +- environment.yml | 17 ++- .../iea22_ptfm_opt_analysis.yaml | 2 +- .../iea22_raft_opt_analysis.yaml | 29 +--- .../0_notebooks/chapter2.ipynb | 2 +- .../stage-3-semisub_of_analysis.yaml | 2 +- .../stage-3-semisub_raft_analysis.yaml | 2 +- .../stage-3.5-semisub_of_analysis.yaml | 2 +- weis/aeroelasticse/openmdao_openfast.py | 14 +- weis/glue_code/gc_PoseOptimization.py | 9 +- weis/inputs/analysis_schema.yaml | 14 +- weis/inputs/validation.py | 38 +++++- weis/visualization/opt_plotting.py | 83 ++++++++++++ weis/visualization/utils.py | 126 ++++++++++++++++++ 15 files changed, 305 insertions(+), 55 deletions(-) diff --git a/.github/workflows/CI_WEIS.yml b/.github/workflows/CI_WEIS.yml index 5311687d5..0ad1f52d4 100644 --- a/.github/workflows/CI_WEIS.yml +++ b/.github/workflows/CI_WEIS.yml @@ -17,7 +17,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "macOS-14", "windows-latest"] # mac13 is intel, mac-14 arm - python-version: ["3.11","3.12","3.13"] + python-version: ["3.12","3.13","3.14"] steps: - uses: actions/checkout@v5 @@ -50,7 +50,7 @@ jobs: # - name: Add dependencies windows specific # if: contains( matrix.os, 'windows') # run: | - # conda install -y gfortran libpython + # conda install -y gfortran gcc # pip install --no-deps control @@ -62,12 +62,12 @@ jobs: # Don't delete after this - + # Note- could add mpich through conda, but takes forever to resolve the environment in testing - name: Add dependencies ubuntu specific if: contains( matrix.os, 'ubuntu') run: | - sudo apt install -y libglu1-mesa - conda install -c conda-forge -y petsc4py mpi4py pyoptsparse mpich + sudo apt install -y libglu1-mesa mpich + conda install -y petsc4py mpi4py pyoptsparse pip install pygmsh==7.1.17 pip install https://github.com/LHEEA/meshmagick/archive/master.zip which -a mpiexec @@ -156,10 +156,10 @@ jobs: - name: Test model creation notebooks and drivers if: contains( matrix.os, 'ubuntu') && contains( github.event_name, 'pull_request') run: | - cd examples/11_model_creation_process - treon 0_notebooks/chapter1.ipynb - treon 0_notebooks/chapter2.ipynb - treon 0_notebooks/chapter3.ipynb + cd examples/11_model_creation_process/0_notebooks + treon chapter1.ipynb + treon chapter2.ipynb + treon chapter3.ipynb # Run parallel script calling OpenFAST - name: Run parallel cases diff --git a/README.md b/README.md index a3ce6a9ac..db9483cde 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The installation instructions below use the environment name, "weis-env," but an 3. If you are on Kestrel, first load some modules and then install: - module load intel-oneapi-compilers intel-oneapi-mpi intel-oneapi-mkl conda + module load intel-oneapi-compilers intel-oneapi-mpi intel-oneapi-mkl pip install --no-deps -e . -v **NOTE:** To use WEIS again after installation is complete, you will always need to activate the conda environment first with `conda activate weis-env` (or `source activate weis-env`). On Kestrel, make sure to reload the necessary modules diff --git a/environment.yml b/environment.yml index 16a78254c..e4839179c 100644 --- a/environment.yml +++ b/environment.yml @@ -5,9 +5,12 @@ channels: dependencies: - bs4 - dash + - dash-bootstrap-components + - dash-mantine-components - dill - jsonmerge - mat4py + - moorpy==1.2.1 - nlopt - numpydoc - numpy @@ -15,26 +18,22 @@ dependencies: - openfast-io==4.2 - pyopenfast==4.2 - openraft>=2.0.3 - - openmdao==3.42 - - osqp + - openmdao + - osqp - pcrunch>=2.1.5 - pip - pyhams>=1.3 + - pyvista #- pyoptsparse + - pyvista - rosco>=2.10.1 - trimesh - - wisdem>=4.1 + - wisdem>=4.2 - pip: - control - - dash-bootstrap-components - - dash-mantine-components - dash-vtk - - pyvista - dearpygui - - orbit-nrel - smt - - windIO>=2.0.1 - - wombat>=0.13.1 # - git+https://github.com/NLRWindSystems/RAFT.git@dev # Needs to be done outside of environment file: # - m2w64-toolchain # [win] diff --git a/examples/03_design_with_openfast/iea22_ptfm_opt_analysis.yaml b/examples/03_design_with_openfast/iea22_ptfm_opt_analysis.yaml index 6ec6f8cae..a027eae52 100644 --- a/examples/03_design_with_openfast/iea22_ptfm_opt_analysis.yaml +++ b/examples/03_design_with_openfast/iea22_ptfm_opt_analysis.yaml @@ -73,7 +73,7 @@ constraints: lower_bound: 0.37 floating: survival_heel: - upper_bound: 0.17453292519943295 # 10 deg + upper_bound: 10.0 # deg metacentric_height: flag: False lower_bound: 15.0 diff --git a/examples/04_frequency_domain_analysis_design/iea22_raft_opt_analysis.yaml b/examples/04_frequency_domain_analysis_design/iea22_raft_opt_analysis.yaml index 7f5d77a91..0a823c25f 100644 --- a/examples/04_frequency_domain_analysis_design/iea22_raft_opt_analysis.yaml +++ b/examples/04_frequency_domain_analysis_design/iea22_raft_opt_analysis.yaml @@ -19,9 +19,6 @@ design_variables: lower_bound: -30.0 upper_bound: -12.0 flag: True - # - names: [col1_freeboard, col2_freeboard, col3_freeboard] - # lower_bound: 10.0 - # upper_bound: 20.0 r_coordinate: - names: [col1_keel, col1_freeboard, col2_keel, col2_freeboard, col3_keel, col3_freeboard] lower_bound: 50.0 @@ -35,18 +32,7 @@ design_variables: lower_bound: 12.0 upper_bound: 16.0 constant: True - # ballast: - # lower_bound: 0 - # upper_bound: 1000 - # - names: [main_column] - # ballast: - # lower_bound: 0 - # upper_bound: 1000 - # - names: [Y_pontoon_lower1, Y_pontoon_lower2, Y_pontoon_lower3] - # diameter: - # lower_bound: 10.0 - # upper_bound: 10.8 - # constant: True + constraints: tower: height_constraint: @@ -73,7 +59,7 @@ constraints: lower_bound: 0.37 floating: survival_heel: - upper_bound: 0.17453292519943295 # 10 deg + upper_bound: 10.0 # deg metacentric_height: flag: True lower_bound: 15.0 @@ -91,14 +77,6 @@ constraints: flag: True buoyancy: flag: False - stress: - flag: False - global_buckling: - flag: False - shell_buckling: - flag: False - mooring_heel: - flag: False freeboard_margin: # keep freeboard from being submerged below water during survival_heel, largest wave flag: True draft_margin: # keep draft from raising above water line during survival_heel, largest wave @@ -109,9 +87,6 @@ constraints: Max_PtfmPitch: flag: True max: 6.0 - Std_PtfmPitch: - flag: False - max: 1.25 # Same as IEA-15MW with same DLCs nacelle_acceleration: flag: True max: 2.0 diff --git a/examples/11_model_creation_process/0_notebooks/chapter2.ipynb b/examples/11_model_creation_process/0_notebooks/chapter2.ipynb index 902698919..8466a34b7 100644 --- a/examples/11_model_creation_process/0_notebooks/chapter2.ipynb +++ b/examples/11_model_creation_process/0_notebooks/chapter2.ipynb @@ -502,7 +502,7 @@ "constraints:\n", " floating:\n", " survival_heel:\n", - " upper_bound: 0.17453292519943295 # 10 deg\n", + " upper_bound: 10.0 # deg\n", " metacentric_height:\n", " flag: True\n", " lower_bound: 1.0 # 15.0 --> Dan's experience\n", diff --git a/examples/11_model_creation_process/1_drivers/stage-3-semisub/stage-3-semisub_of_analysis.yaml b/examples/11_model_creation_process/1_drivers/stage-3-semisub/stage-3-semisub_of_analysis.yaml index 6e1b9f0aa..a5a6cd6e1 100644 --- a/examples/11_model_creation_process/1_drivers/stage-3-semisub/stage-3-semisub_of_analysis.yaml +++ b/examples/11_model_creation_process/1_drivers/stage-3-semisub/stage-3-semisub_of_analysis.yaml @@ -53,7 +53,7 @@ constraints: lower_bound: 0.37 floating: survival_heel: - upper_bound: 0.17453292519943295 # 10 deg + upper_bound: 10.0 # deg metacentric_height: flag: True lower_bound: 15.0 diff --git a/examples/11_model_creation_process/1_drivers/stage-3-semisub/stage-3-semisub_raft_analysis.yaml b/examples/11_model_creation_process/1_drivers/stage-3-semisub/stage-3-semisub_raft_analysis.yaml index 54359d486..13dff2402 100644 --- a/examples/11_model_creation_process/1_drivers/stage-3-semisub/stage-3-semisub_raft_analysis.yaml +++ b/examples/11_model_creation_process/1_drivers/stage-3-semisub/stage-3-semisub_raft_analysis.yaml @@ -53,7 +53,7 @@ constraints: lower_bound: 0.37 floating: survival_heel: - upper_bound: 0.17453292519943295 # 10 deg + upper_bound: 10.0 # deg metacentric_height: flag: True lower_bound: 15.0 diff --git a/examples/11_model_creation_process/1_drivers/stage-3.5-semisubCCD/stage-3.5-semisub_of_analysis.yaml b/examples/11_model_creation_process/1_drivers/stage-3.5-semisubCCD/stage-3.5-semisub_of_analysis.yaml index ceaa36bac..0a2b5e3ca 100644 --- a/examples/11_model_creation_process/1_drivers/stage-3.5-semisubCCD/stage-3.5-semisub_of_analysis.yaml +++ b/examples/11_model_creation_process/1_drivers/stage-3.5-semisubCCD/stage-3.5-semisub_of_analysis.yaml @@ -72,7 +72,7 @@ constraints: lower_bound: 0.37 floating: survival_heel: - upper_bound: 0.17453292519943295 # 10 deg + upper_bound: 10.0 # deg metacentric_height: flag: True lower_bound: 15.0 diff --git a/weis/aeroelasticse/openmdao_openfast.py b/weis/aeroelasticse/openmdao_openfast.py index c31791edd..cdf903d1d 100644 --- a/weis/aeroelasticse/openmdao_openfast.py +++ b/weis/aeroelasticse/openmdao_openfast.py @@ -625,6 +625,7 @@ def setup(self): # Floating outputs self.add_output('Max_PtfmPitch', val=0.0, desc='Maximum platform pitch angle over a set of OpenFAST simulations') + self.add_output('Mean_PtfmPitch', val=0.0, units='deg', desc='Maximum (across cases) mean (of each case) platform pitch angle over a set of OpenFAST simulations') self.add_output('Std_PtfmPitch', val=0.0, units='deg', desc='standard deviation of platform pitch angle') self.add_output('Max_Offset', val=0.0, units='m', desc='Maximum distance in surge/sway direction') @@ -1200,9 +1201,18 @@ def update_FAST_model(self, fst_vt, inputs, discrete_inputs): if not np.any(inputs[f'{fass}_modes'][idir,:]): logger.warning(f'WARNING: {fass} tower shape coefficients are zero which will cause errors in using ElastoDyn') fst_vt['ElastoDynTower']['TwFAM1Sh'] = inputs['fore_aft_modes'][0, :] / np.sum(inputs['fore_aft_modes'][0, :]) - fst_vt['ElastoDynTower']['TwFAM2Sh'] = inputs['fore_aft_modes'][1, :] / np.sum(inputs['fore_aft_modes'][1, :]) fst_vt['ElastoDynTower']['TwSSM1Sh'] = inputs['side_side_modes'][0, :] / np.sum(inputs['side_side_modes'][0, :]) + + # Since the 2nd tower modes are sometimes problematic, if the DOF is not enabled, let's give it a safe, dummy value that won't cause errors in ElastoDyn + fst_vt['ElastoDynTower']['TwFAM2Sh'] = inputs['fore_aft_modes'][1, :] / np.sum(inputs['fore_aft_modes'][1, :]) + if not fst_vt['ElastoDyn']['TwFADOF2']: + fst_vt['ElastoDynTower']['TwFAM2Sh'] = np.zeros_like(inputs['fore_aft_modes'][1, :]) + fst_vt['ElastoDynTower']['TwFAM2Sh'][0] = 1.0 + fst_vt['ElastoDynTower']['TwSSM2Sh'] = inputs['side_side_modes'][1, :] / np.sum(inputs['side_side_modes'][1, :]) + if not fst_vt['ElastoDyn']['TwSSDOF2']: + fst_vt['ElastoDynTower']['TwSSM2Sh'] = np.zeros_like(inputs['side_side_modes'][1, :]) + fst_vt['ElastoDynTower']['TwSSM2Sh'][0] = 1.0 # Calculate yaw stiffness of tower (springs in series) and use in servodyn as yaw spring constant k_tow_tor = inputs['tor_stff'] / np.diff(inputs['tower_z']) @@ -3450,6 +3460,7 @@ def get_floating_measures(self, inputs, outputs): calculate floating measures: - Std_PtfmPitch (max over all dlcs if constraint, mean otheriwse) - Max_PtfmPitch + - Mean_PtfmPitch given: - sum_stats : pd.DataFrame @@ -3463,6 +3474,7 @@ def get_floating_measures(self, inputs, outputs): outputs['Std_PtfmPitch'] = np.mean(sum_stats['PtfmPitch']['std']) outputs['Max_PtfmPitch'] = np.max(sum_stats['PtfmPitch']['max']) + outputs['Mean_PtfmPitch'] = np.max(sum_stats['PtfmPitch']['mean']) # Max platform offset outputs['Max_Offset'] = np.max(sum_stats['PtfmOffset']['max']) diff --git a/weis/glue_code/gc_PoseOptimization.py b/weis/glue_code/gc_PoseOptimization.py index 56eb8e416..7b91b364d 100644 --- a/weis/glue_code/gc_PoseOptimization.py +++ b/weis/glue_code/gc_PoseOptimization.py @@ -260,7 +260,14 @@ def set_constraints(self, wt_opt): raise Exception('Please turn on the call to OpenFAST or RAFT if you are trying to optimize Max_PtfmPitch constraints.') wt_opt.model.add_constraint(f'{self.floating_solve_component}.Max_PtfmPitch', upper = control_constraints['Max_PtfmPitch']['max']) - + + # Mean platform pitch + if control_constraints['Mean_PtfmPitch']['flag']: + if not any(self.level_flags): + raise Exception('Please turn on the call to OpenFAST or RAFT if you are trying to optimize Mean_PtfmPitch constraints.') + wt_opt.model.add_constraint(f'{self.floating_solve_component}.Mean_PtfmPitch', + upper = control_constraints['Mean_PtfmPitch']['max']) + # Platform pitch motion if control_constraints['Std_PtfmPitch']['flag']: if not any(self.level_flags): diff --git a/weis/inputs/analysis_schema.yaml b/weis/inputs/analysis_schema.yaml index df1573d32..775a495d5 100644 --- a/weis/inputs/analysis_schema.yaml +++ b/weis/inputs/analysis_schema.yaml @@ -382,9 +382,21 @@ properties: minimum: 0.0 maximum: 30.0 unit: deg + Mean_PtfmPitch: + type: object + description: The maximum mean platform pitch displacement over all cases. Can be computed in both RAFT and OpenFAST. The higher fidelity option will be used when active. + default: {} + properties: + flag: *flag + max: + type: number + default: 3.0 + minimum: 0.0 + maximum: 30.0 + unit: deg Std_PtfmPitch: type: object - description: Maximum platform pitch standard deviation over all cases. Can be computed in both RAFT and OpenFAST. The higher fidelity option will be used when active. + description: Maximum platform pitch standard deviation over all cases. Can be computed in both RAFT and OpenFAST. The higher fidelity option will be used when active. default: {} properties: flag: *flag diff --git a/weis/inputs/validation.py b/weis/inputs/validation.py index 2d0cac1b6..5404c941b 100644 --- a/weis/inputs/validation.py +++ b/weis/inputs/validation.py @@ -106,4 +106,40 @@ def write_analysis_yaml(instance, foutput): sfx_str = "-analysis.yaml" wisval.write_yaml(instance, foutput + sfx_str) return foutput + sfx_str - + + +def make_paths_absolute(data, base_dir=None): + """Recursively convert relative paths in a nested dict/list to absolute paths. + + Any string value that contains ``/`` or ``\\`` and is not already an + absolute path is joined with *base_dir* and resolved to an absolute path. + This is useful when loading YAML configuration files that contain + relative references to other files or directories. + + Parameters + ---------- + data : dict, list, or str + The data structure to process (typically the result of + ``yaml.safe_load``). + base_dir : str, optional + The directory that relative paths are relative to. Defaults to + the current working directory. + + Returns + ------- + data : dict, list, or str + A copy of the input with relative paths replaced by absolute paths. + """ + if base_dir is None: + base_dir = os.getcwd() + + if isinstance(data, dict): + return {k: make_paths_absolute(v, base_dir) for k, v in data.items()} + elif isinstance(data, list): + return [make_paths_absolute(item, base_dir) for item in data] + elif isinstance(data, str): + if ("/" in data or "\\" in data) and not os.path.isabs(data): + return os.path.abspath(os.path.join(base_dir, data)) + return data + else: + return data diff --git a/weis/visualization/opt_plotting.py b/weis/visualization/opt_plotting.py index 3c5d6c744..c7241218f 100644 --- a/weis/visualization/opt_plotting.py +++ b/weis/visualization/opt_plotting.py @@ -161,3 +161,86 @@ def plot_conv( return fig, axes + +def plot_convergence(data, vars_to_plot, title_prefix, bounds=None, + aliases=None, save_path=None): + """Plot iteration history for a list of recorded variables with optional bound lines. + + This is a lightweight alternative to :func:`plot_conv` that works directly + with the dict returned by :func:`~weis.visualization.utils.load_OMsql` + and accepts a *bounds* dict (e.g. from + :func:`~weis.visualization.utils.load_problem_vars_yaml` or + :func:`~weis.visualization.utils.load_bounds_from_analysis_yaml`). + + Parameters + ---------- + data : dict + ``{var_name: list_of_values_per_iteration}`` as returned by + :func:`~weis.visualization.utils.load_OMsql`. + vars_to_plot : list[str] + OpenMDAO variable names to include in the figure. + title_prefix : str + Text used as the figure super-title. + bounds : dict, optional + ``{var_name: {"lower": float | None, "upper": float | None}}``. + When provided, horizontal dashed lines are drawn at the bound values. + aliases : dict, optional + ``{var_name: "Human-Readable Label"}``. Falls back to the raw + variable name when not provided. + save_path : str, optional + If given, the figure is saved to this path (PNG recommended). + + Returns + ------- + fig : matplotlib.figure.Figure or None + The generated figure, or ``None`` if no plottable variables were found. + """ + bounds = bounds or {} + aliases = aliases or {} + + # Filter to variables actually present in data + vars_present = [v for v in vars_to_plot if v in data] + vars_missing = [v for v in vars_to_plot if v not in data] + if vars_missing: + print(f" Skipping (not recorded): {vars_missing}") + if not vars_present: + print(f" No variables found for '{title_prefix}' — skipping plot.") + return None + + n = len(vars_present) + fig, axes = plt.subplots(n, 1, figsize=(10, 3 * n), sharex=True) + if n == 1: + axes = [axes] + fig.suptitle(title_prefix, fontsize=13) + + for ax, var in zip(axes, vars_present): + vals = np.array(data[var]) + if vals.ndim == 1: + ax.plot(vals, marker="o", ms=4) + else: + for i in range(vals.shape[1]): + ax.plot(vals[:, i], marker="o", ms=4, label=f"[{i}]") + ax.legend(fontsize=8) + + # Draw bounds as horizontal dashed lines + if var in bounds: + if bounds[var].get("upper") is not None: + ax.axhline(bounds[var]["upper"], color="r", ls="--", lw=1.2, + label=f"upper={bounds[var]['upper']:.3g}") + if bounds[var].get("lower") is not None: + ax.axhline(bounds[var]["lower"], color="b", ls="--", lw=1.2, + label=f"lower={bounds[var]['lower']:.3g}") + ax.legend(fontsize=8) + + ax.set_title(aliases.get(var, var), pad=3) + ax.set_ylabel("value", labelpad=4) + ax.grid(True) + + axes[-1].set_xlabel("Optimizer iteration") + plt.tight_layout(rect=[0, 0, 1, 0.98]) + if save_path: + fig.savefig(save_path, dpi=150, bbox_inches="tight") + print(f" Saved: {save_path}") + plt.close(fig) + return fig + diff --git a/weis/visualization/utils.py b/weis/visualization/utils.py index 9c8714ade..11deb2304 100644 --- a/weis/visualization/utils.py +++ b/weis/visualization/utils.py @@ -183,6 +183,132 @@ def load_vars_file(fn_vars): return vars +def load_problem_vars_yaml(fn_vars): + """ + Load upper/lower bounds from a problem_vars.yaml file written by WEIS. + + Parameters + ---------- + fn_vars : str + Path to the problem_vars.yaml file. + + Returns + ------- + bounds : dict + ``{var_name: {"lower": float | None, "upper": float | None}}`` + Values whose magnitude exceeds 1e28 are treated as unconstrained + (returned as ``None``). + """ + INF = 1e28 + bounds = {} + if not os.path.isfile(fn_vars): + return bounds + with open(fn_vars) as f: + pv = yaml.safe_load(f) + for section in ("design_vars", "constraints"): + for entry in pv.get(section, []): + meta = entry[1] # each entry is [name_str, meta_dict] + name = meta["name"] + lo = meta.get("lower", None) + hi = meta.get("upper", None) + bounds[name] = { + "lower": float(lo) if lo not in (None, "", "''") and abs(float(lo)) < INF else None, + "upper": float(hi) if hi not in (None, "", "''") and abs(float(hi)) < INF else None, + } + return bounds + + +def load_bounds_from_analysis_yaml(analysis_yaml_path): + """ + Build a bounds dict from a WEIS/WISDEM analysis_options.yaml file. + + Maps common tower design variable and constraint entries to their + OpenMDAO variable names so that bound lines can be drawn on + convergence plots. + + Parameters + ---------- + analysis_yaml_path : str + Path to the analysis_options.yaml file. + + Returns + ------- + bounds : dict + ``{om_var_name: {"lower": float | None, "upper": float | None}}`` + """ + bounds = {} + if not os.path.isfile(analysis_yaml_path): + return bounds + with open(analysis_yaml_path) as f: + opts = yaml.safe_load(f) + + # --- Design variable bounds --- + dv = opts.get("design_variables", {}).get("tower", {}) + if dv.get("layer_thickness", {}).get("flag"): + bounds["tower.layer_thickness"] = { + "lower": dv["layer_thickness"].get("lower_bound"), + "upper": dv["layer_thickness"].get("upper_bound"), + } + if dv.get("outer_diameter", {}).get("flag"): + bounds["tower.diameter"] = { + "lower": dv["outer_diameter"].get("lower_bound"), + "upper": dv["outer_diameter"].get("upper_bound"), + } + + # --- Constraint bounds --- + tc = opts.get("constraints", {}).get("tower", {}) + + # Normalized constraints (upper = 1.0) + for yaml_key, om_name in [ + ("stress", "towerse.post.constr_stress"), + ("global_buckling", "towerse.post.constr_global_buckling"), + ("shell_buckling", "towerse.post.constr_shell_buckling"), + ("slope", "towerse.slope"), + ("thickness_slope", "towerse.thickness_slope"), + ]: + if tc.get(yaml_key, {}).get("flag"): + bounds[om_name] = {"lower": None, "upper": 1.0} + + # d_to_t has explicit lower/upper + if tc.get("d_to_t", {}).get("flag"): + bounds["towerse.constr_d_to_t"] = { + "lower": tc["d_to_t"].get("lower_bound"), + "upper": tc["d_to_t"].get("upper_bound"), + } + + # taper has explicit lower + if tc.get("taper", {}).get("flag"): + bounds["towerse.constr_taper"] = { + "lower": tc["taper"].get("lower_bound"), + "upper": None, + } + + # frequency_1 has lower/upper + if tc.get("frequency_1", {}).get("flag"): + bounds["floatingse.structural_frequencies"] = { + "lower": tc["frequency_1"].get("lower_bound"), + "upper": tc["frequency_1"].get("upper_bound"), + } + + # --- Floating platform constraints --- + fc = opts.get("constraints", {}).get("floating", {}) + for yaml_key, om_name in [ + ("Max_PtfmPitch", "raft.Max_PtfmPitch"), + ("Mean_PtfmPitch", "raft.Mean_PtfmPitch"), + ("max_nac_accel", "raft.max_nac_accel"), + ("heave_period", "raft.heave_period"), + ("pitch_period", "raft.pitch_period"), + ]: + entry = fc.get(yaml_key, {}) + if entry.get("flag"): + bounds[om_name] = { + "lower": entry.get("lower_bound"), + "upper": entry.get("upper_bound"), + } + + return bounds + + def compare_om_data( dataOM_1, dataOM_2, From 6a905e31c0ec3e93070550bd1d05e8dc89b4ce09 Mon Sep 17 00:00:00 2001 From: dzalkind Date: Thu, 2 Jul 2026 13:25:38 -0600 Subject: [PATCH 2/3] Let rosco toolbox determine optimal tsr, if set up --- weis/control/test/test_tune_rosco.py | 83 ++++++++++++++++++++++++++++ weis/control/tune_rosco.py | 57 ++++++++++++++++++- 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 weis/control/test/test_tune_rosco.py diff --git a/weis/control/test/test_tune_rosco.py b/weis/control/test/test_tune_rosco.py new file mode 100644 index 000000000..aa08d3208 --- /dev/null +++ b/weis/control/test/test_tune_rosco.py @@ -0,0 +1,83 @@ +""" +Unit tests for weis.control.tune_rosco.resolve_tsr_operational + +Regression test for a bug where setting 'TSR_operational' in rosco_tuning_inputs +(passed through to modeling_options['ROSCO']) had no effect on the TSR used to +tune ROSCO / write VS_TSRopt to DISCON.IN, because TuneROSCO.compute() only ever +read the WISDEM-provided tsr_operational input. +""" + +import unittest + +import numpy as np +from rosco.toolbox.turbine import RotorPerformance + +from weis.control.tune_rosco import resolve_tsr_operational + + +def make_cp_surface(): + # Small synthetic Cp(TSR, pitch) surface with an unambiguous maximum + # at TSR = 7.0, pitch = 0 rad. + pitch_initial_rad = np.deg2rad(np.array([0.0, 5.0, 10.0])) + tsr_initial = np.array([5.0, 6.0, 7.0, 8.0, 9.0]) + performance_table = np.array([ + [0.30, 0.20, 0.10], + [0.40, 0.30, 0.20], + [0.48, 0.35, 0.22], # max at TSR=7.0, pitch=0 + [0.42, 0.32, 0.21], + [0.33, 0.25, 0.15], + ]) + return RotorPerformance(performance_table, pitch_initial_rad, tsr_initial) + + +class TestResolveTSROperational(unittest.TestCase): + def setUp(self): + self.Cp = make_cp_surface() + self.rated_rotor_speed = 1.0 # rad/s + self.rotor_radius = 50.0 # m + self.tsr_operational_default = 6.0 # e.g. WISDEM's control.rated_TSR + + def test_auto_compute_from_cp_surface(self): + # TSR_operational = 0 should auto-compute from the Cp surface (Cp.TSR_opt) + rosco_init_options = {'TSR_operational': 0} + tsr, v_rated = resolve_tsr_operational( + rosco_init_options, + self.tsr_operational_default, + self.rated_rotor_speed, + self.rotor_radius, + self.Cp, + ) + self.assertEqual(tsr, self.Cp.TSR_opt) + self.assertEqual(tsr, 7.0) + self.assertAlmostEqual(v_rated, self.rated_rotor_speed * self.rotor_radius / tsr) + + def test_explicit_override(self): + # A positive TSR_operational should be used directly, overriding the WISDEM default + rosco_init_options = {'TSR_operational': 8.5} + tsr, v_rated = resolve_tsr_operational( + rosco_init_options, + self.tsr_operational_default, + self.rated_rotor_speed, + self.rotor_radius, + self.Cp, + ) + self.assertEqual(tsr, 8.5) + self.assertAlmostEqual(v_rated, self.rated_rotor_speed * self.rotor_radius / 8.5) + + def test_default_unchanged_when_key_absent(self): + # If 'TSR_operational' is not in rosco_init_options, the WISDEM-provided + # default should pass through unchanged (pre-existing behavior). + rosco_init_options = {} + tsr, v_rated = resolve_tsr_operational( + rosco_init_options, + self.tsr_operational_default, + self.rated_rotor_speed, + self.rotor_radius, + self.Cp, + ) + self.assertEqual(tsr, self.tsr_operational_default) + self.assertAlmostEqual(v_rated, self.rated_rotor_speed * self.rotor_radius / self.tsr_operational_default) + + +if __name__ == "__main__": + unittest.main() diff --git a/weis/control/tune_rosco.py b/weis/control/tune_rosco.py index d558e44bc..087b4b29f 100644 --- a/weis/control/tune_rosco.py +++ b/weis/control/tune_rosco.py @@ -22,6 +22,49 @@ logger = logging.getLogger("wisdem/weis") +def resolve_tsr_operational(rosco_init_options, tsr_operational_default, rated_rotor_speed, rotor_radius, Cp): + ''' + Resolve the Region 2 TSR_operational (and dependent rated wind speed) used to tune ROSCO. + + If 'TSR_operational' is present in rosco_init_options (i.e. set via rosco_tuning_inputs), + it takes precedence over tsr_operational_default (WISDEM's control.rated_TSR / optimal_tsr): + - 0 triggers auto-computation from the Cp surface (Cp.TSR_opt), matching standalone + ROSCO's convention in rosco.toolbox.turbine.Turbine. + - A positive value is used directly as an override. + If the key is absent, tsr_operational_default is returned unchanged. + + Parameters + ---------- + rosco_init_options : dict + modeling_options['ROSCO'], may optionally contain 'TSR_operational' + tsr_operational_default : float + Default operational TSR, usually from WISDEM's control.rated_TSR + rated_rotor_speed : float + Rated rotor speed, rad/s + rotor_radius : float + Rotor radius, m + Cp : rosco.toolbox.turbine.RotorPerformance + Power coefficient surface, used for auto-computation of TSR_operational + + Returns + ------- + tsr_operational : float + v_rated : float + ''' + tsr_operational = tsr_operational_default + if 'TSR_operational' in rosco_init_options: + tsr_override = float(rosco_init_options['TSR_operational']) + if tsr_override == 0: + tsr_operational = Cp.TSR_opt + logger.info(f"TSR_operational auto-computed from Cp surface: {tsr_operational:.4f}") + elif tsr_override > 0: + tsr_operational = tsr_override + logger.info(f"TSR_operational overridden by ROSCO tuning input: {tsr_override:.4f}") + + v_rated = rated_rotor_speed * rotor_radius / tsr_operational + return tsr_operational, v_rated + + class ServoSE_ROSCO(Group): def initialize(self): self.options.declare('modeling_options') @@ -260,12 +303,12 @@ def compute(self,inputs,outputs, discrete_inputs, discrete_outputs): WISDEM_turbine.rated_power = float(inputs['rated_power'][0]) WISDEM_turbine.rated_torque = float(inputs['rated_torque'][0]) / WISDEM_turbine.Ng * float(inputs['gearbox_efficiency'][0]) WISDEM_turbine.max_torque = WISDEM_turbine.rated_torque * 1.1 # TODO: make this an input if studying constant power - WISDEM_turbine.v_rated = float(inputs['rated_rotor_speed'][0])*float(inputs['R'][0]) / float(inputs['tsr_operational'][0]) WISDEM_turbine.v_min = float(inputs['v_min'][0]) WISDEM_turbine.v_max = float(inputs['v_max'][0]) WISDEM_turbine.max_pitch_rate = float(inputs['max_pitch_rate'][0]) WISDEM_turbine.min_pitch_rate = -float(inputs['max_pitch_rate'][0]) - WISDEM_turbine.TSR_operational = float(inputs['tsr_operational'][0]) + # TSR_operational and v_rated are finalized below, once the Cp surface is available + # (see resolve_tsr_operational), since ROSCO tuning inputs may override the WISDEM value. WISDEM_turbine.TowerHt = float(inputs['TowerHt'][0]) WISDEM_turbine.bld_edgewise_freq = float(inputs['edge_freq'][0]) * 2 * np.pi @@ -296,6 +339,16 @@ def compute(self,inputs,outputs, discrete_inputs, discrete_outputs): WISDEM_turbine.Ct = RotorPerformance(self.Ct_table,self.pitch_vector,self.tsr_vector) WISDEM_turbine.Cq = RotorPerformance(self.Cq_table,self.pitch_vector,self.tsr_vector) + # Resolve TSR_operational (and dependent v_rated), allowing ROSCO tuning inputs + # (rosco_init_options['TSR_operational']) to override the WISDEM value. + WISDEM_turbine.TSR_operational, WISDEM_turbine.v_rated = resolve_tsr_operational( + rosco_init_options, + float(inputs['tsr_operational'][0]), + WISDEM_turbine.rated_rotor_speed, + WISDEM_turbine.rotor_radius, + WISDEM_turbine.Cp, + ) + # Load blade info to pass to flap controller tuning process if rosco_init_options['Flp_Mode'] >= 1: # Create airfoils From 625d92bf9e8a91184aabf83c26bc78c49064f6f1 Mon Sep 17 00:00:00 2001 From: dzalkind Date: Tue, 21 Jul 2026 13:41:00 -0600 Subject: [PATCH 3/3] Pin openmdao, wisdem for release --- environment.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/environment.yml b/environment.yml index fd83ecf7c..e9f0d243e 100644 --- a/environment.yml +++ b/environment.yml @@ -18,7 +18,7 @@ dependencies: - openfast-io==4.2 - pyopenfast==4.2 - openraft>=2.0.3 - - openmdao + - openmdao~=3.44.0 - osqp - pcrunch>=2.1.5 - pip @@ -28,7 +28,7 @@ dependencies: - pyvista - rosco>=2.10.1 - trimesh - - wisdem>=4.2.5 + - wisdem~=4.2.5 # tight coupling to WISDEM's OM output names; unpin when actively developing - pip: - control - dash-vtk