diff --git a/.github/workflows/CI_WEIS.yml b/.github/workflows/CI_WEIS.yml index 89e96b5d3..0ad1f52d4 100644 --- a/.github/workflows/CI_WEIS.yml +++ b/.github/workflows/CI_WEIS.yml @@ -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/examples/04_frequency_domain_analysis_design/iea22_raft_opt_analysis.yaml b/examples/04_frequency_domain_analysis_design/iea22_raft_opt_analysis.yaml index 3396a3ed5..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: @@ -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/weis/aeroelasticse/openmdao_openfast.py b/weis/aeroelasticse/openmdao_openfast.py index 761d61bfb..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') @@ -3459,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 @@ -3472,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,