Source code for oowl.layouts.composites

"""Spatial compositions of layout algorithms."""

from __future__ import annotations

from copy import copy
from typing import TYPE_CHECKING

import numpy as np

from .._random import (
    as_rng,
    derive_seed,
    farthest_point_sample,
    resolve_seed,
    spawn_seeds,
)
from .._validation import _validate_real_array
from .base import (
    _SIZING,
    Farm,
    LayoutAlgorithm,
    _allocate_counts,
    _combine_guides,
    _inside,
    _kept_apart_mask,
    _laid_out_clear,
    _lattice_clearance,
    _no_fit_error,
    _NoFitError,
    _within_share,
)
from .uniform import _uniform_points

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

    from ..boundaries.base import Boundary

__all__ = ["Mixture"]

#: Uniform points drawn per cell for the farthest-point sampling of its site.
#: More let the spread sites reach further into the corners of the farm.
_SITE_SAMPLES = 64

#: Share of its own lattice spacing that a cell keeps between its turbines
#: and those of the cells laid out before it.
_SEAM_SEPARATION = 0.8

#: Share of its own lattice spacing that a cell's domain reaches past its
#: seams into the cells laid out before it, to fill the strip their
#: templates leave short of the seam.
_SEAM_REACH = 1.0

#: Share of its own lattice spacing that a cell leaves along its seams with
#: the cells laid out after it, which they keep their separation from less
#: far. Over 240 farms it lowered the largest empty circle from 1.67 to 1.48
#: spacings and moved the other measures by under 2 %.
_SEAM_YIELD = 0.2

#: Segments per quarter circle of the disks cut around placed turbines.
_DISK_SEGMENTS = 8


def _validate_layouts(layouts) -> tuple[LayoutAlgorithm, ...]:
    """At least two layout templates sized by none of the sizing quantities."""
    layouts = tuple(layouts)
    if len(layouts) < 2:
        raise ValueError("Mixture requires at least two layout templates")
    for index, layout in enumerate(layouts):
        if not isinstance(layout, LayoutAlgorithm):
            raise TypeError(
                f"layout {index} must be a LayoutAlgorithm, not {type(layout).__name__}"
            )
        for name in _SIZING:
            if getattr(layout, name) is not None:
                omitted = name if name == "n_turbines" else "its density"
                raise ValueError(
                    f"layout {index} must omit {omitted} when used as a template"
                )
    return layouts


def _validate_weights(values, count):
    """Weights normalized to sum to one, equal when ``values`` is ``None``.

    A zero weight marks an unused source. At least one must be positive.
    """
    if values is None:
        weights = np.ones(count, dtype=float)
    else:
        weights = _validate_real_array("weights", values)
        if weights.shape != (count,):
            raise ValueError("weights must match the number of layouts")
        if not np.all(np.isfinite(weights)) or np.any(weights < 0):
            raise ValueError("weights must be finite and >= 0")
        if not np.any(weights > 0):
            raise ValueError("weights must include at least one positive value")
    # Scaling by the largest first keeps the sum finite for any finite weights.
    weights = weights / weights.max()
    return tuple(float(value) for value in weights / weights.sum())


def _component_source_counts(component_counts, source_counts):
    """Allocate one integer matrix while preserving both exact margins."""
    remaining = np.asarray(source_counts, dtype=int).copy()
    matrix = np.zeros((len(component_counts), len(source_counts)), dtype=int)
    nonempty = np.flatnonzero(np.asarray(component_counts) > 0)
    for position, component_index in enumerate(nonempty):
        count = int(component_counts[component_index])
        if position == len(nonempty) - 1:
            allocated = remaining.copy()
        else:
            allocated = _allocate_counts(count, remaining)
        matrix[component_index] = allocated
        remaining -= allocated
    return matrix


