Source code for vane.run.auto_sweep

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University
"""Automated operating-point sweep from rotor-performance data or settle runs."""

from __future__ import annotations

import logging
import math
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path
from typing import TYPE_CHECKING, Literal

import numpy as np

from vane._json import write_json_atomic
from vane.io.fst_reader import _fortran_logical
from vane.io.fst_writer import FastInputError, FastInputFile
from vane.run.auto_config import (
    AutoConfiguration,
    InferredValue,
    auto_configure_sweep,
    curve_power_coefficient,
)
from vane.run.deck_files import infer_template_root
from vane.run.driver import (
    LinearizationDriver,
    LinearizationRetryPolicy,
    SweepCase,
    SweepCaseError,
    default_max_workers,
)
from vane.run.executables import select_openfast_executable
from vane.run.floating_auto import (
    FloatingSettlePlan,
    find_controller_library,
    plan_floating_settle,
)
from vane.run.lin_config import (
    MIN_CALC_STEADY_TMAX,
    SETTLE_EDITED_SUBFILE_KEYS,
    LinearizationSettings,
    _validate_operating_configuration,
    needs_rated_wind_speed,
    require_inputs_within,
)
from vane.run.operating_points import (
    OperatingPoint,
    PlatformPosition,
    operating_curve_from_csv,
    operating_points_from_curve,
)
from vane.run.preflight import validate_deck
from vane.run.steady_state import (
    SteadyStateError,
    _hydrodyn_with_damping_matrix,
    _unsettled_message,
    compare_settled_to_curve,
    solve_steady_state,
    validate_settle_schedule,
)
from vane.utils.fs_retry import rmtree_with_retry
from vane.utils.paths import resolve_path

if TYPE_CHECKING:
    from collections.abc import Sequence

    from vane.run.deck_files import TemplateCopyMode
    from vane.run.steady_state import (
        EquilibriumTolerances,
        SettleConvergence,
        SettleCriterion,
        SettlePlatformDamping,
    )
    from vane.run.trim_tuning import TrimTuningPolicy
    from vane.run.trim_verification import TrimTolerances

__all__ = [
    "FLOATING_TRIM_PERIODS",
    "OperatingSweepResult",
    "SettleFailure",
    "SettleFailureReason",
    "SweepSettleError",
    "floating_trim_time",
    "run_operating_sweep",
]

logger = logging.getLogger(__name__)

#: File the sweep writes its auto-configuration record to, inside ``output_dir``.
AUTO_CONFIGURATION_FILE = "auto_configuration.json"
#: Periods of the slowest platform mode a floating linearization trim runs for.
FLOATING_TRIM_PERIODS = 20


