""" parametric_case_config.py Dataclass-based configuration for defining and generating parametric OpenFAST simulation cases (e.g. IEC power-curve or DLC-style batches). Typical parametric axes for an OpenFAST wind turbine study: - Mean wind speed bins (m/s) - Number of turbulence seeds per speed bin - Wind input type (steady, uniform, TurbSim full-field) - Associated TurbSim .bts file (when applicable) Contents: WindType - enum mirroring InflowWind's WindType flag TurbulenceModel - enum of common TurbSim spectral models SimulationCase - a single OpenFAST run's inputs ParametricStudyConfig - the full sweep definition + case generator """ from __future__ import annotations from dataclasses import dataclass, field, asdict from enum import Enum from pathlib import Path from typing import List, Optional import itertools import json class WindType(Enum): """Mirrors InflowWind's WindType input (OpenFAST InflowWind module).""" STEADY = 1 # Steady, uniform wind (no shear/turbulence) UNIFORM = 2 # Uniform wind file (.wnd) - can include shear/gusts TURBSIM_FF = 3 # Binary TurbSim full-field turbulence (.bts) BLADED_FF = 4 # Binary Bladed-style full-field turbulence HAWC_FF = 5 # HAWC-format full-field wind NATIVE_BLADED = 7 # Native Bladed format class TurbulenceModel(Enum): """Common TurbSim turbulence spectral models.""" IECKAI = "IECKAI" # IEC Kaimal IECVKM = "IECVKM" # IEC von Karman GP_LLJ = "GP_LLJ" # Great Plains Low-Level Jet NWTCUP = "NWTCUP" # NWTC (upwind) model NONE = "NONE" # No turbulence (steady/uniform cases) @dataclass class SimulationCase: """ A single OpenFAST run: one point in the parametric matrix. Attributes: case_id: Unique, filesystem-safe identifier for this run (used for output folder / file naming) wind_speed: Mean hub-height wind speed for this case (m/s) seed: Turbulence random seed used to generate this case's TurbSim file (irrelevant for steady/uniform wind) wind_type: Which InflowWind wind model this case uses turbsim_bts: Path to the TurbSim binary full-field file (.bts) for this case. Required when wind_type == WindType.TURBSIM_FF turbulence_intensity: Reference TI (%) used to generate the TurbSim file, kept for traceability shear_exp: Power-law vertical shear exponent (alpha) sim_time: Total simulation length (s). time_step: Time step (s). """ case_id: str wind_speed: float seed: Optional[int] = None wind_type: WindType = WindType.TURBSIM_FF turbsim_bts: Optional[Path] = None turbulence_intensity: Optional[float] = None shear_exp: float = 0.2 sim_time: float = 660.0 time_step: float = 0.0125 def __post_init__(self): if self.wind_type == WindType.TURBSIM_FF and self.turbsim_bts is None: raise ValueError( f"[{self.case_id}] wind_type=TURBSIM_FF requires a turbsim_bts." ) if self.turbsim_bts is not None: self.turbsim_bts = Path(self.turbsim_bts) def to_dict(self) -> dict: """JSON-serializable dict (enums -> names, Path -> str).""" d = asdict(self) d["wind_type"] = self.wind_type.name if self.turbsim_bts is not None: d["turbsim_bts"] = str(self.turbsim_bts) return d @dataclass class ParametricStudyConfig: """ Top-level definition of a parametric OpenFAST sweep Attributes: study_name: Label for this batch of runs fst_template: Path to the template .fst file copied/edited per case output_dir: Root directory where per-case folders are written speed_bins: Mean wind speeds to simulate (m/s), e.g. IEC power curve bins [4, 6, 8, ..., 24] num_seeds: Number of turbulence seeds per speed bin (ignored for steady/uniform wind types) wind_type: Wind model used across the whole study turb_model: TurbSim spectral model (only meaningful when wind_type == TURBSIM_FF) turbsim_bts_dir: Directory containing pre-generated .bts files, or where new ones will be written bts_naming_pattern: Format string for each case's expected .bts filename. Fields: {speed}, {seed} shear_exp: Vertical wind shear exponent, applied to all cases sim_time / dt: Shared simulation settings for all cases base_seed: Starting seed; seeds used are [base_seed, base_seed+1, ..., base_seed+num_seeds-1]. """ study_name: str fst_template: Path output_dir: Path speed_bins: List[float] = field( default_factory=lambda: [4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24] ) num_seeds: int = 6 wind_type: WindType = WindType.TURBSIM_FF turb_model: TurbulenceModel = TurbulenceModel.IECKAI turbsim_bts_dir: Optional[Path] = None bts_naming_pattern: str = "Wind_{speed:02.1f}mps_Seed{seed:02d}.bts" shear_exp: float = 0.2 sim_time: float = 300.0 time_step: float = 0.025 base_seed: int = 1 def __post_init__(self): self.fst_template = Path(self.fst_template) self.output_dir = Path(self.output_dir) if self.turbsim_bts_dir is not None: self.turbsim_bts_dir = Path(self.turbsim_bts_dir) if self.wind_type == WindType.TURBSIM_FF and self.turbsim_bts_dir is None: raise ValueError("wind_type=TURBSIM_FF requires turbsim_bts_dir to be set.") if self.num_seeds < 1: raise ValueError("num_seeds must be >= 1.") if sorted(self.speed_bins) != list(self.speed_bins): raise ValueError("speed_bins should be sorted in ascending order.") @property def seeds(self) -> List[int]: return list(range(self.base_seed, self.base_seed + self.num_seeds)) def generate_cases(self) -> List[SimulationCase]: """ Build the full parametric matrix: every speed bin x every seed (Cartesian product). Steady/uniform wind types collapse to one case per speed, since a turbulence seed is meaningless for them. """ cases: List[SimulationCase] = [] if self.wind_type in (WindType.STEADY, WindType.UNIFORM): for speed in self.speed_bins: case_id = f"{self.study_name}_U{speed:04.1f}" cases.append( SimulationCase( case_id = case_id, wind_speed=speed, seed=None, wind_type=self.wind_type, turbsim_bts=None, shear_exp =self.shear_exp, sim_time =self.sim_time, time_step =self.time_step, ) ) return cases # Turbulent wind types: sweep speed x seed for speed, seed in itertools.product(self.speed_bins, self.seeds): case_id = f"{self.study_name}_U{speed:04.1f}_S{seed:03d}" bts_name = self.bts_naming_pattern.format(speed=speed, seed=seed) bts_path = self.turbsim_bts_dir / bts_name cases.append( SimulationCase( case_id=case_id, wind_speed=speed, seed=seed, wind_type=self.wind_type, turbsim_bts=bts_path, shear_exp=self.shear_exp, sim_time=self.sim_time, time_step =self.time_step, ) ) return cases def to_json(self, indent: int = 2) -> str: """Serialize the study-level config (not individual cases) to JSON.""" d = asdict(self) d["wind_type"] = self.wind_type.name d["turb_model"] = self.turb_model.value d["fst_template"] = str(self.fst_template) d["output_dir"] = str(self.output_dir) if self.turbsim_bts_dir is not None: d["turbsim_bts_dir"] = str(self.turbsim_bts_dir) return json.dumps(d, indent=indent) if __name__ == "__main__": study = ParametricStudyConfig( study_name="IEA15MW_PowerCurve", fst_template=Path("templates/IEA-15-240-RWT.fst"), output_dir=Path("runs/power_curve"), speed_bins=[4, 6, 8, 10, 12, 14, 16, 18, 20], num_seeds=6, wind_type=WindType.TURBSIM_FF, turb_model=TurbulenceModel.IECKAI, turbsim_bts_dir=Path("wind/turbsim_out"), ) cases = study.generate_cases() print(f"Generated {len(cases)} cases for study '{study.study_name}'\n") for c in cases[:5]: print(c.to_dict()) print("...") print("\nStudy config as JSON:\n") print(study.to_json())