Source code for oowl.layouts.grid

"""Aligned parallelogram-grid layout."""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np

from .._validation import _validate_finite, _validate_positive
from .candidate import _CandidateLayout, _ray_counter

if TYPE_CHECKING:  # pragma: no cover
    from ..boundaries.base import Boundary
    from .fitters import SelectedCandidateFitter

__all__ = ["Grid"]


[docs] class Grid(_CandidateLayout): """Occupy selected intersections of a regular parallelogram grid. ``spacing_ratio`` is the length of the second grid vector divided by the first. ``lattice_angle`` is the angle in degrees between them, and ``rotation`` orients the first vector globally. The fitter finds the largest aligned grid that offers at least ``n_turbines`` valid intersections, then selects an exact, well-distributed subset. """ def __init__( self, n_turbines: int | None = None, *, density: dict[str, float] | None = None, spacing_ratio: float = 1.0, lattice_angle: float = 90.0, rotation: float = 0.0, fitter: SelectedCandidateFitter | None = None, seed: int | np.random.Generator | None = None, ): self.spacing_ratio = _validate_positive("spacing_ratio", spacing_ratio) self.lattice_angle = _validate_finite("lattice_angle", lattice_angle) if not 0 < self.lattice_angle < 180: raise ValueError("lattice_angle must be between 0 and 180 degrees") self.rotation = _validate_finite("rotation", rotation) super().__init__( n_turbines, density=density, fitter=fitter, seed=seed, ) def _basis(self, scale: float) -> np.ndarray: angle = np.deg2rad(self.lattice_angle) basis = scale * np.array( [ [1.0, 0.0], [ self.spacing_ratio * np.cos(angle), self.spacing_ratio * np.sin(angle), ], ] ) if self.rotation: theta = np.deg2rad(self.rotation) cosine, sine = np.cos(theta), np.sin(theta) rotation = np.array([[cosine, -sine], [sine, cosine]]) basis = basis @ rotation.T return basis
[docs] def estimate_spacing(self, boundary: Boundary) -> float: """Return a starting length of the first grid vector in meters. Assumes one parallelogram cell per turbine. The estimate only seeds the fitter's search and is not the final spacing. """ cell_factor = self.spacing_ratio * np.sin(np.deg2rad(self.lattice_angle)) return float(np.sqrt(boundary.area / (self._required_n * cell_factor)))
def _detailed_fit(self, boundary, positions, fit): # Markov turbine removal finds grid rows from the basis. return positions, {**fit, "grid_basis": self._basis(fit["scale"]).tolist()} def _lattice(self, boundary, scale, phases): """The basis, origin, phase offsets and index ranges at ``scale``. Each phase's inclusive index ranges cover the boundary's box. """ basis = self._basis(scale) inverse = np.linalg.inv(basis) center = np.asarray(boundary.centroid, dtype=float) minx, miny, maxx, maxy = boundary.bounds # Lattice offsets of the bounding-box corners from the grid origin. corners = ( np.array( [ [minx, miny], [minx, maxy], [maxx, miny], [maxx, maxy], ] ) - center ) offsets = np.asarray(phases, dtype=float).reshape(-1, 2) # Elementwise rather than matrix products, because BLAS kernels vary # with the row count and a product would make a lattice point's last # bits depend on how many other points were generated beside it. translations = offsets[:, :1] * basis[0] + offsets[:, 1:] * basis[1] coefficients = (corners[None, :, :] - translations[:, None, :]) @ inverse lower = np.floor(coefficients.min(axis=1)).astype(int) - 1 upper = np.ceil(coefficients.max(axis=1)).astype(int) + 1 return basis, center, offsets, translations, lower, upper def _candidate_bounds(self, boundary, scale, phases, max_candidates): if scale <= 0: return None _, center, offsets, _, lower, upper = self._lattice(boundary, scale, phases) shape = upper - lower + 1 # A larger scale narrows each index range, give or take one index # each side for rounding, so no field above ``scale`` passes the cap. if np.any((shape[:, 0] + 2) * (shape[:, 1] + 2) > max_candidates): return None unit = self._basis(1.0) # Each phase's index range, its first index varying fastest. cells = shape[:, 0] * shape[:, 1] phase = np.repeat(np.arange(len(offsets)), cells) cell = np.arange(len(phase)) - np.repeat(np.cumsum(cells) - cells, cells) along = (cell % shape[phase, 0] + lower[phase, 0]) + offsets[phase, 0] across = (cell // shape[phase, 0] + lower[phase, 1]) + offsets[phase, 1] units = np.multiply.outer(along, unit[0]) + np.multiply.outer(across, unit[1]) return _ray_counter(boundary, center, units, phase, len(offsets), scale) def _candidate_fields( self, boundary, scale, phases, max_candidates=None, min_candidates=None ): if scale <= 0: return [np.empty((0, 2)) for _ in phases] basis, center, offsets, translations, lower, upper = self._lattice( boundary, scale, phases ) phase, second, first_low, counts = _clipped_rows( boundary, basis, center, translations, lower, upper ) # Candidates are only built inside the boundary's rotated rectangle, # so a thin or diagonal boundary costs what it holds, not its box. Rows # still long and sparse there, as across a thin L, are also cut at the # boundary's edges where that costs less than testing their points. runs = _edge_clipped_runs( boundary, basis, center, translations, phase, second, first_low, counts ) if runs is not None: phase, second, first_low, counts = runs cells = np.bincount(phase, weights=counts, minlength=len(offsets)).astype(int) within = np.ones(len(offsets), dtype=bool) if max_candidates is not None: within = cells <= max_candidates if not np.any(within): return [None for _ in phases] fields = [np.empty((0, 2)) if is_within else None for is_within in within] # A phase's rows bound its candidates, so a phase that cannot reach # ``min_candidates`` skips the containment query. computed = ( within if min_candidates is None else within & (cells >= min_candidates) ) kept = computed[phase] & (counts > 0) if not np.any(kept): return fields lengths = counts[kept] starts = np.cumsum(lengths) - lengths first = np.repeat(first_low[kept], lengths) + ( np.arange(int(lengths.sum())) - np.repeat(starts, lengths) ) point_phase = np.repeat(phase[kept], lengths) # The same elementwise sums as a whole lattice, so each point keeps its # last bits wherever it was built. lattice = np.multiply.outer(first, basis[0]) + np.multiply.outer( np.repeat(second[kept], lengths), basis[1] ) points = lattice + translations[point_phase] points += center inside = boundary._contains(points) # Rows run phase by phase, each in index order, as a whole lattice does, # so each computed phase holds one run of the points. stops = np.cumsum(cells[computed]) for index, start, stop in zip( np.flatnonzero(computed), stops - cells[computed], stops ): fields[index] = points[start:stop][inside[start:stop]] return fields
def _clipped_rows(boundary, basis, center, translations, lower, upper): """Each phase's lattice rows clipped to the boundary's rotated rectangle. Returns per row its phase, second index, first first index and count of points, phase by phase and each in index order. A point left out lies outside the rectangle and so outside the boundary. """ spans = upper[:, 1] - lower[:, 1] + 1 phase = np.repeat(np.arange(len(translations)), spans) second = ( np.arange(len(phase)) - np.repeat(np.cumsum(spans) - spans, spans) ) + lower[phase, 1] origins = center + translations[phase] + second[:, None] * basis[1] normals, limits = boundary._enclosing_halfplanes along = normals @ basis[0] room = limits[None, :] - origins @ normals.T low = lower[phase, 0].astype(float) high = upper[phase, 0].astype(float) rising, falling = along > 0, along < 0 if rising.any(): high = np.minimum(high, np.floor((room[:, rising] / along[rising]).min(axis=1))) if falling.any(): low = np.maximum(low, np.ceil((room[:, falling] / along[falling]).max(axis=1))) # A row parallel to a side lies wholly inside or outside it. level = ~(rising | falling) if level.any(): high[(room[:, level] < 0).any(axis=1)] = -np.inf counts = np.maximum(high - low + 1, 0).astype(int) return phase, second, low.astype(int), counts #: Cost of cutting one row at one edge, as a share of testing one point, #: which decides whether rows are cut at the boundary's edges. _EDGE_PAIR_COST = 0.25 def _edge_clipped_runs( boundary, basis, center, translations, phase, second, low, counts ): """The runs of each row that may lie in the boundary, cut at its edges. Each edge reaching a row, widened by a margin, marks a stretch whose points may lie inside. Between two stretches the row meets no edge, so it lies wholly inside or outside, which one containment test at its middle tells, and an inside gap joins its stretches. Returns runs as :func:`_clipped_rows` returns rows, several to a row, each a superset of the row's points inside, so containment still decides every point. ``None`` when cutting would cost more than testing the rows' points. """ rows = np.flatnonzero(counts) empty = np.empty(0, dtype=int) if not len(rows): return empty, empty, empty, empty starts, ends = boundary._edges along = basis[0] length = float(np.hypot(*along)) across = np.array([-along[1], along[0]]) / length origins = center + translations[phase[rows]] + second[rows, None] * basis[1] minx, miny, maxx, maxy = boundary.bounds margin = 1e-9 * float(np.hypot(maxx - minx, maxy - miny)) + 1e-6 # The rows are parallel, so an edge reaches those whose line lies within # its extent across them, a run of the rows sorted by that height. height = origins @ across by_height = np.argsort(height, kind="stable") sorted_height = height[by_height] start_height, end_height = starts @ across, ends @ across first_row = np.searchsorted( sorted_height, np.minimum(start_height, end_height) - margin, side="left" ) last_row = np.searchsorted( sorted_height, np.maximum(start_height, end_height) + margin, side="right" ) reached = last_row - first_row crossings = int(reached.sum()) if crossings * _EDGE_PAIR_COST >= counts.sum(): return None if not crossings: return empty, empty, empty, empty edge = np.repeat(np.arange(len(starts)), reached) row = by_height[ np.arange(crossings) - np.repeat(np.cumsum(reached) - reached, reached) + np.repeat(first_row, reached) ] rise_from = start_height[edge] - height[row] rise_to = end_height[edge] - height[row] rise = rise_to - rise_from # The share of each edge within the margin of the line, all of one along it. level = rise == 0.0 safe = np.where(level, 1.0, rise) first = np.where(level, 0.0, (-margin - rise_from) / safe) last = np.where(level, 1.0, (margin - rise_from) / safe) enter = np.clip(np.minimum(first, last), 0.0, 1.0) leave = np.clip(np.maximum(first, last), 0.0, 1.0) # Positions along the row in lattice steps from its origin. offset = (origins @ along)[row] / length**2 begin = (starts[edge] @ along) / length**2 - offset finish = (ends[edge] @ along) / length**2 - offset slack = 2.0 * margin / length one = begin + enter * (finish - begin) two = begin + leave * (finish - begin) lows = np.minimum(one, two) - slack highs = np.maximum(one, two) + slack # An edge whose ends both lie past the margin crosses the line cleanly, at # one point, so it turns the row from outside to inside or back. One with # an end within the margin, as at a vertex, leaves the row's side unknown. clean = ((rise_from < -margin) & (rise_to > margin)) | ( (rise_from > margin) & (rise_to < -margin) ) order = np.lexsort((lows, row)) row, lows, highs, clean = row[order], lows[order], highs[order], clean[order] # A stretch starting past every one before it in its row opens a block. The # rows are kept apart by an offset, which only decides where blocks open. apart = float(highs.max() - lows.min()) + 1.0 reached = np.maximum.accumulate(highs + row * apart) - row * apart opens = np.ones(len(row), dtype=bool) opens[1:] = (row[1:] != row[:-1]) | (lows[1:] > reached[:-1]) blocks = np.flatnonzero(opens) block_row, block_low = row[blocks], lows[blocks] block_high = np.maximum.reduceat(highs, blocks) flips = np.add.reduceat(clean.astype(int), blocks) doubtful = np.maximum.reduceat((~clean).astype(int), blocks).astype(bool) # A gap between two blocks of a row meets no edge, so it lies wholly # inside or outside. The row starts outside, and each block turns it by # its clean crossings. After a doubtful block the gap's middle is tested, # and the turns count on from there. index = np.arange(len(blocks)) first_block = np.maximum.accumulate( np.where(np.append(True, block_row[1:] != block_row[:-1]), index, 0) ) turned = np.cumsum(flips) turned = turned - (turned[first_block] - flips[first_block]) anchor = np.maximum.accumulate(np.where(doubtful, index, -1)) anchored = anchor >= first_block same = np.append(block_row[1:] == block_row[:-1], False) inside = np.zeros(len(blocks), dtype=bool) tested = same & anchored & (anchor == index) if tested.any(): middles = 0.5 * (block_high[tested] + block_low[np.flatnonzero(tested) + 1]) points = origins[block_row[tested]] + middles[:, None] * along inside[tested] = boundary._contains(points) counted = same & ~tested base = np.where(anchored, inside[np.maximum(anchor, 0)], False) since = np.where(anchored, turned - turned[np.maximum(anchor, 0)], turned) inside[counted] = base[counted] ^ (since[counted] % 2 == 1) joined = inside[:-1] runs = np.flatnonzero(np.append(True, ~joined)) run_row = block_row[runs] run_low = block_low[runs] run_high = np.maximum.reduceat(block_high, runs) source = rows[run_row] start = np.maximum(np.ceil(run_low), low[source]).astype(int) stop = np.minimum(np.floor(run_high), low[source] + counts[source] - 1).astype(int) return phase[source], second[source], start, np.maximum(stop - start + 1, 0)