def _source_cells(geometry, weights, rng):
    """Power cells of ``geometry`` with areas in proportion to ``weights``.

    Farthest-point sampling over uniform points inside ``geometry`` spreads
    one site per cell to the far reaches of the farm, and power weights fitted
    to those sites give each cell its share of the area, as
    :class:`~oowl.boundaries.partition.PowerDiagram` fits them. Cells grown
    from sites far apart are compact, mostly cut from the farm by straight
    seams. As there, the sites are drawn again when a fit fails, and the cells
    are snapped to one fine grid, so their shared edges match and clearing a
    cell of its neighbors does not lose one in their union. Returns the cells
    in the order of ``weights`` and a JSON-compatible record of them.
    """
    import shapely

    from ..boundaries.partition import (
        _MAX_SITE_FIT_ATTEMPTS,
        _fit_power_cells,
        _partition_precision,
    )

    target = np.asarray(weights, dtype=float)
    target = target / target.sum()
    area = float(geometry.area)
    for attempt in range(1, _MAX_SITE_FIT_ATTEMPTS + 1):
        points = _uniform_points(geometry, _SITE_SAMPLES * len(target), rng)
        sites = farthest_point_sample(points, len(target), rng)
        try:
            cells, power_weights, fit = _fit_power_cells(geometry, sites, target)
        except ValueError:
            if attempt == _MAX_SITE_FIT_ATTEMPTS:
                raise
            continue
        precision = _partition_precision(geometry)
        cells = [shapely.set_precision(cell, precision) for cell in cells]
        areas = [float(cell.area) for cell in cells]
        return cells, {
            "sites": sites.tolist(),
            "power_weights_m2": power_weights.tolist(),
            "areas": areas,
            "target_area_fractions": target.tolist(),
            "area_fractions": [value / area for value in areas],
            "site_attempts": attempt,
            "fit": fit,
        }


def _reaching(cells, reach: float):
    """The last of ``cells`` and the parts of the others within ``reach`` of it.

    The cells tile a farm, so the shape stays inside it.
    """
    import shapely

    cell = cells[-1]
    if len(cells) == 1:
        return cell
    domain = cell.buffer(reach).intersection(shapely.union_all(cells))
    parts = shapely.get_parts(domain)
    # Edges the cells share may leave lines and points in the intersection.
    return shapely.union_all(
        parts[shapely.get_type_id(parts) == shapely.GeometryType.POLYGON]
    )


def _around(cell, domain, placed: np.ndarray, radius: float):
    """``domain`` without the disks of ``radius`` around the ``placed`` turbines.

    ``domain`` holds ``cell``, and only the parts of the shape that meet the
    cell are kept, so the turbines do not stray into gaps among those placed.
    Returns the shape and ``radius``, or ``domain`` and ``0.0`` when no
    placed turbine lies that close. Each disk is a polygon whose edges touch
    its circle, so no point of the shape lies closer than ``radius`` to one.
    """
    import shapely

    if not len(placed):
        return domain, 0.0
    points = shapely.points(placed)
    near = shapely.distance(domain, points) < radius
    if not near.any():
        return domain, 0.0
    disks = shapely.buffer(
        points[near],
        radius / np.cos(np.pi / (4 * _DISK_SEGMENTS)),
        quad_segs=_DISK_SEGMENTS,
    )
    parts = shapely.get_parts(domain.difference(shapely.union_all(disks)))
    return shapely.union_all(parts[shapely.intersects(parts, cell)]), radius


def _filled(layout: LayoutAlgorithm, site: Boundary) -> Farm:
    """``layout`` filled into ``site`` at its spacing, one turbine if two do not fit."""
    try:
        return layout.generate(site)
    except _NoFitError:
        return layout._with_count(1).generate(site)


def _inside_by_cell(boundary, positions) -> list[np.ndarray]:
    """Each array of ``positions``, any a rounding error outside ``boundary`` moved in.

    A cell's edge on the outline of the farm does not share its exact noding.
    """
    moved = _inside(boundary, np.concatenate(positions))
    return np.split(moved, np.cumsum([len(values) for values in positions])[:-1])


