Source code for oowl.plotting

"""Lightweight Matplotlib helpers that follow the active Matplotlib style."""

from __future__ import annotations

from types import MappingProxyType
from typing import Any, Mapping, cast

import matplotlib
import numpy as np
from matplotlib.axes import Axes
from matplotlib.collections import PolyCollection
from matplotlib.figure import Figure
from matplotlib.projections.polar import PolarAxes
from shapely import wkt as shapely_wkt
from shapely.geometry import LineString, MultiLineString, MultiPolygon, Polygon

from ._site.base import sector_angles
from ._site.coverage import Turbulence

_SINGLE_FIGSIZE = (6.0, 6.0)

__all__ = ["STYLE_RCPARAMS", "use_style"]

#: Matplotlib readability defaults applied by :func:`use_style`.
STYLE_RCPARAMS: Mapping[str, Any] = MappingProxyType(
    {
        "figure.dpi": 110.0,
        "savefig.dpi": 110.0,
        "axes.grid": True,
        "grid.alpha": 0.3,
        "font.size": 10.0,
        "axes.titlesize": 12.0,
        "axes.labelsize": 10.0,
        "legend.fontsize": 9.0,
        "xtick.labelsize": 9.0,
        "ytick.labelsize": 9.0,
    }
)


[docs] def use_style() -> None: """Apply OOWL's documentation defaults to global Matplotlib settings. Updates ``matplotlib.rcParams`` with :data:`STYLE_RCPARAMS`, so repeated calls are idempotent. OOWL's own plots do not need it. It does not import :mod:`matplotlib.pyplot`, select a backend or enable interactive mode. """ matplotlib.rcParams.update(STYLE_RCPARAMS)
# Display backends dispose of pyplot figures without enabling interactive mode. _DISPLAY_BACKENDS = ("inline", "ipympl", "widget", "nbagg", "webagg") def _shows_pyplot_figures() -> bool: """Whether the active backend draws the figures pyplot is holding.""" if matplotlib.is_interactive(): return True try: backend = matplotlib.get_backend().lower() except Exception: # pragma: no cover - a backend that refuses to resolve return False return any(name in backend for name in _DISPLAY_BACKENDS) def _new_figure(figsize: tuple[float, float], **kwargs) -> Figure: """Create a figure, registered with pyplot only if the backend displays it. Otherwise return a detached :class:`~matplotlib.figure.Figure` so figures do not leak in pyplot's registry. """ if _shows_pyplot_figures(): import matplotlib.pyplot as plt return plt.figure(figsize=figsize, **kwargs) return Figure(figsize=figsize, **kwargs) def _new_axes(figsize: tuple[float, float] = _SINGLE_FIGSIZE, **subplot_kw) -> Axes: """Create a single-axes figure and return its axes.""" return _new_figure(figsize).subplots(subplot_kw=subplot_kw) def _style_axes(ax: Axes, *, equal: bool = False, grid: bool = True) -> None: """Tidy one axes without changing global Matplotlib state.""" ax.set_axisbelow(True) if grid: ax.grid(True) if equal: ax.set_aspect("equal", adjustable="datalim") for side in ("top", "right"): ax.spines[side].set_visible(False) def _cmap(name: str | None): """The named colormap, or the style's ``image.cmap`` when ``None``.""" if name is None: name = matplotlib.rcParams["image.cmap"] return matplotlib.colormaps[name] def _domain_geometry(cluster): """Scatter domain recorded as WKT in ``metadata["placement"]``, or ``None``.""" placement = cluster.metadata.get("placement") recorded = placement.get("domain") if isinstance(placement, dict) else None if not isinstance(recorded, str): return None geometry = shapely_wkt.loads(recorded) return geometry if isinstance(geometry, (Polygon, MultiPolygon)) else None def _polygons(geometry): """The polygons of a Polygon or MultiPolygon, as a tuple.""" if isinstance(geometry, MultiPolygon): return tuple(geometry.geoms) if isinstance(geometry, Polygon): return (geometry,) raise TypeError(f"Unsupported geometry type: {type(geometry).__name__}") def _plot_exclusion(ax: Axes, interior, edgecolor, linewidth, zorder: float) -> None: """Hatch one ring of excluded ground over whatever lies beneath it.""" ix, iy = interior.xy # The axes facecolor hides the region fill, so the hatch reads as a hole. ax.fill(ix, iy, facecolor=ax.get_facecolor(), edgecolor="none", zorder=zorder) ax.fill( ix, iy, fill=False, hatch="///", edgecolor=edgecolor, linewidth=linewidth, zorder=zorder + 0.1, ) def _plot_domain(ax: Axes, geometry) -> None: """Outline a scatter domain, with its exclusions hatched.""" edgecolor = matplotlib.rcParams["axes.edgecolor"] for poly in _polygons(geometry): x, y = poly.exterior.xy ax.plot(x, y, color=edgecolor, linewidth=1.0, linestyle="--", zorder=0) for interior in poly.interiors: _plot_exclusion(ax, interior, edgecolor, 1.0, zorder=0.1) def _plot_polygon(ax: Axes, poly: Polygon, regions: bool, **kwargs) -> None: x, y = poly.exterior.xy ax.fill(x, y, **kwargs) z = kwargs["zorder"] if "zorder" in kwargs else 1 edgecolor, linewidth = kwargs["edgecolor"], kwargs["linewidth"] for interior in poly.interiors: if regions: _plot_exclusion(ax, interior, edgecolor, linewidth, zorder=z + 0.1) continue # Punch the hole out to the axes background, then edge it like the shape. ix, iy = interior.xy ax.fill(ix, iy, facecolor=ax.get_facecolor(), edgecolor="none", zorder=z + 0.1) ax.plot( ix, iy, color=edgecolor, linewidth=linewidth, alpha=kwargs["alpha"], zorder=z + 0.2, ) def _plot_boundary( geom, ax: Axes | None = None, title: str | None = None, facecolor: str | None = None, edgecolor: str | None = None, alpha: float | None = None, linewidth: float | None = None, regions: bool = True, ) -> Axes: """Plot a Polygon, MultiPolygon or ``Boundary`` on equal axes and return them. ``regions`` hatches holes as excluded ground, else they are punched out. """ if hasattr(geom, "parts"): # a Boundary instance geometries = [part.geometry for part in geom.parts] else: geometries = [geom] if ax is None: ax = _new_axes() # ``None`` selects the first color of the style's property cycle. style = dict( facecolor="C0" if facecolor is None else facecolor, edgecolor="C0" if edgecolor is None else edgecolor, alpha=0.25 if alpha is None else alpha, linewidth=1.5 if linewidth is None else linewidth, ) for geometry in geometries: for poly in _polygons(geometry): _plot_polygon(ax, poly, regions, **style) _style_axes(ax, equal=True, grid=False) ax.autoscale_view() ax.set(xlabel="x [m]", ylabel="y [m]") if title: ax.set_title(title) return ax def _plot_farm( layout, ax: Axes | None = None, title: str | None = None, facecolor: str | None = None, edgecolor: str | None = None, alpha: float | None = None, linewidth: float | None = None, regions: bool = True, **turbine_kwargs, ) -> Axes: """Plot a :class:`~oowl.layouts.base.Farm` boundary and turbines. Extra keywords go to ``scatter``. Turbines take the first property-cycle color, or one cycle color per turbine type, unless ``color`` is given. """ style: dict[str, Any] = {"s": 20.0, "zorder": 5, **turbine_kwargs} if style.get("color") is None: style.pop("color", None) if layout.boundary is not None: ax = _plot_boundary( layout.boundary, ax=ax, title=title, facecolor=facecolor, edgecolor=edgecolor, alpha=alpha, linewidth=linewidth, regions=regions, ) if layout.guide is not None: ax = _plot_string_guide(layout.guide, ax=ax, title=title) if ax is None: ax = _new_axes() pts = np.asarray(layout.positions).reshape(-1, 2) if pts.size: turbines = layout.turbines multiple_types = ( isinstance(turbines, tuple) and len({id(turbine) for turbine in turbines}) > 1 ) if "color" in style or not multiple_types: style.setdefault("color", "C0") ax.scatter(pts[:, 0], pts[:, 1], **style) else: groups: dict[int, tuple[object, list[int]]] = {} for index, turbine in enumerate(turbines): groups.setdefault(id(turbine), (turbine, []))[1].append(index) for color_index, (turbine, indices) in enumerate(groups.values()): selected = pts[indices] label = getattr(turbine, "name", None) or type(turbine).__name__ typed: dict[str, Any] = {**style, "color": f"C{color_index}"} ax.scatter(selected[:, 0], selected[:, 1], label=label, **typed) ax.legend(title="Turbine type", loc="upper left") _style_axes(ax, equal=True, grid=False) ax.autoscale_view() ax.set(xlabel="x [m]", ylabel="y [m]") if title and layout.boundary is None and layout.guide is None: ax.set_title(title) return ax def _plot_cluster( cluster, ax: Axes | None = None, title: str | None = None, facecolor: str | None = None, edgecolor: str | None = None, alpha: float | None = 0.2, linewidth: float | None = None, regions: bool = True, **turbine_kwargs, ) -> Axes: """Plot every member of a :class:`~oowl.Cluster` on shared axes. Members take successive property-cycle colors unless ``color`` is given. ``regions`` also draws the recorded placement domain and its exclusions. """ if ax is None: ax = _new_axes() if turbine_kwargs.get("color") is None: turbine_kwargs.pop("color", None) if regions: domain = _domain_geometry(cluster) if domain is not None: _plot_domain(ax, domain) for index, farm in enumerate(cluster.farms): color = f"C{index}" _plot_farm( farm, ax=ax, facecolor=color if facecolor is None else facecolor, edgecolor=color if edgecolor is None else edgecolor, alpha=alpha, linewidth=linewidth, regions=regions, **{"color": color, **turbine_kwargs}, ) if title: ax.set_title(title) return ax def _plot_wind_rose( source, wind_speed=None, *, ax: Axes | None = None, n_sectors: int = 16, speed_bins=None, n_speed_bins: int = 6, cmap: str | None = None, opening: float = 0.8, edgecolor="none", legend: bool = True, title: str | None = None, figsize: tuple[float, float] = _SINGLE_FIGSIZE, ) -> Axes: """Plot a wind rose from a wind rose, resource, site conditions or arrays. Bars are percentages, stacked by speed bin when speeds are available. A resource's bars are percent of all time, so they sum to the share of time its speed bins cover, and sampled cases' bars are percent of the cases. The colormap defaults to the style's ``image.cmap``. """ theta_deg, table, bins = _wind_rose_table( source, wind_speed, n_sectors, speed_bins, n_speed_bins ) cmap_obj = _cmap(cmap) if bins is None: # Without speeds, one bar per sector shows its probability. colors, labels, legend_title = [cmap_obj(0.5)], None, None else: colors = [cmap_obj(i) for i in np.linspace(0.0, 1.0, len(bins) - 1)] labels = [f"{lo:.1f}\u2013{hi:.1f}" for lo, hi in zip(bins[:-1], bins[1:])] legend_title = "Wind speed [m/s]" if legend else None return _draw_rose( ax, figsize, theta_deg, table, colors, labels=labels, opening=opening, edgecolor=edgecolor, legend_title=legend_title, title=title, ) def _draw_rose( ax, figsize, theta_deg, table, colors, *, labels, opening, edgecolor, legend_title, title, ) -> Axes: """Draw stacked sector bars on a north-up, clockwise polar axes.""" if ax is None: ax = _new_axes(figsize, projection="polar") ax = cast(PolarAxes, ax) ax.set_theta_zero_location("N") ax.set_theta_direction(-1) theta = np.deg2rad(theta_deg) bar_width = np.deg2rad(360.0 / len(theta_deg)) * opening _plot_stacked_polar_bars( ax, theta, table, bar_width, colors, labels=labels, edgecolor=edgecolor ) ax.set_thetagrids(np.arange(0, 360, 90), labels=["N", "E", "S", "W"]) if legend_title is not None: ax.legend( title=legend_title, loc="upper left", bbox_to_anchor=(1.02, 1.0), frameon=False, ) if title: ax.set_title(title, pad=20) return ax def _plot_ti_rose( source, *, ax: Axes | None = None, ti_bins=None, n_ti_bins: int = 8, u_min: float = 4.0, u_max: float = 25.0, n_quadrature: int = 2000, cmap: str | None = None, opening: float = 0.8, edgecolor="none", legend: bool = True, title: str | None = None, figsize: tuple[float, float] = _SINGLE_FIGSIZE, ) -> Axes: """Plot an analytical direction x turbulence-intensity rose. The colormap defaults to the style's ``image.cmap``. """ if not hasattr(source, "ti_rose"): raise TypeError("TI plotting requires an analytical wind resource") if ti_bins is None: ti_bins = source._ti_bins(u_min, u_max, n_ti_bins) bins = np.asarray(ti_bins, dtype=float) theta_deg = np.asarray(source.theta, dtype=float) table = ( source.ti_rose( bins, u_min=u_min, u_max=u_max, n_quadrature=n_quadrature, ).T * 100.0 ) cmap_obj = _cmap(cmap) colors = [cmap_obj(i) for i in np.linspace(0.0, 1.0, len(bins) - 1)] labels = [f"{lo:.3f}\u2013{hi:.3f}" for lo, hi in zip(bins[:-1], bins[1:])] return _draw_rose( ax, figsize, theta_deg, table, colors, labels=labels, opening=opening, edgecolor=edgecolor, legend_title="Turbulence intensity" if legend else None, title=title, ) def _plot_site_conditions( conditions, *, ax=None, bins: int = 30, n_sectors: int | None = None, title: str | None = None, figsize: tuple[float, float] | None = None, boundary_color: str | None = None, scatter_color: str = "C0", hist_color: str = "C0", ) -> np.ndarray: """Plot wind rose, turbulence, stability and speed panels for site conditions. ``ax`` holds the four Axes to draw on, the first polar. The four Axes are returned. ``boundary_color`` defaults to the style's ``axes.edgecolor``. """ if boundary_color is None: boundary_color = matplotlib.rcParams["axes.edgecolor"] metadata = conditions.metadata or {} if n_sectors is None: n_sectors = int(metadata.get("resource", {}).get("n_sectors", 16)) n_axes = 4 if figsize is None: figsize = (18.0, 4.5) if ax is None: fig = _new_figure(figsize, constrained_layout=True) axes = np.asarray( [fig.add_subplot(1, n_axes, 1, projection="polar")] + [fig.add_subplot(1, n_axes, index) for index in range(2, n_axes + 1)], dtype=object, ) else: axes = np.asarray(ax).ravel() if len(axes) != n_axes: raise ValueError(f"ax must contain exactly {n_axes} Axes") if getattr(axes[0], "name", None) != "polar": raise ValueError("the first Axes in ax must use a polar projection") fig = axes[0].figure u = np.asarray(conditions.wind_speed, dtype=float) ti = np.asarray(conditions.turbulence_intensity, dtype=float) weight = np.asarray(conditions.weight, dtype=float) u_min = float(metadata.get("u_min", 4.0)) u_max = float(metadata.get("u_max", 25.0)) _plot_wind_rose( conditions, ax=axes[0], n_sectors=n_sectors, title="Wind rose", ) legend = axes[0].get_legend() if legend is not None: handles, labels = axes[0].get_legend_handles_labels() legend.remove() axes[0].legend( handles, labels, title="Wind speed [m/s]", loc="upper center", bbox_to_anchor=(0.5, -0.08), frameon=False, ncols=2, ) ax = axes[1] ax.scatter(u, ti, s=12, alpha=0.35, color=scatter_color, edgecolors="none") # The Dimitrov band depends on wind speed alone, so it is one envelope. band = metadata.get("resource", {}).get("turbulence", {}) if band.get("type") == "Dimitrov.Turbulence": u_curve = np.linspace(u_min, u_max, 300) sigma_low, sigma_high = Turbulence( band["minimum_intensity"], band["reference_intensity"], band.get("maximum_intensity"), ).bounds(u_curve) _plot_envelope( ax, u_curve, sigma_low / u_curve, sigma_high / u_curve, boundary_color ) ax.set( xlabel="Wind speed [m/s]", ylabel="Turbulence intensity", title="Turbulence samples", ) _style_axes(ax) ax = axes[2] inverse_length = np.asarray(conditions.inverse_obukhov_length, dtype=float) reference_height = float(np.asarray(conditions.reference_height, dtype=float)[0]) values = reference_height * inverse_length ax.scatter( u, values, s=12, alpha=0.35, color=scatter_color, edgecolors="none", ) # Binned percentiles of the drawn samples summarize the stability spread. order = np.argsort(u) chunks = np.array_split(order, min(12, max(1, len(order) // 25))) centers, medians, lower, upper = [], [], [], [] for chunk in chunks: if not len(chunk): continue centers.append(np.mean(u[chunk])) medians.append(np.median(values[chunk])) lower.append(np.percentile(values[chunk], 5)) upper.append(np.percentile(values[chunk], 95)) if len(centers) > 1: ax.plot(centers, medians, color=boundary_color, linewidth=1.4, label="median") ax.plot(centers, lower, color=boundary_color, linewidth=1.0, ls="--") ax.plot( centers, upper, color=boundary_color, linewidth=1.0, ls="--", label="5th to 95th percentile", ) ax.legend(frameon=False) ylabel = rf"Stability $z/L$ at {reference_height:g} m" ax.axhline(0.0, color=boundary_color, linewidth=1.0) ax.set( xlabel="Wind speed [m/s]", ylabel=ylabel, title="Atmospheric stability", ) _style_axes(ax) ax = axes[3] ax.hist( u, bins=bins, weights=weight, color=hist_color, alpha=0.85, edgecolor=ax.get_facecolor(), linewidth=0.5, ) ax.set( xlabel="Wind speed [m/s]", ylabel="Probability", title="Wind-speed distribution", ) _style_axes(ax) if title: fig.suptitle(title) return axes def _plot_envelope( ax: Axes, wind_speed: np.ndarray, lower: np.ndarray, upper: np.ndarray, color: str, ) -> None: """Draw the exact sampling envelope over its complete wind-speed range.""" ax.fill_between(wind_speed, lower, upper, color=color, alpha=0.08, linewidth=0) ax.plot(wind_speed, lower, color=color, linewidth=1.2, linestyle="--") ax.plot(wind_speed, upper, color=color, linewidth=1.2, linestyle="--") def _plot_stacked_polar_bars( ax: Axes, theta: np.ndarray, table: np.ndarray, bar_width: float, colors, *, labels=None, edgecolor="none", ) -> None: """Draw stacked polar bars as one collection per row in *table*.""" table = np.asarray(table, dtype=float) bottom = np.zeros(len(theta), dtype=float) theta_offsets = np.linspace(-0.5 * bar_width, 0.5 * bar_width, 5) labels = labels or [None] * len(table) for heights, color, label in zip(table, colors, labels): top = bottom + heights active = top > bottom if np.any(active): polygons = [] for center, inner, outer in zip(theta[active], bottom[active], top[active]): arc = center + theta_offsets polygons.append( np.column_stack( [ np.concatenate([arc, arc[::-1]]), np.concatenate( [ np.full_like(arc, outer), np.full_like(arc, inner), ] ), ] ) ) collection = PolyCollection( polygons, facecolors=[color], edgecolors=edgecolor, linewidths=0, label=label, ) ax.add_collection(collection) bottom = top y_max = float(bottom.max(initial=0.0)) if y_max > 0.0: ax.set_ylim(0.0, y_max * 1.05) def _resolve_speed_bins(speed_bins, n_speed_bins, wind_speed=None) -> np.ndarray: """Return speed-bin edges, auto-generating them when *speed_bins* is None.""" if speed_bins is not None: return np.asarray(speed_bins, dtype=float) if wind_speed is None: return np.linspace(0.0, 30.0, n_speed_bins + 1) u_lo = float(np.min(wind_speed)) u_hi = float(np.max(wind_speed)) if u_hi <= u_lo: u_hi = u_lo + 1.0 return np.linspace(u_lo, u_hi, n_speed_bins + 1) def _wind_rose_table(source, wind_speed, n_sectors, speed_bins, n_speed_bins): """Return ``(sector_centers_deg, table, speed_bin_edges)`` for a wind rose. ``table`` holds percentages, shape ``(n_rows, n_sectors)``. Edges are ``None`` for a single-row, direction-only rose. """ # An analytical resource tabulates its direction-dependent Weibull distributions. if hasattr(source, "wind_rose"): theta_deg = np.asarray(source.theta, dtype=float) bins = ( np.linspace(4.0, 25.0, n_speed_bins + 1) if speed_bins is None else np.asarray(speed_bins, dtype=float) ) return theta_deg, source.wind_rose(bins).T * 100.0, bins # Otherwise bin samples from SiteConditions or a raw direction array. weights = None if hasattr(source, "wind_direction"): if wind_speed is None: wind_speed = source.wind_speed weights = np.asarray(source.weight, dtype=float) source = source.wind_direction wind_direction = np.asarray(source, dtype=float) sector_width = 360.0 / n_sectors # Sector 0 is centered on north, so edges lie half a sector either side. sector_idx = ( np.floor((wind_direction % 360.0 + sector_width / 2) / sector_width).astype(int) % n_sectors ) if weights is None: weights = np.ones(wind_direction.shape, dtype=float) if wind_speed is None: # Directions alone give one row of sector shares. bins = None table = np.zeros((1, n_sectors)) np.add.at(table, (0, sector_idx), weights) else: wind_speed = np.asarray(wind_speed, dtype=float) bins = _resolve_speed_bins(speed_bins, n_speed_bins, wind_speed) speed_idx = np.clip(np.digitize(wind_speed, bins) - 1, 0, len(bins) - 2) table = np.zeros((len(bins) - 1, n_sectors)) np.add.at(table, (speed_idx, sector_idx), weights) total = table.sum() if total > 0: table = table * 100.0 / total return sector_angles(n_sectors), table, bins def _plot_string_guide( guide, ax: Axes | None = None, title: str | None = None, color: str = "C0", linewidth: float = 2.0, ) -> Axes: """Plot a string guide while preserving visible breaks between segments.""" if ax is None: ax = _new_axes() geometry = guide.geometry lines = geometry.geoms if isinstance(geometry, MultiLineString) else [geometry] for line in lines: if not isinstance(line, LineString): raise TypeError(f"Unsupported string geometry: {type(line).__name__}") x, y = line.xy ax.plot(x, y, color=color, linewidth=linewidth) _style_axes(ax, equal=True, grid=False) ax.autoscale_view() ax.set(xlabel="x [m]", ylabel="y [m]") if title: ax.set_title(title) return ax