Source code for oowl.layouts.base

"""Neutral base classes for layout algorithms and generated results."""

from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from copy import copy
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self

import numpy as np

from .._random import as_rng, derive_seed, pin_seed, resolve_seed
from .._repr import _ArgumentRepr
from .._validation import _validate_integer, _validate_positive

if TYPE_CHECKING:  # pragma: no cover
    from collections.abc import Iterable

    import matplotlib.axes
    from numpy.typing import ArrayLike

    from ..boundaries.base import Boundary
    from ..modifiers import LayoutModifier
    from ..resources import Dimitrov, WindResource
    from ..strings.base import StringGuide
    from ..surfaces import Surface
    from ..turbines import Turbine
    from .fitters import Fitter

__all__ = ["Farm", "LayoutAlgorithm"]


def _allocate_counts(n_turbines: int, weights) -> np.ndarray:
    """Split a count by weight with deterministic largest remainder."""
    weights = np.asarray(weights, dtype=float)
    if weights.ndim != 1 or not len(weights):
        raise ValueError("weights must be a non-empty one-dimensional sequence")
    if not np.all(np.isfinite(weights)) or np.any(weights < 0):
        raise ValueError("weights must be finite and non-negative")
    if weights.sum() <= 0:
        raise ValueError("at least one weight must be positive")
    quotas = int(n_turbines) * weights / weights.sum()
    counts = np.floor(quotas).astype(int)
    remainder = int(n_turbines) - int(counts.sum())
    fractions = quotas - counts
    order = sorted(range(len(weights)), key=lambda index: (-fractions[index], index))
    for index in order[:remainder]:
        counts[index] += 1
    return counts


def _nearest_neighbor_distances(points: np.ndarray) -> np.ndarray:
    """Distance from each of at least two ``(N, 2)`` points to its nearest other."""
    from scipy.spatial import cKDTree  # ty: ignore[unresolved-import]

    return cKDTree(points).query(points, k=2)[0][:, 1]


def _median_spacing(points: np.ndarray) -> float | None:
    """Median positive nearest-neighbor distance, or ``None`` if there is none."""
    if len(points) < 2:
        return None
    nearest = _nearest_neighbor_distances(points)
    usable = nearest[np.isfinite(nearest) & (nearest > 0)]
    return float(np.median(usable)) if len(usable) else None


def _closest_pair(farm: Farm) -> float:
    """Distance between the two nearest turbines, or ``inf`` if there is one."""
    from ..metrics import nearest_neighbor_distances

    if len(farm.positions) < 2:
        return float("inf")
    return float(np.nanmin(nearest_neighbor_distances(farm)))


def _mean_nearest(farm: Farm) -> float:
    """Mean nearest-neighbor distance, or ``nan`` below two turbines."""
    return farm.mean_spacing()


def _loneliest(farm: Farm) -> float:
    """The loneliest turbine's nearest-neighbor distance, ``nan`` below two."""
    return farm.maximum_spacing()


#: The quantities that size a layout inside a boundary, at most one given.
_SIZING = (
    "n_turbines",
    "area_per_turbine",
    "minimum_spacing",
    "mean_spacing",
    "maximum_spacing",
)

#: The sizing quantities a layout holds by searching or filling.
_SPACINGS = ("minimum_spacing", "mean_spacing", "maximum_spacing")

#: The densities a layout takes as ``density={name: value}``, one at most.
_DENSITIES = ("area_per_turbine", "minimum_spacing", "mean_spacing", "maximum_spacing")


def _optional_positive(name: str, value) -> float | None:
    """``value`` as a positive finite float, or ``None``."""
    return None if value is None else _validate_positive(name, value)


def _layout_density(density: "Mapping[str, Any] | None") -> dict[str, float]:
    """A layout's checked ``density`` as ``{name: value}``, empty for ``None``."""
    if density is None:
        return {}
    if not isinstance(density, Mapping):
        raise TypeError(
            "density must be a mapping such as {'minimum_spacing': 800.0}, not "
            f"{type(density).__name__}"
        )
    if "power_density" in density:
        raise ValueError(
            "a layout has no turbines to turn a power_density into a count, so "
            "give it to a Sampler with turbines"
        )
    unknown = sorted(str(name) for name in density if name not in _DENSITIES)
    if unknown:
        raise ValueError(
            f"unknown density {unknown}, which may be {', '.join(_DENSITIES)}"
        )
    if len(density) != 1:
        raise ValueError(
            f"density takes one of {', '.join(_DENSITIES)}, got {len(density)}"
        )
    name, value = next(iter(density.items()))
    return {name: _validate_positive(f"density['{name}']", value)}


class _NoFitError(ValueError):
    """No two turbines of a layout fit its boundary at the spacing asked."""


def _placed_for(modifiers, n_kept: int) -> int:
    """Turbines to place for ``n_kept`` to remain after ``modifiers`` run in order."""
    for modifier in reversed(modifiers):
        n_kept = modifier._placed_for(n_kept)
    return n_kept


def _no_fit_error(spacing: float, area: float, name: str) -> _NoFitError:
    return _NoFitError(
        f"no {name} of two or more turbines fits {area / 1e6:.3g} km2 "
        f"with {spacing:.3g} m spacing"
    )


#: Most turbines a fill may test, as many as a candidate fitter's default
#: ``candidate_cap``, so a spacing far too small for its boundary is refused
#: at once rather than filled for minutes.
_MAX_FILL_TURBINES = 100_000


def _too_many_error(spacing: float, count: int, holder: str) -> ValueError:
    return ValueError(
        f"a spacing of {spacing:.4g} m is too small for this boundary, "
        f"whose {holder} would test about {count:,} turbines"
    )


#: How to size a farm's boundary, the advice of :func:`_unsized_error`.
_SIZE_A_FARM = (
    "Give it area=... in square meters, or let a Sampler size it with area, a "
    "density, or n_turbines with a spacing"
)


def _unsized_error(boundary, advice: str = _SIZE_A_FARM) -> ValueError:
    """The error for laying a farm out on a boundary nothing gave a size."""
    return ValueError(
        f"{type(boundary).__name__} has no area, so it is a unit-size shape. {advice}"
    )


#: Draws that refine a count by the prediction a mean distance gives.
_MEAN_REFINEMENTS = 6