[docs] def floating_trim_time(period: float, max_sim_time: float) -> float: """Return the CalcSteady run time of a floating linearization case. The trim must outlast the slow platform motion the settled initial conditions leave, so the run spans ``FLOATING_TRIM_PERIODS`` periods of the slowest platform mode, within ``[MIN_CALC_STEADY_TMAX, max_sim_time]``. Parameters ---------- period : float Slowest platform natural period (s), from the standstill platform probe. max_sim_time : float Upper bound (s): the retry ladder's ``max_sim_time``. Returns ------- float The run time ``TMax`` (s). Raises ------ ValueError If ``period`` or ``max_sim_time`` is not finite and positive. Notes ----- On the IEA 15-MW semi-submersible (slowest standstill period 125.5 s), settled cases linearized at the rotor-scaled ``TrimTol`` (7.1e-3) trimmed at 6, 8 and 12 m/s within 2400 s (about 19 periods; rotor-speed spread 0.08--0.09 %), but only at 8 m/s within 1200 s and at none within 600 s. Loosening ``TrimTol`` to 0.021 instead let the 6 m/s trim stop with a 6.6 % rotor-speed spread. """ if not (math.isfinite(period) and period > 0.0): msg = f"platform period must be finite and positive, got {period}" raise ValueError(msg) if not (math.isfinite(max_sim_time) and max_sim_time > 0.0): msg = f"max_sim_time must be finite and positive, got {max_sim_time}" raise ValueError(msg) target = FLOATING_TRIM_PERIODS * period return min( max(target, MIN_CALC_STEADY_TMAX), max(max_sim_time, MIN_CALC_STEADY_TMAX) )
@dataclass(frozen=True) class _SettleJudgement: """How each settle run is judged, and the optional two-stage accelerator.""" criterion: SettleCriterion = "auto" platform_damping: SettlePlatformDamping | None = None confirm_time: float | None = None tolerances: EquilibriumTolerances | None = None prescribed_rotor: bool = False platform_period: float | None = None #: Why a settle point is listed in :attr:`OperatingSweepResult.settle_failures`. SettleFailureReason = Literal["not_settled", "failed", "error"] def _json_safe(value: object) -> object: """Return ``value`` with non-finite floats as ``None`` (strict JSON).""" if isinstance(value, float) and not math.isfinite(value): return None if isinstance(value, dict): return {key: _json_safe(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_json_safe(item) for item in value] return value
[docs] @dataclass(frozen=True) class SettleFailure: """A settle point of a sweep that did not reach a confirmed equilibrium. A sweep never aborts because one wind does not settle: the point is recorded here with its diagnostics, the other winds carry on, and only the points that settled are linearized (unless ``require_settled=False``; see :func:`run_operating_sweep`). Parameters ---------- wind_speed : float The wind speed (m/s) of the point. reason : {"not_settled", "failed", "error"} ``"not_settled"``: the settle ran to completion but its criterion was not met (or could not be assessed). ``"failed"``: the settle could not complete (:class:`~vane.run.SteadyStateError`, e.g. OpenFAST failed or its output could not be read). ``"error"``: an unexpected exception, which :func:`run_operating_sweep` re-raises once the sweep is complete. message : str A one-line description of what went wrong. linearized : bool Whether the point was linearized anyway (``"not_settled"`` with ``require_settled=False``, at the settle's last mean). operating_point : OperatingPoint or None, optional The settle's last mean operating point (with its ``settle_convergence``) when the settle ran to completion; ``None`` otherwise. convergence : SettleConvergence or None, optional The settle evidence: the last RMS, every attempt with its horizon and window, the criterion, and for a two-stage settle the confirmation. ``None`` when the settle failed before any run was judged. """ wind_speed: float reason: SettleFailureReason message: str linearized: bool = False operating_point: OperatingPoint | None = None convergence: SettleConvergence | None = None
[docs] def to_dict(self) -> dict[str, object]: """Return the failure as a strict-JSON dictionary (unmeasured RMS as null). Returns ------- dict[str, object] ``wind_speed``, ``reason``, ``message``, ``linearized``, the settled ``rotor_speed_rpm``/``blade_pitch_deg``/``platform`` when measured, and the ``convergence`` evidence. """ point = self.operating_point record: dict[str, object] = { "wind_speed": self.wind_speed, "reason": self.reason, "message": self.message, "linearized": self.linearized, "rotor_speed_rpm": None if point is None else point.rotor_speed_rpm, "blade_pitch_deg": None if point is None else point.blade_pitch_deg, "platform": None if point is None or point.platform is None else asdict(point.platform), "convergence": None if self.convergence is None else self.convergence.to_dict(), } safe = _json_safe(record) assert isinstance(safe, dict) return safe
[docs] class SweepSettleError(SteadyStateError): """Raised at the end of a sweep whose settle stage did not fully succeed. Raised only after every wind has been settled and every usable point linearized, so no completed work is lost: the full :class:`OperatingSweepResult` is attached as :attr:`result`. Raised when ``strict_settle=True`` and any point did not settle, or whenever a settle worker hit an unexpected exception (the first is chained as ``__cause__``). Parameters ---------- message : str The error message. result : OperatingSweepResult The completed sweep result, including its ``settle_failures``. """ def __init__(self, message: str, *, result: OperatingSweepResult) -> None: super().__init__(message) self.result = result
[docs] @dataclass class OperatingSweepResult: """Result of a fully automated settle/linearize operating sweep. Parameters ---------- operating_points : list[OperatingPoint] The linearized operating points, in ``wind_speeds`` order. Settle-derived points carry their ``settle_convergence`` and, when the settle runs were seeded from an operating curve, their ``settle_curve_check``. A settle point that was not linearized is absent here and listed in ``settle_failures``. cases : list[SweepCase] The linearization cases, one per operating point. settle_structural_dofs : dict[str, bool] or None, optional For a settle sweep, the template ElastoDyn flags the settle runs ran with for every structural DOF the linearization sets (the settle copy keeps the deck's flags; see :func:`run_operating_sweep`). ``None`` without settle runs. settle_failures : tuple of SettleFailure, optional The settle points that did not reach a confirmed equilibrium, in ``wind_speeds`` order, with their diagnostics. Empty when every point settled. floating_plan : FloatingSettlePlan or None, optional For a settle sweep, how the settle runs were set up automatically: the controller library used (or that none was loadable, so the rotor was prescribed from the operating curve), the settle damping and the platform probe it was sized from (see :func:`~vane.run.plan_floating_settle`). ``None`` without settle runs. auto_configuration : AutoConfiguration or None, optional Every value the sweep inferred from the deck and curve (template root, OpenFAST executable, rated wind speed and torque, pitch trim gain, module settings), each with its source, and the warnings raised while inferring them. ``None`` with ``auto_configure=False``. """ operating_points: list[OperatingPoint] cases: list[SweepCase] settle_structural_dofs: dict[str, bool] | None = None settle_failures: tuple[SettleFailure, ...] = () floating_plan: FloatingSettlePlan | None = None auto_configuration: AutoConfiguration | None = None
def _has_rotating_point( wind_speeds: Sequence[float], *, curve_wind: Sequence[float] | None, curve_rotor_speed: Sequence[float] | None, settle: bool, ) -> bool: """Return whether any requested point will have a spinning rotor. With an operating curve the rotor speed is read from the curve at each wind (a parked schedule is zero). A settle-derived point at positive wind is taken as rotating, since the controller sets its speed, unless the curve parks every point: a settle without a controller library prescribes the curve's (zero) speeds, and a controller that spins the rotor anyway is still caught when each case is configured. """ winds = [float(wind) for wind in wind_speeds] if curve_wind is not None and curve_rotor_speed is not None: speeds = np.interp( winds, np.asarray(curve_wind, dtype=np.float64), np.asarray(curve_rotor_speed, dtype=np.float64), ) if not settle or not np.any(speeds > 0.0): return bool(np.any(speeds > 0.0)) return any(wind > 0.0 for wind in winds)
[docs] def run_operating_sweep( fst_path: str | Path, output_dir: str | Path, wind_speeds: Sequence[float], *, curve_wind: Sequence[float] | None = None, curve_rotor_speed: Sequence[float] | None = None, curve_pitch: Sequence[float] | None = None, performance_csv: str | Path | None = None, settle: bool = False, settle_time: float = 1200.0, discard_time: float = 600.0, max_settle_time: float | None = None, controller_library: str | Path | None = None, settle_initial_platform: PlatformPosition | Mapping[float, PlatformPosition] | None = None, settle_curve_tolerances: TrimTolerances | None = None, settle_criterion: SettleCriterion = "auto", settle_platform_damping: SettlePlatformDamping | None = None, settle_confirm_time: float | None = None, settle_equilibrium_tolerances: EquilibriumTolerances | None = None, settle_auto_damping: bool = True, controller_search_paths: Sequence[str | Path] = (), settings: LinearizationSettings | None = None, structural_dofs: dict[str, bool] | None = None, trim_tuning: TrimTuningPolicy | None = None, retry_policy: LinearizationRetryPolicy | None = None, executable: str | Path | None = None, template_root: str | Path | None = None, template_copy: TemplateCopyMode = "referenced", timeout: float | None = None, max_workers: int | None = None, reserve_cpus: int = 1, require_settled: bool = True, strict_settle: bool = False, strict_cases: bool = False, validate: bool = True, auto_configure: bool = True, ) -> OperatingSweepResult: """Derive operating points and run an OpenFAST linearization sweep. The function refuses to guess the rotor speed and blade pitch. It needs a rotor-performance source: an operating curve (``curve_wind``/``curve_rotor_speed``/ ``curve_pitch``) or a rotor-performance CSV (``performance_csv``) -- not both -- and/or ``settle=True`` to run the fully coupled controller first and read the operating point from the settled time series. Without any source, or with both a curve and a CSV, the sweep fails before any OpenFAST process is launched. Everything else is configured automatically by default (``auto_configure``): ``run_operating_sweep(fst_path, output_dir, wind_speeds, performance_csv=csv)`` is a complete call. The copy root, the OpenFAST executable, the module settings and the turbine constants (rated wind speed, rated generator torque, pitch trim gain) are read from the deck and the curve, each logged and recorded with its source; anything that still cannot be made linearizable fails before launch with the keyword and its remedy. The combinations mean: * curve or CSV alone -- the curve, interpolated at each wind speed, is the linearization point. * ``settle=True`` alone -- each settle run starts from 0 rpm and 0 deg collective pitch (written to the settle copy's ElastoDyn ``RotSpeed``/``BlPitch``) and the deck's platform position; the settled point is linearized. * ``settle=True`` with a curve or CSV -- the curve is used **only as the settle runs' initial conditions** (``RotSpeed`` and ``BlPitch(1..3)`` at each wind speed), so the rotor does not have to spin up from rest and start-up transients are smaller. The controller still decides where the turbine settles and the **settled** point is linearized, never the curve value. After each settle the settled rotor speed and pitch are compared with the curve (``settle_curve_tolerances``); a deviation outside tolerance is logged as a warning and recorded on the point's ``settle_curve_check`` (and in the run provenance). It means the controller and the curve disagree and is not an error. Every requested wind speed must lie inside the curve's range. The platform offset is never estimated from the curve (VANE computes no thrust); it starts at the deck's values unless ``settle_initial_platform`` supplies it. Parameters ---------- controller_library : str or pathlib.Path or None, optional Bladed-style controller library (e.g. a ``libdiscon.dll`` built for this host) for the ``settle=True`` runs. Each settle case's ServoDyn ``DLL_FileName`` is rewritten to it in the case copy only; the template deck is never edited. ``None`` (the default) finds one automatically (see :func:`~vane.run.find_controller_library`): the deck's own ``DLL_FileName`` when it loads on this host, else a host build of the same library next to it or in ``controller_search_paths``, ``VANE_CONTROLLER_LIBRARIES`` or the configuration file. When none exists the sweep does not fail: each settle runs with the rotor **prescribed** at the operating curve's speed and pitch (no controller; ``prescribed_rotor`` of :func:`~vane.run.solve_steady_state`) and the point is linearized by CalcSteady from the platform equilibrium it finds -- which requires an operating curve or ``performance_csv``. The decision is recorded in :attr:`OperatingSweepResult.floating_plan`. Only valid with ``settle=True`` (the linearization replaces the controller by the trim and never loads the library). settle_initial_platform : PlatformPosition or Mapping or None, optional Initial platform position for the settle runs: one :class:`~vane.run.PlatformPosition` for every wind speed, or a mapping from wind speed to position (winds absent from it keep the deck's position). Only the DOFs given are written. It is an initial condition only: a rough estimate (e.g. the mean surge and pitch of an earlier run) shortens the platform's drift to its offset without changing where it settles. ``None`` (the default) keeps the deck's platform position. Only valid with ``settle=True``; every mapping key must be a requested wind speed. settle_curve_tolerances : TrimTolerances or None, optional Tolerances of the settled-versus-curve comparison (rotor speed relative, pitch absolute; see :func:`~vane.run.compare_settled_to_curve`). ``None`` uses the :class:`~vane.run.TrimTolerances` defaults (1 %, 0.5 deg). Only valid with ``settle=True`` and a curve or CSV. settle_criterion : {"auto", "velocity", "mean_drift"}, optional How each settle run is judged settled (see :func:`~vane.run.solve_steady_state`): ``"auto"`` (the default) starts with the velocity criterion and switches to the mean-drift criterion when the platform carries a sustained, non-decaying oscillation; ``"velocity"`` is platform-velocity RMS within tolerance; ``"mean_drift"`` is operating-point means that no longer drift between the halves of the analysis window. The criterion that judged each point is recorded in its ``settle_convergence`` and in the run provenance. Only valid with ``settle=True``. settle_platform_damping : SettlePlatformDamping or None, optional Settle accelerator for floating platforms. ``None`` (the default) sizes it automatically for a floating deck from a standstill platform probe (see :func:`~vane.run.plan_floating_settle`; ``settle_auto_damping=False`` opts out). Each settle then runs in two stages, first with this temporary linear damping added to HydroDyn's ``AddBLin`` in the settle copy, then undamped from the stage-1 means to confirm the equilibrium is unchanged (see :func:`~vane.run.solve_steady_state` and :class:`~vane.run.SettlePlatformDamping`). The damping is only ever written to the settle case; every linearization case is copied afresh from the template and keeps the deck's own ``AddBLin``. The template must run HydroDyn with an ``AddBLin`` matrix; this is checked before any case is copied. Only valid with ``settle=True``. settle_confirm_time : float or None, optional ``TMax`` (s) of the undamped confirmation run of a two-stage settle. ``None`` (the default) sizes it automatically so its analysis window holds eight periods of the slowest platform mode -- the platform probe's longest period when the probe ran, else the damped stage's dominant period -- never shorter than 1200 s (the length without any period) and never longer than ``max_settle_time`` when given, else 3600 s (see ``confirm_time`` of :func:`~vane.run.solve_steady_state`). A decaying confirmation whose means have not yet returned to the damped equilibrium is rerun once twice as long. Used only with a settle damping. settle_equilibrium_tolerances : EquilibriumTolerances or None, optional Tolerances on a change of the settled means (damped versus undamped stage, and window halves for ``"mean_drift"``). ``None`` uses the :class:`~vane.run.EquilibriumTolerances` defaults. Only valid with ``settle=True``. settle_auto_damping : bool, optional For a floating deck without ``settle_platform_damping``, size the settle damping at half of critical from a standstill platform probe (two short linearizations in ``output_dir``; see :func:`~vane.run.probe_platform`). Default ``True``; ``False`` settles undamped. controller_search_paths : Sequence of str or pathlib.Path, optional Extra controller-library files or directories searched when ``controller_library`` is not given. performance_csv : str or pathlib.Path or None, optional Path to a rotor-performance CSV (see :func:`~vane.run.operating_curve_from_csv` for the column spec). When given it is parsed into the operating curve; it is mutually exclusive with the ``curve_*`` arrays. With ``settle=True`` it seeds the settle runs (see above). structural_dofs : dict[str, bool] or None, optional ElastoDyn structural-DOF flags to set in each case's ED sub-file. ``None`` (the default) keeps the ``settings`` object's own value, whose default disables ``YawDOF`` and ``DrTrDOF`` for a reliable trim (dropping the spurious free-yaw mode and the ~0.62 Hz drivetrain-torsion mode, the latter a deliberate trade-off for trim convergence). Pass a mapping to override — it is merged on top of the defaults, so ``{"DrTrDOF": True}`` re-enables the drivetrain-torsion mode while still disabling ``YawDOF``. A flag listed here but absent from the ED deck is an error. These flags apply to the linearization cases only: the settle copies keep the template deck's own ElastoDyn DOF flags, because a settle runs the deck's real controller on the model it was tuned for (a controller's filters assume the deck's drivetrain; the yaw drive holds the nacelle through ServoDyn). The settled mean rotor speed, pitch and platform position -- all that is carried to the linearization -- do not depend on the drivetrain or yaw flexibility. A difference between the two sets is logged, and the settle flags are recorded in :attr:`OperatingSweepResult.settle_structural_dofs`. retry_policy : LinearizationRetryPolicy or None, optional The convergence retry ladder for the linearization cases (see :class:`~vane.run.LinearizationRetryPolicy`), e.g. a larger ``max_sim_time`` for a slowly settling floating platform. ``None`` (the default) uses the default policy. trim_tuning : TrimTuningPolicy or None, optional When set, each operating point's starting trim tolerance and damping are tuned for its wind-speed region before the convergence retry ladder runs (see :func:`~vane.run.tune_trim_settings`): at/above-rated points start from the looser-tolerance, higher-damping floors those harder trims need, so they tend to converge on the first attempt rather than after a reactive retry. ``None`` (the default) starts every point from ``settings`` unchanged. Tuning only raises the per-case ``trim_tol``/``twr_kdmp``/``bld_kdmp`` toward the floors, so the sweep's provenance is unaffected. settings : LinearizationSettings or None, optional The linearization settings. ``None`` (the default) configures the deck automatically (with ``auto_configure``): the :meth:`~vane.run.LinearizationSettings.offshore` module set, adapted to the deck -- a HydroDyn radiation model whose ``.ss`` file is missing keeps ``RdtnMod`` 2 when the file can be fitted from the WAMIT ``.1`` data (it is then generated in each case copy, ``hydro_radiation_fit``), a radiation or excitation model whose state-space file cannot be had falls back to ``RdtnMod``/``ExctnMod`` 0 with a recorded warning, a deck's own linearizable unsteady-aerodynamics model (``UA_Mod`` 4-6) is kept with ``UAEndRad`` 0.99 where it would reach the tip node under tip loss, and a module the deck does not use is left untouched. Settings passed explicitly are used as given, except that the turbine constants they leave unset are still inferred (see ``auto_configure``). executable : str or pathlib.Path or None, optional The OpenFAST executable. ``None`` (the default) chooses one whose release reads the deck's input-format generation, from the ``VANE_OPENFAST_EXECUTABLES`` environment variable, the file named by ``VANE_OPENFAST_CONFIG`` (default ``~/.vane/openfast_executables.txt``) and ``PATH`` (see :func:`~vane.run.select_openfast_executable`); the sweep fails before any case is copied, naming the release the deck needs, when none can. An explicit executable is used as given, but is rejected up front when its version is known not to read the deck. template_root : str or pathlib.Path or None, optional Directory the deck's files are copied from per case. ``None`` (the default) uses the deck's own directory, or -- when the deck references files outside it (``../5MW_Baseline/...``) and ``auto_configure`` is on -- the deepest directory that holds the deck and every file it references by a relative path (:func:`~vane.run.deck_files.infer_template_root`). An explicit value always wins. template_copy : {"referenced", "tree"}, optional ``"referenced"`` (the default) copies only the files the deck references into each case (settle cases included); ``"tree"`` copies the whole ``template_root``. See :class:`~vane.run.LinearizationDriver`. require_settled : bool, optional What to do with a settle point that does not reach a confirmed equilibrium (its criterion not met, or not assessable). ``True`` (the default): do not linearize it. ``False``: linearize it at the settle's last mean. Either way the sweep carries on with the other winds -- one unsettled point never aborts it -- and the point is listed in :attr:`OperatingSweepResult.settle_failures` with its diagnostics (and, when linearized, its settle evidence is in the case provenance). A settle that cannot complete (OpenFAST fails, unreadable output) is likewise recorded and never linearized. strict_settle : bool, optional When ``True``, raise :class:`SweepSettleError` once the sweep is complete if any settle point is in ``settle_failures``; the full result is attached as its ``result``. Default ``False`` (the failures are logged and returned). An *unexpected* exception in a settle worker is always re-raised this way, after the other winds have been settled and linearized. strict_cases : bool, optional How a linearization case that raises an unexpected exception (a file-system error while its case is copied or configured, say) is reported. Such a case never aborts the sweep: it is recorded in :attr:`OperatingSweepResult.cases` as failed (``converged=False``, ``failure_mode == "case_error"``) and the other points run to completion (see :meth:`vane.run.LinearizationDriver.run_sweep`). Default ``False``: the failure is logged and the result returned. ``True``: raise :class:`vane.run.SweepCaseError` once the sweep is complete, with the full result attached as its ``result``. validate : bool, optional When ``True`` (the default) the template deck is checked under the effective settings (:func:`~vane.run.validate_deck`) and every rotating curve point's trim configuration is checked before any case is copied, and each configured case again before its OpenFAST run. auto_configure : bool, optional When ``True`` (the default) the sweep completes its configuration from the user's own deck and curve, logging every value and recording it with its source in :attr:`OperatingSweepResult.auto_configuration`, in the driver's run provenance and in ``output_dir/auto_configuration.json`` (:func:`~vane.run.auto_configure_sweep`): the template root and executable (above), the module settings when ``settings`` is ``None``, and the turbine constants left unset -- ``rated_wind_speed`` (where the curve's rotor speed and power reach their rated plateau), ``rated_generator_torque`` (controller ``DISCON.IN`` ``VS_RtTq``, else ServoDyn ``VS_RtTq`` with ``VSContrl = 1``, else the curve's ``cp`` at rated) and a ``trim_gain_pitch`` left at its class default (scaled as ``2e-4 * (63 m / TipRad)**3``). VANE computes no rotor performance in doing so. ``False`` restores the explicit behaviour: nothing is inferred, ``settings=None`` means :meth:`~vane.run.LinearizationSettings.offshore`, and ``executable=None`` means ``"openfast"`` on ``PATH``. Raises ------ SweepSettleError After the sweep completes, with ``strict_settle=True`` and a settle failure, or when a settle worker raised an unexpected exception. SweepCaseError After the sweep completes, with ``strict_cases=True`` and a linearization case that raised an unexpected exception. ValueError Before any launch, for a malformed request or a rotating sweep whose rated wind speed is neither given nor inferable. vane.run.DeckValidationError Before any launch, when ``validate`` is on and the template cannot be linearized under the effective settings; every problem is listed with the keyword and its remedy. vane.io.FastInputError Before any launch, when a rotating curve point's trim cannot be configured (e.g. no rated generator torque for an above-rated point). vane.run.OpenFastError Before any launch, when no OpenFAST executable can read the deck. """ workers = ( default_max_workers(reserve_cpus=reserve_cpus) if max_workers is None else max_workers ) if workers < 1: msg = f"max_workers must be >= 1, got {workers}" raise ValueError(msg) # A performance CSV is parsed to the operating curve up front. The CSV and the # curve arrays are two forms of the same source, so only one may be given; either # may be combined with settle=True, where it seeds the settle runs. curve_given = any( item is not None for item in (curve_wind, curve_rotor_speed, curve_pitch) ) if performance_csv is not None: if curve_given: msg = ( "provide one operating curve: performance_csv is mutually exclusive " "with curve_wind/curve_rotor_speed/curve_pitch" ) raise ValueError(msg) curve_wind, curve_rotor_speed, curve_pitch = operating_curve_from_csv( performance_csv ) curve_given = True if settle_initial_platform is not None and not settle: msg = ( "settle_initial_platform applies only to settle=True runs; it is an " "initial condition of the settle, not of the linearization" ) raise ValueError(msg) if settle_curve_tolerances is not None and not (settle and curve_given): msg = ( "settle_curve_tolerances applies only to settle=True runs seeded from an " "operating curve or performance_csv" ) raise ValueError(msg) judgement = _SettleJudgement( criterion=settle_criterion, platform_damping=settle_platform_damping, confirm_time=settle_confirm_time, tolerances=settle_equilibrium_tolerances, ) if judgement != _SettleJudgement() and not settle: msg = ( "settle_criterion, settle_platform_damping, settle_confirm_time and " "settle_equilibrium_tolerances apply only to settle=True runs" ) raise ValueError(msg) inferred: list[InferredValue] = [] if ( auto_configure and template_root is None and Path(fst_path).is_file() and (root := infer_template_root(fst_path)) != resolve_path(fst_path).parent ): # The deck references inputs outside its folder: copy from their common # ancestor, so every relative reference and edited sub-file is in the case. if resolve_path(output_dir).is_relative_to(root): msg = ( f"output_dir {output_dir} lies inside {root}, the directory the deck's " "cases must be copied from (it references files there by relative " "paths, e.g. ../); choose an output_dir outside it, or pass " "template_root explicitly" ) raise ValueError(msg) template_root = root inferred.append( InferredValue( "template_root", str(root), "deck references", "the deepest directory holding the deck and every file it " "references by a relative path", ) ) logger.info( "auto-configured template_root = %s (the deck references files outside " "its own directory)", root, ) _validate_sweep_inputs( Path(fst_path), wind_speeds, curve_wind=curve_wind, curve_rotor_speed=curve_rotor_speed, curve_pitch=curve_pitch, settle=settle, template_root=Path(template_root) if template_root is not None else None, ) if controller_library is not None and not settle: msg = ( "controller_library applies only to settle=True runs; the linearization " "replaces the controller by the trim and never loads the library" ) raise ValueError(msg) if settle_platform_damping is not None: # The damping goes into each settle copy's HydroDyn AddBLin; fail before any # case is copied when the template has none. _hydrodyn_with_damping_matrix(Path(fst_path)) auto: AutoConfiguration | None = None if auto_configure: effective_settings, auto = auto_configure_sweep( fst_path, settings, curve_wind=curve_wind, curve_rotor_speed=curve_rotor_speed, curve_cp=( curve_power_coefficient(performance_csv, curve_wind) if performance_csv is not None and curve_wind is not None else None ), ) auto = AutoConfiguration(tuple(inferred)).extended(auto.inferred, auto.warnings) else: effective_settings = settings or LinearizationSettings.offshore() if ( effective_settings.rated_wind_speed is None and needs_rated_wind_speed(effective_settings, fst_path) and _has_rotating_point( wind_speeds, curve_wind=curve_wind, curve_rotor_speed=curve_rotor_speed, settle=settle, ) ): # Fail at the boundary, before any settle or linearization run is launched; # only a rotating point with trim or controller handling needs the region. reason = ( "" if auto is None else " " + "; ".join( warning for warning in auto.warnings if "rated_wind_speed" in warning ) ) msg = ( "rated_wind_speed is required for a rotating operating sweep (it selects " "torque or pitch trim) and could not be inferred: it is read from the " "operating curve (performance_csv or curve_*), where the rotor speed " "reaches its rated plateau, so supply a curve that extends past rated or " "pass settings with rated_wind_speed set for the turbine, e.g. " "LinearizationSettings.offshore(rated_wind_speed=10.59)." + reason ) raise ValueError(msg.rstrip(" .")) if structural_dofs is not None: effective_settings = replace( effective_settings, structural_dofs=structural_dofs ) if validate: # Every blocking linearization problem of the template under these settings # is reported now, before any case is copied or OpenFAST launched. validate_deck(fst_path, effective_settings) if not settle and curve_wind is not None and effective_settings.trim_controller: _precheck_curve_points( Path(fst_path), wind_speeds, effective_settings, curve_wind=curve_wind, curve_rotor_speed=curve_rotor_speed, curve_pitch=curve_pitch, ) resolved_executable: str | Path if auto_configure: selection = select_openfast_executable(fst_path, executable) resolved_executable = selection.executable assert auto is not None auto = auto.extended( [ InferredValue( "executable", str(selection.executable), selection.source, f"OpenFAST {'.'.join(map(str, selection.version or ())) or '?'}" f" for a {selection.generation.label} deck " f"({'; '.join(selection.generation.evidence)}); requires " f"{selection.generation.describe()}", ) ] ) else: resolved_executable = executable if executable is not None else "openfast" driver = LinearizationDriver( fst_path=Path(fst_path), output_dir=Path(output_dir), settings=effective_settings, executable=resolved_executable, template_root=Path(template_root) if template_root is not None else None, template_copy=template_copy, validate=validate, trim_tuning=trim_tuning, retry_policy=( retry_policy if retry_policy is not None else LinearizationRetryPolicy() ), auto_configuration=None if auto is None else auto.to_dict(), ) if settle and controller_library is None and not curve_given: # Without an operating curve the rotor cannot be prescribed, so a settle needs # a loadable controller library: check that before anything is written or a # platform probe is run. controller = find_controller_library( Path(fst_path), search_paths=controller_search_paths ) if controller.status == "missing": msg = ( "controller library not found on this host for " f"{controller.deck_library!r}, and no operating curve to prescribe " "the rotor from: pass controller_library=..., set " "VANE_CONTROLLER_LIBRARIES, or supply performance_csv / curve_*" ) raise SteadyStateError(msg) if auto is not None: write_json_atomic( Path(output_dir) / AUTO_CONFIGURATION_FILE, _json_safe(auto.to_dict()), overwrite=True, ) settle_dofs = ( _settle_structural_dofs(Path(fst_path), effective_settings) if settle else None ) floating_plan: FloatingSettlePlan | None = None settle_library: Path | None = None if settle: # Resolve the controller library and size the settle damping from the deck # itself (see vane.run.floating_auto); a missing library falls back to a # prescribed-rotor settle seeded from the operating curve. # The settle schedule fails here, before the platform probe launches # OpenFAST, rather than in every settle worker afterwards. validate_settle_schedule( settle_time, discard_time, adaptive=max_settle_time is not None, max_settle_time=max_settle_time, criterion=settle_criterion, confirm_time=settle_confirm_time, ) floating_plan = plan_floating_settle( fst_path, driver.validated_workspace_dir("probe"), controller_library=controller_library, platform_damping=settle_platform_damping, auto_damping=settle_auto_damping, controller_search_paths=controller_search_paths, template_root=template_root, template_copy=template_copy, executable=resolved_executable, timeout=timeout, ) if floating_plan.prescribed_rotor and not curve_given: msg = ( "controller library not found on this host for " f"{floating_plan.controller.deck_library!r}, and no operating curve " "to prescribe the rotor from: pass controller_library=..., set " "VANE_CONTROLLER_LIBRARIES, or supply performance_csv / curve_*" ) raise SteadyStateError(msg) settle_library = floating_plan.controller_library judgement = replace( judgement, platform_damping=floating_plan.platform_damping, prescribed_rotor=floating_plan.prescribed_rotor, # The probe's slowest platform period sizes the undamped confirmation. platform_period=( None if floating_plan.probe is None else floating_plan.probe.slowest_period ), ) period = ( None if floating_plan.probe is None else floating_plan.probe.slowest_period ) if period is not None and driver.settings.sim_time is None: sim_time = floating_trim_time(period, driver.retry_policy.max_sim_time) driver.settings = replace(driver.settings, sim_time=sim_time) record = InferredValue( "sim_time", sim_time, "platform probe", f"{FLOATING_TRIM_PERIODS} periods of the slowest platform mode " f"({period:.4g} s), within [{MIN_CALC_STEADY_TMAX:g} s, " f"{driver.retry_policy.max_sim_time:g} s]", ) logger.info( "auto-configured sim_time = %r from platform probe (%s)", sim_time, record.detail, ) if auto is not None: auto = auto.extended((record,), ()) driver.auto_configuration = auto.to_dict() write_json_atomic( Path(output_dir) / AUTO_CONFIGURATION_FILE, _json_safe(auto.to_dict()), overwrite=True, ) derived = _derive_operating_points( driver, wind_speeds, curve_wind=curve_wind, curve_rotor_speed=curve_rotor_speed, curve_pitch=curve_pitch, settle=settle, settle_time=settle_time, discard_time=discard_time, max_settle_time=max_settle_time, controller_library=settle_library, executable=resolved_executable, timeout=timeout, max_workers=workers, require_settled=require_settled, initial_platform=settle_initial_platform, curve_tolerances=settle_curve_tolerances, judgement=judgement, ) case_error: SweepCaseError | None = None cases: list[SweepCase] = [] if derived.points: try: cases = driver.run_sweep( derived.points, timeout=timeout, max_workers=workers, strict=strict_cases, ) except SweepCaseError as error: # Every case has finished; keep them and raise once the result is built. case_error = error cases = error.cases result = OperatingSweepResult( operating_points=derived.points, cases=cases, settle_structural_dofs=settle_dofs, settle_failures=tuple(derived.failures), floating_plan=floating_plan, auto_configuration=auto, ) if derived.failures: summary = "; ".join( f"{failure.wind_speed:g} m/s {failure.reason}" + (" (linearized at its last mean)" if failure.linearized else "") for failure in derived.failures ) logger.warning( "%d of %d settle point(s) did not reach a confirmed equilibrium: %s", len(derived.failures), len(wind_speeds), summary, ) if derived.errors: msg = ( f"{len(derived.errors)} settle worker(s) raised an unexpected exception " "(the other points were settled and linearized; see .result): " + "; ".join( failure.message for failure in derived.failures if failure.reason == "error" ) ) raise SweepSettleError(msg, result=result) from derived.errors[0] if case_error is not None: raise SweepCaseError( str(case_error), cases=cases, result=result ) from case_error.__cause__ if strict_settle and derived.failures: msg = ( "strict_settle: settle point(s) did not reach a confirmed equilibrium: " + "; ".join( f"{failure.wind_speed:g} m/s: {failure.message}" for failure in derived.failures ) ) raise SweepSettleError(msg, result=result) return result
def _settle_structural_dofs( fst_path: Path, settings: LinearizationSettings ) -> dict[str, bool]: """Return the template's flags for the DOFs the linearization sets. The settle copy keeps the template deck's ElastoDyn DOF flags; the linearization writes ``settings.resolved_structural_dofs()``. Each such flag the template deck carries is returned as the settle runs see it, and a difference from the linearization's value is logged so the two DOF sets are never confused. """ try: deck = FastInputFile.read(fst_path) elastodyn = FastInputFile.read(deck.resolve("EDFile")) except (OSError, FastInputError): return {} flags: dict[str, bool] = {} for flag, linearized in settings.resolved_structural_dofs().items(): if flag not in elastodyn: continue settled = _fortran_logical(elastodyn.get(flag)) if settled is None: continue flags[flag] = settled if settled != linearized: logger.info( "settle runs keep the deck's %s=%s for its controller; the " "linearization uses %s=%s", flag, settled, flag, linearized, ) return flags def _validate_sweep_inputs( fst_path: Path, wind_speeds: Sequence[float], *, curve_wind: Sequence[float] | None, curve_rotor_speed: Sequence[float] | None, curve_pitch: Sequence[float] | None, settle: bool, template_root: Path | None, ) -> None: """Reject a malformed sweep request before any OpenFAST process is launched. Validates the requested wind speeds and (when given) the operating curve at the boundary, raising one clear error per problem, so a misconfigured sweep fails immediately rather than part-way through a long campaign. Parameters ---------- fst_path : pathlib.Path The template primary ``.fst`` deck. wind_speeds : Sequence[float] The requested sweep wind speeds (m/s). curve_wind, curve_rotor_speed, curve_pitch : Sequence[float] or None The operating-curve arrays, when the curve source is used. settle : bool Whether the settle source is requested. template_root : pathlib.Path or None The copy-root directory, when one is given. Raises ------ ValueError If ``wind_speeds`` is empty, non-finite, negative, or contains duplicates; the curve arrays mismatch in length, are too short, are not strictly increasing in wind or do not cover every requested wind speed; or ``template_root`` is given but does not exist or does not contain ``fst_path``. FileNotFoundError If the template ``.fst`` deck does not exist. vane.io.FastInputError If a positive wind speed is requested but the template deck has no usable InflowWind file to set it in (which would silently linearize in still air). """ if not fst_path.is_file(): msg = f"template .fst deck not found: {fst_path}" raise FileNotFoundError(msg) requested = [float(w) for w in wind_speeds] if not requested: msg = "wind_speeds is empty; provide at least one wind speed to linearize" raise ValueError(msg) for wind in requested: if not math.isfinite(wind): msg = f"wind_speeds must all be finite, got {requested}" raise ValueError(msg) if wind < 0.0: msg = f"wind_speeds must be non-negative, got {wind}" raise ValueError(msg) duplicates = sorted({w for w in requested if requested.count(w) > 1}) if duplicates: msg = f"wind_speeds contains duplicate wind speeds: {duplicates}" raise ValueError(msg) if template_root is not None: if not template_root.is_dir(): msg = f"template_root does not exist or is not a directory: {template_root}" raise ValueError(msg) if not resolve_path(fst_path).is_relative_to(resolve_path(template_root)): msg = ( f"fst_path {fst_path} is not inside template_root {template_root}; the " "copy-root must contain the deck" ) raise ValueError(msg) curve_given = any( item is not None for item in (curve_wind, curve_rotor_speed, curve_pitch) ) if curve_given: _validate_curve_arrays(curve_wind, curve_rotor_speed, curve_pitch) assert curve_wind is not None assert curve_rotor_speed is not None assert curve_pitch is not None # Interpolate now: a curve that is not strictly increasing, holds a # non-finite value, or does not cover every requested wind fails here -- # before a settle sweep's platform probe launches OpenFAST. operating_points_from_curve( requested, curve_wind=curve_wind, curve_rotor_speed=curve_rotor_speed, curve_pitch=curve_pitch, ) # Any positive wind point needs a usable InflowWind file in the template to write # the wind into; catch its absence here rather than after copying every case. if any(wind > 0.0 for wind in requested) and not _has_usable_inflow(fst_path): msg = ( f"a positive wind speed was requested but {fst_path} has no usable " "InflowWind file to set it in; the linearization would run in still air" ) raise FastInputError(msg) def _validate_curve_arrays( curve_wind: Sequence[float] | None, curve_rotor_speed: Sequence[float] | None, curve_pitch: Sequence[float] | None, ) -> None: """Reject a partial or mismatched operating curve before launch.""" if curve_wind is None or curve_rotor_speed is None or curve_pitch is None: msg = ( "curve_wind, curve_rotor_speed, and curve_pitch must be supplied together; " "rotor speed and blade pitch are required" ) raise ValueError(msg) lengths = (len(curve_wind), len(curve_rotor_speed), len(curve_pitch)) if len(set(lengths)) != 1: msg = ( "curve_wind, curve_rotor_speed, and curve_pitch must have the same length, " f"got {lengths}" ) raise ValueError(msg) if lengths[0] < 2: msg = f"the operating curve needs at least two samples, got {lengths[0]}" raise ValueError(msg) def _has_usable_inflow(fst_path: Path) -> bool: """Return whether the template deck resolves to an existing InflowWind file.""" try: deck = FastInputFile.read(fst_path) except (OSError, FastInputError): return False if "InflowFile" not in deck: return False try: inflow = deck.resolve("InflowFile") except FastInputError: return False return inflow.is_file() def _precheck_curve_points( fst_path: Path, wind_speeds: Sequence[float], settings: LinearizationSettings, *, curve_wind: Sequence[float], curve_rotor_speed: Sequence[float] | None, curve_pitch: Sequence[float] | None, ) -> None: """Reject curve points the trim cannot be configured for, before any launch. Each rotating curve point is checked against the template deck exactly as :func:`~vane.run.configure_linearization` checks it per case (the generator torque an above-rated trim needs, the ``UAEndRad`` line ``ua_end_radius`` needs, ...), so a configuration error common to every case fails here, naming the keyword to supply, instead of once per case after the sweep has started. """ if curve_rotor_speed is None or curve_pitch is None: return deck = FastInputFile.read(fst_path) points = operating_points_from_curve( wind_speeds, curve_wind=curve_wind, curve_rotor_speed=curve_rotor_speed, curve_pitch=curve_pitch, ) for point in points: if point.is_rotating: _validate_operating_configuration(point, settings, deck) def _derive_operating_points( driver: LinearizationDriver, wind_speeds: Sequence[float], *, curve_wind: Sequence[float] | None, curve_rotor_speed: Sequence[float] | None, curve_pitch: Sequence[float] | None, settle: bool, settle_time: float, discard_time: float, max_settle_time: float | None, controller_library: Path | None, executable: str | Path, timeout: float | None, max_workers: int, require_settled: bool, initial_platform: PlatformPosition | Mapping[float, PlatformPosition] | None = None, curve_tolerances: TrimTolerances | None = None, judgement: _SettleJudgement | None = None, ) -> _DerivedPoints: """Return the linearization points from the curve, settle runs, or both. With both, the curve points seed the settle runs and the settled points are returned, each carrying its comparison with the curve, together with the settle points that did not reach equilibrium. """ curve_given = any( item is not None for item in (curve_wind, curve_rotor_speed, curve_pitch) ) curve_points: list[OperatingPoint] | None = None if curve_given: if curve_wind is None or curve_rotor_speed is None or curve_pitch is None: msg = ( "curve_wind, curve_rotor_speed, and curve_pitch must be supplied " "together; rotor speed and blade pitch are required" ) raise ValueError(msg) curve_points = operating_points_from_curve( wind_speeds, curve_wind=curve_wind, curve_rotor_speed=curve_rotor_speed, curve_pitch=curve_pitch, ) if not settle: return _DerivedPoints(points=curve_points) if not settle: msg = ( "rotor-performance data is required: supply curve_wind/" "curve_rotor_speed/curve_pitch, or set settle=True to derive rotor speed " "and blade pitch from controller settle runs" ) raise ValueError(msg) platforms = _settle_platforms(wind_speeds, initial_platform) return _settled_operating_points( driver, wind_speeds, settle_time=settle_time, discard_time=discard_time, max_settle_time=max_settle_time, controller_library=controller_library, executable=executable, timeout=timeout, max_workers=max_workers, require_settled=require_settled, seeds=curve_points, platforms=platforms, curve_tolerances=curve_tolerances, judgement=judgement, ) def _settle_platforms( wind_speeds: Sequence[float], initial_platform: PlatformPosition | Mapping[float, PlatformPosition] | None, ) -> list[PlatformPosition | None]: """Return each wind speed's initial settle platform position (``None``: deck's). Raises ------ ValueError If a mapping names a wind speed that is not requested (a likely typo that would otherwise silently leave that point at the deck's position). TypeError If a value is not a :class:`~vane.run.PlatformPosition`. """ winds = [float(wind) for wind in wind_speeds] if initial_platform is None: return [None] * len(winds) if isinstance(initial_platform, PlatformPosition): return [initial_platform] * len(winds) if not isinstance(initial_platform, Mapping): msg = ( "settle_initial_platform must be a PlatformPosition or a mapping of wind " f"speed to PlatformPosition, got {type(initial_platform).__name__}" ) raise TypeError(msg) by_wind: dict[float, PlatformPosition] = {} for key, position in initial_platform.items(): if not isinstance(position, PlatformPosition): msg = ( f"settle_initial_platform[{key!r}] must be a PlatformPosition, got " f"{type(position).__name__}" ) raise TypeError(msg) by_wind[float(key)] = position unknown = sorted(set(by_wind) - set(winds)) if unknown: msg = ( f"settle_initial_platform names wind speeds {unknown} that are not in " f"wind_speeds {winds}" ) raise ValueError(msg) return [by_wind.get(wind) for wind in winds] def _settle_edited_subfile_keys( controller_library: Path | None, *, platform_damping: bool = False ) -> tuple[str, ...]: """Return the sub-files a settle case edits. ServoDyn is edited only with a controller-library override, and HydroDyn only when temporary platform damping is added to its ``AddBLin``. """ keys: tuple[str, ...] = SETTLE_EDITED_SUBFILE_KEYS if controller_library is None: keys = tuple(key for key in keys if key != "ServoFile") if platform_damping: keys = (*keys, "HydroFile") return keys def _settled_operating_points( driver: LinearizationDriver, wind_speeds: Sequence[float], *, settle_time: float, discard_time: float, max_settle_time: float | None, controller_library: Path | None = None, executable: str | Path, timeout: float | None, max_workers: int, require_settled: bool, seeds: Sequence[OperatingPoint] | None = None, platforms: Sequence[PlatformPosition | None] | None = None, curve_tolerances: TrimTolerances | None = None, judgement: _SettleJudgement | None = None, ) -> _DerivedPoints: """Settle each wind speed in its own copied case, parallelized by wind. ``seeds`` (the operating curve at each wind, in order) supply the initial rotor speed and pitch, and each settled point is then compared with its seed; ``platforms`` supply the initial platform positions. Without a seed a settle starts from 0 rpm and 0 deg pitch. One point's failure never stops the others: every wind is settled, each outcome is classified (settled, not settled, failed, unexpected error), and the points to linearize are returned in wind order with the failures and errors. """ speeds = list(wind_speeds) seed_of = dict(zip(speeds, seeds, strict=True)) if seeds is not None else {} platform_of = ( dict(zip(speeds, platforms, strict=True)) if platforms is not None else {} ) # Reject any winds that map to the same workspace/operating-point label, not just # exact float duplicates. Two distinct winds that ``:g``-format to the same string # (e.g. 8.0000001 and 8.0000002 -> "ws8") would otherwise share one settle workspace # and clobber each other in parallel, and collide again as duplicate linearization # case names downstream. Failing fast here, naming the offenders, is unambiguous. label_to_winds: dict[str, list[float]] = {} for speed in speeds: label_to_winds.setdefault(f"ws{speed:g}", []).append(speed) colliding = { label: winds for label, winds in label_to_winds.items() if len(winds) > 1 } if colliding: details = "; ".join( f"{sorted(winds)} -> {label!r}" for label, winds in sorted(colliding.items()) ) msg = ( "wind_speeds map to colliding settle case names (distinct winds that " f"format to the same label would share a workspace): {details}" ) raise ValueError(msg) # Validate every settle case directory up front, before any worker copies a tree, so # a name colliding with the template tree fails the whole sweep before a single # rmtree runs (preserving fail-before-launch under concurrency). settle_names = {speed: f"settle_ws{speed:g}" for speed in speeds} for name in settle_names.values(): driver.validated_workspace_dir(name) # ServoDyn is edited (so must lie inside each case) only with a library override. judged = judgement if judgement is not None else _SettleJudgement() settle_keys = _settle_edited_subfile_keys( controller_library, platform_damping=judged.platform_damping is not None ) # A sub-file a settle edits that lies outside the template root lies outside # every case copy too: a configuration error common to all winds, so it fails # here, before any case is copied, rather than once per settle worker. assert driver.template_root is not None require_inputs_within(driver.fst_path, driver.template_root, keys=settle_keys) def run_one(wind_speed: float) -> OperatingPoint: assert driver.template_root is not None # Route through the driver's overlap guard so a settle case whose name collides # with a template directory can never rmtree or be created inside the template. case_dir = driver.validated_workspace_dir(settle_names[wind_speed]) if case_dir.exists(): rmtree_with_retry(case_dir) driver._copy_template(driver.template_root, case_dir) deck = case_dir / driver.fst_path.relative_to(driver.template_root) # The settle run edits the case's sub-files; none may be shared. require_inputs_within(deck, case_dir, keys=settle_keys) seed = seed_of.get(wind_speed) settled = solve_steady_state( deck, wind_speed, rotor_speed_rpm=seed.rotor_speed_rpm if seed is not None else 0.0, blade_pitch_deg=seed.blade_pitch_deg if seed is not None else 0.0, platform_position=platform_of.get(wind_speed), settle_time=settle_time, discard_time=discard_time, executable=executable, timeout=timeout, name=f"ws{wind_speed:g}", adaptive=max_settle_time is not None, max_settle_time=max_settle_time, # The sweep decides what an unsettled point means (see _classify); the # settle itself only measures and reports. require_settled=False, # Settle in the same inflow the linearization will use. wind_shear_exponent=driver.settings.wind_shear_exponent, controller_library=controller_library, criterion=judged.criterion, platform_damping=judged.platform_damping, confirm_time=judged.confirm_time, equilibrium_tolerances=judged.tolerances, prescribed_rotor=judged.prescribed_rotor, platform_period=judged.platform_period, ) if seed is None: return settled return _with_curve_check(settled, seed, curve_tolerances) def attempt(wind_speed: float) -> _SettleOutcome: try: point = run_one(wind_speed) except SteadyStateError as error: logger.error("settle at %g m/s failed: %s", wind_speed, error) # noqa: TRY400 first_line = str(error).strip().splitlines()[0] if str(error) else "" return _SettleOutcome( failure=SettleFailure( wind_speed=wind_speed, reason="failed", message=first_line or type(error).__name__, convergence=error.convergence, ) ) except Exception as error: # recorded per point, re-raised after the sweep logger.exception("settle at %g m/s raised unexpectedly", wind_speed) return _SettleOutcome( failure=SettleFailure( wind_speed=wind_speed, reason="error", message=f"{wind_speed:g} m/s: {type(error).__name__}: {error}", ), error=error, ) # A prescribed-rotor settle only seeds the platform for the CalcSteady trim, # which finds the equilibrium itself: an unsettled seed is still linearized. # A parked point (or one without CalcSteady) gets a single untrimmed # snapshot instead, so it must have settled like any other. trimmed = point.is_rotating and driver.settings.calc_steady return _classify( point, require_settled and not (judged.prescribed_rotor and trimmed) ) if max_workers == 1: outcomes = [attempt(speed) for speed in speeds] else: with ThreadPoolExecutor(max_workers=max_workers) as pool: outcomes = list(pool.map(attempt, speeds)) return _DerivedPoints( points=[outcome.point for outcome in outcomes if outcome.point is not None], failures=[ outcome.failure for outcome in outcomes if outcome.failure is not None ], errors=[outcome.error for outcome in outcomes if outcome.error is not None], ) @dataclass(frozen=True) class _SettleOutcome: """One settle point's result: the point to linearize and/or its failure.""" point: OperatingPoint | None = None failure: SettleFailure | None = None error: Exception | None = None @dataclass(frozen=True) class _DerivedPoints: """The points to linearize, the settle failures, and unexpected errors.""" points: list[OperatingPoint] failures: list[SettleFailure] = field(default_factory=list) errors: list[Exception] = field(default_factory=list) def _classify(point: OperatingPoint, require_settled: bool) -> _SettleOutcome: """Accept a settled point; record an unsettled one (linearized if allowed).""" convergence = point.settle_convergence # A point without a convergence record carries no evidence against it (the # settle solver always attaches one); it is taken as the settle returned it. message = None if convergence is None else _unsettled_message(convergence) if message is None: return _SettleOutcome(point=point) failure = SettleFailure( wind_speed=point.wind_speed, reason="not_settled", message=message, linearized=not require_settled, operating_point=point, convergence=convergence, ) return _SettleOutcome(point=None if require_settled else point, failure=failure) def _with_curve_check( settled: OperatingPoint, seed: OperatingPoint, tolerances: TrimTolerances | None, ) -> OperatingPoint: """Attach the settled-versus-curve comparison, warning when it is flagged.""" check = compare_settled_to_curve(settled, seed, tolerances) if check.status == "flag": logger.warning( "settled point at %g m/s disagrees with the operating curve (%s): rotor " "speed %.4g rpm vs curve %.4g rpm (%+.2f %%, tol %.2g %%), pitch %.3g deg " "vs curve %.3g deg (%+.2f deg, tol %.2g deg); the settled point is " "linearized, so check the controller against the curve", settled.wind_speed, ", ".join(check.flagged_quantities), check.rotor_speed.trimmed, check.rotor_speed.curve, 100.0 * check.rotor_speed.relative_deviation, 100.0 * check.tolerances.rotor_speed_rel, check.pitch.trimmed, check.pitch.curve, check.pitch.deviation, check.tolerances.pitch_deg, ) return replace(settled, settle_curve_check=check)