Source code for oowl.turbines

"""Wind turbine performance models."""

from __future__ import annotations

import math
from abc import ABC, abstractmethod
from dataclasses import KW_ONLY, dataclass
from functools import cache, cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any, overload

import numpy as np

from ._repr import _FieldRepr
from ._validation import (
    _validate_positive,
    _validate_real,
    _validate_real_array,
    _validate_unit_interval,
)

if TYPE_CHECKING:
    from collections.abc import Callable

    from numpy.typing import ArrayLike

__all__ = [
    "Controlled",
    "Idealized",
    "Tabulated",
    "Turbine",
]


_BETZ_LIMIT = 16.0 / 27.0
# Controlled: off-design power loss exp(-decay |λ/λ_opt - 1| ** exponent).
_CP_LOSS_DECAY = 0.8
_CP_LOSS_EXPONENT = 3.0
# Buhl (2005, https://doi.org/10.2172/15016819) replaces momentum thrust above
# this induction.
_BUHL_INDUCTION = 0.4
_BUHL_THRUST = 0.96
# Wind speed about which thrust_slope tilts the design thrust, in m/s.
_THRUST_SLOPE_SPEED = 8.0
# Larger intensities inflate the apparent C_P well above cp_max.
_MAX_TURBULENCE_SMOOTHING = 0.1
# Exported curves add knots at 0 m/s, just outside cut-in and cut-out and at
# twice cut-out, so the zero a table reads outside them keeps idle and parked
# thrust. The gap stays representable after rounding.
_IDLE_EXPORT_SPEED = 0.0
_PARKED_EXPORT_MARGIN = 2.0
_STATE_GAP = 1.0e-6
# Sea-level standard atmosphere, assumed for windIO Cp curves, which carry no
# air density.
_AIR_DENSITY = 1.225
# Speeds evaluated per block, so the curve temporaries stay in cache.
_BLOCK_SIZE = 32768
# Rated-speed scan points evaluated per block, from the idealized rated speed
# up to the first crossing.
_RATED_SCAN_BLOCK = 256


def _validate_cp(name: str, value: float) -> float:
    value = _validate_positive(name, value)
    if value > _BETZ_LIMIT:
        raise ValueError(f"{name} must not exceed the Betz limit")
    return value


def _validate_thrust(name: str, value: float) -> float:
    value = _validate_positive(name, value)
    if value > 1.0:
        raise ValueError(f"{name} must not exceed 1")
    return value


def _as_speed_array(wind_speed) -> tuple[np.ndarray, bool]:
    values = _validate_real_array("wind_speed", wind_speed)
    scalar = values.ndim == 0
    values = np.atleast_1d(values)
    if not np.all(np.isfinite(values)):
        raise ValueError("wind_speed must contain only finite values")
    if np.any(values < 0.0):
        raise ValueError("wind_speed must be non-negative")
    return values, scalar


def _maybe_scalar(values: np.ndarray, scalar: bool) -> float | np.ndarray:
    return float(values[0]) if scalar else values


def _curve_speeds(cutin: float, rated: float, cutout: float, n_points: int):
    """Export speeds, ``n_points`` from cut-in to cut-out plus the state knots.

    ``rated`` adds a knot when it is reached, that is when it is not ``nan``.
    """
    if n_points < 2:
        raise ValueError("n_points must be at least 2")
    # The idle knot sits just below cut-in, and halfway to zero for a cut-in
    # closer to zero than the gap.
    idle = max(cutin - _STATE_GAP, 0.5 * cutin)
    speeds = np.concatenate(
        [
            np.asarray([_IDLE_EXPORT_SPEED, idle], dtype=float),
            np.linspace(cutin, cutout, int(n_points)),
            np.asarray(
                [cutout + _STATE_GAP, cutout * _PARKED_EXPORT_MARGIN], dtype=float
            ),
            np.asarray([] if np.isnan(rated) else [rated], dtype=float),
        ]
    )
    return np.unique(np.round(speeds, 12))


def _axial_induction(cp_values):
    """Invert ``C_P = 4 a (1 - a)^2`` on its lower branch, ``a <= 1/3``."""
    cp_values = np.asarray(cp_values, dtype=float)
    normalized = np.clip(cp_values / _BETZ_LIMIT, 0.0, 1.0)
    argument = np.clip(1.0 - 2.0 * normalized, -1.0, 1.0)
    return (2.0 / 3.0) * (1.0 - np.cos(np.arccos(argument) / 3.0))


def _ct_from_cp(cp_values, cp_rated: float, ct_rated: float):
    """Thrust scaled from ``ct_rated`` by ``a (1 - a)`` of the induction."""
    induction = _axial_induction(cp_values)
    induction_rated = float(_axial_induction(cp_rated))
    denominator = induction_rated * (1.0 - induction_rated)
    return ct_rated * induction * (1.0 - induction) / denominator


def _momentum_cp(induction):
    return 4.0 * induction * (1.0 - induction) ** 2


def _momentum_ct(induction):
    """Momentum thrust of an actuator disc, with Buhl's correction above a = 0.4."""
    momentum = 4.0 * induction * (1.0 - induction)
    buhl = 8.0 / 9.0 - (4.0 / 9.0) * induction + (14.0 / 9.0) * induction**2
    return np.where(induction > _BUHL_INDUCTION, buhl, momentum)


def _induction_from_ct(ct):
    """Invert :func:`_momentum_ct` on its lower branch."""
    ct = np.asarray(ct, dtype=float)
    momentum = 0.5 * (1.0 - np.sqrt(np.clip(1.0 - ct, 0.0, None)))
    buhl = (4.0 + np.sqrt(np.clip(16.0 - 56.0 * (8.0 - 9.0 * ct), 0.0, None))) / 28.0
    return np.where(ct > _BUHL_THRUST, buhl, momentum)