def _refined_count(built, measure, spacing: float, count: int) -> int:
    """The count whose ``measure`` a mean distance predicts to hold ``spacing``.

    A mean nearest-neighbor distance falls about as ``1 / sqrt(n)``, so each
    draw predicts the count ``n * (measure / spacing) ** 2``, held within a
    factor of two of the last. A lattice's mean jumps where its rows change,
    and the prediction then swung across the jump until the draws ran out,
    so the most counts known to hold the spacing and the fewest known not to
    bracket it, and a prediction past either ends the refinement at that
    edge, for the steps of :func:`_most_turbines_for_spacing` to settle.
    Returns the last count when the prediction repeats, the draws fail or
    :data:`_MEAN_REFINEMENTS` run out.
    """
    holds, fails = 0, _MAX_FILL_TURBINES + 1
    for _ in range(_MEAN_REFINEMENTS):
        if count > _MAX_FILL_TURBINES:
            break
        farm = built(count)
        value = np.nan if farm is None else measure(farm)
        if not np.isfinite(value):
            break
        if value >= spacing:
            holds = max(holds, count)
        else:
            fails = min(fails, count)
        predicted = round(count * (value / spacing) ** 2)
        predicted = int(min(max(predicted, max(2, count // 2)), 2 * count))
        if predicted == count:
            break
        if predicted <= holds:
            return holds
        if predicted >= fails:
            return max(holds, fails - 1)
        count = predicted
    return count


def _most_turbines_for_spacing(
    build,
    spacing: float,
    area: float,
    estimate: int | None = None,
    name="layout",
    measure=None,
) -> Farm:
    """Draw the most turbines whose ``measure`` is at least ``spacing``.

    ``measure`` maps a farm to a distance, its closest pair unless given.

    ``build(count)`` draws ``count`` turbines in a boundary of ``area``. The
    count starts at ``estimate``, or else from one trial draw, since spacing
    scales as ``sqrt(area / n)``. It is stepped by one, as most estimates miss
    by a few, and after four steps by doubling ones until a count flips
    between fitting and not, the two then bisected. An estimate far off so
    costs a few draws, not one per count. A step down removes at most an
    eighth of the count, since the closest pair of a layout such as Delaunay
    need not grow as the count falls, and a longer step could pass over every
    count that fits. A count the layout cannot build at all does not fit
    either, and the last such error is kept as the cause when no count fits.
    A mean distance falls smoothly with the count, so the draws first follow
    its prediction and the steps only confirm the count found.
    """
    if measure is None:
        measure = _closest_pair
    failure: ValueError | None = None
    # Every count is drawn from one seed, so a count drawn twice is the same.
    draws: dict[int, Farm | None] = {}

    def built(count: int) -> Farm | None:
        nonlocal failure
        if count not in draws:
            try:
                draws[count] = build(count)
            except ValueError as error:
                failure = error
                draws[count] = None
        return draws[count]

    if estimate is None:
        trial_n = 16
        trial = built(trial_n)
        if trial is None:
            # Too many to build, so step up from the fewest instead.
            estimate = 2
        else:
            closest = measure(trial)
            if not np.isfinite(closest):
                raise ValueError("spacing needs at least two turbines to separate")
            estimate = int(trial_n * (closest / spacing) ** 2)
    count = max(2, int(estimate))
    if measure is not _closest_pair:
        count = _refined_count(built, measure, spacing, count)
    if count > _MAX_FILL_TURBINES:
        raise _too_many_error(spacing, count, "layout")

    def honoured(count: int) -> Farm | None:
        if count > _MAX_FILL_TURBINES:
            return None
        farm = built(count)
        return farm if farm is not None and measure(farm) >= spacing else None

    def steps():
        yield from (1, 1, 1, 1)
        step = 2
        while True:
            yield step
            step *= 2

    # ``low`` fits and ``high`` does not, once both are found.
    first = honoured(count)
    if first is not None:
        low, farm = count, first
        for step in steps():
            more = honoured(low + step)
            if more is None:
                high = low + step
                break
            low, farm = low + step, more
    else:
        high = count
        for step in steps():
            low = max(2, high - min(step, max(1, high // 8)))
            found = honoured(low)
            if found is not None:
                farm = found
                break
            if low == 2:
                raise _no_fit_error(spacing, area, name) from failure
            high = low
    while high - low > 1:
        middle = (low + high) // 2
        more = honoured(middle)
        if more is None:
            high = middle
        else:
            low, farm = middle, more
    return farm


def _closest_distance(points: np.ndarray) -> float:
    """Distance between the two nearest of ``points``, or ``inf`` below two."""
    if len(points) < 2:
        return float("inf")
    return float(_nearest_neighbor_distances(points).min())


def _fewest_turbines_for_ceiling(
    build, spacing: float, area: float, name="layout"
) -> Farm:
    """Draw the fewest turbines, at least two, that leave none farther than ``spacing``.

    The loneliest turbine's nearest neighbor shrinks about as ``1 / sqrt(n)``
    as the count grows, so the count starts where a lattice of ``area /
    spacing ** 2`` cells would sit and each draw predicts the count that
    meets the ceiling from the distance it measured, a little past it so the
    next draw lands on the other side. A count that holds the ceiling and
    the one drawn before it, which did not, are then bisected. A step rises
    at most fourfold and falls at most a quarter, so a poor prediction does
    not pass over every count that holds. A count the layout cannot build
    holds nothing.
    """
    draws: dict[int, tuple[Farm | None, float]] = {}

    def measured(count: int) -> tuple[Farm | None, float]:
        if count > _MAX_FILL_TURBINES:
            raise _too_many_error(spacing, count, "layout")
        if count not in draws:
            try:
                farm = build(count)
            except ValueError:
                draws[count] = (None, np.inf)
            else:
                loneliest = _loneliest(farm)
                draws[count] = (farm, loneliest if np.isfinite(loneliest) else np.inf)
        return draws[count]

    def holding(count: int) -> Farm | None:
        farm, loneliest = measured(count)
        return farm if loneliest <= spacing else None

    count = max(2, round(area / spacing**2))
    # ``low`` does not hold and ``high`` does, once both are found.
    while True:
        found, loneliest = measured(count)
        if found is not None and loneliest <= spacing:
            high, farm = count, found
            if count == 2:
                return found
            target = count * (loneliest / spacing) ** 2 * 0.97
            following = min(
                count - 1, max(int(np.floor(target)), count - count // 4, 2)
            )
            if holding(following) is None:
                low = following
                break
        else:
            low = count
            target = (
                count * 2.0
                if np.isinf(loneliest)
                else count * (loneliest / spacing) ** 2
            )
            following = max(count + 1, min(int(np.ceil(target * 1.03)), 4 * count))
            farm = holding(following)
            if farm is not None:
                high = following
                break
        count = following
    while high - low > 1:
        middle = (low + high) // 2
        found = holding(middle)
        if found is None:
            low = middle
        else:
            high, farm = middle, found
    if farm is None:
        raise _no_fit_error(spacing, area, name)
    return farm


def _kept_apart(positions: np.ndarray, spacing: float) -> np.ndarray:
    """``positions`` without each turbine closer than ``spacing`` to an earlier one."""
    return positions[_kept_apart_mask(positions, spacing)]


def _kept_apart_mask(positions: np.ndarray, spacing: float) -> np.ndarray:
    """Which of ``positions`` no earlier kept one lies closer than ``spacing`` to."""
    from scipy.spatial import cKDTree  # ty: ignore[unresolved-import]

    kept = np.ones(len(positions), dtype=bool)
    if len(positions) < 2:
        return kept
    # Pairs at most just under ``spacing`` apart are the ones closer than it.
    pairs = cKDTree(positions).query_pairs(
        np.nextafter(spacing, 0.0), output_type="ndarray"
    )
    if not len(pairs):
        return kept
    earlier = pairs.min(axis=1)
    later = pairs.max(axis=1)
    order = np.argsort(later, kind="stable")
    # Taken by their later point, every earlier point is settled when reached.
    flags = [True] * len(positions)
    for first, second in zip(earlier[order].tolist(), later[order].tolist()):
        if flags[first]:
            flags[second] = False
    return np.asarray(flags)


def _apart_from(points, references, spacing):
    """``points`` no closer than ``spacing`` to any of ``references``."""
    from scipy.spatial import cKDTree  # ty: ignore[unresolved-import]

    if not len(points) or not len(references):
        return points
    distances, _ = cKDTree(references).query(points)
    return points[distances >= spacing]


#: Share of a piece that clearing it of its neighbors may remove. A piece that
#: a full clearance would cut by more is cleared by less, the most that
#: removes at most this share.
_MAX_CLEARED_SHARE = 0.5

#: Steps that shrink a clearance toward the share it may remove.
_CLEARANCE_STEPS = 8


def _lattice_clearance(piece, count: int) -> float:
    """Half the spacing of ``count`` turbines on an equilateral lattice over ``piece``.

    Two neighbors sharing an edge each give up that much of their own, so
    their turbines stay about a spacing apart across it.
    """
    return 0.5 * float(np.sqrt(2.0 * piece.area / (np.sqrt(3.0) * count)))


def _within_share(piece, cleared, clearance: float, cleared_at):
    """The shape cleared by the largest clearance up to ``clearance`` within the share.

    ``cleared`` is ``piece`` cleared by ``clearance``, and ``cleared_at(c)``
    clears it by ``c``. Clearing may remove at most :data:`_MAX_CLEARED_SHARE`
    of the piece. Along a shared edge the area removed grows about in
    proportion to the clearance, so each step scales the clearance by the
    share over the area removed, a little below it. Returns the shape and the
    clearance, or ``piece`` and ``0.0`` without one.
    """
    allowed = _MAX_CLEARED_SHARE * piece.area
    for step in range(_CLEARANCE_STEPS + 1):
        removed = piece.area - (0.0 if cleared.is_empty else cleared.area)
        if not cleared.is_empty and removed <= allowed:
            return cleared, clearance
        if not clearance or step == _CLEARANCE_STEPS:
            break
        clearance *= 0.95 * allowed / removed
        cleared = cleared_at(clearance)
    return piece, 0.0


def _cleared(pieces, index: int, clearance: float):
    """Piece ``index`` without what lies within ``clearance`` of the others.

    Returns the shape and the clearance, less than ``clearance`` where that
    would remove more than half the piece, and ``0.0`` with the piece itself
    when it has no neighbor that close.
    """
    import shapely

    piece = pieces[index]
    neighbors = [
        other
        for position, other in enumerate(pieces)
        if position != index and piece.distance(other) < clearance
    ]
    if not neighbors:
        return piece, 0.0
    others = shapely.union_all(neighbors)

    def cleared_at(distance):
        return piece.difference(others.buffer(distance))

    return _within_share(piece, cleared_at(clearance), clearance, cleared_at)


def _laid_out_clear(piece, cleared, clearance: float, build):
    """``build`` laid out on ``cleared``, a part of ``piece``, and the clearance.

    ``build(boundary)`` returns a farm inside the boundary it is given. A
    piece too tight for its turbines once cleared is laid out whole, with
    clearance ``0.0``, and an error laying it out whole propagates.
    """
    from ..boundaries.base import _GeometryBoundary

    if clearance:
        try:
            return build(_GeometryBoundary(cleared)), clearance
        except (TypeError, ValueError):
            pass
    return build(_GeometryBoundary(piece)), 0.0


#: Distance, as a share of the farm's span, within which a turbine in a
#: rounding crack between the parts of a boundary joins the nearest part.
_PIECE_CRACK = 1e-9


def _grouped_by_piece(pieces, positions: np.ndarray) -> list[np.ndarray]:
    """Indices of ``positions`` in each of ``pieces``, a turbine in its first piece.

    Partition cells cut with no gap meet only to rounding, so a turbine on a
    shared edge, as a Sunflower's center was, can fall in a crack 2e-13 m
    wide. Such a turbine joins its nearest piece. Raises ``ValueError`` for
    one farther than rounding from every piece.
    """
    import shapely

    remaining = np.ones(len(positions), dtype=bool)
    groups = []
    for piece in pieces:
        inside = remaining & shapely.intersects_xy(
            piece, positions[:, 0], positions[:, 1]
        )
        groups.append(np.flatnonzero(inside))
        remaining &= ~inside
    if remaining.any():
        stray = np.flatnonzero(remaining)
        distances = shapely.distance(
            np.asarray(pieces, dtype=object)[:, None],
            shapely.points(positions[stray])[None, :],
        )
        bounds = shapely.total_bounds(np.asarray(pieces, dtype=object))
        span = max(float(np.hypot(bounds[2] - bounds[0], bounds[3] - bounds[1])), 1.0)
        if distances.min(axis=0).max() > _PIECE_CRACK * span:
            raise ValueError("a filled turbine lies outside every part of the boundary")
        nearest = distances.argmin(axis=0)
        for index, piece_index in zip(stray, nearest):
            groups[piece_index] = np.sort(np.append(groups[piece_index], index))
    return groups


def _spread_selection(candidates, count: int, references, rng) -> np.ndarray:
    """Greedily pick ``count`` candidates, each farthest from all placed points.

    ``references`` are fixed points the picks keep away from. Without any, the
    first pick is a random candidate.
    """
    if count > len(candidates):
        raise ValueError(f"cannot pick {count} of {len(candidates)} candidates")
    selected = []
    first = None
    if not len(references):
        first = int(rng.integers(len(candidates)))
        selected.append(candidates[first])
        references = candidates[first].reshape(1, 2)
    # The squares of the norm's distances, whose square root is monotone, so
    # the root of the least is the least root, without the norm's large
    # intermediate of every coordinate difference.
    dx = candidates[:, None, 0] - references[None, :, 0]
    dy = candidates[:, None, 1] - references[None, :, 1]
    nearest = np.sqrt((dx * dx + dy * dy).min(axis=1))
    # A picked candidate is marked rather than removed, which would copy the
    # arrays at every pick.
    if first is not None:
        nearest[first] = -np.inf
    x = np.ascontiguousarray(candidates[:, 0])
    y = np.ascontiguousarray(candidates[:, 1])
    dx = np.empty_like(x)
    dy = np.empty_like(y)
    box = float(np.ptp(x) * np.ptp(y)) if len(x) else 0.0
    rows = tree = None
    check = np.inf
    while len(selected) < count:
        choice = int(np.argmax(nearest))
        selected.append(candidates[choice])
        reach = float(nearest[choice])
        if (
            rows is None
            and len(x) > _SPREAD_ROWS_FROM
            and reach <= check
            and np.pi * reach * reach * len(x) <= _SPREAD_BALL * box
        ):
            rows, targets, distances, tree = _neighbor_rows(x, y, reach, tree)
            # Candidates that crowd part of their box hold more, so they are
            # counted again once the distance has shrunk by a fifth.
            check = 0.8 * reach
        if rows is None:
            # Each pick only lowers the distances by its own, so update them in
            # place of measuring every candidate against every placed point.
            np.subtract(x, x[choice], out=dx)
            np.subtract(y, y[choice], out=dy)
            np.multiply(dx, dx, out=dx)
            np.multiply(dy, dy, out=dy)
            np.add(dx, dy, out=dx)
            np.sqrt(dx, out=dx)
            np.minimum(nearest, dx, out=nearest)
        else:
            # A pick lies farthest from the placed points, so it brings no
            # candidate farther from it than that any closer to them, and the
            # farthest distance only shrinks. So the pairs within it at the
            # first such pick hold every distance a later pick can lower, and
            # their row in place of a full pass gives the same picks.
            near = targets[rows[choice] : rows[choice + 1]]
            nearest[near] = np.minimum(
                nearest[near], distances[rows[choice] : rows[choice + 1]]
            )
        nearest[choice] = -np.inf
    return np.asarray(selected)


#: Candidates a pick's farthest distance holds around it on average, below
#: which :func:`_spread_selection` lists each candidate's neighbors once
#: rather than measure every candidate at every pick.
_SPREAD_BALL = 64.0

#: Candidates above which listing the neighbors pays. Below, a full pass is
#: quicker than building the lists, twice as quick for 400 candidates.
_SPREAD_ROWS_FROM = 1_000


def _neighbor_rows(x, y, reach: float, tree=None):
    """Each candidate's neighbors within ``reach`` and their distances.

    Rows of a sparse adjacency, so candidate ``i`` has neighbors
    ``targets[rows[i]:rows[i + 1]]``, or ``None`` where a sample of the
    candidates holds more than :data:`_SPREAD_BALL` on average. A distance is
    measured as a full pass of :func:`_spread_selection` measures it, and the
    tree's own rounding is covered by a margin. The tree comes back for the
    next call.
    """
    from scipy.spatial import cKDTree  # ty: ignore[unresolved-import]

    points = np.column_stack([x, y])
    if tree is None:
        tree = cKDTree(points)
    radius = reach * (1.0 + 1e-9)
    sample = points[:: max(1, len(points) // 64)]
    if tree.query_ball_point(sample, radius, return_length=True).mean() > _SPREAD_BALL:
        return None, None, None, tree
    pairs = tree.query_pairs(radius, output_type="ndarray")
    sources = np.concatenate([pairs[:, 0], pairs[:, 1]])
    targets = np.concatenate([pairs[:, 1], pairs[:, 0]])
    # A row's order does not matter, and a stable sort of 16-bit keys is a
    # radix sort, several times faster than sorting the pairs' own indices.
    keys = sources.astype(np.uint16) if len(x) <= 65_535 else sources
    order = np.argsort(keys, kind="stable")
    sources, targets = sources[order], targets[order]
    dx = x[targets] - x[sources]
    dy = y[targets] - y[sources]
    distances = np.sqrt(dx * dx + dy * dy)
    rows = np.concatenate([[0], np.cumsum(np.bincount(sources, minlength=len(x)))])
    return rows, targets, distances, tree


def _inside(boundary, positions: np.ndarray) -> np.ndarray:
    """``positions`` with any a rounding error outside ``boundary`` moved inside.

    The cells of a partition without gaps do not share the exact noding of
    their outline, so a turbine on a cell's outer edge can test just outside.
    """
    from ..boundaries.base import _inset_interior, _project_inside

    if not len(positions):
        return positions
    outline = boundary.geometry
    return _project_inside(outline, _inset_interior(outline), positions)


def _combine_guides(guides):
    """One string guide holding every logical string of ``guides``, or ``None``."""
    guides = [guide for guide in guides if guide is not None]
    if not guides:
        return None
    from ..strings.base import _GeometryString

    return _GeometryString(
        tuple(logical_string for guide in guides for logical_string in guide._strings)
    )


def _type_label(value) -> str:
    """Public name of an attached object's type, for the farm repr."""
    from ..resources import _WindIOResource

    if isinstance(value, _WindIOResource):
        return "windIO resource"
    return type(value).__name__


def _turbines_for(turbines, start: int, stop: int):
    """Turbines for one component's position slice.

    A slice holding a single type collapses to that one turbine.
    """
    if not isinstance(turbines, tuple):
        return turbines
    sliced = turbines[start:stop]
    if sliced and len({id(item) for item in sliced}) == 1:
        return sliced[0]
    return sliced


def _boundary_pieces(boundary, count: int) -> tuple:
    """One boundary per recorded component, in the order generation filled them.

    Generation records one component per partition cell of the boundary, or
    per polygon without cells, so a different count means the farm's
    metadata and boundary no longer describe the same farm, and
    ``ValueError`` is raised.
    """
    from ..boundaries.base import _farm_pieces, _GeometryBoundary

    if boundary is None:
        return (None,) * count
    pieces = _farm_pieces(boundary)
    if len(pieces) != count:
        raise ValueError(
            f"farm metadata records {count} components but its boundary has "
            f"{len(pieces)} parts"
        )
    return tuple(_GeometryBoundary(piece) for piece in pieces)


def _same_geometry(first, second) -> bool:
    """Whether two boundaries or guides, or ``None``, are alike in type and shape."""
    if first is None or second is None:
        return first is second
    return type(first) is type(second) and bool(
        first.geometry.equals_exact(second.geometry, 0.0)
    )


[docs] @dataclass(init=False, repr=False, eq=False) class Farm: """One wind farm with turbine positions and their associated context. ``positions`` has shape ``(N, 2)`` in meters. ``turbines`` is one shared :class:`~oowl.turbines.Turbine` or one per position. ``metadata`` records how the farm was generated, and ``metrics`` caches :meth:`summary`. """ positions: np.ndarray boundary: "Boundary | None" metadata: dict = field(default_factory=dict) guide: "StringGuide | None" = None resource: "WindResource | Dimitrov | None" = None turbines: "Turbine | Sequence[Turbine] | None" = None metrics: dict = field(default_factory=dict, compare=False, repr=False) surface: "Surface | None" = None def __init__( self, positions: ArrayLike, boundary: "Boundary | None", *, metadata: dict | None = None, guide: "StringGuide | None" = None, resource: "WindResource | Dimitrov | None" = None, turbines: "Turbine | Sequence[Turbine] | None" = None, metrics: dict | None = None, surface: "Surface | None" = None, ) -> None: # Copy, so later edits to the caller's array leave the farm unchanged. points = np.array(positions, dtype=float) if points.size == 0: points = np.empty((0, 2), dtype=float) if points.ndim != 2 or points.shape[1] != 2: raise ValueError("positions must have shape (N, 2)") if not np.all(np.isfinite(points)): raise ValueError("positions must contain only finite values") # Positions come first, so the turbine count is checked against them. self.positions = points self.boundary = boundary if metadata is not None and not isinstance(metadata, dict): raise TypeError("metadata must be a dictionary") # Copy, so farms built from one template dict keep their own records. self.metadata = {} if metadata is None else dict(metadata) self.guide = guide self.resource = resource self.turbines = turbines self.metrics = {} if metrics is None else metrics self.surface = surface def __setattr__(self, name, value): if name == "boundary" and value is not None: from ..boundaries.base import Boundary if not isinstance(value, Boundary): raise TypeError( f"boundary must be a Boundary or None, not {type(value).__name__}" ) if name == "resource" and value is not None: if not hasattr(value, "_to_windio_energy_resource"): raise TypeError("resource must be a WindResource, Dimitrov or None") if name == "turbines" and value is not None: from ..turbines import Turbine if not isinstance(value, Turbine): if isinstance(value, (str, bytes)): raise TypeError( "turbines must be a Turbine or a sequence of Turbine " f"objects, not {type(value).__name__}" ) try: value = tuple(value) except TypeError as exc: raise TypeError( "turbines must be a Turbine or a sequence of Turbine objects" ) from exc # The type first, so a wrong kind of value is not reported as # a wrong count. if not all(isinstance(item, Turbine) for item in value): raise TypeError("turbines must contain only Turbine objects") positions = self.__dict__.get("positions") if positions is not None and len(value) != len(positions): raise ValueError( "number of turbines must match the number of positions" ) if name == "surface" and value is not None: from ..surfaces import Surface if not isinstance(value, Surface): raise TypeError("surface must be a Terrain, Bathymetry, or None") object.__setattr__(self, name, value) def __eq__(self, other: object) -> bool: """Whether ``other`` is a farm with the same positions and context. The boundary and guide compare by type and exact geometry, the other parts by their own ``==``. The cached ``metrics`` are not compared. """ if other.__class__ is not self.__class__: return NotImplemented return ( bool(np.array_equal(self.positions, other.positions)) and _same_geometry(self.boundary, other.boundary) and _same_geometry(self.guide, other.guide) and all( getattr(self, name) == getattr(other, name) for name in ("metadata", "resource", "turbines", "surface") ) ) def __repr__(self) -> str: # Key quantities only, not every position and metadata entry. parts = [f"n_turbines={self.n_turbines}"] if self.boundary is not None: parts.append(f"area={self.boundary.area:.6g}") record = self.metadata.get("sample") if isinstance(record, dict): layout = record.get("layout") if isinstance(layout, dict) and layout.get("algorithm"): parts.append(f"layout={layout['algorithm']}") parts.append(f"seed={record.get('root')}, index={record.get('index')}") for name in ("resource", "surface"): value = getattr(self, name) if value is not None: parts.append(f"{name}={_type_label(value)}") if self.turbines is not None: if isinstance(self.turbines, tuple): kinds = len({id(turbine) for turbine in self.turbines}) parts.append(f"turbines={kinds} type{'s' if kinds != 1 else ''}") else: parts.append(f"turbines={type(self.turbines).__name__}") return f"Farm({', '.join(parts)})" @property def n_turbines(self) -> int: """Number of turbine positions in this farm.""" return int(len(self.positions)) @property def parts(self) -> "tuple[Farm, ...]": """This farm's recorded pieces, each as a farm of its own. Pieces come from a disjoint boundary, a partition or a region. On a partitioned boundary ``farm.parts[i]`` is the farm in ``farm.boundary.parts[i]``. A farm of one piece returns ``(self,)``. A boundary whose part count differs from the recorded ``components`` raises ``ValueError``. """ components = self.metadata.get("components") if not components: return (self,) pieces = _boundary_pieces(self.boundary, len(components)) farms = [] for piece, record in zip(pieces, components): start, stop = int(record["start"]), int(record["stop"]) farms.append( Farm( positions=self.positions[start:stop], boundary=piece, metadata={**record, "kind": "part"}, resource=self.resource, turbines=_turbines_for(self.turbines, start, stop), surface=self.surface, ) ) return tuple(farms)
[docs] def summary( self, metrics: Iterable[str] | None = None, refresh: bool = False ) -> dict: """Overview of this farm as an ordered ``name -> value`` dict. ``metrics=None`` gives the turbine count, the mean nearest-neighbor distance and its coefficient of variation, the density and the area. Otherwise pass names from :data:`oowl.metrics.SCALAR_METRICS`. The result is cached on ``self.metrics`` until the positions or the boundary change. Pass ``refresh=True`` to recompute. """ from ..metrics import _metric_names from ..metrics import summary as _summary requested = _metric_names(metrics) # The positions are a public array, so a farm edited in place gave the # summary of its old positions. measured = self.__dict__.get("_measured") if ( not refresh and self.metrics and tuple(self.metrics) == requested and measured is not None and measured[1] is self.boundary and np.array_equal(measured[0], self.positions) ): return dict(self.metrics) self.metrics = _summary(self, requested) self._measured = (self.positions.copy(), self.boundary) return dict(self.metrics)
@property def x(self) -> np.ndarray: """X coordinates of all turbines in meters.""" return self.positions[:, 0] @property def y(self) -> np.ndarray: """Y coordinates of all turbines in meters.""" return self.positions[:, 1] @property def bounds(self) -> tuple[float, float, float, float]: """Turbine-position bounding box ``(min_x, min_y, max_x, max_y)`` in meters.""" if not len(self.positions): raise ValueError("empty farm has no turbine bounds") return ( float(np.min(self.x)), float(np.min(self.y)), float(np.max(self.x)), float(np.max(self.y)), ) @property def centroid(self) -> tuple[float, float]: """Mean turbine position ``(x, y)`` in meters.""" if not len(self.positions): raise ValueError("empty farm has no turbine centroid") center = np.mean(self.positions, axis=0) return (float(center[0]), float(center[1])) @property def extent(self) -> tuple[float, float]: """Turbine-position extent ``(width, height)`` in meters.""" minx, miny, maxx, maxy = self.bounds return (maxx - minx, maxy - miny) @property def coordinates(self) -> np.ndarray: """Return turbine coordinates as ``(x, y, z)`` rows in meters. ``z`` is the terrain elevation when the surface is terrain, else zero. """ z = np.zeros(self.n_turbines, dtype=float) if self.surface is not None and self.surface.kind == "terrain": z = self.surface.at(self.positions) return np.column_stack((self.positions, z))
[docs] def nearest_neighbor_distances(self) -> np.ndarray: """Distance in meters from each turbine to its nearest neighbor. Shape ``(N,)``, filled with ``nan`` when the farm has fewer than two turbines, as in :func:`oowl.metrics.nearest_neighbor_distances`. """ if len(self.positions) < 2: return np.full(len(self.positions), np.nan) return _nearest_neighbor_distances(self.positions)
[docs] def minimum_spacing(self) -> float: """Closest pair in meters, ``nan`` below two turbines. What a layout's ``minimum_spacing`` holds, so a farm sized by one reaches at least it. A sampled farm records it in ``metadata["sample"]["closest_pair"]``. """ distances = self.nearest_neighbor_distances() if len(distances) < 2: return float("nan") return float(np.min(distances))
[docs] def mean_spacing(self) -> float: """Mean nearest-neighbor spacing in meters, ``nan`` below two turbines.""" distances = self.nearest_neighbor_distances() if len(distances) < 2: return float("nan") return float(np.mean(distances))
[docs] def maximum_spacing(self) -> float: """Nearest-neighbor spacing of the loneliest turbine in meters. The largest distance from a turbine to its nearest neighbor, ``nan`` below two turbines, which the top end of a sampler's ``requirements={"spacing": (None, maximum)}`` bounds. """ distances = self.nearest_neighbor_distances() if len(distances) < 2: return float("nan") return float(np.max(distances))
[docs] def capacity(self) -> float: """Installed capacity in watts, the rated powers of the turbines summed. ``nan`` for a farm without turbines. """ from ..turbines import Turbine if self.turbines is None: return float("nan") if isinstance(self.turbines, Turbine): return float(self.turbines.rated_power) * self.n_turbines return float(sum(turbine.rated_power for turbine in self.turbines))
[docs] def coverage(self) -> float: """Share of the boundary's area inside the convex hull of the turbines. Near 1 when the turbines span their whole site and small when they bunch in one part of it. Turbines that enclose no area, fewer than three or in one line, cover none. ``nan`` for a farm without a boundary. """ if self.boundary is None: return float("nan") import shapely site = self.boundary.geometry hull = shapely.MultiPoint(self.positions).convex_hull return float(hull.intersection(site).area / site.area)
[docs] def save(self, path: str | Path, **kwargs: Any) -> Path: """Write this farm to *path* as a windIO document. Wraps :func:`oowl.save` and takes the same keyword arguments (``name``, ``crs``, ``energy_resource``, ``document``). ``document`` is ``"wind_energy_system"`` (default), ``"wind_farm"`` (the default for a farm without a boundary) or ``"site"``. Returns the written path. """ from .._windio import save return save(self, path, **kwargs)
[docs] def plot( self, ax: "matplotlib.axes.Axes | None" = None, **kwargs: Any, ) -> "matplotlib.axes.Axes": """Plot the farm and return its Matplotlib axes. Extra keyword arguments go to Matplotlib's ``scatter`` for the turbines, such as ``s`` and ``color``. Turbines default to the first color of the style's property cycle. ``facecolor``, ``edgecolor``, ``alpha`` and ``linewidth`` style the boundary. ``regions`` shades exclusion zones, and ``regions=False`` punches them out. """ from ..plotting import _plot_farm return _plot_farm(self, ax=ax, **kwargs)
[docs] class LayoutAlgorithm(_ArgumentRepr, ABC): """Base class for one turbine-placement method applied to one farm. A boundary fixes the farm's area, so a layout inside one takes one more quantity to size the farm, ``n_turbines`` or a ``density`` mapping that names one of these. - ``n_turbines``, the exact count of turbines. - ``{"area_per_turbine": square meters}``, the boundary's area per turbine, which rounds to the nearest count of at least one. - ``{"minimum_spacing": meters}``, the most turbines that fit with no pair closer than it. :class:`~oowl.layouts.Uniform` and :class:`~oowl.layouts.Sobol` hold no minimum, so a random farm is sized by its single closest pair and varies widely between seeds. - ``{"mean_spacing": meters}``, the most turbines whose mean nearest-neighbor distance, :meth:`~oowl.Farm.mean_spacing`, is at least it over the whole farm, the turbines shared over its parts by area. - ``{"maximum_spacing": meters}``, the fewest turbines that leave none farther than it from its nearest neighbor, :meth:`~oowl.Farm.maximum_spacing`, over the whole farm. A count and a density over-constrain the boundary's area, so a farm of fixed count is held to a spacing by a sampler's ``requirements`` instead. A spacing that fits fewer than two turbines gives a farm of the one that fits, recorded as ``metadata["fits_one_turbine"]``. A layout with neither is a template for :class:`~oowl.layouts.Mixture` or a sampler, which then supplies one. ``seed`` replays the placement and every unseeded modifier added with :meth:`modify`. """ def __init__( self, n_turbines: int | None = None, *, density: dict[str, float] | None = None, fitter: Fitter | None = None, seed: int | np.random.Generator | None = None, ): if n_turbines is not None: n_turbines = _validate_integer("n_turbines", n_turbines) self.n_turbines = n_turbines given = _layout_density(density) self.area_per_turbine = given.get("area_per_turbine") self.minimum_spacing = given.get("minimum_spacing") self.mean_spacing = given.get("mean_spacing") self.maximum_spacing = given.get("maximum_spacing") self.fitter = fitter self.seed = pin_seed(seed) self.modifiers: tuple[LayoutModifier, ...] = () @property def density(self) -> "dict[str, float] | None": """The density sizing this layout as ``{name: value}``, or ``None``.""" given = { name: getattr(self, name) for name in _DENSITIES if getattr(self, name) is not None } return given or None def _sizing(self) -> str | None: """The one quantity sizing this layout, or ``None`` for a template. Raises ``ValueError`` when several are set. """ given = [name for name in _SIZING if getattr(self, name) is not None] if len(given) > 1: floor = ( ", or keep the spacing as a floor the farm is checked against " "with Sampler(requirements={'spacing': (minimum, None)})" if "minimum_spacing" in given else "" ) raise ValueError( f"{' and '.join(given)} each size a layout, so give one of them" + floor ) return given[0] if given else None @property def _required_n(self) -> int: """``n_turbines`` once a count is known to be set.""" if self.n_turbines is None: raise ValueError("n_turbines is required for generation") return self.n_turbines
[docs] def generate(self, boundary: "Boundary | None" = None) -> Farm: """Generate this layout, optionally fitted to ``boundary``. A multipart boundary is filled part by part, each part receiving its share of turbines by area. The parts are the partition cells of a partitioned boundary, in its ``parts`` order, else the polygons of its shape. ``n_turbines`` counts the turbines left after the modifiers, so the layout places the ones a removal drops on top. A spacing is recorded in the farm's metadata under its own name. With ``minimum_spacing`` a layout that fills directly fills the whole boundary at once, holding its modifiers to the spacing too. Others search over counts, each part on its own, later parts giving way where they meet earlier ones closer than the spacing. ``mean_spacing`` searches over counts of the whole farm, shared over its parts by area. Raises ``TypeError`` when ``boundary`` is not a boundary, and ``ValueError`` when no quantity or several size the layout, when the boundary was never given an area, or when it cannot place its turbines. """ from ..boundaries.base import Boundary if boundary is not None and not isinstance(boundary, Boundary): raise TypeError( f"boundary must be a Boundary or None, not {type(boundary).__name__}" ) if boundary is not None and not boundary._sized: raise _unsized_error(boundary) # A boundary fixes the area, so one more quantity sizes the farm. sizing = self._sizing() if boundary is not None else None if boundary is not None and sizing in _SPACINGS: return self._filled_to(boundary, sizing, getattr(self, sizing)) if boundary is not None and self.area_per_turbine is not None: area = float(boundary.area) count = max(1, round(area / self.area_per_turbine)) farm = self._with_count(count).generate(boundary) farm.metadata["area_per_turbine"] = self.area_per_turbine return farm if self.n_turbines is None: if boundary is None: raise ValueError("a layout without a boundary needs n_turbines") raise ValueError( "n_turbines or a density is required for direct generation, " "layouts without one can be used inside Mixture or a Sampler" ) root_seed = resolve_seed(self.seed) core_seed = derive_seed(root_seed, "placement") # ``n_turbines`` is the count after the modifiers, so the turbines # they remove are drawn on top of it. drawn = copy(self) drawn.n_turbines = _placed_for(self.modifiers, self.n_turbines) if boundary is None: result = drawn._generate_standalone(as_rng(core_seed), root_seed) else: from ..boundaries.base import _farm_pieces pieces = _farm_pieces(boundary) if len(pieces) > 1: result = drawn._generate_multipart( boundary, pieces, root_seed, core_seed ) else: result = drawn._generate_single(boundary, as_rng(core_seed), root_seed) farm = self._modified(result, root_seed) if farm.n_turbines != self.n_turbines: raise ValueError( f"the modifiers of this {self._layout_name} left {farm.n_turbines} " f"turbines, not the n_turbines={self.n_turbines} it was given" ) farm.metadata["requested_n"] = self.n_turbines return farm
def _filled_to(self, boundary: "Boundary", sizing: str, spacing: float) -> Farm: """The farm holding ``boundary`` to ``spacing``, a minimum, mean or maximum. Where fewer than two turbines fit, the farm holds the one that does. """ try: if sizing == "minimum_spacing": farm = self._spaced(boundary, spacing) elif sizing == "maximum_spacing": farm = self._fewest(boundary, spacing) else: farm = self._fill(boundary, spacing, mean=True) except _NoFitError: root_seed = resolve_seed(self.seed) single = self._with_count(1) single.modifiers = () single.seed = root_seed minimum = spacing if sizing == "minimum_spacing" else None farm = self._modified(single.generate(boundary), root_seed, minimum) farm.metadata["fits_one_turbine"] = True farm.metadata[sizing] = spacing return farm def _fewest(self, boundary: "Boundary", spacing: float) -> Farm: """The fewest turbines in ``boundary`` that leave none farther than ``spacing``. Searches exact counts of the whole farm without the modifiers, which then run once on the farm found, held to no spacing, as for a mean. """ pinned = copy(self) pinned.seed = resolve_seed(self.seed) bare = copy(pinned) bare.modifiers = () farm = _fewest_turbines_for_ceiling( lambda count: bare._with_count(count).generate(boundary), spacing, float(boundary.area), self._layout_name, ) return self._modified(farm, pinned.seed) def _modified( self, result: Farm, root_seed: int, minimum_spacing: float | None = None ) -> Farm: """``result`` through this layout's modifiers, each from its own seed. With ``minimum_spacing``, the farm of a minimum-spacing fill, each modifier keeps its pairs that far apart, and ``ValueError`` names one that does not. A mean spacing is a density, which no modifier is held to. """ for index, modifier in enumerate(self.modifiers): effective_seed = ( modifier.seed if modifier.seed is not None else derive_seed(root_seed, "modifiers", index) ) if minimum_spacing is not None: modifier = modifier._held_apart(minimum_spacing) result = modifier.apply(result, as_rng(effective_seed), effective_seed) # The margin covers rounding in the distances a modifier measured. if minimum_spacing is not None and _closest_pair(result) < ( minimum_spacing * (1.0 - 1e-9) ): raise ValueError( f"{type(modifier).__name__} brought turbines closer than" f" minimum_spacing={minimum_spacing:g} m" ) return result @property def _layout_name(self) -> str: """How errors name this layout, ``"Grid layout"`` for a Grid.""" return f"{type(self).__name__} layout" #: Whether :meth:`_fill_positions` places turbines at a spacing directly. _fills_directly = False def _fill_positions( self, boundary: "Boundary", spacing: float, rng: np.random.Generator ) -> tuple[np.ndarray, dict]: """Turbines filling the whole of ``boundary`` no closer than ``spacing``. Returns the positions, in any order, and the fit record. A layout that sets :attr:`_fills_directly` implements it. """ raise NotImplementedError def _spaced(self, boundary: "Boundary", spacing: float) -> Farm: """The farm filling ``boundary`` with no pair closer than ``spacing``. A layout that fills directly does so once over the whole boundary, so pairs across its parts keep the spacing too, and searches exact counts where that fill holds fewer than two turbines. Otherwise exact counts are searched. The modifiers run once on the filled farm. """ if not self._fills_directly: return self._fill(boundary, spacing) root_seed = resolve_seed(self.seed) rng = as_rng(derive_seed(root_seed, "placement")) positions, fit = self._fill_positions(boundary, spacing, rng) # A part's edge on the farm's outline does not share its exact noding, # so a turbine filled on it can lie a rounding error outside the farm. # Rounding must not leave a pair closer than asked either. positions = _kept_apart( _inside(boundary, np.asarray(positions, dtype=float).reshape(-1, 2)), spacing, ) if len(positions) < 2: # A fill grows from one scale, start or center and can miss where # the exact counts fit, their pattern placed to suit each count. return self._fill(boundary, spacing) return self._modified( self._filled_farm(boundary, positions, fit, root_seed), root_seed, spacing ) def _filled_farm(self, boundary, positions, fit, root_seed: int) -> Farm: """The farm of filled ``positions``, one component per part of ``boundary``.""" from ..boundaries.base import _farm_pieces record = { "algorithm": type(self).__name__, "seed": int(root_seed), "requested_n": None, } pieces = _farm_pieces(boundary) if len(pieces) == 1: metadata = { "kind": "single", **record, "achieved_n": int(len(positions)), "fit": fit, "modifiers": [], } return Farm(positions, boundary, metadata=metadata) groups = _grouped_by_piece(pieces, positions) components = [] start = 0 for index, (piece, group) in enumerate(zip(pieces, groups)): components.append( { "component_index": index, "area": float(piece.area), "n_turbines": int(len(group)), "start": start, "stop": start + len(group), "seed": int(root_seed), "layout": {"kind": "single", **record, "fit": fit}, } ) start += len(group) ordered = positions[np.concatenate(groups)] metadata = { "kind": "multipart", **record, "achieved_n": int(len(ordered)), "fit": fit, "components": components, "modifiers": [], } return Farm(ordered, boundary, metadata=metadata) def _fill(self, boundary: "Boundary", spacing: float, mean: bool = False) -> Farm: """The most turbines in ``boundary`` held to ``spacing``. ``mean`` holds their mean nearest-neighbor distance to it, else their closest pair. This searches over exact counts without the modifiers, every try and the first estimate drawn from one seed, and runs the modifiers once on the farm found, as after a direct fill. For a closest pair each part of a multipart boundary is searched on its own. A mean is searched over the whole farm, its count shared over the parts by area, so neighbors across the parts count toward it. """ from ..boundaries.base import _farm_pieces pinned = copy(self) pinned.seed = resolve_seed(self.seed) bare = copy(pinned) bare.modifiers = () pieces = _farm_pieces(boundary) if len(pieces) > 1 and not mean: farm = bare._filled_by_part(boundary, pieces, spacing) else: farm = _most_turbines_for_spacing( lambda count: bare._with_count(count).generate(boundary), spacing, float(boundary.area), bare._fill_estimate(boundary, spacing, mean), self._layout_name, _mean_nearest if mean else _closest_pair, ) return self._modified(farm, pinned.seed, None if mean else spacing) def _filled_by_part(self, boundary, pieces, spacing: float) -> Farm: """Each of ``pieces`` searched on its own, later ones giving way to earlier. A count shared over parts by area puts turbines either side of a shared edge, so over a partition without gaps nearly every count failed. A part too small for two turbines holds none. """ from ..boundaries.base import _GeometryBoundary root_seed = resolve_seed(self.seed) placed = np.empty((0, 2)) guides = [] components = [] for index, piece in enumerate(pieces): part = _GeometryBoundary(piece) child = copy(self) child.seed = derive_seed(root_seed, "part", index) try: farm = _most_turbines_for_spacing( lambda count: child._with_count(count).generate(part), spacing, float(piece.area), child._fill_estimate(part, spacing), self._layout_name, ) except ValueError: farm = None positions = ( np.empty((0, 2)) if farm is None else _inside(boundary, farm.positions) ) positions = _apart_from(positions, placed, spacing) if farm is not None: guides.append(farm.guide) components.append( { "component_index": index, "area": float(piece.area), "n_turbines": int(len(positions)), "start": len(placed), "stop": len(placed) + len(positions), "seed": int(child.seed), "given_way": 0 if farm is None else farm.n_turbines - len(positions), "layout": None if farm is None else farm.metadata, } ) placed = np.vstack([placed, positions]) if len(placed) < 2: raise _no_fit_error(spacing, float(boundary.area), self._layout_name) return Farm( placed, boundary, metadata={ "kind": "multipart", "algorithm": type(self).__name__, "seed": root_seed, "requested_n": None, "achieved_n": int(len(placed)), "components": components, "modifiers": [], }, guide=_combine_guides(guides), ) def _with_count(self, count: int) -> Self: """This layout drawing exactly ``count`` turbines instead of filling.""" layout = copy(self) for name in _SIZING: setattr(layout, name, None) layout.n_turbines = count return layout def _fill_estimate( self, boundary: "Boundary", spacing: float, mean: bool = False ) -> int | None: """A first count for the search, or ``None`` to estimate one by a trial draw. ``mean`` asks for a mean nearest-neighbor ``spacing``, else a minimum. """ return None @abstractmethod def _generate_single(self, boundary, rng, seed: int) -> Farm: """Generate one exact-count layout inside one polygonal farm.""" def _generate_standalone(self, rng, seed: int) -> Farm: raise ValueError(f"{type(self).__name__} requires a boundary for generation") def _single_result(self, positions, boundary, seed, fit, guide=None) -> Farm: positions = np.asarray(positions, dtype=float).reshape(-1, 2) if len(positions) != self.n_turbines: raise ValueError( f"generated {len(positions)} turbines; expected {self.n_turbines}" ) return Farm( positions, boundary, metadata={ "kind": "single", "algorithm": type(self).__name__, "seed": int(seed), "requested_n": self.n_turbines, "achieved_n": int(len(positions)), "fit": fit, "modifiers": [], }, guide=guide, ) def _allocate_parts(self, areas: np.ndarray) -> tuple[np.ndarray, list[dict]]: """Turbines per part, and any extra record each part's layout needs.""" counts = _allocate_counts(self._required_n, areas) return counts, [{} for _ in counts] def _part_layout(self, count: int, record: dict) -> Self: """The unmodified copy of this layout that fills one part.""" child = copy(self) child.n_turbines = count child.modifiers = () return child def _multipart_metadata(self) -> dict: """Farm-level entries a subclass adds to the multipart record.""" return {} def _place_parts(self, pieces, child_seeds) -> list[tuple[int, dict, Farm | None]]: """Fill each piece with its area share, as ``(count, record, farm)``. A piece of several polygons, such as a cell split by a setback, is filled as a multipart farm of its own. Each piece is laid out clear of the others by half its spacing, or by the most that leaves it half its area, recorded as ``clearance``, so the cells of a partition do not place turbines either side of the edge they share. """ areas = np.asarray([piece.area for piece in pieces], dtype=float) counts, records = self._allocate_parts(areas) parts = [] for index, (piece, count, record, child_seed) in enumerate( zip(pieces, counts, records, child_seeds) ): count = int(count) result = None record = {**record, "clearance": 0.0} if count: child = self._part_layout(count, record) cleared, clearance = _cleared( pieces, index, _lattice_clearance(piece, count) ) try: # A cell too tight for its turbines once cleared is laid # out whole, as the record shows. result, clearance = _laid_out_clear( piece, cleared, clearance, partial(child._laid_out_in, seed=child_seed), ) except (TypeError, ValueError) as exc: raise type(exc)( f"Could not generate polygon component {index} " f"with {count} turbines: {exc}" ) from exc record["clearance"] = float(clearance) parts.append((count, record, result)) return parts def _laid_out_in(self, site: "Boundary", seed: int) -> Farm: """This exact-count layout in ``site``, part by part if it has several.""" from ..boundaries.base import _farm_pieces polygons = _farm_pieces(site) if len(polygons) > 1: return self._generate_multipart(site, polygons, seed, seed) return self._generate_single(site, as_rng(seed), seed) def _generate_multipart( self, boundary, pieces, root_seed: int, child_root: int ) -> Farm: """Fill ``pieces`` of ``boundary`` in order, one component each.""" polygons = list(pieces) child_seeds = [ derive_seed(child_root, "part", index) for index in range(len(polygons)) ] parts = self._place_parts(polygons, child_seeds) positions = [] guides = [] components = [] start = 0 for index, (polygon, child_seed, (count, record, result)) in enumerate( zip(polygons, child_seeds, parts) ): stop = start + count if result is not None: positions.append(result.positions) guides.append(result.guide) components.append( { "component_index": index, "area": float(polygon.area), "n_turbines": count, **record, "start": start, "stop": stop, "seed": int(child_seed), "layout": None if result is None else result.metadata, } ) start = stop combined = np.concatenate(positions) if positions else np.empty((0, 2)) combined = _inside(boundary, combined) return Farm( combined, boundary, metadata={ "kind": "multipart", "algorithm": type(self).__name__, "seed": int(root_seed), "requested_n": self.n_turbines, "achieved_n": int(len(combined)), **self._multipart_metadata(), "components": components, "modifiers": [], }, guide=_combine_guides(guides), )
[docs] def modify(self, *modifiers: "LayoutModifier") -> Self: """Return a copy with post-generation modifiers appended in order.""" from ..modifiers import LayoutModifier for modifier in modifiers: if not isinstance(modifier, LayoutModifier): raise TypeError("layout modifiers must be LayoutModifier objects") layout = copy(self) layout.modifiers = self.modifiers + tuple(modifiers) return layout
def __repr__(self) -> str: text = super().__repr__() if self.modifiers: text += f".modify({', '.join(map(repr, self.modifiers))})" return text