"""Clusters of wind farms and the placement of their boundaries."""
from __future__ import annotations
import itertools
import json
from collections.abc import Iterator, Mapping, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any, overload
import numpy as np
import shapely
from shapely import affinity
from shapely.ops import unary_union
from ._random import as_rng, derive_seed
from ._sampling_specs import _json_safe, draw_scalar, is_number
from .boundaries.base import Boundary, _cells, _GeometryBoundary, _translation
from .layouts.base import Farm
if TYPE_CHECKING: # pragma: no cover
import matplotlib.axes
__all__ = ["Cluster"]
def _geometry_radius(geometry) -> float:
"""Largest distance from the centroid of ``geometry`` to its vertices."""
center = np.asarray(geometry.centroid.coords[0], dtype=float)
coordinates = shapely.get_coordinates(geometry)
return float(np.linalg.norm(coordinates - center, axis=1).max())
def _circle_intersections(first, second, first_radius, second_radius):
"""The intersection points of two circles, none when they do not meet."""
delta = second - first
distance = float(np.linalg.norm(delta))
if distance == 0 or distance > first_radius + second_radius:
return []
if distance < abs(first_radius - second_radius):
return []
along = (first_radius**2 - second_radius**2 + distance**2) / (2 * distance)
height_sq = first_radius**2 - along**2
if height_sq < -1e-9 * max(first_radius**2, 1.0):
return []
height = np.sqrt(max(height_sq, 0.0))
direction = delta / distance
foot = first + along * direction
normal = np.asarray([-direction[1], direction[0]])
return [foot + height * normal, foot - height * normal]
def _translated_geometry(geometry, center, centroid=None):
"""``geometry`` moved so its centroid sits at ``center``.
Callers that move one geometry many times pass its ``centroid`` once.
"""
if centroid is None:
centroid = np.asarray(geometry.centroid.coords[0], dtype=float)
offset = np.asarray(center, dtype=float) - centroid
return affinity.translate(geometry, xoff=float(offset[0]), yoff=float(offset[1]))
def _has_clearance(geometry, others, gap: float, area_tolerance: float) -> bool:
"""Whether ``geometry`` overlaps none of ``others`` and stays ``gap`` away."""
for other in others:
distance = geometry.distance(other)
if distance < gap:
return False
# Geometries any distance apart share no area.
if distance == 0 and _shared_area(geometry, other) > area_tolerance:
return False
return True
#: Share of a pair's span the overlay of :func:`_shared_area` snaps to.
_OVERLAY_GRID = 1e-12
def _shared_area(first, second) -> float:
"""The area ``first`` and ``second`` share, measured robustly.
A floating overlay took two touching Channels cells, one's corner on the
other's edge, for overlapping ones and returned most of a cell, as GEOS
3.14.1 did. Snapped to a grid far finer than any tolerance, the overlay
is robust and finds them apart.
"""
minx, miny, maxx, maxy = shapely.total_bounds([first, second])
grid = _OVERLAY_GRID * max(maxx - minx, maxy - miny)
return float(shapely.intersection(first, second, grid_size=grid).area)
class _PlacedGeometries:
"""Placed geometries with their bounding boxes for quick clearance tests."""
def __init__(self, geometries):
self.geometries = list(geometries)
self._bounds = np.empty((0, 4), dtype=float)
if self.geometries:
self._bounds = shapely.bounds(np.asarray(self.geometries, dtype=object))
def append(self, geometry) -> None:
"""Add ``geometry`` to the placed ones."""
self.geometries.append(geometry)
self._bounds = np.vstack([self._bounds, shapely.bounds(geometry)])
def near(self, geometry, gap: float) -> list:
"""The placed geometries whose bounding boxes come within ``gap`` of it.
Every other placed geometry lies farther than ``gap`` from ``geometry``
and cannot overlap it, so it passes any clearance test. They are
returned in placement order.
"""
bounds = shapely.bounds(geometry)
dx = np.maximum(self._bounds[:, 0] - bounds[2], bounds[0] - self._bounds[:, 2])
dy = np.maximum(self._bounds[:, 1] - bounds[3], bounds[1] - self._bounds[:, 3])
separation = np.hypot(np.maximum(dx, 0.0), np.maximum(dy, 0.0))
# The margin absorbs rounding here and in the exact distances.
margin = 1e-9 * (gap + float(np.abs(bounds).max()) + 1.0)
close = np.flatnonzero(separation <= gap + margin)
return [self.geometries[index] for index in close]
def _pull_towards_cluster(
geometry, center, target, placed, gap: float, reach: float = np.inf
):
"""Slide ``geometry`` from ``center`` towards ``target`` while it keeps clear.
Bisects the straight path and returns the moved geometry and its center,
or None as soon as the center is sure to stop farther than ``reach`` from
``target``.
"""
center = np.asarray(center, dtype=float)
travel = float(np.linalg.norm(target - center))
scale = max(travel, _geometry_radius(geometry), 1.0)
area_tolerance = 1e-12 * scale**2
centroid = np.asarray(geometry.centroid.coords[0], dtype=float)
# The margin absorbs rounding in the distance the center stops at.
margin = 1e-9 * (travel + np.abs(center).max() + np.abs(target).max() + 1.0)
def moved(position):
return _translated_geometry(geometry, position, centroid)
def clear(candidate):
others = placed.near(candidate, gap)
return _has_clearance(candidate, others, gap, area_tolerance)
target_geometry = moved(target)
if clear(target_geometry):
return target_geometry, target
low = 0.0
high = 1.0
while (high - low) * travel > 1e-9 * scale:
fraction = (low + high) / 2.0
candidate = moved(center + fraction * (target - center))
if clear(candidate):
low = fraction
else:
high = fraction
# The center stops short of ``high``, so it ends farther away.
if (1.0 - high) * travel - margin > reach:
return None
resolved_center = center + low * (target - center)
return moved(resolved_center), resolved_center
def _candidate_centers(disks, radius: float, gap: float, angular_offset: float):
"""Centers where a disk of ``radius`` touches the placed disks ``gap`` apart.
They lie in six directions around each placed disk, rotated by
``angular_offset``, and where the disk touches two placed disks at once.
"""
candidates = []
angles = angular_offset + np.deg2rad(np.arange(0.0, 360.0, 60.0))
directions = np.column_stack([np.cos(angles), np.sin(angles)])
for center, other_radius in disks:
distance = radius + other_radius + gap
candidates.extend(center + distance * directions)
if len(disks) < 2:
return candidates
# Only pairs of circles that come near each other can meet. The slack
# leaves borderline pairs to the exact test.
centers = np.asarray([center for center, _ in disks], dtype=float)
reach = radius + np.asarray([other for _, other in disks], dtype=float) + gap
delta = centers[:, None, :] - centers[None, :, :]
distances = np.hypot(delta[..., 0], delta[..., 1])
total = reach[:, None] + reach[None, :]
slack = 1e-6 * total
meet = (distances <= total + slack) & (
distances >= np.abs(reach[:, None] - reach[None, :]) - slack
)
for index, other_index in zip(*np.nonzero(np.triu(meet, k=1)), strict=True):
first, first_radius = disks[index]
second, second_radius = disks[other_index]
candidates.extend(
_circle_intersections(
first,
second,
radius + first_radius + gap,
radius + second_radius + gap,
)
)
return candidates
def _clear_of_disks(candidates, disks, radius: float, gap: float):
"""Whether each candidate center keeps its disk clear of the placed disks."""
centers = np.asarray([center for center, _ in disks], dtype=float)
limits = radius + np.asarray([other for _, other in disks], dtype=float) + gap
limits = limits - 1e-9
points = np.asarray(candidates, dtype=float).reshape(-1, 2)
delta = points[:, None, :] - centers[None, :, :]
distances = np.hypot(delta[..., 0], delta[..., 1])
# Distances this close to a limit are decided by the exact norm.
slack = 1e-9 * np.maximum(np.abs(limits), 1.0)
clear = ~np.any(distances < limits - slack, axis=1)
borderline = np.abs(distances - limits) <= slack
for row, column in zip(*np.nonzero(borderline & clear[:, None]), strict=True):
if not clear[row]:
continue
other_center, other_radius = disks[column]
clear[row] = (
np.linalg.norm(candidates[row] - other_center)
>= radius + other_radius + gap - 1e-9
)
return clear
def _compact_position(geometry, placed, disks, gap: float, angular_offset: float):
"""Place ``geometry`` beside the ``placed`` ones, as close to them as it fits.
Candidates are ranked by distance to the cluster center, then by the area
of the convex hull they leave, then by candidate order.
"""
radius = _geometry_radius(geometry)
centroid = np.asarray(geometry.centroid.coords[0], dtype=float)
candidates = _candidate_centers(disks, radius, gap, angular_offset)
tolerance = 1e-10 * max(geometry.area, 1.0)
placed_union = unary_union(placed.geometries)
cluster_center = np.asarray(placed_union.centroid.coords[0], dtype=float)
# The hull of the placed ones carries every vertex the cluster hull needs.
placed_hull = placed_union.convex_hull
# Quantized tie-breaks avoid platform-dependent coordinate noise.
unit = max(radius, 1.0) ** 2
starts = []
clear_of_disks = _clear_of_disks(candidates, disks, radius, gap)
for rank, center in enumerate(candidates):
if not clear_of_disks[rank]:
continue
candidate = _translated_geometry(geometry, center, centroid)
others = placed.near(candidate, gap)
if _has_clearance(candidate, others, gap, tolerance):
travel = float(np.linalg.norm(cluster_center - center))
starts.append((travel, rank, center, candidate))
# Candidates that start close tend to end close. Pulling them first lets
# the others stop as soon as they cannot rank first.
starts.sort(key=lambda item: item[:2])
valid = []
best = None
for _, rank, center, candidate in starts:
reach = np.inf
if best is not None:
# Any center farther than this has a larger quantized distance.
reach = np.sqrt((best + 1e-9) * unit) * (1.0 + 1e-9)
pulled = _pull_towards_cluster(
candidate, center, cluster_center, placed, gap, reach
)
if pulled is None:
continue
candidate, center = pulled
offset = center - cluster_center
center_distance = round(float(np.dot(offset, offset)) / unit, 9)
if best is not None and center_distance > best:
continue
best = center_distance
hull_area = shapely.convex_hull(
shapely.geometrycollections([placed_hull, candidate])
).area
valid.append((center_distance, round(hull_area / unit, 9), rank, candidate))
if not valid:
raise ValueError("could not arrange cluster boundaries without overlap")
*_, selected = min(valid, key=lambda item: item[:-1])
return selected
#: Most sites an uncapped domain fill places.
_SITE_LIMIT = 512
#: Candidate positions offered to one site before it is abandoned.
_SITE_ATTEMPTS = 96
#: Consecutive unplaceable sites after which the domain is treated as full.
_SITE_GIVE_UP = 12
#: Share of a site's inradius the domain is eroded by, so the rounded corners
#: of the eroded outline, drawn inside the exact one, keep every center the
#: site fits at.
_CENTER_EROSION = 0.98
def _center_region(developable, geometry, centroid):
"""Where the centroid of ``geometry`` may lie for it to fit in ``developable``.
A site holds the disk about its centroid out to its nearest edge, so its
centroid lies at least that far inside the domain wherever it fits.
Drawing centers there finds a large site the whole domain rarely offers
room for.
"""
point = shapely.Point(centroid)
if not geometry.contains(point):
return developable
inradius = float(geometry.boundary.distance(point))
return developable.buffer(-_CENTER_EROSION * inradius)
def _scatter_sites(make_site, domain, gap, max_sites, seed):
"""Scatter sites drawn from ``make_site(index)`` at random inside ``domain``.
Sites are added ``gap`` apart until ``max_sites`` are placed, or
:data:`_SITE_LIMIT` without a cap, or :data:`_SITE_GIVE_UP` sites in a
row find no room. A site that finds none does not count, so another is
drawn in its place. Returns ``(parts, groups, records)`` with the member
boundaries, the site group of each part and JSON-compatible placement
records.
"""
developable = domain.geometry
if developable.is_empty or developable.area <= 0:
raise ValueError("domain must enclose a positive developable area")
limit = _SITE_LIMIT if max_sites is None else int(max_sites)
tolerance = 1e-9 * developable.area
placed: list[tuple] = []
# Only sites whose boxes come within the gap can fail the clearance test,
# so the rest are not measured. Every position tried was measured against
# every placed site, four fifths of the slowest regions' time.
geometries = _PlacedGeometries([])
failures = 0
tried: list[float] = []
# Each draw places a site or adds to a run of failures that ends the fill,
# so the loop ends. A site that found no room used up one of the
# ``n_sites``, and 6 asked for placed 5 though smaller ones still fit.
for index in itertools.count():
if len(placed) >= limit:
break
site = make_site(index)
geometry = site.geometry
tried.append(float(geometry.area))
if geometry.area > developable.area:
raise ValueError("a site is larger than the domain's developable area")
site_seed = derive_seed(seed, "site", index)
rng = as_rng(site_seed)
clearance = draw_scalar(
gap, rng, "gap", index=index, choice_seed=seed, minimum=0.0
)
arranged = None
centroid = np.asarray(geometry.centroid.coords[0], dtype=float)
region = _center_region(developable, geometry, centroid)
# A site too wide for anywhere in the domain is offered no position.
attempts = 0 if region.is_empty else _SITE_ATTEMPTS
lowx, lowy, highx, highy = region.bounds
for _ in range(attempts):
center = (
float(rng.uniform(lowx, highx)),
float(rng.uniform(lowy, highy)),
)
# Checking the center first rejects most samples cheaply.
if not shapely.contains_xy(region, center[0], center[1]):
continue
candidate = _translated_geometry(geometry, center, centroid)
if not developable.covers(candidate):
continue
if not _has_clearance(
candidate, geometries.near(candidate, clearance), clearance, tolerance
):
continue
arranged = candidate
break
if arranged is None:
failures += 1
if failures >= _SITE_GIVE_UP:
break
continue
failures = 0
placed.append((index, site, arranged, clearance, site_seed))
geometries.append(arranged)
parts: list = []
groups: list[int] = []
records: list[dict] = []
for group, (index, site, arranged, clearance, site_seed) in enumerate(placed):
offset = np.asarray(arranged.centroid.coords[0], dtype=float) - np.asarray(
site.geometry.centroid.coords[0], dtype=float
)
site_parts = _boundary_parts(site)
# Partition cells move separately so they are not merged.
for part in site_parts:
moved = affinity.translate(
part.geometry, xoff=float(offset[0]), yoff=float(offset[1])
)
parts.append(_GeometryBoundary(moved, positioned=True))
groups.append(group)
records.append(
{
"site": index,
"group": group,
"boundary": type(site).__name__,
"seed": int(site_seed),
"clearance": clearance,
"center": [float(value) for value in arranged.centroid.coords[0]],
"area": float(arranged.area),
"n_parts": len(site_parts),
}
)
if not parts:
smallest = min(tried) / 1e6
raise ValueError(
f"no site fits inside the domain of {developable.area / 1e6:.4g} km2, "
f"where {len(tried)} sites of {smallest:.4g} km2 or more found no "
"room, so use smaller sites or a larger domain"
)
return tuple(parts), tuple(groups), records
def _boundary_parts(boundary: Boundary) -> tuple:
"""One boundary per partition cell or polygon of ``boundary``, else itself."""
parts = boundary.parts
if len(parts) != 1 or parts[0] is not boundary:
return tuple(parts)
geometry = boundary.geometry
if isinstance(geometry, shapely.MultiPolygon):
return tuple(_GeometryBoundary(polygon) for polygon in geometry.geoms)
return (boundary,)
def _manifest_entry(farm: Farm, summary: dict, filename: str) -> dict:
"""The manifest record of one saved farm, with any partition cells."""
entry = {
"file": filename,
"summary": summary,
"metadata": _json_safe(farm.metadata),
}
cells = None if farm.boundary is None else _cells(farm.boundary)
if cells is not None:
# Hex WKB keeps every coordinate bit, so the cells tile the site again.
entry["cells"] = [shapely.to_wkb(cell, hex=True) for cell in cells]
return entry
def _fixed_too_close(first: int, second: int, geometries, gap: float) -> ValueError:
"""The error for two members fixed in place that overlap or break the gap."""
distance = float(geometries[first].distance(geometries[second]))
where = (
"overlap"
if distance == 0.0
else f"are {distance:.6g} m apart, closer than the gap of {gap:.6g} m"
)
return ValueError(
f"cluster members {first} and {second} are fixed in place and {where}. "
"A partition's cells and a boundary given a center keep their place, so "
"space them apart, or set the gap within the partition instead"
)
def _arrange_boundaries(boundaries, gap, seed):
"""Keep positioned boundaries and pack unpositioned ones around them.
Returns the arranged boundaries and JSON-compatible placement records.
"""
# Positioned boundaries must keep a fixed gap between themselves.
fixed_floor = float(gap) if is_number(gap) else 0.0
boundaries = tuple(boundaries)
positioned = [getattr(boundary, "_positioned", True) for boundary in boundaries]
geometries = [boundary.geometry for boundary in boundaries]
fixed_indices = [index for index, value in enumerate(positioned) if value]
unplaced_indices = [index for index, value in enumerate(positioned) if not value]
tolerance = 1e-10 * max(sum(geometry.area for geometry in geometries), 1.0)
for offset, first_index in enumerate(fixed_indices):
for second_index in fixed_indices[offset + 1 :]:
if not _has_clearance(
geometries[first_index],
[geometries[second_index]],
fixed_floor,
tolerance,
):
raise _fixed_too_close(
first_index, second_index, geometries, fixed_floor
)
placed = _PlacedGeometries(geometries[index] for index in fixed_indices)
disks = [
(
np.asarray(geometry.centroid.coords[0], dtype=float),
_geometry_radius(geometry),
)
for geometry in placed.geometries
]
translations = [np.zeros(2, dtype=float) for _ in boundaries]
clearances = [None for _ in boundaries]
angular_offsets = [None for _ in boundaries]
# Quantize equal-area ordering to avoid platform-dependent placement.
scale = max((geometry.area for geometry in geometries), default=1.0) or 1.0
order = sorted(
unplaced_indices,
key=lambda index: (-round(geometries[index].area / scale, 9), index),
)
for index in order:
geometry = geometries[index]
original_center = np.asarray(geometry.centroid.coords[0], dtype=float)
if not placed.geometries:
arranged = _translated_geometry(geometry, (0.0, 0.0))
else:
member_rng = as_rng(derive_seed(seed, "member", index))
clearance = draw_scalar(
gap, member_rng, "gap", index=index, choice_seed=seed, minimum=0.0
)
angular_offset = float(member_rng.uniform(0.0, np.pi / 3.0))
arranged = _compact_position(
geometry,
placed,
disks,
clearance,
angular_offset,
)
clearances[index] = clearance
angular_offsets[index] = float(np.rad2deg(angular_offset))
resolved_center = np.asarray(arranged.centroid.coords[0], dtype=float)
translations[index] = resolved_center - original_center
geometries[index] = arranged
placed.append(arranged)
disks.append((resolved_center, _geometry_radius(arranged)))
arranged_boundaries = tuple(
boundary
if positioned[index]
else _GeometryBoundary(
geometries[index],
positioned=True,
# Partition cells move with the shape, so a farm keeps its parts.
cells=_cells(boundary, _translation(translations[index])),
)
for index, boundary in enumerate(boundaries)
)
records = [
{
"index": index,
"source": type(boundary).__name__,
"positioned": bool(positioned[index]),
"translation": translations[index].astype(float).tolist(),
"center": list(map(float, geometries[index].centroid.coords[0])),
"clearance": clearances[index],
"angular_offset": angular_offsets[index],
}
for index, boundary in enumerate(boundaries)
]
return arranged_boundaries, records
def _member_metadata(metadata: dict, kept: list[int], farms) -> dict:
"""Cut the per-member records of generated cluster ``metadata`` to ``kept``.
Member records follow ``kept`` in order. Site records of a scattered
cluster keep the sites that still hold a kept farm, and the totals derived
from the records are recomputed. Cluster-level keys stay as they are.
"""
if metadata.get("kind") != "generated_cluster":
return metadata
groups = {farm.metadata.get("group") for farm in farms}
def subset(records: list) -> list:
# Site records carry the group that the member farms record.
if records and all(isinstance(r, Mapping) and "group" in r for r in records):
return [record for record in records if record["group"] in groups]
return [records[index] for index in kept]
result = dict(metadata)
if isinstance(result.get("counts"), list):
result["counts"] = subset(result["counts"])
counts = result["counts"]
result["requested_n"] = None if None in counts else sum(counts)
if "achieved_n" in result:
result["achieved_n"] = sum(farm.n_turbines for farm in farms)
if isinstance(result.get("boundaries"), list):
result["boundaries"] = subset(result["boundaries"])
placement = result.get("placement")
if isinstance(placement, Mapping) and isinstance(placement.get("members"), list):
placement = dict(placement)
placement["members"] = subset(placement["members"])
if "coverage" in placement:
area = sum(record["area"] for record in placement["members"])
placement["coverage"] = area / placement["domain_area"]
if "n_sites" in placement:
placement["n_sites"] = len(placement["members"])
result["placement"] = placement
turbines = result.get("turbines")
if isinstance(turbines, Mapping) and isinstance(turbines.get("members"), list):
result["turbines"] = {**turbines, "members": subset(turbines["members"])}
return result
[docs]
class Cluster(Sequence[Farm]):
"""A sequence of :class:`~oowl.Farm` members with metadata for the whole set.
Cluster metadata holds the root seed, the placement record and any shared
wind resource and surface. A slice is a cluster of the kept members whose
per-member records are cut to match.
"""
def __init__(self, farms: Sequence[Farm], *, metadata: Mapping | None = None):
self.farms = tuple(farms)
if not all(isinstance(farm, Farm) for farm in self.farms):
raise TypeError("farms must contain only Farm objects")
self.metadata = _json_safe(dict(metadata or {}))
def __len__(self) -> int:
return len(self.farms)
def __repr__(self) -> str:
return f"Cluster(n_farms={self.n_farms}, n_turbines={self.n_turbines})"
def __eq__(self, other: object) -> bool:
"""Whether ``other`` is a cluster of equal farms and the same metadata."""
if other.__class__ is not self.__class__:
return NotImplemented
return self.farms == other.farms and self.metadata == other.metadata
@overload
def __getitem__(self, index: int) -> Farm: ...
@overload
def __getitem__(self, index: slice) -> Cluster: ...
def __getitem__(self, index: int | slice) -> Farm | Cluster:
if isinstance(index, slice):
kept = list(range(len(self.farms)))[index]
farms = [self.farms[position] for position in kept]
return Cluster(farms, metadata=_member_metadata(self.metadata, kept, farms))
return self.farms[index]
def __iter__(self) -> Iterator[Farm]:
return iter(self.farms)
[docs]
def summary(self, metrics: Sequence[str] | None = None) -> list[dict]:
"""Return one summary row per farm, with its sampling record prefixed.
Each row holds ``index``, ``algorithm`` and ``seed`` from the farm's
``sample`` metadata, which are ``None`` for a farm not drawn by
:meth:`~oowl.Sampler.sample`, plus ``Farm.summary(metrics)``.
"""
rows = []
for farm in self.farms:
record = farm.metadata.get("sample", {})
row = {
"index": record.get("index"),
"algorithm": record.get("layout", {}).get("algorithm"),
"seed": record.get("seed"),
}
row.update(farm.summary(metrics))
rows.append(row)
return rows
[docs]
def save(self, directory: str | Path, **kwargs: Any) -> Path:
"""Write the cluster into ``directory`` and return that directory.
Writes ``manifest.json`` holding ``schema_version``, the cluster
metadata and a ``{file, summary, metadata}`` entry per farm. A farm on
partition cells adds their hex WKB as ``cells``, so its parts reload
in the same order. Each farm is saved as windIO YAML
(``farm_%04d.yaml``), a system with its site where it has a boundary
and a ``wind_farm`` document of its positions and turbines where it
has none, as :meth:`Farm.save` writes them. Extra keyword arguments go
to ``Farm.save``, only ``name`` and ``crs`` to a farm without a
boundary. A resource or surface that several farms share is written
once, as ``resource_%04d.yaml`` or ``surface_%04d.yaml``, which their
files ``!include``. windIO records no guide, and a farm without a
boundary has no site to hold a resource or surface, so
:meth:`Cluster.load` does not recover those.
"""
from ._windio import _save, _SharedComponents
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
shared = _SharedComponents(
directory,
[farm for farm in self.farms if farm.boundary is not None],
kwargs.get("energy_resource"),
kwargs.get("surface_format", "yaml"),
)
layout_only = {key: kwargs[key] for key in ("name", "crs") if key in kwargs}
files = []
for index, farm in enumerate(self.farms):
filename = f"farm_{index:04d}.yaml"
if farm.boundary is None:
_save(farm, directory / filename, document="wind_farm", **layout_only)
else:
_save(farm, directory / filename, shared=shared, **kwargs)
files.append(filename)
manifest = {
"schema_version": 1,
"collection": self.metadata,
"farms": [
_manifest_entry(farm, summary, filename)
for farm, summary, filename in zip(self.farms, self.summary(), files)
],
}
(directory / "manifest.json").write_text(
json.dumps(_json_safe(manifest), indent=2), encoding="utf-8"
)
return directory
[docs]
@classmethod
def load(cls, directory: str | Path) -> "Cluster":
"""Rebuild a cluster from a directory written by :meth:`Cluster.save`.
Loads each farm through windIO, then restores its metadata and
partition cells from the manifest. A farm keeps the name and CRS its
file records, and farms that include one file share the object it
holds. Raises :class:`FileNotFoundError` for a missing manifest and
:class:`ValueError` for an unsupported schema or a malformed entry.
"""
directory = Path(directory)
manifest_path = directory / "manifest.json"
if not manifest_path.is_file():
raise FileNotFoundError(f"collection manifest not found: {manifest_path}")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("schema_version") != 1:
raise ValueError("unsupported Cluster schema")
from ._windio import _Documents, _farm_name, _load
documents = _Documents()
entries = manifest.get("farms", [])
farms = []
# The metadata each windIO file loads with.
recorded = []
for entry in entries:
filename = entry.get("file") if isinstance(entry, Mapping) else None
if not isinstance(filename, str):
raise ValueError("collection manifest entry has no file")
path = directory / filename
member = _load(path, documents)
if not isinstance(member, Farm):
raise ValueError(f"collection member is not a farm: {path}")
farms.append(member)
recorded.append(member.metadata)
farms = tuple(farms)
if len(farms) != len(entries):
raise ValueError("collection manifest and farm files disagree")
for farm, entry, written in zip(farms, entries, recorded):
if isinstance(entry, Mapping) and isinstance(
entry.get("metadata"), Mapping
):
metadata = dict(entry["metadata"])
# A name or CRS passed to save is only in the file, so it is
# kept for the next save to write again.
if written["name"] != _farm_name(metadata):
metadata["name"] = written["name"]
if written.get("crs") is not None:
metadata["crs"] = written["crs"]
farm.metadata = metadata
if isinstance(entry, Mapping) and entry.get("cells") is not None:
if farm.boundary is None:
raise ValueError("collection manifest gives cells without a site")
farm.boundary = _GeometryBoundary(
farm.boundary.geometry,
cells=tuple(shapely.from_wkb(cell) for cell in entry["cells"]),
)
return cls(farms, metadata=manifest.get("collection", {}))
@property
def n_farms(self) -> int:
"""Number of member farms."""
return len(self.farms)
@property
def n_turbines(self) -> int:
"""Total number of turbines across every farm."""
return sum(farm.n_turbines for farm in self.farms)
@property
def positions(self) -> np.ndarray:
"""Every farm's positions concatenated, in order."""
arrays = [farm.positions for farm in self.farms if farm.n_turbines]
return np.concatenate(arrays) if arrays else np.empty((0, 2), dtype=float)
@property
def boundaries(self) -> tuple[Boundary | None, ...]:
"""Boundary of each farm, in order."""
return tuple(farm.boundary for farm in self.farms)
@property
def bounds(self) -> tuple[float, float, float, float]:
"""Turbine-position bounding box ``(min_x, min_y, max_x, max_y)`` in meters."""
positions = self.positions
if not len(positions):
raise ValueError("an empty collection has no turbine bounds")
return (
float(np.min(positions[:, 0])),
float(np.min(positions[:, 1])),
float(np.max(positions[:, 0])),
float(np.max(positions[:, 1])),
)
@property
def centroid(self) -> tuple[float, float]:
"""Mean turbine position ``(x, y)`` across every farm, in meters."""
positions = self.positions
if not len(positions):
raise ValueError("an empty collection has no turbine centroid")
center = np.mean(positions, axis=0)
return (float(center[0]), float(center[1]))
@property
def extent(self) -> tuple[float, float]:
"""Turbine-position extent ``(width, height)`` in meters."""
min_x, min_y, max_x, max_y = self.bounds
return (max_x - min_x, max_y - min_y)
[docs]
def plot(
self, ax: "matplotlib.axes.Axes | None" = None, **kwargs: Any
) -> "matplotlib.axes.Axes":
"""Plot every member farm on one map and return its Matplotlib axes.
Each member is drawn in its own color of the Matplotlib cycle, in
member order. For a cluster scattered across a ``domain``,
``regions`` outlines the domain and shades its exclusion zones, and
``regions=False`` omits them. Other keywords go to
:meth:`~oowl.Farm.plot`, so turbine size is ``s``. Raises
:class:`ValueError` when the cluster holds no farms.
"""
from .plotting import _plot_cluster
if not self.farms:
raise ValueError("no farms to plot")
return _plot_cluster(self, ax=ax, **kwargs)