def _scalar_momentum_cp(induction: float) -> float:
    """:func:`_momentum_cp` of one float, rounded as the array version."""
    complement = 1.0 - induction
    return 4.0 * induction * (complement * complement)


def _scalar_induction_from_ct(ct: float) -> float:
    """:func:`_induction_from_ct` of one float."""
    if ct > _BUHL_THRUST:
        return (4.0 + math.sqrt(max(16.0 - 56.0 * (8.0 - 9.0 * ct), 0.0))) / 28.0
    return 0.5 * (1.0 - math.sqrt(max(1.0 - ct, 0.0)))


def _blockwise(function, values: np.ndarray, size: int) -> np.ndarray:
    """Apply the elementwise *function* to *values* in blocks of *size*."""
    if len(values) <= size:
        return function(values)
    result = np.empty_like(values)
    for start in range(0, len(values), size):
        result[start : start + size] = function(values[start : start + size])
    return result


@cache
def _smoothing_rule() -> tuple[np.ndarray, np.ndarray]:
    """Nine-point Gauss-Hermite nodes and weights for a standard normal."""
    nodes, weights = np.polynomial.hermite_e.hermegauss(9)
    return nodes, weights / weights.sum()


[docs] class Turbine(ABC): """Wind turbine with power and thrust-coefficient curves. The base of every OOWL turbine model. Wind speeds are in m/s, power in watts and heights and diameters in meters. """ name: str | None hub_height: float rotor_diameter: float rated_power: float cutin_wind_speed: float cutout_wind_speed: float @property @abstractmethod def rated_wind_speed(self) -> float: """Wind speed at which rated power is reached, ``nan`` if never.""" @overload def power(self, wind_speed: float) -> float: ... @overload def power(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] @abstractmethod def power(self, wind_speed: ArrayLike) -> float | np.ndarray: """Power in watts at one or more wind speeds."""
@overload def ct(self, wind_speed: float) -> float: ... @overload def ct(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] @abstractmethod def ct(self, wind_speed: ArrayLike) -> float | np.ndarray: """Thrust coefficient at one or more wind speeds."""
[docs] def save(self, path: str | Path, **kwargs: Any) -> Path: """Write this turbine to *path* as a windIO ``turbine`` document. Wraps :func:`oowl.save` and returns the written path. ``name`` defaults to the turbine's name. """ from ._windio import save return save(self, path, **kwargs)
def _to_windio_turbine(self, name: str | None = None, n_points: int = 50) -> dict: """Return the windIO turbine mapping, with curves sampled at ``n_points``.""" speeds = _curve_speeds( self.cutin_wind_speed, self.rated_wind_speed, self.cutout_wind_speed, n_points, ) return self._windio_mapping(name, speeds, self.power(speeds), self.ct(speeds)) def _windio_mapping(self, name, speeds, power_values, ct_values) -> dict: speeds = np.asarray(speeds, dtype=float) return { "name": name or self.name or type(self).__name__, "performance": { "power_curve": { "power_values": np.asarray(power_values, dtype=float).tolist(), "power_wind_speeds": speeds.tolist(), }, "Ct_curve": { "Ct_values": np.asarray(ct_values, dtype=float).tolist(), "Ct_wind_speeds": speeds.tolist(), }, }, "hub_height": float(self.hub_height), "rotor_diameter": float(self.rotor_diameter), }
class _AnalyticalTurbine(_FieldRepr, Turbine): """Validation and helpers shared by the analytical models. Concrete models supply ``cp``, ``ct``, ``power``, ``rated_wind_speed`` and the operating thrust at cut-out, the parked default. """ rated_power: float cp_max: float air_density: float generator_efficiency: float ct_idle: float | None ct_parked: float | None _terminal_operating_ct: float @overload def cp(self, wind_speed: float) -> float: ... @overload def cp(self, wind_speed: ArrayLike) -> np.ndarray: ... @abstractmethod def cp(self, wind_speed: ArrayLike) -> float | np.ndarray: """Power coefficient at one or more wind speeds.""" def _validate_rotor(self) -> None: self.rated_power = _validate_positive("rated_power", self.rated_power) self.rotor_diameter = _validate_positive("rotor_diameter", self.rotor_diameter) self.hub_height = _validate_positive("hub_height", self.hub_height) if self.hub_height < self.rotor_diameter / 2.0: raise ValueError("hub_height must keep the rotor above ground") self.cp_max = _validate_cp("cp_max", self.cp_max) def _validate_operation(self) -> None: if self.ct_idle is not None: self.ct_idle = _validate_unit_interval("ct_idle", self.ct_idle) if self.ct_parked is not None: self.ct_parked = _validate_unit_interval("ct_parked", self.ct_parked) self.air_density = _validate_positive("air_density", self.air_density) self.generator_efficiency = _validate_positive( "generator_efficiency", self.generator_efficiency ) if self.generator_efficiency > 1.0: raise ValueError("generator_efficiency must not exceed 1") self.cutin_wind_speed = _validate_positive( "cutin_wind_speed", self.cutin_wind_speed ) self.cutout_wind_speed = _validate_positive( "cutout_wind_speed", self.cutout_wind_speed ) if self.cutout_wind_speed <= self.cutin_wind_speed: raise ValueError("cutout_wind_speed must exceed cutin_wind_speed") @property def rotor_area(self) -> float: """Rotor swept area in square meters.""" return float(np.pi * (self.rotor_diameter / 2.0) ** 2) def _operating(self, speeds: np.ndarray) -> np.ndarray: return (speeds >= self.cutin_wind_speed) & (speeds <= self.cutout_wind_speed) def _power_scale(self) -> float: """Watts per unit ``C_P`` and cubic meter per cubic second of wind.""" return self.generator_efficiency * 0.5 * self.air_density * self.rotor_area def _idle_and_parked(self, values: np.ndarray, speeds: np.ndarray) -> None: """Set ``ct_idle`` below cut-in and ``ct_parked`` above cut-out, in place. ``values`` arrives zero outside the operating band, the idle default. """ if self.ct_idle is not None: values[speeds < self.cutin_wind_speed] = self.ct_idle above_cutout = speeds > self.cutout_wind_speed if np.any(above_cutout): values[above_cutout] = ( self._terminal_operating_ct if self.ct_parked is None else self.ct_parked )
[docs] @dataclass(repr=False) class Idealized(_AnalyticalTurbine): """Idealized turbine with a constant below-rated ``C_P``. Follows the generalized turbine relations of Sørensen (2023, https://doi.org/10.5194/wes-8-1017-2023), including the idealized cut-in discontinuity. The rated wind speed is derived, not supplied. Parameters ---------- rated_power Electrical rated power in watts. rotor_diameter, hub_height Rotor size and height in meters. cp_max Power coefficient below rated, at most the Betz limit 16/27. ct_rated Thrust coefficient below rated. air_density Air density in kg/m³. generator_efficiency Share of the rotor power delivered as electrical power. cutin_wind_speed, cutout_wind_speed Operating band in m/s. ct_idle, ct_parked Thrust below cut-in, default 0, and above cut-out, default the operating value at cut-out. name Label used in plots and windIO documents. """ rated_power: float rotor_diameter: float hub_height: float cp_max: float _: KW_ONLY ct_rated: float = 0.8 air_density: float = 1.225 generator_efficiency: float = 1.0 cutin_wind_speed: float = 4.0 cutout_wind_speed: float = 25.0 ct_idle: float | None = None ct_parked: float | None = None name: str | None = None def __post_init__(self): self._validate_rotor() self.ct_rated = _validate_unit_interval("ct_rated", self.ct_rated) self._validate_operation() if not self.cutin_wind_speed < self.rated_wind_speed < self.cutout_wind_speed: raise ValueError( "derived rated_wind_speed must lie between cut-in and cut-out" ) @overload def ct(self, wind_speed: float) -> float: ... @overload def ct(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def ct(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the thrust coefficient in [0, 1] at ``wind_speed`` in m/s. ``ct_rated`` below rated. Above it, the thrust follows the momentum induction of the falling ``C_P``. Below cut-in it is ``ct_idle`` (default 0), above cut-out ``ct_parked``, which defaults to the operating value at cut-out. """ speeds, scalar = _as_speed_array(wind_speed) values = np.zeros_like(speeds) operating = self._operating(speeds) values[operating & (speeds <= self.rated_wind_speed)] = self.ct_rated above_rated = operating & (speeds > self.rated_wind_speed) if np.any(above_rated): values[above_rated] = _ct_from_cp( self.cp(speeds[above_rated]), self.cp_max, self.ct_rated ) self._idle_and_parked(values, speeds) return _maybe_scalar(values, scalar)
@cached_property def _terminal_operating_ct(self) -> float: """Operating C_T at cut-out, the parked fallback.""" cp_cutout = float(self.cp(self.cutout_wind_speed)) return float(_ct_from_cp(cp_cutout, self.cp_max, self.ct_rated)) @property def rated_wind_speed(self) -> float: """Wind speed in m/s at which rated power is reached. Derived from ``rated_power``, ``rotor_diameter``, ``cp_max``, ``generator_efficiency`` and ``air_density``. """ cube = self.rated_power / (self._power_scale() * self.cp_max) return float(cube ** (1.0 / 3.0)) @overload def cp(self, wind_speed: float) -> float: ... @overload def cp(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def cp(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the power coefficient at ``wind_speed`` in m/s. Scalar in, scalar out. Zero outside the cut-in to cut-out band. """ speeds, scalar = _as_speed_array(wind_speed) values = np.zeros_like(speeds) operating = self._operating(speeds) values[operating & (speeds <= self.rated_wind_speed)] = self.cp_max above_rated = operating & (speeds > self.rated_wind_speed) values[above_rated] = ( self.cp_max * (self.rated_wind_speed / speeds[above_rated]) ** 3 ) return _maybe_scalar(values, scalar)
@overload def power(self, wind_speed: float) -> float: ... @overload def power(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def power(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the electrical power in watts at ``wind_speed`` in m/s.""" speeds, scalar = _as_speed_array(wind_speed) values = self._power_scale() * np.asarray(self.cp(speeds)) * speeds**3 return _maybe_scalar(values, scalar)
[docs] def metadata(self) -> dict: """Return the turbine configuration as a JSON-compatible dict.""" return { "type": type(self).__name__, "name": self.name, "rated_power": self.rated_power, "rotor_diameter": self.rotor_diameter, "hub_height": self.hub_height, "rated_wind_speed": self.rated_wind_speed, "cp_max": self.cp_max, "ct_rated": self.ct_rated, "air_density": self.air_density, "generator_efficiency": self.generator_efficiency, "cutin_wind_speed": self.cutin_wind_speed, "cutout_wind_speed": self.cutout_wind_speed, "ct_idle": self.ct_idle, "ct_parked": self.ct_parked, }
[docs] @dataclass(repr=False) class Controlled(_AnalyticalTurbine): """Variable-speed turbine whose thrust and power follow one axial induction. The rotor tracks ``optimal_tip_speed_ratio``, held between ``minimum_rotor_speed`` and ``maximum_tip_speed``. The induction follows the realized tip-speed ratio as ``a = a_opt (λ / λ_opt) ** induction_exponent``, where ``a_opt`` gives ``ct_optimal``. Thrust is the momentum value ``4 a (1 - a)``, with Buhl's correction above ``a = 0.4``. Power follows the same induction, and its power coefficient never exceeds ``cp_max`` unless ``turbulence_smoothing`` averages it. Above rated the blades pitch to hold rated power, and the induction falls with the power coefficient. At the minimum rotor speed, near cut-in, the rotor over-induces and the thrust rises above ``ct_optimal``. At the tip-speed limit it falls before rated. ``induction_exponent=0`` gives a flat thrust below rated. Rotor speed, tip-speed ratio and induction are diagnostics only and are not exported to windIO. Parameters ---------- rated_power Electrical rated power in watts. rotor_diameter, hub_height Rotor size and height in meters. cp_max Power coefficient at the optimal tip-speed ratio. optimal_tip_speed_ratio Tip-speed ratio the controller tracks. minimum_rotor_speed Lowest rotor speed in rpm. maximum_tip_speed Highest blade tip speed in m/s. ct_optimal Thrust coefficient at the optimal tip-speed ratio. induction_exponent How strongly the induction follows the tip-speed ratio. ct_peak_shaving Thrust cap, or ``None``. The lower induction also lowers the power. thrust_slope Change of the design thrust per m/s about 8 m/s below rated, as in fine-pitch schedules. Above rated the blades pitch instead. turbulence_smoothing Turbulence intensity, at most 0.1, over which power and thrust are averaged. It rounds the knee at rated as in measured curves and keeps cut-in and cut-out sharp. As in measured curves, the apparent ``C_P`` then exceeds ``cp_max``, by about 3% at 0.1 and up to about 13% just above cut-in, where the average sees no idling speeds. ct_idle, ct_parked Thrust below cut-in, default 0, and above cut-out, default the operating value at cut-out. air_density Air density in kg/m³. generator_efficiency Share of the rotor power delivered as electrical power. cutin_wind_speed, cutout_wind_speed Operating band in m/s. name Label used in plots and windIO documents. """ rated_power: float rotor_diameter: float hub_height: float cp_max: float optimal_tip_speed_ratio: float minimum_rotor_speed: float maximum_tip_speed: float _: KW_ONLY ct_optimal: float = 0.8 induction_exponent: float = 0.35 ct_peak_shaving: float | None = None thrust_slope: float = 0.0 turbulence_smoothing: float = 0.0 air_density: float = 1.225 generator_efficiency: float = 0.96 cutin_wind_speed: float = 3.0 cutout_wind_speed: float = 25.0 ct_idle: float | None = None ct_parked: float | None = None name: str | None = None def __post_init__(self): self._validate_rotor() self.optimal_tip_speed_ratio = _validate_positive( "optimal_tip_speed_ratio", self.optimal_tip_speed_ratio ) self.minimum_rotor_speed = _validate_positive( "minimum_rotor_speed", self.minimum_rotor_speed ) self.maximum_tip_speed = _validate_positive( "maximum_tip_speed", self.maximum_tip_speed ) if self._omega_min >= self._omega_max: raise ValueError("minimum_rotor_speed must stay below maximum_tip_speed") self.ct_optimal = _validate_thrust("ct_optimal", self.ct_optimal) if self.ct_peak_shaving is not None: self.ct_peak_shaving = _validate_thrust( "ct_peak_shaving", self.ct_peak_shaving ) self.induction_exponent = _validate_real( "induction_exponent", self.induction_exponent ) if not np.isfinite(self.induction_exponent) or self.induction_exponent < 0.0: raise ValueError("induction_exponent must be non-negative and finite") self.thrust_slope = _validate_real("thrust_slope", self.thrust_slope) if not np.isfinite(self.thrust_slope): raise ValueError("thrust_slope must be finite") self.turbulence_smoothing = _validate_real( "turbulence_smoothing", self.turbulence_smoothing ) if not 0.0 <= self.turbulence_smoothing <= _MAX_TURBULENCE_SMOOTHING: raise ValueError( f"turbulence_smoothing must be in [0, {_MAX_TURBULENCE_SMOOTHING}]" ) self._validate_operation() band = np.asarray([self.cutin_wind_speed, self.cutout_wind_speed]) design_ct = self._design_ct(band) if np.any(design_ct <= 0.0) or np.any(design_ct > 1.0): raise ValueError( "thrust_slope must keep the design thrust in (0, 1] from cut-in " "to cut-out" ) if self._blade_efficiency > 1.0: raise ValueError("cp_max is unreachable at ct_optimal") if not self.cutin_wind_speed < self.rated_wind_speed < self.cutout_wind_speed: raise ValueError( "controlled turbine must reach rated power after cut-in and before " "cut-out" ) # Above rated the pitched induction is at most 1/3 and the thrust at # most 8/9, so only speeds up to rated can push the thrust above 1. scan = np.linspace(self.cutin_wind_speed, self.cutout_wind_speed, 1025) tracked = scan[scan <= self.rated_wind_speed] if np.max(_momentum_ct(self._tracked_induction(tracked, tracked)[2])) > 1.0: raise ValueError( "thrust coefficient exceeds 1, lower induction_exponent or raise " "minimum_rotor_speed" ) @property def rotor_radius(self) -> float: """Rotor radius in meters.""" return self.rotor_diameter / 2.0 @property def _omega_min(self) -> float: return self.minimum_rotor_speed * 2.0 * np.pi / 60.0 @property def _omega_max(self) -> float: return self.maximum_tip_speed / self.rotor_radius @overload def rotor_speed(self, wind_speed: float) -> float: ... @overload def rotor_speed(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def rotor_speed(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the rotor speed in rpm, zero outside the operating band.""" speeds, scalar = _as_speed_array(wind_speed) return _maybe_scalar(self._omega(speeds) * 60.0 / (2.0 * np.pi), scalar)
@overload def tip_speed_ratio(self, wind_speed: float) -> float: ... @overload def tip_speed_ratio(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def tip_speed_ratio(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the realized tip-speed ratio, zero outside the operating band.""" speeds, scalar = _as_speed_array(wind_speed) return _maybe_scalar(self._tip_speed_ratio(speeds), scalar)
@overload def axial_induction(self, wind_speed: float) -> float: ... @overload def axial_induction(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def axial_induction(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the rotor-averaged axial induction, before turbulence smoothing.""" speeds, scalar = _as_speed_array(wind_speed) return _maybe_scalar(self._induction(speeds), scalar)
def _band_omega(self, speeds: np.ndarray) -> np.ndarray: """Rotor speed in rad/s, for speeds inside the operating band.""" return np.clip( self.optimal_tip_speed_ratio * speeds / self.rotor_radius, self._omega_min, self._omega_max, ) def _omega(self, speeds: np.ndarray) -> np.ndarray: return np.where(self._operating(speeds), self._band_omega(speeds), 0.0) def _tip_speed_ratio(self, speeds: np.ndarray) -> np.ndarray: return np.divide( self._omega(speeds) * self.rotor_radius, speeds, out=np.zeros_like(speeds), where=speeds > 0.0, ) def _design_ct(self, speeds: np.ndarray) -> np.ndarray: return self.ct_optimal + self.thrust_slope * (speeds - _THRUST_SLOPE_SPEED) @cached_property def _optimal_momentum_cp(self) -> float: """Momentum power coefficient of the induction that gives ``ct_optimal``.""" return float(_momentum_cp(_induction_from_ct(np.asarray(self.ct_optimal)))) @cached_property def _blade_efficiency(self) -> float: """Share of the momentum power converted at the design point.""" return float(self.cp_max / self._optimal_momentum_cp) @cached_property def _induction_cap(self) -> float: if self.ct_peak_shaving is None: return float("inf") return float(_induction_from_ct(np.asarray(self.ct_peak_shaving))) @cached_property def _optimal_induction(self) -> np.ndarray: """Design induction without ``thrust_slope``, as a one-element array.""" return _induction_from_ct(np.asarray([self.ct_optimal])) def _tracked_induction( self, speeds: np.ndarray, design_speeds: np.ndarray | None = None ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Tip-speed ratio over its optimum, design induction and induction. For speeds inside the operating band, before pitch regulation. The design thrust is read at ``design_speeds``, by default ``speeds`` held at rated, where pitch regulation takes over from the ``thrust_slope`` schedule. """ ratio = ( self._band_omega(speeds) * self.rotor_radius / speeds / self.optimal_tip_speed_ratio ) if self.thrust_slope == 0.0: design = self._optimal_induction else: if design_speeds is None: design_speeds = np.minimum(speeds, self.rated_wind_speed) design = _induction_from_ct(self._design_ct(design_speeds)) induction = np.minimum( design * ratio**self.induction_exponent, self._induction_cap ) return ratio, design, induction def _tracking_cp( self, speeds: np.ndarray, design_speeds: np.ndarray | None = None ) -> np.ndarray: """Power coefficient before pitch regulation, inside the operating band. Induction beyond the design value adds thrust but no power, and a ``thrust_slope`` that raises the design thrust adds no power either, so ``C_P`` never exceeds ``cp_max``. ``design_speeds`` is as in :meth:`_tracked_induction`. """ ratio, design, induction = self._tracked_induction(speeds, design_speeds) momentum = np.minimum( np.minimum(_momentum_cp(induction), _momentum_cp(design)), self._optimal_momentum_cp, ) loss = np.exp(-_CP_LOSS_DECAY * np.abs(ratio - 1.0) ** _CP_LOSS_EXPONENT) return self._blade_efficiency * momentum * loss def _scalar_tracking(self) -> Callable[[float], tuple[float, float]]: """Return the induction and ``C_P`` of :meth:`_tracking_cp` as a function. The function takes one in-band speed and reads the design thrust there. It repeats the array arithmetic in scalar form, so a bisection step takes about a microsecond. """ tip_speed_ratio = self.optimal_tip_speed_ratio radius = self.rotor_radius omega_min, omega_max = self._omega_min, self._omega_max ct_optimal, thrust_slope = self.ct_optimal, self.thrust_slope exponent, cap = self.induction_exponent, self._induction_cap optimal_momentum = self._optimal_momentum_cp efficiency = self._blade_efficiency def tracking(speed: float) -> tuple[float, float]: omega = min(max(tip_speed_ratio * speed / radius, omega_min), omega_max) ratio = omega * radius / speed / tip_speed_ratio design = _scalar_induction_from_ct( ct_optimal + thrust_slope * (speed - _THRUST_SLOPE_SPEED) ) # NumPy takes the square root for an exponent of 0.5, which pow # can round differently. induction = min(design * float(np.power(ratio, exponent)), cap) momentum = min( _scalar_momentum_cp(induction), _scalar_momentum_cp(design), optimal_momentum, ) loss = math.exp(-_CP_LOSS_DECAY * abs(ratio - 1.0) ** _CP_LOSS_EXPONENT) return induction, efficiency * momentum * loss return tracking @cached_property def rated_wind_speed(self) -> float: """Wind speed in m/s at which rated power is first reached. ``nan`` when rated power is never reached, which the constructor rejects. """ # The first crossing is found on the unregulated curve, so the design # thrust is read at each scanned speed rather than held at rated. scan = np.linspace(self.cutin_wind_speed, self.cutout_wind_speed, 4097) scale = self._power_scale() # C_P never exceeds cp_max, so no speed below the idealized rated speed # reaches rated power. The margin covers rounding. floor = (self.rated_power / (scale * self.cp_max)) ** (1.0 / 3.0) start = int(np.searchsorted(scan, floor * (1.0 - 1e-9))) for block_start in range(start, len(scan), _RATED_SCAN_BLOCK): block = scan[block_start : block_start + _RATED_SCAN_BLOCK] power = scale * self._tracking_cp(block, block) * block**3 crossings = np.flatnonzero(power >= self.rated_power) if len(crossings): index = block_start + int(crossings[0]) break else: return float("nan") if index == 0: return float(scan[0]) lower, upper = float(scan[index - 1]), float(scan[index]) tracking = self._scalar_tracking() for _ in range(60): midpoint = 0.5 * (lower + upper) # Adjacent floats, so further steps change nothing. if midpoint in (lower, upper): break if scale * tracking(midpoint)[1] * midpoint**3 >= self.rated_power: upper = midpoint else: lower = midpoint return 0.5 * (lower + upper) @cached_property def _pitch_efficiency(self) -> float: """Share of the momentum power converted at rated, held while pitching.""" induction, cp = self._scalar_tracking()(self.rated_wind_speed) return cp / _momentum_cp(induction) def _induction(self, speeds: np.ndarray) -> np.ndarray: induction = np.zeros_like(speeds) operating = self._operating(speeds) induction[operating] = _blockwise( self._band_induction, speeds[operating], _BLOCK_SIZE ) return induction def _band_induction(self, speeds: np.ndarray) -> np.ndarray: """Induction inside the operating band, pitched above rated.""" above = speeds > self.rated_wind_speed below = ~above induction = np.empty_like(speeds) # Up to rated the design thrust is read at the speed itself. tracked = speeds[below] induction[below] = self._tracked_induction(tracked, tracked)[2] pitched = speeds[above] cp = self.rated_power / (self._power_scale() * pitched**3) induction[above] = np.minimum( _axial_induction(cp / self._pitch_efficiency), self._induction_cap ) return induction def _sharp_power(self, speeds: np.ndarray) -> np.ndarray: power = self._power_scale() * self._tracking_cp(speeds) * speeds**3 return np.minimum(power, self.rated_power) def _sharp_ct(self, speeds: np.ndarray) -> np.ndarray: return _momentum_ct(self._band_induction(speeds)) def _smoothed(self, curve, speeds: np.ndarray) -> np.ndarray: """Average *curve* over turbulence, for speeds inside the operating band. Speeds pass through *curve* in blocks, which keeps temporaries small. """ if self.turbulence_smoothing == 0.0: return _blockwise(curve, speeds, _BLOCK_SIZE) nodes, weights = _smoothing_rule() factors = 1.0 + self.turbulence_smoothing * nodes def average(block: np.ndarray) -> np.ndarray: shifted = np.clip( block[:, None] * factors, self.cutin_wind_speed, self.cutout_wind_speed, ) values = curve(shifted.ravel()).reshape(shifted.shape) # Summing node by node gives each speed the same value in any # batch, which a BLAS matrix product does not. total = values[:, 0] * weights[0] for node in range(1, len(weights)): total += values[:, node] * weights[node] return total return _blockwise(average, speeds, _BLOCK_SIZE // len(factors)) def _operating_curve(self, curve, wind_speed, fill: float = 0.0): speeds, scalar = _as_speed_array(wind_speed) values = np.full_like(speeds, fill) operating = self._operating(speeds) if np.any(operating): values[operating] = self._smoothed(curve, speeds[operating]) return values, speeds, scalar @overload def power(self, wind_speed: float) -> float: ... @overload def power(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def power(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the electrical power in watts at ``wind_speed`` in m/s.""" values, _, scalar = self._operating_curve(self._sharp_power, wind_speed) return _maybe_scalar(values, scalar)
@overload def cp(self, wind_speed: float) -> float: ... @overload def cp(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def cp(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the power coefficient implied by :meth:`power`. Scalar in, scalar out. Zero outside the cut-in to cut-out band. """ values, speeds, scalar = self._operating_curve(self._sharp_power, wind_speed) operating = self._operating(speeds) values[operating] /= self._power_scale() * speeds[operating] ** 3 return _maybe_scalar(values, scalar)
@overload def ct(self, wind_speed: float) -> float: ... @overload def ct(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def ct(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the thrust coefficient at ``wind_speed`` in m/s. Below cut-in it is ``ct_idle`` (default 0). Above cut-out it is ``ct_parked``, which defaults to the operating value at cut-out. """ values, speeds, scalar = self._operating_curve(self._sharp_ct, wind_speed) self._idle_and_parked(values, speeds) return _maybe_scalar(values, scalar)
@cached_property def _terminal_operating_ct(self) -> float: """Operating C_T at cut-out, the parked fallback.""" cutout = np.asarray([self.cutout_wind_speed]) return float(self._smoothed(self._sharp_ct, cutout)[0])
[docs] def metadata(self) -> dict: """Return the turbine configuration as a JSON-compatible dict.""" return { "type": type(self).__name__, "name": self.name, "rated_power": self.rated_power, "rotor_diameter": self.rotor_diameter, "hub_height": self.hub_height, "rated_wind_speed": self.rated_wind_speed, "cp_max": self.cp_max, "optimal_tip_speed_ratio": self.optimal_tip_speed_ratio, "minimum_rotor_speed": self.minimum_rotor_speed, "maximum_tip_speed": self.maximum_tip_speed, "ct_optimal": self.ct_optimal, "induction_exponent": self.induction_exponent, "ct_peak_shaving": self.ct_peak_shaving, "thrust_slope": self.thrust_slope, "turbulence_smoothing": self.turbulence_smoothing, "air_density": self.air_density, "generator_efficiency": self.generator_efficiency, "cutin_wind_speed": self.cutin_wind_speed, "cutout_wind_speed": self.cutout_wind_speed, "ct_idle": self.ct_idle, "ct_parked": self.ct_parked, }
[docs] @dataclass(init=False, repr=False) class Tabulated(_FieldRepr, Turbine): """Turbine defined by tabulated power and thrust-coefficient curves. Curves are linearly interpolated inside the tabulated range and are zero outside it, so the first and last speeds act as cut-in and cut-out. Parameters ---------- rotor_diameter, hub_height Rotor size and height in meters. wind_speeds Strictly increasing, non-negative wind speeds in m/s. power_values Electrical power in watts at each speed. ct_values Thrust coefficient in [0, 1] at each speed. name Label used in plots and windIO documents. rated_power Rated power in watts. Defaults to the largest tabulated power. """ rotor_diameter: float hub_height: float wind_speeds: np.ndarray power_values: np.ndarray ct_values: np.ndarray name: str | None = None rated_power: float | None = None def __init__( self, rotor_diameter: float, hub_height: float, wind_speeds: ArrayLike, power_values: ArrayLike, ct_values: ArrayLike, *, name: str | None = None, rated_power: float | None = None, ) -> None: self.rotor_diameter = _validate_positive("rotor_diameter", rotor_diameter) self.hub_height = _validate_positive("hub_height", hub_height) if self.hub_height < self.rotor_diameter / 2.0: raise ValueError("hub_height must keep the rotor above ground") # Copies, so later changes to the caller's arrays cannot bypass validation. self.wind_speeds = np.array(wind_speeds, dtype=float) self.power_values = np.array(power_values, dtype=float) self.ct_values = np.array(ct_values, dtype=float) if ( self.wind_speeds.ndim != 1 or self.power_values.shape != self.wind_speeds.shape or self.ct_values.shape != self.wind_speeds.shape or len(self.wind_speeds) < 2 ): raise ValueError("tabulated curves must be equal-length 1-D arrays") if not ( np.all(np.isfinite(self.wind_speeds)) and np.all(np.isfinite(self.power_values)) and np.all(np.isfinite(self.ct_values)) ): raise ValueError("tabulated curves must contain only finite values") if np.any(np.diff(self.wind_speeds) <= 0): raise ValueError("wind_speeds must be strictly increasing") if np.any(self.wind_speeds < 0) or np.any(self.power_values < 0): raise ValueError("wind speeds and power values must be non-negative") if np.any((self.ct_values < 0) | (self.ct_values > 1)): raise ValueError("ct_values must be in [0, 1]") self.name = name if rated_power is None: rated_power = float(np.max(self.power_values)) self.rated_power = _validate_positive("rated_power", rated_power) def __eq__(self, other: object) -> bool: """Whether ``other`` is a tabulated turbine with the same curves and sizes.""" if other.__class__ is not self.__class__: return NotImplemented return ( self.rotor_diameter == other.rotor_diameter and self.hub_height == other.hub_height and self.name == other.name and self.rated_power == other.rated_power and np.array_equal(self.wind_speeds, other.wind_speeds) and np.array_equal(self.power_values, other.power_values) and np.array_equal(self.ct_values, other.ct_values) ) @property def cutin_wind_speed(self) -> float: """Lowest tabulated wind speed in m/s.""" return float(self.wind_speeds[0]) @property def cutout_wind_speed(self) -> float: """Highest tabulated wind speed in m/s.""" return float(self.wind_speeds[-1]) @property def rated_wind_speed(self) -> float: """Wind speed where the first upward segment reaches ``rated_power``. Solved linearly on the tabulation. ``nan`` if never reached. """ rated_power = self.rated_power assert rated_power is not None for index in range(1, len(self.wind_speeds)): lower_power = float(self.power_values[index - 1]) upper_power = float(self.power_values[index]) if lower_power < rated_power <= upper_power: fraction = (rated_power - lower_power) / (upper_power - lower_power) lower_speed = float(self.wind_speeds[index - 1]) upper_speed = float(self.wind_speeds[index]) return lower_speed + fraction * (upper_speed - lower_speed) if self.power_values[0] >= rated_power: return float(self.wind_speeds[0]) return float("nan") def _interp(self, wind_speed, values): speeds, scalar = _as_speed_array(wind_speed) result = np.zeros_like(speeds) operating = (speeds >= self.cutin_wind_speed) & ( speeds <= self.cutout_wind_speed ) result[operating] = np.interp(speeds[operating], self.wind_speeds, values) return _maybe_scalar(result, scalar) @overload def power(self, wind_speed: float) -> float: ... @overload def power(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def power(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the electrical power in watts at ``wind_speed`` in m/s.""" return self._interp(wind_speed, self.power_values)
@overload def ct(self, wind_speed: float) -> float: ... @overload def ct(self, wind_speed: ArrayLike) -> np.ndarray: ...
[docs] def ct(self, wind_speed: ArrayLike) -> float | np.ndarray: """Return the thrust coefficient in [0, 1] at ``wind_speed`` in m/s.""" return self._interp(wind_speed, self.ct_values)
def _to_windio_turbine(self, name: str | None = None, n_points: int = 50) -> dict: """Return the windIO turbine mapping of the tabulated curves. ``n_points`` is ignored because the tabulated speeds are exported as given. """ return self._windio_mapping( name, self.wind_speeds, self.power_values, self.ct_values )
[docs] def metadata(self) -> dict: """Return the turbine configuration as a JSON-compatible dict. ``rated_wind_speed`` is ``None`` when rated power is never reached. """ rated_power = self.rated_power assert rated_power is not None rated_wind_speed = self.rated_wind_speed if np.isnan(rated_wind_speed): rated_wind_speed = None return { "type": type(self).__name__, "name": self.name, "rated_power": float(rated_power), "rotor_diameter": self.rotor_diameter, "hub_height": self.hub_height, "rated_wind_speed": rated_wind_speed, "cutin_wind_speed": self.cutin_wind_speed, "cutout_wind_speed": self.cutout_wind_speed, "n_curve_points": int(len(self.wind_speeds)), }
def _compress_turbines(turbines, n_turbines: int): """Return unique types and position-aligned indices in first-use order.""" if turbines is None: return (), np.empty(0, dtype=int) values = (turbines,) * n_turbines if isinstance(turbines, Turbine) else turbines if len(values) != n_turbines: raise ValueError("number of turbines must match the number of positions") unique = [] indices = [] identities = {} for turbine in values: if not isinstance(turbine, Turbine): raise TypeError("turbines must contain only Turbine objects") key = id(turbine) if key not in identities: identities[key] = len(unique) unique.append(turbine) indices.append(identities[key]) return tuple(unique), np.asarray(indices, dtype=int) def _from_windio_turbines(turbine_types, type_indices): """One shared turbine, or one per position when the types differ.""" if not isinstance(turbine_types, dict): raise ValueError("windIO turbine_types must be a mapping") try: keys = sorted(int(key) for key in turbine_types) except (TypeError, ValueError): raise ValueError("windIO turbine_types keys must be integers") from None types = {} for key in keys: entry = turbine_types.get(str(key), turbine_types.get(key)) if not isinstance(entry, dict): raise ValueError(f"windIO turbine type {key} must be a mapping") for field in ("performance", "rotor_diameter", "hub_height"): if field not in entry: raise ValueError(f"windIO turbine type {key} is missing {field!r}") types[key] = _turbine_from_windio_performance(entry) message = f"windIO layout turbine_types must refer to defined types {keys}" # int() would read 0.7 as type 0 and True as type 1. if any(isinstance(index, (bool, float)) for index in type_indices): raise ValueError(message) try: values = tuple(types[int(index)] for index in type_indices) except (KeyError, TypeError, ValueError): raise ValueError(message) from None return values[0] if values and all(item is values[0] for item in values) else values def _operating_grid(cutin: float, rated: float, cutout: float, ct_speeds) -> np.ndarray: """Half-meter-per-second operating grid that keeps the tabulated knots.""" speeds = np.concatenate( [ np.arange(cutin, cutout, 0.5), np.asarray(ct_speeds, dtype=float), np.asarray([cutin, cutout, rated], dtype=float), ] ) speeds = speeds[(speeds >= cutin) & (speeds <= cutout)] return np.unique(np.round(speeds, 6)) def _windio_curve(curve, curve_key: str, speeds_key: str, values_key: str): """Return a windIO performance curve as equal-length float arrays.""" from ._windio import _number_list if not isinstance(curve, dict): raise ValueError(f"windIO {curve_key} must be a mapping") for key in (speeds_key, values_key): if key not in curve: raise ValueError(f"windIO {curve_key} is missing {key!r}") speeds = _number_list(curve[speeds_key], f"{curve_key} {speeds_key}") values = _number_list(curve[values_key], f"{curve_key} {values_key}") if len(values) != len(speeds): raise ValueError( f"windIO {curve_key} needs one of {values_key} per {speeds_key}" ) # Interpolation reads these speeds, and np.interp does not check them. if not np.all(np.isfinite(speeds)) or np.any(np.diff(speeds) <= 0): raise ValueError( f"windIO {curve_key} {speeds_key} must be finite and strictly increasing" ) return speeds, values def _turbine_from_windio_performance(entry) -> Tabulated: """Build a :class:`Tabulated` from any windIO performance form. A ``Cp`` curve gives ``P = 0.5 rho A C_P u^3`` at sea-level density. The rated form rises cubically to rated power. Thrust is interpolated from the ``Ct`` curve. Power and ``Ct`` curves on different speeds are linearly interpolated onto the union of both, each reading zero outside its own speeds as a :class:`Tabulated` curve does. """ from ._windio import _number performance = entry["performance"] if not isinstance(performance, dict): raise ValueError("windIO turbine performance must be a mapping") ct = performance.get("Ct_curve") if not isinstance(ct, dict): raise ValueError("windIO turbine performance must contain a Ct curve") rotor_diameter = _number(entry["rotor_diameter"], "turbine rotor_diameter") shared = { "rotor_diameter": rotor_diameter, "hub_height": _number(entry["hub_height"], "turbine hub_height"), "name": entry.get("name"), } ct_speeds, ct_values = _windio_curve(ct, "Ct_curve", "Ct_wind_speeds", "Ct_values") power = performance.get("power_curve") if power is not None: speeds, power_values = _windio_curve( power, "power_curve", "power_wind_speeds", "power_values" ) if not np.array_equal(speeds, ct_speeds): power_speeds = speeds speeds = np.union1d(power_speeds, ct_speeds) power_values = np.interp( speeds, power_speeds, power_values, left=0.0, right=0.0 ) ct_values = np.interp(speeds, ct_speeds, ct_values, left=0.0, right=0.0) return Tabulated( wind_speeds=speeds, power_values=power_values, ct_values=ct_values, **shared, ) area = np.pi * (rotor_diameter / 2.0) ** 2 efficiency = _number( performance.get("generator_efficiency", 1.0), "generator_efficiency" ) cp_curve = performance.get("Cp_curve") if cp_curve is not None: cp_speeds, cp_values = _windio_curve( cp_curve, "Cp_curve", "Cp_wind_speeds", "Cp_values" ) speeds = np.union1d(cp_speeds, ct_speeds) cp = np.interp(speeds, cp_speeds, cp_values, left=0.0, right=0.0) return Tabulated( wind_speeds=speeds, power_values=0.5 * _AIR_DENSITY * area * cp * speeds**3 * efficiency, ct_values=np.interp(speeds, ct_speeds, ct_values, left=0.0, right=0.0), **shared, ) required = ( "rated_power", "rated_wind_speed", "cutin_wind_speed", "cutout_wind_speed", ) if any(name not in performance for name in required): raise ValueError( "windIO turbine performance needs a power curve, a Cp curve, or " "rated power with its operating interval" ) rated_power, rated_speed, cutin, cutout = ( _number(performance[name], name) for name in required ) speeds = _operating_grid(cutin, rated_speed, cutout, ct_speeds) below = speeds < rated_speed power_values = np.full(speeds.shape, rated_power) power_values[below] = rated_power * (speeds[below] / rated_speed) ** 3 return Tabulated( wind_speeds=speeds, power_values=power_values, ct_values=np.interp(speeds, ct_speeds, ct_values, left=0.0, right=0.0), rated_power=rated_power, **shared, )