[docs] class Mixture(LayoutAlgorithm): """Combine layout methods in weighted regions of one farm. Every template in ``layouts`` must omit ``n_turbines`` and ``density``. The farm is split into one compact cell per source, a power diagram whose cell areas follow ``weights`` exactly, equal by default, and each template is laid out in its own cell. The weights also fix each source's exact turbine count, and a source allotted no turbines takes no cell. The cells are laid out in turn. A later cell keeps 0.8 of its own spacing from the turbines already placed and reaches one spacing past its seams, so it fills the strip an earlier cell leaves short of a seam, and an earlier cell leaves 0.2 of its spacing free along the later ones. Turbines either side of a seam stay at least 0.8 of the smaller spacing apart. A cell keeps at least half its area clear, and one its template cannot fill once cleared is laid out whole, as ``laid_out_whole`` in its cell record shows, and only its turbines may come closer. With ``density={"minimum_spacing": meters}`` each cell is filled at it, a later one no closer to the turbines already placed than the spacing. A cell too small for two turbines at the spacing takes one, and one left no room takes none. A template given its own seed keeps it, and the others draw from seeds spawned from the mixture's. ``metadata["origin"]`` gives each turbine's source, ``parts`` each source's layout and seed and ``cells`` the power sites and weights, the cell areas, the clearance each cell kept and whether it was laid out whole. """ def __init__( self, layouts: Sequence[LayoutAlgorithm], n_turbines: int | None = None, *, density: dict[str, float] | None = None, weights: Sequence[float] | None = None, seed: int | np.random.Generator | None = None, ): self.layouts = _validate_layouts(layouts) self.weights = _validate_weights(weights, len(self.layouts)) self._fixed_counts = None super().__init__( n_turbines, density=density, seed=seed, ) def _source_counts(self) -> np.ndarray: """Each source's turbines, a part's share of them inside a multipart farm.""" if self._fixed_counts is not None: return np.asarray(self._fixed_counts, dtype=int) return _allocate_counts(self._required_n, self.weights) def _source_seeds(self, seed: int) -> list[int]: """Each source's seed, its own if it was given one, else spawned from ``seed``.""" spawned = spawn_seeds(seed, len(self.layouts)) return [ int(spawned[source]) if template.seed is None else resolve_seed(template.seed) for source, template in enumerate(self.layouts) ] def _mixture(self, seed: int, source_seeds, requested_n, farms, positions, cells): """The positions, metadata and guide of one mixture of its sources' farms. ``source_seeds`` are the seeds the sources drew with. ``farms`` and ``positions`` map each source given a cell to its farm and the positions kept of it, which a fill records as ``given_way``. """ ordered = [] origin = [] parts = [] start = 0 for source, template in enumerate(self.layouts): farm = farms.get(source) values = positions.get(source, np.empty((0, 2))) stop = start + len(values) part = { "index": source, "algorithm": type(template).__name__, "weight": self.weights[source], "n_turbines": int(len(values)), "start": start, "stop": stop, "seed": source_seeds[source], "layout": None if farm is None else farm.metadata, } if self.minimum_spacing is not None: part["given_way"] = 0 if farm is None else farm.n_turbines - len(values) parts.append(part) ordered.append(values) origin.extend([source] * len(values)) start = stop metadata = { "kind": "mixture", "algorithm": "Mixture", "seed": int(seed), "requested_n": requested_n, "achieved_n": start, "weights": list(self.weights), "counts": [part["n_turbines"] for part in parts], "origin": origin, "cells": cells, "parts": parts, "modifiers": [], } guide = _combine_guides([farm.guide for farm in farms.values()]) return np.concatenate(ordered), metadata, guide def _generate_single(self, boundary, rng, seed): """One layout of each source in its cell, the cells laid out in order. A later cell reaches ``_SEAM_REACH`` of its lattice spacing into the earlier ones and keeps ``_SEAM_SEPARATION`` of it from their turbines, so its template fills in up to them, also in the strip an earlier template leaves short of the seam. An earlier cell leaves ``_SEAM_YIELD`` of its spacing free along the later cells. """ counts = self._source_counts() if len(counts) != len(self.layouts) or int(counts.sum()) != self._required_n: raise ValueError("mixture source counts do not match n_turbines") seeds = self._source_seeds(seed) active = np.flatnonzero(counts > 0) cells, record = _source_cells( boundary.geometry, np.asarray(self.weights)[active], rng ) farms = {} clearances = [] whole = [] placed = np.empty((0, 2)) for index, (cell, source) in enumerate(zip(cells, active.tolist())): count = int(counts[source]) layout = copy(self.layouts[source]) layout.n_turbines = count layout.seed = seeds[source] spacing = 2.0 * _lattice_clearance(cell, count) domain = _reaching(cells[: index + 1], _SEAM_REACH * spacing) if index + 1 < len(cells): import shapely # The cells after it take the strip along their seams. later = shapely.union_all(cells[index + 1 :]) domain = domain.difference(later.buffer(_SEAM_YIELD * spacing)) radius = _SEAM_SEPARATION * spacing region, wanted = _around(cell, domain, placed, radius) region, clearance = _within_share( cell, region, wanted, lambda distance: _around(cell, domain, placed, distance)[0], ) try: farm, clearance = _laid_out_clear( cell, region, clearance, layout.generate ) except (TypeError, ValueError) as exc: raise type(exc)( f"Could not lay out mixture source {source} " f"with {count} turbines in its cell: {exc}" ) from exc farms[source] = farm clearances.append(float(clearance)) # Turbines lay near it, but clearing it of them took too much. whole.append(bool(wanted) and not clearance) placed = np.concatenate([placed, farm.positions]) moved = _inside_by_cell(boundary, [farm.positions for farm in farms.values()]) cells_record = { "sources": active.tolist(), **record, "clearances": clearances, "laid_out_whole": whole, } combined, metadata, guide = self._mixture( seed, seeds, self.n_turbines, farms, dict(zip(farms, moved)), cells_record, ) return Farm(combined, boundary, metadata=metadata, guide=guide) def _spaced(self, boundary: Boundary, spacing: float) -> Farm: """Every cell filled with its template at a minimum ``spacing``, in order. Each part of a multipart boundary is split into cells of its own. A later cell is filled around the turbines already placed, at least the spacing from them, and reaches ``_SEAM_REACH`` of the spacing into the earlier cells of its part, so seams and parts keep it and stay dense on both sides. A turbine that rounding leaves closer gives way. The modifiers run once on the filled farm. """ from ..boundaries.base import _farm_pieces, _GeometryBoundary root_seed = resolve_seed(self.seed) pieces = _farm_pieces(boundary) if len(pieces) == 1: seeds = [root_seed] rngs = [as_rng(derive_seed(root_seed, "placement"))] else: seeds = [ derive_seed(root_seed, "part", index) for index in range(len(pieces)) ] rngs = [as_rng(seed) for seed in seeds] active = np.flatnonzero(np.asarray(self.weights) > 0).tolist() weights = np.asarray(self.weights)[active] partitions = [ _source_cells(piece, weights, rng) for piece, rng in zip(pieces, rngs) ] cells = [cell for piece_cells, _ in partitions for cell in piece_cells] part_seeds = [self._source_seeds(seed) for seed in seeds] farms = [] clearances = [] placed = np.empty((0, 2)) for index, cell in enumerate(cells): part, slot = divmod(index, len(active)) source = active[slot] layout = copy(self.layouts[source]) layout.minimum_spacing = spacing layout.seed = part_seeds[part][source] domain = _reaching(partitions[part][0][: slot + 1], _SEAM_REACH * spacing) region, clearance = _around(cell, domain, placed, spacing) # Turbines placed around it can leave a cell no room at all. farm = ( None if region.is_empty else _filled(layout, _GeometryBoundary(region)) ) farms.append(farm) clearances.append(float(clearance)) if farm is not None: placed = np.concatenate([placed, farm.positions]) filled = _inside_by_cell( boundary, [np.empty((0, 2)) if farm is None else farm.positions for farm in farms], ) kept = np.split( _kept_apart_mask(np.concatenate(filled), spacing), np.cumsum([len(values) for values in filled])[:-1], ) if sum(int(mask.sum()) for mask in kept) < 2: raise _no_fit_error(spacing, float(boundary.area), self._layout_name) mixtures = [] for part, (seed, (_, record)) in enumerate(zip(seeds, partitions)): cell_slice = slice(part * len(active), (part + 1) * len(active)) cells_record = { "sources": active, **record, "clearances": clearances[cell_slice], # A fill lays every cell out around the turbines before it. "laid_out_whole": [False] * len(active), } part_farms = { source: farm for source, farm in zip(active, farms[cell_slice]) if farm is not None } positions = { source: values[mask] for source, values, mask in zip( active, filled[cell_slice], kept[cell_slice] ) } mixtures.append( self._mixture( seed, part_seeds[part], None, part_farms, positions, cells_record ) ) if len(pieces) == 1: positions, metadata, guide = mixtures[0] farm = Farm(positions, boundary, metadata=metadata, guide=guide) else: farm = self._filled_parts(boundary, pieces, seeds, root_seed, mixtures) return self._modified(farm, root_seed, spacing) def _filled_parts(self, boundary, pieces, seeds, root_seed, mixtures) -> Farm: """The multipart farm of each part's filled mixture, one component each.""" components = [] start = 0 for index, (piece, seed, (positions, metadata, _)) in enumerate( zip(pieces, seeds, mixtures) ): components.append( { "component_index": index, "area": float(piece.area), "n_turbines": int(len(positions)), "start": start, "stop": start + len(positions), "seed": int(seed), "layout": metadata, } ) start += len(positions) source_counts = np.sum( [metadata["counts"] for _, metadata, _ in mixtures], axis=0 ) return Farm( np.concatenate([positions for positions, _, _ in mixtures]), boundary, metadata={ "kind": "multipart", "algorithm": "Mixture", "seed": int(root_seed), "requested_n": None, "achieved_n": start, "source_counts": source_counts.astype(int).tolist(), "components": components, "modifiers": [], }, guide=_combine_guides([guide for _, _, guide in mixtures]), ) def _allocate_parts(self, areas): counts = _allocate_counts(self._required_n, areas) matrix = _component_source_counts(counts, self._source_counts()) return counts, [{"source_counts": row.astype(int).tolist()} for row in matrix] def _part_layout(self, count, record): # Each part draws its share of every source from the global apportionment. child = super()._part_layout(count, record) child._fixed_counts = tuple(record["source_counts"]) return child def _multipart_metadata(self): return {"source_counts": [int(value) for value in self._source_counts()]}